JAVA

자바 스레드 — 여러 일을 동시에 시키기(THREAD·SYNCHRONIZED)

PANCO_IT
#자바#JAVA#스레드#thread#synchronized

스레드 없이 순차로 처리할 때와 스레드로 동시에 처리할 때를 비교한 그림

스레드는 프로그램 안에서 일을 나눠 맡는 또 다른 일꾼입니다.

6편에서 파일을 읽고 썼습니다. 그런데 파일이 아주 크면 어떻게 될까요. 읽는 동안 프로그램이 통째로 멈춰 버립니다. 다운로드가 끝날 때까지 버튼도 안 눌리는 앱, 한 번쯤 겪어 보셨을 겁니다. 일을 나눠서 동시에 시키는 것, 그게 오늘의 주제인 스레드입니다.

지금까지는 한 줄로 서 있었습니다

지금까지 쓴 코드는 전부 위에서 아래로, 한 줄씩 차례대로 실행됐습니다. 앞 작업이 끝나야 뒤 작업이 시작되죠.

for (int i = 0; i < 5; i++) {
    System.out.println("작업1...");
    Thread.sleep(500);          // 0.5초 쉬기
}
for (int i = 0; i < 5; i++) {
    System.out.println("작업2...");
    Thread.sleep(500);
}
// 작업1이 다 끝난 뒤에야 작업2 시작 → 총 5초

Thread.sleep(500)은 0.5초 멈추라는 뜻입니다. 이 코드는 작업1을 2.5초 동안 하고, 그다음 작업2를 2.5초 동안 합니다. 합쳐서 5초죠. 두 작업이 서로 상관없는 일이라면 굳이 기다릴 이유가 없는데도 말입니다.

쉽게 — 은행 창구가 하나뿐인 상황과 같습니다. 앞사람 업무가 끝나야 내 차례가 오죠. 스레드는 창구를 하나 더 여는 것입니다. 손님이 둘로 나뉘어 동시에 처리되니 전체 시간이 줄어듭니다.

스레드 만들기 — 일감과 일꾼

자바에서 스레드를 만드는 방법은 두 단계입니다. 할 일을 Runnable로 정의하고, 그걸 Thread에 실어 보내는 것입니다.

class Task implements Runnable {
    private String name;
    Task(String name) { this.name = name; }

    @Override
    public void run() {                    // 이 안이 스레드가 할 일
        for (int i = 0; i < 5; i++) {
            System.out.println(name + "...");
            try { Thread.sleep(500); } catch (InterruptedException e) { }
        }
    }
}

Thread t1 = new Thread(new Task("작업1"));
Thread t2 = new Thread(new Task("작업2"));
t1.start();
t2.start();
// 작업1과 작업2가 번갈아 출력됨 → 총 2.5초 안팎

Runnable일감, Thread는 그 일감을 들고 뛰는 일꾼이라고 보면 됩니다. run() 안에 적은 것이 그 스레드가 할 일이고요.

여기서 중요한 변화가 하나 있습니다. 출력 순서가 실행할 때마다 달라집니다. 어느 스레드가 먼저 차례를 받을지는 운영체제가 정하기 때문입니다. 순서가 보장되지 않는다는 점이 스레드를 쓸 때 가장 먼저 받아들여야 할 성질입니다.

단골 함정 — start와 run은 다릅니다

입문자가 거의 반드시 한 번은 밟는 지점입니다. 시작할 때 run()을 부르면 어떻게 될까요.

t1.run();    // 스레드가 안 생김! 그냥 메서드 호출
t1.start();  // 새 스레드에서 실행

run()을 직접 부르면 새 스레드가 만들어지지 않고, 지금 스레드에서 그냥 메서드 하나를 실행할 뿐입니다. 결과적으로 순차 실행이 되어 버리죠. 오류도 안 나서 알아채기 어렵습니다.

구분하는 방법은 간단합니다. 새 일꾼을 부르는 명령은 start() 하나뿐입니다. run()은 그 일꾼이 도착해서 실제로 하는 일이고요. 코드가 동시에 안 돌아가는 것 같으면 이 자리부터 확인하면 됩니다.

같이 만지면 숫자가 샙니다

스레드가 둘 이상이면 새로운 문제가 생깁니다. 같은 변수를 여러 스레드가 동시에 고칠 때입니다.

자물쇠 없이 공유 변수를 고칠 때와 synchronized로 잠갔을 때 비교

그림 1. 공유하는 값이 있으면 한 번에 하나씩 들어가도록 자물쇠를 채워야 합니다.

counter++는 한 덩어리처럼 보이지만 실제로는 읽고, 더하고, 저장하는 세 동작입니다. 두 스레드가 동시에 이 일을 하면 이런 일이 벌어집니다.

  1. 스레드A가 counter를 읽습니다. 값은 10.
  2. 그 순간 스레드B도 counter를 읽습니다. 역시 10.
  3. A가 11을 저장합니다.
  4. B도 11을 저장합니다.

두 번 더했는데 결과는 11입니다. 하나가 사라졌죠. 이런 상태를 경쟁 상태(race condition)라고 부릅니다. 10만 번쯤 반복하면 수백 개가 어긋나는데, 실행할 때마다 결과가 달라 원인을 찾기도 어렵습니다.

synchronized — 한 번에 한 명만

해결책은 한 번에 한 스레드만 들어가게 막는 것입니다. synchronized 블록이 그 자물쇠 역할을 합니다.

private static int counter = 0;
private static final Object lock = new Object();   // 자물쇠로 쓸 객체

public void run() {
    for (int i = 0; i < 100000; i++) {
        synchronized (lock) {        // 이 안은 한 번에 한 스레드만
            counter++;
        }
    }
}

synchronized (lock) 블록에 들어간 스레드가 나올 때까지 다른 스레드는 문 앞에서 기다립니다. 그래서 값이 어긋나지 않습니다. 대신 기다리는 시간만큼 느려지므로, 꼭 필요한 범위만 최소로 감싸는 것이 요령입니다. 반복문 전체를 감싸면 결국 순차 실행과 다를 게 없어집니다.

하나 더. 여러 스레드가 끝날 때까지 기다리려면 join()을 씁니다.

t1.start();
t2.start();
t1.join();     // t1이 끝날 때까지 메인 스레드가 대기
t2.join();
System.out.println("최종 값: " + counter);   // 이제 정확한 값

join() 없이 바로 결과를 출력하면, 스레드들이 아직 일하는 중이라 엉뚱한 값이 찍힙니다.

직접 실행해 보기

아래 코드를 두세 번 돌려 보세요. 출력 순서가 매번 달라지는 것을 확인할 수 있습니다. 그다음 synchronized 줄을 주석 처리하고 다시 돌려 최종 값이 어떻게 되는지 비교해 보시기 바랍니다.

스레드와 synchronized
class Counter implements Runnable {
  static int counter = 0;
  static final Object lock = new Object();
  private String name;
  Counter(String name) { this.name = name; }

  public void run() {
      for (int i = 0; i < 10000; i++) {
          synchronized (lock) {      // 이 줄과 짝 괄호를 지우면 값이 어긋납니다
              counter++;
          }
      }
      System.out.println(name + " 완료");
  }
}

public class Main {
  public static void main(String[] args) throws InterruptedException {
      Thread t1 = new Thread(new Counter("작업1"));
      Thread t2 = new Thread(new Counter("작업2"));

      t1.start();                    // run()이 아니라 start()
      t2.start();

      t1.join();                     // 둘 다 끝날 때까지 대기
      t2.join();

      System.out.println("최종 값: " + Counter.counter);   // 20000이어야 정상
  }
}

📱 코드 실행(실습)은 태블릿·PC에서 이용할 수 있어요. 모바일에서는 위 코드를 눈으로 따라가며 확인하세요.

한 장 정리

항목내용
스레드프로그램 안에서 일을 나눠 맡는 실행 흐름
만들기Runnable로 할 일 정의 → Thread에 실어 start()
start vs runstart()만 새 스레드 생성, run()은 그냥 메서드 호출
경쟁 상태여러 스레드가 같은 변수를 동시에 고쳐 값이 어긋남
synchronized한 번에 한 스레드만 들어가게 잠금 (범위는 최소로)
join그 스레드가 끝날 때까지 기다림

이것만 기억하면 됩니다. 스레드는 창구를 하나 더 여는 것이라 전체 시간이 줄지만, 순서가 보장되지 않습니다. 새 스레드는 start()로만 시작되고, 여러 스레드가 같은 값을 만지면 반드시 잠가야 합니다.

미니 퀴즈

개념을 제대로 잡았는지 직접 풀어 봅니다. 정답은 눌러야 나옵니다.

미니 퀴즈

OX 2문제 + 4지선다 3문제


포켓코딩(JAVA3) 7편 · 예제 출처: 강사 개인 GitHub 저장소. 다음 편: 네트워크 — 소켓으로 두 프로그램 연결하기.

PANCO_IT

IT 지식을 정리하는 개발자

관련 글