배경
제가 진행했던 프로젝트에서는 초대 링크를 통해 관리하는 펫 그룹에 다른 유저를 가입시킬 수 있는 기능이 있으며, 해당 기능은 다음과 같은 요구 사항을 가지고 있습니다.
- 초대 링크는 uuid 값을 이용하여 생성되고 해당 uuid 값은 Redis에 저장된다.
- 초대 링크 값을 통해 가입 요청이 들어오면, Redis에 링크 uuid 값이 있는지 확인하고 가입을 진행한다.
- 초대 링크는 1회용이다. 즉 생성된 초대 링크를 누군가 사용 완료한다면 다른 사람이 사용할 수 없게 Redis에서 해당 uuid 값이 삭제되어야 한다.
조건에 따라서 하나의 초대 링크에 여러 유저의 요청이 동시에 들어오더라도, 오직 첫 번째 요청만 정상 처리되고 나머지 유저의 요청은 링크 만료 예외가 터지며 가입이 실패해야 합니다.
하지만 동일한 링크를 동시에 여러 유저가 호출하여 가입하는 상황을 테스트 해 본 결과, 하나의 링크로 한 명 이상이 가입되는 문제가 발생하게 되었습니다.
이에 해결 과정을 고민하면서 동시성 문제 뿐 아니라, 데이터의 일관성이 깨지는 문제 상황도 함께 경험하게 되어 그 과정을 기록해보고자 합니다.
문제 상황 : 기존 코드
@Transactional
public void register(String uuidLink, Long userId) {
Users user = userRepository.findById(userId)
.orElseThrow(UserInformationNotFoundException::new);
InvitationInformation invitation = invitationRepository.findById(uuidLink).orElseThrow(
InvitationLinkExpiredException::new);
Pet pet = petRepository.findById(invitation.getPetId())
.orElseThrow(PetNotFoundException::new);
validateRegister(user.getId(), pet.getId());
UserPet userPetGroup = UserPet.builder()
.user(user)
.pet(pet)
.role(UserPetRole.GUEST).build();
userPetRepository.save(userPetGroup);
// 종료하고 해당 링크 삭제하기
invitationRepository.delete(invitation);
}
처음 이 코드를 작성할 때, 초대 링크를 삭제하는 로직을 어디에 배치해야 할지 고민이 많았습니다. 왜냐하면 위치에 따라 각각 다음과 같은 문제가 발생할 수 있겠다고 생각했기 때문이에요. (내부에 MySQL 작업이 포함되어 있어 `@Transactional`은 필요한 상황)
- 조회하자마자 삭제하도록 삭제 로직을 조회 바로 다음에 배치한다면 : `@Transactional` 로 인해 메서드 중간에 예외가 발생하는 경우 현재 트랜잭션이 롤백될 것이지만, 이때 Redis에 저장된 값을 삭제하는 작업을 진행한 상태라면 이 삭제 작업은 롤백되지 않는다.
- 일관성 문제의 발생
- 따라서 링크 삭제 작업을 맨 아래로 배치하여, 예외가 발생할 수 있는 나머지 작업이 모두 수행된 다음에 링크를 Redis에서 삭제하도록 해보자
- 다른 작업들을 정상적으로 마친 후 실행되도록 삭제 로직을 맨 마지막으로 배치한다면 : 링크가 맨 마지막에 삭제되도록 설계하는 경우, 다른 스레드의 요청들이 링크에 접근할 수 있는 시간이 길어지게 되고, 자연스럽게 하나의 링크로 동시에 가입될 확률이 높아지는 문제가 발생한다.
- 동시성 문제의 발생
- 그렇다면 링크 조회 바로 다음에 삭제를 진행하도록 위치를 조정할 수도 있지만, 이렇게 되면 첫 번째 경우와 동일하게 역시 일관성 문제가 생긴다.
결국 하나를 해결하려고 하면 다른 하나의 문제가 발생하는 상황으로, 코드의 위치를 변경하는 것만으로는 문제를 완벽하게 해결할 수 없었습니다.
따라서 추가적인 방법들을 도입해서 두 가지 문제를 해결해보고자 했는데, 이번 포스팅에서는 먼저 동시성 문제부터 해결 방법을 살펴볼게요.
어플리케이션 수준에서의 동시성 제어
우선 동시성 제어를 위해 사용할 수 있는 방법들 중, 코드 변경으로 시도해 볼 수 있는 방법들을 우선 시도해보려고 합니다.
synchronized
가장 간단하고 기초적인 방법인 `synchronized`의 사용입니다. `register` 메서드에 해당 키워드를 사용하여 테스트를 해보도록 하겠습니다.
테스트 코드는 다음과 같은 사항을 체크하도록 했습니다.
- 10명의 사용자가 거의 동시에 `register` 를 호출한다.
- 테스트가 끝나면 성공한 사용자는 한 명이어야 한다.
- 초대 링크가 만료되었다는 예외 9건이 발생해야 한다.
- 최종적으로 테스트 초대 링크는 삭제되어야 한다.
@Transactional
public synchronized void register(String uuid, Long userId) {
...
}
단일 서버 환경에서 테스트를 진행하기 때문에, 결과를 확인해보면 아래와 같이 테스트를 통과하는 것을 볼 수 있습니다.
그런데 아시는 것처럼 `@Transactional`과 `synchronized`를 함께 사용하는 경우에는 사실 우리가 예상하는 대로 동시성 문제가 잘 해결되지 않습니다.
(Transactional과 synchronized를 함께 사용하면 왜 문제가 생기는지에 관련해서는 아래 포스팅을 참고해 주세요)
트랜잭션 격리 수준과 동시성 제어 이야기 (1) : @Transactional과 synchronized
들어가기 전.. Spring @Transactional with synchronized keyword doesn't workLet's say I have a java class with a method like this (just an example) @Transactional public synchronized void onRequest(Request request) { if (request.shouldAddBook()) { if (
seondays.tistory.com
하지만 몇번을 테스트해 봐도 위 코드는 정상적으로 링크 하나에 사용자 한 명이 가입됩니다. 그 이유는 링크를 조회해 오는 대상 DB가 Redis이기 때문인데요.
기본적으로 RDB를 서포트하도록 구현된`@Transactional`은 Redis에는 아무런 영향도 끼치지 못하기 때문에 있어도 없는 것처럼 동작하게 되고, 그 결과 `synchronized`만이 Redis 작업에 정상적으로 작동하여 테스트가 성공합니다.
하지만 다음과 같은 이유들로 단일 환경인 상황에서 잘 작동하더라도 이 방법은 적합하지 않다고 생각했습니다.
- 현재 `register`에는 MySQL과 Redis 두 가지 DB 작업이 모두 들어가 있기 때문에 예상하지 못한 문제가 발생하게 될 여지가 있음
- 일반적으로 사용했을 때 문제가 되는 조합을 사용하게 되면 해당 코드를 읽는 사람들에게 혼란을 줄 수 있음
- Redis에서 발생하는 동시성 문제의 근본적인 이유를 해결하는 방법이 아님
getAndDelete
현재 동시성 문제가 발생하는 이유는 앞서 잠시 살펴봤듯이 값의 조회와 삭제 사이의 간격 때문입니다. 만일 다른 스레드에서 끼어들 틈 없이 조회와 삭제 연산이 하나의 단위로 수행된다면 문제를 해결할 수 있을 것입니다.
여기서의 `조회와 삭제 연산이 하나의 단위로 수행`이 의미하는 것이 바로 원자성 `atomic` 개념입니다.
저의 사례에서는 링크값을 `읽는 동시에 삭제`하는 것이 필요하기 때문에, 조회와 삭제 두 명령어를 원자성을 지니는 하나의 명령어로 설정해보도록 하겠습니다.
Redis에는 읽는 동시에 삭제라는 원자적 명령을 지원하기 위한 두 가지 방법이 있습니다.
- GETDEL : Get the value of key and delete the key (redis 6.2 이후부터 사용 가능)
- Lua scripts : 스크립트로 작성된 명령은 하나의 명령인 것 처럼 실행되며, Redis는 해당 스크립트의 원자적 실행을 보장한다.
GETDEL
Returns the string value of a key after deleting the key.
redis.io
Scripting with Lua
Executing Lua in Redis
redis.io
요구되는 로직이 더 복잡했다면 스크립트를 작성해야 했겠지만, 조회-삭제 연산을 위한 명령어가 존재하기 때문에 이번에 저는 `GETDEL` 를 사용했습니다.
Spring Data Redis는 RedisTemplate에서 사용할 수 있도록 `ValueOperations` 인터페이스 안에 `getAndDelete` 메서드를 선언해 두었기 때문에 이것을 이용해 코드를 변경하겠습니다.
public Optional<StoredInvitationInformation> getAndDelete(String uuid) {
String key = makeKey(uuid);
return Optional.ofNullable(redisTemplate.opsForValue().getAndDelete(key));
}
@Transactional
public void register(String uuid, Long userId) {
Users user = userRepository.findById(userId)
.orElseThrow(UserInformationNotFoundException::new);
// getAndDelete로 GETDEL 명령어를 사용하도록 변경했다
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);
}
코드를 변경하고 동일한 테스트 코드를 돌려보면 정상적으로 테스트를 잘 통과합니다. 역시 Redis 모니터링으로도 메서드 실행 시 GETDEL이 잘 찍히는 것을 볼 수 있습니다.
이유 정리
Redis는 명령어 큐에 입력되는 명령어들을 저장하고, 이것들을 싱글 스레드로 처리하기 때문에 다음과 같은 특징들을 가지게 됩니다. 그렇기 때문에 데이터의 일관성과 어느정도의 동시성 안전이 보장되는 셈이라고 할 수 있는데요.
- 하나의 클라이언트의 명령어가 완료되기 전까지 다른 클라이언트들의 명령어가 실행될 수 없음
- 명령어는 queue에 쌓인 순서대로 실행됨
- 동시성을 지니지만, 병렬적으로 작동하지는 않기 때문에 하나의 명령어가 실행되는 동안에는 다른 명령어의 간섭을 받지 않음
하지만 처음 코드에서 조회와 삭제를 했을 때는 문제가 있었죠. 논리적으로 하나의 연산이어야 해서 쪼개질 수 없는 `GET`과 `DEL`이 각각 분리되어 있는 개별 명령어로 입력되기 때문입니다.
매우 짧은 시간 안에 동시다발적으로 명령어가 쌓이기 때문에 1번 클라이언트의 `GET-DEL` 작업이 모두 완료되기 이전에 다른 클라이언트들의 `GET` 명령이 우선 들어올 수 있고, 때문에 아무리 순차적으로 간섭 없이 명령어를 처리한다고 한들 원하는 대로 작동하지 않았습니다.
따라서 이 근본적인 원인인 `명령어 자체가 원자성이 없는 상태`를 해결해주는 방식으로 조회와 삭제를 한 번에 처리해 주는 `GETDEL`를 사용했습니다.
그렇게 되면 명령어 큐의 모습은 다음과 같이 바뀌게 되는데요.
이제 조회-삭제 로직이 하나의 명령어로 간섭받지 않고 수행되기 때문에 오직 첫 번째로 접근한 클라이언트만 초대 링크의 정보를 확인하고 가입한 후 삭제할 수 있게 되었습니다.
마지막으로 아래에서 지금까지 알아본 3가지 케이스의 명령어 로그를 비교하면서 확인해 보세요!
마무리
이번 포스팅에서는 `delete`를 맨 아래로 놓았다고 가정한 후, `getAndDelete`를 이용하여 동시성 문제를 해결해보았습니다.
하지만 아직 끝난 게 아닙니다! 기존 코드에서 초대 링크의 `delete` 로직을 어느 위치에 두느냐에 따라 각각 다른 두 가지 문제가 발생했다는 것을 기억하시나요? 해결하지 못한 일관성 문제가 남아있습니다.
기본적으로 Redis는 Spring 트랜잭션에 참여하지 않기 때문에, 초대 링크의 정보를 조회-삭제하고 나서 코드의 나머지 부분에서 어떠한 예외가 발생한다면 초대 링크는 롤백되지 못하고 그대로 삭제됩니다.
가입이 정상적으로 완료되지 못했는데, 초대 링크만 소비되는 상황은 데이터의 일관성을 해치는 문제가 되는 상황이라고 할 수 있겠죠.
다음 포스팅에서 1차로 수정한 코드를 가지고 계속해서 이 일관성 문제를 해결하는 방법을 알아보도록 하겠습니다.