// 서버 레벨 락의 예시
public class CacheService {
private final Object lock = new Object();
private Map<String, Object> cache = new HashMap<>();
public void updateCache(String key, Object value) {
synchronized(lock) {
// 캐시 업데이트 작업
cache.put(key, value);
}
}
}
-- DB 레벨 락의 예시
SELECT * FROM users WHERE id = 1 FOR UPDATE;
@Service
public class ProductCacheService {
private final ReentrantLock lock = new ReentrantLock();
private Map<Long, Product> productCache = new ConcurrentHashMap<>();
public Product getProduct(Long id) {
try {
lock.lock();
return productCache.computeIfAbsent(id, this::loadFromDB);
} finally {
lock.unlock();
}
}
}
@Service
public class RequestLimiter {
private final Object lock = new Object();
private int requestCount = 0;
public boolean canProcess() {
synchronized(lock) {
if (requestCount < 100) {
requestCount++;
return true;
}
return false;
}
}
}
@Repository
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(@Param("id") Long id);
}
@Transactional
public void processOrder(Long orderId) {
// DB 락을 통한 동시 주문 방지
Order order = orderRepository.findByIdWithPessimisticLock(orderId);
// 주문 처리 로직
}
| 특성 | 서버 레벨 락 | DB 레벨 락 |
|---|---|---|
| 유효 범위 | 단일 JVM 내에서만 유효 | 모든 서버/애플리케이션에서 유효 |
| 보호 대상 | 메모리 상의 객체 | 데이터베이스의 데이터 |
| 처리 속도 | 빠름 (메모리 접근) | 상대적으로 느림 (디스크 I/O 발생) |
| 구현 방식 | synchronized, ReentrantLock 등 | SELECT FOR UPDATE, 트랜잭션 격리 수준 등 |
| 데이터 지속성 | 서버 재시작시 초기화 | 영구 보존 |
| 분산 환경 | 분산 환경에서 사용 불가 | 분산 환경에서 사용 가능 |
| 사용 사례 | - 메모리 캐시 관리 - 애플리케이션 내부 카운터 - 임시 데이터 처리 | - 재고 관리 - 주문/결제 처리 - 데이터 정합성이 중요한 처리 |
| 장점 | - 빠른 처리 속도 - 구현 간단 - 오버헤드 적음 | - 강력한 데이터 정합성 - 분산 환경 지원 - 영속성 보장 |
| 단점 | - 단일 서버로 제한 - 서버 장애시 락 손실 - 분산 환경에서 사용 불가 | - 상대적으로 느린 속도 - 데드락 가능성 - 리소스 사용량 많음 |
public class BetterLockExample {
// 1. 락의 범위를 최소화
private final ReentrantLock lock = new ReentrantLock();
public void processWithLock() {
// 2. try-finally로 락 해제 보장
lock.lock();
try {
// 3. 크리티컬 섹션을 최소화
doSomething();
} finally {
lock.unlock();
}
}
}
@Service
public class OrderService {
@Transactional(isolation = Isolation.SERIALIZABLE)
public void processOrder(Long orderId) {
// 1. 적절한 트랜잭션 격리 수준 선택
// 2. 데드락 방지를 위한 타임아웃 설정
// 3. 락의 범위를 최소화
orderRepository.findByIdWithLock(orderId);
}
}
서버 레벨 락 사용
DB 레벨 락 사용
서버 레벨 락과 DB 레벨 락은 각각의 장단점과 적합한 사용 사례가 있습니다.
실제 구현시에는 두 가지를 적절히 조합하여 사용하는 것이 좋습니다.
예를 들어, 캐시와 DB를 함께 사용하는 경우 서버 레벨 락으로 캐시를 보호하고,
DB 레벨 락으로 실제 데이터를 보호하는 방식을 사용할 수 있습니다.
재고 시스템의 기본 흐름:
[주문 요청] → [재고 확인] → [재고 차감] → [주문 완료]
동시 요청 시 문제점 예시:
서버 A: 재고 100개 → 읽기 → 차감(-10) → 저장(90)
서버 B: 재고 100개 → 읽기 → 차감(-20) → 저장(80)
서버 C: 재고 100개 → 읽기 → 차감(-30) → 저장(70)
[서버 A] → [SELECT FOR UPDATE] → [DB]
[서버 B] → [대기] → [DB]
[서버 C] → [대기] → [DB]
[서버들] → [Redis 분산 락] → [DB 처리]
읽기: [서버] → [캐시 조회] → [없으면 DB 조회]
쓰기: [서버] → [DB 갱신] → [캐시 무효화]
[조회 요청] → [DB 카운트 증가] → [응답]
[조회 요청] → [Redis 카운트 증가]
└─── 주기적으로 ──→ [DB 일괄 업데이트]
// Redis에 카운트 증가
public void incrementViewCount(Long productId) {
String key = "product:view:" + productId;
redisTemplate.opsForValue().increment(key);
}
// 배치로 DB 업데이트 (스케줄링)
@Scheduled(fixedRate = 300000) // 5분마다
public void syncViewCountToDb() {
// Redis → DB 동기화
}
[재고 확인] → [결제 요청] → [결제 완료] → [재고 차감]
락 점유 시간 = 재고 확인 + 결제 처리 시간(외부 API) + 재고 차감
[재고 확인(낙관적 락)] → [결제 요청] → [재고 차감(비관적 락)]
[서버] → [Redis 락 획득] → [네트워크 장애] → [락 해제 불가]
// Redis 분산 락 획득 시도
boolean locked = redissonClient.tryLock("lockKey",
5, // 대기 시간
10, // 락 유효 시간
TimeUnit.SECONDS);
[락 획득] → [TTL 설정] → [처리 완료 or TTL 만료 시 자동 해제]
[락 획득] → [백그라운드 락 갱신] → [처리 완료 시 락 해제]
Watch Dog 패턴의 기본 원리
1. 최초 락 획득
[서버] → [Redis에 락 생성 (TTL: 30초)]
2. 백그라운드 갱신
[Watch Dog Thread] → [매 10초마다 TTL 갱신]
3. 정상 케이스
락 획득 → 작업 수행 → 명시적 락 해제
4. 비정상 케이스 (서버 다운)
락 획득 → 서버 다운 → Watch Dog 중단 → TTL 만료 → 자동 락 해제
Watch Dog가 필요한 이유
긴 작업 시간
장애 상황 대응
정상 상황: 락 획득 → Watch Dog 갱신 → 작업 완료 → 락 해제
장애 상황: 락 획득 → 서버 다운 → Watch Dog 중단 → TTL 만료
초당 100건에서 1000건으로 확장하는 문제의 핵심은 "동시성 처리 방식의 변화"입니다. 원리적으로 설명해보겠습니다.
현재 시스템의 가장 큰 병목은 데이터베이스 락입니다.
왜냐하면 기본적인 DB 락 방식은 이런 흐름을 가집니다:
[요청] → [DB 락 획득 대기] → [락 획득] → [데이터 처리] → [락 해제]
이때 초당 1000건의 요청이 들어오면:
해결의 핵심은 "락의 범위를 줄이고, 락 획득 시간을 최소화" 하는 것입니다.
기존: SELECT FOR UPDATE (비관적 락)
변경: VERSION 컬럼 사용 (낙관적 락)
[요청들] → [메시지 큐] → [처리기]
[요청] → [캐시 확인] → [DB 접근]
이렇게 하면 DB 락에 의한 병목을 피하면서도 데이터 정합성을 유지할 수 있습니다. 결국 확장성은 "얼마나 락을 효율적으로 관리하는가"에 달려있습니다.