Skip to content

Blog

멀티 스레드 테스트에서 발생하는 @Transactional가 주는 문제

실행 환경: Java 17, Spring Boot 3.1.5, JUnit 5.9.3

상품 재고 기능 개발 중, 동시성 테스트를 하기 위해 멀티 스레드를 이용하여 테스트를 진행했다.
테스트를 수행하기 전에 저장 될 데이터를 미리 저장해두고, 당시 평소와 같이 테스트 수행 후 롤백되도록 @Transactional을 추가하는 방식으로 테스트를 진행했다.

@SpringBootTest
class OrderConcurrentTest {
// 의존성 주입 ...
private User user;
private Product product;
private List<OrderInfo> savedOrderList;
@Transactional // 독립적인 테스트를 위해 테스트 메서드 실행 후 롤백되도록 @Transactional 어노테이션 추가
@CsvSource({
"300, 300, 300, 0, 0",
"300, 299, 299, 0, 1",
"300, 301, 300, 1, 0",
"300, 350, 300, 50, 0",
})
@ParameterizedTest
@DisplayName("동시에 승인 요청을 보내면 재고만큼 승인되고 나머지는 실패한다.")
void approveOrderWithMultipleRequests(
int stock,
int orderCount,
int expectedSuccess,
int expectedFail,
int expectedStock
) {
// 테스트 수행 전 데이터 저장
product = productRepository.save(
generateProductWithPriceAndStock(BigDecimal.valueOf(1000), stock)
);
user = userRepository.save(generateUser());
savedOrderList = getSavedOrderList(orderCount); // orderCount만큼 주문 데이터 저장 및 리스트에 저장
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// 테스트 수행
executeConcurrentActions(orderIndex -> {
try {
// ...
// 리스트에 저장된 orderIndex번째 주문 승인 요청
OrderConfirmRequest orderConfirmRequest = generateOrderConfirmRequest(
savedOrderList.get(orderIndex)
);
orderController.confirmOrder(orderConfirmRequest); // 실제 테스트 대상 메서드
successCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
}
}, orderCount, 32);
// 테스트 결과 검증
Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
assertThat(updatedProduct.getStock()).isEqualTo(expectedStock);
assertThat(successCount.get()).isEqualTo(expectedSuccess);
assertThat(failCount.get()).isEqualTo(expectedFail);
}
// ...
private void executeConcurrentActions(
Consumer<Integer> action,
int repeatCount,
int threadSize
) {
AtomicInteger atomicInteger = new AtomicInteger();
CountDownLatch countDownLatch = new CountDownLatch(repeatCount);
ExecutorService executorService = Executors.newFixedThreadPool(threadSize);
for (int i = 1; i <= repeatCount; i++) {
executorService.execute(() -> {
int index = atomicInteger.incrementAndGet() - 1;
action.accept(index);
countDownLatch.countDown();
});
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}

코드는 위와 같으며, 불필요한 부분은 최대한 생략했으나 간략하게 정리하면 다음과 같다.

  1. 테스트 수행 전 데이터 저장
    • 주문할 상품 / 주문자 데이터 저장
    • savedOrderList에 저장된 주문 수만큼 주문 데이터 저장
  2. 멀티스레드 테스트 수행
    • savedOrderList에 저장된 order에 대해 승인 요청을 보냄
    • 메서드 내부에 수행 전 저장 한 데이터를 조회하는 로직이 존재(비관적 락을 통해 조회)
    • 승인 요청 결과에 따라 성공 / 실패 카운트 증가
  3. 테스트 결과 검증

기존 멀티 스레드를 적용하기 전의 테스트 코드는 정상적으로 수행되었으나, 멀티 스레드를 적용하면서 테스트가 실패하였다.
테스트 수행 중 저장된 데이터를 조회하는 로직이 존재하는데, 저장된 데이터를 조회하는 시점에 데이터가 존재하지 않아 수행 중인 테스트가 실패한 것이다.

테스트 수행 중 저장된 데이터를 조회하는 시점에 데이터가 존재하지 않아 테스트가 실패한 것인데, @Transactional 어노테이션에 원인이 있다.
알다시피 @Transactional 어노테이션을 적용하면 메서드에 하나의 트랜잭션에 묶이게 되는데, 결국 메서드가 끝나기 전까지는 트랜잭션이 커밋되지 않는 말과 같다.
결과적으로 @Transactional 어노테이션을 적용한 위의 테스트는 아래와 같이 수행된다.

  1. 트랜잭션 시작(트랜잭션 A)
  2. 테스트 수행 전 데이터 저장(트랜잭션 A)
    • 커밋은 되지 않은 상태로, 트랜잭션 A에서만 조회 가능한 상태
    • 실제 데이터베이스에는 저장되지 않은 상태
  3. 멀티 스레드 테스트 수행
    • 새로운 스레드를 생성하면서 실행하기 때문에 별도의 트랜잭션에서 수행 됨(트랜잭션 B, C, …)
    • 트랜잭션 A가 아닌 다른 트랜잭션에서는 2번에서 저장한 데이터를 조회할 수 없음
    • 데이터 조회 에러 발생
    • 그 이후 로직이 수행되지 않고 메서드 종료
  4. 테스트 실패
  5. 트랜잭션 종료(트랜잭션 A)
@SpringBootTest
class OrderConcurrentTest {
// ...
@Transactional
void approveOrderWithMultipleRequests(
// ...
) {
// ...
System.out.println(entityManager.getDelegate()); // 테스트 수행 전 데이터 저장 시점의 트랜젝션
// ...
// 테스트 수행
executeConcurrentActions(orderIndex -> {
try {
// ...
System.out.println(entityManager.getDelegate()); // 테스트 수행 중 데이터 조회 시점의 트랜젝션
// ...
} catch (Exception e) {
// ...
}
}, orderCount, 32);
// ...
}
// ...
}

System.out.println(entityManager.getDelegate());를 통해 세션 정보와 트랜잭션 정보를 출력해보면,
테스트 수행 전 데이터 저장 시점의 트랜젝션은 커밋 되지 않은 open 상태이고, 테스트 수행 중 데이터 조회 시점엔 모두 다른 트랜잭션에서 수행됐음을 알 수 있다.

SessionImpl(161593456<open>) # 테스트 수행 전 데이터 저장 시점의 트랜젝션 -> open 상태
SessionImpl(734517556<closed>)
SessionImpl(1193267507<closed>)
SessionImpl(600492612<closed>)
SessionImpl(916520713<closed>)
SessionImpl(508597066<closed>)
SessionImpl(195662923<closed>)
SessionImpl(48837314<closed>)
SessionImpl(776960045<closed>)
SessionImpl(924763232<closed>)
SessionImpl(1204832111<closed>)
SessionImpl(399468152<closed>)
SessionImpl(1035630793<closed>)
SessionImpl(1949013162<closed>)
SessionImpl(175375417<closed>)
SessionImpl(258355164<closed>)
SessionImpl(1957971112<closed>)
SessionImpl(380388185<closed>)
...

우선 고려해볼 수 있지만 실제로 사용할 수 없거나 적합하지 않은 방법들은 다음과 같다.

  • Propagation.MANDATORY 사용

    • Propagation.MANDATORY: 이미 존재하는 부모 트랜잭션이 있으면 부모 트랜잭션을 합류시키고, 존재하지 않으면 예외를 발생시키는 전파 방식
    • 멀티 스레드 방식인 새로운 스레드를 생성하면서 테스트를 수행하고 있기 때문에 해당 스레드엔 부모 트랜잭션이 존재하지 않기 때문에 예외 발생
  • Isolation.READ_UNCOMMITTED 사용

    • Isolation.READ_UNCOMMITTED: 트랜잭션에 처리 중인 혹은 아직 커밋되지 않은 데이터를 다른 트랜잭션이 읽는 것을 허용하는 격리 수준
    • 테스트를 수행하는 스레드가 데이터 저장 시점의 트랜잭션을 읽을 수 있도록 하면 데이터 조회 에러는 해결할 수는 있지만, 실제로 사용하기에 적합하지 않다.

실제로 해결할 수 있는 방법으론 아래 두 가지 방법을 생각해 보았다.

  • sql.init.mode 옵션을 사용하여 테스트 수행 전 데이터베이스에 직접 저장
  • @Transactional 어노테이션을 제거하여 테스트 수행 전 저장된 데이터를 커밋하여 데이터베이스에 반영

이번 포스팅에서는 @Transactional 어노테이션 제거 방법을 사용했으며, @BeforeEach, @AfterEach을 통해 테스트 독립성을 확보했다.

@SpringBootTest
class OrderConcurrentTest {
// ...
@BeforeEach
void setUp() {
orderRepository.deleteAll();
productRepository.deleteAll();
userRepository.deleteAll();
}
@AfterEach
void tearDown() {
orderRepository.deleteAll();
productRepository.deleteAll();
userRepository.deleteAll();
}
// ...
}

@Transactional 어노테이션은 하나의 트랜잭션으로 묶어주어 편리하게 사용할 수 있게 도와주지만 멀티 스레드 환경에서는 예기치 못한 문제가 발생할 수 있다.
스프링 프레임 워크에서는 많은 편리한 기능을 제공하지만 그만큼 내부 동작 방식을 잘 알고 사용해야 한다는 것을 다시 한 번 느낄 수 있었다.
현재까지는 @Transactional을 제거하여 테스트 전/후 데이터를 컨트롤하는 방법이 적합하다고 생각하지만, 더 나은 해결책에 대한 고민이 필요한 것 같다.

`@Transactional`을 통한 선언적 트랜잭션 관리 방식에서 Self Invocation 문제가 발생하는 이유

실행 환경: Java 17, Spring Boot 3.1.5, MySQL 8.0.33

Spring에서는 @Transactional을 통해 트랜잭션을 관리할 수 있으며, 이를 통해 트랜잭션을 쉽게 사용할 수 있다. @Transactional을 통한 선언적 트랜잭션 관리 방식을 사용하면 프록시 방식의 AOP가 적용되는데 그로 인해 아래와 같은 특징을 가지게 된다.

  • @Transactional 애노테이션이 특정 클래스나 메서드에 하나라도 있으면 트랜잭션 AOP는 프록시를 만들어 스프링 컨테이너에 등록
  • 실제 객체 대신에 프록시를 스프링 빈에 등록한 뒤 프록시는 실제 객체를 참조
  • 만약 다른 곳에서 해당 객체를 의존관계 주입을 요청하게 되면 스프링 컨테이너에서 실제 객체 대신에 등록되어 있던 프록시 객체를 주입
  • 최종적으로 다른 객체 -> 실제 객체 -> 프록시 객체 순으로 의존관계가 주입되게 됨

결국 요청을 받게되면 아래와 같이 트랜잭션이 동작하게 된다.

  1. 프록시 객체가 요청을 먼저 받음
  2. 프록시 객체에서 트랜잭션 시작
  3. 프록시 객체에서 실제 객체의 메서드 호출
  4. 실제 객체 로직 수행
  5. 프록시 객체에서 트랜잭션 커밋 또는 롤백

@Transactional 선언 방식은 프록시 객체를 통해 수행되는데, 만약 프록시 객체를 거치지 않고 대상 객체를 직접 호출하게 되면 트랜잭션이 적용되지 않는다. 보통의 경우는 프록시 객체를 거치기 때문에 문제가 되지 않지만, 대상 객체 내부에서 메서드 호출을 하게 되면 프록시를 거치지 않게 되어 위의 문제가 발생할 수 있다.

@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
public void externalCreatePost(PostCreateRequest postCreateRequest) {
internalCreatePost(postCreateRequest);
}
@Transactional
public void internalCreatePost(PostCreateRequest postCreateRequest) {
User user = userService.getUserById(postCreateRequest.getUserId());
postRepository.save(postCreateRequest.toEntity(user));
System.out.println(entityManager.isJoinedToTransaction()); // false
if (true) {
throw new RuntimeException();
}
}
}

internalCreatePost 메서드에 트랜잭션이 선언 되어 있기 때문에 트랜잭션 적용을 기대할 수 있지만, externalCreatePost 메서드를 통해 호출하게 되어 적용되지 않고 다음과 같이 동작하게 된다.

  1. 클라이언트에서 프록시 호출
  2. 프록시에서 external 메서드에 트랜잭션이 적용되어 있지 않기 때문에 트랜잭션 없이 메서드 호출
  3. 실제 externalCreatePost 메서드 실행
  4. externalCreatePost 메서드 내부에서 internalCreatePost 메서드 호출
  5. 실행 된 internalCreatePost 메서드는 실제 객체에서 실행되기 때문에 트랜잭션이 적용되지 않음

실제로 entityManager.isJoinedToTransaction()을 통해 트랜잭션이 적용되었는지 확인해보면 적용되지 않았고, DB에도 롤백 없이 데이터가 저장됐다. 단순히 생각했을 때 이 방법을 해결하기 위해서 externalCreatePost 메서드에도 @Transactional을 추가하여 해결해 볼 수 있을 것이다.

@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
@Transactional // 추가
public void externalCreatePost(PostCreateRequest postCreateRequest) {
internalCreatePost(postCreateRequest);
}
@Transactional
public void internalCreatePost(PostCreateRequest postCreateRequest) {
User user = userService.getUserById(postCreateRequest.getUserId());
postRepository.save(postCreateRequest.toEntity(user));
System.out.println(entityManager.isJoinedToTransaction()); // true
if (true) {
throw new RuntimeException();
}
}
}

실제로 위와 같이 externalCreatePost 메서드에도 @Transactional을 추가하면 트랜잭션이 적용되었고, internalCreatePost 메서드에서 예외가 발생하면 롤백이 되는 것을 확인할 수 있었다.

propagation 속성을 통해 트랜잭션 전파 방식을 설정할 수 있는데, 그 중 REQUIRES_NEW 방식을 사용하면 항상 새로운 트랜잭션을 생성하여 실행하게 된다. 위의 internalCreatePost 메서드에 propagation = REQUIRES_NEW을 추가했을 때 새로운 트랜잭션을 생셩하여 실행되는지 확인해보자.

@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
@Transactional
public void externalCreatePost(PostCreateRequest postCreateRequest) {
System.out.println(entityManager.getDelegate()); // SessionImpl(1985594165<open>)
internalCreatePost(postCreateRequest);
// 내부 메서드 종료 후 예외 발생
if (true) {
throw new RuntimeException();
}
}
@Transactional(propagation = REQUIRES_NEW) // 새로운 트랜잭션 생성
public void internalCreatePost(PostCreateRequest postCreateRequest) {
System.out.println(entityManager.getDelegate()); // SessionImpl(1985594165<open>)
User user = userService.getUserById(postCreateRequest.getUserId());
postRepository.save(postCreateRequest.toEntity(user));
// 정상적으로 메서드 종료
}
}

위와 같이 internalCreatePost 메서드에 propagation = REQUIRES_NEW을 추가했지만 하나의 트랜잭션으로 실행되고 있었다. 실제 entityManager.getDelegate()를 통해 세션을 확인해보면 동일한 세션을 사용하고 있었으며, 모두 롤백 처리 되어 DB에 데이터가 저장되지 않았다.

  • @Transactional 선언 방식은 프록시 객체를 통해 트랜잭션을 관리하기 때문에 프록시 객체를 거치지 않고 직접 호출하게 되면 트랜잭션이 적용되지 않음
  • 때문에 내부에서 호출한 메서드에 @Transactional을 추가하더라도 프록시 객체를 거치지 않아 새로운 트랜잭션 정책이 적용되지 않음
  • 결국internalCreatePost 메서드에 적용된 REQUIRES_NEW 정책 뿐만 아니라 @Transactional 자체가 무시됨

** entityManager.getDelegate(): 현재 사용되고 있는 세션을 확인하는 메서드(같은 세션 == 같은 영속성 컨텍스트 == 같은 트랜잭션)

@Transactional 선언 방식을 사용하면서 위와 같은 문제를 해결하고 싶다면, 자기 호출을 하지 않도록 다른 클래스를 통해 호출하는 것이 가장 좋은 방법이다. (AopContext나 Self Injection을 통해 자기 호출을 할 수 있지만, 이는 권장되지 않는 방법이다.)

PostService.java
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
private final PostServiceInternal postServiceInternal;
@Transactional
public void externalCreatePost(PostCreateRequest postCreateRequest) {
System.out.println(entityManager.getDelegate()); // SessionImpl(1431135451<open>)
this.postServiceInternal.internalCreatePost(postCreateRequest);
if (true) {
throw new RuntimeException();
}
}
}
// PostServiceInternal.java
@Service
@RequiredArgsConstructor
public class PostServiceInternal {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
@Transactional(propagation = REQUIRES_NEW)
public void internalCreatePost(PostCreateRequest postCreateRequest) {
System.out.println(entityManager.getDelegate()); // SessionImpl(460044237<open>)
User user = userService.getUserById(postCreateRequest.getUserId());
postRepository.save(postCreateRequest.toEntity(user));
}
}

PostServiceInternal을 주입받아 internalCreatePost 메서드를 호출하면 정상적으로 새로운 트랜잭션을 생성하는 것을 확인할 수 있다. PostService에서는 예외가 발생하였지만, internalCreatePost 메서드는 정상적으로 종료됐기 때문에 DB에 정상적으로 데이터가 저장되었다. 또한 entityManager.getDelegate()를 통해 세션을 확인해보면 서로 다른 세션을 사용하고 있음을 확인할 수 있었다.

자기 호출에 대한 문제를 인지하고 있었다면 다른 클래스를 통해 호출하면 되지만, 내부 호출을 인지하지 못한다면 의도한대로 트랜잭션이 적용되지 않을 수 있다. 때문에 의도치 않은 자기 호출을 막기 위해서 하위 메서드는 항상 private으로 선언하여 외부에서 호출할 수 없도록 하는 것이 좋다.

private에 Transactional을 적용한 경우

위 상황에서 조금 다른 아래와 같은 상황을 생각해보자.

  • internalCreatePost 메서드에서 데이터 저장 수행
  • externalCreatePost 메서드에서도 데이터 저장 수행
  • internalCreatePost 메서드에서 예외 발생
PostService.java
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
private final PostServiceInternal postServiceInternal;
// 먼저 외부에서 호출되는 메서드
@Transactional
public void externalCreatePost(PostCreateRequest postCreateRequest) {
System.out.println(entityManager.getDelegate()); // SessionImpl(1431135451<open>)
this.postRepository.save(
postCreateRequest.toEntity(userService.getUserById(postCreateRequest.getUserId()))
);
this.postServiceInternal.internalCreatePost(postCreateRequest);
}
}
// PostServiceInternal.java
@Service
@RequiredArgsConstructor
public class PostServiceInternal {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
// PostService의 externalCreatePost 메서드에서 호출되는 메서드
@Transactional(propagation = REQUIRES_NEW)
public void internalCreatePost(PostCreateRequest postCreateRequest) {
System.out.println(
"get delegate: " + entityManager.getDelegate() // SessionImpl(460044237<open>)
);
User user = userService.getUserById(postCreateRequest.getUserId());
postRepository.save(postCreateRequest.toEntity(user));
if (true) {
throw new RuntimeException();
}
}
}

internalCreatePost 메서드를 REQUIRES_NEW으로 설정했기 때문에, 두 서비스의 트랜잭션이 독립적으로 실행되길 기대할 것이다. 때문에 internalCreatePost에서 예외가 발생하더라도 externalCreatePost에서 데이터가 저장될 것으로 예상할 수 있지만 실제론 그렇지 않다.

externalCreatePostinternalCreatePost
트랜잭션 시작(id 1)
데이터 저장 수행(id 1)
internalCreatePost 메서드 호출
새로운 트랜잭션 생성(id 2)
데이터 저장 수행(id 2)
예외 발생
예외로 인한 트랜잭션 롤백(id 2)
트랜잭션 종료(id 2)
internalCreatePost 예외 전달 받음
예외로 인한 트랜잭션 롤백(id 1)
트랜잭션 종료(id 1)

원인은 아주 간단하게 전파 받은 예외를 처리하지 않았기 때문인데, 전파 받은 예외를 처리하도록 수정하면 해결할 수 있다.

PostService.java
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final UserService userService;
private final EntityManager entityManager;
private final PostServiceInternal postServiceInternal;
@Transactional
public void externalCreatePost(PostCreateRequest postCreateRequest) {
System.out.println(entityManager.getDelegate()); // SessionImpl(1431135451<open>)
this.postRepository.save(
postCreateRequest.toEntity(userService.getUserById(postCreateRequest.getUserId()))
);
try {
this.postServiceInternal.internalCreatePost(postCreateRequest);
} catch (Exception e) { // 예외 처리 추가
System.out.println("catch exception");
}
// 예외를 처리하게 되면서 트랜잭션 커밋 수행
}
}

Self Invocation 문제는 @Transactional 선언 방식을 사용할 때 발생할 수 있는 문제로, 내부 호출을 통해 발생할 수 있다. 다양한 해결 방법이 있겠지만, 객체의 책임을 분리하고 외부에서 호출하도록 설계하는 것이 가장 좋은 방법인 것 같다. 또한, REQUIRES_NEW는 새로운 스레드를 생성하는 것이 아닌 새로운 트랜잭션을 생성하는 것을 명심해야 한다.

Spring Data JPA Cursor 기반 페이징 성능 개선기

실행 환경: Java 17, Spring Boot 3.1.5, MySQL 8.0.33

Offset이 아닌 Cursor 기반으로 페이징 처리를 하면 성능이 향상된다는 이야기는 들었으나, 실제로 적용해볼 기회가 없어 이번 기회에 적용해보았다.
기존 단순 Offset 기반으로 페이징 처리된 코드와 Cursor 기반으로 페이징 처리된 코드와 성능을 비교해보고자 했는데,
그 과정에서 의도대로 동작하지 않는 부분이 있어 이에 대한 원인과 해결 방법을 포스팅하게 되었다.

만약 한 번에 100만개의 데이터를 가져온다고 가정하면, 서버는 모든 100만개의 데이터를 메모리에 올리게 되고, 이를 클라이언트에게 전달하게 된다.
이렇게 되면 통신 비용이 많이 들고, 서버의 메모리를 많이 사용하게 되어 성능 저하나 메모리로 인한 서버 다운 등의 문제가 발생할 수 있다.

때문에 특정 개수만큼 가져오는 페이지네이션이라는 기법을 사용하고 있으며, 이를 구현하는 방법은 크게 두 가지가 있다.

  • Offset 기반: MySQL의 LIMITOFFSET을 사용하여 구현
  • Cursor 기반: MySQL의 LIMITWHERE을 사용하여 구현

이 방식들의 자세한 설명과 장단점은 아래와 같이 정리할 수 있다.

SQL의 LIMITOFFSET을 사용하여 구현하는 방식으로, 간단하고 쉽게 구현할 수 있으며 Cursor에 비해 여러 기능을 구현하기 쉽다.

SELECT *
FROM table
LIMIT 10 OFFSET 10; -- 11번째부터 10개의 데이터를 가져온다.

구현하기 쉽다는 장점이 있지만, 단점도 존재한다.

  1. 조회한 데이터가 많고, 건너 뛰는(OFFSET) 데이터가 많을 경우 성능이 저하된다.
  2. 데이터가 삭제되거나 추가되면 페이지네이션의 결과가 달라질 수 있다.(2번 페이지에서 확인한 데이터가 다음 페이지에서 다시 노출될 수 있음)

SQL의 LIMITWHERE을 사용하여 구현하는 방식으로, LIMITOFFSET 방식의 단점을 보완할 수 있다.

SELECT *
FROM table
WHERE id > 10 -- id가 10보다 큰 데이터를 가져온다.(OFFSET 기능을 수행)
LIMIT 10; -- 10개의 데이터를 가져온다.

이렇게 되면 건너 뛰는 데이터를 WHERE 절에서 처리하기 때문에 성능이 저하되지 않지만, 이 방식에도 단점이 존재한다.

  1. WHERE 절에 사용되는 컬럼은 중복이 없고(UNIQUE), 인덱스가 존재해야 한다.(적용할 순 있으나 성능이 저하될 수 있음)
  2. 1페이지에서 5페이지 데이터를 바로 조회하는 기능(정확한 데이터 개수만큼 건너 뛰어서 조회하는 기능)을 구현하기 어렵다.

때문에 Cursor 기반으로 페이징 처리가 가능한 기능이면 Cursor 기반으로 페이징 처리를 하고, 그 외의 경우에는 다른 방법(커버링 인덱스 등)을 고려해보는 것이 좋다.

각 방법의 성능 차이는 건너 뛰는 개수가 커질 수록 성능 차이가 커지는 것을 확인할 수 있었다.(데이터 3,260,000개 기준, 5회 측정)
우선 Offset 기반으로 페이징 처리를 한 경우의 성능은 아래와 같다.

SELECT *
FROM posts
LIMIT 0, 20; # 40~80ms
SELECT *
FROM posts
LIMIT 1000000, 20; # 40~80ms
SELECT *
FROM posts
LIMIT 2000000, 20; # 500ms~600ms
SELECT *
FROM posts
LIMIT 3000000, 20; # 800ms~900ms

Offset 기반은 건너 뛰는 개수에 따라 쿼리 속도가 선형적으로 증가하는 것을 확인할 수 있다.
반면 Cursor 기반으로 페이징 처리를 한 경우의 성능은 아래와 같다.

SELECT *
FROM posts
WHERE id > 0 # 50~70ms
LIMIT 20;
SELECT *
FROM posts
WHERE id > 1000000 # 50~70ms
LIMIT 20;
SELECT *
FROM posts
WHERE id > 2000000 # 50~70ms
LIMIT 20;
SELECT *
FROM posts
WHERE id > 3000000 # 50~70ms
LIMIT 20;

Cursor 기반은 건너 뛰는 개수에 따라 쿼리 속도가 일정하고 최대 약 20배 정도 빠른 것을 확인할 수 있다.

Spring Data JPA에서 Cursor 기반 페이징 처리

Section titled “Spring Data JPA에서 Cursor 기반 페이징 처리”

MySQL에서 유의미한 차이를 확인한 뒤 Spring Data JPA에서 Cursor 기반으로 페이징 처리를 하기 위해 아래와 같이 코드를 작성했다.

public interface PostRepository extends JpaRepository<Post, Long> {
// Offset 기반 페이징 처리
@Query("select p from Post p join fetch p.user")
Page<Post> findAllWithUser(Pageable pageable);
// Cursor 기반 페이징 처리(새롭게 추가한 코드)
@Query("select p from Post p join fetch p.user where p.id > :cursorId")
Page<Post> findAllWithUserByCursor(Long cursorId, Pageable pageable);
}

그 후 Postman을 통해 테스트를 진행했으나 의도와는 다르게 동작하는 것을 확인할 수 있었다.
우선 의도대로 동작한 케이스를 확인해보자.

  • 의도대로 동작한 Cursor 기반 조회

Cursor 기반 조회 - 80ms

  • 의도대로 동작한 Offset 기반 조회

Offset 기반 조회 - 2560ms

건너 뛰는 것이 많은 케이스일 땐(page 150000, 약 3,000,000개) 우리가 원하는, 그리고 위에서 확인한 성능 차이가 나는 것을 확인할 수 있었다.
하지만 건너 뛰는 것이 적은 케이스일 땐(page 59, 약 1,200개) 성능 차이가 거의 없는 것을 확인할 수 있었다.

  • 의도하지 않은 결과 Cursor 기반 조회

Cursor 기반 조회 - 1739ms

  • 의도하지 않은 결과 Offset 기반 조회

Offset 기반 조회 - 1745ms

결론부터 말하자면 위 구현 코드에서 Page 타입으로 반환하고 있기 때문이다.(Page 타입 반환은 내용 뿐만 아니라 개수에 대한 정보도 같이 반환한다.)

{
"message": "성공적으로 모든 게시글이 조회되었습니다.",
"data": {
"content": [
...
],
"pageable": {
...
},
"totalPages": 6300,
"totalElements": 126000,
...
}
}

이런 정보를 반환하기 위해 COUNT 쿼리를 실행하고 있기 때문인데, 결국에 요청하는 쿼리는 아래와 같이 된다.

  • Offset 기반 조회: WHERE 조건 없이 전체 데이터에 대한 OFFSET + 데이터 전체 건 수 COUNT
  • Cursor 기반 조회: WHERE 조건에 맞는 데이터에 대한 OFFSET + WHERE 조건에 맞는 데이터 전체 건 수 COUNT

그럼 COUNT 쿼리가 성능에 어떤 영향을 주었길래 이렇게 성능 차이가 발생했을까?
이유엔 크게 아래 세 가지 요인이 있다.

  • offset 특성 상 건너 뛰는 데이터가 적을 땐 성능 저하가 적음
  • 조건 없는 COUNT 쿼리 요청 시 많은 건을 조회하는 WHERE 보다 빠르게 조회됨 (참고)
  • 조건에 잡힌 데이터가 많을 수록 COUNT 쿼리의 속도 저하가 커짐
-- 전체 데이터에 대한 COUNT 쿼리
SELECT COUNT(*)
FROM posts;
-- 300ms
-- 조건에 잡힌 데이터가 적은 경우 COUNT 쿼리
SELECT COUNT(*)
FROM posts
WHERE id > 3000000;
-- 50~100ms
-- 조건에 잡힌 데이터가 많은 경우 COUNT 쿼리
SELECT COUNT(*)
FROM posts
WHERE id > 50000;
-- 500~700ms

때문에 결과적으로 아래와 같은 결과가 나오게 된다.

  • Offset 기반 쿼리: 건너 뛰는 데이터가 적음(성능 저하가 적음) + WHERE 조건 없이 COUNT 쿼리 요청(성능 저하가 적음)
  • Cursor 기반 쿼리: 건너 뛰는 데이터가 많음(성능 동일) + WHERE 조건에 잡힌 데이터가 많은 COUNT 쿼리 요청(성능 저하가 큼)

결국 Offset 기반 쿼리는 성능 저하가 적은 쿼리가 실행되고, Cursor 기반 쿼리는 성능 저하가 큰 쿼리가 실행되어 성능이 서로 거의 비슷해지는 상황이 발생하게 된 것이다.

Cursor 기반 페이징의 경우 전체 데이터 건 수를 꼭 가져올 필요가 없기 때문에 Count 쿼리를 실행하지 않도록 수정하면 된다.
Page 타입이 아닌 Slice 타입으로 반환하도록 수정하면 Count 쿼리가 실행되지 않게 된다.

// Page.java, Page는 Slice를 상속받으면서 개수에 대한 정보만 추가로 가지고 있다.
public interface Page<T> extends Slice<T> {
static <T> Page<T> empty() {
return empty(Pageable.unpaged());
}
static <T> Page<T> empty(Pageable pageable) {
return new PageImpl<>(Collections.emptyList(), pageable, 0);
}
int getTotalPages();
long getTotalElements();
<U> Page<U> map(Function<? super T, ? extends U> converter);
}
public interface PostRepository extends JpaRepository<Post, Long> {
// Page -> Slice로 수정
@Query("select p from Post p join fetch p.user where p.id > :cursorId")
Slice<Post> findAllWithUserByCursor(Long cursorId, Pageable pageable);
}

이렇게 Slice 타입으로 반환하면 Count 쿼리가 실행되지 않기 때문에 기존에 구현했던 Cursor 기반 쿼리의 성능이 향상되면서 일정한 성능을 유지할 수 있게 된다.

Slice 반환 Cursor 조회 - 37ms

Slice 반환 Cursor 조회 - 36ms

결제 정보 검증을 통한 안전한 결제 연동 시스템 구현 - 토스 페이먼츠

실행 환경: Java 17, Spring Boot 3.1.5
서버 위주의 포스팅이기 때문에 클라이언트 코드는 간략하게 작성
프론트엔드는 토스페이먼츠에서 제공해준 샘플 프로젝트 리액트 일부 수정 사용
!! 해당 문서는 2023년 11월 기준의 코드로 작성되었으며, 구현 코드는 많은 부분이 변경되었습니다.

KG이니시스, 다날 페이먼트, 토스 페이먼츠 등의 결제 서비스를 제공하는 업체들이 존재하는데, 그 중 토스페이먼츠를 이용하여 결제 시스템을 구현해보았다.
토스페이먼츠에서는 이미 클라이언트 코드를 제공해주어 쉽게 구현할 수 있다.
하지만 클라이언트에서만 처리하는 것은 보안에 취약하기 때문에 중간에 서버를 두어 검증 하는 단계를 추가하여 결제 시스템을 구현해보고자 한다.

문서가 잘 되어 있어 해당 문서를 참고하는 것이 가장 좋으나 핵심 용어를 간단하게 요약하면 아래와 같다.

  • 결제 위젯: 토스페이먼츠에서 제공해주는 결제 위젯 SDK로, 결제 요청을 위한 정보를 받아 결제 요청을 보내준다.
  • Client Key: 결제 위젯을 사용하기 위해 필요한 키로, 토스 페이먼츠에서 제공해준다.
  • Secret Key: 결제 승인 및 조회를 위해 필요한 키로, 토스 페이먼츠에서 제공해준다.(공개하면 안되는 비밀키이므로 해당 프로젝트에서는 서버에서 사용하도록 하였다.)
  • 결제 후 리다이렉트: 결제가 완료되면 결제 정보와 함께 리다이렉트를 해주는데, 이때 결제 정보는 URL 파라미터로 전달된다.
    • 결제 인증만 완료된 상태로, 완전히 결제가 완료된 것은 아니다.
  • 결제 승인: 인증된 결제를 최종 승인하는 것으로, 결제 승인이 완료되면 결제가 완전히 완료된 것이다.

출처(https://docs.tosspayments.com/guides/learn/payment-flow)

결제 흐름 페이지에도 나와있듯이 결제 흐름은 세 단계로 나눌 수 있다.

  1. 요청
  2. 인증
  3. 승인

문서가 잘 되어 있기 때문에 전체적인 기본 플로우나 각 단계에 대한 설명은 토스 제공 공식 문서를 참고하는 것이 좋은 것 같다.
기본적인 결제 흐름은 위와 같으며 안전한 결제를 위하여 결제 정보를 검증하기 위해 서버를 두어 검증자의 역할을 수행하도록 하였다.

서버를 둔 토스 결제 흐름

  1. 주문 번호 생성 요청: 클라이언트에서 결제 요청 전 주문 번호를 서버에 요청
  2. 구매 요청 검증 및 DB 저장: 서버에서는 주문 번호를 생성하고 받은 정보와 주문 번호를 DB에 저장
  3. 주문 번호 반환: 서버에서 생성한 주문 번호를 클라이언트에게 반환
  4. 결제 요청: 받은 주문 정보로 결제 요청
  5. 결제 인증: 결제 요청을 통해 결제 위젯이 뜨면 결제 인증 진행
  6. 성공 리다이렉트: 결제 인증이 완료되면 결제 정보와 함께 성공 페이지로 리다이렉트
  7. 결제 승인 요청: 성공 페이지로 리다이렉트 되면 서버에 결제 승인 요청
  8. 결제 정보 조회: 서버에서 결제 인증 단계에서 토스페이먼츠에 저장된 결제 정보 조회
  9. 결제/주문 정보 양방향 검증: 서버에서 결제 정보와 클라이언트에서 받은 결제 정보, DB에 저장된 주문 정보 검증
  10. 결제 승인: 결제 정보에 이상이 없다면 토스에 결제 승인 요청
  11. DB 업데이트: 결제 완료로 DB 업데이트
  12. 성공내역 반환: 클라이언트에게 성공 내역 반환
  13. 결제 완료: 클라이언트에서 결제 완료 페이지로 이동

각 과정에 대한 설명을 코드와 함께 아래에서 자세히 설명하도록 하겠다.

1. 주문 번호 생성 요청 - (클라이언트)

Section titled “1. 주문 번호 생성 요청 - (클라이언트)”

실제 결제를 하기 전에 주문 번호를 생성해야 하는데, 토스에서 제공해준 기존 클라이언트 코드에서는 직접 주문 번호를 생성하고 있었다.
이러한 정보는 클라이언트에서 생성하는 것 보단 서버에서 생성하는 것이 더 안전하다고 생각하여 서버에서 주문 번호를 생성하도록 하였다.

const requestData = {
userId: USER_ID, // 인증 처리 생략으로 인해 userId를 직접 넘겨줌
amount: PRICE,
orderProduct: { // 구매하는 상품 정보와 수량
productId: PRODUCT_ID,
quantity: QUANTITY,
},
}
// 주문 번호 생성 요청
const response = await fetch("http://localhost:8080/api/v1/orders/create", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestData),
})

아래 화면에서 결제하기 버튼을 누르면 실제 결제 요청을 시작하기 전에 서버에 주문 번호를 생성하는 요청을 보내도록 구현하였다.

결제 선택 클라이언트 화면

2. 구매 요청 검증 및 DB 저장 + 3. 주문 번호 반환 - (서버)

Section titled “2. 구매 요청 검증 및 DB 저장 + 3. 주문 번호 반환 - (서버)”

클라이언트에서 주문 번호를 요청하면 서버에서는 주문 번호 반환 뿐만 아니라 구매 상품에 대한 검증과 DB에 저장하는 작업을 수행하게 된다.
수행 작업 및 순서는 주석의 번호를 따라가며 확인할 수 있다.

OrderService.java
public class OrderService {
// ...
@Transactional
public OrderCreateResponse createOrder(OrderCreateRequest orderCreateRequest) {
OrderProduct orderProduct = orderCreateRequest.getOrderProduct();
OrderInfo createdOrder = orderInfoRepository.save(
// 1. toEntity()로 builder()를 호출하여 생성자를 호출
orderCreateRequest.toEntity(
// userId로 사용자 정보 조회
userService.getById(orderCreateRequest.getUserId()),
// productId로 구매 상품 정보 조회
productService.getById(orderProduct.getProductId())
));
return new OrderCreateResponse(createdOrder); // 4. Order ID를 포함한 생성된 주문 정보 반환
}
// ...
}
// OrderInfo.java
@Getter
@Entity
@Table(name = "order_info")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderInfo extends BaseTime {
// ... 필드 생략
// 2. 생성자 호출
@Builder
protected OrderInfo(/* ... */) {
// ...
this.validateProductInfo(totalAmount, quantity); // 3. 생성 완료 전 상품 정보 검증
}
private void validateProductInfo(BigDecimal totalAmount, Integer quantity) {
this.product.validateStock(quantity); // 상품 재고 검증
// 상품 가격 * 수량 == 결제 금액 검증
BigDecimal totalPrice = this.product.getPrice().multiply(BigDecimal.valueOf(quantity));
if (totalAmount.compareTo(totalPrice) != 0) {
throw OrderInfoException.of(OrderInfoErrorMessage.INVALID_TOTAL_AMOUNT);
}
}
// ...
}

우선 상품 금액과 결제 금액이 일치하는지 검증하고, DB에 저장한다.(여기서 저장한 정보는 이후 승인 요청에서 검증을 위해 사용된다.)
그 후 클라이언트에게 주문 번호를 반환하여 클라이언트가 결제 요청을 할 수 있도록 한다.

4. 결제 요청 + 5. 결제 인증 + 6. 성공 리다이렉트 - (클라이언트)

Section titled “4. 결제 요청 + 5. 결제 인증 + 6. 성공 리다이렉트 - (클라이언트)”
// 위의 클라이언트 로직 이후...
// 반환 된 주문 번호 저장
const orderId = json.orderId;
try {
// 결제 요청 시작
await paymentWidget?.requestPayment({
orderId: orderId,
orderName: ORDER_NAME,
customerName: CUSTOMER_NAME,
customerEmail: CUSTOMER_EMAIL,
successUrl: `${window.location.origin}/success`,
failUrl: `${window.location.origin}/fail`,
});
} catch (error) {
// 에러 처리
console.error(error);
}

주문 번호를 성공적으로 받게 되면 클라이언트에서 결제 정보를 통해 요청을 하게 되고 아래 화면에서 결제 완료 버튼을 누르면 결제 인증이 진행된다.

결제 승인 보내기 전 클라이언트 화면

결제 인증까지 완료된다면 성공 페이지로 리다이렉트 되는데, 이 때 리다이렉트 정보에는 paymentKey, orderId, amount 정보가 포함되어 있다.
각 정보는 결제에 있어 중요한 정보이기 때문에 서버에서 검증 과정을 거치도록 한다.

7. 결제 승인 요청 - (클라이언트)

Section titled “7. 결제 승인 요청 - (클라이언트)”

토스에서 제공해준 클라이언트 코드에서는 성공 페이지로 리다이렉트 되면 결제 승인 요청을 클라이언트에서 직접하도록 되어있다.
하지만 결제 승인 요청을 클라이언트에서 직접 하게 되면 결제 정보를 조작하여 요청을 할 수 있기 때문에 서버에 요청을 보내 검증 과정을 거치도록 하였다.

// 리다이렉트로 받은 결제 정보 데이터를 서버에게 전달하기 위해 데이터 생성
const requestData = {
userId: USER_ID,
orderId: searchParams.get("orderId"),
amount: searchParams.get("amount"),
paymentKey: searchParams.get("paymentKey"),
};
async function confirm() {
// 토스가 아닌 우리가 구축한 서버에 결제 승인 요청
const response = await fetch("http://localhost:8080/api/v1/orders/confirm", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestData),
})
const json = await response.json();
// 실패 시 에러 페이지로 이동
if (!response.ok) {
console.log(json);
navigate(`/fail?message=${json.message}`)
return;
}
console.log(json);
}
confirm();

8. 결제 정보 조회 + 9. 결제/주문 정보 검증 + 10. 결제 승인 + 11. DB 업데이트 + 12. 성공내역 반환 - (서버)

Section titled “8. 결제 정보 조회 + 9. 결제/주문 정보 검증 + 10. 결제 승인 + 11. DB 업데이트 + 12. 성공내역 반환 - (서버)”

이제 서버에서는 결제 승인 요청을 받게 되는데, 클라이언트에서 받은 승인 요청 정보와 결제 요청 및 승인을 통해 저장된 토스페이먼츠 결제 정보를 검증하게 된다.
우선 결제 정보 조회 및 승인 요청 코드는 아래와 같다.

@Service
public class PaymentService {
@Value("${spring.myapp.toss-payments.secret-key}")
private String secretKey; // 토스 페이먼츠 인증에 사용되는 시크릿키
@Value("${spring.myapp.toss-payments.api-url}")
private String tossApiUrl; // 토스 페이먼츠 API URL
public TossPaymentResponse getPaymentInfoByOrderId(String orderId) {
return findPaymentInfoByOrderId(orderId)
.orElseThrow(() -> PaymentException.of(PaymentErrorMessage.NOT_FOUND));
}
// 결제 정보 조회
public Optional<TossPaymentResponse> findPaymentInfoByOrderId(String orderId) {
return HttpUtils.requestGetWithBasicAuthorization(
tossApiUrl + "/orders/" + orderId,
EncodeUtils.encodeBase64(secretKey + ":"),
TossPaymentResponse.class);
}
// 결제 승인 요청
public TossPaymentResponse confirmPayment(TossConfirmRequest tossConfirmRequest) {
return HttpUtils.requestPostWithBasicAuthorization(
tossApiUrl + "/confirm",
EncodeUtils.encodeBase64(secretKey + ":"),
tossConfirmRequest,
TossPaymentResponse.class);
}
// ...
}

노출되면 안되는 값들은 application.yaml로 관리하였고, RestTemplate을 사용하여 토스측에 결제 정보 조회 및 승인 요청을 보내도록 하였다.
다음으로 자세한 수행 작업 및 순서는 주석의 번호를 따라가며 확인할 수 있다.

OrderService.java
public class OrderService {
// ...
@Transactional
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. Order ID 저장 때 생성된 주문 정보 조회(+ Pesimistic Lock으로 재고 차감 동시성 제어)
OrderInfo orderInfo = this.getOrderInfoByOrderPessimisticLock(
orderConfirmRequest.getOrderId()
);
// 2. 재고 충분한지 확인 + 재고 차감
productService.reduceStock(orderInfo.getProduct().getId(), orderInfo.getQuantity());
// 3. 결제 정보 조회
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
// 4. 저장된 정보 + 클라이언트 요청 정보 + 토스에 저장된 결제 정보 검증
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 5. 결제 승인
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
// 6. OrderInfo 업데이트
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
// 7. 성공내역 반환
return new OrderConfirmResponse(confirmedOrderInfo);
}
// ...
}

OrderInfo와 재고를 차감할 Product, 그리고 User에 대해 Lock을 걸어 동시에 재고 차감 및 수정되는 것을 방지하였다.
검증 및 데이터 변경은 OrderInfo 엔티티에서 수행하도록 하였고, 검증하는 정보는 주석에 작성해두었다.

OrderInfo.java
@Getter
@Entity
@Builder
@Table(name = "order_info")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderInfo extends BaseTime {
// ...
// (위 코드의 4번에서 호출)저장된 정보 + 클라이언트 요청 정보 + 토스에 저장된 결제 정보 검증
public void validateInProgressOrder(TossPaymentResponse paymentInfo,
OrderConfirmRequest orderConfirmRequest) {
// 주문 상태가 IN_PROGRESS가 아니라면 결제 승인 요청을 할 수 없음
if (!paymentInfo.getStatus().equals(OrderStatus.IN_PROGRESS.getStatusName())) {
throw OrderInfoException.of(OrderInfoErrorMessage.NOT_IN_PROGRESS_ORDER);
}
this.validateOrderInfo(paymentInfo, orderConfirmRequest);
}
// (위 코드의 6번에서 호출)OrderInfo 업데이트
public OrderInfo confirmOrder(TossPaymentResponse paymentInfo,
OrderConfirmRequest orderConfirmRequest) {
// 승인 요청 후 결제 정보가 DONE이 아니라면 결제 승인이 완료되지 않음
if (!paymentInfo.getStatus().equals(OrderStatus.DONE.getStatusName())) {
throw OrderInfoException.of(OrderInfoErrorMessage.NOT_DONE_PAYMENT);
}
this.validateOrderInfo(paymentInfo, orderConfirmRequest);
updateOrderPaymentInfo(paymentInfo); // 결제 정보 업데이트
return this;
}
// 검증 로직, validateInProgressOrder/confirmOrder에서 호출
private void validateOrderInfo(TossPaymentResponse paymentInfo,
OrderConfirmRequest orderConfirmRequest) {
// 저장된 order id == 클라이언트 요청 order id
if (!this.orderId.equals(orderConfirmRequest.getOrderId())) {
throw OrderInfoException.of(OrderInfoErrorMessage.INVALID_ORDER_ID);
}
// 저장된 user id == 클라이언트 요청 user id
if (!this.user.getId().equals(orderConfirmRequest.getUserId())) {
throw OrderInfoException.of(OrderInfoErrorMessage.INVALID_USER_ID);
}
// 클라이언트 요청 payment key == 토스에 저장된 payment key
if (!paymentInfo.getPaymentKey().equals(orderConfirmRequest.getPaymentKey())) {
throw OrderInfoException.of(OrderInfoErrorMessage.INVALID_PAYMENT_KEY);
}
// 클라이언트 요청 amount == 토스에 저장된 total amount == 상품 가격 * 수량
if (!compareAmounts(paymentInfo, orderConfirmRequest)) {
throw OrderInfoException.of(OrderInfoErrorMessage.INVALID_TOTAL_AMOUNT);
}
}
private boolean compareAmounts(
TossPaymentResponse paymentInfo,
OrderConfirmRequest orderConfirmRequest
) {
BigDecimal paymentInfoTotalAmount = BigDecimal.valueOf(paymentInfo.getTotalAmount());
BigDecimal orderConfirmRequestAmount = orderConfirmRequest.getAmount();
BigDecimal orderInfoAmount = this.product.getPrice()
.multiply(BigDecimal.valueOf(this.quantity));
return orderInfoAmount.compareTo(paymentInfoTotalAmount) == 0 &&
orderInfoAmount.compareTo(orderConfirmRequestAmount) == 0 &&
orderConfirmRequestAmount.compareTo(paymentInfoTotalAmount) == 0;
}
// ...
}

코드를 살펴보면 validateOrderInfo가 결국 결제 승인 요청 전/후로 두 번 호출 되는 것을 확인할 수 있다.

  • 요청 전: 아직 검증되지 않은 결제 정보가 불필요하게 결제 승인 요청 되는 것을 방지하기 위해 검증을 수행
  • 요청 후: 승인 요청 후 올바르게 결제 정보를 승인하였는지 다시 한 번 검증

검증 자체는 큰 비용이 아니기 때문에 불필요하게 결제 승인 요청을 보내는 것 보다는 검증 로직을 두 번 호출하는 것이 더 안전하다고 판단하였다.
비슷한 맥락으로 승인 요청 전 결제 정보 조회 API 요청 전에 재고 검증을 먼저 수행하도록 하였다.

결제가 무사히 완료되면 결제 완료 페이지로 이동하게 되면서 결과를 확인할 수 있다.

결제 성공 클라이언트 화면

또한 토스 페이먼츠의 테스트 결제내역에서도 동일한 주문번호가 남아있어 정상적으로 결제가 완료되었음을 확인할 수 있다.

토스 결제 내역 화면

외부 API를 연동하여 통신을 하게 되는 부분에서 여러 문제점들이 발생할 수 있다고 생각하였다.
추후 이러한 문제점들을 개선 방향으로 잡으면 좋을 것 같다.

OrderService.java
public class OrderService {
// ...
@Transactional // 외부 API 2회 요청이 있는 트랜잭션 범위
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
OrderInfo orderInfo = this.getOrderInfoByOrderPessimisticLock(
orderConfirmRequest.getOrderId()
);
productService.reduceStock(orderInfo.getProduct().getId(), orderInfo.getQuantity());
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 결제 승인
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
// 요청이 지연 되는 경우..
if (true)
throw new Exception("test"); // 만약 승인 이후 오류 발생하면?
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
return new OrderConfirmResponse(confirmedOrderInfo);
}
// ...
}

결제 승인 이후 오류가 발생한다면 서버의 데이터베이스에는 전부 롤백이 되지만 토스에는 결제 승인된 내역이 그대로 남아있게 된다.
우선은 변동 가능성이 큰 재고를 가진 Product에 대한 Lock을 얻어 내부에서 오류가 발생할 확률은 낮지만, 해당 케이스에 대한 처리가 필요하다.

결제 승인 단계에서 통신 중 응답이 지연되는 경우가 발생할 수 있다.
우리 서버의 타임 아웃이 5초이고, 토스 API의 응답이 지연되어 6초가 걸렸다고 가정하면 우리 서버는 5초 후에 응답을 받지 못하고 타임 아웃이 발생하게 된다.
하지만 토스사에서는 결제 승인이 완료되었기 때문에 결제가 완료된 것으로 처리되지만 우리 서버에서는 결제 승인이 완료되지 않은 것으로 처리될 수 있다.
이번에도 마찬가지로 데이터베이스는 롤백되지만 토스사에는 결제 승인된 내역이 그대로 남아있게 된다.

현재 트랜잭션 범위가 메서드 전체에 걸려있는데, 해당 메서드 안에 외부 API 요청이 2회가 있기 때문에 트랜잭션의 시간적 범위가 넓어지게 된다.
여기서 API 타임 아웃이 발생하게 되면 락을 획득한 상태에서 계속 대기하게 되는데, 이는 다른 사용자의 요청이 많아질 경우 성능 저하를 발생시킬 수 있다.

@Builder 사용의 여러가지 방법과 안전하게 사용하기

실행 환경: Java 17, Spring Boot 3.1.4

빌더 패턴을 사용하면 객체를 생성할 때 많은 이점을 얻을 수 있다. (링크 참조)
하지만 빌더 패턴을 사용하면 많은 코드를 작성해야 하는 단점이 존재하나 Lombok의 @Builder 어노테이션 을 사용하면 이러한 단점을 보완할 수 있다.(성능 저하라는 단점도 있으나 미미한 편)

Builder를 사용하면 모든 필드를 매개변수로 받는 생성자를 필요로 한다.

** @Builder 어노테이션 내부 설명
* If a member is annotated, it must be either a constructor or a method. If a class is annotated,
* then a package-private constructor is generated with all fields as arguments
* (as if {@code @AllArgsConstructor(access = AccessLevel.PACKAGE)} is present
* on the class), and it is as if this constructor has been annotated with {@code @Builder} instead.
* Note that this constructor is only generated if you haven't written any constructors and also haven't
* added any explicit {@code @XArgsConstructor} annotations. In those cases, lombok will assume an all-args
* constructor is present and generate code that uses it; this means you'd get a compiler error if this
* constructor is not present.

생성자가 없는 경우엔 @Builder 어노테이션에서 자동 생성해주지만, 다른 생성자가 있는 경우엔 @AllArgsConstructor로 생성자를 추가하여 사용할 수 있다.

@Getter
@Entity
@Builder
@Table(name = "order_info")
@NoArgsConstructor // @Entity는 빈 생성자(기본 생성자)가 필요
@AllArgsConstructor // @NoArgsConstructor가 있기 때문에 명시적으로 @AllArgsConstructor를 추가
public class OrderInfo extends BaseTime {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(name = "quantity", nullable = false)
private Integer quantity;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
@Builder.Default
@Column(name = "status", nullable = false)
private String status = ORDER_CREATE_STATUS;
// ...필드 및 메서드 생략
// 주문 정보 검증
public void validateProductInfo(BigDecimal totalAmount, Integer quantity) {
// 검증 로직
}
}

이 Entity 클래스를 생성하고 데이터베이스에 저장하기 위해 다음과 같이 사용할 수 있다.
주문 정보를 생성하고 검증 후 저장하는 간단한 서비스 코드이다.

dto.java
@Getter
@RequiredArgsConstructor
public class OrderCreateRequest {
private final Long userId;
private final BigDecimal amount;
private final OrderProduct orderProduct;
public OrderInfo toEntity(User user, Product product) {
return OrderInfo.builder()
.user(user)
.product(product)
.quantity(this.orderProduct.getQuantity())
.totalAmount(this.amount)
.build();
}
}
// service.java
public class OrderService {
// ...
public OrderCreateResponse createOrder(OrderCreateRequest orderCreateRequest) {
// 1. 주문 생성
OrderInfo createdOrderInfo = orderCreateRequest.toEntity(
userService.getById(orderCreateRequest.getUserId()),
productService.getById(orderProduct.getProductId())
);
// 2. 주문 상품 정보 검증
createdOrderInfo.validateProductInfo(
orderCreateRequest.getAmount(),
orderProduct.getQuantity()
);
// 3. 주문 정보 저장
OrderInfo createdOrder = orderInfoRepository.save(createdOrderInfo);
return new OrderCreateResponse(createdOrder);
}
// ...
}

서비스 로직을 살펴보면 크게 세 가지 단계로 나눌 수 있다.

  1. 주문 생성
  2. 주문 상품 정보 검증
  3. 주문 정보 저장

위 코드에서는 2번 단계를 성실하게 수행하여 결과적으로 데이터베이스에 검증이 완료 된 주문 정보만 저장할 수 있게 되었다.

하지만 2번 단계를 생략하여 저장하게 되면 올바르지 않은 주문 정보가 데이터베이스에 저장될 수 있다.(다른 메서드에서 생성하거나 기존 코드가 수정되는 등)
때문에 2번 단계를 서비스 코드에서 수행하는 것이 아닌 Entity 클래스에서 생성할 때 검증하는 것이 더 안전하다고 볼 수 있다.

@Builder 어노테이션을 사용하면서 생성 시 검증 로직을 추가하는 방법으로 두 가지를 생각해볼 수 있다.

  1. build() 메서드를 재작성
  2. 생성자에서 검증 로직 수행

@Builder 어노테이션을 사용하면 build() 메서드를 호출하여 인스턴스를 생성하게 되는데, 이 메서드를 재작성할 수 있다.

entity.java
@Getter
@Entity
@Builder
@Table(name = "order_info")
@NoArgsConstructor
@AllArgsConstructor
public class OrderInfo extends BaseTime {
// @Builder.Default가 정상적으로 동작하지 않은 것을 제외하고 위의 코드와 동일
public static class OrderInfoBuilder {
private void validateProductInfo(BigDecimal totalAmount, Integer quantity) {
// 검증 로직
}
// build() 메서드 재작성
public OrderInfo build(BigDecimal totalAmount, Integer quantity) {
this.validateProductInfo(totalAmount, quantity);
return new OrderInfo(
this.id,
this.user,
this.product,
this.orderId,
this.paymentKey,
this.orderName,
this.method,
this.quantity,
this.totalAmount,
this.status,
this.requestedAt,
this.approvedAt,
this.lastTransactionKey
);
}
}
}
// dto.java
@Getter
@RequiredArgsConstructor
public class OrderCreateRequest {
private static final String ORDER_CREATE_STATUS = "READY";
private final Long userId;
private final String orderId;
private final BigDecimal amount;
private final OrderProduct orderProduct;
public OrderInfo toEntity(User user, Product product) {
return OrderInfo.builder()
.user(user)
.product(product)
.orderId(this.orderId)
.quantity(this.orderProduct.getQuantity())
.totalAmount(this.amount)
.status(ORDER_CREATE_STATUS) // @Build.Default 동작하지 않아 직접 추가
.build(amount, this.orderProduct.getQuantity()); // build() 호출 시 필요한 매개변수 추가
}
}

build() 메서드를 호출 할 때 필요한 매개변수를 추가하여 검증 로직을 수행할 수 있게 되었지만, 많은 단점이 생겼다.

  1. @Builder.Default가 정상적으로 동작하지 않아 기본 값을 직접 할당해야 함
  2. 해당 클래스의 생성자를 직접 호출하는 경우 검증 로직이 수행되지 않음
  3. 코드의 양이 많아지고 굉장히 복잡해짐

2번의 문제 같은 경우엔 @AllArgsConstructor(access = PROTECTED)를 사용하여 외부에서 생성자를 호출하지 못하도록 제한할 수 있지만,
1, 3번의 문제는 여전히 존재하고, 2번의 문제도 여전히 내부에서 생성자를 호출하는 경우에는 검증 로직이 수행되지 않는다.

위의 문제를 해결하기 위해 더 간단하고 안전한 방법은 생성자를 직접 추가하는 것이다.

entity.java
@Getter
@Entity
@Builder
@Table(name = "order_info")
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 외부에서의 생성자 호출 방지
// @AllArgsConstructor 제거
public class OrderInfo extends BaseTime {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(name = "quantity", nullable = false)
private Integer quantity;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
@Builder.Default
@Column(name = "status", nullable = false)
private String status = ORDER_CREATE_STATUS;
// ...필드 및 메서드 생략
@SuppressWarnings("java:S107") // 파라미터 개수 8개 이상 경고 무시
protected OrderInfo(Long id, User user, Product product, String orderId, String paymentKey,
String orderName, String method, Integer quantity, BigDecimal totalAmount,
String status, LocalDateTime requestedAt, LocalDateTime approvedAt,
String lastTransactionKey) {
this.id = id;
this.user = user;
this.product = product;
this.orderId = orderId;
this.paymentKey = paymentKey;
this.orderName = orderName;
this.method = method;
this.quantity = quantity;
this.totalAmount = totalAmount;
this.status = status;
this.requestedAt = requestedAt;
this.approvedAt = approvedAt;
this.lastTransactionKey = lastTransactionKey;
this.validateProductInfo(totalAmount, quantity); // 모든 값이 할당된 후 검증 로직 수행
}
public void validateProductInfo(BigDecimal totalAmount, Integer quantity) {
// 검증 로직
}
}
// dto.java
@Getter
@RequiredArgsConstructor
public class OrderCreateRequest {
private final Long userId;
private final BigDecimal amount;
private final OrderProduct orderProduct;
// 간결했던 가장 처음의 코드로 복귀
public OrderInfo toEntity(User user, Product product) {
return OrderInfo.builder()
.user(user)
.product(product)
.quantity(this.orderProduct.getQuantity())
.totalAmount(this.amount)
.build();
}
}

이 방법을 사용하면 위애서 언급 된 문제를 해결할 수 있다.

  1. @Builder.Default가 정상적으로 동작
  2. 해당 클래스의 생성자를 직접 호출하는 경우에도 검증 로직이 수행
  3. 생성자 추가로 코드의 양은 비슷하지만, build() 메서드를 재작성하는 것보다는 코드가 간결해짐

이렇게 함으로써 객체가 어느 시점에 생성되든 검증 로직이 수행되도록 할 수 있게 되었다.
추가적으로 @NoArgsConstructor(access = AccessLevel.PROTECTED)를 사용하여 기본 생성자를 외부에서 호출하지 못하도록 제한하였다.

@Builder 어노테이션은 생성자에도 사용할 수 있는데, 이 방식을 사용해 꼭 필요한 매개변수만 받을 수 있도록 할 수 있다.

@Getter
@Entity
@Table(name = "order_info")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderInfo extends BaseTime {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(name = "quantity", nullable = false)
private Integer quantity;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
@Column(name = "status", nullable = false)
private String status;
// ...필드 및 메서드 생략
@Builder
protected OrderInfo(User user, Product product, Integer quantity, BigDecimal totalAmount) {
this.user = user;
this.product = product;
this.quantity = quantity;
this.totalAmount = totalAmount;
// Builder.Default 대신 직접 할당
this.orderId = generateOrderId();
this.status = OrderStatus.READY.getStatusName();
this.validateProductInfo(totalAmount, quantity);
}
public void validateProductInfo(BigDecimal totalAmount, Integer quantity) {
// 검증 로직
}
}

이 방식을 사용하면 불필요한 매개변수까지 포함되는 것을 방지하는 것 뿐만 아니라, 어떤 필드가 생성 시 전달 받고 기본 값으로 할당되는지도 명확하게 알 수 있다.

@Builder 어노테이션을 사용하는 경우 롬복을 사용하고 있기 때문에 무의식적으로 @AllArgsConstructor를 사용할 수 있다.
@AllArgsConstructor를 사용하면 모든 필드를 매개변수로 받는 생성자가 생성 될 뿐만 아니라 생성 시 검증 로직을 수행할 수 없게 된다.
때문에 생성자에 @Builder 어노테이션을 사용하여 꼭 필요한 파라미터만 받을 수 있도록 하고, 생성 시 검증 로직을 수행할 수 있도록 하는 것이 좋다.