동시성 이슈 해결 - 정리

박준형·2025년 4월 4일

스프링 개발

목록 보기
5/20
post-thumbnail

'플리빗'이라는 유저 취향 공유 플랫폼의 개발 완료 후 유저테스트 중 하나의 포스팅에 반응하는 부분에서 문제가 생기게 되었다.

포스팅에 좋아요 표시와 같은 반응 버튼을 누르면 올라가고 내려가야하는데 동시에 많은 요청을 하거나 마우스로 빠른 속도로 클릭 시 카운트가 무한히 올라가거나 무한히 내려가는 현상이 발생하였다.

이 문제 해결을 위해 여러가지 자료를 찾아보며 문제의 원인이 동시성 이슈에 있다는 것을 깨닫고 문제 해결을 위해 동시성 이슈에 관하여 찾아보며 정리를 해보는 시간을 가지기로 했다.



🔐 자바에서 동시성 문제 해결하는 7가지 방법

좋아요 기능처럼 단순해 보이는 기능도, 다수의 사용자가 동시에 접근하면 동시성 이슈(Race Condition)가 발생할 수 있다.

예를 들어 10명이 동시에 좋아요를 누르면, 실제로는 10이 올라가야 하는데 3~4만 올라가는 상황이 발생함. 이것이 바로 레이스 컨디션(Race Condition)이며, 시스템의 정합성을 무너뜨릴 수 있다.

이번 글에서는 자바(Spring 기반)에서 동시성 문제를 해결할 수 있는 방법 7가지를 실무적으로 정리해보고, 특히 Redis를 활용한 분산 락 방식과 Redisson 사용법까지 함께 소개하려고 한다.

  • 나는 Redis를 이용한 분산 락 방식을 사용하려고 함!!


1. synchronized – 자바 기본 락

자바에서 가장 기본적인 동기화 방식이다.
한 번에 하나의 스레드만 접근할 수 있도록 메서드나 블록에 synchronized 키워드를 붙인다.

public synchronized void like() {
    likeCount++;
}

✔ 장점
구현이 가장 간단
단일 JVM에서는 안정적인 동기화

❌ 단점
다중 서버 환경에서는 동작하지 않음
성능 저하 (스레드 블로킹 발생)

-> 가장 간단하게 사용할 수 있지만 요즘 대부분은 여러 서버를 동시에 사용하다 보니 실질적으로 동시성 이슈를 해결할 수는 없다!!



2. AtomicInteger, AtomicLong – 비블로킹 원자 연산

AtomicLong은 내부적으로 CAS(Compare-And-Swap) 알고리즘을 이용해
락 없이 값을 안전하게 변경한다.

AtomicLong likeCount = new AtomicLong();
likeCount.incrementAndGet();

✔ 장점
synchronized보다 훨씬 빠름
락 없이 원자성 보장

❌ 단점
JVM 안에서만 유효 (분산 환경에서는 무효)
서버 재시작 시 초기화됨

-> 락 사용 없이 동시성 이슈를 해결할 수 있지만 서버 재시작 시 초기화되는 문제가 크다는 생각이 들었다.



3. 데이터베이스 관련 락

동시성 이슈 해결 시 가장 많이 들어본 비관적 락과 낙관적 락이 있다.

3.1. Pessimistic Lock (비관적 락) – SELECT FOR UPDATE

DB 수준에서 락을 먼저 걸고 트랜잭션을 수행한다.
JPA에서는 @Lock(PESSIMISTIC_WRITE)를 어노테이션으로 붙여 구현한다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Post p WHERE p.id = :id")
Post findByIdWithLock(@Param("id") Long id);

✔ 장점
정합성 강력 보장
충돌이 많아도 안전하게 처리 가능

❌ 단점
대기 시간이 발생해 성능 저하
데드락 가능성 있음

-> 충돌을 해결할 수 있지만 조회할 때 매번 락을 사용하므로 데이터베이스 부담을 크게 느낄 수 있을 것 같다.

3.2. Optimistic Lock (낙관적 락) – @Version

충돌이 적을 것이라 가정하고, 저장 시점에 버전 필드로 충돌 여부를 체크한다.

@Version
private Long version;

저장 시 버전이 일치하지 않으면 OptimisticLockingFailureException 발생
→ 재시도 필요
→ 만약 두 스레드 A,B가 동시에 version 1인 데이터에 접근할 경우 먼저 스레드 A의 작업 이후 version을 수정하여 2로 바꾸고 스레드 B는 version이 변경된 것을 확인하고 새로운 version의 데이터를 조회하게 된다.

✔ 장점
락을 걸지 않아 성능 저하 적음
충돌이 드문 데이터에 적합

❌ 단점
충돌 발생 시 예외 처리 및 재시도 필요
다량 트래픽/경합 구간에서는 비효율



4. Redis INCR 기반 카운팅

Redis의 INCR 명령은 원자적으로 동작한다.
분산 서버 환경에서도 중복 없이 정확한 카운팅이 가능하다.

redisTemplate.opsForValue().increment("post:123:likeCount");

✔ 장점
매우 빠름
멀티 서버에서도 정확한 수치 보장

❌ 단점
DB와의 동기화 필요
Redis 장애 시 데이터 유실 가능



5. Redis 분산 락 – SET NX 방식 (직접 구현)

Redis의 SET 명령에 NX(존재하지 않을 때만 설정) + PX(TTL 설정) 옵션을 함께 써서
하나의 클라이언트만 임계 구역에 진입하도록 락을 구현할 수 있다.

🔧 예제 코드 (with UUID)

String key = "lock:post:123";
String uuid = UUID.randomUUID().toString();

// 락 획득 (3초 유효)
Boolean isLocked = redisTemplate.opsForValue()
    .setIfAbsent(key, uuid, Duration.ofSeconds(3));

if (Boolean.TRUE.equals(isLocked)) {
    try {
        // 좋아요 증가 로직
    } finally {
        // 락 소유자만 해제 가능하도록 검증
        String currentValue = redisTemplate.opsForValue().get(key);
        if (uuid.equals(currentValue)) {
            redisTemplate.delete(key);
        }
    }
}

✔ 장점
멀티 서버 환경에서도 락 기능 구현 가능
TTL 설정으로 데드락 방지

❌ 단점
락 해제 시 검증 미흡하면 다른 클라이언트 락을 해제할 수 있음
검사 → 삭제 사이 타이밍 이슈 존재 → Lua 스크립트로 원자화 권장
구현 복잡도 있음

-> 🛠 실무 팁
락 획득 + 해제를 원자적으로 처리하려면 Lua 스크립트 또는 Redisson 사용 권장!



6. Redis 분산 락 – Redisson 활용 (추천)

Redisson은 Redis를 활용해 분산 락, 페어락, 세마포어 등을 고수준 API로 제공합니다.
내부적으로 락 소유자 검증, 자동 갱신, 만료 설정 등을 모두 처리해줍니다.

📦 의존성 추가
build.gradle에 아래와 같은 의존성을 추가해준다.

implementation 'org.redisson:redisson-spring-boot-starter:3.23.2'

🧩 사용 예제

@Autowired
private RedissonClient redissonClient;

public void likeWithLock(Long postId) {
    RLock lock = redissonClient.getLock("lock:post:" + postId);

    boolean isLocked = false;
    try {
        // 락 획득 시도 (최대 5초 대기, 3초 동안 점유)
        isLocked = lock.tryLock(5, 3, TimeUnit.SECONDS);
        if (isLocked) {
            // 좋아요 증가 처리
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    } finally {
        if (isLocked && lock.isHeldByCurrentThread()) {
            lock.unlock(); // 락 해제
        }
    }
}

✔ 장점
락 소유자 자동 검증
TTL 설정 + 자동 연장 기능
Lua 스크립트로 락 해제 안정성 확보

❌ 단점
별도 라이브러리 필요
Redis 다운 시 락 기능 불가

🛠 실무 팁
tryLock(waitTime, leaseTime) 설정을 적절히 조절해야 한다.
Redis Cluster 또는 Sentinel 환경과 함께 쓰면 고가용성까지 확보할 수 있다.



✅ 7. 상황별 추천 전략

단일 서버, 간단한 로직 -> synchronized, AtomicLong
JPA 사용 + 충돌 적은 필드 업데이트 -> Optimistic Lock
충돌 많고 정합성 최우선 -> Pessimistic Lock
실시간 집계, 분산 환경 -> Redis INCR
공유 자원 락 필요 (멀티 서버) Redis 분산 락 (SET NX or Redisson)
락 안전성과 유지보수 고려 -> Redisson (라이브러리 활용)

상황에 따른 여러가지 동시성 이슈 해결방법을 적절히 고려해야 적용해보아야 한다.

나는 앞서 있었던 문제 해결을 위해 이 중 SET NX를 이용한 Redis 분산 락을 이용하여 문제 해결을 해보려고 한다.

위와 같이 Redis 분산락을 이용한 해결 후 추후 Redis Cluster와 Sentinel 환경에 대한 공부 이후 Redisson 라이브러리 활용을 하여 수정해보는 과정을 생각중이다.

이번주는 시간이 없어서 학습만 하고 추후에 프로젝트에 동시성 이슈 해결을 적용해볼 계획이다!!



참고자료
SpringBoot 동시성 이슈 해결
[Java] 멀티 쓰레드 동시성 이슈
동시성 이슈 처리 및 테스트 방안 - 1

profile
매일 매일 성장하기

0개의 댓글