문제 상황
키즈핑 프로젝트를 진행하면서 이벤트 당첨자 조회에 캐싱을 적용하기로 했습니다.
이에 따라, ConcurrentHashMap을 사용해 로컬 캐시를 직접 구현했습니다.
아래 코드의 findWinner 메서드는 couponWinnerCacheStore에 캐시가 비어 있는 경우 DB에서 데이터를 가져와 couponWinnerCacheStore에 당첨자를 저장하여 DB와 캐시 데이터 간의 정합성을 유지하도록 설계했습니다.
단일 스레드 환경에서는 findWinner() 메서드를 통한 조회에 문제가 없다고 생각했습니다.
하지만 멀티 스레드 환경에서는 상황이 달라질 수 있습니다.
여러 스레드가 동시에 findWinner() 메서드에 접근할 수 있기 때문에, 결과적으로 updateTodayWinners() 메서드가 여러 번 호출되어 불필요한 DB 쿼리가 발생할 가능성이 생깁니다. 특히 이벤트 당첨자 조회는 트래픽 부하가 심한 상황에서 빈번히 호출되기 때문에, 이런 경우에 불필요한 쿼리가 발생하는 것은 성능에 큰 영향을 미칠 수 있습니다.
jmeter로 테스트
실제 불필요한 쿼리가 발생하는지 jmeter로 테스트를 진행 했습니다.
스레드는 2000개를 생성하여 테스트를 진행 했습니다.
콘솔에 찍힌 로그를 확인하니 220개의 쿼리가 발생한것을 확인 했습니다.
이에 따라, 멀티 스레드 환경에서도 추가적인 쿼리가 발생하지 않도록 쿼리 호출을 최소화할 방법을 고민하게 되었습니다.
@Slf4j
@Component
public class CouponWinnerCacheRepository {
private final ConcurrentHashMap<Long, Set<Long>> couponWinnerCacheStore = new ConcurrentHashMap<>();
...
public boolean findWinnersIfAbsent(Long eventId, Long userId) {
if (couponWinnerCacheStore.isEmpty()) {
updateTodayWinners();
}
return couponWinnerCacheStore.containsKey(eventId) && couponWinnerCacheStore.get(eventId).contains(userId);
}
...
// 새벽 2시 30분에 오늘 날짜 당첨자를 저장
@Scheduled(cron = "0 30 2 * * *")
public 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() 호출 부분에 임계 영역을 설정하여 문제를 해결하는 것입니다.
updateTodayWinners() 호출에 임계 영역을 만들면 여러 스레드가 동시에 접근하는 것을 방지할 수 있고, 그 결과 하나의 스레드만 호출하게 되어 불필요한 DB 쿼리 발생을 막을 수 있습니다.
public boolean findWinnersIfAbsent(Long eventId, Long userId) {
if (couponWinnerCacheStore.isEmpty()) {
updateTodayWinners();
}
return couponWinnerCacheStore.containsKey(eventId) && couponWinnerCacheStore.get(eventId).contains(userId);
}
그래서 이러한 부분에 대해 임계 영역을 만들기 위해 락을 사용하기로 결정했습니다.
락을 사용한다면 크게 3가지 방식으로 사용할 수 있습니다.
- 애플리케이션 레벨에서의 락
- DB 락
- 분산 락
각 락 옵션에 대한 장단점을 알아보겠습니다.
1. 애플리케이션 레벨에서의 락 (예: ReentrantLock 또는 synchronized)
- 장점: 구현이 간단하고 성능 저하가 적음. JVM 내에서 관리되므로 설정이 쉬움.
- 단점: 단일 인스턴스의 애플리케이션에서만 유효. 만약 여러 서버 인스턴스가 있으면, 락이 다른 인스턴스에는 적용되지 않으므로 동일한 로직이 중복 실행될 수 있음.
2. DB 락 (예: MySQL의 SELECT FOR UPDATE, row-level locking)
- 장점: 데이터베이스에서 락을 관리하므로, 다중 서버 환경에서도 락이 유지됨. 일반적인 트랜잭션과 함께 사용할 수 있어 일관성 유지가 쉬움.
- 단점: 데이터베이스에 추가적인 락 부하를 발생시켜 성능 저하가 발생할 수 있음. 락 경쟁이 많아지면, DB의 성능에 직접적인 영향을 미칠 수 있음.
3. 분산 락 (예: Redis의 SETNX)
- 장점: 여러 서버 인스턴스에서 락을 공유할 수 있어, 분산 환경에서 중복 작업을 방지할 수 있음. Redis와 같은 분산 락을 사용하면, 특정 시점에 하나의 인스턴스만 작업을 수행하게 보장할 수 있음.
- 단점: Redis와 같은 별도의 시스템이 필요하므로 설정과 관리가 복잡해질 수 있음.
그렇다면 현재 키즈핑 프로젝트에서는 어떤 락을 사용하면 좋을까요?
애플리케이션 레벨에서의 락 사용
현재 DB에서 조회하는 이벤트 당첨자 데이터는 변경되지 않는 고정된 데이터입니다. 사용자는 단순히 couponWinnerCacheStore에 저장된 당첨자 정보를 조회하기만 하고, DB의 당첨자 데이터 자체는 변경되지 않습니다. 즉 데이터 일관성이 크게 요구되지 않고 매일 정해진 시간에 캐시가 업데이트되는 정도이며 조회 작업만 수행되는 상황입니다.
이처럼 이벤트 당첨자 데이터가 변경되지 않고 읽기만 필요한 상황이라면, DB 락이나 분산 락을 사용하는 것은 과도한 선택일 수 있습니다. 따라서 현재 상황에서는 애플리케이션 레벨에서의 락을 사용하는 게 가장 적합한 방식이라고 생각하여 애플리케이션 레벨에서의 락을 사용하기로 결정했습니다.
아래는 좀 더 자세히 이유를 정리했습니다.
이유
- 데이터가 변경되지 않음: 당첨자 데이터가 DB에서 고정된 값으로 유지되므로, 여러 서버 인스턴스에서 읽기 작업을 동기화하는 정도만 필요한 상황입니다.
- 성능 최적화: 분산 락이나 DB 락을 적용할 경우 불필요한 리소스 소비와 성능 저하가 발생할 수 있습니다.
Synchronized vs ReentrantLock
애플리케이션 레벨에서의 락을 사용한다면 크게 2가지 방식을 사용할 수 있습니다.
바로 synchronized와 ReentrantLock 락 방식입니다.
그래서 각 방식의 장단점에 대해 알아보겠습니다.
1. Synchronized의 장점과 단점
장점
- 간단한 구현: synchronized는 Java 언어 수준에서 지원하는 키워드로, 별도의 객체를 생성하지 않아도 되며 코드가 단순해집니다.
- 자동 해제: synchronized 블록은 메서드가 끝나면 자동으로 락이 해제되므로, try-finally 구문이 필요하지 않습니다.
단점
- 유연성 부족: synchronized는 락을 걸 때 타임아웃을 설정하거나 락 획득 여부를 확인할 수 있는 옵션이 없습니다. 한 스레드가 락을 얻지 못할 경우, 계속 대기 상태로 남게 됩니다.
- 재진입 가능하나 일부 제어 기능 부족: synchronized도 재진입은 가능하지만, 명시적으로 잠금 해제와 같은 기능은 없습니다.
2. ReentrantLock의 장점과 단점
장점
- 타임아웃 설정 가능: tryLock(long timeout, TimeUnit unit)을 통해 특정 시간 동안만 락을 대기하도록 설정할 수 있어, 대기 시간이 길어지는 것을 방지할 수 있습니다.
- 유연한 락 제어: ReentrantLock은 명시적으로 lock()과 unlock()을 호출하므로, 복잡한 락 제어가 필요한 경우 더 유연하게 사용할 수 있습니다.
- 공정성 옵션: ReentrantLock은 new ReentrantLock(true)와 같이 공정성을 설정하여, 락 대기 중인 스레드가 공정하게 락을 획득하도록 설정할 수 있습니다.
단점
- 구현 복잡성: ReentrantLock은 try-finally 구문으로 항상 락을 해제해 줘야 하므로, synchronized보다 코드가 복잡해질 수 있습니다.
Synchronized vs ReentrantLock 성능 비교
초기에는 2000개의 스레드를 생성하고 2000 단위로 진행하여 최종적으로 6000개까지 성능 테스트를 진행 했습니다.
Synchronized 성능 테스트
Synchronized 테스트 코드
Double-Checked Locking 패턴을 적용하고 성능 테스트를 진행 했습니다.
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);
}
스레드 2000개
스레드 4000개
스레드 6000개
ReentrantLock 성능 테스트
ReentrantLock 테스트 코드
private final ReentrantLock lock = new ReentrantLock();
public boolean findWinnersIfAbsent(Long eventId, Long userId) {
if (couponWinnerCacheStore.isEmpty()) {
try {
if (lock.tryLock(10, TimeUnit.SECONDS)) {
try {
if (couponWinnerCacheStore.isEmpty()) {
updateTodayWinners();
}
} finally {
lock.unlock();
}
} else {
log.warn("Lock 획득 실패 - 다른 스레드에서 캐시를 업데이트 중");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("Lock 대기 중 인터럽트 발생", e);
}
}
return couponWinnerCacheStore.containsKey(eventId) &&
couponWinnerCacheStore.get(eventId).contains(userId);
}
스레드 2000개
스레드 4000개
tryLock 10ms 대기
tryLock에 대해 10ms를 대기하니 아래와 같이 Lock 획득에 실패하는 문제가 발생 했습니다.
그래서 스레드 4000개부터는 Lock 획득 시간을 조정하여 테스트를 진행 했습니다.
tryLock 100ms 대기
여전히 락 획득 실패 문제가 발생 했습니다.
tryLock 1초 대기
tryLock 5초 대기
tryLock 10초 대기
스레드 6000개
성능비교
우선 스레드 6000개부터는 두 방식 모두 에러율이 많이 발생 했습니다.
그래서 스레드 4000개에서 두 방식의 성능을 비교하면 synchronized 방식이 평균 응답시간, 처리량 부분에서 우수하고 에러율도 압도적으로 synchronized 방식이 낮았습니다.
synchronized
- 평균 응답 시간: 110.33 ms
- 평균 처리량: 5362.33 requests per second
- 평균 에러율: 0.18%
ReentrantLock
- 평균 응답 시간: 154.64 ms
- 평균 처리량: 5232.73 requests per second
- 평균 에러율: 19%
이처럼 평균 응답시간이나 처리량 부분에서 우수하고 에러율도 더 낮은 synchronized를 방식을 선택 했습니다.
synchronized 방식이 ReentrantLock보다 성능이 더 좋게 나온 이유
ReentrantLock은 명시적인 Lock 획득 및 해제 과정에서 추가적인 코드 실행 비용이 발생합니다. 특히, 트래픽이 많아질수록 락 경합(Contention) 비용이 발생하여, 현재 테스트 환경처럼 동시 요청이 많은 경우에는 오버헤드가 더욱 커질 가능성이 큽니다. 따라서 synchronized 방식이 응답 시간, 처리량, 에러율 측면에서 더 우수한 성능을 보인 것으로 판단됩니다.
Connection reset
발생한 오류는 Connection reset 에러였습니다. 이 오류는 클라이언트(JMeter)가 서버로부터 데이터를 모두 받기 전에 연결을 강제로 종료할 때 발생합니다. JMeter는 기본적으로 2초 이내에 응답이 없으면 자동으로 연결을 닫도록 설정되어 있습니다. 이번 테스트에서는 서버의 부하로 인해 응답 시간이 2초를 초과하면서, JMeter가 응답을 기다리지 않고 연결을 강제 종료하여 해당 오류가 발생한 것으로 보입니다.
java.net.SocketException: Connection reset
at java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:318)
at java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:346)
at java.base/sun.nio.ch.NioSocketImpl$1.read(NioSocketImpl.java:796)
at java.base/java.net.Socket$SocketInputStream.read(Socket.java:1099)
at org.apache.http.impl.io.SessionInputBufferImpl.streamRead(SessionInputBufferImpl.java:137)
at org.apache.http.impl.io.SessionInputBufferImpl.fillBuffer(SessionInputBufferImpl.java:153)
at org.apache.http.impl.io.SessionInputBufferImpl.readLine(SessionInputBufferImpl.java:280)
at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:138)
at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:56)
at org.apache.http.impl.io.AbstractMessageParser.parse(AbstractMessageParser.java:259)
at org.apache.http.impl.DefaultBHttpClientConnection.receiveResponseHeader(DefaultBHttpClientConnection.java:163)
at org.apache.http.impl.conn.CPoolProxy.receiveResponseHeader(CPoolProxy.java:157)
at org.apache.http.protocol.HttpRequestExecutor.doReceiveResponse(HttpRequestExecutor.java:273)
at org.apache.http.protocol.HttpRequestExecutor.execute(HttpRequestExecutor.java:125)
at org.apache.http.impl.execchain.MainClientExec.execute(MainClientExec.java:272)
at org.apache.http.impl.execchain.ProtocolExec.execute(ProtocolExec.java:186)
at org.apache.http.impl.execchain.RetryExec.execute(RetryExec.java:89)
at org.apache.http.impl.execchain.RedirectExec.execute(RedirectExec.java:110)
at org.apache.http.impl.client.InternalHttpClient.doExecute(InternalHttpClient.java:185)
at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:83)
at org.apache.jmeter.protocol.http.sampler.HTTPHC4Impl.executeRequest(HTTPHC4Impl.java:940)
at org.apache.jmeter.protocol.http.sampler.HTTPHC4Impl.sample(HTTPHC4Impl.java:651)
at org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy.sample(HTTPSamplerProxy.java:66)
at org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.sample(HTTPSamplerBase.java:1311)
at org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.sample(HTTPSamplerBase.java:1300)
at org.apache.jmeter.threads.JMeterThread.doSampling(JMeterThread.java:651)
at org.apache.jmeter.threads.JMeterThread.executeSamplePackage(JMeterThread.java:570)
at org.apache.jmeter.threads.JMeterThread.processSampler(JMeterThread.java:501)
at org.apache.jmeter.threads.JMeterThread.run(JMeterThread.java:268)
at java.base/java.lang.Thread.run(Thread.java:1583)
Double-Checked Locking 패턴 적용
public synchronized boolean findWinnersIfAbsent(Long eventId, Long userId) {
if (couponWinnerCacheStore.isEmpty()) {
updateTodayWinners();
}
return couponWinnerCacheStore.containsKey(eventId) && couponWinnerCacheStore.get(eventId).contains(userId);
}
synchronzied를 적용하여 멀티 스레드 환경에서 updateTodayWinners() 함수 호출 시에 발생할 수 있는 동시성 문제를 해결할 수 있습니다.
하지만 문제점이 있습니다.
둘 이상의 클라이언트 요청이 있을 시에, findWinnersIfAbsent 메서드에 Synchronzied를 적용했기 때문에 어떠한 스레드가 먼저 findWinnersIfAbsent에 접근하면 findWinnersIfAbsent 메서드 전체에 Lock이 걸립니다. 따라서 다른 스레드는 Lock이 풀릴 때까지 기다려야 합니다.
현재 이벤트 당첨자 조회 같은 경우에는 굉장히 많은 트래픽이 발생하는 상황입니다.
이러한 상황에서 하나의 스레드가 findWinnersIfAbsent 메서드를 호출한다면 다른 모든 스레드들은 해당 메서드에 걸린 락이 풀릴 때까지 기다려야 하기 때문에 성능 저하를 불러옵니다.
자료를 조사하면서 조금 더 나은 성능을 보장할 수 있는 방법을 찾았습니다.
dclp (double checked locking pattrn)
double-checked locking은 null check 혹은 isEmpty() 같은 부분을 synchronized 밖으로 빼서 락 해제를 기다리지 않고 처리하게 만들어 줍니다.
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);
}
findWinnersIfAbsent 메서드에 synchronzied를 사용하지 않고 couponWinnerCacheStore가 비었는지 확인한 후, 비었다면 그때 Synchronzied를 적용하여 스레드를 동기화해서 couponWinnerCacheStore가 비었는지 다시 체크하는 방법입니다.
밖에서 하는 체크는 couponWinnerCacheStore가 비어 있지 않았다면 빠르게 이벤트 당첨자를 조회하기 위함이고, 안에서 하는 체크는 couponWinnerCacheStore가 비어있는 경우 단 하나의 스레드가 updateTodayWinners()를 호출하기 위함입니다.
따라서 double checked locking을 적용하여 findWinnersIfAbsent 메서드에 Synchronzied를 사용한 것보다는 성능 저하를 개선할 수 있다고 생각합니다.
참고
'프로젝트 > Kidsping' 카테고리의 다른 글
개발 기록 - 선착순 응모 시스템 이슈 및 해결 과정 (0) | 2024.10.30 |
---|---|
트러블 슈팅 - 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 |