2026년 10월 28일 상태 갱신: #6261은 병합 없이 닫혔습니다. 아래 20/20 결과는 이 제안의 고정 커밋 실험 기록이며 상류 채택 성과가 아닙니다. 유지보수자의 별도 구현 #6417 이후 발견한 no-watch 종료 교착은 후속 글: 시작되지 않은 watcher의 종료를 기다리던 문제에서 다룹니다. 후속 수정 #6427과 이 제안의 코드·검증 결과를 구분합니다.

MediaMTX의 CertLoader.Close()는 종료 신호를 보낸 뒤 반환했습니다. 그런데 이미 시작한 인증서 갱신 작업은 계속 실행돼, Close 이후에도 로그를 쓰거나 인증서를 다시 채울 수 있었습니다. 호출자는 정리가 끝났다고 생각하지만 그 객체를 쓰는 작업은 아직 남아 있는 순서였습니다.

저는 PR #6261에서 종료 요청→watcher 완료 대기→자원 정리 순서를 제안했습니다. 그 순서를 검증하려고 인증서 갱신 중간을 멈춘 상태에서 Close를 호출했습니다. 같은 회귀 테스트는 수정 전 20/20 실패, 수정 후 20/20 통과했습니다. 이는 아래의 고정 커밋 쌍에서 얻은 결과이며, PR은 2026년 10월 15일 확인 기준 OPEN입니다.

평소에는 보이지 않는 수명 경계였다

인증서 watcher는 파일 변경을 기다리다가 인증서나 키가 바뀌면 새 내용을 읽습니다. 아무 작업 없이 select에서 대기 중이면 done을 닫는 것만으로도 깨어나 종료할 수 있습니다. 이 정상적인 경우만 반복하면 Close의 순서가 올바르다고 생각하기 쉽습니다.

하지만 이미 갱신 처리를 시작했다면 종료 채널을 닫아도 그 작업은 즉시 사라지지 않습니다. 기존 Close는 watcher의 완료를 기다리지 않고 감시 자원과 인증서 상태를 정리했습니다. 남은 작업은 Close 이후에도 Parent.Log를 호출하거나 cl.cert를 다시 채울 수 있었습니다.

여러 채널이 닫히는 경우 select의 준비 상태도 고려해야 했습니다. done과 두 감시 채널이 함께 닫혀 있으면, 반복문이 돌아왔을 때 종료 분기만 선택된다고 가정할 수 없습니다. 핵심은 채널이 닫혔다는 사실보다 그 채널을 사용하는 고루틴이 끝났다는 사실이었습니다.

신호 뒤에 완료 대기를 넣었다

수정은 watcher 시작 전에 WaitGroup에 등록하고, 고루틴이 끝날 때 완료를 알리도록 하는 것입니다. Close는 종료 요청 뒤 Wait()로 작업 완료를 기다린 다음 감시 자원과 인증서를 정리합니다. watcher를 시작하지 않는 초기화 경로에서는 카운터가 0이므로 대기가 바로 끝납니다.

순서 동작 지키려는 조건
시작 고루틴 실행 전에 Add(1) Close가 시작을 놓치지 않음
종료 요청 done 닫기 대기 중 watcher를 깨움
완료 대기 Wait() 진행 중 갱신의 반환 확인
정리 감시 자원·인증서 상태 정리 살아 있는 작업이 정리된 상태를 다시 사용하지 않음

기존 서버들의 shutdown 순서나 CertLoader의 공개 API를 바꾸지는 않았습니다. 이미 사용하던 종료 패턴에 맞춰 loader 안에서 자신의 작업 수명을 책임지는 범위를 택했습니다. 제 역할은 이 경계와 검증 범위를 확정하는 것이었고, 코드 변경·실측·리뷰는 AI와 협업했습니다.

sleep으로 우연을 기다리지 않고 갱신을 멈췄다

경합 버그를 검증하려고 테스트를 오래 반복하는 방법만으로는 중요한 타이밍을 만난다는 보장이 없습니다. 이 테스트는 실제 인증서와 키 파일을 다시 써 reload를 유발한 뒤, 테스트용 부모 로거의 Parent.Log에서 진행을 멈췄습니다. 그 상태에서 Close를 호출합니다.

검증할 조건은 갱신 작업을 풀어주기 전에는 Close가 반환하지 않는 것입니다. 수정 전에는 막혀 있는 갱신 작업을 남긴 채 Close가 반환해 실패했습니다. 수정 후에는 그 작업을 풀어주고 끝난 다음 반환했습니다. 같은 순서를 제가 만들 수 있어야, 20번 통과가 단지 중요한 타이밍을 피한 결과인지와 구별할 수 있었습니다.

2026년 10월 4일 macOS/arm64·Go 1.26.5에서 같은 TestCloseDuringReload를 -race -count=20으로 실행한 기록은 다음과 같습니다.

비교 대상 커밋 결과
수정 전 base 04825598 20/20 실패: 갱신 중 Close 반환
당시 수정본 head 296e3cea 20/20 통과: 갱신 완료까지 대기

원시 before·after 로그의 개별 PASS/FAIL 40행을 집계한 결과입니다. 현재 PR head 전체를 이번 글 작성 때 다시 실행했다는 뜻은 아닙니다. 동일한 역사적 커밋 쌍의 비교이며, 공개 PR의 테스트 원문과 포트폴리오 38쪽에서 재현 조건을 확인할 수 있습니다.

통과 시간이 길어진 것을 성능 저하로 읽지 않는다

수정 전 테스트는 약 0.02초 만에 실패했고, 수정 후 예시는 약 0.52초에 통과했습니다. 이 테스트에서는 빨리 반환하는 것이 결함입니다. 진행을 의도적으로 멈춘 갱신 작업을 남겨 두고 나왔기 때문입니다. 수정 후 걸린 시간에는 테스트가 넣은 대기가 포함돼 있어 처리속도의 전후 비교값으로 사용할 수 없습니다.

실제 동작의 대가는 별도로 있습니다. 갱신 중 로그 목적지가 느리면 watcher의 완료도 늦어지고 Close도 기다립니다. 또 이미 대기 중인 파일 변경이 있으면 한 번 더 갱신하고 끝날 수 있습니다. 종료 함수의 반환을 실제 작업 완료에 맞췄다는 장점과 대기 가능성을 함께 설명해야 합니다. 이 시험은 모든 로그 목적지에서의 종료 상한을 증명하지 않습니다.

20회 대조가 확인한 범위는 갱신 중 Close가 먼저 반환하던 특정 순서입니다. 다른 모든 경합의 부재나 느린 로그 목적지에서의 종료 상한까지 증명하지는 않습니다.

제가 바꾸려 한 계약은 호출자가 Close의 반환 뒤에 무엇을 믿을 수 있는가였습니다. 종료 신호만 보내는 것이라면 남은 작업을 호출자가 고려해야 하지만, watcher 완료까지 기다리면 정리된 인증서를 같은 작업이 다시 채우는 순서를 막을 수 있습니다. 그 완료 보장을 얻는 대신 진행 중 작업의 대기 시간도 Close가 책임지도록 한 선택입니다.


이 글은 PokeClip·MediaMTX 기술 기록 모음의 일부입니다. 포트폴리오 전체본 38쪽에서 연결된 사례도 함께 볼 수 있습니다.