V 실전: 채팅 서버 — 동시성 프로그래밍 실습
문법 시리즈 10편에서
spawn과 채널을 배웠다. 이론으로만 보면 "그래서 이걸 어디에 쓰지?"라는 의문이 생길 수 있다. 이번 편에서 답을 보여준다. 여러 사람이 동시에 접속해서 메시지를 주고받는 채팅 서버를 만든다. 동시성 프로그래밍의 진정한 힘을 체감할 수 있을 것이다.
이번 편에서 만드는 것
터미널에서 동작하는 멀티유저 채팅 서버다.
[터미널 1: 서버]
🚀 채팅 서버 시작: localhost:9000
📥 새 접속: Alice
📥 새 접속: Bob
📤 Alice: 안녕하세요!
📤 Bob: 반갑습니다!
[터미널 2: Alice]
닉네임: Alice
✅ 서버에 접속했습니다
Bob: 반갑습니다!
[터미널 3: Bob]
닉네임: Bob
✅ 서버에 접속했습니다
Alice: 안녕하세요!
TCP 소켓이란?
코드를 작성하기 전에, TCP 소켓이 무엇인지 간단히 알아보자.
소켓은 네트워크 전화기와 같다. 전화를 걸고(연결), 말하고(데이터 전송), 끊는(연결 종료) 것과 같은 과정을 거친다.
클라이언트 (전화 거는 쪽) 서버 (전화 받는 쪽)
┌──────────────┐ ┌──────────────┐
│ │ │ 대기 중... │
│ 연결 요청 │ ─── 링~ 링~ ────→ │ │
│ │ │ 수락! │
│ │ ←── 연결 됨 ────→ │ │
│ │ │ │
│ "안녕하세요" │ ───────────────→ │ 수신 │
│ │ ←─────────────── │ "반갑습니다" │
│ │ │ │
│ 연결 종료 │ ─── 끊기 ──────→ │ 정리 │
└──────────────┘ └──────────────┘
| 용어 | 전화 비유 | 프로그래밍 |
|---|---|---|
| 서버 | 콜센터 (전화 대기) | net.listen_tcp() |
| 클라이언트 | 전화 거는 사람 | net.dial_tcp() |
| 연결 | 통화 시작 | accept() |
| 데이터 전송 | 대화 | write(), read() |
| 연결 종료 | 전화 끊기 | close() |
4~5편에서 만든 vweb 서버도 내부적으로는 TCP 소켓을 사용한다. vweb이 HTTP 프로토콜을 자동 처리해줬다면, 이번에는 원시(raw) TCP 소켓을 직접 다뤄본다.
1단계: TCP 서버 기초 — 에코 서버
가장 간단한 TCP 서버부터 시작하자. 클라이언트가 보낸 메시지를 그대로 돌려보내는 에코 서버다.
import net
fn main() {
// 포트 9000에서 대기
mut server := net.listen_tcp(.ip, '0.0.0.0:9000') or {
println('서버 시작 실패: ${err}')
return
}
println('🚀 에코 서버 시작: localhost:9000')
// 클라이언트 접속 대기 (무한 루프)
for {
mut conn := server.accept() or {
println('접속 수락 실패: ${err}')
continue
}
println('📥 새 클라이언트 접속!')
// 클라이언트 처리
handle_client(mut conn)
}
}
fn handle_client(mut conn net.TcpConn) {
defer { conn.close() or {} }
for {
// 데이터 읽기
mut buf := []u8{len: 1024}
bytes_read := conn.read(mut buf) or {
println('📤 클라이언트 연결 종료')
return
}
if bytes_read == 0 {
println('📤 클라이언트 연결 종료')
return
}
// 받은 메시지
message := buf[..bytes_read].bytestr().trim_space()
println('수신: ${message}')
// 그대로 돌려보내기 (에코)
response := '에코: ${message}\n'
conn.write(response.bytes()) or {
println('전송 실패')
return
}
}
}
핵심 부분을 하나씩 짚어보자.
net.listen_tcp(.ip, '0.0.0.0:9000') — 포트 9000에서 TCP 연결을 대기한다. 0.0.0.0은 "모든 네트워크 인터페이스에서 접속을 받겠다"는 뜻이다.
server.accept() — 클라이언트의 접속을 기다린다. 누군가 접속하면 TcpConn(연결 객체)을 반환한다. 이 함수는 블로킹이다 — 접속이 올 때까지 멈춰서 기다린다.
conn.read(mut buf) — 클라이언트가 보낸 데이터를 버퍼에 읽는다. 바이트 배열로 읽으므로 .bytestr()로 문자열로 변환한다.
conn.write(response.bytes()) — 데이터를 클라이언트에 보낸다. 문자열을 .bytes()로 바이트 배열로 변환해서 전송한다.
테스트
서버를 실행한 후, 다른 터미널에서 telnet이나 nc (netcat)로 접속한다.
# 터미널 1: 서버 시작
v run echo_server.v
# 터미널 2: 클라이언트 접속
telnet localhost 9000
# 또는
nc localhost 9000
메시지를 입력하면 에코: 메시지로 돌아온다. 하지만 이 서버에는 치명적인 문제가 있다.
문제점: 한 번에 한 명만
현재 서버는 handle_client가 끝날 때까지 다음 accept()를 호출하지 않는다. 즉, 한 명이 접속 중이면 다른 사람은 접속할 수 없다.
클라이언트A 접속 중... → handle_client() 실행 중 (블로킹)
클라이언트B 접속 시도... → accept()에 도달 못함 → 대기
이 문제를 해결하려면 동시성이 필요하다.
2단계: spawn으로 다중 클라이언트 처리
문법 시리즈 10편에서 배운 spawn을 사용한다. 각 클라이언트를 별도의 스레드에서 처리하면 된다.
import net
fn main() {
mut server := net.listen_tcp(.ip, '0.0.0.0:9000') or {
println('서버 시작 실패: ${err}')
return
}
println('🚀 에코 서버 시작: localhost:9000')
for {
mut conn := server.accept() or {
continue
}
println('📥 새 클라이언트 접속!')
// 🔑 핵심: spawn으로 별도 스레드에서 처리
spawn handle_client(mut conn)
// ↑ 여기서 바로 다음 accept()로 넘어간다
}
}
fn handle_client(mut conn net.TcpConn) {
// (이전과 동일한 코드)
defer { conn.close() or {} }
for {
mut buf := []u8{len: 1024}
bytes_read := conn.read(mut buf) or { return }
if bytes_read == 0 { return }
message := buf[..bytes_read].bytestr().trim_space()
response := '에코: ${message}\n'
conn.write(response.bytes()) or { return }
}
}
딱 한 줄만 바꿨다. handle_client(mut conn) → spawn handle_client(mut conn). 이 한 줄이 서버의 동시 처리 능력을 근본적으로 바꿔놓는다.
spawn 사용 후:
클라이언트A 접속 → spawn handle_client() ← 별도 스레드
↓ 즉시 복귀
클라이언트B 접속 → spawn handle_client() ← 별도 스레드
↓ 즉시 복귀
클라이언트C 접속 → spawn handle_client() ← 별도 스레드
이제 여러 명이 동시에 접속할 수 있다. 하지만 아직 채팅은 아니다. 에코는 보낸 사람에게만 돌려보내지, 다른 사람에게는 전달하지 않기 때문이다.
3단계: 채널로 메시지 브로드캐스트
채팅의 핵심은 브로드캐스트다. 한 사람이 보낸 메시지를 모든 접속자에게 전달해야 한다.
이것을 구현하려면 채널(channel)이 필요하다. 각 클라이언트 스레드가 메시지를 채널에 보내고, 브로드캐스트 스레드가 채널에서 메시지를 꺼내서 모든 클라이언트에게 전달하는 구조다.
클라이언트A ─── 메시지 ──→ ┌────────┐ ──→ 클라이언트A
│ 채널 │ ──→ 클라이언트B
클라이언트B ─── 메시지 ──→ │(큐) │ ──→ 클라이언트C
│ │
클라이언트C ─── 메시지 ──→ └────────┘
↑
브로드캐스트 스레드가
채널에서 꺼내서 모두에게 전송
4단계: 완성 — 멀티유저 채팅 서버
서버 코드 (chat_server.v)
import net
import sync
// 채팅 메시지
struct ChatMessage {
sender string
content string
}
// 클라이언트 관리
struct Client {
name string
mut:
conn net.TcpConn
}
// 공유 상태 (접속 중인 클라이언트 목록)
struct SharedState {
mut:
clients []&Client
mtx sync.Mutex
}
fn (mut state SharedState) add_client(client &Client) {
state.mtx.@lock()
state.clients << client
state.mtx.unlock()
}
fn (mut state SharedState) remove_client(name string) {
state.mtx.@lock()
state.clients = state.clients.filter(it.name != name)
state.mtx.unlock()
}
fn (mut state SharedState) broadcast(msg ChatMessage) {
state.mtx.@lock()
for mut client in state.clients {
// 보낸 사람 제외
if client.name != msg.sender {
text := '${msg.sender}: ${msg.content}\n'
client.conn.write(text.bytes()) or {}
}
}
state.mtx.unlock()
}
// 클라이언트 처리
fn handle_client(mut conn net.TcpConn, msg_ch chan ChatMessage) {
defer { conn.close() or {} }
// 닉네임 받기
conn.write('닉네임을 입력하세요: '.bytes()) or { return }
mut buf := []u8{len: 256}
bytes_read := conn.read(mut buf) or { return }
if bytes_read == 0 { return }
name := buf[..bytes_read].bytestr().trim_space()
if name.len == 0 { return }
// 입장 알림
conn.write('✅ 채팅방에 입장했습니다. (종료: /quit)\n'.bytes()) or { return }
msg_ch <- ChatMessage{ sender: '🔔 시스템', content: '${name}님이 입장했습니다' }
println('📥 새 접속: ${name}')
// 메시지 수신 루프
for {
mut msg_buf := []u8{len: 1024}
n := conn.read(mut msg_buf) or {
msg_ch <- ChatMessage{ sender: '🔔 시스템', content: '${name}님이 퇴장했습니다' }
println('📤 연결 종료: ${name}')
return
}
if n == 0 {
msg_ch <- ChatMessage{ sender: '🔔 시스템', content: '${name}님이 퇴장했습니다' }
println('📤 연결 종료: ${name}')
return
}
message := msg_buf[..n].bytestr().trim_space()
if message.len == 0 { continue }
// 종료 명령
if message == '/quit' {
conn.write('👋 안녕히 가세요!\n'.bytes()) or {}
msg_ch <- ChatMessage{ sender: '🔔 시스템', content: '${name}님이 퇴장했습니다' }
println('📤 퇴장: ${name}')
return
}
// 메시지 전달
println('💬 ${name}: ${message}')
msg_ch <- ChatMessage{ sender: name, content: message }
}
}
// 브로드캐스트 스레드
fn broadcaster(msg_ch chan ChatMessage, mut state SharedState) {
for {
msg := <-msg_ch or { return }
state.broadcast(msg)
}
}
fn main() {
mut server := net.listen_tcp(.ip, '0.0.0.0:9000') or {
println('서버 시작 실패: ${err}')
return
}
println('🚀 채팅 서버 시작: localhost:9000')
println(' 접속: telnet localhost 9000')
println('')
// 메시지 채널과 공유 상태
msg_ch := chan ChatMessage{cap: 100}
mut state := &SharedState{}
// 브로드캐스트 스레드 시작
spawn broadcaster(msg_ch, mut state)
// 클라이언트 접속 대기
for {
mut conn := server.accept() or {
continue
}
// 클라이언트를 공유 상태에 추가 (닉네임은 나중에 설정)
// spawn으로 별도 스레드에서 처리
spawn fn (mut c net.TcpConn, ch chan ChatMessage, mut s SharedState) {
// 먼저 닉네임을 받아야 하므로 임시로 handle_client에서 처리
handle_client(mut c, ch)
}(mut conn, msg_ch, mut state)
}
}
코드 구조 설명
이 코드는 3개의 동시 실행 단위로 구성된다.
┌─────────────────────────────────────────────┐
│ main 스레드 │
│ └─ accept() 루프: 새 클라이언트 대기 │
│ ↓ 접속 시 │
│ spawn handle_client() │
├─────────────────────────────────────────────┤
│ handle_client 스레드 (클라이언트당 1개) │
│ └─ 닉네임 받기 │
│ └─ 메시지 수신 루프 │
│ └─ 메시지를 채널에 전송: msg_ch <- msg │
├─────────────────────────────────────────────┤
│ broadcaster 스레드 (1개) │
│ └─ 채널에서 메시지 수신: msg := <-msg_ch │
│ └─ 모든 클라이언트에게 전송 │
└─────────────────────────────────────────────┘
SharedState와 sync.Mutex: 여러 스레드가 동시에 클라이언트 목록에 접근하므로, 뮤텍스(Mutex) 로 보호한다. 10편에서 배운 shared 키워드와 유사한 개념이다. @lock()과 unlock() 사이에서만 데이터를 읽고 쓴다.
메시지 채널 (chan ChatMessage): 모든 클라이언트 스레드가 메시지를 하나의 채널에 보낸다. 브로드캐스트 스레드가 이 채널에서 메시지를 꺼내서 모든 클라이언트에게 전달한다. 채널이 스레드 간 안전한 통신 통로 역할을 한다.
실행 및 테스트
# 터미널 1: 서버 시작
v run chat_server.v
# 🚀 채팅 서버 시작: localhost:9000
# 터미널 2: 첫 번째 클라이언트
telnet localhost 9000
# 닉네임을 입력하세요: Alice
# ✅ 채팅방에 입장했습니다. (종료: /quit)
# 터미널 3: 두 번째 클라이언트
telnet localhost 9000
# 닉네임을 입력하세요: Bob
# ✅ 채팅방에 입장했습니다. (종료: /quit)
Alice가 메시지를 입력하면 Bob에게 전달되고, Bob이 입력하면 Alice에게 전달된다.
[Alice 화면] [Bob 화면]
> 안녕하세요! 🔔 시스템: Alice님이 입장했습니다
> 반갑습니다!
Bob: 반갑습니다!
> 오늘 날씨 좋네요 Alice: 오늘 날씨 좋네요
문법 시리즈 연결
| 활용된 문법 | 배운 편 | 이번 편에서 |
|---|---|---|
spawn (동시 실행) | 10편 | 클라이언트마다 별도 스레드 |
채널 (chan T) | 10편 | 메시지 브로드캐스트 통로 |
<-ch, ch <- | 10편 | 메시지 전송/수신 |
| 구조체, 메서드 | 6편 | SharedState, Client |
defer | 3편 | conn.close() 자동 정리 |
or {} 에러 처리 | 8편 | 네트워크 에러 처리 |
배열 filter | 5편 | 클라이언트 목록에서 제거 |
match | 4편 | /quit 명령 처리 |
📝 정리
- [x] TCP 소켓 —
net.listen_tcp()(서버),net.dial_tcp()(클라이언트) - [x] accept/read/write — 연결 수락, 데이터 읽기/쓰기
- [x] 에코 서버 — 받은 메시지를 그대로 돌려보내는 최소 구현
- [x]
spawn으로 다중 처리 — 한 줄 변경으로 동시 접속 지원 - [x] 채널 브로드캐스트 — 메시지를 채널에 보내고, 전담 스레드가 모두에게 전달
- [x]
sync.Mutex— 공유 데이터를 여러 스레드가 안전하게 접근 - [x] 3-스레드 구조 — main(수락) + handler(클라이언트당) + broadcaster(전달)
다음 편 예고
실전 마지막 편: 할일 관리 웹앱 — V로 풀스택 종합 프로젝트
시리즈의 대미를 장식한다! 4~5편의 Todo API에 HTML 프론트엔드를 입히고, 브라우저에서 동작하는 할일 관리 웹앱을 완성한다. vweb 템플릿, CSS 스타일링, 폼 처리, 리다이렉트 — V 하나로 풀스택 웹 개발이 가능하다는 것을 증명한다.