주문과 결제는 같은 요청에 대한 같은 응답이 민감하기 때문에 구현하면서 멱등성 처리를 고민할 필요가 있다.
주문에서는 같은 주문 생성 요청 재시도가 들어왔을 때 기존 주문을 반환해야 한다.
이를 위해 주문에 idempotency_key라는 unique 키를 하나 부여한다.
처음 흐름은 단순했다.
Order existingOrder = orderRepository.findByIdempotencyKey(request.getIdempotencyKey())
.orElse(null);
if (existingOrder != null) {
...
}
이 방식을 통해 이미 주문이 생성된 뒤 다시 요청이 들어오는 순차 재시도에 대해서 같은 응답을 반환하기 때문에 멱등성이 보장된다.
하지만 같은 주문 생성 요청이 동시에 들어올 경우 문제가 발생한다.
요청 A: findByIdempotencyKey -> null
요청 B: findByIdempotencyKey -> null
두 요청이 동시에 findByIdempotencyKey를 수행하면 둘 다 아직 주문이 없다고 판단할 수 있다.
그 결과 두 요청 모두 주문 생성 로직으로 들어가고, 멱등성이 보장되지 않는다.
처음에는 findByIdempotencyKey에 비관적 락을 걸면 해결할 수 있을 것이라고 생각했다.
하지만 이 방식은 최초 동시 생성 상황을 막지 못한다.
비관적 락은 이미 존재하는 row에 걸린다.
그런데 주문 생성의 문제 상황은 아직 주문 row가 없는 상태에서 발생한다.
요청 A: idempotencyKey=abc 조회 -> 없음
요청 B: idempotencyKey=abc 조회 -> 없음
이 경우에는 잠글 row가 없다.
따라서 두 요청 모두 “없음”을 보고 생성 로직으로 진입할 수 있다.
일반적으로 락이 동작하는 경우는 대상 주문이 이미 존재하는 경우이다.
예를 들어,
상태 변경: 기존 주문 row를 조회하고 잠근 뒤 수정
주문 생성: row가 없으면 새로 생성
즉 둘 다 row가 없으면 락이 걸리지 않는다는 점은 같지만, 상태 변경은 row가 없으면 실패로 끝나고, 주문 생성은 row가 없으면 insert로 이어진다는 차이가 있다.
동시 요청까지 고려하려면 DB 레벨의 제약이 필요했다.
고려한 방법은 두 가지였다.
idempotency_key에 unique 제약을 걸고, 저장 중 중복이 발생하면 DataIntegrityViolationException을 처리하는 방식이다.
1. idempotency_key로 기존 주문 조회
2. 없으면 주문 생성 시도
3. unique 제약 충돌 발생
4. 이미 생성된 주문을 다시 조회해서 반환
이 방식은 DB가 중복 insert를 막고, 애플리케이션은 try catch문을 통해 예외를 잡아서 기존 응답으로 바꿔준다.
DB의 upsert 문을 사용하는 방식이다.
insert into p_order (...)
values (...)
on conflict (idempotency_key) do nothing
충돌이 발생하면 insert하지 않고, 이후 애플리케이션 단에서 로직을 통해 기존 주문을 조회해서 반환한다.
주문 생성에서는 첫 번째 방식인 unique 제약 + 예외 처리를 선택했다.
이유는 주문 생성 로직이 단순히 Order row 하나를 저장하는 흐름이 아니었기 때문이다.
주문 생성에는 OrderItem 생성, 메뉴 검증, 총액 계산 등 여러 로직이 포함되어 있다.
기존 주문 생성 흐름은 다음과 같다.
1. User, Store, Address 조회
2. Order 엔티티 생성
3. Menu 조회 및 검증
4. OrderItem 생성
5. 총액 계산
6. Order 저장
7. OrderItem 저장
이 흐름에서는 OrderItem이 참조하는 Order가 곧 저장될 실제 엔티티다.
Order order = Order.createOrder(...);
// OrderItem 생성
// Menu 검증
// 총액 계산
orderRepository.saveAndFlush(order);
orderItemRepository.saveAll(orderItems);
반면 upsert는 JPA 엔티티를 생성하고 저장하는 흐름이 아니라, DB에 row를 바로 삽입하는 흐름이다.
int inserted = orderRepository.insertOrderIfAbsent(...);
Order savedOrder = orderRepository.findByIdempotencyKey(...).orElseThrow();
이 경우 OrderItem 생성 및 검증 등 로직을 수행하고 upsert를 수행해도 실제 저장된 Order 엔티티는 애플리케이션 메모리에 없어서 다시 savedOrder를 조회하고 OrderItem을 생성하는 등 로직 구성이 복잡해졌다.
결국 주문 생성에서는 DB 중심의 upsert 메커니즘과 JPA 엔티티 중심의 애플리케이션 생성 흐름이 맞지 않았다.
따라서 엔티티의 생성과 저장 사이에 구성해야 할 다른 엔티티나 검증 흐름이 많을수록 upsert는 어색해질 수 있다고 판단했다.
unique 제약 + 예외 처리 방식을 사용하면서 트랜잭션 처리도 고려해야 했다.
같은 트랜잭션 안에서 insert 중 unique 제약 예외가 발생하면, 해당 트랜잭션은 rollback-only 상태가 될 수 있다.
Java에서 예외를 catch하더라도 같은 트랜잭션 안에서 재조회하거나 이후 로직을 계속 진행하는 것은 안전하지 않다.
그래서 충돌이 발생할 수 있는 저장 로직을 별도 서비스로 분리했다. saveAndFlush를 통해 예외를 커밋 단계까지 끌고가지 않도록 한다.
@Service
@RequiredArgsConstructor
public class OrderCreateService {
private final OrderRepository orderRepository;
...
@Transactional
public OrderResponse save(...) {
orderRepository.saveAndFlush(order);
...
}
}
그리고 트랜잭션 전파를 막기위해 바깥의 createOrder는 트랜잭션을 적용하지 않고 예외를 처리한다.
public OrderResponse createOrder(...) {
...
try {
return orderCreateService.save(...);
} catch (DataIntegrityViolationException e) {
...
}
}
이때 저장 메서드를 같은 클래스 내부 메서드로 분리하면 안 된다.
Spring의 @Transactional은 프록시 기반으로 동작하기 때문에 같은 클래스 내부 호출에서는 트랜잭션이 적용되지 않는다.
따라서 별도의 OrderCreateService를 만들고, OrderService에서 이를 주입받아 호출했다.
OrderService.createOrder
-> 트랜잭션 없음, 예외 처리 담당
OrderCreateService.save
-> 트랜잭션 있음, 실제 저장 담당
결제는 주문과 다르게 이미 payment_key라는 고유한 키가 존재한다.
따라서 별도의 멱등성 키를 만들지 않고 payment_key를 멱등성 키로 사용했다.
결제에서는 두 가지 충돌을 고려해야 했다.
1. 같은 paymentKey로 결제를 동시에 요청하는 경우
2. 같은 orderId에 서로 다른 paymentKey로 결제를 요청하는 경우
첫 번째는 멱등성 문제다.
요청 A: orderId=1, paymentKey=pay-1
요청 B: orderId=1, paymentKey=pay-1
같은 paymentKey는 같은 결제 요청이므로, 하나만 생성되고 나머지는 기존 결제를 반환해야 한다.
두 번째는 무결성 문제다.
요청 A: orderId=1, paymentKey=pay-1
요청 C: orderId=1, paymentKey=pay-2
같은 주문에 서로 다른 결제가 여러 개 생기면 안 된다.
따라서 payment_key뿐 아니라 order_id도 unique 제약으로 보호했다.
payment_key unique: 같은 결제 요청 재시도 방어
order_id unique: 같은 주문에 결제가 여러 개 생기는 것 방어
우선 주문에 적용한 예외 처리 방식을 사용하지 않은 이유는 만약 결제에서 unique 예외 처리 방식을 사용했다면 payment_key와 order_id 중 어떤 unique 제약이 터졌는지 구분하기 어렵다.
이 경우 DB 예외 메시지나 constraint name에 의존해야 할 수 있다.
결제는 주문과 다르게 Payment row 하나를 paymentKey 기준으로 insert-or-ignore 하면 되는 흐름에 가깝다.
OrderItem처럼 함께 구성해야 할 하위 엔티티가 없고, paymentKey라는 명확한 키도 존재한다.
insert into p_payment (...)
values (...)
on conflict do nothing
upsert를 사용하면 insert 결과값으로 분기할 수 있다.
int inserted = paymentRepository.insertPaymentIfAbsent(...);
if (inserted == 1) {
// 최초 결제 생성
}
if (inserted == 0) {
// payment_key 또는 order_id unique 충돌
}
inserted == 0인 경우 다시 paymentKey로 조회한다.
이 조회 결과에 따라 분기를 구분할 수 있다.
paymentKey로 조회 결과가 존재한다
-> 같은 paymentKey 중복 요청이다.
-> 기존 Payment를 반환한다.
paymentKey로 조회 결과가 없다
-> paymentKey 충돌이 아니라 orderId 충돌로 insert가 실패한 것이다.
-> 같은 주문에 다른 paymentKey가 먼저 저장된 상황이다.
-> 예외를 반환한다.
이러한 점에서 결제에는 upsert를 채택하게 되었다.
upsert에도 단점은 있다.
가장 큰 단점은 DB 종속 쿼리를 사용한다는 점이다.
PostgreSQL에서는 다음과 같이 작성한다.
on conflict do nothing
하지만 DB마다 upsert 문법은 다르다.
따라서 H2 기반 테스트 환경이나 다른 DB로 전환할 경우 문제가 생길 수 있다.
또한 JPA 엔티티 그래프를 먼저 구성해야 하는 로직에서는 upsert가 오히려 서비스 흐름을 복잡하게 만들 수 있다.
주문 생성이 그런 경우였다.
따라서 upsert는 모든 멱등성 처리에 무조건 좋은 방식은 아니다.
저장 대상의 성격에 따라 다르게 선택해야 한다.
이번에 주문과 결제를 처리하면서 멱등성 문제의 해결 방안과 멱등성에도 동시성 문제가 발생할 경우 해결 방안에 대해서 생각해보게 되었고 도메인에 따라 적합한 해결 방식이 다를 수 있다는 것을 알게 되었다.