본문 바로가기
정리

자바 동시성이슈

by dyddyd0 2025. 4. 28.

동시성이슈란,

여러 쓰레드가 동시에 공유 자원에 접근하면서 발생하는 문제.

멀티쓰레드 환경에서 여러 쓰레드가 동시에 같은 변수나 객체에 접근해서 값을 읽거나 쓸 때,
의도하지 않은 결과(값 꼬임, 덮어쓰기, 데이터 손실 등)가 발생하는 문제이다.

 

 

간단한 예제로 알아보자.

두 개의 쓰레드는 각각 count를 1씩 10번 더해 최종 count = 20을 기대하고 있다.

public class ConcurrencyExample {
    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            String threadName = Thread.currentThread().getName();
            for (int i = 0; i < 10; i++) {
                int before = count;
                count++;
                int after = count;
                System.out.println(threadName + " - count: " + before + " → " + after);
                try {
                    Thread.sleep(10); // 동시성 이슈 유도
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };

        Thread t1 = new Thread(task, "쓰레드-1");
        Thread t2 = new Thread(task, "쓰레드-2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("최종 count 값: " + count);
    }
}

결과를 보자면 다음과 같다.

쓰레드-2 - count: 1 → 2
쓰레드-1 - count: 0 → 1
쓰레드-1 - count: 2 → 3
쓰레드-2 - count: 3 → 4
쓰레드-1 - count: 4 → 5
쓰레드-2 - count: 5 → 6
쓰레드-2 - count: 6 → 7
쓰레드-1 - count: 6 → 7
쓰레드-2 - count: 7 → 8
쓰레드-1 - count: 8 → 9
쓰레드-2 - count: 9 → 10
쓰레드-1 - count: 9 → 10
쓰레드-2 - count: 10 → 11
쓰레드-1 - count: 11 → 12
쓰레드-1 - count: 12 → 13
쓰레드-2 - count: 13 → 14
쓰레드-1 - count: 14 → 15
쓰레드-2 - count: 15 → 16
쓰레드-1 - count: 16 → 17
쓰레드-2 - count: 17 → 18
최종 count 값: 18

두 개의 쓰레드는 공유자원 count에 접근하여 count 값을 +1 하는 작업을 한다.

결과가 위와 같이 나온 이유는

하나의 쓰레드가 작업 중인 결과가 반영되기 이전에 공유자원 count에 접근해 작업을 실행하기 때문이다.

쓰레드-2 count: 6 → 7

쓰레드-2의 작업 결과인 count = 7로 반영되기 이전에 count = 6을 가져와서 작업을 함 →

쓰레드-1 count: 6 → 7

 

최종 count 값을 20으로 예상했지만 동시성문제로 값이 꼬였다.

 

 

동시성 이슈를 다루는 방법

동시성 문제를 해결하기 위한 방법은 여러 가지 존재한다.

각각의 특징에 맞게 알맞게 해결하자.

해결 방법 설명 특징
synchronized 메서드나 블록 단위에서 락을 걸어 한 번에 하나의 쓰레드만 접근 가능하게 함 사용 간단, 성능은 낮을 수 있음
ReentrantLock java.util.concurrent.locks 패키지의 명시적 락 더 정교한 락 제어 (tryLock, interruptible 등), 락 해제 필수
Atomic 클래스 AtomicInteger, AtomicLong 등 원자성 보장 클래스 가벼운 동기화, CAS(compare-and-swap) 기반
Concurrent Collections ConcurrentHashMap, CopyOnWriteArrayList 등 동시성에 안전한 컬렉션 사용
Thread-safe 클래스 사용 예: StringBuffer (vs StringBuilder) 내부적으로 synchronized 처리됨
Executor & ThreadPool 쓰레드 생성, 실행을 안정적으로 관리 동시성 제어 + 자원 낭비 방지
Volatile 키워드 변수의 변경을 모든 쓰레드가 즉시 보게 함 원자성은 보장 X, 가시성만 보장

 

 

 

위에서 본 예제 코드를 Synchronized 키워드를 이용해 해결해 보자.

package concurrency;

public class SyncConcurrencyEx {
    static int count = 0;

    public static synchronized void increment() {
        count++;
    }

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            String threadName = Thread.currentThread().getName();
            for (int i = 0; i < 10; i++) {
                int before = count;
                increment();
                int after = count;
                System.out.println(threadName + " - count: " + before + " → " + after);
                try {
                    Thread.sleep(10); // 동시성 이슈 유도
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };

        Thread t1 = new Thread(task, "쓰레드-1");
        Thread t2 = new Thread(task, "쓰레드-2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("최종 count 값: " + count);
    }
}

결과는 아래와 같다.

쓰레드-1 - count: 0 → 1
쓰레드-2 - count: 1 → 2
쓰레드-2 - count: 2 → 3
쓰레드-1 - count: 3 → 4
쓰레드-2 - count: 4 → 5
쓰레드-1 - count: 5 → 6
쓰레드-2 - count: 6 → 7
쓰레드-1 - count: 7 → 8
쓰레드-2 - count: 8 → 9
쓰레드-1 - count: 9 → 10
쓰레드-2 - count: 10 → 11
쓰레드-1 - count: 11 → 12
쓰레드-1 - count: 12 → 13
쓰레드-2 - count: 13 → 14
쓰레드-1 - count: 14 → 15
쓰레드-2 - count: 15 → 16
쓰레드-2 - count: 16 → 17
쓰레드-1 - count: 17 → 18
쓰레드-1 - count: 18 → 19
쓰레드-2 - count: 18 → 20
최종 count 값: 20

 

count ++ 는 사실 세 가지 단계로 진행된다.

1. count 읽기 (load)
2. +1 계산
3. 결과를 count에 저장 (store)

 

synchronized를 쓰면 한 쓰레드가 increment()를 실행하는 동안
다른 쓰레드는 그 메서드에 진입 자체를 못 하게 락을 건다.
그래서 위 3단계 연산이 원자적(atomic)으로 실행되고,
모든 증가 작업이 정확히 누적된다.

 

조금 더 현실적인 예제로 보자면 계좌 인출 상황이 있다.

  1. A와 B가 동시에 계좌 잔액을 읽음 → 둘 다 100만 원이라고 봄
  2. A가 50만 원 인출 → 50만 원 남음
  3. B도 50만 원 인출 → 또 50만 원 남음으로 착각함
  4. 결과적으로 100만 원 있었던 계좌가 150만 원을 갖게 된 상황 → 문제 상황
package concurrency;

public class AccountConcurrency {
    static int balance = 1000000; // 100만원

    public static void withdraw(String user, int amount) {
        int currentBalance = balance; // 잔액 조회
        System.out.println(user + " 조회 잔액: " + currentBalance);

        // 인출 가능 여부 확인
        if (currentBalance >= amount) {
            System.out.println(user + " 인출 시도: " + amount);
            try {
                Thread.sleep(100); // 시간 지연: 동시성 이슈 유도
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            balance = currentBalance - amount; // 잔액 차감
            System.out.println(user + " 인출 성공 후 잔액: " + balance);
        } else {
            System.out.println(user + " 인출 실패 (잔액 부족)");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Runnable taskA = () -> withdraw("A", 500000);
        Runnable taskB = () -> withdraw("B", 500000);

        Thread t1 = new Thread(taskA);
        Thread t2 = new Thread(taskB);

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("최종 잔액: " + balance);
    }
}

결과 →

B 조회 잔액: 1000000
B 인출 시도: 500000
A 조회 잔액: 1000000
A 인출 시도: 500000
A 인출 성공 후 잔액: 500000
B 인출 성공 후 잔액: 500000
최종 잔액: 500000

 

 

 

 

대표적인 동시성 이슈로는  →

 

  • Race Condition(경쟁상태): 실행 순서에 따라 결과가 달라짐
  • Dirty Read/Write: 아직 완료되지 않은 데이터를 다른 쓰레드가 읽거나 씀
  • Deadlock(교착상태): 서로 자원을 기다리며 무한 대기 상태

 

 

알아본 예제는

Race Condition이고, 동시에 Dirty Read도 발생함.

  • A와 B가 동시에 balance 값을 읽고 동시에 수정하기 때문에,
    "누가 먼저 실행될지 예측할 수 없고", 결과가 비정상적이 될 수 있다.
    이건 Classic Race Condition이고,
    그 안에서 발생하는 "잘못된 중간 상태"를 Dirty Read라고 부름.
반응형

'정리' 카테고리의 다른 글

스프링 Bean의 scope  (0) 2025.04.30
싱글톤패턴  (0) 2025.04.29
동기(Synchronous)와 비동기(Asynchronous), blocking과 nonBlocking  (0) 2025.04.27
RDBMS vs NoSQL  (0) 2025.04.26
Collection정리하기 List, Set, Map  (0) 2025.04.25