Jar 파일을 리눅스에서 실행하면 스레드 수는?
- OS 스레드와 JVM 스레드가 매핑되어서 생성된다
- 이를 Native Thread Implementation 또는 1:1 Threading Model이라고 한다
Spring Boot의 스레드 풀
- 기본적으로 내장 Tomcat을 사용할 경우 Thread Pool이 생성된다
- Default로 최대 200개의 스레드를 가질 수 있다
- 각 요청은 이 스레드 풀의 워커 스레드에 의해 처리된다
질문 1.
Docker Image로 Container를 띄우면 이 Container는 실행되는 OS의 프로세스와 스레드를 할당받는 걸로 아는데 맞아?
- 컨테이너와 프로세스 관계
- Docker 컨테이너는 호스트 OS의 격리된 프로세스 그룹으로 실행된다
- Linux의 namespaces와 cgroups를 사용해 프로세스를 격리하고 자원을 제한한다
- 실제로는 호스트 OS의 커널을 공유한다
- Spring Boot 애플리케이션의 경우
- 컨테이너 안에서 실행되는 JVM도 호스트 OS의 프로세스이다
- JVM의 스레드는 여전히 호스트 OS의 네이티브 스레드로 매핑된다
- 다만 컨테이너의 자원 제한(CPU, 메모리 등)의 영향을 받는다
- 확인 방법
bash
Copy
# 호스트 OS에서 컨테이너 프로세스 확인
docker top [container-id]
# 컨테이너 내부의 프로세스 확인
docker exec [container-id] ps -ef
# 스레드 확인
docker exec [container-id] ps -eLf
- 컨테이너 구조
- 각 컨테이너는 자체 애플리케이션, 라이브러리, 런타임을 포함
- 컨테이너들은 서로 격리되어 있음
- Namespace 격리
- PID Namespace: 프로세스 ID 격리
- 컨테이너 내부에서는 PID1부터 시작하지만 호스트 시스템에서는 PID 1234 등을 가질 수 있다
- Network Namespace: 네트워크 스택 격리
- 각 컨테인너에 독립적인 네트워크 스택을 제공한다
- 네트워크 인터페이스들
- IP 주소
- 라우팅 테이블
- 포트 번호
- iptables 규칙
- 컨테이너가 생성될 때 도커는 veth(virtual ethernet) 쌍을 생성한다
- 한쪽 끝(veth블라블라)은 호스트의 네트워크 네임스페이스에 존재
- 다른 쪽 끝(eth0)은 컨테이너의 네트워크 네임스페이스에 존재
- 이 두 인터페이스는 파이프처럼 연결되어 트래픽을 전달한다
- 각 컨테인너에 독립적인 네트워크 스택을 제공한다
- PID Namespace: 프로세스 ID 격리
- 포트 매핑
- -p 8080:80
- Mount Namespace: 파일시스템 마운트 격리
- User Namespace: 사용자/그룹 ID 격리
3. 리소스 제어 (cgroups)
- CPU 사용량 제어
- 메모리 사용량 제어
- I/O 사용량 제어
4. 커널 공유
- 모든 컨테이너는 동일한 호스트 OS 커널을 공유
- 커널을 통해 시스템 리소스에 접근
질문 2.
Kotlin Spring Boot일 때 Coroutine이 쓰는 Dispatchers Pool의 스레드도 OS 스레드인지?
- Dispatchers의 스레드 풀
- Dispatchers.Default: CPU 코어 수에 비례한 크기의 스레드 풀 사용 (최소 2개, 최대 CPU 코어 수+1)
- Dispatchers.IO: 공유 스레드 풀 사용 (기본 64개 스레드까지)
- 이 모든 스레드들은 실제 OS 네이티브 스레드이다
- Coroutine과 스레드의 차이점
- Coroutine은 경량 스레드(lightweight thread)라고 불리지만, 실제로는 스레드 위에서 실행되는 작업 단위이다
- 한 스레드에서 여러 코루틴이 실행될 수 있다 (M:N 관계)
- 코루틴 간의 전환은 스레드 전환보다 훨씬 가벼운 작업이다
질문 3.
어떤 Coroutine Scope이 실행될 때 사용할 Thread 종류를 할당하고 그 Scope 안에서 코루틴의 전환이 스레드 전환 콘텍스트 비용보단 저렴하다는 건지?
- Coroutine Scope와 Dispatcher
kotlin
Copy
// Dispatcher를 통해 어떤 스레드 풀을 사용할지 결정
CoroutineScope(Dispatchers.Default).launch {
// 이 스코프 내의 모든 코루틴은 Default 스레드 풀을 사용
// 코루틴 간의 전환 (suspend 함수 호출 등)은 매우 가벼움
val result1 = async { heavyComputation() }
val result2 = async { anotherComputation() }
}
- 컨텍스트 전환 비용 비교
- 스레드 전환: OS 수준의 콘텍스트 스위칭 발생 (비용이 비쌈)
-
- 프로그램 카운터(PC): 다음에 실행할 명령어의 주소
- 스택 포인터(SP): 현재 스택의 top 위치
- 범용 레지스터: 데이터 임시 저장
- 상태 레지스터: CPU 상태 정보CPU 레지스터 상태 저장
-
- 스레드 전환: OS 수준의 콘텍스트 스위칭 발생 (비용이 비쌈)
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용 스레드 이게 다 따로 생성되는지?
- Spring Boot의 요청 처리용 스레드 풀 (Tomcat Thread Pool)
kotlin
Copy
server.tomcat.threads.max=200// application.properties
- 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
}
}
- 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개, 그리고 코루틴 스레드는 숫자 설정이 없는 상태라면 코루틴 스레드 할당되는 공식은?
- Docker 기본 설정
- 제한을 걸지 않으면 호스트 시스템의 리소스를 공유해서 사용
- CPU 코어도 호스트의 모든 코어를 사용 가능
- 각 스레드 풀의 상태
- 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)의 스레드가 생성될 수 있다.
하지만 여기서 중요한 점은:
- 두 컨테이너가 같은 호스트의 CPU 자원을 공유한다
- 각 컨테이너는 독립적으로 동작하지만, 결국 같은 호스트 OS의 리소스를 사용한다
- 리소스 경합이 발생할 수 있어 실제 성능은 떨어질 수 있게 된다
이런 상황에서는 Docker의 리소스 제한을 설정하는 것이 좋다
yaml
Copy
# docker-compose.yml 예시
services:
app1:
cpus: '4'
pids_limit: 150
app2:
cpus: '4'
pids_limit: 150
이렇게 각 컨테이너의 리소스를 명시적으로 제한하면 더 예측 가능한 동작을 기대할 수 있습니다.
질문 7.
서버의 성능을 최대로 뽑아먹으려면 큰 서버에 여러 컨테이너 vs 여러 작은 서버에 각 컨테이너
하나의 서버에 여러 컨테이너 장점
- 리소스 공유와 활용이 효율적
- 관리가 단순
- 비용 효율적
- 컨테이너 간 통신이 빠름(같은 호스트 내이기 때문에)
여러 서버에 분산
- 장애 격리가 잘됨(한 서버의 문제가 다른 서버에 영향 없음)
- 개별 서비스 스케일링이 더 쉬움
- 리소스 경합이 덜함
- 하드웨어 장애 시 일부 서비스만 영향
따라서 상황에 맞게 결정하는 게 좋다
- 리소스 사용 패턴이 서로 보완적인 서비스들은 같은 서버에 두는 게 효율적
- 중요도가 높거나 리소스를 많이 사용하는 서비스는 별도 서버에 분리
- 트래픽 패턴이 예측 가능하다면 적절히 그룹화
질문 8.
쿠버네티스 마스터 서버 한대랑 노드 서버 3대가 있는 상태에서 서버 트래픽 양 때문에 서버 스케일 아웃을 한다면 있는 서버 내에서 스케일 아웃해야 해서 한 서버에 여러 컨테이너가 올라가는지?
쿠버네티스 환경에서 스케일 아웃을 할 때는 크게 두 가지 상황이 발생할 수 있다.
- 현재 노드(서버)들의 리소스에 여유가 있는 경우:
- 기존 노드들 내에서 새로운 Pod(컨테이너)들이 생성됨
- 결과적으로 한 서버에 여러 컨테이너가 실행되게 됨
- 쿠버네티스 스케줄러가 각 노드의 리소스 상태를 보고 최적의 위치에 배치
- 현재 노드들의 리소스가 부족한 경우:
- Pod들이 Pending 상태로 남게 됨
- 이때는 실제로 물리 노드(서버)를 추가해야 함
- 클라우드 환경이라면 자동으로 노드를 추가하도록 설정 가능 (Node Autoscaling)
예측 가능하도록 pod 당 리소스 설정을 해놓으면 좋다
apiVersion: v1
kind: Pod
spec:
containers:
- name: app
resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"
'Spring' 카테고리의 다른 글
왜 기본으로 HikariCP를 선택할까? 어떤 옵션이 있을까? (3) | 2024.12.25 |
---|---|
ObjectMapper는 꼭 try-catch로 써야 하는걸까? (2) | 2024.12.25 |