들어가며
요즘 대부분의 개발자들이 바이브 코딩 또는 못해도 gpt나 클로드, 제미나이 같은 LLM을 사용해서 개발을 할 것이다. AI가 코드를 구현해주면 개발자의 검증이 반드시 필요한데 성능 문제, 비즈니스 및 도메인 지식, 소프트웨어 구조 설계 등이 AI 시대의 개발자로서 필요한 역량이라고 생각한다. 코드가 그냥 굴러만 가더라도, 병목현상이 벌어지는 구간이 어디인지 검증을 해봐야하고 비즈니스가 어떻게 흘러가는지와 도메인 지식을 결합하여 코드를 어떻게 처리해야 좋을지, 그리고 복합적으로 따져봤을 때 최적의 아키텍처나 구조 등을 직접 결정해야할 것이다.
나는 이런 AI 시대에 지속가능한(?) 개발자로 살아남기 위해서 잘 바뀌지 않는 근본 지식을 탄탄하게 하고자 생각했다. 예를들면 CS, 디자인패턴, 프로그래밍 패러다임 등등 말이다. 이 중에서 CS지식을 깊이있게 알고있다면 병목현상이 나는 부분을 찾아서 디버깅 할 때 유리할 것이고, 디자인패턴과 프로그래밍 패러다임 등을 알아두면 소프트웨어 아키텍처 설계를 할 때 매우 도움이 될 것이다. 그래서 제일 기본적인 CS를 공부하기 위해서 그냥 책을 보는건 재미가 없을 것 같고 한 번 특정 프로젝트를 하면서 배워보면 재밌지 않을까 라는 단순한 생각에 레디스와 같은 인메모리 데이터베이스를 직접 구현해보면 어떨까 생각들었다.
이 프로젝트를 만들어나가는 과정에서 네트워크, OS, DB, 자료구조의 모든 부분을 커버할 수는 없겠지만 그래도 인메모리 데이터베이스를 구현해 나가는 과정에서 TCP 소켓 통신, 프로토콜 설계, 동시성 제어, 해시 테이블 같은 CS 주제들을 자연스럽게 만나게 된다. 단순히 책으로 개념을 읽고 넘어가는 것이 아니라 코드로 직접 구현해보며 체득하는 것이 이 시리즈의 목표이다.
구현 언어로는 Go를 선택했다. 익숙한 자바를 두고 굳이 Go를 고른 이유는 딱히 큰 이유는 없다. 그냥 무언가 계속 새로운걸 도전해보고 싶었다. 최근 해보고싶었던 것 중에 하나가 golang으로 개발하는 것이었다.
시작하기 앞서, 이 시리즈의 모든 포스팅에서는 이론적인 부분을 먼저 설명하고 그 후에 구현한 코드가 어떻게 이론과 맞물리는지를 포스팅 해나갈 예정이다. 또한, golang을 처음 사용해보기 때문에 문법 및 특징에 대해서도 덧붙여 작성할 것이다. 그럼 바로 시작해보자.
네트워크 통신의 추상화
Redis, MySQL, PostgreSQL 같은 데이터베이스들의 공통점은 바로 소켓을 통해 클라이언트와 통신한다는 점이다.
소켓은 1983년 BSD Unix 4.2에서 처음 도입된 이후, 40년이 넘도록 네트워크 프로그래밍의 사실상 표준으로 자리 잡고 있다. 소켓은 복잡한 네트워크 계층 구조를 단순한 파일 읽기/쓰기 인터페이스로 추상화해준다.
OSI 7계층 모델. 소켓은 Transport Layer(4계층)에서 동작하며, 상위 계층에게 단순한 인터페이스를 제공한다.
소켓 API를 호출하면 그 아래에서는 각 계층이 자기 역할을 수행한다.
- Transport Layer (TCP) - 데이터를 세그먼트로 분할하고 순서 번호를 부여하며, 손실된 패킷을 재전송한다. 흐름 제어와 혼잡 제어도 이 계층의 책임이다
- Network Layer (IP) - 세그먼트에 출발지/목적지 IP 주소를 붙여 패킷을 만들고 라우팅 테이블을 참조해 다음 hop을 결정한다
- Data Link Layer (Ethernet) - 패킷을 프레임으로 감싸고 MAC 주소를 붙인다. CRC 체크섬으로 물리적 전송 오류를 감지한다
- Physical Layer - 프레임을 전기 신호, 광 신호, 혹은 무선 전파로 변환해 실제 매체를 통해 전송한다
왜 TCP인가
데이터베이스가 UDP 대신 TCP를 선택하는 이유는 명확하다.
| 특성 | TCP | UDP |
|---|---|---|
| 연결 | 연결 지향 (Connection-oriented) | 비연결 (Connectionless) |
| 신뢰성 | 패킷 손실 시 재전송 보장 | 보장 없음 |
| 순서 | 순서 보장 | 순서 보장 없음 |
| 용도 | DB, 웹, 이메일 | 스트리밍, 게임, DNS |
데이터베이스에서 SET key value 명령이 누락되거나 순서가 바뀌면 데이터 무결성이 깨진다. 따라서 신뢰성과 순서가 보장되는 TCP는 필수적인 선택이다.
소켓의 본질
Unix 철학
Unix/Linux에서 소켓은 파일 디스크립터로 표현된다. 운영체제 관점에서 보면 소켓도 결국 하나의 파일이다.
파일 디스크립터는 유닉스 계열 운영체제에서 프로세스가 파일이나 기타 I/O 리소스를 식별하기 위해 사용하는 추상적인 번호이다.
프로세스가 socket() 시스템 콜을 호출하면 커널은 새로운 파일 디스크립터를 할당한다.
0번은 표준 입력(stdin)
1번은 표준 출력(stdout)
2번은 표준 에러(stderr)
이미 0, 1, 2번이 차지되어 있으므로 새로 생성된 소켓은 보통 3번부터 시작한다. 클라이언트 연결이 들어올 때마다 4번, 5번, 6번… 이렇게 새로운 파일 디스크립터가 할당된다.
Linux 커널의 파일 디스크립터 테이블 구조
이러한 설계가 주는 이점은 크게 세 가지다.
read(),write(),close()같은 함수로 파일이든 소켓이든 동일하게 다룰 수 있다파일 디스크립터는 유한한 자원이므로, 닫지 않으면 FD 누수가 발생한다
이것이 go에서
defer conn.Close()가 중요한 이유다
소켓 생성의 내부 과정
net.Listen("tcp", ":6379")를 호출하면 커널에서는 다음과 같은 일이 일어난다.
SYN Queue, Accept Queue 생성 Kernel-->>App: 성공 또는 에러
각 시스템 콜이 하는 일을 좀 더 자세히 풀어보겠다.
- socket(AF_INET, SOCK_STREAM, 0)
AF_INET (Address Family IPv4)- 어떤 네트워크 주소 체계를 쓸 것인가를 정한다. 우리가 흔히 쓰는 IPv4(e.g. 127.0.0.1) 주소를 사용하겠다는 뜻이다SOCK_STREAM- 어떤 방식으로 데이터를 주고받을 것인가를 정한다. 바이트 스트림 기반의 TCP 방식을 의미한다
- fd = 3
- 커널이 생성된 소켓에 부여한 식별 번호다. 파일 디스크립터가 바로 이것이다
- 0(입력), 1(출력), 2(에러)는 이미 사용 중이므로 새로 만든 소켓은 보통 3번부터 할당받는다. 이후 모든 소켓 작업(
bind,listen,accept)에서 애플리케이션은 이 “3번"이라는 번호를 통해 커널과 소통한다
- bind(fd, {0.0.0.0:6379})
- 소켓에 특정 주소와 포트를 묶는 과정이다. 이 호출이 성공하면 커널의 포트 테이블에 해당 포트가 이 소켓의 것으로 등록된다
- listen(fd, backlog=128)
- 소켓을 LISTEN 상태로 전환하고, 연결 요청을 받을 준비를 한다
backlog=128은 동시에 연결을 기다릴 수 있는 최대 대기열의 크기를 의미한다. 식당에 비유하면 대기 명단에 적을 수 있는 최대 인원수다. 이 숫자가 찼을 때 들어오는 연결 요청은 거절될 수 있다- 이 시점에 커널은 SYN Queue와 Accept Queue를 생성한다
요약하자면 go의 net.Listen() 한 줄 뒤에서, 애플리케이션은 fd 3번이라는 통로를 만들고 IPv4/TCP 방식을 선택한 뒤 6379번 포트를 점유하고 128명까지 대기할 수 있는 공간을 마련하여 클라이언트의 접속을 기다리는 상태가 된 것이다.
포트 바인딩의 의미
포트 6379에 바인딩한다는 것은 커널의 포트 테이블에 등록하여 해당 포트로 들어오는 패킷을 이 소켓으로 라우팅하겠다는 의미다.
한 번 바인딩되면 배타적으로 점유된다
다른 프로세스가 같은 포트를 사용하려 하면
EADDRINUSE에러가 발생한다0-1023 범위의 Well-known Port는 root 권한이 필요하지만, 6379는 User Port 범위라 일반 사용자도 사용할 수 있다
포트가 이미 사용 중일 때 흔히 보는 에러 메시지는 다음과 같다.
$ go run main.go
listen tcp :6379: bind: address already in use
이 경우 lsof -i :6379 명령으로 해당 포트를 점유하고 있는 프로세스를 확인하고 종료하면 된다.
TCP 3-way Handshake
왜 3번이어야 하는가
TCP 연결이 신뢰성을 보장하려면, 양쪽 모두 송신 능력과 수신 능력이 있음을 확인해야 한다.

TCP 3-way Handshake 과정. SYN → SYN-ACK → ACK 순서로 진행된다.
이 과정을 풀어서 설명하면 다음과 같다.
SYN - 클라이언트가 “나 보낼 수 있어, 내 시퀀스 번호는 1000이야"라고 알린다
SYN-ACK - 서버가 “나도 보낼 수 있어(SYN), 1000번 잘 받았어 다음엔 1001 보내줘(ACK)“라고 응답한다
ACK - 클라이언트가 “시퀀스 번호 잘 받았어"라고 확인한다
2-way로는 충분하지 않다. 클라이언트가 SYN을 보내고 서버가 SYN-ACK를 보내는 것까지는 서버의 수신 능력과 송신 능력이 검증된다. 하지만 클라이언트의 수신 능력은 아직 검증되지 않은 상태다. 클라이언트가 마지막 ACK를 보내야 비로소 서버는 “이 클라이언트는 내 응답을 제대로 받을 수 있구나"라고 확신할 수 있다. 그래서 3번이다.
TCP 상태 머신
TCP 연결은 하나의 유한 상태 머신으로 모델링된다. 소켓은 항상 특정 상태에 있으며, 이벤트(패킷 수신, 시스템 콜 호출 등)에 의해 다른 상태로 전이된다.
TCP 상태 전이 다이어그램. 모든 TCP 연결은 CLOSED에서 시작하여 CLOSED로 돌아온다.
위 다이어그램이 한 눈에 들어오지 않을 수 있다. 연결 수립과 연결 종료를 나눠서 살펴보겠다.
연결 수립 단계의 상태
서버 쪽과 클라이언트 쪽의 상태 전이를 3-way handshake 과정과 함께 정리하면 다음과 같다.
![]()
연결 수립 과정
서버 측 상태 전이
CLOSED → (listen() 호출) → LISTEN → (SYN 수신) → SYN_RECEIVED → (ACK 수신) → ESTABLISHED
클라이언트 측 상태 전이
CLOSED → (connect() 호출, SYN 전송) → SYN_SENT → (SYN-ACK 수신, ACK 전송) → ESTABLISHED
양쪽 모두 ESTABLISHED 상태에 도달해야 데이터 전송이 가능하다.
![]()
연결 종료 과정
연결 종료 단계의 상태
TCP 연결 종료는 4-way Handshake로 이루어진다. 연결 수립보다 한 단계가 더 필요한 이유는 양쪽이 독립적으로 전송을 종료해야 하기 때문이다. 한쪽이 보낼 데이터가 끝났다고 해서 상대방도 끝난 것은 아니다.
먼저 연결을 끊으려는 쪽(Active Close)의 상태 전이를 보겠다.
ESTABLISHED → (FIN 전송) → FIN_WAIT_1 → (ACK 수신) → FIN_WAIT_2 → (FIN 수신, ACK 전송) → TIME_WAIT → (2MSL 대기) → CLOSED
상대방 쪽(Passive Close)의 상태 전이는 이렇다.
ESTABLISHED → (FIN 수신, ACK 전송) → CLOSE_WAIT → (close() 호출, FIN 전송) → LAST_ACK → (ACK 수신) → CLOSED
TIME_WAIT과 CLOSE_WAIT
이 두 상태는 서버를 운영할 때 자주 만나게 된다. 각각이 왜 존재하는지 이해하면 실전에서 네트워크 문제를 디버깅할 때 큰 도움이 된다.
TIME_WAIT
연결을 먼저 끊은 쪽이 진입하는 상태다. 마지막 ACK가 상대방에게 도착했는지 확신할 수 없기 때문에 일정 시간(2 * MSL, 보통 60초) 동안 기다린다. 만약 마지막 ACK가 유실되면 상대방이 FIN을 재전송할 것이고, TIME_WAIT 상태에서 이를 다시 처리할 수 있다.
서버를 재시작했을 때 “address already in use” 에러가 발생하는 경우가 있는데, 이전 연결의 TIME_WAIT이 아직 남아있기 때문이다. go에서는 SO_REUSEADDR 소켓 옵션을 설정하면 TIME_WAIT 상태의 포트도 재사용할 수 있다.
CLOSE_WAIT
FIN을 수신한 쪽에서 아직 close()를 호출하지 않은 상태다. 이 상태가 많이 쌓여 있다면 애플리케이션 코드에서 연결을 제때 닫지 않고 있다는 신호다. Go에서 defer conn.Close()를 빠뜨리면 CLOSE_WAIT 상태의 소켓이 쌓이게 된다.
왜 상태를 알아야 하는가
터미널에서 netstat -an | grep 6379를 실행하면 다음과 같은 출력을 볼 수 있다.
tcp 0 0 0.0.0.0:6379 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:6379 127.0.0.1:52301 ESTABLISHED
tcp 0 0 127.0.0.1:52301 127.0.0.1:6379 TIME_WAIT
마지막 column이 TCP 상태다. CLOSE_WAIT이 수백 개 쌓여 있다면 FD 누수를 의심해야 하고 TIME_WAIT이 과도하게 많다면 서버가 연결을 먼저 끊는 구조인지 확인해 봐야 한다. 상태 머신을 모르면 이 출력을 보고도 아무런 판단을 내릴 수 없다.
SYN Queue와 Accept Queue
서버가 listen() 상태일 때, 커널은 두 개의 큐를 관리 한다.
- SYN Queue (Half-Open Connection Queue)
- 클라이언트가
SYN(연결 요청)을 보냈지만 아직 완전히 연결되지 않은 상태의 요청들이 머무는 곳이다. - 애플리케이션이 응답(
SYN-ACK)을 보낸 후 클라이언트의 마지막 확인(ACK)을 기다리는 단계이다.
- 클라이언트가
- Accept Queue (Established Connection Queue)
- 3-Way Handshake가 완전히 끝나서 즉시 통신할 준비가 된 연결들이 머무는 곳이다.
- 애플리케이션이
accept()함수를 호출하면 커널은 이 큐에서 완성된 연결을 하나씩 꺼내어 앱에게 전달해준다.
큐가 넘치면 어떻게 되는가
두 큐 모두 크기 제한이 있다. 넘치면 어떤 일이 벌어지는지 알아보겠다.
Accept Queue 오버플로우
Accept Queue가 가득 찬 상태에서 새로운 3-way Handshake가 완료되면, 커널은 완료된 연결을 넣을 곳이 없다. 이 경우 Linux 커널의 기본 동작은 해당 연결의 마지막 ACK를 무시하는 것이다. 클라이언트는 이미 ESTABLISHED 상태라고 생각하지만 서버 측에서는 연결이 완료 처리되지 않는다. 결국 클라이언트는 타임아웃을 경험하게 된다.
이 큐의 크기는 listen() 시스템 콜의 backlog 파라미터와 커널 파라미터 net.core.somaxconn 중 작은 값으로 결정된다. Linux 기본값은 4096이다.
# Accept Queue 최대 크기 확인 (Linux)
$ sysctl net.core.somaxconn
net.core.somaxconn = 4096
SYN Queue 오버플로우
SYN Queue가 가득 찬 상태에서 새로운 SYN이 들어오면 서버는 해당 요청을 무시한다. 정상적인 트래픽 폭주라면 큐 크기를 늘려 해결할 수 있지만, 악의적인 공격이라면 이야기가 달라진다.
SYN Queue의 크기는 커널 파라미터 net.ipv4.tcp_max_syn_backlog로 설정된다.
# SYN Queue 최대 크기 확인 (Linux)
$ sysctl net.ipv4.tcp_max_syn_backlog
net.ipv4.tcp_max_syn_backlog = 1024
SYN Flood 공격과 SYN Cookies
악의적인 클라이언트가 SYN만 대량으로 보내고 ACK를 보내지 않으면 SYN Queue가 가득 차서 정상 연결을 거부하게 된다. 이것이 SYN Flood 공격이라 불리는 DDoS 공격의 한 유형이다.
공격의 핵심은 단순하다. TCP 연결 수립에는 서버 측 자원(SYN Queue 슬롯)이 필요한데, 공격자는 SYN을 보내는 데 자원이 거의 들지 않는다. 비대칭적인 자원 소모를 이용하는 것이다.
이에 대한 방어 메커니즘이 SYN Cookies다. SYN Cookies가 활성화되면 커널은 SYN Queue에 연결 정보를 저장하지 않는다. 대신 SYN-ACK의 시퀀스 번호 자체에 연결 정보(타임스탬프, MSS, 출발지 IP/Port의 해시 등)를 암호학적으로 인코딩한다.
클라이언트가 정상적으로 ACK를 보내오면 서버는 그 ACK 번호에서 원래의 연결 정보를 복원할 수 있다. 결과적으로 SYN Queue를 전혀 사용하지 않고도 정상적인 연결은 수립된다. 정상 클라이언트 입장에서는 달라지는 것이 없고 공격자가 보낸 반쯤 열린 연결은 서버 자원을 소모하지 않게 되는 것이다.
# SYN Cookies 활성화 확인 (Linux)
$ sysctl net.ipv4.tcp_syncookies
net.ipv4.tcp_syncookies = 1
서버 소켓의 생애주기
Listening Socket vs Connection Socket
이 두 가지 구분은 매우 중요하다.
Listening Socket은 문지기 역할을 한다.
포트 6379에서 새 연결을 기다린다
직접 데이터를 주고받지는 않는다
프로세스당 포트당 하나만 존재한다
Connection Socket은 accept() 호출 시마다 새로 생성된다.
실제로 클라이언트와 데이터를 주고받는다
각 Connection Socket은 4-tuple로 유일하게 식별된다
Local IP, Local Port, Remote IP, Remote Port
fd = 3, 포트 6379
'문지기 역할'"] LS -->|"accept()"| CS1["Connection Socket
fd = 4 ↔ Client A"] LS -->|"accept()"| CS2["Connection Socket
fd = 5 ↔ Client B"] LS -->|"accept()"| CS3["Connection Socket
fd = 6 ↔ Client C"] end
Blocking I/O와 프로세스 스케줄링
accept()에서 블로킹이 일어나면
conn, err := listener.Accept() // <- 여기서 멈춘다
이 코드가 실행될 때 Accept Queue가 비어 있으면 프로세스는 더 이상 할 일이 없다. 이때 운영체제는 해당 프로세스를 Sleep(Waiting) 상태로 전환한다.
프로세스의 세 가지 상태
운영체제에서 프로세스는 크게 세 가지 상태를 가진다.
- Running - CPU를 점유하고 명령어를 실행 중인 상태
- Ready - 실행할 준비가 되었지만 CPU 할당을 기다리는 상태. 실행 큐(Run Queue)에 들어가 있다
- Sleeping (Waiting/Blocked) - I/O 등 외부 이벤트를 기다리는 상태. CPU를 양보하고 대기 큐(Wait Queue)에 들어간다
accept()를 호출한 프로세스는 Running 상태에서 Sleeping 상태로 전환된다. 이때 커널은 이 프로세스를 소켓의 **대기 큐(Wait Queue)**에 등록해 둔다. CPU 입장에서는 이 프로세스가 사라진 것이나 마찬가지다. 다른 프로세스에게 CPU 시간을 넘겨줄 수 있으므로 자원 낭비는 발생하지 않는다.
커널이 프로세스를 깨우는 방법
클라이언트가 연결을 시도하면 SYN 패킷이 도착하고 3-way handshake가 완료되면 해당 연결이 accept 큐에 들어간다. 이 시점에 커널은 소켓의 대기 큐에서 sleeping 상태의 프로세스를 찾아 ready 상태로 전환한다.
ready 상태가 된 프로세스는 스케줄러에 의해 CPU를 다시 할당받으면 running 상태로 돌아오며, accept()는 accept 큐에서 연결을 꺼내 반환한다. 프로세스 입장에서는 accept()를 호출한 직후에 바로 결과를 받은 것처럼 보인다. 중간에 잠들었다 깨어난 과정은 완전히 투명하게 처리된다.
왜 블로킹이 문제인가
현재 구현한 서버에서 accept() 다음 handleConnection()을 호출하면, 그 함수가 끝나야 다시 accept()로 돌아온다. 그 사이에 도착한 클라이언트들은 accept 큐에서 대기해야 한다.
이것이 나중에 고루틴이 필요한 이유다. handleConnection()을 별도의 고루틴에서 실행하면 메인 고루틴은 즉시 다음 accept()로 돌아갈 수 있다. 한 클라이언트를 처리하는 동안에도 다른 클라이언트의 연결을 계속 받을 수 있게 되는 것이다.
코드 구현
server.go 전체 코드
package server
import (
"log"
"net"
)
type Server struct {
listener net.Listener
addr string
}
func New(addr string) *Server {
return &Server{addr: addr}
}
func (s *Server) Start() error {
listener, err := net.Listen("tcp", s.addr)
if err != nil {
return err
}
s.listener = listener
defer s.listener.Close()
log.Printf("현재 서버가 [%s] 에서 리스닝중입니다.", s.addr)
for {
conn, err := s.listener.Accept()
if err != nil {
log.Print("연결 중 오류: ", err)
continue
} else {
s.handleConnection(conn)
}
}
}
func (s *Server) handleConnection(conn net.Conn) {
defer conn.Close()
conn.Write([]byte("+PONG\r\n"))
log.Printf("클라이언트가 성공적으로 연결되었습니다: %s", conn.RemoteAddr())
}
라인별 Deep Dive
구조체 정의
type Server struct {
listener net.Listener // Listening Socket 인터페이스
addr string // 바인딩 주소
}
net.Listener는 go 표준 라이브러리에서 정의한 인터페이스로, Accept(), Close(), Addr() 메서드를 가진다. Listening Socket 을 go에서 추상화한 것이 바로 이 인터페이스다.
생성자 함수
func New(addr string) *Server {
return &Server{addr: addr}
}
Go에는 생성자가 없으므로 New 또는 NewXxx 패턴을 사용하는 것이 관례이다.
포인터(
*Server)를 반환하는 이유는 구조체 복사를 방지하고, 메서드에서 상태 변경이 가능하도록 하기 위함이다&Server{...}문법은 구조체 리터럴을 생성하고 그 주소를 반환한다
서버 시작
func (s *Server) Start() error {
listener, err := net.Listen("tcp", s.addr) // 1
if err != nil {
return err // 2
}
s.listener = listener // 3
defer s.listener.Close() // 4
각 라인에서 일어나는 일을 정리하면 다음과 같다.
1 -
net.Listen()호출 시 내부적으로socket(),bind(),listen()시스템콜이 순차적으로 실행된다. 소켓 생성의 내부 과정에서 본 시퀀스 다이어그램이 이 한 줄에서 일어나는 것이다.2 - 포트 충돌 등과 같은 에러를 체크한다
3 - 리스너를 저장한다. 나중에
Close()를 호출하기 위함이다4 -
defer는 함수 종료 시 FD 해제를 보장한다.
Accept 루프
for {
conn, err := s.listener.Accept() // 1
if err != nil {
log.Print("연결 중 오류: ", err)
continue // 2
} else {
s.handleConnection(conn) // 3
}
}
1 -
Accept()는 Accept Queue에서 3-way handshake가 완료된 연결을 하나 꺼내온다. 큐가 비어있으면 프로세스가 Sleep 상태에 들어간다.2 -
return이 아닌continue를 사용하는 것이 중요하다. 일시적인 네트워크 문제로 서버 전체가 죽으면 안 되기 때문이다.3 - 반환된
conn은 Connection Socket이다. 이 소켓을 통해 실제 클라이언트와 데이터를 주고받는다.
연결 처리
func (s *Server) handleConnection(conn net.Conn) {
defer conn.Close() // 1
conn.Write([]byte("+PONG\r\n")) // 2
log.Printf("클라이언트가 성공적으로 연결되었습니다: %s", conn.RemoteAddr())
}
각 클라이언트와의 연결이 수립될 때마다 새로운 FD가 할당된다.
1번의 defer conn.Close()가 없다면, 함수 중간에 에러로 return될 경우 연결이 닫히지 않아 FD 누수 가 발생한다. defer를 사용하면 함수가 어떻게 종료되든 반드시 Close()가 호출되어 리소스를 반환한다.
2번에서 +PONG\r\n은 RESP(Redis Serialization Protocol) 형식이다.
- 맨 앞의
+는 Simple String 타입을 나타낸다. \r\n(CRLF)은 네트워크 프로토콜의 표준 줄바꿈이다.
Write() 메서드가 string이 아닌 []byte를 받는 이유는 go의 표준 I/O 인터페이스(io.Writer)를 따르기 위함이다. 네트워크는 본질적으로 텍스트가 아닌 바이트 스트림으로 데이터를 전송하므로, 바이트 슬라이스를 사용하는 것이 가장 직관적이고 효율적이다.
테스트 코드 분석
테스트는 단순한 것부터 복잡한 것 순으로 진행한다.
- 서버 인스턴스 생성 테스트
- 포트 리스닝 테스트
- PONG 응답 테스트
- 연결 종료 테스트
위 순서대로 진행 예정이다.
서버 인스턴스 생성 테스트
// New(":6379")가 nil이 아닌 Server를 반환하는지
func TestServerCreation(t *testing.T) {
// given: 바인딩할 주소
addr := ":6379"
// when: 서버 인스턴스 생성
server := New(addr)
// then: nil이 아니어야 함
if server == nil {
t.Fatalf("actual: %s, expected: 서버인스턴스", server)
}
}
간단하게 server가 nil 이 아니라면 서버 인스턴스가 정상적으로 생성되었다는 판단 하에 통과하는 테스트이다.
포트 리스닝 테스트
func TestServerListensOnPort(t *testing.T) {
server := New(":6379")
go server.Start() // 1 고루틴으로 시작
time.Sleep(time.Second) // 2 서버 준비 대기
conn, err := net.Dial("tcp", "localhost:6379") // 3 연결 시도
if err != nil {
t.Fatalf("포트 리스닝 실패")
}
defer conn.Close()
}
1번에서 고루틴을 사용하는 이유는 server.Start()가 무한 루프로 블로킹되기 때문이다.
고루틴 없이 호출하면
net.Dial()은 절대 실행되지 않는다고루틴으로 실행하면 서버는 별도 고루틴에서 돌아가고, 메인 고루틴은 계속 진행하여 연결을 시도할 수 있다
응답 테스트
// 연결 시 "+PONG\r\n"을 응답하여야 한다.
func TestPongResponse(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()
buf := make([]byte, 50)
n, err := conn.Read(buf)
if err != nil {
t.Fatal("읽기 실패")
}
// then: 응답값과 기댓값 비교
received := string(buf[:n])
expected := "+PONG\r\n"
if received != expected {
t.Fatalf("actual: %s, expected: %s", received, expected)
}
}
서버로부터 전송된 데이터를 읽어 바이트 슬라이스 buf 에 저장한다. 여기서 n은 실제 읽어온 바이트 수를 의미한다.
읽어온 바이트를 문자열로 변환하여 received 변수에 저장을 하고, 기대하는 응답값도 expected 에 저장해준다.
이 후 실제 받은 값인 received와 expected 를 비교하여 응답이 잘 오는지 확인 한다.
EOF로 연결 종료 확인
// 응답 후 서버가 연결을 종료하는지 검증 (Read()가 io.EOF를 반환하면 연결이 종료된 것)
func TestConnectionClose(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()
// 첫 번째 Read: PONG 응답 수신
buf := make([]byte, 50)
_, err = conn.Read(buf)
if err != nil {
t.Fatal("첫 번째 읽기 실패")
}
// then: 두 번째 Read 시도 -> 서버가 연결을 닫았다면 EOF 반환
n, err := conn.Read(buf)
// EOF 또는 읽은 바이트가 0이면 연결이 종료된 것임
if err == nil && n > 0 {
t.Fatalf("서버가 연결을 종료하지 않음. 추가로 읽은 데이터: %s", string(buf[:n]))
}
}
EOF(End Of File) 는 더 이상 읽을 데이터가 없다는 신호다.
서버가 응답을 보낸 후 conn.Close()를 호출하면 FIN 패킷이 전송되고, 클라이언트의 다음 Read() 호출은 EOF를 반환한다.
마치며
이번 포스팅에서는 BSD 소켓 API를 통해 TCP 연결이 수립되는 과정을 살펴봤다. 겉으로는 socket, bind, listen, accept라는 네 가지 함수를 순서대로 호출하는 단순한 작업처럼 보이지만 그 이면에는 커널이 수행하는 복잡한 상태 관리와 큐잉 메커니즘이 존재한다는 것을 알 수 있었다.
listen()이 단순히 포트를 열어두는 것이 아니라 커널 영역에서 SYN Queue와 Accept Queue를 통해 연결 요청의 부하를 관리한다는 점이 인상 깊었다. 이런 부분이 왜 트래픽이 급증할 때 커널 파라미터 튜닝이 필요한지, 그리고 왜 OS 레벨의 동작 방식을 이해해야 하는지를 잘 보여주고있다.
그리고 TCP 상태 머신과 TIME_WAIT 같은 개념도 실제 운영 환경에서 발생하는 네트워크 장애나 리소스 누수 문제는 결국 netstat으로 찍히는 TCP 상태를 해석해야 원인을 찾을 수 있기 때문에 상당한 도움이 될 것이다.
이제 클라이언트와 통신할 수 있는 연결은 확보했다. 그렇지만 현재 서버는 클라이언트가 무엇을 보내든 무조건 +PONG만 반환하고 있다. 다음 포스팅에서는 소켓으로 들어오는 바이트 스트림을 읽고 의미 있는 명령어로 변환하는 파싱 과정과 그 과정에서 필수적인 I/O 버퍼링에 대해 다뤄보겠다.
참고