@Transactional 어노테이션을 사용했는데 등록, 수정이 안돼요😥
위는 실제로 내가 사수에게 물었던 질문이다.
때는, 스프링도 JPA도 거의 모르고 그저 일을 쳐내기 급급했던 시절...
상사가 컨트롤러 작성할 때, 간단한 등록과 수정은 로직이 동일하니 같은 API로 만들고 서비스에서 분류하는 쪽으로 작업해보라고 하셨고, 그렇게 작업을 시작했다.
대충 간단하게 예시를 보이자면,
@PostMapping("/api/v1/products/save")
public ApiResponse<Long> saveProduct(Product request) {
return ApiResponse.ok(productService.saveProduct(request));
}
위 처럼 컨트롤러를 작성하고
service에서 request에 기본키인 id가 없으면 등록을, id가 있으면 수정을 하도록 분기를 시켰다.
😎그리고 의심없이 등록메소드와 수정메소드에 @Transactional을 달았다.
'아싸! 별거 아니네. 다했다~'라고 신나하면서 테스트를 했는데,,,,기능이 전혀 동작하지 않았다😱
나는 등록, 수정, 삭제와 같이 DB의 트랜잭션이 적용되어야 할 곳에 @Transactional을 달아주면 된다고 알고 있었다.
딱히 특별한 오류가 발생하지도 않아서 도저히 원인을 알 수 없었다.
혹시..하는 마음으로 @Transactional을 saveProduct에도 달아줬더니 잘 동작했다!!!! 😆
당시에 상사한테 이렇게 하니까 안돼서, 이래저래 해보니 되더라. 왜 그런거냐고 물어봤었는데 상사도 좀 보시다가 모르겠다고 하셨다.😑 일이 바쁘다는 핑계로 개인노션에 '나중에 알아보기' 라고 적어놨었다.
그렇게 잊혀지고 있다가 최근에 스프링공부를 하던 도중, 이때 생각이 나면서 이마를 탁! 치게 되었다.
지금 생각해보면 그분도 많이 모르셨구나...경력이 거의 8-9년차 이상이셨던 것 같은데...;;
이래서 사수를 잘 만나야 제대로 배울 수 있다고 하나보다.
'나는 절대 이런 사수가 되지 말아야지, 개념을 알고 개발해야지' 라는 다짐을 다시금 하게 된다.
🤦♀️ 대체 원인이 뭐야?!
원인을 분석하기에 앞서 스프링이 AOP를 구현하는 방법으로 프록시 패턴을 사용하는 것을 알아야한다.
간단히 설명하면 스프링은 @Transactional이 붙은 클래스나, @Transactional이 붙은 메소드가 있는 클래스에 대해 프록시 객체를 Bean으로 등록한다. 때문에 클라이언트에서 해당 클래스에 대한 요청이 오면, 실제 클래스를 호출하는 것이 아니라, 스프링 빈에 등록된 프록시 객체가 호출을 가로채 트랜잭션을 처리한다.
즉, @Transactional이 적용된 메소드 호출은 실제 빈을 직접 호출하는 것이 아닌 프록시 객체가 대신 호출된다. 이 과정에서 AOP관련 로직인 트랜잭션 관리를 수행하고, 그 후 실제 빈의 메소드를 호출한다.
그렇기 때문에 프록시객체를 거치지 않고 실제 객체를 직접 호출하게 되면 AOP가 적용되지 않고 트랜잭션도 적용되지 않는다고 한다. 이게 바로 원인이었다!!! 유레카!!
프록시는 외부에서의 호출에만 개입할 수 있고, 빈 내부에서의 호출에는 개입할 수 없다. 컨트롤러에서 등록/수정 분기를 위해 처음 호출 하는 것이 saveProduct메소드이다. 따라서 외부에서 호출 된 saveProduct메소드처리를 위해 프록시객체를 거치게 되는데, saveProduct메소드에는 @Transactional이 없어서 AOP 로직(트랜잭션 시작,커밋,롤백 등)이 적용되지 않는다.
좀 더 풀어서 설명하면,
컨트롤러에서 호출된 ProductService의 프록시객체에서 saveProduct메소드에 @Transactional 처리로직이 없기 때문에 프록시는 다음 동작을 위해 실제 빈, 실제 ProductService의 saveProduct메소드를 호출하였을 것이다.
그 후 saveProduct메소드로부터 분기처리되어 (@Transactional이 달려있는) createProduct/updateProduct 메소드까지 요청은 잘 도착했지만, 현재 동작은 프록시객체가 아닌 실제 빈에서의 동작이기 때문에 AOP로직이 적용되지 않았던 것이다.
🙋♀️해결은 어떻게 하지?!
🙋♀️그럼 saveProduct메소드에 @Transactional을 붙여서 해결하면 되나?!
결론적으로 말하자면 내가 해결했던 것처럼 @Transactional을 saveProduct에 달아도 된다.
이 경우 외부호출로 불리는 메소드가 saveProduct메소드이기 때문에 프록시객체가 호출 됐을 때, @Transactional으로 인해 트랜잭션이 시작되어 있는 상태가 된다.
그리고 saveProduct메소드에서 해당 트랜잭션이 끝나기 전에 내부 메소드인 createProduct/updateProduct 메소드를 호출하여 내부메소드의 트랜잭션을 참여시키기 때문이다.
(트랜잭션 전파 타입의 기본값이 REQUIRED이기 때문에, 진행중인 트랜잭션 내부에 새로운 트랜잭션이 있으면 기존 트랜잭션에 참여하게 된다.)
그렇다면 saveProduct/createProduct/updateProduct 메소드에 전부 @Transactional을 선언하거나, saveProduct에만 @Transactional을 선언하고 나머지는 선언하지 않아도 동작하는지 궁금해서 아래 코드들로 테스트 해보니 역시나 잘 동작 하였다.
다시 강조하면 이미 saveProduct메소드에서 트랜잭션이 시작되고, 이 트랜잭션이 끝나기 전에 다른 메소드를 호출하는 것이어서 위 두 코드 모두 동작하는 것이다.
위 케이스는 간단한 케이스였기에 따로 클래스를 분리하진 않았지만,
굳이 내부메소드에 @Transactional을 붙여야 한다면 내부메소드를 위한 별도의 클래스를 생성하여 프록시를 통해 접근할 수 있도록 변경해야 한다고 한다.
📌정리
- 프록시는 외부에서의 호출에만 개입할 수 있고, 빈 내부에서의 직접 호출에는 개입할 수 없다.
- 내부 메소드의 @Transactional은 프록시를 통해 처리되지 않기 때문에 AOP 로직이 작동하지 않는다.
- 개념을 아는 개발자가 되자!!
'스프링' 카테고리의 다른 글
@ManyToOne단방향에서 object references an unsaved transient instance 예외와 save의 flush 로직 (0) | 2024.03.21 |
---|