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쪽에서 연결된 사례도 함께 볼 수 있습니다.