localhost의 함정: Windows에서의 10초 걸리는 데이터베이스 연결
요약
Windows 환경에서 RAG 시스템 구축 중 발생한 데이터베이스 연결 지연 문제를 다룹니다. localhost 사용 시 IPv6 우선 순위로 인해 발생하는 TCP 연결 지연 원인과 해결 방법을 설명합니다.
핵심 포인트
- Windows의 localhost는 IPv6(::1)를 IPv4(127.0.0.1)보다 우선시함
- Docker 포트가 IPv4에만 바인딩된 경우 IPv6 연결 시도 시 타임아웃 발생
- localhost 대신 127.0.0.1을 직접 사용하여 DNS 모호성 해결 가능
- 연결 타임아웃(connect_timeout) 설정을 통해 잠재적 지연 방지 권장
앱의 모든 질문에 약 15초가 소요되었습니다. 모델은 빨랐습니다. 검색 (Retrieval)도 빨랐습니다. 그 10초는 다른 어딘가에 숨어 있었고, 며칠 동안 저는 그 위치를 알 수 없었습니다.
이 앱은 SEPA 결제 규칙서를 기반으로 제가 구축한 RAG (Retrieval-Augmented Generation) 시스템인 payments-rag입니다. 평이한 영어로 질문을 던지면, Postgres + pgvector에서 관련 구절을 검색하고, 이를 Claude로 보내 정확한 규칙서 페이지를 인용하여 답변을 반환합니다. 목표는 단순히 빠른 것이 아니라 5초 미만으로 처리하는 것입니다. 제 Windows 머신에서는 매 쿼리마다 그 세 배의 시간이 걸렸고, 답변은 정확했습니다. 그 사실이 왠지 상황을 더 악화시켰습니다. 정확하지만 느린 것은
명확했습니다. 연결 자체에 10.1초가 걸렸습니다. 쿼리도, 임베딩 (embedding)도 아닌, TCP 연결 (TCP connect)이 문제였습니다. 이 버그는 "그냥 느려요"라는 모호한 말 뒤에 숨어 며칠 동안 존재해 왔습니다. 우리가 측정하는 순간, 스크린샷 한 장만으로도 위치를 찾아낼 수 있었습니다.
실제로 무슨 일이 일어나고 있었나
연결 문자열 (connection string)에는 localhost라고 적혀 있었습니다. 그 단어 하나가 일련의 사건을 촉발했습니다.
Windows에서 localhost는 듀얼 스택 (dual-stack)입니다. 즉, IPv6 ::1과 IPv4 127.0.0.1 모두로 해석되며, 운영체제 (OS)는 IPv6를 우선시합니다. 따라서 Postgres 클라이언트는 먼저 ::1:5433으로 연결을 시도했습니다.
그곳에는 아무것도 리스닝 (listening)하고 있지 않았습니다. 이 설정에서 Postgres는 Docker에서 실행 중이었고, 게시된 포트 (published port)는 IPv4에만 바인딩 (bound)되어 있었습니다. IPv4 루프백 (loopback)은 응답하지만, ::1은 응답하지 않습니다.
그리고 connect_timeout이 설정되어 있지 않았기 때문에, 클라이언트는 OS가 자체적인 일정에 따라 포기하고 IPv4로 재시도할 때까지 죽은 IPv6 경로에서 대기했습니다. 그 포기하는 데 걸리는 시간이 약 10초입니다. 그 후 IPv4 연결은 밀리초 (milliseconds) 단위로 성공하고, 쿼리는 정상적으로 실행되며, 정답이 정확하게 돌아옵니다. 모든 쿼리가 매번 이 통행료를 지불해야 했던 것입니다.
이 문제가 고통을 주려면 세 가지 조건이 맞아떨어져야 했습니다:
localhost는 듀얼 스택이며, Windows는 IPv6를 먼저 시도합니다.- Docker가 컨테이너 포트를 IPv4에만 게시했으므로,
::1에서 응답할 대상이 없었습니다. connect_timeout이 없어서, 빠르게 실패하고 오류를 알리는 대신 클라이언트가 모든 연결마다 OS 수준의 폴백 (fallback)이 완료될 때까지 기다렸습니다.
이 세 가지 중 하나만 제거해도 문제는 사라지며, 이것이 바로 이 문제가 며칠 동안 살아남았던 정확한 이유입니다. Linux와 macOS를 사용하는 동료들의 환경에서는 동일한 코드가 빠르게 작동했습니다.
해결 방법
단 한 단어면 됩니다. localhost를 127.0.0.1로 바꾸면 DNS 모호성을 완전히 건너뛰고 Docker가 실제로 게시하는 IPv4 루프백으로 바로 연결됩니다:
# .env
DATABASE_URL=postgresql://user:pass@127.0.0.1:5433/mydb
그리고 타임아웃 (timeout)을 설정하여, 이러한 종류의 버그가 다시는 조용한 멈춤 (silent hang) 상태로 숨어들지 못하게 합니다:
psycopg.connect(DATABASE_URL, connect_timeout=10)
멈춤(Hang) 현상은 에러보다 더 나쁩니다. 에러는 어디가 아픈지 알려주지만, 멈춤 현상은 아무 말도 없이 당신의 지연 시간(Latency) 예산만 갉아먹습니다.
전후 비교:
| host | 연결 지연 시간 (connect latency) |
|---|---|
localhost (IPv6 우회) | 10,137 ms |
127.0.0.1 (직접 IPv4 연결) | 27 ms |
단 한 단어를 바꿨을 뿐인데, 약 375배 빨라졌습니다.
누군가 이 방법을 과도하게 적용하지 않도록 주의사항을 하나 덧붙이자면, 이 함정은 위에서 언급한 특정 조합이 충족되어야 발생합니다. Windows의 해결 순서(resolution order), IPv4로만 게시된 컨테이너 포트, 그리고 연결 제한 시간(connect timeout)의 부재가 결합되어야 합니다. 만약 당신의 스택이 이와 다르다면, 당신의 10초는 다른 곳에 숨어 있을 것입니다.
이 경험을 통해 얻은 교훈
- IPv6가 개입되면
localhost는127.0.0.1이 아닙니다. 포트가 IPv4로 매핑된 컨테이너의 경우, 명시적인 주소를 사용하는 것을 권장합니다. 루프백(Loopback)이 어디서나 루프백인 Linux나 macOS에서도 동일하게 동작합니다. - 항상 연결 제한 시간(connect timeout)을 설정하세요. 빠르게, 그리고 명확하게 실패(Fail fast and loud)해야 합니다.
- 저렴한 관측성(Observability)은 그 값어치를 충분히 합니다. 단계별 타이머(per-stage timer)와 한 줄짜리 상태 확인(health check)을 구현하는 데는 합쳐서 아마 한 시간 정도의 작업이 걸렸을 것이며, 이는 며칠 동안 지속된 "그냥 좀 느려요"라는 문제를 5분 만의 해결책으로 바꾸어 놓았습니다.
단계별 타이머와 상태 확인 기능은 모두 프로젝트에 영구적으로 남았습니다. 이 기능들은 제 자리를 찾았습니다. 이제 동일한 상태 확인 뷰를 통해 요청 시 및 10분마다 다섯 가지 종속성을 모두 확인합니다. 따라서 다음번에 어디서 발생하든, 침묵 속의 멈춤 현상이 발생하더라도 그것이 하나의 이야기가 되기 전에 수치로 나타날 것입니다.
Serhiy Kucherenko는 백엔드 시스템과 LLM 툴링을 구축합니다. 이 이야기가 나온 프로젝트인 payments-rag는 오픈 소스입니다: github.com/KucherenkoSerhiy/payments-rag.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기