들어가며

이전 포스팅에서 TCP 서버를 만들어 클라이언트 연결을 받고 +PONG\r\n을 응답하는 데까지 성공했다.
하지만 실제 데이터베이스라면 클라이언트가 보낸 명령어를 읽고 해석해야 한다. PING이면 PONG을, GET key면 해당 값을 응답해주듯 말이다.

문제는 클라이언트가 PING\r\n을 보냈다고 해서 서버가 정확히 PING\r\n을 한 번에 받는다는 보장이 없다. 이번 포스팅에서는 이 문제의 본질을 TCP 프로토콜의 구조까지 내려가서 이해하고 커널과 유저 공간 사이의 데이터 흐름을 살펴본 뒤, go의 bufio.Readerio.Reader 인터페이스를 활용해 스트림 파싱을 구현하는 과정을 다룬다.




TCP는 “스트림"이다

메시지 경계가 없다

스트림은 데이터의 경계 없이 순서대로 이어지는 바이트의 나열을 의미한다.
클라이언트가 아래와 같이 두 번에 나눠 보내도

"PING\r\n"

"ECHO hello\r\n"

서버는 아래처럼 한 번에 받을 수 있다.

"PING\r\nECHO hel"

그리고 다음 Read() 호출에서 나머지를 받는다.

"lo\r\n"

반대로 클라이언트가 한 번에 보낸 데이터가 아래와 같이 여러 번의 Read()로 나뉘어 들어올 수도 있다.

"P"

"ING\r\n"

이것이 스트림 기반 프로토콜의 특성이다. TCP는 바이트들의 순서만 보장할 뿐, “여기서 메시지가 끝난다"는 경계를 알려주지 않는다.


왜 이런 일이 발생하는가

네트워크를 통해 데이터가 전송될 때, 여러 계층에서 데이터가 분할되거나 합쳐진다.

  • MTU(Maximum Transmission Unit) - 네트워크 인터페이스가 한 번에 전송할 수 있는 최대 크기. 이더넷의 경우 보통 1500바이트다. 이보다 큰 데이터는 여러 패킷으로 나뉜다.
  • Nagle 알고리즘 - 작은 패킷들을 모아서 한 번에 전송하는 최적화 기법. 네트워크 효율을 높이지만, 여러 메시지가 하나로 합쳐질 수 있다.
  • 수신 버퍼 - 커널이 도착한 패킷들을 버퍼에 쌓아두고, 애플리케이션이 Read()를 호출할 때 버퍼에 있는 만큼 전달한다. 버퍼에 여러 메시지가 쌓여있으면 한 번에 읽힌다.

결국 애플리케이션 레벨에서 메시지 경계를 직접 파싱해야 한다.


메시지 경계를 정하는 두 가지 방법

방식설명예시
구분자 기반특정 문자(열)로 메시지 끝을 표시HTTP 헤더(\r\n), Redis RESP(\r\n)
길이 접두사메시지 앞에 길이를 명시HTTP Content-Length, Protocol Buffers

Redis의 RESP 프로토콜은 \r\n을 구분자로 사용한다. 이 방식으로 메시지 경계를 구분하면 된다.




TCP 세그먼트의 구조

TCP가 바이트 스트림을 어떻게 전달하는지 이해하려면 실제 네트워크를 흐르는 TCP 세그먼트의 내부를 들여다볼 필요가 있다.


세그먼트 헤더 필드

애플리케이션이 Write()로 데이터를 보내면 TCP 계층은 이 데이터를 적절한 크기로 잘라 각각에 헤더를 붙인다. 이렇게 만들어진 단위가 TCP 세그먼트다.

TCP Segment Header

RFC 793에 정의된 TCP 헤더 구조이며, 각 필드가 하는 역할은 다음과 같다.

  • Source Port / Destination Port (각 16비트) - 송신자와 수신자의 포트 번호이다. 이 포트 번호와 IP 주소를 조합한 4-tuple로 각 연결이 고유하게 식별된다.
  • Sequence Number (32비트) - 이 세그먼트에 담긴 데이터의 첫 번째 바이트가 전체 스트림에서 몇 번째 바이트인지를 나타낸다. TCP가 “순서 보장"을 할 수 있는 핵심 메커니즘이다.
  • Acknowledgment Number (32비트) - “여기까지 잘 받았으니 다음엔 이 번호부터 보내줘"라는 의미이다. 수신 측이 송신 측에게 보내는 확인 응답이다.
  • Flags (6비트) - 세그먼트의 용도를 나타내는 플래그들이다. SYN(연결 시작), ACK(확인 응답), FIN(연결 종료), RST(연결 강제 초기화), PSH(즉시 전달 요청) 등이 있다.
  • Window (16비트) - 수신 측이 현재 받을 수 있는 바이트 수이다. TCP 흐름 제어에서 자세히 다룬다.
  • Checksum (16비트) - 헤더와 데이터의 무결성을 검증하기 위한 값이다. 전송 중 비트가 깨졌는지 수신 측에서 확인한다.
  • Urgent Pointer (16비트) - URG 플래그가 설정된 경우, 긴급 데이터의 끝 위치를 가리킨다. 실무에서는 거의 사용되지 않는다.
  • Options and Padding (가변 길이, 최대 40바이트) - MSS(Maximum Segment Size), Window Scale, Timestamp 등 추가 협상 정보를 담는다. 헤더 크기가 항상 32비트의 배수가 되도록 패딩을 붙인다.

시퀀스 번호로 순서를 보장하는 원리

TCP가 “순서를 보장한다"는 말의 구체적인 메커니즘을 시퀀스 번호를 통해 살펴보자.

클라이언트가 PING\r\nECHO hello\r\n이라는 18바이트를 보낸다고 가정한다. 초기 시퀀스 번호가 1000이라면, TCP 계층은 이 데이터를 여러 세그먼트로 나눠 보낼 수 있다.

sequenceDiagram participant C as 클라이언트 participant S as 서버 Note over C: 초기 시퀀스 번호 = 1000 C->>S: SEQ=1000, 6바이트 "PING\r\n" S-->>C: ACK=1006 (다음엔 1006번부터 보내줘) C->>S: SEQ=1006, 12바이트 "ECHO hello\r\n" S-->>C: ACK=1018 (다음엔 1018번부터 보내줘) Note over S: 수신 버퍼에 순서대로 재조립
"PING\r\nECHO hello\r\n"

첫 번째 세그먼트의 시퀀스 번호는 1000이고 6바이트를 담고 있으므로, 두 번째 세그먼트의 시퀀스 번호는 1006이 된다. 서버는 ACK 번호로 1006을 보내면서 “1005번 바이트까지 잘 받았으니, 1006번부터 보내달라"고 알린다.

만약 네트워크 경로가 달라서 두 번째 세그먼트가 먼저 도착한다면 수신 측 TCP 계층은 시퀀스 번호를 보고 이 세그먼트가 1006번부터 시작한다는 것을 알고, 1000~1005번 바이트가 도착할 때까지 버퍼에 보관해둔다. 이후 첫 번째 세그먼트가 도착하면 순서대로 재조립해서 애플리케이션에 전달한다.

이렇게 TCP는 네트워크의 비순차적 전달을 시퀀스 번호로 복원하지만, 그 결과물은 여전히 경계 없는 바이트 스트림이다. 순서가 복원된 바이트 열에서 메시지를 구분하는 것은 애플리케이션의 몫이다.




커널 공간과 유저 공간

TCP 세그먼트가 어떻게 생겼는지 알았으니, 이제 그 세그먼트가 네트워크 카드에서 애플리케이션 단까지 어떤 경로를 거쳐 전달되는지 살펴보자. 이 과정을 이해하면 왜 시스템콜이 비싸고 왜 버퍼링이 필요한지가 자연스럽게 납득이 된다.


두 개의 세계

운영체제는 메모리를 두 영역으로 나눈다.

  • 커널 공간(Kernel Space) - 운영체제의 핵심 코드가 실행되는 영역이다. 하드웨어에 직접 접근할 수 있고, 모든 메모리 주소를 참조할 수 있다. 네트워크 스택, 파일 시스템, 디바이스 드라이버 등이 여기서 동작한다.

  • 유저 공간(User Space) - 일반 애플리케이션이 실행되는 영역이다. 하드웨어에 직접 접근할 수 없고, 할당받은 메모리만 사용할 수 있다. 필자가 작성한 inmemory-db 서버도 여기서 실행된다.

이렇게 나누는 이유는 보호 때문이다. 만약 아무 프로그램이나 네트워크 카드에 직접 접근하거나 다른 프로세스의 메모리를 읽을 수 있다면 보안과 안정성을 보장할 수 없다. 그래서 운영체제는 유저 공간의 프로그램이 하드웨어 자원에 접근하려면 반드시 시스템콜을 통해 커널에 요청하도록 강제하는 것이다.


시스템콜의 비용

시스템콜이 단순한 함수 호출과 다른 점은 모드 전환(mode switch) 이 발생한다는 것이다.

일반 함수 호출은 같은 유저 공간 안에서 스택 프레임을 쌓고, 점프하고, 돌아오면 끝이다. 하지만 시스템콜은 다음과 같은 과정을 거친다.

  • CPU의 실행 모드를 유저 모드에서 커널 모드로 전환한다
  • 유저 공간의 레지스터 상태를 저장한다
  • 커널 코드를 실행한다
  • 결과를 유저 공간으로 복사한다
  • 레지스터 상태를 복원하고 유저 모드로 되돌아간다

이 과정 자체가 수백 나노초 정도 소요되며, 추가로 CPU 캐시가 오염되면서 간접적인 성능 저하도 생긴다. 한 번의 시스템콜이 대략 수백 나노초라면 사소해 보일 수 있지만 1바이트를 읽을 때마다 시스템콜을 호출하면 이 비용이 수백만 번 반복되면서 치명적일 것이다.


데이터가 네트워크에서 애플리케이션까지 도달하는 경로

클라이언트가 PING\r\n을 보냈을 때, 이 데이터가 서버의 go 애플리케이션에 도달하기까지의 과정을 따라가 보자.

flowchart TB subgraph Network["네트워크"] NIC["NIC (네트워크 카드)"] end subgraph KernelSpace["커널 공간"] Driver["네트워크 드라이버"] IPStack["IP 계층"] TCPStack["TCP 계층"] RecvBuf["소켓 수신 버퍼"] end subgraph UserSpace["유저 공간"] SysCall["read() 시스템콜"] UserBuf["유저 버퍼 ([]byte)"] App["Go 애플리케이션"] end NIC -->|"인터럽트"| Driver Driver -->|"패킷 전달"| IPStack IPStack -->|"IP 헤더 제거"| TCPStack TCPStack -->|"TCP 헤더 제거, 순서 재조립"| RecvBuf RecvBuf -->|"모드 전환"| SysCall SysCall -->|"데이터 복사"| UserBuf UserBuf --> App

순서대로 설명하면 다음과 같다.

  • NIC 가 전기 신호로 패킷을 수신하고 CPU에 인터럽트를 건다
  • 네트워크 드라이버가 패킷을 커널 메모리로 복사한다
  • IP 계층이 IP 헤더를 처리하고 TCP 계층으로 넘긴다
  • TCP 계층이 시퀀스 번호를 확인하고 순서대로 재조립한 뒤 소켓의 수신 버퍼에 저장한다
  • 애플리케이션이 Read()를 호출하면 시스템콜이 발생하고, 커널은 수신 버퍼의 데이터를 유저 공간 버퍼로 복사한다
  • 애플리케이션은 복사된 데이터를 사용한다

핵심은 마지막 두 단계다. 데이터는 커널의 수신 버퍼에 이미 도착해 있지만 애플리케이션이 가져가려면 시스템콜을 통해 커널 공간에서 유저 공간으로 데이터를 복사해야 한다. 이 과정에서 모드 전환 비용이 발생하기 때문에 한 번에 가능한 많은 데이터를 가져오는 것이 효율적이다. 이것이 바로 버퍼링의 동기가 된다.



버퍼링의 원리

직접 Read()의 문제점

conn.Read()를 직접 호출해서 \r\n을 찾는다고 가정해보자. 두 가지 선택지가 있다.

1바이트씩 읽는 방법

for {
	buf := make([]byte, 1)
	conn.Read(buf) // 시스템콜 발생
	
	if buf[0] == '\n' {
		break
	}
}

문제는 Read()가 호출될 때마다 시스템콜이 발생한다는 것이다. 시스템콜은 사용자 모드에서 커널 모드로 전환하는 비용이 들기 때문에 1바이트마다 호출하면 성능이 심각하게 저하된다.

큰 버퍼로 읽는 방법

buf := make([]byte, 4096)
n, _ := conn.Read(buf)
// buf[:n]에서 \r\n을 찾아야 함
// 만약 \r\n이 없다면? 다음 Read()를 해야 함
// 만약 \r\n 뒤에 다음 메시지가 같이 왔다면? 보관해야 함

한 번에 많이 읽으면 시스템콜은 줄지만, 메시지 경계를 넘어서 읽을 수 있다. 다음 메시지의 일부가 함께 들어오면 어딘가에 보관해뒀다가 다음 파싱에 사용해야 한다.
이 문제를 해결하는 것이 버퍼링이다.


bufio.Reader의 내부 구조

go 표준 라이브러리의 bufio.Reader는 위의 두 문제를 모두 해결해 준다.

reader := bufio.NewReader(conn)
line, err := reader.ReadString('\n') // '\n'까지 읽어서 반환

이 한 줄이 내부적으로 하는 일은 다음과 같다.

  1. 버퍼에 데이터가 있는지 확인

  2. 없으면 conn.Read()로 버퍼 채우기

  3. 버퍼에서 \n을 찾기

  4. \n까지의 데이터 반환, 나머지는 버퍼에 보관

  5. \n이 없으면 다시 2번으로

bufio.Reader는 내부적으로 읽기 포인터와 쓰기 포인터를 관리한다. 개념적으로 보면 다음과 같은 구조다.

[  이미 읽은 데이터  |  아직 안 읽은 데이터  |  빈 공간  ]
                     ^r                       ^w
                     읽기 포인터               쓰기 포인터

ReadString('\n')을 호출하면 읽기 포인터(r)부터 쓰기 포인터(w) 사이에서 \n을 찾는다. 찾으면 r부터 \n까지의 데이터를 반환하고 읽기 포인터를 \n 다음으로 이동시킨다. 나머지 데이터는 버퍼에 그대로 남아 있으므로, 다음 ReadString() 호출 시 시스템콜 없이 바로 사용할 수 있다.

만약 버퍼에서 \n을 찾지 못하면 conn.Read()를 호출해서 쓰기 포인터(w) 뒤의 빈 공간을 채운 뒤 다시 검색한다.


기본 버퍼 크기가 4KB인 이유

bufio.NewReader()가 생성하는 기본 버퍼 크기는 4096바이트(4KB)다.

대부분의 운영체제에서 메모리 관리의 기본 단위인 page size 가 4KB이기 때문이다. 운영체제는 메모리를 페이지 단위로 할당하고 관리하므로, 4KB 정렬된 버퍼를 사용하면 메모리 할당과 캐시 효율이 최적화된다.


성능 차이를 숫자로 보기

1MB(1,048,576바이트)의 데이터를 읽는 상황을 비교해보자.

버퍼 없이 1바이트씩 읽는 경우

  • 시스템콜 횟수: 1,048,576회
  • 각 시스템콜마다 모드 전환 발생
  • 예상 소요 시간 (시스템콜 1회당 ~500ns 가정): 약 524ms

bufio.Reader로 4KB씩 읽는 경우

  • 시스템콜 횟수: 1,048,576 / 4,096 = 256회
  • 나머지 바이트는 유저 공간 메모리 접근으로 처리 (수 나노초)
  • 예상 소요 시간: 약 0.13ms + 메모리 접근 시간

같은 데이터를 읽는데 시스템콜 횟수가 4,096배 차이난다.




io.Reader 인터페이스의 설계 철학

Go I/O의 핵심 추상화

bufio.NewReader()의 시그니처를 보면 io.Reader를 받는다.

func NewReader(rd io.Reader) *Reader
type Reader interface {
	Read(p []byte) (n int, err error)
}

Read() 메서드 하나만 있으면 io.Reader를 구현한 것이 된다. net.Conn, os.File, bytes.Buffer, strings.Reader 등 다양한 타입이 이 인터페이스를 구현한다.

이런 점 덕분에 bufio.Reader는 네트워크 연결이든 파일이든 메모리 버퍼든 동일하게 동작한다.


하나의 메서드가 주는 힘

io.Reader에는 메서드가 Read() 딱 하나뿐이다. 이렇게 작은 인터페이스를 설계한 것은 의도적인 선택이다.

인터페이스가 작을수록 구현하기 쉽고 구현하기 쉬울수록 더 많은 타입이 이를 만족시킬 수 있다. 그리고 더 많은 타입이 만족시킬수록 composition의 가능성이 넓어진다.

서버에서 데이터가 흐르는 경로를 보면 이 조합이 실제로 어떻게 작동하는지 알 수 있다.

net.Conn (io.Reader) → bufio.Reader (io.Reader)

net.Connio.Reader를 구현하므로 bufio.NewReader()에 그대로 넣을 수 있다. bufio.Reader는 내부적으로 Read()만 호출하기 때문에 데이터 출처가 TCP 소켓이든 파일이든 신경 쓰지 않는다. 각 계층이 자기 역할만 하면서 자연스럽게 연결되는 구조다.

  • net.Conn - 네트워크에서 바이트를 읽는다
  • bufio.Reader - 시스템콜을 줄이고 ReadString() 같은 편리한 메서드를 제공한다

bufio.Reader 역시 io.Reader를 구현하므로 필요하다면 그 위에 또 다른 Reader를 쌓을 수도 있다.


테스트에서 빛나는 인터페이스

이 설계가 주는 실질적인 이점 중 하나는 네트워크 없이 테스트할 수 있다는 것이다.

bufio.NewReader()net.Conn이 아니라 io.Reader를 받기 때문에 테스트 시 실제 TCP 연결 대신 strings.Readerbytes.Buffer를 넣을 수 있다.

// 실제 서버 코드
bufReader := bufio.NewReader(conn)          // conn = net.Conn

// 테스트 코드 - 네트워크 없이 동일한 동작 검증 가능
input := "*1\r\n$4\r\nPING\r\n"
bufReader := bufio.NewReader(strings.NewReader(input))  // strings.Reader

strings.NewReader()가 반환하는 *strings.ReaderRead() 메서드를 가지고 있으므로 io.Reader를 만족시킨다. bufio.Reader 입장에서는 데이터가 네트워크에서 오든 문자열에서 오든 전혀 알 필요가 없다.


EOF

io.ReaderRead() 메서드는 스트림의 끝에 도달하면 io.EOF 에러를 반환한다.

n, err := reader.Read(buf)

if err == io.EOF {
	// 더 이상 읽을 데이터가 없음
}

TCP 연결에서 EOF는 상대방이 연결을 종료했다는 의미이다. 클라이언트가 conn.Close()를 호출하면 FIN 패킷이 전송되고 서버의 다음 Read()는 EOF를 반환한다.




코드 구현

handleConnection 전체 코드

func (s *Server) handleConnection(conn net.Conn) {
	// 연결 종료 예약
	defer conn.Close()

	reader := bufio.NewReader(conn)

	for {
		read, err := reader.ReadString('\n')
		// EOF면 클라이언트가 연결을 끊은 것이니 루프 탈출 필요
		if err != nil {
			return
		}
		
		// 구분자(\r\n 혹은 \n) 제거
		read = strings.TrimSpace(read)

		// SplitN(타겟 문자열, 구분자, split할 부분문자열의 개수)
		var commandList []string = strings.SplitN(read, " ", 2)
		command := strings.ToUpper(commandList[0])

		switch command {

		case "PING":
			conn.Write([]byte("+PONG\r\n"))

		case "ECHO":
			if len(commandList) > 1 {
				conn.Write([]byte("+" + commandList[1] + "\r\n"))
			} else {
				conn.Write([]byte("-ERR missing argument\r\n"))
			}

		default:
			conn.Write([]byte("-ERR unknown command\r\n"))
		}
	}
}

라인별 Deep Dive

버퍼 Reader 생성

reader := bufio.NewReader(conn)

net.Conn은 Reader 인터페이스를 구현하고 있으므로 bufio.NewReader()에 바로 전달할 수 있다. 이 시점에 4KB 크기의 내부 버퍼가 생성된다.

중요한 점은 이 reader를 연결당 한 번만 생성해야 한다는 것이다. 만약 매번 bufio.NewReader(conn)를 호출하면 이전 버퍼에 남아있던 데이터가 유실된다. bufio.Reader의 내부 구조에서 본 것처럼 읽기 포인터와 쓰기 포인터 사이에 아직 읽지 않은 데이터가 남아있을 수 있기 때문이다.


명령어 읽기 루프

for {
	read, err := reader.ReadString('\n')
	if err != nil {
		return
	}
}

무한 루프에서 ReadString('\n')을 호출한다. 이 메서드는 \n을 만날 때까지 읽고, 구분자를 포함한 문자열을 반환한다.

이 루프가 블로킹되는 시점은 ReadString() 내부에서 reader의 버퍼가 비어있고 conn.Read()를 호출하는 순간이다. 커널의 수신 버퍼에 데이터가 없으면 클라이언트가 다음 명령어를 보낼 때까지 고루틴은 대기 상태에 들어간다.

err != nil인 경우는 두 가지다.

  • io.EOF - 클라이언트가 연결을 종료함

  • 기타 에러 - 네트워크 오류 등

어느 경우든 return으로 함수를 종료하면 defer conn.Close()가 실행되어 리소스가 정리된다.


줄바꿈 처리

read = strings.TrimSpace(read)

ReadString('\n')의 반환값에는 \n이 포함되어 있다. 여기에 \r이 붙어있을 수도 있다. (OS 환경에 따라 다름)

  • macOS/Linux의 netcat은 \n만 전송

  • Windows나 프로토콜 스펙은 \r\n 전송

OS마다 줄바꿈 문자가 다르므로, strings.TrimSpace()로 양쪽 공백 문자를 모두 제거한다. \r, \n, 공백, 탭 등이 한 번에 정리된다. 처음에는 read = strings.TrimSuffix(read, "\r\n") 와 같이 TrimSuffix 를 사용했다가 macOS 환경에서 진행을 하다보니 파싱이 제대로 안됐었다. PING을 입력하면 mac에서는 “PING\n” 을 보내는데 TrimSuffix를 사용하면 타겟 문자열의 접미사 “\r\n” 을 제거 하였다. 실제 요청 명령어는 “PING\n” 이니까 제대로 파싱이 될 수가 없는거다. 그래서 TrimSpace() 를 사용하여 해결했다.


명령어 파싱

var commandList []string = strings.SplitN(read, " ", 2)
command := strings.ToUpper(commandList[0])

strings.SplitN()은 최대 n개의 부분 문자열로 분리한다.

  • "PING"["PING"]

  • "ECHO hello"["ECHO", "hello"]

  • "ECHO hello world"["ECHO", "hello world"] (2개까지만 분리)

strings.ToUpper()로 명령어를 대문자로 변환한다. ping, Ping, PING 모두 동일하게 처리하기 위함이다.


명령어 분기

switch command {

case "PING":
	conn.Write([]byte("+PONG\r\n"))

case "ECHO":
	if len(commandList) > 1 {
		conn.Write([]byte("+" + commandList[1] + "\r\n"))
	} else {
		conn.Write([]byte("-ERR missing argument\r\n"))
	}

default:
	conn.Write([]byte("-ERR unknown command\r\n"))
}

go의 switch는 자동으로 break가 적용되므로 명시적으로 작성하지 않아도 된다.
특이사항으로는 명령어로 ECHO가 들어왔을 경우 배열크기가 1 이하라면 echo만 입력하고 그 뒤에 따라오는 argument를 입력하지 않았기에 에러를 내뱉도록 해주는 것이다.

응답 형식은 RESP(Redis Serialization Protocol)를 따른다.

  • + 접두사 - Simple String (성공 응답)

  • - 접두사 - Error (에러 응답)

  • \r\n - 메시지 종료 구분자




테스트 코드 분석

테스트는 크게 네 가지 시나리오를 검증한다.

  • PING 명령어 응답

  • ECHO 명령어 응답

  • 여러 명령어 순차 처리

  • 에러 응답 처리


PING 명령어 테스트

// 클라이언트가 PING 명령어 전송 후 서버 PONG 응답하는지 검증
func TestReadPingCommand(t *testing.T) {
	// given: 서버 시작
	server := New(":6379")
	go server.Start()
	time.Sleep(time.Second)

	// when: 클라이언트가 연결 후 PING 명령어 전송하고 응답 수신
	conn, err := net.Dial("tcp", "localhost:6379")
	if err != nil {
		t.Fatal("연결 실패")
	}
	defer conn.Close()

	conn.Write([]byte("PING\r\n"))
	reader := bufio.NewReader(conn)
	read, err := reader.ReadString('\n')

	if err != nil {
		t.Fatal("응답 읽기 실패")
	}

	// then: 응답값이 일치한지 검증
	if read != "+PONG\r\n" {
		t.Fatalf("잘못 된 응답 값: %s", read)
	}
}

테스트 클라이언트도 bufio.NewReader를 사용해 서버 응답을 읽는다. 서버와 마찬가지로 TCP 스트림의 특성 때문에 한 번의 Read()로 전체 응답이 온다는 보장이 없기 때문이다.


ECHO 명령어 테스트

// 클라이언트가 "ECHO ..." 명령어 전송 후 서버가 정상적으로 응답하는지 검증
func TestReadEchoCommand(t *testing.T) {
	// given: 서버 시작
	server := New(":6379")
	go server.Start()
	time.Sleep(time.Second)

	// when: 클라이언트가 연결 후 echo 명령어 전송하고 응답 수신
	conn, err := net.Dial("tcp", "localhost:6379")
	if err != nil {
		t.Fatal("연결 실패")
	}
	defer conn.Close()

	conn.Write([]byte("ECHO HELLO\r\n"))
	reader := bufio.NewReader(conn)
	read, err := reader.ReadString('\n')

	if err != nil {
		t.Fatal("응답 읽기 실패")
	}

	// then: 응답값이 일치한지 검증
	if read != "+HELLO\r\n" {
		t.Fatalf("잘못 된 응답 값: %s", read)
	}
}

위에 작성된 PING 명령어 테스트와 입력 명령어, 검증 값만 다를 뿐 구조는 똑같다.

여러 명령어 순차 처리

// 여러 명령어를 수신 후 모든 명령어에 대하여 정상적으로 응답하는지 검증
func TestMultipleCommands(t *testing.T) {
	// given: 서버 시작
	server := New(":6379")
	go server.Start()
	time.Sleep(time.Second)

	// when: 클라이언트가 연결 후 여러 명령어를 순차적으로 전송
	conn, err := net.Dial("tcp", "localhost:6379")
	if err != nil {
		t.Fatal("연결 실패")
	}
	defer conn.Close()

	// 첫 번째 명령어 PING
	conn.Write([]byte("PING\r\n"))
	reader := bufio.NewReader(conn)
	read, _ := reader.ReadString('\n')

	if read != "+PONG\r\n" {
		t.Fatalf("잘못 된 응답 값: %s", read)
	}

	// 두 번째 명령어 ECHO HELLO
	conn.Write([]byte("ECHO HELLO\r\n"))
	read2, _ := reader.ReadString('\n')

	if read2 != "+HELLO\r\n" {
		t.Fatalf("잘못 된 응답 값: %s", read2)
	}

	// 세 번째 명령어 PING
	conn.Write([]byte("PING\r\n"))
	read3, _ := reader.ReadString('\n')

	if read3 != "+PONG\r\n" {
		t.Fatalf("잘못 된 응답 값: %s", read3)
	}
}

핵심은 하나의 연결에서 여러 명령어를 순차적으로 보내고 받는다는 것이다. 서버가 무한 루프로 명령어를 계속 읽고 있기 때문에 클라이언트는 연결을 끊지 않고 여러 요청을 보낼 수 있다.

bufio.NewReader를 한 번만 생성하고 재사용하는 것도 중요하다. 매번 새로 만들면 내부 버퍼에 남아있던 이전 응답 데이터가 유실될 수 있기 때문이다.


에러 응답 테스트

// 클라이언트가 서버가 알 수 없는 명령어를 전송 후 서버가 에러를 정상적으로 응답하는지 검증
func TestInvalidCommand(t *testing.T) {
	// given: 서버 시작
	server := New(":6379")
	go server.Start()
	time.Sleep(time.Second)

	conn, err := net.Dial("tcp", "localhost:6379")
	if err != nil {
		t.Fatal("연결 실패")
	}
	defer conn.Close()

	// when: 클라이언트가 ECHO 명령어를 보내는데 인자를 작성하지 않았을 때
	conn.Write([]byte("ECHO\r\n"))
	reader := bufio.NewReader(conn)
	read, _ := reader.ReadString('\n')

	// then: 에러 응답 검증
	if read != "-ERR missing argument\r\n" {
		t.Fatalf("잘못 된 응답 값: %s", read)
	}

	// when: 클라이언트가 서버에서 알 수 없는 명령어를 보냈을 때
	conn.Write([]byte("HELLO\r\n"))
	read2, _ := reader.ReadString('\n')

	// then: 에러 응답 검증
	if read2 != "-ERR unknown command\r\n" {
		t.Fatalf("잘못 된 응답 값: %s", read2)
	}
}

에러 케이스도 정상적인 흐름이다. 클라이언트가 잘못된 명령어를 보내도 서버가 죽지 않고 에러 응답을 반환한 뒤 계속 동작해야 한다.




TCP 흐름 제어와 수신 버퍼

TCP 세그먼트 헤더의 Window 필드가 TCP의 흐름 제어(flow control) 메커니즘의 핵심이다. 서버가 Read()를 호출하며 데이터를 가져가는 과정과 직접적으로 연결되는 개념이므로 짚고 넘어갈 필요가 있다.


흐름 제어란

송신 측이 무작정 빠르게 데이터를 보내면 수신 측이 처리하지 못하고 데이터가 유실될 수 있다. TCP의 흐름 제어는 수신 측이 감당할 수 있는 속도에 맞춰 송신 속도를 조절하는 메커니즘이다.

수신 측은 자신의 수신 버퍼에 남은 여유 공간을 매 ACK 패킷의 Window 필드에 담아 송신 측에 알려준다. 송신 측은 이 값을 초과해서 데이터를 보내지 않는다.

sequenceDiagram participant C as 클라이언트 (송신) participant S as 서버 (수신) Note over S: 수신 버퍼 크기 = 65535 bytes C->>S: 데이터 10000 bytes S-->>C: ACK, Window=55535 Note over S: 버퍼 사용량 10000 / 65535 C->>S: 데이터 30000 bytes S-->>C: ACK, Window=25535 Note over S: 버퍼 사용량 40000 / 65535 Note over S: 애플리케이션이 Read()로
20000 bytes를 가져감 S-->>C: ACK, Window=45535 Note over S: 버퍼 사용량 20000 / 65535

서버가 데이터를 수신하면 윈도우가 줄어들고, 애플리케이션이 Read()로 데이터를 가져가면 윈도우가 다시 늘어난다. 송신 측은 윈도우 값을 보고 보낼 수 있는 양을 조절한다.


애플리케이션이 느리면 무슨 일이 벌어지는가

서버의 handleConnection()이 명령어 처리에 시간이 오래 걸리거나, Read()를 충분히 자주 호출하지 않으면 커널의 수신 버퍼가 계속 차올라 결국 꽉 차게 된다. 이때 서버가 보내는 ACK 패킷의 Window 값은 0이 된다. 이 상태를 Zero Window라고 한다.

  • 송신 측(클라이언트)은 Window=0을 받으면 데이터 전송을 멈춘다
  • 대신 주기적으로 Zero Window Probe 패킷을 보내 수신 측의 버퍼가 비었는지 확인한다
  • 서버가 Read()로 데이터를 가져가서 버퍼에 여유가 생기면, Window > 0인 ACK를 보내고 전송이 재개된다

이 메커니즘은 현재 구현하고 있는 서버에서도 의미가 있다. for 루프에서 reader.ReadString('\n')을 호출하며 계속 데이터를 소비하고 있기 때문에 수신 버퍼가 꽉 차는 일은 정상적인 상황에서는 거의 일어나지 않는다. 하지만 만약 명령어 처리 로직이 오래 걸리는 연산(예를 들어 디스크에 데이터를 쓰는 영속성 작업)을 포함한다면, 그 동안 Read()가 호출되지 않아 수신 버퍼가 쌓이고 클라이언트 입장에서는 전송이 느려지는 back pressure가 자연스럽게 발생한다.

TCP가 별도의 애플리케이션 로직 없이도 흐름 제어를 해준다는 사실은 왜 데이터베이스가 TCP를 선택하는지 설명해주는 것 같다.

마치며

이번 포스팅에서는 “클라이언트가 보낸 메시지를 읽는다"는 겉보기에 단순한 과제 안에 TCP 세그먼트의 시퀀스 번호, 커널과 유저 공간 사이의 데이터 복사, 시스템콜의 비용, 버퍼링의 필요성, 그리고 Go의 인터페이스의 설계 철학까지 다양한 CS 개념을 배운 것 같다.

개인적으로 인상 깊었던 부분은 bufio.Reader 하나가 해결하는 문제의 범위다. 시스템콜 최소화, 메시지 경계 파싱, 미사용 데이터 보관이라는 세 가지 문제를 내부 버퍼와 포인터 관리만으로 깔끔하게 풀어낸다. 표준 라이브러리가 얼마나 정교하게 설계되어 있는지 보여주는 훌륭한 사례라 생각한다.

그리고 굳이 TCP 흐름 제어까지 살펴본 이유는, 우리 서버의 Read() 호출이 단순히 “데이터를 읽는다"는 것 이상의 의미를 갖기 때문이다. 애플리케이션이 데이터를 소비하는 속도가 곧 수신 윈도우 크기를 결정하고 이 점이 결국 송신 측의 전송 속도까지 제어한다. 커널이 알아서 처리해주는 메커니즘이지만, 이 원리를 알고 있어야 나중에 성능 문제가 생겼을 때 원인을 추적할 수 있을 것이라 생각한다.

마지막으로 현재 구현한 명령어 형식은 매우 단순하다. PING, ECHO hello 같은 한 줄짜리 텍스트만 처리할 수 있다. 하지만 실제 Redis는 RESP라는 더 정교한 프로토콜을 사용한다.

*2\r\n

$4\r\n

ECHO\r\n

$5\r\n

hello\r\n

이렇게 배열 크기, 문자열 길이를 명시하는 방식이다. 다음 포스팅에서는 이 RESP 프로토콜을 직접 구현해볼 예정이다.


참고