이지은님의 블로그
250504 - Java Spring 도서 앱 구현: 주문 후 재고 감소의 동시성 제어(3) - 분산락 구현(Redisson, AOP 기반과 함수형 기반, RedissonClient) 본문
250504 - Java Spring 도서 앱 구현: 주문 후 재고 감소의 동시성 제어(3) - 분산락 구현(Redisson, AOP 기반과 함수형 기반, RedissonClient)
queenriwon3 2025. 5. 4. 18:55▷ 오늘 배운 것
낙관락과 비관락에 이어 분산락 또한 구현하며 낙관락 비관락끼리의 차이점을 비교해보고자 한다.
<<목차>>
1. 분산락이란?
2. 분산락 구현 방법
1) Redis
2) Redis에서 사용할 수 있는 분산락 클라이언트
3) AOP 기반 vs 함수형 기반 분산락 구현 방식 비교
3. 분산락 적용 방법
1) build.gradle 설정
2) RedissonConfig
3) RedisLockClient
4) 분산락 적용 메서드
4. 테스트코드로 검증
5. 결론
1. 분산락이란?
분산된 여러 시스템이 하나의 공유 자원을 안전하게 접근할 수 있도록 보장하는 동기화 기술이다.
예를 들어, 여러 서버가 동시에 한 상품의 재고를 감소시킬 경우, 재고가 0인데도 -1, -2가 되어버리는 문제가 발생할 수 있다. 이 문제를 방지하기 위해 분산락을 통해 한 번에 한 서버만 자원에 접근하도록 제어할 수 있다.
한번에 한 스레드만 실행할 수 있기 때문에 동시성 문제가 발생하지 않는다. 대신 로직이 길어 락이 점유하는 시간이 길다면 성능저하가 발생할 수 있다. 분산락의 원리는 락을 획득한 프로세스 또는 스레드만 공유자원에 접근할 수 있도록 하는 것이다.
장점은 서버 분산 환경에서도 프로세스들의 원자적 연산이 가능하다는 것이다.
2. 분산락 구현 방법
1) Redis
Redis는 인메모리 기반의 NoSQL 데이터베이스로, 빠른 속도와 다양한 자료구조 지원 덕분에 분산락 구현에 자주 사용된다.
Redis의 특징
- 빠른 속도: 초당 100,000 QPS 이상의 처리 속도
- 싱글 스레드 구조: 경합이 적어 동시성 문제 발생 확률이 낮음
- 다양한 자료구조: String, List, Set, Sorted Set, Hash 등 지원
- 캐시, 세션, 큐, 락 등 다목적으로 활용 가능
2) Redis에서 사용할 수 있는 분산락 클라이언트
Redis에서 사용할 수 있는 분산락 클라이언트
클라이언트 | 특징 | 락 지원 |
Jedis | 오래된 클라이언트, 동기식 | 직접 구현 필요 |
Lettuce | 넌블로킹 비동기 방식, Spring 공식 권장 | 직접 구현 필요 |
Redisson | 고수준 API 제공, 분산락 지원 | RLock으로 기본 제공 |
분산락을 간단하게 구현할 수 있다는 점에서 Redisson 을 사용하여 분산락을 구현하기로 정했다.
3) AOP 기반 vs 함수형 기반 분산락 구현 방식 비교
1️⃣ AOP 기반 방식
@DistributedLock(key = "#bookId")
public void decreaseStock(Long bookId) {
...
}
AOP기반 방식은 @DistributedLock 커스텀 어노테이션을 만들어 AOP에서 락 관련 로직을 공통 관심사로 분리한다. 이 방식은 비즈니스 로직이 깔끔해지고 락관련 코드를 재사용할 수 있다.
단점은 AOP설정과 표현식처리가 복잡하고(문자열로 실행기준 작성) 락 불필요 영역이 많을시 성능저하가 발생할 수 있다.
구성: 마킹 어노테이션 + 분산락 Aspect + 적용 메서드
2️⃣ 함수형 방식
distributedLockExecutor.execute("book:" + bookId, () -> {
decreaseStock(bookId);
});
함수를 사용하여 분산락을 적용하는 방식으로 AOP의 단점을 보완하여 명확하게 락 범위를 설정할 수 있다. 명시적으로 락 범위를 제어할 수 있으며, 내부호출의 문제가 없다.
코드 관리 및 확장성과는 거리가 멀어 재사용이 힘들다는 점이 단점이 되겠다.
표로 정리하자면 다음과 같다.
항목 | AOP 기반 방식 | 함수형 방식 |
적용 명확성 | 낮음 (pointcut 확인 어려움) | 높음 (코드에서 바로 보임) |
내부 호출 | 적용 안 됨 | 항상 적용됨 |
성능 제어 | 어렵다 (락 범위 명확하지 않음) | 쉬움 (범위 직접 설정) |
유지보수 | AOP 설정까지 변경해야 함 | 로직만 보면 됨 |
테스트 편의성 | 어렵다 | 좋다 |
실수 가능성 | 높음 (적용 안 되는 경우 생김) | 낮음 (명시적 사용) |
적용되는 곳이 주문과정에서 재고 감소이기때문에 세밀한 락 적용이 필요하다고 판단, 따라서 Redisson + 함수형 방식을 채택했다.
3. 분산락 적용 방법
1) build.gradle 설정
implementation 'org.redisson:redisson-spring-boot-starter:3.20.0'
2) RedissonConfig
@Configuration
public class RedissonConfig {
private final RedisProperties redisProperties;
public RedissonConfig(RedisProperties redisProperties) {
this.redisProperties = redisProperties;
}
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + redisProperties.getHost() + ":" + redisProperties.getPort())
.setConnectionMinimumIdleSize(1)
.setConnectionPoolSize(10)
.setRetryAttempts(3)
.setRetryInterval(1500)
.setTimeout(3000);
String password = redisProperties.getPassword();
if (password != null && !password.isBlank()) {
config.useSingleServer().setPassword(password);
}
return Redisson.create(config);
}
}
이 코드는 Redis에서 설정했던 것을 Redisson에서도 동일하게 적용하기 위해 설정을 해주는 클래스이다. Redis설정값을 주입 받을 수 있으며, RedissonClient를 빈 등록하여 서비스나 리포지토리 클래스에서 주입 받아 사용할 수 있게 한다.
- setConnectionMinimumIdleSize(1): 최소 유휴 커넥션 수 (1개 유지)
- setConnectionPoolSize(10): 최대 커넥션 풀 크기 (최대 10개의 Redis 연결을 유지)
- setRetryAttempts(3): Redis 명령 실패 시 최대 재시도 횟수
- setRetryInterval(1500): 재시도 간 간격 (ms) – 1.5초
- setTimeout(3000): 명령 실행 타임아웃 (3초)
이 설정을 통해 분산락 실패시 재시도를 설정할 수 있다.
3) RedisLockClient
@Slf4j
@Component
@RequiredArgsConstructor
public class RedissonLockClient {
private final RedissonClient redissonClient;
private static final long WAIT_TIME = 3L;
private static final long LEASE_TIME = 5L;
public void runWithLockOrElse(String lockKey, Runnable action, Runnable fallback) {
RLock lock = redissonClient.getLock(lockKey);
boolean isLocked = false;
try {
log.info("[LOCK START] key={}, thread={}", lockKey, Thread.currentThread().getName());
isLocked = lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS);
if (!isLocked) {
log.warn("[LOCK FAIL] key={} - 락 획득 실패", lockKey);
if (fallback != null) {
fallback.run();
} else {
throw new RuntimeException("락 획득 실패 - fallback 없음");
}
return;
}
action.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Redisson 락 인터럽트 발생", e);
} catch (Exception e) {
throw new RuntimeException("Redisson 락 처리 중 예외 발생", e);
} finally {
if (isLocked && lock.isHeldByCurrentThread()) {
lock.unlock();
log.info("[LOCK END] key={}, thread={}", lockKey, Thread.currentThread().getName());
}
}
}
}
이 코드는 Redisson을 이용한 분산락(Distributed Lock)을 간결하고 안정적으로 처리하기 위한 유틸성 클래스이다. runWithLockOrElse라는 메서드를 통해, 락 획득에 성공하면 비즈니스 로직(action)을 실행하고, 실패하면 대체 로직(fallback)을 실행하는 방식이다.
주요 흐름은 다음과 같이 흘러간다.
1️⃣ RLock 객체 생성
RLock lock = redissonClient.getLock(lockKey);
2️⃣ 락 획득 시도
isLocked = lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS);
설정되어있는 값에 따라 최대 3초간 락을 기다리며 시도하고 락을 획득하면 5초간 가지고 있도록 타임아웃을 설정한다.
3️⃣ 락획득 실패시 fallback 처리
if (!isLocked) {
if (fallback != null) fallback.run();
else throw new RuntimeException("락 획득 실패 - fallback 없음");
return;
}
4️⃣ 락획득 성공시 작업 실행 이후 락 해제
action.run();
lock.unlock();
4) 분산락 적용 메서드
public void decreaseStockWithDistributedLock(Long bookId, int quantity) {
String lockKey = "bookStockLock:" + bookId;
redissonLockClient.runWithLockOrElse(lockKey, () -> {
Book book = bookService.findBookByIdOrElseThrow(bookId);
book.decreaseStock(quantity);
bookRepository.saveAndFlush(book);
}, () -> {
throw new BadRequestException("현재 주문량이 많아 재고 확인에 실패했습니다.");
});
}
분산락을 이용해 락을 획득하여 재고 감소처리를 하는 로직이다.
lockKey로 책에 대한 고유한 락키를 설정하고, 같은 책 ID에 대해선 항상 동일한 키가 사용되므로, 동일 자원에 대한 경쟁을 제어할 수 있다.
이후 redissonLockClient.runWithLockOrElse의 첫번째 파라미터는 락 키, 두번째인자는 락을 획득했을 때 실행하는 코드, 세번째인자는 실패시 예외처리를 작성했다.
두번째 인자를 함수형으로 작성한바 있는데, 락을 획득해서 책을 찾고 그 책의 수량을 감소, 이후 저장 및 플러시하는 과정이 담겨있다. 이때 주의할 점은 따로 락을 획득해서 저장과 플러시를 하기 때문에@Trasactional을 사용하지 않는 것이다.
이 과정을 통해서 분산락을 구현할 수 있다.
4. 테스트코드로 검증
테스트 시나리오는 역시 100개의 스레드가 동시에 재고 감소요청을 보내고 이에 대한 충돌과 락 획득에 대한 테스트를 할 것이다.
@Test
void 동시에_재고감소_요청_시_분산락_처리() throws InterruptedException {
int requestCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(10);
CountDownLatch latch = new CountDownLatch(requestCount);
AtomicInteger successfulUpdates = new AtomicInteger(0);
for (int i = 0; i < requestCount; i++) {
executorService.submit(() -> {
try {
bookStockService.decreaseStockWithDistributedLock(bookId, 1);
successfulUpdates.incrementAndGet();
} catch (Exception e) {
System.out.println("실패: " + e.getMessage());
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
Book book = bookRepository.findById(bookId).orElseThrow();
System.out.println("성공한 재고 감소 수: " + successfulUpdates.get());
System.out.println("최종 재고: " + book.getStock());
assertEquals(successfulUpdates.get(), 100 - book.getStock());
}
먼저 락을 구현하지 않은 코드의 테스트를 실행시켜보면 다음과 같다.
100개의 재고를 100번 동시에 재고 감소를 성공시켰음에도 재고가 0으로 남지 않고 87건이 남는다. 분산락을 구현하지 않으면 데이터 정합성이 깨지는 것을 알 수 있다.
그럼 분산락을 적용시킨 테스트는 어떨까?
락 획득 실패시 BadRequestException이 발생하도록 구현했지만, 100번 모두 락 획득을 하여 BadRequestException은 발생하지 않았고 100번 모두 재고 감소를 성공했다. 하지만 성능상 1초가량 걸린다는 것을 확인할 수 있었다.
그럼 비관적락 낙관락 모두 분산락과 비교를 해보자
전략 | 성공률 | 정합성 | 성능 (시간) | 특이사항 |
락 미적용 | 높음 (표면상) | ❌ 낮음 | 빠름 | 정합성 깨짐 🚨 |
비관적 락 | ✅ 100% | ✅ 높음 | 중간 | 확실한 정합성 보장 |
낙관적 락 (미재시도) | 낮음 | ✅ 높음 | 빠름 | 실패 많음 |
낙관적 락 + 재시도 | 중간~높음 | ✅ 높음 | 중간~느림 | 데이터 정합성과 성능 이득 |
분산 락(Redisson) | ✅ 100% | ✅ 높음 | 느림 | 가장 안정적 |
5. 결론
분산락은 Redis등을 사용하여 락을 구현하는 방법이며, 분산시스템, 여러 서버에서 동시에 주문을 처리할 경우 사용하는데 좋다. 하지만 속도가 다른 락에 비해 느리다는 단점이 있다.
▷ 참고한 블로그
분산락으로 해결하는 동시성 문제(이론편)
분산락이 무엇인지 알아봅시다.
velog.io
[Spring] Redis(Redisson) 분산락을 활용하여 동시성 문제 해결하기
동시성문제를 해결해보자
velog.io