배경
[Java Spring] 초대 링크를 통한 가입 로직 트러블슈팅 (2) : 예외 발생 시 데이터 롤백을 위해 Redis 트
배경제가 진행했던 프로젝트에서는 초대 링크를 통해 관리하는 펫 그룹에 다른 유저를 가입시킬 수 있는 기능이 있으며, 해당 기능은 다음과 같은 요구 사항을 가지고 있습니다.초대 링크는 uuid
seondays.tistory.com
(위 포스팅에서 이어집니다)
제가 진행했던 프로젝트에서는 초대 링크를 통해 관리하는 펫 그룹에 다른 유저를 가입시킬 수 있는 기능이 있으며, 해당 기능은 다음과 같은 요구 사항을 가지고 있습니다.
- 초대 링크는 uuid 값을 이용하여 생성되고 해당 uuid 값은 Redis에 저장된다.
- 초대 링크 값을 통해 가입 요청이 들어오면, MySQL에 저장된 유저, 펫 정보와 Redis에 링크 uuid 값이 있는지를 확인하고 가입을 진행한다.
- 초대 링크는 1회용이다. 즉 생성된 초대 링크를 누군가 사용 완료한다면 다른 사람이 사용할 수 없게 Redis에서 해당 uuid 값이 삭제되어야 한다.
이를 위해 하나의 메서드에서 여러 건의 JPA 작업과 Redis 작업을 함께 진행하고 있으며, 해당 작업들이 하나의 트랜잭션 안에서 진행되도록 `@Transactional`을 적용해 두었습니다.
@Transactional
public void register(String uuid, Long userId) {
// 타겟 유저 조회
Users user = userRepository.findById(userId)
.orElseThrow(UserInformationNotFoundException::new);
// Redis GETDEL로 초대링크 값 조회와 동시에 삭제
// ‼️ 트랜잭션 설정 시 여기에서 값을 조회한 값은 null이 된다
StoredInvitationInformation invitation = invitationRepository.getAndDelete(uuid).orElseThrow(
InvitationLinkExpiredException::new);
// 타겟 펫 조회
Pet pet = petRepository.findById(invitation.petId())
.orElseThrow(PetNotFoundException::new);
// 가입 유효성 검사
validateRegister(user.getId(), pet.getId());
// 유저-펫 테이블에 저장하여 펫 그룹에 유저 가입 완료
UserPet userPet = UserPet.builder()
.user(user)
.pet(pet)
.role(UserPetRole.GUEST).build();
userPetRepository.save(userPet);
}
문제 상황
위 메서드에서 현재 해결해야 하는 문제는 다음과 같습니다.
- `@Transactional`로 인해 메서드 중간에 예외가 발생하는 경우 현재 트랜잭션이 롤백될 것이지만, 이때 Redis에 저장된 값을 삭제하는 작업인 `getAndDelete()`가 이미 진행된 상태라면 이 삭제 작업은 롤백되지 않는다.
- 데이터의 일관성과 메서드 로직의 원자성을 준수하기 위해, 예외 발생 시 삭제된 Redis 값도 롤백되도록 변경하고 싶다.
앞선 포스팅에서 문제 해결을 위해 Redis 트랜잭션을 활성화도록 `redisTemplate.setEnableTransactionSupport(true);` 설정을 추가하였는데요.
하지만 이로 인해 Redis 작업이 실시간이 아니라 트랜잭션 커밋 이후에 일괄 실행되도록 변경되었고, 메서드 중간에서 값을 조회하는 경우 조회 작업 역시 지연되어 필요한 값이 아닌 null을 가져와버리게 되는 문제가 발생했습니다.
해결 방법 아이디어 구상
기존 코드는 Redis 트랜잭션을 기존 스프링 트랜잭션에 참여시킴으로써, `getAndDelete()` 메서드를 통해 명령어 `GETDEL`이 실행되더라도 Redis의 값을 즉시 삭제하지 않고 스프링 트랜잭션이 정상적으로 커밋된 이후에 삭제되도록 하려는 의도를 가지고 있었습니다.
그렇게 되면 커밋 이전에 예외가 발생하게 되는 경우에는 대기 중인 `GETDEL` 명령이 실행되지 않기 때문에, 마치 겉으로 보기에는 삭제된 값이 되돌려진 것처럼 보이게 되기 때문입니다.
하지만 이 경우에 값 조회를 실시간으로 하는 것이 불가능했기 때문에 방법을 바꿔야 했고, 조회는 바로 실행되지만 삭제는 트랜잭션으로 나중에 실행되도록 두 명령어를 다시 쪼개야겠다고 생각하게 되었습니다.
1. Redis 트랜잭션 관리를 직접 하기, 그런데 이제 낙관적 락을 곁들인
이제 조회는 트랜잭션 밖에서, 삭제는 트랜잭션 안에서 진행되도록 변경해 보겠습니다.
기존의 `setEnableTransactionSupport` 으로는 이런 범위를 세밀하게 조정할 수가 없기 때문에 옵션을 끄고, `SessionCallback`을 이용하여 직접 트랜잭션을 설정합니다.
한 가지 주의할 점은 원자적 명령어인 `GETDEL`이 이제는 `GET`과 `DEL`로 나눠졌기 때문에, 다시 동시성 문제가 발생하게 된다는 것입니다.
이에 저는 `WATCH`를 통해 낙관적 락을 추가하여 키를 감시하고, 키 값이 수정되는 경우에는 트랜잭션이 실패해서 값을 가져올 수 없도록 처리했습니다.
public Optional<StoredInvitationInformation> getAndDelete(String uuid) {
String key = makeKey(uuid);
return redisTemplate.execute(new SessionCallback<>() {
@Override
public Optional<StoredInvitationInformation> execute(RedisOperations operations)
throws DataAccessException {
// WATCH를 통해 key에 낙관적 락 적용
operations.watch(key);
// 조회는 트랜잭션에 포함되지 않음 => 바로 실행됨
StoredInvitationInformation storedInvitationInformation = (StoredInvitationInformation) operations.opsForValue()
.get(key);
// 트랜잭션 시작
operations.multi();
operations.delete(key);
// EXEC 실행 시 WATCH 키값이 바뀌면 트랜잭션 실패
List<Object> execResult = operations.exec();
if (execResult.isEmpty()) {
// 트랜잭션 실패
return Optional.empty();
} else {
// 트랜잭션 성공
return Optional.ofNullable(storedInvitationInformation);
}
}
});
}
기존에는 현재 진행 중인 스프링 트랜잭션에 Redis 트랜잭션을 참여시켰기 때문에 `MULTI`, `EXEC`가 기존 트랜잭션 범위 내에서 실행되었지만, 수정된 코드는 자체 Redis 트랜잭션에서 작업이 따로 일어나기 때문에 전체 트랜잭션 흐름에서 어떤 예외가 발생하든지 전혀 영향을 받지 않습니다.
따라서 이 방법만으로는 메서드 예외 발생 시 삭제된 링크 정보를 롤백할 수 없다는 한계가 있기 때문에, 결국 어떻게든 스프링 트랜잭션에 개입하여야 할 필요성을 느꼈습니다.
이런 경우 사용할 수 있는 클래스가 스레드별 리소스 및 트랜잭션 동기화를 관리하는 역할을 하는 `TransactionSynchronizationManager`인데요. `TransactionSynchronizationManager`의 `registerSynchronization`를 사용하여 현재 트랜잭션의 작업이 커밋된 이후, 혹은 완전히 종료된 이후에 특정 로직을 동작하게 할 수 있습니다.
이를 이용해 다음과 같이 코드를 작성하면 트랜잭션 커밋 이후 삭제 작업이 일어나도록 할 수 있을 것 같습니다.
@Transactional
public void register(String uuid, Long userId) {
// findById를 통해 링크 정보 조회
StoredInvitationInformation invitation = invitationRepository.findById(uuid)
.orElseThrow(InvitationLinkExpiredException::new);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// 트랜잭션이 커밋된 이후, delete를 통해 링크 정보 삭제
invitationRepository.delete(uuid);
}
});
(.. 중복 코드 생략..)
}
2. 분산 락 사용하기
다만 위와 같은 방식으로 설계를 변경하려면 기존에 사용하던 repository 계층 코드인 `getAndDelete()`는 사용할 수가 없게 되고, 조회와 삭제를 담당하는 메서드가 별개의 메서드로 나눠져야 합니다.
따라서 동시성 컨트롤을 위한 새로운 방법이 필요하게 되었는데요. 따라서 이번에는 아예 Redis 트랜잭션을 사용하지 않고, Redisson을 이용한 분산 락으로 비관적 락을 걸어 레이스 컨디션 자체가 생기지 않도록 해보고자 했습니다.
다음 두 가지 측면을 고려하면서 코드를 작성했습니다.
- 락을 대기하는 시간이 길어지면 성능에 문제가 생긴다.
- 락은 필요한 곳에서만 최대한 짧게 보유하도록 하기 위해 Redis 작업과 상관없는 나머지 코드들은 락을 걸지 않았다.
- 트랜잭션 범위보다 락의 범위가 더 넓어야 한다 => 커밋까지 완료되고 트랜잭션이 종료된 후에 락이 해제되어야 한다.
- 따라서 `afterCommit()`보다 더 늦게 실행되는 `afterCompletion()`에서 락을 해제하도록 했다.
repository
// 삭제 메서드
public void delete(String uuid) {
String key = makeKey(uuid);
redisTemplate.delete(key);
}
// 조회 메서드
public Optional<StoredInvitationInformation> findById(String uuid) {
String key = makeKey(uuid);
StoredInvitationInformation storedInvitationInformation = redisTemplate.opsForValue()
.get(key);
return Optional.ofNullable(storedInvitationInformation);
}
service
@Transactional
public void register(String uuid, Long userId) {
StoredInvitationInformation invitation;
String lockKey = "invitation:lock:" + uuid;
RLock lock = redissonClient.getLock(lockKey);
lock.lock();
try {
invitation = invitationRepository.findById(uuid)
.orElseThrow(InvitationLinkExpiredException::new);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
invitationRepository.delete(uuid);
}
@Override
public void afterCompletion(int status) {
lock.unlock();
}
});
} catch (Exception e) {
lock.unlock();
throw e;
}
(.. 그 외 코드 동일 생략 ..)
}
이렇게 변경하면 드디어 정상적으로 모든 테스트가 성공하는 것을 볼 수 있습니다!
3. 생각 반대로 전환하기 : 보상 메커니즘 도입
이렇게 문제는 해결되었으나, 만일 기존 `getAndDelete()` 메서드를 그대로 사용해야 하는 조건이 있다면 어떻게 해야 할지가 궁금해졌습니다. 사실 최대한 1편에서 처음 구현한 코드를 바꾸지 않은 채로 수정하고 싶었거든요.
`getAndDelete()`는 조회와 삭제가 하나의 원자적 명령어로 동시에 실행되기 때문에 수정한 코드처럼 `afterCommit()`을 이용하기는 어려웠습니다. 이 말인즉슨, 일단 조회가 성공하게 된다면 무조건 실제 삭제가 동시에 일어날 수밖에 없다는 것인데요.
그렇기 때문에 기존 설계 방법인 `예외 없이 커밋된 경우에만 삭제하기`와 반대로 생각해서, `예외가 발생한 경우에만 삭제한 값을 다시 저장`해주도록 하는 보상 메커니즘을 적용해 보았습니다.
repository
public void restore(String uuid, StoredInvitationInformation target) {
String key = makeKey(uuid);
long leftSeconds = SecondConverter.stringToLong(target.expiration());
redisTemplate.opsForValue().set(key, target, leftSeconds, TimeUnit.SECONDS);
}
service
@Transactional
public void register(String uuid, Long userId) {
StoredInvitationInformation invitation = invitationRepository.getAndDelete(uuid)
.orElseThrow(InvitationLinkExpiredException::new);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status == STATUS_ROLLED_BACK) {
invitationRepository.restore(uuid, invitation);
}
}
});
(.. 그 외 코드 동일 생략 ..)
}
`afterCompletion`은 트랜잭션 완료 후 호출되는 메서드로, 트랜잭션의 완료 상태를 상수로 전달받습니다. 이를 통해 트랜잭션의 결과가 어떻게 되었는지를 알 수 있는데, 이것을 체크하여 결과가 롤백인 경우 삭제한 값을 다시 저장하도록 할 수 있었습니다.
이렇게 변경한 코드도 문제없이 테스트를 잘 통과하네요.
마무리
이렇게 무려 3편에 걸친 시리즈인 초대 링크 트러블슈팅 정리글이 마무리되었습니다.
이것저것 시도해 본 방법들 중 결국 저는 마지막 3번 방법을 채택했는데요. 굳이 분산락까지는 필요 없다는 것을 확인했기 때문에, 그렇다면 락을 제거하고 코드의 복잡성을 낮추는 편이 좋겠다는 생각이었습니다.
물론 진짜진짜 철저하게 간다면, 3번 방법은 삭제 후 보상 메커니즘이라는 점에서 위험성이 조금 더 있을지도 모르겠습니다.
`restore`가 실패하는 경우도 있을 수 있으니까요. 만일 대상 데이터가 중요한 데이터인 경우 재시도 로직 같은 것들을 넣어야 하지 않을까? 하는 생각도 드네요.
이번 트러블슈팅이 생각해 볼 것도 많고, 배울 점도 많았던 주제였던 것 같아요.
이 당시 어떤 고민을 했었는지 잊지 않기 위해 이번에는 다소 긴 분량으로 정리를 하게 되었는데, 읽으시는 분들이 재밌게 보셨을지.. 아니면 지루하게만 해드린 건 아닌지 조금 걱정도 되네요 😂
혹시라도 끝까지 글을 읽어주신 분이 계시다면.. 읽어주셔서 정말 감사하다는 말씀을 드리고 싶습니다. 감사합니다!