이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 롤백은 어떤 실패를 해결하고 어떤 실패는 해결하지 못하는가?
  • DB 오류를 재시도할 때 왜 멱등성이 필요한가?
  • 주문, 결제, 포인트 같은 기능에서 중복 실행을 어떻게 막는가?

개요

트랜잭션 실패는 롤백으로 끝나는 것처럼 보이지만, 실무에서는 더 복잡하다. DB 변경은 롤백될 수 있어도 외부 API 호출, 메시지 발행, 이메일 전송은 자동으로 롤백되지 않는다. 또한 deadlock이나 일시적 네트워크 오류는 재시도할 수 있지만, 재시도는 중복 실행 위험을 만든다.

안전한 재시도에는 멱등성이 필요하다. 같은 요청이 여러 번 실행되어도 결과가 한 번 실행된 것과 같아야 한다.

원리

Rollback, retry, idempotency의 원리는 실패를 “없애는 것”이 아니라 실패가 반복되거나 중간에서 끊겨도 데이터와 외부 부작용을 한 번의 의도된 결과로 수렴시키는 것이다. DB rollback은 DB 변경만 되돌리고, retry는 같은 작업을 다시 실행하며, idempotency는 재실행이 중복 결과를 만들지 않게 막는다.

백엔드 개발자는 오류를 재시도하기 전에 세 가지를 분리해야 한다. DB가 이미 commit했는지, 외부 시스템이 이미 처리했는지, 같은 요청을 식별할 안정적인 key가 있는지다. 이 분리가 없으면 deadlock 재시도, 결제 재요청, 메시지 재발행이 장애를 더 키울 수 있다.

롤백의 범위

다음 코드를 보자.

@Transactional
public void pay(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    paymentClient.approve(order.paymentKey());
    order.markPaid();
}

paymentClient.approve는 성공했는데 order.markPaid 후 DB 커밋이 실패하면 어떻게 될까? DB에는 결제 완료가 기록되지 않았지만 외부 결제는 성공했을 수 있다. DB 롤백은 외부 결제를 되돌리지 못한다.

이런 흐름은 결제 승인 전후 상태를 나누고, 외부 결제 결과를 재조회하거나 보상 취소를 수행할 수 있게 설계해야 한다.

재시도 가능한 오류

일반적으로 재시도 후보가 되는 오류는 다음이다.

  • deadlock victim
  • serialization failure
  • 일시적 네트워크 오류
  • 일시적 DB failover
  • lock timeout 일부

하지만 모든 오류를 재시도하면 안 된다. Unique 제약 위반, 잔액 부족, 권한 없음은 재시도해도 성공하지 않는다.

public boolean isRetryable(Throwable ex) {
    return ex instanceof DeadlockLoserDataAccessException
        || ex instanceof CannotSerializeTransactionException
        || ex instanceof TransientDataAccessResourceException;
}

실제 예외 타입은 DB, 드라이버, Spring 변환 계층에 따라 다르므로 팀 기준을 정해야 한다.

Idempotency Key

외부에서 요청을 재시도할 수 있는 API에는 멱등 키를 둔다.

CREATE TABLE payment_requests (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    idempotency_key varchar(100) NOT NULL,
    order_id bigint NOT NULL,
    status varchar(20) NOT NULL,
    created_at timestamp NOT NULL DEFAULT now(),
    CONSTRAINT uq_payment_requests_key UNIQUE (idempotency_key)
);

요청이 다시 들어오면 같은 key로 기존 처리 결과를 반환하거나 진행 중 상태를 확인한다.

@Transactional
public PaymentResult requestPayment(PaymentCommand command) {
    PaymentRequest request = paymentRequestRepository
        .findByIdempotencyKey(command.idempotencyKey())
        .orElseGet(() -> paymentRequestRepository.save(command.toRequest()));
 
    if (request.isCompleted()) {
        return PaymentResult.from(request);
    }
 
    return paymentProcessor.process(request);
}

Unique 제약은 동시 재시도 요청에서도 중복 request 생성을 막는다.

동시 요청에서는 findByIdempotencyKeysave 사이에도 race가 생길 수 있다. 따라서 애플리케이션 조회는 빠른 응답을 위한 최적화이고, 최종 중복 방지는 uq_payment_requests_key 같은 DB 제약이 맡아야 한다. Unique 위반이 발생하면 기존 요청을 다시 조회해 같은 결과를 반환하는 흐름을 준비한다.

재시도 정책

재시도는 backoff와 jitter를 둔다.

@Retryable(
    retryFor = DeadlockLoserDataAccessException.class,
    maxAttempts = 3,
    backoff = @Backoff(delay = 100, multiplier = 2, random = true)
)
public void updateInventory(Long productId) {
    inventoryService.decrease(productId);
}

재시도는 짧고 안전한 트랜잭션에 적합하다. 긴 외부 API 호출이나 대량 작업을 무작정 재시도하면 장애를 증폭한다.

운영 로그에는 재시도 이유와 key를 남긴다.

retry payment request idempotencyKey=pay_20260701_abc attempt=2 cause=DeadlockLoserDataAccessException

이 로그가 없으면 장애 후 중복 결제인지, 정상 재시도인지, 사용자의 반복 클릭인지 구분하기 어렵다.

실전 팁

  • 재시도 가능한 오류와 불가능한 오류를 분류한다.
  • 멱등 키는 클라이언트 요청 경계에서 받거나 서버가 안정적으로 생성한다.
  • Unique 제약으로 멱등 키 중복을 DB에서 막는다.
  • 재시도에는 backoff, jitter, 최대 횟수, 관측 로그를 둔다.
  • 외부 시스템 호출 결과는 재조회와 보상 가능성을 고려한다.
  • Unique 위반을 멱등 성공으로 해석할 수 있는 경로와 진짜 중복 오류로 볼 경로를 구분한다.
  • 재시도 대상 예외는 SQLState, Spring 예외 변환, DB별 오류 코드를 기준으로 팀 표준을 둔다.
  • retry metric을 남긴다. deadlock/serialization failure가 증가하면 단순 성공률이 아니라 동시성 설계 신호로 본다.

위험 신호!

  • 모든 RuntimeException을 재시도한다.
  • 결제, 주문, 포인트 API에 멱등 키가 없다.
  • 재시도 로그가 없어 중복 실행 여부를 추적할 수 없다.
  • DB 롤백이 외부 API까지 되돌린다고 믿는다.
  • deadlock 재시도로 증상만 덮고 빈도를 모니터링하지 않는다.
  • 멱등 키 없이 client timeout 후 같은 요청을 다시 받아 중복 주문을 만든다.
  • retry 로그에는 attempt만 있고 idempotency key나 원인 예외가 없다.
  • Unique 위반을 모두 500으로 처리해 멱등 재조회 기회를 잃는다.

확인 질문

  • DB 롤백이 해결하지 못하는 대표 문제는 무엇인가?
    • 외부 API 호출, 메시지 발행, 이메일 전송처럼 DB 밖에서 이미 발생한 부작용은 자동으로 되돌리지 못한다.
  • 재시도 전에 멱등성이 필요한 이유는 무엇인가?
    • 같은 요청이 여러 번 실행되어도 중복 결제, 중복 주문, 중복 포인트 지급이 발생하지 않아야 하기 때문이다.
  • 재시도 가능한 오류와 불가능한 오류를 구분해야 하는 이유는 무엇인가?
    • 비즈니스 실패나 제약 위반처럼 재시도해도 성공하지 않는 오류를 반복하면 부하만 늘기 때문이다.
  • Unique 제약 위반이 멱등 처리에서 항상 실패가 아닌 이유는 무엇인가?
    • 같은 idempotency key 요청이 이미 처리 중이거나 완료된 신호일 수 있으므로, 기존 요청 상태를 재조회해 같은 결과를 반환할 수 있기 때문이다.

참고 문서