회사에서 운영중인 프로젝트에서 저장되지 않도록 저장한 로직에서 저장이 진행되어 추후 요청에 오류가 발생하는 모습이 확인이 되었다. 살펴보니 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 기준 어떤 예외가 감지되었는지 감지되지 않았는지를 잘 판단하면 앞으로 문제가 발생하진 않을 것 같다!
'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 |