티스토리 뷰

오픈소스에 처음 기여를 시작할 때 가장 막막했던 건 "무엇을 고쳐야 하는가"였습니다. 큰 기능을 새로 만들 자신은 없었고, 그렇다고 오타 하나만 고치기에는 뭔가 아쉬웠습니다. 그래서 택한 방법은 이미 잘 돌아가는 코드 안에서 "한쪽만 다르게 동작하는 부분"을 찾는 것이었습니다. 이번에 다룰 기여(#8497)도 그렇게 찾은 것입니다. OpenTelemetry Java의 Prometheus exporter에서, 같은 메서드가 속성의 위치에 따라 배열 값을 서로 다른 형식으로 내보내던 문제였습니다.

PR 링크를 먼저 남겨 둡니다. https://github.com/open-telemetry/opentelemetry-java/pull/8497 에서 실제 변경과 리뷰 과정을 볼 수 있습니다.

Prometheus exporter가 속성을 라벨로 바꾸는 지점

OpenTelemetry는 메트릭에 여러 속성을 붙일 수 있습니다. 이 속성은 세 군데에서 옵니다. 측정 지점(point)에 직접 붙는 속성, 계측 스코프(instrumentation scope)에 붙는 속성, 그리고 리소스(resource)에 붙는 속성입니다. Prometheus는 이런 속성을 전부 메트릭의 라벨로 표현합니다. 그래서 exporter는 이 셋을 한데 모아 라벨 이름과 값의 쌍으로 바꿔야 합니다.

이 변환은 exporters/prometheus 모듈의 Otel2PrometheusConverter.convertAttributes() 메서드가 맡습니다. 이 메서드는 point 속성, scope 속성, resource 속성을 차례로 훑으면서 labelNameToValue 맵에 채워 넣습니다. 문제는 값을 문자열로 바꾸는 방식이 경로마다 달랐다는 점입니다.

 

point 속성 경로는 toLabelValue(key.getType(), value)라는 헬퍼를 거칩니다. 이 헬퍼는 속성 타입을 보고 분기합니다. 문자열·불리언·정수·실수 같은 스칼라 값은 그냥 toString() 결과를 돌려주지만, 배열 타입(STRING_ARRAY 등)일 때는 ["a","b"] 같은 JSON 문자열로 인코딩합니다. 반면 scope 속성과 resource 속성 경로는 타입을 보지 않고 값에 곧바로 Object.toString()을 불렀습니다.

배열이 스펙과 어긋난 모습으로 나가고 있었습니다

OpenTelemetry 속성의 배열은 내부적으로 자바 List로 담깁니다. List에 그냥 toString()을 부르면 [a, b]가 나옵니다. 쉼표 뒤에 공백이 붙고, 원소를 감싸는 따옴표가 없고, 원소 안에 쉼표나 따옴표, 제어문자가 들어 있어도 escape되지 않습니다. 즉 유효한 JSON이 아닙니다.

 

OpenTelemetry의 Prometheus 호환 규약은 문자열이 아닌 속성 값을 속성 스펙의 문자열 표현 규칙에 따라 바꾸라고 하고, 그 규칙은 배열을 JSON 배열로 적으라고 합니다. 같은 문서의 계측 스코프 절은 scope 속성도 이 규칙을 그대로 따라 라벨로 만들라고 적어 두었습니다. point 속성 경로는 이미 이 형식에 맞춰져 있었습니다. 이 정렬은 이전 PR(#7291)에서 이뤄졌고, 그 PR은 이슈 #5987("Fix serialization of arrays in prometheus exporter")를 겨냥한 것이었습니다. 그런데 그 수정이 point 경로만 손보고 scope·resource 경로는 놓쳤습니다. 이슈 #5987이 계속 열려 있던 이유이기도 합니다.

 

결과적으로 같은 converter가, 같은 배열 값을, 속성이 어디에 붙었느냐에 따라 다르게 내보내고 있었습니다. point에 붙으면 ["a","b"], scope나 resource에 붙으면 [a, b]였습니다. 이건 취향 차이로 남겨 둔 게 아니라 한 PR이 절반만 고치고 지나간 자리였습니다. 이슈나 이전 PR 어디에도 scope·resource를 일부러 다르게 두겠다는 메인테이너의 언급이 없었다는 점을 확인하고 나서야, 고쳐도 되는 문제라고 판단했습니다.

 

고침은 두 줄, 판단은 그보다 오래

수정 자체는 짧습니다. scope 경로와 resource 경로에서 toString()을 부르던 자리를, point 경로가 이미 쓰던 toLabelValue(...)로 바꾸는 것뿐입니다.

// scope 속성 (수정 전)
labelNameToValue.putIfAbsent(
    OTEL_SCOPE_ATTRIBUTE_PREFIX + key.getKey(), value.toString());

// scope 속성 (수정 후)
labelNameToValue.putIfAbsent(
    OTEL_SCOPE_ATTRIBUTE_PREFIX + key.getKey(),
    toLabelValue(key.getType(), value));

resource 경로도 같은 방식으로 attributeValue.toString()을 toLabelValue(attributeKey.getType(), attributeValue)로 바꿨습니다. 두 경로 모두 반복문이나 람다가 이미 AttributeKey를 손에 쥐고 있어서, point 경로와 똑같이 key.getType()으로 타입 정보를 바로 얻을 수 있었습니다. 새 메서드를 만들거나 추상화를 더할 필요가 없었습니다.

 

여기서 신경 쓴 건 회귀였습니다. toLabelValue는 스칼라 타입에 대해서는 여전히 toString() 결과를 돌려줍니다. 그래서 문자열·불리언·정수·실수 속성의 출력은 바뀌지 않습니다. 실제로 형식이 달라지는 건 배열 타입뿐이고, 그것도 스펙에 맞는 방향으로만 바뀝니다. 변경의 영향 범위를 배열 속성 하나로 좁게 묶어 둘 수 있다는 점이, 이 수정을 안심하고 올릴 수 있게 해 준 근거였습니다.

 

테스트로 "고치기 전엔 실패, 후엔 통과"를 남기기

OpenTelemetry Java는 PR마다 CI가 커버리지 변화를 보고하고, 덜 덮인 부분이 있으면 리뷰어가 그쪽을 더 들여다봅니다. 저장소 규칙을 익히면서 배운 원칙 가운데 하나는, 버그 수정이라면 그 버그를 재현하는 테스트를 먼저 만들어 두는 것이었습니다. 고치기 전에는 실패하고 고친 뒤에는 통과하는 테스트가 있어야, 변경이 실제로 문제를 해결했다는 증거가 남습니다.

 

그래서 두 경로를 각각 덮는 테스트를 Otel2PrometheusConverterTest에 추가했습니다. 하나는 배열 값을 가진 scope 속성이 otel_scope_foo 라벨에서 ["a","b"]로 나오는지 단언하는 arrayValuedScopeAttributeSerializedAsJson 테스트입니다. 다른 하나는 리소스 속성 필터를 통과한 배열 값이 라벨에서 JSON으로 나오는지 확인하는 케이스를, 기존 파라미터화 테스트 묶음에 인자 하나로 더한 것입니다. 두 경로 중 한쪽만 덮으면 나머지 절반이 다시 빠질 수 있어서, scope와 resource를 모두 넣는 걸 조건으로 두었습니다. 이 프로젝트는 JUnit5와 AssertJ를 쓰기 때문에, 단언은 assertThat(...).isEqualTo(...) 형태로 맞췄습니다.

 

빌드는 JDK 21 이상을 요구하지만 산출물은 Java 8과 호환되어야 합니다. 테스트 코드에도 Java 9 이상의 API를 쓰면 안 된다는 제약이 있어서, 익숙한 최신 문법을 무심코 넣지 않도록 조심했습니다. 모듈 단위로 :exporters:prometheus:check를 돌려 테스트와 포맷 검사를 통과시킨 뒤에 정리했습니다.

 

공개 API를 건드리지 않는 변경이라는 점

이 저장소에서 stable 아티팩트의 공개 API를 바꾸면 japicmp가 빌드를 막고, 바뀐 내용을 docs/apidiffs 아래 파일로 함께 커밋해야 합니다. 그래서 손대는 코드가 공개 표면에 닿는지부터 확인하는 습관이 필요했습니다. 확인해 보니 exporters/prometheus는 모듈의 gradle.properties에 otel.release=alpha가 적혀 있어 -alpha 버전으로 배포되는 모듈이었습니다. alpha 아티팩트에는 호환성 보장이 없고, 빌드 설정도 이 속성이 있는 모듈에는 japicmp 검사를 걸지 않습니다. 그와 별개로 이번 수정은 convertAttributes()라는 private 메서드 안에서 헬퍼 호출만 바꾼 것이라 공개 시그니처가 그대로였고, 커밋할 API diff도 생기지 않았습니다.

 

대신 사용자에게 보이는 출력이 달라지는 변경이라, CHANGELOG.md의 미배포(Unreleased) 항목에 Prometheus exporter의 배열 scope·resource 속성을 JSON 문자열로 직렬화하도록 고쳤다는 한 줄을 남겼습니다. 배열 속성을 라벨로 쓰던 사용자라면 출력 형식이 [a, b]에서 ["a","b"]로 바뀌기 때문입니다. 이 항목을 어떻게 쓸지 보려고 앞선 #7291을 찾아봤는데, 그 PR이 바꾼 파일에는 CHANGELOG가 없었습니다. 대신 1.51.0 릴리스 노트에 "Prometheus: fix serialization of arrays" 한 줄이 PR 링크와 함께 들어가 있었습니다. 이 저장소는 릴리스를 정리하면서 항목을 채우는 쪽에 가까웠고, 제가 넣은 줄도 1.65.0 릴리스 노트에 PR 링크가 붙은 형태로 남았습니다.

 

이 프로젝트가 OpenTelemetry Specification을 구현하는 핵심 컴포넌트라는 점도 판단에 영향을 줬습니다. 스펙에 없는 새 동작을 끼워 넣는 변경이었다면 먼저 스펙 쪽에 제기해야 했을 것입니다. 하지만 이번 건은 스펙이 이미 안내하는 형식으로 맞추는 수정이었고, 같은 메서드 안의 형제 경로가 그 형식을 이미 구현하고 있었습니다. 그래서 새 결정을 내리는 기여가 아니라, 이미 정해진 방향에서 빠진 자리를 메우는 기여로 정리할 수 있었습니다.

 

남은 것

돌아보면 이 기여에서 어려웠던 부분은 코드를 고치는 일이 아니었습니다. "이게 정말 버그인가, 아니면 누군가 의도한 것인가"를 가리는 일이었습니다. 관련 이슈와 이전 PR을 읽고, 같은 파일 안의 형제 경로가 어떻게 동작하는지 대조하고, 메인테이너가 다르게 두겠다고 말한 흔적이 없는지 확인하는 과정이 실제 diff 두 줄보다 오래 걸렸습니다.

 

작은 변경일수록 근거를 촘촘히 챙겨야 리뷰어가 빠르게 받아들일 수 있다는 걸 이번에 배웠습니다. 같은 메서드 안에서 형식이 갈리는 코드를 발견했을 때, 그 자리가 정답을 이미 보여 주고 있었던 셈입니다. 앞으로도 새로운 걸 만들기 전에, 이미 있는 코드가 스스로 알려 주는 불일치부터 살펴보려 합니다.