핵심 한 줄

취소를 확정하려면 외부 PG사에 환불을 HTTP로 요청해야 한다. 그런데 그 호출을 @Transactional 안에 두면, 수백 ms~수 초의 응답을 기다리는 내내 DB 커넥션과 행 락을 붙잡는다 — 커넥션풀이 말라 죽는다. 답은 하나다. 느린 외부 세계를 짧은 두 트랜잭션 사이로 밀어낸다: TX1(준비)→트랜잭션 밖 HTTP→TX2(반영). 그리고 도메인이 “어떤 HTTP 클라이언트인지” 모르게 Port/Adapter로 가른다.

시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)


1. 문제 — 취소 확정에 외부가 끼어든다

환불은 우리 DB만의 일이 아니다. 취소를 확정하려면 실제 돈을 되돌려야 하고, 그건 우리가 아니라 PG사(토스페이먼츠 등)의 서버가 한다. 그래서 흐름 한가운데에 HTTP 호출이 박힌다.

refundPort.refund(pgKey, cancelId);   // ← 외부 PG로 나가는 HTTP

문제는 이 호출의 성격이다. PG는 우리 통제 밖이다. 빠르면 수백 ms, 느리면 수 초, 가끔은 타임아웃. 우리 코드의 어떤 최적화로도 저 서버를 빠르게 만들 수 없다.

이제 순진한 버전을 상상해 보자. “환불 레코드 저장 → PG 호출 → 결과 반영”을 하나의 트랜잭션으로 묶는다.

@Transactional
public void refund(...) {
    save(refund);                 // 행 하나 잠금
    PGResult r = pgClient.call(); // ← 여기서 3초 기다림
    refund.apply(r);              // 그동안 계속 트랜잭션 안
}

이게 왜 위험한가. @Transactional은 메서드가 끝날 때까지 DB 커넥션을 붙잡는다. PG가 3초를 끌면 그 3초 내내 커넥션 하나와 그 행의 락을 쥐고 있다. 요청이 몰리면 커넥션풀이 순식간에 마르고, 환불과 무관한 다른 요청까지 커넥션을 못 얻어 줄줄이 막힌다. 이건 6편에서 잡았던 “느린 작업은 트랜잭션 밖으로”라는 감각의 직접적인 확장이다. 6편은 우리 안의 느린 대기였고, 7편은 아예 남의 서버를 기다린다.

2. 결정 1 — 트랜잭션을 세 조각으로 쪼갠다

핵심 원칙은 짧다. 트랜잭션은 DB 안의 일만 하고 빨리 끝나라. 느린 외부 호출은 그 바깥에 있어야 한다.

그래서 하나였던 흐름을 셋으로 가른다.

  1. TX1 (준비) — 환불 레코드를 READY 상태로 저장하고 짧게 커밋한다. 락과 커넥션을 곧바로 놓는다.
  2. 트랜잭션 밖 (외부 호출) — 여기서 PG에 HTTP를 던진다. 몇 초가 걸리든 이제 어떤 DB 자원도 붙잡지 않는다.
  3. TX2 (반영) — 돌아온 결과로 상태를 COMPLETED/FAILED 등으로 확정한다. 다시 짧게 커밋.

RefundService.refund()가 정확히 이 세 박자다.

public PGResult refund(String pgKey, String cancelId, Money refund){
    // TX1: READY로 저장하고 즉시 커밋 (RefundProcessor.changeStatusToReady)
    try {
        refundProcessor.changeStatusToReady(cancelId, refund, pgKey);
    } catch (DataIntegrityViolationException e) {
        // cancelId 유니크 충돌 → 이미 처리된 요청 (멱등 처리)
        Refund done = refundRepository.findByCancelId(cancelId)
            .orElseThrow(() -> new IllegalArgumentException("not found"));
        if (Objects.equals(done.getStatus(), RefundStatus.COMPLETED)) {
            return PGResult.ok("200", done.getRef());
        }
        throw new DuplicateKeyException("멱등키 중복");
    }
 
    // 트랜잭션 밖: 느린 PG HTTP 호출 (재시도 포함)
    PGResult pgresult = null;
    for (int attempt = 1; attempt <= maxRetry; attempt++) {
        pgresult = refundPort.refund(pgKey, cancelId);
        if (pgresult.status().equals(PGStatus.UNKNOWN)) backoff(attempt);
        else break;
    }
 
    // TX2: 결과 반영해 상태 확정
    return refundProcessor.changeStatusChangeCausePGResult(cancelId, pgresult);
}

눈여겨볼 점. changeStatusToReadychangeStatusChangeCausePGResultRefundProcessor에 있고 **각각 @Transactional**이다. 반면 refund() 자신에는 @Transactional이 없다. 그래서 두 트랜잭션은 서로 독립적으로 열리고 닫히며, 그 사이의 HTTP 호출은 어떤 트랜잭션에도 속하지 않는다. 이게 의도다.

왜 별도 클래스(RefundProcessor)로 뺐나? 스프링의 @Transactional은 프록시로 동작해서 같은 클래스 안의 self-invocation에는 걸리지 않는다. refund()가 같은 클래스의 트랜잭션 메서드를 직접 부르면 프록시를 안 거쳐 트랜잭션이 안 열린다. 그래서 트랜잭션 단위를 다른 빈으로 분리해 프록시를 확실히 태운다.

TX1이 READY를 남기는 건 단순한 순서 조정이 아니다. “환불을 시도하려 한다”는 사실을 HTTP를 던지기 전에 DB에 못박아 둔다. 그래야 다음 편에서 다룰 “그래서 결국 어떻게 됐나”를 추적할 근거가 생긴다.

3. 결정 2 — 도메인은 HTTP를 몰라야 한다 (Port/Adapter)

두 번째 문제. 서비스가 PG를 어떻게 부르는가. 만약 RefundServiceRestClient를 직접 들고 토스 API의 URL·헤더·JSON 바디를 안다면, 도메인 로직이 특정 PG사의 HTTP 세부사항에 묶인다. PG를 바꾸거나 테스트하려는 순간 서비스 전체가 흔들린다.

그래서 경계를 하나 세운다. 도메인이 원하는 건 오직 “환불하는 능력”이지 “토스에 POST 하는 법”이 아니다. 그 능력만 인터페이스로 선언한다 — 이게 Port다.

public interface RefundPort {
    PGResult refund(String pgKey, String cancelId);
}

실제 HTTP를 아는 건 이 인터페이스의 구현체, Adapter다.

@Component
public class TossPGAdapter implements RefundPort {
    @Override
    public PGResult refund(String pgKey, String cancelId) {
//        RestClient .. post(header, body);  ← 진짜 HTTP는 여기만 안다
        String ref = "MSG" + pgKey.hashCode();
        return PGResult.ok("200", ref);
    }
}

RefundServiceRefundPort 타입만 주입받는다. 토스인지, 다른 PG인지, 아예 가짜인지 모른다. 이 무지가 이득이다.

  • 구현 교체가 공짜. PG를 바꾸면 새 RefundPort 구현체를 하나 더 만들어 갈아끼우면 끝. 서비스는 한 줄도 안 바뀐다.
  • 테스트에 가짜 어댑터 주입이 공짜. 테스트에선 “항상 성공/실패/타임아웃을 반환하는” RefundPort를 넣으면 진짜 PG 없이도 서비스의 세 박자 흐름을 검증할 수 있다. 다음 편에서 다룰 타임아웃 시나리오도 이 가짜로 재현한다.

의존성 방향이 뒤집힌 것이 핵심이다. 도메인이 인프라(HTTP)를 향하지 않고, 인프라가 도메인이 정의한 인터페이스를 향해 구현된다.

4. 결정 3 — 값 객체를 DB에 넣되 오염시키지 않기 (AttributeConverter)

작지만 중요한 결이 하나 더 있다. 환불 금액은 1편에서 만든 Money 값 객체다. 이걸 refund 테이블에 저장해야 하는데, 방법이 갈린다.

순진하게 하면 Money@Embeddable이나 @Column 같은 JPA 애노테이션을 바른다. 그 순간 순수했던 값 객체가 영속화 기술에 오염된다. Money는 돈의 규칙만 알아야지 “나는 어느 컬럼에 어떻게 매핑되는가”를 알 필요가 없다.

그래서 변환 책임을 바깥으로 뺀다. MoneyConverter가 Money와 DB 컬럼(BIGINT) 사이를 오간다.

@Converter(autoApply = true)
public class MoneyConverter implements AttributeConverter<Money, Long> {
    @Override
    public Long convertToDatabaseColumn(Money money) {
        return money == null ? null : money.toLong();     // Money → BIGINT
    }
    @Override
    public Money convertToEntityAttribute(Long value) {
        return value == null ? null : Money.won(value);   // BIGINT → Money
    }
}

덕분에 Money는 JPA 애노테이션 0개를 유지한다. 그리고 Refund 엔티티는 그냥 private Money refund;라고 쓸 뿐, @Convert조차 안 붙인다.

@Entity
@Table(name = "refund")
public class Refund {
    private RefundStatus status;
    @Column(unique = true)
    private String cancelId;   // ← 멱등 키 (4편의 유니크 제약)
    private Money refund;      // ← 애노테이션 없이 Money 그대로
    ...
}

비결은 @Converter(autoApply = true)다. 이 한 줄이 “모든 엔티티의 Money 필드에 이 변환기를 자동 적용하라”는 뜻이라, 필드마다 @Convert를 붙일 필요가 없다. 값 객체는 순수하게, 영속화는 조용히.

5. 원리 — “트랜잭션 밖으로 나가면?”

서문에서 L3를 이렇게 정의했다. “동시에 오면? 두 번 오면? 트랜잭션 밖으로 나가면? 이 편은 세 번째 질문이다.

앞의 두 질문(동시성·멱등성)은 우리 DB 안에서 벌어지는 일이었다. 이번엔 다르다. 흐름 한가운데에 우리가 통제할 수 없는 느린 세계가 끼어든다. 이럴 때 사고의 기본형은 이렇다.

느린 외부 호출을 만나면, 그것을 짧은 두 트랜잭션 사이로 밀어낸다. 트랜잭션은 DB 자원을 짧게 잡고 끝내고, 외부 세계는 그 바깥에서 자기 속도로 흐르게 둔다.

그리고 그 외부 세계를 코드에 들일 때는 **경계(Port)**로 감싸서, 도메인이 인프라의 세부를 모르게 한다. 이 두 감각 — “느린 건 트랜잭션 밖으로”, “외부는 인터페이스 뒤로” — 는 스택이 Java든 아니든, PG가 토스든 스트라이프든 남는다.

6. 그런데 — 응답을 못 받으면?

트랜잭션 밖으로 밀어냈고, Port로 감쌌다. 흐름은 깔끔하다. 성공하면 TX2에서 COMPLETED, 실패하면 FAILED.

그런데 눈치챘겠지만 코드에 세 번째가 있다. PGStatus.UNKNOWN, RefundStatus.UNKNOWN. 재시도 루프가 도는 이유이기도 하다.

PG에 HTTP를 던졌는데 타임아웃이 났다고 하자. 이건 성공인가 실패인가? 우리가 아는 건 오직 “응답을 못 받았다”뿐이다. PG 쪽에서 환불이 이미 처리됐을 수도, 아직 안 됐을 수도 있다. 여기서 “응답 못 받음 = 실패”라고 단정하면 돈이 샌다.

응답을 못 받았다는 건 실패가 아니다. 그건 모른다는 것이다 — 다음 편의 주제.

08.응답을 못 받았다는 실패가 아니다