Redis + Spring WebFlux 대규모 트래픽 처리 시스템

Bam·2025년 9월 6일
0

projects

목록 보기
5/6
post-thumbnail

미니 프로젝트 목표

Spring Boot 환경에서의 대규모 트래픽 서비스를 구현하는 것이 목표입니다. 서비스라고 표현했으나 엄밀히 말하면 모듈(API) 개발에 가까운 형태로 진행하려고 합니다.

다음 두 가지 목표를 가지고 개발을 진행합니다.
1. 대규모 트래픽 서비스의 개발
2. 해당 서비스의 효율 향상 방법


주요 사용 기술

  • Spring Webflux: 대규모 트래픽 처리 시스템 구현을 위한 Spring 진영의 프레임워크
  • Redis: 고속 처리를 위한 인메모리 기반 DB
  • Apache JMeter: 대규모 트래픽 테스트를 위한 테스트/부하 도구

Redis의 경우 이 포스트를 참조하시여 Docker로의 실행을 준비해주세요.


프로젝트 코드 구조

Controller

@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

PurchaseService

@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 객체를 반환하게 됩니다.

ProductCacheService

@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);
    }
}

decrementStockSafely

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를 통해 실제로 테스트를 수행해보도록 하겠습니다.

0개의 댓글