본문 바로가기
Project/SpringBoot

자바의 동시성 처리 방법

by 꽃요미 2025. 5. 5.

* 문제 발생

Spring 프로젝트를 진행하면서 동시성 문제에 직면했다.

인게임( quiz ) -> 대기방( game ) 으로 리다이렉트 할때 문제 발생.

다중 사용자들이 한번에 대기방으로 이동

따라서 동시에 DB 접근을 하기 때문에 경쟁 조건 ( Race Condition ) 이 발생.

 

* 해결 방법

총 2가지로 해결 가능함

1. Java의 동시성 해결 키워드를 사용

 - Synchronized 키워드

 - reentrantLock 키워드

2. 비동기 코드를 추가해서 결과를 반환 함

 - executorService 를 이용해서 비동기 처리 후

 CompleteableFuture로 처리한 작업의 결과를 순차적으로 반환 받음

 - @Async 어노테이션으로도 처리가 가능하다

 

첫번째 Synchronized 키워드를 조사한대로 정리

 

 * Synchronized

 - 모니터 락을 사용함

 - 위 모니터 락은 객체 ( instance ) 마다 1개씩 존재함

 - 따라서 각 new 객체로 생성되면, 각각이 다른 Lock을 획득함

 - 장점으로 간단하게 키워드만 붙여주면 되서 가독성이 좋음

 - 락 공정성을 제어할 수 없음

주요 메소드 ( 모니터 락을 사용하는 기능 )

 - wait : 현재 스레드를 wating 상태로 변경함

 - norify : wating 상태의 스레드를 하나 꺠움

 

 * reentrantLock

1. Condition 객체

 - 모니터 락을 사용하는 Synchronized와 달리 Condition 객체를 사용함

 - reentrantLock은 단순히 lock 만을 진행하고, Condition 객체로 lock 을 해제함

 Condition 객체의 주요 메소드 ( reentrantlock 에서 사용 )

 - await : 현재 스레드를 blocking 함

 - signal : 대기 중인 스레드 중 하나를 깨움

여러 Condition 객체를 만들어서 핸들링 가능함

 

++ 특정 이벤트를 기다리는 복수 대기 큐 ( notFull/notEmpty 같은 생산자.소비자 패턴 ) 에 유용하다는 점.

 

2. 공정성

 - 객체 생성시 true를 전달하면 공정락, false를 반환하면 비 공정락이 적용됨

 - 공정하다는 의미는 락이 걸린 순서대로 락을 해제한다는 의미이다 

종류 공정락 비 공정락
락 대기 순서 FIFO 대기큐 무시, 즉시 재시도 성공가능
락 획득 순서 순서대로 순서대로x ( 랜덤 )
Stravation X O
성능 오버헤드 발생 처리가 빠르다

 

 - 여기서 비 공정락에 stravation이 발생하는 이유는 순서대로 락 해제를 하지 않기 때문에 먼저 들어온 스레드가 계속해서 기다릴 수 있기 때문

 

* 여기서 reentrantLock의 메소드에 종류가 많다. 차례대로 설명하겠음.

 1. lock.tryLock()

 - tryLock() : 인자를 전달하지 않으면 기다리지 않고, 락이 가능할때 바로 시도. 그렇지 않으면 false를 반환한다.

 - tryLock(time, unit) : 첫번째 인자에 시간, 두번째 인자에 단위를 전달하면 전달한 시간만큼 기다림. 획득하지 못 하면 마찬가지로 false 반환.

 

 2. lock.lockInterruptibly()

 - 위 lock() 과 다르게 락을 획득하기 전까지 무한정 대기함.

 - 중간에 작업 취소 요청 ( interrupt ) 이 들어오면 스레드를 종료함.

 - 장점으로 데드락 회피에 유용함.

 

- 위 방법들을 종합적으로 고려해본 결과

1. synchronized 키워드는 객체 ( 모니터락 ) 단위로 lock 을 하기 때문에 속도가 느리다.

 - 위 부분은 Java 1.6 이후 GC나 JIT 최적화 덕에 성능 차이가 크지 않을 수 있음. 

  실제 서비스에서는 유지보수성, 코드 가독성, 디버깅 편의성도 함께 고려해야함.

2. 속도를 높이기 위해서 executorService 를 활용하면 비동기로 데이터를 주고 받기 때문에

CompleteableFuture 객체를 활용해도 오류가 발생할 확률이 높아진다.

3. 최종적으로 reenterantLock 을 사용.

4. lock 메소드가 3가지 존재함.

 - lock() : 락을 획득할때까지 무한정 대기함

 - tryLcok() : 획득할 수 있으면 획득하고, 아니면 false 반환

 - lockInterruptibly() : 락을 획득하기 전까지 inerrupt 가 들어오면 즉시 스레드를 종료함

5. 무한정 자원을 점유해서 데드락 상황이 발생하는 lock() 메소드 보다는 tryLock() 을 사용하여

일정 시간이 지나면 해제가 되는 것이 조금 더 안정적이라고 생각하였다.

- 따라서 방 입장을 호출하는 doEnter() 메소드를 reentrantlock의 tryLock() 을 사용하여 lock을 진행하고

데이터 정합성을 보장하였음.

 

* 문제 발생

방 입장시 동시에 2명의 클라이언트가 동시에 입장을 한다면 마찬가지로 동시성 문제가 발생한다.

동시에 인원수 + 1 로직이 실행되며 DB에 반영되면 데이터 정합성에 어긋나는 로직이다.

단순히 int형 변수의 정합성을 보장하려면 객체 단위로 lock 을 하는 synchronized

가독성이 떨어지는 reentrantlock을 사용하기 보다, 자바가 지원하는 AtomicInteger 를 활용하는 것이 좋다.

 

* AtomicInteger

 

 - 장점

락을 사용하지 않고 ( Lock-free ) CPU 명령어인 CAS (Compare And Set)을 이용해

get - compute - set 과정을 원자적 ( Atomic ) 으로 처리합니다.

따라서 락을 사용하지 않기 때문에 가벼운 스레드 안전성을 제공함.

 

CAS 연산 단계 ( 메모리 주소 'V', 예상 값 'A', 새로운 값 'B')

 1. 메모리의 현재 값 (V)을 읽어 옴

 2. 현재 값 (V)과 예상 값 (A)을 비교.

 3. 만약 현재 값 (V)과 예상 값 (A)이 같다면, 현재 값 (V)을 새로운 값 (B) 으로 변경.

 4. 만약 현재 값 (V)과 예상 값 (A)이 다르다면, 연산은 실패. 아무런 변경을 하지 않음.

 

예상 값 : 현재 메모리에 저장된 값이라고 생각하는 값. 즉 CAS 연산을 수행하는 스레드가 "이 값이 지금도 메모리에 그대로 있을 것이다" 라고 예상하는 값.

 ex)

  1. 스레드 A가 변수 counter의 값을 읽어와서 10이라고 확인.
  2. 스레드 A는 counter 값을 1 증가시키려고함. 이때 스레드 A는 "현재 counter의 값은 10일 것이다"라고 예상. (예상값 = 10)
  3. 스레드 A는 CAS 연산을 수행하여 counter의 현재 값과 예상값(10)을 비교.
  4. 만약 다른 스레드가 counter 값을 변경하지 않았다면, 현재 값은 10일 것이고, CAS 연산은 성공하여 counter 값을 11로 변경.
  5. 만약 다른 스레드가 counter 값을 이미 변경했다면 (예: 12로 변경), 현재 값은 10이 아닐 것이고, CAS 연산은 실패. 스레드 A는 다시 counter 값을 읽어와서 새로운 예상값으로 CAS 연산을 재시도.

CAS는 동시성 제어를 위해 유용한 원자적 연산이다. CAS를 사용하면 락을 사용하지 않고도 안전하게 데이터를 갱신할 수 있다. 근데 생각해보면 멀티스레드 환경이라면 현재값을 여러 스레드에서 동시에 읽고 예상값과 비교하여 일치한다면 예측불가능한 값으로 바뀔 수 있지 않을까?

 

Java에서는 CAS 연산이 원자적으로 처리되며 락을 사용하지 않는다. 그러나 실제로 CPU 레벨에서 락이 걸리게 된다. 이러한 방식은 하드웨어 기반의 락을 통해 보장된다.

 

따라서 CAS 연산 자체는 락을 사용하지 않지만 하드웨어적으로 원자성을 보장하기 위한 락 메커니즘이 적용된다. CPU 비교 및 교체 연산은 매우 빠르게 이루어지며 이는 비교적 저렴한 비용으로 동시성 문제를 해결할 수 있다. 그럼 과연 얼마나 차이가 나는지 굉장히 간단한 테스트를 해보자. 테스트 코드는 김영한 강사님의 강의에서 나오는 코드다.

 

public class BasicInteger implements IncrementInteger {
    private int value;

    @Override
    public void increment() {
        value++;
    }

    @Override
    public int get() {
        return value;
    }
}

public class VolatileInteger implements IncrementInteger {
    volatile private int value;

    @Override
    public void increment() {
        value++;
    }

    @Override
    public int get() {
        return value;
    }
}

public class SyncInteger implements IncrementInteger {
    private int value;

    @Override
    public synchronized void increment() {
        value++;
    }

    @Override
    public int get() {
        return value;
    }
}

public class MyAtomicInteger implements IncrementInteger {
    AtomicInteger atomicInteger = new AtomicInteger(0);

    @Override
    public void increment() {
        atomicInteger.incrementAndGet();
    }

    @Override
    public int get() {
        return atomicInteger.get();
    }
}

 

BasicInteger는 lock 없이 단순히 값을 증가시키고 있다. VolatileInteger는 메모리 가시성을 위한 volatile 키워드만 추가한 상태다. 원자성은 보장해주지 않는다. SyncInteger는 synchronized 키워드를 이용해 원자적으로 값을 증가시키고 있고, MyAtomicInteger는 CAS연산을 이용하는 AtomicInteger를 이용하고 있다.

 

BasicInteger: ms=42
VolatileInteger: ms=629
SyncInteger: ms=940
MyAtomicInteger: ms=686

 

각각 0부터 1,000,000까지 단순히 값을 증가시켰다. 어떠한 lock도 없는 BasicInteger가 가장 빨랐고 다음으로 메모리 가시성만 확보하고 있는데 VolatileInteger와 CAS를 이용하여 원자성까지 보장하는 MyAtomicInteger가 비슷했다. 마지막으로 synchronized를 쓰고 있는 SyncInteger가 가장 느렸다. 이처럼 CAS를 이용하면 실제로 lock을 거는것 보다 빠르게 연산을 할 수 있다.

 

그럼 원자적 연산이 필요하다면 항상 CAS 연산이 맞을까?

 

CAS는 예상값과 현재값이 다를 경우 spin lock으로 대기하게 된다. spin lock은 스레드가 BLOCKED, WAITING 상태로 빠지지 않고 RUNNABLE 상태로 락을 흭득할 때 까지 계속 요청을 한다. 즉 스레드가 CPU를 사용하면서 계속 대기하는 것이다. 그러므로 CAS는 안전한 임계영역이 필요하지만, 연산이 길지 않고 짧게 끝날 때만 사용해야 한다.

 

네트워크 I/O나 디스크 I/O 와 같이 오래 기다리는 작업에 사용하면 CPU를 계속 사용하면서 기다리는 결과가 나올 수 있다. 이런 연산의 경우 ReentrantLock을 이용하여 스레드를 WAITING 상태로 전환하는 것이 더 효율적이다. 

atomicInteger 예시사진

 

 

또한 ABA 문제가 발생 가능하다..

 

 

// 추가 예정

 

 

 

 - 단점

 

 

주요 메소드

 

get() : 현재 저장된 값을 반환

set(int newValue) : 값을 강제로 newValue 로 설정

getAndIncrement() : 현재 값을 반환한 뒤 1 증가 (post-increment)

incrementAndGet() : 1 증가시킨 뒤 그 값을 반환 (pre-increment)

getAndAdd(int delta) : 현재 값을 반환한 뒤 delta 만큼 더함

addAndGet(int delta) : delta 만큼 더한 뒤 그 값을 반환

compareAndSet(int expect, int update) : 현재 값이 expect 와 같으면 update 로 바꾸고 성공하면 true, 아니면 false 반환

updateAndGet(IntUnaryOperator updateFunction) : 람다식으로 전달될 함수를 적용하여 새로운 값을 계산 저장 반환.

getAndUpdate(IntUnaryOperator updateFunction) : 람다식 결과를 계산해 값을 저장하되, 이전 값을 반환.

 

그래서 프로젝트의 인원수 증가 로직을 살펴보면

 

private void updateRoomSubscriptionCount(Long roomId) {
        AtomicInteger count = roomSubscriptionCount.get(roomId);

        if (count == null) {

            return;
        }

        int currentCount = count.updateAndGet(current -> {
            if (current < 1) {
                throw new RuntimeException("방 인원 음수가 될 수 없습니다.: " + roomId);
            }

            roomPeopleCacheTemplate.opsForValue().decrement(ROOM_ID_PREFIX + roomId);

            return current - 1;
        });

        if (currentCount <= 0) {
            cleanUpEmptyRoom(roomId);
        }

        redisEventPublisher.publishChangeCurrentPeople(REDIS_PUBLISH_CHANNEL, new ChangeCurrentPeopleResponse(roomId, currentCount, System.currentTimeMillis()));
    }

 

- 각 방에 저장되어있는 roomSubscriptionCount의 Map에 저장되어있는 인원수를 반환 받아 원자적 계산이 가능한 AtomicInteger 객체에 저장

- updateAndGet() 함수로 새로운 값을 계산하여 저장. 반환을 하는데, 여기서는 인원수가 음수일때는 예외를 던지고

그렇지 않으면 로드 밸런싱 되는 상황을 고려하여 redisTemplate의 객체인 roomPeopleCacheTemplate 의 값을 1 감소 후 반환

- 만약 음수 시 cleanUpEmptyRoom() 을 호출 후 인원수를 redis로 갱신

 

++ Scale out 상황에서의 동시성 처리

 

- 들어가기 전에 Scale out 상황에 관해서 정리.

1. Scale-Up

 - 동일 서버의 용량을 증설하는 목적임.

 - 서버 스펙을 업그레이드 한다. 서버의 사양을 높이는 것이기 때문에 수직 스케일링 (vertical scaling) 이라 함

 - 기존의 8G 메모리, 1T 하드디스크 서버로 구성되었다고 한다면

 16G 메모리, 10T 하드디스크 사양으로 업그레이드 하는 것이다.

 - AWS에서는 스펙이 더 좋은 인스턴스 타입으로 교체하는 것이다.

 

2. Scale out

 - 서버의 자원 사용량이 부족하여 비슷한 사양의 서버를 추가함.

 - 하나의 장비에서 서비스를 처리함에 있어 한계에 부딪힐 경우 비슷한 스펙의 서버를 "추가" 함으로 부하를 분산하는데 목적이 있음.

 - 컴퓨팅의 성능 상승보다 컴퓨팅 수를 늘리는 것

 - 스케일 업 방식과 다르게 성능과 비용이 비례한다는 장점이 존재

 

3. Scale in

 - scale-out 과 반대되는 개념.

즉 부족으로 늘렸던 자원을 다시 회수하는 작업

 - 작업이 완료되어 필요없는 Scale Out 으로 늘렸던 컴퓨팅 수를 줄이는 것이다.

 

즉 지금은 2번째 Scale out 을 한 상황이다. AWS의 EC2 인스턴스를 2개 붙여서 서버의 부하를 줄였다.

위 상황에 대해서 같은 클라이언트가 여러번 방 생성 요청을 보냈을때의 동시성 처리를 해야한다.

 

 - 로드 밸런싱된 상황에서 reentrantLock을 설정하게 되면 단일 서버에서만 스레드를 잠금/해제 하기 때문에

동시성 처리가 제대로 이루어 지지 않는다. 

따라서 서버 사이의 중앙 저장소인 Redis를 사용해서, 인스턴스 간 락 획득/해제를 공유하므로 클러스터 ( 서버들 ) 기준으로 

"한 번만" 실행되도록 보장해 줌.

 

* 그래서 프로젝트에서 아래와 같이 레디스 락을 적용했다.

 

 

 - 프로젝트에서는 UUID ( Universally Unique Identifier ) 의 줄임말로 이론상 전 세계 어디서든 중복되지 않는 고유값을 생성함.

 - 따라서 위 UUID를 클라이언트 쪽에서 생성하여 서버쪽으로 넘겨줌.

 - 서버는 UUID를 활용하여 redissionLock에 key 값으로 설정하여 저장함.

 

 1. 최초 저장된 시점에서 방 중복 생성 ( Ex. 더블 클릭, 로드 밸런싱 서버에서의 동시 생성 등... ) 

  - 클라이언트가 동일한 UUID 로 짧은 시간에 여러 번 방 생성 요청을 보낼 경우, 락 없이 처리하면 두 개 이상의 방이 생성될 수 있다.

분산 락을 걸면 "한 번에 오직 한 서버만" 해당 UUID 를 처리하므로, 최초 성공한 서버만 방을 만들고 나머지는 캐시된 응답을 반환함.

 

 2. 분산 환경 동기화

  - 일반적인 ReentrantLock 은 JVM 프로세스 내에서만 유효하지만, 클러스터로 띄운 여러 인스턴스 사이에서는 통신하지 않음.

  - 반면 Redisson의 분산 락은 Redis 를 중앙 저장소로 사용해, 인스턴스 간 락 획득/해제를 공유하므로 "한 번만" 실행되도록 보장해 줌.