프로세스와 스레드, 자바 메모리 구조

핵심 요약

  • 프로세스(Process)는 운영체제로부터 자원을 할당받은 작업의 단위(공장)이며, 스레드(Thread)는 프로세스 내에서 실행되는 흐름의 단위(일꾼)입니다.
  • CPU는 시분할 시스템(Multitasking)을 통해 여러 작업을 동시에 처리하는 것처럼 보이게 하며, 실제 물리적 병렬 처리는 멀티프로세싱입니다.
  • 자바의 메모리 구조(JVM)는 Method, Heap, Stack, PC Register, Native Method Stack으로 나뉘며, 스레드는 Stack과 PC Register를 독립적으로 가집니다.
  • 스레드 구현 시 상속보다는 Runnable 인터페이스를 구현하는 것이 확장성과 유지보수 측면에서 권장됩니다.

목차

안녕하세요. Java 백엔드 개발의 핵심, 멀티스레드와 동시성 시리즈의 첫 번째 문을 엽니다. 많은 입문자가 ‘스레드’라는 단어를 들으면 막연한 두려움을 갖습니다. 하지만 서버 개발자로서 대용량 트래픽을 처리하기 위해 반드시 넘어야 할 산이기도 합니다. 오늘은 그 첫걸음으로 운영체제 레벨의 개념과 자바가 메모리를 사용하는 방식에 대해 아주 쉽게 풀어보겠습니다.

# 사전 준비: 유틸리티 클래스

본 시리즈의 실습을 원활하게 진행하기 위해, 중복 코드를 줄여주는 유틸리티 클래스를 먼저 정의하고 넘어가겠습니다. Thread.sleep() 호출 시 매번 발생하는 예외 처리를 감싸둔 ThreadUtils와 현재 시간 및 스레드 이름을 예쁘게 출력해주는 LogUtils입니다.

ThreadUtils.java

package utils;

public abstract class ThreadUtils {

  public static void sleep(long milis) {
    try {
      Thread.sleep(milis);
    } catch (InterruptedException e) {
      LogUtils.info("인터럽트 발생 > " + e.getMessage());
      throw new RuntimeException(e);
    }
  }
  
}

LogUtils.java

package utils;

import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

public class LogUtils {

  public static void info(Object obj) {
    System.out.printf("[%s][%-9s] %s%n", 
     current(),
     Thread.currentThread().getName(),
      obj.toString()
    );
  }

  private static String current() {
    return LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss.SSS"));
  }
}

1. 멀티태스킹과 멀티프로세싱의 오해와 진실

우리는 흔히 컴퓨터가 동시에 여러 작업을 한다고 생각합니다. 음악을 들으며 코딩을 하고, 브라우저로 검색도 하니까요. 하지만 여기에는 ‘동시’라는 단어의 두 가지 의미가 숨어 있습니다.

멀티태스킹 (Multi-tasking): 소프트웨어적 동시성

단일 CPU(코어)가 여러 작업(Task)을 동시에 처리하는 것처럼 ‘보이게’ 하는 기술입니다. 실제로는 CPU가 찰나의 순간마다 작업을 번갈아 가며 처리합니다. 워낙 속도가 빠르다 보니 사용자 눈에는 동시에 실행되는 것처럼 느껴지는 것이죠. 이를 시분할(Time Sharing)이라고도 합니다.

멀티프로세싱 (Multi-processing): 하드웨어적 병렬성

여러 개의 CPU(멀티 코어)가 ‘실제로’ 여러 작업을 동시에 수행하는 것입니다. 물리적인 코어가 여러 개이기 때문에, 진짜 의미에서의 동시 처리가 가능합니다. 하드웨어의 성능을 통해 처리 능력을 극대화하는 개념입니다.

2. 프로세스와 스레드: 공장과 일꾼 이야기

그렇다면 작업의 주체인 프로세스와 스레드는 정확히 무엇일까요? 가장 이해하기 쉬운 비유는 ‘공장(Process)’과 ‘일꾼(Thread)’입니다.

프로세스 (Process)

운영체제 위에서 실행 중인 프로그램의 인스턴스입니다. 프로그램이 저장장치에 있는 코드 덩어리(정적 상태)라면, 프로세스는 이를 메모리에 올려 실행 가능한 상태(동적 상태)로 만든 것입니다.

  • 특징: 각 프로세스는 운영체제로부터 고유한 메모리 공간을 할당받습니다. 원칙적으로 서로의 영역을 침범할 수 없으며 격리되어 있습니다.

프로세스의 메모리 구성

프로세스는 할당받은 메모리를 효율적으로 사용하기 위해 네 가지 영역으로 나눕니다.

구성요소 설명 위치
코드(Code) 실행할 프로그램의 명령어가 기계어 형태로 저장됨 코드 영역
데이터(Data) 전역 변수, 정적(static) 변수 등 프로그램 시작과 끝을 함께하는 데이터 데이터 영역
스택(Stack) 함수 호출 정보, 지역 변수, 반환 주소 등 임시 데이터 스택 영역
힙(Heap) 객체(Instance)와 같이 동적으로 할당되는 데이터 힙 영역
PCB 프로세스 제어 블록. 프로세스 상태와 CPU 사용 정보를 관리 OS 커널 영역

스레드 (Thread)

스레드는 프로세스 내에서 실행되는 실제 작업의 흐름 단위입니다. 프로세스가 공장 건물이라면, 스레드는 그 안에서 기계를 돌리는 일꾼입니다.

  • 메모리 공유: 같은 프로세스 내의 스레드들은 코드, 데이터, 힙 영역을 공유합니다. 공장의 시설을 모든 일꾼이 공유하는 것과 같습니다.
  • 독립성: 단, 각 스레드는 작업의 흐름을 기억하기 위해 자신만의 스택(Stack) 영역을 가집니다.

3. 자바 메모리 구조 (JVM Runtime Data Area) 완벽 해부

자바 개발자라면 JVM이 메모리를 어떻게 관리하는지, 그리고 스레드가 이 공간을 어떻게 사용하는지 반드시 알아야 합니다.

JVM은 운영체제로부터 메모리를 할당받아 다음과 같이 5가지 영역으로 관리합니다.

  • Method Area (공유): 클래스 정보, static 변수, 상수 풀이 저장됩니다. 모든 스레드가 공유합니다.
  • Heap Area (공유): new 키워드로 생성된 객체(Instance)와 배열이 저장됩니다. GC(가비지 컬렉터)의 주요 대상이며, 모든 스레드가 공유합니다.
  • Stack Area (독립): 각 스레드마다 하나씩 생성됩니다. 메서드 호출 시 프레임(Frame)이 쌓이며, 지역 변수와 매개변수가 저장됩니다. 메서드가 종료되면 사라집니다.
  • PC Register (독립): 각 스레드가 현재 실행 중인 JVM 명령어의 주소를 가리킵니다. 스레드가 번갈아 실행될 때 어디까지 실행했는지 기억하는 역할을 합니다.
  • Native Method Stack (독립): 자바 외의 언어(C, C++ 등)로 작성된 네이티브 코드를 실행하기 위한 공간입니다.

핵심 포인트: 스레드끼리는 Heap 영역을 공유하기 때문에 객체를 통해 데이터를 주고받을 수 있지만, 동시에 이로 인해 동시성 문제(Concurrency Issue)가 발생할 수 있습니다.

4. 자바에서 스레드를 만드는 2가지 방법

자바에서 스레드를 생성하는 방법은 크게 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법이 있습니다. 실무에서는 Runnable 구현 방식을 더 권장합니다.

방법 1: Thread 클래스 상속

가장 직관적인 방법이지만, 자바는 다중 상속을 지원하지 않기 때문에 Thread를 상속받으면 다른 클래스를 상속받을 수 없다는 단점이 있습니다.

public class MyThread extends Thread {
  @Override
  public void run() {
    LogUtils.info("Thread 상속 방식 작업 수행");
  }
}
// 실행 코드 MyThread t = new MyThread();
// t.start();

방법 2: Runnable 인터페이스 구현 (권장)

작업(Task)의 내용과 스레드(Worker)를 분리하는 방식입니다. 유연성이 높고, 추후 스레드 풀(Thread Pool)이나 실행자 프레임워크와 결합하기 좋습니다.

public class UserRunnable implements Runnable {
  @Override
  public void run() {
    LogUtils.info("Runnable 인터페이스 방식 작업 수행");
  }
}

// 실행 코드 Runnable task = new UserRunnable();
// Thread t = new Thread(task);
// t.start();

보너스: 데몬 스레드 (Daemon Thread)

스레드는 크게 주 작업을 수행하는 사용자 스레드(User Thread)와 보조 작업을 수행하는 데몬 스레드로 나뉩니다. 데몬 스레드는 메인 스레드를 포함한 모든 사용자 스레드가 종료되면, 자신의 작업이 남았더라도 강제로 종료됩니다. 가비지 컬렉션(GC), 자동 저장 기능 등이 이에 해당합니다.

Thread daemon = new Thread(new UserRunnable());

daemon.setDaemon(true); // 반드시 start() 호출 전에 설정해야 함 daemon.start();

지금까지 프로세스와 스레드의 기본 개념, 그리고 자바 메모리 구조에 대해 알아보았습니다. 다음 포스트에서는 스레드의 생명주기와 제어 방법에 대해 더 깊이 있게 다뤄보겠습니다.


이 글은 어떠셨나요? 자유롭게 의견을 남겨주세요! 💬