본문으로 건너뛰기

안동민 개발노트

본문 시작

DNS·IP·TCP·UDP와 연결 경계

이름 해석에서 프록시 업스트림까지 요청이 지나는 연결 경계를 JDK 17 루프백 TCP·UDP 테스트로 검증합니다.

앞 문서에서는 스프링 빈의 스코프와 객체 수명 경계를 다뤄습니다.

이제 https://board.example/api/posts/42라는 요청이 서버 프로세스에 도달하기 전의 경계를 봅니다. 이름 해석은 board.example에 대한 IP 주소 후보를 제공하지만, 그 주소의 특정 전송 프로토콜·포트에 프로세스가 수신 대기 중인지는 증명하지 않습니다.

클라이언트는 후보 주소 하나와 전송 프로토콜, 포트를 결합해 연결을 시도합니다. HTTPS의 HTTP/1.1·2는 일반적으로 TCP를 연 뒤 TLS를 수립하고, HTTP/3는 TLS 1.3을 통합한 QUIC 연결을 수립해야 HTTP 요청을 보낼 수 있습니다. 서버 앞에 리버스 프록시가 있다면 공개 리스너와 애플리케이션 업스트림은 서로 다른 소켓 경계입니다.

구성된 이름 서비스가 주소 후보를 반환하고 DNS 경로에서만 A·AAAA 레코드와 RR TTL이 관여하며 JVM은 성공·실패 결과를 자체 정책으로 cache한 뒤, 이 HTTPS 요청은 TCP·TLS 또는 UDP·QUIC 경로와 프록시의 별도 upstream을 거쳐 컨테이너 handler로 이어집니다.

NAME · TRANSPORT · LISTENER

이름은 주소 후보만 만들고 전송 방식과 리스너가 실제 HTTP 경로를 결정한다

이름 해석은 연결이 아니라 목적지 후보를 제공합니다. 이 HTTPS 요청에서 클라이언트와 서버가 사용하기로 한 HTTP 버전에 맞는 전송 방식과 공개 리스너를 고르고, 프록시가 별도 upstream 연결로 애플리케이션의 HTTP handler까지 요청을 넘깁니다.

RESOLUTION · CANDIDATES

구성된 이름 서비스는 주소 후보를 돌려줄 뿐 연결을 열지 않는다

  1. 구성된 이름 서비스에 host를 묻는다

    InetAddress는 hosts 파일, OS resolver, DNS 등이 조합된 구성 경로에서 주소 값을 얻습니다. 반환값만으로 실제 원본을 역추론하지 않습니다.

  2. DNS 경로라면 A·AAAA 레코드가 관여한다

    AAAAA는 IPv4와 IPv6를 나타내는 DNS 레코드입니다. 구성 경로가 DNS를 사용할 때만 이 레코드와 RR TTL이 관여하며, 이름 서비스 반환값은 원본 레코드 종류와 TTL을 노출하지 않습니다.

  3. JVM cache는 별도 수명을 가진다

    운영체제 resolver cache와 별개로 JVM도 성공·실패 결과를 보관할 수 있으므로, DNS 변경 반영 시점을 따로 확인합니다.

HTTP VERSION · CONNECTION

이 HTTPS 요청에 사용하는 HTTP 버전이 두 전송 경로를 나눈다

  1. HTTPS의 HTTP/1.1과 HTTP/2

    공개 TCP 443에 연결하고 TLS를 협상한 뒤 HTTP 요청과 응답을 주고받습니다.

  2. HTTP/3

    공개 UDP 443에서 QUIC을 사용합니다. QUIC handshake에 TLS 1.3이 통합되고 그 위에 HTTP/3 stream이 놓입니다.

  3. 연결은 요청보다 오래 재사용될 수 있다

    한 번 만든 TCP·TLS 또는 QUIC 연결은 여러 요청에 재사용될 수 있으므로, 요청 하나와 연결 하나를 같은 수명으로 보지 않습니다.

PUBLIC EDGE · UPSTREAM

공개 리스너와 애플리케이션 리스너는 서로 다른 연결 경계다

  1. 프록시가 공개 443을 받는다

    TCP 443·UDP 443에서 외부 연결을 받고 TLS나 QUIC을 종료합니다.

  2. 별도 upstream을 선택한다

    프록시는 내부의 app:8080으로 새 연결을 만들거나 기존 연결을 재사용합니다. 외부 연결이 그대로 애플리케이션에 이어지는 것은 아닙니다.

  3. 컨테이너가 HTTP를 handler에 매핑한다

    애플리케이션 리스너가 내부 HTTP 요청을 받고, 컨테이너가 method·URI·media type을 해석해 controller handler를 호출합니다.

추적 순서: 이름 후보 → 전송·보안 handshake → 공개 리스너 → 별도 upstream → 컨테이너 handler


구성된 이름 서비스와 캐시 경계를 구분합니다

InetAddress.getAllByName()은 JVM에 구성된 이름 서비스를 통해 주소 후보를 얻습니다. 그 경로에는 호스트 파일, 운영체제 리졸버, 로컬 캐시와 DNS가 결합될 수 있습니다. 따라서 localhost가 루프백 주소로 해석되는 테스트는 구성된 이름 서비스의 스모크 테스트이지, 외부 권한 DNS 서버와 UDP 패킷까지 검증하는 밀폐형 DNS 테스트가 아닙니다.

전통적 DNS 질의와 응답은 UDP와 TCP를 모두 사용할 수 있습니다. UDP 응답이 잘렸거나 큰 응답이 필요한 경우에는 TCP가 사용될 수 있고, 현대 리졸버는 운영 정책에 따라 처음부터 TCP를 선택할 수도 있습니다. DNS over TLS나 DNS over HTTPS를 사용하면 하위 전송 경로는 또 달라집니다. “DNS는 항상 UDP”라고 진단하면 안 됩니다.

DNS 리소스 레코드의 TTL은 DNS 캐시가 그 레코드를 재사용할 수 있는 기간을 나타냅니다. 반면 JDK 17의 networkaddress.cache.ttlnetworkaddress.cache.negative.ttl은 JVM이 성공·실패 이름 해석 결과를 보관하는 별도의 보안 속성입니다. 실행 환경과 보안 설정에 따라 JVM 캐시 기간은 권한 DNS RR TTL과 일치하지 않을 수 있습니다. InetAddress가 반환한 주소만 보고 원본 RR TTL이나 실제 질의 전송 방식을 역추론하지 않습니다.

실제 외부 도메인이 없다는 가정에 테스트를 걸지 않습니다. 실패 분류는 주입한 리졸버가 UnknownHostException을 던지게 해 결정적으로 검증합니다.

src/test/java/example/network/NameResolutionTest.java
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;
    }
}

주소·전송 프로토콜·포트가 리스너를 고릅니다

IP 주소는 호스트의 네트워크 인터페이스를 향합니다. TCP와 UDP는 서로 다른 포트 공간을 사용하므로, 같은 숫자 8080이라도 TCP/8080UDP/8080은 다른 접점입니다. 서버 프로세스가 주소·전송 프로토콜·포트 조합에 리스너를 바인드해야 운영체제가 도착한 연결이나 데이터그램을 그 소켓에 전달할 수 있습니다.

테스트에서 ServerSocket(0)을 사용하면 운영체제가 현재 네임스페이스의 빈 TCP 포트를 고릅니다. 아래 테스트는 JDK 17 API만 사용하고, accept·connect·read·future·executor 종료 모두에 명시적 제한 시간을 둡니다. ServerSocket을 닫은 뒤 executor를 중지하므로 accept가 남아 스레드를 붙잡지 않습니다.

src/test/java/example/network/TcpLineExchangeTest.java
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");
        }
    }
}

TCP는 연결 안에서 신뢰할 수 있고 순서가 있는 바이트 스트림을 제공합니다. 송신 측의 두 write()가 두 TCP 세그먼트나 수신 측의 두 read()와 대응한다는 규칙은 없습니다. 위 코드에서 요청 라인을 나누는 것은 TCP 패킷 경계가 아니라 HTTP/1.1이 사용하는 CRLF와 BufferedReader.readLine()의 애플리케이션 프레이밍입니다.


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 connect 또는 QUIC 연결, TLS 핸드셰이크, 커넥션 풀 대기, 응답 헤더 대기, 본문 read 경계로 나누어 관찰합니다. 하나의 read timeout을 늘려도 이름 해석 실패나 연결 거부는 해결되지 않습니다. QUIC은 TLS 핸드셰이크를 통합하므로 사용하는 클라이언트가 노출하는 타이머 이름과 경계를 문서로 확인합니다.

Java 소켓의 write() 성공은 로컬 소켓·TCP 스택이 바이트를 받아들였다는 뜻이고, TCP ACK는 원격 TCP 종단이 해당 바이트를 수신했음을 확인했다는 뜻입니다. 어느 것도 원격 애플리케이션이 바이트를 소비했거나 게시글 생성 트랜잭션이 커밋되었거나 한 번만 실행되었음을 증명하지 않습니다. 서버가 커밋한 뒤 응답이 끊기면 클라이언트는 결과를 모르는 모호한 상태에 놓입니다. POST를 무조건 재시도하지 말고, 업무 멱등성 키·유일성 제약·결과 조회로 중복 효과를 제어합니다.


UDP는 수신한 데이터그램의 경계를 보존합니다

UDP는 발신지와 목적지 주소·포트가 붙은 데이터그램을 전달합니다. 수신한 데이터그램 하나는 메시지 경계를 유지하지만, UDP 자체는 연결 핸드셰이크, 도착, 순서, 중복 제거, 재전송을 보장하지 않습니다. DatagramSocket.connect()도 기본 상대를 고르고 일부 입력을 필터링할 뿐 TCP와 같은 핸드셰이크나 신뢰성을 추가하지 않습니다.

수신 버퍼가 데이터그램보다 작으면 Java API는 그 버퍼에 맞춰 내용을 잘라 전달합니다. 애플리케이션이 이를 TCP 스트림처럼 이어 붙여 나머지를 다음 receive()에서 받을 수는 없습니다. 아래 루프백 테스트는 정상 버퍼에서의 한 데이터그램 경계와 의도적으로 작은 버퍼의 잘림을 같이 검증합니다. 이 결과는 루프백에서 관찰한 API 동작이지 실제 네트워크의 도착 보장이 아닙니다.

src/test/java/example/network/UdpLoopbackTest.java
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 데이터그램을 직접 사용하는 설계와 같은 보장을 갖는다고 취급하지 않습니다.

TCP와 UDP의 보장·비보장 범위, QUIC의 연결·통합 TLS 1.3·흐름/혼잡 제어와 stream 신뢰성을 구분하고, 모호한 업무 재시도는 멱등성 key·중복 제거·transaction 결과 상태로 안전하게 만드는 책임을 설명합니다.

TRANSPORT CONTRACT · APPLICATION SAFETY

TCP·UDP는 다른 전송 계약을 제공하고 재시도 안전성은 애플리케이션이 완성한다

전송 계층의 성공은 업무 처리의 성공과 같지 않습니다. QUIC은 UDP 위에 연결·통합 TLS 1.3·흐름/혼잡 제어와 신뢰 가능한 stream을 구성하지만, 결과가 모호한 업무 재시도의 안전성은 멱등성 key·중복 제거·transaction 결과 상태가 완성합니다.

TCP · BYTE STREAM

TCP는 순서 있는 신뢰 가능한 byte stream을 제공한다

  1. 빠진 byte를 재전송하고 순서를 복원한다

    애플리케이션은 연결 안에서 전송한 byte가 같은 순서로 도착하는 stream을 읽습니다.

  2. 메시지 경계는 제공하지 않는다

    한 번의 send와 한 번의 read는 일대일이 아닙니다. HTTP 같은 상위 protocol이 길이와 frame 경계를 정합니다.

  3. 처리·영속화·정확히 한 번은 보장하지 않는다

    byte가 전달돼도 상대 handler의 실행, database commit, exactly-once 결과까지 TCP가 확인하지는 않습니다.

  4. 연결 단절 뒤 결과는 모호할 수 있다

    요청을 보낸 뒤 응답 전에 연결이 끊기면 처리 전인지 처리 후인지 전송 상태만으로 판단할 수 없습니다.

UDP · DATAGRAM

UDP는 datagram 경계를 보존하지만 도착과 순서를 약속하지 않는다

  1. 한 datagram이 하나의 메시지 경계다

    수신 측은 stream을 다시 나누는 대신 개별 datagram 단위로 읽습니다.

  2. 도착·순서·중복 제거는 보장하지 않는다

    손실, 순서 변경, 중복이 가능하므로 필요한 sequence·ack·retry 정책은 상위 계층이 정합니다.

  3. 작은 DatagramPacket byte buffer는 datagram을 자른다

    Java에서 수신용 DatagramPacket에 제공한 byte 배열이 datagram보다 작으면 남은 부분은 잘려 버립니다. 이는 socket queue 크기인 SO_RCVBUF와 다른 경계입니다.

  4. 손실 허용 신호는 별도 판정이 맞다

    heartbeat는 개별 재전송보다 최근 미수신 횟수와 시간으로 상태를 판정할 수 있습니다.

QUIC · IDEMPOTENCY · STATE

QUIC은 전송 계약을 보강하고 업무 재시도는 애플리케이션 상태가 안전하게 만든다

  1. QUIC은 UDP 위에 연결과 신뢰 가능한 stream을 구성한다

    QUIC은 연결, 통합 TLS 1.3, stream별 순서·재전송·흐름 제어와 혼잡 제어를 제공하고 HTTP/3를 운반합니다. raw UDP의 비보장 계약을 그대로 노출하는 방식이 아닙니다.

  2. 멱등성 key와 중복 제거 기록을 둔다

    중요한 업무 변경은 요청 key를 저장하고 같은 key의 재시도를 기존 결과에 연결해 부작용 중복을 막습니다.

  3. transaction 상태와 결과 조회로 끝낸다

    업무 상태를 원자적으로 기록하고 결과 조회 경로를 제공하면 응답이 사라져도 처리 여부를 확인한 뒤 재시도할 수 있습니다.

전송 보장과 업무 보장을 분리하면 timeout 뒤의 retry가 왜 별도 설계를 요구하는지 드러납니다.


증상은 실패한 경계를 좁힐 뿐 원인을 확정하지 않습니다

관찰한 증상먼저 나눌 경계확인할 사실
이름 해석 실패구성된 이름 서비스UnknownHostException, 리졸버 제한 시간, 성공·실패 JVM 캐시 정책, 반환된 주소 후보
연결 거부 또는 connect 시간 초과TCP 리스너·IP 경로ConnectException은 해당 접점의 미수신·RST 가능성, 시간 초과는 라우팅·방화벽·손실 가능성
TLS 핸드셰이크 실패암호화·서버 식별SNI, 인증서 SAN·신뢰 체인·유효 기간, ALPN, 클라이언트 시계
프록시 502·503·504프록시와 업스트림 사이잘못된 업스트림 응답, 일시 사용 불가, 업스트림 마감 시간 초과를 서로 구분
HTTP 404요청을 처리한 HTTP 라우터프록시의 호스트·경로 규칙이 생성했는지, 업스트림 애플리케이션이 생성했는지
느린 응답 또는 read 시간 초과연결 후 응답 경계커넥션 풀 대기, 프록시·애플리케이션 처리 시간, 응답 헤더·본문 read 마감
쓰기 후 결과 불명·재시도전송 완료와 업무 커밋 사이멱등성 키, 유일성 제약, 결과 조회, 비멱등 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가 될 수 있습니다. 실제 제품의 상태 코드 정책과 프록시 로그로 어느 호프가 응답을 만들었는지 확인합니다.

ForwardedX-Forwarded-* 헤더는 외부 클라이언트도 임의로 보낼 수 있습니다. 신뢰 경계의 엣지 프록시가 외부에서 들어온 전달 헤더를 제거하고 자신이 관찰한 값으로 다시 작성하게 합니다. 애플리케이션은 지정한 신뢰 프록시에서 온 헤더만 해석해야 하며, 임의의 클라이언트 헤더를 원본 스킴·호스트·클라이언트 주소로 믿지 않습니다.


연습 문제

임시 TCP 리스너가 열려 있을 때의 연결 성공과, 같은 ServerSocket을 닫은 뒤 같은 포트로 연결할 때의 거부를 분리하세요. 고정 포트나 운영체제 오류 메시지 문자열에 의존하지 말고 오류 유형과 발생 단계만 검증합니다.

src/test/java/example/network/TcpReachabilityExerciseTest.java
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이 닫힌 뒤 executor를 정리합니다. 실패 테스트는 같은 루프백 포트에서 ConnectException이 발생했다는 단계만 고정하고, 운영체제마다 다른 메시지는 비교하지 않습니다.

이제 한 단계 더 확장해 응답을 받기 전 연결이 끊긴 POST를 가정하고, 업무 멱등성 키를 사용해 재시도가 중복 게시글을 만들지 않도록 설계해 보세요. 소켓 성공 여부와 업무 처리 유일성은 서로 다른 검증 대상입니다.

다음 ch4-2는 URI의 스킴·호스트·포트·경로·쿼리·프래그먼트와 브라우저가 요청을 만드는 경계를 소유합니다. 이 문서는 URI 분해나 브라우저 네비게이션을 앞당겨 다루지 않습니다.