알림 발행 실패 시 전체 트랜잭션 롤백 트러블 슈팅 - KimGyuBek/Threadly GitHub Wiki

문제 상황

기존 환경

  • 서비스 구성: threadly-service + Kafka + Zookeeper
  • 알림 발행 방식: 동기 처리 (트랜잭션 내에서 직접 Kafka 호출)
  • PostLike 서비스에서 좋아요 처리 시 알림 동시 발행

테스트 대상: 전체 알림 서비스(게시글 좋아요, 댓글 생성, 팔로우 요청/수락 등) 중 게시글 좋아요 알림에 한정

발생한 문제

  1. Kafka 장애 상황에서 동기 처리의 문제점

    • Kafka 브로커 다운 시 전체 트랜잭션 롤백
    • 핵심 기능(좋아요)까지 실패하여 사용자 경험 저하
    • 비즈니스 로직과 부가 기능(알림)의 강결합 문제
  2. 실제 테스트 시나리오

    • KafkaException 유발: 알림 발행 과정 중 발생할 수 있는 다양한 오류 중 KafkaException을 의도적으로 유발하여 테스트
    • 테스트 목적: 알림 발행 오류로 인한 전체 비즈니스 트랜잭션 롤백 문제 검증
    • k6 부하 테스트 도구를 사용한 성능 및 안정성 측정
    • duration 기반 테스트: 동일 시간(3분) 동안 최대 처리량으로 공정한 성능 비교
    • 동기 vs 비동기 처리 방식의 실증적 비교

해결 과정

1단계: k6 부하 테스트를 통한 문제 재현

1. 테스트 환경 설정

동기 방식 테스트 (sync-test.js):

// Kafka 예외를 고려한 좋아요 테스트
function likePostWithKafkaException(authToken, postId, vuId) {
  const likeResponse = http.post(
      `${BASE_URL}/api/posts/${postId}/likes`,
      null,
      {
        headers: {'Authorization': `Bearer ${authToken}`},
        tags: {endpoint: 'posts_like_sync'},
      }
  );

  if (likeResponse.status === 500) {
    // 예상된 시나리오: 동기 방식에서 Kafka 예외로 인한 트랜잭션 롤백
    businessLogicSuccessRate.add(false);
    kafkaFailureRate.add(true);
    transactionRollbackRate.add(true);
  }
}

비동기 방식 테스트 (async-test.js):

// 비동기 방식의 좋아요 테스트
function likePostWithAsyncProcessing(authToken, postId, vuId) {
  const likeResponse = http.post(
      `${BASE_URL}/api/posts/${postId}/likes`,
      null,
      {
        headers: {'Authorization': `Bearer ${authToken}`},
        tags: {endpoint: 'posts_like_async'},
      }
  );

  if (likeResponse.status === 200) {
    // 비동기 방식의 핵심: Kafka 예외와 무관하게 200 응답
    businessLogicSuccessRate.add(true);
    coreFeatureAvailability.add(true);
  }
}

2. 실제 측정 결과 분석

  • Before (동기 알림 발행): Before - 동기 방식-1 Before - 동기 방식-3

    동기 방식에서는 Kafka 예외로 인해 PostLikeCommandService 트랜잭션이 대부분 롤백됨. 전체 시스템 롤백률이 높게 나타나는 이유는 조회성 트랜잭션과 알림 트랜잭션이 포함된 전체 서비스 트랜잭션 통계이기 때문임.

  • After (비동기 이벤트 처리): After - 비동기 방식-1 After - 비동기 방식-2

    비동기 방식에서 전체 롤백률이 20%로 나타나는 것은 조회성 트랜잭션과 기타 서비스 트랜잭션이 포함된 수치임. 즉 PostLikeCommandService의 좋아요 기능은 100% 커밋 상태 유지, Kafka 예외가 핵심 비즈니스 로직에 완전히 격리됨.

3. k6 테스트 결과 비교

동기 방식 측정 결과 (sync.json)

메트릭 수치 분석
총 요청 수 3,430개 3분 duration 테스트 (10 VU)
성공 요청 1,720개 (50.1%) 절반만 성공
실패 요청 1,710개 (49.9%) Kafka 예외로 인한 실패
비즈니스 로직 성공률 0% 모든 좋아요 기능 실패
트랜잭션 롤백률 100% 알림 실패 시 전체 롤백
평균 응답시간 26ms -
P95 응답시간 40ms 성공 응답 기준
P99 응답시간 94ms 성공 응답 기준

비동기 방식 측정 결과 (async.json)

메트릭 수치 분석
총 요청 수 6,550개 3분 duration 테스트 - 성능 향상으로 더 많은 요청 처리 가능
성공 요청 6,550개 (100%) 모든 요청 성공
실패 요청 0개 (0%) Kafka 예외와 격리됨
비즈니스 로직 성공률 100% 핵심 기능 완전 보장
트랜잭션 롤백률 0% 알림과 무관하게 커밋
평균 응답시간 25ms 동기 방식보다 빠름
P95 응답시간 31ms 22% 개선 (40ms → 31ms)
P99 응답시간 36ms 62% 개선 (94ms → 36ms)

2단계: 아키텍처 전환 구현

1. 기존 동기 처리 방식의 문제점

@Service
@RequiredArgsConstructor
public class PostLikeCommandService implements LikePostUseCase {

  private final NotificationService notificationService; // 동기 처리
  private final CreatePostLikePort createPostLikePort;

  @Transactional
  @Override
  public LikePostApiResponse likePost(LikePostCommand command) {
    // 1. 비즈니스 로직 (좋아요 저장)
    PostLike newLike = post.like(command.getUserId());
    createPostLikePort.createPostLike(newLike);  // 성공

    // 2. 알림 발행 (동기) - Kafka 예외 발생 지점
    try {
      notificationService.publish(createNotificationCommand());
    } catch (KafkaException e) {
      log.error("Kafka 발행 실패: {}", e.getMessage());
      throw e; // 예외 재발생 → 전체 롤백!
    }

    return new LikePostApiResponse(post.getPostId(), getLikeCount(command));
  }
}

문제점:

  • Kafka 예외 시 100% 트랜잭션 롤백
  • 좋아요 기능까지 완전 중단
  • 사용자에게 500 에러 반환

2. 비동기 이벤트 처리로 전환

@Service
@RequiredArgsConstructor
public class PostLikeCommandService implements LikePostUseCase {

  private final ApplicationEventPublisher eventPublisher; // 비동기 처리
  private final CreatePostLikePort createPostLikePort;

  @Transactional
  @Override
  public LikePostApiResponse likePost(LikePostCommand command) {
    // 1. 비즈니스 로직 (좋아요 저장)
    PostLike newLike = post.like(command.getUserId());
    createPostLikePort.createPostLike(newLike);  // 성공

    // 2. 이벤트 발행 (메모리상 저장) - 예외 없음
    eventPublisher.publishEvent(PostLikedEvent.create(
        post.getPostId(), command.getUserId(), post.getUserId()
    ));

    return new LikePostApiResponse(post.getPostId(), getLikeCount(command));
    // 트랜잭션 커밋 완료 → 좋아요 저장 성공!
  }
}

// 비동기 이벤트 핸들러
@Component
@RequiredArgsConstructor
public class PostLikeEventHandler {

  private final NotificationService notificationService;

  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  @Async("eventExecutor")
  public void handlePostLikedEvent(PostLikedEvent event) {
    try {
      // 트랜잭션 커밋 후 별도 스레드에서 처리
      NotificationPublishCommand command = new NotificationPublishCommand(
          event.getPostAuthorId(),
          event.getUserId(),
          NotificationType.POST_LIKE,
          new PostLikeMeta(event.getPostId())
      );

      notificationService.publish(command);

    } catch (Exception e) {
      // 알림 실패해도 좋아요 기능에는 영향 없음
      log.error("알림 발행 실패 (좋아요는 정상 저장됨): postId={}, error={}",
          event.getPostId(), e.getMessage());
    }
  }
}

개선 효과:

  • Kafka 예외와 비즈니스 로직 완전 격리
  • 좋아요 기능 100% 가용성 확보
  • 알림은 백그라운드에서 처리

3단계: 성능 및 안정성 개선 검증

1. 핵심 메트릭 비교

지표 동기 방식 비동기 방식 개선 효과
비즈니스 로직 성공률 0% 100% 100%p 개선
트랜잭션 롤백률 100% 0% 100%p 개선
HTTP 에러율 49.85% 0% 49.85%p 개선
평균 응답시간 26ms 25ms 4% 개선
P95 응답시간 40ms 31ms 22% 개선
P99 응답시간 94ms 36ms 62% 개선
처리량 (RPS) 19.0 req/s 36.3 req/s 91% 향상
반복 처리율 9.48 iter/s 18.13 iter/s 91% 향상

2. 사용자 영향 분석

동기 방식 (Kafka 예외 발생 시)

사용자 행동: 좋아요 버튼 클릭
시스템 응답: "Internal Server Error" (500)
실제 결과: 좋아요 저장 실패
성공률: 0% (기능 사용 불가)

비동기 방식 (Kafka 예외 발생 시)

사용자 행동: 좋아요 버튼 클릭
시스템 응답: "좋아요 완료" (200 OK)
실제 결과: 좋아요 저장 성공, 알림만 지연 또는 실패
성공률: 100% (알림 발행 오류는 체감하지 못 함)

3. k6 테스트 상세 결과

동기 방식 k6 메트릭

{
  "business_logic_success_rate": 0,
  // 0% 성공
  "kafka_failure_rate": 1,
  // 100% Kafka 실패  
  "estimated_rollback_rate": 1,
  // 100% 롤백
  "http_error_rate": 0.49854227405247814,
  // 49.85% HTTP 에러
  "worker_failure_rate_percent": 100
  // 100% 워커 실패
}

비동기 방식 k6 메트릭

{
  "business_logic_success_rate": 1,
  // 100% 성공
  "core_feature_availability": 1,
  // 100% 핵심 기능 가용
  "event_publishing_failure_rate": 0,
  // 0% 이벤트 발행 실패
  "http_error_rate": 0,
  // 0% HTTP 에러  
  "worker_failure_rate_percent": 0
  // 0% 워커 실패
}

4단계: 운영 관점 개선 사항

1. 장애 대응 방식 변화

알림 발행 서비스 오류 시 대응 (KafkaException 예시)

적용 범위: 알림 발행 과정에서 발생할 수 있는 모든 오류 (Kafka 브로커 장애, 네트워크 오류, 인증 실패, 직렬화 오류 등)

동기 방식 대응:

  • 즉시 전체 핵심 기능(좋아요) 중단
  • 사용자에게 500 Internal Server Error 반환
  • 긴급 알림 서비스 복구 작업 필요
  • 사용자 공지 필요 ("일시적 장애")
  • 복구 완료까지 대상 서비스 중단

비동기 방식 대응:

  • 핵심 기능(좋아요) 정상 동작 유지
  • 사용자에게 200 OK 응답 (즉시 성공 처리)
  • 알림만 백그라운드에서 지연 처리
  • 여유 있는 알림 서비스 복구 작업 가능
  • 사용자 공지 불필요 (핵심 기능 정상 운영)

2. CS 문의 감소 효과

동기 방식 시 예상 CS 문의

  • "좋아요를 눌렀는데 에러가 나요"
  • "좋아요 기능이 작동하지 않아요"
  • "서버 오류가 계속 발생해요"

비동기 방식 시 예상 CS 문의

  • "알림이 조금 늦게 와요"
  • "특정 알림이 안 와요"

좋아요 기능 관련 문의 대폭 감소


고려 사항

k6 테스트 실행 방법

테스트 공정성 확보:

  • duration 기반 테스트 (3분간 지속)
  • 동일한 VU 수 (10개)
  • 동일한 Kafka 장애 조건
  • 총 요청 수 차이는 성능 향상의 결과 (동기: 3,430개 → 비동기: 6,550개)

동기 방식 테스트

# 알림 발행 시 KafkaException 발생하도록 유도 (동일 조건)
# 동기 방식 테스트 실행 (3분간 지속)
k6 run sync-test.js 

# 결과: sync.json 파일 생성

비동기 방식 테스트

# 알림 발행 시 KafkaException 발생하도록 유도 (동일 조건)
docker-compose stop kafka

# 비동기 방식 테스트 실행 (3분간 지속)
k6 run async-test.js 

# 결과: async.json 파일 생성

결론

k6 테스트 결과 기반 핵심 성과

  1. 완벽한 장애 격리: 비즈니스 로직 성공률 0% → 100%
  2. 트랜잭션 안정성: 롤백률 100% → 0% (완전 해소)
  3. 성능 대폭 향상: P99 응답시간 94ms → 36ms (62% 개선)
  4. 처리량 증가: 19 RPS → 36.3 RPS (91% 향상)
  5. 사용자 경험: HTTP 에러율 49.85% → 0% (완전 해소)

핵심 학습사항

  1. 정량적 측정의 중요성: k6 부하 테스트를 통한 객관적 성능 비교
  2. 장애 격리 설계: 핵심 기능과 부가 기능의 명확한 분리 필요
  3. 비동기 처리의 효과: 단순한 아키텍처 변경으로 극적인 성능 개선 가능
  4. 사용자 중심 사고: 기술적 완벽함보다 사용자 경험 우선 고려

실무 적용 권장사항

  • 유사한 문제 상황에서 k6 테스트를 통한 정량적 분석 선행
  • 핵심 기능과 부가 기능 분리 시 이벤트 기반 아키텍처 적극 활용
  • 장애 시나리오별 테스트 자동화로 아키텍처 변경의 효과 지속 검증
  • 모니터링 메트릭을 세분화하여 장애 영향도 최소화
⚠️ **GitHub.com Fallback** ⚠️