0. 들어가기 전
현재 진행하고 있는 동선을 만드는 프로젝트에서 다음과 같은 요구사항이 존재했습니다.
'사용자는 하루에 1개의 동선만 생성할 수 있다.'
처음에 이를 구현하기 위해서는 단순히 DB에서 회원 식별자와 날짜로 데이터가 존재하는지 확인하는 검증 로직을 수행했었습니다.
그래서 요청 시에 이미 DB에 멤버가 가진 해당 날짜의 동선이 존재한다면 예외를 발생시키게 구현했습니다.
그러나 구현 이후에 다음과 같은 상황을 떠올려봤습니다.
'사용자가 동선을 만들 때 완료 버튼을 여러번 누르면 어떻게 되지?'
동선을 만들 때 위의 완료 버튼을 누르면 동선 추가 API 요청이 보내지도록 설계가 되어있었습니다.
이때 일반적인 상황에서 사용자는 완료 버튼을 1번 누르고 기다리겠지만, 다음과 같은 특수한 상황을 예상해봤습니다.
- 사용자의 네트워크가 지연되어서 응답이 지연되는 경우
- 서버의 발생하는 부하에 따라 응답이 지연되는 경우
이러한 상황이 되면, 사용자는 응답이 지연되기 때문에 실패한 것으로 판단하고 완료 버튼을 여러 번 누를 것입니다.
또, 이러한 특수한 상황이 아니더라도 사용자가 단순히 실수로 버튼을 여러 번 누를 수도 있을 것입니다.
이때 서버에서는 DB에 동선이 있는지 검증 후 예외를 발생시키는 로직이 있음에도 불구하고 여러 개의 동선이 새로 생성될 것입니다.
실제로 멀티 스레드 테스트를 통해서 1명의 사용자가 동시에 3개의 요청을 한 경우를 테스트한 결과,
아래와 같이 필드가 같은 3개의 동선이 생성된 것을 확인할 수 있었습니다.
따라서, 이렇게 중복으로 요청이 들어올 경우에 대비해서 처음 1번의 요청만 성공하도록 하고
이후의 요청들은 모두 실패하도록 구현을 해야 했습니다.
어떻게 구현을 했는지 기록해보도록 하겠습니다!
1. 중복 요청을 어떻게 식별할까? (멱등키)
먼저 중복 요청을 방지하기 위해서는 중복 요청에 대해서 '멱등성'을 보장해야 했습니다.
멱등성이란, 첫 번째 수행을 한 뒤 여러 차례 적용해도 결과를 변경시키지 않는 작업 또는 기능의 속성을 뜻합니다.
즉, 멱등한 작업의 결과는 한 번 수행하든 여러 번 수행하든 같습니다.
멱등성을 보장해서 여러 번의 중복 요청이 와도 처음 요청 시에만 동선을 생성하고,
그 후에는 생성한 결과가 변하지 않게 해야합니다. (새로운 동선이 생성되지 않도록 예외 처리)
여기서 멱등성을 보장하는 시간은 서버의 최대 응답 지연 시간을 고려해서 설정했습니다.
최대 응답 지연 시간 동안의 들어오는 요청은 모두 중복 요청이라고 판단했습니다.
그래서 저는 최대 10초 동안 응답 지연이 있을 수 있다고 고려해서 멱등성 보장 시간을 10초로 결정했습니다.
그리고 요청이 중복되었음을 판단할 요청의 식별자, 즉 멱등키가 필요했습니다.
해당 식별자는 현재 비스니스에서 '1명의 사용자의 같은 날짜 동선 생성 요청'을 식별해야 했습니다.
그래서 멱등키는 다음과 같은 요소가 들어가도록 결정하게 되었습니다.
- addRoute(동선 생성 메소드 이름)
- memberId(사용자 식별자)
- date(동선 날짜)
이후 구현이 어떻게 되든 우선 중복 요청을 식별하는 멱등키는 위의 요소를 포함하여 설계를 진행했습니다.
이렇게 처음 요청에서 멱등키를 생성하고, 요청마다 들어오는 멱등키를 체크하여 중복 요청을 방지할 수 있습니다.
2. 멱등키를 어디에 저장할까?
앞서 멱등키를 10초 주기로 생성하는 설계 부분까지 살펴봤습니다.
이때 매번 요청마다 멱등성을 보장하는 로직을 살펴보면,
결국 하나의 요청에서 해당 요청에 대한 멱등키가 존재하는지 판단해야 할 것입니다.
그래서 결론적으로는 멱등키를 저장해놓는 저장소가 필요합니다.
멱등키의 저장되는 특성을 살펴보면 다음과 같습니다.
- 동선 생성은 비즈니스적으로 잦게 요청된다.
- 10초 주기로 동선 생성 요청이 올 때마다 새로운 멱등키를 생성해야 한다.
- 동선 생선 요청 시마다 멱등키를 조회해야한다.
- 멱등키는 다른 비즈니스 데이터와 별다른 관계를 가지지 않는다.
고민했던 저장소로는 기존의 비즈니스 데이터를 저장하고 있었던 MySQL RDB와 인메모리 DB인 Redis입니다.
처음에는 기존에 사용하고 있었던 MySQL DB에 멱등키를 저장하려고 했으나,
결국 다음과 같은 이유로 Redis를 사용해서 저장하도록 결정했습니다.
- 저장되는 멱등키의 특성이 Redis에 더 적합하다.
- 데이터 접근이 잦다.
- 다른 비즈니스 데이터와 별다른 관계를 갖지 않는다.
- 동시 요청으로 멱등키를 저장할 때 성능 측면에서 Redis가 적합하다. (동시성 처리 시 성능)
- 첫 요청 시 사용자가 여러번 빠르게 버튼을 누르게 되면 동시 요청으로 멱등키 저장이 되는 상황
- 이러한 동시성 부분은 Lock을 통해서 해결
- RDB의 Lock은 비관적 락을 사용할 텐데 처리 성능이 Redis가 인메모리 DB이므로 더 빠르다.
- 멱등키의 조회 부하가 다른 데이터에 영향을 미친다.
- RDB에 저장하게 된다면, 잦은 조회가 발생하는 멱등키의 조회 부하 때문에 다른 데이터 Write/Read 성능에 영향을 미친다.
3. Redis Set을 사용한 중복 방지 구현
멱등키를 저장하는 Redis의 자료 구조로는 Set을 선택했습니다.
Redis의 명령 중 SET을 사용하고 NX를 옵션으로 전달하여 명령을 수행할 것입니다.
(Redis 2.6.12 이전에는 SETNX 명령이 별도로 있었지만, Deprecated 되어 SET 명령어에 NX를 옵션으로 전달해야 합니다.)
- SET : key-value 형태인 Set 자료 구조의 key에 해당하는 value를 지정하는 명령어
- NX 옵션 : Not Exist의 약자로, Key가 존재하지 않을 때만 value를 지정하는 명령어
해당 명령어를 사용한 Flow는 다음과 같습니다.
- 사용자의 요청이 들어오면 멱등성이 보장되는 멱등키를 생성한다.
- 해당 멱등키를 Redis에 Set 자료 구조로 SET NX를 통해 저장한다. (Spring Data Redis의 RedisTemplate 사용)
- 멱등키에 해당하는 키가 존재하지 않으면 만료 시간이 10초로 멱등키를 저장하고 Application에 true를 반환한다.
- 멱등키에 해당하는 키가 이미 존재한다면 저장하지 않고 Application에 false를 반환한다.
- SET NX 명령어 성공 여부에 따라 분기 처리로 로직을 수행한다.
- True : 첫 요청으로 판단하여 동선을 생성한다.
- False : 중복된 요청으로 판단하여 예외를 발생시킨다.
이를 그림으로 나타내면 아래와 같습니다.
그럼 본격적으로 직접 코드단에서 구현을 해보겠습니다.
Spring에서는 Spring Data Redis를 사용하여 추상화된 인터페이스로 간단하게 Redis를 사용할 수 있습니다.
따라서, 먼저 Spring Data Redis 의존성을 다음과 같이 추가해줍니다.
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
이렇게 의존성을 추가하면, Redis의 명령어를 간단하게 사용하게 해주는 Helper 클래스인 RedisTemplate을 사용할 수 있습니다.
RouteService
@Service
@Slf4j
@Transactional
@RequiredArgsConstructor
public class RouteService {
...
private final RedisTemplate redisTemplate;
public Long addRoute(final Long memberId, final RouteAddRequest request) {
// 비즈니스 로직 (동선 생성)
...
// 멱등성 보장 로직 (Redis)
final String action = "addRoute:";
final LocalDate date = request.date();
final String idempotentKey = action + date + ":" + memberId;
final Boolean isFirstRequest = redisTemplate.opsForValue().setIfAbsent(idempotentKey, "success", 10, TimeUnit.SECONDS);
if (!isFirstRequest) {
log.info("동선 생성 시 중복 요청 발생 - 멱등키 : {}", idempotentKey);
throw new RouteException.DuplicateSameDateException();
}
}
}
- 로직 순서 : 비즈니스 로직 수행 이후 멱등성 보장 Redis 로직 수행하도록 구현
- 비즈니스 로직에도 필드 검증 등 여러 검증이 포함되어서 해당 검증을 통과하지 못하면 예외가 발생하는 상황
- 만약, 멱등키를 저장하는 로직이 비즈니스 로직 이전에 수행된다고 가정하면,
- 멱등키를 저장해서 10초간 오는 요청의 예외가 발생하도록 설정된다.
- 그러나, 이후 비즈니스 로직에서 수행하는 검증의 예외가 발생하면 MySQL의 트랜잭션은 롤백되어 동선 데이터는 저장되지 않는다.
- 이때 다음 요청이 들어올 경우 멱등키가 저장되어 있기 때문에 중복 요청으로 판단하여 예외가 발생해서 동선을 MySQL에 저장하는 로직이 수행되지 않는다.
- 그래서 결국 사용자는 멱등성이 보장되는 10초 동안 기다리다가, 결국 동선 생성에 실패할 것이다.
- 이러한 상황을 방지하기 위해 멱등키를 저장하는 시점을 비즈니스 로직의 검증이 통과된 시점으로 구현
- 멱등키(idempotentKey) : 수행하는 동작(addRoute) + 동선 생성할 날짜(date) + 멤버 식별자(memberId)로 구성
- ex : addRoute:2023-05-28:1
- redisTemplate.opsForValue().setIfAbsent() : RedisTemplate을 통해 SET NX 명령어 수행 후 boolean 반환
- 멱등키를 key로, 저장 성공의미의 "success"를 value로 저장
- 만료 시간은 10초로 설정하여 10초 후에 자동으로 삭제되도록 설정 (멱등성 보장 주기 10초)
- Redis SET NX 결과가 false면 중복 요청이므로 로깅 후 예외 발생
- Redis SET NX 결과가 true면 첫 요청이므로 이후의 비즈니스 로직인 동선 생성 진행
이렇게 생각보다는 간단하게 구현할 수 있었습니다.
4. 중복 요청 테스트
프로덕션 코드를 구현해봤으니, 마지막으로 중복 요청이 들어왔을 때 잘 동작하는지 테스트해보도록 하겠습니다.
해당 테스트는 멀티 스레드 테스트를 통해 동시에 1명의 사용자가 여러 요청을 보내는 시나리오로 동작하도록 했습니다.
RouteServiceTest
@Test
@DisplayName("1명의 사용자가 동선 생성 요청을 여러번 보내는 경우 1번만 성공하고 예외가 발생한다.")
void throws_multiple_request() throws InterruptedException, ExecutionException {
// given
final RouteAddRequest routeAddRequest = new RouteAddRequest(기본_동선_날짜, 기본_동선_제목, Collections.emptyList(), 기본_동선_이동수단, Collections.emptyList());
final ExecutorService executorService = Executors.newFixedThreadPool(30);
final int requestCount = 10;
final CountDownLatch countDownLatch = new CountDownLatch(requestCount);
final Member savedMember = executorService.submit(() -> {
return memberRepository.save(MemberFixtures.기본_회원_엔티티());
}).get();
// when
List<Future<Long>> results = IntStream.range(0, requestCount)
.mapToObj(i -> executorService.submit(() -> {
try {
return routeService.addRoute(savedMember.getId(), routeAddRequest);
} finally {
countDownLatch.countDown();
}
}))
.toList();
countDownLatch.await();
int successCount = 0;
int failCount = 0;
for (Future<Long> result : results) {
try {
result.get();
successCount++;
} catch (Exception e) {
System.out.println(e.getMessage());
failCount++;
}
}
// then
assertThat(successCount).isEqualTo(1);
assertThat(failCount).isEqualTo(requestCount - 1);
}
- ExecutorService : 멀티 스레드 테스트를 위해 30개의 고정 스레드풀 할당하여 생성
- CountDownLatch : 멀티 스레드 작업이 모두 끝날 때까지 대기를 위해서 사용
- 테스트 시나리오 : 같은 사용자가 같은 날짜의 총 10번의 동선 생성 요청을 동시에 보내는 상황
테스트 Flow
- 초기 데이터 트랜잭션 완료를 위해 먼저 하나의 스레드를 할당받아서 초기 데이터인 멤버를 저장하고 결과 반환
- 총 요청 수인 10개의 스레드를 각각 할당받아서 동선 생성 요청 처리 후 결과 반환
- 총 10개의 동선 생성 요청의 결과 리스트를 돌면서 try-catch로 결과 가져오기
- 작업이 성공했으면 successCount++
- 작업이 실패했으면 예외가 발생하므로 catch에서 failCount++
- 검증 : 총 10개의 중복 요청 중 1개의 요청만 성공하고, 나머지 9개의 요청은 실패해야한다.
테스트 결과
위의 결과처럼 테스트가 성공하고, 같은 멱등키로 중복 예외가 총 9개 발생하여 로깅이 잘 되는 것을 알 수 있습니다.
Reference
https://upcurvewave.tistory.com/646
https://www.youtube.com/watch?v=Tspovheq264&t=159s
'이슈 & 트러블 슈팅' 카테고리의 다른 글
[이슈] JPA N+1 문제 해결 및 성능 비교 (feat. Batch Size) (1) | 2024.07.11 |
---|---|
[이슈] Redis 캐싱을 통해 조회 성능 개선하기 (0) | 2024.07.01 |
[이슈 & 트러블 슈팅] 동시성 보장하기 (feat. 비관적 락, Redis 분산 락) (3) | 2024.06.11 |
[트러블 슈팅] 멀티 스레드 테스트 시 초기 데이터 오류(feat. 트랜잭션 격리 수준) (0) | 2024.06.06 |
[트러블 슈팅] SpringBoot에서 application.yml 이외의 yml 프로퍼티 적용하기 (0) | 2022.08.26 |