부제: 동일한 bean에서는 @Transactional 적용이 되지 않는다.
😎 문제 상황
줍줍은 자꾸만 사라지는 슬랙 메시지를 백업해주는 서비스입니다.
최근 줍줍에서는 신규 이용자들의 유입을 위해 '워크스페이스 등록'이라는 기능을 추가했습니다. 해당 기능의 확장으로 기존에는 특정 워크스페이스에 초대된 사용자들만 서비스를 이용할 수 있었지만 이제는 누구든지 워크스페이스를 등록하고 이용할 수 있게 되었습니다. 해당 기능을 추가하며 팀 내에서 워크스페이스 등록하면 바로 로그인이 되게 만들 것이냐, 로그인 과정을 따로 거치게 만들 것이냐에 대한 논의가 있었는데요.
기존에는 첫 회원가입 시 위 사진과 같은 절차로 로그인을 진행하고 있었습니다. 사실상 사용자는 같은 슬랙 인증을 2번 진행하고 있는 상황입니다. 로그인 사용자 경험 측면을 고려하여 워크스페이스 등록을 하면 바로 로그인이 될 수 있도록 만들기로 하였습니다.
😏 변경된 로직
기존의 WorkspaceService에서는 아래와 같은 로직만 존재했습니다.
@Transactional
public void register(final String code) {
// Slack에서 workspace 정보 호출
WorkspaceInfoDto workspaceInfoDto = externalClient.callWorkspaceInfo(code);
// 이미 등록한 적 있는 workspace인지 검증
validateExistWorkspace(workspaceInfoDto.getWorkspaceSlackId());
// workspace, channel 등 워크스페이스와 관련된 정보 DB에 저장
initWorkspaceInfos(workspaceInfoDto);
}
바로 로그인을 하기 위해 아래와 같이 로그인 로직이 추가되었습니다.
@Transactional
public LoginResponse register(final String code) {
// 워크스페이스 등록 로직
// ...
return login(memberInfoDto);
}
private LoginResponse login(final MemberInfoDto memberInfoDto) {
// 로그인 로직
}
여기서 한가지 고민이 생겼습니다. 저희 팀원인 봄이 로그인 로직이 실패하더라도 워크스페이스 등록 로직에서 저장한 데이터는 커밋되어야 하지 않은가? 라는 제안을 했었고 합당한 이야기라고 생각했습니다. 어떤 식으로 두 로직을 분리할까 고민하다 얼마 전 트랜잭션 전파를 배워서 해당 기능을 이용해보려고 했습니다.
@Transactional
public LoginResponse register(final String code) {
// 워크스페이스 등록 로직
// ...
return login(memberInfoDto);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
LoginResponse login(final MemberInfoDto memberInfoDto) {
// 로그인 로직
}
결론부터 말하면 위 방법은 실패했습니다. REQUIRES_NEW는 기존에 활성화된 트랜잭션이 있으면 해당 트랜잭션을 일시 중지하고 새로운 트랜잭션을 생성합니다. 'TransactionSynchronizationManager'을 통해 트랜잭션 정보를 가져와보니 login에서 사용하는 트랜잭션과 register에서 사용하는 트랜잭션과 동일했습니다.
🙄 왜 새로운 트랜잭션 생성이 안됐을까?
몇 가지 테스트를 더 해보았으나 한 클래스 안에서 @Transactional 어노테이션을 단 다른 메서드를 호출해도 계속 같은 트랜잭션을 사용했습니다.
In proxy mode (which is the default), only external method calls coming in through the proxy are intercepted. This means that self-invocation (in effect, a method within the target object calling another method of the target object) does not lead to an actual transaction at runtime even if the invoked method is marked with @Transactional. Also, the proxy must be fully initialized to provide the expected behavior, so you should not rely on this feature in your initialization code - for example, in a @PostConstruct method. ㅡ (스프링 공식 문서)
Spring은 메서드 또는 클래스에 @Transactional이 달린 클래스들에 대한 proxy를 생성합니다. (AOP와 @Transactional의 연관성을 모른다면 이 링크를 참고해주시길 바랍니다.) 그리고 트랜잭션 적용을 위해 프록시를 통해 호출하는 메서드만 인터셉트합니다. 동일한 bean에 있는 메서드라면 프록시를 통해 호출하는 것이 아닙니다. 내부 메서드를 호출한다는 것은 this.메서드()를 호출한 것과 같은 의미입니다. this는 프록시가 아닌 원본 객체, 타깃을 의미하는 것이므로 프록시가 적용되지 않습니다.
😄 해결 방법
해결 방법은 정말정말 간단합니다. Service를 분리하면 됩니다. 로그인 하는 로직을 AuthService로 옮겼습니다. 그리고 WorkspaceController에서 해당 로직을 호출하는 방식으로 변경하였습니다.
@GetMapping("/slack-workspace")
public LoginResponse registerWorkspace(@RequestParam @NotEmpty final String code) {
MemberInfoDto memberInfoDto = workspaceService.registerWorkspace(code);
return authService.login(memberInfoDto);
}
WorkspaceService에서 AuthService를 호출하는 방법도 있지만 해당 방법은 사용하지 않았습니다. 각 로직을 다른 트랜잭션에서 동작하게 하려면 AuthService의 login 메서드에 트랜잭션 전파 레벨을 REQUIRES_NEW로 설정해야 하는데요. AuthService의 login에서 exception이 발생해 롤백이 발생되는 상황을 가정해보겠습니다. WorkspaceService의 register에서 저장한 데이터들은 그대로 보존되기를 기대하지만 실제로는 롤백이 일어납니다. 별도의 로직을 추가하면 롤백이 일어나지 않도록 만들 수 있지만 Controller에서 따로 호출하는 것이 훨씬 깔끔해 해당 방법을 선택하지 않았습니다.
롤백이 되는 이유는 로직의 실행 순서를 생각해보시면 쉽게 이해되실 것 같습니다. 아래 이미지를 참고해주세요 :)
- cheese10yun님의 블로그
- stack overflow
- 우테코 크루 이프, 열음, 라쿤 도움 감사합니다!!
'Develop > Spring+JPA' 카테고리의 다른 글
[Spring/AOP] JDK Dynamic Proxy vs CGLIB Proxy (0) | 2022.11.23 |
---|---|
[JPA] @Query와 @Transactional을 꼭 같이 써야하나요?🤔 (3) | 2022.11.17 |
[Spring Boot] 기본 Logging Framework는 진짜 'Logback'일까? (0) | 2022.10.13 |
[Spring] Local Transaction vs Global Transaction (8) | 2022.10.09 |
[Spring] 내 테스트에만 stub 적용하기 😈 (2) | 2022.10.03 |