Overview
이번 글에서는 키즈핑 프로젝트에서 선착순 응모 시스템을 개발하는 과정에서 겪었던 다양한 이슈와, 이를 어떻게 해결해 나갔는지에 대해 이야기하려고 합니다.
선착순 응모 시스템 요구사항 및 목표
주어진 요구사항은 다음과 같습니다.
- 회원은 응모 페이지에 접속 후 이름, 전화번호를 입력 후 선착순 100명 이벤트에 응모한다.
- 사용자는 중복 응모를 못한다.
- 다음날 오후 1시에 응모 페이지에서 이벤트 당첨 여부를 확인한다.
- 응모 페이지는 매일 오후 1시에 가장 많은 트래픽을 받는다. (1분에 10만 요청을 10분간)
이러한 요구사항을 분석하여 쿠폰 100개 발급을 보장하고, 높은 트래픽을 견디는 시스템 구축을 목표로 삼았습니다. 초기 응모 시스템의 흐름은 쿠폰 요청을 받으면, DB에서 현재 쿠폰 개수를 조회한 뒤 100개 이하인 경우 DB에 저장하는 방식이었습니다.
쿠폰 개수 조회에 대한 동시성 이슈
이러한 로직을 바탕으로 1000명의 사용자가 이벤트를 동시에 응시하는 테스트 코드를 실행했습니다.
실행결과 쿠폰 개수가 100개를 초과하는 문제가 발생했습니다.
@Test
@DisplayName("동시에 1000명의 사용자가 이벤트 응모 시 중복 없이 100개의 쿠폰이 발급되는지 테스트")
public void testConcurrentCouponApplicationWith1000Users_shouldIssueOnly100Coupons() throws InterruptedException {
int threadCount = 1000;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
int startUserId = 3002;
for (int i = 0; i < threadCount; i++) {
Long userId = (long) (startUserId + i);
executorService.submit(() -> {
try {
ApplyCouponRequest applyCouponRequest = applyCoupon(1L, userId, "이름" + userId, "번호" + userId);
couponService.applyCoupon(applyCouponRequest);
} finally {
latch.countDown();
}
});
}
latch.await();
Thread.sleep(2000);
long count = couponRepository.count();
assertThat(count).isEqualTo(100);
}
쿠폰 개수 조회 시, 동시성 이슈 발생 원인은?
이러한 문제가 발생하는 이유는 쿠폰 개수 조회 시점과 발급 시점이 달랐기 때문입니다.
멀티스레드 환경에서는 이러한 공유 자원에 대해 race condition 문제가 발생할 수 있습니다.
이로 인해 쿠폰 개수 검증을 통과해 101개 이상의 쿠폰이 발급되는 상황이 생길 수 있습니다.
좀 더 자세한 예시를 들어 설명해 보겠습니다.
초기 예상 시나리오
초기 예상 시나리오는 보이는 이미지와 같이 먼저 스레드 1이 쿠폰 개수를 조회해 오고 쿠폰을 저장합니다. 그러면 스레드 2가 쿠폰 개수를 조회할 때는 쿠폰의 개수가 100개 이므로 쿠폰 개수 검증을 통과하지 못해 쿠폰을 발급받지 못합니다.
실제 동작
하지만 실제 멀티스레드 환경에서는 스레드 1과 스레드 2가 동시에 쿠폰 개수를 조회할 수 있습니다.
이로 인해 두 스레드 모두 쿠폰 개수 검증을 통과하게 되고, 각각 쿠폰을 저장하여 최종적으로 총 쿠폰 개수가 101개가 되는 문제가 발생할 수 있습니다.
동시성 문제 해결하기
현재 로직에서 동시성 이슈의 근본적인 원인은 발급된 쿠폰 개수의 정합성 관리에 있습니다.
좀 더 자세히 얘기하면 발급된 쿠폰의 개수를 조회하는 로직과 실제 쿠폰을 발급하는 로직 간의 시점 차이로 인해 동시성 문제가 발생하고 있습니다. 따라서 발급된 쿠폰 개수의 정확성을 보장하기만 하면 race condition을 효과적으로 방지할 수 있습니다.
어떻게 해결할 수 있을까? Redis!
Redis는 INCR 명령어를 통해 특정 키의 값을 1씩 증가시키는 기능을 제공합니다. 이 INCR 명령어는 시간 복잡도가 O(1)로 매우 빠르며, Redis가 싱글 스레드로 동작하기 때문에 동시성 이슈로 인한 경쟁 조건을 방지할 수 있습니다. 따라서 Redis의 INCR 명령어를 사용해 쿠폰 개수를 제어하면, 높은 성능을 유지하면서도 데이터의 정합성을 보장할 수 있습니다.
문제 해결을 위해 Redis의 INCR 명령어를 사용하는 방식으로 코드를 수정했습니다.
수정된 코드를 기반으로 테스트를 다시 실행한 결과, 정상적으로 테스트가 통과하는 것을 확인할 수 있었습니다.
@Repository
@RequiredArgsConstructor
public class CouponRedisRepository {
private static final String EVENT_COUPON_COUNT = "EVENT:COUPON:COUNT:";
private final RedisTemplate<String, String> redisTemplate;
public Long increment(Long eventId) {
String eventKey = EVENT_COUPON_COUNT + eventId;
return redisTemplate
.opsForValue()
.increment(eventKey);
}
}
@Override
public void applyCoupon(ApplyCouponRequest applyCouponRequest) {
Long count = couponRedisRepository.increment(applyCouponRequest.getEventId());
if (count > 100) {
return;
}
User user = userRepository.findById(applyCouponRequest.getUserId())
.orElseThrow(() -> new RuntimeException("no event"));
Event event = eventRepository.findById(applyCouponRequest.getEventId())
.orElseThrow(() -> new RuntimeException("no event"));
log.info("user.getId() {}", user.getId());
log.info("event.getId() {}", event.getId());
Coupon coupon = Coupon.builder()
.user(user)
.event(event)
.name(applyCouponRequest.getName())
.phone(applyCouponRequest.getPhone())
.build();
couponRepository.save(coupon);
}
중복된 사용자의 검증에 대한 이슈
이번에는 동일한 사용자가 여러 번 중복 요청을 했을 때 쿠폰이 한 번만 발급되는지 테스트를 진행했습니다. 그러나 테스트 결과, 동일 사용자에게 쿠폰이 한 번만 발급되는 것이 아니라 총 100개가 발급되어 테스트가 실패했습니다. 실제 DB에서도 동일 사용자에게 100개의 쿠폰이 발급된 것을 확인할 수 있었습니다.
@Test
@DisplayName("동일한 사용자가 중복 요청 시 단일 쿠폰만 발급되는지 검증")
public void shouldIssueOnlyOneCouponPerUserWhenMultipleRequestsAreMade() throws InterruptedException {
ApplyCouponRequest applyCouponRequest = applyCoupon(1L, 1L, "이름2", "번호2");
int threadCount = 1000;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
couponService.applyCoupon(applyCouponRequest);
} finally {
latch.countDown();
}
});
}
latch.await();
Thread.sleep(2000);
long count = couponRepository.count();
assertThat(count).isEqualTo(1);
}
이러한 문제를 해결하기 위해서는 유저 id 별로 쿠폰 발급 개수를 1개로 제한하기만 하면 됩니다.
이럴 때 사용 가능한 set이라는 자료구조가 있습니다. set은 값을 유니크하게 저장할 수 있는 자료구조입니다.
따라서 값을 2번 저장해도 1개만 남는 특성을 가지고 있습니다.
Redis Set 자료구조 활용하기
Redis의 set 자료구조를 활용하여 요소의 중복 여부를 판단할 수 있습니다.
즉 Redis는 set 자료구조를 지원하므로 쿠폰 발급 여부를 레디스에 적재하여 확인하는 것입니다.
Redis는 싱글 스레드 기반 환경에서 동작하므로 멀티 스레드 환경에서 중복 발행을 고려하지 않아도 됩니다.
또한 Redis Set에 값을 추가하는 sadd 명령어는 O(1)의 시간복잡도를 갖으므로 성능 저하에 대한 부담도 줄어듭니다. (출처 : http://redisgate.kr/redis/command/sadd.php)
그래서 문제 해결을 위해 Redis의 set 연산을 사용하는 방식으로 코드를 수정했습니다.
@Repository
@RequiredArgsConstructor
public class CouponRedisRepository {
private static final String EVENT_KEY_PREFIX = "EVENT_KEY_";
private final RedisTemplate<String, String> redisTemplate;
public Long add(Long eventId, Long userId) {
String eventKey = EVENT_KEY_PREFIX + eventId;
return redisTemplate
.opsForSet()
.add(eventKey, userId.toString());
}
...
}
서비스 로직에는 아래 검증 로직을 추가합니다.
@Override
public void applyCoupon(ApplyCouponRequest applyCouponRequest) {
Long apply = couponRedisRepository.add(applyCouponRequest.getEventId(), applyCouponRequest.getUserId());
if (apply != 1) {
return;
}
Long count = couponRedisRepository.increment(applyCouponRequest.getEventId());
if (count > 100) {
return;
}
User user = userRepository.findById(applyCouponRequest.getUserId())
.orElseThrow(() -> new RuntimeException("no event"));
Event event = eventRepository.findById(applyCouponRequest.getEventId())
.orElseThrow(() -> new RuntimeException("no event"));
log.info("user.getId() {}", user.getId());
log.info("event.getId() {}", event.getId());
Coupon coupon = Coupon.builder()
.user(user)
.event(event)
.name(applyCouponRequest.getName())
.phone(applyCouponRequest.getPhone())
.build();
couponRepository.save(coupon);
}
다시 테스트를 하기 위해 redis에 저장되어 있는 쿠폰 개수를 초기화 해주는 작업을 해줍시다
수정된 코드를 기반으로 테스트를 다시 실행한 결과, 정상적으로 테스트가 통과하는 것을 확인할 수 있었습니다.
DB에도 동일 사용자에게 하나의 쿠폰만 저장이 된 걸 확인했습니다.
DB 부하에 대한 이슈
지금까지 Redis를 도입하고 싱글 스레드의 특성을 통해 멀티 스레드 환경에서 쿠폰 개수의 값을 보장하고 중복 사용자에 대한 검증을 처리했습니다 이번에는 기존 문제 해결 방 안에서 추가적으로 발생할 수 있는 문제를 알아보겠습니다.
- 응모 페이지는 매일 오후 1시에 가장 많은 트래픽을 받는다. (1분에 10만 요청을 10분간)
주어진 요구사항 중에 하나는 많은 트래픽을 처리하길 요구했습니다.
현재 쿠폰 발급 로직을 살펴보면 다음과 같습니다.
- Redis에 현재 발행된 쿠폰의 개수를 조회한다(항상 최근의 개수를 조회하도록 보장)
- 발급이 가능한지 확인한다.(e.g. 발행된 쿠폰의 개수가 100개 이하인지, 중복 사용자인지)
- 발급이 가능하다면 데이터베이스에 쿠폰을 저장하여 발급한다.
// 쿠폰 발급 로직
@Override
public void applyCoupon(ApplyCouponRequest applyCouponRequest) {
Long apply = couponRedisRepository.add(applyCouponRequest.getEventId(), applyCouponRequest.getUserId());
if (apply != 1) {
return;
}
Long count = couponRedisRepository.increment(applyCouponRequest.getEventId());
if (count > 100) {
return;
}
...
// 발급이 가능한 경우 -> 쿠폰 새로 생성(발급) -> MySQL에 저장
couponRepository.save(coupon);
}
여기서 발생할 수 있는 문제는 데이터베이스에 직접 쿠폰을 발급하면서 발생합니다.
선착순 이벤트 특성상 짧은 시간에 순간적으로 트래픽이 몰리고 발급하는 쿠폰의 개수가 많아질수록 데이터베이스에 부하를 주게 됩니다. 만일 다양한 서비스에 운영 중인 RDB에 위와 같이 쿠폰을 발급한다면 다른 서비스로 장애가 이어질 수도 있습니다.
좀 더 자세한 예시로 설명을 하겠습니다.
예시
현재 mysql은 1분에 100개의 insert가 가능하다고 가정해 보겠습니다.
그리고 아래 이미지와 같이 요청이 들어오는 상황을 가정해 보겠습니다.

첫 번째로 발생할 수 있는 문제는 쿠폰 생성 요청이 오래 걸리고 타임아웃 정책에 따라 일부는 발급이 누락될 수 있습니다. 현재 가정한 데이터베이스의 스펙은 1분에 100개의 Insert만 가능하므로 총 10000개의 쿠폰을 생성하려면 100분이 소요됩니다.
또한 쿠폰 생성 요청 이후에 들어온 주문 생성과 회원가입 요청은 무려 100분 뒤에 처리됩니다. 심지어 타임아웃 정책에 따라 누락될 수 있습니다. 결과적으로 짧은 시간에 순간적으로 많은 요청을 전달한다면 DB에 부하로 이어질 수 있고 서비스 지연 혹은 오류로 이어질 것입니다.

부하테스트
실제로 어느 정도의 부하가 발생하는지 알기 위해 부하 테스트를 진행해 봤습니다.
테스트는 K6를 사용하여 진행하였습니다.
스크립트 실행(coupon-apply-script.js)
import http from 'k6/http';
import {check, sleep} from 'k6';
export const options = {
stages: [
{duration: '10s', target: 30000},
{duration: '10s', target: 30000},
{duration: '10s', target: 20000},
{duration: '10s', target: 10000},
{duration: '10s', target: 5000},
{duration: '10s', target: 5000},
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% 요청이 500ms 이내에 응답
http_req_failed: ['rate<0.01'], // 에러율 1% 미만
},
};
// `uuidv4()` 함수로 무작위 phone 번호 생성
function generatePhoneNumber() {
return '010-' + String(Math.floor(1000 + Math.random() * 9000)) + '-' + String(Math.floor(1000 + Math.random() * 9000));
}
export default function () {
// 요청 본문 데이터
const userId = __VU; // `__VU`: 현재 가상 사용자 ID를 사용하여 고유 ID로 설정
const payload = JSON.stringify({
userId: userId,
eventId: 1, // 테스트할 이벤트 ID
name: '테스트유저' + userId,
phone: generatePhoneNumber(),
});
// POST 요청 헤더
const headers = {
'Content-Type': 'application/json',
};
// POST 요청 전송
const response = http.post('http://localhost:8080/api/coupons/apply', payload, {headers});
check(response, {
'is OK': (r) => r.status === 200,
'response contains success message': (r) => typeof r.body === 'string',
});
sleep(1); // 1초 휴식
}
실제 이벤트 응모 상황을 가정하여, 트래픽 급증과 감속 패턴을 시뮬레이션할 수 있도록 stages를 아래와 같이 설정했습니다.
stages: [
{duration: '10s', target: 30000},
{duration: '10s', target: 30000},
{duration: '10s', target: 20000},
{duration: '10s', target: 10000},
{duration: '10s', target: 5000},
{duration: '10s', target: 5000},
],
총 2번의 부하 테스트를 진행했고 진행 결과는 다음과 같습니다.
주요 분석 포인트
- 성공률과 에러율:
- 성공률: 둘 다 약 89%의 성공률을 기록했습니다.
- 에러율 (http_req_failed): 에러율은 약 10%로, 요청이 과부하 상태에서 실패한 것으로 추측됩니다.
- 응답 시간 (http_req_duration):
- 상위 95% 응답 시간: 대략 950ms ~ 1.2s 이내에 응답함을 확인했습니다. 일부 요청에서는 최대 1분까지 대기 시간이 발생했습니다. 이는 트래픽 증가로 인해 서버가 부하를 감당하지 못한 현상으로 보입니다.
모니터링 툴을 통해 정확히 어떤 부분에서 부하가 발생하는지 확인하지는 못했지만, 테스트 결과를 통해 서버나 데이터베이스(DB)에서 병목 현상이 발생할 가능성을 고려하게 되었습니다. 스케일 아웃이나 로드밸런싱을 통해 서버 부하를 분산하는 테스트도 해보면 좋겠지만, 생각보다 간단하지 않아 우선적으로 DB에 대한 부하를 줄이는 방향으로 접근하기로 했습니다.
이에 따라 DB에 대한 직접적인 커넥션을 모두 끊고, 쿠폰 발급 정보를 우선 Redis에 저장하도록 로직을 수정했습니다. 이후 스케줄러를 사용하여 새벽 시간에 Redis에 저장된 데이터를 DB에 저장하는 방식으로 변경하여 DB 부하를 줄이고자 했습니다.
// 쿠폰 요청 데이터를 Redis에 저장
public void saveApplyCoupon(ApplyCouponRequest request) {
String couponKey = EVENT_KEY + request.getEventId() + USER_KEY + request.getUserId();
hashOperations.put(couponKey, "userId", request.getUserId().toString());
hashOperations.put(couponKey, "eventId", request.getEventId().toString());
hashOperations.put(couponKey, "name", request.getName());
hashOperations.put(couponKey, "phone", request.getPhone());
}
@Override
public void applyCoupon(ApplyCouponRequest applyCouponRequest) {
Long isAlreadyApplied = couponRedisRepository.verifyParticipation(applyCouponRequest.getEventId(),
applyCouponRequest.getUserId());
if (isAlreadyApplied != 1) {
return;
}
Long currentCouponCount = couponRedisRepository.incrementIssuedCouponCount(applyCouponRequest.getEventId());
if (currentCouponCount > 100) {
return;
}
couponRedisRepository.saveApplyCoupon(applyCouponRequest);
}
@Slf4j
@Component
@RequiredArgsConstructor
public class CouponScheduler {
...
@Scheduled(cron = "0 0 2 * * *")
@Transactional
public void saveRedisDataToDatabase() {
Set<String> keys = redisTemplate.keys("EVENT:*USER:*");
if (keys != null) {
for (String key : keys) {
Map<Object, Object> data = redisTemplate.opsForHash().entries(key);
try {
Long eventId = extractEventId(key);
Long userId = extractUserId(key);
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException());
Event event = eventRepository.findById(eventId)
.orElseThrow(() -> new EventNotFoundException());
Coupon coupon = Coupon.builder()
.user(user)
.event(event)
.name((String) data.get("name"))
.phone((String) data.get("phone"))
.build();
couponRepository.save(coupon);
// 데이터베이스에 저장이 완료되면 Redis 데이터 삭제
couponRedisRepository.deleteCouponKeys(eventId, userId);
} catch (Exception e) {
log.error("[saveRedisDataToDatabase] Error processing key {}: {}", key, e.getMessage());
throw e;
}
}
}
}
...
}
부하 테스트
결과적으로, 요청 수가 286,647 → 405,155 으로 41% 증가했음에도 불구하고, 성공률은 89.04%에서 97.96%로 향상 되었으며, 평균 응답 시간은 534.79ms에서 159.16ms로 약 70% 개선 되었습니다.
이번 개선은 성능 자체의 향상보다는 DB에 대한 부하를 줄이는 데 중점을 두었습니다. 트래픽이 급증하는 상황에서 DB에 직접 저장하는 방식 대신, Redis에 임시로 데이터를 저장한 후 스케줄러를 통해 주기적으로 DB에 반영하는 방식으로 변경하였습니다. 이 방식은 실시간으로 DB에 부담이 가는 것을 방지하고, 시스템이 고부하 상황에서도 안정적으로 동작할 수 있도록 설계 했습니다. 결과적으로 DB 부하가 완화되고, 응답 속도와 성공률이 개선된 것입니다.
응답 속도와 DB 부하를 줄이기 위한 캐싱 적용
마지막으로 해결해야 할 이슈는 캐싱입니다.
가장 많은 트래픽이 몰릴 API는 응모 신청 API이지만, 응모 페이지로 접근하기 전에 호출되는 API와 당첨자 조회 API에도 상당한 트래픽이 몰릴 것으로 예상했습니다. 이러한 API들이 매번 DB와 연결되어 데이터를 조회하면, 반복적인 DB 조회로 인한 부하와 응답 시간 지연이 우려되었습니다. 특히 당첨자 조회 API는 높은 트래픽이 예상되어 더욱 문제 될 가능성이 있었습니다. 이를 해결하기 위해 응모 페이지 접근 전 호출되는 API와 당첨자 조회 API에 캐싱을 적용하여 DB 부하를 줄이고 응답 속도를 개선하고자 했습니다.
redis 캐시
응모 페이지 접근 전에 호출되는 이벤트 목록 조회 API와 이벤트 상세 조회 API에 캐싱을 적용했습니다.
Redis 캐시를 사용한 이유는 분산 서버 환경을 고려했기 때문입니다. 이벤트가 새로 추가되거나 정보가 변경될 때, 모든 서버에서 최신 정보를 일관되게 반영할 수 있도록 Redis 공유 캐시를 활용했습니다.
TTL(Time-To-Live)은 이벤트 기간에 맞춰 설정했습니다. 예를 들어, 이벤트가 일주일 동안 진행될 경우 TTL을 일주일로 설정하고, 하루 동안 진행되는 이벤트의 경우 TTL을 하루로 설정하는 방식입니다. 이를 통해 이벤트 기간 동안만 캐시가 유효하게 유지되도록 했습니다.
@Slf4j
@RestController
@RequestMapping("/api/events")
@RequiredArgsConstructor
public class EventController {
private final EventService eventService;
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<GetEventResponse>> getEvent(@PathVariable Long id) {
GetEventResponse response = eventService.getEvent(id);
return ApiResponse.ok(ExceptionCode.OK.getCode(), response, ExceptionCode.OK.getMessage());
}
@GetMapping
public ResponseEntity<ApiResponse<Page<GetEventResponse>>> getAllEvents(
@RequestParam(name = "page", defaultValue = "1") int page,
@RequestParam(name = "size", defaultValue = "10") int size) {
CachedPage<GetEventResponse> response = eventService.getAllEvents(page, size);
return ApiResponse.ok(ExceptionCode.OK.getCode(), response, ExceptionCode.OK.getMessage());
}
...
}
@Slf4j
@Service
@RequiredArgsConstructor
public class EventServiceImpl implements EventService {
private final EventRepository eventRepository;
@Override
@Cacheable(value = "eventPagesCache", key = "#id")
public GetEventResponse getEvent(Long id) {
Event event = eventRepository.findById(id)
.orElseThrow(() -> new EventNotFoundException());
return GetEventResponse.of(event);
}
@Override
@Cacheable(value = "eventPagesCache", key = "'events:page:' + #p0 + ':size:' + #p1")
public CachedPage<GetEventResponse> getAllEvents(int page, int size) {
...
return new CachedPage<>(eventResponses, eventPage.getNumber(), eventPage.getSize(),
eventPage.getTotalElements());
}
}
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Value("${spring.cache.redis.time-to-live}")
private Duration defaultTtl;
@Value("${spring.cache-ttl.eventPagesCacheTtl}")
private Duration eventPagesCacheTtl;
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator
.builder()
.allowIfSubType(Object.class)
.build();
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.activateDefaultTyping(typeValidator, ObjectMapper.DefaultTyping.NON_FINAL);
RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(defaultTtl)
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) // key: string
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper))); // value: json
// 각 캐시별 TTL 설정을 읽어오는 Map
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
cacheConfigurations.put("eventPagesCache", cacheConfig.entryTtl(eventPagesCacheTtl));
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(cacheConfig)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
}
로컬 캐시
현재 DB에서 조회하는 이벤트 당첨자 데이터는 변경되지 않는 고정된 데이터입니다. 사용자는 단순히 캐시에 저장된 당첨자 정보를 조회하기만 하며, DB의 당첨자 데이터는 변동이 없습니다. 따라서 데이터 일관성 요구가 낮고, 캐시는 매일 정해진 시간에만 업데이트되며 조회 작업만 수행되는 상황입니다.
이러한 이유로 이벤트 당첨자 데이터는 변경될 가능성이 없어, 공유 캐시가 필요하지 않다고 판단하여 로컬 캐시를 적용했습니다.
CouponController
@Slf4j
@RestController
@RequestMapping("/api/coupons")
@RequiredArgsConstructor
public class CouponController {
private final CouponService couponService;
...
@GetMapping("/winners/check")
public ResponseEntity<ApiResponse<CheckWinnerResponse>> checkWinner(
@RequestParam("eventId") Long eventId,
@RequestParam("userId") Long userId) {
CheckWinnerRequest request = CheckWinnerRequest.builder().eventId(eventId).userId(userId).build();
CheckWinnerResponse response = couponService.isWinnerInCache(request);
String message = response.isWinningYn() ? "축하합니다! 이벤트에 당첨되셨습니다." : "이벤트에 당첨되지 않았습니다.";
return ApiResponse.ok(ExceptionCode.OK.getCode(), response, message);
}
}
CouponServiceImpl
@Slf4j
@Service
@RequiredArgsConstructor
public class CouponServiceImpl implements CouponService {
private final CouponWinnerCacheRepository couponWinnerCacheRepository;
...
@Override
public CheckWinnerResponse isWinnerInCache(CheckWinnerRequest request) {
Long userId = request.getUserId();
Long eventId = request.getEventId();
boolean isWinner = couponWinnerCacheRepository.findWinnersIfAbsent(eventId, userId);
return CheckWinnerResponse.of(isWinner);
}
}
CouponWinnerCacheRepository
@Slf4j
@Component
@RequiredArgsConstructor
public class CouponWinnerCacheRepository {
private final ConcurrentHashMap<Long, Set<Long>> couponWinnerCacheStore = new ConcurrentHashMap<>();
private final CouponRepository couponRepository;
public boolean findWinnersIfAbsent(Long eventId, Long userId) {
if (couponWinnerCacheStore.isEmpty()) {
synchronized (this) {
if (couponWinnerCacheStore.isEmpty()) {
updateTodayWinners();
}
}
}
return couponWinnerCacheStore.containsKey(eventId) && couponWinnerCacheStore.get(eventId).contains(userId);
}
// 새벽 2시에 당첨자 캐시 초기화
@Scheduled(cron = "0 0 2 * * *")
private void clearCouponWinnerCache() {
couponWinnerCacheStore.clear();
log.info("Coupon winner cache has been cleared at 2 AM.");
}
// 새벽 2시 30분에 오늘 날짜 당첨자를 저장
@Scheduled(cron = "0 30 2 * * *")
private void updateTodayWinners() {
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
LocalDateTime currentTime = LocalDateTime.now();
List<Coupon> winners = couponRepository.findCouponsCreatedTodayBeforeNow(startOfDay,
currentTime);
for (Coupon coupon : winners) {
Long eventId = coupon.getEvent().getId();
Long userId = coupon.getUser().getId();
couponWinnerCacheStore
.computeIfAbsent(eventId, k -> new ConcurrentSkipListSet<>())
.add(userId);
}
}
}
(updateTodayWinners()에 대한 동시성 문제는 아래 글을 참고해 주시면 감사하겠습니다.
https://an-jjin.tistory.com/171 )
부하 테스트
캐싱 적용 전후로 부하 테스를 진행했습니다.
캐싱 전
캐싱 후
결과를 비교하면 캐싱 적용 전에는 성공률이 84%였으나, 캐싱 적용 후에는 성공률이 97%로 개선되었습니다.
또한, 최대 응답 시간이 2.7초에서 약 500ms로 개선되었습니다.
최종 시스템 아키텍처
마지막으로, 선착순 응모 시스템의 아키텍처를 설명드리겠습니다.
이 시스템의 핵심 요소는 Redis를 활용하여 동시성 문제를 해결하고, 이를 통해 DB 부하를 줄인 점입니다.
또한, 이벤트 응모 페이지에 접근하기 전 호출되는 API들에 캐싱을 적용하여 불필요한 DB 요청을 최소화했습니다.
이러한 구조를 통해 시스템의 성능과 안정성을 더욱 높일 수 있었습니다.
참고
https://mangkyu.tistory.com/370
https://1-7171771.tistory.com/138
https://engineering.linecorp.com/ko/blog/atomic-cache-stampede-redis-lua-script
'프로젝트 > Kidsping' 카테고리의 다른 글
트러블 슈팅 - 동시성 문제와 Double-Checked Locking 패턴 적용 (0) | 2024.11.04 |
---|---|
트러블 슈팅 - AWS 프리티어 EC2 인스턴스 메모리 부족 현상 해결하기 (0) | 2024.10.25 |
개발 기록 - N + 1 문제 fetch join, Batch Size로 해결 (0) | 2024.10.24 |
개발 기록 - Java에서 Enum 의 비교는 '==' 인가? 'equals' 인가? (0) | 2024.10.22 |
개발 기록 - 자녀 성향 진단 로직 구현 및 리팩터링 (0) | 2024.10.22 |