Skip to main content

12. 멀티스레딩과 동시성 기초

자바를 배우다 보면 어느 순간 이런 생각이 듭니다. "내 프로그램은 왜 한 가지 일만 순서대로 해야 하지?" 파일을 읽는 동안 화면은 멈추고, 네트워크 응답을 기다리는 동안 다른 일은 못 하고, 계산 하나가 오래 걸리면 전체 프로그램이 답답해집니다. 이때 등장하는 강력한 무기가 바로 멀티스레딩과 동시성입니다.

이번 장에서는 어려운 이론보다 먼저 감각을 잡아 보겠습니다. 스레드는 프로그램 안에서 일하는 작은 작업자라고 생각하면 됩니다. 작업자가 한 명이면 모든 일을 혼자 처리하느라 바쁘고, 여러 명이면 역할을 나눌 수 있습니다. 다만 작업자가 많아지면 서로 부딪히지 않게 규칙도 필요합니다. 동시성 프로그래밍은 바로 그 규칙을 배우는 과정입니다.12

멀티스레딩과 동시성 기초

동시성과 멀티스레딩은 무엇이 다를까

먼저 비슷해 보이는 두 단어를 구분해 봅시다. 동시성(concurrency)은 여러 작업을 한꺼번에 다루는 능력입니다. 꼭 같은 순간에 실제로 동시에 실행되지 않아도 됩니다. 중요한 것은 하나의 작업이 끝날 때까지 다른 작업이 완전히 멈춰 있지 않고, 여러 작업이 조금씩이라도 전진하는 구조라는 점입니다. 반면 멀티스레딩(multithreading)은 하나의 프로그램 안에서 여러 스레드를 사용해 그런 구조를 만드는 대표적인 방법입니다.12

또 하나 자주 나오는 단어가 병렬성(parallelism)입니다. 병렬성은 여러 CPU 코어에서 여러 작업이 진짜 같은 시각에 실행되는 상태를 말합니다. 정리하면 이렇습니다.

  • 동시성: 여러 일을 엮어서 처리하는 설계

  • 멀티스레딩: 그 설계를 구현하는 대표 기술

  • 병렬성: 여러 코어에서 실제로 동시에 실행되는 상태

예를 들어 채팅 프로그램을 떠올려 봅시다. 메시지를 보내는 일, 새 메시지를 받는 일, 화면을 갱신하는 일은 서로 성격이 다릅니다. 이런 일을 한 줄로만 처리하면 답답해지고, 나누어 처리하면 훨씬 자연스러워집니다.1

프로세스와 스레드

운영체제에서 프로세스는 실행 중인 프로그램입니다. 그리고 스레드는 그 프로세스 안에서 실제 코드를 실행하는 흐름입니다. 같은 프로세스 안의 스레드들은 메모리를 공유합니다. 그래서 데이터를 쉽게 주고받을 수 있지만, 반대로 서로 같은 데이터를 건드리다가 충돌이 나기도 쉽습니다.23

핵심은 이렇습니다.

  • 같은 프로세스의 스레드들은 힙 메모리 같은 자원을 함께 사용합니다.

  • 하지만 각 스레드는 자기만의 스택을 가집니다.

  • 공유 자원이 많을수록 협업은 쉬워지지만, 실수하면 데이터가 꼬일 수 있습니다.

이 특성 때문에 멀티스레딩은 빠르고 유연하지만, 조심스럽게 다뤄야 합니다.

스레드 만들기

자바에서 스레드를 만드는 방법은 여러 가지가 있지만, 입문 단계에서는 세 가지를 알면 충분합니다. 중요한 사실은 새로운 스레드에서 실행하려면 run()이 아니라 start()를 호출해야 한다는 점입니다. run()을 직접 호출하면 그냥 현재 스레드에서 메서드를 부르는 것과 다르지 않습니다.24

Thread 상속

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("실행 중: " + Thread.currentThread().getName());
    }
}

public class Main {
    public static void main(String[] args) {
        MyThread t = new MyThread();
        t.start();
    }
}

간단하지만, 이미 다른 클래스를 상속하고 있다면 사용할 수 없다는 제약이 있습니다.

Runnable 구현

class MyTask implements Runnable {
    @Override
    public void run() {
        System.out.println("작업 실행: " + Thread.currentThread().getName());
    }
}

public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(new MyTask());
        t.start();
    }
}

실무에서는 이 방식이 더 자주 쓰입니다. 작업 내용과 스레드 관리를 분리할 수 있기 때문입니다.

람다 사용

Runnable은 함수형 인터페이스이므로 람다로도 간단히 작성할 수 있습니다.

public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            System.out.println("람다 스레드: " + Thread.currentThread().getName());
        });
        t.start();
    }
}

짧은 작업이라면 가장 읽기 편합니다.

스레드의 상태와 자주 쓰는 메서드

스레드는 태어나자마자 곧바로 일하지 않습니다. 자바의 스레드는 생성되고, 실행 준비를 하고, 잠깐 기다리기도 하고, 일을 마치면 종료됩니다. 자바는 이런 상태를 Thread.State로 표현합니다.4

상태의미
NEW스레드 객체만 만들어진 상태
RUNNABLE실행 가능 상태. 실제 실행 중인 경우도 포함
BLOCKED락을 얻지 못해 대기하는 상태
WAITING다른 스레드의 신호를 기다리는 상태
TIMED_WAITING일정 시간 동안 기다리는 상태
TERMINATED실행이 끝난 상태

초반에 자주 쓰는 메서드는 다음 정도입니다.

  • start() : 새 스레드를 시작합니다.

  • sleep(ms) : 현재 스레드를 잠시 재웁니다.

  • join() : 다른 스레드가 끝날 때까지 기다립니다.

  • interrupt() : "그만 기다리거나 멈출 준비를 해"라고 알리는 신호를 보냅니다.

예를 들어 join()은 메인 스레드가 작업 스레드의 종료를 기다리게 할 때 유용합니다.

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            for (int i = 1; i <= 3; i++) {
                System.out.println("작업 중... " + i);
            }
        });

        worker.start();
        worker.join();

        System.out.println("작업 완료 후 다음 코드 실행");
    }
}

join()이 없으면 메인 흐름이 먼저 끝나 버릴 수도 있습니다.

왜 문제가 생길까: 공유 자원과 경쟁 상태

멀티스레딩의 진짜 어려움은 여기서 시작됩니다. 여러 스레드가 같은 데이터를 동시에 읽고 수정하면 결과가 뒤죽박죽될 수 있습니다. 이런 상황을 경쟁 상태(race condition)라고 합니다.34

아래 코드는 얼핏 보면 아무 문제 없어 보입니다.

class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) counter.increment();
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) counter.increment();
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(counter.getCount());
    }
}

기대값은 2000이지만, 실제로는 더 작은 값이 나올 수 있습니다. 이유는 count++가 한 번에 끝나는 단일 동작이 아니기 때문입니다. 내부적으로는 대략 다음 순서로 진행됩니다.3

  1. 현재 값을 읽고

  2. 1을 더한 뒤

  3. 다시 저장합니다.

두 스레드가 같은 순간에 같은 값을 읽어 버리면, 둘 다 증가시켰다고 믿는데 실제 저장은 한 번만 반영될 수 있습니다. 마치 두 사람이 같은 장부를 동시에 펴서 같은 줄에 숫자를 적어 버리는 셈입니다.

synchronized로 한 번에 한 스레드만 통과시키기

공유 자원을 안전하게 다루려면 임계 구역(critical section)을 보호해야 합니다. 임계 구역은 여러 스레드가 동시에 들어오면 안 되는 코드 구간입니다. 자바에서 가장 기본적인 도구는 synchronized입니다.3

synchronized 메서드

class SafeCounter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

이제 한 번에 한 스레드만 increment() 안으로 들어갈 수 있습니다.

synchronized 블록

메서드 전체를 잠그는 대신 필요한 부분만 잠글 수도 있습니다.

class BankAccount {
    private int balance = 0;
    private final Object lock = new Object();

    public void deposit(int amount) {
        synchronized (lock) {
            balance += amount;
        }
    }

    public int getBalance() {
        synchronized (lock) {
            return balance;
        }
    }
}

이 방식은 잠금 범위를 더 세밀하게 조절할 수 있어서 불필요한 대기를 줄이는 데 도움이 됩니다.

또 하나 중요한 점이 있습니다. synchronized는 단순히 "한 명씩 들어오세요"만 보장하는 것이 아닙니다. 같은 락으로 보호된 코드 사이에서는 메모리 가시성도 보장해 줍니다. 즉, 한 스레드가 수정한 값을 다른 스레드가 더 신뢰할 수 있게 됩니다.34

하지만 무조건 많이 잠근다고 좋은 것은 아닙니다. 잠금이 너무 크면 병렬성이 줄고, 잘못 설계하면 교착 상태까지 생길 수 있습니다. 자물쇠는 필요할 때만, 짧게 거는 것이 좋습니다.

volatile과 AtomicInteger는 무엇이 다를까

처음 동시성을 배울 때 volatile을 보면 마치 마법처럼 느껴집니다. 하지만 volatile은 만능이 아닙니다.

volatile은 값의 최신 상태가 다른 스레드에도 보이도록 하는 키워드입니다. 즉, 가시성(visibility) 문제를 해결해 줍니다. 그러나 원자성(atomicity)까지 해결해 주지는 않습니다.3

다음 예시는 volatile이 잘 어울리는 경우입니다.

class TaskRunner {
    private volatile boolean running = true;

    public void stop() {
        running = false;
    }

    public void runTask() {
        while (running) {
            // 작업 수행
        }
    }
}

한 스레드가 running = false로 바꾸면, 다른 스레드는 그 변화를 볼 수 있습니다.

하지만 카운터처럼 증가 연산이 필요한 경우는 다릅니다. count++는 여전히 읽기-계산-쓰기의 여러 단계이므로 volatile만으로는 안전하지 않습니다. 이런 경우에는 AtomicInteger 같은 원자 클래스가 더 적합합니다.3

import java.util.concurrent.atomic.AtomicInteger;

class AtomicCounter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }
}

정리하면, volatile은 "최신 값을 보이게", AtomicInteger는 "연산까지 안전하게" 만들어 준다고 기억하면 좋습니다.

스레드를 직접 관리하지 말고 ExecutorService를 써 보자

초보 단계에서는 new Thread(...).start()가 이해하기 쉽습니다. 하지만 작업이 많아지면 매번 스레드를 만들고 관리하는 일 자체가 부담이 됩니다. 그래서 자바는 ExecutorService를 제공합니다. 이것은 작업 제출과 스레드 관리를 분리해 주는 고수준 API입니다.15

가장 큰 장점은 스레드 풀(thread pool)을 사용할 수 있다는 점입니다. 스레드를 미리 준비해 두고 재사용하면, 매번 새로 만드는 비용을 줄일 수 있습니다.56

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(3);

        for (int i = 1; i <= 5; i++) {
            int taskId = i;
            executor.submit(() -> {
                System.out.println("작업 " + taskId + " 실행: "
                        + Thread.currentThread().getName());
            });
        }

        executor.shutdown();
    }
}

여기서 newFixedThreadPool(3)은 작업이 5개여도 한 번에 최대 3개의 스레드만 사용합니다. 나머지 작업은 대기열에서 순서를 기다립니다.6

자주 보는 생성 방식은 다음과 같습니다.

  • newFixedThreadPool(n) : 고정된 개수의 스레드 사용

  • newSingleThreadExecutor() : 하나의 스레드로 순차 실행

  • newCachedThreadPool() : 필요하면 스레드를 늘리되 재사용도 함

  • newScheduledThreadPool(n) : 지연 실행이나 주기 실행 지원

중요한 습관이 하나 있습니다. 작업이 끝났다면 shutdown()을 호출해야 합니다. 그렇지 않으면 풀은 다음 작업을 기다리며 살아 있을 수 있고, 프로그램이 끝나지 않는 원인이 되기도 합니다.5

결과가 필요하면 Callable과 Future

Runnable은 결과를 반환하지 않습니다. 그래서 "계산은 백그라운드에서 하고, 끝나면 값을 받아 오고 싶다"면 Callable과 Future를 사용합니다.5

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newSingleThreadExecutor();

        Callable<Integer> task = () -> {
            Thread.sleep(1000);
            return 10 * 10;
        };

        Future<Integer> future = executor.submit(task);

        System.out.println("다른 일을 먼저 할 수 있음");

        Integer result = future.get();
        System.out.println("결과: " + result);

        executor.shutdown();
    }
}

future.get()은 결과가 준비될 때까지 기다립니다. 즉, 블로킹 호출입니다. 따라서 Future는 비동기 작업을 시작하게 해 주지만, 결과를 가져오는 시점에는 다시 기다릴 수 있다는 점을 기억해야 합니다.5

Callable과 Future를 쓰면 좋은 상황은 분명합니다. 파일 여러 개를 읽고 각 결과를 모으거나, 외부 API를 여러 군데 호출한 뒤 값을 합칠 때처럼 나중에 결과가 꼭 필요한 작업에 잘 어울립니다.

동시성 컬렉션을 사용하자

ArrayList, HashMap, HashSet은 기본적으로 멀티스레드 환경에 안전하지 않습니다. 한 스레드가 넣고 다른 스레드가 동시에 지우거나 순회하면 예상하지 못한 문제가 생길 수 있습니다. 이럴 때는 java.util.concurrent 패키지의 컬렉션을 고려해야 합니다.14

대표적인 예를 보겠습니다.

  • ConcurrentHashMap : 여러 스레드가 동시에 접근하는 맵에 적합

  • CopyOnWriteArrayList : 읽기는 많고 수정은 적은 리스트에 적합

  • BlockingQueue : 생산자-소비자 패턴에 유용

예를 들어 여러 작업 스레드가 공용 캐시를 업데이트해야 한다면 HashMap보다 ConcurrentHashMap이 더 안전한 선택입니다.

import java.util.concurrent.ConcurrentHashMap;

public class Main {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> scores = new ConcurrentHashMap<>();
        scores.put("java", 100);
        scores.merge("java", 10, Integer::sum);

        System.out.println(scores.get("java"));
    }
}

동시성 컬렉션을 쓴다고 해서 모든 문제가 사라지는 것은 아닙니다. 하지만 적어도 공용 자료구조 때문에 프로그램이 와르르 무너지는 일을 크게 줄여 줍니다.

가상 스레드: 자바 21이 들고 온 새로운 카드

자바 21부터는 가상 스레드(virtual thread)를 정식으로 사용할 수 있습니다. 이것은 동시성 세계에서 꽤 큰 변화입니다. 전통적인 자바 스레드, 즉 플랫폼 스레드(platform thread)는 운영체제 스레드와 밀접하게 연결되어 있어서 많이 만들수록 비용이 커집니다. 반면 가상 스레드는 JVM이 더 가볍게 관리할 수 있도록 설계되었습니다.7

중요한 포인트는 이것입니다. 가상 스레드는 더 빠른 스레드가 아니라, 더 많이 다룰 수 있는 스레드입니다. 즉, 속도 자체를 마법처럼 올려 준다기보다, 특히 I/O 대기 시간이 많은 서버 프로그램에서 처리량과 확장성을 높이는 쪽에 강합니다.7

예를 들어 네트워크 요청, 데이터베이스 조회, 파일 읽기처럼 대부분의 시간을 "기다리면서" 보내는 작업에는 가상 스레드가 아주 잘 어울립니다. 반대로 CPU를 계속 태우는 무거운 계산 작업은 가상 스레드가 특별히 유리하지 않습니다.7

간단한 예시는 이렇습니다.

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = Thread.ofVirtual().start(() -> {
            System.out.println("가상 스레드 실행: " + Thread.currentThread());
        });

        thread.join();
    }
}

또는 작업 단위마다 가상 스레드를 하나씩 쓰는 전용 실행기도 사용할 수 있습니다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    public static void main(String[] args) throws Exception {
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            executor.submit(() -> {
                System.out.println("작업 1: " + Thread.currentThread());
                return null;
            }).get();
        }
    }
}

문법은 익숙하지만, 내부의 확장성은 꽤 다릅니다. 그래서 앞으로 자바 서버 개발에서는 가상 스레드가 점점 더 중요한 기본기가 될 가능성이 큽니다.

어떤 상황에서 무엇을 쓰면 좋을까

처음엔 도구가 많아 보여도 기준은 생각보다 단순합니다.

  • 짧은 실험이나 학습용 예제라면 Thread를 직접 만들어도 괜찮습니다.

  • 일반적인 실무 작업 분배라면 ExecutorService가 기본 선택입니다.

  • 작업 결과를 받아야 한다면 Callable과 Future를 붙입니다.

  • 여러 스레드가 같은 자료구조를 건드린다면 동시성 컬렉션을 우선 검토합니다.

  • 대기 시간이 많은 대규모 I/O 작업이라면 가상 스레드를 고려합니다.

즉, 스레드를 많이 다룬다고 해서 항상 직접 new Thread()를 반복하는 것은 아닙니다. 자바는 이미 더 안전하고 편한 도구들을 준비해 두었습니다.

자주 하는 실수

동시성은 작은 실수도 크게 터집니다. 특히 아래 실수는 정말 자주 나옵니다.

  • start() 대신 run()을 직접 호출한다.

  • count++가 안전하다고 착각한다.

  • volatile 하나면 모든 문제가 해결된다고 믿는다.

  • ExecutorService를 만들고 shutdown()을 잊는다.

  • HashMap이나 ArrayList를 여러 스레드가 함께 써도 괜찮다고 생각한다.

  • 잠금을 너무 크게 잡아 성능을 떨어뜨린다.

  • 여러 락을 서로 다른 순서로 잡아 교착 상태를 만든다.

동시성 버그는 항상 재현되지 않아서 더 무섭습니다. 어제는 멀쩡했는데 오늘만 틀리고, 내 컴퓨터에서는 되는데 서버에서만 깨지는 일이 흔합니다. 그래서 더더욱 공유 자원을 줄이고, 검증된 도구를 사용하고, 잠금 범위를 최소화하는 습관이 중요합니다.

마무리

이번 장에서 배운 핵심은 분명합니다. 멀티스레딩은 프로그램을 더 빠르고 더 반응성 있게 만들 수 있지만, 그 대가로 조심스러운 설계가 필요합니다. 스레드를 만드는 법보다 더 중요한 것은 공유 자원을 어떻게 안전하게 다룰지, 그리고 직접 스레드를 만들지 않고 더 높은 수준의 도구를 언제 쓸지를 아는 일입니다.

이제 여러분은 다음 질문에 답할 수 있어야 합니다.

  • 스레드는 왜 필요한가?

  • synchronized는 무엇을 지켜 주는가?

  • volatile과 AtomicInteger는 어떻게 다른가?

  • 왜 ExecutorService가 편한가?

  • Future는 왜 미래의 결과라고 부르는가?

  • 가상 스레드는 언제 빛나는가?

이 감각만 제대로 잡혀도 자바 동시성의 반은 넘어온 셈입니다. 다음 장에서는 이 흐름을 더 깊게 확장해, JVM과 메모리, 성능이라는 더 본격적인 무대로 들어가 보겠습니다.

참고

1Lesson: Concurrency

2Java Concurrency and Multithreading – A Complete Guide with Examples

3Java concurrency multi-threading tutorial

4Java Learning Platform Concurrency Guide

5A Guide to the Java ExecutorService

6Executors API

7Virtual Threads

책 목록

처음부터 실무까지 단계별로 배우는 JAVA 학습

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기