CodeGym /행동 /JAVA 25 SELF /공유 자원과 동기화: 동시 접근 문제

공유 자원과 동기화: 동시 접근 문제

JAVA 25 SELF
레벨 52 , 레슨 0
사용 가능

1. 공유 자원이란 무엇인가

공유 자원은 이미 일상에서 접해 보셨을 겁니다. 가족이 함께 사는 집은 그 가족의 공유 자원입니다. 사무실 냉장고는 모든 동료의 공유 자원입니다. 느낌이 오시죠.

프로그래밍에서 공유 자원이란 여러 스레드가 동시에 접근할 수 있는 변수, 객체, 또는 데이터 구조를 말합니다. 예를 들어:

  • 처리된 주문 수를 세는 카운터 변수.
  • 어떤 스레드들은 채우고 다른 스레드들은 처리하는 요청 목록.
  • 여러 스레드가 동시에 쓰기 작업을 하는 열린 파일.
  • 프로그램의 서로 다른 부분이 사용하는 데이터베이스 연결.

Java에서는 여러 스레드가 접근할 수 있는 모든 객체나 변수는 잠재적으로 “공유 자원”이 됩니다.

공유 자원 예: 전역 카운터

public class Counter {
    public int value = 0;
}

여러 스레드가 이 카운터를 증가시킨다면, 모두 동일한 변수 value에 접근하게 됩니다 — 이것이 바로 공유 자원입니다.

2. 동시 접근의 문제

단일 스레드 프로그램에서는 간단합니다. 하나의 스레드 — 하나의 실행자 — 기차가 레일을 달리듯 차례대로 코드를 따라갑니다. 하지만 스레드가 여러 개가 되는 순간 진짜 “칼춤”이 시작됩니다. 스레드들이 가장 예상치 못한 지점에서 서로의 작업에 끼어들 수 있기 때문입니다.

Race condition(경쟁 상태)

Race condition은 프로그램의 결과가 스레드들의 동작이 “어떻게 뒤섞였는지”에 따라 달라지는 상황을 말합니다. 즉, 같은 프로그램을 여러 번 실행해도 결과가 매번 다를 수 있습니다 — 이것은 버그라기보다 멀티스레딩의 “특성”입니다.

고전적인 예: 두 스레드가 카운터를 증가

간단한 상황을 모델링해 봅시다. 공유 카운터가 있고, 두 스레드가 각각 천 번씩 그 값을 증가시킵니다.

public class Counter {
    public int value = 0;
}

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

        Runnable incrementTask = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.value++; // 위험한 부분!
            }
        };

        Thread t1 = new Thread(incrementTask);
        Thread t2 = new Thread(incrementTask);

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

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

        System.out.println("기대값: 2000");
        System.out.println("실제 값: " + counter.value);
    }
}

화면에는 무엇이 표시될까요?

어쩔 때는 2000, 어쩔 때는 1985, 또 어떤 때는 1937... 왜 그럴까요? counter.value++ 연산은 원자적이지 않기 때문입니다! 이 연산은 세 단계로 이루어집니다:

  1. 현재 counter.value 값을 읽는다.
  2. 그 값을 1 증가시킨다.
  3. 다시 기록한다.

두 스레드가 동시에 같은 값을 읽고, 둘 다 증가시킨 뒤, 둘 다 그 결과를 기록하면 — 증가 중 하나가 “사라집니다”. 이것이 바로 lost update — 업데이트 손실입니다.

일관성 없는 객체 상태

여러 필드로 구성된 복잡한 객체에서 스레드들이 각기 다른 필드를 동시에 변경하면, 객체는 “이상한” 혹은 일관성 없는 상태가 될 수 있습니다. 예를 들어, 계좌 잔액은 줄었는데 거래 내역은 갱신되지 않았다면 — 고객은 당황하고, 회계 담당자는 충격을 받습니다.

3. 왜 동기화가 필요한가

동기화는 프로그램에 이렇게 말하는 방법입니다. “멈춰! 이 코드 조각은 동시에 오직 한 스레드만 실행해야 해! 나머지는 기다려!” 화장실 문에 “청소 중! 출입 금지!” 표지판이 붙어 있는 것과 같습니다. 한 사람 안에 있는 동안 다른 사람들은 밖에서 기다립니다(그리고 안에서 오래 머무는 사람을 마음속으로 원망하죠).

데이터 무결성 보장

카운터가 항상 올바르게 증가하도록 하려면, 여러 스레드가 그 값을 동시에 변경하지 못하도록 해야 합니다.

예: 카운터 증가 동기화

public class Counter {
    public int value = 0;

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

이제 두 스레드가 increment()를 호출하더라도, 같은 시점에는 오직 하나의 스레드만 이 메서드를 실행할 수 있습니다. 다른 하나는 첫 번째가 끝날 때까지 기다립니다.

도식: 동기화 시 무슨 일이 일어나는가

+-------------------+
|  스레드 1          |    --\
+-------------------+      \
      |                      \
      V                       \
+-------------------+          > [ synchronized increment() ]
|  스레드 2          |    --/  /
+-------------------+      / /
      |                    / /
      V                   / /
+-------------------+    / /
|  스레드 3          | --/ /
+-------------------+    /
      |                 /
      V                /
+-------------------+ /
|  스레드 N          |/
+-------------------+

모든 스레드는 보호된 코드 구간을 실행하기 위해 대기열에 섭니다. 한 번에 오직 하나의 스레드만 “임계 구역”( synchronized 블록) 안에 있을 수 있습니다.

4. 동기화 방식 간단 소개

Java의 동기화는 단 하나의 방법이 아니라, 공유 자원을 동시 접근으로부터 보호하기 위한 “도구 모음”입니다.

키워드 synchronized

Java의 기본 동기화 도구입니다. 두 가지 방식으로 사용할 수 있습니다:

동기화된 메서드

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

동기화된 블록

public void increment() {
    synchronized (this) {
        value++;
    }
}

여기서 this는 락이 걸리는 객체입니다. 한 스레드가 이 블록을 실행하는 동안, 같은 객체에 대해 동일한 블록에 들어오려는 다른 스레드는 기다리게 됩니다.

java.util.concurrent의 특화 클래스

  • Lock, ReentrantLocksynchronized의 보다 유연한 대안.
  • ReadWriteLock — 읽기 락과 쓰기 락을 분리.
  • Semaphore — 동시에 코드를 실행할 수 있는 스레드 수를 제한.
  • CountDownLatch, CyclicBarrier 등 — 스레드 작업을 조율.

중요: 오늘은 기초만 살펴봅니다 — 이 클래스들은 나중에 더 자세히 이야기하겠습니다.

5. 실습 예제: 멀티스레드 카운터 애플리케이션

어떤 서비스에 대한 사용자 호출 통계를 만든다고 가정해 봅시다. 각 스레드는 사용자 하나를 나타내며, 공용 카운터를 증가시킵니다.

동기화 없이

public class Counter {
    public int value = 0;
}

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

        Runnable user = () -> {
            for (int i = 0; i < 10000; i++) {
                counter.value++;
            }
        };

        Thread t1 = new Thread(user);
        Thread t2 = new Thread(user);
        Thread t3 = new Thread(user);

        t1.start();
        t2.start();
        t3.start();

        t1.join();
        t2.join();
        t3.join();

        System.out.println("기대값: 30000");
        System.out.println("실제 값: " + counter.value);
    }
}

결과: 거의 항상 30000보다 작습니다. 때로는 훨씬 작습니다! 왜냐하면 스레드들이 서로의 작업을 “끼어들어” 덮어쓰기 때문입니다.

동기화: 오류를 고치기

public class Counter {
    public int value = 0;

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

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

        Runnable user = () -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(user);
        Thread t2 = new Thread(user);
        Thread t3 = new Thread(user);

        t1.start();
        t2.start();
        t3.start();

        t1.join();
        t2.join();
        t3.join();

        System.out.println("기대값: 30000");
        System.out.println("실제 값: " + counter.value);
    }
}

결과: 항상 30000입니다. 와, 동기화가 작동합니다!

6. 유용한 포인트

시각화: race condition은 어떻게 보이나

두 스레드가 어떻게 인크리먼트를 “잃어버리는지” 보여 주는 작은 표를 그려 봅시다:

단계 스레드 1 스레드 2 value 값
1 value=0 읽음 0
2 value=0 읽음 0
3 1로 증가 0
4 1로 증가 0
5 1 기록 1
6 1 기록 1

언제 동기화가 필요한가

동기화가 항상 필요한 것은 아닙니다. 변수가 자기만의 작은 세계에서 오직 하나의 스레드만 다룬다면 — 긴장 풀어도 됩니다. 하지만 그 변수를 다른 스레드와 공유하는 순간, 동기화 없이는 안 됩니다. 설령 “괜찮을 것 같다”고 느껴져도 — 믿지 마세요. 경쟁 상태는 교활합니다. 오랫동안 숨어 있다가 가장 부적절한 순간에 불쑥 터집니다.

앞으로 다룰 내용: 또 어떤 동기화 수단이 있나

오늘은 기본 도구인 synchronized만 살짝 맛봤습니다. 다음 강의에서는 다음을 다룹니다:

  • 객체 모니터가 어떻게 동작하는지와 락의 유형.
  • 정적 동기화 메서드(static + synchronized).
  • volatile 키워드의 동작 방식과 필요성.
  • 현대적인 동기화 클래스들(Lock, Semaphore 등).

7. 공유 자원 다룰 때 흔한 실수

실수 №1: 멀티스레딩을 무시함.
가장 흔한 실수 중 하나는 변수가 여러 스레드에서 접근될 수 있다는 사실을 고려하지 않는 것입니다. 지금은 프로그램이 단일 스레드더라도, 나중에 누군가 스레드를 추가하면 — 버그가 “어디선가” 나타납니다.

실수 №2: 부족하거나 과도한 동기화.
공유 자원 접근을 동기화하지 않으면 race condition과 일관성 없는 데이터가 생깁니다. 반대로 전부 다 동기화하면, 락으로 “숨이 막혀” 프로그램이 느려집니다. 항상 정말 필요한 부분만 신중하게 동기화하세요.

실수 №3: 잘못된 객체에 동기화.
서로 다른 객체에 대해 동기화(예: 지역 변수나 문자열 리터럴에 대해)하면 공유 자원을 보호할 수 없습니다. 모든 스레드는 동일한 객체에 대해 동기화해야 합니다.

실수 №4: 비원자 연산에서 원자성을 기대함.
i++는 원자적이지 않습니다! 변수에 volatile을 선언하더라도 인크리먼트가 원자적이 되지는 않습니다. 이러한 연산에는 동기화가 필요합니다.

실수 №5: “운 좋게도 내 환경에서는 잘 된다”.
Race condition은 내 컴퓨터에서는 드러나지 않을 수 있지만, 서버나 사용자 환경에서는 반드시 드러납니다. 멀티스레드 프로그램에서 “설마”에 기대지 마세요!

1
과제
JAVA 25 SELF, 레벨 52, 레슨 0
잠금
생산 라인 디버깅 🏭
생산 라인 디버깅 🏭
1
과제
JAVA 25 SELF, 레벨 52, 레슨 0
잠금
팀 게임 점수 🎮
팀 게임 점수 🎮
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION