문서 읽는 데 52분 · day33

Day 33 — Thread 기초: 스레드를 만들고 다루기

목차 18
전체 33강 중 33강 · 자바 기초
난이도 · 입문

축하해요. 지난 시간 Day 32 로 우리는 필수 과정(Day 1~32)을 완주했어요. 변수에서 출발해 객체지향, 컬렉션과 예외, 그리고 람다·Stream·Optional 같은 모던 자바까지 — 인스타그램의 "두뇌"를 순수 자바로 만들 수 있는 체력을 갖췄죠.

오늘부터는 심화 과정이에요. 더 깊이 가보고 싶은 분들을 위한 선택 여정이고, 그 첫 주제가 바로 동시성 — "여러 일을 동시에 하기" 예요.

생각해보면 이상한 게 하나 있어요. 인스타 피드를 열면 사진 여러 장이 거의 동시에 주르륵 뜨죠. 그런데 지금까지 우리가 짠 프로그램은 늘 위에서 아래로, 한 번에 한 줄씩, 순서대로만 실행됐어요. 한 줄이 끝나야 다음 줄로 넘어갔죠. 그렇다면 사진 10장을 "동시에" 받는 건 대체 어떻게 하는 걸까요?

그 답이 오늘 배울 스레드(Thread) 예요. 한 프로그램 안에서 여러 흐름이 동시에 흘러가게 만드는 기술이죠. 새 개념이라 낯설 수 있지만, 늘 그랬듯 "왜 필요한가" 부터 천천히 짚을게요. 시작해봐요!

🎯 학습 목표

  • 프로세스와 스레드가 무엇인지, 둘이 어떻게 다른지 그림으로 이해해요.
  • Thread 클래스를 상속해 첫 스레드를 만들고, start()run() 의 결정적 차이를 알아요.
  • Runnable 인터페이스로 "할 일" 과 "일꾼" 을 분리하고, 람다로 짧게 적어요.
  • 스레드가 거치는 6가지 상태(생명주기)를 getState() 로 들여다봐요.
  • sleep() 으로 잠깐 멈추고, join() 으로 다른 스레드가 끝나길 안전하게 기다려요.
  • interrupt() 로 돌고 있는 스레드에 "그만" 신호를 보내는 협조적 중단을 익혀요.

오늘의 로드맵

  • Step 1 — 프로세스 vs 스레드: 한 프로그램 안의 여러 일꾼 (개념).
  • Step 2 — 첫 스레드 만들기: Thread 상속과 start() vs run().
  • Step 3Runnable 인터페이스: 할 일과 일꾼을 분리, 람다로.
  • Step 4 — 스레드 생명주기: 태어나서 사라지기까지 6가지 상태.
  • Step 5 — 멈추고 기다리기: sleep()join().
  • Step 6 — 그만해 신호: interrupt() 와 협조적 중단.

Step 1: 프로세스 vs 스레드 — 한 프로그램 안의 여러 일꾼

새 기술을 배우기 전에 그 바탕부터 짚을게요. 스레드를 이해하려면 먼저 프로세스(process) 를 알아야 해요.

여러분 컴퓨터에서 카카오톡을 켜면 "카카오톡" 이 하나 실행되죠. 크롬을 켜면 "크롬" 이 하나 실행되고요. 이렇게 실행 중인 프로그램 하나하나를 프로세스라고 불러요. 각 프로세스는 자기만의 독립된 메모리 공간을 받아요. 그래서 크롬이 갑자기 꺼져도 카카오톡은 멀쩡하죠. 서로 메모리가 분리돼 있으니까요.

그럼 스레드(thread) 는 뭘까요? 스레드는 한 프로세스 안에서 실제로 코드를 실행하는 흐름 이에요. 지금까지 우리 프로그램에도 사실 스레드가 있었어요. main 메서드를 실행하는 흐름, 그게 바로 "main 스레드" 였죠. 단 한 명의 일꾼이 위에서 아래로 코드를 훑으며 일했던 거예요.

핵심은 여기예요. 한 프로세스 안에 스레드를 여러 개 둘 수 있어요. 일꾼을 여러 명 고용하는 거죠. 그러면 여러 흐름이 동시에 일할 수 있어요.

텍스트
   프로세스 하나 = 실행 중인 프로그램 하나 (예: 우리 인스타 앱)
   ──────────────────────────────────────────────
        ┌──────────────────────┐
        │         Heap         │  ← 객체·데이터를 함께 두는 곳 (모든 스레드가 공유)
        └──────────────────────┘
        ┌───────┐  ┌───────┐  ┌───────┐
        │Stack A│  │Stack B│  │Stack C│  ← 호출 스택은 스레드마다 따로 (각자의 작업 내역)
        └───────┘  └───────┘  └───────┘
         스레드 A    스레드 B    스레드 C

스레드들은 Heap(힙) 이라는 공간을 함께 봐요. 우리가 만든 Member, Post, 이미지 데이터 같은 객체들이 여기 있죠. 그래서 여러 스레드가 같은 데이터를 같이 다룰 수 있어요. 반면 각 스레드는 자기만의 호출 스택(Stack) 을 따로 가져요. 메서드를 호출하고 지역 변수를 쌓는 그 공간은 스레드마다 독립이에요.

그런데 왜 굳이 여러 스레드가 필요할까요? 인스타 피드를 떠올려봐요. 사진 10장을 불러오는데, 한 장 받는 데 1초가 걸린다고 해봐요.

텍스트
 순차 (일꾼 한 명): 한 장 받고, 끝나면 다음 장 받기
   img1 ─▶ img2 ─▶ img3 ─▶ ... ─▶ img10        총 10초

 동시 (일꾼 여러 명): 10장을 한꺼번에 받기 시작
   img1  ─▶ 끝
   img2  ─▶ 끝
   img3  ─▶ 끝
   ...                                          총 ~1초
   img10 ─▶ 끝

한 명이 하나씩 받으면 10초가 걸리지만, 열 명이 동시에 한 장씩 받으면 ~1초면 끝나죠. 이게 동시성이 주는 힘이에요. 화면이 멈추지 않고 빠릿하게 반응하는 비결이기도 하고요.

💡 정리하면, 프로세스는 "실행 중인 프로그램 한 개", 스레드는 "그 안에서 일하는 실행 흐름" 이에요. 한 프로세스 안에 스레드 여럿을 두면 여러 일을 동시에 처리할 수 있어요. 스레드들은 Heap(데이터)은 공유하고, 호출 스택은 각자 따로 가져요.

🙋 학생 질문 — "튜터님, 스레드를 많이 만들수록 무조건 빨라지나요?"

아주 좋은 직감이에요. 아쉽게도 "무조건" 은 아니에요. 스레드도 공짜가 아니거든요. 스레드 하나를 만들면 메모리(각자의 호출 스택)가 들고, CPU 가 이 스레드 저 스레드를 번갈아 실행하느라 전환 비용도 들어요.

게다가 CPU 코어 수에는 한계가 있어서, 코어가 4개인데 스레드를 1000개 만들면 진짜로 동시에 도는 건 결국 몇 개뿐이고 나머지는 차례를 기다려요. 그래서 실무에서는 스레드를 무작정 많이 만들지 않고, 적당한 수만큼 미리 만들어두고 재사용 하는 방식을 써요. 그 방법은 다음 여정에서 따로 배울 거예요. 지금은 "스레드도 비용이 있다" 만 기억해두면 충분해요.


Step 2: 첫 스레드 만들기 — Thread 상속과 start() vs run()

개념을 잡았으니 진짜 스레드를 만들어봐요. 자바에서 스레드를 만드는 첫 번째 방법은 Thread 클래스를 상속 하는 거예요. Thread 를 물려받은 다음, "이 스레드가 할 일" 을 run() 메서드 안에 적어두면 돼요.

Java
// com/instagram/javabasic/concurrent/ThreadBasics.java
public class ThreadBasics extends Thread {

    // run() 이 실제로 어느 스레드에서 돌았는지 기록해 둬요 (main 인지, 새 스레드인지 확인용).
    private String executedThreadName;

    // 작업이 끝까지 수행됐는지 표시하는 깃발.
    private boolean done;

    // 이 스레드가 할 일. start() 로 시작하면 새 스레드 위에서, run() 을 직접 부르면 부른 쪽 스레드에서 돌아요.
    @Override
    public void run() {
        // 지금 이 코드를 돌리고 있는 스레드의 이름을 가져와요.
        this.executedThreadName = Thread.currentThread().getName();
        System.out.println("[" + executedThreadName + "] 피드 새로고침 작업을 시작합니다");
        this.done = true;
    }
}

run() 안에서 Thread.currentThread().getName() 으로 지금 이 코드를 실행하는 스레드의 이름 을 적어뒀어요. 잠시 뒤 이게 누구인지 보면 재밌는 걸 알게 돼요.

이제 이 스레드를 실행할 차례인데, 여기서 오늘의 가장 중요한 함정이 등장해요. 바로 start()run() 의 차이 예요. 둘 다 "실행" 처럼 보이지만 완전히 달라요.

Java
// === run() 을 직접 불러보기 (새 스레드 X) ===
ThreadBasics direct = new ThreadBasics();
direct.run();  // 메서드를 그냥 호출 → 부른 쪽(main) 스레드에서 실행
System.out.println("run() 을 직접 부른 스레드 → " + direct.getExecutedThreadName());  // main

// === start() 로 새 스레드에서 돌려보기 ===
ThreadBasics worker = new ThreadBasics();
worker.start();   // 새 호출 스택(새 스레드) 생성 → 그 위에서 run() 실행
worker.join();    // 새 스레드가 끝날 때까지 기다려요
System.out.println("start() 로 실행된 스레드 → " + worker.getExecutedThreadName());
System.out.println("main 스레드 이름 → " + Thread.currentThread().getName());

실행하면 이런 결과가 나와요.

  • run()직접 부른 경우 → 기록된 스레드 이름이 main 이에요. 새 스레드가 안 생겼다는 뜻이죠. 그냥 평범한 메서드 호출이라, 부른 쪽(main 스레드)이 그 일을 직접 한 거예요.
  • start() 를 부른 경우 → 기록된 이름이 Thread-0 같은 다른 이름 이에요. JVM 이 새 호출 스택(새 스레드)을 만들고, 그 위에서 run() 을 돌렸다는 증거예요.
텍스트
 direct.run()  ─────▶  main 스레드가 직접 실행      (새 흐름 없음)

 worker.start() ─────▶  JVM 이 새 스레드를 만들고
                        ╰─▶ 그 위에서 run() 실행     (main 과 별개로 흘러감)

그러니까 새 스레드를 진짜로 띄우고 싶으면 반드시 start() 를 불러야 해요. run() 을 직접 부르면 새 스레드는 안 생기고 그냥 메서드 하나 호출한 게 돼버려요. 이게 초보자가 가장 많이 하는 실수예요. 코드가 컴파일도 되고 실행도 되니까 한참 동안 눈치 못 채기도 하고요.

🙋 학생 질문 — "튜터님, start() 가 결국 run() 을 부르는 거면 그냥 run() 을 부르면 안 되나요?"

핵심을 정확히 찌르는 질문이에요. 차이는 "누가 run() 을 실행하느냐" 예요.

run() 을 직접 부르면 → 부른 쪽 스레드(main) 가 그 일을 처음부터 끝까지 다 해요. main 은 그동안 다른 일을 못 하고요. 이건 동시성이 전혀 아니에요. 그냥 순서대로 메서드 하나 실행한 거죠.

start() 를 부르면 → JVM 이 새 스레드 를 만들어 그 위에서 run() 을 돌려요. main 은 start() 한 줄을 끝내고 곧장 다음 줄로 넘어가죠. 이제 두 흐름이 동시에 흘러가요. 우리가 원하는 게 바로 이거예요.

그래서 규칙은 간단해요. 새 스레드가 필요하면 start()run() 을 직접 부르는 건 (특별한 이유가 없다면) 거의 항상 실수예요.


Step 3: Runnable 인터페이스 — 할 일과 일꾼을 분리

Thread 를 상속하는 방법을 봤어요. 그런데 실무에서는 이것보다 더 즐겨 쓰는 두 번째 방법이 있어요. 바로 Runnable(러너블) 인터페이스 예요.

Runnable 은 "할 일" 하나만 담는 약속이에요. 안에 run() 메서드 딱 하나만 있죠. 우리가 할 일을 Runnable 로 만든 다음, 그걸 new Thread(러너블) 에 태워서 start() 하면 새 스레드가 그 일을 돌려줘요. "할 일(Runnable)" 과 "그 일을 돌리는 일꾼(Thread)" 이 깔끔하게 나뉘는 거예요.

인스타 피드를 열면 이미지 여러 장이 동시에 로딩되죠? 그 장면을 FeedLoader 로 흉내 내봐요.

Java
// com/instagram/javabasic/concurrent/FeedLoader.java
public class FeedLoader implements Runnable {

    // 이 로더가 책임지는 이미지 번호.
    private final int imageId;

    // 이 이미지 로딩이 끝났는지 표시하는 깃발.
    private boolean loaded;

    public FeedLoader(int imageId) {
        this.imageId = imageId;
    }

    // 이 스레드가 할 일: 자기 담당 이미지를 로딩하고 완료 표시.
    @Override
    public void run() {
        System.out.println("[" + Thread.currentThread().getName() + "] 이미지 " + imageId + "번 로딩 완료");
        this.loaded = true;
    }
}

implements Runnable 로 붙이고 run() 하나만 채웠어요. 각 FeedLoader 는 자기가 맡은 이미지 한 장만 책임져요. 서로 다른 변수(imageId)를 가지니 서로 간섭하지 않죠. 이제 이 할 일을 스레드에 태워봐요.

Java
// === Runnable 을 구현한 클래스로 스레드 띄우기 ===
FeedLoader loader = new FeedLoader(1);
Thread thread = new Thread(loader);  // Runnable 을 Thread 에 태운다
thread.start();
thread.join();
System.out.println("이미지 1번 로딩됨? → " + loader.isLoaded());

// === 피드 이미지 3장을 동시에 로딩 ===
for (int id = 1; id <= 3; id++) {
    new Thread(new FeedLoader(id)).start();
}

new Thread(loader) 처럼 할 일을 일꾼에게 건네주는 모습이 보이죠. 반복문으로 FeedLoader 세 개를 만들어 각각 스레드에 태우면, 이미지 3장이 동시에 로딩되기 시작해요.

그런데 Runnable 의 진짜 매력은 따로 있어요. Runnable 은 메서드가 하나뿐인 인터페이스라, 지난 시간에 배운 람다(->)로 한 줄로 만들 수 있어요. 클래스를 따로 만들 필요도 없죠.

Java
// === 람다로 Runnable 만들기 (클래스 없이 한 줄로) ===
Runnable task = () -> System.out.println("[" + Thread.currentThread().getName() + "] 람다 작업 실행");
new Thread(task).start();

Day 25 에서 "메서드 하나짜리 인터페이스는 람다로 짧게" 라고 배웠던 게 여기서 빛을 발해요. Runnable 도 정확히 그런 인터페이스라, 간단한 작업은 클래스 없이 람다 한 줄로 스레드에 태울 수 있어요.

🙋 학생 질문 — "튜터님, Thread 상속이랑 Runnable 구현 중에 뭘 써야 하나요?"

대부분의 경우 Runnable 구현(또는 람다)이 권장 돼요. 이유가 두 가지예요.

첫째, 자바는 클래스를 하나만 상속할 수 있어요. Thread 를 상속해버리면 다른 클래스를 물려받을 자리가 없어져요. 반면 Runnable 은 인터페이스라 implements 로 붙이면 되고, 다른 상속과 겹치지 않아요.

둘째, "할 일" 과 "그 일을 돌리는 일꾼" 을 분리할 수 있어요. Runnable 은 순수하게 할 일만 담고, 그걸 어떤 스레드에 태울지는 바깥에서 정하죠. 역할이 깔끔하게 나뉘어요. 게다가 람다로 짧게 적을 수 있다는 보너스까지 있고요.

그래서 기억하기 쉽게 정리하면 — "무엇을 할지" 는 Runnable 에 담고, "누가 할지" 는 Thread 에 맡긴다 예요.


Step 4: 스레드 생명주기 — 태어나서 사라지기까지

스레드는 만들어진 순간부터 완전히 끝날 때까지 여러 상태(state) 를 거쳐요. 사람이 신입으로 들어와 일하다가 퇴근하는 것처럼요. 자바는 스레드의 상태를 6가지로 정의해두고, getState() 로 지금 상태를 들여다볼 수 있게 해줘요.

텍스트
   new Thread()        start()             run() 끝
       │                  │                    │
       ▼                  ▼                    ▼
   ┌───────┐  start  ┌──────────┐  완료  ┌────────────┐
   │  NEW  │ ──────▶ │ RUNNABLE │ ─────▶ │ TERMINATED │
   └───────┘         └──────────┘        └────────────┘
                        │     ▲
           sleep·대기   │     │   깨어남
                        ▼     │
                  ┌─────────────────────┐
                  │   TIMED_WAITING     │  ← sleep 처럼 정해진 시간 멈춤
                  │   WAITING / BLOCKED │  ← 신호·자물쇠를 기다리며 멈춤
                  └─────────────────────┘

여섯 상태를 간단히 보면 이래요.

  • NEW — 만들어졌지만 아직 start() 를 안 부른 상태. 대기 중인 신입이에요.
  • RUNNABLE — 실행 중이거나 CPU 차례를 기다리는 중. 일하는 중이죠.
  • TIMED_WAITINGsleep(시간) 처럼 정해진 시간만큼 쉬는 중.
  • WAITING — 다른 스레드의 신호를 무한정 기다리며 멈춘 상태.
  • BLOCKED — 자물쇠(lock)를 기다리며 막힌 상태.
  • TERMINATEDrun() 이 끝나서 완전히 종료된 상태. 퇴근 완료예요.

이 중에서 NEW(시작 전)TERMINATED(종료 후) 는 "딱 정해진 시점" 이라 언제 봐도 확실하게 관찰돼요. 코드로 확인해봐요.

Java
// com/instagram/javabasic/concurrent/ThreadLifecycle.java
// start() 를 부르기 직전의 상태를 돌려준다 → 항상 NEW.
public static Thread.State observeBeforeStart() {
    Thread thread = new Thread(() -> {
    });
    return thread.getState();  // NEW
}

// join() 으로 끝까지 기다린 직후의 상태를 돌려준다 → 항상 TERMINATED.
public static Thread.State observeAfterJoin() throws InterruptedException {
    Thread thread = new Thread(() -> {
    });
    thread.start();
    thread.join();
    return thread.getState();  // TERMINATED
}

new Thread(...) 로 막 만든 직후엔 아직 start() 를 안 불렀으니 상태가 NEW 예요. 그리고 start()join() 으로 끝까지 기다리면 run() 이 다 끝난 뒤라 TERMINATED 가 되죠. main 에서 직접 찍어보면 이렇게 흘러가요.

Java
Thread thread = new Thread(() -> {
    System.out.println("[" + Thread.currentThread().getName() + "] 작업 실행 중");
});

System.out.println("start() 전 상태   → " + thread.getState());  // NEW
thread.start();
thread.join();
System.out.println("join() 후 상태    → " + thread.getState());  // TERMINATED
🙋 학생 질문 — "튜터님, RUNNABLE 이나 TIMED_WAITING 도 코드로 딱 잡아서 확인할 수 있나요?"

이게 동시성의 까다로운 점이에요. RUNNABLE 같은 상태는 순식간에 지나가서 정확히 그 순간을 잡아내기가 어려워요. 내가 getState() 를 부르는 찰나에 스레드가 이미 다른 상태로 넘어가 있을 수 있거든요.

그래서 처음에는 NEW(시작 전)TERMINATED(종료 후) 처럼 "확실히 정해진 시점" 부터 눈에 익히는 게 좋아요. 이 두 상태는 언제 관찰해도 항상 같은 값이 나오니까요. TIMED_WAITING 같은 중간 상태는 "스레드가 sleep 으로 쉬는 동안엔 이런 상태구나" 정도로 개념만 잡아두면 충분해요. 정확한 순간을 코드로 붙잡으려고 애쓸 필요는 없어요.


Step 5: 멈추고 기다리기 — sleep과 join

스레드를 다룰 때 거의 항상 함께 쓰는 두 가지 도구를 배워봐요. 바로 sleep()join() 이에요.

  • Thread.sleep(밀리초) — 지금 스레드를 그 시간만큼 잠깐 재워요. 자는 동안 상태는 TIMED_WAITING 이에요.
  • 작업스레드.join() — 그 작업 스레드가 끝날 때까지 "나" 를 멈춰서 기다려요.

join() 이 왜 중요할까요? 작업 스레드가 어떤 값을 채워주는데, 그게 다 끝나기 전에 읽으면 아직 빈 값일 수 있어요. join() 으로 "끝날 때까지 기다린 다음" 에 읽으면 결과가 확정돼 있어서 안전하죠. 코드로 봐요.

Java
// com/instagram/javabasic/concurrent/SleepAndJoin.java
// 작업 스레드를 띄우고, join() 으로 끝까지 기다린 뒤 결과를 돌려줘요.
public int runAndWait() throws InterruptedException {
    Thread worker = new Thread(() -> {
        try {
            // 무거운 계산을 흉내 내려고 잠깐 재워봐요 (이 동안 상태는 TIMED_WAITING).
            Thread.sleep(50);
        } catch (InterruptedException e) {
            // 기다리다 깨어났다는 신호를 다시 살려두는 게 예의 바른 처리예요.
            Thread.currentThread().interrupt();
        }
        this.result = 42;
        this.finished = true;
    });

    worker.start();
    worker.join();  // worker 가 끝날 때까지 main 을 멈춰서 기다린다
    return result;  // 여기 도착했을 땐 result 가 확정돼 있어요
}

흐름을 그림으로 보면 이래요.

텍스트
 main   : worker.start() ─┐                                  ┌─▶ join() 끝 → result 읽기 (확정!)
                          │                                  │
 worker :                 └─▶ sleep(50) ─▶ result=42 ─▶ 끝 ──┘
                       └── main 은 이 동안 join() 으로 기다려요 ──┘

workersleep(50) 으로 잠깐 일하는 흉내를 내고 result = 42 를 채운 뒤 끝나요. mainjoin() 으로 그게 다 끝날 때까지 기다렸다가 result 를 읽죠. 그래서 읽는 순간엔 값이 항상 확정돼 있어요. main 에서 확인하면 이렇게 나와요.

Java
SleepAndJoin job = new SleepAndJoin();

System.out.println("작업 시작 전 finished? → " + job.isFinished());  // false
int answer = job.runAndWait();
System.out.println("join() 후 finished?   → " + job.isFinished());  // true
System.out.println("확정된 결과           → " + answer);             // 42

여기서 한 가지 더 짚을 게 있어요. Thread.sleep()InterruptedException 이라는 예외를 던질 수 있어요. 지난 시간에 배운 checked 예외 라서, try-catch 로 감싸거나 throws 로 넘겨야 컴파일이 돼요. "기다리는 중에 누가 그만하라고 깨우면" 이 예외가 터지는데, 그 처리법은 바로 다음 단계에서 자세히 볼게요.

🙋 학생 질문 — "튜터님, catch 안에서 왜 또 interrupt() 를 부르나요? 이상해 보여요."

날카로운 관찰이에요. catch (InterruptedException e) 안에서 Thread.currentThread().interrupt() 를 다시 부르는 게 어색해 보이죠?

이유가 있어요. sleep()InterruptedException 을 던지면서 깨어나는 순간, 자바는 "이 스레드가 인터럽트됐다" 는 표시(깃발)를 꺼버려요. 그런데 우리가 catch 로 잡아서 그냥 넘어가면, 이 스레드를 부른 바깥쪽 코드는 "어? 누가 중단 신호를 보냈었나?" 를 알 길이 없어져요. 신호가 조용히 사라지는 거죠.

그래서 interrupt() 를 한 번 더 불러 깃발을 다시 켜두는 거예요. "나는 중단 신호를 받았다" 는 사실을 보존해서, 바깥 코드가 나중에라도 알아챌 수 있게요. 지금은 "중단 신호를 받았으면 그 사실을 잃어버리지 않게 다시 표시해두는 예의 바른 습관" 정도로 기억해두면 충분해요.


Step 6: 그만해 신호 — interrupt와 협조적 중단

마지막으로, 돌고 있는 스레드를 멈추고 싶을 때를 배워봐요. 작업 스레드한테 "이제 그만해" 라고 하려면 어떻게 할까요?

자바에는 interrupt() 라는 메서드가 있어요. 그런데 이건 스레드를 강제로 죽이는 게 아니에요. "이제 그만해 주면 좋겠어" 라고 살짝 신호(깃발)를 꽂아두는 것뿐이에요. 이걸 협조적 중단 이라고 불러요. 멈출지 말지는 그 스레드가 스스로 isInterrupted() 로 깃발을 확인하고 결정하죠.

왜 이렇게 빙 둘러서 할까요? 옛날엔 Thread.stop() 으로 스레드를 강제로 멈출 수 있었어요. 하지만 지금은 쓰면 안 돼요(deprecated). 강제로 끊으면 하던 작업이 어중간한 상태에서 잘려서 데이터가 깨질 수 있거든요. 그래서 "신호를 보내고, 받는 쪽이 안전한 지점에서 스스로 정리하고 멈추는" 협조적 방식이 표준이 됐어요.

중단 신호를 확인하는 방법은 두 갈래예요. 먼저, sleep() 처럼 기다리는 중이라면 InterruptedException 이 터지면서 깨어나요.

Java
// com/instagram/javabasic/concurrent/ThreadInterrupt.java
// === sleep() 중인 스레드를 interrupt() 로 깨우기 ===
Thread sleeper = new Thread(() -> {
    try {
        System.out.println("[" + Thread.currentThread().getName() + "] 10초 동안 잘게요...");
        Thread.sleep(10_000);
        System.out.println("푹 잤어요");  // 여기까진 도달 못 해요 (중간에 깨워질 거라서)
    } catch (InterruptedException e) {
        System.out.println("[" + Thread.currentThread().getName() + "] 누가 깨웠어요! 잠에서 깸 (InterruptedException)");
    }
});
sleeper.start();
Thread.sleep(100);   // 잠들 시간을 잠깐 준 뒤
sleeper.interrupt(); // "그만 자고 일어나" 신호
sleeper.join();

sleeper 는 10초를 자려고 했지만, maininterrupt() 로 깨우자 InterruptedException 이 터지면서 catch 로 빠져나와요. "푹 잤어요" 는 영영 출력되지 않죠. 자던 스레드를 깨우는 가장 흔한 방법이에요.

두 번째 방법은, 루프를 돌며 isInterrupted() 로 깃발을 직접 확인하는 거예요.

Java
// === isInterrupted() 루프로 협조적 중단 ===
Thread worker = new Thread(() -> {
    int count = 0;
    while (!Thread.currentThread().isInterrupted()) {
        count++;  // 신호가 올 때까지 계속 일하는 척
    }
    System.out.println("[" + Thread.currentThread().getName() + "] 중단 신호를 받고 멈췄어요");
});
worker.start();
Thread.sleep(100);
worker.interrupt();  // 강제 종료가 아니라 "그만해 줄래?" 부탁
worker.join();

핵심은 while (!Thread.currentThread().isInterrupted()) 한 줄이에요. "중단 신호가 안 왔으면 계속 일한다" 는 뜻이죠. maininterrupt() 로 깃발을 꽂으면, worker 는 다음 루프 검사에서 그걸 발견하고 스스로 루프를 빠져나와 안전하게 정리하고 멈춰요. 누가 강제로 끊는 게 아니라, 받는 쪽이 자기가 멈춰도 되는 지점에서 멈추는 거예요.

💡 interrupt() 는 "꺼져!" 가 아니라 "그만해 줄래?" 라는 부탁 이에요. 받는 스레드는 isInterrupted() 로 신호를 확인하거나, 기다리던 중이면 InterruptedException 으로 알아채요. 강제로 끊지 않으니 데이터가 어중간하게 깨질 일이 없죠. 그래서 옛날의 stop() 은 버려지고 이 협조적 방식이 표준이 됐어요.

🙋 학생 질문 — "튜터님, 받는 스레드가 신호를 무시하고 계속 일하면 어떡하죠?"

맞아요, 협조적 중단은 "받는 쪽의 협조" 가 전제예요. isInterrupted() 를 한 번도 확인하지 않는 스레드라면 interrupt() 신호를 보내도 멈추지 않아요. 그래서 오래 도는 작업을 만들 때는 중간중간 중단 신호를 확인하는 습관 이 중요해요. 우리가 본 while (!isInterrupted()) 처럼요.

"그럼 강제로 끊을 수 있는 게 더 편하지 않나요?" 싶을 수 있어요. 하지만 강제 종료는 하던 일이 어중간하게 잘리는 위험이 너무 커요. 예를 들어 파일에 절반만 쓰다가 끊기면 그 파일이 망가지죠. 그래서 자바는 "조금 번거롭더라도 안전한" 협조적 방식을 택한 거예요. 안전을 위해 약간의 불편을 감수하는 설계라고 보면 돼요.


마무리

오늘은 동시성의 첫걸음으로 스레드를 만들고 다루는 법을 배웠어요.

  • 프로세스 vs 스레드 — 프로세스는 실행 중인 프로그램 하나, 스레드는 그 안에서 일하는 실행 흐름이에요. 한 프로세스 안에 스레드를 여럿 두면 여러 일을 동시에 처리해요. Heap(데이터)은 공유, 호출 스택은 각자 따로.
  • Thread 상속과 start() vs run()run() 에 할 일을 적고 start() 로 새 스레드를 띄워요. run() 을 직접 부르면 새 스레드가 안 생기는 함정, 꼭 기억하세요.
  • Runnable 인터페이스 — "할 일" 과 "일꾼" 을 분리해요. 단일 상속 제약에 안 걸리고, 람다로 짧게 적을 수 있어 실무에서 더 즐겨 써요.
  • 생명주기 — NEW에서 시작해 RUNNABLE을 거쳐 TERMINATED로 끝나는 6가지 상태. getState() 로 들여다봐요.
  • sleep과 joinsleep() 으로 잠깐 멈추고, join() 으로 다른 스레드가 끝나길 기다려 결과를 안전하게 읽어요. InterruptedException 은 checked 예외라 처리해줘야 하고요.
  • interrupt — 강제 종료가 아니라 "그만해 줄래?" 협조적 중단 신호예요. 받는 쪽이 안전한 지점에서 스스로 멈추죠.

스레드를 만들고, 기다리고, 멈추는 기본기를 갖췄어요. 이제 여러 스레드가 각자 자기 일을 하는 모습까지 봤죠.

다음 시간엔 — 동시성의 함정

그런데 오늘 우리가 본 스레드들은 사실 서로 간섭하지 않는 얌전한 스레드들이었어요. 각자 자기 이미지, 자기 변수만 다뤘죠. 만약 여러 스레드가 같은 데이터를 동시에 건드리면 어떻게 될까요?

인스타 게시물의 좋아요를 떠올려봐요. 1000명이 거의 동시에 좋아요를 눌러서, 1000개의 스레드가 같은 좋아요 수 를 동시에 1씩 올린다면요?

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

 스레드 A:  5를 읽음 ─▶ 6으로 만듦 ─▶ 5에 6을 씀
 스레드 B:  5를 읽음 ─▶ 6으로 만듦 ─▶ 5에 6을 씀
               ↑ 둘 다 "5" 를 읽어버려서
                 두 번 눌렀는데 결과가 7이 아니라 6!

분명 두 명이 눌렀는데 좋아요가 1만 올라가는, 이상한 일이 벌어질 수 있어요. 이걸 경쟁 상태(race condition) 라고 불러요. 다음 시간엔 이 함정이 왜 생기는지 직접 재현해보고, synchronized 라는 자물쇠로 어떻게 막는지, 그리고 두 스레드가 서로를 기다리다 영영 멈춰버리는 데드락 까지 — 동시성의 함정과 그 해결책을 배울 거예요.

오늘 만든 스레드 기본기가 그 모든 이야기의 출발점이에요. 정말 수고 많으셨어요!


과제

오늘 배운 스레드 만들기·join·interrupt 를 직접 손으로 써보며 익히는 과제예요. 모두 우리 인스타그램 도메인 위에서 풀어봐요. concurrent 패키지의 오늘 예제들을 옆에 두고 비교하면서 작성하면 편해요.

과제 1 (기초): 알림 발송 스레드를 람다로 띄우고 기다리기

상황 배경: 게시물에 댓글이 달리면 작성자에게 알림을 보내야 해요. 알림 발송은 시간이 좀 걸리는 일이라(네트워크를 타니까요) 별도 스레드에서 처리하고, 메인 흐름은 그게 끝날 때까지 기다렸다가 "발송 완료" 를 확인하려 해요.

🎯 해결 미션:

  1. Runnable람다 로 하나 만들어, 실행되면 "[스레드이름] 알림 발송 완료" 를 출력하도록 하세요. (Step 3 의 람다 Runnable 을 떠올려보세요.)
  2. Runnablenew Thread(...) 에 태워 start() 로 띄우세요.
  3. join() 으로 그 스레드가 끝날 때까지 기다린 뒤, main 에서 "메인: 알림 발송이 끝난 걸 확인했습니다" 를 출력하세요.
  4. 출력 순서가 항상 "알림 발송 완료" → "메인: ... 확인" 순서로 나오는지 확인하고, 왜 그런지(join 덕분) 한 줄로 설명해보세요.

과제 2 (응용): 피드 이미지 5장 동시 로딩 후 전부 완료 확인하기

상황 배경: Step 3 의 FeedLoader 를 활용해, 피드 이미지 5장을 동시에 로딩하고 다섯 장이 모두 로딩 끝났는지 를 안전하게 확인하려 해요. 핵심은 "다 끝나기 전에 확인하면 안 된다" 예요.

🎯 해결 미션:

  1. FeedLoader 5개를 만들어 각각 다른 imageId(1~5)를 주세요.
  2. FeedLoaderThread 에 태워 start() 로 5개를 동시에 띄우세요. 이때 만든 Thread 들을 리스트에 모아두세요.
  3. 리스트의 모든 스레드에 대해 join() 을 불러, 다섯 스레드가 전부 끝날 때까지 기다리세요.
  4. join 이 모두 끝난 뒤, 다섯 FeedLoaderisLoaded() 가 전부 true 인지 확인해 "이미지 5장 모두 로딩 완료!" 를 출력하세요.
  5. 만약 3번의 join 을 빼먹으면 어떤 문제가 생길지 한 줄로 적어보세요.

과제 3 (심화): 무한히 도는 작업을 interrupt로 멈추기

상황 배경: "최신 피드가 올라왔는지" 를 계속 확인하는 백그라운드 작업이 있다고 해봐요. 이 작업은 무한 루프로 돌다가, 사용자가 앱을 닫으면 협조적으로 멈춰야 해요. Step 6 의 isInterrupted() 루프를 응용해봐요.

🎯 해결 미션:

  1. Runnable 을 하나 만들어, while (!Thread.currentThread().isInterrupted()) 루프 안에서 "피드 확인 중..." 같은 일을 반복하도록 하세요. (너무 빨리 돌면 출력이 폭주하니, 루프 안에 Thread.sleep(200) 을 넣고 InterruptedException 도 처리해보세요.)
  2. 이 작업을 스레드로 띄운 뒤, main 에서 잠깐(Thread.sleep(1000)) 기다렸다가 interrupt() 로 멈춤 신호를 보내세요.
  3. join() 으로 그 스레드가 실제로 멈출 때까지 기다린 뒤, "백그라운드 작업이 안전하게 종료됐습니다" 를 출력하세요.
  4. interrupt() 가 "강제 종료" 가 아니라 "부탁" 인데도 이 스레드가 잘 멈추는 이유를, 코드의 어느 부분 덕분인지 짚어 한 줄로 설명해보세요.

생각해볼 주제

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

1. start() 대신 run() 을 직접 불러도 잘 돌아가는데, 왜 이게 "조용한 버그" 일까?

worker.run() 을 직접 불러도 컴파일 에러가 안 나고, run() 안의 코드도 멀쩡히 실행돼요. 출력도 정상으로 보이고요. 그래서 초보자는 한참 동안 문제를 눈치채지 못해요.

하지만 이건 새 스레드를 전혀 만들지 않은 거예요. 동시성을 기대하고 짠 코드가 사실은 그냥 순서대로 도는 코드였던 거죠. "에러는 안 나는데 의도와 다르게 동작하는 버그" 가 왜 더 위험할 수 있을지, 그리고 이런 실수를 어떻게 알아챌 수 있을지 생각해보세요.

2. 스레드를 많이 만들수록 정말 빨라질까?

이미지 10장을 받는 데 스레드 10개를 쓰면 빨라졌어요. 그럼 10000장을 받을 때 스레드 10000개를 만들면 10000배 빨라질까요?

스레드 하나하나가 메모리를 쓰고, CPU 가 여러 스레드를 번갈아 실행하는 데도 비용이 든다는 점을 떠올려보세요. 그리고 내 컴퓨터의 CPU 코어 수에는 한계가 있다는 것도요. "스레드 수" 와 "실제 속도" 사이의 관계가 왜 단순 비례가 아닌지, 그렇다면 적당한 스레드 수는 어떻게 정해야 할지 고민해보세요.

3. interrupt() 는 왜 강제로 죽이지 않고 "부탁" 하는 방식일까?

자바는 일부러 스레드를 강제 종료하는 길을 막아두고(stop() deprecated), 대신 "신호를 보내면 받는 쪽이 알아서 멈추는" 협조적 중단만 남겨뒀어요.

언뜻 보면 강제로 끊는 게 더 확실하고 편할 것 같아요. 그런데도 자바가 굳이 번거로운 협조적 방식을 표준으로 삼은 이유가 뭘까요? 스레드가 파일을 쓰는 도중이거나 데이터를 절반만 바꾼 상태에서 강제로 끊기면 어떤 일이 벌어질지 상상해보면 답의 실마리가 보일 거예요. "안전" 과 "편함" 사이의 선택이라는 관점에서 생각해보세요.

✅ 예시 답안정답 보기

오늘 과제는 스레드를 만들고, 기다리고, 멈추는 세 동작을 하나씩 손에 익히는 흐름이에요. 과제 1 은 람다 Runnablejoin 으로 "발송이 끝났는지" 를 안전하게 확인하고, 과제 2 는 여러 스레드를 모두 join 해서 "전부 끝났는지" 를 보고, 과제 3 은 interrupt 로 무한히 도는 작업을 협조적으로 멈춰요. concurrent 패키지의 오늘 예제(FeedLoader·SleepAndJoin·ThreadInterrupt)를 옆에 두고 비교하면서 보면 편해요.

과제 예시답안

과제 1 예시답안 — 알림 발송 스레드를 람다로 띄우고 기다리기

핵심 접근

이 과제의 핵심은 두 가지예요. "할 일을 람다 Runnable 로 만들기" 와 "그 스레드가 끝날 때까지 join 으로 기다리기" 죠.

알림 발송은 run() 메서드 하나면 되는 일이라, 클래스를 따로 만들 필요 없이 람다 한 줄로 Runnable 을 만들 수 있어요. 그리고 메인 흐름이 "발송이 끝났다" 를 확인하려면, start() 직후에 바로 확인하면 안 돼요. 새 스레드가 아직 일하는 중일 수 있거든요. join() 으로 그 스레드가 끝날 때까지 기다린 다음에 확인해야 결과가 확정돼 있어요.

예시 구현

Java
// com/instagram/javabasic/concurrent/solution/day33/NotificationSender.java
public void sendAndWait() throws InterruptedException {
    Runnable task = () -> {
        System.out.println("[" + Thread.currentThread().getName() + "] 알림 발송 완료");
        this.sent = true;
    };
    Thread thread = new Thread(task);
    thread.start();
    thread.join();  // 발송 스레드가 끝날 때까지 기다린다 → 돌아오면 sent 가 확정돼 있어요
}

task 가 람다로 만든 Runnable 이에요. 이걸 new Thread(task) 에 태워 start() 로 띄우면 새 스레드가 알림 발송을 하죠. thread.join() 이 끝나고 나면 발송이 완료된 뒤라, main 에서 isSent() 가 항상 true 예요. 그래서 출력은 언제 실행해도 "알림 발송 완료" → "메인: ... 확인" 순서로 나와요. join 이 그 순서를 보장해주거든요.

채점 포인트

확인할 점 왜 중요한가
할 일을 람다 Runnable 로 만들었는가 메서드 하나짜리 작업은 클래스 없이 람다로 짧게 — 오늘 배운 핵심이에요
start() 로 새 스레드를 띄웠는가 run() 을 직접 부르면 새 스레드가 안 생겨요
join() 으로 기다린 뒤 확인했는가 안 기다리고 바로 확인하면 아직 발송 전일 수 있어요
출력 순서가 항상 일정한가 join 덕분에 "발송 완료" 가 "메인 확인" 보다 먼저 나와요

흔한 실수

  • start() 대신 run() 을 직접 불러요. 컴파일도 되고 출력도 비슷하게 나오지만, 새 스레드가 전혀 안 생긴 거예요. 동시성을 의도했다면 반드시 start() 예요.
  • join() 을 빼먹어요. 그러면 발송 스레드가 일하는 중에 main 이 먼저 isSent() 를 확인해버려서, 운에 따라 false 가 나오거나 출력 순서가 뒤죽박죽이 돼요.
  • InterruptedException 을 처리하지 않아 컴파일이 안 돼요. join()sleep() 은 checked 예외를 던지니 throws 로 넘기거나 try-catch 로 감싸야 해요.

과제 2 예시답안 — 피드 이미지 5장 동시 로딩 후 전부 완료 확인하기

핵심 접근

여러 스레드를 동시에 띄우는 것까지는 쉬워요. 어려운 건 "다섯 개가 모두 끝났는지" 를 안전하게 확인하는 부분이에요.

요령은 만든 스레드들을 리스트에 모아두는 거예요. 그래야 나중에 그 리스트를 돌면서 하나하나 join() 을 부를 수 있거든요. 다섯 스레드를 전부 join 하고 나면 비로소 다섯 작업이 모두 끝난 게 보장돼요. 그제서야 각 FeedLoaderisLoaded() 를 확인하면 안전하죠. 만약 join 을 빼고 확인하면, 아직 로딩 중인 스레드가 있어서 false 가 섞여 나올 수 있어요.

예시 구현

Java
// com/instagram/javabasic/concurrent/solution/day33/FeedBatchLoader.java
public boolean loadAll(int imageCount) throws InterruptedException {
    List<FeedLoader> loaders = new ArrayList<>();
    List<Thread> threads = new ArrayList<>();

    // 1) FeedLoader 를 imageCount 개 만들어 각각 스레드로 동시에 띄운다.
    for (int id = 1; id <= imageCount; id++) {
        FeedLoader loader = new FeedLoader(id);
        Thread thread = new Thread(loader);
        loaders.add(loader);
        threads.add(thread);
        thread.start();
    }

    // 2) 만들어둔 모든 스레드가 끝날 때까지 join 으로 기다린다.
    for (Thread thread : threads) {
        thread.join();
    }

    // 3) join 이 다 끝났으니, 모든 로더가 로딩 완료됐는지 안전하게 확인한다.
    for (FeedLoader loader : loaders) {
        if (!loader.isLoaded()) {
            return false;
        }
    }
    return true;
}

세 단계가 또렷하게 나뉘어요. 먼저 반복문으로 FeedLoader 다섯 개를 만들어 각각 스레드에 태워 start() 하고(동시에 출발), 만든 스레드들을 리스트에 모아둬요. 그다음 그 리스트를 돌며 전부 join() 해서 다섯 작업이 모두 끝나길 기다리고요. 마지막에야 각 로더의 isLoaded() 를 확인해 하나라도 안 끝났으면 false, 전부 끝났으면 true 를 돌려줘요.

채점 포인트

확인할 점 왜 중요한가
스레드를 리스트에 모아뒀는가 나중에 전부 join 하려면 만든 스레드를 손에 쥐고 있어야 해요
모든 스레드를 join 했는가 하나라도 안 기다리면 그 스레드는 아직 로딩 중일 수 있어요
join 을 다 한 isLoaded() 를 확인했는가 순서가 핵심이에요. 기다린 다음에 확인해야 결과가 확정돼요
각 로더가 독립적으로 동작하는가 서로 다른 imageId 를 가져 간섭하지 않아요 (경쟁 상태 없음)

흔한 실수

  • start() 를 부르는 반복문 안에서 바로 join() 도 같이 불러요. 그러면 한 스레드를 띄우자마자 끝날 때까지 기다리고 다음 걸 띄우니, 사실상 하나씩 순서대로 도는 거예요. 동시 로딩이 아니죠. 다 띄운 뒤에 따로 모아서 join 해야 해요.
  • 스레드를 리스트에 안 모으고 join 할 방법을 잃어버려요. start()Thread 객체를 어딘가 담아둬야 나중에 기다릴 수 있어요.
  • join 없이 곧장 isLoaded() 를 확인해요. 운이 좋으면 맞지만, 로딩이 안 끝난 스레드가 있으면 false 가 섞여 나오는 불안정한 코드가 돼요.

과제 3 예시답안 — 무한히 도는 작업을 interrupt로 멈추기

핵심 접근

무한 루프로 도는 작업을 바깥에서 멈추려면, 강제로 끊는 게 아니라 신호를 보내고 받는 쪽이 스스로 멈추게 해야 해요. 그래서 두 부분이 짝을 이뤄요.

받는 쪽(작업 스레드)은 while (!Thread.currentThread().isInterrupted()) 로 "중단 신호가 안 왔으면 계속 일한다" 를 적어요. 그리고 루프 안에 Thread.sleep(200) 을 넣어 너무 빨리 돌지 않게 하는데, sleep 중에 신호를 받으면 InterruptedException 이 터지면서 깃발이 꺼져요. 이때 catch 에서 interrupt() 를 다시 불러 깃발을 살려두면, 다음 while 검사에서 루프를 빠져나가요. 보내는 쪽(main)은 잠깐 기다렸다가 interrupt() 로 신호를 보내고, join() 으로 실제로 멈출 때까지 기다리면 돼요.

예시 구현

Java
// com/instagram/javabasic/concurrent/solution/day33/FeedWatcher.java
public Thread startWatching() {
    Thread watcher = new Thread(() -> {
        // 중단 신호가 안 왔으면 계속 확인한다.
        while (!Thread.currentThread().isInterrupted()) {
            System.out.println("[" + Thread.currentThread().getName() + "] 피드 확인 중...");
            try {
                Thread.sleep(200);  // 너무 빨리 돌지 않게 잠깐 쉰다
            } catch (InterruptedException e) {
                // sleep 중 신호를 받으면 깃발이 꺼지니, 다시 살려 루프를 빠져나가게 한다.
                Thread.currentThread().interrupt();
            }
        }
        this.stopped = true;
        System.out.println("[" + Thread.currentThread().getName() + "] 피드 확인을 멈췄어요");
    });
    watcher.start();
    return watcher;
}

while (!isInterrupted()) 가 협조적 중단의 심장이에요. maininterrupt() 로 깃발을 꽂으면, watcher 는 다음 루프 검사에서 그걸 보고 스스로 빠져나와 stopped = true 로 정리하고 멈춰요. main 쪽은 이렇게 돌려요.

Java
Thread thread = watcher.startWatching();
Thread.sleep(1000);  // 1초 동안 백그라운드로 피드를 확인하게 둔다
thread.interrupt();  // "그만해 줄래?" 협조적 중단 신호
thread.join();       // 실제로 멈출 때까지 기다린다

interrupt() 는 "부탁" 이지만, 받는 쪽이 isInterrupted() 를 확인하고 InterruptedException 을 처리하도록 협조해뒀기 때문에 안전하게 멈춰요.

채점 포인트

확인할 점 왜 중요한가
while (!isInterrupted()) 로 루프를 돌았는가 협조적 중단의 핵심. 신호를 스스로 확인해야 멈출 수 있어요
sleepInterruptedException 을 처리했는가 checked 예외라 처리 안 하면 컴파일이 안 돼요
catch 에서 interrupt() 로 깃발을 다시 살렸는가 안 살리면 깃발이 꺼진 채라 루프를 못 빠져나갈 수 있어요
interrupt()join() 으로 멈춤을 확인했는가 신호만 보내고 안 기다리면 정말 멈췄는지 알 수 없어요

흔한 실수

  • while (true) 로 무한 루프를 돌고 isInterrupted() 를 확인하지 않아요. 그러면 interrupt() 신호를 보내도 멈추지 않아요. 협조적 중단은 받는 쪽이 신호를 확인해야 성립해요.
  • catch (InterruptedException e) 안을 비워둬요(아무것도 안 함). sleep 이 던진 예외로 깃발이 꺼진 채 넘어가면, 루프가 신호를 놓쳐 안 멈출 수 있어요. interrupt() 로 깃발을 되살리는 게 안전해요.
  • Thread.stop() 으로 강제로 멈추려 해요. 지금은 쓰면 안 되는(deprecated) 방식이에요. 하던 일이 어중간하게 잘릴 위험이 커서 협조적 중단을 써야 해요.

생각해볼 주제 예시답안

생각해볼 주제 1 예시답안 — start() 대신 run() 을 직접 부르면 왜 "조용한 버그" 일까?

[문제 상황 요약]

worker.run() 을 직접 불러도 컴파일 에러가 안 나고, run() 안의 코드도 멀쩡히 실행돼요. 출력도 정상으로 보이죠. 그래서 초보자는 한참 동안 문제를 눈치채지 못해요. 하지만 이건 새 스레드를 전혀 만들지 않은 거예요. 에러는 안 나는데 의도와 다르게 동작하는 이 버그가 왜 더 위험할 수 있을까요?

[튜터의 가이드 및 해설]

핵심은 "에러가 안 나는 버그가 가장 찾기 어렵다" 예요.

run() 을 직접 부르면 새 스레드가 안 생기고, 부른 쪽(main)이 그 일을 처음부터 끝까지 직접 해요. 즉 동시성을 기대하고 짠 코드가 사실은 그냥 순서대로 도는 코드인 거죠. 그런데 결과만 보면 멀쩡해요. 알림도 발송되고, 이미지도 로딩되고요. 차이는 "속도" 와 "동시성" 에서만 나타나요.

문제는 이 차이가 평소엔 잘 안 드러난다는 거예요. 개발할 때 데이터가 적으면 순차로 돌아도 충분히 빠르거든요. 그러다 실제 서비스에서 요청이 몰리는 순간, "분명 동시에 처리되게 짰는데 왜 이렇게 느리지?" 하고 그제서야 의심하게 돼요. 한참 뒤에, 그것도 가장 바쁠 때 터지는 거죠.

이런 실수를 알아채는 방법이 오늘 배운 트릭이에요. Thread.currentThread().getName() 으로 "이 코드를 누가 실행했는지" 를 찍어보면, run() 직접 호출은 main 이, start()Thread-0 같은 다른 이름이 나와요. 의심스러우면 실행 스레드 이름을 출력해보는 게 가장 확실해요. 그래서 규칙은 단순하게 외워두는 게 좋아요. "새 스레드는 언제나 start()."

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

"run() 을 직접 호출하면 새 스레드가 생기지 않고 호출한 스레드가 그 일을 직접 수행합니다. 컴파일도 되고 결과도 정상이라 에러가 없지만, 의도한 동시성이 사라진 '조용한 버그' 입니다. 이런 버그는 평소엔 드러나지 않다가 트래픽이 몰릴 때 성능 문제로 나타나 찾기가 더 어렵습니다. 그래서 새 스레드 실행은 반드시 start() 를 쓰고, 의심될 때는 실행 스레드 이름을 찍어 확인하는 습관이 중요하다고 생각합니다."


생각해볼 주제 2 예시답안 — 스레드를 많이 만들수록 정말 빨라질까?

[문제 상황 요약]

이미지 10장을 받는 데 스레드 10개를 쓰면 빨라졌어요. 그럼 10000장을 받을 때 스레드 10000개를 만들면 10000배 빨라질까요? "스레드 수" 와 "실제 속도" 사이의 관계가 단순 비례가 아닌 이유는 뭘까요?

[튜터의 가이드 및 해설]

답부터 말하면, 스레드를 늘린다고 무한정 빨라지진 않아요. 두 가지 비용 때문이에요.

첫째, 스레드 하나하나가 공짜가 아니에요. 스레드를 만들면 각자의 호출 스택을 위한 메모리가 들어요. 수천 개를 만들면 그 메모리만으로도 부담이 커지죠. 둘째, CPU 가 여러 스레드를 번갈아 실행하는 데도 비용이 들어요. CPU 코어 수는 정해져 있는데(예: 4개나 8개), 스레드가 그보다 훨씬 많으면 진짜로 동시에 도는 건 코어 수만큼뿐이에요. 나머지는 차례를 기다리고, CPU 는 이 스레드 저 스레드로 전환하느라 오히려 시간을 써요.

그래서 적당한 수가 중요해요. 그 "적당한 수" 는 일의 성격에 따라 달라요. 이미지 다운로드처럼 대부분의 시간을 "기다리는" 일(네트워크 응답 대기)은 코어 수보다 많은 스레드를 둬도 효율적이에요. 한 스레드가 기다리는 동안 다른 스레드가 일하면 되니까요. 반대로 복잡한 계산처럼 CPU 를 계속 쓰는 일은 코어 수에 가깝게 두는 게 좋아요. 그보다 많아 봤자 서로 자리를 다투기만 하거든요.

실무에서는 그래서 스레드를 매번 새로 만들기보다, 적당한 수만큼 미리 만들어두고 재사용 하는 방식을 써요. 그 방법은 다음 여정에서 따로 배울 거예요. 지금은 "스레드는 비용이 있고, 무작정 늘리는 게 답은 아니다" 만 잡아두면 충분해요.

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

"스레드는 늘릴수록 빨라지는 게 아닙니다. 스레드마다 메모리(스택)가 들고, CPU 가 여러 스레드를 전환하는 비용도 있으며, 실제 동시 실행은 코어 수만큼만 가능하기 때문입니다. 적정 스레드 수는 작업 성격에 따라 다른데, 네트워크 대기가 많은 작업은 코어 수보다 넉넉히, CPU 를 계속 쓰는 작업은 코어 수에 가깝게 두는 게 효율적입니다. 그래서 실무에서는 스레드를 매번 생성하지 않고 스레드 풀로 적정 수를 재사용하는 방식이 표준이라고 이해하고 있습니다."


생각해볼 주제 3 예시답안 — interrupt() 는 왜 강제로 죽이지 않고 "부탁" 할까?

[문제 상황 요약]

자바는 스레드를 강제 종료하는 길을 일부러 막아두고(stop() deprecated), 대신 "신호를 보내면 받는 쪽이 알아서 멈추는" 협조적 중단만 남겨뒀어요. 언뜻 보면 강제로 끊는 게 더 확실하고 편할 것 같은데, 왜 굳이 번거로운 협조적 방식을 표준으로 삼았을까요?

[튜터의 가이드 및 해설]

이건 "안전" 과 "편함" 사이의 선택 이에요. 자바는 안전을 골랐어요.

강제 종료가 위험한 이유를 그려볼게요. 어떤 스레드가 파일에 데이터를 쓰는 중이라고 해봐요. 절반쯤 썼을 때 누가 그 스레드를 강제로 뚝 끊으면, 파일은 절반만 쓰인 망가진 상태로 남아요. 데이터를 여러 군데 나눠 바꾸는 작업이라면 더 심해요. 첫 번째는 바꿨는데 두 번째는 못 바꾼 어중간한 상태에서 멈추면, 데이터 사이의 앞뒤가 안 맞아 버리죠. 한번 깨진 데이터는 되돌리기도 어렵고요.

협조적 중단은 이 문제를 피해요. 신호를 받은 스레드가 자기가 멈춰도 되는 안전한 지점 에서 멈추니까요. 파일을 다 쓴 뒤, 또는 한 묶음의 작업을 끝낸 뒤에 "아, 그만하라는 신호가 왔네" 하고 정리하고 내려가는 거예요. 그래서 데이터가 어중간하게 깨질 일이 없어요.

대신 책임은 받는 쪽에 있어요. isInterrupted() 를 확인하지 않는 스레드는 신호를 보내도 안 멈춰요. 그래서 오래 도는 작업을 만들 땐 중간중간 신호를 확인하는 습관이 중요하죠. 자바의 선택은 "조금 번거롭더라도, 데이터를 깨뜨리지 않는 안전한 방식" 이에요. 강제로 끊는 편함보다 안전을 더 값지게 본 설계라고 보면 돼요.

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

"interrupt() 는 강제 종료가 아니라 중단을 요청하는 신호이고, 멈추는 시점은 받는 스레드가 안전한 지점에서 스스로 결정합니다. 강제 종료(stop())가 deprecated 된 이유는 작업이 어중간한 상태(파일을 절반만 쓰거나 데이터를 일부만 변경한 상태)에서 끊기면 복구하기 어려운 손상이 생기기 때문입니다. 자바는 약간의 번거로움을 감수하더라도 데이터 무결성을 지키는 협조적 중단을 표준으로 택한 것이고, 그래서 오래 도는 작업에는 중단 신호를 주기적으로 확인하는 코드를 넣는 것이 중요하다고 생각합니다."

전체 목록 자바 기초
이 토픽을 끝까지 봤어요 🎉 다른 트랙도 둘러보기 →

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.