Spring

Spring Boot 배포 시 스레드 개수에 대한 궁금증 정리

무호흡냥냥펀치 2024. 12. 18. 23:29

Jar 파일을 리눅스에서 실행하면 스레드 수는?

  • OS 스레드와 JVM 스레드가 매핑되어서 생성된다
  • 이를 Native Thread Implementation 또는 1:1 Threading Model이라고 한다

Spring Boot의 스레드 풀

  • 기본적으로 내장 Tomcat을 사용할 경우 Thread Pool이 생성된다
  • Default로 최대 200개의 스레드를 가질 수 있다
  • 각 요청은 이 스레드 풀의 워커 스레드에 의해 처리된다

질문 1.

Docker Image로 Container를 띄우면 이 Container는 실행되는 OS의 프로세스와 스레드를 할당받는 걸로 아는데 맞아?

  1. 컨테이너와 프로세스 관계
    • Docker 컨테이너는 호스트 OS의 격리된 프로세스 그룹으로 실행된다
    • Linux의 namespaces와 cgroups를 사용해 프로세스를 격리하고 자원을 제한한다
    • 실제로는 호스트 OS의 커널을 공유한다
  2. Spring Boot 애플리케이션의 경우
    • 컨테이너 안에서 실행되는 JVM도 호스트 OS의 프로세스이다
    • JVM의 스레드는 여전히 호스트 OS의 네이티브 스레드로 매핑된다
    • 다만 컨테이너의 자원 제한(CPU, 메모리 등)의 영향을 받는다
  3. 확인 방법
bash
Copy
# 호스트 OS에서 컨테이너 프로세스 확인
docker top [container-id]

# 컨테이너 내부의 프로세스 확인
docker exec [container-id] ps -ef

# 스레드 확인
docker exec [container-id] ps -eLf

  1. 컨테이너 구조
    • 각 컨테이너는 자체 애플리케이션, 라이브러리, 런타임을 포함
    • 컨테이너들은 서로 격리되어 있음
  2. Namespace 격리
    • PID Namespace: 프로세스 ID 격리
      • 컨테이너 내부에서는 PID1부터 시작하지만 호스트 시스템에서는 PID 1234 등을 가질 수 있다
    • Network Namespace: 네트워크 스택 격리
      • 각 컨테인너에 독립적인 네트워크 스택을 제공한다
        • 네트워크 인터페이스들
        • IP 주소
        • 라우팅 테이블
        • 포트 번호
        • iptables 규칙
      • 컨테이너가 생성될 때 도커는 veth(virtual ethernet) 쌍을 생성한다
      • 한쪽 끝(veth블라블라)은 호스트의 네트워크 네임스페이스에 존재
      • 다른 쪽 끝(eth0)은 컨테이너의 네트워크 네임스페이스에 존재
      • 이 두 인터페이스는 파이프처럼 연결되어 트래픽을 전달한다

  • 포트 매핑
    • -p 8080:80

  • Mount Namespace: 파일시스템 마운트 격리
  • User Namespace: 사용자/그룹 ID 격리

3. 리소스 제어 (cgroups)

  • CPU 사용량 제어
  • 메모리 사용량 제어
  • I/O 사용량 제어

4. 커널 공유

  • 모든 컨테이너는 동일한 호스트 OS 커널을 공유
  • 커널을 통해 시스템 리소스에 접근


질문 2.

Kotlin Spring Boot일 때 Coroutine이 쓰는 Dispatchers Pool의 스레드도 OS 스레드인지?

  1. Dispatchers의 스레드 풀
    • Dispatchers.Default: CPU 코어 수에 비례한 크기의 스레드 풀 사용 (최소 2개, 최대 CPU 코어 수+1)
    • Dispatchers.IO: 공유 스레드 풀 사용 (기본 64개 스레드까지)
    • 이 모든 스레드들은 실제 OS 네이티브 스레드이다
  2. Coroutine과 스레드의 차이점
    • Coroutine은 경량 스레드(lightweight thread)라고 불리지만, 실제로는 스레드 위에서 실행되는 작업 단위이다
    • 한 스레드에서 여러 코루틴이 실행될 수 있다 (M:N 관계)
    • 코루틴 간의 전환은 스레드 전환보다 훨씬 가벼운 작업이다

질문 3.

어떤 Coroutine Scope이 실행될 때 사용할 Thread 종류를 할당하고 그 Scope 안에서 코루틴의 전환이 스레드 전환 콘텍스트 비용보단 저렴하다는 건지?

  1. Coroutine Scope와 Dispatcher
kotlin
Copy
// Dispatcher를 통해 어떤 스레드 풀을 사용할지 결정
CoroutineScope(Dispatchers.Default).launch {
// 이 스코프 내의 모든 코루틴은 Default 스레드 풀을 사용

// 코루틴 간의 전환 (suspend 함수 호출 등)은 매우 가벼움
    val result1 = async { heavyComputation() }
    val result2 = async { anotherComputation() }
}
  1. 컨텍스트 전환 비용 비교
    • 스레드 전환: OS 수준의 콘텍스트 스위칭 발생 (비용이 비쌈)
        • 프로그램 카운터(PC): 다음에 실행할 명령어의 주소
        • 스택 포인터(SP): 현재 스택의 top 위치
        • 범용 레지스터: 데이터 임시 저장
        • 상태 레지스터: CPU 상태 정보CPU 레지스터 상태 저장
1. ThreadA 실행 중
       a. Thread A의 레지스터 값을 메모리에 저장(PCB에 저장)
            i. 프로그램 카운터 값
            ii. 스택 포인터 값
            iii. 기타 레지스터 값들
2. ThreadB의 레지스터 값을 메모리에서 로드
3. ThreadB 실행 시작
PCB란?

- Process Control Block 운영체제가 프로세스를 관리하기 위해서 프로세스마다 유지하는 정보 구조체 즉, 메타데이터이다
- 주요 정보
    1.  프로세스 관리 정보
        - Process ID (PID)
        - 프로세스 상태 (실행, 준비, 대기 등)
        - 프로세스 우선순위
        - 프로그램 카운터 값
        - CPU 레지스터들의 값
        - CPU 스케줄링 정보
     2. 메모리 관리 정보
        - 메모리 할당 정보 (메모리 경계, 크기)
        - 페이지 테이블 정보
        - 세그먼트 테이블 정보
    3. 파일 관리 정보
        - 열린 파일 목록
        - 파일 디스크립터
        - 현재 디렉토리 정보
    4. I/O 상태 정보
        - 할당된 I/O 장치 목록
        - 열린 파일 정보
Process A 실행 중

컨텍스트 스위치 발생

Process A의 상태를 PCB에 저장

Process B의 PCB 정보를 로드

Process B 실행
  • 메모리 맵 전환
    • 콘텍스트 스위칭 시 가상 메모리 주소 공간의 전환을 의미한다

메모리 맵 전환의 비용

    - TLB 무효화로 인한 페이지 테이블 워크 증가
            - TLB는 가상 메모리 주소를 물리적 메모리 주소로 변환하는 정보를 캐싱하는 하드웨어 캐시이다
    - 페이지 폴트 가능성 증가
            - 가상 메모리의 주소에 해당하는 페이지가 물리 메모리에 없는 경우
    - 캐시 미스 증가
    - 메모리 접근 시간 증가
  • CPU 캐시 무효화 가능성
    • CPU 캐시에 저장된 데이터가 더 이상 유효하지 않은 현상
1. ThreadA가 실행 중
    a. CPU 캐시 - A의 데이터로 채워짐
2. 컨텍스트 스위칭 발생
3. ThreadB로 전환
    a. CPU 캐시 - A의 데이터는 더 이상 유효하지 않음
    b. B의 데이터로 새로 채워야 함 (캐시 미스 발생)
  • 코루틴 전환:
    • 같은 스레드 내에서 발생
    • 단순히 실행 상태와 스택 정보만 저장
    • 메모리 접근이 적고 가벼움
fun example(continuation: ExampleContinuation) {
    // 상태 확인
    when(continuation.label) {
        0 -> {
            // 첫 실행
            continuation.x = 10
            continuation.label = 1  // 다음 상태 설정
            delay(1000, continuation)
        }
        1 -> {
            // 첫 번째 중단 후 재개
            val x = continuation.x  // 저장된 상태 복원
            println(x)
            continuation.label = 2  // 다음 상태 설정
            delay(2000, continuation)
        }
        2 -> {
            // 두 번째 중단 후 재개
            val x = continuation.x
            println(x + 1)
        }
    }
}

이런 구조 덕분에 코루틴으로 비동기 프로그래밍을 할 때 많은 동시성 작업을 효율적으로 처리할 수 있다


질문 4.

Spring Boot 실행 시 사용할 Thread Pool의 Max Size를 200으로 잡아도 비동기용 스레드 그리고 Coroutine용 스레드 이게 다 따로 생성되는지?

  1. Spring Boot의 요청 처리용 스레드 풀 (Tomcat Thread Pool)
kotlin
Copy
server.tomcat.threads.max=200// application.properties
  1. Spring의 비동기 처리용 스레드 풀 (@Async)
kotlin
Copy
@Configuration
class AsyncConfig : AsyncConfigurer {
    @Bean
    fun asyncExecutor(): Executor {
        val executor = ThreadPoolTaskExecutor()
        executor.corePoolSize = 10
        executor.maxPoolSize = 50
        executor.queueCapacity = 100
        executor.setThreadNamePrefix("Async-")
        return executor
    }
}
  1. Coroutine의 Dispatchers 스레드 풀
kotlin
Copy
// Default dispatcher는 CPU 코어 수에 기반한 스레드 풀// IO dispatcher는 기본 64개까지의 스레드

coroutineScope {
    launch(Dispatchers.IO) {
// IO 작업용 스레드 풀 사용
    }
}

즉, 각각은 독립적인 스레드 풀을 사용합니다:

  • Tomcat의 200개는 HTTP 요청 처리용
  • @Async용 스레드 풀은 별도로 설정한 개수만큼
  • Coroutine dispatcher도 별도의 스레드 풀 사용

총 사용되는 스레드 수는 이들의 합이 된다. 따라서 서버의 전체 리소스를 고려해서 각 풀의 크기를 적절히 조정해야 한다. 특히 Docker 컨테이너 환경이라면 더욱 신중한 설정이 필요하다.

 


질문 5.

Docker에서 Jar 실행할 때 Thread Pool Max Size가 200개고, 비동기 2개, 그리고 코루틴 스레드는 숫자 설정이 없는 상태라면 코루틴 스레드 할당되는 공식은?

  1. Docker 기본 설정
    • 제한을 걸지 않으면 호스트 시스템의 리소스를 공유해서 사용
    • CPU 코어도 호스트의 모든 코어를 사용 가능
  2. 각 스레드 풀의 상태
    • Tomcat Thread Pool: 최대 200개 생성 가능
    • 비동기 처리용: 2개
    • Coroutine Dispatchers:
      • Dispatchers.Default: min(코어 수 * 2, 128) 개 스레드
      • Dispatchers.IO: 64개까지 생성 가능

예를 들어 8코어 시스템이라면:

  • Tomcat: 최대 200개
    • 비동기: 2개
    • Coroutine Default: 16개(8 * 2)
    • Coroutine IO: 필요에 따라 최대 64개

즉, 이론적으로는 최대 200 + 2 + 16 + 64 = 282개의 스레드가 생성될 수 있다.

하지만 실제로는

  • 모든 스레드가 동시에 최대치까지 생성되지는 않음
  • 필요에 따라 동적으로 생성되고 제거됨
  • Coroutine의 경우 스레드 재사용이 효율적으로 이루어짐

질문 6.

이미 컨테이너가 하나 떠있는 상태에서 똑같은 환경의 컨테이너를 하나 더 띄운 경우 리소스는?

컨테이너 1:

  • Tomcat: 최대 200개
  • 비동기: 2개
  • Coroutine Default: 16개(8 코어 기준)
  • Coroutine IO: ~64개

컨테이너 2:

  • 위와 동일한 구성으로 별도 생성

즉, 이론적으로는 두 컨테이너 합쳐서 최대 564개(282 * 2)의 스레드가 생성될 수 있다.

하지만 여기서 중요한 점은:

  1. 두 컨테이너가 같은 호스트의 CPU 자원을 공유한다
  2. 각 컨테이너는 독립적으로 동작하지만, 결국 같은 호스트 OS의 리소스를 사용한다
  3. 리소스 경합이 발생할 수 있어 실제 성능은 떨어질 수 있게 된다

이런 상황에서는 Docker의 리소스 제한을 설정하는 것이 좋다

yaml
Copy
# docker-compose.yml 예시
services:
  app1:
    cpus: '4'
    pids_limit: 150
  app2:
    cpus: '4'
    pids_limit: 150

이렇게 각 컨테이너의 리소스를 명시적으로 제한하면 더 예측 가능한 동작을 기대할 수 있습니다.


질문 7.

서버의 성능을 최대로 뽑아먹으려면 큰 서버에 여러 컨테이너 vs 여러 작은 서버에 각 컨테이너

하나의 서버에 여러 컨테이너 장점

  1. 리소스 공유와 활용이 효율적
  2. 관리가 단순
  3. 비용 효율적
  4. 컨테이너 간 통신이 빠름(같은 호스트 내이기 때문에)

여러 서버에 분산

  1. 장애 격리가 잘됨(한 서버의 문제가 다른 서버에 영향 없음)
  2. 개별 서비스 스케일링이 더 쉬움
  3. 리소스 경합이 덜함
  4. 하드웨어 장애 시 일부 서비스만 영향

따라서 상황에 맞게 결정하는 게 좋다

  1. 리소스 사용 패턴이 서로 보완적인 서비스들은 같은 서버에 두는 게 효율적
  2. 중요도가 높거나 리소스를 많이 사용하는 서비스는 별도 서버에 분리
  3. 트래픽 패턴이 예측 가능하다면 적절히 그룹화

질문 8.

쿠버네티스 마스터 서버 한대랑 노드 서버 3대가 있는 상태에서 서버 트래픽 양 때문에 서버 스케일 아웃을 한다면 있는 서버 내에서 스케일 아웃해야 해서 한 서버에 여러 컨테이너가 올라가는지?

쿠버네티스 환경에서 스케일 아웃을 할 때는 크게 두 가지 상황이 발생할 수 있다.

  1. 현재 노드(서버)들의 리소스에 여유가 있는 경우:
    • 기존 노드들 내에서 새로운 Pod(컨테이너)들이 생성됨
    • 결과적으로 한 서버에 여러 컨테이너가 실행되게 됨
    • 쿠버네티스 스케줄러가 각 노드의 리소스 상태를 보고 최적의 위치에 배치
  2. 현재 노드들의 리소스가 부족한 경우:
    • Pod들이 Pending 상태로 남게 됨
    • 이때는 실제로 물리 노드(서버)를 추가해야 함
    • 클라우드 환경이라면 자동으로 노드를 추가하도록 설정 가능 (Node Autoscaling)

예측 가능하도록 pod 당 리소스 설정을 해놓으면 좋다

apiVersion: v1
kind: Pod
spec:
  containers:
  - name: app
    resources:
      requests:
        memory: "64Mi"
        cpu: "250m"
      limits:
        memory: "128Mi"
        cpu: "500m"