레이블이 DevOps인 게시물을 표시합니다. 모든 게시물 표시
레이블이 DevOps인 게시물을 표시합니다. 모든 게시물 표시

수요일

Terraform state lock 에러 해결 — Error acquiring the state lock 원인과 force-unlock 안전하게 쓰는 법

요약: Terraform의 "Error acquiring the state lock"은 다른 실행이 state lock을 잡고 있거나, 비정상 종료로 lock이 해제되지 않아 발생합니다. 먼저 Lock Info로 실행 중인 작업이 없는지 확인한 뒤 -lock-timeout으로 대기하고, 실행 주체가 종료된 경우에만 terraform force-unlock LOCK_ID로 해제하세요.

🔍 검색 키워드: Terraform state lock 에러, Error acquiring the state lock 해결, terraform force-unlock 사용법, Terraform S3 DynamoDB lock, Terraform apply 중단 lock 해제, terraform lock ID

증상: terraform plan/apply가 state lock 에러로 멈춘다

팀 단위로 Terraform을 쓰거나 CI에서 apply를 돌리다 보면, 아무 변경도 하지 않았는데 다음과 같은 에러와 함께 명령이 중단되는 경우가 있습니다. 이 에러는 원격 백엔드(S3+DynamoDB, Terraform Cloud 등)에 state lock이 남아 있어서 발생합니다.

Error: Error acquiring the state lock

Error message: ConditionalCheckFailedException: The conditional request failed
Lock Info:
  ID:        3f2a9c1e-7b64-1d0e-9a3f-6c1b2d4e5f70
  Path:      my-bucket/prod/terraform.tfstate
  Operation: OperationTypeApply
  Who:       runner@ci-worker-12
  Version:   1.6.6
  Created:   2026-09-30 01:12:44 +0000 UTC

원인: 다른 실행이 진행 중이거나, 비정상 종료로 lock이 남음

Terraform은 state 파일이 동시에 수정되어 깨지는 것을 막기 위해 plan/apply 전에 lock을 잡고, 끝나면 해제합니다. lock이 남는 대표적인 경우는 다음과 같습니다.

  • 정상적인 동시 실행: 다른 팀원이나 CI 파이프라인이 실제로 apply 중인 경우
  • 비정상 종료: Ctrl+C 강제 종료, CI job 취소·타임아웃, 네트워크 단절로 unlock이 실행되지 못한 경우
  • DynamoDB 잔여 항목: S3 백엔드의 lock 테이블에 이전 실행의 LockID 항목이 그대로 남은 경우
  • 병렬 파이프라인 설계 문제: 같은 state를 쓰는 job이 동시에 트리거되도록 구성된 경우

해결방법 1: 먼저 진짜 실행 중인지 확인하기

가장 중요한 단계입니다. 에러 메시지의 Who, Created, Operation을 확인해 지금도 실제로 실행 중인 작업인지 먼저 확인하세요. 실행 중인 apply의 lock을 강제로 풀면 state가 손상될 수 있습니다. 해당 CI job이 이미 종료됐거나 담당자가 중단했음을 확인한 뒤에만 다음 단계로 넘어갑니다.

해결방법 2: 대기 옵션으로 자동 재시도

잠깐 겹친 실행이라면 lock이 풀릴 때까지 기다리게 할 수 있습니다. CI에서는 이 옵션을 기본으로 두는 것이 좋습니다.

terraform plan -lock-timeout=5m
terraform apply -lock-timeout=5m

해결방법 3: terraform force-unlock으로 안전하게 해제

실행 주체가 이미 죽었다고 확인됐다면, 에러 메시지에 나온 Lock ID로 강제 해제합니다.

terraform force-unlock 3f2a9c1e-7b64-1d0e-9a3f-6c1b2d4e5f70

Do you really want to force-unlock?
  Enter a value: yes

Terraform state has been successfully unlocked!

해결방법 4: S3 + DynamoDB 백엔드에서 lock 항목 직접 삭제

force-unlock이 실패하는 경우(권한 문제, 항목 손상 등)에는 lock 테이블의 항목을 직접 확인하고 삭제할 수 있습니다. 이 방법은 최후의 수단이며, 삭제 전에 반드시 항목 내용을 확인하세요.

aws dynamodb get-item --table-name terraform-locks \
  --key '{"LockID":{"S":"my-bucket/prod/terraform.tfstate"}}'

aws dynamodb delete-item --table-name terraform-locks \
  --key '{"LockID":{"S":"my-bucket/prod/terraform.tfstate"}}'

재발 방지: 백엔드 설정과 CI 구성

lock 자체는 문제가 아니라 안전장치입니다. 남는 lock을 줄이려면 백엔드에 lock 테이블을 명시하고, CI에서는 같은 state에 대해 job이 직렬로만 실행되도록 제한합니다.

terraform {
  backend "s3" {
    bucket         = "my-bucket"
    key            = "prod/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}
# GitHub Actions: 같은 state에 대한 apply를 직렬화
concurrency:
  group: terraform-prod
  cancel-in-progress: false

정리표

상황확인 방법조치
다른 실행이 진행 중Who/Created 확인, CI 로그-lock-timeout으로 대기
비정상 종료로 lock 잔존실행 주체가 종료됐는지 확인terraform force-unlock LOCK_ID
force-unlock 실패DynamoDB 항목 조회항목 확인 후 delete-item
반복 발생CI 동시 실행 여부concurrency 직렬화, timeout 설정

핵심은 lock을 풀기 전에 실행 중인 작업이 없는지 반드시 확인하는 것입니다. 확인 없이 force-unlock을 습관처럼 쓰면 두 실행이 동시에 state를 쓰게 되어 복구하기 어려운 손상이 생길 수 있습니다.

자주 묻는 질문(FAQ)

terraform force-unlock을 써도 안전한가요?

실행 중인 plan/apply가 없다는 것을 확인한 뒤에만 안전합니다. lock은 state 동시 수정을 막는 장치이므로, 작업이 살아 있는 상태에서 강제로 풀면 두 실행이 동시에 state를 써서 손상될 수 있습니다. Lock Info의 Who, Created 값과 CI 로그로 먼저 확인하세요.

Error acquiring the state lock이 계속 반복되는 이유는 무엇인가요?

CI에서 같은 state를 쓰는 job이 동시에 실행되거나, job이 취소·타임아웃되어 unlock이 실행되지 못하는 경우가 대부분입니다. concurrency 그룹으로 apply를 직렬화하고 -lock-timeout을 지정해 두면 재발을 줄일 수 있습니다.

-lock=false 옵션을 사용해도 되나요?

lock을 아예 잡지 않는 옵션이라 동시 실행 시 state가 손상될 위험이 있어 권장하지 않습니다. 읽기 전용 plan처럼 영향이 없음을 확실히 아는 경우에만 제한적으로 고려하세요.

force-unlock에 필요한 Lock ID는 어디서 확인하나요?

에러 메시지의 Lock Info 블록에 있는 ID 값입니다. 터미널 출력이 사라졌다면 DynamoDB lock 테이블 항목의 Info 필드에서도 확인할 수 있습니다.

참고 문서: terraform force-unlock · State Locking

최종 업데이트: 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분 안에 잡을 수 있습니다.

Nginx upstream 502 에러 완벽 해결 가이드

 🔍 검색 키워드: Nginx upstream 502, Nginx Bad Gateway, Nginx 리버스 프록시 에러, Nginx 서버 다운


Nginx upstream 502 에러 완벽 해결 가이드


증상: 502 Bad Gateway

브라우저에서 사이트에 접속했는데 갑자기 502 Bad Gateway 에러가 뜨나요?


502 Bad Gateway

nginx/1.24.0


이것이 upstream 502 에러입니다. Nginx가 백엔드 서버(upstream)로부터 유효한 응답을 받지 못했을 때 발생합니다.


원인 분석


원인 1: 백엔드 애플리케이션 서버 다운

가장 흔한 원인입니다. Node.js, PHP-FPM, Gunicorn 등 실제 애플리케이션 서버가 죽어있거나 재시작 중인 경우입니다.


원인 2: upstream 타임아웃

백엔드 서버 응답이 너무 느려서 Nginx가 설정된 타임아웃 시간 안에 응답을 받지 못하는 경우입니다.


원인 3: 소켓/포트 연결 실패

Nginx 설정의 upstream 주소(포트, 소켓 경로)가 실제 백엔드 서버와 일치하지 않는 경우입니다.


원인 4: 백엔드 서버의 버퍼 크기 초과

응답 헤더나 바디가 Nginx의 기본 버퍼 크기를 초과하면 502가 발생할 수 있습니다.


해결 방법


방법 1: 백엔드 서버 상태 확인

가장 먼저 애플리케이션 서버가 실제로 살아있는지 확인합니다.


systemctl status my-app

또는

pm2 status

또는

docker ps


프로세스가 죽어있다면 재시작하고, 재시작 후에도 계속 죽는다면 애플리케이션 로그를 확인해야 합니다.


방법 2: Nginx 에러 로그 확인

정확한 원인은 Nginx 에러 로그에서 확인할 수 있습니다.


tail -f /var/log/nginx/error.log


connect() failed (111: Connection refused) 메시지가 보이면 백엔드 서버 연결 자체가 안 되는 것이고, upstream timed out 메시지가 보이면 타임아웃 문제입니다.


방법 3: upstream 설정 확인 및 수정

nginx.conf 또는 사이트 설정 파일에서 upstream 블록을 확인합니다.


upstream backend {

    server 127.0.0.1:3000;

}


server {

    location / {

        proxy_pass http://backend;

    }

}


포트 번호가 실제 애플리케이션이 리스닝하는 포트와 일치하는지 반드시 확인합니다.


방법 4: 타임아웃 값 조정

백엔드 처리가 원래 느린 작업(대용량 파일 업로드, 무거운 연산)이라면 타임아웃을 늘려줍니다.


proxy_connect_timeout 60s;

proxy_send_timeout 60s;

proxy_read_timeout 60s;


단, 무작정 늘리기보다 애플리케이션 자체의 응답 속도를 개선하는 것이 근본적인 해결책입니다.


방법 5: 버퍼 크기 조정

헤더/바디 크기 초과 문제라면 버퍼 설정을 늘립니다.


proxy_buffer_size 16k;

proxy_buffers 4 32k;

proxy_busy_buffers_size 64k;


방법 6: 헬스체크 및 자동 재시작 설정

재발 방지를 위해 systemd나 pm2, docker의 헬스체크와 자동 재시작 설정을 구성해두면 서버 다운으로 인한 502를 최소화할 수 있습니다.


정리

- 502는 Nginx가 백엔드로부터 유효한 응답을 못 받았다는 의미

- 에러 로그(error.log)를 먼저 확인하는 것이 진단의 시작

- Connection refused는 서버 다운, timed out은 응답 지연 문제

- upstream 포트 설정 오타는 의외로 자주 발생하는 원인

- 헬스체크와 자동 재시작으로 재발 방지


Nginx는 프록시일 뿐이므로, 502 에러의 근본 원인은 대부분 백엔드 애플리케이션 쪽에 있다는 점을 기억해야 합니다.

목요일

Docker 멀티스테이지 빌드 캐시 최적화하기

🔍 검색 키워드: Docker 멀티스테이지 빌드, Dockerfile 캐시 성능, Docker 레이어 캐싱, 빌드 시간 단축

Docker 멀티스테이지 빌드 캐시 최적화하기

증상

Dockerfile로 이미지를 빌드할 때마다 다음과 같은 문제가 발생한다:

$ docker build -t myapp:latest .
  Step 3/10 : RUN npm install    # 매번 30초 이상 소요
  Step 4/10 : RUN npm run build  # 매번 60초 이상 소요
  ...
  real    2m 15s

코드는 조금만 바뀌었는데도 빌드 시간이 계속 길어진다. CI/CD 파이프라인에서 빌드만 몇 분씩 걸려서 배포 속도가 느리다. 특히 npm, pip 같은 의존성 설치 단계가 매번 전부 다시 실행된다.

원인

Docker는 각 RUN, COPY, ADD 단계를 하나의 레이어로 취급하고, 파일이 변경되면 그 이후의 모든 레이어를 다시 빌드한다. 예를 들어:

FROM node:18
  COPY . /app              # 레이어1: 모든 파일 복사
  WORKDIR /app
  RUN npm install          # 레이어2: 의존성 설치 (레이어1 변경되면 캐시 무효)
  RUN npm run build        # 레이어3: 빌드 (레이어2 변경되면 캐시 무효)

코드를 한 줄만 바꿔서 COPY . /app을 실행하면 npm install, npm run build가 모두 다시 실행된다. 즉, 캐시를 제대로 활용하지 못한다.

멀티스테이지 빌드를 사용하면서도 각 스테이지 간 의존성이 꼬여 있으면 캐시 효율이 떨어진다.

해결방법

1. 의존성 파일을 먼저 복사 (핵심!)

FROM node:18 AS builder
  
  WORKDIR /app
  
  # package.json, package-lock.json만 먼저 복사
  COPY package*.json ./
  
  # 의존성 설치 (코드 변경 시 캐시 유지)
  RUN npm ci
  
  # 이제 코드 복사
  COPY . .
  
  # 빌드
  RUN npm run build
  
  # 런타임 스테이지
  FROM node:18-alpine
  
  WORKDIR /app
  
  # 의존성만 복사 (빌드 아티팩트 불필요)
  COPY --from=builder /app/node_modules ./node_modules
  COPY --from=builder /app/dist ./dist
  COPY package*.json ./
  
  EXPOSE 3000
  CMD ["node", "dist/index.js"]

캐시 효과:

  • package.json 불변 → npm install 캐시 유지
  • 코드만 변경 → COPY . . 부터만 재실행
  • 빌드 시간 30초 → 5초

2. Docker Buildkit으로 고급 캐싱 활용

// syntax=docker/dockerfile:1.4
  
  FROM node:18 AS builder
  
  WORKDIR /app
  
  COPY package*.json ./
  
  // --mount=type=cache로 npm cache 디렉토리 보존
  RUN --mount=type=cache,target=/root/.npm \
      npm ci --prefer-offline
      
      COPY . .
      RUN npm run build
      
      FROM node:18-alpine
      
      WORKDIR /app
      
      COPY --from=builder /app/node_modules ./node_modules
      COPY --from=builder /app/dist ./dist
      COPY package*.json ./
      
      EXPOSE 3000
      CMD ["node", "dist/index.js"]

빌드 명령:

DOCKER_BUILDKIT=1 docker build -t myapp:latest .

효과:

  • npm cache가 빌드 간에 유지되어 더 빠름
  • 오프라인 설치 최적화

3. 불필요한 파일 제외 (.dockerignore)

.git
  .gitignore
  node_modules
  npm-debug.log
  .env
  .env.local
  dist
  build
  coverage
  .DS_Store
  README.md

효과:

  • COPY . . 시 변경되지 않은 파일이 많으면 해시 계산이 더 오래 걸림
  • .dockerignore로 제외하면 캐시 재계산 범위 축소

4. 여러 스테이지에서 캐시 공유

// syntax=docker/dockerfile:1.4
  
  FROM node:18 AS dependencies
  
  WORKDIR /app
  COPY package*.json ./
  
  RUN --mount=type=cache,target=/root/.npm \
      npm ci
      
      // 빌드 스테이지1
      FROM dependencies AS builder1
      
      COPY . .
      RUN npm run build
      
      // 빌드 스테이지2 (테스트)
      FROM dependencies AS tester
      
      COPY . .
      RUN npm test
      
      // 최종 스테이지
      FROM node:18-alpine
      
      WORKDIR /app
      
      COPY --from=builder1 /app/dist ./dist
      COPY --from=dependencies /app/node_modules ./node_modules
      COPY package*.json ./
      
      EXPOSE 3000
      CMD ["node", "dist/index.js"]

5. 번들 사이즈 최소화 (캐시 영향 없지만 이미지 크기 감소)

FROM node:18 AS builder
  
  WORKDIR /app
  COPY package*.json ./
  RUN npm ci
  
  COPY . .
  RUN npm run build
  
  // 크기 최소화: dependencies만 복사, devDependencies 제외
  FROM node:18-alpine
  
  WORKDIR /app
  
  COPY --from=builder /app/dist ./dist
  
  // 본번 의존성만 설치 (devDependencies 없음)
  COPY package*.json ./
  RUN npm ci --omit=dev
  
  EXPOSE 3000
  CMD ["node", "dist/index.js"]

정리표

문제 원인 해결법
npm install 매번 재실행 COPY . . 후 RUN npm install package.json만 먼저 복사
캐시가 가끔만 작동 불필요 파일 포함 시 해시 변경 .dockerignore 작성
빌드 시간 여전히 길다 npm cache 미보존 Buildkit + --mount=cache 활용
이미지 크기 커짐 devDependencies 포함 npm ci --omit=dev
멀티스테이지 간 캐시 미공유 각 스테이지가 독립적 dependencies AS 공용 스테이지 생성

TIP: docker build --progress=plain으로 상세 로그 확인, docker image history myapp:latest로 각 레이어 크기 확인 가능. 캐시 강제 무효화는 docker build --no-cache 또는 COPY . . --chown=node:node 같은 타임스탐프 변경 명령으로.

PostgreSQL Connection Pool 고갈 해결하기

🔍 검색 키워드: PostgreSQL connection pool 에러, 커넥션 풀 소진, 데이터베이스 연결 오류, 최대 연결 초과

PostgreSQL Connection Pool 고갈 해결하기

증상

애플리케이션이 실행되다가 갑자기 다음과 같은 에러가 발생한다:

FATAL: sorry, too many clients already

또는:

Error: connect ECONNREFUSED - PostgreSQL connection failed

또는 connection pool 라이브러리에서:

Error: timeout acquiring a connection from the pool
  Error: Client has already been released to the pool

특히 트래픽이 많아지거나 장시간 실행되는 배치 작업 후에 자주 발생한다. 새로운 요청이 들어와도 DB 연결을 못 하고 응답 불가 상태가 된다.

원인

PostgreSQL은 최대 동시 연결 수 제한이 있고 (기본값 100), connection pool은 재사용 가능한 연결 수를 미리 정해둔다. 고갈 현상은 다음 경우에 발생:

  1. 연결을 반환 안 함: 쿼리 후 연결을 pool에 반환하지 않아 계속 증가
  2. 타임아웃 후 좀비 연결: 응답 없는 연결이 pool에 남아있음
  3. Pool 사이즈 설정 과소: 동시 사용자/요청량에 비해 pool이 너무 작음
  4. 쿼리 오래 걸림: 느린 쿼리가 연결을 오래 점유
  5. Connection leak: 예외 발생 시 연결을 close하지 않는 코드

해결방법

1. Connection Pool 설정 최적화

Node.js + pg (node-postgres):

const { Pool } = require('pg');
  
  const pool = new Pool({
    host: 'localhost',
      port: 5432,
        database: 'mydb',
          user: 'postgres',
            password: 'password',
              max: 20,                    // 최대 연결 수 (기본 10)
                idleTimeoutMillis: 30000,   // 30초 idle 후 닫기
                  connectionTimeoutMillis: 2000, // 연결 생성 타임아웃
                  });
                  
                  module.exports = pool;

Python + psycopg2:

import psycopg2.pool
  
  connection_pool = psycopg2.pool.SimpleConnectionPool(
      1,      // minimum connections
          20,     // maximum connections
              database="mydb",
                  user="postgres",
                      password="password",
                          host="localhost"
                          )
                          
                          // 사용
                          conn = connection_pool.getconn()
                          try:
                              // 쿼리 실행
                                  pass
                                  finally:
                                      connection_pool.putconn(conn)

Java + HikariCP:

HikariConfig config = new HikariConfig();
  config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");
  config.setUsername("postgres");
  config.setPassword("password");
  config.setMaximumPoolSize(20);          // 최대 연결
  config.setMinimumIdle(5);               // 최소 유휴 연결
  config.setConnectionTimeout(2000);       // 2초 타임아웃
  config.setIdleTimeout(600000);          // 10분 idle 타임아웃
  config.setMaxLifetime(1800000);         // 30분 최대 수명
  
  HikariDataSource dataSource = new HikariDataSource(config);

2. 연결이 제대로 반환되는지 확인

Node.js에서 연결 누수 확인:

const pool = new Pool(config);
  
  pool.on('error', (err, client) => {
    console.error('Unexpected error on idle client', err);
      process.exit(-1);
      });
      
      pool.on('connect', () => {
        console.log('New connection created');
        });
        
        // 항상 try-finally로 연결 반환 보장
        const client = await pool.connect();
        try {
          const result = await client.query('SELECT * FROM users WHERE id = $1', [1]);
            return result.rows;
            } finally {
              client.release();  // 반드시 실행
              }

또는 with 문 활용 (Python):

from contextlib import contextmanager
  
  @contextmanager
  def get_db_connection():
      conn = connection_pool.getconn()
          try:
                  yield conn
                      finally:
                              connection_pool.putconn(conn)
                              
                              // 사용
                              with get_db_connection() as conn:
                                  cursor = conn.cursor()
                                      cursor.execute("SELECT * FROM users WHERE id = %s", (1,))
                                          // 예외 발생해도 자동으로 반환됨

3. 느린 쿼리 최적화

-- 인덱스 추가
  CREATE INDEX idx_users_id ON users(id);
  
  -- 실행계획 확인
  EXPLAIN ANALYZE SELECT * FROM users WHERE status = 'active';
  
  -- 필요 없는 조인 제거, WHERE 조건 최적화
  SELECT u.id, u.name
  FROM users u
  WHERE u.created_at > NOW() - INTERVAL '7 days'
    AND u.status = 'active'
    LIMIT 100;

4. PostgreSQL 서버 설정 확인

# PostgreSQL 최대 연결 수 조회
  psql -U postgres -c "SHOW max_connections;"
  
  # 현재 연결 수 조회
  psql -U postgres -c "SELECT count(*) FROM pg_stat_activity;"
  
  # 연결 상세 정보
  psql -U postgres -c "SELECT datname, count(*) FROM pg_stat_activity GROUP BY datname;"

필요하면 postgresql.conf에서:

max_connections = 200   # 기본값 100에서 증가

5. 좀비 연결 정리

-- 유휴 연결 종료
  SELECT pg_terminate_backend(pid)
  FROM pg_stat_activity
  WHERE datname = 'mydb'
    AND state = 'idle'
      AND query_start < now() - interval '30 minutes';
      
      -- 슬로우 쿼리 강제 종료 (주의)
      SELECT pg_terminate_backend(pid)
      FROM pg_stat_activity
      WHERE query_start < now() - interval '5 minutes'
        AND state != 'idle';

정리표

상황 원인 해결법
연결 고갈 후 다시 증가 연결을 close하지 않음 try-finally 또는 context manager로 보장
Pool 타임아웃 pool이 너무 작음 max 크기 증가, idleTimeoutMillis 조정
"too many clients" 에러 PostgreSQL 최대 연결 도달 max_connections 증가 또는 app pool 감소
간헐적 연결 오류 connectionTimeoutMillis 너무 짧음 타임아웃 값 증가 또는 네트워크 확인
메모리 누수 좀비 연결이 메모리 점유 주기적으로 idle 연결 정리 (pg_terminate_backend)

TIP: 프로덕션에서는 반드시 connection pool 크기, 타임아웃, idle 설정을 검토하고, 정기적으로 pg_stat_activity로 모니터링하자.