자바에서의 스레드 및 멀티스레딩 기초

병렬 처리를 활용하면 복잡한 작업을 여러 부분으로 나누어 다양한 CPU 코어에 분산 실행함으로써 처리 효율을 크게 향상시킬 수 있습니다. 그렇지 않으면 한 코어가 과부하 상태일 때 다른 코어들은 비활성 상태로 방치되는 '일코어 곤란, 여덟코어 관람' 현상이 발생합니다. 프로세스는 서로 다른 CPU에 배치될 수 있어 병렬 처리를 가능하게 하며, 이로 인해 코어 활용도가 높아집니다.

하지만 서버 환경에서는 동시에 많은 클라이언트 요청이 들어오는 경우가 많습니다. 만약 각 요청마다 새로운 프로세스를 생성하고 종료한다면, 자주 연결/접속이 이루어지는 상황에서는 프로세스 생성과 해제의 부담이 매우 커지게 됩니다. 이를 해결하기 위해 스레드(경량 프로세스)가 등장했습니다. 프로세스보다 생성/종료 비용이 낮아 효율성이 뛰어납니다.

1. 스레드의 개념 이해

스레드는 하나의 실행 흐름을 의미하며, 각 스레드는 독립적으로 코드를 순차적으로 실행할 수 있습니다. 여러 스레드는 동시에 동작하며, 서로 다른 코드 조각을 수행합니다.

간단히 말해, 스레드는 프로세스의 구성 요소이며, 하나의 프로세스는 최소한 하나 이상의 스레드를 포함합니다. 반드시 존재해야 하는 기본 스레드를 메인 스레드라고 합니다.

프로세스 상태를 설명하는 구조체인 PCB(Process Control Block)는 실제로는 스레드 단위로 정의되며, 여러 개의 PCB가 연결되어 하나의 프로세스를 나타냅니다. 즉, 프로세스 내부에 단일 스레드만 존재할 경우, 그 스레드의 PCB가 프로세스 정보를 담고 있다고 볼 수 있습니다.

동일한 프로세스 내의 여러 스레드는 메모리 영역과 파일 디스크립터 테이블을 공유하지만, 각각은 독립적인 실행 상태를 가집니다. 예를 들어, 스레드 1이 생성한 객체는 스레드 2에서도 접근 가능하지만, 각 스레드는 별개로 CPU에 할당되어 실행됩니다.

핵심 원칙 두 가지:

  1. 프로세스는 시스템이 리소스를 할당하는 기본 단위이다.
  2. 스레드는 시스템이 실행을 스케줄링하는 기본 단위이다.

왜 스레드가 프로세스보다 경량화되어 있는가?
스레드는 이미 프로세스가 할당한 리소스를 공유하므로, 새 리소스를 할당할 필요 없이 바로 사용할 수 있습니다. 반면 프로세스 생성 시에는 전체 메모리 공간, 파일 핸들, 디스크립터 등이 새로 할당되어야 하기 때문에 비용이 큽니다.

멀티스레딩에서 한 스레드가 예외를 발생시키고 적절히 처리되지 않으면, 전체 프로세스가 종료될 수 있으며, 다른 스레드들도 함께 중단됩니다. 반면 다중 프로세스 환경에서는 프로세스 간 고립성이 높아 하나가 실패하더라도 다른 프로세스에 영향을 주지 않습니다. 실제 개발에서는 상황에 따라 적절한 방식을 선택해야 합니다.

주요 면접 질문 정리: 프로세스와 스레드의 차이점

  1. 정의: 프로세스는 리소스 할당의 기본 단위, 스레드는 실행 스케줄링의 기본 단위.
  2. 리소스 공유: 각 프로세스는 독립된 주소 공간과 리소스를 갖지만, 스레드는 프로세스의 리소스를 공유한다.
  3. 스위칭 오버헤드: 프로세스 전환 시 전체 컨텍스트 저장/복원이 필요하여 비용이 크고, 스레드 전환은 자체 컨텍스트만 변경하면 되어 비용이 적다.
  4. 안정성: 프로세스는 격리되어 있으므로 한 프로세스의 오류가 다른 프로세스에 영향을 주지 않지만, 스레드는 공유 자원을 사용하기 때문에 한 스레드의 오류가 전체 프로세스에 영향을 줄 수 있다.

2. 스레드 생성 방법 구현

스레드는 운영체제에서 제공하는 API를 통해 제어되며, JVM은 이를 래핑하여 사용할 수 있도록 제공합니다. 여기서는 Thread 클래스를 통해 스레드를 표현합니다.

(1) Thread 상속 후 run 메서드 재정의

class WorkerThread extends Thread {
    @Override
    public void run() {
        System.out.println("방법 1: 스레드를 상속하고 run 메서드 재정의");
    }
}

public class Example1 {
    public static void main(String[] args) {
        Thread worker = new WorkerThread();
        worker.start();
        System.out.println("메인 스레드 실행 중...");
    }
}

이 코드는 두 개의 스레드를 생성합니다: 메인 스레드와 새로 생성된 worker 스레드입니다. 실행 결과는 스레드 간 순서가 보장되지 않고, 운영체제의 선점형 스케줄링에 따라 무작위로 실행됩니다.

다음과 같이 무한 루프로 수정해보면, 두 스레드가 동시에 지속적으로 실행되는 모습을 확인할 수 있습니다:

class WorkerThread extends Thread {
    @Override
    public void run() {
        while (true) {
            System.out.println("워커 스레드 실행 중");
            try {
                Thread.sleep(3000); // 3초 대기 후 재시작
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
    }
}

public class Example1 {
    public static void main(String[] args) {
        Thread worker = new WorkerThread();
        worker.start();

        while (true) {
            System.out.println("메인 스레드 실행 중");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
    }
}

결과를 보면, 두 스레드가 번갈아 실행되며 실행 순서는 비순차적이고 운영체제에 의해 선점적으로 결정됨을 알 수 있습니다.

핵심 포인트: run() 메서드는 스레드가 수행할 작업을 정의하는 것이지, 직접 호출되는 것이 아닙니다. t.start()를 호출했을 때, 운영체제의 스레드 생성 API가 호출되어 내부에 PCB(프로세스 제어 블록)가 생성되고 스케줄러 대기열에 추가됩니다. 이후 해당 스레드가 실행되면, run() 메서드가 자동 실행됩니다.

이처럼 start()가 호출된 후 run()이 실행되는 구조는 콜백 함수의 전형적인 예입니다. 자바의 Comparator 인터페이스도 비슷한 원리로 작동합니다.

(2) Runnable 인터페이스 사용

작업 로직과 스레드 실행 구현을 분리하여 코드의 결합도를 낮추는 방법입니다.

class Task implements Runnable {
    @Override
    public void run() {
        System.out.println("방법 2: Runnable 인터페이스를 구현하여 작업 정의");
    }
}

public class Example2 {
    public static void main(String[] args) {
        Thread thread = new Thread(new Task());
        thread.start();
        System.out.println("메인 스레드 실행 중...");
    }
}

이 방식은 Runnable이 작업을 정의하고, Thread는 그 작업을 실행하는 역할만 수행합니다. 책임 분리가 명확해져 유지보수성과 유연성이 향상됩니다.

(3) 익명 내부 클래스 사용

public class Example3 {
    public static void main(String[] args) {
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("방법 3: 익명 내부 클래스로 스레드 생성");
            }
        });
        t.start();
        System.out.println("메인 스레드 실행 중...");
    }
}

(4) Lambda 표현식 (권장 방식)

public class Example4 {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            System.out.println("방법 4: 람다 표현식으로 스레드 작업 정의");
        });
        t.start();
        System.out.println("메인 스레드 실행 중...");
    }
}

람다 표현식은 코드를 간결하게 만들며, 특히 단일 추상 메서드 인터페이스(예: Runnable)를 구현할 때 매우 효과적입니다.

정리

이 모든 방법은 결국 스레드가 수행할 작업을 정의하고, Thread.start()를 통해 시스템에 스레드 생성 요청하는 것입니다. 내부적으로 운영체제가 해당 스레드를 생성하고 스케줄링합니다.

다음 글에서는 스레드의 생명주기, 동기화 문제, 그리고 동시성 제어 기법을 더 깊이 탐구하겠습니다.

태그: java Thread Multi-threading Runnable Lambda Expression

5월 23일 03:53에 게시됨