Spring Boot 환경에서의 대규모 트래픽 서비스를 구현하는 것이 목표입니다. 서비스라고 표현했으나 엄밀히 말하면 모듈(API) 개발에 가까운 형태로 진행하려고 합니다.
다음 두 가지 목표를 가지고 개발을 진행합니다.
1. 대규모 트래픽 서비스의 개발
2. 해당 서비스의 효율 향상 방법
Redis의 경우 이 포스트를 참조하시여 Docker로의 실행을 준비해주세요.
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final PurchaseService purchaseService;
//구매 요청
@PostMapping("/purchase")
public Mono<ResponseEntity<ApiResponse<PurchasePayload>>> purchase(
@RequestBody PurchaseRequest purchaseRequest,
ServerWebExchange exchange
) {
return purchaseService.purchase(purchaseRequest, exchange);
}
}
Mono<T>: Return Type의 Mono는 WebFlux에서 제공하는 리액티브 타입 중 하나입니다.ServerWebExchange: WebFlux에서 요청과 응답을 추상화한 컨테이너 인터페이스.HttpServletRequest + HttpServletResponse와 유사한 기능
ApiResponse는 프로젝트 내 응답에서 공통으로 사용되는 응답 형태를 정의한 DTO입니다.public record ApiResponse<T>( String status, String code, String message, T data, Meta meta ) { public static <T> ApiResponse<T> ok(String code, String message, T data, String path) { return new ApiResponse<>("OK", code, message, data, Meta.now(path)); } public static <T> ApiResponse<T> error(String code, String message, String path) { return new ApiResponse<>("ERROR", code, message, null, Meta.now(path)); } public record Meta(Instant timestamp, String path) { public static Meta now(String path) { return new Meta(Instant.now(), path); } } }공통 응답은 다음과 같은 형태로 이루어집니다.
{ "status": "OK", "code": "PURCHASE_SUCCESS", "message": "구매 성공", "data": { "productId": 1, "remainingStock": 99 }, "meta": { "timestamp": "2025-09-01T00:00:01Z", "path": "/api/products/purchase" } }
@Service
@RequiredArgsConstructor
public class PurchaseService {
private final ProductCacheService productCacheService;
//상품 구매 요청 처리
public Mono<ResponseEntity<ApiResponse<PurchasePayload>>> purchase(
PurchaseRequest purchaseRequest,
ServerWebExchange exchange
) {
Long id = purchaseRequest.productId();
String path = exchange.getRequest().getPath().value();
return productCacheService.decrementStockSafely(id)
.flatMap(result -> {
switch (result) {
case SUCCESS:
return productCacheService.getStock(id)
.map(remain -> ResponseEntity.ok(
ApiResponse.ok(
ResultCode.PURCHASE_SUCCESS.name(),
"구매 성공",
new PurchasePayload(id, remain),
path
)
));
case OUT_OF_STOCK:
return Mono.just(ResponseEntity.status(409).body(
ApiResponse.error(ResultCode.OUT_OF_STOCK.name(), "품절", path)));
case NO_STOCK_KEY:
default:
return Mono.just(ResponseEntity.status(500).body(
ApiResponse.error(ResultCode.NO_STOCK_KEY.name(), "재고 키 없음", path)));
}
});
}
}
구매 요청을 처리하는 서비스 클래스/메소드 입니다. 구매 요청 후 Redis에서 원자적으로
안전하게 재고를 감소시키는 부분은 ProductCacheService에서 처리하도록 되어 있습니다.
ProductCacheService.decrementStockSafely의 결과에 따라서 성공, 실패(재고 없음 or 키 없음)의 결과를 Mono 객체로 반환합니다.
Mono.just()는 하나의 값을 Warp한 Mono 객체를 생성하는 Factory Method입니다.
위 예제에서는 각 상황에 맞는 ResponseEntity를 감싼 Mono 객체를 반환하게 됩니다.
@Service
@RequiredArgsConstructor
public class ProductCacheService {
private final ReactiveStringRedisTemplate reactiveStringRedisTemplate;
private final ObjectMapper objectMapper;
//테스트 시작 전 재고 없는 경우 초기화
//redis-cli에서 SET stock:product:를 통해 직접 삽입도 가능
public Mono<Boolean> initStockIfAbsent(Long id, Long stock) {
String stockKey = "stock:product:" + id;
return reactiveStringRedisTemplate.opsForValue()
.setIfAbsent(stockKey, String.valueOf(stock));
}
//구매 서비스 수행 후 재고 감소
public Mono<DecrementResult> decrementStockSafely(Long id) {
String stockKey = "stock:product:" + id;
String lua = """
local k=KEYS[1]
local v=redis.call('GET', k)
if (not v) then return -1 end
v=tonumber(v)
if (v <= 0) then return 0 end
redis.call('DECR', k)
return 1
""";
RedisScript<Long> script = RedisScript.of(lua, Long.class);
return reactiveStringRedisTemplate
.execute(script, List.of(stockKey), List.of())
.single()
.map(v -> ((Number) v).intValue())
.map(code -> {
switch (code) {
case 1:
return DecrementResult.SUCCESS;
case 0:
return DecrementResult.OUT_OF_STOCK;
default:
return DecrementResult.NO_STOCK_KEY;
}
});
}
//재고 조회
public Mono<Long> getStock(Long id) {
String key = "stock:product:" + id;
return reactiveStringRedisTemplate.opsForValue()
.get(key)
.switchIfEmpty(Mono.error(new NoStockKeyException("재고 키 없음: " + key)))
.map(Long::parseLong);
}
}
public Mono<DecrementResult> decrementStockSafely(Long id) {
String stockKey = "stock:product:" + id;
//다수 요청에도 원자적 실행을 보장하기 위한 lua 스크립트
String lua = """
local k=KEYS[1] //첫 번째 키 가져오기 stock:product:{id}
local v=redis.call('GET', k) //해당 key 값 조회(재고 조회)
if (not v) then return -1 end //key 값 없으면 -1 반환 (재고 키 없음)
v=tonumber(v) //문자열 값을 숫자로 변환
if (v <= 0) then return 0 end //값이 0이하면 0 반환 (품절)
redis.call('DECR', k) //값이 1 이상이면 DECR 수행 (재고 1 감소)
return 1 //성공 1 반환
""";
RedisScript<Long> script = RedisScript.of(lua, Long.class);
return reactiveStringRedisTemplate
.execute(script, List.of(stockKey), List.of())
.single()
.map(v -> ((Number) v).intValue())
.map(code -> {
switch (code) {
case 1:
return DecrementResult.SUCCESS;
case 0:
return DecrementResult.OUT_OF_STOCK;
default:
return DecrementResult.NO_STOCK_KEY;
}
});
}
구매 요청 처리 과정에서 동시성 문제를 막으면서 안전하게 재고 감소를 하기 위한 메소드입니다.
Lua 스크립트를 사용하여 재고 감소 처리가 Redis 내에서 원자적으로 수행되도록 설정이렇게 준비하면 당장은 Redis + WebFlux를 이용한 대규모 트래픽 처리 모듈을 가장 간단하게 구현(효율 신경 X)했습니다. 다음 포스트에서는 JMeter를 통해 실제로 테스트를 수행해보도록 하겠습니다.