우연한 계기로 사용하던 도구와 프레임워크에서 마주친 버그를 직접 고치기 시작했고, 자신감이 붙으면서 오픈소스 기여를 하나의 꾸준한 활동으로 삼게 되었다.
이번 포스트를 통해 2026년 4월 동안 기여한 내용을 정리하고자 한다.
Ktor의 InetSocketAddress는 hostname: String + port: Int 생성자만 제공했다.
UDP 패킷 파싱이나 바이너리 프로토콜 구현에서는 이미 raw IP 바이트를 들고 있는 상황이 많은데, 이를 문자열로 변환했다가 다시 생성자에 넘기는 불필요한 오버헤드가 있었다.
ByteArray + Int 생성자를 expect 선언으로 추가하고, JVM/nonJvm 양쪽에서 4바이트(IPv4) 또는 16바이트(IPv6)만 허용하도록 검증한 뒤, 그 외 길이는 IllegalArgumentException을 던지도록 했다.
JVM에서는 검증된 바이트 배열을 java.net.InetAddress.getByAddress에 넘겨 java.net.InetSocketAddress를 만들고, Native/JS 쪽 nonJvm 구현에서는 바이트 배열을 IPv4 도트 표기 또는 IPv6 콜론-헥스 문자열로 변환한 뒤 기존 (hostname, port) 생성자에 위임했다.
Ktor의 route handler에서 필수 파라미터를 꺼낼 때는 ?: throw 또는 ?: return 보일러플레이트가 반복됐다.
Parameters.getOrFail이 query/form에는 존재했지만 header·cookie·path parameter에는 없어서 확장함수를 추가했다.
val id = call.parameters["id"] ?: throw BadRequestException("Missing id")
val token = call.request.headers["Authorization"] ?: return@get call.respond(HttpStatusCode.BadRequest)
이렇게 쓰던 함수를
val id = call.requirePathParameter("id")
val token = call.requireHeader("Authorization")
다음과 같이 사용할 수 있게 됐다.
Ktor의 RawSourceChannel은 JVM에서 java.nio.channels.ReadableByteChannel을 구현하는 어댑터로, Ktor의 ByteReadChannel을 NIO 채널처럼 사용하게 해준다.
awaitContent()에서 기대하는 것은 데이터가 준비되면 true, EOF이면 false, 에러면 throw인데 실제 구현은 다음과 같다.
override suspend fun awaitContent(min: Int): Boolean {
if (closedToken != null) return true
따라서 다음과 같이 변경하였다.
override suspend fun awaitContent(min: Int): Boolean {
if (closedToken != null) {
closedCause?.let { throw it }
return buffer.remaining >= min
}
Kotlin 코루틴의 runTest는 가상 시간(virtual time)으로 테스트를 실행한다. delay(1000) 같은 호출을 실제로 1초 기다리지 않고 즉시 통과시켜 테스트를 빠르게 만드는 메커니즘이다.
그런데 Ktor 클라이언트의 HttpTimeout 플러그인은 타임아웃 코루틴을 실행할 때 호출자의 coroutineContext(특히 TestDispatcher)를 캡처하지 않고 별도의 CoroutineScope를 새로 만들고 있었다.
그 결과 runTest 안에서도 타임아웃이 실제 시간으로 동작해 테스트가 간헐적으로 실패한다.
Send 인터셉터 시작 시점에 currentCoroutineContext()로 호출자 컨텍스트를 캡처하고, 그 컨텍스트로 만든 CoroutineScope에서 applyRequestTimeout을 실행하도록 변경해 해결했다.
val callerContext = currentCoroutineContext()
// ...
CoroutineScope(callerContext).applyRequestTimeout(request, this.requestTimeoutMillis)
Ktor의 ktor-network 모듈은 소켓 입출력, 셀렉터(이벤트 다중화), TLS 같은 저수준 네트워크 기능을 제공한다. 다른 Ktor 모듈이 이 모듈 위에 올라가 있고, 외부 사용자가 직접 호출하는 public API도 적지 않다.
InterestSuspensionsMap(JVM): 셀렉터에서 "이 소켓이 읽기/쓰기 가능해질 때까지 대기" 같은 코루틴 continuation을 이벤트별로 보관하는 자료구조. 클래스와 addSuspension / invokeForEachPresent / removeSuspension 메서드 KDoc 추가.
SocketAddress.toJavaAddress()(JVM): Ktor 자체 주소 객체를 표준 java.net.SocketAddress로 변환하는 확장 함수. DNS 역방향 조회를 일으키지 않는다는 사실을 KDoc에 명시.
ClosedChannelCancellationException(common): 채널이 닫혀서 진행 중이던 I/O 작업이 취소될 때 던져지는 예외.
HashAndSign.Companion.byCode()(TLS): TLS 핸드셰이크에서 (해시 코드, 서명 코드) 바이트 쌍으로 서명 알고리즘을 조회하는 함수. 익명 서명 코드일 때 IllegalStateException, 알 수 없는 해시 코드일 때 TLSException을 던진다는 점 KDoc에 기술.
scoped(라우트별) Sessions 플러그인은 라우트의 sendPipeline 인터셉터에서 Set-Cookie 헤더를 붙인다.
문제는 핸들러가 call.respond()를 명시적으로 호출하지 않고 call.response.status(...)로 상태 코드만 세팅하고 끝낼 경우 sendPipeline이 발화하지 않아 Set-Cookie가 누락된다는 점이었다.
RoutingRoot에서 라우팅 파이프라인 실행 후 routingApplicationCall.isHandled가 false이면서 status가 세팅돼 있으면 명시적으로 respond(status)를 호출하도록 변경해, scoped sendPipeline 인터셉터가 정상적으로 동작하도록 수정했다.
routingCallPipeline.execute(routingApplicationCall)
if (!routingApplicationCall.isHandled) {
routingApplicationCall.response.status()?.let { status ->
routingApplicationCall.respond(status)
}
}
Ktor의 Sessions 플러그인은 데이터 변경 여부와 무관하게 매 응답마다 Set-Cookie를 재전송한다.
쿠키 만료 시각을 갱신하기 위한 의도된 동작이지만, 변경 없는 세션에도 매번 Set-Cookie가 붙으면 Cloudflare 같은 캐싱 프록시가 정적 자산 응답을 캐시하지 못하고, 대역폭도 낭비된다.
opt-in 플래그 sendOnlyIfModified를 CookieSessionBuilder / HeaderSessionBuilder에 추가하고, true일 때 incoming session과 newValue를 구조적 동등성(equals())으로 비교해 동일하면 재전송을 스킵하도록 했다. 기본값은 false라 기존 동작과 호환된다.
install(Sessions) {
cookie<MySession>("SESSION") {
sendOnlyIfModified = true
}
}
핵심 비교 로직(SessionData.kt):
if (provider.sendOnlyIfModified && incoming && newValue !== oldValue && newValue == oldValue) {
return
}
Ktor의 Netty 엔진(NettyHttp1Handler)은 채널 예외를 잡아 로그로 남긴다.
그런데 클라이언트 측 disconnect로 인한 IOException("Connection reset by peer" 등)이 DEBUG 레벨로 기록되어, 기본 DEBUG 로그가 노이즈로 가득 차는 문제가 있었다.
해당 분기의 로그 레벨을 DEBUG에서 TRACE로 한 단계 낮췄다.
// 변경 이전
environment.log.debug("I/O operation failed", cause)
// 변경 이후
environment.log.trace("I/O operation failed", cause)
ProtobufVarint32FrameDecoder는 Protobuf 메시지의 varint32 길이 프리픽스를 파싱해 프레임을 분리한다.
다른 프레임 디코더 LengthFieldBasedFrameDecoder, DelimiterBasedFrameDecoder는 모두 maxFrameLength를 지원해 과도한 메모리 할당을 방지하지만, 이 디코더만 빠져 있었다.
maxFrameLength 파라미터를 받는 생성자를 추가하고, 초과 시 해당 바이트를 건너뛴 뒤 TooLongFrameException을 던지도록 했다.
기본 생성자는 Integer.MAX_VALUE로 두어 하위 호환성을 유지했다.
Netty의 codec-http2 테스트들은 에러 처리 경로를 검증하기 위해 일부러 예외를 던지는 시나리오가 많다.
일관성을 위해 가짜 예외를 추가하여 8개 테스트 클래스에서 같은 의미로 만드는 예외를 통일했다.
가짜 예외는 스택 정보가 필요 없어서 fillInStackTrace()를 override 해서 스택 트레이스를 채우지 않도록 했다.
단일 인스턴스를 재사용하므로 테스트 반복 중 객체 할당 비용도 줄어든다.
static final RuntimeException FAKE_EXCEPTION = new RuntimeException("Fake exception") {
private static final long serialVersionUID = -8316972447187527869L;
@Override
public Throwable fillInStackTrace() {
return this;
}
};
codec-http3 모듈이 netty-incubator-codec-http3 독립 레포에서 Netty 메인 레포로 통합되었는데 문서에는 반영되어 있지 않아서 수정했다.
Spring Cloud Gateway의 MVC ProxyExchange는 RestTemplate으로 백엔드에 요청을 프록시하고, Micrometer는 http.client.requests 메트릭의 uri 태그에 URI 템플릿 패턴(/foos/{id})을 기록한다.
ProxyExchange.exchange() 함수에서 rest.exchange(RequestEntity, ...) 오버로드만 사용하고 있어 URI 템플릿이 RestTemplate에 전달되지 않았다.
이로 인해 uri 태그가 항상 "none"으로 찍히는 문제가 있었다. 아래와 같이 URI 템플릿을 보존하는 오버로드를 호출하도록 수정했다.
/**
* Sets the uri for the backend call using a URI template with variables. When a
* template is provided, the downstream {@link RestTemplate} call preserves the
* template pattern for observability (e.g. Micrometer URI tags).
* @param uriTemplate the URI template (e.g. {@code "http://service/foos/{id}"})
* @param uriVariables the variables to expand in the template
* @return this for convenience
*/
public ProxyExchange<T> uri(String uriTemplate, Map<String, ?> uriVariables) {
this.uriTemplate = uriTemplate;
this.uriVariables = uriVariables;
this.uri = rest.getUriTemplateHandler().expand(uriTemplate, uriVariables);
return this;
}
Spring Cloud Gateway에서 모듈명이 변경되었는데, 문서에는 반영되지 않아 수정했다.
spring-cloud-gateway-server-mvc -> spring-cloud-gateway-server-webmvc
Rust 컴파일러는 라이프타임 표기가 누락된 타입에 대해 수정 제안을 하는데, 빈 angle bracket(<>)이 있는 타입에 제안을 생성할 때 Foo<'a, 'a, >처럼 불필요한 trailing comma가 끝에 붙었다.
내부 파서 데이터 대신 소스 코드 텍스트에서 직접 > 문자의 존재를 확인하는 방식으로 빈 bracket을 판별하도록 수정하여, 빈 bracket이면 trailing comma 없이, 기존 파라미터가 있으면 기존처럼 comma를 포함해 제안하는 방식으로 수정하였다.
Rust의 .await는 좌변이 Future를 구현해야 한다.
impl Future는 Future지만 &impl Future는 그렇지 않기 때문에, future의 참조에 .await를 걸면 컴파일이 실패한다.
이 상황에서 컴파일러가 어떤 수정을 제안해야 하는지가 #87211에서 4년 넘게 열려 있던 문제다.
대표 케이스는 두 가지다.
// (1) 표현식에 &가 붙은 경우
async fn run(fut: impl Future<Output = ()>) {
(&fut).await;
}
// (2) 파라미터 타입에 &가 붙은 경우
async fn run(fut: &impl Future<Output = ()>) {
fut.await;
}
// (3) 둘 다 &가 붙은 경우 — 표현식 쪽만 떼야 함
async fn run(fut: &impl Future<Output = ()>) {
(&fut).await;
}
기존 진단은 단순히 "future를 구현하지 않는다"는 사실만 보고했고, 수정 제안이 있어도 .await를 지우라는 식으로 어긋나는 안내였다.
사용자가 진짜로 원하는 건 future 자체이므로 .await를 떼는 건 답이 아니다. 또 표현식과 파라미터 양쪽에 &가 붙은 (3)번 케이스에서, 단순히 "내부 타입이 Future니까 &를 떼라"고 제안하면 &dyn Future 같은 자리에서는 잘못된 수정을 권하게 된다.
suggestions.rs에 다음 두 가지를 추가했다.
진단 흐름은 다음과 같이 바뀌었다.
.await의 수신자 타입이 &T이고 T: Future인 경우에만 동작한다.NestJS의 WebSocket 어댑터(IoAdapter)는 Socket.IO 기반으로 동작한다.
bindMessageHandlers()는 게이트웨이의 메시지 핸들러를 소켓에 바인딩하고, disconnect 시 구독을 해제하는 리스너를 등록한다.
문제는 동일 소켓에 대해 여러 게이트웨이가 bindMessageHandlers()를 호출할 때 disconnect 리스너가 호출 횟수만큼 중복 등록된다는 점이었다.
WeakMap<Socket, Observable>로 소켓별 disconnect$ Observable을 캐싱해, 같은 소켓에 대한 두 번째 호출부터는 캐시된 Observable을 재사용하도록 하여 멱등성을 보장했다. WeakMap을 쓴 덕분에 소켓이 GC될 때 엔트리도 자동으로 정리된다.
private readonly disconnectMap = new WeakMap<Socket, Observable<any>>();
let disconnect$ = this.disconnectMap.get(socket);
if (!disconnect$) {
disconnect$ = fromEvent(socket, DISCONNECT_EVENT).pipe(share(), first());
this.disconnectMap.set(socket, disconnect$);
}
detekt는 YAML 기반 설정으로 규칙 프로퍼티를 커스터마이즈하는 Kotlin 정적 분석 도구이다.
사용자가 threshold를 threshhold로 오타내면 "Property 'threshhold' is misspelled or does not exist"라는 메시지만 출력되어 편집 거리 알고리즘을 구현하였다. 오타와 가장 가까운 실제 프로퍼티를 찾아 "Did you mean 'threshold'?" 형태로 제안하도록 수정했다.
Ghostty는 Zig로 작성된 크로스플랫폼 터미널 에뮬레이터이다.
macOS에서는 SwiftUI 기반으로 터미널 surface를 렌더링하며, 프레임이 변경될 때 updateOSView가 호출되어 화면을 갱신하는데, 시스템 부하가 높을 때 이 전파가 지연되면서 surface가 재렌더링되지 않는 버그가 발생했다.
명시적으로 재트리거하는 방식으로 수정하였다.
func updateOSView(_ scrollView: SurfaceScrollView, context: Context) {
// Nothing to do: SwiftUI automatically updates the frame size, and
// SurfaceScrollView handles the rest in response to that
}
func updateOSView(_ scrollView: SurfaceScrollView, context: Context) {
// SwiftUI may defer frame updates under system load (e.g., memory
// pressure, heavy I/O) or when external window managers trigger rapid
// layout changes. When that happens, the scroll view's bounds can
// fall out of sync with the size reported by GeometryReader, causing
// the surface to render at stale dimensions.
guard scrollView.bounds.size != size else { return }
scrollView.needsLayout = true
}
mise는 프로젝트별 개발 도구 버전을 관리하는 CLI이다(asdf의 Rust 재구현).
Python/Ruby 설치 시 GitHub release attestation(서명 검증)을 수행하고, 이를 비활성화하려면 [settings] 섹션 아래에 플래그를 두어야 한다.
이때 에러 메시지가 명확하지 않아 under [settings] in mise.toml로 수정하였다.
mise는 mise activate bash 평가 결과를 통해 셸에 mise 함수와 __MISE_EXE 변수를 주입한다.
이 변수는 child 셸이나 hook이 mise 바이너리 경로를 다시 찾아갈 때 쓰인다.
activate.sh에서 __MISE_EXE=...로만 정의해 export하지 않아, 새로 띄운 child 셸에는 변수가 전파되지 않고, Linux에서 ARGV0가 절대 경로가 아닌 bare name(mise)으로 들어오는 경우가 있어, PATH가 바뀐 셸에서는 같은 바이너리로 다시 들어가지 못했다.
# 변경 이전
__MISE_EXE=/usr/local/bin/mise
# 변경 이후
export __MISE_EXE=/usr/local/bin/mise
Rust 쪽에서는 ARGV0가 bare name이면 which::which로 절대 경로로 resolve하고, 실패 시 MISE_BIN을 폴백으로 쓰도록 했다.
커널 Documentation 안에 흩어져 있던 오타 3건을 정리했다.
Documentation/core-api/list.rst
reinitalizes -> reinitializesDocumentation/block/data-integrity.rst
sub-struture -> sub-structureDocumentation/gpu/drm-uapi.rst
unpriviledged -> unprivileged적용된 커밋: 8cfbcf36f97c (docs-next)