SpringBoot - @Transactional 정리하기 - 2

2025. 4. 23. 00:22·SpringBoot
🔎 본 글을 읽기 전 해당 게시글을 먼저 읽고 오시는 것을 추천드립니다.
 

SpringBoot - @Transcational 정리하기

들어가며이번 게시글에서는 Transactional 어노테이션을 왜 사용하는지에 대해 정리해볼 것이다. 🤔왜 Transactional을 붙여야 할까?제일 처음 들었던 생각은 Transactional 어노테이션을 왜 붙여야 할까?

tentenball.tistory.com

 

Introduce

지난 게시글에서는 Transactional 어노테이션을 사용하는 이유에 대해서 살펴보았다. 프로젝트에서 발생했던 Transactional 이슈에 대해 알아보고 이에 관련된 Transactional들의 옵션에 대해서 알아볼 것이다.

 

ISSUE

현재 진행중인 프로젝트에는 장소를 예약하기 위한 결제 기능이 존재한다. 결제를 진행할 시 DB에 결제에 관련된 데이터가 생성되는데 어느날 결제를 1번 진행했는데 동일한 데이터가 2개가 생성되는 이슈를 발견하였다.

DB에 데이터가 중복 생성

결제 시스템이면 매우 중요한 시스템인데 왜 데이터가 두개가 생성되었을까...몹시 당황스러웠다.

굉장히 당황스러움이시여..

천천히 해당 부분의 코드를 살펴본 후 흐름을 다음과 같이 정리할 수 있었다.

@Transactional
public void 장소예약_결제_승인(String paymentId, String storeId) {
    // 1. 외부 결제 API를 통해 결제 정보 불러오기
	
    // 2. 결제 정보를 통해 여러 엔티티 조회 (DB 조회 발생)

    // 3. 조회한 엔티티를 바탕으로 예약 엔티티 생성
	
    // 4. 예약 완료 알림 전송 (DB 조회 발생)
	
}


@Transactional(propagation = Propagation.REQUIRES_NEW)
public void 예약_엔티티_생성(2.에서 조회한 엔티티) {
    // 예약 엔티티 생성 로직
}

사실 해당 이슈의 출발점은 알림 전송이 실패함에서 시작되었다. "장소예약_결제_승인" 메서드가 하나의 트랜잭션 단위이다 보니 1, 2, 3번의 과정이 성공해도 4번 과정인 알림 전송이 실패하게 되면 3번에 대한 쓰기 과정이 롤백된다. 이로 인해서 결제가 성공했음에도 예약에 대한 정보를 볼 수 없는 좋지 않은 상황이 발생했다...

 

이를 해결하기 위해서는 4번의 과정에서 에러가 발생하더라도 결제가 성공했다면 3번 과정에 대한 성공이 무조건적으로 보장되도록 수정이 필요했다. 이를 위해 3번의 메서드에 @Transactional과 propagation 옵션을 사용했다.

 

Transactional의 Propagation 옵션

위는 Transactional의 propagation 옵션에 대해 간단하게 표로 정리한 것이다.

 

Transactional의 기본 propagation 옵션은 REQUIRED이다. 여기에서 REQUIRES_NEW와 NESTED 옵션에 대해서만 간단하게 설명하겠다.

 

NESTED는 이미 진행중인 트랜잭션에 중첩(자식) 트랜잭션을 만드는 것으로, 독립적인 트랜잭션을 만드는 REQUIRES_NEW와 다르다. NESTED에 의한 중첩 트랜잭션은 부모 트랜잭션의 영향(커밋과 롤백)을 받지만, 중첩 트랜잭션이 외부에 영향을 주지는 않는다. 즉, 중첩 트랜잭션이 롤백 되어도 외부 트랜잭션은 커밋이 가능하지만 외부 트랜잭션이 롤백되면 중첩 트랜잭션은 함께 롤백되는 것이다. 하지만 REQUIRED_NEW는 외부 트랜잭션이 롤백되어도 새로운 트랜잭션에는 영향을 끼치지 않는다.

 

ISSUE 분석하기

propagation 옵션에 대해 알았으니, 위에서 작성했던 시나리오를 바탕으로 간단한 예제코드를 만들어 이슈에 대해 분석해보자.

@Transactional
public void test() {
    User user = userService.read(1L);

    postService.append(user);

    Post post = postService.readByUserId(user.getId())
        .orElseThrow(() -> new IllegalArgumentException("게시글 조회 실패"));
    log.info("postId : {}", post.getId());
}

// UserService
public User read(Long id) {
    return userRepository.findById(id)
        .orElseThrow(() -> new IllegalArgumentException("xxx"));
}

// PostService
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Post append(User user) {
    Post post = Post.builder().title("title").user(user).build();
    return postRepository.save(post);
}

public Optional<Post> readByUserId(Long userId) {
    return postRepository.findByUserId(userId);
}

코드로 살펴보았을 때 User를 찾고, Post를 생성하고, 생성한 Post를 userId를 통해 조회한다.

Post 엔티티가 아무것도 없다고 가정하였을 때 위의 코드는 정상적인 플로우로 작동해야 한다.

 

하지만 해당 메서드를 실행해보았다.

DB에는 생성된 Post가 올바르게 저장되었다. 하지만 아래의 결과가 나왔다.

user를 조회하는 select 쿼리, post를 생성하는 insert 쿼리, post를 조회하는 select 쿼리가 모두 존재함에도 Post를 조회하는데 실패한 이유가 뭘까?

 

우선 Propagation 옵션에 의해 postService.append에서는 독립적인 트랜잭션이 실행될 것이다. test() 메서드에서 실행되는 트랜잭션을 Tx1, postService.append에서 실행되는 트랜잭션을 Tx2로 두자.

 

Tx1이 시작된 시점에서 영속성 컨텍스트는 해당 시점의 스냅샷을 유지한다. Tx2도 동일할 것이다.

Tx1에서 User를 조회 후 Tx2에서 Post가 생성되고 커밋이 되지만, Tx1은 트랜잭션 시작 당시의 스냅샷을 사용하여 조회쿼리를 실행한다. 트랜잭션 시작 당시의 스냅샷은 Post가 존재하지 않을 것이고, 이에 Post 조회 쿼리를 실행하면 에러가 발생하는 것이다.

 

그렇게 Tx1에서는 에러가 발생하지만, Tx2는 독립적인 트랜잭션이기 때문에 커밋이 될 것이고, 결과적으로 로그에는 에러가 발생하지만 DB에는 Post가 생성되게 된다.

 

이러한 현상이 발생하는 데에는 격리수준과 연관이 있다. (Dirty Read를 방지하기 위함.)

격리수준을 해당 글에서 다루기에는 주제와 벗어나기 때문에 아래의 게시글을 참고하면 좋을 것 같다.

 

[DB] 트랜잭션과 동시성 제어

Introduce결제 시스템을 구축하면서 동시성 문제를 경험한 적이 있다. 여러 사용자가 동시에 결제를 시도할 때, 예상하지 못한 데이터 불일치와 정합성 문제가 발생했으며, 이를 해결하기 위해 다

woojjam.tistory.com

 

 

위의 생각이 맞는지 검증해보자. 현재 상태에서 test메서드를 한번 더 실행하게 되면 어떻게 될까?

정상적인 시나리오라면 Post가 생성되고 UserId가 1인 Post가 2개이므로 단일 데이터 조회를 실시하는 readByUserId메서드에서 에러가 발생할 것이다.

 

하지만 propagation 옵션에 의해 id가 3인 post가 조회되어 로그에 찍힐 것이고, id가 4인 post가 생성될 것이다.

아래 사진은 test 메서드를 한번 더 실행한 결과이다.

예상대로 id가 3인 post가 조회된 것을 볼 수 있다.

 

그럼 아까 글의 초반부에서 보았던 데이터가 2개 생성되는 현상은 왜 발생한 것일까?

 

현재 사용하는 외부 결제 API에서 웹훅을 통해 데이터를 받아오는데, 해당 웹훅을 사용하는 메서드에서 에러가 발생하면 재시도를 하게 된다.

@Transactional
public void 장소예약_결제_승인(String paymentId, String storeId) {
    // 1. 외부 결제 API를 통해 결제 정보 불러오기
	
    // 2. 결제 정보를 통해 여러 엔티티 조회 (DB 조회 발생)

    // 3. 조회한 엔티티를 바탕으로 예약 엔티티 생성
	
    // 4. 예약 완료 알림 전송 (방금 생성한 예약에 대한 DB 조회 발생)
	
}


@Transactional(propagation = Propagation.REQUIRES_NEW)
public void 예약_엔티티_생성(2.에서 조회한 엔티티) {
    // 예약 엔티티 생성 로직
}

그러면 장소예약_결제_승인 메서드의 1 → 2 → 3  → 4(에러 발생) 과정에서 예약 엔티티가 생성되고, 에러가 발생하였으므로 장소예약_결제_승인 메서드를 재실행 하게 되면 1 → 2 → 3(중복된 예약 엔티티 생성) → 4(에러가 발생한 과정에서 생성된 예약 엔티티를 인식하여 알림이 전송됨) 의 시나리오가 진행되게 되어 예약 엔티티가 2개 생성되는 것이었다.

 

 

문제 해결하기

그럼 해결책은 무엇일까?

public void test() {
    User user = userService.read(1L);

    postService.append(user);

    Post post = postService.readByUserId(user.getId())
        .orElseThrow(() -> new IllegalArgumentException("게시글 조회 실패"));
    log.info("postId : {}", post.getId());
}

// UserService
@Transactional(readOnly = true)
public User read(Long id) {
    return userRepository.findById(id)
        .orElseThrow(() -> new IllegalArgumentException("xxx"));
}

// PostService
@Transactional
public Post append(User user) {
    Post post = Post.builder().title("title").user(user).build();
    return postRepository.save(post);
}

@Transactional(readOnly = true)
public Optional<Post> readByUserId(Long userId) {
    return postRepository.findByUserId(userId);
}

트랜잭션의 단위를 위의 코드처럼 나누어보았다.

트랜잭션을 코드처럼 나누게 된다면 위의 그림과 같이 트랜잭션의 단위가 3개로 나누어질 것이고, 다른 트랜잭션 간의 성공/실패 여부에 영향을 받지 않게 될 것이다.

Tx2가 성공하면 Tx3이 시작할 때 Tx2가 종료된 이후의 시점의 스냅샷을 가지기 때문에 Post조회에 문제가 없을 것이다.

 

말로 하는것 보다 test메서드를 실행해보자.

DB의 Post 테이블

코드를 실행한 결과 select문과 insert문을 통해 post가 잘 생성되었고, select문을 통해 id가 6인 데이터를 잘 읽어오는 것을 확인할 수 있다.

그럼 append메서드는 성공하지만 readByUserId에서 에러를 발생시켜 보자.

 

readByUserId 메서드에서 에러를 발생시키기 위해 user_id와 매핑시킨 Comment 엔티티를 만들고 readByUserId를 수정했다.

public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "content", nullable = false)
    private String content;

    @Column(name = "likes", nullable = false)
    @ColumnDefault("0")
    private int likes;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;
 }
 
 // PostService
 @Transactional(readOnly = true)
 public Optional<Post> readByUserId(Long userId) {
    Comment comment = commentRepository.findByUserId(userId)
        .orElseThrow(()-> new IllegalArgumentException("댓글 조회 실패"));
    return postRepository.findByUserId(userId);
}

Post 테이블과 Comment 테이블에 데이터가 아무것도 없다고 가정하자.

test메서드에서 Post 엔티티가 생성되고 readByUserId를 실행하였을 때 userId가 1인 Comment는 존재하지 않아 에러가 발생할 것이다. 하지만 트랜잭션의 단위를 분리했기 때문에 Post 엔티티는 생성되었을 것이다.

test 메서드를 실행하여 확인해보자.

DB의 Post 테이블

댓글 조회에 실패하여 PostService의 readByUserId에서 실행한 트랜잭션이 실패했지만 PostService의 append메서드는 성공하였으므로 Post 엔티티가 생성되어 DB에 저장된 것을 확인할 수 있다.

 

 

마치며

프로젝트에서 발생한 이슈에 대해 분석하며 예제 코드를 통해 이유를 확인할 수 있었고, Transactional 어노테이션의 원리와 사용법에 대해 더 익숙해진 것 같다. 여러분들도 Transcational 어노테이션에 한발짝 더 가까워졌길 바라며 글을 마친다.

'SpringBoot' 카테고리의 다른 글

SpringBoot - @Transcational 정리하기  (1) 2025.04.08
스프링부트 스케줄링, 재시도 정책 설계하기 - 2  (0) 2025.03.26
스프링부트 스케줄링, 실패한다고 끝이 아니다.. -1  (1) 2025.02.26
'SpringBoot' 카테고리의 다른 글
  • SpringBoot - @Transcational 정리하기
  • 스프링부트 스케줄링, 재시도 정책 설계하기 - 2
  • 스프링부트 스케줄링, 실패한다고 끝이 아니다.. -1
tentenball
tentenball
tentenball 님의 블로그 입니다.
  • tentenball
    tentenball 님의 블로그
    tentenball
  • 전체
    오늘
    어제
    • 전체 (9)
      • DevOps (1)
      • SpringBoot (4)
      • WEB (1)
      • FCM (0)
      • AWS (2)
      • TroubleShooting (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    springboot
    트랜잭션
    transactional
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
tentenball
SpringBoot - @Transactional 정리하기 - 2
상단으로

티스토리툴바