문서 읽는 데 44분 · day34

Day 34 — 동시성의 함정: 경쟁 상태와 동기화

목차 19
전체 48강 중 34강 · 자바 기초
난이도 · 입문

지난 시간 Day 33 에서 우리는 스레드를 만들고, 기다리고(join), 멈추는(interrupt) 기본기를 익혔어요. 그런데 그때 본 스레드들은 사실 아주 얌전한 스레드들이었어요. 각자 자기 이미지, 자기 변수만 다뤘죠. 서로의 일에 끼어들지 않았어요.

그래서 마지막에 이런 질문을 남겨뒀죠. 여러 스레드가 같은 데이터를 동시에 건드리면 어떻게 될까? 1000명이 거의 동시에 좋아요를 눌러서, 1000개의 스레드가 같은 좋아요 수 를 동시에 1씩 올린다면요?

지난 시간엔 이걸 그림으로만 봤어요. 오늘은 진짜로 코드를 돌려서 그 함정을 눈으로 확인할 거예요. 분명히 1000번 눌렀는데 좋아요가 1000보다 모자라게 나오는, 좀 으스스한 장면을요. 그리고 그게 왜 생기는지 파헤친 뒤, synchronized 라는 자물쇠로 어떻게 막는지, 마지막엔 두 스레드가 서로를 기다리다 영영 멈춰버리는 데드락 까지 — 동시성의 함정과 그 해결책을 차근차근 익혀봐요.

오늘이 조금 어렵게 느껴질 수 있어요. 동시성은 원래 베테랑 개발자도 까다로워하는 주제거든요. 하지만 늘 그랬듯 "왜 이런 일이 생기지?" 부터 천천히 짚을 테니 편하게 따라와요. 시작해봐요!

🎯 학습 목표

  • 여러 스레드가 같은 데이터를 동시에 건드릴 때 생기는 경쟁 상태(race condition) 를 직접 재현해요.
  • count++ 가 사실은 세 단계로 쪼개진다는 것, 그래서 원자성(atomicity) 이 없다는 걸 이해해요.
  • 한 스레드의 변경이 다른 스레드에 안 보일 수 있는 메모리 가시성 문제와 volatile 키워드를 알아요.
  • synchronized 로 경쟁 상태를 막고, 임계 구역(critical section) 이 무엇인지 이해해요.
  • 락(lock)은 공짜가 아니라는 것 — 락의 범위와 비용 을 가늠해요.
  • 두 스레드가 서로를 기다리다 멈추는 데드락(deadlock) 을 일으켜 보고, 락 순서 통일 로 해결해요.

오늘의 로드맵

  • Step 1 — 좋아요가 새는 현장: 경쟁 상태 재현.
  • Step 2 — 왜 1이 사라질까: count++ 는 한 동작이 아니다 (원자성).
  • Step 3 — 다른 스레드의 변경이 안 보인다: 메모리 가시성과 volatile.
  • Step 4 — 자물쇠를 채우자: synchronized 로 경쟁 상태 봉인.
  • Step 5 — 락은 공짜가 아니다: 범위와 비용.
  • Step 6 — 서로를 기다리다 멈추다: 데드락.
  • Step 7 — 데드락 풀기: 락 순서 통일.

Step 1: 좋아요가 새는 현장 — 경쟁 상태 재현

지난 시간 마지막에 던졌던 그 질문, 바로 코드로 확인해봐요. 인기 게시물에 여러 사람이 동시에 좋아요를 누르는 상황을 흉내 내볼게요.

좋아요 카운터는 아주 단순해요. count 라는 숫자 하나가 있고, 좋아요를 누르면 count++ 로 1을 올려요.

Java
// com/instagram/javabasic/concurrent/LikeCounterRace.java
public class LikeCounterRace {

    // 좋아요 개수. 아무 보호 장치 없이 여러 스레드가 함께 건드려요 (그래서 위험해요).
    private int count;

    // 좋아요 한 번. count++ 는 안전해 보이지만 사실 세 단계라 동시에 부르면 깨질 수 있어요.
    public void like() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

이제 스레드 100개를 만들어서, 각 스레드가 like() 를 1000번씩 부르게 해봐요. 100 × 1000 = 100,000. 우리가 기대하는 좋아요 수는 당연히 정확히 100,000 이겠죠. 스레드를 띄우고 기다리는 건 지난 시간에 배운 start()join() 그대로예요.

Java
// com/instagram/javabasic/concurrent/LikeCounterRace.java
public static int runWorkload(int threads, int perThread) throws InterruptedException {
    LikeCounterRace counter = new LikeCounterRace();
    Thread[] workers = new Thread[threads];

    for (int i = 0; i < threads; i++) {
        workers[i] = new Thread(() -> {
            for (int n = 0; n < perThread; n++) {
                counter.like();
            }
        });
    }
    for (Thread w : workers) {
        w.start();
    }
    for (Thread w : workers) {
        w.join();  // 모든 일꾼이 끝날 때까지 기다린다
    }
    return counter.getCount();
}

main 에서 runWorkload(100, 1000) 을 돌려서 결과를 출력해봐요. Run 버튼을 누르면 이런 식으로 나와요 (정확한 숫자는 실행할 때마다 달라져요).

텍스트
=== 보호 없는 좋아요 카운터 (경쟁 상태) ===
기대한 좋아요 수  100000
실제 좋아요 수    97342
사라진 좋아요     2658 개

분명히 10만 번 눌렀는데 9만 7천여 개밖에 안 됐어요. 2천 개가 넘는 좋아요가 그냥 사라진 거예요. 게다가 다시 실행하면 98,015, 그다음엔 96,887… 매번 결과가 들쭉날쭉해요.

이렇게 여러 스레드가 같은 데이터를 동시에 건드리면서, "누가 언제 끼어드느냐" 에 따라 결과가 달라지는 현상을 경쟁 상태(race condition) 라고 불러요. 스레드들이 같은 값을 두고 서로 경쟁하듯 달려들어서 붙은 이름이에요.

범인은 count++ 한 줄이에요. 너무 짧아서 한 번에 끝나는 한 동작처럼 보이지만, 사실은 그렇지 않아요.

텍스트
 좋아요 수 = 5  (지금 값)

 스레드 A:  5 읽음 ── 6 계산 ── 6 저장
 스레드 B:  5 읽음 ── 6 계산 ── 6 저장
              두 스레드가 똑같이 5를 읽어버림
               두 번 눌렀는데 결과는 7이 아니라 6 (하나가 사라짐)

두 스레드가 거의 동시에 "지금 5네" 하고 읽어버리면, 둘 다 6을 만들어 6을 써넣어요. 두 번 눌렀는데 결과는 7이 아니라 6이 되는 거죠. 이런 끼어듦이 10만 번 중 수천 번 일어나서 좋아요가 샌 거예요.

💡 여러 스레드가 보호 없이 같은 데이터를 동시에 바꾸면, 끼어드는 타이밍에 따라 결과가 틀어지는 경쟁 상태(race condition)가 생겨요. 증상은 "분명 맞게 짰는데 숫자가 가끔 안 맞고, 게다가 매번 다르다" 예요.

🙋 학생 질문 — "튜터님, 어떨 땐 딱 100000 이 나오기도 하던데요?"

네, 그럴 수 있어요. 그래서 경쟁 상태가 더 무서운 거예요. 끼어듦은 "운"에 달려 있어서, 운 좋게 스레드들이 안 겹치면 정답이 나오기도 해요. 특히 스레드 수나 반복 횟수가 적으면 멀쩡히 100,000 이 나오는 일도 잦아요.

이게 바로 경쟁 상태 버그가 잡기 어려운 이유예요. 내 노트북에서 테스트할 땐 멀쩡한데, 사용자가 몰린 실제 서비스에서만 가끔 터지거든요. "재현이 안 되는 버그" 가 제일 골치 아프죠. 그래서 우리는 "운 좋게 안 터지는" 코드가 아니라, "절대 안 터지는" 코드를 만들어야 해요. 그 방법을 오늘 배울 거고요.


Step 2: 왜 1이 사라질까 — count++는 한 동작이 아니다

Step 1 에서 "count++ 가 사실 세 단계" 라고 했죠. 이번엔 그 세 단계를 또렷하게 눈으로 증명해봐요.

count++ 한 줄은 컴퓨터 입장에선 이렇게 세 단계로 쪼개져요.

텍스트
 count++  은 사실...

   (1) 읽기  : 지금 count 가 몇인지 읽어온다
   (2) 더하기: 읽어온 값에 1을 더한다
   (3) 쓰기  : 더한 값을 다시 count 에 써넣는다

이 세 단계가 중간에 끊기지 않고 한 덩어리로 끝나는 성질을 원자성(atomicity) 이라고 불러요. 더 이상 쪼갤 수 없는 원자(atom)처럼, 통째로 일어나거나 아예 안 일어나거나 둘 중 하나라는 뜻이에요. 그런데 count++ 에는 이 원자성이 없어요. 세 단계 사이사이에 다른 스레드가 얼마든지 끼어들 수 있죠.

Step 1 에선 이 끼어듦이 "운 나쁠 때 가끔" 일어났어요. 그래서 증명하기가 좀 애매했죠. 이번엔 (1)읽기와 (3)쓰기 사이에 일부러 sleep 으로 틈을 벌려서, 끼어듦이 항상 일어나게 만들어볼게요.

Java
// com/instagram/javabasic/concurrent/AtomicityDemo.java
// count++ 한 줄을 (읽기 → 끼어들 틈 → 쓰기) 로 손수 펼쳐 쓴 메서드예요.
public void incrementSlowly() throws InterruptedException {
    int read = count;        // (1) 읽기
    Thread.sleep(100);       // 일부러 만든 틈 — 이 사이에 다른 스레드가 끼어들어요
    count = read + 1;        // (2)+(3) 더해서 다시 쓰기
}

이제 스레드 두 개가 거의 동시에 incrementSlowly() 를 한 번씩 부르게 해봐요. 둘 다 시작하자마자 (1)읽기로 들어가서 sleep 에 빠지니까, 둘 다 쓰기 전의 같은 값(0) 을 읽게 돼요.

텍스트
 count = 0  에서 시작

 [스레드 A] read = count   (0을 읽음)
 [스레드 B] read = count   (역시 0을 읽음)    A가 아직 안 썼을 때 끼어듦
 [스레드 A] count = read + 1   (count = 1)
 [스레드 B] count = read + 1   (count = 1)    또 1을 씀, A의 결과를 덮어씀

  두 번 +1 했는데 최종 count = 1  (증가 하나가 통째로 사라짐)

실행해보면 기대값은 2인데 결과는 항상 1 이 나와요. 두 스레드가 똑같이 0을 읽고, 둘 다 "0 + 1 = 1" 을 써넣었거든요. 한 스레드의 증가가 다른 스레드에게 통째로 덮어써진 거죠. Step 1 에서 좋아요가 샌 것과 정확히 같은 원리예요. 다만 여기선 틈을 일부러 벌려서 100% 재현했을 뿐이고요.

💡 count++ 는 읽기·더하기·쓰기 세 단계라 원자성이 없어요. 그래서 여러 스레드가 동시에 부르면 "읽고 → 끼어듦 → 덮어쓰기" 로 갱신이 사라질 수 있어요. 한 줄처럼 보이는 코드가 사실은 여러 단계일 수 있다 — 이게 동시성 버그의 핵심이에요.


Step 3: 다른 스레드의 변경이 안 보인다 — 메모리 가시성과 volatile

경쟁 상태 말고, 동시성엔 또 다른 함정이 하나 더 있어요. 이번엔 "값이 덮어써지는" 게 아니라 "바뀐 값이 아예 안 보이는" 문제예요.

잠깐 컴퓨터 구조를 짚을게요. CPU 는 메인 메모리(RAM)에서 값을 읽을 때마다 매번 가지러 가면 느려요. 그래서 자주 쓰는 값은 CPU 가까이에 있는 작은 저장 공간(캐시) 에 복사해두고 거기서 빠르게 읽고 써요. 문제는 여러 스레드가 각자 다른 캐시를 볼 수 있다는 거예요. 한 스레드가 값을 바꿔도, 그게 자기 캐시에만 반영되고 메인 메모리나 다른 스레드의 캐시엔 한동안 전달이 안 될 수 있어요.

이렇게 한 스레드의 변경이 다른 스레드에게 곧바로 보인다는 보장이 없는 문제를 메모리 가시성(memory visibility) 이라고 불러요.

텍스트
 메인 메모리:  running = true     (main 이 false 로 바꿈)

 워커 스레드 캐시 ── true  (옛날 값을 계속 봄)    변경이 안 보임!
 main  스레드 캐시 ── false (방금 바꾼 값)

직접 확인해봐요. running 이라는 깃발이 true 인 동안 빙빙 도는 워커 스레드를 띄우고, main 이 잠깐 뒤에 runningfalse 로 바꿔서 "그만 돌아" 신호를 보내요.

Java
// com/instagram/javabasic/concurrent/VisibilityDemo.java
// volatile 덕분에 main 이 false 로 바꾸면 워커가 곧바로 그 변경을 본다.
private volatile boolean running = true;

public void stop() {
    running = false;  // 워커에게 "그만 돌아라" 신호를 보낸다
}

public Thread startWorker() {
    Thread worker = new Thread(() -> {
        while (running) {
            // 바쁘게 돌며 신호를 살핀다 (busy-wait).
        }
        System.out.println("[워커] running=false 신호를 보고 멈췄어요");
    });
    worker.start();
    return worker;
}

핵심은 running 앞에 붙은 volatile 이에요. volatile 은 "이 값은 캐시하지 말고 항상 메인 메모리에서 직접 읽고 써라" 는 뜻이에요. 그러면 main 이 바꾼 값을 워커가 곧바로 보게 돼서, 신호를 받고 루프를 빠져나와 무사히 멈춰요.

만약 volatile 을 빼면 어떻게 될까요? 워커가 자기 캐시에 남은 옛날 true 만 계속 보면서, mainfalse 로 바꿨는데도 영영 안 멈출 수 있어요. 끝나야 할 작업이 안 끝나고 CPU 만 잡아먹는 거죠. (실제로 그렇게 동작할지는 환경마다 달라서 늘 재현되진 않지만, 그렇게 될 위험이 있다는 게 핵심이에요.)

⚠️ 아주 중요한 한 가지 — volatile 은 가시성만 해결해요. 원자성은 못 고쳐요. 그래서 Step 1 의 count++volatile 만 붙여도 좋아요는 여전히 새요. "변경이 보이느냐(가시성)" 와 "끊기지 않고 끝나느냐(원자성)" 는 다른 문제거든요. 여러 스레드가 값을 함께 바꿔야 한다면 volatile 로는 부족하고, 다음 Step 의 synchronized 가 필요해요.

🙋 학생 질문 — "그럼 volatile 은 언제 쓰나요?"

딱 이 데모처럼 "한 스레드만 값을 바꾸고, 나머지는 읽기만 하는 신호 깃발" 에 잘 어울려요. running 처럼 켜고 끄는 플래그가 대표적이죠. 바꾸는 쪽이 하나뿐이라 원자성은 문제가 안 되고, 그 변경이 다른 스레드에 빨리 보이기만 하면 되니까요.

반대로 좋아요 카운터처럼 여러 스레드가 같이 값을 바꾸는 경우엔 volatile 만으로 안 돼요. 그땐 synchronized 를 써야 해요. "읽기만 여럿 / 쓰기는 하나" 면 volatile, "쓰기도 여럿" 이면 synchronized — 이렇게 기억하면 편해요.


Step 4: 자물쇠를 채우자 — synchronized로 경쟁 상태 봉인

이제 경쟁 상태를 제대로 막아봐요. 문제의 뿌리는 "여러 스레드가 count++ 의 세 단계 중간에 끼어드는 것" 이었죠. 그러니 한 스레드가 세 단계를 다 끝낼 때까지 다른 스레드를 못 들어오게 막으면 돼요.

자바는 이걸 위해 synchronized 라는 키워드를 줘요. 메서드에 synchronized 를 붙이면 "이 메서드는 한 번에 한 스레드만 들어올 수 있다" 는 뜻이 돼요. Step 1 의 좋아요 카운터에 이 한 단어만 더해볼게요.

Java
// com/instagram/javabasic/concurrent/LikeCounterSafe.java
public class LikeCounterSafe {

    private int count;

    // synchronized 덕분에 한 번에 한 스레드만 들어와 count++ 를 끝까지 마쳐요.
    public synchronized void like() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

like() 앞에 synchronized 단어 하나 붙은 것 말고는 Step 1 과 똑같아요. 그런데 결과는 완전히 달라져요.

텍스트
=== synchronized 로 봉인한 좋아요 카운터 ===
기대한 좋아요 수  100000
실제 좋아요 수    100000
사라진 좋아요     0 개

10만 개가 한 개도 안 새고 정확히 맞아요. 몇 번을 다시 돌려도 항상 100,000 이에요. 한 스레드가 like() 안에서 읽기·더하기·쓰기를 끝낼 때까지, 다른 스레드는 문 앞에서 기다리니까요.

이렇게 한 번에 한 스레드만 들어올 수 있는 코드 구간 을 임계 구역(critical section) 이라고 불러요. 그리고 그 문을 잠그는 열쇠를 락(lock) 또는 모니터(monitor) 라고 해요. 자바에서는 모든 객체가 락을 하나씩 갖고 있어서, synchronized 메서드는 "그 객체의 락을 잡은 스레드만 들어온다" 는 뜻이 돼요.

텍스트
 synchronized 없을 때:
   A, B 가 동시에 들어옴 ── 끼어듦 ── 갱신 손실

 synchronized 있을 때:
   A 가 락을 잡고 들어감 ── B 는 문 앞에서 대기
   A 가 끝나고 락을 놓으면 ── 그제야 B 가 들어감 ── 끼어듦 없음, 정확

💡 synchronized 는 임계 구역을 "한 번에 한 스레드만" 으로 봉인해서 경쟁 상태를 막아요. 게다가 락을 잡고 풀 때 메모리도 함께 정리돼서 원자성과 가시성을 한꺼번에 해결해줘요. 그래서 여러 스레드가 값을 같이 바꿔야 할 땐 synchronized 가 가장 든든한 기본기예요.


Step 5: 락은 공짜가 아니다 — 범위와 비용

synchronized 가 만능처럼 보이죠? 그럼 모든 메서드에 다 붙이면 안전하고 좋지 않을까요? 여기엔 함정이 있어요. 락은 공짜가 아니에요.

한 스레드가 락을 잡고 있는 동안 다른 스레드들은 전부 문 앞에서 기다려야 해요. 락으로 잠근 구간이 넓고 오래 걸릴수록, 기다리는 줄이 길어지고 전체가 느려져요. 모처럼 스레드를 여러 개 띄워 동시에 일하려 했는데, 락 때문에 결국 한 줄로 서서 차례차례 처리하게 되는 거죠.

그래서 "꼭 보호가 필요한 부분만 좁게 잠그는" 기술이 필요해요. synchronized 는 메서드 전체에 붙이는 것 말고, 블록 단위로 원하는 몇 줄만 잠글 수도 있어요.

Java
// com/instagram/javabasic/concurrent/SyncScopeDemo.java
private final Object lock = new Object();
private int count;

// 방법 A: 메서드 전체를 잠근다. 들어오는 순간부터 나갈 때까지 한 스레드만.
public synchronized void likeWholeMethod() {
    count++;
}

// 방법 B: 공유 값을 건드리는 줄만 좁게 잠근다.
public void likeNarrowBlock() {
    // (여기서 시간이 걸리는 준비 작업을 했다면 잠그지 않고 같이 할 수 있어요)
    synchronized (lock) {
        count++;  // 정말 보호가 필요한 임계 구역만 좁게 잠근다
    }
}

방법 B 의 synchronized (lock) { ... } 가 블록 방식이에요. 중괄호 안에 있는 동안만 lock 이라는 자물쇠 객체를 잡아요. 그 앞뒤의 준비 작업(예: 이미지 가공, 로그 남기기)은 잠그지 않으니 여러 스레드가 동시에 할 수 있고, 진짜 공유 값을 건드리는 count++ 한 줄만 좁게 보호하는 거죠.

텍스트
 [메서드 전체 잠금]  ▓▓▓▓▓▓▓▓▓▓▓▓    들어와서 나갈 때까지 통째로 잠김
                     준비작업까지 한 스레드가 독점

 [블록만 좁게 잠금]  ░░░░▓▓░░░░      준비작업(░)은 같이, 카운트(▓)만 잠금
                     기다리는 시간이 줄어 더 잘 돌아감

두 방법 다 결과는 똑같이 정확해요. 차이는 속도예요. 락을 좁게 잡을수록 다른 스레드가 기다리는 시간이 줄어서 전체 처리량이 좋아져요.

여기서 한 가지 균형 감각이 필요해요. 락을 너무 좁게 잡아서 정작 보호해야 할 부분을 빼먹으면 다시 경쟁 상태가 터지고, 너무 넓게 잡으면 느려져요. "정확성은 지키되, 꼭 필요한 만큼만 잠근다" 가 핵심이에요.

💡 락은 안전을 주는 대신 속도를 깎아요. 잠근 구간이 넓을수록 다른 스레드가 오래 기다리죠. 그래서 synchronized 블록으로 공유 데이터를 건드리는 임계 구역만 좁게 잠그는 게 좋아요. 락은 넓을수록 안전한 게 아니라, 정확성을 지키는 선에서 최소로 잡는 게 좋아요.


Step 6: 서로를 기다리다 멈추다 — 데드락

락을 쓰다 보면 만나는, 동시성에서 가장 악명 높은 함정이 있어요. 바로 데드락(deadlock, 교착 상태) 이에요. 두 스레드가 서로가 쥐고 있는 자물쇠를 기다리느라 둘 다 영영 못 움직이는 상태예요.

좁은 골목에서 두 차가 마주 보고 멈춘 장면을 떠올려봐요. 서로 상대가 먼저 비켜주길 기다리는데, 둘 다 안 비키면 영영 못 지나가죠. 데드락이 딱 그 모습이에요.

자물쇠 두 개(lockA, lockB)가 있다고 해봐요. 한 스레드는 A 를 먼저 잡고 그다음 B 를 잡으려 하고, 다른 스레드는 B 를 먼저 잡고 그다음 A 를 잡으려 해요. 순서가 엇갈린 거죠.

Java
// com/instagram/javabasic/concurrent/DeadlockDemo.java
// A → B 순서로 자물쇠를 잡으려는 스레드.
public Thread makeThreadAB() {
    Thread t = new Thread(() -> {
        synchronized (lockA) {
            System.out.println("[AB] lockA 잡음, 이제 lockB 를 기다려요");
            sleepQuietly(100);          // 상대가 lockB 를 먼저 잡을 틈을 준다
            synchronized (lockB) {       // 여기서 영영 못 들어가요 (상대가 lockB 를 쥠)
                System.out.println("[AB] lockB 도 잡음 (여기는 도달 못 해요)");
            }
        }
    });
    t.setDaemon(true);  // main 이 끝나면 함께 정리되는 일꾼으로 만든다
    return t;
}

다른 스레드 makeThreadBA() 는 이걸 거울처럼 뒤집어서 B 를 먼저, 그다음 A 를 잡으려 해요. 두 스레드가 첫 자물쇠를 잡은 뒤 sleep 으로 잠깐 틈을 주면, 둘 다 첫 자물쇠는 쥐었는데 두 번째 자물쇠는 상대가 쥐고 있어서 영영 못 잡아요.

텍스트
 스레드1:  lockA 잡음 ── lockB 를 기다림  (그런데 스레드2가 lockB 를 쥠)
 스레드2:  lockB 잡음 ── lockA 를 기다림  (그런데 스레드1이 lockA 를 쥠)

  서로가 쥔 자물쇠를 기다리느라 둘 다 영영 멈춤  = 순환 대기(circular wait)

실행해보면 두 스레드 모두 첫 자물쇠를 잡았다는 메시지까지만 찍고, 그 뒤로는 아무 일도 안 일어나요. 둘 다 멈춰버린 거예요. 잠깐 기다렸다가 두 스레드가 아직 살아있는지(isAlive()) 확인해보면 둘 다 true — 끝나지 못하고 멈춰 있다는 뜻이죠.

여기서 데모 코드에 setDaemon(true) 가 보일 거예요. 데드락에 빠진 스레드는 스스로 못 끝나니까, 데몬(daemon) 스레드 — main 이 끝나면 JVM 과 함께 자동으로 정리되는 일꾼 — 으로 만들어둔 거예요. 안 그러면 프로그램이 그 멈춘 스레드 때문에 영영 안 끝나거든요.

💡 데드락은 두 스레드가 서로가 쥔 락을 기다려 둘 다 멈추는 상태예요. 핵심 원인은 락을 잡는 순서가 엇갈려서 생기는 순환 대기(circular wait)예요. 한쪽은 A→B, 다른 쪽은 B→A 로 잡으려다 서로를 막는 거죠.


Step 7: 데드락 풀기 — 락 순서 통일

데드락은 무섭지만, 원인을 알면 해결책은 의외로 단순해요. Step 6 에서 데드락은 "락을 잡는 순서가 엇갈려서" 생겼죠. 그럼 모든 스레드가 자물쇠를 똑같은 순서로 잡게 하면 돼요.

좁은 골목에 "한 방향으로만 지나가세요" 일방통행 규칙을 붙이는 것과 같아요. 모두가 같은 방향으로만 가면 마주 보고 멈출 일이 없죠.

Java
// com/instagram/javabasic/concurrent/DeadlockFixed.java
// 두 스레드가 모두 이 메서드를 쓰게 해서, 락 순서를 A → B 로 통일해요.
public Thread makeOrderedThread(String name) {
    Thread t = new Thread(() -> {
        synchronized (lockA) {        // 항상 A 를 먼저
            System.out.println("[" + name + "] lockA 잡음");
            sleepQuietly(50);
            synchronized (lockB) {    // 그다음 B
                System.out.println("[" + name + "] lockB 도 잡음 → 일 끝, 두 자물쇠 모두 놓음");
            }
        }
    });
    t.setDaemon(true);
    return t;
}

두 스레드 모두 makeOrderedThread 로 만들어서 둘 다 A→B 순서로 잡게 했어요. Step 6 과 딱 한 가지, 순서만 통일했을 뿐이에요.

텍스트
 [전] 엇갈린 순서
   스레드1:  A ── B
   스레드2:  B ── A       마주 보고 멈춤 (데드락)

 [후] 통일된 순서
   스레드1:  A ── B
   스레드2:  A ── B       먼저 A 잡은 쪽이 끝낼 때까지 다른 쪽은 A 앞에서 대기
                            순환 대기가 사라짐 (데드락 없음)

이제 실행해보면 두 스레드 모두 일을 끝내고 무사히 종료해요. 먼저 A 를 잡은 스레드가 B 까지 잡아 일을 마치고 두 자물쇠를 모두 놓을 때까지, 다른 스레드는 A 앞에서 잠깐 기다리기만 하면 되거든요. 서로를 마주 보고 막는 순환 대기가 사라진 거죠.

데드락은 한번 터지면 디버깅이 정말 까다로워요. 그래서 실무에서는 "여러 락을 잡아야 할 땐 팀 전체가 항상 같은 순서로 잡는다" 를 규칙으로 정해두곤 해요. 터진 뒤에 잡는 것보다 애초에 안 생기게 막는 게 훨씬 낫거든요.

💡 데드락의 가장 흔한 해결책은 락 순서 통일이에요. 여러 락이 필요하면 모든 스레드가 늘 정해진 순서로 잡게 하면, 순환 대기가 생기지 않아 데드락이 사라져요. 일방통행 규칙을 떠올리면 편해요.


마무리

오늘은 여러 스레드가 같은 데이터를 동시에 건드릴 때 생기는 함정과 그 해결책을 배웠어요.

  • 경쟁 상태(race condition) — 보호 없이 같은 데이터를 동시에 바꾸면, 끼어드는 타이밍에 따라 결과가 틀어져요. 좋아요 10만 개가 새는 걸 직접 봤죠.
  • 원자성(atomicity)count++ 는 읽기·더하기·쓰기 세 단계라 원자성이 없어요. 그 사이 끼어들면 갱신이 사라져요.
  • 메모리 가시성과 volatile — 한 스레드의 변경이 다른 스레드에 안 보일 수 있어요. volatile 은 가시성을 해결하지만, 원자성은 못 고쳐요.
  • synchronized — 임계 구역을 "한 번에 한 스레드만" 으로 봉인해 경쟁 상태를 막아요. 원자성과 가시성을 한꺼번에 해결해줘요.
  • 락의 범위와 비용 — 락은 공짜가 아니에요. 꼭 필요한 임계 구역만 좁게 잠그는 게 좋아요.
  • 데드락과 락 순서 통일 — 락 순서가 엇갈리면 둘 다 멈춰요. 모든 스레드가 같은 순서로 잡게 하면 풀려요.

동시성은 "여러 일을 빠르게" 해주는 강력한 도구지만, 같은 데이터를 함께 다룰 땐 늘 조심해야 한다는 걸 배웠어요. 이 감각은 나중에 서버를 다룰 때 — 수천 명이 동시에 접속하는 상황에서 — 정말 큰 자산이 될 거예요.

다음 시간엔 — 스레드를 관리하자

오늘 우리는 스레드를 필요할 때마다 new Thread(...) 로 직접 만들었어요. 그런데 Step 1 에서 스레드를 100개나 만들었던 거 기억하시죠? 스레드 하나를 만드는 데도 메모리와 비용이 든다고 지난 시간에 얘기했고요. 요청이 들어올 때마다 매번 새 스레드를 만들고 버리면, 그 비용이 만만치 않아요.

그래서 다음 시간엔 스레드를 미리 몇 개 만들어두고 재사용하는 "스레드풀(thread pool)" 을 배워요. ExecutorService 라는 도구로 스레드를 직접 만들지 않고 "일감만 던지면" 알아서 처리하게 맡기는 방식이죠. 또 오늘 synchronized 로 무겁게 막았던 카운터를, 더 가볍게 안전을 보장하는 Atomic 변수로도 다뤄볼 거예요. 오늘 배운 경쟁 상태와 동기화의 감각이 그 모든 이야기의 든든한 바탕이 돼요. 정말 수고 많으셨어요!


과제

오늘 배운 경쟁 상태·synchronized·데드락을 직접 손으로 재현하고 고쳐보는 과제예요. 모두 우리 인스타그램 도메인 위에서 풀어봐요. concurrent 패키지의 오늘 예제들을 옆에 두고 비교하면서 작성하면 편해요.

과제 1 (기초): 팔로워 수가 새는 현장 재현하기

상황 배경: 인기 계정이 라이브 방송을 켜자 수많은 사람이 거의 동시에 팔로우를 눌러요. 팔로워 수를 세는 카운터에 보호 장치가 없으면 어떤 일이 생기는지, Step 1 의 LikeCounterRace 를 본떠 직접 재현해봐요.

🎯 해결 미션:

  1. FollowerCounterRace 같은 클래스를 만들어, int count 필드와 보호 없는 follow()(안에서 count++), getCount() 를 두세요.
  2. 스레드 50개를 만들어 각각 follow() 를 2000번씩 부르게 하고, 모든 스레드를 start() 한 뒤 join() 으로 전부 기다리세요. (Step 1 의 runWorkload 구조를 참고하세요.)
  3. 기대값(50 × 2000 = 100,000)과 실제 getCount() 를 출력해, 실제 값이 더 작게 나오는지 확인하세요.
  4. 프로그램을 3번 실행해 결과가 매번 다른지 보고, 왜 그런지(경쟁 상태) 한 줄로 설명해보세요.

과제 2 (응용): synchronized로 조회수 카운터 안전하게 만들기

상황 배경: 게시물 조회수도 여러 사람이 동시에 볼 때마다 1씩 올라가요. 과제 1 처럼 새면 안 되겠죠. Step 4 의 LikeCounterSafe 를 본떠 안전한 카운터를 만들어봐요.

🎯 해결 미션:

  1. ViewCounter 클래스를 만들어 int count 와 조회수를 올리는 메서드를 두되, 그 메서드를 synchronized 로 보호하세요.
  2. 과제 1 과 똑같은 워크로드(스레드 50개 × 2000번)로 돌리세요.
  3. 이번엔 결과가 항상 정확히 100,000 이 나오는지, 여러 번 실행해 확인하세요.
  4. synchronized 를 붙였을 때와 뗐을 때 결과가 어떻게 달라지는지 비교하고, 왜 그런지 "임계 구역" 이라는 말을 써서 설명해보세요.

과제 3 (심화): 포인트 이체에서 데드락을 만들고 풀기

상황 배경: 두 사용자 계정 사이에 포인트를 주고받는 기능이 있어요. 각 계정에는 자물쇠가 하나씩 있고, 이체하려면 보내는 계정과 받는 계정의 자물쇠를 둘 다 잡아야 해요. 그런데 A→B 이체와 B→A 이체가 동시에 일어나면 데드락이 생길 수 있어요. Step 6·7 을 응용해봐요.

🎯 해결 미션:

  1. Account 객체 두 개(accountA, accountB)를 만들고, 각각을 자물쇠로 쓰세요(객체 자체를 synchronized 의 락으로).
  2. 스레드1 은 A→B 이체(A 락 → B 락 순서로 잡음), 스레드2 는 B→A 이체(B 락 → A 락 순서로 잡음)를 하게 하고, 첫 락을 잡은 뒤 sleep 으로 틈을 줘 데드락을 재현하세요. (스레드는 setDaemon(true) 로.)
  3. 두 스레드가 멈춰 끝나지 않는지(join(2000)isAlive()true) 확인하세요.
  4. 이제 두 스레드가 항상 같은 순서(예: 계정 번호가 작은 쪽부터)로 락을 잡도록 고쳐서, 데드락이 사라지고 두 이체가 모두 끝나는지 확인하세요. 무엇을 바꿨더니 풀렸는지 한 줄로 정리해보세요.

생각해볼 주제

혼자 고민해도 좋고, 동료와 토론해도 좋아요. 정답을 외우기보다 "나라면 어떻게 설명할까" 를 떠올리며 읽어보세요.

1. 좋아요 카운터에 volatile만 붙이면 경쟁 상태가 해결될까?

Step 3 에서 volatile 은 "변경을 다른 스레드가 곧바로 보게 해준다" 고 배웠어요. 그럼 Step 1 의 좋아요 카운터(count)에 volatile 만 붙이면, 변경이 곧바로 공유되니까 좋아요가 안 새지 않을까요?

volatile 이 해결하는 것(가시성)과 좋아요가 새는 원인(원자성)이 같은 문제인지 다른 문제인지 떠올려보세요. count++ 가 세 단계라는 걸 생각하면, volatile 을 붙여도 두 스레드가 여전히 같은 값을 읽고 덮어쓸 수 있는지 따져볼 수 있을 거예요.

2. 락을 넓게 잡으면 무조건 안전한데, 왜 굳이 좁게 잡으려 할까?

synchronized 를 메서드 전체에, 아니 모든 메서드에 다 붙이면 경쟁 상태 걱정은 사라져요. 안전하다는 면에서는 분명 편하죠. 그런데도 실무에서는 락을 가능한 한 좁게 잡으려고 애써요.

스레드를 여러 개 띄운 이유가 "동시에 빠르게 일하기" 였다는 걸 떠올려보세요. 락이 넓으면 그 스레드들이 결국 어떻게 되는지, "안전" 과 "속도" 사이에서 무엇을 잃고 무엇을 얻는지 생각해보세요.

3. 데드락은 터진 뒤에 고치기 어려운데, 미리 막으려면 어떻게 해야 할까?

데드락은 한번 빠지면 스레드가 영영 안 풀려서, 보통 프로그램을 강제로 재시작해야 해요. 게다가 가끔만 터져서 재현도 어렵죠. 그래서 "터지면 고친다" 보다 "애초에 안 생기게 한다" 가 훨씬 중요해요.

Step 7 의 락 순서 통일이 왜 데드락을 원천 차단하는지, 그리고 여러 사람이 함께 개발하는 팀에서 이런 규칙을 어떻게 약속으로 만들 수 있을지 생각해보세요. 혼자 지키는 규칙과 팀 전체가 지키는 규칙의 차이도 떠올려보면 좋아요.

✅ 예시 답안정답 보기

오늘 과제는 동시성의 함정을 직접 재현하고 고쳐보는 흐름이에요. 과제 1 은 보호 없는 카운터에서 경쟁 상태를 재현하고, 과제 2 는 synchronized 로 그걸 막고, 과제 3 은 두 자물쇠를 잡는 순서가 엇갈려 생기는 데드락을 만들고 락 순서 통일로 풀어요. concurrent 패키지의 오늘 예제(LikeCounterRace·LikeCounterSafe·DeadlockDemo·DeadlockFixed)를 옆에 두고 비교하면서 보면 편해요.

과제 예시답안

과제 1 예시답안 — 팔로워 수가 새는 현장 재현하기

핵심 접근

Step 1 의 LikeCounterRace 와 똑같은 구조예요. 보호 없는 count++ 를 여러 스레드가 동시에 부르면, count++ 의 세 단계(읽기·더하기·쓰기) 사이에 끼어듦이 생겨 갱신이 사라져요. 스레드 50개가 각자 2000번씩 follow() 를 부르면 기대값은 100,000 이지만, 보호가 없으니 실제로는 그보다 작게 나와요. 스레드를 띄우고 기다리는 건 지난 시간에 배운 start()join() 그대로예요.

예시 구현

Java
// com/instagram/javabasic/concurrent/solution/day34/FollowerCounterRace.java
public class FollowerCounterRace {

    // 팔로워 수. 아무 보호 장치 없이 여러 스레드가 함께 건드려요.
    private int count;

    // count++ 는 안전해 보이지만 사실 세 단계라 동시에 부르면 깨질 수 있어요.
    public void follow() {
        count++;
    }

    public int getCount() {
        return count;
    }

    public static int runWorkload(int threads, int perThread) throws InterruptedException {
        FollowerCounterRace counter = new FollowerCounterRace();
        Thread[] workers = new Thread[threads];

        for (int i = 0; i < threads; i++) {
            workers[i] = new Thread(() -> {
                for (int n = 0; n < perThread; n++) {
                    counter.follow();
                }
            });
        }
        for (Thread w : workers) {
            w.start();
        }
        for (Thread w : workers) {
            w.join();  // 모든 일꾼이 끝날 때까지 기다린다
        }
        return counter.getCount();
    }
}

main 에서 runWorkload(50, 2000) 을 돌리면 기대값 100,000 보다 작은 값이, 그것도 실행할 때마다 다른 값이 나와요. 끼어드는 타이밍이 매번 달라지기 때문이에요.

채점 포인트

항목 확인
count++ 를 보호 없이 둬서 경쟁 상태를 재현했는가
스레드 50개를 start() 한 뒤 모두 join() 으로 기다렸는가
기대값과 실제값을 비교 출력하고, 실제값이 더 작게 나오는가
여러 번 실행해 결과가 매번 다른 이유(경쟁 상태)를 설명했는가

흔한 실수

  • join() 을 빼먹고 바로 getCount() 를 읽으면, 스레드들이 아직 다 안 끝나서 더 엉뚱하게 작은 값이 나와요. "다 끝난 뒤에 읽는다" 를 join() 으로 보장해야 경쟁 상태만 깔끔하게 관찰할 수 있어요.
  • 스레드 수나 반복 횟수가 너무 적으면 운 좋게 100,000 이 그대로 나오기도 해요. 손실을 또렷이 보려면 스레드·반복을 넉넉히 줘야 해요.

과제 2 예시답안 — synchronized로 조회수 카운터 안전하게 만들기

핵심 접근

과제 1 과 구조는 똑같은데, 카운터를 올리는 메서드에 synchronized 한 단어만 더하면 돼요. 그러면 "이 메서드는 한 번에 한 스레드만 들어올 수 있다" 가 보장돼서, 한 스레드가 count++ 의 세 단계를 끝낼 때까지 다른 스레드는 문 앞에서 기다려요. 끼어듦이 사라지니 갱신도 안 사라지고, 결과가 항상 정확히 100,000 이 나와요.

예시 구현

Java
// com/instagram/javabasic/concurrent/solution/day34/ViewCounter.java
public class ViewCounter {

    private int count;

    // synchronized 덕분에 한 번에 한 스레드만 들어와 count++ 를 끝까지 마쳐요.
    public synchronized void view() {
        count++;
    }

    public int getCount() {
        return count;
    }

    // 과제 1과 같은 워크로드지만, synchronized 라 결과가 항상 정확해요.
    public static int runWorkload(int threads, int perThread) throws InterruptedException {
        ViewCounter counter = new ViewCounter();
        Thread[] workers = new Thread[threads];

        for (int i = 0; i < threads; i++) {
            workers[i] = new Thread(() -> {
                for (int n = 0; n < perThread; n++) {
                    counter.view();
                }
            });
        }
        for (Thread w : workers) {
            w.start();
        }
        for (Thread w : workers) {
            w.join();
        }
        return counter.getCount();
    }
}

과제 1 과 딱 한 가지, view() 앞에 synchronized 가 붙은 것만 달라요. 그런데 결과는 매번 정확히 100,000 으로 바뀌어요.

채점 포인트

항목 확인
카운터를 올리는 메서드를 synchronized 로 보호했는가
과제 1 과 같은 워크로드(50 × 2000)로 돌렸는가
여러 번 실행해도 항상 정확히 100,000 이 나오는가
synchronized 를 뗐을 때와 비교하고 "임계 구역" 으로 설명했는가

흔한 실수

  • count 필드에 volatile 만 붙이고 synchronized 는 안 붙이는 경우. volatile 은 가시성만 해결할 뿐 count++ 의 원자성은 못 고쳐서, 여전히 값이 새요. 여러 스레드가 함께 바꾸는 값은 synchronized 로 막아야 해요.
  • getCount() 에까지 굳이 synchronized 를 붙이려다 헷갈리는 경우. 핵심은 값을 바꾸는 view() 를 보호하는 거예요.

과제 3 예시답안 — 포인트 이체에서 데드락을 만들고 풀기

핵심 접근

계정 객체 자체를 자물쇠로 써요(synchronized (account)). 이체하려면 보내는 계정과 받는 계정의 자물쇠를 둘 다 잡아야 하는데, A→B 이체와 B→A 이체가 동시에 일어나면 한쪽은 A→B 순서로, 다른 쪽은 B→A 순서로 자물쇠를 잡으려다 순환 대기 가 생겨 데드락에 빠져요. 해결책은 Step 7 그대로예요. 받은 순서와 상관없이 항상 같은 기준(여기선 계정 id 가 작은 쪽)으로 자물쇠를 잡으면 순환 대기가 사라져요.

예시 구현

데드락 버전 — 받은 from → to 순서 그대로 잡아요. 두 스레드에 from·to 를 엇갈리게 주면 데드락이 나요.

Java
// com/instagram/javabasic/concurrent/solution/day34/PointTransferDeadlock.java
public Thread makeDeadlockThread(String name, Account from, Account to, int amount) {
    Thread t = new Thread(() -> {
        synchronized (from) {  // 받은 순서 그대로 from 을 먼저 잡아요
            sleepQuietly(100);  // 상대가 반대쪽 자물쇠를 먼저 잡을 틈을 준다
            synchronized (to) {  // 여기서 영영 못 들어가요 (상대가 to 를 쥐고 있음)
                from.withdraw(amount);
                to.deposit(amount);
            }
        }
    });
    t.setDaemon(true);  // main 이 끝나면 함께 정리되는 일꾼으로 만든다
    return t;
}

해결 버전 — from·to 를 받은 순서와 상관없이, 항상 id 가 작은 쪽을 먼저 잡아요.

Java
// com/instagram/javabasic/concurrent/solution/day34/PointTransferDeadlock.java
public Thread makeOrderedThread(String name, Account from, Account to, int amount) {
    Thread t = new Thread(() -> {
        // 받은 순서와 상관없이, 항상 id 가 작은 쪽을 first 로 정해요.
        Account first = from.getId() < to.getId() ? from : to;
        Account second = from.getId() < to.getId() ? to : from;

        synchronized (first) {        // 항상 id 가 작은 쪽을 먼저
            sleepQuietly(50);
            synchronized (second) {   // 그다음 id 가 큰 쪽
                from.withdraw(amount);
                to.deposit(amount);
            }
        }
    });
    t.setDaemon(true);
    return t;
}

데드락 버전을 돌리면 두 스레드가 첫 자물쇠만 잡은 채 멈춰서, join(2000) 뒤에 둘 다 isAlive()true 로 나와요. 해결 버전은 둘 다 무사히 끝나서 isAlive()false 가 되고요.

채점 포인트

항목 확인
계정 객체를 자물쇠로 써서 두 락을 모두 잡는 구조를 만들었는가
락 순서를 엇갈리게 + sleep 으로 데드락을 재현했는가
데드락 스레드를 setDaemon(true) 로 만들어 멈춤을 안전하게 다뤘는가
항상 같은 순서(id 작은 쪽부터)로 잡도록 고쳐 데드락을 해결했는가

흔한 실수

  • 해결 버전에서 "스레드1 만" 순서를 바꾸고 스레드2 는 그대로 두는 경우. 한쪽만 바꾸면 여전히 순서가 엇갈려서 데드락이 풀리지 않아요. 모든 스레드가 같은 순서를 따라야 해요.
  • 데드락 스레드를 데몬으로 안 만들면, 멈춘 스레드 때문에 프로그램이 영영 안 끝나요. 재현 데모에서는 setDaemon(true) 가 안전장치예요.

생각해볼 주제 예시답안

생각해볼 주제 1 예시답안 — 좋아요 카운터에 volatile만 붙이면 경쟁 상태가 해결될까?

[문제 상황 요약]

volatile 은 한 스레드의 변경을 다른 스레드가 곧바로 보게 해줘요. 그렇다면 좋아요 카운터 countvolatile 만 붙이면, 변경이 곧바로 공유되니 좋아요가 안 새지 않을까요?

[튜터의 가이드 및 해설]

아쉽지만 해결되지 않아요. volatile 이 고치는 문제와 좋아요가 새는 원인이 서로 다르기 때문이에요.

volatile 이 해결하는 건 가시성 — "바뀐 값이 다른 스레드에 보이느냐" 예요. 반면 좋아요가 새는 원인은 원자성 — "count++ 의 세 단계가 중간에 안 끊기느냐" 예요. volatile 을 붙여도 count++ 는 여전히 읽기·더하기·쓰기 세 단계로 쪼개져요. 두 스레드가 거의 동시에 들어오면, 둘 다 같은 값(예: 5)을 읽고, 둘 다 6을 써넣어서 갱신 하나가 사라지는 건 똑같아요. 값이 "곧바로 보이는" 것과 "끼어듦 없이 끝나는" 것은 별개니까요.

여러 스레드가 같은 값을 함께 바꾸는 경우엔 synchronized 로 임계 구역을 보호해야 해요. volatile 은 "한 스레드만 쓰고 나머지는 읽기만 하는 신호 깃발(running 같은)" 에 어울리는 도구예요.

🎯 면접관을 홀리는 핵심 멘트

"volatilesynchronized 는 푸는 문제가 다릅니다. volatile 은 가시성, 즉 한 스레드의 변경을 다른 스레드가 즉시 보게 하는 것만 보장하고, 원자성은 보장하지 않습니다. 그래서 count++ 처럼 읽고-더하고-쓰는 복합 연산은 volatile 만으로는 갱신 손실을 막을 수 없고 synchronized 가 필요합니다. 저는 '한 쪽만 쓰고 여럿이 읽는 플래그'에는 volatile을, '여럿이 함께 값을 바꾸는 경우'에는 synchronized를 쓰는 기준으로 구분합니다."


생각해볼 주제 2 예시답안 — 락을 넓게 잡으면 안전한데, 왜 굳이 좁게 잡을까?

[문제 상황 요약]

synchronized 를 메서드 전체에, 심지어 모든 메서드에 다 붙이면 경쟁 상태 걱정은 사라져요. 안전 면에서는 편하죠. 그런데도 실무에서는 락을 가능한 한 좁게 잡으려 애써요. 왜일까요?

[튜터의 가이드 및 해설]

핵심은 "안전" 과 "속도" 사이의 균형이에요.

스레드를 여러 개 띄운 이유는 여러 일을 동시에 빠르게 처리하려는 거였어요. 그런데 락을 잡으면 그 구간은 한 번에 한 스레드만 지나갈 수 있어요. 락으로 잠근 구간이 넓고 오래 걸릴수록, 나머지 스레드들은 전부 문 앞에서 줄을 서서 기다려요. 결국 동시에 일하려고 만든 스레드들이 한 줄로 서서 차례차례 처리하게 되니, 동시성의 이점이 사라지고 전체가 느려지는 거죠.

그래서 synchronized 블록으로 진짜 공유 데이터를 건드리는 임계 구역만 좁게 잠그고, 그 앞뒤의 준비 작업(이미지 가공, 로그 남기기 등)은 여러 스레드가 같이 하게 둬요. 다만 너무 좁게 잡아서 보호해야 할 부분을 빼먹으면 다시 경쟁 상태가 터지니, "정확성은 지키되 꼭 필요한 만큼만" 잠그는 균형 감각이 필요해요.

🎯 면접관을 홀리는 핵심 멘트

"락은 안전을 주는 대신 동시성을 깎습니다. 잠근 구간이 넓을수록 다른 스레드의 대기 시간이 길어져 처리량이 떨어지기 때문입니다. 그래서 저는 공유 상태를 실제로 변경하는 임계 구역만 최소한으로 잠그고, 그 외의 작업은 락 밖에서 병렬로 처리하도록 설계합니다. 단, 락 범위를 좁히려다 보호 대상을 빠뜨리면 경쟁 상태가 생기므로, '정확성을 지키는 선에서 최소'라는 기준이 중요하다고 봅니다."


생각해볼 주제 3 예시답안 — 데드락은 터진 뒤에 고치기 어려운데, 미리 막으려면?

[문제 상황 요약]

데드락은 한번 빠지면 스레드가 영영 안 풀려서 보통 프로그램을 강제로 재시작해야 해요. 게다가 가끔만 터져서 재현도 어렵죠. 그래서 "터지면 고친다" 보다 "애초에 안 생기게 한다" 가 훨씬 중요해요. 어떻게 막을까요?

[튜터의 가이드 및 해설]

가장 확실하고 단순한 예방책이 Step 7 의 락 순서 통일이에요.

데드락은 여러 스레드가 락을 엇갈린 순서 로 잡으려다 순환 대기가 생겨서 발생해요. 한쪽은 A→B, 다른 쪽은 B→A 로 잡으려다 서로를 막는 거죠. 그러니 모든 스레드가 늘 정해진 순서(예: 자원의 id 가 작은 쪽부터)로만 락을 잡게 하면, "서로를 마주 보고 기다리는" 고리가 애초에 만들어지지 않아요. 좁은 골목에 일방통행 규칙을 붙이는 것과 같아요.

그런데 이건 혼자 지켜선 소용이 없어요. 한 사람만 순서를 지키고 다른 사람이 반대로 잡으면 여전히 데드락이 나니까요. 그래서 실무에서는 "여러 락을 잡아야 할 땐 팀 전체가 항상 이 순서로 잡는다" 를 코딩 규칙이나 코드 리뷰 기준으로 약속해둬요. 혼자 지키는 습관이 아니라 팀이 함께 지키는 규칙일 때 비로소 데드락을 원천 차단할 수 있는 거죠.

🎯 면접관을 홀리는 핵심 멘트

"데드락은 발생 후 대응보다 예방이 핵심이고, 가장 실용적인 예방책은 락 획득 순서를 전역적으로 통일하는 것입니다. 모든 스레드가 자원을 동일한 순서(예: 자원 식별자 오름차순)로 잠그면 순환 대기 조건이 깨져 데드락이 구조적으로 발생하지 않습니다. 중요한 건 이 규칙이 한 명의 습관이 아니라 팀 전체의 합의여야 한다는 점이라, 저는 락 순서 규칙을 코드 리뷰에서 함께 점검하는 항목으로 두는 것이 효과적이라고 봅니다."

전체 목록 자바 기초