IT·테크/개발

Java 멀티 스레드 — Thread와 Runnable로 작업 만들고 synchronized로 지키기

레루루 2026. 7. 28. 21:48
반응형

이 글은 2022년 5월 기준 기록입니다.

 

스레드는 하나의 프로세스 내에서의 작업 단위입니다. 별도로 스레드를 만들지 않아도 이미 하나는 돌고 있습니다. 모든 자바 프로그램은 처음에 메인 스레드가 main() 메소드를 호출하면서 시작하고, 메인 스레드가 종료되면 프로그램이 종료됩니다.

스레드 작업은 두 가지 방법으로 만듭니다

첫 번째는 Thread 클래스를 상속받는 방법입니다. run() 메소드를 재정의해 그 안에 작업 내용을 적습니다.

public class NewThread extends Thread {

    @Override
    public void run() {
        // 스레드 작업 내용 작성
    }
}
public static void main(String[] args) {

    Thread t = new NewThread();
    t.start();

}

 

두 번째 방법을 이해하려면 Thread 클래스의 정체를 먼저 봐야 합니다. Thread 클래스는 Runnable 인터페이스에 대한 구현체입니다. Runnable 인터페이스에 정의된 run() 메소드를 Thread 클래스에서 start() 메소드를 통해 새 스레드로 동작시키는 구조입니다.

 

따라서 작업 단위를 Runnable 인터페이스의 구현체로 생성할 수 있습니다.

public class NewTask implements Runnable {

    @Override
    public void run() {
        // 작업 내용 작성
    }

}
public static void main(String[] args) {

    Runnable run = new NewTask();
    Thread t = new Thread(run);
    t.start();
}

 

작업이 한 번만 쓰이는 것이라면 따로 클래스로 구현체를 만들 필요 없이 익명 구현 객체로 작성해도 됩니다.

public static void main(String[] args) {

    Runnable run = new Runnable() { // 익명 구현 객체를 이용한 스레드 생성
        @Override
        public void run() {
            // 작업 내용 작성
        }
    };
    Thread t = new Thread(run);
    t.start();

}

 

Thread에 대한 익명 객체로 run() 메소드를 재정의해서 사용하는 것도 가능합니다.

start()를 불러야 비로소 새 스레드가 생깁니다

Thread 객체는 run() 메소드와 start() 메소드를 둘 다 가지고 있습니다. 이름이 비슷해 헷갈리지만 결과는 완전히 다릅니다.

 

run()을 호출하면 단순히 메소드를 호출하는 것이어서 실제 동작은 싱글 스레드가 됩니다. 새 스레드는 만들어지지 않고 호출한 스레드가 그 코드를 그냥 실행할 뿐입니다.

 

start() 메소드를 호출해야 새 스레드로 run() 메소드에 정의된 작업을 수행할 수 있습니다. 멀티 스레드 코드를 짰는데 순차 실행되는 것처럼 보인다면 이 지점을 먼저 확인할 일입니다.

공유 객체는 스레드끼리 값을 덮어씁니다

여러 스레드가 하나의 객체를 함께 쓰면 문제가 생깁니다.

 

Calculatormemory 필드를 두 스레드가 공유하는 상황입니다. User1 스레드가 memory = 100을 넣고 2초간 일시 정지한 사이에, User2 스레드가 memory = 50으로 값을 바꿔 버립니다. 잠시 뒤 깨어난 User1이 출력하는 값은 자신이 넣은 100이 아니라 50입니다. 값을 쓰고 읽는 사이에 다른 스레드가 끼어들었기 때문입니다.

 

이런 구간을 한 번에 하나의 스레드만 지나가도록 막는 것이 synchronized입니다. 메소드 전체에 걸 수도 있습니다.

public synchronized void method() {
    임계 영역;  // 단 하나의 스레드만 실행
}

 

메소드 전체를 잠글 필요가 없다면 블록 단위로 범위를 좁힐 수도 있습니다.

public void method () {
  // 여러 스레드가 실행 가능 영역
  ...
  synchronized(공유객체) {
    임계 영역 // 단 하나의 스레드만 실행
  }
  // 여러 스레드가 실행 가능 영역
  ...
}

 

블록 방식은 잠그는 범위를 최소한으로 두고, 공유 객체를 명시해 어떤 자원에 대한 잠금인지 드러낸다는 차이가 있습니다.

스레드 그룹과 스레드 풀은 관리 단위를 바꿉니다

스레드가 늘어나면 하나씩 다루는 것 자체가 일이 됩니다.

 

Thread Group은 여러 개의 스레드를 하나의 그룹으로 묶어서 그룹 단위로 관리할 수 있게 해 줍니다.

Thread Pool은 관점을 한 번 더 뒤집습니다. 작업에 대해 스레드의 생성과 종료 등을 큐(Queue, FIFO)를 이용해 관리합니다. 스레드 풀에 작업을 지시하면 스레드 풀이 자원을 관리하면서 자동으로 작업을 스케줄링합니다. 개발자가 스레드를 만들고 없애는 대신 할 일만 던지는 셈입니다.

 

스레드 풀은 Executors의 다음 메소드로 생성하며, ExecutorService의 구현 객체 타입을 리턴합니다.

메소드명(매개변수) 초기 스레드 수 코어 스레드 수 최대 스레드 수
newCachedThreadPool() 0 0 Integer.MAX_VALUE
newFixedThreadPool(int nThreads) 0 nThreads nThreads

 

각 작업은 Runnable 또는 Callable 타입으로 생성합니다. 둘의 차이는 리턴 값이 있느냐 없느냐입니다.

// Runnable 구현 클래스
Runnable task = new Runnable() {
  @Override
  public void run() {
    //스레드가 처리할 작업 내용
  }
}
// Callable 구현 클래스
Callable<T> task = new Callable<T> {
  @Override
  public T call() throws Exception {
    //스레드가 처리할 작업 내용
    return T;
  }
}

 

Runnablerun()void라 결과를 돌려줄 수 없고, Callablecall()T를 반환하며 throws Exception도 붙습니다. 작업의 결과를 받아야 한다면 선택지는 하나입니다.

반응형