OPEN SOURCE

[Open source contribution] OpenTelemetry-Java 오픈소스 기여 경험기 - 공개 API 오버로드는 누가 테스트하는가 — 기여로 배운 커버리지

ebson 2026. 7. 17. 15:02

오픈소스에 기여해 보고 싶다는 생각은 오래 했지만, 막상 시작은 늘 미뤘습니다. 큰 저장소를 열면 어디부터 봐야 할지 막막했고, 내가 고칠 만한 자리가 있을까 싶었습니다. 그러다 OpenTelemetry Java(open-telemetry/opentelemetry-java)를 골라 한 모듈씩 천천히 들여다보기로 했습니다. 모든 코드를 다 읽겠다는 욕심은 처음부터 버렸습니다. 대신 테스트 코드부터 읽었습니다. 테스트는 그 모듈이 무엇을 보장하려 하는지 가장 솔직하게 보여 주는 자리라고 생각했기 때문입니다.

 

이 글은 그렇게 찾은 작은 기여 한 건(#8483)을 정리한 기록입니다. 바뀐 코드는 단 한 줄이지만, 그 한 줄을 올리기까지 저장소의 구조와 규칙을 익히며 배운 것이 적지 않았습니다.


형제 테스트가 서로 다르게 생겼다

처음 눈에 걸린 건 :api:incubator 모듈의 한 테스트였습니다. ExtendedDefaultLoggerTest는 no-op 로거가 isEnabled를 어떻게 답하는지 확인하면서, 두 가지 호출 형태를 모두 단언하고 있었습니다.

assertThat(logger.isEnabled(Severity.ERROR, Context.current())).isFalse();
assertThat(logger.isEnabled(Severity.ERROR)).isFalse();

Logger에는 isEnabled 오버로드가 둘 있습니다. 하나는 Context를 함께 받는 isEnabled(Severity, Context)이고, 다른 하나는 현재 컨텍스트를 가정하는 isEnabled(Severity)입니다. 둘 다 @since 1.61.0으로, #8200 "Stabilize enabled api"에서 stable로 자리 잡은 공개 API입니다. 인터페이스 정의를 보면 단일 인자 쪽은 두 인자 쪽으로 넘기는 기본 구현을 갖고 있습니다.

 

default boolean isEnabled(Severity severity) {
  return isEnabled(severity, Context.current());
}

그런데 같은 동작을 검증하는 다른 자리, 그러니까 여러 구현이 공유하는 테스트 베이스 AbstractDefaultLoggerTest를 열어 보니 모양이 달랐습니다. 두 인자 오버로드만 단언하고 단일 인자 쪽은 빠져 있었습니다.

 

@Test
void defaultIsEnabled() {
  assertThat(getLogger().isEnabled(Severity.ERROR, Context.root())).isFalse();
}

같은 인터페이스의 같은 기능을, 한쪽 테스트는 두 형태 다 확인하고 다른 쪽은 한 형태만 확인하고 있었습니다. 이 비대칭이 의도된 것인지, 아니면 그냥 빠진 것인지 확인해 보고 싶었습니다.


공유 베이스가 무엇을 책임지는지부터 이해하기

여기서 잠시 멈춰 모듈 구조를 살펴봐야 했습니다. OpenTelemetry Java는 애플리케이션이 호출하는 API와 그것을 실제로 구현하는 SDK를 나눠 둡니다. API는 SDK가 설치되기 전까지 아무 일도 하지 않는 no-op이 기본값입니다. 로그 신호에서 그 no-op 역할을 하는 것이 DefaultLogger입니다.

 

AbstractDefaultLoggerTest는 :api:testing-internal이라는 내부 테스트 유틸 모듈에 있는 추상 클래스입니다. 이 베이스가 no-op 로거가 지켜야 할 단언을 모아 두면, 각 모듈의 구체 테스트가 이 베이스를 상속해 자기 구현을 주입합니다. :api:all의 DefaultLoggerTest는 베이스를 상속하면서 getLogger()가 DefaultLogger를 돌려주도록 채워 둡니다.

class DefaultLoggerTest extends AbstractDefaultLoggerTest {
  @Override
  protected Logger getLogger() {
    return DefaultLogger.getInstance();
  }
}

:api:incubator의 ExtendedDefaultLoggerTest도 같은 베이스를 상속하되, 인큐베이터 쪽 확장 구현을 주입합니다. 즉 베이스에 단언을 한 줄 넣으면 그 단언은 베이스를 상속한 모든 구체 테스트에서 함께 실행됩니다. 반대로 베이스에 빠진 단언은, 인큐베이터 테스트가 자기 안에 따로 적어 두지 않는 한 :api:all의 DefaultLogger에는 영향을 주지 못합니다.

 

이 구조를 확인하고 나니 처음의 비대칭이 무엇을 뜻하는지 분명해졌습니다. 단일 인자 isEnabled(Severity)는 인큐베이터 테스트 안에는 직접 적혀 있어 확장 구현에서는 실행됐지만, 공유 베이스에는 없으니 :api:all의 DefaultLogger에 대해서는 어떤 구체 테스트로도 실행되지 않고 있었습니다. DefaultLogger는 두 인자 오버로드만 false로 재정의하고, 단일 인자는 인터페이스 기본 구현을 그대로 씁니다. 동작 자체는 false로 같지만, 그 경로가 테스트로 고정돼 있지는 않았던 셈입니다.


고친 것은 한 줄, 고민한 것은 그보다 많았다

수정은 베이스의 단언 한 줄을 더하는 일이었습니다.

@Test
void defaultIsEnabled() {
  assertThat(getLogger().isEnabled(Severity.ERROR, Context.root())).isFalse();
  assertThat(getLogger().isEnabled(Severity.ERROR)).isFalse();
}

이 한 줄로 단일 인자 오버로드가 베이스를 상속하는 모든 no-op 구현에서 false를 돌려준다는 사실이 검증되고, 인큐베이터 테스트에만 흩어져 있던 단언이 공유 베이스로 모이면서 형제 테스트 사이의 비대칭도 사라집니다.

 

여기서 스스로 경계한 부분이 있습니다. 이 변경은 버그를 잡은 것이 아닙니다. 프로덕션 코드는 손대지 않았고, 단일 인자 오버로드는 수정 전에도 이미 false를 돌려주고 있었습니다. 그러니 테스트는 수정 전에도 통과하고 수정 후에도 통과합니다. 빨강에서 초록으로 바뀌는 종류의 변화가 아니라, 지금까지 어떤 테스트도 실행하지 않던 공개 API 경로를 앞으로도 계속 확인하도록 고정해 두는 변화입니다. PR 본문에도 이 점을 그대로 적었습니다. 작은 변경을 실제 장애를 막은 것처럼 부풀리지 않는 편이 리뷰어에게도, 나중에 이 기록을 볼 저에게도 정직하다고 생각했습니다.


저장소의 규칙을 익히며 부딪힌 것들

가장 헷갈렸던 건 어떻게 검증하느냐였습니다. 처음에는 베이스가 들어 있는 모듈만 돌리면 될 줄 알고 :api:testing-internal:test를 실행했는데, 정작 제가 더한 단언은 실행되지 않았습니다. 베이스 클래스는 :api:testing-internal의 src/main에 있지만, 그 단언을 실제로 돌리는 구체 테스트는 :api:all과 :api:incubator의 src/test에 있기 때문입니다. 그래서 검증은 두 모듈을 따로 돌려야 했습니다.

./gradlew :api:all:test --tests "io.opentelemetry.api.logs.DefaultLoggerTest"
./gradlew :api:incubator:test --tests "io.opentelemetry.api.incubator.logs.ExtendedDefaultLoggerTest"

다음으로 신경 쓴 건 공개 API 호환성이었습니다. OpenTelemetry Java는 -alpha가 붙지 않은 아티팩트를 stable로 보고, 그 표면이 깨지면 japicmp가 빌드를 실패시킵니다. 공개 API가 바뀌면 docs/apidiffs 아래 diff를 PR에 함께 커밋해야 합니다. 이번 변경은 테스트 코드 한 줄이라 공개 시그니처를 건드리지 않았고, :api:testing-internal은 내부용 모듈이라 사용자에게 보이는 동작도 바뀌지 않습니다. 그래서 apidiff도 CHANGELOG도 필요 없었습니다. 다만 "이 변경이 정말 공개 표면을 건드리지 않는가"를 스스로 확인하는 과정 자체가, stable과 alpha를 나누고 internal 패키지를 따로 두는 이 프로젝트의 설계를 이해하는 계기가 됐습니다.

 

행정적인 절차도 처음에는 낯설었습니다. CNCF 프로젝트라 첫 PR에서는 EasyCLA 봇이 CLA 서명을 요구합니다. 또 이 저장소는 squash merge를 쓰기 때문에 PR 제목이 그대로 main의 커밋 메시지가 됩니다. 그래서 제목을 영어 명령형으로, 무엇을 했는지 한 문장으로 읽히게 다듬는 데 생각보다 시간을 들였습니다. 최종적으로는 "Assert single-arg Logger.isEnabled overload in shared default logger test"로 정했습니다.


작은 기여가 남긴 것

돌이켜 보면 이 기여에서 바뀐 코드는 한 줄이지만, 그 한 줄에 도달하기까지 한 일이 더 많았습니다. 형제 테스트를 나란히 놓고 차이를 의심해 본 것, 공유 베이스가 여러 모듈의 구현을 어떻게 함께 검증하는지 따라가 본 것, 그리고 내 변경이 어떤 보장에 영향을 주는지 직접 빌드를 돌려 확인한 것입니다.

 

한 가지 습관이 생겼습니다. 코드를 볼 때 "여기 형제가 있는가, 형제와 모양이 같은가"를 먼저 묻게 됐습니다. 같은 인터페이스를 검증하는 두 자리가 서로 다르게 생겼다면, 둘 중 하나는 빠뜨린 것이거나 의도가 따로 있는 것입니다. 어느 쪽이든 확인해 볼 가치가 있습니다. 이번에는 그 작은 차이가 공개 API 한 경로의 커버리지 공백으로 이어져 있었습니다.

 

거창한 기능을 만들어야만 기여가 되는 것은 아니라는 점도 배웠습니다. 이미 잘 관리되는 저장소일수록 큰 결함은 드물지만, 형제 사이의 작은 불일치는 남아 있곤 합니다. 그런 자리를 찾아 사실에 근거해 조심스럽게 고치는 일도 충분히 의미가 있다고, 이번 경험으로 조금 더 믿게 됐습니다. 다음에는 또 다른 모듈의 테스트부터 천천히 읽어 볼 생각입니다.