회원가입 API 시에 발생한 문제입니다.
회원가입 시 사용자의 이메일로 이메일 토큰이 있는 url을 보내 이메일 인증을 진행합니다.
이 때, 이메일 토큰 검증용도로 redis에 이메일 토큰을 미리 저장해둡니다.
@Override
@Transactional
public void join(User user) {
user.changeHashedPassword(passwordEncoder);
isEmailDuplicate(user.getEmail());
isNicknameDuplicate(user.getNickname());
userRepository.save(user);
redisStrategy.addEmailToken(user.getEmailCheckToken(), user.getId());
emailStrategy.sendUserJoinMessage(user.getEmailCheckToken(), user.getEmail()); // 이메일 전송이 실패할 경우에는?
위와 같은 회원가입 코드가 있으며 다음과 같은 로직이 일어납니다.
1. 사용자의 비밀번호를 hassed password 로 변경
2. 이메일과 닉네임 중복 여부 확인
3. 인증되지 않은 role을 가진 사용자를 DB 저장
4. 이메일 토큰을 redis 캐시에 저장
5. 이메일 전송
만약 이메일 전송이 실패할 경우에는 사용되지 않는데도 불구하고 캐시에는 이메일 토큰이 쌓이게 됩니다. 그래서 이메일 전송이 실패할 경우 캐시에 쌓인 이메일 토큰을 다시 제거해줘야 합니다.
스프링에서는 @Transactional 어노테이션을 사용하여 AOP 방식으로 트랜잭션을 롤백할 수 있습니다. 만약 @Transactional을 적용하여 어떤 메서드를 실행한다면 DB 변경 관련 로직은 롤백이 됩니다.
위의 회원가입 코드에서는 DB에 사용자를 저장하는 코드가 롤백이 되겠죠.
하지만, 레디스와 같은 다른 스토리지에 어떠한 데이터를 추가하거나 삭제할 경우에는 이를 수동으로 롤백시켜줘야 합니다.
스프링에서는 Transaction과 관련하여 트랜잭션 동기화라는 특징이 있습니다.
트랜잭션 동기화란, 트랜잭션을 시작하기 위한 Connection 객체를 특별한 동기화 저장소에 보관해두고 필요할 때 꺼내쓸 수 있도록 하는 기술입니다.
TransactionSynchronizeManager라는 것을 사용하며, 이 기술은 커넥션을 가져오는 것 외에도 트랜잭션을 동기화나 트랜잭션을 관리할 수 있는 기술을 제공합니다.
또한, 트랜잭션 롤백 후 처리를 하기 위해서는 이와 관련해서 TransactionSynchronization 클래스를 이용할 수 있습니다. 이 클래스에서는 트랜잭션 커밋 후(afterCommit) , 완료 후(afterCompletion) 와 같은 메소드를 이용하여 트랜잭션 후에 로직들을 수행할 수 있습니다.
위 기술들을 이용하여 레디스에 저장한 이메일 토큰을 제거해주는 트랜잭션 롤백 후처리를 적용할 수 있습니다.
//UsereServiceImpl.java
@Override
@Transactional
public void join(User user) {
...
userTransactionService.removeCacheAfterRollback(user.getEmailCheckToken());
}
//UserTransactionServiceImpl.java
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void removeCacheAfterRollback(String emailToken) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status == STATUS_ROLLED_BACK) {
redisStrategy.deleteValue(emailToken);
}
}
});
}
회원가입이 롤백이 되었을 때 위와 같이 afterCompletion 메서드를 통해 트랜잭션 상태(status) 가 롤백인 경우 캐시에 있는 토큰을 제거합니다.
또한, TransactionSynchronizationManager.registerSynchronization메서드를 통해 새로운 트랜잭션 동기화를 등록해줍니다. 이를 등록하지 않을경우, 롤백 후처리 메서드가 제대로 작동하지 않을 수 있기 때문입니다.
TransactionSynchronization 문서에는 after completion을 사용할 시 전파 속성을 required_new로 설정하여 트랜잭션을 새로 만들라고 지시합니다.
NOTE: The transaction will have been committed or rolled back already, but the transactional resources might still be active and accessible. As a consequence, any data access code triggered at this point will still "participate" in the original transaction, allowing to perform some cleanup (with no commit following anymore!), unless it explicitly declares that it needs to run in a separate transaction. Hence: Use PROPAGATION_REQUIRES_NEW for any transactional operation that is called from here.
그 이유는 after completion 메서드가 호출될 때에는 아직까지 기존의 트랜잭션 관련 리소스가 정리되어 있지 않습니다. 그래서 기존 트랜잭션에 그대로 참여하게 되는데 이후에 바로 리소스를 정리해버려 트랜잭션 후처리가 작동하지 않을 수 있기 때문입니다. 따라서, 기존 트랜잭션과 별도의 트랜잭션을 생성해주어 기존의 리소스가 아닌 새로운 리소스로 후처리가 작동하도록 해줘야 합니다.
UserService 와 UserTransactionService를 따로 분리한 이유는 다음과 같습니다.
@Transactional은 프록시 방식의 AOP를 사용합니다. 따라서 userServiceImpl의 join이 @Transactional이 선언되어있으면 UserServiceImpl의 프록시 객체가 타깃 객체인 UserServiceImpl의 join 메서드를 대신 호출하며 트랜잭션을 설정합니다.
만약 타깃 객체에서 타깃 객체의 다른 메서드를 직접 호출하는 경우에는 프록시를 거치지 않고 직접 타깃의 메소드를 호출하기 때문에 트랜잭션이 적용되지 않습니다.
join 메서드와 removeCacheAfterRollback 이라는 메서드가 같은 클래스에 있게 되면,
트랜잭션 required_new로 설정한 전파 속성은 무시되고,
단순히 기존 join 메서드의 트랜잭션에 참여하게 될 뿐입니다.
(기존 join 메서드의 트랜잭션 전파 속성이 requied이기때문에 기존 트랜잭션에 참여합니다.)
따라서 UserService와 UserTransactionService를 분리하여 새로운 트랜잭션이 만들어지는 것이 가능하도록 했습니다.
위와 같은 리팩토링을 거쳐 트랜잭션 후처리가 가능하게 되었습니다.
처음에는 메일 전송이 실패하면 회원가입도 실패되어야 된다는 생각을 가졌습니다.
그런데 대부분의 서비스에서 이메일 전송이 실패되었다고 회원가입도 실패했다고 해야할까요? 실제로는 그렇지 않습니다. 이메일 전송이 실패했다고 회원가입은 롤백해선 안됩니다.
이메일 전송이 실패하면 다시 전송 할 수 있도록 이메일 전송에 대한 가용성을 획득해야 합니다.
토비의 스프링 5장, 6장