Skip to main content

Command Palette

Search for a command to run...

카프카 입문 시리즈 10편: Exactly-Once Semantics — Idempotent Producer와 Transaction Coordinator

Updated
12 min read

분산 시스템에서 "정확히 한 번"은 오랫동안 농담이었습니다. 메시지 한 통을 보내려면 네트워크 timeout, broker 장애, producer 재시작이 모두 정상 동작 안에 들어오기 때문입니다. 그럼에도 Kafka 0.11(2017)은 EOS를 가능하다고 선언했고, Kafka 4.0(2025)에 와서 그 약속은 server-side defense까지 더해 더 단단해졌습니다. 이 글은 그 약속이 어떻게 두 개의 도구(Idempotent Producer와 Transaction Coordinator) 위에 세워졌는지, 그리고 KIP-447·KIP-890이 어떤 구멍을 메웠는지를 같은 그림 위에서 풀어냅니다.

세 가지 delivery semantics

Producer에서 broker로 메시지를 보내고 ack을 기다리는 단순한 모델만 봐도 세 가지 보장이 갈립니다.

Semantics 동작 결과
At-most-once ack 받기 전에 다음으로 진행, 실패해도 재시도하지 않음 메시지 유실 가능
At-least-once ack 못 받으면 재시도 메시지 중복 가능
Exactly-once ack 못 받아도 재시도하되 중복은 broker가 걸러냄 유실·중복 모두 없음

기본 Kafka producer는 retries > 0을 기본값으로 두기 때문에 자연스럽게 at-least-once에 위치합니다. 문제는 at-least-once의 안전망이 "재시도"인데, 그 재시도가 곧 중복의 원인이라는 점입니다. EOS는 이 모순을 두 단계로 해결합니다.

  1. Idempotent Producer — 한 producer 인스턴스가 한 partition으로 같은 메시지를 두 번 보내더라도 broker가 두 번째를 무시합니다. 단일 partition 안의 중복만 막습니다.
  2. Transactions — 여러 partition에 걸친 쓰기와 consumer offset 커밋을 한 단위로 묶어 "전부 보이거나, 전부 안 보이는" 원자성을 제공합니다.

이 두 도구가 같이 동작할 때 비로소 read-process-write 패턴에서 EOS가 성립합니다.

Idempotent Producer

무엇을 중복으로 정의하는가

enable.idempotence=true를 켠 producer가 broker로부터 받은 응답이 timeout으로 돌아오지 않았다고 가정합시다. 이때 두 가지 가능성이 공존합니다.

  • 메시지가 broker에 도착했지만 ack 응답만 유실되었다 (broker는 이미 쓴 상태).
  • 메시지가 broker에 도착하지 못했다 (broker는 받지 못한 상태).

Producer는 안전하게 재시도하고, 두 경우 모두에서 같은 결과를 만들어야 합니다. broker는 같은 메시지가 두 번 오는 상황을 자기 쪽에서 분별해야 합니다.

Producer ID와 sequence number

Idempotent producer가 처음 broker에 접속하면 InitProducerId RPC가 transaction coordinator(또는 idempotent-only 모드에서는 임의의 broker)로부터 다음 두 값을 발급받습니다.

  • Producer ID(PID) — 64-bit, broker가 유일성을 보장
  • Producer epoch — 16-bit, 같은 PID에 대한 세대 번호

이후 producer는 partition마다 0부터 시작하는 sequence number를 메시지에 붙입니다. broker(파티션 leader)는 PID·partition별로 마지막으로 수용한 sequence number를 메모리에 들고 있다가, 새 메시지를 받을 때 세 가지로 판정합니다.

들어온 seq broker가 본 마지막 seq 판정
last + 1 last 정상 수용
<= last last 중복 — DuplicateSequenceException, broker는 OK 응답
> last + 1 last 갭 발생 — OutOfOrderSequenceException

중복이 도착하면 broker는 메시지를 한 번 더 쓰지 않은 채로 producer에게 "잘 받았다"라고 답해줍니다. Producer는 자기 입장에서 재시도가 성공한 셈이므로 정상으로 처리합니다. broker가 한 번 더 쓰는 일은 일어나지 않습니다.

flowchart LR
  P[Producer] -->|"send(PID, epoch, seq=N)"| L[Partition Leader]
  L --> C{Compare seq N\nwith lastSeq for PID}
  C -->|"N == last + 1"| OK[Append to log\nreturn OK]
  C -->|"N <= last"| DUP[Skip append\nreturn OK as duplicate]
  C -->|"N > last + 1"| ERR[Return OutOfOrderSequenceException]

Epoch와 zombie fencing

같은 PID가 동시에 두 군데에서 살아 있다면 sequence 검증만으로는 부족합니다. 옛 producer 인스턴스가 죽지 않고 살아남아 같은 PID로 메시지를 더 쓰는 "zombie" 시나리오가 그것입니다. 이때 producer epoch가 끼어듭니다.

InitProducerId를 다시 호출하면 broker는 기존 PID는 유지하되 epoch를 한 칸 올려줍니다. 이후 broker는 같은 PID로 들어온 메시지의 epoch가 자신이 기억하는 최신 epoch보다 작으면 InvalidProducerEpochException을 돌려주고 거부합니다. zombie는 자기도 모르는 사이에 fence(차단)됩니다.

max.in.flight.requests.per.connection이 5인 이유

Idempotent producer를 켜면 max.in.flight.requests.per.connection이 최대 5로 제한됩니다. broker가 PID·partition별로 마지막 다섯 개의 sequence를 캐시에 들고 있기 때문입니다. 다섯 개를 넘어 동시에 떠 있는 요청이 생기면 일부 응답이 순서대로 돌아오지 못해 sequence 캐시 밖으로 밀려나고, 재시도가 들어왔을 때 중복 판정을 못 합니다. 5라는 숫자는 broker 쪽 캐시 크기의 직접적인 결과입니다.

Idempotent producer가 강제하는 다른 설정도 같은 맥락입니다.

  • acks=all — leader뿐만 아니라 ISR(In-Sync Replicas) 전부의 복제 ack을 기다림
  • retries > 0 — 재시도 자체가 EOS의 전제
  • enable.idempotence=true — 위 두 값을 자동 조정

이 셋을 다 충족하지 않으면 producer는 시작 시 ConfigException을 던지고 죽습니다.

Idempotent만으로 부족한 부분

Idempotent producer는 하나의 producer 인스턴스가 하나의 partition으로 보낸 메시지의 중복만 막습니다. 다음은 처리하지 못합니다.

  • 여러 partition에 걸친 원자적 쓰기 (한 partition은 쓰고 다른 partition은 실패하는 부분 실패)
  • Consumer offset 커밋과 produce를 한 단위로 묶기 (read-process-write에서 가장 흔한 EOS 요구)
  • Producer가 재시작되어 새 인스턴스로 살아난 뒤의 일관성

이 갭을 메우는 것이 Transactions입니다.

Transactions API

다섯 개의 producer 메서드

Kafka 트랜잭션은 producer에 다섯 개의 메서드로 노출됩니다.

KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.initTransactions();

try {
    while (true) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
        producer.beginTransaction();

        for (ConsumerRecord<String, String> r : records) {
            producer.send(new ProducerRecord<>("out", r.key(), transform(r.value())));
        }

        Map<TopicPartition, OffsetAndMetadata> offsets = currentOffsets(consumer, records);
        producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata());

        producer.commitTransaction();
    }
} catch (KafkaException e) {
    producer.abortTransaction();
}
  • initTransactions() — 한 번만 호출. transaction coordinator를 찾고 PID·epoch을 발급받음
  • beginTransaction() — 클라이언트 상태만 바꿈, 아직 broker에 RPC 안 나감
  • send() — 트랜잭션 컨텍스트 안에서의 produce
  • sendOffsetsToTransaction(offsets, groupMetadata) — 같은 트랜잭션 안에 consumer offset 커밋을 포함
  • commitTransaction() / abortTransaction() — 트랜잭션 종료

beginTransaction 자체는 broker로 요청을 보내지 않습니다. 첫 send에서 해당 partition을 트랜잭션에 등록하는 AddPartitionsToTxn RPC가 발생하면서 비로소 트랜잭션이 broker 쪽에서도 "Ongoing"이 됩니다.

transactional.id의 의미

핵심 설정은 transactional.id입니다. 이 값은 producer 인스턴스가 재시작되어도 동일하게 유지되어야 하는 "논리적 identity"입니다.

  • 같은 transactional.id로 새 producer가 initTransactions()를 호출하면, broker는 그 ID에 묶여 있던 이전 producer의 epoch를 강제로 올리고 fence 합니다.
  • 진행 중이던 옛 트랜잭션이 있었다면 broker가 그것을 abort 처리합니다.

transactional.id는 zombie producer를 잡는 기제이자, 트랜잭션 단위의 PID·epoch 라이프사이클을 묶는 키입니다. Kafka Streams가 EOS 모드에서 task 단위로 transactional.id를 자동 부여하는 이유가 여기에 있습니다.

read-process-write 패턴

가장 흔한 EOS 사용 패턴은 다음 셋을 하나의 트랜잭션으로 묶는 것입니다.

  1. Source topic에서 consumer가 record를 읽음
  2. Producer가 처리 결과를 sink topic에 produce
  3. 같은 트랜잭션 안에서 consumer offset을 커밋

이 셋을 한 트랜잭션으로 묶으면 commit/abort가 같이 일어나므로, 재시작 후에도 "이번 트랜잭션은 처리됐는가" 한 가지만 판단하면 됩니다. KIP-447이 이를 가능하게 만들었습니다.

Transaction Coordinator 내부

__transaction_state 토픽

모든 트랜잭션 메타데이터는 내부 토픽 __transaction_state에 저장됩니다. 기본 50개 partition, replication factor 3입니다. transactional.idhash(transactional.id) % numPartitions 공식으로 partition에 매핑되고, 그 partition의 leader broker가 곧 해당 트랜잭션의 transaction coordinator입니다.

Producer는 initTransactions 호출 시 FindCoordinator RPC로 자기 transactional.id에 해당하는 coordinator broker를 찾습니다.

TransactionMetadata 자료구조

각 트랜잭션의 상태는 다음 필드로 표현됩니다 (Apache Kafka의 TransactionMetadata.scala 참조).

transactionalId: String
producerId: Long
lastProducerId: Long
producerEpoch: Short
lastProducerEpoch: Short
txnTimeoutMs: Int
state: TransactionState
topicPartitions: mutable.Set[TopicPartition]
txnStartTimestamp: Long
txnLastUpdateTimestamp: Long
pendingState: Option[TransactionState]
hasFailedEpochFence: Boolean

topicPartitions가 핵심입니다. AddPartitionsToTxn RPC가 올 때마다 이 집합에 partition이 추가되며, 트랜잭션 종료 시 이 partition 전부에 transaction marker를 써야 합니다.

TransactionState 여덟 가지 상태

TransactionState는 다음 여덟 가지 값을 가집니다.

State 의미
Empty InitProducerId 직후, 아직 partition 추가 안 됨
Ongoing AddPartitionsToTxn을 적어도 한 번 받음, 진행 중
PrepareCommit EndTxn(commit)을 받고 marker 쓰기 시작
PrepareAbort EndTxn(abort)을 받고 marker 쓰기 시작
CompleteCommit 모든 partition에서 marker ack 수신
CompleteAbort 모든 partition에서 abort marker ack 수신
Dead 캐시에서 곧 제거될 상태
PrepareEpochFence epoch bump 중 (zombie fencing 진행)
flowchart LR
  Empty -->|AddPartitionsToTxn| Ongoing
  Empty -->|AddOffsetsToTxn| Ongoing
  Ongoing -->|EndTxn commit| PrepareCommit
  Ongoing -->|EndTxn abort| PrepareAbort
  PrepareCommit -->|markers acked| CompleteCommit
  PrepareAbort -->|markers acked| CompleteAbort
  CompleteCommit --> Empty
  CompleteAbort --> Empty
  Ongoing -->|"fence (new InitProducerId)"| PrepareEpochFence
  PrepareEpochFence -->|epoch bumped| PrepareAbort

상태 전이가 일어날 때마다 새 record가 __transaction_state에 append됩니다. coordinator가 죽어도 다른 broker가 같은 partition의 leader로 승격하면서 이 로그를 재생해 상태를 복원합니다.

Two-phase commit과 transaction marker

Transaction은 사용자 partition 안에 두 종류의 control record로 표현됩니다.

  • Commit marker — "지금 이 offset 이전의 트랜잭션 레코드는 커밋되었다"
  • Abort marker — "지금 이 offset 이전의 트랜잭션 레코드는 abort되었다"

Marker는 일반 데이터와 동일한 로그 segment에 끼어들지만, attribute 비트로 control record임이 표시되어 일반 consumer에는 노출되지 않습니다.

전체 트랜잭션 RPC 흐름

flowchart TD
  P[Producer] -->|"1. FindCoordinator(txnId)"| Any[Any broker]
  P -->|"2. InitProducerId(txnId)"| TC[Transaction Coordinator]
  TC -->|"persist PID, epoch, state=Empty"| TS[__transaction_state]
  P -->|"3. AddPartitionsToTxn(p1, p2)"| TC
  TC -->|"persist state=Ongoing"| TS
  P -->|"4. Produce to p1, p2"| L1[Partition Leaders]
  P -->|"5. EndTxn commit"| TC
  TC -->|"persist state=PrepareCommit"| TS
  TC -->|"6. WriteTxnMarkers commit"| L1
  L1 -->|"append commit marker"| L1
  TC -->|"persist state=CompleteCommit"| TS

각 단계마다 coordinator는 자기 상태를 __transaction_state에 먼저 기록하고 그 다음에 다음 RPC를 보냅니다. 이 강제된 순서가 장애 복구의 토대입니다. PrepareCommit까지 기록된 트랜잭션은 무조건 끝까지 commit을 진행해야 하고(forward recovery), Ongoing에서 timeout이 난 트랜잭션은 abort로 굴러갑니다.

transaction.timeout.ms

기본값은 60000ms(1분)이고, broker가 허용하는 상한은 transaction.max.timeout.ms(기본 900000ms = 15분)입니다. Coordinator는 첫 AddPartitionsToTxn 시점에서 timer를 켜고, 이 시간 안에 EndTxn을 받지 못하면 트랜잭션을 abort로 굴립니다.

Marker 한 번에 묶기

Coordinator는 같은 broker로 가야 하는 marker들을 한 WriteTxnMarkers RPC로 묶어 보냅니다. 트랜잭션에 N개 partition이 들어 있고 그 leader들이 M개 broker에 분산돼 있다면 RPC는 M번만 발생합니다. 다만 각 partition 입장에서는 데이터 record와 별개로 한 번의 marker 쓰기가 더해진다는 비용은 피할 수 없습니다.

Consumer 쪽: read_committed와 LSO

isolation.level

Consumer의 isolation.level은 두 값을 가집니다.

  • read_uncommitted(기본) — 트랜잭션 진행 여부와 무관하게 모든 record를 그대로 노출
  • read_committed — 커밋된 트랜잭션의 record만 노출

EOS를 쓰려면 consumer는 반드시 read_committed로 설정해야 합니다. 한쪽이라도 read_uncommitted이면 abort된 메시지가 그대로 dirty read됩니다.

Last Stable Offset (LSO)

read_committed consumer가 보는 partition의 끝은 high watermark가 아니라 Last Stable Offset (LSO) 입니다. LSO는 다음 둘 중 더 작은 값입니다.

  • High watermark
  • 현재 열려 있는(아직 commit/abort marker가 없는) 트랜잭션 중 가장 작은 시작 offset

즉 진행 중인 트랜잭션이 하나라도 있으면 LSO는 그 트랜잭션 시작 지점에서 멈춥니다. 트랜잭션이 commit되면 LSO가 marker offset 이후로 점프하고, abort되면 그 트랜잭션의 record들은 broker가 fetch 응답에서 걸러냅니다.

.txnindex 파일

각 log segment에는 .txnindex 파일이 동반될 수 있습니다. 이 파일은 segment 안의 abort된 트랜잭션 범위들을 기록합니다.

log segment 00000000000000123456.log
              00000000000000123456.index   (offset -> position)
              00000000000000123456.timeindex
              00000000000000123456.txnindex (firstOffset, lastOffset, lastStableOffset)

Fetch 응답이 만들어질 때 broker는 .txnindex에서 응답 범위와 겹치는 abort range를 추출해 consumer에게 같이 보냅니다. Consumer 클라이언트는 그 범위에 들어가는 record를 메모리에서 걸러낸 뒤 사용자 코드에 노출합니다. Abort된 트랜잭션이 없으면 .txnindex 자체가 만들어지지 않습니다.

flowchart LR
  C[Consumer] -->|"Fetch (read_committed)"| B[Broker]
  B --> R[Read records up to LSO]
  B --> A[Read .txnindex for aborted ranges]
  B -->|"records + aborted PID list"| C
  C --> F[Filter aborted records in client]
  F --> U[User code]

KIP-447: sendOffsetsToTransaction과 consumer group fencing

이전의 한계

KIP-447 이전에는 transactional.id가 consumer instance와 1:1로 묶여야 했습니다. consumer 그룹의 partition assignment가 바뀌면 producer를 새로 만들어야 했고, 그 결과 일반적인 read-process-write 애플리케이션은 input partition 수만큼 producer 인스턴스를 띄워야 했습니다.

변경된 점

sendOffsetsToTransaction(offsets, ConsumerGroupMetadata)이 새로 받게 된 ConsumerGroupMetadata에는 consumer group id, generation id, member id가 들어 있습니다. Coordinator는 offset 커밋을 받을 때 이 generation/member id가 group coordinator의 현재 값과 일치하는지 검증하고, 일치하지 않으면 IllegalGenerationException/FencedInstanceIdException 등으로 거부합니다.

결과적으로 zombie producer가 부활해 옛 generation의 consumer 정보를 들고 커밋을 시도하면 자동으로 fence됩니다. producer 한 개로 여러 input partition의 처리를 묶을 수 있게 되어 transactional.id가 task 단위가 아닌 application 단위로 커질 수 있게 됐습니다.

KIP-890: Transactions Server-Side Defense (Kafka 4.0)

Hanging transaction 문제

KIP-890 이전에는 다음 시나리오에서 "hanging transaction" — coordinator는 abort했다고 믿지만 사용자 partition에는 marker가 안 써진 record가 남는 — 이 발생할 수 있었습니다.

  1. Producer가 Produce 요청을 보냄
  2. Network 지연으로 broker에 도착이 늦어짐
  3. Producer가 retry 한도를 넘어 트랜잭션을 abort, coordinator는 partition들에 abort marker 기록
  4. 1번에서 늦게 도착한 Produce가 leader에 도달, abort 후에 데이터 record로 append됨
  5. 이 record는 어떤 marker로도 닫히지 않은 채 LSO를 영원히 앞에서 막음

Transaction Version 2의 epoch bump

해법은 매 트랜잭션마다 producer epoch을 한 칸씩 올리는 것입니다. KIP-890 / Transaction Version 2에서 coordinator는 EndTxn을 처리하고 marker를 쓰기 직전에 epoch을 +1 한 뒤, 새 epoch을 marker에 박아 넣습니다. Partition leader는 marker의 epoch가 "현재 자기가 보는 producer epoch보다 strictly greater"인지 확인합니다. 늦게 도착한 옛 epoch의 Produce는 자동으로 거부됩니다.

EndTxn response v5 이상은 새 producerId와 새 epoch를 같이 돌려주고, client는 이를 받아 다음 트랜잭션에 사용합니다. epoch가 Short.MAX_VALUE에 도달하면 client가 새 PID를 할당받아 wrap-around 합니다.

적용 조건

  • 서버: transaction.version=2 feature flag 활성화. Apache Kafka 4.0부터 새 클러스터의 기본값
  • 클라이언트: 4.0+ producer. 옛 클라이언트는 자동으로 v1 protocol로 fallback

운영 함정 다섯 가지

1. transactional.id 재사용 정책

같은 application의 같은 task에는 같은 transactional.id를 부여하고, 다른 task에는 절대 공유하지 않습니다. 인스턴스가 죽고 살아날 때 fence가 동작하려면 ID가 동일해야 합니다. 임의로 UUID를 매번 새로 만들면 zombie producer가 계속 살아 있을 수 있습니다.

2. Consumer를 read_uncommitted로 두는 실수

isolation.level 기본값은 read_uncommitted입니다. EOS pipeline의 어디 한 군데라도 consumer가 기본값으로 남아 있으면 abort된 record를 그대로 읽어 처리하게 됩니다. 다운스트림 effect가 외부 시스템(DB, 외부 API)에 닿는다면 EOS는 그 순간 깨집니다.

3. transaction.timeout.ms vs transaction.max.timeout.ms

Producer가 설정한 transaction.timeout.ms가 broker의 transaction.max.timeout.ms(기본 15분)보다 크면 InvalidTxnTimeoutException이 떨어집니다. 트랜잭션 길이가 길어진다면 broker 측 상한을 같이 올려야 합니다. 다만 너무 긴 트랜잭션은 그동안 LSO를 막아 read_committed consumer의 지연을 만듭니다.

4. Non-transactional producer가 같은 partition에 쓰는 경우

같은 partition을 transactional producer와 non-transactional producer가 같이 쓰면, non-transactional producer의 record는 어떤 marker로도 닫히지 않으므로 LSO 진척에 직접 영향은 없지만 데이터 모델이 깨집니다. 그리고 다른 transactional producer가 동시에 진행 중인 트랜잭션이 있으면 그 트랜잭션 끝나기 전까지 non-transactional record도 read_committed consumer 입장에서는 보이지 않습니다.

5. Hanging transaction 진단

KIP-664가 추가한 kafka-transactions.sh --describe-producers / --abort 도구는 partition 단위로 producer 메타데이터를 보여주고, 필요 시 강제 abort marker를 써넣을 수 있습니다. LSO가 갑자기 멈춰 consumer lag이 무한히 증가한다면 가장 먼저 의심해야 하는 것은 hanging transaction이고, KIP-890이 적용된 클러스터에서는 이 도구가 필요할 일이 거의 사라집니다.

정리

Kafka의 exactly-once는 마법이 아니라 두 계층의 협업입니다. Idempotent producer는 PID·sequence·epoch라는 세 개의 키로 single-partition single-instance 중복을 broker 쪽에서 걸러냅니다. 그 위에 transaction coordinator는 __transaction_state 로그와 TransactionState 8-상태 머신으로 multi-partition 원자성과 consumer offset 커밋을 묶어주고, 사용자 partition에는 control record로 marker를 박아 넣어 read_committed consumer가 LSO까지만 보도록 만듭니다. KIP-447은 producer-consumer scalability 한계를 풀었고, KIP-890은 hanging transaction을 서버 쪽 epoch bump로 원천 차단했습니다.

EOS를 운영에 들이려면 다음 세 가지를 잊지 않으면 됩니다. transactional.id는 인스턴스가 아닌 논리적 정체성에 매기고, consumer는 반드시 read_committed로 둘 것. 그리고 트랜잭션은 짧게 가져갈 것 — LSO를 막는 것은 결국 비용이기 때문입니다.

참고자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ