콘텐츠로 이동

Rust 네트워킹: TCP 서버와 HTTP 클라이언트

우리가 쓰는 프로그램은 거의 다 다른 컴퓨터와 이야기를 나눠요. 채팅 앱도, 게임도, 날씨 위젯도요. 속을 들여다보면 모두 연결(connection)을 열고, 바이트를 조금 보내고, 답을 읽어요. Rust는 이 일을 아주 잘해요. 빠르고, 메모리를 적게 쓰고, 새벽 3시에 네트워크 코드를 죽게 만드는 버그를 컴파일러가 많이 잡아 줘요.

이 페이지에서는 작은 TCP 서버와 클라이언트를 만들고, 여러 클라이언트를 동시에 처리해 보고(처음엔 스레드로, 그다음엔 async tokio로), 마지막으로 reqwest로 실제 HTTP API를 호출해요. TCP 예제는 여기 Rust Playground에서 바로 실행돼요. reqwest 예제는 인터넷이 필요해서, 여러분 컴퓨터에서 cargo run으로 실행해야 해요. 모든 예제는 직접 빌드하고 실행해 봤고, 보여 드리는 결과는 실제 결과예요.

  • 네트워크 서비스와 프록시. Cloudflare는 프록시용 오픈 소스 프레임워크인 Pingora(영어)를 Rust로 만들었어요. 서비스 메시 Linkerd(영어)도 Rust로 만든 프록시를 써요.
  • 웹 API와 백엔드. tokio 위에서 동작하는 axum으로 만들어요. 프로젝트: 웹 API 만들기를 보세요.
  • API 클라이언트와 도구. GitHub를 호출하는 명령줄 도구, 봇, 내 사이트가 살아 있는지 확인하는 스크립트 같은 것들이요.
  • 직접 만든 프로토콜. 게임 서버, 채팅 서버, UDP로 센서 값을 보내는 장치 같은 것들이요.

어려운 점도 있어요. 비동기 Rust는 배우는 데 시간이 꽤 걸리고, 네트워크 크레이트를 처음 빌드하면 1~2분은 걸려요. API 하나를 호출하는 10줄짜리 스크립트라면 Python이 더 빨리 짤 수 있을 거예요. Rust는 프로그램이 빨라야 할 때, 몇 달씩 쉬지 않고 돌아야 할 때, 연결을 아주 많이 처리해야 할 때 진가를 발휘해요.

크레이트 용도 방식 이럴 때 골라요
std::net Rust에 기본으로 들어 있는 TCP, UDP 소켓 블로킹 공부할 때, 작은 도구, 연결이 몇 개뿐일 때. 의존성이 없어요.
tokio 비동기 TCP, UDP, 타이머를 갖춘 비동기 런타임 비동기 연결이 한꺼번에 아주 많을 때. 대부분의 네트워크 크레이트가 tokio 위에서 동작해요.
reqwest 쓰기 편한 HTTP 클라이언트(JSON, 타임아웃, TLS) 비동기(블로킹도 선택 가능) 웹 API를 호출할 때. 보통 가장 먼저 고르는 크레이트예요.
ureq 작고 단순한 HTTP 클라이언트 블로킹 async나 tokio를 전혀 쓰고 싶지 않을 때.
hyper reqwest와 axum 밑에서 도는 저수준 HTTP 엔진 비동기 프레임워크를 만들거나, 모든 걸 직접 제어해야 할 때.
axum HTTP 서버용 웹 프레임워크 비동기 웹 API를 만들 때. axum 프로젝트를 보세요.

모두 성숙하고 널리 쓰이는 크레이트예요. 추천하는 순서는 이래요. std::net으로 기본 개념을 익히고, 그다음 tokio, reqwest, axum으로 넘어가세요.

프로그램 하나에 TCP 서버와 클라이언트 넣기

섹션 제목: “프로그램 하나에 TCP 서버와 클라이언트 넣기”

TCP는 두 프로그램 사이에 바이트가 흐르는 스트림(stream)을 만들어 줘요. 전화 통화와 비슷해요. 서버는 주소와 포트에서 기다리고(listen), 클라이언트는 거기로 접속해요(connect). 그러면 양쪽 모두 읽고 쓸 수 있어요.

보통 서버와 클라이언트는 서로 다른 프로그램이에요. 여기서는 Playground에서 실행할 수 있도록 둘을 한 프로그램에 넣었어요. 서버는 스레드에서 돌고, main이 클라이언트예요.

use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn main() -> std::io::Result<()> {
// 포트 0은 "아무 빈 포트나"라는 뜻이에요. 그다음 어떤 포트를 받았는지 물어봐요.
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
// 서버: 클라이언트 하나를 받아서, 한 줄을 읽고, 한 줄로 답해요.
let server = thread::spawn(move || -> std::io::Result<()> {
let (stream, _) = listener.accept()?;
let mut name = String::new();
BufReader::new(&stream).read_line(&mut name)?;
writeln!(&stream, "안녕하세요, {}님!", name.trim())?;
Ok(())
});
// 클라이언트: 접속해서, 한 줄을 보내고, 답을 읽어요.
let stream = TcpStream::connect(addr)?;
writeln!(&stream, "Ferris")?;
let mut reply = String::new();
BufReader::new(&stream).read_line(&mut reply)?;
print!("클라이언트가 받은 답: {reply}");
server.join().unwrap()?;
println!("서버 종료");
Ok(())
}

실행 결과:

클라이언트가 받은 답: 안녕하세요, Ferris님!
서버 종료
▶ 직접 실행해 보기

눈여겨볼 점이 몇 가지 있어요.

  • 127.0.0.1은 “이 컴퓨터 안에서만”이라는 뜻이에요. 다른 컴퓨터에서도 서버에 접속해야 한다면 0.0.0.0을 쓰세요.
  • TCP는 메시지 하나가 어디서 끝나는지 전혀 몰라요. 그래서 간단한 규칙을 정했어요. 메시지 하나 = 한 줄이에요. writeln!은 println!이 화면에 쓰듯 스트림에 써요. 끝에 \n도 붙여 주고요. read_line은 그 \n까지 읽어요.
  • 네트워크 호출은 언제든 실패할 수 있어요. 그래서 모두 Result를 돌려주고, 우리는 ?를 써요.
  • &stream 하나로 읽기와 쓰기를 모두 할 수 있어요. &TcpStream이 Read와 Write를 둘 다 구현하기 때문이에요.

스레드로 여러 클라이언트 처리하기

섹션 제목: “스레드로 여러 클라이언트 처리하기”

진짜 서버라면 2번 클라이언트가 1번 클라이언트가 끝날 때까지 기다리게 하면 안 돼요. 전통적인 해결책은 연결마다 스레드 하나예요. 반복문이 클라이언트를 받아서 새 스레드에 넘기고, 곧바로 다시 accept로 돌아가요.

이 서버는 받은 줄마다 단어 수를 세서 답해요. 클라이언트 셋이 동시에 접속해요.

use std::io::{BufRead, BufReader, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::thread;
// 서버 쪽: 받은 줄마다 단어 수로 답해요.
fn handle_client(stream: TcpStream) -> std::io::Result<()> {
for line in BufReader::new(&stream).lines() {
let words = line?.split_whitespace().count();
writeln!(&stream, "단어 {words}개")?;
}
Ok(()) // 클라이언트가 연결을 닫았어요
}
// 클라이언트 쪽: 한 줄을 보내고, 답을 돌려줘요.
fn ask(addr: SocketAddr, msg: &str) -> std::io::Result<String> {
let stream = TcpStream::connect(addr)?;
writeln!(&stream, "{msg}")?;
let mut reply = String::new();
BufReader::new(&stream).read_line(&mut reply)?;
Ok(format!("{msg:?} -> {}", reply.trim()))
}
fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
thread::spawn(move || {
for stream in listener.incoming() {
let stream = stream.unwrap();
thread::spawn(move || handle_client(stream)); // 클라이언트마다 스레드 하나
}
});
// 클라이언트 셋이 동시에, 각자 자기 스레드에서 접속해요.
let clients = ["안녕하세요 여러분", "Rust는 정말 배우기 재미있어요", "다음에 또 만나요"]
.map(|msg| thread::spawn(move || ask(addr, msg)));
// 순서대로 기다려서, 출력 순서가 항상 같게 해요.
for c in clients {
println!("{}", c.join().unwrap()?);
}
Ok(())
}

실행 결과:

"안녕하세요 여러분" -> 단어 2개
"Rust는 정말 배우기 재미있어요" -> 단어 4개
"다음에 또 만나요" -> 단어 3개
▶ 직접 실행해 보기

클라이언트가 자기 TcpStream을 버리면(drop) 연결이 닫히고, lines()가 끝나고, 그 클라이언트의 스레드도 끝나요. accept 반복문은 끝나지 않는데, 서버라면 원래 그래요. 여기서는 main이 반환될 때 프로그램이 그냥 멈춰요. 이 패턴은 단순하고, 클라이언트가 수십, 수백 개 정도면 잘 동작해요. 하지만 수천 개가 되면 스레드마다 드는 메모리가 꽤 커져요. 바로 그때 async가 등장해요.

tokio에서는 클라이언트 하나가 스레드가 아니라 태스크(task) 하나예요. 태스크는 아주 가벼워서, 서버 하나가 연결을 수천 개도 넘게 붙잡고 있을 수 있어요. 위의 스레드 버전과 비교해 보세요. std::net은 tokio::net이 되고, thread::spawn은 tokio::spawn이 되고, 프로그램이 네트워크를 기다리는 곳마다 .await를 붙여요.

use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
// 같은 단어 세기 서버지만, 클라이언트마다 스레드 대신 가벼운 태스크를 써요.
async fn handle_client(stream: TcpStream) -> std::io::Result<()> {
let (read_half, mut write_half) = stream.into_split();
let mut lines = BufReader::new(read_half).lines();
while let Some(line) = lines.next_line().await? {
let words = line.split_whitespace().count();
write_half.write_all(format!("단어 {words}개\n").as_bytes()).await?;
}
Ok(())
}
async fn ask(addr: std::net::SocketAddr, msg: &str) -> std::io::Result<String> {
let mut stream = TcpStream::connect(addr).await?;
stream.write_all(format!("{msg}\n").as_bytes()).await?;
let mut reply = String::new();
BufReader::new(stream).read_line(&mut reply).await?;
Ok(format!("{msg:?} -> {}", reply.trim()))
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(handle_client(stream)); // 클라이언트마다 태스크 하나
}
});
// 클라이언트 셋을 한꺼번에 시작하고, 순서대로 하나씩 기다려요.
let clients = ["안녕하세요 여러분", "Rust는 정말 배우기 재미있어요", "다음에 또 만나요"]
.map(|msg| tokio::spawn(ask(addr, msg)));
for c in clients {
println!("{}", c.await.unwrap()?);
}
Ok(())
}

실행 결과:

"안녕하세요 여러분" -> 단어 2개
"Rust는 정말 배우기 재미있어요" -> 단어 4개
"다음에 또 만나요" -> 단어 3개
▶ 직접 실행해 보기

into_split()은 읽기용 절반과 쓰기용 절반을 따로 돌려줘요. 그래서 읽는 쪽과 쓰는 쪽을 서로 다른 변수에 둘 수 있어요. 앞에서처럼 accept 반복문은 영원히 돌아요. main이 반환되면 tokio가 모든 태스크를 멈춰요.

UDP에는 연결이 없어요. 주소로 메시지(데이터그램, datagram)를 하나씩 보내고, 각 메시지는 통째로 도착하거나 아예 도착하지 않아요. 순서도, 도착도 보장되지 않아요. 패킷 하나 잃어버리는 것보다 속도가 중요한 게임, 영상 통화, DNS, 센서 같은 곳에 써요. std::net에서는 UdpSocket 타입 하나면 돼요.

let socket = UdpSocket::bind("127.0.0.1:0")?;
socket.send_to(b"temp=21.5", "127.0.0.1:9000")?; // 메시지 하나를 주소 하나로 보내요
let mut buf = [0u8; 1500];
let (n, from) = socket.recv_from(&mut buf)?; // 누구에게서든 메시지 하나가 올 때까지 기다려요

tokio::net::UdpSocket도 똑같이 동작하고, .await만 붙이면 돼요.

네트워크에서는 늘 뭔가가 잘못돼요. 아무도 기다리고 있지 않거나, 와이파이가 끊기거나, 서버가 멈춰 버려요. 프로그램을 건강하게 지키는 규칙은 두 가지예요. 모든 에러를 처리하고, 절대 끝없이 기다리지 않기. 기본적으로 읽기는 끝없이 기다려요. set_read_timeout으로 이걸 고칠 수 있어요.

use std::io::{ErrorKind, Read};
use std::net::{TcpListener, TcpStream};
use std::time::Duration;
fn main() -> std::io::Result<()> {
// 1. 아무도 기다리고 있지 않아요: 접속이 바로 실패해요.
let free_port = TcpListener::bind("127.0.0.1:0")?.local_addr()?; // 버려졌으니 = 닫혔어요
match TcpStream::connect(free_port) {
Ok(_) => println!("접속됐다고?!"),
Err(e) => println!("접속 실패: {:?}", e.kind()),
}
// 2. 접속은 받아 주지만 절대 답하지 않는 서버.
let silent = TcpListener::bind("127.0.0.1:0")?;
let mut stream = TcpStream::connect(silent.local_addr()?)?;
stream.set_read_timeout(Some(Duration::from_millis(200)))?;
let mut buf = [0u8; 64];
match stream.read(&mut buf) {
Ok(n) => println!("{n}바이트 읽음"),
// Linux는 WouldBlock을, Windows는 TimedOut을 알려 줘요.
Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {
println!("200ms가 지나서 읽기를 포기했어요")
}
Err(e) => println!("다른 에러: {e}"),
}
Ok(())
}

실행 결과:

접속 실패: ConnectionRefused
200ms가 지나서 읽기를 포기했어요
▶ 직접 실행해 보기

e.kind()는 match로 나눠 볼 수 있는 ErrorKind(영어)를 돌려줘요. ConnectionRefused는 “상대가 접속을 거부했다”는 뜻의 ErrorKind 이름이라 영어 그대로 나와요. 접속 자체가 느릴 때를 위한 TcpStream::connect_timeout도 있어요. tokio에서는 어떤 퓨처든 tokio::time::timeout으로 감싸면 돼요.

let result = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await;
// Err(_) = 너무 오래 걸렸어요. Ok(inner) = 읽기가 끝났어요(inner는 읽기 자체의 Result예요)

HTTP는 웹의 언어이고, TCP 위에서 동작해요. TcpStream 위에서 HTTP를 손으로 직접 쓸 수도 있지만, 클라이언트 크레이트가 HTTPS, 리다이렉트, 헤더, 압축 같은 세부 사항을 대신 처리해 줘요. 이 예제들은 인터넷 접속과 reqwest 크레이트가 필요해서 Playground에서는 실행되지 않아요. 여러분 컴퓨터에서 실행해 보세요.

Terminal window
cargo new api-client
cd api-client
cargo add reqwest --features json
cargo add tokio --features full
cargo add serde --features derive
[dependencies]
reqwest = { version = "0.13", features = ["json"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

연습용으로 만들어진 무료 가짜 API인 JSONPlaceholder(영어)를 쓸 거예요. 처음 빌드할 때는 크레이트를 꽤 많이 내려받아 컴파일하니까 1분쯤 기다려 주세요.

reqwest::get(url).await?.text().await?를 쓰면 응답 본문을 String으로 받을 수 있어요. 그런데 JSON이라면 더 좋은 방법이 있어요. json 기능을 켜면 reqwest가 serde를 써서 응답을 바로 여러분의 구조체로 바꿔 주고, 거꾸로 구조체를 요청 본문으로 바꿔 줘요.

use serde::{Deserialize, Serialize};
// 여기 적은 필드만 읽어요. JSON에 있는 다른 필드("userId" 같은)는 무시돼요.
#[derive(Deserialize)]
struct Todo {
id: u32,
title: String,
completed: bool,
}
#[derive(Serialize)]
struct NewTodo {
title: String,
completed: bool,
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let client = reqwest::Client::new();
// GET: JSON -> 여러분의 구조체
let todo: Todo = client
.get("https://jsonplaceholder.typicode.com/todos/1")
.send()
.await?
.json()
.await?;
println!("#{}: {} (done: {})", todo.id, todo.title, todo.completed);
// POST: 여러분의 구조체 -> JSON (이 테스트 API는 답은 하지만, 실제로 저장하지는 않아요)
let new = NewTodo { title: "Learn reqwest".to_string(), completed: false };
let response = client
.post("https://jsonplaceholder.typicode.com/todos")
.json(&new)
.send()
.await?;
println!("status: {}", response.status());
println!("{}", response.text().await?);
Ok(())
}
#1: delectus aut autem (done: false)
status: 201 Created
{
"title": "Learn reqwest",
"completed": false,
"id": 201
}

(이 결과는 위 코드를 실제로 실행해서 얻은 것이라, 코드와 결과를 영어 그대로 두었어요. delectus aut autem은 JSONPlaceholder가 돌려주는 가짜 라틴어 제목이에요.)

Client는 하나만 만들어서 계속 다시 쓰세요. 연결을 열어 둔 채로 재사용해서, 다음 요청이 더 빨라져요. .json(&new)는 Content-Type: application/json 헤더도 알아서 붙여 줘요.

async를 쓰고 싶지 않다면? ureq는 작은 블로킹 클라이언트예요. ureq::get(url).call()?.body_mut().read_to_string()?로 본문을 String으로 받을 수 있고, tokio도 필요 없어요.

reqwest에는 기본 타임아웃이 없어요. 그래서 멈춘 서버 하나 때문에 프로그램이 끝없이 기다릴 수 있어요. 클라이언트에 타임아웃을 설정하세요. 또 하나 알아 둘 점이 있어요. 404 Not Found는 reqwest에게 에러가 아니에요. 요청 자체는 성공했고, 답이 “없어요”였을 뿐이니까요. 4xx와 5xx 응답을 에러로 바꾸려면 error_for_status()를 호출하세요.

use std::time::Duration;
// 네트워크 문제뿐 아니라 4xx/5xx 응답도 에러로 바꿔요.
async fn fetch(client: &reqwest::Client, url: &str) -> Result<String, reqwest::Error> {
let response = client.get(url).send().await?.error_for_status()?;
response.text().await
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
// 타임아웃이 없으면, 멈춘 서버에 보낸 요청은 끝없이 기다릴 수 있어요.
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()?;
// 접속은 받아 주지만 절대 답하지 않는 로컬 서버.
let silent = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let stuck = format!("http://{}/", silent.local_addr().unwrap());
let urls = [
"https://jsonplaceholder.typicode.com/todos/1",
"https://jsonplaceholder.typicode.com/todos/999999",
"http://127.0.0.1:1/",
&stuck,
];
for url in urls {
match fetch(&client, url).await {
Ok(body) => println!("ok, {} bytes", body.len()),
Err(e) if e.is_status() => println!("server said {}", e.status().unwrap()),
Err(e) if e.is_connect() => println!("could not connect: {e}"),
Err(e) if e.is_timeout() => println!("gave up after 2 seconds"),
Err(e) => println!("other error: {e}"),
}
}
Ok(())
}
ok, 83 bytes
server said 404 Not Found
could not connect: error sending request for url (http://127.0.0.1:1/)
gave up after 2 seconds

결과의 네 줄은 차례대로 “성공, 83바이트”, “서버가 404 Not Found라고 답함”, “접속할 수 없음”, “2초 뒤에 포기함”이라는 뜻이에요. is_status(), is_connect(), is_timeout()은 어떤 종류의 실패인지 알려 줘요. 그래서 나중에 다시 시도할지, 메시지를 보여 줄지, 포기할지 정할 수 있어요. 더 큰 프로그램에서는 anyhow로 이런 에러에 맥락을 쉽게 덧붙일 수 있어요.

  • 양쪽이 서로를 기다려요. 클라이언트는 read_line을 호출했는데, 클라이언트가 \n을 빠뜨리는 바람에 서버도 아직 읽는 중이에요. 아무도 보내지 않으니 프로그램이 멈춰 버려요. 메시지가 어디서 끝나는지 미리 약속하세요(줄바꿈이 가장 쉬운 규칙이에요).
  • “끝까지 읽기”를 너무 일찍 해요. read_to_string은 상대가 연결을 닫을 때까지 기다려요. 계속 열려 있는 연결에서는 영원히 돌아오지 않아요. 대신 한 줄씩 읽으세요.
  • async 코드 안에서 블로킹해요. tokio 태스크 안에서 std::net이나 std::thread::sleep을 쓰면 워커 스레드 하나가 통째로 막혀요. async 코드에서는 tokio::net과 tokio::time::sleep을 쓰세요.
  • 타임아웃을 깜빡해요. 빠른 로컬 네트워크에서는 테스트가 다 통과해요. 그러다 느린 서버 하나가 모든 걸 얼려 버리죠. 타임아웃은 첫날부터 설정하세요.

add 2 3이나 mul 4 5 같은 줄을 읽고 결과로 답하는 서버를 만들어 보세요. 잘못된 줄에는 에러: ... 같은 짧은 메시지로 답해요. main에서 연결 하나로 질문 네 개를 보내서 테스트하세요. 힌트: split_whitespace()로 줄을 나누고, parse::<i64>()로 숫자를 파싱하고, writeln!으로 답하세요.

정답 보기
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn calculate(line: &str) -> String {
let parts: Vec<&str> = line.split_whitespace().collect();
let [op, a, b] = parts[..] else {
return "에러: <연산> <a> <b> 형식으로 보내 주세요".to_string();
};
let (Ok(a), Ok(b)) = (a.parse::<i64>(), b.parse::<i64>()) else {
return "에러: 숫자가 아니에요".to_string();
};
match op {
"add" => (a + b).to_string(),
"mul" => (a * b).to_string(),
_ => format!("에러: 모르는 연산 {op}"),
}
}
fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
let server = thread::spawn(move || -> std::io::Result<()> {
let (stream, _) = listener.accept()?;
for line in BufReader::new(&stream).lines() {
writeln!(&stream, "{}", calculate(&line?))?;
}
Ok(())
});
let stream = TcpStream::connect(addr)?;
let mut replies = BufReader::new(&stream);
for question in ["add 2 3", "mul 4 5", "add 2 x", "div 8 2"] {
writeln!(&stream, "{question}")?;
let mut answer = String::new();
replies.read_line(&mut answer)?;
println!("{question} = {}", answer.trim());
}
drop(stream); // 연결을 닫으면 서버의 반복문이 끝나요
server.join().unwrap()
}

실행 결과:

add 2 3 = 5
mul 4 5 = 20
add 2 x = 에러: 숫자가 아니에요
div 8 2 = 에러: 모르는 연산 div
▶ 직접 실행해 보기

계산 로직을 평범한 calculate 함수에 넣어 두면, 네트워크 없이도 테스트할 수 있어요. let ... else 줄은 입력 모양이 맞지 않을 때 일찍 반환해요.

2. 끝낸 할 일 세기 (내 컴퓨터에서)

섹션 제목: “2. 끝낸 할 일 세기 (내 컴퓨터에서)”

reqwest로 https://jsonplaceholder.typicode.com/todos?userId=1을 가져와 보세요. 할 일들이 담긴 JSON 배열이 돌아와요. 할 일이 모두 몇 개인지, 그중 몇 개를 끝냈는지, 그리고 아직 끝내지 않은 첫 번째 할 일의 제목을 출력하세요. 힌트: Vec<Todo>로 역직렬화한 다음, filter와 find 같은 반복자 메서드를 쓰세요.

정답 보기
use serde::Deserialize;
#[derive(Deserialize)]
struct Todo {
title: String,
completed: bool,
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let todos: Vec<Todo> = reqwest::get("https://jsonplaceholder.typicode.com/todos?userId=1")
.await?
.error_for_status()?
.json()
.await?;
let done = todos.iter().filter(|t| t.completed).count();
println!("user 1 has {} to-dos, {done} completed", todos.len());
let first_open = todos.iter().find(|t| !t.completed).unwrap();
println!("first open one: {}", first_open.title);
Ok(())
}
user 1 has 20 to-dos, 11 completed
first open one: delectus aut autem

1번 사용자는 할 일이 20개이고 그중 11개를 끝냈어요. 아직 끝내지 않은 첫 번째 할 일은 delectus aut autem이에요. JSON 배열은 Vec에 대응하고, serde가 원소를 하나하나 채워 줘요. 구조체에는 필요한 필드만 적으면 돼요.

  • std::net은 추가 크레이트 없이 블로킹 TCP(TcpListener, TcpStream)와 UDP(UdpSocket)를 제공해요.
  • 포트 0에 바인딩하면 아무 빈 포트나 받을 수 있고, local_addr()로 어떤 포트인지 다시 읽을 수 있어요.
  • TCP는 바이트 스트림이에요. 메시지가 어디서 끝나는지 약속하세요. 예를 들어 한 줄에 메시지 하나처럼요.
  • 여러 클라이언트는 연결마다 스레드 하나로 처리하거나, 수천 개가 필요하면 tokio 태스크로 처리해요.
  • HTTP API는 reqwest(비동기)나 ureq(블로킹)로 호출하고, serde가 JSON을 구조체로 바꿔 줘요.
  • 타임아웃은 항상 설정하고, 404가 성공처럼 보이지 않도록 error_for_status()를 쓰세요.

이해도 점검

1. TcpListener를 127.0.0.1:0에 바인딩하면 어떻게 되나요?

2. 이 페이지의 예제들은 왜 메시지마다 끝에 \n을 보낼까요?

3. reqwest가 404 Not Found 응답을 받았어요. send().await는 무엇을 돌려주나요?

4. tokio 서버가 클라이언트마다 std::net과 std::thread::sleep을 쓰고 있어요. 무엇이 문제일까요?

점수: 0 / 4