Java에서 단일 서버에서 분산 환경까지 락 사용하기
락이 필요한 이유
멀티스레드 또는 멀티프로세스 환경에서 여러 작업이 동시에 동일한 자원에 접근할 때 데이터 불일치가 발생할 수 있습니다. 락은 이러한 상황에서 한 번에 하나의 작업만 공유된 자원에 접근하도록 보장합니다.
락의 역할:
- 데이터 일관성 유지
- 동시 충돌 방지
- 원자성 보장
단순히 이해하자면, 공중화장실 문의 잠금 장치와 비슷합니다. 한 번에 한 명만 사용할 수 있으며, 다른 사람들은 대기해야 합니다.
단일 서버 락의 제한 사항
synchronized 키워드
Java에서 가장 간단한 락 메커니즘입니다.
public class CounterService {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
ReentrantLock
더 유연한 락 메커니즘을 제공합니다.
public class CounterService {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
}
단일 서버 락의 문제점:
- 단일 JVM 내에서만 작동
- 여러 서비스 인스턴스 간에는 배타성이 없음
- 분산 환경에서는 무효화됨
분산 환경의 도전 과제
애플리케이션이 여러 서버에 배포되면 단일 서버 락은 더 이상 적합하지 않습니다.
분산 환경에서의 문제점:
- 여러 서비스 인스턴스가 동일한 작업을 동시에 실행할 수 있음
- 재고 차감, 주문 생성 등에서 데이터 불일치 발생 가능성 증가
- JVM 간의 락 메커니즘이 필요함
Redis 기반의 분산 락
간단한 Redis 분산 락
Redis의 SET 명령어를 사용하여 구현합니다.
@Component
public class SimpleRedisLock {
@Autowired
private StringRedisTemplate redisTemplate;
public boolean tryLock(String key, String value, long expireTime) {
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(key, value, expireTime, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
}
public void releaseLock(String key, String value) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Arrays.asList(key), value);
}
}
사용 예시
@Service
public class OrderService {
@Autowired
private SimpleRedisLock redisLock;
public void createOrder(Long userId) {
String lockKey = "order:user:" + userId;
String lockValue = UUID.randomUUID().toString();
if (redisLock.tryLock(lockKey, lockValue, 30)) {
try {
performOrderCreation(userId);
} finally {
redisLock.releaseLock(lockKey, lockValue);
}
} else {
throw new RuntimeException("잠금 획득 실패. 나중에 다시 시도해주세요.");
}
}
private void performOrderCreation(Long userId) {
// 주문 생성 로직
}
}
Redisson 기반의 분산 락
Redisson은 더 강력한 분산 락 구현을 제공합니다.
의존성 추가
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.20.1</version>
</dependency>
Redisson 설정
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://localhost:6379")
.setDatabase(0);
return Redisson.create(config);
}
}
Redisson 락 사용
@Service
public class OrderService {
@Autowired
private RedissonClient redissonClient;
public void createOrder(Long userId) {
String lockKey = "order:user:" + userId;
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
performOrderCreation(userId);
} else {
throw new RuntimeException("잠금 획득 실패. 나중에 다시 시도해주세요.");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("잠금 획득 중단됨");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private void performOrderCreation(Long userId) {
// 주문 생성 로직
}
}
Redisson의 이점:
- 자동 갱신 메커니즘
- 재진입 락 지원
- 공평 락, 읽기/쓰기 락 등 다양한 락 타입
- 더 완벽한 예외 처리
주석 기반 분산 락 도구
수동으로 락을 획득하고 해제하는 것은 실수가 많습니다. 이를 해결하기 위해 주석을 사용할 수 있습니다.
사용자 정의 락 주석
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key() default "";
long waitTime() default 10;
long leaseTime() default 30;
TimeUnit timeUnit() default TimeUnit.SECONDS;
String errorMessage() default "잠금 획득 실패. 나중에 다시 시도해주세요.";
}
AOP 구현
@Aspect
@Component
public class DistributedLockAspect {
@Autowired
private RedissonClient redissonClient;
@Around("@annotation(distributedLock)")
public Object around(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
String lockKey = generateLockKey(joinPoint, distributedLock.key());
RLock lock = redissonClient.getLock(lockKey);
try {
boolean acquired = lock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
distributedLock.timeUnit()
);
if (!acquired) {
throw new RuntimeException(distributedLock.errorMessage());
}
return joinPoint.proceed();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("잠금 획득 중단됨");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private String generateLockKey(ProceedingJoinPoint joinPoint, String key) {
if (StringUtils.hasText(key)) {
return parseKey(key, joinPoint);
}
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = joinPoint.getSignature().getName();
return className + ":" + methodName;
}
private String parseKey(String key, ProceedingJoinPoint joinPoint) {
if (key.contains("#")) {
return parseSpEL(key, joinPoint);
}
return key;
}
private String parseSpEL(String key, ProceedingJoinPoint joinPoint) {
return key.replace("#userId", String.valueOf(joinPoint.getArgs()[0]));
}
}
주석 기반 분산 락 사용
@Service
public class OrderService {
@DistributedLock(key = "order:user:#userId", waitTime = 5, leaseTime = 30)
public void createOrder(Long userId) {
performOrderCreation(userId);
}
@DistributedLock(key = "inventory:product:#productId")
public void decreaseInventory(Long productId, Integer quantity) {
performInventoryDecrease(productId, quantity);
}
private void performOrderCreation(Long userId) {
// 주문 생성 로직
}
private void performInventoryDecrease(Long productId, Integer quantity) {
// 재고 차감 로직
}
}
분산 락 사용 시 주의사항
1. 락 시간 초과 설정
업무 수행 시간에 따라 적절한 시간을 설정해야 합니다.
@DistributedLock(key = "complex:task:#taskId", leaseTime = 60)
public void executeComplexTask(String taskId) {
// 복잡한 업무 로직
}
@DistributedLock(key = "simple:task:#taskId", leaseTime = 10)
public void executeSimpleTask(String taskId) {
// 간단한 업무 로직
}
2. 락 범위 조정
보안성을 보장하면서 성능 저하를 피하기 위해 적절한 범위를 설정해야 합니다.
@DistributedLock(key = "user:operation:#userId")
public void userOperation(Long userId) {
// 특정 사용자의 작업만 잠금
}
@DistributedLock(key = "global:operation")
public void globalOperation() {
// 모든 사용자에게 영향을 미치는 작업
}
3. 예외 처리
예외 상황에서도 락이 올바르게 해제되도록 해야 합니다.
@DistributedLock(key = "order:#orderId", errorMessage = "주문 처리 중입니다. 중복 요청을 피하세요.")
public void processOrder(Long orderId) {
try {
performOrderProcessing(orderId);
} catch (Exception e) {
log.error("주문 처리 실패: {}", orderId, e);
throw e;
}
}
성능 최적화 팁
1. 연결 풀 구성
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://localhost:6379")
.setConnectionPoolSize(50)
.setConnectionMinimumIdleSize(10);
return Redisson.create(config);
}
}
2. 락 대기 전략
@DistributedLock(key = "quick:#id", waitTime = 0)
public void quickOperation(String id) {
// 즉각적으로 실패
}
@DistributedLock(key = "normal:#id", waitTime = 3)
public void normalOperation(String id) {
// 3초 대기
}
요약
Java 락의 발전 과정: 단일 서버 락:
- synchronized, ReentrantLock
- 단일 JVM 내에서만 사용 가능
분산 락:
- Redis 기반 구현
- JVM 간 협력 지원
주석 기반 분산 락:
- 간편한 사용법
- 코드 반복 줄임, 오류 감소
선택 가이드라인:
- 단일 애플리케이션: synchronized 또는 ReentrantLock 사용
- 분산 애플리케이션: Redisson 분산 락 사용
- 간결함 추구: 주석 기반 분산 락 사용
이러한 락 업그레이드 방안을 숙지하면 어떤 환경에서도 데이터 보호를 확실히 할 수 있습니다.