OPEN SOURCE

[Open source contribution] OpenTelemetry-Java 오픈소스 기여 경험기 - 검증을 통과하던 host 없는 엔드포인트 막기

ebson 2026. 7. 18. 14:55

OpenTelemetry Java 저장소에 몇 건의 변경을 올리면서, 저는 큰 기능을 만드는 것보다 이미 있는 코드가 스스로 약속한 것을 지키고 있는지 확인하는 쪽이 초보 기여자에게 더 맞는 일이라는 생각을 하게 됐습니다. 이번 글에서 다룰 PR #8489는 그런 종류의 변경입니다. 프로덕션 코드 네 줄과 테스트 파일 하나가 전부지만, 그 네 줄에 도달하기까지 확인해야 했던 것들이 저에게는 더 남았습니다.

 


엔드포인트를 검증한다는 말의 의미

OpenTelemetry Java에서 exporter는 수집한 텔레메트리를 OTLP 같은 프로토콜로 백엔드에 보내는 역할을 합니다. 이때 어디로 보낼지를 지정하는 값이 엔드포인트고, 보통 http://localhost:4318 같은 문자열로 들어옵니다. exporters/common 모듈의 EndpointUtil.validateEndpoint(String)은 그 문자열을 받아 java.net.URI로 파싱한 뒤 검사해서 돌려주는 유틸리티입니다.

 

제가 이 메서드를 들여다보기 시작한 계기는 특별하지 않았습니다. exporters/common은 소스가 마흔 개 남짓이고, 그중에서도 이름에 Util이 붙은 것들은 계약이 짧고 분명해서 읽기 편했습니다. 직렬화 쪽 코드는 protobuf 원본 알고리즘을 그대로 옮긴 부분이 많아 제가 판단하기에 어려웠지만, 입력 검증은 "무엇을 받고 무엇을 거절하는가"만 보면 되니까 초보자가 붙어볼 만하다고 느꼈습니다.

당시 코드는 이런 모양이었습니다.

public static URI validateEndpoint(String endpoint) {
  URI uri;
  try {
    uri = new URI(endpoint);
  } catch (URISyntaxException e) {
    throw new IllegalArgumentException("Invalid endpoint, must be a URL: " + endpoint, e);
  }

  if (uri.getScheme() == null
      || (!uri.getScheme().equals("http") && !uri.getScheme().equals("https"))) {
    throw new IllegalArgumentException(
        "Invalid endpoint, must start with http:// or https://: " + uri);
  }
  return uri;
}

읽어보면 검사는 두 가지입니다. 문자열이 URI 문법에 맞는지, 그리고 scheme이 http나 https인지. 에러 메시지는 "must start with http:// or https://"라고 말합니다. 여기서 제가 걸린 지점은 메시지와 실제 검사가 미묘하게 다르다는 것이었습니다. 메시지는 http://로 시작해야 한다고 말하지만, 코드가 실제로 보는 것은 scheme이 http인지 여부이지 뒤에 //가 붙었는지가 아니었습니다.


슬래시 두 개가 없어도 통과하는 문자열

의심이 들어 확인해본 것은 java.net.URI가 슬래시 없는 문자열을 어떻게 파싱하는지였습니다.

URI uri = new URI("http:localhost:4317");
uri.getScheme();    // "http"
uri.isOpaque();     // true
uri.getHost();      // null
uri.getAuthority(); // null

URI는 RFC 3986의 문법을 따르는 클래스라서, scheme: 뒤에 슬래시 두 개가 오지 않으면 나머지 전체를 scheme-specific part로 보고 opaque URI로 취급합니다. mailto:someone@example.com이 그런 형태입니다. 즉 http:localhost:4317은 문법적으로 문제가 없는 URI이고, scheme도 정확히 http입니다. 위 검증의 두 조건을 모두 통과합니다. 그런데 host는 null입니다. 보낼 곳이 없는 엔드포인트가 검증을 통과해 그대로 반환되는 셈입니다.

 

슬래시가 하나만 있는 https:/foo도 확인해봤습니다. 이쪽은 opaque가 아니라 hierarchical URI로 파싱되지만, authority 부분이 없어서 역시 host가 null이었습니다. 형태는 다르지만 결과는 같았습니다.

 

이 값들이 어디까지 흘러가는지도 따라가 봤습니다. EndpointUtil.validateEndpoint를 호출하는 곳은 HttpExporterBuilder.setEndpoint(), GrpcExporterBuilder.setEndpoint(), 그리고 JaegerRemoteSamplerBuilder.setEndpoint()였습니다. 셋 다 검증을 통과한 URI를 그대로 필드에 저장합니다. 결국 사용자가 오타로 슬래시를 빠뜨리면 빌더를 만드는 시점에는 아무 일도 일어나지 않고, 한참 뒤 실제로 export를 시도할 때 가서야 실패하게 됩니다. 설정이 잘못됐다는 신호가 설정하는 자리가 아니라 먼 곳에서 나타나는 것이 이 결함의 실제 모습이었습니다.

 


이게 버그라고 말할 근거를 찾기

여기까지는 "동작이 이상해 보인다"에 불과했고, 저는 이게 정말 결함인지 아니면 의도된 관대함인지 판단할 근거가 필요했습니다. 이 저장소는 OpenTelemetry Specification을 구현하는 곳이라, 스펙에 없는 동작을 제 취향으로 바꾸는 변경은 스펙 리포에 먼저 이야기를 꺼내는 것이 순서입니다. 반대로 코드가 이미 하기로 되어 있던 일을 못 하고 있는 것이라면 버그 수정으로 다룰 수 있습니다. 그 구분이 이 변경을 올려도 되는지를 가르는 기준이었습니다.

 

처음 눈이 간 곳은 이름이 거의 같은 형제 메서드였습니다. exporters/otlp/all 모듈의 OtlpConfigUtil.validateEndpoint(String, boolean)는 URI가 아니라 java.net.URL을 씁니다.

URL endpointUrl;
try {
  endpointUrl = new URL(endpoint);
} catch (MalformedURLException e) {
  throw new ConfigurationException("OTLP endpoint must be a valid URL: " + endpoint, e);
}
if (!endpointUrl.getProtocol().equals("http") && !endpointUrl.getProtocol().equals("https")) {
  throw new ConfigurationException(
      "OTLP endpoint scheme must be http or https: " + endpointUrl.getProtocol());
}

URL은 URI와 달리 프로토콜별 핸들러가 문자열을 해석합니다. localhost는 "no protocol"로, gopher://localhost는 "unknown protocol"로 각각 MalformedURLException이 됩니다. 그래서 저는 처음에 URL 쪽이 더 엄격한 기준이니 여기에 기대면 되겠다고 생각했습니다.

 

그런데 직접 돌려보니 아니었습니다. new URL("http:localhost:4317")은 예외를 던지지 않습니다. protocol은 http, host는 null이 아니라 빈 문자열, path는 localhost:4317이 됩니다. URL도 authority가 없는 문자열을 그 자체로 막아주지는 않는 것입니다. 형제 메서드가 이 값을 어디서 걸러내는지는 뒤따르는 다른 검사에 달려 있었고, 결국 "형제가 이미 거부하니 이쪽도 거부해야 한다"는 근거는 세울 수 없었습니다. 형제 코드가 다르게 생겼다는 사실만으로는 이쪽이 버그라고 말할 수 없다는 것을, 확인해보고 나서야 알았습니다.

근거가 된 것은 결국 메서드 자신이 이미 하고 있던 말이었습니다. scheme 검사가 실패할 때 던지는 메시지는 "must start with http:// or https://"입니다. 그런데 http:localhost:4317은 http://로 시작하지 않는데도 통과합니다. 메시지가 약속한 것과 코드가 실제로 검사하는 것이 어긋나 있었습니다.

 

테스트도 같은 이야기를 하고 있었습니다. exporters/otlp/testing-internal의 AbstractHttpTelemetryExporterTest에는 잘못된 설정을 확인하는 테스트가 있는데, localhost와 gopher://localhost가 정확히 그 "must start with http:// or https://" 메시지와 함께 거부된다고 단언합니다. 같은 단언이 AbstractGrpcTelemetryExporterTest와 JaegerRemoteSamplerTest에도 있습니다. 이 메서드의 계약이 "실제로 요청을 보낼 수 있는 http(s) 주소만 받는다"라는 것을 테스트가 이미 코드로 표현하고 있었던 셈입니다. host 없는 URI는 그 계약에서 빠진 케이스였습니다.

 

혹시 같은 수정을 누가 이미 올려두지 않았는지도 확인했습니다. 열린 PR을 엔드포인트 검증 키워드로 검색해봤지만 걸리는 것은 없었습니다. 중복 작업으로 리뷰어의 시간을 쓰는 일은 피하고 싶었습니다.


두 줄로 줄이기까지

처음 제가 적어둔 가드는 이런 모양이었습니다.

if (uri.isOpaque() || uri.getHost() == null) {

opaque URI가 문제라는 것을 먼저 발견했으니 그것부터 막고, 혹시 몰라 host 검사를 덧붙인 형태였습니다. 그런데 다시 확인해보니 https:/foo는 isOpaque()가 false인데 host는 null이었습니다. 즉 isOpaque()는 host가 없다는 것을 보장해주지 못하고, 반대로 제가 막고 싶었던 두 케이스는 getHost() == null 하나로 전부 걸러집니다. 앞의 조건은 아무것도 추가로 잡아주지 못하면서 읽는 사람에게 "opaque와 host-null이 서로 다른 두 가지 문제인가"라는 질문만 남기는 조건이었습니다.

 

최종적으로 들어간 변경은 이렇습니다.

    if (uri.getHost() == null) {
      throw new IllegalArgumentException(
          "Invalid endpoint, must start with http:// or https://: " + uri);
    }
    return uri;

메시지는 바로 위 scheme 검사와 같은 문장을 그대로 씁니다. 새 메시지를 만들면 사용자 입장에서는 "http로 시작하는데 왜 http로 시작하라고 하지"와 "host가 없다"는 두 가지 다른 안내를 받게 되는데, 사실 두 경우 모두 사용자가 해야 할 일은 같습니다. http://로 시작하는 온전한 주소를 쓰는 것입니다. 기존 메시지의 문구가 원래 의도했던 것도 그 안내였다고 보고, 새로 만드는 대신 재사용했습니다. 문자열 보간도 기존 가드가 uri를 쓰고 있어서 같은 방식으로 맞췄습니다.

 

작게 유지하려고 신경 쓴 부분이 하나 더 있습니다. 이 파일을 읽으면서 손대고 싶은 지점이 몇 군데 눈에 띄었지만, 지금 고치려는 문제와 관계없는 변경은 넣지 않았습니다. 기여 문서가 변경을 작게 유지하고 리팩터링과 동작 변경을 한 PR에 섞지 말라고 안내하기도 하고, 리뷰어 입장에서 무엇을 봐야 하는지가 흐려지는 것이 실제로 손해라고 생각했습니다. 포맷은 ./gradlew :exporters:common:spotlessApply로 맞추는 것으로 충분했습니다.


테스트를 어디에 둘 것인가

처음에는 이미 이 검증을 단언하고 있는 OTLP 쪽 테스트에 케이스를 몇 줄 더하는 방법을 생각했습니다. 그런데 다시 보니 어색했습니다. 결함이 사는 곳은 exporters/common인데 그 모듈을 검증하는 테스트가 다른 모듈에 있는 모양이 되기 때문입니다. exporters/common에는 EndpointUtil을 직접 겨냥한 테스트가 없었으니, 없던 것을 만드는 쪽이 맞다고 판단했습니다.

 

EndpointUtilTest는 JUnit 5의 파라미터라이즈드 테스트와 AssertJ로 썼습니다. 이 저장소는 테스트에서 JUnit 5 + AssertJ를 쓰고, 프로덕션 코드뿐 아니라 테스트 코드도 Java 8 호환이어야 합니다. 빌드에는 JDK 21 이상이 필요하지만 산출물이 Java 8을 타깃으로 하기 때문입니다.

  @ParameterizedTest
  @MethodSource("invalidEndpoints")
  void validateEndpoint_invalid(String endpoint) {
    assertThatThrownBy(() -> EndpointUtil.validateEndpoint(endpoint))
        .isInstanceOf(IllegalArgumentException.class)
        .hasMessageContaining("must start with http:// or https://");
  }

  private static Stream<Arguments> invalidEndpoints() {
    return Stream.of(
        Arguments.argumentSet("opaque, no host", "http:localhost:4317"),
        Arguments.argumentSet("single slash, no host", "https:/foo"),
        Arguments.argumentSet("no scheme", "localhost"),
        Arguments.argumentSet("wrong scheme", "gopher://localhost"));
  }

거부되는 케이스만 넣지 않고 통과해야 하는 케이스도 함께 넣었습니다. http://localhost:4318, https://localhost:4317, 경로가 붙은 http://localhost:4318/v1/traces, 그리고 userinfo가 붙은 http://foo:bar@localhost:4317/path입니다. 마지막 것을 넣은 이유는 제 가드가 host를 보는 조건이라 authority에 사용자 정보가 섞인 형태를 잘못 걸러낼 가능성이 있는지 확인하고 싶어서였습니다. URI는 userinfo와 host를 분리해서 파싱하므로 host는 정상적으로 localhost가 나오고 통과합니다. 새 검사가 막아야 할 것만 막고 기존에 통과하던 입력은 그대로 통과한다는 것을 보여주는 쪽이, 거부 케이스만 나열하는 것보다 리뷰어에게 필요한 정보라고 생각했습니다.

 

검증은 ./gradlew :exporters:common:check로 돌렸습니다. 이 저장소에서 check는 테스트만 도는 것이 아니라 스타일 검사와 ErrorProne/NullAway, japicmp까지 함께 도는 관문이라, 모듈 단위로 이것 하나만 통과시키면 로컬에서 확인할 수 있는 것은 대체로 확인한 셈이 됩니다. 저장소 전체 빌드는 로컬에서 돌리지 않는 것이 안내된 방식이고 CI가 그 몫을 합니다.


internal 패키지라는 것의 무게

이 변경을 준비하면서 처음으로 제대로 이해하게 된 것이 internal 패키지였습니다. EndpointUtil은 io.opentelemetry.exporter.internal 패키지에 있고, 클래스 Javadoc에는 이런 문장이 붙어 있습니다.

/**
 * Utilities for validating exporter endpoints.
 *
 * <p>This class is internal and is hence not for public use. Its APIs are unstable and can change
 * at any time.
 */

이 고지 문구는 손으로 잘 붙이자고 정해둔 관례가 아니라 빌드 과정의 커스텀 ErrorProne 검사가 강제하는 것입니다. 그리고 VERSIONING.md에서 internal 패키지는 semver 보장 대상에서 빠집니다. 클래스가 public이어도 저장소 바깥에서 쓰라고 만든 것이 아니라는 뜻입니다.

 

이 사실이 제 변경에 준 영향은 구체적이었습니다. 이 저장소에서 공개 API가 바뀌면 docs/apidiffs/current_vs_latest/ 아래 파일이 다시 만들어지고 그 diff를 PR에 함께 커밋해야 하는데, 제 변경은 메서드 본문에만 손을 댔으니 시그니처가 그대로여서 apidiff에 변동이 없었습니다. 파일이 안 바뀐 것이 빠뜨린 게 아니라 정상이라는 것을 스스로 설명할 수 있어야 했습니다. internal이라는 것도 여기에 함께 작용합니다. 만약 같은 성격의 변경이 stable 아티팩트의 공개 표면에 있었다면 japicmp가 빌드를 세웠을 테고, 이야기는 훨씬 길어졌을 것입니다.


동작 변경이라는 점을 숨기지 않기

정직하게 말하면 이 변경은 순수한 오타 수정과 다릅니다. 어제까지 통과하던 입력이 오늘부터 예외를 던지게 되는 변경입니다. internal 패키지라 semver 관점에서는 문제가 되지 않지만, 그것과 별개로 리뷰어가 판단할 때 알아야 하는 사실이라고 생각했습니다.

 

그래서 PR 본문에 그 점을 한 줄로 적었습니다. host 없는 엔드포인트가 이전에는 scheme 검증을 통과했지만 이제 거부된다고 썼고, 거부되는 값들이 어차피 연결할 host가 없어 export가 불가능한 값이라는 것을 근거로 함께 적었습니다. 이걸 빼고 "검증 강화"라고만 썼다면 리뷰어가 스스로 알아내야 했을 것이고, 그건 제가 아껴야 할 시간을 리뷰어에게 떠넘기는 일이었습니다. CHANGELOG는 넣지 않았는데, 사용자에게 노출되는 설정이나 기본값이 바뀐 것이 아니라고 봤기 때문입니다.

 

PR 제목은 "Reject host-less endpoints in EndpointUtil.validateEndpoint"로 붙였습니다. 이 저장소는 squash merge를 쓰기 때문에 PR 제목이 그대로 main의 커밋 메시지가 됩니다. 나중에 누군가 git log를 훑을 때 무엇이 바뀌었는지 한 줄로 읽히는 영어 명령형 문장이어야 한다는 것을, 저는 다른 PR들의 제목을 보면서 익혔습니다.


남은 것

돌아보면 이 기여에서 실제로 코드를 쓴 시간은 짧았습니다. 대부분의 시간은 "이게 정말 버그인가"를 스스로 납득하는 데 썼습니다. 그리고 처음에 근거로 삼으려던 것은 빗나갔습니다. 형제 validator가 URL을 쓰니 더 엄격할 것이라고 짐작했지만, 직접 돌려보니 URL도 host 없는 문자열을 그대로 받아들였습니다. 형제 코드가 다르게 생겼다는 것은 출발점이 될 뿐이고, 그 자체로는 어느 쪽이 맞는지 말해주지 않았습니다.

 

결국 근거가 된 것은 그 메서드가 스스로 약속한 말과 기존 테스트가 적어둔 계약이었습니다. 에러 메시지가 "must start with http:// or https://"라고 말하는데 http://로 시작하지 않는 값이 통과한다면, 그건 취향 문제가 아니라 코드가 자기 말을 못 지키고 있는 것입니다. 어떤 코드가 스스로 무엇을 약속했는지는 대개 그 옆이나 그 아래에 남아 있고, 짐작 대신 그걸 찾아 대조하는 일이 초보 기여자가 할 수 있는 가장 확실한 검증이라는 걸 배웠습니다.

 

또 하나는 조건 하나를 지우는 일에도 근거가 필요하다는 점이었습니다. isOpaque()를 뺀 것은 코드가 짧아 보여서가 아니라, 실제로 확인해보니 그 조건이 잡아주는 케이스가 없었기 때문입니다. 무언가를 넣을 때만 근거를 대고 뺄 때는 대충 넘어가기 쉬운데, 남는 조건 하나가 다음 사람에게는 "이건 왜 있지"라는 질문이 됩니다.

 

큰 기능을 만든 것도 아니고 대단한 성능 개선을 한 것도 아닙니다. 다만 잘못된 설정이 조용히 지나가던 자리에서 바로 멈추게 됐고, 저는 그 과정에서 URI와 URL의 파싱이 왜 다른지, internal 패키지가 무엇을 면제받고 무엇을 면제받지 못하는지, 동작이 바뀌는 변경을 어떻게 설명해야 하는지를 몸으로 익혔습니다. 다음에 비슷한 코드를 만나면 조금 더 빨리 의심할 수 있을 것 같습니다. 지금은 그 정도로 충분하다고 생각합니다.