Skip to content

Spring & JPA

5 posts with the tag “Spring & JPA”

멀티 스레드 테스트에서 발생하는 @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

@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 어노테이션을 사용하여 꼭 필요한 파라미터만 받을 수 있도록 하고, 생성 시 검증 로직을 수행할 수 있도록 하는 것이 좋다.

BeanCreationException 예외로 알아보는 빈 생명주기

실행 환경: Java 17, Spring Boot 3.1.4

스프링 부트로 커맨드 라인 애플리케이션을 만들던 중, csv 관련 에러 테스트 중 예외 처리가 의도하지 않은 방향으로 흘러가는 것을 발견했다.

우선 아래는 애플리케이션을 실행하고 유지하는 CommandLineRunner 인터페이스를 구현한 CommandLineExecutor 클래스이며,
애플리케이션 실행 및 정책은 다음과 같이 설정하였다.

  • RuntimeException 발생 시: warning 로그를 남기고 실행 상태 유지
  • Exception 발생 시: error 로그를 남기고 실행 종료
@Slf4j
@Component
@RequiredArgsConstructor
public class CommandLineExecutor implements CommandLineRunner {
private final ConsoleIOHandler consoleIOHandler;
private final FunctionHandler functionHandler;
private boolean isRunning = true;
@Override
public void run(String... args) {
while (isRunning) {
progress();
}
}
private void progress() {
try {
consoleIOHandler.printMenuTitle(ConsoleConstants.VOUCHER_PROGRAM_START_MESSAGE);
consoleIOHandler.printEnumString(Function.class);
String command = consoleIOHandler.getInputWithPrint();
Function.fromString(command)
.ifPresentOrElse(
function -> function.execute(functionHandler),
() -> {
throw InputException.of(InputErrorMessage.INVALID_COMMAND);
});
} catch (RuntimeException e) {
log.warn(e.getMessage());
} catch (Exception e) {
isRunning = false;
log.error(Arrays.toString(e.getStackTrace()));
}
}
}

다음으로는 csv 파일을 읽고 쓰는 로직인 CsvFileHandler 클래스이며, 호출 시점 및 에러 처리는 아래와 같이 구현하였다.

// CsvCustomerRepository.java: @PostConstruct와 @PreDestroy를 통해 빈 생성 및 소멸될 때 CsvFileHandler 클래스의 파일 입출력 메서드 호출
@Profile("default")
@Repository
public class CsvCustomerRepository implements CustomerRepository {
private final Map<UUID, Customer> customerDatabase = new ConcurrentHashMap<>();
// ...
@PostConstruct
public void init() {
Function<String[], Customer> parser = line -> { /* ... */ };
List<Customer> customers = csvFileHandler.readListFromCsv(
parser,
CSV_LINE_TEMPLATE
); // CSV 파일 읽기
customers.forEach(customer -> customerDatabase.put(customer.getId(), customer));
}
@PreDestroy
public void destroy() {
List<Customer> customers = customerDatabase.values()
.stream()
.toList();
Function<Customer, String> serializer = customer -> { /* ... */ };
csvFileHandler.writeListToCsv(customers, serializer); // CSV 파일 쓰기
}
}
// CsvFileHandler.java: 파일 입출력 처리 로직, R/W 중 IOException이 발생하면 RuntimeException을 상속 받은 사용자 정의 예외로 변환하여 throw
public class CsvFileHandler {
private static final String CSV_DELIMITER = ",";
private final String filePath;
// ...
public <T> List<T> readListFromCsv(Function<String[], T> parser, String csvLineTemplate) {
List<T> itemList = new ArrayList<>();
try (BufferedReader bufferedReader = new BufferedReader(new FileReader(filePath))) {
while (true) {
String line = bufferedReader.readLine();
if (line == null) {
break;
}
String[] parts = line.split(CSV_DELIMITER);
itemList.add(parser.apply(parts));
}
} catch (IOException e) {
throw FileException.of(
FileErrorMessage.IO_EXCEPTION
); // IOException 발생 시 사용자 정의 예외로 변환하여 throw
}
return itemList;
}
public <T> void writeListToCsv(List<T> itemList, Function<T, String> serializer) {
try (BufferedWriter bufferedWriter = new BufferedWriter(new FileWriter(filePath))) {
for (T item : itemList) {
String csvLine = serializer.apply(item);
bufferedWriter.write(csvLine);
bufferedWriter.newLine();
}
} catch (IOException e) {
throw FileException.of(
FileErrorMessage.IO_EXCEPTION
); // IOException 발생 시 사용자 정의 예외로 변환하여 throw
}
}
}

우선 애플리케이션 실행 중에 파일 경로에 파일 명을 수정하여 존재하지 않는 파일을 읽도록 하여 IOException이 발생하도록 했다.
의도한 대로 사용자 정의 에러가 발생하고 CommandLineExecutor에서 예외를 처리하여 정의한 메시지가 warning 로그로 남은 뒤 애플리케이션이 계속 유지됐다.

2023-10-24 23:36:51.326 [main] WARN d.s.commandline.CommandLineExecutor -- An error occurred during file input/output operations.

이번에는 애플리케이션 시작 전 파일명을 잘못 입력하여 애플리케이션 초기화 중에 IOException이 발생하도록 했다.
이번에는 CommandLineExecutor에서 예외를 처리하지 못하고 애플리케이션이 바로 종료되었고, 아래 로그가 출력되었다.

2023-10-24 23:06:39.147 [main] WARN o.s.c.a.AnnotationConfigApplicationContext -- Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'commandLineExecutor' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/commandline/CommandLineExecutor.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'functionHandler' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/commandline/function/FunctionHandler.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'customerController' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/controller/CustomerController.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'customerService' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/service/CustomerService.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
2023-10-24 23:06:39.169 [main] ERROR o.s.boot.SpringApplication -- Application run failed
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'commandLineExecutor' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/commandline/CommandLineExecutor.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'functionHandler' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/commandline/function/FunctionHandler.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'customerController' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/controller/CustomerController.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'customerService' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/service/CustomerService.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
...
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'functionHandler' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/commandline/function/FunctionHandler.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'customerController' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/controller/CustomerController.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'customerService' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/service/CustomerService.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
...
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'customerController' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/controller/CustomerController.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'customerService' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/service/CustomerService.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
...
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'customerService' defined in file [/Users/hyoguoo/Repositories/hyoguoo/springboot-basic/out/production/classes/devcourse/springbootbasic/service/CustomerService.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
...
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'csvCustomerRepository': Invocation of init method failed
...
Caused by: springbootbasic.exception.FileException: An error occurred during file input/output operations.
...

로그를 살펴보면 직접 정의한 FileException은 가장 마지막 라인에 존재하고, 그 위엔 BeanCreationException가 존재하여 빈 생성 중 발생한 예외로 추측 할 수 있다.
빈 관련 에러라는 것을 확인했으니 빈 생명주기를 살펴보자.

  1. 스프링 컨테이너 생성
  2. 스프링 빈 생성
  3. 의존 관계 주입
  4. 초기화 콜백
  5. 사용(실제 애플리케이션(빈) 동작 단계)
  6. 소멸 전 콜백
  7. 스프링 종료

여기서 파일을 읽어오는 단계는 @PostConstruct는 4번 초기화 콜백에 해당하며, 빈이 생성되고, 빈의 의존관계 주입이 완료된 후 호출된다.
하지만 CommandLineExecutor가 동작하는 순간은 5번 사용 단계에 해당하기 때문에, 애초에 해당 에러를 처리하지 못하는 것이다.
그 흐름을 자세히 살펴보면 아래와 같다.

  1. 애플리케이션 시작 전 파일명 잘못 입력
  2. 빈 초기화 중 @PostConstruct 애노테이션을 통해 CsvCustomerRepositoryinit() 메서드 호출
  3. init() 메서드에서 CsvFileHandlerreadListFromCsv() 메서드 호출
  4. CsvFileHandler 내부에서 IOException 발생
  5. FileException으로 변환하여 throw
  6. @PostContruct 애노테이션에서 발생한 빈 초기화 중 발생한 예외이기 때문에 BeanCreationException으로 감싸져서 throw
  7. 애플리케이션 초기화 중 발생했기 때문에 CommandLineExecutor이 동작하기 전에 예외가 발생

사실 어찌보면 너무나 당연한 지식을 기반한 내용이지만, Spring의 여러 기능을 사용하게 되면서 생각하지 못한(의도하지 않은) 경로로 예외가 흘러가는 것을 확인할 수 있었다.
다시 한 번 빈 생명주기에 대해 공부할 수 있었고, 그 흐름을 이해하는 것이 중요하다는 것을 깨달았다.
만약 BeanCreationException이 발생하면, 빈이 생성되는 과정에서 문제가 있는 것이므로 빈 생명주기를 생각하면서 디버깅을 해보자.