본문 바로가기

카테고리 없음

[Moong] 분산환경 동시성 처리를 위한 5가지 방안 비교

동일 회원의 모임 중복 참여 동시성 이슈 — 5가지 해결 방법 비교

문제 상황: 동일 회원의 모임 참여 동시성 이슈

뭉 서비스에는 모임 가계부 단위인 pet_group, 회원인 member, 모임원인 Crew가 존재합니다. 서비스 정책으로 하나의 pet_group에는 동일 회원이 한 번만 존재할 수 있습니다.

기존에는 Crew 테이블의 (group_id, member_id)에 Unique Index를 걸어 따닥 동시성 이슈를 해결했습니다. 그러나 그룹 나가기 기능을 위해 deleted_at Soft Delete를 적용하면서 문제가 생겼습니다.

✅ 트랜잭션 1
→ 그룹에 2번 회원이 있는지 확인
→ 없으니 save 요청
(member_id, group_id, deleted_at) = (2, 1, null) 삽입

✅ 트랜잭션 2
→ 그룹에 2번 회원이 있는지 확인
→ 없으니 save 요청
→ Unique Key는 null 중복을 허용하므로 (2, 1, null) 참여 허용

⇒ 중복 회원 참여 시나리오 발생!

Q. 그냥 (member_id, group_id) Unique Index를 사용하면 안 되나요?
같은 회원이 모임을 나갔다가 재참여하는 경우, 이전 삭제 기록으로 인해 Unique Index 에러가 발생합니다.
(member_id, group_id) = [1, 1]이 이미 존재하므로 재참여 시 에러 발생.

이에 팀원들이 각 방법을 하나씩 맡아 비교 테스트를 진행했습니다. 단순히 동시성을 핸들링하는 것이 아니라 DB ↔ Server ↔ Redis 간 100회 동시 요청을 보내며 어떤 부분에 리소스 사용률 및 병목이 생기는지 확인하고자 했습니다.


1. Synchronized

JVM 모니터락을 활용한 단일 프로세스 동시성 제어

Java에서 제공하는 동시성 핸들링 방법으로, 메서드 블록 단위의 임계영역을 생성합니다.

public synchronized PetGroupParticipateResponse participate(Member member, PetGroupParticipateRequest request) {
    return transactionTemplate.execute((status) -> {
        // 약속 참여 로직
    });
}
⚠️ 주의: @Transactional과 함께 사용 시
Spring AOP 기반의 @Transactional과 함께 사용하면 모니터락이 해제된 후 커밋되기 전에 후속 트랜잭션이 시작될 수 있습니다. 이를 방지하기 위해 TransactionTemplate을 활용해 임계영역 내부에서 트랜잭션이 열리도록 조정했습니다.

K6 부하 테스트 결과 (100회 동시 요청)

K6 상세 결과 보기
checks_succeeded...: 1.00%  1 out of 100

HTTP
http_req_duration..............: avg=1.05s    min=422.42ms  max=1.71s
  { expected_response:true }...: avg=528.18ms

EXECUTION
iteration_duration.............: avg=1.16s

CloudWatch 모니터링 지표

항목이전이후변화
EC2 CPU (%)1.043.78+2.74
EC2 Memory (%)38.4538.85+0.40
DB CPU (%)1.041.17+0.13 ✅ 낮음
DB Memory (%)69.9169.90-0.01
HikariCP Usage Count0100+100
HikariCP Usage Avg-15.23ms-
Duration-528.18ms-

✅ 장점

  • 추가 컴포넌트 없이 애플리케이션 단에서 간단히 적용
  • JVM 내부 모니터락으로 속도가 가장 빠름
  • DB 부하 거의 없음

❌ 단점

  • 메서드 블록 단위 임계영역 — 같은 회원+모임 조합만 제한 불가
  • TransactionTemplate 사용으로 서비스 코드 가독성 저하
  • 단일 프로세스 내에서만 동작 (수평 확장 불가)
  • 선행 트랜잭션의 무한 대기 문제

2. 트랜잭션 격리수준 Serializable

읽기에도 공유락을 잡아 트랜잭션을 논리적으로 직렬화

Serializable 격리수준에서는 읽기 요청에서도 공유락을 잡기 때문에 트랜잭션이 순차적으로 진행되는 것과 같은 논리성을 가집니다. @Transactional의 isolation 옵션 변경만으로 쉽게 구현 가능합니다.

@Transactional(isolation = Isolation.SERIALIZABLE)
public PetGroupParticipateResponse participate(Member member, PetGroupParticipateRequest request) {
    validateAlreadyAttended(targetGroupCrews, member.getId());
    Crew savedCrew = crewRepository.save(new Crew(targetGroup, member));
    return new PetGroupParticipateResponse(savedCrew.getId());
}

CloudWatch 모니터링 지표

항목이전이후변화
EC2 CPU (%)0.963.97+3.01
EC2 Memory (%)36.6739.99+3.32 ⚠️
DB CPU (%)1.041.18+0.14
DB Memory (%)68.8669.26+0.40
Duration-778.58ms-

Serializable은 읽기 과정에서 공유 잠금을 획득하고 트랜잭션이 끝날 때까지 유지하므로, DB와의 네트워크 통신이 지속적으로 필요합니다. 반면 Synchronized는 JVM 내부에서만 동작하기 때문에 DB CPU 변화가 거의 없었습니다.

✅ 장점

  • DB 레벨 제어 — 멀티 인스턴스 환경에서도 적용 가능
  • 코드 변경 최소화 (격리수준 옵션만 변경)
  • 같은 회원+모임 조합에 한정된 충돌만 직렬화

❌ 단점

  • 읽기 작업에도 공유 잠금 — DB 부하 증가
  • 잠금 경합 심화 시 Dead Lock 발생 가능
  • DB와의 잠금 통신 증가 → EC2 네트워크 I/O 및 CPU 부하 상승

3. Redis 분산락

Redisson 기반 Pub/Sub 방식의 분산 락

Lettuce 대신 Redisson을 선택한 이유는 다음과 같습니다.

  • 내장 WatchDog 스레드로 leaseTime 자동 연장 가능
  • Redis Pub/Sub 기반 락 release/acquire — 스핀락 대비 Redis 부하 감소
  • 다양한 락 구현체와 간단한 인터페이스 기본 제공
DistributedLock 애너테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String key();
    TimeUnit timeUnit() default TimeUnit.SECONDS;
    long waitTime() default 5L;
    long leaseTime() default 10L;
}
DistributedLockAspect (AOP)
@Order(Ordered.HIGHEST_PRECEDENCE) // 트랜잭션보다 먼저 수행
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {

    private final RedissonClient redissonClient;

    @Around("@annotation(DistributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
        DistributedLock distributedLock = /* 애너테이션 추출 */;
        String lockKey = createLockKey(...);
        RLock lock = redissonClient.getLock(lockKey);

        boolean acquired = lock.tryLock(
            distributedLock.waitTime(),
            distributedLock.leaseTime(),
            distributedLock.timeUnit()
        );

        if (!acquired) throw new RuntimeException("락 획득에 실패했습니다.");

        try {
            return joinPoint.proceed();
        } finally {
            if (lock.isHeldByCurrentThread()) lock.unlock();
        }
    }
}
@Transactional
@DistributedLock(
    key = "'participate:' + #groupId + ':' + #member.id",
    waitTime = 5,
    leaseTime = 10
)
public PetGroupParticipateResponse participate(long groupId) {
    // 모임 참여 로직
}

CloudWatch 모니터링 지표

항목이전이후변화
EC2 CPU (%)1.013.67+2.66
EC2 Memory (%)40.2039.81-0.39
DB CPU (%)1.031.17+0.14
Redis CPU (%)0.630.74+0.11
Redis Memory (%)23.5223.54+0.02
HikariCP Usage Avg-8.81ms✅ 낮음
Duration-737.76ms-

✅ 장점

  • 인메모리 기반으로 락 획득/해제 속도가 빠름
  • TTL 기반 자동 해제 — 무한 대기 문제 방지
  • 다중 서버 환경 지원 (수평 확장 가능)
  • DB 부하를 Redis로 분산

❌ 단점

  • 락 획득/해제마다 네트워크 통신 필요
  • Standalone 구조에서 Redis가 SPOF
  • TTL 자동 연장, 분산 환경 일관성 등 추가 고려사항 증가

4. DB Row Lock

락 전용 테이블 + Unique Index 기반 스핀락

락 전용 테이블을 생성하고 (group_id, member_id)로 행을 삽입/삭제하여 임계영역을 만드는 방식입니다. 후속 트랜잭션이 같은 키로 삽입을 시도하면 Unique 에러가 발생하고, backoff와 재시도로 스핀락처럼 동작합니다.

DistributedLock 엔티티
@Entity
@Table(name = "distributed_lock")
public class DistributedLock {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "lock_key", nullable = false, unique = true)
    private String lockKey;
}
DistributedLockService (락 획득/해제)
public boolean tryAcquireLock(String lockKey, int retryCount, long retryInterval) {
    int attempts = 0;
    while (attempts < retryCount) {
        try {
            lockRepository.saveAndFlush(new DistributedLock(null, lockKey));
            return true;
        } catch (DataIntegrityViolationException e) {
            attempts++;
            Thread.sleep(retryInterval); // backoff
        }
    }
    return false;
}

@Transactional(propagation = Propagation.REQUIRES_NEW) // 중요!
public void releaseLock(String lockKey) {
    lockRepository.deleteByLockKey(lockKey);
}
⚠️ REQUIRES_NEW 전파레벨이 중요한 이유
락 해제 로직에 REQUIRED 전파레벨을 사용하면 임계영역에서 열린 기존 트랜잭션에 참여하게 되어, 롤백 시 락도 함께 롤백되어 해제되지 않는 문제가 발생합니다.

CloudWatch 모니터링 지표

항목이전이후변화
EC2 CPU (%)0.993.17+2.18
EC2 Memory (%)36.6538.44+1.79
DB CPU (%)1.061.24+0.18
HikariCP Usage Count0108+108
HikariCP Usage Avg5.95ms54.43ms+48.48ms ⚠️
Duration-342.63ms응답속도는 빠름

✅ 장점

  • 추가 인프라 없이 DB만으로 락 구현
  • DB ACID 특성 그대로 활용
  • Unique 제약 조건 기반으로 동작 원리가 직관적

❌ 단점

  • 명시적 해제 실패 시 후속 트랜잭션 무한 대기
  • 고트래픽 시 DB 부하 증가 (HikariCP avg: 5.95 → 54.43ms)
  • REQUIRES_NEW 등 전파레벨 설계 복잡
  • 스핀락 방식 재시도로 커넥션풀 고갈 위험
  • 수평 확장 시 락 테이블로의 부하 집중

5. DB Named Lock

MySQL GET_LOCK / RELEASE_LOCK 기반 이름공간 락

MySQL의 GET_LOCK(key, timeout)을 사용해 DB 이름공간에 락을 거는 방식입니다. 문자열 키를 기반으로 락을 걸기 때문에 비즈니스 도메인에 맞는 키 설계가 자유롭습니다.

NamedLockRepository
@Repository
public class NamedLockRepository {
    private final JdbcTemplate jdbcTemplate;

    public Integer getLock(String key, long timeout) {
        return jdbcTemplate.queryForObject("SELECT GET_LOCK(?, ?)", Integer.class, key, timeout);
    }

    public Integer releaseLock(String key) {
        return jdbcTemplate.queryForObject("SELECT RELEASE_LOCK(?)", Integer.class, key);
    }
}
NamedLockAop (AOP)
@Order(value = Ordered.HIGHEST_PRECEDENCE)
@Aspect
@Component
public class NamedLockAop {
    @Around("@annotation(com.moong.annotation.namedlock.NamedLock)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        String key = namedLock.key();
        Connection lockConnection = dataSource.getConnection(); // 전용 커넥션 확보

        Integer result = lockRepository.getLock(key, namedLock.timeout());
        if (result == null || result != 1) throw new RuntimeException("Lock 획득 실패");

        try {
            return joinPoint.proceed();
        } finally {
            lockRepository.releaseLock(key);
            lockConnection.close();
        }
    }
}
⚠️ 커넥션 2개 필요
DB Named Lock은 커넥션에 종속적입니다. 락 획득 커넥션과 해제 커넥션의 동일성이 보장되어야 하므로, 실로직용 커넥션 1개 + 락 전용 커넥션 1개로 총 2개 이상의 커넥션이 필요합니다.

CloudWatch 모니터링 지표

항목이전이후변화
EC2 CPU (%)1.015.80+4.79 ⚠️
EC2 Memory (%)39.7440.29+0.55
DB CPU (%)1.081.17+0.09
HikariCP Usage Count0107+107
HikariCP Usage Avg9ms202ms+193ms 🚨
Duration-avg 1.2s5가지 중 가장 느림

✅ 장점

  • 추가 인프라 없이 DB만으로 구현
  • TTL 지원 — 무한 대기 문제 방지
  • 문자열 기반 유연한 키 설계

❌ 단점

  • 커넥션 2개 필요 → 커넥션 고갈 위험 (avg 202ms)
  • MySQL 전용 문법 — 벤더 종속성
  • 고트래픽 시 DB 부하 및 커넥션 경쟁 가장 심함

전체 비교 요약

방법 Duration (avg) HikariCP avg DB 부하 수평확장 추가 인프라
Synchronized 528ms ✅ 15.23ms ✅ 낮음 ✅ ❌ 불가 불필요
Serializable 778ms - 중간 ✅ 가능 불필요
Redis 분산락 737ms 8.81ms ✅ 낮음 ✅ ✅ 가능 Redis
DB Row Lock 342ms ✅ 54.43ms ⚠️ 높음 ⚠️ △ 제한적 불필요
DB Named Lock 1.2s 🚨 202ms 🚨 높음 🚨 △ 제한적 불필요

Moong 팀의 선택: Redis 분산락

🎯 선택 기준

기준 1. 수평 확장에도 호환 가능한 방식
단일 프로세스 내에서만 동작하는 Synchronized는 배제했습니다. 비즈니스적으로 동일 모임+동일 회원에 대해서만 임계영역을 만들 수 없어 성능 문제도 있었습니다.

기준 2. DB 부하 최소화
DB 한 대에 의존하는 구조에서 실제 운영 중 프론트 부하로 인해 Connection Pool이 고갈되는 상황을 경험했습니다. DB Named Lock (avg 202ms), DB Row Lock (avg 54ms)은 커넥션 경쟁이 너무 심했습니다. Redis를 활용하면 동시성 핸들링 자원과 핵심 비즈니스 자원을 분리할 수 있습니다.

기준 3. 락 경합 최소화
Serializable은 읽기 요청에도 공유락을 잡아 전반적인 부하를 증가시키고 데드락 위험이 있었습니다. Redis는 싱글 스레드 기반 원자적 연산으로 레코드 수준의 락 전파가 없어 데드락 위험이 현저히 낮습니다.

✅ 최종 채택: Redis 분산락 (Redisson)
수평 확장 호환성, DB 부하 최소화, 락 경합 최소화 3가지 기준을 모두 만족하는 유일한 방안이었습니다.

마치며

해당 연구는 약 4일간 진행되었습니다. 데이터 세팅부터 모니터링 환경 구축, K6 동시성 테스트까지 많은 준비작업이 필요했습니다.

각 테스트를 진행하며 DB, Redis, EC2 인프라의 모니터링 지표를 통해 각 대안이 어떤 요소에 얼만큼의 부하를 주는지 직접 확인할 수 있었습니다. 단순히 "어떤 방법이 맞다"가 아니라, 서비스의 인프라 구조와 트래픽 패턴에 따라 최적의 선택이 달라질 수 있다는 것을 체감한 시간이었습니다.