IP·TCP·UDP와 DNS
게시판 요청이 도메인 이름에서 서버 프로세스까지 도달하는 경로를 루프백 TCP와 UDP 코드로 재현하고 계층별 실패를 구분합니다.
브라우저 요청은 한 번에 서버 코드로 순간 이동하지 않습니다.
DNS가 board.example 같은 이름을 IP 주소로 바꾸고, TCP가 서버와 신뢰할 수 있는 연결을 만들며, HTTPS라면 TLS가 암호화 통로를 준비한 뒤, HTTP가 요청과 응답 형식을 정합니다.
UDP는 연결을 먼저 만들지 않고 작은 메시지를 보내는 방식으로 DNS 같은 곳에서 사용됩니다.
브라우저에서 https://board.example/api/posts/42를 열었는데 응답이 없다면 곧바로 컨트롤러 중단점부터 볼 일이 아닙니다.
DNS, TCP, TLS, HTTP 가운데 어느 단계에서 실패했는지 순서대로 나눠야 합니다.
DNS 이름 해석
도메인 이름은 사람이 관리하기 좋은 식별자이고 IP 주소는 네트워크 인터페이스를 향한 주소입니다.
DNS 조회 결과는 하나가 아닐 수 있습니다.
IPv4와 IPv6, 여러 영역의 주소, 로드 밸런서 주소가 TTL 동안 캐시될 수 있습니다.
package board.http;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.net.InetAddress;
import java.net.UnknownHostException;
import org.junit.jupiter.api.Test;
class DnsResolutionTest {
@Test
void localhost가_하나_이상의_loopback_주소로_해석된다()
throws Exception {
var addresses = InetAddress.getAllByName("localhost");
assertThat(addresses).isNotEmpty();
assertThat(addresses)
.allMatch(InetAddress::isLoopbackAddress);
}
@Test
void 예약된_invalid_도메인은_해석되지_않는다() {
assertThatThrownBy(() ->
InetAddress.getByName("board.invalid"))
.isInstanceOf(UnknownHostException.class);
}
}.invalid는 존재하지 않도록 예약된 최상위 도메인이므로 실패 테스트가 실제 외부 DNS 등록 상태에 흔들리지 않습니다.
운영 장애에서는 UnknownHostException, 리졸버 시간 초과, 예상과 다른 주소 반환을 구분합니다.
호스트 파일과 JVM·OS·로컬 리졸버 캐시 때문에 같은 이름도 프로세스마다 잠시 다른 주소를 볼 수 있습니다.
DNS가 성공했다고 서버가 살아 있다는 뜻은 아닙니다.
주소 후보를 얻었을 뿐 그 주소의 목적 포트에 연결해야 합니다.
포트와 프로세스 접점
IP 주소가 호스트까지 안내하면 포트가 어느 서버 소켓으로 전달할지 정합니다.
Spring Boot의 내장 서버가 server.port=8080에 바인딩하면 운영체제가 그 포트의 TCP 연결을 해당 프로세스에 넘깁니다.
이미 다른 프로세스가 사용 중이면 애플리케이션 시작 단계에서 바인딩이 실패합니다.
고정 포트를 테스트에 쓰면 다른 프로세스와 충돌합니다.
ServerSocket(0)으로 운영체제에 빈 임시 포트를 요청해 실제 연결을 확인합니다.
package board.http;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.Test;
class TcpLoopbackTest {
@Test
void 한_Tcp_연결에서_요청과_응답_byte를_교환한다()
throws Exception {
var loopback = InetAddress.getLoopbackAddress();
try (var server = new ServerSocket(0, 1, loopback);
var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
var accepted = executor.submit(() -> {
try (var socket = server.accept();
var reader = new BufferedReader(
new InputStreamReader(
socket.getInputStream(), UTF_8));
var writer = new PrintWriter(
socket.getOutputStream(), true, UTF_8)) {
var request = reader.readLine();
writer.println("ack:" + request);
return socket.getRemoteSocketAddress();
}
});
try (var client =
new Socket(loopback, server.getLocalPort());
var reader = new BufferedReader(
new InputStreamReader(
client.getInputStream(), UTF_8));
var writer = new PrintWriter(
client.getOutputStream(), true, UTF_8)) {
writer.println("post-42");
assertThat(reader.readLine())
.isEqualTo("ack:post-42");
}
assertThat(accepted.get()).isNotNull();
}
}
}이 예제의 post-42는 아직 HTTP 메시지가 아닙니다.
TCP는 순서가 보존된 바이트 스트림을 제공할 뿐 요청 라인, 헤더, JSON의 의미를 모릅니다.
내장 Tomcat이 스트림을 HTTP 문법으로 해석한 뒤 DispatcherServlet에 요청을 넘깁니다.
DnsResolutionTest > localhost가_하나_이상의_loopback_주소로_해석된다() PASSED
DnsResolutionTest > 예약된_invalid_도메인은_해석되지_않는다() PASSED
TcpLoopbackTest > 한_Tcp_연결에서_요청과_응답_byte를_교환한다() PASSED
BUILD SUCCESSFULTCP 스트림과 HTTP 경계
TCP는 연결을 맺고 순서 번호, 확인 응답, 재전송, 흐름 제어로 바이트 순서와 전달을 관리합니다.
애플리케이션은 패킷 경계를 그대로 받지 않고 스트림을 읽습니다.
한 번의 write()가 한 번의 read()와 대응한다고 가정하면 안 됩니다.
HTTP/1.1은 그 스트림 위에서 시작-라인과 헤더, 본문 길이로 메시지 경계를 정합니다.
HTTP/2는 하나의 TCP 연결에 여러 스트림을 프레임으로 다중화합니다.
HTTP/3는 UDP 위의 QUIC을 사용하지만 애플리케이션이 보는 HTTP 메서드·URI·상태 의미는 이어집니다.
연결 문제를 조사할 때 다음 시간을 따로 봅니다.
- DNS 조회: 이름에서 주소 후보를 얻는 시간
- 연결 타임아웃: TCP 또는 QUIC 연결을 만드는 시간
- TLS 핸드셰이크: 인증서와 암호 모음을 합의하는 시간
- 응답 타임아웃: 연결 뒤 애플리케이션 응답을 기다리는 시간
- 연결 풀 대기: 이미 정한 동시 연결 한도에서 차례를 기다리는 시간
read timeout을 무작정 늘리면 DNS나 연결 실패는 해결되지 않습니다.
어느 단계의 타이머인지 클라이언트 라이브러리 설정 이름과 메트릭으로 확인해야 합니다.
UDP 손실 정책
UDP는 목적 주소와 포트가 붙은 데이터그램을 보냅니다.
TCP처럼 연결 핸드셰이크, 순서 보장, 재전송을 기본 제공하지 않습니다.
메시지 경계는 유지되지만 도착·중복·순서는 애플리케이션 프로토콜이 책임집니다.
package board.http;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import org.junit.jupiter.api.Test;
class UdpLoopbackTest {
@Test
void datagram_하나의_경계를_유지해_받는다()
throws Exception {
var loopback = InetAddress.getLoopbackAddress();
try (var receiver = new DatagramSocket(0, loopback);
var sender = new DatagramSocket()) {
var bytes = "board-heartbeat".getBytes(UTF_8);
sender.send(new DatagramPacket(
bytes,
bytes.length,
loopback,
receiver.getLocalPort()));
var buffer = new byte[64];
var packet = new DatagramPacket(
buffer, buffer.length);
receiver.setSoTimeout(1_000);
receiver.receive(packet);
assertThat(new String(
packet.getData(),
packet.getOffset(),
packet.getLength(),
UTF_8))
.isEqualTo("board-heartbeat");
}
}
}루프백 한 번이 성공했다고 UDP가 전달을 보장하는 것은 아닙니다.
이 테스트는 데이터그램 API의 메시지 경계를 보여 줍니다.
실제 네트워크에서 하트비트가 유실될 수 있으므로 “세 번 연속 미수신이면 오프라인”처럼 상위 정책이 필요합니다.
중요한 게시글 생성처럼 한 번만 처리돼야 하는 업무를 원시 UDP 데이터그램 하나에 맡기지 않습니다.
계층별 장애 진단
| 관찰한 증상 | 먼저 볼 계층 | 대표 확인 |
|---|---|---|
| 이름을 찾지 못함 | DNS | 리졸버·주소 후보·TTL |
| 연결 거부 | TCP 포트 | 프로세스 실행·바인딩 주소·포트 |
| 연결 시간 초과 | 라우팅/방화벽 | 경로·보안 규칙·수신 대기 여부 |
| 인증서 이름 불일치 | TLS | SNI·SAN·프록시 인증서 |
| 404 | HTTP 라우팅 | 메서드·URI·매핑 |
| 415 | HTTP 표현 | Content-Type·컨버터 |
| 500 | 애플리케이션 | 예외와 트레이스 ID |
localhost:8080에서 성공하고 도메인에서 실패한다면 컨트롤러보다 리버스 프록시, DNS, TLS, 전달된 헤더를 먼저 봅니다.
반대로 TCP 연결과 TLS가 모두 성공하고 404가 오면 네트워크는 HTTP 응답을 운반하는 역할을 이미 수행했습니다.
이제 호스트·메서드·경로 매핑을 조사합니다.
바인드 주소와 공개 범위
127.0.0.1에만 바인딩한 서버는 같은 호스트에서만 접근할 수 있습니다.
0.0.0.0은 모든 IPv4 인터페이스에서 수신 대기하라는 뜻이지 클라이언트가 접속할 목적 주소가 아닙니다.
컨테이너 환경에서는 프로세스 내부 포트, 호스트 발행 포트, 로드 밸런서 리스너가 각각 다를 수 있습니다.
게시판 개발 서버는 로컬 루프백에 두고, 운영에서는 애플리케이션 인스턴스를 공개 인터넷에 직접 노출하기보다 리버스 프록시나 로드 밸런서 뒤에 둡니다.
프록시가 TLS를 종료하면 애플리케이션까지는 별도 연결이 생기며 원래 스킴·호스트·클라이언트 주소는 신뢰 가능한 전달 헤더 정책으로 복원해야 합니다.
연습 문제
루프백 ServerSocket을 하나 열고 한 클라이언트는 정상 포트로, 다른 클라이언트는 서버를 닫은 뒤 같은 포트로 연결하게 하세요.
성공 응답과 연결 거부를 구분하고, 테스트가 고정 포트에 의존하지 않게 만듭니다.
이어 TCP 서버가 받은 첫 줄을 HTTP 요청 라인 형태로 바꾸되 아직 헤더를 해석하지 않습니다.
해설 보기
ServerSocket(0)에서 실제 포트를 읽고 정상 교환이 끝난 뒤 서버를 닫습니다.
그 다음 new Socket(loopback, port)가 IOException을 던지는지 확인합니다.
오류 메시지 문자열은 운영체제마다 달라질 수 있으므로 클래스와 발생 단계만 검증합니다.
int port;
try (var server = new ServerSocket(
0, 1, InetAddress.getLoopbackAddress())) {
port = server.getLocalPort();
// accept와 정상 교환
}
assertThatThrownBy(() ->
new Socket(InetAddress.getLoopbackAddress(), port))
.isInstanceOf(IOException.class);클라이언트가 보내는 첫 줄을 GET /api/posts/42 HTTP/1.1로 바꿔도 TCP는 문자열 의미를 모릅니다.
다음 문서에서 URI 구성 요소를 분해하고 브라우저가 이 요청을 만들기 전후의 단계를 추적합니다.