[트러블슈팅] Kafka + Outbox를 붙이면서 만난 Spring Boot 테스트 실패 해결기 📨🔥

임선구·2026년 7월 20일

트러블슈팅

목록 보기
16/16

커피 주문 프로젝트에 Kafka + Outbox 패턴을 적용하면서 발생한 여러 문제와 해결 과정을 정리한 글입니다.


1. 구현하려던 기능 🚀

커피 주문 프로젝트에서 주문이 완료되면 주문 데이터를 외부 데이터 플랫폼으로 전송해야 했다.

처음에는 주문이 성공한 뒤 바로 Kafka로 이벤트를 보내는 방식을 생각했다.

하지만 그렇게 하면 이런 문제가 생길 수 있다.

DB 주문 저장 성공
→ Kafka 발행 실패
→ 주문은 성공했지만 데이터 플랫폼에는 전달되지 않음

그래서 Outbox 패턴을 적용했다.

구조는 다음과 같다.

주문 요청
→ OrderFacade
→ 포인트 차감
→ 주문 저장
→ 포인트 이력 저장
→ outbox_events 테이블에 주문 완료 이벤트 저장
→ 트랜잭션 커밋
→ OutboxEventPublisher가 PENDING 이벤트 조회
→ Kafka로 발행
→ 성공 시 SENT 처리
→ DataPlatformConsumer가 수신
→ Mock 데이터 플랫폼 전송 로그 출력

주문 트랜잭션 안에서는 Kafka를 직접 호출하지 않고, 먼저 outbox_events 테이블에 이벤트를 저장했다.


2. 첫 번째 문제: Jackson 예외 클래스를 찾지 못함 🧩

Outbox 이벤트를 JSON 문자열로 저장하기 위해 ObjectMapper를 사용했다.

처음에는 아래처럼 작성했다.

import tools.jackson.core.JsonProcessingException;
import tools.jackson.databind.ObjectMapper;

그런데 IntelliJ에서 에러가 발생했다.

Cannot resolve symbol 'JsonProcessingException'

3. 원인 🔍

프로젝트 환경이 Spring Boot 4 계열이었고, Jackson 3이 사용되고 있었다.

기존 Jackson 2에서는 보통 아래 패키지를 많이 사용한다.

com.fasterxml.jackson.core.JsonProcessingException

하지만 현재 환경에서는 tools.jackson 패키지를 사용하고 있었고, 예외 타입도 JsonProcessingException이 아니라 JacksonException을 사용해야 했다.


4. 해결 ✅

아래처럼 변경했다.

import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;

그리고 catch 문도 수정했다.

private String serializeEvent(OrderCompletedEvent event) {
    try {
        return objectMapper.writeValueAsString(event);
    } catch (JacksonException e) {
        throw new CustomException(ErrorCode.OUTBOX_EVENT_CREATE_FAILED);
    }
}

이후 빌드는 통과했다.


5. 두 번째 문제: KafkaTemplate Bean을 찾지 못함 🚨

Outbox 이벤트를 Kafka로 발행하기 위해 OutboxEventPublisher를 만들었다.

@Component
public class OutboxEventPublisher {

    private final OutboxService outboxService;
    private final KafkaTemplate<String, String> kafkaTemplate;

    public OutboxEventPublisher(
        OutboxService outboxService,
        KafkaTemplate<String, String> kafkaTemplate
    ) {
        this.outboxService = outboxService;
        this.kafkaTemplate = kafkaTemplate;
    }
}

그런데 테스트를 실행하니 전체 빌드가 실패했다.

./gradlew clean build

에러는 다음과 같았다.

CoffeeOrderApplicationTests > contextLoads() FAILED

Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException

핵심은 이거였다.

No beans of 'KafkaTemplate<String, String>' type found

6. 원인 💥

OutboxEventPublisher는 Spring Bean으로 등록된다.

따라서 테스트 환경에서 contextLoads()가 실행될 때도 Spring은 OutboxEventPublisher를 생성하려고 한다.

그런데 이 Bean을 만들기 위해서는 KafkaTemplate<String, String>이 필요하다.

문제는 테스트 컨텍스트에서 KafkaTemplate Bean이 준비되어 있지 않았다는 점이다.

즉, 운영 코드에 Kafka 관련 컴포넌트를 추가한 순간, 테스트 컨텍스트도 영향을 받게 된 것이다.


7. 해결: KafkaProducerConfig 추가 🛠️

KafkaTemplate<String, String> Bean을 명시적으로 등록했다.

package com.sparta.coffeeorder.config;

import java.util.HashMap;
import java.util.Map;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;

@Configuration
public class KafkaProducerConfig {

    @Value("${spring.kafka.bootstrap-servers:127.0.0.1:9092}")
    private String bootstrapServers;

    @Bean
    @ConditionalOnMissingBean
    public ProducerFactory<String, String> producerFactory() {
        Map<String, Object> configProps = new HashMap<>();

        configProps.put(
            ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,
            bootstrapServers
        );
        configProps.put(
            ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
            StringSerializer.class
        );
        configProps.put(
            ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
            StringSerializer.class
        );

        return new DefaultKafkaProducerFactory<>(configProps);
    }

    @Bean
    @ConditionalOnMissingBean
    public KafkaTemplate<String, String> kafkaTemplate(
        ProducerFactory<String, String> producerFactory
    ) {
        return new KafkaTemplate<>(producerFactory);
    }
}

이후 다시 빌드를 실행했다.

./gradlew clean build

빌드가 성공했다.


8. 세 번째 문제: 테스트에서 Kafka Listener와 Scheduler가 같이 뜰 수 있음 ⚠️

Kafka Consumer와 Outbox Scheduler를 추가하면 테스트 환경에서도 이들이 같이 실행될 가능성이 있다.

예를 들어 아래와 같은 컴포넌트가 있었다.

@Scheduled(fixedDelay = 5000)
public void publishPendingEvents() {
    List<OutboxEvent> outboxEvents = outboxService.findPublishTargets(PUBLISH_LIMIT);

    for (OutboxEvent outboxEvent : outboxEvents) {
        publish(outboxEvent);
    }
}

그리고 Kafka Consumer도 있었다.

@KafkaListener(
    topics = "order-completed",
    groupId = "coffee-order-data-platform"
)
public void consumeOrderCompletedEvent(String payload) {
    log.info("[MockDataPlatform] 주문 데이터 전송 완료. payload={}", payload);
}

운영 환경에서는 필요하지만, 테스트 환경에서는 외부 인프라와 연결되면서 테스트가 불안정해질 수 있다.


9. 해결: 테스트 환경에서 Kafka Listener와 Scheduling 비활성화 ✅

src/test/resources/application-test.yml에 아래 설정을 추가했다.

spring:
  kafka:
    listener:
      auto-startup: false

  task:
    scheduling:
      enabled: false

전체 테스트 설정은 이런 형태였다.

spring:
  datasource:
    url: jdbc:h2:mem:coffee_order_test;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    username: sa
    password:
    driver-class-name: org.h2.Driver

  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
    open-in-view: false

  kafka:
    listener:
      auto-startup: false

  task:
    scheduling:
      enabled: false

이렇게 해서 테스트 중에는 Kafka Listener와 Scheduler가 자동으로 동작하지 않도록 했다.


10. 네 번째 문제: 테스트 클래스 생성자 주입 실패 🧪

동시성 테스트를 작성하면서 테스트 클래스에 생성자 주입을 사용했다.

@SpringBootTest
@ActiveProfiles("test")
class OrderFacadeConcurrencyTest {

    private final OrderFacade orderFacade;
    private final PointService pointService;

    OrderFacadeConcurrencyTest(
        OrderFacade orderFacade,
        PointService pointService
    ) {
        this.orderFacade = orderFacade;
        this.pointService = pointService;
    }
}

그런데 테스트 실행 시 아래 에러가 발생했다.

ParameterResolutionException

11. 원인 🔍

운영 코드에서는 생성자 주입을 사용하면 Spring이 Bean을 주입해준다.

하지만 테스트 클래스는 JUnit이 먼저 객체를 생성하려고 한다.

테스트 클래스 생성자에 여러 파라미터가 있을 때 JUnit이 이 파라미터들을 어떻게 주입해야 할지 명확히 알지 못하면 ParameterResolutionException이 발생할 수 있다.


12. 해결: 생성자에 @Autowired 추가 ✅

테스트 클래스 생성자에 @Autowired를 명시했다.

import org.springframework.beans.factory.annotation.Autowired;

@Autowired
OrderFacadeConcurrencyTest(
    OrderFacade orderFacade,
    PointService pointService,
    MenuRepository menuRepository,
    OrderRepository orderRepository,
    PointWalletRepository pointWalletRepository,
    PointHistoryRepository pointHistoryRepository,
    OutboxEventRepository outboxEventRepository
) {
    this.orderFacade = orderFacade;
    this.pointService = pointService;
    this.menuRepository = menuRepository;
    this.orderRepository = orderRepository;
    this.pointWalletRepository = pointWalletRepository;
    this.pointHistoryRepository = pointHistoryRepository;
    this.outboxEventRepository = outboxEventRepository;
}

이후 동시성 테스트가 정상적으로 실행되었다.


13. 동시성 테스트 내용 🧵

포인트 결제에서 가장 중요한 것은 잔액보다 많이 차감되지 않는 것이다.

테스트 시나리오는 다음과 같았다.

사용자 포인트: 10,000원
메뉴 가격: 4,500원
동시 주문 요청: 3건

정상이라면 3건 중 2건만 성공해야 한다.

성공 2건: 4,500원 × 2 = 9,000원
실패 1건: 포인트 부족
최종 잔액: 1,000원

테스트에서는 CountDownLatch를 사용해 여러 스레드가 동시에 주문을 시작하도록 했다.

CountDownLatch readyLatch = new CountDownLatch(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(threadCount);

그리고 결과를 검증했다.

assertThat(successCount.get()).isEqualTo(2);
assertThat(failureCount.get()).isEqualTo(1);

PointWallet pointWallet = pointWalletRepository.findByUserId(userId)
    .orElseThrow();

assertThat(pointWallet.getBalance()).isEqualTo(1000);
assertThat(orderRepository.findAll()).hasSize(2);
assertThat(outboxEventRepository.findAll()).hasSize(2);

이 테스트를 통해 포인트 차감이 동시에 들어와도 잔액을 초과해서 차감되지 않는 것을 확인했다.


14. Outbox 이벤트 발행 확인 📬

주문이 성공하면 outbox_events 테이블에 이벤트가 먼저 저장된다.

초기 상태는 다음과 같다.

status = PENDING
event_type = ORDER_COMPLETED
topic = order-completed

이후 OutboxEventPublisher가 주기적으로 PENDING 이벤트를 조회해서 Kafka로 발행한다.

발행 성공 후에는 상태가 변경된다.

PENDING → SENT

DB에서 확인한 쿼리는 다음과 같다.

SELECT
    id,
    aggregate_id,
    event_type,
    status,
    retry_count,
    published_at,
    last_error
FROM outbox_events
ORDER BY id DESC;

정상 결과는 다음과 같았다.

status = SENT
retry_count = 0
published_at = 값 있음
last_error = NULL

15. Kafka Consumer 수신 확인 📡

Kafka Consumer도 정상적으로 메시지를 수신했다.

서버 로그에는 다음과 같은 로그가 출력되었다.

Subscribed to topic(s): order-completed
partitions assigned: [order-completed-0]
[MockDataPlatform] 주문 데이터 전송 완료. payload={...}

즉, 최종 흐름이 정상적으로 동작했다.

주문 성공
→ outbox_events 저장
→ Kafka 발행
→ Consumer 수신
→ Mock 데이터 플랫폼 전송 로그 출력

16. 최종 구조 정리 🧱

최종 구조는 다음과 같다.

OrderController
→ OrderFacade
→ MenuService
→ PointService
→ OrderService
→ OutboxService
→ Repository

Kafka + Outbox 흐름은 다음과 같다.

OrderFacade
→ 주문/포인트 처리 트랜잭션
→ outbox_events 저장
→ 트랜잭션 커밋
→ OutboxEventPublisher
→ Kafka order-completed topic 발행
→ DataPlatformConsumer
→ Mock 데이터 플랫폼 전송 로그 출력

17. 이번 과정에서 배운 점 📌

이번 Kafka + Outbox 적용 과정에서 배운 점은 많았다.

1. 외부 인프라를 붙이면 테스트 컨텍스트도 같이 고려해야 한다

Kafka Producer, Consumer, Scheduler는 운영 코드에서만 영향을 주는 것이 아니었다.

@SpringBootTest는 애플리케이션 컨텍스트를 전체 로딩하기 때문에 관련 Bean들이 테스트에서도 생성된다.

따라서 테스트 환경에서는 필요한 설정을 분리해야 한다.


2. Outbox는 Kafka 발행 실패로 인한 이벤트 유실 가능성을 줄인다

Kafka를 주문 트랜잭션 안에서 바로 호출하면 DB 저장과 Kafka 발행 사이에 불일치가 생길 수 있다.

Outbox를 사용하면 주문 데이터와 이벤트 데이터를 같은 DB 트랜잭션으로 저장할 수 있다.

주문 저장 성공 = Outbox 이벤트 저장 성공
주문 저장 실패 = Outbox 이벤트도 롤백

이후 Kafka 발행은 별도 Publisher가 처리하기 때문에 재시도와 상태 관리가 가능하다.


3. 동시성 테스트는 구현의 안정성을 설명할 수 있는 좋은 근거가 된다

단순히 "비관적 락을 적용했다"라고 말하는 것보다, 테스트로 검증하는 것이 훨씬 설득력 있다.

이번 테스트에서는 10,000원을 가진 사용자에게 4,500원 주문 3건을 동시에 요청했고, 2건만 성공하는 것을 확인했다.


18. 정리 🧹

이번 트러블슈팅을 요약하면 다음과 같다.

문제원인해결
JsonProcessingException 인식 실패Jackson 3 환경에서 예외 타입 차이JacksonException 사용
KafkaTemplate Bean 없음테스트 컨텍스트에서 KafkaTemplate Bean 미등록KafkaProducerConfig 추가
테스트에서 Kafka/Scheduler 실행 가능성테스트 환경에서도 전체 Bean 로딩application-test.yml에서 비활성화
테스트 생성자 주입 실패JUnit이 생성자 파라미터를 주입하지 못함생성자에 @Autowired 추가
동시 주문 잔액 초과 가능성동시에 포인트 차감 요청 발생PESSIMISTIC_WRITE 락 + 동시성 테스트

이번 경험을 통해 Kafka나 Outbox 같은 외부 인프라 기반 구조를 붙일 때는 단순히 기능 구현만 보는 것이 아니라,
트랜잭션 경계, 테스트 환경, Bean 등록, 동시성 검증까지 함께 고려해야 한다는 점을 배웠다.


profile
끝까지 가면 내가 다 이겨

0개의 댓글