Java에서 단일 서버에서 분산 환경까지 락 사용하기

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 분산 락 사용
  • 간결함 추구: 주석 기반 분산 락 사용

이러한 락 업그레이드 방안을 숙지하면 어떤 환경에서도 데이터 보호를 확실히 할 수 있습니다.

태그: java Redis DistributedLock

5월 29일 07:07에 게시됨