Kim-Baek 개발자 이야기

네트워킹 - 운영체제가 네트워크를 다루는 방법 본문

컴퓨터 공학/운영체제

네트워킹 - 운영체제가 네트워크를 다루는 방법

김백개발자 2026. 1. 8. 01:42
반응형

서버가 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()
                    }
                }
            }
        }
    }
}

핵심:

  1. Selector에 채널 등록: 관심 있는 이벤트 지정 (ACCEPT, READ, WRITE)
  2. select() 호출: 이벤트 발생까지 대기 (블로킹이지만 CPU 안 씀)
  3. 준비된 채널만 처리: 효율적

성능:

  • 단일 스레드로 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
        )
    }
}

문제:

  1. 외부 API 호출마다 스레드 블로킹 (500ms)
  2. 스레드 풀 200개
  3. 초당 요청 400개
  4. 200 / (1000ms / 500ms) = 100 TPS 한계
  5. 나머지 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() 동작:

  1. 애플리케이션이 write() 호출
  2. 데이터를 송신 버퍼에 복사
  3. write() 즉시 반환 (버퍼에 공간 있으면)
  4. 운영체제가 백그라운드로 전송

read() 동작:

  1. 애플리케이션이 read() 호출
  2. 수신 버퍼에서 데이터 복사
  3. 버퍼가 비어있으면 블로킹 (또는 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 모델:

  1. Blocking I/O
    • 장점: 간단
    • 단점: 스레드 낭비
    • 사용: 단순한 서버
  2. Non-Blocking I/O
    • 장점: 즉시 반환
    • 단점: Busy Waiting
    • 사용: I/O Multiplexing과 함께
  3. I/O Multiplexing
    • select/poll/epoll
    • 준비된 소켓만 처리
    • 사용: 고성능 서버
  4. 비동기 I/O
    • 완전 Non-Blocking
    • 이벤트 기반
    • 사용: WebFlux, Node.js

성능 최적화:

  • 버퍼 크기 조정 (64KB~256KB)
  • Zero-Copy (sendfile)
  • Interrupt Coalescing
  • 커널 파라미터 튜닝

실무 포인트:

  1. Blocking I/O는 I/O-bound에서 병목
  2. 비동기 I/O로 스레드 효율 극대화
  3. epoll은 10,000+ 연결에 최적
  4. 버퍼 크기와 시스템 콜 트레이드오프
  5. 커널 파라미터로 연결 수 제한 해제

다음 글에서는 "파일 시스템"을 다루면서, 데이터가 디스크에 어떻게 저장되는지, 파일 접근을 어떻게 최적화하는지 이야기해보겠다.

 

반응형
Comments