티스토리 뷰

Java 서버 애플리케이션을 운영하다 보면, 동시성 문제는 언제나 “나중에” 모습을 드러냅니다. 코드가 처음 작성될 때는 정상적으로 동작하고, 테스트 환경에서도 특별한 문제가 보이지 않습니다. 그러나 트래픽이 증가하고, 배포와 재시작이 반복되며, 장애 상황이 겹치기 시작하면 그동안 숨겨져 있던 동시성 설계의 허점이 운영 문제로 이어집니다. 이 글에서는 Java 애플리케이션에서 동시성을 다룰 때 반드시 지켜야 할 몇 가지 핵심 원칙을, 이론이 아니라 실제로 문제가 되는 지점을 중심으로 설명합니다.

스레드 생성과 관리의 책임

동시성에서 가장 먼저 짚어야 할 부분은 스레드 생성과 관리의 책임입니다. Thread를 직접 생성해 사용하는 방식은 구현이 단순해 보이지만, 스레드의 생명주기를 누가 관리하는지 코드 수준에서 명확히 드러나지 않는다는 문제가 있습니다. 서버 애플리케이션에서는 요청 처리와 분리된 백그라운드 작업이 많고, 이 경우 스레드의 시작과 종료 책임이 코드 여기저기에 흩어지기 쉽습니다.

다음과 같은 코드는 실무에서 자주 발견됩니다.

public void startAsyncTask() {
    Thread t = new Thread(() -> {
        while (true) {
            doWork();
        }
    });
    t.start();
}



이 코드는 정상적으로 실행되지만, 중요한 질문에 답하지 못합니다. 이 스레드는 언제 종료되는지, 애플리케이션 종료 시 누가 이 스레드를 멈추는지, 실행 중 예외가 발생하면 어떻게 되는지가 코드에서 드러나지 않습니다. run() 내부에서 예외가 발생하면 스레드는 조용히 종료되고, 해당 작업이 더 이상 수행되지 않는다는 사실을 호출자는 알기 어렵습니다. 반대로 무한 루프 구조에서는 종료 신호가 전달되지 않는 한, 스레드가 JVM 종료 시점까지 살아남아 종료 지연을 유발합니다.

운영 환경에서는 이러한 코드가 다음과 같은 형태로 관측됩니다. 애플리케이션 종료 요청이 들어왔지만 JVM이 내려가지 않고 대기 상태로 남아 있습니다. 스레드 덤프를 보면 생성 지점이 불분명한 사용자 스레드가 남아 있고, 해당 스레드가 어떤 작업을 수행 중인지 추적하기 어렵습니다. 이 과정에서 파일 핸들, 소켓, 데이터베이스 커넥션과 같은 리소스가 함께 남아 리소스 누수로 이어집니다.

같은 작업을 ExecutorService 기반으로 구성하면 구조가 달라집니다. 이 말인 즉슨, 문제를 없애준다는 것이 아니라, 문제를 다룰 수 있는 구조를 제공한다는 것입니다. 
첫 번째 구조에서는 스레드 생성, 실행 시작, 종료 시점, 예외 처리, 종료 대기의 제어 지점이 모두 분산되었습니다. 첫번째 구조에서는 이 스레드가 왜 아직 살아있는지를 추적해야 한다면, 두번째 구조에서는 왜 종료되지 않았는지를 하나의 제어 지점에서 판단할 수 있습니다. 실무 관점에서, 첫번째 구조는 스레드가 시스템 밖으로 흘러나간다고 한다면, 두번째 구조는 스레드가 시스템 안에 묶인다고 말할 수 있습니다. 요지는, ExecutorService는 스레드를 잘 쓰게 해주는 도구라기보다는, 스레드를 시스템의 수명 주기 안에 가두는 도구라는 것입니다. 

ExecutorService executor = Executors.newFixedThreadPool(4);

public void submitTask() {
    executor.submit(() -> doWork());
}



이 구조의 핵심은 스레드를 직접 관리하지 않는다는 점이 아닙니다. 작업 제출과 실행 책임이 분리되면서, 스레드 수와 종료 시점을 하나의 제어 지점에서 통제할 수 있다는 점입니다. 현재 실행 중인 작업은 Executor 내부 큐 안에 있습니다. 일정 시간 내 종료되지 않으면 어떻게 할 것인지를 awaitTermination()으로 제어합니다. shutdown()과 shutdownNow()를 통해 종료 시점이 명확해지고, 애플리케이션 종료 로직에서 스레드 풀의 상태를 확인할 수 있습니다. 이 차이는 성능 최적화의 문제가 아니라, 장애 상황에서 시스템을 통제할 수 있는지의 문제입니다. 스레드 생명주기가 코드 구조로 드러나지 않는 설계는, 운영 환경에서 예측 불가능한 상태로 이어집니다. 다시 말해서, ExecutorService를 사용하더라도 인터럽트 처리, 종료 조건, 작업 설계를 제대로 하지 않으면 마찬가지로 운영 장애로 이어집니다. 



InterruptedException과 blocking API

다음으로 자주 문제가 되는 지점은 InterruptedException 처리입니다. 인터럽트는 오류 알림이 아니라, 스레드 취소와 종료를 위한 제어 신호입니다. sleep, wait, join, BlockingQueue와 같은 blocking API는 모두 인터럽트를 통해 대기 상태에서 깨어나도록 설계되어 있습니다.

sleep은 지정된 시간 동안 현재 스레드를 멈춥니다. wait는 모니터를 해제한 채 조건을 기다립니다. join은 다른 스레드의 종료를 기다립니다. BlockingQueue의 take 계열 메서드는 데이터가 들어올 때까지 무기한 대기합니다. 이들 메서드는 공통적으로, 인터럽트가 전달되면 즉시 대기 상태를 종료하고 InterruptedException을 발생시킵니다.

문제는 이 예외를 어떻게 다루느냐입니다. 실무에서 가장 자주 발견되는 형태는 다음과 같습니다.

while (true) {
    try {
        Runnable task = queue.take();
        task.run();
    } catch (InterruptedException e) {
        // 예외 무시
    }
}



이 코드는 표면적으로 문제가 없어 보입니다. 큐에 작업이 없으면 대기하고, 작업이 오면 처리합니다. 그러나 인터럽트가 전달되는 순간 실행 흐름을 따라가 보면 문제가 분명해집니다. 인터럽트는 take 호출을 깨우고, 예외는 catch 블록에서 소비됩니다. 이후 while 루프는 다시 시작되고, 스레드는 즉시 다음 take 호출로 들어갑니다. 상위에서 전달한 “이 작업을 중단하라”는 제어 신호는 여기서 완전히 사라집니다.

운영 환경에서는 이 코드가 다음과 같은 형태로 관측됩니다. 애플리케이션 종료 요청이 들어왔지만, 작업 스레드는 계속 대기 상태로 남아 있습니다. ExecutorService.shutdown()을 호출해도 스레드는 종료되지 않습니다. 결국 강제 종료에 의존하게 되고, 처리 중이던 작업은 중간 상태로 남습니다. 종료 지연, 스레드 풀 정체, 재기동 후 데이터 불일치가 함께 나타납니다.

같은 구조를 인터럽트 전파 원칙에 맞게 수정하면 실행 흐름이 달라집니다.

while (!Thread.currentThread().isInterrupted()) {
    try {
        Runnable task = queue.take();
        task.run();
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        break;
    }
}



이 코드에서 중요한 점은 두 가지입니다. 첫째, InterruptedException을 catch한 뒤 현재 스레드의 인터럽트 상태를 복원합니다. 둘째, 루프를 종료하여 실행 흐름을 명확히 끊습니다. 이로 인해 상위 호출자가 전달한 취소 신호는 스레드의 생명주기 종료로 이어집니다.

이 차이는 코드 스타일의 문제가 아닙니다. 전자는 “언제든 멈출 수 있는 구조”처럼 보이지만 실제로는 멈출 수 없습니다. 후자는 인터럽트를 제어 흐름의 일부로 받아들이고, 종료 시점을 코드 구조로 드러냅니다. 운영 환경에서 이 차이는 애플리케이션이 정상적으로 내려가는지, 강제 종료에 의존하는지의 차이로 나타납니다.

blocking API 자체가 문제인 것은 아닙니다. 문제는 이 호출이 어떤 스레드에서 실행되고, 인터럽트가 어떻게 전달되는지입니다. 인터럽트를 무시하는 blocking 호출은, 스레드를 멈추는 코드가 아니라 시스템을 멈추는 코드로 바뀝니다.



synchronized, Lock, volatile의 역할 구분

동기화 수단 선택 역시 단순한 문법 문제가 아닙니다. synchronized, Lock, volatile은 서로 다른 목적을 가진 도구입니다.

synchronized는 단일 객체의 불변 조건을 보호해야 할 때 적합합니다. 임계 구역이 명확하고, 락 범위가 단순한 경우에 사용합니다.

public class Counter {
    private int value;

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



이 코드는 value라는 상태에 대한 불변 조건이 명확합니다. 그러나 모든 접근을 무조건 synchronized로 감싸면 락 경합이 발생하고, 스레드 덤프에서 병목 지점이 명확히 드러나는 성능 저하로 이어집니다.

Lock은 락 획득과 해제를 명시적으로 제어해야 하는 경우에 사용합니다. 타임아웃이 필요하거나, 조건 변수 기반의 대기가 필요한 경우입니다.

Lock lock = new ReentrantLock();

public void update() {
    if (lock.tryLock()) {
        try {
            doUpdate();
        } finally {
            lock.unlock();
        }
    }
}



이 구조에서는 일정 시간 이상 대기하지 않고 실패를 선택할 수 있습니다. 이는 synchronized로 표현하기 어렵습니다. 반면 unlock() 누락 시 즉시 데드락으로 이어지므로, 사용 범위는 명확히 제한되어야 합니다.

volatile은 가시성만 필요할 때 사용합니다. 상태 플래그나 종료 신호 전달이 대표적인 사례입니다.

private volatile boolean running = true;

public void stop() {
    running = false;
}



이 경우 running 값의 변경은 즉시 다른 스레드에 보입니다. 그러나 증가 연산이나 복합 조건 검사는 보호되지 않습니다. 이를 상태 변경에 사용하면 경쟁 상태가 발생합니다.

중요한 것은 도구 선택이 아니라, 보호하려는 불변 조건이 무엇인지입니다. 이를 정의하지 않은 동기화 코드는 성능 문제나 데드락으로 운영 환경에서 되돌아옵니다. 이러한 구분은 Java Concurrency in Practice에서 일관되게 강조하는 전제와도 일치합니다.



blocking API와 처리량 저하

blocking API 사용에 대한 인식도 중요합니다. blocking 호출은 호출 스레드의 실행 흐름을 멈춥니다. 스레드 수가 제한된 구조에서 blocking 작업이 늘어나면, 처리량은 급격히 떨어집니다. 이는 단순히 느려지는 문제가 아니라, 정상 요청이 대기열에 쌓이며 연쇄적인 타임아웃을 만드는 구조적 문제입니다. blocking 호출을 사용할 때는, 이 호출이 어느 스레드에서 실행되는지, 그 스레드가 시스템 전체에서 어떤 역할을 하는지 명확해야 합니다. 이를 고려하지 않은 설계는, 부하 상황에서 급격한 품질 저하로 이어집니다.



작업 취소와 graceful shutdown

작업 취소와 graceful shutdown 설계는 동시성 코드의 완성도를 가르는 기준입니다. 정상 종료만 가정한 코드는 운영 환경을 고려하지 않은 코드입니다. 배포, 재시작, 장애 복구 과정에서는 실행 중인 작업을 중단해야 하는 상황이 반드시 발생합니다. 이때 취소 요청이 어떻게 전달되고, 작업이 어디에서 멈추는지 설계되어 있지 않다면, 시스템은 강제 종료에 의존하게 됩니다. 이는 리소스 누수와 데이터 손상으로 이어집니다. 취소와 종료는 부가 기능이 아니라, 동시성 설계의 핵심 요소입니다.



가상 스레드 환경에서도 변하지 않는 원칙

가상 스레드 환경에서도 이 원칙들은 변하지 않습니다. 가상 스레드는 스레드 수 제약을 완화하지만, 실행 흐름과 제어 신호의 의미를 바꾸지는 않습니다. 인터럽트, 취소, 동기화의 개념은 그대로 유지됩니다. 오히려 가상 스레드 환경에서는 문제가 더 늦게, 더 조용히 드러날 수 있습니다. 기존 동시성 원칙을 무시해도 된다는 해석은 위험합니다.



정리하면, Java 동시성 모범 사례는 문법이나 API 선택의 문제가 아닙니다. 어떤 선택이 실행 흐름을 어떻게 만들고, 그 결과가 운영 환경에서 어떻게 관측되는지를 이해하는 문제입니다. 스레드를 어떻게 생성하고, 인터럽트를 어떻게 전달하며, 상태를 어떻게 보호하는지는 모두 시스템의 통제 가능성과 직결됩니다. 지금 작성한 동시성 코드가 왜 그런 형태인지 설명할 수 없다면, 그 코드는 언젠가 운영 환경에서 문제로 돌아올 가능성이 큽니다. 동시성 코드는 “동작하는 코드”가 아니라, “통제 가능한 코드”여야 합니다.