[Spring] @Transactional 내에서 Exception 처리 범위에 대하여

2025. 5. 30. 00:09·Spring/Spring 기본
728x90
반응형

회사에서 운영중인 프로젝트에서 저장되지 않도록 저장한 로직에서 저장이 진행되어 추후 요청에 오류가 발생하는 모습이 확인이 되었다. 살펴보니 Checked Exception 을 누수한 상황에서 Entity 정보가 저장이 되지 않을 것을 기대해서 발생한 오류였다.

 

 

체크 예외도 Transactional 범위 안에서 발생시 당연히 롤백할 것이라 생각한 내 잘못이였다. 검색을 통해 알아보니 @Transactional 범위와 Exception 종류 및 처리 방식에 대한 DB 반영 여부는 Case 별로 다양하기 때문에 확실히 이해해두고, 기억이 안날때마다 봐야할 필요성을 느꼈다.

 

 

  Case A Case B Case C
1번 체크 예외 누수 언체크 예외 누수 언체크 예외 잡기
2번 (체크 예외) 자식 예외 누수, 부모 누수 자식 예외 누수, 부모 잡음  
3번 (언체크 예외) 자식 예외 누수, 부모 누수 자식 예외 누수, 부모 잡음 자식 예외 잡음
4번 (새 Tx, 체크 예외) 2번과 동일 2번과 동일  
5번 (새 Tx, 부모 언체크 예외) 자식 param 수정 자식 FK 형성 독립 관계

 

 

위와 같이 상황을 분기하여 테스트를 설계하였다. 당장 어떤 설계인지 몰라도 내용을 보면 이해할 수 있다. 우선 등장하는 Entity 객체들은 다음과 같다. Childcake 는 5번에서만 등장한다. 모든 상황은 정확한 test 를 위해 직접 API 요청을 보내며 test 진행하였다. 

 

 

@Entity
public class Mooncake {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    public Mooncake(){
        this.name = "mooncake";
    }
}


@Entity
public class Childcake {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "mooncake_id", nullable = true)
    private Mooncake mooncake;
    
    private String name;
    
    public Childcake(Mooncake mooncake){
        this.mooncake = mooncake;
        this.name = "childcake";
    }
    
    public Childcake(){
        this.name = "No Parent childcake";
    }
}

 

 

 

1번. 기본 체크 예외와 언체크 예외 동작성 차이

 

 

@Transactional
public void test1_caseA() throws SomeCheckedException { // 체크 누수
    Mooncake mc = new Mooncake();
    mRepo.save(mc);
    throw new SomeCheckedException();
}

@Transactional
public void test1_caseB() { // 언체크 누수
    Mooncake mc = new Mooncake();
    mRepo.save(mc);
    throw new SomeUncheckedException();
}

@Transactional
public void test1_caseC() { // 언체크 잡음
    Mooncake mc = new Mooncake();
    mRepo.save(mc);
    try {
        throw new SomeUncheckedException();
    } catch (SomeUncheckedException e) {
        System.out.println("caught unchecked exception");
    }
}
--------------
Table : Mooncake
1        mooncake
3        mooncake

 

 

결과를 보면 알 수 있듯이, CheckedException 인 case A는 저장이 되었고, UncheckedException 인 case B는 저장이 되지 않았다. Case C는 Uncheck 예외를 잡아줬으므로, Transactional 입장에선 예외가 발생하지 않았으므로 저장을 진행해준 것을 알 수있다. 체크 예외와 언체크 예외의 큰 차이점이라 할 수 있고, 프로젝트에선 이 부분에서 미스가 났었다. 

 

 

추가 Point

1 - PK 생성방식에 따라 과정에 차이가 있지만, 결론적으로는 PK를 건너뛰게 된다. PK에 빈 값이 존재할 수 있는 경우 중 하나로 볼 수 있다.
2 - 체크예외는 예상할 수밖에 없기 때문에 인지했다는 가정하에 커밋을 진행하게 되고, 언체크 예외는 예상하지 못할 수 있기 때문에 커밋을 진행하지 않는게 기본 동작이라고 예측된다고 한다 (배민 블로그)

 

 

 

2번. 자식 함수에서 발생한 체크 예외에 대한 상황

 

 

@Transactional
public void test2_caseA() throws SomeCheckedException { // 자식 체크 예외 누수, 부모에서 누수
    Mooncake mc = new Mooncake();
    mRepo.save(mc);
    child.child_leak_checked(mc);
    // 이후로 바뀌어도 적용되지 않음
    mc.setName("parent changed name!");
}

@Transactional
public void test2_caseB() { // 자식 체크 예외 누수, 부모에서 잡음
    Mooncake mc = new Mooncake();
    mRepo.save(mc);
    try{
        child.child_leak_checked(frog); 
        mc.setName("parent changed name 2!"); // 여기서 바꾼건 바뀐대로 저장되지 않는다
    } catch (SomeCheckedException e) { 
        mc.setName("parent changed name!"); // 여기서 바꾼건 바뀐대로 저장된다
        System.out.println("parent caught checked exception!");
    }
}

-- Child Class
public void child_leak_checked(Mooncake mc) throws SomeCheckedException {
    mc.setName("Child Mooncake");
    throw new SomeCheckedException();
}

----------------
Table : Mooncake
1        Child Mooncake
3        parent changed name!

 

 

1번에서 알아본 바와 같이, Checked Exception 은 누수되어도 영속성 컨텍스트에 있는 객체에 대한 커밋은 진행시킨다. 잡지 않는다면 Service 단 이후로 예외가 누수되어 처리될 것이고, catch 를 잡는다면 그냥 잡을 뿐이다. 당연히 예외가 발생한 함수 이후의 코드는 호출되지 않기때문에, "changed name 2" 로 변경되지 않는다. 참고로, Child 에서 Checked 예외를 잡는 경우는 더 결과 예측이 쉽기 때문에 따로 진행하지 않았다.

 

 

 

3번. 자식 함수에서 발생한 언체크 예외에 대한 상황

 

 

@Transactional
public void test3_caseA() {  // 자식 언체크 누수, 부모 언체크 누수
    Mooncake mc = new Mooncake();
    mRepo.save(mc);
    child.child_leak_unchecked(mc);
}

@Transactional
public void test3_caseB() {  // 자식 언체크 누수, 부모 잡음
    Mooncake mc = new Mooncake();
    mRepo.save(mc);
    try {
        child.child_leak_unchecked(mc);
    } catch (SomeUncheckedException exception) {
        mc.setName("parent changed name!");
        System.out.println("parent caught unchecked exception!");
    }
}

@Transactional
public void test3_caseC() {  // 자식 언체크 잡음
    Mooncake mc = new Mooncake();
    mRepo.save(mc);
    child.child_catch_unchecked(mc);
}

-- Child Class
public void child_leak_unchecked(Mooncake mc) {
    mc.setName("Child Mooncake");
    throw new SomeUncheckedException();
}

public void child_catch_unchecked(Mooncake mc) {
    mc.setName("Child Mooncake");
    try {
        throw new SomeUncheckedException();
    } catch (SomeUncheckedException e) { // 여기서 이름을 바꿀 수 있다 
        mc.setName("Child caught this mc unchk exc");
        System.out.println("child caught unchecked exception!");
    }
}

----------------
Table : Mooncake
2        parent changed name!
3        Child caught this mc unchk exc

 

 

CaseA 는 Uncheck 가 Transactional 범위 밖으로 누수되었기 때문에, Rollback 을 진행하게 된다. 1번 테스트에서 알아봤듯이 이 때도 PK는 사용되어 하나를 건너 뛰게 된다. Case B는 언체크 예외가 Transactional 범위 밖으로 누수되지 않았기 때문에, 롤백이 진행되지 않는다. 또한, catch 구문에서 setName 을 통해 바꾼 이름 역시 반영이 되는걸 알 수 있다. Case C는 사실 Parent 입장에서는 아무일도 일어나지 않은 것이기 때문에, 정상 저장이 된다. 


위와 같이 체크 및 언체크 상황 여부와 try/catch 상황 여부의 차이로 인해 DB 반영에 차이가 있음을 알 수 있다. 깔끔하게 구분하는 방법은 Transactional 입장에서 Exception 을 감지했는가, 어떤 Exception 을 감지했는가? 만을 생각한다면 조금 더 깔끔하게 구분할 수 있다. 하지만, 이 경우는 Transactional 이 Child 의 범위로 전파되는 기본 속성 내일 경우였다. 만약, Child 가 새로운 Transactional 을 가져가는 REQUIRES_NEW 가 적용되어 있을 경우에는 어떻게 다를지 살펴보자.

 

 

 

4번. 자식 함수에서 REQUIRED_NEW Transaction, 2번 Test 와 동일

 

 

... Test 로직 자체는 2번 Test 와 동일

-- Child Class
@Transactional(Transactional.TxType.REQUIRES_NEW)
public void child_leak_checked(Mooncake mc) throws SomeCheckedException {
    mc.setName("Child Mooncake");
    throw new SomeCheckedException();
}

----------------
Table : Mooncake
1        Child Mooncake
3        parent changed name!

 

 

사실 지금까지 살펴본 것처럼 Checked 는 기본적으로 Tx를 롤백하지 않기 때문에, 결과가 동일할 것임이 어느정도 예상할 수 있다. A Case 인 경우 체크 예외가 누수된채로 자식 Tx가 종료되고 부모 Tx도 누수된채로 종료될 뿐 결과는 차이가 없다. B Case 도 마찬가지이다. 체크 예외는 롤백 옵션이 아니기 때문에 차이를 만들 필요가 없기도 하다.

 

 

 

5번. 자식 함수에서 REQUIRED_NEW Transaction, 부모에서 언체크 예외 발생

 


체크 예외야 그렇다 쳐도, 언체크 예외는 중요한 차이를 만들 수 있다. 하지만 3번에서 한 예제들은 자식에서 예외를 발생시키는 방식으로 test를 해서 새로운 Tx 옵션을 넣어도 DB상 결과 차이가 없다 (생각해보면 알 수 있다). 따라서, 부모에서 예외가 발생할 수 있는 상황을 만들어 보았다.

 

 

@Transactional
public void test5_caseA() { // 자식 New Tx, 넘겨준 객체에 대해 변경, 부모에서 언체크 예외
    Mooncake mc = new Mooncake();
    mRepo.save(mc);
    child.child_change_name(mc);
    throw new SomeUncheckedException();
}

@Transactional
public void test5_caseB() { // 자식 New Tx, 넘겨준 객체에 대해 연관 객체 형성, 부모에서 언체크 예외
    Mooncake mc = new Mooncake();
    mRepo.save(mc);
    child.child_creates_related(frog);
    throw new SomeUncheckedException();
}

@Transactional
public void test5_caseA() { // 자식 New Tx, 각자 객체 형성, 부모에서 언체크 예외
    Mooncake mc = new Mooncake();
    mRepo.save(mc);
    child.child_creates_non_related();
    throw new SomeUncheckedException();
}

-- Child Class
@Transactional(Transactional.TxType.REQUIRES_NEW)
public void child_change_name(Mooncake mc) {
    mc.setName("Child Mooncake");
}

@Transactional(Transactional.TxType.REQUIRES_NEW)
public void child_creates_related(Mooncake mc) {
    Childcake cc = new Childcake(mc);
    cRepo.save(cc);
}

@Transactional(Transactional.TxType.REQUIRES_NEW)
public void child_creates_non_related() {
    Childcake cc = new Childcake();
    cRepo.save(cc);
}

----------------
Table : Mooncake
빈 테이블

Table : Childcake
2          No Parent childcake

 

 

A Case 는 Child 에서 A가 생성한 속성을 바꾼 이후 flush 를 했더라도, Parent 에서 잡고 있는 영컨이 롤백되기 때문에, 최종적으로는 저장되지 않는다. B Case 는 재밌는 일이 일어나는 것을 알 수 있는데, 데드락이 발생한다. 이 경우는 예외 상관 없이 데드락이 발생하고, 부모객체와 자식 객체 아무것도 최종적으로 저장되지 않은채 모두 롤백된다. 이는 FK 제약조건 때문이며, Parent row 를 한 Tx에서 추가하기 위해 대기 중 (자식 함수 종료 대기 중) 인데 다른 Tx에서 FK 제약조건으로 참조할 때 자식 Tx에선 해당 칼럼에 락을 얻기 위해 대기하기 때문에 발생한다. (완료되지 않은 Tx를 다른 Tx가 참조하려 함, ACID 위배)

 


C Case 는 REQUIRES_NEW 및 언체크 예외를 사용하기 적합한 Case 이다. 부모와 자식이 연관되어 호출하지만, 부모에서 롤백이 발생해도 자식에는 영향을 주지 않는 Case 이다. 자식은 완전히 독립적으로 Tx 를 종료하기 때문이다. 결과적으로 부모의 Mooncake 는 롤백되었지만, 자식의 자체 Transaction 안에서는 Childcake 를 성공적으로 커밋하였다. 

 

 

 

정리하며

 

 

편리한 라이브러리 사용에는 항상 대가가 따른다

 

 

Transactional 입장에서 예외가 감지 되었는지, 감지 되었다면 어떤 종류의 예외인지를 파악하면서 로직을 짜야한다는 것을 알 수 있었다. 이 상황을 인지한다면 noRollbackFor 와 rollbackFor 옵션을 필요에 따라서 사용하며 더 완성도 있는 예외를 설계할 수 있을 것이다.

 

 

또한 REQUIRES_NEW 를 쓸 때는 굉장히 신중해야 할 것 같다. 반드시 필요한 경우에만 독립성을 보장해서 쓴다던가.. 해야할 것 같다. 그 외에는 최종 Transactional 기준 어떤 예외가 감지되었는지 감지되지 않았는지를 잘 판단하면 앞으로 문제가 발생하진 않을 것 같다!

 

728x90
반응형

'Spring > Spring 기본' 카테고리의 다른 글

[Spring과 DB] 5-2 Spring 에서의 예외 추상화  (1) 2023.08.16
[Spring과 DB] 5-1 Spring 에서의 예외처리 지원  (0) 2023.08.15
[Spring 기본] Bean Scope  (0) 2022.10.19
[Spring 기본] Bean 생명주기 콜백  (0) 2022.10.18
[Spring 기본] 의존 관계 주입 전략 (DI Strategy)  (0) 2022.10.17
'Spring/Spring 기본' 카테고리의 다른 글
  • [Spring과 DB] 5-2 Spring 에서의 예외 추상화
  • [Spring과 DB] 5-1 Spring 에서의 예외처리 지원
  • [Spring 기본] Bean Scope
  • [Spring 기본] Bean 생명주기 콜백
문케이크
문케이크
    반응형
  • 문케이크
    누구나 개발할 수 있다
    문케이크
  • 전체
    오늘
    어제
    • 전체 보기 (122)
      • CS 이론 (13)
        • 운영체제 (8)
        • 네트워크 (2)
        • 알고리즘 (0)
        • Storage (3)
      • Spring (26)
        • Spring 기본 (12)
        • Spring 심화 (0)
        • JPA (11)
        • Spring Security (3)
      • 리액티브 (0)
        • RxJava (0)
      • SW 설계 (14)
        • OOP (0)
        • UML (3)
        • OOAD (0)
        • Design Pattern (11)
      • Java (8)
      • 웹 운영 (17)
        • AWS (15)
        • 운영 구축 (2)
      • Testing (3)
        • Unit (3)
      • Extra (3)
        • API 적용 (1)
      • 인프라 기술 (5)
        • Kubernetes (2)
        • Elasticsearch (3)
      • Logging (7)
        • Spring (5)
        • 인프라 (2)
      • 일상 (2)
        • 음식점 리뷰 (2)
        • Extra (0)
  • 블로그 메뉴

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

  • 공지사항

    • 문케이크의 블로그
  • 인기 글

  • 태그

    spring boot
    OOAD
    Spring
    Setter
    lazy loading
    Design Pattern
    n+1
    spring container
    Java
    runtime exception
    Configuration
    mockito
    객체지향
    decorator
    analyzer
    단위테스트
    SRP
    GoF
    junit
    김영한
    JPA
    k8s
    Composite
    디자인 패턴
    BEAN
    composition
    di
    OOP
    elasticsearch
    lombok
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
문케이크
[Spring] @Transactional 내에서 Exception 처리 범위에 대하여
상단으로

티스토리툴바