Java Thread 3부 - synchronized로 동시성 문제 막기

청록비
3 read

2부에서는 sleep(), join(), interrupt()로 Thread를 제어하는 방법을 봤다.

이번 글에서는 Thread에서 가장 중요한 문제를 다룬다. 바로 동시성 문제다.

여러 Thread가 같은 데이터를 동시에 건드리면 예상과 다른 결과가 나올 수 있다.

이 문제를 막기 위해 자바에서는 synchronized를 사용할 수 있다.


문제가 되는 코드

먼저 간단한 카운터 예제를 보자.

class Counter {
    private int count = 0;

    public void increase() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

increase()count 값을 1 증가시킨다.

보기에는 아무 문제 없어 보인다.

이제 두 개의 Thread가 동시에 increase()를 10,000번씩 호출하게 해보자.

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10_000; i++) {
                counter.increase();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 10_000; i++) {
                counter.increase();
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println(counter.getCount());
    }
}

기대하는 결과는 당연히 20000이다.

하지만 실제로 실행해보면 이런 결과가 나올 수 있다.

18342

또 실행하면 다르게 나올 수도 있다.

19711

왜 이런 일이 생길까?


count++는 한 번에 실행되지 않는다

문제는 count++다.

count++는 코드상으로는 한 줄이다.

하지만 실제로는 대략 다음 과정으로 동작한다.

1. count 값을 읽는다
2. 읽은 값에 1을 더한다
3. 다시 count에 저장한다

즉, 한 번에 끝나는 작업이 아니다.

이때 두 Thread가 동시에 끼어들면 문제가 생긴다.

count = 10

Thread-1: count 값을 읽음 → 10
Thread-2: count 값을 읽음 → 10

Thread-1: 10 + 1 저장 → 11
Thread-2: 10 + 1 저장 → 11

두 번 증가해야 하니까 결과는 12가 되어야 한다.

하지만 실제 결과는 11이다.

증가 한 번이 사라졌다.

이런 문제를 Race Condition이라고 한다.


Race Condition

Race Condition은 여러 Thread가 같은 데이터를 동시에 사용하면서 실행 순서에 따라 결과가 달라지는 문제다.

중요한 포인트는 이것이다.

결과가 실행 순서에 따라 달라진다

Thread 실행 순서는 개발자가 마음대로 정할 수 없다.

운영체제 스케줄러가 결정한다.

그래서 같은 코드를 실행해도 어떤 날은 정상처럼 보이고, 어떤 날은 틀린 결과가 나올 수 있다.

이게 동시성 버그가 어려운 이유다.

항상 터지는 게 아니라, 가끔 터진다.


synchronized 사용하기

이 문제를 막으려면 한 번에 하나의 Thread만 increase()를 실행하게 만들면 된다.

자바에서는 synchronized를 사용할 수 있다.

class Counter {
    private int count = 0;

    public synchronized void increase() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

다시 실행하면 결과는 정상적으로 나온다.

20000

synchronized가 붙은 메서드는 한 번에 하나의 Thread만 실행할 수 있다.

즉, increase() 실행 중에는 다른 Thread가 같은 메서드에 들어오지 못한다.


synchronized는 락을 건다

synchronized는 내부적으로 락(lock)을 사용한다.

락은 쉽게 말해 열쇠다.

Thread가 synchronized 영역에 들어간다
→ 락을 얻는다
→ 작업을 수행한다
→ 락을 반납한다

락을 가진 Thread만 synchronized 영역을 실행할 수 있다.

다른 Thread는 락이 반납될 때까지 기다린다.


인스턴스 메서드의 락

다음 코드를 보자.

class Counter {
    public synchronized void increase() {
        System.out.println("increase 실행");
    }

    public synchronized void decrease() {
        System.out.println("decrease 실행");
    }
}

increase()decrease()는 둘 다 synchronized 메서드다.

이 경우 두 메서드는 같은 락을 사용한다.

바로 현재 객체의 락이다.

synchronized 인스턴스 메서드
→ this 객체의 락 사용

즉, 같은 Counter 객체에 대해 한 Thread가 increase()를 실행 중이면, 다른 Thread는 decrease()에도 동시에 들어갈 수 없다.

같은 객체의 락을 공유하기 때문이다.


객체가 다르면 락도 다르다

중요한 점이 있다.

락은 객체마다 다르다.

Counter counter1 = new Counter();
Counter counter2 = new Counter();

counter1counter2는 서로 다른 객체다.

따라서 락도 다르다.

counter1의 락
counter2의 락

한 Thread가 counter1.increase()를 실행 중이어도, 다른 Thread는 counter2.increase()를 실행할 수 있다.

서로 다른 락을 사용하기 때문이다.


synchronized 블록

메서드 전체가 아니라 특정 부분에만 락을 걸 수도 있다.

이때는 synchronized 블록을 사용한다.

class Counter {
    private int count = 0;

    public void increase() {
        synchronized (this) {
            count++;
        }
    }

    public int getCount() {
        return count;
    }
}

이 코드는 아래 코드와 비슷하다.

public synchronized void increase() {
    count++;
}

다만 synchronized 블록은 락의 범위를 더 세밀하게 조절할 수 있다.


락 범위는 작게 잡는 것이 좋다

synchronized는 문제를 막아주지만, 성능에는 영향을 줄 수 있다.

왜냐하면 한 번에 하나의 Thread만 실행할 수 있기 때문이다.

예를 들어 다음 코드를 보자.

public synchronized void work() {
    // 오래 걸리는 작업
    heavyJob();

    // 공유 데이터 수정
    count++;
}

이렇게 하면 heavyJob()이 실행되는 동안 다른 Thread는 계속 기다려야 한다.

공유 데이터 수정이 필요한 부분만 잠그는 것이 더 좋다.

public void work() {
    // 오래 걸리는 작업
    heavyJob();

    synchronized (this) {
        count++;
    }
}

락은 필요한 곳에만 짧게 거는 것이 좋다.


static synchronized

인스턴스 메서드의 synchronized는 객체의 락을 사용한다.

그렇다면 static 메서드는 어떨까?

class Counter {
    public static synchronized void increase() {
        System.out.println("static increase 실행");
    }
}

static synchronized 메서드는 객체 락이 아니라 클래스 락을 사용한다.

static synchronized
→ 클래스 객체의 락 사용
→ Counter.class

즉, 다음과 비슷하다.

class Counter {
    public static void increase() {
        synchronized (Counter.class) {
            System.out.println("static increase 실행");
        }
    }
}

인스턴스 락과 클래스 락은 서로 다르다.


synchronized가 해결하는 것

synchronized는 크게 두 가지를 보장한다.

1. 한 번에 하나의 Thread만 실행
2. 변경된 값이 다른 Thread에게 보이도록 보장

첫 번째는 이해하기 쉽다.

같은 락을 사용하는 synchronized 영역에는 한 번에 하나의 Thread만 들어갈 수 있다.

두 번째는 조금 더 중요하다.

여러 Thread가 같은 값을 읽고 쓸 때, 한 Thread가 변경한 값을 다른 Thread가 제대로 보지 못하는 문제가 생길 수 있다.

이를 메모리 가시성 문제라고 한다.

synchronized는 락을 얻고 반납하는 과정에서 이런 가시성 문제도 함께 해결해준다.


synchronized를 쓴다고 모든 게 해결되지는 않는다

synchronized는 강력하지만 만능은 아니다.

잘못 사용하면 성능이 떨어질 수 있고, 심하면 데드락이 발생할 수도 있다.

예를 들어 두 Thread가 서로의 락을 기다리면 프로그램이 멈출 수 있다.

Thread-1: A 락을 잡고 B 락을 기다림
Thread-2: B 락을 잡고 A 락을 기다림

이런 상황을 Deadlock이라고 한다.

Deadlock은 뒤에서 실무 파트에서 다시 다룬다.


핵심 정리

Thread 3부의 핵심은 이것만 기억하면 된다.

  1. 여러 Thread가 같은 데이터를 동시에 수정하면 문제가 생길 수 있다.

  2. count++는 한 번에 실행되는 작업이 아니다.

  3. 실행 순서에 따라 결과가 달라지는 문제를 Race Condition이라고 한다.

  4. synchronized는 한 번에 하나의 Thread만 실행하게 만든다.

  5. 인스턴스 synchronizedthis 객체의 락을 사용한다.

  6. 객체가 다르면 락도 다르다.

  7. static synchronized는 클래스 락을 사용한다.

  8. 락 범위는 가능한 작게 잡는 것이 좋다.

  9. synchronized는 원자성과 메모리 가시성을 함께 보장한다.

이번 글의 핵심은 하나다.

공유 데이터를 동시에 수정하면 위험하다.
그래서 잠금이 필요하다.

다음 글에서는 Thread가 서로 기다리고 깨우는 방법을 다룬다.

바로 wait(), notify(), notifyAll()이다.

#Cloud#Infrastructure#Serverless#Tech2024