mLog 명판이 있는 전화 교환대에서 연결선을 꽂고 전달할 편지를 따로 두는 흰색 3D 유령 교환원

Spring Boot 연결 오류 진단: TCP 포트 테스트와 HTTP 415를 구분하는 방법

네트워크 Spring Boot 2026년 9월 13일

외부 연동이 실패했을 때 먼저 구분할 것은 “포트에 연결하지 못했는가”와 “연결한 상대가 요청을 거절했는가”다. 둘은 고칠 설정도, 확인할 담당자도 다르다.

예를 들어 HTTP 415를 받았다면 요청을 해석한 HTTP 구성 요소가 응답한 것이다. 반대로 응답이 없다고 해서 방화벽 차단이 확정되지는 않는다. DNS·경로·TCP·TLS·프록시·원격 애플리케이션 중 어디서 멈췄는지 추가 증거가 필요하다.

이 글은 2026-09-13 공식 문서를 확인하고 Java 표준 API로 로컬 실험을 수행해 작성했다. 회사 서버에서 발생한 장애를 해결했다는 기록은 아니다. 외부 연동망·TLS·RestClient 통합 실행은 검증하지 않았으며, 실제 실행한 항목은 별도로 표시한다.

1. 먼저 요청의 경로를 적는다

브라우저에서 누른 테스트 버튼이 Spring Boot를 거쳐 외부로 나간다면 연결은 하나가 아니다. 브라우저→Spring Boot 구간과 Spring Boot→외부 시스템 구간이 있다. 중간에 프록시가 있으면 구간이 더 늘어난다.

따라서 브라우저가 받은 상태 코드만 보고 외부 시스템이 응답했다고 단정하면 안 된다. 진단 컨트롤러가 내부 예외를 잡아 항상 HTTP 200으로 감싸거나, 게이트웨이가 대신 오류를 만들 수도 있다.

먼저 다음 정보를 모은다.

  • 실제 외부 호출을 수행한 서버·컨테이너와 실행 시각
  • 승인된 목적지의 호스트명·포트·HTTP 또는 HTTPS 여부
  • HTTP 프록시 사용 여부와 연결하려는 상대가 프록시인지 원 서버인지
  • 외부 호출의 예외 원인 체인 또는 HTTP 응답 상태
  • 요청을 따라갈 수 있는 상관관계 ID

토큰·비밀번호·업무 전문은 이 단계의 진단 자료에 필요하지 않다. 원문 전체를 로그에 남기기보다 필요한 분류와 안전한 식별자만 보존한다.

2. RestClient와 TCP 테스트는 목적이 다르다

Spring의 RestClient는 동기식 HTTP 클라이언트다. 임의의 원시 TCP 전문을 송수신하는 도구가 아니다. 요청 팩토리와 실제 HTTP 클라이언트 구현에 따라 연결·타임아웃 설정도 달라질 수 있다. Spring REST Clients

HTTP 요청의 본문에 바이너리를 넣는 것과, TCP 소켓에 특정 전문을 직접 쓰는 것은 다르다. application/octet-stream을 지정해도 HTTP 메서드·헤더·상태 코드가 없는 원시 TCP 통신으로 바뀌지 않는다.

연동 명세에서 우선 확인할 것은 다음이다.

  • HTTP라면 URL, 메서드, Content-Type, 인코딩, 인증 방식, 성공 판정 기준
  • 원시 TCP라면 접속 주소, 포트, 메시지 경계, 길이 필드, 문자 인코딩, 응답 규칙
  • 어느 방식이든 TLS 사용 여부와 연결 종료·재시도 규칙

TCP 연결만 검사할 때는 업무 전문을 보내지 않는 작은 소켓 테스트로 범위를 제한할 수 있다. 다만 연결 자체도 상대 로그·보안 모니터링에 남을 수 있으므로 승인된 한 목적지에 필요한 횟수만 실행한다.

3. 결과가 무엇을 증명하는지 구분한다

관측 알 수 있는 것 아직 알 수 없는 것
DNS 실패 이름 해석이 완료되지 않음 목적지 포트의 실제 상태
TCP connect 성공 선택한 주소·포트로 그 시점에 연결 성립 TLS·HTTP·인증·업무 처리 성공
연결 거부 계열 연결 시도가 실패함 서비스 미기동인지 중간 정책 거부인지
연결 시간 초과 제한 시간 안에 connect가 끝나지 않음 어떤 장비가 원인인지
읽기 시간 초과 연결 뒤 지정한 읽기 대기에서 제한 초과 상대 업무 처리 성공·실패 여부
HTTP 415 HTTP 응답자가 요청 형식을 지원하지 않아 거절 최종 업무 서버까지 도달했는지

Java ConnectException은 원격 주소·포트에 연결하는 도중의 오류이며, 전형적인 예가 리스너가 없는 경우다. 예외 이름만으로 방화벽 상태를 확정하는 코드는 피한다. Java ConnectException

Google Cloud의 연결 점검 문서도 주소·포트·리스너·클라이언트와 서버의 방화벽·경로를 나누어 확인한다. 하나의 timeout 메시지로 네트워크 전체를 판정하지 않는 접근이다. Google Cloud 한국어 연결 점검

4. Java 표준 API로 한 번만 TCP 연결을 검사한다

다음은 HTTP 컨트롤러가 아니라 관리자용 작은 Java 프로그램이다. 승인받은 목적지만 직접 입력한다. 웹 요청의 host나 port를 그대로 전달하는 방식으로 배포하지 않는다.

import java.io.IOException;
import java.net.*;

public class TcpProbe {
    public static String probe(String host, int port, int timeoutMs) {
        if (host == null || host.isBlank() || port < 1 || port > 65535
                || timeoutMs < 1 || timeoutMs > 10000) {
            throw new IllegalArgumentException("Invalid destination or timeout");
        }
        try {
            InetAddress address = InetAddress.getByName(host);
            try (Socket socket = new Socket(Proxy.NO_PROXY)) {
                socket.connect(new InetSocketAddress(address, port), timeoutMs);
                return "TCP_CONNECTED";
            }
        } catch (UnknownHostException e) {
            return "DNS_FAILED";
        } catch (SocketTimeoutException e) {
            return "CONNECT_TIMEOUT";
        } catch (ConnectException e) {
            return "CONNECT_FAILED";
        } catch (IOException e) {
            return "IO_FAILED";
        }
    }

    public static void main(String[] args) {
        if (args.length != 3) {
            throw new IllegalArgumentException("Expected: host port timeout-ms");
        }
        System.out.println(probe(args[0], Integer.parseInt(args[1]),
                Integer.parseInt(args[2])));
    }
}

JDK에서 위 코드를 TcpProbe.java로 저장하고 실행할 수 있다. 다음 숫자는 실행 형식의 예시이며 운영 권장값이 아니다. 127.0.0.1:18080에 자신의 테스트 리스너를 먼저 준비한 경우에만 이 주소가 의미가 있다.

java TcpProbe.java 127.0.0.1 18080 1000

코드는 애플리케이션 데이터를 쓰거나 읽지 않고 연결 뒤 소켓을 닫는다. Proxy.NO_PROXY는 Java 소켓에 직접 연결을 명시한 것이므로 실제 RestClient가 사용하는 HTTP 프록시 경로와 같다고 가정하면 안 된다. 프록시가 필수인 조직에서는 승인 없이 직접 연결을 시도하지 않는다.

TCP_CONNECTED는 서비스 건강 상태가 아니라 좁은 연결 결과다. 예를 들어 앞단 로드밸런서가 연결을 받았지만 뒤의 업무 서버는 실패할 수도 있다.

이 간소화 CLI는 네트워크 실패도 문자열로 출력한다. 프로세스 종료 코드 0만 보고 연결 성공으로 판단하지 말고 출력 분류를 확인한다. 운영 모니터링에 연결하려면 별도의 종료 코드·오류 처리 규약을 정해야 한다.

5. timeout 숫자 하나가 전체 제한 시간은 아니다

위 코드에는 두 가지 중요한 제한이 있다.

첫째, InetAddress.getByName()이 이름을 해석한 뒤에 connect()를 호출한다. 따라서 connect에 넣은 시간은 DNS를 포함한 프로그램 전체 실행 제한이 아니다. 또한 여기서는 선택된 주소 하나를 시험할 뿐, 호스트명의 모든 IPv4·IPv6 주소를 검증하지 않는다. Java InetAddress

둘째, 연결 시간 제한과 읽기 시간 제한은 다르다. Socket.connect(endpoint, timeout)의 제한은 연결 단계에 적용된다. setSoTimeout()은 소켓 입력 스트림의 읽기 대기에 적용되며, 이미 시작된 전체 업무를 취소하는 장치가 아니다. 이 예제는 읽지 않으므로 일반 점검 함수에는 setSoTimeout()을 넣지 않았다. 값 0은 무한 대기로 해석될 수 있어 예제는 양수만 받는다. Java Socket

실제 HTTP 클라이언트는 커넥션 풀에서 기다리는 시간까지 따로 있을 수 있다. 따라서 “timeout 1초를 넣었으니 모든 경우 1초에 종료한다”는 설명은 피한다. 구현체·DNS·풀 대기·TLS·응답 대기의 경계를 확인해야 한다.

6. 이번에 실제로 확인한 로컬 결과

2026-09-13 작업 환경의 OpenJDK 17.0.20에서 같은 probe() 함수와 별도 테스트 코드를 실행했다. 테스트는 127.0.0.1의 임시 포트만 사용했으며 회사망이나 외부 시스템을 조회하지 않았다.

실험 구성 실제 결과
로컬 ServerSocket이 듣고 있는 포트에 접속 TCP_CONNECTED
서버 측에서 점검 연결의 입력을 읽음 데이터 없이 EOF, EOF_NO_BYTES
로컬 리스너 종료 뒤 같은 포트에 접속 CONNECT_FAILED
연결은 받되 데이터를 보내지 않는 서버에서 읽기 대기 READ_TIMEOUT
connect 제한값 0 입력 REJECTED

이 실험은 “TCP 연결 성공”과 “읽기 성공”이 다르다는 것을 확인한다. 외부 방화벽이 패킷을 버리는 연결 시간 초과는 재현하지 않았다. TLS·HTTP 415·Spring 컨트롤러·RestClient 호출도 이 로컬 테스트에 포함되지 않았다.

리드 타임아웃 실험은 별도의 클라이언트에서 setSoTimeout(100) 후 읽기를 호출했다. 일반 probe()는 여전히 읽지 않는다. 100은 재현용 설정이지 서비스에 권장하는 제한 시간이 아니다. 닫힌 임시 포트는 다른 프로세스가 재사용할 가능성이 있어 모든 환경에서 동일한 결과를 보장하지 않는다.

위 결과는 본문과 동일한 probe() 함수에 별도의 로컬 테스트 코드를 붙여 얻었다. 본문 CLI에는 자체 테스트 옵션이 없다.

7. 반드시 실제 호출이 나가는 위치에서 확인한다

내 PC에서 성공한 결과는 서버에서 성공한 결과가 아니다. 서버 호스트에서 성공해도 컨테이너의 DNS·네트워크·출구 정책은 다를 수 있다. Spring Boot가 컨테이너 안에 있다면 그 실행 환경을 기준으로 승인된 점검 방법을 선택한다.

localhost는 지금 코드를 실행하는 환경 자신을 가리킨다. 원격 상대 주소를 넣어야 하는데 이를 사용하면 엉뚱한 대상을 점검하게 된다. 프록시를 이용한다면 직접 목적지 검사와 프록시를 통과하는 실제 요청을 나누어 기록한다.

DB 접속과 터널 경로의 차이는 기존 Windows·Neon 5432 연결과 SSH 터널에서 별도로 다뤘다. 이번 테스트는 SSH 터널을 만들거나 조직 정책을 우회하는 절차가 아니다.

8. TCP가 확인된 다음 HTTP 415를 본다

HTTP 415는 요청한 리소스와 메서드에서 지원하지 않는 콘텐츠 형식을 이유로 거절한 상태다. Content-Type이나 Content-Encoding 때문일 수도 있고, 실제 데이터를 검사한 결과일 수도 있다. RFC 9110 §15.5.16

이 단계에서는 명세와 실제 요청의 메서드·경로·헤더·바이트 인코딩을 대조한다. application/octet-stream을 쓰라는 명세라면 JSON 객체 직렬화가 아닌 요구된 바이트 형식인지 확인한다. Content-Type만 바꾸면 어떤 전문이든 맞게 된다고 생각하지 않는다.

반대로 415가 나오지 않았다는 사실은 방화벽 차단의 증거가 아니다. 다른 HTTP 오류, TLS 실패, 접속 실패, 응답 유실 또는 예외 포장이 먼저 일어날 수 있다. 원래 예외와 응답을 분리해 확인한다.

502가 프록시에서 발생했다면 NPM 502 진단으로 프록시와 업스트림 사이를 확인한다. 브라우저에서 CORS 메시지가 보인다면 Vue·IBSheet CORS와 프록시처럼 브라우저의 정책 오류와 서버 간 연결을 구분한다.

8-1. 헤더만 바꿔도 해결될까? 같은 주소에 세 가지 요청을 보내 본다

2026-09-29 추가 실험. 6절의 기존 Java 소켓 실험과 별도로, Python 3.12.14에서 작은 HTTP 수신기를 만들고 127.0.0.1의 임시 포트로만 요청했다. 아래 결과는 이 실험용 수신기의 동작이다. Spring Boot, RestClient, 회사 연동 서버, TLS 또는 방화벽을 재현한 결과가 아니다.

수신기는 POST /probe, Content-Type: application/octet-stream, 본문 바이트 PING을 받아야 성공하도록 정했다. 포트와 경로는 같게 두고 헤더와 본문만 바꾸면 “연결은 됐지만 표현 형식이 다름”과 “헤더는 맞지만 본문이 다름”을 분리할 수 있다.

요청실제 응답해석
application/json + PING415 / UNSUPPORTED_MEDIA_TYPE실험 수신기가 Content-Type을 거절했다.
application/octet-stream + {"ping":true}400 / EXPECTED_PING_BYTES헤더를 바꿔도 JSON 바이트가 PING 바이트로 변하지 않았다.
application/octet-stream + PING200 / PONG이 실험의 요청 조건을 모두 충족했다.
application/octet-stream + 빈 본문400 / EXPECTED_PING_BYTES빈 입력을 성공 처리하지 않았다.
application/octet-stream + 1,025바이트413 / TOO_LARGE실험에서 정한 1,024바이트 상한을 넘었다.

400과 413의 반환 조건 및 본문 상한은 이 수신기에 직접 구현한 시험 규칙이다. 실제 기관 API가 같은 상황에 같은 코드를 반환한다는 뜻은 아니다. HTTP 415는 지원하지 않는 요청 형식 때문에 거절됐음을 나타내며, 헤더뿐 아니라 실제 데이터 검사로 결정될 수도 있다. RFC 9110 §15.5.16

같은 실험을 직접 실행하는 방법

다음 전체 코드를 http-media-type-lab.py로 저장한 뒤 python3 http-media-type-lab.py를 실행한다. 외부 패키지는 필요하지 않다. 포트를 자동 배정하고, 정해진 요청을 검사한 뒤 서버를 닫는다. 파일을 제공하는 서버가 아니며 외부 주소로 요청하지 않는다. 이 코드는 신뢰하는 로컬 입력을 위한 시험 도구다. Python의 http.server를 운영 서비스로 배포하지 않는다. Python HTTP 서버 문서

"""Local HTTP fixture; no Spring Boot or external endpoint is exercised."""
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
import json
import platform


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *_):
        pass

    def do_POST(self):
        size = int(self.headers.get("Content-Length", "0"))
        if self.path != "/probe":
            status, message = 404, b"NOT_FOUND"
        elif not 0 <= size <= 1024:
            status, message = 413, b"TOO_LARGE"
        elif self.headers.get_content_type() != "application/octet-stream":
            status, message = 415, b"UNSUPPORTED_MEDIA_TYPE"
        elif self.rfile.read(size) != b"PING":
            status, message = 400, b"EXPECTED_PING_BYTES"
        else:
            status, message = 200, b"PONG"
        self.send_response(status)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Content-Length", str(len(message)))
        self.end_headers()
        self.wfile.write(message)


def main():
    cases = [
        ("wrong_header", "application/json", b"PING", 415),
        ("wrong_body", "application/octet-stream", b'{"ping":true}', 400),
        ("matching_header_and_body", "application/octet-stream", b"PING", 200),
        ("empty_body", "application/octet-stream", b"", 400),
        ("body_too_large", "application/octet-stream", b"X" * 1025, 413),
    ]
    results = []
    with HTTPServer(("127.0.0.1", 0), Handler) as server:
        worker = Thread(target=server.serve_forever, daemon=True)
        worker.start()
        try:
            for name, media_type, payload, expected in cases:
                connection = HTTPConnection("127.0.0.1", server.server_port, timeout=3)
                try:
                    connection.request("POST", "/probe", body=payload,
                                       headers={"Content-Type": media_type})
                    response = connection.getresponse()
                    body = response.read().decode("utf-8")
                    result = {"case": name, "status": response.status,
                              "body": body, "expected_status": expected}
                    results.append(result)
                    if response.status != expected:
                        raise AssertionError(result)
                finally:
                    connection.close()
        finally:
            server.shutdown()
            worker.join(timeout=3)
    print(json.dumps({"python": platform.python_version(), "bind": "127.0.0.1",
                      "status": "PASS", "results": results}, indent=2))


if __name__ == "__main__":
    main()

출력 JSON에서 status: PASS와 각 status를 확인한다. 예상 상태와 다르면 예외가 발생하며 프로세스가 실패한다. 이 PASS는 위 다섯 입력을 이 수신기가 의도대로 처리했다는 뜻이다.

실제 Spring 연동으로 돌아왔을 때의 다음 판단

명세가 바이트 전문을 요구하면 먼저 전송 직전 데이터의 길이·문자 인코딩·필드 구분을 확인한다. HTTP 헤더를 바꾸는 것과 객체를 바이트 배열로 직렬화하는 것은 별개다. 운영 토큰이나 전문 원문을 공유하는 대신, 승인된 테스트 데이터의 바이트 길이와 기대 형식이 일치하는지 비교한다.

실제 호출에서 415 대신 400 또는 다른 응답이 나왔다면 “방화벽 문제로 바뀌었다”고 결론 내리지 않는다. 어떤 구성 요소가 그 응답을 만들었는지 상관관계 ID와 서버 기록으로 확인하고, 해당 단계의 명세와 대조한다. 응답 없이 시간 초과가 났다면 위 시험으로는 원인을 알 수 없다. 컨테이너 건강 검사도 같은 원리로, 연결 성공·HTTP 응답·업무 성공을 각각 판정해야 한다.

9. 진단 기능을 만들다가 새 위험을 만들지 않는다

서버 접속 권한이 없다고 누구나 임의의 호스트·포트를 입력하는 진단 API를 열면 안 된다. 서버가 대신 내부 목적지에 접근하는 기능이 될 수 있다. OWASP도 SSRF 방어에서 허용 대상과 네트워크 경계 통제를 강조한다. OWASP SSRF 예방

꼭 관리 기능이 필요하다면 대상은 서버 설정에서 승인된 고정 목록으로 관리하고, 사용자 입력은 그 목록의 식별자만 선택하게 한다. 관리자 인증·권한·호출 빈도 제한·감사 기록·폐기 시점도 정한다. HTTP 진단에 리디렉션을 추가하면 변경된 목적지도 통제해야 한다.

이번 예제에는 그런 웹 API를 제공하지 않았다. 실제 연동 테스트에서도 원인을 모르는 상태로 운영 전문을 반복 전송하거나 인증서 검증을 끄지 않는다. 응답이 없었다고 업무가 실행되지 않았다는 보장은 없으므로, 쓰기 요청의 재시도는 멱등성과 결과 조회 절차를 먼저 정해야 한다.

10. 담당자에게 전달할 최소 점검 기록

다음은 실측 결과가 아니라 기록 양식이다. 주소·네트워크 정보는 승인된 담당자에게만 전달하고 공개 글에는 비식별화한다.

확인 시각 및 시간대:
실행 위치(서버/컨테이너):
승인된 목적지 식별자:
선택된 대상 주소와 포트:
직접 연결/프록시 경유:
DNS 결과:
TCP 연결 결과:
TLS 또는 HTTP 확인 여부:
HTTP 상태 또는 예외 분류:
상관관계 ID:
미확인 범위:

“방화벽 열어 주세요”보다 “이 실행 위치에서 이 목적지로 TCP 연결 단계가 실패했고 HTTP 응답은 아직 확인하지 못했다”가 더 정확한 요청이다. 연결 성공·프로토콜 성공·업무 성공을 분리하면 불필요한 설정 변경과 반복 호출을 줄일 수 있다.

태그

mLog

웹 개발과 서버 운영 과정에서 마주친 문제와 해결 과정을 기록합니다. 직접 확인한 설정과 실행 결과를 함께 정리해, 비슷한 문제를 겪는 분들이 참고할 수 있도록 합니다.