9단원 앞쪽의 네 가지, 설정 읽기, 성질 테스트, 로그에서 비밀값 빼기, 작은 HTTP API를 서비스의 한 조각씩으로 짜 보는 네 문제다. 표준 라이브러리 밖의 크레이트를 쓰므로, 각 코드 파일 첫 주석의 의존성을 Cargo.toml에 넣고 cargo run과 cargo test로 돌린다.
p1 · 틀린 것을 모두 모으는 설정 읽기
다음 세 값을 환경에서 읽어 Limits를 만드는 함수 fn load(env: &HashMap<&str, &str>) -> Result<Limits, Vec<String>>를 써라.
| 변수 | 없을 때 | 규칙 |
|---|---|---|
APP_MAX_BODY_KB |
64 | 1~10240의 정수 |
APP_ALLOWED_ORIGINS |
오류(필수) | 쉼표로 나눈 목록. 앞뒤 공백을 지우고 빈 항목은 버린다. 항목마다 http://나 https://로 시작 |
APP_ADMIN_PASSWORD |
오류(필수) | 12글자 이상. Limits를 {:?}로 찍어도 보이면 안 된다 |
- 틀린 것은 첫 번째에서 멈추지 말고 모두 모아 돌려준다.
- 비밀번호가 틀렸을 때 오류 메시지에 그 값을 넣지 않는다.
- (가) 함수를 써라. (나) 아래 입력에서 오류 목록은 무엇인가?
[("APP_MAX_BODY_KB", "0"), ("APP_ALLOWED_ORIGINS", "https://a.example,ftp://x"), ("APP_ADMIN_PASSWORD", "hunter2")]
사용 개념: 설정과 비밀값의 오류 모으기와 Secret 감싸개.
풀이 전략: 변수마다 따로 검사하고, 틀리면 오류를 Vec에 넣은 뒤 다음 변수로 간다. 맞는지 틀렸는지는 맨 끝에서 한 번에 판정한다. 비밀번호는 Debug가 가려진 글자만 찍는 새 타입에 넣는다.
풀이
- 비밀값 상자: 필드가 하나인 튜플 구조체에 손으로
Debug를 짠다. 안의 값은expose()로만 꺼낸다.
#[derive(Clone)]
pub struct Secret(String);
impl Secret {
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Secret([REDACTED])")
}
}
#[derive(Debug)]
pub struct Limits {
pub max_body_kb: u32,
pub allowed_origins: Vec<String>,
pub admin_password: Secret,
}
- 기본값이 있는 숫자: 없으면 64. 있으면 파싱과 범위를 함께 본다. 틀리면 오류를 넣고 아무 값(0)이나 둔다. 어차피 끝에서
Err가 된다.
let max_body_kb = match env.get("APP_MAX_BODY_KB") {
None => 64,
Some(v) => match v.parse::<u32>() {
Ok(n) if (1..=10_240).contains(&n) => n,
_ => {
errs.push(format!("APP_MAX_BODY_KB={v:?}: expected 1..=10240"));
0
}
},
};
- 목록과 항목마다 검사: 쉼표로 나누고 다듬은 뒤, 비었으면 “필수” 오류, 아니면 항목마다 앞머리를 본다.
let allowed_origins: Vec<String> = env
.get("APP_ALLOWED_ORIGINS")
.map(|v| {
v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
if allowed_origins.is_empty() {
errs.push("APP_ALLOWED_ORIGINS is required".to_string());
}
for o in &allowed_origins {
if !(o.starts_with("http://") || o.starts_with("https://")) {
errs.push(format!(
"APP_ALLOWED_ORIGINS item {o:?}: must start with http:// or https://"
));
}
}
- 비밀값은 값 없이 보고: 길이는 바이트가 아니라 글자 수(
chars().count())로 센다. 한글 비밀번호도 글자 수로 따지기 위해서다.
let admin_password = match env.get("APP_ADMIN_PASSWORD") {
None => {
errs.push("APP_ADMIN_PASSWORD is required".to_string());
None
}
Some(p) if p.chars().count() < 12 => {
errs.push("APP_ADMIN_PASSWORD: must be at least 12 characters".to_string());
None
}
Some(p) => Some(Secret(p.to_string())),
};
- 끝에서 한 번에 판정:
match admin_password {
Some(admin_password) if errs.is_empty() => Ok(Limits {
max_body_kb,
allowed_origins,
admin_password,
}),
_ => Err(errs),
}
- (나) 결과: 세 변수가 모두 틀려서 오류가 3개다.
hunter2는 어디에도 없다.
- APP_MAX_BODY_KB="0": expected 1..=10240
- APP_ALLOWED_ORIGINS item "ftp://x": must start with http:// or https://
- APP_ADMIN_PASSWORD: must be at least 12 characters
맞는 입력(APP_ALLOWED_ORIGINS에 "https://a.example, http://localhost:5173", 비밀번호 "correct-horse-battery")을 {:?}로 찍으면 Limits { max_body_kb: 64, allowed_origins: ["https://a.example", "http://localhost:5173"], admin_password: Secret([REDACTED]) }이다. 공백이 지워졌고 비밀번호는 가려졌다.
변형. 빈 환경(HashMap::new())을 넣으면 오류는 몇 개이고 무엇인가? APP_MAX_BODY_KB=lots만 틀리고 나머지가 맞으면?
답
빈 환경이면 2개다: APP_ALLOWED_ORIGINS is required, APP_ADMIN_PASSWORD is required. APP_MAX_BODY_KB는 없으면 64라서 오류가 아니다. lots만 틀리면 1개, APP_MAX_BODY_KB="lots": expected 1..=10240이다. 숫자가 아닌 것과 범위 밖인 것을 같은 갈래(_)에서 받아 같은 메시지를 낸다. 둘 다 테스트(empty_env_reports_two_missing, non_number_body_limit)로 확인했다.
p2 · 고르게 나누기의 성질 테스트
total개를 parts명에게 최대한 고르게 나누는 fn split_even(total: u64, parts: usize) -> Vec<u64>를 써라. 나머지는 앞사람부터 하나씩 더 받는다. 예: split_even(10, 3) = [4, 3, 3], split_even(2, 4) = [1, 1, 0, 0].
- (가) 함수를 써라.
parts가 0이면 panic한다. - (나) 어떤 입력에서도 맞아야 하는 성질 셋을 적고 proptest 테스트로 만들어라.
- (다) 나머지를 버리는 틀린 구현
vec![total / parts as u64; parts]를total ∈ 0..10000,parts ∈ 1..50에서 같은 성질로 검사하면, 축소가 끝난 반례(total, parts)는 무엇인가?
사용 개념: 테스트 전략의 성질 테스트와 축소, panic을 쓸 때.
풀이 전략: “이 입력이면 이 값”을 늘어놓는 대신, 결과가 늘 지켜야 하는 규칙을 찾는다. 나눈 결과라면 개수, 합, 고른 정도가 보인다. 축소 결과를 예측할 때는 “두 값을 더 줄이면 왜 통과하는가”를 따진다.
풀이
- (가) 몫과 나머지: 몫
q를 모두에게 주고, 나머지r명에게 하나씩 더 준다.i < r이 참이면 1인u64::from(bool)을 쓴다.
pub fn split_even(total: u64, parts: usize) -> Vec<u64> {
assert!(parts > 0, "parts must be positive");
let q = total / parts as u64;
let r = (total % parts as u64) as usize;
(0..parts).map(|i| q + u64::from(i < r)).collect()
}
- (나) 성질 셋: 길이가
parts다. 합이total이다. 최댓값과 최솟값의 차가 1 이하다. 어긋나면 어느 성질인지 돌려주는 함수로 묶는다.
fn properties(f: fn(u64, usize) -> Vec<u64>, total: u64, parts: usize) -> Result<(), String> {
let v = f(total, parts);
if v.len() != parts {
return Err(format!("len {} != {parts}", v.len()));
}
if v.iter().sum::<u64>() != total {
return Err(format!("sum {} != {total}", v.iter().sum::<u64>()));
}
let (mn, mx) = (
v.iter().min().copied().unwrap_or(0),
v.iter().max().copied().unwrap_or(0),
);
if mx - mn > 1 {
return Err(format!("max-min = {}", mx - mn));
}
Ok(())
}
proptest! {
#[test]
fn split_even_keeps_all_three_properties(total in 0u64..1_000_000, parts in 1usize..100) {
prop_assert_eq!(properties(split_even, total, parts), Ok(()));
}
}
- (다) 어디서 깨지나: 나머지가 0이 아니면 합이 모자란다.
total = 0이면 나머지가 없어 통과하고,parts = 1이면 나머지가 늘 0이라 통과한다. 축소는 두 값을 아래로 줄이다가 더 줄이면 통과하는 곳에서 멈춘다. - (다) 결과:
(1, 2)이다. 그때 틀린 구현은[0, 0]을 돌려주고, 성질 검사는Err("sum 0 != 1")이다. 실행 결과도Some((1, 2)),Some([0, 0])이었다.
변형. split_even(5, 0)은 어떻게 되는가? 0명에게 나누는 경우를 Result로 돌려주지 않고 panic으로 둔 것은 왜 괜찮은가?
답
assert!가 parts must be positive 메시지로 panic한다. 테스트 zero_parts_panics가 #[should_panic(expected = "parts must be positive")]로 확인한다. 0명에게 나누는 것은 부르는 쪽 코드의 버그이지, 실행 중에 흔히 올 수 있는 입력이 아니다. 사용자가 고르는 값이라면 앞 단계에서 검사해 Result로 돌려준다. 메시지를 넣었기 때문에 assert! 없이 0으로 나눠 panic할 때보다 원인이 바로 보인다.
p3 · 카드 번호가 없는 주문 로그
주문 처리 함수 fn handle_order(order_id: u64, amount: u64, card: &str) -> bool에 #[instrument]를 달아라.
- span에는
order_id와amount가 남고card는 남지 않는다. - 결제 결과
status("paid"또는"declined")는 처리 도중에 정해진다. 정해진 뒤의 줄에는 span에status가 붙는다. - 함수 안에서 카드의 마지막 네 자리만
last4필드로 남기고"charging"을 찍는다. 금액이 1000 이하면"order done"을, 넘으면 WARN으로"limit exceeded"를 찍는다. - (가) 함수를 써라. (나)
handle_order(1, 500, "4111111111111111")과handle_order(2, 5_000, "5500000000000004")를 JSON 구독자 아래에서 부른다. 주문 2의 줄은 몇 개이고, 각 줄의span에status가 있는가? (다) 카드 번호가 출력 어디에도 없음을 어떻게 확인하나?
사용 개념: tracing으로 관측하기의 skip, 나중에 채우는 필드, JSON 출력.
풀이 전략: 빼야 할 인자는 skip으로, 나중에 정해질 필드는 이름만 먼저 선언한다. 그리고 출력을 문자열로 붙잡아 기계로 검사한다. 눈으로 보는 확인은 다음 사람이 코드를 고칠 때 지켜 주지 못한다.
풀이
- (가) 속성:
skip(card)로 카드를 빼고,fields(status = Empty)로 이름만 선언한다.Empty는tracing::field::Empty다.
#[instrument(skip(card), fields(status = Empty))]
fn handle_order(order_id: u64, amount: u64, card: &str) -> bool {
info!(last4 = &card[card.len() - 4..], "charging");
let ok = amount <= 1_000;
Span::current().record("status", if ok { "paid" } else { "declined" });
if ok {
info!("order done");
} else {
warn!(amount, "limit exceeded");
}
ok
}
&card[card.len() - 4..]는 카드 문자열이 4바이트보다 짧으면 panic한다. 실제 코드에서는 앞 단계에서 형식을 검사한 뒤 부른다.
- (나) 줄 세기: 주문마다
charging한 줄과 결과 한 줄, 모두 4줄이다. 주문 2의 줄은 2개다. 첫 줄은record보다 먼저 찍혀서status가 없고, 둘째 줄에는"status":"declined"가 있다.
{"fields":{"last4":"0004","message":"charging"},"level":"INFO","span":{"amount":5000,"name":"handle_order","order_id":2},…}
{"fields":{"amount":5000,"message":"limit exceeded"},"level":"WARN","span":{"amount":5000,"name":"handle_order","order_id":2,"status":"declined"},…}
- (다) 기계로 확인:
with_writer에 메모리 버퍼를 주고, 붙잡은 글자 전체에 두 카드 번호가 없는지assert!한다. 주문 2의 줄은l["span"]["order_id"] == 2로 골라낸다.
let sub = tracing_subscriber::fmt()
.json()
.with_writer(move || w.clone())
.finish();
tracing::subscriber::with_default(sub, || { /* 두 주문 */ });
assert!(!text.contains("4111111111111111") && !text.contains("5500000000000004"));
변형. fields(status = Empty)를 빼고 #[instrument(skip(card))]만 둔 채 똑같이 record("status", …)를 부르면 어떻게 되나? 컴파일 오류인가, 실행 중 오류인가?
답
둘 다 아니다. 오류 없이 status가 빠진다. Span::record는 span을 만들 때 선언한 필드만 받고, 선언하지 않은 이름은 조용히 무시한다. 실행해 보면 그 함수의 span은 {"amount":500,"name":"handle_order_undeclared","order_id":3}였다. 그래서 나중에 채울 필드는 반드시 Empty로 먼저 선언하고, 붙었는지를 테스트로 본다.
p4 · 메모 API
키 하나에 메모 하나를 저장하는 HTTP API를 axum으로 짜라.
| 요청 | 인증 | 응답 |
|---|---|---|
PUT /notes/{key} 본문 {"text":"…"} |
x-api-key 헤더 필요 |
새로 만들면 201, 바꾸면 200 |
GET /notes/{key} |
없음 | 200 {"text":"…"}, 없으면 404 |
DELETE /notes/{key} |
x-api-key 헤더 필요 |
지우면 204, 없으면 404 |
- 키는 영문자·숫자로 1~32글자다. 아니면 400이다.
- 헤더가 없거나 틀리면 401이다.
- 오류는 모두 에러 타입 하나에서
{"error":"…"}JSON으로 만든다. - (가) 라우터를 써라. (나) API 키가
k-123인 라우터에 아래 요청 7개를 차례로oneshot으로 보낸다. 상태 코드 목록은?
1. PUT /notes/todo (키 없음) {"text":"buy milk"}
2. PUT /notes/todo (k-123) {"text":"buy milk"}
3. PUT /notes/todo (k-123) {"text":"buy milk"}
4. GET /notes/todo
5. GET /notes/bad-key
6. DELETE /notes/todo (k-123)
7. GET /notes/todo
사용 개념: axum으로 HTTP 서비스 만들기의 추출기 순서, IntoResponse 에러, oneshot.
풀이 전략: 핸들러마다 되풀이되는 검사(인증, 키 모양)를 Result<(), ApiError>를 돌려주는 함수로 뺀다. 그러면 핸들러에서는 ? 한 글자로 끝난다. 본문을 읽는 Json은 마지막 인자에 둔다.
풀이
- 에러 타입 하나: 세 가지 실패를 상태 코드와 메시지로 바꾼다.
enum ApiError {
Unauthorized,
BadKey(String),
NotFound(String),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, msg) = match self {
ApiError::Unauthorized => (
StatusCode::UNAUTHORIZED,
"missing or wrong x-api-key".to_string(),
),
ApiError::BadKey(k) => (
StatusCode::BAD_REQUEST,
format!("bad key {k:?}: use 1-32 letters or digits"),
),
ApiError::NotFound(k) => (StatusCode::NOT_FOUND, format!("note {k:?} not found")),
};
(status, Json(serde_json::json!({ "error": msg }))).into_response()
}
}
- 공통 검사 두 개:
fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> {
match headers.get("x-api-key") {
Some(v) if v.as_bytes() == state.api_key.as_bytes() => Ok(()),
_ => Err(ApiError::Unauthorized),
}
}
fn valid_key(key: &str) -> Result<(), ApiError> {
let ok = (1..=32).contains(&key.len()) && key.chars().all(|c| c.is_ascii_alphanumeric());
if ok { Ok(()) } else { Err(ApiError::BadKey(key.to_string())) }
}
- 핸들러:
State,HeaderMap,Path를 앞에,Json을 마지막에 둔다.insert가 돌려주는 옛 값이None이면 새로 만든 것이다.
async fn put_note(
State(s): State<Arc<AppState>>,
headers: HeaderMap,
Path(key): Path<String>,
Json(note): Json<Note>,
) -> Result<StatusCode, ApiError> {
authorize(&s, &headers)?;
valid_key(&key)?;
let created = s.notes.write().await.insert(key, note.text).is_none();
Ok(if created { StatusCode::CREATED } else { StatusCode::OK })
}
async fn delete_note(
State(s): State<Arc<AppState>>,
headers: HeaderMap,
Path(key): Path<String>,
) -> Result<StatusCode, ApiError> {
authorize(&s, &headers)?;
valid_key(&key)?;
match s.notes.write().await.remove(&key) {
Some(_) => Ok(StatusCode::NO_CONTENT),
None => Err(ApiError::NotFound(key)),
}
}
get_note는 인증 없이 valid_key만 보고, 없으면 ApiError::NotFound(key)를 돌려준다.
- 라우터 조립: 한 경로에 메서드 셋을 잇는다.
Router::new()
.route("/notes/{key}", put(put_note).get(get_note).delete(delete_note))
.with_state(state)
- (나) 결과:
[401, 201, 200, 200, 400, 204, 404].- 1은 헤더가 없어 401이다. 2는 처음 넣어 201, 3은 같은 키를 바꿔 200이다.
- 4의 본문은
{"text":"buy milk"}다. 5는-가 들어 있어 400이고, 본문은{"error":"bad key \"bad-key\": use 1-32 letters or digits"}다. - 6은 지워서 204, 7은 이제 없어 404
{"error":"note \"todo\" not found"}다.
변형. 키가 틀린 x-api-key: nope로 DELETE /notes/x를 보내면 401과 404 중 무엇이 오는가? 33글자 키 GET /notes/aaa…a는?
답
401이다. delete_note가 authorize를 가장 먼저 부르기 때문이다. 순서를 바꿔 404를 먼저 보내면, 키 없는 사람도 어떤 메모가 있는지 없는지를 응답 코드로 알아낼 수 있다. 33글자 키는 valid_key의 1..=32에 걸려 400이다. 둘 다 테스트(wrong_key_is_unauthorized_even_for_delete, key_longer_than_32_is_rejected)로 확인했다.