Java

Java에서 멀티 스레드와 동기화 처리

김승진 2025. 4. 10. 15:21

 

 

배경

Java에서는 멀티스레드 환경에서 동시성 이슈를 마주할 수 있습니다. 어떤 원인 때문에 이러한 문제를 만나고 어떻게 해결할 수 있는지 확인해 보겠습니다. 

 

 

동기화란?

동기화란 스레드 간 공유 자원을 안전하게 접근할 수 있게 해주는 매커니즘입니다. 여러 스레드가 동시에 값을 바꾸려고 할 때 원자성과 가시성을 보장하는 게 특징입니다.

그렇다면 원자성과 가시성은 무엇이고 어떤 문제로 인해 원자성과 가시성을 보장해야 하는지 순차적으로 알아 보기 위해 다음과 같은 순서로 진행하겠습니다.

 

  • 공유자원과 임계영역
  • 경쟁 상태
  • 원자성과 가시성
  • 동기화 - 블로킹/논블로킹

 

공유자원과 임계영역

 

공유 자원(Shared Resource) : 여러 스레드가 동시에 접근할 수 있는 자원

임계 영역(Critical Section) : 공유자원들 중 여러 스레드가 동시에 접근했을때 문제가 생길 수 있기에 하나의 스레드만 접근할 수 있는 영역

 

여기서 임계 영역에서 생길 수 있는 문제는 경쟁 상태(Race Condition)입니다.

경쟁 상태는 둘 이상의 스레드가 공유 자원을 병행적으로 읽거나 쓰는 동작을 할 때 접근 순서에 따라 실행 결과 달라지는 상황을 의미합니다. 경쟁 상태에 대한 예로는 Read-Modify-Write, Check-then-act 가 있습니다. 순차적으로 예시를 보겠습니다.

 

 


 

Read-Modify-Write

public class ReadModifyWrite {

    public static void main(String[] args) {
        Add add = new Add();

        ExecutorService executor = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 100; i++) {
            executor.submit(add::process);
        }
        executor.shutdown();
        add.printResult();
    }

    static class Add{
        int number = 0;

        void process(){
            number++;
        }
        void printResult(){
            System.out.println(number);
        }
    }
}

 

위 코드 실행 결과입니다. ExecutorService를 통해 동시에 접근하는 상황을 만들었습니다. 스레드풀에 있는 스레드 5개가 동시에 접근합니다. 100번 1씩 증가하기에 결과값은 100을 예상하지만 출력 예시를 보면 62입니다. 이는 Read-Modify-Write 에 따른 문제입니다. 이렇게 되는 이유는 number++; 코드가 내부적으로는 읽기 → 연산 → 쓰기 세 단계로 이루어진 복합 연산이기 때문입니다. 아래 예시에서 확인하겠습니다.

 

 

 

 

 

 

예를 들어, number = 0 인 상태에서 스레드 A, B가 동시에 접근해보겠습니다. 스레드 A가 먼저 수행되어 number를 읽고 1을 증가하려고 합니다. 이때 스레드 A의 작업이 끝나기 전에 스레드 B도 해당 자원에 접근합니다. 스레드 A가 아직 number를 증가하지 않았기에 스레드 B가 읽은 number도 0 입니다.

 

따라서 스레드A, B가 number의 값을 2 증가하길 기대했지만 위와 같은 상황에서는 1만 증가하게 됩니다. 그렇기에 위에서 실행한 결과도 100이 아닌 62가 나왔습니다.

 

 

Check-Then-Act

public class CheckThenAct {

    static int count = 0;

    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(10);

        for (int i = 0; i < 50; i++) {
            executor.submit(() -> {
                count++; // Act (먼저 행동)
                if (count < 30) { // Check (그 후 검사)
                    try {
                        Thread.sleep(1); // 처리 지연 시뮬레이션
                    } catch (InterruptedException ignored) {}
                    System.out.println("30까지 얼마 남지 않았습니다! Count = " + count);
                }
            });
        }

        executor.shutdown();
    }
}

 

 

위 코드를 보면 총 50번 count를 1 증가합니다. 따라서 count가 30이 되는 순간 분기점을 수행해선 안 됩니다. 하지만 출력문에는 26에서 50이 출력되는 것과 같이 30 이상에 대해 조건문을 수행합니다. 이것도 Check-Then-Act 로 진행하기에 발생한 문제입니다. 아래 예시에서 확인하겠습니다.

 

 

 

 

스레드 A에서는 30을 넘지 않기에 number + 1을 통해 30으로 만듭니다. 분기문을 통과하고 수행하려고 하는데 스레드 B가 number + 1을 수행합니다. 그렇다면 number은 30인 상태입니다. 우리의 조건은 30 이상인 경우에는 분기문을 수행하지 않아야 하지만 스레드 A가 수행하는 분기문이 끝나기 전에 다른 스레드가 number를 증가하였기에 발생한 문제입니다.

이러한 문제는 원자성이 보장되지 않아 발생했습니다. 즉, 공유 자원에 대한 처리가 끝나기 전에 여러 처리가 일어나기 때문입니다. 원자성은 무엇이고 어떻게 해결할까요?

 


 

원자성

 

원자성 : 공유 자원에 대한 작업의 단위가 더이상 쪼갤 수 없는 하나의 연산인 것처럼 동작하는 것

count++; 는 내부적으로는 Read-Modify-Write와 같이 count 읽기 → count + 1 변경 → count = 1 쓰기 로 진행됩니다.

우리 눈에는 count++; 라는 하나의 작업처럼 보이지만 실제로는 여러 작업이 수행됩니다. 따라서 이러한 내부적인 여러 작업 단위를 하나의 작업으로 구성하여 원자성을 보장하면 위 예시와 같은 문제를 해결할 수 있습니다.

자바에서는 원자성을 Atomic, Lock, synchronized 등 여러 방법으로 보장하고 synchronized 예제를 통해 확인하겠습니다.

 

public class ReadModifyWrite_Atomic {

    public static void main(String[] args) throws InterruptedException {
        Add add = new Add();

        ExecutorService executor = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 100; i++) {
            executor.submit(add::process);
        }
        executor.shutdown();
        // 모든 작업 완료 대기
        while (!executor.isTerminated()) {
            Thread.sleep(10);
        }
        add.printResult();
    }

    static class Add{
        int number = 0;

        synchronized void process(){
            number++;
        }

        void printResult(){
            System.out.println(number);
        }
    }
}

 

 


 

가시성

가시성 : 바라보는 것이 같은 것

위 정의는 추상적이며 실제로는 스레드 간 CPU 캐시에 대한 값을 메인 메모리에 반영하거나 CPU 캐시 대신 메인 메모리를 바라보게 하여 같은 것을 바라보게 하는 것이라 생각해도 됩니다.

왜 가시성을 보장해야 할까요? 아래 예시를 통해 보겠습니다.

 

public class Visibility {

    static boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            while (running) {
                // do nothing
            }
            System.out.println("Thread 종료됨");
        });

        worker.start();

        Thread.sleep(1000); // 1초 후에 플래그 변경
        running = false;    // 종료 플래그 설정
        System.out.println("main 스레드 종료 플래그 설정");
    }
}

 

 

 

위 코드에는 Thread.sleep(1000); 으로 인해 1초 뒤에 running의 값이 false 로 바뀌면서 메인 메서드가 종료되어야 합니다. 하지만 출력 예시와 같이 23초가 되었는데 종료되지 않습니다. 왜 이런 문제가 생길까요?

 

 

 

 

main 스레드와 work 스레드는 running 변수를 읽어 메인메모리와 각 CPU 캐시에 running = false 를 저장합니다. 위 코드에서 Thread.sleep(1000); 을 통해 work 스레드가 대기 상태일때 main 스레드는 running = true → false 로 변경하고 메인 메모리에도 반영합니다. 하지만 1초 이후, 깨어난 worker 스레드는 여전히 CPU 캐시만 바라보고 있기에 메인 메모리에 저장된 running = flase 임에도 while 문을 탈출하지 못했기 때문에 발생한 문제입니다.

 

이가 위에서 언급한 가시성에 대한 부분이고 가시성이 보장되지 않았기 때문입니다.

자바에서는 가시성에 대한 대표적 해결책으로 volatile을 제공합니다. volatile을 사용하면 CPU 캐시가 아닌 메인 메모리를 확인하기 때문에 가시성이 보장되고 이와 같은 문제를 해결합니다. 아래 예시를 보겠습니다.

 

 

public class VisibilityVolatile {

    static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            while (running) {
                // do nothing
            }
            System.out.println("Thread 종료됨");
        });

        worker.start();

        Thread.sleep(1000); // 1초 후에 플래그 변경
        running = false;    // 종료 플래그 설정
        System.out.println("main 스레드 종료 플래그 설정");
    }
}

 

volatile 를 통해 가시성을 확보하니 해당 메소드 수행이 무한 루프를 돌지 않고 끝났습니다.

원자성과 가시성을 보장하여 지금까지 발생한 문제를 해결했습니다. 원자성과 가시성을 확보하는 것을 동기화라고 합니다.

 


 

동기화

 

동기화에는 크게 블로킹/논블로킹 방식이 있습니다.

블로킹 방식은 특정 스레드가 작업을 수행하는동안 다른 작업을 진행하지 않고 대기하는 방식입니다. Ex) Monitor, Sychronized

Monitor, Sychronized 는 공통적으로 순차적인 처리를 통해서 원자성과 가시성을 보장합니다. 하지만 이러한 동기화에는 꼭 이점만 있지 않습니다. 성능저하와 데드락 문제가 발생할 수 있습니다.

 

 

성능 저하

하나의 자원에 대해 하나의 스레드만 접근할 수 있기에 이렇게 스레드가 대기하게 됩니다. 따라서 많은 스레드가 몰린 경우, 스레드가 낭비되어 성능저하를 일으킬 수 있습니다.

 

 

 

데드락 상황

 

스레드 A, B가 있습니다. 스레드 A, B 둘다 상의와 하의라는 자원을 순차적으로 접근해야 합니다.

다만 접근 순서는 다음과 같습니다. 스레드 A : 상의 → 하의, 스레드 B : 하의 → 상의

 

위와 같은 상황에서는 스레드 A는 상의를 얻은 채 하의에 접근하기 위해 기다립니다. 하지만 스레드 B도 하의를 얻은 채 상의를 기다립니다.

결국, 스레드 A, B 둘다 자원을 얻기 위해 무한정 대기하는 상황이 발생하고 이를 데드락이라고 합니다.

논블로킹은 블로킹과 다르게 다른 스레드의 작업여부와 상관없이 자신의 작업을 수행하는 방식입니다. Ex) Atomic 타입

 

 

Atomic 타입은 CAS 알고리즘과 volatile 을 통해 원자성과 가시성을 보장합니다.

 

CAS 알고리즘은 위와 같습니다.

 

 


 

결론

여러 환경 요소로 인해 위에서 확인한 예시와 같이 우리의 의도와 다른 결과가 나올 수 있습니다.

지금까지 상황에 맞는 방법을 선택하여 사용하여 공유 자원에 대한 문제를 해결할 수 있습니다. 특히 원자성, 가시성을 확보하여 동기화 처리를 한다면 의도한 결과를 얻을 수 있습니다.

 

다만 동기화로 인한 trade-off 도 존재합니다. synchronized 와 같이 성능 저하, 데드락 등 위험 요소가 있으며 Atomic 또한 다중 변수 접근, ABA와 같은 문제가 발생할 수 있으니 참고하길 바랍니다.

생략되고 언급하지 않은 부분도 많기에 결론에 대한 내용이 부족하다면 언급한 키워드에 대해 검색하시면 됩니다.

'Java' 카테고리의 다른 글

Thread와 Thread Pool  (0) 2025.03.27
자바 제네릭(Generics)의 이해와 활용  (0) 2025.03.20