DNS·IP·TCP·UDP와 연결 경계
이름 해석에서 프록시 업스트림까지 요청이 지나는 연결 경계를 JDK 17 루프백 TCP·UDP 테스트로 검증합니다.
앞 장에서는 Spring 빈의 스코프와 객체의 수명을 다루었습니다. 이번 장에서는 https://board.example/api/posts/42라는 요청이 서버 프로세스에 닿기 전에 무엇을 거치는지 살펴봅니다. 이름 해석은 board.example에 대한 IP 주소 후보를 알려 줄 뿐, 그 주소의 특정 전송 프로토콜과 포트에서 어떤 프로세스가 기다리고 있는지까지 알려 주지는 않습니다.
클라이언트는 주소 후보 하나에 전송 프로토콜과 포트를 더해 연결을 시도합니다. HTTPS에서 HTTP/1.1과 HTTP/2는 보통 TCP 연결을 연 뒤 TLS를 맺고, HTTP/3는 TLS 1.3이 들어 있는 QUIC 연결을 맺어야 HTTP 요청을 보낼 수 있습니다. 서버 앞에 리버스 프록시가 있다면, 바깥에 공개된 리스너와 애플리케이션이 기다리는 업스트림(upstream)은 서로 다른 소켓입니다.
NAME · TRANSPORT · LISTENER
이름은 주소 후보만 만들고 전송 방식과 리스너가 실제 HTTP 경로를 결정한다
이름 해석은 연결이 아니라 목적지 후보를 제공합니다. 이 HTTPS 요청에서 클라이언트와 서버가 사용하기로 한 HTTP 버전에 맞는 전송 방식과 공개 리스너를 고르고, 프록시가 별도 upstream 연결로 애플리케이션의 HTTP handler까지 요청을 넘깁니다.
RESOLUTION · CANDIDATES
구성된 이름 서비스는 주소 후보를 돌려줄 뿐 연결을 열지 않는다
구성된 이름 서비스에 host를 묻는다
InetAddress는 hosts 파일, OS resolver, DNS 등이 조합된 구성 경로에서 주소 값을 얻습니다. 반환값만으로 실제 원본을 역추론하지 않습니다.DNS 경로라면 A·AAAA 레코드가 관여한다
A와AAAA는 IPv4와 IPv6를 나타내는 DNS 레코드입니다. 구성 경로가 DNS를 사용할 때만 이 레코드와 RR TTL이 관여하며, 이름 서비스 반환값은 원본 레코드 종류와 TTL을 노출하지 않습니다.JVM cache는 별도 수명을 가진다
운영체제 resolver cache와 별개로 JVM도 성공·실패 결과를 보관할 수 있으므로, DNS 변경 반영 시점을 따로 확인합니다.
HTTP VERSION · CONNECTION
이 HTTPS 요청에 사용하는 HTTP 버전이 두 전송 경로를 나눈다
HTTPS의 HTTP/1.1과 HTTP/2
공개
TCP 443에 연결하고 TLS를 협상한 뒤 HTTP 요청과 응답을 주고받습니다.HTTP/3
공개
UDP 443에서 QUIC을 사용합니다. QUIC handshake에 TLS 1.3이 통합되고 그 위에 HTTP/3 stream이 놓입니다.연결은 요청보다 오래 재사용될 수 있다
한 번 만든 TCP·TLS 또는 QUIC 연결은 여러 요청에 재사용될 수 있으므로, 요청 하나와 연결 하나를 같은 수명으로 보지 않습니다.
PUBLIC EDGE · UPSTREAM
공개 리스너와 애플리케이션 리스너는 서로 다른 연결 경계다
프록시가 공개 443을 받는다
TCP 443·UDP 443에서 외부 연결을 받고 TLS나 QUIC을 종료합니다.별도 upstream을 선택한다
프록시는 내부의
app:8080으로 새 연결을 만들거나 기존 연결을 재사용합니다. 외부 연결이 그대로 애플리케이션에 이어지는 것은 아닙니다.컨테이너가 HTTP를 handler에 매핑한다
애플리케이션 리스너가 내부 HTTP 요청을 받고, 컨테이너가 method·URI·media type을 해석해 controller handler를 호출합니다.
요청 전달 관계: 이름 후보 → 공개 프록시의 TCP·TLS 또는 UDP·QUIC 경로 → 별도 upstream → 컨테이너 handler. handshake는 해당 공개 종단과 수행합니다.
그림은 이 경로를 한눈에 보여 줍니다. 구성된 이름 서비스가 IP 주소 후보를 돌려주면, HTTP/1.1과 HTTP/2는 TCP 443과 TLS로, HTTP/3는 UDP 443과 QUIC으로 공개 프록시에 연결합니다. 두 경로는 둘 중 하나를 고르는 갈래이며 차례로 지나가는 것이 아닙니다. 프록시는 연결을 끝낸 뒤 app:8080 업스트림으로 별도의 연결을 만들거나 재사용해 요청을 넘기고, 서블릿 컨테이너가 메서드와 URI를 보고 핸들러를 고릅니다. 화살표는 요청이 전달되는 관계이며, 이름 해석과 핸드셰이크가 끝나는 시각을 한 줄로 늘어놓은 것이 아닙니다.
이 장의 테스트를 준비하는 순서
4장은 HTTP와 그 아래의 전송 계층 자체를 다루므로, 지금까지 쌓아 온 게시판 프로젝트와 분리된 작은 테스트 프로젝트에서 진행합니다. 게시판 프로젝트는 Java 25와 Spring Boot 4.1.2를 쓰지만, 이 장의 예제는 Java 17과 Spring Framework 6.2.11을 기준으로 확인한 별도 단위입니다. 그러니 게시판 프로젝트의 설정을 바꾸거나, 서로 다른 Spring 버전을 한 의존성 그래프에 섞지 않습니다.
이 글은 JDK의 소켓 API와 JUnit Jupiter만 있으면 됩니다. Java 17 이상으로 JUnit 5 테스트를 실행할 수 있는 별도 프로젝트를 만들고, 각 코드 제목에 적힌 src/test/java 경로와 패키지를 맞추어 파일을 둡니다. 루프백 소켓을 열 권한도 필요합니다. 먼저 NameResolutionTest로 구성된 이름 서비스를, TcpLineExchangeTest로 줄 단위 구분을, UdpLoopbackTest로 데이터그램 단위를 확인하고, 마지막의 TcpReachabilityExerciseTest는 결과를 직접 예상한 뒤 실행하는 연습입니다.
4장의 아홉 글이 쓰는 의존성을 처음에 한 번에 적어 두면 글마다 빌드 파일을 고치지 않아도 됩니다. 빈 디렉터리를 하나 만들고 게시판 프로젝트의 gradlew, gradlew.bat, gradle/wrapper 디렉터리를 그대로 복사해 같은 Gradle 9.8.2 Wrapper를 씁니다. 그다음 아래의 settings.gradle과 build.gradle을 둡니다. Spring 모듈의 버전은 6.2.11 BOM이 맞춥니다. 서블릿 API 6.0.0, Jackson BOM 2.18.4.1, JUnit BOM 5.13.4, Jakarta Validation API 3.0.2는 Spring Framework 6.2.11이 자기 빌드에 쓰는 버전을 그대로 따랐습니다. 컴파일은 --release 17로 하므로 JDK 17 이상이면 어느 JDK로 Gradle을 실행해도 됩니다.
rootProject.name = 'http-basics'plugins {
id 'java'
}
repositories {
mavenCentral()
}
dependencies {
implementation platform('org.springframework:spring-framework-bom:6.2.11')
implementation 'org.springframework:spring-webmvc'
testImplementation platform('org.junit:junit-bom:5.13.4')
testImplementation platform('com.fasterxml.jackson:jackson-bom:2.18.4.1')
testImplementation 'org.springframework:spring-test'
testImplementation 'jakarta.servlet:jakarta.servlet-api:6.0.0'
testImplementation 'jakarta.validation:jakarta.validation-api:3.0.2'
testImplementation 'com.fasterxml.jackson.core:jackson-databind'
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.release = 17
}
tasks.named('test') {
useJUnitPlatform()
}코드의 @Timeout과 소켓마다 둔 제한 시간은 실패했을 때 끝없이 기다리지 않게 하려는 장치입니다. 클래스나 의존성을 찾지 못하는 컴파일 실패와, 제한 시간 안에 통신하지 못한 실행 실패를 같은 원인으로 보지 않습니다. 또 이 글의 테스트만으로 외부 DNS나 프록시, TLS, 실제 게시판 서버를 준비한 것은 아닙니다.
구성된 이름 서비스와 캐시를 구분합니다
InetAddress.getAllByName()은 JVM에 구성된 이름 서비스를 거쳐 주소 후보를 얻습니다. 그 경로에는 호스트 파일, 운영체제의 리졸버(resolver), 로컬 캐시, DNS가 섞여 있을 수 있으며, DNS 리졸버가 위임을 따라 이름을 찾는 과정은 DNS의 구조와 동작에서 자세히 다룹니다. 그래서 localhost가 루프백 주소로 해석되는 테스트는 구성된 이름 서비스가 동작하는지 확인하는 간단한 점검일 뿐, 바깥의 권한 DNS 서버와 UDP 패킷까지 확인하는 테스트가 아닙니다.
전통적인 DNS 질의와 응답은 UDP와 TCP를 모두 쓸 수 있습니다. UDP 응답이 잘렸거나 큰 응답이 필요하면 TCP로 다시 질의할 수 있고, 요즘의 리졸버는 운영 정책에 따라 처음부터 TCP를 쓰기도 합니다. DNS over TLS나 DNS over HTTPS를 쓰면 아래의 전송 경로가 또 달라지므로, "DNS는 언제나 UDP"라고 진단하면 안 됩니다.
DNS 리소스 레코드의 TTL은 DNS 캐시가 그 레코드를 다시 쓸 수 있는 기간입니다. 반면 JDK 17의 networkaddress.cache.ttl과 networkaddress.cache.negative.ttl은 JVM이 성공한 이름 해석과 실패한 이름 해석의 결과를 얼마나 보관할지 정하는 별도의 보안 속성입니다. 실행 환경과 보안 설정에 따라 JVM의 캐시 기간은 DNS 레코드의 TTL과 다를 수 있으므로, InetAddress가 돌려준 주소만 보고 원래 레코드의 TTL이나 실제 질의 방식을 짐작하지 않습니다.
테스트는 바깥에 어떤 도메인이 없다는 가정에 기대지 않습니다. 이름 해석의 실패는 테스트에서 넣어 준 리졸버가 UnknownHostException을 던지게 해서 항상 같은 결과로 확인합니다.
package example.network;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
@Timeout(value = 5, unit = SECONDS)
class NameResolutionTest {
@Test
void localhost는_구성된_이름_서비스에서_루프백_후보를_제공한다()
throws Exception {
var addresses = InetAddress.getAllByName("localhost");
assertTrue(addresses.length > 0);
assertTrue(Arrays.stream(addresses)
.anyMatch(InetAddress::isLoopbackAddress));
}
@Test
void 이름_해석_실패는_주입한_리졸버로_분류한다() {
Resolver resolver = ignored -> {
throw new UnknownHostException();
};
assertThrows(
UnknownHostException.class,
() -> resolver.resolve("board.example"));
}
@FunctionalInterface
private interface Resolver {
InetAddress[] resolve(String name) throws UnknownHostException;
}
}localhost는 IPv4와 IPv6 루프백처럼 후보가 여러 개일 수 있으므로, 첫 테스트는 개수를 정하지 않고 그중 하나 이상이 루프백 주소인지만 봅니다. 둘째 테스트의 Resolver는 테스트 안에 둔 작은 함수형 인터페이스입니다. 이 테스트는 주입한 리졸버가 던진 예외가 호출한 쪽에 그대로 전달되는지만 확인하며, JVM의 이름 서비스는 실행하지 않습니다. 실제 코드에서도 이름 해석을 이런 역할 뒤에 두어야 테스트가 실패 경로를 바꿔 끼울 수 있습니다.
더 깊이: JVM은 이름 해석 결과를 얼마나 오래 들고 있을까
앞에서 말한 두 보안 속성의 기본값은 JDK의 보안 설정 파일 java.security에 적혀 있습니다. JDK 17의 이 파일에 따르면, 성공한 이름 해석은 보안 관리자(security manager)가 설정되어 있으면 영원히, 설정되어 있지 않으면 이 구현에서 30초 동안 캐시됩니다. 실패한 이름 해석은 networkaddress.cache.negative.ttl=10에 따라 10초 동안 캐시되는데, 파일의 주석은 일부 Windows 네트워크 환경에서 실패한 조회가 5초 가까이 걸릴 수 있다는 점을 이유로 듭니다.
게시판 프로젝트가 쓰는 JDK 25의 같은 파일은 성공한 해석의 기본값을 보안 관리자 조건 없이 30초로 적고, JDK 17의 파일에는 없던 networkaddress.cache.stale.ttl도 둡니다. 이 속성은 오래된 이름을 몇 초 동안 더 쓸지를 정하며 기본값은 0, 곧 쓰지 않음입니다. 이처럼 캐시 정책은 JDK 버전에 따라 바뀔 수 있으므로, 운영에 쓰는 JDK의 설정 파일과 실행 옵션을 직접 확인합니다.
이 기본값은 운영에서 다음처럼 읽힙니다. DNS 레코드의 TTL이 5분이어도 JVM은 성공한 결과를 30초 뒤에 다시 묻고, 일시적으로 실패한 이름은 10초 동안 같은 실패를 돌려줍니다. 또 캐시가 갱신되어도 이미 열려 있는 연결은 처음 연결한 IP 주소를 그대로 쓰므로, 주소를 바꾸는 배포에서는 커넥션 풀의 연결 수명도 함께 봐야 합니다.
주소·전송 프로토콜·포트가 리스너를 고릅니다
IP 주소는 호스트의 네트워크 인터페이스를 가리킵니다. TCP와 UDP는 서로 다른 포트 공간을 쓰므로, 같은 숫자 8080이라도 TCP/8080과 UDP/8080은 다른 접점입니다. 서버 프로세스가 주소, 전송 프로토콜, 포트의 조합에 리스너를 바인드(bind)해야 운영체제가 도착한 연결이나 데이터그램을 그 소켓에 넘겨줄 수 있습니다.
테스트에서 ServerSocket(0)을 쓰면 운영체제가 지금 비어 있는 TCP 포트를 골라 줍니다. 아래 테스트는 JDK 17의 API만 쓰고, accept, connect, read, Future, 실행기 종료에 모두 제한 시간을 둡니다. ServerSocket을 닫은 뒤에 실행기를 멈추므로 accept가 스레드를 붙잡고 남지 않습니다.
package example.network;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
@Timeout(value = 8, unit = SECONDS)
class TcpLineExchangeTest {
private static final int IO_TIMEOUT_MILLIS = 2_000;
@Test
void 두_write는_CRLF로_끝나는_하나의_HTTP1_요청_라인이_된다()
throws Exception {
var loopback = InetAddress.getLoopbackAddress();
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
try (var server = listen(loopback)) {
var accepted = executor.submit(() -> {
try (var socket = server.accept()) {
socket.setSoTimeout(IO_TIMEOUT_MILLIS);
var reader = new BufferedReader(new InputStreamReader(
socket.getInputStream(), UTF_8));
var writer = new BufferedWriter(new OutputStreamWriter(
socket.getOutputStream(), UTF_8));
var requestLine = reader.readLine();
writer.write("HTTP/1.1 204 No Content\r\n");
writer.flush();
return requestLine;
}
});
try (var client = connect(loopback, server.getLocalPort())) {
var output = client.getOutputStream();
output.write("GET /api/posts/42".getBytes(UTF_8));
output.write(" HTTP/1.1\r\n".getBytes(UTF_8));
output.flush();
var reader = new BufferedReader(new InputStreamReader(
client.getInputStream(), UTF_8));
assertEquals(
"HTTP/1.1 204 No Content",
reader.readLine());
}
assertEquals(
"GET /api/posts/42 HTTP/1.1",
accepted.get(3, SECONDS));
}
} finally {
stop(executor);
}
}
@Test
void 한_write의_두_줄은_애플리케이션_CRLF로_나뉜다()
throws Exception {
var loopback = InetAddress.getLoopbackAddress();
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
try (var server = listen(loopback)) {
var accepted = executor.submit(() -> {
try (var socket = server.accept()) {
socket.setSoTimeout(IO_TIMEOUT_MILLIS);
var writer = new BufferedWriter(new OutputStreamWriter(
socket.getOutputStream(), UTF_8));
writer.write("alpha\r\nbeta\r\n");
writer.flush();
}
return true;
});
try (var client = connect(loopback, server.getLocalPort())) {
var reader = new BufferedReader(new InputStreamReader(
client.getInputStream(), UTF_8));
assertEquals("alpha", reader.readLine());
assertEquals("beta", reader.readLine());
}
assertTrue(accepted.get(3, SECONDS));
}
} finally {
stop(executor);
}
}
private static ServerSocket listen(InetAddress address)
throws IOException {
var server = new ServerSocket(0, 1, address);
server.setSoTimeout(IO_TIMEOUT_MILLIS);
return server;
}
private static Socket connect(InetAddress address, int port)
throws IOException {
var socket = new Socket();
socket.connect(
new InetSocketAddress(address, port),
IO_TIMEOUT_MILLIS);
socket.setSoTimeout(IO_TIMEOUT_MILLIS);
return socket;
}
private static void stop(ExecutorService executor)
throws InterruptedException {
executor.shutdownNow();
if (!executor.awaitTermination(3, SECONDS)) {
fail("executor did not terminate");
}
}
}첫 테스트에서 클라이언트가 두 번에 나누어 쓴 바이트를 서버가 CRLF까지 읽으면 요청 줄 하나가 됩니다. 서버가 보내는 HTTP/1.1 204 No Content도 여기서는 readLine으로 읽는 상태 줄 한 줄일 뿐입니다. 완전한 HTTP/1.1 응답이라면 204여도 헤더 영역을 끝내는 빈 줄까지 있어야 하므로, 이 테스트는 HTTP 메시지 구조를 모두 구현한 서버가 아니라 소켓의 바이트와 애플리케이션의 줄 구분을 비교하는 실험입니다. 두 번째 테스트가 한 번에 쓴 alpha와 beta를 readLine 두 번으로 나누어 읽는 이유도 write 횟수가 아니라 CRLF 때문입니다.
TCP는 연결 안에서 믿을 수 있고 순서가 지켜지는 바이트 스트림을 제공하며, 메시지의 끝을 애플리케이션이 정해야 하는 이유는 TCP의 핵심 특성에서 자세히 다룹니다. 보내는 쪽의 write() 두 번이 TCP 세그먼트 두 개나 받는 쪽의 read() 두 번과 짝을 이룬다는 규칙은 없습니다. 위 코드에서 요청 줄을 나누는 것은 TCP 패킷이 나뉜 지점이 아니라, HTTP/1.1이 쓰는 CRLF와 BufferedReader.readLine()이라는 애플리케이션 쪽의 구분입니다. 실제 TCP 패킷이 몇 개였고 얼마씩 나뉘었는지는 이 테스트가 관찰하지 않습니다.
HTTP 버전마다 연결과 제한 시간이 다릅니다
https URI에서 HTTP/1.1과 HTTP/2는 보통 TCP 연결 위에 TLS를 맺습니다. HTTP/1.1은 시작 줄과 헤더, 본문 길이로 메시지를 나누고, HTTP/2는 이진 프레임과 스트림을 하나의 TCP 연결에 여러 개 함께 싣습니다. 그래도 TCP는 스트림을 순서대로 넘겨야 하므로, 잃어버린 세그먼트의 재전송을 기다리는 동안 상관없는 HTTP/2 스트림까지 전송 계층에서 멈출 수 있습니다.
HTTP/3는 같은 HTTP 메서드와 상태, 필드의 의미를 QUIC 위에 옮겨 놓은 것입니다. QUIC v1은 아래의 전송으로 UDP를 쓰지만, 그 위에 연결과 스트림별 신뢰성, 순서, 흐름 제어, 혼잡 제어를 직접 구현하고 TLS 1.3 핸드셰이크를 전송 프로토콜 안에 넣었습니다. 그러니 "HTTP/3는 UDP라서 믿을 수 없다"는 결론도, "HTTP는 언제나 TCP를 쓴다"는 결론도 틀립니다.
제한 시간은 이름 해석, TCP 연결이나 QUIC 연결, TLS 핸드셰이크, 커넥션 풀 대기, 응답 헤더 대기, 본문 읽기로 나누어 봅니다. 읽기 제한 시간 하나를 늘린다고 이름 해석 실패나 연결 거부가 풀리지는 않습니다. QUIC은 TLS 핸드셰이크를 안에 품고 있으므로, 쓰는 클라이언트가 어떤 타이머를 어떤 이름으로 제공하는지 문서로 확인합니다.
Java 소켓의 write()가 성공했다는 것은 로컬 소켓과 TCP 스택이 바이트를 받아들였다는 뜻이고, TCP ACK는 상대편 TCP가 그 바이트를 받았다는 확인입니다. 어느 쪽도 상대 애플리케이션이 그 바이트를 처리했다거나, 게시글을 만드는 트랜잭션이 커밋되었다거나, 정확히 한 번 실행되었다는 뜻은 아닙니다. 서버가 커밋한 뒤 응답을 보내다 연결이 끊기면 클라이언트는 결과를 알 수 없는 상태가 됩니다. 그래서 POST를 무조건 다시 보내지 말고, 업무용 멱등성 키와 유일성 제약 조건, 결과 조회로 중복 효과를 막아야 합니다.
UDP는 수신한 데이터그램 단위를 보존합니다
UDP는 보내는 쪽과 받는 쪽의 주소와 포트가 붙은 데이터그램(datagram)을 전달합니다. 받은 데이터그램 하나는 메시지 단위를 그대로 지키지만, UDP 자체는 연결 핸드셰이크도, 도착이나 순서도, 중복 제거와 재전송도 보장하지 않습니다. DatagramSocket.connect()도 기본 상대를 정하고 다른 곳에서 온 입력 일부를 걸러 낼 뿐, TCP 같은 핸드셰이크나 신뢰성을 더하지 않습니다.
받는 버퍼가 데이터그램보다 작으면 Java API는 버퍼 크기에 맞춰 내용을 잘라서 넘겨줍니다. 이때 TCP 스트림처럼 남은 부분을 다음 receive()에서 이어 받을 수는 없습니다. 아래 루프백 테스트는 버퍼가 충분할 때 데이터그램 하나가 나뉘지 않고 도착하는 것과, 일부러 작게 만든 버퍼에서 내용이 잘리는 것을 함께 확인합니다. 이 결과는 루프백에서 관찰한 API의 동작일 뿐, 실제 네트워크에서 도착을 보장한다는 뜻이 아닙니다.
package example.network;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
@Timeout(value = 5, unit = SECONDS)
class UdpLoopbackTest {
private static final int RECEIVE_TIMEOUT_MILLIS = 2_000;
@Test
void 데이터그램_단위와_작은_수신_버퍼의_잘림을_관찰한다()
throws Exception {
var loopback = InetAddress.getLoopbackAddress();
try (var receiver = new DatagramSocket(0, loopback);
var sender = new DatagramSocket()) {
receiver.setSoTimeout(RECEIVE_TIMEOUT_MILLIS);
send(sender, loopback, receiver.getLocalPort(), "pulse");
var complete = new DatagramPacket(new byte[32], 32);
receiver.receive(complete);
assertEquals("pulse", text(complete));
var original = "board-heartbeat";
send(sender, loopback, receiver.getLocalPort(), original);
var truncated = new DatagramPacket(new byte[5], 5);
receiver.receive(truncated);
assertEquals("board", text(truncated));
assertTrue(original.getBytes(UTF_8).length
> truncated.getLength());
}
}
private static void send(
DatagramSocket sender,
InetAddress address,
int port,
String text) throws Exception {
var bytes = text.getBytes(UTF_8);
sender.send(new DatagramPacket(
bytes, bytes.length, address, port));
}
private static String text(DatagramPacket packet) {
return new String(
packet.getData(),
packet.getOffset(),
packet.getLength(),
UTF_8);
}
}UDP를 그대로 쓰는 하트비트라면 일련번호와 수신 마감 시간을 넣고, 잠깐의 손실과 장애를 구분하는 "연속 몇 번 미수신" 같은 정책을 애플리케이션 프로토콜에 둡니다. 데이터그램이 두 번 도착해도 부작용이 두 번 일어나지 않도록 식별자와 멱등성 규칙도 필요합니다. QUIC 같은 완성된 프로토콜은 UDP 위에 스트림의 신뢰성과 혼잡 제어를 더한 것이므로, UDP 데이터그램을 직접 쓰는 설계와 같은 보장을 가진다고 보지 않습니다.
TRANSPORT CONTRACT · APPLICATION SAFETY
TCP·UDP는 다른 전송 계약을 제공하고 재시도 안전성은 애플리케이션이 완성한다
전송 계층의 성공은 업무 처리의 성공과 같지 않습니다. QUIC은 UDP 위에 연결·통합 TLS 1.3·흐름/혼잡 제어와 신뢰 가능한 stream을 구성하지만, 결과가 모호한 업무 재시도의 안전성은 멱등성 key·중복 제거·transaction 결과 상태가 완성합니다.
TCP · BYTE STREAM
TCP는 순서 있는 신뢰 가능한 byte stream을 제공한다
빠진 byte를 재전송하고 순서를 복원한다
애플리케이션은 연결 안에서 전송한 byte가 같은 순서로 도착하는 stream을 읽습니다.
메시지 경계는 제공하지 않는다
한 번의
send와 한 번의read는 일대일이 아닙니다. HTTP 같은 상위 protocol이 길이와 frame 경계를 정합니다.처리·영속화·정확히 한 번은 보장하지 않는다
byte가 전달돼도 상대 handler의 실행, database commit, exactly-once 결과까지 TCP가 확인하지는 않습니다.
연결 단절 뒤 결과는 모호할 수 있다
요청을 보낸 뒤 응답 전에 연결이 끊기면 처리 전인지 처리 후인지 전송 상태만으로 판단할 수 없습니다.
UDP · DATAGRAM
UDP는 datagram 경계를 보존하지만 도착과 순서를 약속하지 않는다
한 datagram이 하나의 메시지 경계다
수신 측은 stream을 다시 나누는 대신 개별 datagram 단위로 읽습니다.
도착·순서·중복 제거는 보장하지 않는다
손실, 순서 변경, 중복이 가능하므로 필요한 sequence·ack·retry 정책은 상위 계층이 정합니다.
작은 DatagramPacket byte buffer는 datagram을 자른다
Java에서 수신용
DatagramPacket에 제공한 byte 배열이 datagram보다 작으면 남은 부분은 잘려 버립니다. 이는 socket queue 크기인SO_RCVBUF와 다른 경계입니다.손실 허용 신호는 별도 판정이 맞다
heartbeat는 개별 재전송보다 최근 미수신 횟수와 시간으로 상태를 판정할 수 있습니다.
QUIC · IDEMPOTENCY · STATE
QUIC은 전송 계약을 보강하고 업무 재시도는 애플리케이션 상태가 안전하게 만든다
QUIC은 UDP 위에 연결과 신뢰 가능한 stream을 구성한다
QUIC은 연결, 통합 TLS 1.3, stream별 순서·재전송·흐름 제어와 혼잡 제어를 제공하고 HTTP/3를 운반합니다. raw UDP의 비보장 계약을 그대로 노출하는 방식이 아닙니다.
멱등성 key와 중복 제거 기록을 둔다
중요한 업무 변경은 요청 key를 저장하고 같은 key의 재시도를 기존 결과에 연결해 부작용 중복을 막습니다.
transaction 상태와 결과 조회로 끝낸다
업무 상태를 원자적으로 기록하고 결과 조회 경로를 제공하면 응답이 사라져도 처리 여부를 확인한 뒤 재시도할 수 있습니다.
전송 보장과 업무 보장을 분리하면 timeout 뒤의 retry가 왜 별도 설계를 요구하는지 드러납니다.
그림은 전송 계층마다 보장하는 것과 보장하지 않는 것, 그리고 업무 결과를 안전하게 만드는 일은 별도라는 점을 정리합니다. TCP는 순서 있는 바이트 스트림을 주지만 메시지의 구분과 처리 완료는 보장하지 않고, UDP는 데이터그램 단위를 지키지만 도착과 순서는 약속하지 않습니다. QUIC은 UDP 위에 연결과 신뢰할 수 있는 스트림을 만들지만, 결과를 알 수 없는 업무 요청을 다시 보내도 안전하게 만드는 것은 멱등성 키와 중복 제거 기록, 트랜잭션 결과 상태라는 애플리케이션의 몫입니다.
증상은 실패한 구간을 좁힐 뿐 원인을 확정하지 않습니다
관찰한 증상마다 먼저 어느 구간을 의심하고 무엇을 확인할지 정리하면 다음과 같습니다. 증상은 실패한 구간을 좁혀 줄 뿐, 그것만으로 원인이 정해지지는 않습니다.
| 관찰한 증상 | 먼저 나눌 구간 | 확인할 것 |
|---|---|---|
| 이름 해석 실패 | 구성된 이름 서비스 | UnknownHostException, 리졸버 제한 시간, JVM의 성공·실패 캐시 정책, 돌려받은 주소 후보 |
| 연결 거부나 연결 시간 초과 | TCP 리스너와 IP 경로 | ConnectException은 그 접점에 리스너가 없거나 RST를 받았을 가능성, 시간 초과는 라우팅과 방화벽, 손실 가능성 |
| TLS 핸드셰이크 실패 | 암호화와 서버 식별 | SNI, 인증서의 SAN과 신뢰 체인, 유효 기간, ALPN, 클라이언트의 시계 |
프록시의 502, 503, 504 | 프록시와 업스트림 사이 | 잘못된 업스트림 응답, 일시적 사용 불가, 업스트림 마감 시간 초과를 서로 구분 |
HTTP 404 | 요청을 처리한 HTTP 라우터 | 프록시의 호스트·경로 규칙이 만든 것인지, 업스트림 애플리케이션이 만든 것인지 |
| 느린 응답이나 읽기 시간 초과 | 연결된 뒤의 응답 구간 | 커넥션 풀 대기, 프록시와 애플리케이션의 처리 시간, 응답 헤더와 본문 읽기 마감 |
| 쓴 뒤 결과를 모름, 재시도 | 전송 완료와 업무 커밋 사이 | 멱등성 키, 유일성 제약 조건, 결과 조회, 멱등하지 않은 POST의 자동 재시도 금지 |
TCP 연결과 TLS가 성공한 뒤 404가 왔다면, 적어도 HTTP 응답을 만들 수 있는 곳까지는 닿았다는 뜻입니다. 하지만 그 응답이 반드시 Spring MVC 컨트롤러에서 나왔다는 뜻은 아닙니다. 프록시나 게이트웨이도 호스트나 경로 규칙이 없으면 404를 만들 수 있습니다.
바인드 주소와 프록시 업스트림은 서로 다른 소켓입니다
127.0.0.1은 IPv4 루프백 주소여서 같은 네트워크 네임스페이스의 프로세스만 접근할 수 있습니다. 0.0.0.0은 IPv4의 모든 로컬 인터페이스에서 기다리라는 바인드용 와일드카드이며, 클라이언트가 접속할 목적지 주소가 아닙니다. IPv6에서는 루프백 ::1과 바인드용 비지정 주소 ::를 따로 구분합니다.
컨테이너 안의 127.0.0.1은 호스트의 루프백이 아니라 그 컨테이너 네트워크 네임스페이스의 루프백입니다. 프로세스의 리스너, 컨테이너 포트, 호스트에 공개한 포트, 서비스나 로드 밸런서의 리스너는 각각 다른 이름 공간의 다른 소켓일 수 있습니다. 컨테이너 안에서 localhost:8080이 성공했다고 해서 호스트나 인터넷에서도 같은 접근이 성공한다고 결론 내리지 않습니다.
리버스 프록시는 예를 들어 공개 :443 리스너에서 클라이언트의 TLS와 HTTP를 끝내고, app:8080 업스트림으로 별도의 연결을 만듭니다. 그래서 클라이언트 쪽 연결이 성공해도, 업스트림이 잘못된 응답을 보내면 502, 일시적으로 처리할 수 없으면 503, 마감 시간 안에 응답하지 않으면 504가 될 수 있습니다. 실제 제품의 상태 코드 정책과 프록시의 로그로 어느 구간이 응답을 만들었는지 확인합니다.
Forwarded나 X-Forwarded-* 헤더는 바깥의 클라이언트도 아무 값이나 넣어 보낼 수 있습니다. 그래서 신뢰할 수 있는 가장 바깥의 프록시가 외부에서 들어온 전달 헤더를 지우고, 자신이 관찰한 값으로 다시 써야 합니다. 애플리케이션은 지정한 신뢰 프록시에서 온 헤더만 해석하고, 아무 클라이언트가 보낸 헤더를 원래의 스킴이나 호스트, 클라이언트 주소로 믿지 않습니다.
정리
- 이름 해석은 IP 주소 후보를 줄 뿐이며, 실제로 요청을 받는 곳은 주소와 전송 프로토콜, 포트의 조합에 바인드된 리스너입니다.
- DNS는 UDP와 TCP를 모두 쓸 수 있고, JVM의 이름 해석 캐시는 DNS 레코드의 TTL과 따로 동작합니다.
- TCP는 순서 있는 바이트 스트림이어서
write횟수로 메시지가 나뉘지 않으며, HTTP/1.1은 CRLF와 길이로 메시지를 나눕니다. - UDP는 데이터그램 단위를 지키지만 도착과 순서를 보장하지 않으며, HTTP/3의 QUIC은 그 위에 신뢰할 수 있는 스트림과 TLS 1.3을 더합니다.
- 전송이 성공해도 업무 처리가 끝났다는 뜻은 아니므로, 결과를 모르는 POST의 재시도는 멱등성 키와 결과 조회로 안전하게 만듭니다.
연습 문제
1. 열린 포트와 닫힌 포트
임시 TCP 리스너가 열려 있을 때의 연결 성공과, 같은 ServerSocket을 닫은 뒤 같은 포트로 연결할 때의 거부를 서로 다른 테스트로 나누어 확인하세요. 고정 포트나 운영체제의 오류 메시지 문자열에 기대지 말고, 오류의 종류와 실패한 단계만 확인합니다.
해설 보기
package example.network;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import java.io.IOException;
import java.net.ConnectException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
@Timeout(value = 8, unit = SECONDS)
class TcpReachabilityExerciseTest {
private static final int IO_TIMEOUT_MILLIS = 2_000;
@Test
void 임시_리스너가_열려_있는_동안은_연결된다()
throws Exception {
var loopback = InetAddress.getLoopbackAddress();
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
try (var server = listen(loopback)) {
var accepted = executor.submit(() -> {
try (var socket = server.accept()) {
socket.setSoTimeout(IO_TIMEOUT_MILLIS);
return socket.getLocalPort();
}
});
try (var client = connect(
loopback, server.getLocalPort())) {
assertTrue(client.isConnected());
}
assertEquals(
server.getLocalPort(),
accepted.get(3, SECONDS));
}
} finally {
stop(executor);
}
}
@Test
void ServerSocket을_닫은_뒤_같은_포트는_연결을_거부한다()
throws Exception {
var loopback = InetAddress.getLoopbackAddress();
int closedPort;
try (var server = listen(loopback)) {
closedPort = server.getLocalPort();
}
try (var client = new Socket()) {
assertThrows(
ConnectException.class,
() -> client.connect(
new InetSocketAddress(
loopback, closedPort),
IO_TIMEOUT_MILLIS));
}
}
private static ServerSocket listen(InetAddress address)
throws IOException {
var server = new ServerSocket(0, 1, address);
server.setSoTimeout(IO_TIMEOUT_MILLIS);
return server;
}
private static Socket connect(InetAddress address, int port)
throws IOException {
var socket = new Socket();
socket.connect(
new InetSocketAddress(address, port),
IO_TIMEOUT_MILLIS);
socket.setSoTimeout(IO_TIMEOUT_MILLIS);
return socket;
}
private static void stop(ExecutorService executor)
throws InterruptedException {
executor.shutdownNow();
if (!executor.awaitTermination(3, SECONDS)) {
fail("executor did not terminate");
}
}
}성공 테스트는 ServerSocket을 닫은 뒤 실행기를 정리합니다. 실패 테스트는 같은 루프백 포트에서 ConnectException이 났다는 단계만 확인하고, 운영체제마다 다른 메시지는 비교하지 않습니다. 이 실험에는 닫은 임시 포트를 확인하기 전에 다른 프로세스가 그 포트에 새 리스너를 열지 않았다는 조건도 필요합니다. 예상과 달리 연결에 성공했다면, 닫은 소켓이 살아 있다고 결론 내리기 전에 지금 그 포트를 누가 쓰고 있는지부터 확인합니다.
2. 응답 전에 끊긴 POST
이 연습에서 업무용 멱등성 키는 같은 회원이 보낸 하나의 생성 요청을 구별하는 값이며, 같은 회원과 키로 확정된 결과를 조회하고 원래 요청 내용과 비교할 수 있다고 가정합니다. 서버가 회원 7의 키 R1로 게시글을 커밋했지만, 응답을 보내는 도중 연결이 끊겼습니다. 클라이언트가 새 키 R2로 다시 생성하는 것과, 기존 R1의 결과를 확인하는 것은 무엇이 다를까요? R1로 다시 보낸 요청의 내용까지 바뀌었다면 이전의 성공 응답을 그대로 돌려줘도 될까요?
해설 보기
연결이 끊겼다는 것은 저장된 것이 없다는 증명이 아닙니다. 새 키 R2는 다른 생성 요청으로 취급되므로, 별도의 업무 제약이 없다면 게시글이 하나 더 만들어질 수 있습니다. 반면 회원 7과 R1로 확정된 결과를 확인해 요청 내용도 같다면, 그 결과를 다시 돌려주어 같은 생성을 되풀이하지 않습니다. 내용이 바뀌었다면 같은 요청으로 보지 않고 충돌로 처리해야 하며, 이전의 성공을 새 내용의 성공처럼 돌려주지 않습니다. 이 풀이의 업무 규칙은 추가로 가정한 것이며, 위의 루프백 소켓 테스트가 데이터베이스 저장이나 중복 제거까지 구현하거나 확인한 것은 아닙니다.
다음 글 URI와 브라우저 요청에서는 주소 문자열을 구성 요소로 나누고, 실제 요청의 대상에 어떤 값이 들어가는지 확인합니다. 연결할 후보를 얻었다는 것과 올바른 HTTP 요청 대상을 만들었다는 것은 서로 다른 단계입니다.