[Go] Context에 관한 고찰 - 2 : Context의 중요성과 메모리 누수

2025. 7. 24. 17:06·Language/Go

~Go Context 시리즈~

[1] Context에 관한 고찰 - 1 : Context 란

[2] Context에 관한 고찰 - 2 : Context의 중요성과 고루틴 누수

[3] Context에 관한 고찰 - 3 : Graceful Shutdown의 미학


Context가 중요한 이유

Context의 중요성을 간과하고 context.Background()를 남발하는 코드는 당장에는 문제없이 동작하는 것처럼 보일 수 있다. 이는 마치 브레이크 없는 자동차로 텅 빈 직선 도로를 달리는 것과 같은데, 장애물이 나타나거나(에러 발생), 교차로에서 멈춰야 하거나(요청 취소), 주차해야 할 때(서버 종료) 비로소 재앙이 시작된다...후후... (그나저나 AI 비유법 기깔난다..ㅋㅋㅋㅋ 다시 돌리려다가 웃겨서 그냥 가져옴)

Context 의 부재가 야기하는 문제는 단순히 사소한 버그가 아니라 goroutine leak (고루틴 누수)라는 심각한 결함으로 이어지기 때문이다.

원인 모를 메모리 누수 원인을 찾고 있는 과거의 나와 비슷함...ㅠ

🔸 좀비 고루틴

go에서 go someFunc() 코드로 고루틴을 실행하면 해당 고루틴은 함수가 종료(return)될 때까지 절대 멈추지 않는다.

그렇다면, 만약 이 고루틴이 DB 응답이나 외부 API 응답을 기다리며 무한적 블로킹이 된다면 어떻게 될까?

바로 좀비 고루틴으로 변질이 되고야 만다. 영원히 메모리와 시스템 리소스를 점유한채 사라지지 않는 좀비가 되는 것이다.

그냥 짤이 넣고 싶었어... 탐욕스럽게 서버 리로스 먹고 있는 골룸을 표현한걸로~

고루틴 누수는 곧 메모리 누수로 직결된다. 누수된 고루틴은 자신의 스택 메모리를 점유할 뿐 아니라, 그 고루틴이 참조하고 있는 모든 변수(ex. HTTP 요청으로 받은 (어쩌면) 거대한 데이터, DB 커넥션 객체)들 역시 가비지 컬렉터(GC)가 수집할 수 없게 만든다.


여기서 잠깐. 고루틴 누수가 어떻게 메모리 누수로 이어지는지 정확하게 이해하려면, Go의 메모리 관리 모델, 특히 스택(Stack)과 힙(Heap), 그리고 가비지 컬렉터(Grabage Collector)의 상호작용을 살펴봐야 한다.

만약 Context에 좀 더 집중하고 싶다면 이 '부록' 부분은 스킵해도 무방하다.

더보기

🔸 부록: Go의 메모리 관리 모델

고루틴의 스택 메모리

우선 스택에 대해 간단하게 알아보자.

스택은 함수 호출과 관련된 데이터를 저장하는 메모리 영역이다. 함수가 호출될 때마다 해당 함수의 지역 변수, 파라미터, 반환 주소 등을 저장하기 위한 공간, 즉 '스택 프레임(stack frame)'이 스택에 쌓인다(push). 함수가 반환되면 해당 스택 프레임은 스택에서 제거된다(pop). 이 구조는 메모리 할당과 해제가 매우 빠르다는 장점이 있다.

 

Go에서는 모든 고루틴이 자신만의 독립적인 스택을 가진다. 고루틴이 처음 생성될 때, Go 런타임은 약 2KB의 작은 스택 공간을 할당해준다. 고루틴 내에서 함수 호출이 깊어지거나 큰 지역 변수가 사용되어 스택이 더 필요해지면, 런타임이 알아서 스택 크기를 동적으로 늘려준다.

 

고루틴이 종료되지 않고 누수된다는 것은, 바로 이 고루틴의 스택 메모리 전체가 해제되지 않고 영원히 남아있게 된다는 것을 의미한다. 고루틴 10,000개가 누수되면, 최소 10,000 * 2KB = 20MB 의 스택 메모리가 그 자체로 낭비되는 것이다 (으악)! 하지만 이것은 빙산의 일각에 불과하다, 진짜 문제는 스택이 참조하는 힙 메모리에서 발생한다.

 

힙 메모리와 GC

힙은 프로그램의 생명주기 동안 동적으로 할당되고 해제되는, 크기가 정해져 있지 않은 데이터들을 위한, 훨씬 더 큰 메모리 영역이라고 볼 수 있다. 채널, 슬라이스, 맵, 구조체의 포인터처럼 컴파일 시점에 크기를 알 수 없거나 여러 함수/고루틴에서 공유되어야 하는 데이터들이 힙에 저장된다. 스택에 비해 할당과 해제 속도가 느리다는 단점도 있지만.

 

Go의 가비지 컬렉터(GC)는 바로 이 힙 영역을 주기적으로 스캔하여, 더 이상 어떤 코드에서도 참조되지 않는 '쓰레기(garbage)' 객체들을 찾아내어 메모리를 해제하는 역할을 한다.

 

GC가 어떤 객체를 청소해야하는지 판단하는 기준은 "Reachability(참조 도달 가능성)" 에 따른다. GC는 Root Set(루트 집합)에서 시작해서 객체들의 참조 관계를 따라 그래프를 순회한다. 루트 집합에는 전역 변수, 그리고 현재 실행 중인 모든 고루틴의 스택이 포함된다. 이 루트집합에서 시작하여 도달할 수 있는 모든 객체는 '살아있는(live)' 객체로 간주되고, 도달할 수 없는 객체는 '죽은(dead)' 객체, 즉 쓰레기로 간주되어 청소된다.

 

GC의 방해물: 좀비 고루틴

자 그러면 조금 감이 오지 않는가? 본격적으로 Context의 부재로 인해 좀비 고루틴이 발생했을 때 어떤 일이 벌어지는지 단계적으로 살펴 보겠다.

// Example: 좀비가 된 고루틴의 스택 프레임
func handleAPIRequest(...) {
	// req 변수는 스택에 존재
    // req가 가리키는 실제 데이터는 힙에 존재
    var req *MyRequestStruct = parseRequest(r)
    
    // ... 이 고루틴은 여기서 영원히 멈춤 ...
    db.QueryRowContext(context.Background(), "...")
}
  1. API 요청을 처리하던 고루틴이 context.Background()를 사용하여 DB 쿼리를 보냈다고 가정해보자. 사용자는 떠났지만, 이 고루틴은 DB 응답을 기다리며 영원히 블로킹된다.
  2. 이 좀비 고루틴의 스택에는 여러 변수들이 존재한다. 예를 들어, API 핸들러에서 파싱한 거대한 JSON 요청 본문을 담고 있는 *MyRequest Struct 포인터 변수 req가 있을 수 있다. 이 구조체 데이터 자체는 힙에 할당되어 있다.
  3. GC가 작동하면서 루트 집합을 스캔하기 시작한다. 그러면서 GC는 현재 실행 중인 고루틴 목록을 확인하다가 우리가 만든 좀비 고루틴을 발견한다. 이 고루틴은 종료되지 않았으므로 '살아있는' 상태다.  따라서 GC는 이 좀비 고루틴의 스택 전체를 '살아있는' 루트 집합의 일부로 간주한다.
  4. GC는 이 좀비 고루틴의 스택을 따라 참조를 추적한다.
    • 좀비 고루틴의 스택은 살아있다 -> 스택에 있는 req 변수도 살아있다 -> 따라서 req 변수가 가리키고 있는 힙에 있는 거대한 MyReqeustStruct 데이터도 살아있다!
  5. 이제 GC는 결론을 내린다. "이 MyRequestStruct 데이터는 비록 다른 어떤 정상적인 코드에서도 사용되지 않지만, 살아있는 좀비 고루틴이 참조하고 있으므로 절대 쓰레기가 아니다!" 라고. 결과적으로 이 데이터는 영원히 힙 메모리에서 해제되지 않는다. 이것이 바로 고루틴 누수가 거대한 힙 메모리 누수로 이어지는 핵심적인 메커니즘이다.

 

따라서 Context를 올바르게 사용하여 고루틴을 제때 종료시키는 것은, 단순히 고루틴의 작은 스택 메모리를 해제하는 행위를 넘어, 그 고루틴이 붙들고 있던 힙 메모리에 대한 거대한 참조의 사슬을 끊어내어 GC가 정상적으로 일할 수 있도록 해주는 매우 중요한 작업이다.


더 심각한 것은 한정된 시스템 리소스의 고갈이다. DB 커넥션, 파일 핸들, 네트워크 소켓 등은 개수가 제한되어 있다. 좀비 고루틴이 이런 리소스를 점유한 채 반납하지 않는다면, 결국 시스템은 더 이상 새로운 요청을 처리할 수 없는 마비 상태에 빠지게 될 것이다.

 

Context는 바로 이 좀비 고루틴에게 "이제 그만 기다리고 종료해!!!" 라는 신호를 보내는 유일한 표준 안전 장치다.

구체적인 사례를 통해 살펴보자.


🔸 사례 1: 백그라운드 worker 고루틴의 누수

채널에서 작업을 기다리는 worker 용 고루틴이 있다고 가정해보자.

 

잘못된 코드 - 취소 불가:

func startWorker(jobs <-chan int) {
	// jobs 채널이 닫히지 않으면 이 고루틴은 영원히 여기서 대기한다.
    for job := range jobs {
    	fmt.Printf("Processing job %d\n", job)
        time.Sleep(1 * time.Second)
    }
   	// 이 라인은 영원히 도달하지 못할 수 있다.
    fmt.Println("Worker finished.")
}

만약 jobs 채널에 데이터를 보내는 쪽에서 에러가 발생하여 채널을 닫지 못하고 비정상 종료된다면, startWorker 고루틴은 range jobs에서 영원히 블로킹 된다.

 

올바른 코드:

func startWorker(ctx context.Context, jobs <-chan int) {
    for {
        select {
        case <-ctx.Done():
            // 상위 작업에서 취소 신호를 보냄 (예: 서버 종료)
            fmt.Println("Worker shutting down.")
            return // 고루틴 안전하게 종료
        case job, ok := <-jobs:
            if !ok {
                // 채널이 닫혔을 경우
                fmt.Println("Worker finished.")
                return
            }
            fmt.Printf("Processing job %d\n", job)
            time.Sleep(1 * time.Second)
        }
    }
}

Context를 이용해 select 문이 ctx.Done() 채널을 함께 감시하므로, 설령 jobs 채널에 문제가 생기더라도 외부에서 Context를 취소함으로써 워커를 안전하게 종료시킬 수 있다.

 

🔸 사례 2: DB 쿼리에서의 누수

우리가 직접 go 키워드를 쓰지 않더라도 누수는 발생할 수 있다.

대부분의 I/O 라이브러리는 내부적으로 고루틴을 사용하기 때문이다.

 

잘못된 코드 - 요청 취소 무시:

func handleGetUser(...) {
    // 사용자가 요청을 취소해도 이 DB 작업은 멈추지 않는다.
    // DB가 바쁘면 이 작업은 30초 이상 걸릴 수도 있다. (별다른 설정을 안해줬을 경우)
    row := db.QueryRowContext(context.Background(), "SELECT * FROM users WHERE id=1")
    
    // ... row.Scan() ...
    // 만약 사용자가 1초 만에 브라우저를 닫았다면?
    // 이 핸들러의 나머지 코드는 의미가 없지만, DB 작업은 계속된다.
}

db.QueryRowContext는 내부적으로 '응답 대기용 고루틴'을 사용한다. context.Background()를 전달하면, 사용자가 요청을 취소(브라우저 닫기 등)해도 그 신호가 전달되지 않는다. 그 결과, 아무도 기다리지 않는 쿼리가 DB 커넥션을 점유한 채 계속 실행되고, 이는 직접적인 DB 커넥션풀 고갈로 이어진다.

 

올바른 코드:

func handleGetUser(r *http.Request) {
    // r.Context()는 HTTP 요청의 생명주기와 연결된 컨텍스트다.
    // 클라이언트 연결이 끊어지면 자동으로 취소된다.
    row := db.QueryRowContext(r.Context(), "SELECT * FROM users WHERE id=1")

    // ... row.Scan() ...
    // 이제 사용자가 요청을 취소하면, r.Context()가 취소되고,
    // DB 드라이버는 즉시 쿼리를 중단하고 커넥션을 반납한다.
}

r.Context()를 전달하는 것만으로, 우리는 보이지 않는 곳에서 동작하는 라이브러리의 고루틴까지 안전하게 제어할 수 있게 된다.

 

🔸 사례 3: 외부 HTTP/RPC 호출에서의 누수

외부 서비스 호출은 지연 시간과 실패 가능성이 가장 높은 작업이다. Context를 이용한 타임아웃 설정은 필수적이므로 명심하자!

 

잘못된 코드 -  무한 대기:

func callExternalService() {
    // 외부 서비스가 응답하지 않으면 이 호출은 영원히 또는
    // TCP 기본 타임아웃(수 분)까지 기다린다.
    req, _ := http.NewRequest("GET", "https://api.slow-service.com/data", nil)
    resp, err := http.DefaultClient.Do(req)
    // 이 고루틴은 외부 서비스가 응답할 때까지 다른 일을 전혀 못하고 멈춰있다.
}

이런 코드가 포함된 서버는 외부 서비스 하나가 장애를 일으키는 것만으로 모든 자원이 고갈되어 연쇄적으로 장애가 전파되는 Cascading Failure에 매우 취약하다.

 

올바른 코드:

func callExternalService(parentCtx context.Context) error {
    // 이 외부 API 호출은 3초의 타임아웃을 설정한다.
    ctx, cancel := context.WithTimeout(parentCtx, 3*time.Second)
    defer cancel() // 리소스 정리

    req, err := http.NewRequestWithContext(ctx, "GET", "https://api.slow-service.com/data", nil)
    if err != nil {
        return err
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        // 3초가 지나면 여기서 `context.DeadlineExceeded` 에러가 발생한다.
        return err
    }
    // ...
    return nil
}

WithTimeout을 통해 우리는 실패의 범위를 따로 분리해서 관리하고, 시스템 전체가 외부 서비스의 문제에 종속되는 것을 막을 수 있다.


이후 내용은 3편에서 계속... TO BE CONTINUED ~

'Language > Go' 카테고리의 다른 글

[Go] 배치 서버 OOM 분석을 위한 메트릭 설계와 프로파일링 전략  (0) 2025.08.26
[Go] Context에 관한 고찰 - 3 : Graceful Shutdown의 미학  (0) 2025.07.28
[Go] WaitGroup vs ErrGroup 비교  (0) 2025.07.23
[Go] Context에 관한 고찰 - 1  (0) 2025.07.11
[Go] field 이름을 기준으로 field 초기화하기 with reflect  (0) 2024.11.08
'Language/Go' 카테고리의 다른 글
  • [Go] 배치 서버 OOM 분석을 위한 메트릭 설계와 프로파일링 전략
  • [Go] Context에 관한 고찰 - 3 : Graceful Shutdown의 미학
  • [Go] WaitGroup vs ErrGroup 비교
  • [Go] Context에 관한 고찰 - 1
빵빵0
빵빵0
(아직은) 공부하고 정리하는 블로그입니다.
  • 빵빵0
    Hack Your World
    빵빵0
  • 전체
    오늘
    어제
    • 분류 전체보기 (97)
      • Error Handling (7)
      • Project (5)
        • MEV (2)
      • Architecture (0)
        • API (0)
        • Cache (0)
        • 사소한 고민거리 (0)
      • Computer Science (4)
        • Data Structure (2)
        • Database (1)
        • Cloud (0)
        • OS (0)
        • Infra, Network (1)
        • AI (0)
      • Language (12)
        • Go (11)
        • Rust (0)
        • Move (1)
        • Python (0)
        • Java (0)
      • Algorithm (40)
        • BaekJoon (18)
        • Programmers (7)
        • LeetCode (6)
        • NeetCode (9)
      • SW Books (9)
        • gRPC Up & Running (1)
        • System Design Interview (2)
        • 스프링 입문을 위한 자바 객체지향의 원리와 이해 (6)
        • 블록체인 해설서 (0)
        • 후니의 쉽게 쓴 CISCO 네트워킹 (0)
      • BlockChain (5)
        • Research (1)
        • Meetup (4)
        • Tech (0)
      • Own (8)
        • TIR(Today I Read) (3)
        • Personal (2)
        • Novel (0)
        • Memo (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    chart
    LeetCode
    ethereum
    NeetCode
    BEAKJOON
    golang
    EVM
    blockchain
    BFS
    2024
    KBW
    Python
    큐
    스택
    MongoDB
    context
    DP
    Programmers
    go
    백준
    Hash Table
    Greedy
    블록체인
    Palindrome
    BaekJoon
    MEV
    two pointer
    candlechart
    goroutine
    프로그래머스
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
빵빵0
[Go] Context에 관한 고찰 - 2 : Context의 중요성과 메모리 누수
상단으로

티스토리툴바