ONE QUESTION AT A TIME.

천천히 생각하고, 분명하게.

TODAY’S QUESTION610 / 1500
언어·런타임난이도 하5분 답변 목표 · 보강본

외부 API가 느려지자 Go 서버의 고루틴 수가 계속 늘어납니다. 정상적인 대기 증가인지 누수인지 어떻게 구분하고 제한하나요?

잠깐, 답변을 보기 전에
먼저 내 말로 설명해 볼까요?

답변과 점검 포인트 펼치기답변 접기
01

구두 답변

정의를 외우기보다, 원리와 조건을 연결해 설명해 보세요.

요청마다 고루틴을 만드는 것 자체는 자연스러운 패턴입니다. 다만 가볍다는 것이 끝없이 만들어도 된다는 뜻은 아닙니다. 런타임이 많은 고루틴을 여러 OS 스레드에 나눠 실행해 주더라도, 끝나지 않은 작업이 잡고 있는 메모리와 연결까지 없애 주지는 않기 때문입니다.

고루틴은 Go 런타임이 관리하는 실행 흐름입니다. 잠든 고루틴도 스택과 참조 중인 데이터를 유지할 수 있어 CPU를 거의 쓰지 않는다고 비용이 없는 것은 아닙니다. 요청이 완료되면 줄어드는 대기와, 받을 사람이 없는 채널에 영원히 보내려는 대기를 나눠 보는 것이 핵심입니다.

예를 들어 외부 API를 호출하는 고루틴을 요청마다 만든다고 하겠습니다. 외부 응답이 느려지면 기존 작업이 끝나기 전에 새 작업이 들어와 고루틴 수가 쌓입니다. 같은 요청량에서 처리 시간이 길어지면 진행 중인 작업도 그만큼 늘어나는 것입니다. 이때 CPU는 낮아도 응답 버퍼와 연결 풀은 부족해질 수 있습니다. 저는 고루틴 수가 늘었다는 이유만으로 누수라고 단정하지 않고, 외부 대기 시간과 요청 종료 후 감소 여부를 함께 보겠습니다.

실제 누수는 종료할 수 없는 대기에서 생기기도 합니다. 요청이 취소돼 결과를 받을 사람이 없어졌는데, 작업 고루틴이 채널로 결과를 보내려다 계속 멈춰 있는 경우입니다. 이 고루틴은 일을 하지 않더라도 참조 중인 데이터를 유지합니다. context 취소를 전달하는 것만으로 자동 종료되지는 않으므로, 채널 대기에서도 취소를 확인하고 빠져나오게 해야 합니다. 부모 작업은 취소를 알린 뒤 자식들이 실제로 종료됐는지도 확인해야 합니다.

외부 API 지연으로 쌓이는 문제라면 동시에 호출할 작업 수부터 제한하겠습니다. 다만 고루틴을 먼저 만든 뒤 세마포어에서 기다리게 하면 대기 고루틴은 계속 늘 수 있습니다. 저는 가능하면 작업을 만들기 전에 실행 허가를 받고, 기다리는 요청 수에도 상한을 두겠습니다. 상한에 도달하면 무조건 더 받기보다 제한된 시간만 기다리거나 재시도 가능한 실패를 돌려주는 정책을 정하겠습니다.

원인을 찾을 때는 pprof의 고루틴 덤프에서 어떤 호출 위치에 대기가 몰렸는지 보겠습니다. 부하를 끈 뒤에도 같은 채널 전송에서 작업이 남으면 종료 경로를 의심하고, 외부 요청이 끝나며 정상 감소한다면 과도한 동시성이 문제인지 보겠습니다. 고루틴 개수 하나보다 어디서 기다리며 언제 끝나야 하는지를 기준으로 판단하겠습니다.

적체와 누수는 수명으로 구분합니다

하위 응답이 느려져 정상 요청이 잠시 쌓이는 것은 적체(backlog)이고, 더 이상 완료할 수 없는 실행 흐름이 남는 것은 고루틴 누수(goroutine leak)입니다. 둘 다 고루틴 수 증가로 보일 수 있어 숫자 하나로 구분할 수 없습니다. 부하를 내렸는데도 특정 채널 송신이나 영원한 대기에 같은 스택이 남는지 봐야 합니다. 반대로 정상적으로 모두 끝나더라도 최대 대기 수가 너무 커 메모리가 고갈된다면 누수가 아니라는 이유로 안전한 것은 아닙니다. 수명 문제와 용량 문제를 따로 고치겠습니다.

Go 스케줄러를 설명할 때 실행할 고루틴을 G, OS 스레드를 M, Go 코드를 실행하는 데 필요한 스케줄링 자원을 P라고 부릅니다. GOMAXPROCS는 동시에 Go 코드를 실행할 P 수를 제한하는 값이지 전체 고루틴 수나 OS 스레드 수의 상한이 아닙니다. 네트워크 대기에서 고루틴을 주차하고 다른 고루틴을 실행할 수 있어도, 차단된 시스템 호출이나 cgo 등의 경로는 다른 비용을 가질 수 있습니다. 고루틴이 가볍다는 것은 대기 중인 버퍼와 외부 연결이 공짜라는 뜻이 아닙니다.

취소는 전달만 하지 말고 기다리는 지점에서 사용합니다

작업이 끝나 결과를 보내려는데 호출자가 먼저 떠난 경우를 보겠습니다. 버퍼 없는 채널의 송신은 수신자를 기다리므로 영원히 멈출 수 있습니다. 송신과 ctx.Done을 select로 기다려 취소 시 반환하게 하거나, 한 번의 결과라면 버퍼 하나로 송신자가 끝날 수 있게 하는 방법이 있습니다. 다만 버퍼 하나는 무한 결과 스트림이나 취소를 무시하는 외부 호출을 해결하지 않습니다. ctx를 함수 인자로 넘기는 것과 그 함수가 실제로 취소를 관찰하는 것은 다른 계약입니다.

select에서 결과 송신과 취소가 모두 준비됐다면 결과 송신이 선택될 수도 있습니다. 따라서 취소가 전달됐다는 사실만으로 뒤늦은 결과가 절대 없다고 가정하지 않겠습니다. 종료 상태나 요청 세대를 확인해 늦은 결과를 폐기하고, 보내는 쪽과 받는 쪽 중 누가 채널을 닫을 수 있는지 정해야 합니다. 요청이 끝난 뒤에도 반드시 지속해야 하는 작업은 임의의 goroutine으로 남기지 않고 내구 작업 큐로 넘겨 독립된 소유자와 재시도 상태를 갖게 하겠습니다.

프로파일은 서로 다른 대기를 보여 줍니다

고루틴 프로파일은 현재 어떤 스택에 실행 흐름이 있는지 보여 주고, block 프로파일은 채널이나 동기화에서 누적한 차단 시간을 관찰하는 데 도움을 줍니다. mutex 프로파일은 락 경합을 보는 도구이며 활성화와 샘플링 비용도 확인해야 합니다. 단일 덤프의 대기 스택만으로 영구 누수를 단정하지 않고 시간 간격을 두고 개수와 상태가 변하는지 보겠습니다. HTTP 연결 풀 대기, 결과 채널 송신, 락 대기는 같은 대기라도 고칠 위치가 다릅니다.

수정 후에는 외부 API를 느리게 한 상태에서 입력률을 유지하고, 이후 부하를 끊어 고루틴 수와 메모리가 안정 범위로 돌아오는지 확인하겠습니다. 생성한 작업 수·종료한 작업 수·현재 살아 있는 작업 수를 함께 남기면 요청이 끝났는데 작업만 남는 구간을 찾기 쉽습니다. 이런 관측은 새로운 지표를 많이 만드는 것이 목적이 아니라, 각 작업에 종료 조건과 정리 책임이 실제로 있다는 증거를 얻기 위한 것입니다.

02

득점 포인트

이해가 드러나는 지점을 확인해 보세요.

  • 정상 적체와 누수를 구분한다. 하위 응답 지연 및 부하 종료 뒤 감소 여부로 서로 다른 원인을 판단한다.
  • 취소 가능한 종료 경로를 설명한다. 채널 대기에서도 취소를 확인하고 실제 자식 작업 종료까지 책임진다.
  • 생성 전후의 제한 효과를 구분한다. 고루틴 내부 세마포어만으로 대기 작업 수가 제한되지 않음을 짚는다.
  • 프로파일에서 행동으로 이어 간다. 대기 스택과 자원 회복을 보고 종료 경로 수정과 진입 제어 중 무엇이 필요한지 정한다.
03

감점 포인트

놓치기 쉬운 조건과 오해를 짚어 보세요.

  • “고루틴이 많으면 무조건 누수다”라고 말한다. 부하와 정상적인 대기 증가일 수 있어 수명과 회복 여부를 봐야 한다.
  • “context를 취소하면 하위 고루틴이 강제 종료된다”고 말한다. 작업이 취소 신호를 확인하고 반환해야 한다.
  • “GC가 사용하지 않는 고루틴을 회수한다”고 말한다. 블로킹된 실행 흐름과 그 참조는 종료 경로 없이 남을 수 있다.
  • “작업자 수를 제한했으니 메모리는 안전하다”고 말한다. 대기열과 대기 고루틴도 자원을 보유한다.
04

더 파고들 거리

하나 더 질문하며 이해의 범위를 넓혀 보세요.

득점·감점 포인트는 학습용 자기 점검 기준이며, 특정 회사의 실제 채점표가 아닙니다.

원리부터 공부하고 싶다면

답변을 보충하는 개념·예제·슈도코드를 읽어 보세요.

← 전체 질문 보기