Post

백엔드 시스템의 안정성 - 트랜잭션 설계와 관심사 분리

백엔드 시스템의 안정성 - 트랜잭션 설계와 관심사 분리

백엔드 개발자로서 시스템을 운영하다 보면 피할 수 없는 주제가 바로 ‘장애’입니다. 완벽한 코드는 없기에 장애는 언제든 발생할 수 있습니다. 하지만 장애가 발생했을 때 시스템 전체가 무너지는 것과, 핵심 기능은 살아있는 것은 천지 차이입니다.

이번 포스트에서는 백엔드 시스템의 안정성을 높이기 위한 트랜잭션 관리 전략관심사 분리에 대해 다룹니다. 특히, 회원가입 프로세스를 예시로 들어 SPOF(Single Point Of Failure)를 제거하고 시스템의 결합도를 낮추는 방법을 살펴보겠습니다.

장애 대응의 핵심 원칙

장애 대응은 단순히 “터진 서버를 다시 켜는 것”이 아닙니다. 장애 대응 프로세스는 크게 세 가지 단계로 나뉩니다.

  1. 예방 (Prevention): 사전에 예측 가능한 장애를 방지하는 설계.
  2. 탐지와 전파 (Detection & Propagation): 장애 발생 시 빠르게 인지하고 관련 담당자에게 알리는 것.
  3. 재발 방지 (Prevention of Recurrence): 동일한 장애가 다시 발생하지 않도록 근본 원인을 제거하는 것.

이 중 예방 단계에서 개발자가 가장 신경 써야 할 부분은 트랜잭션의 범위를 적절히 설정하여 장애의 전파를 막는 것입니다.

Case Study: 회원가입 로직의 함정

일반적인 회원가입 프로세스를 생각해 봅시다. 사용자가 가입 버튼을 누르면 서버 내부에서는 다음과 같은 일들이 일어납니다.

1
2
3
4
5
6
7
8
9
10
11
@Transactional
public void signUp(SignUpRequest request) {
    // 1. 가입 정보 검증 및 저장 (핵심 로직)
    User user = userRepository.save(request.toEntity());
    
    // 2. 환영 메일 발송 (외부 API 호출)
    emailService.sendWelcomeEmail(user.getEmail());
    
    // 3. 웰컴 쿠폰 지급 (내부/외부 서비스 호출)
    couponService.issueWelcomeCoupon(user.getId());
}

이 코드는 논리적으로 완벽해 보이지만, 운영 관점에서는 치명적인 잠재 위험을 안고 있습니다.

문제점 1: 긴 트랜잭션으로 인한 DB 커넥션 고갈

@Transactional이 걸려 있는 동안 DB Connection은 계속 유지됩니다. 만약 2번 과정인 메일 발송 서버(SMTP)가 느려지거나 응답이 없다면 어떻게 될까요? 메일 서버의 응답을 기다리는 동안 DB Connection은 반환되지 못합니다. 가입 요청이 몰릴 경우 DB Connection Pool이 고갈되어 전체 서비스 장애로 이어질 수 있습니다.

문제점 2: 부가 기능 실패가 핵심 기능 실패로 전파

메일 발송 서버가 다운되었다고 가정해 봅시다. 메일 발송이 실패하면 RuntimeException이 발생하여 트랜잭션이 롤백됩니다. 결과적으로 “회원가입 정보 저장”까지 모두 취소되어 유저는 가입을 할 수 없게 됩니다.

핵심 질문: 환영 메일을 못 받았다고 해서 회원가입을 막는 것이 비즈니스적으로 옳은 결정일까요?

해결책: 트랜잭션 분리와 비동기 처리

이 문제를 해결하기 위해 핵심 로직(회원가입)부가 로직(메일, 쿠폰)을 분리해야 합니다. 부가 로직의 실패가 핵심 로직에 영향을 주지 않도록 설계하는 것이 중요합니다.

개선된 설계: 이벤트 기반 아키텍처 (Event-Driven)

Spring의 ApplicationEventPublisherKafka와 같은 메시지 큐를 활용하여 결합도를 낮출 수 있습니다.

1
2
3
4
5
6
7
8
@Transactional
public void signUp(SignUpRequest request) {
    // 1. 가입 정보 검증 및 저장 (핵심 로직)
    User user = userRepository.save(request.toEntity());
    
    // 2. 가입 완료 이벤트 발행 (비동기 처리를 위한 트리거)
    eventPublisher.publishEvent(new UserSignedUpEvent(user));
}

이제 signUp 메소드는 오직 회원 저장만 담당하고 즉시 트랜잭션을 종료합니다. DB Connection은 빠르게 반환됩니다.

이벤트 리스너를 통한 후속 처리

발행된 이벤트는 별도의 리스너(혹은 Consumer)가 받아 처리합니다.

1
2
3
4
5
6
7
8
9
10
@Async
@EventListener
public void handleWelcomeEmail(UserSignedUpEvent event) {
    try {
        emailService.sendWelcomeEmail(event.getUser().getEmail());
    } catch (Exception e) {
        // 실패 시 로그를 남기거나, 재시도 큐에 적재 (회원가입 트랜잭션과는 무관)
        log.error("메일 발송 실패: {}", event.getUser().getId());
    }
}

메시지 큐(Kafka) 도입 시 고려사항

시스템 규모가 커지면 단순 인메모리 이벤트 대신 Kafka와 같은 메시지 브로커를 사용하게 됩니다. 이때도 주의할 점이 있습니다.

Kafka의 특징과 주의점

  • 순서 보장: 파티션 키가 같다면 순서가 보장되지만, 파티션을 늘리면 전체 순서 보장은 어려울 수 있습니다.
  • 중복 처리: At Least Once (최소 한 번 전송) 정책으로 인해 메시지가 중복 전달될 수 있습니다. 컨슈머(Consumer)는 멱등성(Idempotency)을 보장하도록 설계해야 합니다.

대체재 비교

| 도구 | 특징 | 적합한 케이스 | |:—:|:—|:—| | Kafka | 대용량 데이터, 고성능, 파티셔닝 | 대규모 트래픽, 로그 집계 | | Redis Pub/Sub | 빠름, 메시지 지속성 없음(휘발성) | 실시간 알림, 단순 이벤트 버스 | | AWS SQS | 완전 관리형, 무한 확장성, 쉬운 사용 | 인프라 관리 부담 최소화 시 |

결론: “실패를 격리하라”

대용량 트래픽을 감당하는 백엔드 시스템의 핵심은 ‘빠른 실패 격리’입니다.

  • 핵심 로직과 부가 로직을 분리하십시오.
  • 트랜잭션은 가능한 짧게 유지하여 자원(DB Connection)을 빠르게 반환하십시오.
  • 부가 기능(알림, 쿠폰 등)은 비동기로 처리하여 메인 서비스의 응답 속도를 보장하십시오.

다음 포스트에서는 이러한 시스템의 상태를 실시간으로 감시하는 ‘장애 탐지와 모니터링 체계’에 대해 알아보겠습니다.

This post is licensed under CC BY 4.0 by the author.