Kafka와 아웃박스 패턴으로 이벤트 파이프라인 구축하기

2025. 9. 5. 00:04·Spring

TL;DR

Kafka 기반 이벤트 파이프라인을 구축하면서 겪었던 현실적인 고민들을 공유합니다. "At Least Once" 보장을 위한 아웃박스 패턴, 컨슈머에서의 멱등성 처리, 그리고 동시성 제어까지 - 완벽한 설계는 아니지만, 마주한 문제들을 어떻게 해결했는지 솔직하게 담았습니다.


문제 정의: 왜 Kafka가 필요했을까?

기존에 Spring ApplicationEvent를 활용한 이벤트 기반 아키텍처를 구축했지만, 새로운 요구사항이 생겼습니다:

  • 상품별 유저 이벤트 집계: 일별 좋아요 수, 조회 수, 주문 수 추적
  • 감사 로그: 모든 비즈니스 이벤트의 완전한 기록
  • 캐시 관리: 상품 상세에 대한 캐시관리

기존 방식의 한계가 명확했습니다:

// 기존: 애플리케이션 내부 이벤트만 처리 가능
@EventListener
public void handleProductLike(ProductLikeEvent event) {
    // 같은 애플리케이션 내에서만 동작
}

Kafka를 도입

  • 확장성: 새로운 컨슈머 애플리케이션 추가 가능
  • 내구성: 메시지가 디스크에 저장되어 애플리케이션 장애 시에도 안전
  • 순서 보장: 파티션 키로 같은 상품 이벤트의 순서 보장

하지만 가장 걱정스러운 부분은 메시지 유실과 중복 처리 문제였습니다.


Producer 측: 아웃박스 패턴으로 At Least Once 보장

고민: "DB 트랜잭션은 성공했는데 Kafka 발행이 실패하면?"

가장 무서웠던 시나리오입니다

  1. 사용자가 상품에 좋아요 클릭
  2. DB에 좋아요 데이터 저장 성공 
  3. Kafka로 이벤트 발행 실패 
  4. 결과: 좋아요는 저장되었지만 집계는 누락

해결책: 아웃박스 패턴 적용

트랜잭션 내에서 이벤트를 별도 테이블에 먼저 저장하고, 스케줄러가 주기적으로 Kafka에 발행하는 방식을 선택했습니다.

 

1단계: 아웃박스 테이블 설계

@Entity
@Table(name = "outbox_event", indexes = {
    @Index(name = "idx_published_created", columnList = "published, createdAt"),
    @Index(name = "idx_aggregate_created", columnList = "aggregateId, createdAt")
})
public class OutboxEvent extends BaseEntity {
    private String eventId;
    private String aggregateId;  // productId - 파티션 키로 사용
    private EventType eventType;
    private String payload;      // JSON 형태로 저장
    private String topicName;
    private Boolean published = false;
    private ZonedDateTime publishedAt;
    
    public static OutboxEvent createProductLike(String topicName, String aggregateId, String payload) {
        OutboxEvent outboxEvent = new OutboxEvent();
        outboxEvent.eventId = UUID.randomUUID().toString();
        outboxEvent.aggregateId = aggregateId;
        outboxEvent.eventType = EventType.PRODUCT_LIKED;
        outboxEvent.topicName = topicName;
        outboxEvent.payload = payload;
        return outboxEvent;
    }
}

 

2단계: Spring Event를 활용한 아웃박스 저장

@Component
public class OutboxEventPublisherImpl implements OutboxEventPublisher {
    private final ApplicationEventPublisher applicationEventPublisher;
    
    @Override
    public void publish(ProductLikeEvent event) {
        try {
            String payload = objectMapper.writeValueAsString(event);
            applicationEventPublisher.publishEvent(
                OutboxEvent.createProductLike(
                    "product-like-events",
                    event.productId().toString(),
                    payload)
            );
        } catch (Exception e) {
            log.error("Failed to save event to outbox: {}", event, e);
            throw new RuntimeException("Outbox save failed", e);
        }
    }
}

 

Spring의 @TransactionalEventListener를 사용해 같은 트랜잭션 내에서 아웃박스에 저장

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(OutboxEvent event) {
    outboxEventRepository.save(event);
}

 

3단계: 스케줄러로 Kafka 발행

@Component
public class OutboxEventScheduler {
    
    @Scheduled(fixedDelayString = "#{${scheduling.tasks.outbox-publishing.interval-seconds:5} * 1000}")
    public void publishPendingEvents() {
        distributedLock.executeWithLock(OUTBOX_LOCK_KEY, LOCK_TIMEOUT, this::doPublishEvents);
    }
    
    private void doPublishEvents() {
        List<OutboxEvent> events = outboxEventRepository.findUnpublishedEvents(batchSize);
        
        for (OutboxEvent event : events) {
            try {
                String enrichedPayload = addEventMetadataToPayload(
                    event.getPayload(),
                    event.getEventId(),
                    event.getEventType().toString(),
                    event.getAggregateId()
                );
                
                kafkaTemplate.send(
                    event.getTopicName(),
                    event.getAggregateId(),  // 파티션 키로 순서 보장
                    enrichedPayload
                );
                
                outboxEventRepository.markAsPublished(event.getEventId());
            } catch (Exception e) {
                log.error("이벤트 발행 실패: eventId={}", event.getEventId(), e);
            }
        }
    }
}

 

Producer 설정으로 At Least Once 보장

@Bean
public ProducerFactory<Object, Object> producerFactory(KafkaProperties props) {
    Map<String, Object> cfg = new HashMap<>(props.buildProducerProperties());
    cfg.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
    cfg.put(ProducerConfig.ACKS_CONFIG, "all");  // 모든 리플리카 확인
    cfg.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
    cfg.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 300_000);
    return new DefaultKafkaProducerFactory<>(cfg);
}

 

선택한 이유와 고민

선택한 이유:

  • DB 트랜잭션과 이벤트 발행을 완전히 분리
  • 스케줄러 실패 시에도 재시도 가능
  • 분산 락으로 여러 인스턴스에서 중복 발행 방지

아직 부족한 부분:

  • 스케줄러가 영구적으로 실패할 경우에 대한 알람 부재
  • 오래된 미발행 이벤트에 대한 처리 정책 부재

Consumer 측: 멱등성 처리의 실제 구현

고민: "같은 이벤트가 여러 번 와도 최종 결과는 한 번만 반영되어야 한다"

At Least Once 보장은 중복 메시지 가능성을 의미합니다. 특히 상품 집계 데이터는 중복 처리되면 안 되는 중요한 데이터였습니다.

해결책: First Insert 기법 활용

EventHandled 테이블을 통한 멱등성 보장을 구현했습니다

 

1단계: EventHandled 테이블 설계

@Entity
@Table(name = "event_handled", 
       uniqueConstraints = @UniqueConstraint(name = "uk_event_id", columnNames = {"eventId"}))
public class EventHandled extends BaseEntity {
    @Column(nullable = false, unique = true)
    private String eventId;
    
    private String eventType;
    private String aggregateId;
    
    @Enumerated(EnumType.STRING)
    private ProcessingResult processingResult;  // IN_PROGRESS, SUCCESS, FAILURE
    
    private String errorMessage;
    
    public static EventHandled inProgress(String eventId, String eventType, String aggregateId) {
        return EventHandled.builder()
            .eventId(eventId)
            .eventType(eventType)  
            .aggregateId(aggregateId)
            .processingResult(ProcessingResult.IN_PROGRESS)
            .build();
    }
}

 

2단계: 멱등성 처리기 구현

@Component
public class IdempotentEventProcessor {
    
    @Transactional
    public boolean processExactlyOnce(String eventId, String eventType, 
                                     String aggregateId, Runnable work) {
        if (!StringUtils.hasText(eventId)) {
            log.warn("eventId가 비어있어 처리 불가");
            return false;
        }
        
        // First Insert 시도
        boolean started = recorder.tryStart(eventId, eventType, aggregateId);
        if (!started) {
            log.debug("중복 감지(이미 실행되었거나 실행 중): eventId={}", eventId);
            return false;
        }
        
        try {
            work.run();  // 실제 비즈니스 로직 실행
            recorder.markSuccess(eventId);
            return true;
        } catch (Exception e) {
            recorder.markFailure(eventId, e.getMessage());
            throw e;
        }
    }
}

 

3단계: First Insert 구현

@Component
public class EventHandledRecorder {
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public boolean tryStart(String eventId, String eventType, String aggregateId) {
        try {
            eventHandledRepository.save(EventHandled.inProgress(eventId, eventType, aggregateId));
            return true;  // 최초 삽입 성공
        } catch (DataIntegrityViolationException dup) {
            return false; // 이미 처리 중이거나 완료됨
        }
    }
}

 

4단계: 배치 컨슈머 템플릿 구현

모든 컨슈머에서 공통으로 사용할 수 있는 배치 처리 템플릿을 만들었습니다:

public abstract class BatchConsumerTemplate<E> {
    
    private final IdempotentEventProcessor idempotent;
    private final DltPublisher dltPublisher;
    
    protected abstract boolean isValid(E dto);
    protected abstract String eventId(E dto);
    protected abstract String eventType(E dto);
    protected abstract String aggregateId(E dto);
    protected abstract void process(E dto);
    
    public final void handleBatch(String topic, List<E> events, 
                                 List<String> keys, List<Integer> partitions, 
                                 List<Long> offsets, Acknowledgment ack) {
        int processed = 0, invalid = 0, failed = 0, duplicate = 0;
        
        for (int i = 0; i < events.size(); i++) {
            E dto = events.get(i);
            String key = keys.get(i);
            
            try {
                if (!isValid(dto)) {
                    sendToDlt(topic, key, dto, "invalid-payload", partitions.get(i), offsets.get(i));
                    invalid++;
                    continue;
                }
                
                boolean executed = idempotent.processExactlyOnce(
                    eventId(dto),
                    eventType(dto),
                    aggregateId(dto),
                    () -> process(dto)
                );
                
                if (executed) processed++;
                else duplicate++;
                
            } catch (Exception e) {
                failed++;
                sendToDlt(topic, key, dto, e.getClass().getSimpleName(), partitions.get(i), offsets.get(i));
            }
        }
        
        ack.acknowledge();
        log.info("batch done: total={}, processed={}, invalid={}, failed={}", 
                events.size(), processed, invalid, failed);
    }
}

 

실제 컨슈머 구현 예시

@Component
public class ProductLikeEventConsumer extends BatchConsumerTemplate<ProductLikeEventDto> {
    
    private final ProductLikeEventProcessor processor;
    
    @Override protected boolean isValid(ProductLikeEventDto dto) { return dto != null && dto.isValid(); }
    @Override protected String eventId(ProductLikeEventDto dto) { return dto.getEventId(); }
    @Override protected String eventType(ProductLikeEventDto dto) { return dto.getEventType(); }
    @Override protected String aggregateId(ProductLikeEventDto dto) { return dto.getAggregateId(); }
    
    @Override
    protected void process(ProductLikeEventDto dto) {
        processor.processEvent(dto.toCommand());
    }
    
    @KafkaListener(
        topics = "${app.kafka.topics.product-like-events:product-like-events}",
        containerFactory = KafkaConfig.BATCH_LISTENER,
        groupId = "${app.kafka.consumer-groups.product-like-collector:product-like-collector}"
    )
    public void onMessage(@Payload List<ProductLikeEventDto> events,
                         @Header(KafkaHeaders.RECEIVED_KEY) List<String> keys,
                         @Header(KafkaHeaders.RECEIVED_PARTITION) List<Integer> partitions,
                         @Header(KafkaHeaders.OFFSET) List<Long> offsets,
                         @Header(KafkaHeaders.RECEIVED_TOPIC) List<String> topics,
                         Acknowledgment ack) {
        String topic = topics.isEmpty() ? "unknown" : topics.getFirst();
        handleBatch(topic, events, keys, partitions, offsets, ack);
    }
}

동시성 제어: 하나의 로우에 여러 컨슈머가 접근할 때

문제 상황

ProductMetrics 테이블의 하나의 로우(특정 날짜의 특정 상품)에 여러 컨슈머가 동시에 접근하는 상황:

  • 좋아요 컨슈머: likeCount++
  • 조회 컨슈머: viewCount++
  • 주문 컨슈머: salesCount++

동시에 같은 로우를 업데이트하면 Lost Update 문제가 발생할 수 있습니다.

해결책: DB 비관적락을 통한 동시성 제어

@Service
public class ProductMetricsService {
    
    private final ProductMetricsRepository productMetricsRepository;
    private final StringRedisTemplate stringRedisTemplate;
    
    @Transactional
    public void metricProductLike(ProductLikeMetricCommand command) {
        // 오늘 날짜의 메트릭을 조회하거나 생성
        ProductMetrics productMetrics = productMetricsRepository
            .findTodayProductMetric(command.getProductId())
            .orElseGet(() -> productMetricsRepository.save(
                ProductMetrics.createNew(command.getProductId(), command.getMetricDate())
            ));
            
        // 비즈니스 로직 실행
        if (command.isLikeEvent()) {
            productMetrics.incrementLikeCount();
        } else {
            productMetrics.decrementLikeCount();
        }
        
        // 캐시 무효화
        String cacheKey = "product:option:v1:" + command.getAggregateId();
        stringRedisTemplate.delete(cacheKey);
    }
}

 

DB 레벨에서의 동시성 제어:

public interface ProductMetricsJpaRepository extends JpaRepository<ProductMetrics, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "5000")) // 5s
    Optional<ProductMetrics> findByProductIdAndMetricDate(Long productId, ZonedDateTime metricDate);
}

 

고민했던 다른 방법들

1. Redis 분산 락 사용

@DistributedLock(key = "product_metrics:#{#command.productId}:#{T(java.time.LocalDate).now()}")
public void metricProductLike(ProductLikeMetricCommand command) {
    // 처리 로직
}

 

2. 메시지 큐를 이용한 직렬화

  • 같은 productId의 모든 이벤트를 하나의 파티션으로 보내서 순차 처리

 

최종 선택: DB 락을 선택한 이유

  • 트랜잭션과 일관성 있는 처리
  • Redis 장애 시에도 동작 보장
  • 구현 복잡도가 상대적으로 낮음

감사 로그: 모든 이벤트를 놓치지 않는 방법

요구사항

모든 비즈니스 이벤트(좋아요, 주문, 조회)를 빠짐없이 기록해야 했습니다. 

해결책: 별도 컨슈머 그룹으로 모든 토픽 구독

@Component
public class AuditLogEventConsumer {
    
    @KafkaListener(
        topics = {
            "${app.kafka.topics.product-like-events:product-like-events}",
            "${app.kafka.topics.product-order-events:product-order-events}",
            "${app.kafka.topics.product-view-events:product-view-events}"
        },
        containerFactory = KafkaConfig.BATCH_LISTENER,
        groupId = "${app.kafka.consumer-groups.audit-collector:audit-collector}"  // 별도 그룹
    )
    public void onAuditBatch(List<ConsumerRecord<String, byte[]>> records, Acknowledgment ack) {
        int processed = 0, skipped = 0, failed = 0;
        
        for (ConsumerRecord<String, byte[]> rec : records) {
            try {
                String raw = new String(rec.value(), StandardCharsets.UTF_8);
                UniversalEventDto dto = objectMapper.readValue(raw, UniversalEventDto.class);
                dto.setRawPayload(raw);  // 원본 데이터 보존
                dto.setTopicName(rec.topic());
                
                if (!dto.isValid()) {
                    sendDlt(rec.topic(), rec.key(), raw, e.getClass().getSimpleName(), 
                       rec.partition(), rec.offset());
            }
        }
        
        ack.acknowledge();
        log.info("audit batch done: total={}, processed={}, skipped={}, failed={}",
                records.size(), processed, skipped, failed);
    }
}

감사 로그 설계 원칙

1. 원본 데이터 보존

@Entity
public class AuditLog extends BaseEntity {
    private String eventId;
    private String eventType;
    private String topicName;
    private String aggregateId;
    
    @Lob
    @Column(columnDefinition = "json")
    private String rawPayload;  // 원본 JSON 그대로 저장
    
    private ZonedDateTime eventOccurredAt;
    private ZonedDateTime processedAt;
}

 

2. 범용 DTO 설계

public class UniversalEventDto {
    private String eventId;
    private String eventType;
    private String aggregateId;
    private String timestamp;
    private String topicName;
    private String rawPayload;
    
    public boolean isValid() {
        return StringUtils.hasText(eventId) 
            && StringUtils.hasText(eventType)
            && StringUtils.hasText(aggregateId);
    }
    
    public AuditLogCommand toCommand() {
        return AuditLogCommand.builder()
            .eventId(eventId)
            .eventType(eventType)
            .topicName(topicName)
            .aggregateId(aggregateId)
            .rawPayload(rawPayload)
            .eventOccurredAt(parseTimestamp(timestamp))
            .build();
    }
}

컨슈머 그룹을 분리한 이유

메트릭 컬렉터 vs 감사 로그 컬렉터

  • 메트릭 컬렉터: product-like-collector
    • 집계 데이터 생성에 집중
    • 처리 속도가 중요
    • 일부 실패 시 재처리 가능
  • 감사 로그 컬렉터: audit-collector
    • 모든 이벤트 완전한 기록
    • 데이터 무결성이 최우선
    • 실패 시 반드시 재처리

컨슈머 그룹을 분리함으로써:

  • 각각 독립적인 오프셋 관리
  • 한쪽 장애가 다른 쪽에 영향 없음
  • 처리 속도와 정책을 다르게 설정 가능

현실적인 고민들과 트레이드오프

1. "정말 이렇게 복잡하게 해야 할까?"

처음에는 단순하게 시작하려고 했습니다:

// 처음 생각했던 단순한 방식
@KafkaListener(topics = "product-events")
public void handle(String message) {
    ProductEvent event = parse(message);
    productMetricsService.updateMetrics(event);
    // 끝!
}

하지만 현실은 달랐습니다:

  • 메시지 중복: 네트워크 이슈로 같은 이벤트가 여러 번 도착
  • 처리 실패: 일시적 DB 장애로 일부 메시지만 실패
  • 순서 보장: 좋아요 → 취소 순서가 바뀌면 잘못된 집계

"복잡해 보이지만, 운영 환경에서는 이 모든 것이 실제로 발생가능한 문제들이었습니다."

2. "EventHandled 테이블과 감사 로그 테이블을 왜 분리했을까?"

초기에는 하나의 테이블로 통합하려고 했습니다:

// 처음 생각: 하나의 테이블로 통합
@Entity  
public class EventRecord {
    private String eventId;
    private ProcessingResult result;  // 처리 상태
    private String payload;           // 감사 로그용 데이터
    // ...
}

분리한 이유:

  1. 목적의 차이
    • EventHandled: 중복 방지용, 처리 상태 추적
    • AuditLog: 완전한 이력 보존, 컴플라이언스
  2. 접근 패턴의 차이
    • EventHandled: eventId 기준 빠른 조회
    • AuditLog: 시간대별, 이벤트 타입별 분석 쿼리

3. "장애 상황에서는 어떻게 될까?"

시나리오별 대응 방안:

장애 상황 영향도 대응 방안

장애 상황 영향도 대응 방안
Kafka 브로커 다운 Producer 발행 실패 아웃박스 테이블에 누적, 복구 후 자동 발행
Consumer 앱 다운 메시지 처리 중단 재시작 시 마지막 커밋 오프셋부터 재개
DB 장애 EventHandled 저장 실패 재시도 후 DLT로 이동, 수동 복구
중복 메시지 잘못된 집계 EventHandled로 자동 차단

 

DLT(Dead Letter Topic) 활용:

@Bean
public DeadLetterPublishingRecoverer deadLetterPublishingRecoverer() {
    return new DeadLetterPublishingRecoverer(dltKafkaTemplate);
}

@Bean  
public CommonErrorHandler kafkaErrorHandler(DeadLetterPublishingRecoverer recoverer) {
    ExponentialBackOffWithMaxRetries backOff = new ExponentialBackOffWithMaxRetries(5);
    backOff.setInitialInterval(500);
    backOff.setMaxInterval(5_000);
    
    return new DefaultErrorHandler(recoverer, backOff);
}

토픽과 파티션 설계: 순서 보장의 딜레마

고민: "이벤트 순서가 정말 중요할까?"

같은 상품에 대한 이벤트들:

  1. 상품 조회 (10:00)
  2. 좋아요 클릭 (10:01)
  3. 좋아요 취소 (10:02)

만약 3→2 순서로 처리되면 최종적으로 좋아요 상태가 잘못될 수 있습니다.

해결책: ProductId 기반 파티셔닝

// 아웃박스에서 Kafka 발행 시
kafkaTemplate.send(
    event.getTopicName(),
    event.getAggregateId(),  // productId를 파티션 키로 사용
    enrichedPayload
);

토픽 구조:

  • product-like-events: 좋아요/좋아요취소 (key=productId)
  • product-order-events: 주문 완료 (key=productId)
  • product-view-events: 상품 조회 (key=productId)

 



회고:  배운 것들

잘했던 점

1. 단계적 접근 Spring Event → 아웃박스 패턴 → Kafka 순으로 점진적으로 도입했습니다. 필요에 따라 발전

 

2. 현실적인 문제 해결 이론적인 완벽함보다는 실제 운영에서 발생할 수 있는 문제들에 집중했습니다:

  • 메시지 중복 처리
  • 부분 실패 상황
  • 장애 복구 방안

3. 모니터링과 로깅

log.info("batch done: total={}, processed={}, invalid={}, failed={}", 
        events.size(), processed, invalid, failed);

매 배치 처리 후 상세한 통계를 남겨서 문제 상황을 빠르게 파악할 수 있도록 했습니다.

아쉬웠던 점

1. 테스트 부족 통합 테스트 작성이 생각보다 복잡했습니다:

  • Testcontainers로 Kafka 테스트 환경 구성
  • 장애 상황 테스트

2. 카프카에 대한 이해부족

  • Kafka를 처음 다뤄보면서 가장 어려웠던 부분입니다. 표면적인 구현은 했지만, 깊이 있는 이해가 부족했습니다:
  • 낯선 설정과 용어들: Producer, Consumer의 수많은 설정 옵션들과 ISR, 리더/팔로워, Consumer Lag 등의 개념들이 처음에는 너무 낯설고 어려웠습니다
  • 설정값의 근거 부족: 문서나 예제를 참고해서 설정했지만, 왜 이 값이어야 하는지, 우리 상황에 맞는 최적값이 무엇인지 판단하기 어려웠습니다
  • 운영 관점의 부족: 개발 환경에서는 잘 동작하지만, 실제 프로덕션에서 어떤 문제가 발생할 수 있는지, 모니터링은 어떻게 해야 하는지 경험이 부족했습니다

기술적으로 성장한 부분

1. 트랜잭션 경계에 대한 이해

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)  // 같은 트랜잭션
vs  
@Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)  // 별도 트랜잭션

언제 묶고 언제 분리할지에 대한 감각이 생겼습니다.

 

2. 분산 시스템의 현실

  • 네트워크는 언제든 실패할 수 있다
  • 메시지는 중복될 수 있다
  • 순서는 보장되지 않을 수 있다

이런 제약사항들을 받아들이고 설계에 반영하는 법을 배웠습니다.

 

3. 운영 관점의 사고

"개발 완료"가 끝이 아니라, 운영에서 문제없이 동작하는 것이 진짜 완료라는 걸 깨달았습니다.


마무리: 완벽하지 않지만 동작하는 시스템

이번 프로젝트를 통해 "완벽한 설계"보다는 "현실적인 문제 해결"이 더 중요하다는 걸 배웠습니다.

아직 부족한 부분들이 많습니다:

  • 더 정교한 에러 핸들링
  • 포괄적인 모니터링
  • 성능 최적화

하지만 다음 요구사항들은 충족했습니다:

  • ✅ 메시지 유실 없음 (At Least Once)
  • ✅ 중복 처리 방지 (멱등성)
  • ✅ 순서 보장 (파티션 키)
  • ✅ 장애 복구 가능
  • ✅ 확장 가능한 구조

개발자로서 가장 중요하게 생각하는 것: "왜 이렇게 했는지"에 대한 명확한 답을 갖는 것입니다.

  • 왜 아웃박스 패턴을 선택했는가? → 트랜잭션과 메시지 발행을 분리하기 위해
  • 왜 EventHandled 테이블을 만들었는가? → 중복 처리를 방지하기 위해
  • 왜 컨슈머 그룹을 분리했는가? → 각각 다른 정책으로 처리하기 위해

완벽한 분산 시스템을 만든 것은 아니지만, 비즈니스 요구사항을 만족하는 현실적인 해결책을 찾았다고 생각합니다.

앞으로도 "기술을 위한 기술"이 아닌 "문제 해결을 위한 기술 선택"을 하는 개발자가 되고 싶습니다.

'Spring' 카테고리의 다른 글

Spring Batch로 대용량 데이터 처리하기: 학습 정리  (0) 2025.10.19
Redis ZSET으로 실시간 랭킹 시스템 구축하기  (0) 2025.09.11
ApplicationEvent로 비즈니스 경계 나누기: 고민과 선택  (3) 2025.08.28
결제 시스템에 Circuit Breaker를 도입하며 – 계층별 책임 분리와 안정성 확보 전략  (1) 2025.08.22
DB 인덱스 설계와 Redis 캐싱(Port-Adapter) 적용기  (2) 2025.08.15
'Spring' 카테고리의 다른 글
  • Spring Batch로 대용량 데이터 처리하기: 학습 정리
  • Redis ZSET으로 실시간 랭킹 시스템 구축하기
  • ApplicationEvent로 비즈니스 경계 나누기: 고민과 선택
  • 결제 시스템에 Circuit Breaker를 도입하며 – 계층별 책임 분리와 안정성 확보 전략
JoshDev
JoshDev
    • 분류 전체보기 (24)
      • Java (3)
      • Spring (9)
      • Test Code (2)
      • WIL (6)
      • Vue.js (2)
      • WEB (0)
      • DB (1)
        • MySQL (1)
  • 인기 글

  • hELLO· Designed By정상우.v4.10.4
JoshDev
Kafka와 아웃박스 패턴으로 이벤트 파이프라인 구축하기
상단으로

티스토리툴바