Java

Java 동시성 프로그래밍 완벽 가이드 - synchronized부터 Virtual Threads까지

백엔드 개발자 김승원 2026. 3. 30. 13:30

들어가며

멀티코어 프로세서가 보편화된 현대 환경에서 동시성 프로그래밍은 Java 개발자에게 필수 역량입니다. 단순히 Thread를 생성하는 것을 넘어, 안전하고 효율적인 동시성 코드를 작성하려면 Java가 제공하는 다양한 동기화 메커니즘을 깊이 이해해야 합니다. 이 글에서는 가장 기본적인 synchronized부터 Java 21의 Virtual Threads까지, 실무에서 반드시 알아야 할 동시성 도구들을 체계적으로 정리합니다.

1. synchronized - 가장 기본적인 동기화

synchronized는 Java의 내장 동기화 메커니즘으로, 모니터 락(Monitor Lock)을 기반으로 동작합니다. 메서드 또는 블록 단위로 적용할 수 있으며, 한 번에 하나의 스레드만 임계 영역에 진입할 수 있도록 보장합니다.

public class Counter {
    private int count = 0;

    // 메서드 레벨 동기화
    public synchronized void increment() {
        count++;
    }

    // 블록 레벨 동기화 - 더 세밀한 제어 가능
    public void incrementWithBlock() {
        synchronized (this) {
            count++;
        }
    }

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

synchronized의 한계점:

  • 락 획득 시 타임아웃을 설정할 수 없어 무한 대기가 발생할 수 있습니다.
  • 읽기/쓰기 락을 구분할 수 없어, 읽기 작업이 많은 경우 불필요한 대기가 발생합니다.
  • 공정성(fairness)을 보장하지 않아 특정 스레드가 계속 락을 획득하지 못하는 기아(starvation) 현상이 발생할 수 있습니다.

2. ReentrantLock - 유연한 명시적 락

java.util.concurrent.locks.ReentrantLock은 synchronized의 한계를 극복한 명시적 락입니다. 타임아웃, 인터럽트 가능, 공정성 설정 등 다양한 기능을 제공합니다.

import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.TimeUnit;

public class BoundedBuffer<T> {
    private final Object[] items;
    private int putIdx, takeIdx, count;

    private final ReentrantLock lock = new ReentrantLock(true); // 공정성 보장
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    public BoundedBuffer(int capacity) {
        items = new Object[capacity];
    }

    public void put(T item) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length) {
                notFull.await(); // 버퍼가 가득 차면 대기
            }
            items[putIdx] = item;
            if (++putIdx == items.length) putIdx = 0;
            count++;
            notEmpty.signal(); // 소비자에게 알림
        } finally {
            lock.unlock(); // 반드시 finally에서 해제
        }
    }

    @SuppressWarnings("unchecked")
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) {
                notEmpty.await(); // 버퍼가 비면 대기
            }
            Object item = items[takeIdx];
            if (++takeIdx == items.length) takeIdx = 0;
            count--;
            notFull.signal(); // 생산자에게 알림
            return (T) item;
        } finally {
            lock.unlock();
        }
    }
}

tryLock()을 사용하면 락 획득에 타임아웃을 설정하여 데드락을 예방할 수 있습니다.

public boolean transferMoney(Account from, Account to, long amount) {
    long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
    while (true) {
        if (from.lock.tryLock()) {
            try {
                if (to.lock.tryLock()) {
                    try {
                        from.debit(amount);
                        to.credit(amount);
                        return true;
                    } finally {
                        to.lock.unlock();
                    }
                }
            } finally {
                from.lock.unlock();
            }
        }
        if (System.nanoTime() > deadline) return false; // 타임아웃
        Thread.sleep(1); // 잠시 대기 후 재시도
    }
}

3. Atomic 클래스 - 락 없는 원자적 연산

java.util.concurrent.atomic 패키지는 CAS(Compare-And-Swap) 연산을 활용하여 락 없이도 원자적 연산을 수행합니다. 락 기반 동기화보다 경합이 적은 상황에서 훨씬 높은 성능을 보입니다.

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.LongAdder;

public class AtomicExamples {

    // AtomicLong - 단순 카운터
    private final AtomicLong requestCount = new AtomicLong(0);

    public void handleRequest() {
        long currentCount = requestCount.incrementAndGet();
        // requestCount는 여러 스레드에서 안전하게 증가
    }

    // LongAdder - 높은 경합 상황에서 AtomicLong보다 우수한 성능
    private final LongAdder highContentionCounter = new LongAdder();

    public void highThroughputCount() {
        highContentionCounter.increment(); // 내부적으로 셀 분할
        long total = highContentionCounter.sum(); // 전체 합산
    }

    // AtomicReference - 락 없는 스택 구현
    private final AtomicReference<Node<String>> top = new AtomicReference<>();

    public void push(String value) {
        Node<String> newHead = new Node<>(value);
        Node<String> oldHead;
        do {
            oldHead = top.get();
            newHead.next = oldHead;
        } while (!top.compareAndSet(oldHead, newHead)); // CAS 재시도
    }

    static class Node<T> {
        final T value;
        Node<T> next;
        Node(T value) { this.value = value; }
    }
}

Atomic 클래스 선택 가이드: 단순 카운터에는 AtomicLong, 높은 경합 상황의 카운터에는 LongAdder, 객체 참조의 원자적 교체에는 AtomicReference를 사용하세요.

4. CompletableFuture - 비동기 프로그래밍의 핵심

CompletableFuture는 Java 8에서 도입된 강력한 비동기 프로그래밍 도구입니다. 콜백 체이닝, 조합, 예외 처리 등 복잡한 비동기 워크플로우를 선언적으로 구성할 수 있습니다.

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

public class OrderService {
    private final ExecutorService executor = Executors.newFixedThreadPool(10);

    public CompletableFuture<OrderResult> processOrder(Long orderId) {
        // 여러 비동기 작업을 병렬로 실행
        CompletableFuture<User> userFuture = CompletableFuture
            .supplyAsync(() -> userService.findById(orderId), executor);

        CompletableFuture<Inventory> inventoryFuture = CompletableFuture
            .supplyAsync(() -> inventoryService.check(orderId), executor);

        CompletableFuture<Double> priceFuture = CompletableFuture
            .supplyAsync(() -> pricingService.calculate(orderId), executor);

        // 세 결과를 조합
        return userFuture
            .thenCombine(inventoryFuture, (user, inventory) -> {
                if (!inventory.isAvailable()) {
                    throw new RuntimeException("재고 부족");
                }
                return new UserInventory(user, inventory);
            })
            .thenCombine(priceFuture, (ui, price) ->
                new OrderResult(ui.user(), ui.inventory(), price)
            )
            .exceptionally(ex -> {
                log.error("주문 처리 실패: {}", ex.getMessage());
                return OrderResult.failed(ex.getMessage());
            });
    }

    // 여러 API를 동시에 호출하고 가장 빠른 결과 사용
    public CompletableFuture<ExchangeRate> getExchangeRate() {
        return CompletableFuture.anyOf(
            CompletableFuture.supplyAsync(() -> apiA.getRate()),
            CompletableFuture.supplyAsync(() -> apiB.getRate()),
            CompletableFuture.supplyAsync(() -> apiC.getRate())
        ).thenApply(result -> (ExchangeRate) result);
    }
}

5. Virtual Threads (Java 21) - 경량 스레드의 혁명

Java 21에서 정식 도입된 Virtual Threads는 기존 플랫폼 스레드의 한계를 극복합니다. OS 스레드와 1:1 매핑되는 플랫폼 스레드와 달리, Virtual Thread는 JVM이 관리하는 경량 스레드로 수백만 개까지 생성할 수 있습니다.

import java.util.concurrent.Executors;
import java.util.concurrent.StructuredTaskScope;
import java.util.List;
import java.util.stream.IntStream;

public class VirtualThreadExamples {

    // 기본 Virtual Thread 생성
    public void basicVirtualThread() {
        Thread vThread = Thread.ofVirtual()
            .name("worker-", 0)
            .start(() -> {
                System.out.println(Thread.currentThread());
                // I/O 블로킹 작업도 효율적으로 처리
            });
    }

    // Virtual Thread 기반 ExecutorService
    public void handleMassiveRequests() {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            // 10만 개의 동시 작업도 문제없이 처리
            IntStream.range(0, 100_000).forEach(i ->
                executor.submit(() -> {
                    String result = callExternalApi(i);
                    saveToDatabase(result);
                    return result;
                })
            );
        } // try-with-resources로 자동 종료 대기
    }

    // Spring Boot에서 Virtual Threads 활성화 (application.yml)
    // spring:
    //   threads:
    //     virtual:
    //       enabled: true
}

Virtual Threads 사용 시 주의사항:

  • synchronized 블록 내에서 블로킹 I/O를 수행하면 캐리어 스레드가 고정(pinning)됩니다. 이 경우 ReentrantLock으로 대체하세요.
  • CPU 집약적 작업에는 Virtual Thread의 이점이 없습니다. I/O 바운드 작업에 사용하세요.
  • ThreadLocal 사용을 최소화하세요. 수백만 개의 Virtual Thread가 각각 ThreadLocal을 가지면 메모리 문제가 발생합니다.

6. 데드락 방지 전략

데드락은 두 개 이상의 스레드가 서로가 가진 락을 기다리며 영원히 블로킹되는 현상입니다. 실무에서 데드락을 예방하는 핵심 전략을 알아봅시다.

// 전략 1: 락 순서 고정 (Lock Ordering)
public class DeadlockPrevention {

    // 항상 ID가 작은 계좌부터 락을 획득
    public void transfer(Account a, Account b, long amount) {
        Account first = a.getId() < b.getId() ? a : b;
        Account second = a.getId() < b.getId() ? b : a;

        synchronized (first) {
            synchronized (second) {
                a.debit(amount);
                b.credit(amount);
            }
        }
    }
}

// 전략 2: tryLock + 타임아웃
public boolean safeTransfer(Account a, Account b, long amount)
        throws InterruptedException {
    boolean aLocked = false, bLocked = false;
    try {
        aLocked = a.getLock().tryLock(1, TimeUnit.SECONDS);
        bLocked = b.getLock().tryLock(1, TimeUnit.SECONDS);
        if (aLocked && bLocked) {
            a.debit(amount);
            b.credit(amount);
            return true;
        }
    } finally {
        if (aLocked) a.getLock().unlock();
        if (bLocked) b.getLock().unlock();
    }
    return false; // 락 획득 실패 시 재시도 또는 에러 처리
}

정리: 상황별 도구 선택 가이드

상황 추천 도구 이유
간단한 임계 영역 보호 synchronized 코드 간결, JVM 최적화
타임아웃, 공정성 필요 ReentrantLock 유연한 제어 가능
단순 카운터/플래그 Atomic 클래스 락 없이 고성능
비동기 워크플로우 CompletableFuture 선언적 비동기 조합
대량 I/O 바운드 작업 Virtual Threads 경량, 높은 동시성

동시성 프로그래밍에서 가장 중요한 원칙은 가능한 한 공유 가변 상태를 줄이는 것입니다. 불변 객체를 사용하고, 상태를 공유해야 한다면 적절한 동기화 도구를 선택하세요. Java의 동시성 도구는 계속 발전하고 있으며, 프로젝트의 요구사항에 맞는 도구를 선택하는 것이 핵심입니다.