Post

(COMAtching) AOP&Redis 활용 매칭 기능 멱등성 보장-1

(COMAtching) AOP&Redis 활용 매칭 기능 멱등성 보장-1

AOP&Redis 활용 매칭 기능 멱등성 보장-1

COMAtching 프로젝트 운영중 생긴 멱등성 문제를 해결하는 과정에 대한 글입니다.
Stack: Spring-Boot, Java, Redis, HTTP

문제상황


COMAtching 요청시 사용자가 같은 버튼을 여러번 클릭하게 되어 같은 매칭요청이 2번 호출되는 문제가 발생하였습니다.

Stop Charging Your Users Twice: How to Prevent Double-Click on ...

위 그림처럼 사용자가 잘못해서 2번 요청될경우 서버는 당연하게도 2번의 요청을 모두 수행했습니다.

이로 인해서 한번의 매칭 요청에도 2번의 매칭이 소요되었고 포인트도 2배로 소모되는 치명적인 버그였습니다.

📖 문제해결을 위해서 선례들을 찾아봤고 멱등성이라는 키워드에 대해서 알게 되었습니다.

멱등성(Idempotency)이란?


“멱등하다”라는 뜻은 같은 작업을 여러번 수행해도, 결과 상태가 항상 동일한 성질이라고 말할 수 있습니다.

예를 들어서

1. 멱등한 경우

아래 요청은 사용자의 이름을 특정 값으로 설정하는 작업입니다.

1
2
3
4
PUT /users/1/name
{
  "name": "Jun"
}
  • 1번 호출 → 이름 = Jun
  • 10번 호출 → 이름 = Jun

같은 요청을 여러 번 수행하더라도, 사용자의 이름이라는 결과 상태는 항상 동일하게 유지됩니다.

따라서 이 요청은 멱등하다고 말할 수 있습니다.

2. 멱등하지 않은 경우

아래 요청은 포인트를 누적해서 증가시키는 작업입니다.

1
2
3
4
POST /points/add
{
  "amount": 100
}
  • 1번 호출 → +100
  • 2번 호출 → +200
  • 3번 호출 → +300

요청을 수행할 때마다 포인트가 계속 증가하면서, 호출 횟수에 따라 결과 상태가 달라집니다.

이 경우에는 멱등하지 않다고 말할 수 있습니다.

🤔 매칭 기능은 멱등했을까?

매칭 기능의 시퀀스 다이어그램

matching_seq.png

매칭 기능은 HTTP POST 메서드로 호출되고 body에 담긴 매칭 옵션을 기반으로 매칭 결과를 DB에 저장하고 응답하는 과정을 거칩니다.

RFC 7231 (HTTP/1.1) 에서는 POST 메서드는 멱등함을 보장하지 않는다고 명시하고 있습니다.

매칭 결과를 매번 생성하고 포인트를 차감하여 DB에 반영하기 때문에 값이 지속적으로 누적되어 번화합니다.

그렇기 때문에 매칭 기능은 본질적으로 멱등하지 않은 기능입니다.

어떻게 멱등성을 보장할까?


참고

그렇다면 중복 요청이 발생했을때 방지하기 위해서는 수동으로 멱등함을 보장해주어야 했습니다.

비슷한 문제를 토스에서도 결제부분에서 해결한 사례를 찾아볼 수 있었고 참고할 수 있었습니다.

IETF에서는 멱등키(Idempotent-Key)를 요청 헤더에 포함하여 검증하는 방식을 제안합니다.

검증 조건

에러 코드시나리오
400 Bad Request멱등해야 하는 API 요청에 멱등키가 누락됐거나 형식에 맞지 않는 키값이 들어왔을 때
409 Conflict이전 요청 처리가 아직 진행 중인데 같은 멱등키로 새로운 요청이 올 때
422 Unprocessable Entity재시도 된 요청 본문(payload)이 처음 요청과 다른데 같은 멱등키를 또 사용했을 때

 🔗  내가 생각한 현재 검증 조건의 맹점 

아키텍처 (Architecture)

round_robin

서버에서 Nginx를 통해 Round Robin 매커니즘으로 번갈아가며 요청을 받고 있었기 때문에 외부에서 공동으로 사용할 수 있는 저장소로 Redis를 선택했습니다.

client에서 보낸 Redis에 멱등키와 body를 key-value로 저장하여 위 검증조건을 만족시킬 수 있는 멱등한 기능을 구현합니다.

구현


작동 방식

  1. 멱등키를 client에서 생성하여 header에 담아서 HTTP 요청을 보낸다.

  2. 해당키와 요청된 body 내용을 key-value쌍으로 Redis에 저장

  3. 멱등성을 체크하고자 하는 Service layer 메서드 실행전 Idempotent key 확인로직 수행

    👉 @Idempotent 어노테이션과 Spring AOP를 활용

멱등성 체크 시퀀스 다이어그램

idempotent_seq.png

1️⃣ CachingRequestFilter.java - 필터

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class CachingRequestFilter implements Filter {
	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
		throws IOException, ServletException {

		HttpServletRequest httpServletRequest = (HttpServletRequest) request;

		if (!"GET".equalsIgnoreCase(httpServletRequest.getMethod())) {
			ContentCachingRequestWrapper wrappedRequest =
				new ContentCachingRequestWrapper(httpServletRequest);
			filterChain.doFilter(wrappedRequest, response);
		} else {
			filterChain.doFilter(request, response);
		}
	}
}

왜 Request Body를 캐싱해야 할까?

멱등성 검증에서 가장 중요한 건 단순히 “이 요청이 왔는가?”가 아니라

👉 “이 요청이 어떤 내용으로 왔는가?” 입니다.

하지만 여기서 한 가지 문제가 있습니다.

HttpServletRequest요청 본문을 한 번만 읽을 수 있습니다.

HttpServletServletInputStream이라는 스트림 데이터로 요청읽기 때문에 메모리에 요청을 올리지 않습니다.

@RequesetBody와 같이 Controller에서 직렬화가 되면 소모되고 끝납니다.

또한 Controller에서 읽은 파라미터를 AOP에서 읽을 수 없습니다.

그래서 body를 검증하는 과정에서 조회를 하면 이후 과정에서는 body를 읽을 수 없게 됩니다. 이를 위해 미리 ContentCachingRequestWrapper를 활용하여 AOP에서도 요청에 접근가능 하도록 구현했습니다.

필터 동작

  • HttpServletRequest는 body를 한 번 읽으면 소멸
  • ContentCachingRequestWrapper는 body를 내부 버퍼에 캐싱
  • POST / PUT / DELETE 처럼 body가 있는 요청에만 적용
  • GET 요청은 QueryString만 사용하므로 wrapper 불필요

즉, AOP에서 body를 읽을 수 있도록 사전 작업을 해두는 필터입니다.


2️⃣ FilterConfig.java – 필터 등록

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class FilterConfig {

	@Bean
	public FilterRegistrationBean<CachingRequestFilter> loggingFilter() {
		FilterRegistrationBean<CachingRequestFilter> registrationBean =
			new FilterRegistrationBean<>();

		registrationBean.setFilter(new CachingRequestFilter());
		registrationBean.addUrlPatterns("/*");
		registrationBean.setOrder(0);

		return registrationBean;
	}
}
  • order(0)으로 가장 앞단에서 실행
  • 컨트롤러, 인터셉터, AOP보다 먼저 body를 감싸도록 보장

3️⃣ @Idempotent 어노테이션

1
2
3
4
5
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
	int expireTime() default 7;
}

역할

  • 멱등성 검증이 필요한 메서드에만 적용
  • Redis TTL을 어노테이션으로 제어
  • 비즈니스 로직과 멱등성 로직을 분리
1
2
@Idempotent(expireTime = 7)
public void matchUser(...) { ... }

IdempotentAspect.java – 멱등성 AOP 검증

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {

	private final StringRedisTemplate stringRedisTemplate;

	@Pointcut("@annotation(idempotent)")
	public void pointCut(Idempotent idempotent) {
	}

	@Before(value = "pointCut(idempotent)", argNames = "joinPoint, idempotent")
	public void before(JoinPoint joinPoint, Idempotent idempotent) throws IOException {
		HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest();

		String requestKey = getRequestKey(request);
		String requestValue = getRequestValue(request);

		int expireTime = idempotent.expireTime();

		Boolean isPoss = stringRedisTemplate
			.opsForValue()
			.setIfAbsent(requestKey, requestValue, expireTime, TimeUnit.SECONDS);

		if (Boolean.FALSE.equals(isPoss)) {
			handleRequestException(requestKey, requestValue);
		}

	}

	private String getRequestKey(final HttpServletRequest request) {
		String token = request.getHeader("requestKey");

		if (token == null)
			throw new IllegalArgumentException();

		return token;
	}

	private String getRequestValue(final HttpServletRequest request) {
		if (!"GET".equalsIgnoreCase(request.getMethod())) {
			ContentCachingRequestWrapper cachingRequest = (ContentCachingRequestWrapper)request;

			return new String(cachingRequest.getContentAsByteArray(), StandardCharsets.UTF_8);
		} else {
			return request.getQueryString();
		}
	}

	private void handleRequestException(final String requestKey, final String requestValue) {

		String originRequestValue = stringRedisTemplate.opsForValue().get(requestKey);

		//요청이 내용이 비어있지 않으면서 원래 요청이랑 같은 경우
		if (!requestValue.isBlank() && !requestValue.equals(originRequestValue))
			throw new IdempotentException(ResponseCode.UNPROCESSABLE_ENTITY);

		//요청이 같지 않은 경우
		else
			throw new IdempotentException(ResponseCode.CONFLICT);
	}
}

이 AOP는 Service 메서드 실행 직전에 동작합니다.

전체 흐름

  1. 요청에서 requestKey 헤더 추출
  2. 요청 body(or query)를 문자열로 추출
  3. Redis에 SET NX + TTL 시도
  4. 이미 존재하면 → 예외 처리

케이스 분기

상황의미응답
같은 키 + 다른 body재사용된 키422
같은 키 + 같은 body중복 클릭409

👉 단순 중복과 잘못된 재시도를 구분

실제 적용

매칭 기능을 담당하는 서비스 레이어 requestMatch 메서드에 Idempotent를 다음과 같이 적용할 수 있었습니다.

image-20260119003843388

image

image

Postman을 통해 동일한 요청을 연속으로 호출해 멱등성 동작을 검증했습니다.

  • 첫 번째 요청
    • 새로운 requestKey로 요청
    • Redis에 키가 존재하지 않아 정상 처리
    • 200 OK 응답
  • 두 번째 요청
    • 동일한 requestKey로 재요청
    • Redis에 이미 처리 이력이 존재
    • Service 로직 실행 전 차단
    • 409 Conflict 응답

이를 통해 같은 요청이 여러 번 전달되더라도 비즈니스 로직은 단 한 번만 수행됨을 확인할 수 있었습니다.

특히 Controller까지는 요청이 도달하지만, Service 실행 직전에 Redis 기반 멱등성 검증이 이루어지기 때문에 포인트 차감과 같은 핵심 로직을 안전하게 보호할 수 있었습니다.




Ref
https://sanggi-jayg.tistory.com/entry/%EB%A9%B1%EB%93%B1%EC%84%B1-Idempotence%EC%99%80-HTTP-API-%EC%84%A4%EA%B3%84 https://gukin-han.tistory.com/17

This post is licensed under CC BY 4.0 by the author.