들어가며
지금까지 만든 서버는 RESP 프로토콜을 파싱하고, SET/GET 명령어로 데이터를 저장하고 조회할 수 있다. 기능적으로는 꽤 그럴듯하지만 치명적인 한계가 하나 있다.
현재의 코드 구조로는 클라이언트 A가 연결 중이면, 클라이언트 B는 A의 처리가 끝날 때까지 기다려야 한다. 1편에서 살펴본 Accept Queue에 연결이 쌓이기만 할 뿐 서버가 꺼내가지 못하는 상황이다. 실제 데이터베이스에서 이런 구조는 쓸 수 없다.
이번 포스팅에서는 이 문제를 해결하기 위해 동시성의 핵심 개념을 살펴보고 go의 고루틴이 왜 경량 스레드라 불리는지 GMP 스케줄러 모델까지 파고든 뒤 코드 한 줄로 서버를 동시성 서버로 탈바꿈시키는 과정을 다루겠다.
동시성 vs 병렬성
이 두 개념은 자주 혼동되기에 한 번 짚고넘어갈 필요가 있다.
- 동시성(Concurrency) - 여러 작업을 번갈아가며 처리하는 구조 이다. 싱글 코어에서도 가능하다.
- 병렬성(Parallelism) - 여러 작업을 물리적으로 동시에 실행하는 상태이다. 멀티 코어가 필요하다.
비유하면 동시성은 한 명의 요리사가 파스타 물을 올려두고 그 사이에 샐러드를 만드는 것이고, 병렬성은 두 명의 요리사가 각자 요리를 하는 것이다.
현재 서버에 필요한 것은 동시성이다. 클라이언트 A가 명령어를 보내길 기다리는 동안 클라이언트 B의 요청을 처리할 수 있으면 된다.
프로세스와 스레드
동시성을 구현하려면 “동시에 실행할 수 있는 실행 흐름"이 필요하다. 운영체제가 제공하는 실행 단위가 프로세스와 스레드이다.
프로세스
실행 중인 프로그램의 인스턴스다. 각 프로세스는 독립된 메모리 공간(코드, 데이터, 힙, 스택)을 가지며, 다른 프로세스의 메모리에 직접 접근할 수 없다.
이 격리 덕분에 한 프로세스가 죽어도 다른 프로세스에 영향을 주지 않지만 프로세스 간 데이터를 주고받으려면 IPC(Inter-Process Communication)라는 별도의 메커니즘을 거쳐야 한다. 파이프, 소켓, 공유 메모리 등이 여기에 해당하며, 메모리를 직접 공유하는 것에 비해 비용이 크다.
스레드
프로세스 내에서 실행되는 흐름의 단위다. 같은 프로세스의 스레드들은 코드, 데이터, 힙 영역을 공유하고, 스택만 각자 가진다.
스택"] T2["스레드 2
스택"] T3["스레드 3
스택"] end end
메모리를 공유하기 때문에 스레드 간 통신은 빠르다. 별도의 IPC 없이 같은 변수를 읽고 쓸 수 있다. 이 특성이 멀티 클라이언트 처리의 전통적 해법으로 스레드가 사용되는 이유다. 클라이언트마다 스레드를 하나씩 생성하면 각 클라이언트를 독립적으로 처리할 수 있다.
하지만 메모리 공유는 양날의 검이기도 하다. 여러 스레드가 같은 데이터에 동시에 접근하면 예측할 수 없는 결과가 발생할 수 있다. 이 문제는 이후 포스팅에서 다룰 예정이다.
컨텍스트 스위칭의 비용
CPU 코어 하나가 여러 스레드를 번갈아 실행하려면 컨텍스트 스위칭(Context Switching) 이 필요하다. 현재 실행 중인 스레드의 상태를 저장하고 다음 스레드의 상태를 복원하는 과정이다.
스위칭 시 일어나는 일
- 현재 스레드의 레지스터(프로그램 카운터, 스택 포인터 등)를 메모리에 저장한다
- 다음 스레드의 레지스터 상태를 메모리에서 복원한다
- CPU 캐시가 무효화될 수 있다 (새 스레드가 다른 메모리 영역을 사용하므로 캐시 미스 증가)
여기서 핵심은 커널 레벨 스레드의 스위칭에는 시스템콜이 필요하다는 것이다. 2편에서 살펴본 것처럼 시스템콜은 유저 모드에서 커널 모드로의 전환 비용을 수반한다. 레지스터 저장/복원 자체보다 이 모드 전환과 캐시 오염이 더 비싼 경우가 많다.
스레드가 수십 개일 때는 문제가 없지만, 수천 개로 늘어나면 이야기가 달라진다. 실제 작업을 처리하는 시간보다 스레드를 갈아 끼우는 데 CPU 시간을 더 쓰게 될 수 있다. 이것이 “스레드가 많아지면 오히려 느려진다"는 현상의 원인이다.
커널 스레드 vs 유저 스레드
스레드를 누가 관리하느냐에 따라 두 가지 모델로 나뉜다.
커널 레벨 스레드 (1:1 모델)
운영체제가 직접 관리하는 스레드다. 유저 스레드 하나가 커널 스레드 하나에 대응된다.
- 스레드 생성/소멸/스위칭에 시스템콜 필요
- 스레드당 약 1~2MB 고정 스택 할당
- 실질적으로 수천 개가 한계
자바의 전통적인 Thread 클래스가 이 모델이다. 1만 개의 클라이언트를 처리하려면 1만 개의 OS 스레드가 필요하고, 스택만 10~20GB를 차지한다.
유저 레벨 스레드 (M:N 모델)
런타임(라이브러리)이 관리하는 스레드다. M개의 유저 스레드를 N개의 커널 스레드 위에서 스케줄링한다.
- 시스템콜 없이 유저 공간에서 스위칭 가능
- 스레드당 수 KB의 작은 스택 (필요시 자동 확장)
- 수십만~수백만 개 생성 가능
Go의 고루틴이 바로 이 모델이다. go 런타임이 적은 수의 OS 스레드 위에서 수많은 고루틴을 스케줄링한다.
| 커널 스레드 (1:1) | 유저 스레드 (M:N) | |
|---|---|---|
| 관리 주체 | OS 커널 | 런타임 |
| 스위칭 비용 | 시스템콜 | 유저 공간 함수 호출 |
| 스택 크기 | 1~2MB 고정 | 2KB 시작, 동적 확장 |
| 최대 개수 | 수천 | 수십만~수백만 |
| 대표 구현 | Java Thread, POSIX pthread | Go goroutine, Erlang process |
GMP 모델
Go 스케줄러의 핵심 구조다. 고루틴이 어떻게 OS 스레드 위에서 효율적으로 실행되는지 이 모델이 설명한다.
세 가지 구성요소
G (Goroutine)
- 실행할 작업 단위
- 실행 중(Running), 실행 가능(Runnable), 대기 중(Waiting) 세 가지 상태를 가진다
go func()호출 시 새로운 G가 생성된다
M (Machine)
- 실제 OS 스레드
- CPU에서 코드를 실행하는 물리적 주체
- 개수는 기본적으로
GOMAXPROCS(CPU 코어 수)만큼 활성화된다
P (Processor)
- 논리적 프로세서
- 실행 가능한 G들의 큐(Local Run Queue)를 관리한다
- M과 G를 연결하는 중간자 역할
동작 원리
G3 → G4 → G5"] end subgraph P2["P (Processor)"] LRQ2["Local Run Queue
G6 → G7"] end GRQ["Global Run Queue
G8, G9, G10"] P1 -->|"G3 꺼냄"| M1["M (OS Thread)
G3 실행 중"] P2 -->|"G6 꺼냄"| M2["M (OS Thread)
G6 실행 중"] P2 -.->|"큐가 비면
Work Stealing"| P1 GRQ -.->|"로컬 큐 비면
글로벌에서 가져옴"| P2
- P는 자신의 로컬 큐에서 G를 하나 꺼내 M에서 실행한다
- G가 I/O 등으로 블로킹되면, M은 해당 G를 파킹하고 큐에서 다른 G를 꺼내 실행한다
- 로컬 큐가 비면 다른 P의 큐에서 G를 훔쳐온다 (Work Stealing)
- 다른 P의 큐도 비어있으면 글로벌 큐에서 가져온다
G가 블로킹되어도 M은 놀지 않는다
이것이 GMP 모델의 가장 중요한 특성이다.
현재 구현된 서버에서 reader.Read()를 호출하면 클라이언트가 데이터를 보낼 때까지 해당 고루틴은 대기 상태에 들어간다. 전통적인 스레드 모델에서는 OS 스레드 자체가 블로킹되지만 go에서는 다르게 동작한다.
go 런타임의 netpoller 가 네트워크 I/O를 감시하고 있다가 해당 고루틴의 I/O가 준비되면 다시 실행 가능 큐에 넣어준다. 그 사이에 M은 다른 G를 실행하고 있으므로 CPU 자원이 낭비되지 않는다.
고루틴 A: Read() 호출 -> 대기 상태로 전환 -> netpoller에 등록
M(OS 스레드): 고루틴 A 대신 -> 고루틴 B 실행 -> 고루틴 C 실행 -> ...
netpoller: 데이터 도착 감지 -> 고루틴 A를 Runnable로 전환 -> 큐에 복귀
1편에서 accept()가 블로킹되면 프로세스가 sleep 상태로 전환되고,커널이 이벤트 도착 시 ready로 깨운다고 설명했다. GMP 모델에서는 이 과정이 커널이 아닌 go 런타임 수준에서 더 가볍게 일어나는 것이다.
고루틴이 가벼운 이유
고루틴이 OS 스레드보다 압도적으로 가벼운 이유를 구체적으로 정리하면 다음과 같다.
작은 스택
고루틴의 초기 스택은 2KB다. OS 스레드는 보통 1~2MB의 고정 스택을 할당받는다. 단순 계산으로 비교하면 다음과 같다.
- OS 스레드 10만 개: 100,000 × 2MB = 200GB (현실적으로 불가능)
- 고루틴 10만 개: 100,000 × 2KB = 약 200MB (일반 서버에서 충분히 가능)
고루틴의 스택은 필요에 따라 자동으로 확장된다. 처음에는 2KB로 시작하지만, 함수 호출이 깊어지면 런타임이 더 큰 스택을 할당하고 기존 데이터를 복사한다. 처음부터 큰 스택을 예약할 필요가 없으니 메모리 효율이 높다.
유저 레벨 스위칭
고루틴 간 전환은 go 런타임 내부의 함수 호출로 처리된다. 커널 모드로의 전환이 필요 없으므로 OS 스레드의 컨텍스트 스위칭보다 훨씬 빠르다.
협력적 스케줄링 요소
Go 스케줄러는 선점형과 협력적 스케줄링을 혼합해서 사용한다. 고루틴은 다음과 같은 시점에서 스위칭 포인트가 발생한다.
- 함수 호출 시 (스택 크기 검사와 함께)
- 채널 연산 시
- I/O 연산 시
runtime.Gosched()명시적 호출 시
이런 자연스러운 스위칭 포인트들이 있기 때문에 별도의 타이머 인터럽트 없이도 효율적인 스케줄링이 가능하다.
코드 구현
server.go
package server
import (
"bufio"
"inmemory-db/internal/protocol"
"inmemory-db/internal/storage"
"log"
"net"
"strings"
)
// TCP 서버
type Server struct {
listener net.Listener
addr string
store *storage.Store
}
func New(addr string) *Server {
return &Server{
addr: addr,
store: storage.New(),
}
}
// Start는 서버를 시작하고 연결을 수신합니다.
// 이 함수는 블로킹됩니다 (무한 루프).
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 {
// Accept() 호출 -> 연결대기(블로킹)
conn, err := s.listener.Accept()
if err != nil {
log.Print("연결 중 오류: ", err)
continue
} else {
go s.handleConnection(conn)
}
}
}
// 단일 클라이언트 연결을 처리합니다.
func (s *Server) handleConnection(conn net.Conn) {
// 연결 종료 예약
defer conn.Close()
bufReader := bufio.NewReader(conn)
reader := protocol.NewReader(bufReader)
writer := protocol.NewWriter(conn)
for {
value, err := reader.Read()
// EOF면 클라이언트가 연결을 끊은 것이니 루프 탈출 필요
if err != nil {
return
}
command := strings.ToUpper(value.Array[0].Str)
switch command {
case "PING":
writer.WriteSimpleString("PONG")
case "ECHO":
if len(value.Array) > 1 {
writer.WriteBulkString(value.Array[1].Str)
} else {
writer.WriteError("missing argument")
}
case "SET":
key := value.Array[1].Str
value := value.Array[2].Str
s.store.Set(key, value)
writer.WriteSimpleString("OK")
case "GET":
key := value.Array[1].Str
result, exist := s.store.Get(key)
if exist {
writer.WriteBulkString(result)
} else {
writer.WriteNull()
}
default:
writer.WriteError("unknown command")
}
}
}
TestConcurrentClients
func TestConcurrentClients(t *testing.T) {
// given
input := "*1\r\n$4\r\nPING\r\n"
server := New(":6379")
go server.Start()
time.Sleep(time.Second)
// when: 여러 클라이언트가 동시에 접속
var wg sync.WaitGroup
clientCount := 10
for i := 0; i < clientCount; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
conn, _ := net.Dial("tcp", "localhost:6379")
defer conn.Close()
conn.Write([]byte(input))
reader := bufio.NewReader(conn)
response, _ := reader.ReadString('\n')
// then: 모든 클라이언트가 PONG 응답
if response != "+PONG\r\n" {
t.Errorf("클라이언트 %d 응답 실패: %s", id, response)
}
}(i)
}
wg.Wait()
}
라인별 Deep Dive
go s.handleConnection(conn)
for {
conn, err := s.listener.Accept()
// ...
go s.handleConnection(conn)
}
이전 챕터까지의 코드와 달라진 것은 go 키워드 하나뿐이다. 하지만 이 한 단어가 서버의 동작 방식을 근본적으로 바꾼다.
go를 붙이면 handleConnection()은 새로운 고루틴에서 실행된다. GMP 모델의 관점에서 보면 새로운 G가 생성되어 현재 P의 로컬 큐에 추가되는 것이다. Accept 루프를 돌고 있는 메인 고루틴은 handleConnection()의 완료를 기다리지 않고 즉시 다음 Accept()를 호출한다.
변경 전후를 비교하면 다음과 같다.
Before (순차 처리)
Accept() -> handleConnection(A) -> [A 처리 중] → Accept() -> handleConnection(B)
B는 여기서 대기
After (동시 처리)
Accept() -> go handleConnection(A) -> Accept() -> go handleConnection(B) -> Accept() -> ...
accept 루프가 클라이언트 처리에 의해 블로킹되지 않으므로 연결 요청이 들어오는 즉시 수락할 수 있다.
sync.WaitGroup
sync.WaitGroup은 여러 고루틴의 완료를 기다리는 동기화 도구다.
var wg sync.WaitGroup
for i := 0; i < clientCount; i++ {
wg.Add(1) // 카운터 +1
go func(id int) {
defer wg.Done() // 카운터 -1 (고루틴 종료 시)
// ...
}(i)
}
wg.Wait() // 카운터가 0이 될 때까지 블로킹
Add(1)- “고루틴 하나가 시작된다"는 신호이다. 내부 카운터를 1 증가시킨다Done()- “고루틴 하나가 끝났다"는 신호이다. 내부 카운터를 1 감소시킨다.defer로 호출해서 고루틴이 어떻게 종료되든 반드시 실행되도록 한다Wait()- 카운터가 0이 될 때까지 현재 고루틴을 블로킹한다. 모든 클라이언트 고루틴이 완료되어야 테스트가 끝난다
고루틴 내에서 t.Errorf를 쓰는 이유
t.Fatalf는 내부적으로 runtime.Goexit()를 호출해서 현재 고루틴을 종료시킨다. 테스트 함수의 메인 고루틴에서 호출하면 테스트가 즉시 실패 처리되지만, go func() 안에서 호출하면 그 자식 고루틴만 종료될 뿐 테스트 자체가 실패로 보고되지 않을 수 있다. t.Errorf는 실패를 기록하되 고루틴을 종료하지 않으므로, 모든 고루틴이 정상적으로 완료된 뒤 테스트 결과가 정확하게 보고된다.
클로저와 루프 변수
go func(id int) {
// id는 각 고루틴의 독립적인 복사본
}(i)
go func() 호출 시 i를 인자로 전달하는 이유는 클로저의 변수 캡처 때문이다. 만약 i를 인자 없이 직접 참조하면 모든 고루틴이 같은 i 변수를 공유하게 되어, 고루틴이 실행될 시점에 i가 이미 루프의 마지막 값으로 바뀌어 있을 수 있다. 함수 인자로 넘기면 각 고루틴이 호출 시점의 값을 복사해서 가져가므로 이 문제를 피할 수 있다.
아직 해결하지 않은 문제
고루틴으로 동시 처리가 가능해졌지만 한 가지 문제점이 있다.
// 고루틴 A // 고루틴 B
count := store.Get("x") count := store.Get("x")
count = count + 1 count = count + 1
store.Set("x", count) store.Set("x", count)
// x가 1에서 3이 되어야 하는데, 2가 될 수 있다
여러 고루틴이 동일한 store에 동시에 접근하면 Race Condition이 발생한다. 두 고루틴이 같은 값을 읽고 각자 1을 더한 뒤 각자 저장하면 증가분 하나가 소실된다.
go에서는 go test -race 플래그로 Race Detector를 활성화할 수 있다. 이 도구는 테스트 실행 중에 두 개 이상의 고루틴이 같은 메모리에 동시 접근하면서 하나 이상이 쓰기인 경우를 감지해 경고를 출력한다.
현재는 PING 명령어만 사용하기 때문에 동시 접속 테스트에서 race가 감지되지 않는다. PING은 store에 접근하지 않으므로 공유 자원 충돌이 발생하지 않는다. 만약 10개의 고루틴이 동시에 SET/GET을 수행하는 테스트를 작성하면 race가 바로 터질 것이다.
마치며
go 키워드 하나를 추가하는 것만으로 서버가 동시에 여러 클라이언트를 처리할 수 있게 되었다. 코드 변경은 한 줄이지만 그 한 줄이 가능하려면 GMP 스케줄러가 수만 개의 고루틴을 소수의 OS 스레드 위에서 효율적으로 관리해줘야 한다. 고루틴이 I/O로 블로킹되면 M이 다른 G를 실행하고, 큐가 비면 다른 P에서 훔쳐오는 이 일련의 과정이 go 키워드 뒤에서 자동으로 일어난다.
다만 동시성이 공짜로 오는 것은 아니다. 여러 고루틴이 같은 데이터에 접근하는 순간 Race Condition이라는 새로운 문제가 생긴다. 현재 store는 클라이언트들 간의 공유자원으로 동시 쓰기에 안전하지 않다. 다음 포스팅에서는 이 문제를 다뤄보겠다.
참고