| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 카카오
- 자바
- 스프링 핵심원리
- 예제로 배우는 스프링 입문
- 자바스크립트
- 스프링부트
- 엘라스틱서치
- Spring
- 이펙티브자바
- 알고리즘
- JavaScript
- java
- Effective Java
- 클린아키텍처
- 알고리즘정렬
- springboot
- 이펙티브 자바
- Sort
- 김영한
- 스프링
- 티스토리챌린지
- Effective Java 3
- kubernetes
- effectivejava
- 함수형프로그래밍
- ElasticSearch
- k8s
- 오블완
- Kotlin
- 스프링핵심원리
- Today
- Total
Kim-Baek 개발자 이야기
네트워킹 - 운영체제가 네트워크를 다루는 방법 본문
서버가 10,000개 연결을 처리하는 방법
Court Alarm이 성장하면서 동시 접속자가 급증했다. 처음엔 100명, 그 다음 1,000명, 그리고 어느 날 10,000명이 동시에 접속했다.
서버가 버텨낼 수 있을까? 놀랍게도 서버는 문제없이 동작했다. CPU 사용률 40%, 메모리 사용률 60%. 여유가 있었다.
어떻게 서버 한 대가 10,000개의 동시 연결을 처리할까?
답은 운영체제의 네트워크 스택에 있었다. 운영체제는 네트워크 연결을 효율적으로 관리하는 정교한 메커니즘을 가지고 있다. 소켓, 버퍼, 인터럽트, I/O 다중화...
5년 차가 되어서도 이 부분이 가장 어려웠다. 하지만 실제 성능 문제를 해결하면서 운영체제의 네트워크 처리를 제대로 이해하게 됐다.
소켓(Socket)이란 무엇인가
기본 개념
소켓은 **네트워크 통신을 위한 끝점(endpoint)**이다. 파일처럼 읽고 쓸 수 있는 추상화다.
클라이언트 서버
┌─────────────┐ ┌─────────────┐
│ 애플리케이션│ │ 애플리케이션│
├─────────────┤ ├─────────────┤
│ Socket │ ←──── 네트워크 ────→ │ Socket │
├─────────────┤ ├─────────────┤
│ 운영체제 │ │ 운영체제 │
└─────────────┘ └─────────────┘
소켓은 파일 디스크립터다:
- Linux에서 모든 것은 파일
- 네트워크 연결도 파일처럼 취급
- read(), write() 함수로 데이터 송수신
소켓의 종류
1. Stream Socket (TCP)
// TCP 소켓 - 신뢰성 있는 연결 지향
val socket = Socket("example.com", 80)
val output = socket.getOutputStream()
val input = socket.getInputStream()
// 데이터 전송
output.write("GET / HTTP/1.1\r\n\r\n".toByteArray())
// 응답 수신
val response = input.readBytes()
특징:
- 연결 지향 (Connection-oriented)
- 신뢰성 보장 (패킷 순서, 재전송)
- 양방향 통신 (Full-duplex)
- 느림 (오버헤드)
2. Datagram Socket (UDP)
// UDP 소켓 - 빠르지만 신뢰성 없음
val socket = DatagramSocket()
val serverAddress = InetAddress.getByName("example.com")
// 데이터 전송 (패킷 단위)
val data = "Hello".toByteArray()
val packet = DatagramPacket(data, data.size, serverAddress, 9999)
socket.send(packet)
// 응답 수신
val buffer = ByteArray(1024)
val receivePacket = DatagramPacket(buffer, buffer.size)
socket.receive(receivePacket)
특징:
- 비연결 지향 (Connectionless)
- 신뢰성 없음 (패킷 손실 가능)
- 빠름 (오버헤드 적음)
- 실시간 스트리밍에 적합
소켓 생명주기
서버 소켓:
1. socket() - 소켓 생성
2. bind() - IP:Port에 바인딩
3. listen() - 연결 대기 시작
4. accept() - 클라이언트 연결 수락
5. read/write - 데이터 송수신
6. close() - 연결 종료
클라이언트 소켓:
1. socket() - 소켓 생성
2. connect() - 서버에 연결
3. read/write - 데이터 송수신
4. close() - 연결 종료
실제 구현: 간단한 TCP 서버
// ❌ 단순한 싱글 스레드 서버 (한 번에 1개 연결만)
fun simpleServer() {
val serverSocket = ServerSocket(8080)
println("서버 시작: 포트 8080")
while (true) {
// 클라이언트 연결 대기 (블로킹)
val clientSocket = serverSocket.accept()
println("클라이언트 연결: ${clientSocket.inetAddress}")
// 요청 읽기
val input = BufferedReader(InputStreamReader(clientSocket.getInputStream()))
val request = input.readLine()
println("요청: $request")
// 응답 보내기
val output = PrintWriter(clientSocket.getOutputStream(), true)
output.println("HTTP/1.1 200 OK")
output.println("Content-Type: text/plain")
output.println()
output.println("Hello, World!")
// 연결 종료
clientSocket.close()
}
}
문제점:
- 한 번에 1개 연결만 처리
- 첫 번째 클라이언트 처리하는 동안 다른 클라이언트는 대기
- 동시 접속자 100명이면 99명은 기다림
멀티스레드 서버
스레드 생성 방식
// ✅ 연결마다 새 스레드 생성
fun multiThreadServer() {
val serverSocket = ServerSocket(8080)
println("멀티스레드 서버 시작: 포트 8080")
while (true) {
val clientSocket = serverSocket.accept()
// 각 연결을 별도 스레드에서 처리
thread {
handleClient(clientSocket)
}
}
}
fun handleClient(socket: Socket) {
try {
val input = BufferedReader(InputStreamReader(socket.getInputStream()))
val output = PrintWriter(socket.getOutputStream(), true)
val request = input.readLine()
println("[${Thread.currentThread().name}] 요청: $request")
// 무거운 작업 시뮬레이션
Thread.sleep(1000)
output.println("HTTP/1.1 200 OK")
output.println("Content-Type: text/plain")
output.println()
output.println("Hello from thread: ${Thread.currentThread().name}")
} catch (e: Exception) {
e.printStackTrace()
} finally {
socket.close()
}
}
장점:
- 동시에 여러 클라이언트 처리 가능
- 간단한 구현
문제점:
- 연결 1개 = 스레드 1개
- 10,000개 연결 = 10,000개 스레드 (불가능!)
- Context Switching 오버헤드
- 메모리 부족 (스레드당 1MB Stack)
스레드 풀 방식
// ✅ 스레드 풀 사용
fun threadPoolServer() {
val serverSocket = ServerSocket(8080)
val executor = Executors.newFixedThreadPool(100) // 100개 스레드 풀
println("스레드 풀 서버 시작: 100 스레드")
while (true) {
val clientSocket = serverSocket.accept()
executor.submit {
handleClient(clientSocket)
}
}
}
개선:
- 스레드 개수 제한 (100개)
- 스레드 재사용 (생성/소멸 비용 절약)
여전한 문제:
- 10,000개 연결에 100개 스레드
- 9,900개 연결은 대기
- I/O 대기 중에도 스레드 점유
Blocking vs Non-Blocking I/O
Blocking I/O의 문제
// Blocking I/O
val socket = serverSocket.accept() // 블로킹: 연결 올 때까지 대기
val data = input.read() // 블로킹: 데이터 올 때까지 대기
문제 시나리오:
Thread 1: Client A 연결
↓
read() 호출
↓
데이터 올 때까지 대기... (1초) ← 이 동안 스레드 낭비!
↓
데이터 도착
↓
처리 (10ms)
스레드는 1초 중 990ms를 놀고 있다!
Non-Blocking I/O
// Non-Blocking I/O 설정
import java.nio.channels.*
import java.nio.ByteBuffer
fun nonBlockingServer() {
// 서버 소켓 채널 생성
val serverChannel = ServerSocketChannel.open()
serverChannel.bind(InetSocketAddress(8080))
serverChannel.configureBlocking(false) // Non-Blocking 모드
println("Non-Blocking 서버 시작")
val clients = mutableListOf<SocketChannel>()
while (true) {
// accept()가 즉시 반환 (연결 없으면 null)
val clientChannel = serverChannel.accept()
if (clientChannel != null) {
clientChannel.configureBlocking(false)
clients.add(clientChannel)
println("새 연결: ${clientChannel.remoteAddress}")
}
// 모든 클라이언트 확인
val iterator = clients.iterator()
while (iterator.hasNext()) {
val client = iterator.next()
val buffer = ByteBuffer.allocate(1024)
// read()가 즉시 반환 (데이터 없으면 0)
val bytesRead = client.read(buffer)
if (bytesRead > 0) {
buffer.flip()
println("데이터 수신: ${bytesRead}바이트")
// Echo 응답
client.write(buffer)
} else if (bytesRead == -1) {
// 연결 종료
client.close()
iterator.remove()
}
}
// 잠깐 대기 (CPU 100% 방지)
Thread.sleep(10)
}
}
개선:
- accept(), read()가 즉시 반환
- 하나의 스레드로 여러 연결 처리
- 데이터 없으면 다른 연결 확인
여전한 문제:
- 계속 루프 돌며 확인 (Busy Waiting)
- CPU 낭비
- 연결 1,000개면 매번 1,000번 확인
I/O Multiplexing (다중화)
select() 시스템 콜
개념: 여러 소켓 중 준비된 소켓만 알려줌
운영체제에게 물어봄:
"이 1,000개 소켓 중에 읽을 데이터가 있는 거 알려줘"
운영체제:
"Socket 3, 17, 42에 데이터 있어요"
→ 3개만 확인하면 됨!
Java에서는 Selector 사용:
import java.nio.channels.Selector
import java.nio.channels.SelectionKey
import java.nio.ByteBuffer
fun selectorServer() {
val serverChannel = ServerSocketChannel.open()
serverChannel.bind(InetSocketAddress(8080))
serverChannel.configureBlocking(false)
// Selector 생성
val selector = Selector.open()
// 서버 채널을 Selector에 등록 (ACCEPT 이벤트)
serverChannel.register(selector, SelectionKey.OP_ACCEPT)
println("Selector 서버 시작")
while (true) {
// 이벤트가 발생할 때까지 대기 (블로킹)
// CPU 안 씀!
val readyCount = selector.select()
if (readyCount == 0) continue
// 준비된 채널들만 처리
val keys = selector.selectedKeys()
val iterator = keys.iterator()
while (iterator.hasNext()) {
val key = iterator.next()
iterator.remove()
when {
// 새 연결
key.isAcceptable -> {
val server = key.channel() as ServerSocketChannel
val client = server.accept()
client.configureBlocking(false)
// 클라이언트를 Selector에 등록 (READ 이벤트)
client.register(selector, SelectionKey.OP_READ)
println("새 연결: ${client.remoteAddress}")
}
// 읽기 가능
key.isReadable -> {
val client = key.channel() as SocketChannel
val buffer = ByteBuffer.allocate(1024)
val bytesRead = client.read(buffer)
if (bytesRead > 0) {
buffer.flip()
// Echo 응답
client.write(buffer)
} else if (bytesRead == -1) {
// 연결 종료
key.cancel()
client.close()
}
}
}
}
}
}
핵심:
- Selector에 채널 등록: 관심 있는 이벤트 지정 (ACCEPT, READ, WRITE)
- select() 호출: 이벤트 발생까지 대기 (블로킹이지만 CPU 안 씀)
- 준비된 채널만 처리: 효율적
성능:
- 단일 스레드로 10,000개 연결 처리 가능
- CPU 사용률 낮음
- 메모리 효율적
select vs poll vs epoll
Linux에는 여러 I/O 다중화 방법이 있다:
1. select()
// 최대 1,024개 파일 디스크립터
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(socket_fd, &readfds);
select(max_fd + 1, &readfds, NULL, NULL, NULL);
// 모든 fd를 순회하며 확인
for (int i = 0; i < max_fd; i++) {
if (FD_ISSET(i, &readfds)) {
// 처리
}
}
한계:
- 최대 1,024개
- 매번 전체 확인 (O(n))
- fd_set 복사 오버헤드
2. poll()
// 제한 없음
struct pollfd fds[10000];
fds[0].fd = socket_fd;
fds[0].events = POLLIN;
poll(fds, nfds, timeout);
// 모든 fd를 순회하며 확인
for (int i = 0; i < nfds; i++) {
if (fds[i].revents & POLLIN) {
// 처리
}
}
개선:
- 개수 제한 없음
- 여전히 O(n)
3. epoll() (Linux 최신)
// epoll 인스턴스 생성
int epoll_fd = epoll_create1(0);
// 관심 이벤트 등록
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = socket_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, socket_fd, &ev);
// 이벤트 대기
struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
// 준비된 fd만 반환됨! O(1)
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
// 처리
}
최고 성능:
- 개수 제한 없음
- O(1) 성능
- 준비된 fd만 반환
- 대부분의 고성능 서버가 사용 (Nginx, Redis)
비교:
방법 최대 개수 성능 사용처
| select() | 1,024 | O(n) | 구형 Unix |
| poll() | 무제한 | O(n) | 호환성 중요 |
| epoll() | 무제한 | O(1) | Linux 고성능 서버 |
실전: Court Alarm API 서버 최적화
초기 문제
Court Alarm API 서버가 느렸다.
증상:
- 동시 접속자 1,000명
- 평균 응답시간: 2초
- CPU 사용률: 20% (낮음!)
- 스레드 상태: 대부분 WAITING
원인 분석:
# 스레드 덤프 분석
jstack <pid> | grep "WAITING" | wc -l
# 180개 (전체 200개 중)
# 무엇을 기다리나?
jstack <pid> | grep -A 5 "WAITING"
결과:
"http-nio-8080-exec-42" waiting on condition
at java.net.SocketInputStream.socketRead0(Native Method)
at java.net.SocketInputStream.read()
→ 외부 API 호출을 기다리는 중!
문제 코드
// ❌ Blocking I/O로 외부 API 호출
@Service
class WeatherService {
fun getWeather(location: String): Weather {
val url = "https://api.weather.com/forecast?location=$location"
// 여기서 블로킹!
// 응답 올 때까지 스레드 대기 (평균 500ms)
val response = RestTemplate().getForObject(url, WeatherResponse::class.java)
return Weather(
temperature = response.temp,
condition = response.condition
)
}
}
@RestController
class CourtController(private val weatherService: WeatherService) {
@GetMapping("/courts/{id}")
fun getCourt(@PathVariable id: Long): CourtDto {
val court = courtRepository.findById(id)
// 날씨 정보 추가 (500ms 블로킹)
val weather = weatherService.getWeather(court.location)
return CourtDto(
id = court.id,
name = court.name,
weather = weather
)
}
}
문제:
- 외부 API 호출마다 스레드 블로킹 (500ms)
- 스레드 풀 200개
- 초당 요청 400개
- 200 / (1000ms / 500ms) = 100 TPS 한계
- 나머지 300개 요청은 대기 → 응답 시간 증가
해결 방법 1: 비동기 호출
// ✅ 비동기 Non-Blocking I/O
@Service
class AsyncWeatherService {
private val asyncRestTemplate = AsyncRestTemplate()
fun getWeatherAsync(location: String): CompletableFuture {
val url = "https://api.weather.com/forecast?location=$location"
// 비동기 호출 (즉시 반환)
val future = CompletableFuture()
asyncRestTemplate.getForEntity(url, WeatherResponse::class.java)
.addCallback(
{ response ->
future.complete(Weather(
temperature = response.body.temp,
condition = response.body.condition
))
},
{ error ->
future.completeExceptionally(error)
}
)
return future
}
}
@RestController
class CourtController(private val weatherService: AsyncWeatherService) {
@GetMapping("/courts/{id}")
fun getCourt(@PathVariable id: Long): DeferredResult {
val result = DeferredResult(5000L) // 5초 타임아웃
// 즉시 반환 (스레드 해제)
CompletableFuture.supplyAsync {
courtRepository.findById(id)
}.thenCompose { court ->
// 날씨 비동기 조회
weatherService.getWeatherAsync(court.location)
.thenApply { weather ->
CourtDto(
id = court.id,
name = court.name,
weather = weather
)
}
}.whenComplete { dto, error ->
if (error != null) {
result.setErrorResult(error)
} else {
result.setResult(dto)
}
}
return result
}
}
개선:
- 외부 API 호출 중에 스레드 해제
- 응답 오면 콜백으로 처리
- 같은 200개 스레드로 훨씬 많은 요청 처리
결과:
- TPS: 100 → 800 (8배 증가)
- 평균 응답시간: 2초 → 600ms
- CPU 사용률: 20% → 60%
해결 방법 2: Spring WebFlux (Reactive)
// ✅ WebFlux로 완전한 Non-Blocking
@Service
class ReactiveWeatherService {
private val webClient = WebClient.create("https://api.weather.com")
fun getWeather(location: String): Mono {
return webClient.get()
.uri("/forecast?location=$location")
.retrieve()
.bodyToMono(WeatherResponse::class.java)
.map { response ->
Weather(
temperature = response.temp,
condition = response.condition
)
}
.timeout(Duration.ofSeconds(3))
}
}
@RestController
class ReactiveCourtController(
private val courtRepository: ReactiveCourtRepository,
private val weatherService: ReactiveWeatherService
) {
@GetMapping("/courts/{id}")
fun getCourt(@PathVariable id: Long): Mono {
return courtRepository.findById(id)
.flatMap { court ->
weatherService.getWeather(court.location)
.map { weather ->
CourtDto(
id = court.id,
name = court.name,
weather = weather
)
}
}
}
}
WebFlux 특징:
- 완전한 Non-Blocking 스택
- Netty 기반 (epoll 사용)
- 이벤트 루프 모델
- 적은 스레드로 많은 연결 처리
성능 비교:
방식 스레드 수 TPS 평균 응답시간 CPU
| Blocking (Tomcat) | 200 | 100 | 2,000ms | 20% |
| Async (Tomcat) | 200 | 800 | 600ms | 60% |
| Reactive (Netty) | 8 | 1,200 | 400ms | 70% |
WebFlux가 12배 더 높은 처리량!
네트워크 버퍼와 흐름 제어
송수신 버퍼
각 소켓은 운영체제에 송수신 버퍼를 가진다:
애플리케이션 운영체제
┌─────────────┐ ┌─────────────────┐
│ write() │───────────→│ 송신 버퍼 │───→ 네트워크
│ │ │ (커널 공간) │
└─────────────┘ └─────────────────┘
┌─────────────┐ ┌─────────────────┐
│ read() │←───────────│ 수신 버퍼 │←─── 네트워크
│ │ │ (커널 공간) │
└─────────────┘ └─────────────────┘
write() 동작:
- 애플리케이션이 write() 호출
- 데이터를 송신 버퍼에 복사
- write() 즉시 반환 (버퍼에 공간 있으면)
- 운영체제가 백그라운드로 전송
read() 동작:
- 애플리케이션이 read() 호출
- 수신 버퍼에서 데이터 복사
- 버퍼가 비어있으면 블로킹 (또는 0 반환)
버퍼 크기 조정
val socket = Socket()
// 송신 버퍼 크기 설정 (기본: 8KB)
socket.sendBufferSize = 64 * 1024 // 64KB
// 수신 버퍼 크기 설정 (기본: 8KB)
socket.receiveBufferSize = 64 * 1024 // 64KB
println("송신 버퍼: ${socket.sendBufferSize / 1024}KB")
println("수신 버퍼: ${socket.receiveBufferSize / 1024}KB")
언제 조정해야 하나:
큰 버퍼 (64KB~256KB):
- 대용량 파일 전송
- 네트워크 지연이 큰 경우 (해외 서버)
- 처리량 중요
작은 버퍼 (4KB~8KB):
- 실시간 통신 (게임, 채팅)
- 메모리 제한적
- 지연 시간 중요
실무 예시: 파일 업로드 최적화
// ❌ 작은 버퍼로 파일 전송 (느림)
fun slowUpload(file: File, socket: Socket) {
val input = FileInputStream(file)
val output = socket.getOutputStream()
val buffer = ByteArray(1024) // 1KB (너무 작음)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
// 1KB마다 write() 시스템 콜 → 오버헤드!
}
}
// ✅ 큰 버퍼로 파일 전송 (빠름)
fun fastUpload(file: File, socket: Socket) {
// 소켓 버퍼 크기 증가
socket.sendBufferSize = 256 * 1024 // 256KB
val input = FileInputStream(file)
val output = socket.getOutputStream()
val buffer = ByteArray(64 * 1024) // 64KB
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
// 64KB마다 write() → 시스템 콜 1/64
}
}
성능 측정:
파일 크기: 100MB
네트워크: 100Mbps
slowUpload (1KB 버퍼):
- 전송 시간: 25초
- 시스템 콜 횟수: 102,400번
fastUpload (64KB 버퍼):
- 전송 시간: 10초 (2.5배 빠름)
- 시스템 콜 횟수: 1,600번 (64배 감소)
Zero-Copy 최적화
일반적인 파일 전송:
1. 파일 → 커널 버퍼 (read)
2. 커널 버퍼 → 애플리케이션 버퍼 (복사)
3. 애플리케이션 버퍼 → 소켓 버퍼 (write)
4. 소켓 버퍼 → 네트워크 카드 (전송)
총 4번 복사!
Zero-Copy (sendfile):
import java.nio.channels.FileChannel
fun zeroCopyUpload(file: File, socket: Socket) {
val fileChannel = FileInputStream(file).channel
val socketChannel = socket.channel
// 커널 공간에서 직접 전송 (복사 0번)
fileChannel.transferTo(0, file.length(), socketChannel)
// 애플리케이션 버퍼 거치지 않음!
}
성능:
파일 크기: 1GB
네트워크: 1Gbps
일반 전송:
- 전송 시간: 12초
- CPU 사용률: 40%
Zero-Copy:
- 전송 시간: 8초 (1.5배 빠름)
- CPU 사용률: 5% (8배 감소)
Zero-Copy 사용처:
- Nginx (정적 파일 서빙)
- Kafka (로그 전송)
- 대용량 파일 다운로드
네트워크 인터럽트 처리
인터럽트란
네트워크 카드가 데이터를 받으면 CPU에게 인터럽트를 보낸다:
1. 네트워크 카드가 패킷 수신
2. DMA로 메모리에 복사
3. CPU에 인터럽트 신호
4. CPU가 하던 일 멈춤
5. 인터럽트 핸들러 실행
6. 패킷 처리
7. 원래 작업 재개
문제: 패킷이 초당 100,000개 오면?
→ 인터럽트 100,000번/초
→ CPU가 인터럽트 처리만 하느라 바쁨
→ 애플리케이션 실행 못 함
Interrupt Coalescing
여러 패킷을 모아서 한 번에 인터럽트:
Before:
패킷 1 도착 → 인터럽트
패킷 2 도착 → 인터럽트
패킷 3 도착 → 인터럽트
...
(10,000번 인터럽트)
After (Interrupt Coalescing):
패킷 1~100 도착 → 버퍼에 모음
100개 모이거나 10μs 지나면 → 인터럽트 1번
(100번 인터럽트로 감소)
설정:
# 네트워크 카드 설정 확인
ethtool -c eth0
# Interrupt Coalescing 설정
ethtool -C eth0 rx-usecs 10 rx-frames 100
# 10μs 또는 100개 패킷마다 인터럽트
NAPI (New API)
Linux의 고성능 네트워크 처리:
Interrupt Mode:
- 패킷 적을 때
- 패킷마다 인터럽트
Polling Mode:
- 패킷 많을 때
- 인터럽트 끄고 계속 폴링
- CPU가 주도권
자동 전환:
패킷 적음 → Interrupt Mode
↓
패킷 증가 (임계값 초과)
↓
Polling Mode
↓
패킷 감소
↓
Interrupt Mode로 복귀
효과:
- 높은 부하에서 성능 향상
- 인터럽트 오버헤드 감소
- 자동 최적화
TCP 최적화 파라미터
중요한 커널 파라미터
# TCP 버퍼 크기
sysctl -w net.core.rmem_max=16777216 # 수신 버퍼 최대 16MB
sysctl -w net.core.wmem_max=16777216 # 송신 버퍼 최대 16MB
sysctl -w net.ipv4.tcp_rmem="4096 87380 16777216" # 최소, 기본, 최대
sysctl -w net.ipv4.tcp_wmem="4096 65536 16777216"
# 동시 연결 수
sysctl -w net.core.somaxconn=65535 # listen() 백로그 큐 크기
sysctl -w net.ipv4.tcp_max_syn_backlog=8192 # SYN 백로그
# TIME_WAIT 소켓 재사용
sysctl -w net.ipv4.tcp_tw_reuse=1 # TIME_WAIT 재사용 허용
# TCP 윈도우 스케일링
sysctl -w net.ipv4.tcp_window_scaling=1 # 큰 윈도우 크기 허용
# TCP Fast Open
sysctl -w net.ipv4.tcp_fastopen=3 # 클라이언트, 서버 모두
Court Alarm 최적화 경험
초기 설정으로는 10,000 동시 연결을 처리 못 했다.
문제:
# 로그 확인
dmesg | grep "TCP"
# 출력:
TCP: request_sock_TCP: too many open files
TCP: drop open request from X.X.X.X
원인:
- somaxconn = 128 (너무 작음)
- tcp_max_syn_backlog = 1024 (부족)
- File descriptor limit = 1024
해결:
# 1. TCP 파라미터 조정
sysctl -w net.core.somaxconn=65535
sysctl -w net.ipv4.tcp_max_syn_backlog=8192
# 2. File descriptor 증가
ulimit -n 65535
# 영구 적용
echo "* soft nofile 65535" >> /etc/security/limits.conf
echo "* hard nofile 65535" >> /etc/security/limits.conf
# 3. 애플리케이션 재시작
systemctl restart court-alarm-api
결과:
- 동시 연결: 1,000 → 10,000 (10배)
- 연결 거부 에러: 0건
- 안정적인 운영
면접에서 이렇게 답하자
1단계: 기본 개념 (30초)
"소켓은 네트워크 통신의 끝점으로, 파일처럼 read/write할 수 있는 추상화입니다. TCP는 연결 지향으로 신뢰성을 보장하고, UDP는 비연결 지향으로 빠르지만 신뢰성이 없습니다."
2단계: Blocking vs Non-Blocking (1분)
"Blocking I/O는 데이터가 올 때까지 스레드가 대기합니다. 문제는 I/O 대기 중에도 스레드를 점유한다는 점입니다. Non-Blocking I/O는 즉시 반환하고, I/O Multiplexing(select, epoll)으로 준비된 소켓만 처리합니다. 하나의 스레드로 수천 개 연결을 처리할 수 있습니다."
3단계: 실무 경험 (2분)
"Court Alarm에서 외부 API 호출 때문에 응답이 느렸습니다. 200개 스레드가 평균 500ms씩 블로킹되면서 TPS가 100으로 제한됐습니다. 비동기 I/O로 전환해서 외부 API 호출 중에 스레드를 해제했고, TPS가 800으로 8배 증가했습니다."
"추가로 Spring WebFlux로 전환하면서 완전한 Non-Blocking 스택을 구축했습니다. Netty의 epoll을 사용해 8개 스레드로 동시 접속 10,000명을 처리했고, TPS는 1,200까지 올랐습니다."
4단계: 성능 최적화 (1분 30초)
"파일 업로드를 최적화할 때 버퍼 크기를 1KB에서 64KB로 늘렸더니 시스템 콜이 64배 감소하고 전송 속도가 2.5배 빨라졌습니다. 추가로 Zero-Copy(sendfile)를 적용해 커널 공간에서 직접 전송하도록 해서 CPU 사용률을 40%에서 5%로 줄였습니다."
"동시 연결 수를 늘리기 위해 커널 파라미터를 조정했습니다. somaxconn을 128에서 65535로, tcp_max_syn_backlog를 1024에서 8192로 늘렸고, file descriptor limit을 65535로 설정했습니다. 결과적으로 동시 연결 10,000개를 안정적으로 처리할 수 있게 됐습니다."
핵심 팁
구체적인 숫자:
- ❌ "성능이 좋아졌어요"
- ✅ "TPS 100에서 1,200으로 12배 증가, 8개 스레드로 10,000 동시 연결"
기술 스택 명시:
- Blocking → Async (CompletableFuture) → WebFlux (Netty)
- select() → poll() → epoll()
- 일반 전송 → Zero-Copy (sendfile)
문제 → 해결 → 결과:
- 문제: 외부 API 블로킹, TPS 100
- 해결: 비동기 I/O, WebFlux, epoll
- 결과: TPS 1,200, 동시 연결 10,000
정리
운영체제의 네트워크 처리는 고성능 서버의 핵심이다.
주요 개념:
소켓:
- 네트워크 통신의 끝점
- 파일처럼 read/write
- TCP (신뢰성) vs UDP (속도)
I/O 모델:
- Blocking I/O
- 장점: 간단
- 단점: 스레드 낭비
- 사용: 단순한 서버
- Non-Blocking I/O
- 장점: 즉시 반환
- 단점: Busy Waiting
- 사용: I/O Multiplexing과 함께
- I/O Multiplexing
- select/poll/epoll
- 준비된 소켓만 처리
- 사용: 고성능 서버
- 비동기 I/O
- 완전 Non-Blocking
- 이벤트 기반
- 사용: WebFlux, Node.js
성능 최적화:
- 버퍼 크기 조정 (64KB~256KB)
- Zero-Copy (sendfile)
- Interrupt Coalescing
- 커널 파라미터 튜닝
실무 포인트:
- Blocking I/O는 I/O-bound에서 병목
- 비동기 I/O로 스레드 효율 극대화
- epoll은 10,000+ 연결에 최적
- 버퍼 크기와 시스템 콜 트레이드오프
- 커널 파라미터로 연결 수 제한 해제
다음 글에서는 "파일 시스템"을 다루면서, 데이터가 디스크에 어떻게 저장되는지, 파일 접근을 어떻게 최적화하는지 이야기해보겠다.
'컴퓨터 공학 > 운영체제' 카테고리의 다른 글
| I/O - 블로킹, 논블로킹, 동기, 비동기의 진실 (0) | 2026.01.08 |
|---|---|
| 파일 시스템 - 데이터는 디스크에 어떻게 저장될까 (0) | 2026.01.08 |
| 동기화와 통신 - 멀티스레드의 가장 어려운 문제 (0) | 2026.01.07 |
| 메모리 관리 - 5년차 개발자가 겪은 메모리 누수와의 전쟁 (0) | 2026.01.06 |
| 프로세스와 스레드 - 경력 5년차 개발자의 실전 경험 (1) | 2026.01.05 |
