왜 이걸 알아야 할까?

개발 중에 이런 상황을 겪어볼 수 있다.

  • 스레드가 고갈돼서 서버가 멈춘다.
  • @Async 붙였는데 오히려 느려졌다.
  • WebClient 썼는데 로그 순서가 뒤죽박죽이다.
  • ThreadLocal 데이터가 갑자기 사라진다.

이 문제들의 공통점은 "스레드" 이다.

 

내가 만든 코드가 실제로 어떤 스레드에서 돌아가고 있는지 모른다면, 성능파악과 튜닝도 어려울것이다.

 

그래서 HTTP 요청 하나당 실제로 몇 개의 스레드가 작동하는지 환경별로 한번 알아보자.

 

환경 설명
Spring MVC 기본 동기 처리(Tomcat)
Spring MVC + @Async 일부 작업 비동기 분리
Spring MVC + WebClient 외부 API 호출 시 비동기
WebFlux + Netty 전체 논블로킹, 이벤트 기반

 

 

요청 하나 = 스레드 하나?는 옛말?

보통 아래와 같이 생각한다.

 

이 구조는 맞다 그런데 여기에 다음이 붙는 순간 스레드가 복제된다.

  • @Async 사용
  • WebClient 비동기 콜백
  • 로깅 비동기 처리
  • Netty 기반 이벤트 루프

각 케이스를 로그와 함께 한번 알아보자.

 

 

1. [Spring MVC] 동기 방식

@RestController
public class TestController {

    @GetMapping("/sync")
    public String sync() {
        log.info("스레드: {}", Thread.currentThread().getName());
        return "ok";
    }
}

 

실행 결과 

 

  • Controller → Service → Repository → 응답까지 모두 하나의 워커 스레드(exec-4) 에서 작동한다.
  • 요청 하나 = 스레드 하나 (정확)

 

 

2. [Spring MVC + @Async] - 작업을 나눴더니 스레드가 늘어났다.

@Service
public class MyService {
    @Async
    public void doAsync() {
        log.info("비동기 스레드: {}", Thread.currentThread().getName());
    }
}

@GetMapping("/async")
public String async() {
    log.info("요청 스레드: {}", Thread.currentThread().getName());
    myService.doAsync();
    return "ok";
}

 

실행 결과

 

  • 요청은 exec-1 이 처리했다.
  • 내부 로직은 별도 스레드(task-1)로 넘어갔다.
  • 요청은 하나지만 2개의 스레드가 동작했다.

 

 

3. [Spring MVC + WebClient] - 응답을 기다리는 동안 다른 스레드가 개입

@GetMapping("/webclient")
public Mono<String> callApi() {
    log.info("요청 시작: {}", Thread.currentThread().getName());

    return webClient.get()
        .uri("http://localhost:8081/slow")
        .retrieve()
        .bodyToMono(String.class)
        .doOnNext(response -> log.info("응답 받은 스레드: {}", Thread.currentThread().getName()));
}

 

실행 결과 (Spring WebClinet + 비동기)

  • 시작은 exec-2 에서 진행했다.
  • WebClient는 내부적으로 다른 스레드 풀에서 응답을 처리했다.
  • 요청 하나에 최소 2개 이상의 스레드가 동작했다.

 

 

4. [Spring WebFlux + Netty] - 더 이상 스레드 흐름은 예측 불가

@GetMapping("/webflux")
public Mono<String> reactive() {
    log.info("요청 시작: {}", Thread.currentThread().getName());

    return Mono.just("응답")
        .delayElement(Duration.ofMillis(500))
        .doOnNext(data -> log.info("응답 처리 스레드: {}", Thread.currentThread().getName()));
}

 

실행 결과

  • 요청은 reactor-http-nio-* 에서 수신했다.
  • 응답은 완전히 다른 워커 스레드에서 처리했다 (parallel-*)
  • 1 요청에 최소 2개 이상 스레드, 심지어 바뀔 수도 있다.

 

정리해보자면

환경 요청당 스레드 수 특징
Spring MVC 1 예측 가능, 단순
MVC + @Async 2+ 스레드 분기
MVC + WebClient 2+ 응답 처리 별도
WebFlux 2 ~ n 스레드 다변화, 예측 어려움

 

스레드가 바뀌면 ThreadLocl에 저장한 값은 사라지고, 

로깅, 트랜잭션, MDC, 세션 등 "스레드 고정이 전제된 기능"이 깨질 수 있다.

Thread에 대해서 오래전에 정리했던 적이 있다. 하지만 일을 하다가.. 아직도 Thread에 대해서 잘모른다는 느낌이 들어서 더 자세하게 다시한번 정리를 하고자 한다.

 

https://codingstudy95.tistory.com/67

 

스레드

사전적 의미로 한 가닥의 실이라는 뜻으로 한가지 작업을 실행하기 위해 순차적으로 실행할 코드를 실처럼 이어놓았다고 해서 유래된 이름이다. 하나의 스레드는 하나의 코드 실행 흐름이므로

codingstudy95.tistory.com

 

 

스레드란 무엇인가!!?

자바에서 Thread(스레드)는 프로그램 내에서 동시에 실행되는 작업의 단위를 의미한다. 회사에서 여러 사람이 각자의 다른 일을 동시에 하는 것과 마찬가지로, 스레드는 하나의 프로그램 안에서 여러 작업을 동시에 처리할 수 있도록 해준다.

(예: 데이터 처리, 사용자 요청 처리, 파일 입출력 등)

  • 단일 스레딩 : 한 사람이 모든 일을 순서대로 처리하는 것 -> 모든 작업이 순차적으로 처리되므로, 하나의 작업이 오래 걸리면 다른 작업들도 지연된다.
  • 멀티스레딩 : 여러 사람이 동시에 각자 일을 분담해서 처리하는 것 -> 여러 스레드가 동시에 작업을 처리하여, 한 작업이 늦어도 다른 작업은 계속 진행될 수 있다.

 

 

그럼 자바에서는 왜 멀티스레딩이 필요할까?

 

식당에서 한 사람이 모든 요리를 한다면 주문이 많은 경우 오래 걸리겠지만, 여러 요리사가 동시에 각자 다른 요리를 준비하면, 음식이 빨리 준비되는것과 비슷하다.

 

사용자 경험 향상

한 번에 한 작업만 처리한다면, 사용자가 어떤 요청을 할 때마다 기다려야 한다. 하지만 멀티스레딩을 사용하면, 여러 작업이 동시에 처리되어 응답 속도가 빨라지고 사용자 경험이 개선된다.

 

자원 활용의 극대화

컴퓨터는 여러 CPU 코어를 가지고 있는데, 멀티스레딩을 통해 이 코어들을 동시에 사용할 수 있다. 즉, 컴퓨터의 능력을 최대한 활용하여 더 빠르고 효율적으로 작업할 수 있다.

 

 

자바에서 스레드를 만들어보자 자바에서는 스레드를 만드는 방법이 두 가지 있다.

 

1. Thread 클래스 상속하기

// MyThread.kt
class MyThread : Thread() {
    // run 메서드를 재정의하여 스레드가 실행할 작업을 정의합니다.
    override fun run() {
        // 스레드가 실행될 때, "Hello from MyThread!"를 5번 출력합니다.
        for (i in 1..5) {
            println("Hello from MyThread! - $i")
            // 잠깐 멈추는 시간 (1000밀리초 = 1초)
            Thread.sleep(1000)
        }
    }
}

fun main() {
    // MyThread 클래스의 인스턴스를 생성하고, start()를 호출하면 스레드가 실행됩니다.
    val thread = MyThread()
    thread.start()
}
  • MyThread는 Thread 클래스를 상속받아 만든 새로운 스레드 클래스이다.
  • run() 메서드 안에 스레드가 해야 할 일을 작성한다.
  • thread.start() 를 호출하면, 새로운 스레드가 시작되어 run() 메서드의 내용이 실행된다.

 

2. Runnable 인터페이스 구현하기

또 다른 방법은 Runnable 인터페이스를 구현하는 것이다. 이 방법은 클래스 상속의 제약을 피할 수 있다는 장점이 있다.

// MyRunnable.kt
class MyRunnable : Runnable {
    override fun run() {
        // 스레드가 실행될 때, "Hello from MyRunnable!"를 5번 출력합니다.
        for (i in 1..5) {
            println("Hello from MyRunnable! - $i")
            Thread.sleep(1000)
        }
    }
}

fun main() {
    // Runnable 인터페이스를 구현한 MyRunnable 인스턴스를 Thread에 전달하여 실행합니다.
    val runnable = MyRunnable()
    val thread = Thread(runnable)
    thread.start()
}
  • MyRunnable 은 Runnable 인터페이스를 구현하여, run() 메서드 안에 작업 내용을 정의한다.
  • 이 객체를 Thread 생성자에 넘겨주고, start() 를 호출하면 스레드가 실행된다.

 

스레드의 생명주기와 상태

자바 스레드는 여러 상태를 가진다. 각 상태는 스레드가 어떤 작업을 하고 있는지를 나타낸다.

 

  • New: 스레드가 생성되었지만 아직 실행되지 않은 상태
  • Runnable: 실행 중이거나 실행 준비가 된 상태
  • Blocked/Waiting: 다른 스레드에 의해 잠시 멈춰 있는 상태
  • Timed Waiting: 일정 시간 후에 다시 실행될 상태
  • Terminated: 스레드의 작업이 모두 끝난 상태

한 사람이 일어나서 출근 준비를 하는 것처럼, 스레드도 만들어진 후 실행 준비, 작업 중, 대기, 그리고 작업 종료의 과정을 거친다.

 

 

 

근데 문제가 발생할 수 있다. 멀티스레딩에서 여러 스레드가 동시에 같은 데이터를 수정하려 할 때 문제가 발생할 수 있다.

이를 경쟁 조건 (Race Condition) 이라고 하며, 이를 해결하기 위해 동기화(Synchronization) 를 사용한다.

 

예를 들어 콘서트 티켓을 예매할때 한 좌석을 동시에 두명이 예매하려고 할때 좌석을 누구에게 할당해야 할까?  이런 문제를 해결하려면, 한 사람이 작업을 끝낼 때까지 기다리도록 해야 한다.

 

 

synchronized 키워드 사용 예제

class Counter {
    var count: Int = 0

    // synchronized를 사용해 여러 스레드가 동시에 count를 수정하지 않도록 보호합니다.
    @Synchronized
    fun increment() {
        count++
    }
}

fun main() {
    val counter = Counter()
    val threads = mutableListOf<Thread>()

    // 10개의 스레드를 생성하여 동시에 increment()를 호출합니다.
    for (i in 1..10) {
        val thread = Thread {
            for (j in 1..1000) {
                counter.increment()
            }
        }
        threads.add(thread)
        thread.start()
    }

    // 모든 스레드가 끝날 때까지 대기합니다.
    threads.forEach { it.join() }

    // 10개의 스레드가 각각 1000번씩 increment했으므로, 최종 결과는 10000이어야 합니다.
    println("최종 count: ${counter.count}")  // 결과: 10000
}
  • @Synchronized 어노테이션을 사용해 increment() 메서드에 동시에 접근하는 것을 막는다.
  • 여러 스레드가 동시에 increment() 를 호출해도, 동기화 덕분에 안전하게 실행된다.   

 

 

내가 회사에서 일하면서 실제로 겪은 스레드 문제가 있다.

 

경쟁 조건과 데드락

  • 경쟁조건 : 여러 스레드가 동시에 데이터를 수정할 때 예상치 못한 결과가 발생하는것
  • 데드락(DeadLock) : 두 스레드가 서로 상대방이 가진 자원을 기다리면서 무한 대기에 빠지는 상황이다.

동기화 블록이나 Lock 객체를 사용해, 자원에 접근하는 순서를 잘 관리해야 한다. 

 

실제로 데드락에 빠지는 로직을 개발하여 정말..난리 난리가 났었던...일이...후..

 

 

스레드 풀 (Thread Pool) 

매번 새로운 스레드를 생성하는 대신, 미리 일정 개수의 스레드를 만들어 두고 재사용하는 방법. 스레드의 생성 비용을 줄이고, 시스템 자원을 효율적으로 사용할 수 있다.

import java.util.concurrent.Executors

fun main() {
    // 고정 크기의 스레드 풀 생성 (3개의 스레드)
    val executor = Executors.newFixedThreadPool(3)

    // 10개의 작업을 스레드 풀에 제출합니다.
    for (i in 1..10) {
        executor.submit {
            println("작업 $i 시작: ${Thread.currentThread().name}")
            Thread.sleep(1000)
            println("작업 $i 완료: ${Thread.currentThread().name}")
        }
    }

    // 스레드 풀 종료
    executor.shutdown()
}
  • Executors.newFixedThreadPool(3)를 통해 3개의 스레드로 이루어진 풀을 생성한다.
  • 여러 작업이 동시에 제출되지만, 동시에 최대 3개 작업만 실행되고 나머지는 대기한다.

사용자 관점에서 한번 생각해보자

 

대부분의 웹 애플리케이션에서는 사용자가 데이터를 수정하고 저장을 누르면, 스레드 풀(Thread Pool) 에서 미리 만들어진 스레드 중 하나가 해당 요청을 처리한다.

 

즉, 사용자가 콘텐츠를 수정하고 저장 버튼을 클릭하면

  1. 웹 서버는 이미 생성되어 대기 중인 스레드 풀에서 하나의 스레드를 할당한다
  2. 해당 스레드가 수정 작업 로직을 실행하고
  3. 작업이 완료되면 그 스레드는 스레드 풀로 돌아가 재사용된다.

이제 스레드에 대해 확실히 알게 된 것 같다.

 

 

 

'Back-End > Java' 카테고리의 다른 글

null 체크, try-catch가 난무하는 코드 수정해보기  (6) 2025.06.09
HTTP 요청 하나당 스레드는 몇 개나 동작할까?  (0) 2025.05.12
try-catch  (0) 2024.07.06
스레드  (1) 2024.07.05
프로세스  (0) 2024.07.04

+ Recent posts