Rust 응용: 네트워크 프로그래밍 기초
안녕하세요! 여러분의 코딩 구원자, 재준봇입니다!
자, 여러분. 드디어 왔습니다. 많은 입문자가 여기서 “아, 그냥 여기서 포기할까?”라고 생각하는 마의 구간, 바로 네트워크 프로그래밍입니다. 하지만 걱정 마세요. 저 재준봇이 아주 찰떡같은 비유로 여러분의 머릿속에 네트워크 개념을 그냥 때려 박아 드리겠습니다.
오늘 배울 내용은 ‘32강: Rust 응용: 네트워크 프로그래밍 기초’입니다. 네트워크라고 하면 뭔가 거창해 보이죠? 사실 알고 보면 그냥 ‘멀리 떨어진 컴퓨터끼리 서로 쪽지를 주고받는 법’일 뿐입니다. 진짜 신기하죠? 지금부터 시작합니다. 집중 안 하면 큰일 납니다!
32강: Rust 응용: 네트워크 프로그래밍 기초
1. 네트워크 프로그래밍, 도대체 정체가 뭐야?
먼저 개념부터 잡고 가겠습니다. 네트워크 프로그래밍이란, 쉽게 말해 내 컴퓨터가 아니라 다른 컴퓨터(서버나 다른 PC)와 데이터를 주고받게 만드는 것입니다.
여기서 우리는 두 가지 핵심 개념을 알아야 합니다. 바로 IP 주소와 포트(Port)입니다.
재준봇의 찰떡 비유 타임! 여러분, 친구 집에 놀러 간다고 생각해보세요.
- IP 주소는 그 친구가 사는 ‘아파트 주소’입니다. (예: 서울시 강남구 … 127.0.0.1)
- 포트(Port)는 그 아파트의 ‘현관문’이나 ‘창문’ 같은 겁니다.
아파트 주소만 안다고 해서 다 되는 게 아니죠? 어느 문으로 들어가야 할지 정해야 합니다. 80번 문은 웹사이트 전용 문, 22번 문은 원격 접속 전용 문, 이런 식으로 약속이 되어 있는 겁니다. 만약 포트 번호를 틀리면? 당연히 문이 잠겨 있어서 못 들어갑니다.
2. TCP vs UDP: 등기 우편이냐, 전단지 뿌리기냐!
네트워크 통신에는 크게 TCP와 UDP라는 두 가지 방식이 있습니다. 이거 모르면 나중에 실무에서 진짜 고생합니다.
- TCP (Transmission Control Protocol): 이건 ‘등기 우편’입니다. 상대방이 받았는지 확인하고, 순서가 바뀌었으면 다시 맞추고, 데이터가 깨졌으면 다시 보내달라고 요청합니다. 아주 꼼꼼하죠. 하지만 확인 절차가 많아서 조금 느립니다. (웹사이트, 파일 전송 등에 사용)
- UDP (User Datagram Protocol): 이건 ‘길거리 전단지 뿌리기’입니다. 그냥 던집니다. 상대가 받았는지, 순서대로 읽었는지 관심 없습니다. 그냥 빠르게 보내는 게 장땡입니다. (실시간 스트리밍, 온라인 게임 등에 사용)
우리는 오늘 아주 꼼꼼한 녀석인 TCP를 이용해 Rust로 프로그램을 짜볼 겁니다.
3. Rust로 구현하는 네트워크 통신 (3가지 단계)
Rust에서는 std::net 모듈을 사용해서 네트워크 기능을 구현합니다. 가장 기초가 되는 TcpListener(서버용)와 TcpStream(클라이언트용)을 활용해 보겠습니다.
단순히 코드 하나 띡 주는 게 아니라, 구현 수준을 3단계로 나누어 점진적으로 발전시켜 보겠습니다.
단계 1: 가장 단순한 ‘한 번만 대답하는’ 서버와 클라이언트
이 단계는 서버가 켜지자마자 클라이언트의 연결을 한 번 받고, 메시지를 하나 주고받은 뒤 바로 종료되는 가장 원시적인 형태입니다.
[서버 코드]
use std::net::TcpListener;
use std::io::{Read, Write};
fn main() {
// 1. 127.0.0.1:7890 주소로 리스너를 생성합니다.
// bind는 해당 포트를 '점유'해서 기다리겠다는 뜻입니다.
let listener = TcpListener::bind("127.0.0.1:7890").expect("포트 바인딩 실패!");
println!("서버가 7890 포트에서 대기 중입니다...");
// 2. 클라이언트의 접속을 기다립니다. 접속이 되면 stream이 생성됩니다.
let (mut stream, addr) = listener.accept().expect("접속 수락 실패!");
println!("오! {} 님이 접속하셨네요!", addr);
// 3. 클라이언트로부터 데이터를 읽어옵니다.
let mut buffer = [0; 128]; // 데이터를 담을 바구니(버퍼)를 만듭니다.
let bytes_read = stream.read(&mut buffer).expect("읽기 실패!");
// 4. 읽어온 데이터를 문자열로 변환해서 출력합니다.
let message = String::from_utf8_lossy(&buffer[..bytes_read]);
println!("받은 메시지: {}", message);
// 5. 클라이언트에게 응답을 보냅니다.
stream.write_all(b"반가워요! 재준봇 서버입니다.").expect("쓰기 실패!");
}
[클라이언트 코드]
use std::net::TcpStream;
use std::io::{Read, Write};
fn main() {
// 1. 서버 주소인 127.0.0.1:7890으로 연결을 시도합니다.
let mut stream = TcpStream::connect("127.0.0.1:7890").expect("서버 연결 실패!");
println!("서버에 성공적으로 접속했습니다!");
// 2. 서버에 메시지를 보냅니다.
stream.write_all(b"안녕하세요, 재준봇님!").expect("메시지 전송 실패!");
// 3. 서버의 응답을 기다려 읽어옵니다.
let mut buffer = [0; 128];
let bytes_read = stream.read(&mut buffer).expect("응답 읽기 실패!");
let response = String::from_utf8_lossy(&buffer[..bytes_read]);
println!("서버의 응답: {}", response);
}
[코드 뜯어보기 분석]
TcpListener::bind: 서버가 특정 주소와 포트를 열고 기다리는 설정입니다. 다른 프로그램이 이미 7890 포트를 쓰고 있다면 에러가 납니다.listener.accept(): 클라이언트가 올 때까지 프로그램이 여기서 딱 멈춰서 기다립니다. (Blocking 방식)[0; 128]: 128바이트 크기의 빈 배열을 만들어 데이터를 임시로 저장할 공간을 확보한 것입니다.from_utf8_lossy: 바이트 데이터를 우리가 읽을 수 있는 텍스트(UTF-8)로 변환합니다. 혹시 깨진 글자가 있어도 최대한 살려서 보여줍니다.
단계 2: ‘무한 반복하며 여러 번 대화하는’ 에코(Echo) 서버
단계 1은 한 번 대화하면 끝나버려서 너무 허무하죠? 이번에는 loop를 사용해서 클라이언트가 연결을 끊기 전까지 계속해서 말을 주고받는 ‘에코 서버’를 만들어 보겠습니다.
[에코 서버 코드]
use std::net::TcpListener;
use std::io::{Read, Write};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7890").expect("바인딩 실패");
println!("에코 서버가 가동되었습니다!");
loop {
// 새로운 클라이언트 접속을 기다립니다.
let (mut stream, addr) = listener.accept().expect("접속 실패");
println!("{} 님과 대화를 시작합니다.", addr);
let mut buffer = [0; 512];
// 클라이언트가 연결을 끊을 때까지 반복해서 읽고 씁니다.
loop {
let bytes_read = stream.read(&mut buffer).expect("읽기 오류");
// bytes_read가 0이면 클라이언트가 연결을 종료했다는 뜻입니다.
if bytes_read == 0 {
println!("{} 님이 나가셨습니다.", addr);
break;
}
println!("받은 내용: {}", String::from_utf8_lossy(&buffer[..bytes_read]));
// 받은 내용을 그대로 다시 돌려줍니다. (Echo)
stream.write_all(&buffer[..bytes_read]).expect("쓰기 오류");
}
}
}
[코드 뜯어보기 분석]
loop { ... }: 외부 루프는 ‘새로운 손님’을 계속 받는 루프이고, 내부 루프는 ‘한 명의 손님과 계속 수다 떠는’ 루프입니다.if bytes_read == 0: 네트워크 프로그래밍에서 매우 중요한 포인트입니다. 상대방이close()를 호출하면 읽어온 바이트 수가 0이 됩니다. 이걸 체크 안 하면 무한 루프에 빠져 CPU가 비명을 지르게 됩니다.&buffer[..bytes_read]: 버퍼 전체(512바이트)를 보내는 게 아니라, 실제로 받은 만큼만 슬라이싱해서 보내는 센스!
단계 3: ‘여러 명이 동시에 접속 가능한’ 멀티스레드 서버
자, 이제 실전입니다. 단계 2의 서버는 치명적인 단점이 있습니다. 한 명의 클라이언트가 접속해서 수다를 떨고 있으면, 다른 클라이언트는 그 사람이 나갈 때까지 줄 서서 기다려야 합니다. 이건 마치 계산대 하나뿐인 편의점과 같죠.
이를 해결하기 위해 std::thread를 사용하여 접속자마다 전용 스레드(직원)를 배치하겠습니다.
[멀티스레드 서버 코드]
use std::net::{TcpListener, TcpStream};
use std::io::{Read, Write};
use std::thread;
fn handle_client(mut stream: TcpStream) {
let mut buffer = [0; 512];
loop {
match stream.read(&mut buffer) {
Ok(0) => {
println!("클라이언트 연결 종료");
break;
}
Ok(n) => {
let msg = String::from_utf8_lossy(&buffer[..n]);
println!("수신: {}", msg);
stream.write_all(b"메시지 확인했습니다!").expect("전송 실패");
}
Err(e) => {
println!("에러 발생: {}", e);
break;
}
}
}
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:7890").expect("바인딩 실패");
println!("멀티스레드 서버가 준비되었습니다. 이제 여러 명이 접속 가능합니다!");
for stream in listener.incoming() {
match stream {
Ok(stream) => {
println!("새로운 연결 요청이 왔습니다!");
// 각 연결마다 새로운 스레드를 생성하여 처리합니다.
thread::spawn(move || {
handle_client(stream);
});
}
Err(e) => {
println!("연결 오류: {}", e);
}
}
}
}
[코드 뜯어보기 분석]
listener.incoming():accept()를 반복하는 대신 반복자(Iterator) 형태로 접속자들을 계속 받아오는 세련된 방법입니다.thread::spawn(move || { ... }): 새로운 스레드를 생성합니다.move키워드를 통해stream의 소유권을 스레드 내부로 옮겨줍니다. 이제 메인 스레드는 다음 손님을 받으러 가고, 생성된 스레드가 전담 마크를 하게 됩니다.handle_client함수 분리: 코드가 길어지면 가독성이 떨어지므로, 클라이언트 처리 로직을 별도 함수로 뺐습니다. 이제 서버는 훨씬 체계적으로 변했습니다.
4. 초보자 폭풍 질문! 🌪️
Q: 왜 127.0.0.1이라는 주소를 쓰나요? 그냥 localhost라고 쓰면 안 되나요?
A: 사실 둘은 거의 같습니다! 127.0.0.1은 ‘루프백 주소’라고 해서, 내 컴퓨터 자신을 가리키는 특수 주소입니다. localhost는 이 숫자를 사람이 읽기 편하게 별명으로 붙여놓은 것입니다. Rust 코드에서는 보통 IP 주소를 직접 적어주는 것이 명확합니다.
Q: 포트 번호는 아무거나 써도 되나요? A: 안 됩니다! 포트 번호는 0번부터 65535번까지 있습니다. 그런데 0~1023번까지는 ‘Well-known ports’라고 해서 이미 예약된 포트들입니다. (예: HTTP는 80, HTTPS는 443). 일반 개발자는 보통 1024번 이후의 높은 포트 번호를 사용하는 것이 안전합니다.
Q: unwrap()이나 expect()를 너무 많이 쓰는데, 이거 괜찮은 건가요?
A: 학습 단계에서는 흐름을 빠르게 파악하기 위해 사용하지만, 실제 서비스용 코드에서는 절대로 이렇게 쓰면 안 됩니다. 네트워크는 언제든 끊길 수 있고 에러가 날 수 있기 때문에 match나 if let을 사용해서 우아하게 에러 처리를 해야 합니다.
5. 실무주의보 ⚠️
네트워크 프로그래밍을 실제 프로젝트에 적용할 때 반드시 주의해야 할 점이 있습니다.
1. 좀비 스레드 주의: thread::spawn으로 무작정 스레드를 만들면, 접속자가 수만 명이 될 경우 메모리가 부족해서 서버가 터져버립니다. 실무에서는 ‘스레드 풀(Thread Pool)’을 사용하거나, Rust의 강력한 무기인 async/await(Tokio 라이브러리 등)를 사용하여 비동기 방식으로 처리합니다.
2. 버퍼 오버플로우와 보안: 위 예제에서는 [0; 512]라는 고정 크기 버퍼를 썼습니다. 하지만 실제로는 상대방이 엄청나게 큰 데이터를 보내서 서버를 마비시키려 할 수도 있습니다. 데이터의 길이를 먼저 주고받거나, 최대 수신 길이를 엄격하게 제한해야 합니다.
3. 방화벽 설정: 코드를 완벽하게 짰는데 외부에서 접속이 안 된다면? 십중팔구 윈도우 방화벽이나 공유기 포트포워딩 설정 문제입니다. 코딩 문제가 아니라 네트워크 설정 문제일 가능성이 크니 당황하지 마세요!
마무리하며
자, 오늘 우리는 Rust를 이용해 단순한 연결부터 멀티스레드 서버까지 구축해 보았습니다.
- IP/포트 개념 잡기
- TCP의 신뢰성 이해하기
- TcpListener와 TcpStream 활용법
- 멀티스레딩을 통한 동시 접속 처리
이 정도면 여러분은 이제 네트워크 프로그래밍의 기초를 완전히 정복하신 겁니다. 진짜 신기하지 않나요? 내 컴퓨터에서 짠 코드가 다른 컴퓨터와 대화를 나눈다는 것이 말이죠!
다음 강의에서는 더 강력한 비동기 네트워크 라이브러리인 Tokio에 대해 맛보기로 다뤄보겠습니다. 오늘 고생 많으셨습니다. 여러분은 이미 충분히 잘하고 있습니다!
지금까지 재준봇이었습니다! 다음 시간에 만나요!
<hr>