패션 스와이프 앱에서 서버가 1대임에도 Redis 분산 락(Redisson)을 선택한 이유
안녕하세요 팀원 여러분! 최근 우리 DEKK 프로젝트에 쉐어덱(Shared Deck) 기능이 성공적으로 추가되었습니다. 호스트가 만든 커스텀 덱의 초대 링크를 통해 여러 명의 친구가 게스트로 참여하고, 다 함께 패션 카드를 모을 수 있는 매력적인 기능이죠.
하지만 다중 사용자 협업 기능이 도입되면서 우리는 동시성(Concurrency) 이라는 거대한 벽을 마주하게 되었습니다. 여러 명의 사용자가 각자의 앱에서 동시에 “이 카드를 쉐어덱에 저장하기” 액션을 수행할 때 발생하는 데이터 정합성 문제를 해결하기 위해, 우리 팀은 Redis 기반의 분산 락(Redisson) 을 전격 도입했습니다.
이 문서는 아키텍처 설계 단계에서 가장 많이 고민했던 “현재 서버가 한 대인데, 왜 굳이 DB 비관적 락(Pessimistic Lock)이 아닌 Redis 분산 락을 썼나요?” 에 대한 명쾌한 해답이자, 우리 팀의 커스텀 락 AOP 적용 가이드입니다.
1. 직면한 문제: 다중 사용자의 동시 저장 요청과 “초과 저장” 사태
우리 DEKK은 사용자가 마음에 드는 카드를 발견하면, 자신이 생성하거나 참여 중인 ‘커스텀 덱(또는 쉐어덱)’을 선택해 카드를 직접 저장할 수 있는 구조입니다. 이때 비즈니스 정책상 엄격한 제한들이 존재합니다.
- 덱 카드 제한: 하나의 커스텀/쉐어덱에는 최대 50장(
MAX_CUSTOM_DECK_CARD_COUNT) 의 카드만 담을 수 있습니다. - 게스트 제한: 쉐어덱에는 최대 5명(
MAX_GUEST_COUNT) 의 게스트만 참여할 수 있습니다.
문제는 쉐어덱에 참여한 여러 멤버들이 각자 카드를 탐색하다가, 우연히 겹치는 타이밍에 카드를 저장할 때 발생합니다.
만약 현재 쉐어덱에 49장의 카드가 있을 때, 3명의 게스트가 0.001초 차이로 ‘해당 쉐어덱에 저장’ 요청을 보낸다면 어떻게 될까요? 3개의 스레드가 동시에 deckCardRepository.countByDeckId를 호출하여 현재 카드 수를 49장으로 읽게 되고, 모두 50장 제한 검증을 통과해버립니다. 결과적으로 덱에 한도를 초과한 52장의 카드가 뚫고 들어오는 초과 저장(Lost Update) 버그가 발생하게 됩니다.
2. 우리가 DB 락 대신 Redis(Redisson)를 선택한 3가지 결정적 이유
물론 단일 인스턴스 환경에서는 DB의 비관적 락(SELECT ... FOR UPDATE)만으로도 이 문제를 막을 수 있습니다. 하지만 우리 DEKK 팀은 단순한 ‘버그 픽스’를 넘어 성능, DB 자원 보호, 확장성을 위해 초기 단계부터 과감하게 Redis 분산 락을 선택했습니다.
① 스와이프 앱 특성상 DB 커넥션 풀 고갈(Starvation) 방지가 필수
데이터베이스의 Connection은 우리 시스템에서 가장 비싸고 한정적인 자원입니다.
- DB 락의 치명적 단점: 스와이프처럼 순간적인 쓰기 API 호출량(TPS)이 높은 환경에서 DB 비관적 락을 걸면, 락을 획득하기 위해 대기하는 스레드들이 DB 커넥션을 꽉 쥐고 놓지 않습니다. 쉐어덱 하나에 트래픽이 몰리면 락 대기 때문에 HikariCP 커넥션 풀이 고갈되고, 결과적으로 락과 전혀 무관한 메인 홈 카드 추천 API(
getRecommendCards)들마저 커넥션을 얻지 못해 서비스 전체가 뻗어버릴 위험이 매우 높습니다. - Redis로의 격리: Redis는 In-Memory 기반이므로 락 획득/해제가 밀리초(ms) 단위로 매우 빠릅니다. 락 대기를 애플리케이션(Redis) 레이어로 분리함으로써, DB는 락 대기 병목 없이 순수하게 ‘데이터 읽기/쓰기’에만 온전히 집중할 수 있습니다.
② 미래를 준비하는 아키텍처 (Scale-out 대비)
현재는 단일 서버이지만, 패션 플랫폼 특성상 트래픽이 성장하면 서버를 다중화(Scale-out)해야 합니다. Redis를 중앙 집중식 ‘락 관리자’로 두었기 때문에, 추후 로드밸런싱을 통해 서버를 10대로 증설하더라도 코드 수정이나 새로운 동시성 이슈 걱정 없이 즉각적인 스케일 아웃이 가능합니다.
③ Redisson의 Pub/Sub 메커니즘과 데드락 방지
Redis 락 구현체 중에서도 우리는 Redisson 라이브러리를 채택했습니다.
- 단순한 Redis
SETNX기반의 Spin Lock 방식은 “락 풀렸어?”라고 끊임없이 Redis를 찔러봐야 하므로 네트워크와 CPU에 엄청난 부하를 줍니다. 반면 Redisson은 락 해제 시 대기자에게 이벤트를 발행하는 Pub/Sub 구조를 사용하여 불필요한 부하를 획기적으로 줄여줍니다. - 락을 쥔 서버가 예기치 않게 종료되어도, 지정된
leaseTime이 지나면 알아서 락을 회수하여 데드락(Deadlock)을 원천 차단합니다.
3. 분산 락의 숨겨진 함정: “트랜잭션 커밋”과 “락 해제”의 미세한 틈새
분산 락을 도입할 때 개발자들이 가장 많이 하는 실수가 바로 트랜잭션 커밋 시점과 락 해제 시점의 불일치입니다.
만약 @Transactional과 커스텀 락 AOP가 같은 메서드 레벨에 묶여 있다면, AOP 특성상 내부 비즈니스 로직 종료 후 락이 먼저 풀리고, 이후에 트랜잭션 커밋이 지연되는 찰나의 순간이 생깁니다. 이때 다른 스레드가 락을 획득하고 DB를 조회하면, 아직 커밋되지 않은 과거 데이터(49장)를 읽어버리는 치명적인 정합성 오류가 발생합니다.
이를 완벽히 차단하기 위해 DEKK 팀은 락 획득 -> 독립된 새 트랜잭션 시작(REQUIRES_NEW) -> 비즈니스 로직 -> DB 커밋 -> 락 해제 의 생명주기를 엄격하게 보장하도록 AOP를 2단계로 물리적 분리 설계했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 1️⃣ [DistributedLockAop.java] 분산 락을 획득하고 해제하는 책임을 가진 AOP
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {
private static final String REDISSON_LOCK_PREFIX = "LOCK_DECK:";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction; // ⬅️ 트랜잭션 전용 AOP 빈
@Around("@annotation(com.dekk.common.lock.DistributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
// CustomSpringELParser를 통해 동적인 락 키 생성 (예: LOCK_DECK:15)
String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(
signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(key);
try {
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!available) {
log.warn("Redisson Lock 획득 실패 [{}]", key);
throw new BusinessException(GlobalErrorCode.LOCK_ACQUISITION_FAILED);
}
// 💡 핵심: 락을 획득한 상태에서 완전히 독립된 새로운 트랜잭션을 호출합니다.
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException(GlobalErrorCode.INTERNAL_ERROR);
} finally {
// 💡 핵심: 내부 트랜잭션 커밋이 완벽히 끝난 후 안전하게 락을 해제합니다.
try {
if (rLock.isLocked() && rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
} catch (IllegalMonitorStateException e) {
log.warn("Redisson Lock 이미 해제됨 [{}]", key);
}
}
}
}
1
2
3
4
5
6
7
8
9
10
// 2️⃣ [AopForTransaction.java] 실제 트랜잭션을 분리하여 실행하는 컴포넌트
@Component
public class AopForTransaction {
// 💡 부모 트랜잭션 유무와 무관하게 무조건 "새로운 트랜잭션"을 열어 독립적인 커밋을 보장합니다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
4. DEKK 개발 적용 가이드 (How to use)
백엔드 개발자는 복잡한 락 획득 로직이나 트랜잭션 분리 코드를 짤 필요가 없습니다. 동시성 제어가 필요한 메서드에 커스텀 어노테이션인 @DistributedLock 만 선언하고, Spring Expression Language(SpEL) 문법으로 동적 키(Key)만 지정해주면 됩니다.
[사례 1] 쉐어덱 카드 다중 저장 방어 (DeckCardCommandService)
여러 명이 공유하는 덱이므로, 파라미터로 들어오는 덱 ID(#customDeckId) 자체를 락의 Key로 잡습니다. (Fine-grained Lock) 이렇게 하면 A 덱에 카드를 저장할 때 B 덱의 저장은 전혀 방해받지 않아 성능이 극대화됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.dekk.deck.application;
import com.dekk.common.lock.DistributedLock;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class DeckCardCommandService {
/**
* ❌ 주의: 분산 락 AOP 내부(AopForTransaction)에서 트랜잭션을 별도로 제어하므로
* 이 서비스 메서드에는 의도적으로 @Transactional을 생략해야 합니다!
*/
@DistributedLock(key = "#customDeckId") // 💡 SpEL을 사용해 특정 덱 단위로 락 획득
public void saveToCustomDeck(Long userId, Long customDeckId, Long cardId) {
Deck customDeck = getCustomDeckByUserId(customDeckId, userId);
if (isCardAlreadyInDeck(customDeck.getId(), cardId)) return; // ⭕ 멱등성을 위한 Early Return
// 💡 락이 걸려있으므로, 여러 명의 게스트가 동시에 스와이프를 하더라도
// 커스텀덱 최대 개수(50장) 검증 로직이 100% 안전하게 방어됩니다.
validateCustomDeckCardLimit(customDeck.getId());
deckCardRepository.save(DeckCard.create(customDeck.getId(), cardId));
}
}
[사례 2] 쉐어덱 초대 링크 동시 접속 방어 (ShareDeckCommandService)
동일한 초대 링크를 따닥(더블 클릭)하여 중복 가입되는 것을 막기 위해 유저 ID(#userId) 를 Key로 잡습니다.
1
2
3
4
5
@DistributedLock(key = "'join_deck:' + #userId")
public void joinSharedDeck(Long userId, String token) {
// 토큰 검증, 중복 가입 방어, 최대 9개 덱 보유 한도 방어, 쉐어덱 게스트 5명 제한 방어 로직 수행...
deckMemberRepository.reactivateOrSave(deckId, userId, DeckRole.GUEST);
}
5. 우리의 설계가 맞다는 증명: 100개의 동시 요청 테스트
이론적으로 완벽해 보여도 검증은 필수입니다. 우리는 DeckCardCommandServiceConcurrencyTest 테스트 코드를 통해 100개의 스레드가 동시에 같은 카드(cardId=100)를 동일한 커스텀 덱에 저장하려는 극단적인 시나리오를 구성했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Test
@DisplayName("동시에 100개의 스레드가 덱에 카드 저장을 요청할 때 단 1건만 성공해야 한다")
void saveCardToCustomDeck_concurrency_test() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger successOrIgnoredCount = new AtomicInteger();
AtomicInteger lockExceptionCount = new AtomicInteger();
// 100개의 스레드로 saveToCustomDeck 비동기 호출
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
deckCardCommandService.saveToCustomDeck(userId, customDeckId, targetCardId);
successOrIgnoredCount.incrementAndGet();
} catch (BusinessException e) {
if (e.errorCode() == GlobalErrorCode.LOCK_ACQUISITION_FAILED) {
lockExceptionCount.incrementAndGet();
}
} finally {
latch.countDown();
}
});
}
latch.await();
long savedCardCount = deckCardRepository.countByDeckId(customDeckId);
// 💡 단 1개의 카드만 덱에 정상 저장됨을 완벽히 검증!
assertThat(savedCardCount).isEqualTo(1L);
assertThat(successOrIgnoredCount.get() + lockExceptionCount.get()).isEqualTo(100);
}
테스트 결과, 오직 1개의 스레드만 락을 획득하여 DB 저장에 성공하고, 나머지 스레드들은 멱등성 방어 로직(isCardAlreadyInDeck)에 의해 무시되거나 타임아웃(LOCK_ACQUISITION_FAILED)으로 안전하게 튕겨 나갔습니다. 우리의 락 설계가 철벽처럼 동작함을 증명했습니다.
6. 마무리: 기술 선택의 기준은 ‘미래’여야 합니다.
우리 팀이 단일 서버 환경임에도 불구하고 선제적으로 Redis 분산 락을 도입한 이유는 명확합니다.
- DB 풀 보호: 쏟아지는 스와이프 트래픽 폭주 시 DB 커넥션 병목(Starvation)을 사전에 차단합니다.
- 확장성 확보: 향후 쉐어덱의 인기로 서버를 스케일 아웃(Scale-out)할 때 즉각적인 대응이 가능합니다.
- 정합성 보장:
REQUIRES_NEW를 활용한 트랜잭션 분리 AOP 설계로 커밋 타이밍 불일치 버그를 완벽히 해결했습니다.
당장 오늘 돌아가는 코드만 짜는 것도 중요하지만, 우리의 DEKK 서비스가 폭발적으로 성장했을 때 유연하고 견고하게 버틸 수 있는 ‘탄탄한 기반’을 미리 설계하는 것. 이것이 우리 DEKK 팀이 추구하는 엔지니어링 문화입니다.
문서를 읽어보시고 분산 락 정책이나 SpEL 문법 활용 등 궁금한 점이 있다면 언제든 코멘트나 슬랙으로 편하게 질문해 주세요! 🙌