테스트에서 서버를 띄울 때 포트 충돌을 피하는 가장 확실한 방법은 커널이 할당하는 임시(ephemeral) 포트를 쓰는 것이다. 포트 0에 바인딩하고 실제로 배정된 포트를 조회하는 방법을 설명한다.
technically a blog ==================
home | blog | newsletter | patreon | consulting
2026년 2월 23일 월요일
테스트에서 서버를 띄워 실제로 엔드투엔드 요청을 보내 보는 일은 흔하다. 모든 것이 함께 잘 동작하는지 확인하기 위한 아주 중요한 종류의 테스트다. 내가 하는 일의 대부분은 복잡한 웹 백엔드인데, 목(mock) 테스트에서는 요청 처리나 미들웨어, 초기화 설정이 실제와 조금만 달라도 위험이 너무 크다… 엔드투엔드 테스트를 최소한 어느 정도는 반드시 해야 한다. 그렇지 않으면 언젠가 반드시 발등을 찍을 도박을 하는 셈이다.
이건 정말 좋지만, 금방 문제에 부딪힌다. 바로 포트 충돌(port collision)이다! 여러 테스트를 동시에 돌리면서 각각 별도의 서버를 띄우면, 이런저런 이유로 둘이 같은 포트를 골라 버릴 수 있다. 혹은 개발 머신에서 다른 프로세스가 우연히 당신이 고른 포트를 이미 쓰고 있을 수도 있다. 게다가 이게 짜증 나는 점은, 자주 재현하기가 어렵다는 것이다.
그럼… 이걸 어떻게 해결할까? 제목을 읽었으니[1] 우리가 어디로 갈지 이미 알겠지만, 그래도 같이 가 보자.
가능한 해결책은 몇 가지가 있다. 아마 가장 떠올리기 쉬운 건 임의로 포트를 골라 바인딩하는 것이다. 이 방법은 대부분의 경우 잘 동작하지만, 불안정해질 수밖에 없다. 충돌 확률을 낮출 수는 있어도, 가끔은 반드시 터진다. 참고로, 10% 확률로 실패하는 테스트보다 더 나쁜 게 있다면 1% 확률로 실패하는 테스트라고 생각한다. 충분히 자주 깨지지 않아서 아무도 급하게 고치지 않지만, 팀에서 일하면 매일 한 번쯤은 만나게 될 정도로는 깨진다. 내가 어떻게 아는지 물어보라.
충돌이 얼마나 자주 일어나는지는 많은 요인에 달렸다. 그 범위에서 포트를 몇 번 바인딩하는지? 같은 범위에 바인딩할 수 있는 다른 서비스가 얼마나 있는지? 두 작업이 동시에 실행될 가능성이 얼마나 되는지?
간단한 예를 들어 보자. 9000-9999 범위에서 임의의 포트를 고르고, 동시에 실행되는 테스트가 4개라서 실행 시간이 겹친다고 하자. 이 범위에서 균등하게 샘플링한다면, 두 번째 테스트에서 충돌할 확률은 1/1000, 세 번째는 2/1000, 네 번째는 3/1000이다. 충돌이 전혀 없을 확률은 (999/1000 * 998/1000 * 997/1000) = 0.994가 된다. 즉 충돌 확률은 0.6%다. 아주 끔찍하진 않지만, 좋다고 하긴 어렵다!
각 테스트가 고르는 포트를 1씩 증가시키게 할 수도 있다. 나도 예전에 이렇게 해 본 적이 있는데, 한 종류의 충돌 문제는 피할 수 있지만 새로운 문제가 생긴다. 이제 첫 포트부터 시작해서 범위 전체를 훑게 된다. 시스템에서 그 범위에 바인딩하는 다른 게 무엇이든 하나라도 실행 중이라면, 결국 충돌을 만나게 된다!
그리고 전체 테스트 스위트를 병렬로 돌린다면, 모두가 같은 시작 포트에서 출발하므로 오히려 문제에 더 쉽게 부딪힌다.
우리가 계속 겪어 온 문제는 “완전한 정보가 없다”는 점이다. 시스템 상태와 현재 열려 있는 모든 포트를 알고 있다면, 사용 중이 아닌 포트에 바인딩하는 건 쉬운 문제다. 그리고 그 정보를 알고 있는 존재가 있다. 바로 커널이다.
그리고 알고 보면, 커널에게 그걸 요청할 수 있다. “안 쓰는 괜찮은 포트 하나 주세요”라고 말하면 실제로 준다! 커널은 이런 용도로 쓰는 포트 범위를 가지고 있다. 시스템마다 다르지만, 보통은 구체적인 범위가 그렇게 중요하지는 않다. 내 시스템에서는 /proc/sys/net/ipv4/ip_local_port_range를 확인해서 그 범위를 볼 수 있다. 내 임시 포트 범위는 32768부터 60999까지다. 왜 위쪽 끝이 끝까지 올라가지 않고 거기서 멈추는지 궁금해서, 이건 나중에 더 조사해 볼 일이다.
리눅스에서 임시 포트를 얻으려면 포트 0에 bind 하거나 listen 하면 된다. 그러면 커널이 임시 포트 범위에서 포트를 하나 골라 준다. 그리고 커널이 추적하고 있으니, 그 포트가 사용 가능하다는 것도 보장된다. 물론 임시 포트 범위가 완전히 고갈되면 문제가 생길 수 있긴 하지만… 음, 솔직히 그 한계에 도달했다면 다른 문제가 이미 잔뜩 있을 가능성이 크다[2].
유일한 문제는, 알 수 없는 포트에 바인딩했으면 어떻게 요청을 보내느냐는 것이다. 바인딩된 포트는 다른 시스템 콜인 getsockname(2)로 얻을 수 있다. 이 호출은 소켓이 어떤 주소에 바인딩되어 있는지 알려 주고, 우리는 그 정보를 가지고 무언가를 할 수 있다. 테스트라면, 리스너(listener)에서 요청을 보내는 쪽(requester)으로 이 포트를 전달하는 방법이 필요하다. 둘이 같은 프로세스에 있다면, 나는 보통 리스너에 주입(inject)하거나 주소를 반환하도록 해서 해결한다. postgres나 redis를 임시 포트로 띄우는 식이라면, 출력에서 포트를 파싱해야 할 텐데, 귀찮긴 해도 가능은 하다.
내가 작업 중인 웹 앱에서의 예시를 보자. 아래는 간단한 테스트가 어떻게 생겼는지다. 웹 서버를 띄우되 포트 0에 바인딩하고, 주소를 반환받는다. 그 다음 그 주소로 요청을 보낼 수 있다!
rust#[tokio::test] pub async fn gets_200_healthcheck() { let config = test_config().unwrap(); let (addr, handle) = launch_webserver("localhost", 0, app(&config)) .await .expect("server should launch"); let url = format!("http://{addr}/_health"); let resp = reqwest::get(url).await.expect("should get response"); assert_eq!(resp.status().as_u16(), 200); abort_and_wait(handle).await; }
그리고 launch_webserver 내부에서 관련된 두 줄은 다음이다:
rustlet listener = TcpListener::bind(format!("{host}:{port}")).await?; let addr = listener.local_addr()?;
…여기서 우리의 경우 port = 0이다.
이렇게만 하면 되고, 훨씬 더 신뢰할 수 있는 테스트 환경을 얻을 수 있다.
긴장감을 주는 제목은 재미있고, 스토리텔링을 개선하고, 주의를 끌 수 있다고 생각한다. 하지만 때로는 명확하고 정직하게, 제목에서부터 스포일러로 답을 알려 주는 게 필요하다. 사람들이 빠르게 핵심을 내재화하고 싶은 정보를 전달할 때는, 답을 미리 공개하는 게 정말 좋다. ↩
만약 정말로 이 문제에 부딪힌다면, 어떤 상황에서 그랬는지 나는 정말 듣고 싶다. 이런 종류의 문제는 직접 들여다보고 해결해 보고 싶은 문제다. 좀 지저분한 문제이긴 하지만, 이렇게 될 수밖에 없었던 아주 흥미로운 이유가 반드시 있을 거라고 생각한다. ↩
소프트웨어 프로젝트에 도움이 필요하다면, 나와 함께 일하는 것!을 고려해 주세요.
이 글을 공유해 주시고, 뉴스레터나 RSS 피드를 구독해 주세요. 의견이나 질문이 있다면 개인 이메일로 보내도 된다.
더 나은 프로그래머가 되고 싶나요? Recurse Center에 참여하세요!
훌륭한 프로그래머를 채용하고 싶나요? Recurse Center를 통해 채용하세요!
© 2015-2026 Nicole Tietz-Sokolskaya