레이블이 에러해결인 게시물을 표시합니다. 모든 게시물 표시
레이블이 에러해결인 게시물을 표시합니다. 모든 게시물 표시

수요일

Go goroutine leak 완벽 해결 — 고루틴이 계속 늘어나는 원인과 pprof 진단법

요약: Go의 goroutine leak은 채널 전송이 영원히 블로킹되거나 context 취소가 전파되지 않아 고루틴이 종료되지 못할 때 발생합니다. 버퍼 채널(크기 1), context.WithTimeout과 defer cancel(), 종료 조건이 있는 select를 쓰면 해결되고, pprof의 goroutine 프로파일로 누수 위치를 찾을 수 있습니다.

🔍 검색 키워드: Go goroutine leak, 고루틴 누수 해결, Go pprof goroutine 분석, Go context cancel 누수, Go 메모리 계속 증가, goroutine 개수 증가 원인

증상: 서버 메모리와 goroutine 수가 계속 늘어난다

Go 서비스를 배포한 뒤 시간이 지날수록 메모리 사용량이 우상향하고, 재시작하면 잠시 정상으로 돌아오는 패턴이 나타납니다. 이때 가장 먼저 의심해야 할 것이 goroutine leak(고루틴 누수)입니다. pprof 엔드포인트로 확인하면 아래와 같이 고루틴 수가 비정상적으로 큽니다.

$ curl http://localhost:6060/debug/pprof/goroutine?debug=1
goroutine profile: total 10432
10380 @ 0x43a5f6 0x4066bc 0x4062f5 0x6f1a2b 0x46f8c1
#	0x6f1a2a	main.fetchAll.func1+0x4a	/app/main.go:42

정상 서비스라면 요청이 끝난 뒤 고루틴 수가 원래 수준으로 돌아와야 하는데, 요청 수에 비례해 계속 쌓인다면 어딘가에서 고루틴이 영원히 블로킹되어 있다는 뜻입니다.

원인: 아무도 받지 않는 채널, 끝나지 않는 대기

고루틴은 스스로 종료되지 않으면 GC가 회수하지 못합니다. 대표적인 누수 원인은 다음과 같습니다.

  • 수신자 없는 채널 전송: 언버퍼드 채널에 값을 보내는데 받는 쪽이 이미 타임아웃으로 빠져나간 경우
  • context 미전파: HTTP 요청이 취소되어도 하위 고루틴에 ctx를 넘기지 않아 계속 실행되는 경우
  • time.Ticker 미정지: Stop()을 호출하지 않아 고루틴과 타이머가 남는 경우
  • 무한 for-select 루프: 종료 조건(done 채널, ctx.Done())이 없는 워커

아래는 가장 흔한 누수 코드입니다. 타임아웃이 발생하면 fetch 고루틴은 ch에 값을 보내려고 영원히 대기합니다.

func fetchWithTimeout(url string) (string, error) {
    ch := make(chan string) // 언버퍼드 채널
    go func() {
        ch <- slowRequest(url) // 받는 쪽이 없으면 영원히 블로킹
    }()
    select {
    case res := <-ch:
        return res, nil
    case <-time.After(1 * time.Second):
        return "", errors.New("timeout") // 여기서 반환하면 위 고루틴은 누수
    }
}

해결방법 1: 버퍼 채널로 전송 블로킹 제거

채널 버퍼를 1로 두면 받는 쪽이 사라져도 고루틴은 값을 넣고 정상 종료됩니다. 결과가 하나뿐인 경우 가장 간단한 해결책입니다.

ch := make(chan string, 1) // 버퍼 1: 수신자가 없어도 전송이 완료됨

해결방법 2: context로 취소 전파

요청 단위 작업은 반드시 context를 받아 하위 호출까지 전달하고, 블로킹 지점마다 ctx.Done()을 함께 select 합니다.

func fetchWithTimeout(ctx context.Context, url string) (string, error) {
    ctx, cancel := context.WithTimeout(ctx, time.Second)
    defer cancel() // 반드시 호출해서 타이머 리소스 해제
    ch := make(chan string, 1)
    go func() { ch <- slowRequestCtx(ctx, url) }()
    select {
    case res := <-ch:
        return res, nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

해결방법 3: 워커 종료 조건과 Ticker 정리

func worker(ctx context.Context) {
    t := time.NewTicker(time.Minute)
    defer t.Stop() // Ticker는 반드시 Stop
    for {
        select {
        case <-t.C:
            doWork()
        case <-ctx.Done():
            return // 종료 조건 필수
        }
    }
}

누수 진단 방법: pprof와 테스트

운영 중에는 net/http/pprof를 열어 goroutine 프로파일을 두 번 떠서 비교하면 어느 코드 위치에서 고루틴이 쌓이는지 바로 보입니다. 개발 단계에서는 go.uber.org/goleak 라이브러리로 테스트 종료 시점에 남은 고루틴을 자동 검출할 수 있습니다.

go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top
(pprof) list main.fetchAll

정리표

누수 원인증상해결
수신자 없는 채널 전송chan send 상태로 고루틴 누적버퍼 채널(1) 또는 select+ctx
context 미전파요청 취소 후에도 작업 지속ctx 전달, WithTimeout+defer cancel
Ticker 미정지타이머와 고루틴 잔존defer t.Stop()
종료 조건 없는 루프워커가 영구 실행ctx.Done() 케이스 추가

핵심은 모든 고루틴은 종료 경로를 가져야 한다는 원칙입니다. 고루틴을 시작하는 코드를 쓸 때 “이 고루틴은 언제 끝나는가?”를 먼저 답할 수 없다면 누수 후보입니다.

자주 묻는 질문(FAQ)

goroutine leak은 GC가 자동으로 정리해 주나요?

아니요. Go의 가비지 컬렉터는 블로킹 중인 고루틴을 회수하지 않습니다. 고루틴은 스스로 함수를 끝내야 사라지므로, 모든 고루틴에 종료 경로(ctx.Done(), 버퍼 채널, done 채널)를 만들어야 합니다.

고루틴이 몇 개부터 누수로 봐야 하나요?

절대적인 기준 숫자는 없습니다. 트래픽이 줄어도 고루틴 수가 내려가지 않고 시간에 따라 계속 증가한다면 누수 신호입니다. runtime.NumGoroutine() 값을 메트릭으로 수집해 추세를 확인하세요.

테스트 단계에서 goroutine leak을 자동으로 잡을 수 있나요?

go.uber.org/goleak 패키지의 goleak.VerifyNone(t)를 테스트 끝에 호출하면 종료되지 않은 고루틴이 있을 때 테스트가 실패합니다.

time.After를 select에서 써도 누수가 생기나요?

time.After가 만든 타이머는 만료되기 전까지 메모리에 남습니다. 반복문 안에서 자주 쓴다면 time.NewTimer를 만들고 Stop()으로 정리하거나 context.WithTimeout을 쓰는 편이 안전합니다.

관련 글: FastAPI async 블로킹 완벽 해결 — 이벤트 루프 막힘 해결법

참고 문서: net/http/pprof · context 패키지 · uber-go/goleak

최종 업데이트: 2026-10-01 · 작성: 내멋스레 사는 이야기

Redis maxmemory 설정 — OOM 에러와 eviction policy 완벽 가이드

증상 — 에러 메시지

OOM command not allowed when used memory > 'maxmemory'.
ERR command not allowed when used memory > 'maxmemory'

캐시 서버로 Redis를 운영하다 보면 어느 날 갑자기 쓰기 명령이 전부 실패하는 상황을 만납니다. OOM(Out of Memory) 에러는 Redis가 설정된 최대 메모리 한도에 도달했을 때 발생하며, 기본 정책(noeviction)에서는 새 데이터를 추가할 수 없습니다.

원인

Redis는 기본적으로 maxmemory 제한이 없어서 서버 물리 메모리를 모두 소모할 수 있습니다. 운영 환경에서 명시적으로 제한을 걸면, 그 한도를 넘는 순간 maxmemory-policy 설정에 따라 동작이 결정됩니다. 기본값인 noeviction은 메모리가 가득 차면 쓰기 에러를 반환합니다.

해결방법

1. maxmemory 및 정책 설정

# redis.conf 파일 수정
maxmemory 2gb
maxmemory-policy allkeys-lru

# 또는 런타임에 즉시 적용 (재시작 불필요)
redis-cli CONFIG SET maxmemory 2gb
redis-cli CONFIG SET maxmemory-policy allkeys-lru

# 설정 확인
redis-cli CONFIG GET maxmemory
redis-cli CONFIG GET maxmemory-policy

2. Eviction Policy 종류와 용도

# noeviction (기본): 메모리 초과 시 쓰기 에러 반환
# allkeys-lru  : 모든 키 중 LRU 순서로 삭제 — 순수 캐시에 권장
# volatile-lru : TTL 있는 키 중 LRU 순서로 삭제 — 혼합 환경
# allkeys-lfu  : 사용 빈도 낮은 키 삭제 — Redis 4.0+
# volatile-ttl : TTL 짧은 키부터 삭제 — 세션 저장소
# allkeys-random: 무작위 삭제

3. 메모리 사용량 모니터링

redis-cli INFO memory
# 주요 항목: used_memory_human, maxmemory_human, mem_fragmentation_ratio

redis-cli DBSIZE                  # 전체 키 개수
redis-cli MEMORY USAGE mykey      # 특정 키 메모리 크기(bytes)

4. Spring Boot에서 Redis 메모리 효율화

@Bean
public RedisCacheConfiguration cacheConfiguration() {
    return RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(30))   // TTL 필수 지정
        .disableCachingNullValues()          // null 캐싱 방지
        .serializeValuesWith(
            RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer())
        );
}

정책 선택 가이드

사용 목적권장 정책이유
순수 캐시 (세션 없음)allkeys-lru모든 키를 대상으로 LRU 교체, 가장 일반적
캐시 + 영구 데이터 혼합volatile-lruTTL 있는 캐시 키만 삭제, 영구 키 보존
세션 저장소volatile-ttl만료 임박 세션 먼저 삭제
쓰기 보장 필수noeviction메모리 부족 시 에러 반환, 데이터 손실 없음
접근 빈도 편중 심함allkeys-lfu자주 쓰는 키 보존, Redis 4.0 이상

핵심 요약

  • maxmemory와 maxmemory-policy는 항상 함께 설정한다.
  • 캐시 전용 서버라면 allkeys-lru가 기본 선택지다.
  • Spring Boot에서는 entryTtl로 TTL을 반드시 지정해 키가 무한정 쌓이지 않게 한다.
  • mem_fragmentation_ratio가 1.5 이상이면 MEMORY PURGE 또는 재시작을 고려한다.

금요일

Nginx 502 Bad Gateway 완벽 해결 가이드 — upstream 연결 실패 원인과 해결

🔍 검색 키워드: Nginx 502 Bad Gateway 해결, Nginx upstream 502 에러, upstream connect() failed, Nginx 리버스 프록시 502, upstream timed out 해결

증상: 502 Bad Gateway 에러 발생

Nginx를 리버스 프록시로 사용할 때 클라이언트가 갑자기 502 Bad Gateway 에러를 마주치는 경우가 있습니다. 브라우저에는 아무 설명도 없고, Nginx 에러 로그를 보면 이런 메시지가 남습니다:

2026/09/18 09:12:34 [error] 12345#12345: *1 connect() failed (111: Connection refused) while connecting to upstream, client: 1.2.3.4, server: example.com, request: "GET / HTTP/1.1", upstream: "http://127.0.0.1:8080/", host: "example.com"

2026/09/18 09:13:01 [error] 12345#12345: *2 upstream timed out (110: Connection timed out) while reading response header from upstream, client: 1.2.3.4, upstream: "http://127.0.0.1:8080/"

원인: upstream 서버 연결 실패

Nginx 502 에러의 원인은 크게 세 가지입니다:

  • upstream 서버가 죽어 있음 — WAS(Spring Boot, Node.js 등)가 다운됨
  • upstream 응답 시간 초과 — 서버는 살아있지만 너무 느리게 응답
  • SELinux/방화벽 차단 — 네트워크 정책이 연결을 막음

먼저 어떤 케이스인지 확인해야 합니다:

# upstream 서버 포트 확인
ss -tlnp | grep 8080

# 서비스 상태 확인
systemctl status myapp

# Nginx 설정 문법 확인
nginx -t

해결 방법

케이스 1: upstream 서버가 꺼진 경우 → 재시작

# systemd 서비스라면
sudo systemctl restart myapp

# nohup으로 띄운 Spring Boot라면
nohup java -jar /opt/myapp/app.jar > /var/log/myapp.log 2>&1 &

케이스 2: 응답 시간 초과 → 타임아웃 설정 조정

Nginx의 기본 proxy_read_timeout은 60초입니다. 배치 처리나 파일 업로드처럼 오래 걸리는 요청은 이 값을 늘려야 합니다:

server {
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_connect_timeout 10s;
        proxy_send_timeout 300s;
        proxy_read_timeout 300s;
    }
}

설정 변경 후 반드시 reload:

sudo nginx -s reload

케이스 3: SELinux가 연결 차단 → 정책 허용

CentOS/RHEL 계열에서 SELinux가 활성화된 경우 Nginx가 upstream에 연결하지 못할 수 있습니다:

# SELinux 상태 확인
getenforce

# Nginx → upstream 네트워크 연결 허용
sudo setsebool -P httpd_can_network_connect on

케이스 4: upstream 이중화로 장애 대응

단일 upstream이 죽으면 502가 납니다. 백업 서버를 설정해두면 자동 전환됩니다:

upstream backend {
    server 127.0.0.1:8080;
    server 127.0.0.1:8081 backup;
}

server {
    location / {
        proxy_pass http://backend;
        proxy_next_upstream error timeout http_502;
    }
}

에러 유형별 빠른 참고표

에러 로그 키워드 원인 해결
Connection refused (111) upstream 프로세스 다운 서비스 재시작
upstream timed out (110) 응답 지연 / 타임아웃 proxy_read_timeout 증가
Permission denied (13) SELinux/방화벽 차단 setsebool 또는 방화벽 해제
no live upstreams while connecting upstream 전체 다운 백업 서버 추가

Nginx 502는 대부분 upstream 프로세스 상태 확인 → 타임아웃 튜닝 → SELinux 정책 순서로 해결됩니다. 에러 로그의 키워드를 보고 케이스를 특정하면 대부분 10분 안에 잡을 수 있습니다.

일요일

MySQL deadlock 완벽 해결 가이드

MySQL 고동시성 환경에서 deadlock은 흔한 문제입니다.

원인: 두 개 이상의 트랜잭션이 서로의 락을 기다리면서 교착상태 발생.

해결방법:
1. 트랜잭션 순서 통일 - 모든 트랜잭션에서 같은 순서로 테이블 접근
2. 타임아웃 설정 - innodb_lock_wait_timeout 단축
3. 애플리케이션 재시도 - deadlock 발생 시 자동 재시도
4. 격리 레벨 검토 - READ COMMITTED 사용
5. 인덱스 확인 - 전체 스캔 방지

가장 효과적인 방법은 접근 순서 통일과 재시도 로직입니다.