내가 담당한 포인트 상점 도메인에서 어떤 문제점이 있을까? 를 고민해봤다.
앞서 포인트 적립에 대한 동시성 문제도 있었지만, 또 심각한 문제가 있었다.
그린 위닛 애플리케이션은 웹앱 서비스이다.
웹앱이라는 것은 Wifi, 4G, 5G 등 무선 네트워크를 사용하기 때문에 대표적인 두 가지 문제가 발생할 수 있다.
다양한 간섭
무선 네트워크 특성 상, 동일 주파수에서 발생하는 무수한 장애물이 존재할 것이다. 그래서 CSMA/CA 기법 특히 채널 호핑을 통해 이를 수준급으로 처리한다.
채널 호핑은 간섭이 심할 때 자동으로 더 깨끗한 주파수 채널로 전환하여 통신 품질을 유지하려는 시도이다. 하지만 이러한 기술적 노력에도 불구하고, 무선 환경의 본질적인 불안정성 때문에 완벽한 통신 신뢰성을 보장하기는 어렵다.
특히 채널 전환 과정이나 심한 간섭 상황에서는 일시적인 패킷 손실이나 지연이 발생할 수 있다.
이게 어떤 문제가 될까?
HTTP 프로토콜 특성 상 Stateless라서 서버는 클라이언트 연결이 끊기더라도 상태를 저장하지 않고, 클라이언트가 연결이 종료된다면 다시 연결해야 된다..
결국 HTTP 특성으로 인해 브라우저 혹은 앱은 자동으로 재시도 로직을 실행한다. 혹은 사용자가 갑자기 요청이 끊겼다는 느낌으로 인해 같은 기능을 한 번 더 수행할 수 있다.
만약 위의 문제들로 주문 요청을 한다고 가정하자.
이 때, 서버에서 발생하는 일
이런 문제점 때문에 같은 사용자에게 3건의 상품 주문이 되어버린다...
왜 이런 문제가 발생할까?
근본적으로 모바일 서비스의 특징이다.
다음으로 HTTP Method의 특징이다.
주문 서비스는 Post 메서드로 멱등성이 보장되지 않는 기능이다.
멱등하다의 의미는 뭘까?
여러번 호출에도 같은 값을 보장해야한다.
하지만, 주문은 매 생성마다 새로운 주문 번호를 부여하고 포인트 차감등 매 작업마다 값이 달라진다.
그래서 해당 문제를 해결하기 위해 멱등성을 관리하는 기법이 필요하다.
해당 프로젝트에서는 멱등성 관리기법 중 Idempotency Key를 이용했고, 그 내용을 지금 작성한다.
우선 Red Cycle로 실패하는 테스트를 만들어보자. 당연히 실패해야 한다.
@TestConfiguration
public class OrderTestConfig {
@Bean
@Primary
public PointProductService pointProductService() {
return mock;
}
@Bean
@Primary
public DeliveryAddressService deliveryAddressService() {
return mock;
}
@Bean
@Primary
public PointSpendClient pointSpendClient() {
return mock;
}
}
@Test
void 동일한_멱등키로_주문_요청_세_번을_할_경우_한_건만_처리된다() {
// given
SingleOrderRequest orderRequest = new SingleOrderRequest(1L, 1L, 1);
// when
ApiTemplate<Long> firstResponse = requestOrder(orderRequest);
ApiTemplate<Long> secondResponse = requestOrder(orderRequest);
ApiTemplate<Long> thirdResponse = requestOrder(orderRequest);
// then
List<Order> all = orderRepository.findAll();
assertThat(all).hasSize(1);
assertThat(firstResponse.result()).isOne();
assertThat(secondResponse.result()).isOne();
assertThat(thirdResponse.result()).isOne();
}
주문 컨텍스트만 테스트하기 위해 서비스에서 사용하는 외부 컨텍스트들은 모킹해두고, 위에서 작성한 시나리오로 테스트 케이스를 만들어봤다.

결과는 당연히 실패했고, 예상한대로 3개의 주문이 모두 저장되었다.



응답도 각자 다른 ID를 반환하는 것을 볼 수 있다.
이 테스트를 성공하도록 작업해보자!
@Entity
@Table(name = "idempotencies")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class IdemPotency {
@Id
private String idempotencyKey;
@Getter
@Column(nullable = false)
private String response;
private IdemPotency(String idempotencyKey, ApiTemplate<?> response) {
this.idempotencyKey = idempotencyKey;
this.response = ApiTemplateObjectMapper.toString(response);
}
public static IdemPotency of(String idempotencyKey, ApiTemplate<?> response) {
return new IdemPotency(idempotencyKey, response);
}
public ApiTemplate<?> toResponse() {
return ApiTemplateObjectMapper.toApiTemplate(response);
}
}
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotencyAspect {
private final IdemPotencyRepository idempotencyRepository;
private final HttpServletRequest request;
@Around("@annotation(Idempotent)")
public Object checkIdempotency(ProceedingJoinPoint joinPoint) throws Throwable {
String idempotencyKey = Optional.ofNullable(request.getHeader("idempotency-Key"))
.orElseThrow(() -> new BusinessException(GlobalExceptionMessage.REQUIRED_IDEMPOTENCY_KEY));
Optional<IdemPotency> optionalIdemPotency = idempotencyRepository.findById(idempotencyKey);
if (optionalIdemPotency.isPresent()) {
return optionalIdemPotency.get().toResponse();
}
Object result = joinPoint.proceed();
IdemPotency newIdempotency = IdemPotency.of(idempotencyKey, (ApiTemplate<?>)result);
idempotencyRepository.save(newIdempotency);
return result;
}
}
코드는 생각보다 간단하다. 멱등성은 상품 교환 외에 다른 컨텍스트에서도 사용할 수 있기에, 공통 모듈로 분리할 필요가 있다.
Order Endpoint에 @Idempotent를 추가하고 다시 테스트를 확인해보자.
@Test
void 동일한_멱등키로_주문_요청_세_번을_할_경우_한_건만_처리된다() {
// given
SingleOrderRequest orderRequest = new SingleOrderRequest(1L, 1L, 1);
// when
System.out.println("TEST CASE 1 ----------------");
ApiTemplate<Long> firstResponse = requestOrder(orderRequest);
System.out.println("TEST CASE 2 ----------------");
ApiTemplate<Long> secondResponse = requestOrder(orderRequest);
System.out.println("TEST CASE 3 ----------------");
ApiTemplate<Long> thirdResponse = requestOrder(orderRequest);
// then
List<Order> all = orderRepository.findAll();
assertThat(all).hasSize(1);
assertThat(firstResponse.result()).isOne();
assertThat(secondResponse.result()).isOne();
assertThat(thirdResponse.result()).isOne();
}
구분 하기 쉽도록 각 테스트 별로 문구를 추가하자.



테스트 결과 멱등하게 처리가 된 것을 확인할 수 있다.
사실, 이 방식은 멱등성을 보장하기 위한 기법이지만, 100% 완료된 것은 아니다.
2가지의 추가 작업이 더 필요하다.
- 동시성 문제
만약 CPU 스케줄링, 혹은 네트워크 지연으로 인해 사용자가 연달아 여러번 API 요청을 한다면 어떻게 될까?
동시성 문제가 발생할 수 있다.
바로 다음 포스트에서는 멱등성의 동시성을 관리하면서 한계를 개선해보려고 한다.
- 처리 속도와 Resource의 한계
현재 MySQL에서 Idempotency를 관리하므로 DB에는 매요청에 대한 Idempotency 결과가 기록이 된다.
이 정보가 계속해서 관리할 필요가 있을까?
Redis를 활용해서 TTL 설정을 통해 속도도 빠르고, 기간이 만료되면 자동으로 처리하도록 관리할 수도 있다.
해당 내용은 포스트에 작성할 지는 모르겠지만 프로젝트 서버 비용에 따라 적용할 예정이다. MVP에서는 YANGI를 따라, 필요한 만큼만 !