커머스 백엔드에서 메뉴, 프로모션, 매장 운영 정책 같은 "거의 안 바뀌지만 모든 요청이 읽는" 데이터는 거의 예외 없이 메모리 캐시로 들어간다. 트래픽이 큰 시간대에 이 캐시를 어떻게 갱신할지가 곧 시스템의 안정성을 결정한다. 갱신 순간에 락을 잘못 잡으면 모든 조회 스레드가 멈추고, 락을 너무 느슨하게 풀면 절반은 옛 데이터, 절반은 새 데이터를 보는...
커머스 백엔드에서 메뉴, 프로모션, 매장 운영 정책 같은 "거의 안 바뀌지만 모든 요청이 읽는" 데이터는 거의 예외 없이 메모리 캐시로 들어간다. 트래픽이 큰 시간대에 이 캐시를 어떻게 갱신할지가 곧 시스템의 안정성을 결정한다. 갱신 순간에 락을 잘못 잡으면 모든 조회 스레드가 멈추고, 락을 너무 느슨하게 풀면 절반은 옛 데이터, 절반은 새 데이터를 보는 일관성 사고가 난다.
CJ푸드빌 같은 외식 커머스 환경에서는 점심 직전과 저녁 직전 트래픽 피크 직전에 운영자가 메뉴 가격, 품절 여부, 할인율을 바꾸는 패턴이 흔하다. "변경은 분당 수 건, 조회는 초당 수천 건"이라는 비대칭이 핵심이다. 이때 단순 synchronized로 막아 버리면 운영자 1명의 메뉴 갱신이 점심 피크의 모든 조회를 줄 세우는 사고가 난다. 시니어 백엔드 면접에서 동시성 락 질문이 들어오면 대부분의 답이 synchronized vs ReentrantLock까지로 끝나는데, 실제 차별점은 "왜 read-heavy 캐시에서는 RWLock이 모자라고 StampedLock의 optimistic read가 필요한가"를 설명할 수 있느냐다.
가장 먼저 분리해야 할 두 가지 축이 있다.
volatile, 동기화 블록, final 필드 초기화 후 publish 같은 수단이 해결한다.synchronized, 명시적 Lock, Atomic* CAS 연산이 해결한다.volatile은 단일 변수의 가시성만 보장하고 복합 연산의 원자성은 보장하지 않는다. 캐시 객체 전체를 통째로 갈아 끼우는 패턴에서는 volatile 하나로 충분할 수 있지만, "맵 안의 한 항목만 수정"에는 절대 부족하다. 이 구분이 면접에서 가장 자주 헷갈리는 지점이다.
JVM 내장 모니터 락. 진입/이탈이 자동이고 구현이 간단하지만, 다음 한계가 있다.
상태가 거의 안 바뀌고 호출 빈도가 낮은 영역(예: 초기화 가드, 카운터 증가)에 한정해서 쓴다.
synchronized의 기능 확장판. tryLock, 인터럽트 가능 lockInterruptibly, 공정성 옵션, 다중 Condition을 지원한다. 그러나 read와 write를 여전히 구분하지 않으므로 read-heavy 캐시 갱신용으로는 여전히 부적합하다.
읽기 락과 쓰기 락을 분리한다.
HashMap 같은 공유 자료구조를 캐시로 두고 갱신할 때 가장 직관적인 도구다. 다만 읽기 락도 락이다. 매 read마다 락 객체의 내부 카운터를 CAS로 증가시키고 메모리 배리어가 발생한다. 초당 수만 read 환경에서는 이 비용이 무시 못 할 수준이 된다. 그리고 writer starvation 문제도 있다 — 끊임없이 읽기가 들어오면 쓰기가 영영 잡히지 않을 수 있어, 공정성 옵션을 켜면 throughput이 또 떨어진다.
Java 8에서 도입된 StampedLock의 장점은 optimistic read다.
tryOptimisticRead()는 락을 잡지 않고 stamp(버전 번호)만 받는다. 비용이 거의 0에 가깝다.validate(stamp)로 그 사이에 쓰기가 있었는지 검증한다.이 패턴은 "쓰기는 드물고 읽기는 빈번하다"라는 캐시 갱신 시나리오와 정확히 맞는다. write가 안 들어오는 99.9%의 경우, read는 락 없이 끝난다. write가 끼어들었을 때만 fall back한다.
단, StampedLock은 재진입을 지원하지 않고, Condition도 없으며, optimistic read 구간에서는 읽는 데이터가 일관된 상태가 아닐 수 있으므로 읽은 값을 일단 지역 변수로 복사한 뒤 validate로 검증해야 한다. 이 사용 규칙을 모르고 쓰면 오히려 위험하다.
캐시가 정적이고 일관된 스냅샷 단위로 갱신된다면, 락을 안 쓰고 끝낼 수도 있다.
volatile 또는 AtomicReference로 들고 있다가, 갱신 시 새로운 불변 캐시 객체를 통째로 만들어서 참조만 바꿔치기(swap)한다.이 패턴은 메뉴 캐시, 프로모션 정책 캐시처럼 "1~5분에 한 번 전체를 다시 빌드해도 되는" 경우에 가장 깔끔하다. 단점은 부분 갱신이 안 된다는 것 — 메뉴 한 줄을 바꾸려고 전체 캐시를 다시 만든다. 그러나 외식 커머스의 마스터 데이터는 보통 수백~수천 건 수준이라 전체 재빌드 비용이 크지 않다.
후보자가 이전에 다뤘던 "슬롯 머신용 정적 데이터 캐시"는 사실상 같은 구조의 문제다. 게임 슬롯의 심볼 테이블/배당률은 운영자가 가끔 바꾸고, 게임 스레드는 매 스핀마다 읽는다. 외식 커머스로 옮기면 다음으로 매핑된다.
| 게임 도메인 | 커머스 도메인 |
|---|---|
| 슬롯 심볼 테이블 | 매장별 메뉴 마스터 |
| 배당률 테이블 | 프로모션/할인율 정책 |
| 운영자 콘솔의 정적 데이터 변경 | 점주/본사의 메뉴/가격/품절 변경 |
| 매 스핀의 배당 계산 | 매 주문의 가격 계산 |
공통 패턴은 "운영 변경은 분 단위, 조회는 초 단위, 일관된 스냅샷이 필요" 라는 것이다. 따라서 채택할 수 있는 갱신 모델은 보통 다음 셋 중 하나다.
public class BadMenuCache {
private final Map<Long, Menu> menus = new HashMap<>();
public synchronized Menu get(long id) {
return menus.get(id);
}
public synchronized void reload(List<Menu> latest) {
menus.clear();
for (Menu m : latest) menus.put(m.getId(), m);
}
}문제점:
clear() 직후의 빈 맵을 다른 스레드가 보지 못하긴 하지만, 어쨌든 모든 read가 멈춘다.public final class MenuSnapshot {
private final Map<Long, Menu> byId;
public MenuSnapshot(Map<Long, Menu> byId) {
this.byId = Map.copyOf(byId);
}
public Menu get(long id) { return byId.get(id); }
}
public class MenuCache {
private final AtomicReference<MenuSnapshot> ref =
new AtomicReference<>(new MenuSnapshot(Map.of()));
public Menu get(long id) {
return ref.get().get(id);
}
public void reload(List<Menu> latest) {
Map<Long, Menu> next = new HashMap<>();
for (Menu m : latest) next.put(m.getId(), m);
ref.set(new MenuSnapshot(next));
}
}부분 변경이 실제로 잦아서 매번 전체 reload가 부담스러울 때.
public class RwLockMenuCache {
private final Map<Long, Menu> map = new HashMap<>();
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public Menu get(long id) {
lock.readLock().lock();
try { return map.get(id); }
finally { lock.readLock().unlock(); }
}
public void update(Menu m) {
lock.writeLock().lock();
try { map.put(m.getId(), m); }
finally { lock.writeLock().unlock(); }
}
}read 동시성은 확보되지만 매 read마다 락 카운터를 만진다는 비용은 남는다.
public class StampedMenuCache {
private Map<Long, Menu> map = new HashMap<>();
private final StampedLock sl = new StampedLock();
public Menu get(long id) {
long stamp = sl.tryOptimisticRead();
Map<Long, Menu> snapshot = map;
Menu found = snapshot.get(id);
if (!sl.validate(stamp)) {
stamp = sl.readLock();
try {
found = map.get(id);
} finally {
sl.unlockRead(stamp);
}
}
return found;
}
public void update(Menu m) {
long stamp = sl.writeLock();
try {
Map<Long, Menu> next = new HashMap<>(map);
next.put(m.getId(), m);
map = next;
} finally {
sl.unlockWrite(stamp);
}
}
}volatile Map 하나만 두고 그 Map에 put을 직접 한다 — Map 내부 상태가 깨진 채로 read 스레드에 보일 수 있다. swap 패턴이 아니면 안 된다.JDK 17+, Maven 또는 Gradle 단일 모듈로 충분하다. 외부 의존성 없이 JMH 또는 직접 짠 스레드 풀 벤치만으로 의미 있는 비교가 가능하다.
src/main/java/cache/MenuCache.java # AtomicReference 버전
src/main/java/cache/RwLockMenuCache.java
src/main/java/cache/StampedMenuCache.java
src/test/java/cache/CacheBench.java # ExecutorService 기반 부하기JMH를 도입할 수 있으면 @Benchmark 메서드 3종(read-only, read-heavy with 1% write, balanced)을 두고 비교한다. 도입하지 않더라도 Executors.newFixedThreadPool(64)에 read 스레드 32, write 스레드 1~2개를 섞어 30초 돌린 뒤 read 횟수를 비교하면 패턴별 throughput 차이가 명확히 보인다.
public class CacheBench {
public static void main(String[] args) throws Exception {
StampedMenuCache cache = new StampedMenuCache();
for (long i = 0; i < 1000; i++) cache.update(new Menu(i, "m" + i));
AtomicLong reads = new AtomicLong();
ExecutorService es = Executors.newFixedThreadPool(33);
long end = System.currentTimeMillis() + 5000;
for (int i = 0; i < 32; i++) {
es.submit(() -> {
ThreadLocalRandom r = ThreadLocalRandom.current();
while (System.currentTimeMillis() < end) {
cache.get(r.nextLong(1000));
reads.incrementAndGet();
}
});
}
es.submit(() -> {
while (System.currentTimeMillis() < end) {
cache.update(new Menu(0, "updated"));
Thread.sleep(50);
}
return null;
});
es.shutdown();
es.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("reads=" + reads.get());
}
}StampedMenuCache, RwLockMenuCache, MenuCache(AtomicReference)를 차례로 끼워 넣고 reads 수치를 비교하면 read-heavy 시나리오에서 optimistic read와 swap 패턴의 우위가 가시화된다.
질문이 "동시성 어떻게 다루셨어요?" 또는 "캐시 갱신 중 조회 일관성은 어떻게 보장합니까?"로 들어오면, 다음 흐름이 안전하다.
이 흐름은 후보자가 단순히 키워드를 외운 것이 아니라 read/write 비대칭을 보고 도구를 고른다는 인상을 준다.
volatile Map 단독 사용으로 자료구조 내부를 직접 수정하는 코드가 없는가.