Kafka 전달 보장: Producer와 Consumer 멱등성 실전
inventory-service는 Outbox Pattern과 전달 보장을 지원하기 위해 인메모리 저장소에서 PostgreSQL로 전환합니다. docker-compose.yaml에 postgres-inventory 컨테이너를 추가하세요. image postgres:17, ports 5432:5432, 환경 변수 POSTGRES_USER=postgres, POSTGRES_PASSWORD=postgres, POSTGRES_DB=inventory_db.
inventory-service의 pom.xml에 다음 의존성을 추가하세요:
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
application.properties에서 연결을 지정하세요:
spring.datasource.url=jdbc:postgresql://localhost:5432/inventory_db
spring.datasource.username=postgres
spring.datasource.password=postgres
inventory 테이블 생성:
CREATE TABLE inventory (
id BIGSERIAL PRIMARY KEY,
product_name VARCHAR(256) NOT NULL UNIQUE,
quantity INT NOT NULL CHECK (quantity >= 0)
);
INSERT INTO inventory (product_name, quantity) VALUES
('Smartphone', 5),
('Tablet', 10),
('Desktop', 6);
Inventory 엔티티:
@Entity
@Table(name = "inventory")
public class Inventory {
@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "product_name", nullable = false, unique = true, length = 256)
private String productName;
@Column(name = "quantity", nullable = false)
private Integer quantity;
public Inventory(String productName, Integer quantity) {
this.productName = productName;
this.quantity = quantity;
}
// getters, setters
}
deductStock이 포함된 Repository:
public interface InventoryRepository extends JpaRepository<Inventory, Long> {
@Modifying
@Query("UPDATE Inventory i SET i.quantity = i.quantity - :quantity WHERE i.productName = :productName AND i.quantity >= :quantity")
int deductStock(@Param("productName") String productName, @Param("quantity") Integer quantity);
}
서비스의 KafkaListener 핸들러는 재고 가용성을 확인하고 InventoryReservedEvent 또는 InventoryRejectedEvent를 전송합니다.
acks 설정과 exactly-once 의미론 이해
spring.kafka.producer.acks 매개변수는 확인 응답을 제어합니다: 0은 at-most-once, 1은 부분적, all은 모든 팔로워 복제 시 at-least-once입니다.
분산 시스템에서 exactly-once 전달은 두 장군 문제(Two Generals' Problem)로 인해 불가능합니다. exactly-once 의미론으로 구현되는데, 이는 at-least-once 전달 + producer와 consumer 멱등성입니다.
Producer 멱등성: ProducerId, Epoch, 그리고 시퀀스 번호
멱등성은 ack 손실로 인한 재시도 시 중복을 방지합니다. 다음으로 활성화됩니다:
spring.kafka.producer.properties.enable.idempotence=true
브로커는 연결에 (ProducerId, ProducerEpoch) 쌍을 할당합니다:
- ProducerId: 고유 producer 식별자
- ProducerEpoch: 재시작 시 증가, 좀비 producer로부터 보호
각 메시지는 시퀀스 번호로 번호가 매겨집니다. 브로커는 ProducerId/Epoch/sequence 내 중복을 거부합니다.
좀비 producer 시나리오:
- Producer가 크래시하지만 새 프로듀서 재시작 후 "부활"
- 이전 프로듀서가 높은 시퀀스 번호로 계속 진행
- 새 프로듀서가 1부터 시작 — ProducerEpoch로 구분
Transactional.id는 재시작 시 ProducerId 보존을 보장합니다 (Spring이 initTransactions() 호출). 없으면 새 랜덤 ProducerId입니다.
중요한 멱등성 매개변수
spring.kafka.producer.acks=all
spring.kafka.producer.retries=2147483647 # >0
spring.kafka.producer.properties.max.in.flight.requests.per.connection=5 # ≤5
spring.kafka.producer.properties.enable.idempotence=true
위반 시 멱등성이 비활성화됩니다. order-service와 inventory-service에 적용하세요.
이중 처리에 대한 Consumer 멱등성
컨슈머가 오프셋 커밋 후 비즈니스 로직 완료 전에 크래시하면 메시지를 두 번 처리할 수 있습니다. inventory-service의 경우:
- OrderPlacedEvent 읽기
- PostgreSQL에서 deductStock() (원자적으로)
- InventoryReservedEvent 전송
- 크래시
- 오프셋 재읽기, deductStock() 반복 — 하지만 quantity 이미 감소, UPDATE가 작동하지 않음
이는 조건부 UPDATE를 통해 비즈니스 로직 수준에서 멱등성을 보장합니다.
이 접근법의 장점:
- 추가 오프셋 저장소 불필요
- DB ACID 트랜잭션 활용
- WHERE quantity >= :quantity로 간단 구현
주요 포인트
- Producer 멱등성: enable.idempotence=true + acks=all + retries>0 + max.in.flight≤5
- ProducerEpoch: 재시작 시 좀비 producer 보호
- Transactional.id: ProducerId 보존, initTransactions() 필요
- Consumer: DB 조건부 UPDATE로 비즈니스 로직 멱등성 보장
- Exactly-once 의미론: at-least-once + 멱등성, 문자 그대로의 exactly-once 아님
— Editorial Team
아직 댓글이 없습니다.