일요일

Python ModuleNotFoundError 해결 — pip install 했는데 왜 못 찾냐

🔍 검색 키워드: ModuleNotFoundError No module named, pip install 했는데 에러, Python 모듈 못 찾는 문제, ImportError No module named 해결, 파이썬 가상환경 모듈 에러, pip install 후 import 에러

pip install pandas 했다. 분명히 잘 됐다. 근데 코드 실행하면 이 에러가 난다.

ModuleNotFoundError: No module named 'pandas'

이걸 처음 보는 사람은 당황하고, 두 번 이상 본 사람은 원인을 알면서도 순간 헷갈린다. 이 에러는 "모듈이 없다"는 게 아니라 "네가 실행하는 Python이 설치된 곳을 못 찾는다"는 뜻이다. 패턴별로 원인과 해결을 정리한다.


핵심 원인: pip와 python이 같은 환경을 보고 있지 않다

이 에러의 90%는 여기서 나온다. 시스템에 Python이 여러 개 있거나, 가상환경이 활성화되지 않은 상태에서 설치하면 발생한다.

먼저 어떤 Python, 어떤 pip를 쓰는지 확인한다:

# 현재 사용 중인 Python 경로 확인
which python
which python3
python --version

# 현재 사용 중인 pip 경로 확인
which pip
which pip3

# pip가 어디에 설치하는지 확인
pip show pandas

pip show pandas를 했는데 나오면 설치는 된 것이다. 근데 python이 다른 경로면 서로 다른 환경이다.


해결 1: python -m pip 를 쓴다 (가장 확실한 방법)

# ❌ 이렇게 쓰면 pip가 어떤 Python에 연결됐는지 불확실
pip install pandas

# ✅ 이렇게 쓰면 실행하는 Python과 100% 같은 환경에 설치
python -m pip install pandas
python3 -m pip install pandas

python -m pip는 현재 실행 중인 Python 인터프리터에 pip를 직접 연결한다. 이 방법이 가장 확실하다.


해결 2: 가상환경 활성화 확인

가상환경(venv)을 쓰면 activate가 되어 있어야 한다. 안 된 상태에서 설치하면 시스템 Python에 설치되고, 가상환경 Python은 못 찾는다.

# 가상환경 생성
python -m venv venv

# 활성화 — macOS/Linux
source venv/bin/activate

# 활성화 — Windows
venv\Scripts\activate

# 활성화 확인 (앞에 (venv) 표시 나옴)
# (venv) user@machine:~$

# 활성화된 상태에서 설치
pip install pandas

# 비활성화
deactivate

IDE(VS Code, PyCharm)를 쓰면 인터프리터 설정도 확인한다.

  • VS Code: Ctrl+Shift+PPython: Select Interpreter → 가상환경 경로 선택
  • PyCharm: SettingsProjectPython Interpreter → 가상환경 선택

해결 3: PyPI 패키지명과 import명이 다른 경우

설치는 됐는데 이름이 달라서 못 찾는 케이스다. 대표적인 예시들:

# 설치명 → import명 (다른 경우)
pip install Pillow          # import PIL
pip install opencv-python   # import cv2
pip install scikit-learn    # import sklearn
pip install python-dotenv   # import dotenv
pip install beautifulsoup4  # import bs4
pip install pyyaml          # import yaml
# ❌ 잘못 쓰는 경우
import Pillow  # ModuleNotFoundError

# ✅ 맞는 import
import PIL
from PIL import Image

해결 4: Python 버전이 여러 개인 환경 (pyenv, conda 등)

# 현재 활성 Python 버전 확인
pyenv version

# 프로젝트 디렉토리에서 로컬 버전 설정
pyenv local 3.11.8

# 해당 버전으로 설치
python -m pip install pandas

conda를 쓰는 경우:

# conda 환경 목록
conda env list

# 환경 활성화
conda activate myenv

# 해당 환경에 설치 (conda 우선, pip는 없을 때만)
conda install pandas
# 또는
pip install pandas

해결 5: 같은 이름의 .py 파일이 있는 경우

프로젝트 폴더에 numpy.py, random.py 같이 표준 라이브러리나 패키지와 같은 이름의 파일을 만들면 충돌한다.

my_project/
├── main.py
├── numpy.py        ← ❌ 이게 문제 (패키지보다 먼저 import됨)
└── utils.py

# main.py에서
import numpy  # 실제 numpy가 아닌 my_project/numpy.py를 로드 → 에러

파일명을 바꿔야 한다. 표준 라이브러리나 외부 패키지와 이름이 겹치지 않도록 한다.


해결 6: __init__.py 누락 (직접 만든 패키지)

my_project/
├── main.py
└── utils/
    ├── helper.py   ← 있음
    └── __init__.py ← ❌ 없으면 에러

# __init__.py 생성 (내용은 비워도 됨)
touch utils/__init__.py

진단 체크리스트

상황 확인 명령어 해결
설치가 됐는지 모르겠다 pip show 패키지명 없으면 python -m pip install
설치됐는데 못 찾는다 which python vs which pip python -m pip install 사용
가상환경 쓰는데 에러 which python이 venv 경로인지 source venv/bin/activate
IDE에서만 에러 IDE 인터프리터 설정 가상환경 Python 경로로 변경
설치명 헷갈린다 PyPI에서 패키지 검색 import명 확인 후 설치
직접 만든 모듈 에러 디렉토리에 __init__.py 있는지 touch 패키지디렉토리/__init__.py

빠른 진단 스크립트

어디에 설치됐는지 한 번에 보고 싶을 때:

import sys

print("Python 실행 경로:", sys.executable)
print("Python 버전:", sys.version)
print("\nPATH 목록:")
for path in sys.path:
    print(" -", path)

이걸 실행해서 나오는 경로들을 확인한다. 내가 설치한 경로가 sys.path에 없으면 Python이 찾을 수가 없다.


정리

ModuleNotFoundError는 모듈 자체가 없는 게 아니라 현재 Python이 보는 경로에 없는 것이다. 순서대로 확인하면 빠르게 해결된다:

  1. python -m pip install 패키지명으로 설치 — pip와 python 경로 일치 보장
  2. 가상환경 활성화 여부 확인 (which python이 venv 경로인지)
  3. 패키지명과 import명이 다른 경우 확인 (Pillow→PIL, opencv-python→cv2 등)
  4. 같은 이름의 .py 파일 충돌 여부 확인
  5. 직접 만든 패키지라면 __init__.py 누락 확인

python -m pip install을 습관화하면 1~2번 문제는 거의 안 만난다.

Next.js Hydration Error 해결 — 서버/클라이언트 불일치 원인과 디버깅 완전 가이드

🔍 검색 키워드: Next.js hydration error, hydration failed, Text content does not match server-rendered HTML, Hydration Mismatch, next.js 하이드레이션 에러 해결, useEffect hydration, next.js 서버 클라이언트 불일치

Next.js 프로젝트를 띄웠는데 콘솔에 이런 에러가 뜨는 순간 멘탈이 흔들린다.

Error: Hydration failed because the initial UI does not match what was rendered on the server.
Warning: Text content did not match. Server: "2026-06-21" Client: "2026-06-20"

에러 메시지는 있는데 어디서 터지는지 안 보인다. 서버에서는 잘 렌더링되는 것 같은데 클라이언트에서 뭔가 다르다는 것이다. 이 글에서 원인별로 정리해서 어디서 문제인지 빠르게 찾을 수 있도록 쓴다.


하이드레이션(Hydration)이 뭔지 먼저

Next.js는 서버에서 HTML을 미리 렌더링하고, 클라이언트에서 그 HTML 위에 React가 붙는다(hydrate). 이때 서버에서 만든 HTML과 클라이언트가 첫 렌더링하는 결과물이 완전히 일치해야 한다. 다르면 React가 신뢰를 잃고 에러를 던진다.


원인 1: 브라우저 전용 API를 렌더 중에 접근

가장 흔한 케이스다. window, document, localStorage 같은 객체는 서버(Node.js)에 없다.

// ❌ 잘못된 코드 — 서버에서 window가 undefined라 에러
function MyComponent() {
  const width = window.innerWidth; // 서버에서 undefined
  return <div>너비: {width}px</div>;
}
// ✅ 해결 — useEffect 안에서만 접근
import { useState, useEffect } from 'react';

function MyComponent() {
  const [width, setWidth] = useState(0);

  useEffect(() => {
    setWidth(window.innerWidth);
  }, []);

  return <div>너비: {width}px</div>;
}

또는 컴포넌트 전체를 클라이언트 전용으로 만들어야 한다면:

// ✅ dynamic import + ssr: false
import dynamic from 'next/dynamic';

const ClientOnlyComponent = dynamic(() => import('./ClientOnlyComponent'), {
  ssr: false,
});

원인 2: 날짜/시간 값 불일치

두 번째로 흔하고, 찾기 까다롭다. 서버는 UTC로 렌더링하고 클라이언트는 로컬 타임존으로 렌더링한다.

// ❌ 문제 코드
function PostDate() {
  const today = new Date().toLocaleDateString('ko-KR');
  return <span>{today}</span>;
  // 서버(UTC): "2026. 6. 21."
  // 클라이언트(KST +9): "2026. 6. 22."
}
// ✅ 해결 1 — 서버에서 데이터로 날짜를 받아 props로 전달
function PostDate({ date }: { date: string }) {
  return <span>{date}</span>;
}

// 서버 컴포넌트에서:
export default function Page() {
  const date = new Date().toISOString().split('T')[0]; // "2026-06-21" (UTC 기준 고정)
  return <PostDate date={date} />;
}
// ✅ 해결 2 — 클라이언트 전용 렌더링
function PostDate() {
  const [date, setDate] = useState('');

  useEffect(() => {
    setDate(new Date().toLocaleDateString('ko-KR'));
  }, []);

  if (!date) return null;
  return <span>{date}</span>;
}

원인 3: Math.random() / Date.now() 같은 불확정 값

서버와 클라이언트에서 각각 한 번씩 실행되므로 결과가 달라진다.

// ❌ 문제 코드
function Avatar() {
  const id = Math.floor(Math.random() * 1000); // 서버 42, 클라이언트 731
  return <img src={`/avatar/${id}.jpg`} />;
}

// ✅ 해결 — userId 기반의 결정론적 값 사용
function Avatar({ userId }: { userId: number }) {
  return <img src={`/avatar/${userId % 100}.jpg`} />;
}

원인 4: 인증 상태 불일치 (Clerk, NextAuth 등)

인증 라이브러리 쓸 때 자주 발생한다. 서버는 쿠키 없이 렌더링하고, 클라이언트는 로그인 상태를 감지해서 다른 UI를 그린다.

// ✅ 해결 - 인증 상태에 따라 분기되는 UI는 클라이언트 전용으로
'use client';

import { useUser } from '@clerk/nextjs';

export default function Header() {
  const { isLoaded, isSignedIn } = useUser();

  if (!isLoaded) return null; // 로드 전엔 아무것도 렌더링 안 함

  return isSignedIn ? <UserButton /> : <SignInButton />;
}

원인 5: Partial Prerendering(PPR) + Suspense 경계 없음 (Next.js 15+)

Next.js 15 이상에서 PPR(Partial Prerendering)을 쓸 때, 동적 데이터 영역을 <Suspense>로 감싸지 않으면 미스매치가 난다.

// ✅ 해결 — 동적 컴포넌트를 Suspense로 래핑
import { Suspense } from 'react';

export default function Page() {
  return (
    <Suspense fallback={<div>로딩 중...</div>}>
      <DynamicContent />
    </Suspense>
  );
}

async function DynamicContent() {
  const data = await fetchDynamicData();
  return <div>{data.value}</div>;
}

원인 6: 빌드 캐시 오염

코드는 맞는데 .next 캐시가 오래된 경우다. 주로 use client 경계 변경 후 발생한다.

# 캐시 완전 삭제 후 재시작
rm -rf .next
npm run dev

# 프로덕션 빌드라면
rm -rf .next
npm run build
npm start

빠른 진단 체크리스트

증상 의심 원인 해결
window is not defined 브라우저 API 직접 접근 useEffect 안에서 접근
날짜/숫자 값 불일치 new Date(), Math.random() 렌더 중 사용 useEffect 또는 서버 props 전달
인증 UI 불일치 인증 상태 SSR isLoaded 체크 후 렌더
코드 변경 후 갑자기 발생 빌드 캐시 오염 rm -rf .next 후 재시작
PPR 활성화 후 발생 Suspense 경계 없음 동적 컴포넌트 Suspense 래핑

정리

Hydration Error는 "서버에서 만든 HTML과 클라이언트 첫 렌더가 다르다"는 한 가지 원인에서 파생된다. 원인 찾는 순서:

  1. 브라우저 전용 API(window, document, localStorage) 렌더 중 직접 접근 여부 확인
  2. new Date(), Math.random() 같은 비결정론적 값 렌더 중 사용 여부 확인
  3. .next 캐시 삭제 후 재시작
  4. 인증 라이브러리 상태 로드 완료 전 렌더 여부 확인
  5. PPR 설정과 Suspense 경계 검토

대부분은 1~3번에서 해결된다.

토요일

GitHub Actions Node.js 빌드 실패 완전 정리 — npm ci, 캐시, 의존성 에러 해결

Kubernetes OOMKilled 에러 해결 (Exit Code 137) — Spring Boot/Java Pod 메모리 초과 완전 정리

🔍 검색 키워드: kubernetes oomkilled, exit code 137, pod oomkilled 해결, spring boot kubernetes memory, k8s OOMKilled java, 쿠버네티스 메모리 에러, kubectl oomkilled 원인

증상

Pod를 describe 했더니 이런 게 나온다.

State:          Running
  Last State:   Terminated
    Reason:     OOMKilled
    Exit Code:  137
    Started:    ...
    Finished:   ...

kubectl get pods에서 STATUS가 OOMKilled 또는 Error로 뜨고, Restarts 카운터가 계속 올라간다.

원인

컨테이너가 설정된 resources.limits.memory를 초과해서 커널이 강제로 프로세스를 죽인 것이다. Exit Code 137은 SIGKILL(128 + 9)을 의미한다.

Java 기반 Spring Boot 앱에서 특히 자주 발생하는 이유가 있다:

  • JVM íž™ 크기는 컨테이너 limit와 별개로 동작
  • JVM은 기본적으로 호스트 ì „ì²´ 메모리를 기준으로 힙을 잡으려 함
  • Off-heap 메모리(Metaspace, Stack, Direct Buffer 등)ê°€ íž™ 외에 추가로 소비됨

결과적으로 -Xmx512m 설정에 limit를 512Mi로 주면 반드시 OOMKilled 된다.

빠른 진단

1. 현재 메모리 사용량 확인

# Pod 메모리 현황
kubectl top pod <pod-name> -n <namespace>

# 상세 상태 확인
kubectl describe pod <pod-name> -n <namespace> | grep -A 10 "Last State"

# 최근 종료된 컨테이너 로그
kubectl logs <pod-name> --previous -n <namespace>

2. 리소스 설정 확인

kubectl get pod <pod-name> -o jsonpath='{.spec.containers[*].resources}' -n <namespace>
{
  "limits": { "memory": "512Mi" },
  "requests": { "memory": "256Mi" }
}

3. 노드 전체 메모리 현황

kubectl top nodes
kubectl describe node <node-name> | grep -A 5 "Allocated resources"

체크리스트

항목확인 방법정상 기준
Pod OOMKilled 여부kubectl describe pod → Reason: OOMKilledCompleted 또는 Running
현재 메모리 사용량kubectl top podlimit의 70% 이하
JVM 힙 설정JAVA_OPTS 환경변수limit의 50~75%
Metaspace 제한-XX:MaxMetaspaceSize256m 이하 권장
requests ≤ limitsDeployment YAML항상 requests ≤ limits
OOM Heap Dump-XX:+HeapDumpOnOutOfMemoryError파일 생성 확인

해결 방법

방법 1: JVM 힙을 컨테이너 limit에 맞게 명시 설정

가장 흔한 실수. limit 대비 JVM 힙 비율을 반드시 맞춰야 한다.

# deployment.yaml
containers:
  - name: my-spring-app
    image: my-app:latest
    resources:
      requests:
        memory: "512Mi"
        cpu: "250m"
      limits:
        memory: "1Gi"
        cpu: "1000m"
    env:
      - name: JAVA_OPTS
        value: "-Xms256m -Xmx768m -XX:MaxMetaspaceSize=256m -XX:+UseContainerSupport"

메모리 배분 계산 예시 (limit: 1Gi = 1024Mi)

영역크기비고
JVM Heap (-Xmx)768Milimit의 약 75%
Metaspace256Mi-XX:MaxMetaspaceSize
Stack, Direct Buffer 등~100MiOS/JVM 관리
총계~1124Milimit 초과 → OOMKilled 위험!
위 예시도 빡빡하다. limit를 1.5Gi 이상으로 올리거나 Metaspace를 줄여야 한다.

방법 2: -XX:+UseContainerSupport 활용 (Java 11+)

Java 11 이상에서는 JVM이 컨테이너 limit를 자동 인식한다.

env:
  - name: JAVA_OPTS
    value: "-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:MaxMetaspaceSize=256m"

-XX:MaxRAMPercentage=75.0은 컨테이너 limit의 75%를 자동으로 힙에 할당한다. -Xmx 하드코딩 없이 limit만 조정해도 알아서 따라온다.

Java 8 사용자: update 191 이상이면 UseContainerSupport 지원됨. 미만이면 -Xmx를 직접 계산해서 설정해야 한다.

방법 3: 실제 메모리 누수인 경우 — 힙 덤프 분석

limit를 올려도 계속 OOMKilled 된다면 실제 누수를 의심해야 한다.

env:
  - name: JAVA_OPTS
    value: >-
      -Xmx768m
      -XX:+HeapDumpOnOutOfMemoryError
      -XX:HeapDumpPath=/tmp/heapdump.hprof
      -XX:+ExitOnOutOfMemoryError

덤프 파일 로컬로 복사:

kubectl cp <namespace>/<pod-name>:/tmp/heapdump.hprof ./heapdump.hprof

Eclipse MAT 또는 VisualVM으로 열어서 Leak Suspects Report 돌린다.

방법 4: Spring Boot Actuator로 메모리 실시간 모니터링

<!-- pom.xml -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# application.yaml
management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus
# JVM 메모리 사용량 확인
curl http://localhost:8080/actuator/metrics/jvm.memory.used
curl http://localhost:8080/actuator/metrics/jvm.memory.max

Prometheus + Grafana로 시계열 수집하면 OOMKilled 발생 패턴을 잡기 쉽다.

방법 5: Node.js / Python Pod라면

Node.js:

env:
  - name: NODE_OPTIONS
    value: "--max-old-space-size=768"
resources:
  limits:
    memory: "1Gi"

Python (Gunicorn):

command: ["gunicorn", "--workers=2", "--threads=2", "app:app"]
resources:
  limits:
    memory: "512Mi"

HPA와 VPA 연계

단기 해결은 limit 증가지만, 장기적으로는 자동 스케일링이 필요하다.

# HPA - 메모리 기준 스케일 아웃
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: my-spring-app-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-spring-app
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 70

정리

OOMKilled는 대부분 limit 설정 부족 아니면 JVM 힙과 limit 불일치에서 온다. Spring Boot 앱이라면 순서대로 확인:

  1. kubectl describe pod로 OOMKilled 확인
  2. kubectl top pod로 실사용량 파악
  3. JAVA_OPTS에서 -Xmx vs limit 비율 점검
  4. Java 11+이면 UseContainerSupport + MaxRAMPercentage로 교체
  5. 그래도 반복되면 힙 덤프 분석

limit를 무한정 올리는 게 해결책이 아니다. 적정 비율로 잡고, 모니터링으로 추세를 보면서 튜닝하는 게 맞다.

금요일

.env 파일인데 환경변수가 undefined — dotenv 로딩 에러 완전 해결

🔍 검색 키워드: process.env undefined, dotenv not working, .env 파일 적용 안됨, python-dotenv not loading, 환경변수 undefined, docker 환경변수 안됨, next.js env not working

상황

.env 파일을 분명히 만들었는데 코드에서 undefined가 찍힌다.

# .env
DATABASE_URL=postgresql://localhost/mydb
SECRET_KEY=mysecretkey
console.log(process.env.DATABASE_URL); // undefined ???

또는 Python에서:

import os
print(os.getenv("SECRET_KEY"))  # None ???

배포 환경에서만 안 된다거나, Docker 안에서만 안 된다거나, 로컬은 되는데 서버에선 안 된다거나. 패턴은 다양하지만 원인은 몇 가지로 좁혀진다.


Node.js — dotenv 관련

원인 1: dotenv를 아예 안 불렀거나 너무 늦게 불렀다

// 잘못된 예 — DB 모듈보다 나중에 dotenv 로드
const db = require('./database'); // 이미 process.env 읽음
require('dotenv').config();       // 너무 늦었다
// 올바른 예 — 진입점 파일 최상단에서 먼저
require('dotenv').config();

const db = require('./database');
const app = require('./app');

TypeScript / ES Modules:

import 'dotenv/config'; // 이 방법이 제일 깔끔하다

// 또는
import dotenv from 'dotenv';
dotenv.config();

import { createConnection } from './db';

원인 2: .env 파일 경로가 다르다

기본적으로 dotenvprocess.cwd() 기준으로 .env를 찾는다. 실행 위치가 프로젝트 루트가 아니면 못 찾는다.

import path from 'path';
import dotenv from 'dotenv';

dotenv.config({
  path: path.resolve(__dirname, '../.env'),
});

원인 3: .env 파일이 .gitignore에 있고 서버에 없다

로컬에서만 됐던 이유가 이거다. .env는 보통 .gitignore에 들어가 있어서 서버에 배포가 안 된다.

# GitHub Actions — 시크릿 주입
steps:
  - name: Create .env file
    run: |
      echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" >> .env
      echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> .env

원인 4: 변수명에 공백이나 따옴표 문제

# 잘못된 .env
DATABASE_URL = postgresql://localhost/mydb   # = 주변 공백 금지

# 올바른 .env
DATABASE_URL=postgresql://localhost/mydb
SECRET_KEY=mysecret
APP_NAME="My App"  # 값에 공백이 필요하면 따옴표 사용

Python — python-dotenv

from dotenv import load_dotenv
import os

# .env 파일 로드 (현재 디렉토리 기준)
load_dotenv()

# 또는 경로 명시
load_dotenv(dotenv_path='/path/to/.env')

# 이미 설정된 환경변수를 덮어쓰려면
load_dotenv(override=True)

print(os.getenv("DATABASE_URL"))
💡 load_dotenv()는 이미 시스템에 설정된 환경변수는 덮어쓰지 않는다. 테스트 환경에서 .env 값으로 강제하려면 override=True를 써야 한다.
# Django settings.py
import os
from pathlib import Path
from dotenv import load_dotenv

BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env')

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.getenv('DB_NAME'),
        'USER': os.getenv('DB_USER'),
        'PASSWORD': os.getenv('DB_PASSWORD'),
        'HOST': os.getenv('DB_HOST', 'localhost'),
        'PORT': os.getenv('DB_PORT', '5432'),
    }
}

Next.js / React — 프레임워크별 규칙

Next.js는 dotenv를 직접 쓰지 않고 자체 env 로딩 시스템을 쓴다.

  • 서버에서만 쓰는 변수: DATABASE_URL=value
  • 클라이언트(브라우저)에서도 쓰는 변수: NEXT_PUBLIC_API_URL=value (반드시 NEXT_PUBLIC_ 접두사)
// 서버 컴포넌트 / API Route에서만 접근 가능
process.env.DATABASE_URL // ✅

// 클라이언트에서 접근하려면 NEXT_PUBLIC_ 접두사 필수
process.env.NEXT_PUBLIC_API_URL // ✅
process.env.API_URL // 클라이언트에서는 undefined ❌

환경 파일 우선순위:

.env.local > .env.development.local > .env.development > .env
⚠️ NEXT_PUBLIC_ 변수는 빌드 시 정적으로 교체된다. 빌드 후 값을 바꿔도 소용없다. 반드시 빌드 전에 설정해야 한다.

Docker / Docker Compose

# docker-compose.yml
services:
  app:
    build: .
    # 방법 1: env_file 지정 (권장)
    env_file:
      - .env

    # 방법 2: 직접 명시
    environment:
      - DATABASE_URL=postgresql://db:5432/mydb
      - SECRET_KEY=${SECRET_KEY}  # 호스트 환경변수에서
# Docker run
docker run --env-file .env myapp
docker run -e DATABASE_URL=xxx -e SECRET_KEY=yyy myapp
⚠️ Dockerfile에서 COPY .env .를 하면 이미지에 시크릿이 박힌다. .dockerignore.env를 넣고, 런타임에 주입하는 방식을 써야 한다.
# .dockerignore
.env
.env.*

상황별 체크리스트

증상원인확인/해결
로컬만 됨, 서버 안 됨.env가 서버에 없음CI/CD 시크릿 주입 또는 서버에 .env 생성
dotenv.config() 했는데 undefinedimport 순서 문제진입점 최상단에서 dotenv 먼저 로드
특정 변수만 undefined변수명 오타, 공백.env 파일 문법 확인
Docker 안에서 undefinedenv_file 미설정env_file 또는 -e 옵션으로 주입
Next.js 클라이언트에서 undefinedNEXT_PUBLIC_ 접두사 누락접두사 추가 후 재빌드
Python에서 Noneload_dotenv() 미호출import 후 load_dotenv() 호출
기존 시스템 변수가 우선override 미설정load_dotenv(override=True)

디버깅 팁

// 로드된 환경변수 키 목록 확인
console.log('ENV keys:', Object.keys(process.env).filter(k => !k.startsWith('npm_')));

// .env 경로 확인
const path = require('path');
console.log('Looking for .env at:', path.resolve(process.cwd(), '.env'));
# .env 파일에서 읽은 값만 확인 (시스템 변수 제외)
from dotenv import dotenv_values
config = dotenv_values(".env")
print(config)

마무리

환경변수 에러는 대부분 세 가지 중 하나다: .env 파일이 없거나, dotenv 로드를 너무 늦게 했거나, 프레임워크별 규칙을 무시했거나.

서버에 배포했을 때 갑자기 안 된다면 .env가 서버에 실제로 존재하는지부터 확인하고, Docker면 --env-file이나 env_file로 주입됐는지 확인한다. Next.js면 클라이언트에서 쓰는 변수에 NEXT_PUBLIC_ 붙이고 재빌드. 이 세 가지가 90%다.

PostgreSQL "FATAL: password authentication failed" 에러 완전 해결 가이드

🔍 검색 키워드: postgresql fatal password authentication failed, psql role does not exist, postgresql connection refused, pg_hba.conf, postgresql 비밀번호 에러

상황

PostgreSQL에 접속하려는데 이런 에러가 뜬다.

FATAL: password authentication failed for user "myapp"

또는

FATAL: role "myapp" does not exist

또는

psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed:
FATAL: Peer authentication failed for user "postgres"

전부 다르게 생겼지만 원인은 비슷한 경우가 많다. 하나씩 짚어보자.


원인 1: 비밀번호가 틀렸다 (가장 흔한 경우)

앱 설정 파일에 넣은 비밀번호와 실제 DB 비밀번호가 다른 거다. .env, application.yml, database.yml 등에서 비밀번호 오타나 환경별 혼용이 잦다.

확인 방법:

# postgres 유저로 직접 접속해서 비밀번호 재설정
sudo -u postgres psql

-- 현재 유저 목록 확인
\du

-- 비밀번호 재설정
ALTER USER myapp WITH PASSWORD 'newpassword';

원인 2: 유저 자체가 없다 (role does not exist)

DB는 있는데 유저를 만든 적이 없거나, 다른 환경에서 만든 유저가 이 환경에는 없는 경우다. 로컬에서 개발하다가 스테이징 DB로 붙으려 할 때 자주 발생한다.

해결:

-- postgres 슈퍼유저로 접속 후
CREATE USER myapp WITH PASSWORD 'yourpassword';

-- 데이터베이스 권한 부여
GRANT ALL PRIVILEGES ON DATABASE mydb TO myapp;

-- 스키마 권한도 줘야 하는 경우
GRANT ALL ON SCHEMA public TO myapp;

원인 3: pg_hba.conf 인증 방식 문제 (Peer auth failed)

Peer authentication failed 에러는 pg_hba.conf의 인증 방식 설정 문제다. 로컬 소켓 접속 시 OS 유저명과 DB 유저명이 같아야 하는 peer 방식으로 설정돼 있을 때 발생한다.

pg_hba.conf 위치 확인:

sudo -u postgres psql -c "SHOW hba_file;"
# 보통 /etc/postgresql/14/main/pg_hba.conf 또는 /var/lib/pgsql/data/pg_hba.conf

pg_hba.conf 수정:

sudo nano /etc/postgresql/14/main/pg_hba.conf

수정 전:

local   all   all   peer

수정 후 (비밀번호 인증으로 변경):

local   all   all   md5

또는 특정 유저만:

local   mydb   myapp   md5
host    mydb   myapp   127.0.0.1/32   md5

변경 후 재시작:

sudo systemctl restart postgresql

원인 4: Docker 환경에서 환경변수 미전달

Docker로 PostgreSQL 띄울 때 POSTGRES_PASSWORD 없이 컨테이너를 올리거나, 앱 컨테이너에 DB 접속 정보를 제대로 안 넘긴 경우다.

# docker-compose.yml 올바른 예시
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: mydb
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: mypassword
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp -d mydb"]
      interval: 5s
      timeout: 5s
      retries: 5

  app:
    build: .
    environment:
      DATABASE_URL: postgresql://myapp:mypassword@db:5432/mydb
    depends_on:
      db:
        condition: service_healthy
💡 depends_on은 컨테이너 시작 순서만 보장한다. PostgreSQL이 실제로 준비됐는지는 healthcheckcondition: service_healthy로 처리해야 한다.

원인 5: 접속 호스트/포트 오류

Connection refused는 PostgreSQL이 해당 주소에서 리슨하고 있지 않다는 뜻이다.

# PostgreSQL이 실제로 떠 있는지
sudo systemctl status postgresql

# 어느 포트에서 리슨 중인지
sudo ss -tlnp | grep 5432

# 외부 접속 허용 설정 확인
sudo grep listen_addresses /etc/postgresql/14/main/postgresql.conf

외부에서 접속하려면 postgresql.conf에서:

listen_addresses = '*'

그리고 pg_hba.conf에도 원격 접속 허용 라인 추가:

host    all   all   0.0.0.0/0   md5

상황별 체크리스트

증상확인할 것해결책
password authentication failed비밀번호 오타, 환경 혼용ALTER USER ... WITH PASSWORD
role does not exist유저 미생성CREATE USER + 권한 부여
Peer authentication failedpg_hba.conf 설정peer → md5 변경 후 재시작
Connection refusedPostgreSQL 미실행 또는 포트 불일치서비스 상태 확인, listen_addresses 설정
Docker에서만 발생컨테이너 간 네트워크, 환경변수db 호스트명 사용, healthcheck 추가

Node.js 접속 예시 (pg 라이브러리)

const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.DB_HOST || 'localhost',
  port: parseInt(process.env.DB_PORT || '5432'),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
  connectionTimeoutMillis: 5000,
  idleTimeoutMillis: 30000,
  max: 10,
});

pool.query('SELECT NOW()', (err, res) => {
  if (err) {
    console.error('DB 연결 실패:', err.message);
  } else {
    console.log('DB 연결 성공:', res.rows[0].now);
  }
});

Python (psycopg2, SQLAlchemy)

import psycopg2
from psycopg2 import OperationalError
import os

def create_connection():
    try:
        conn = psycopg2.connect(
            host=os.getenv("DB_HOST", "localhost"),
            port=int(os.getenv("DB_PORT", 5432)),
            database=os.getenv("DB_NAME"),
            user=os.getenv("DB_USER"),
            password=os.getenv("DB_PASSWORD"),
        )
        print("PostgreSQL 연결 성공")
        return conn
    except OperationalError as e:
        print(f"연결 실패: {e}")
        raise

from sqlalchemy import create_engine
DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_recycle=3600)

마무리

PostgreSQL 접속 에러는 대부분 세 가지다: 비밀번호 틀림, 유저 없음, pg_hba.conf 설정 문제. 에러 메시지를 정확히 읽으면 원인이 나온다. FATAL: 뒤에 오는 텍스트가 전부다. Connection refused는 PostgreSQL 자체가 안 떠있거나 포트가 다른 거고, authentication failed는 자격증명 문제, role does not exist는 유저 생성을 안 한 거다.

Docker 환경이면 컨테이너 간 네트워크와 healthcheck까지 챙겨야 한다.

목요일

Python 메모리 누수 디버깅 완전 가이드

Spring Boot UnexpectedRollbackException 완전 해결 가이드

🔍 검색 키워드: Spring Boot UnexpectedRollbackException, 트랜잭션 롤백 에러, Spring @Transactional 에러, rollback-only 에러, Spring 트랜잭션 전파

이 에러, 왜 뜨는 건가

운영 중에 갑자기 이런 에러를 마주하는 경우가 있다.

org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only

겉으로 보면 "조용히 롤백됐다"는 건데, 왜 롤백됐는지 이유가 안 보인다. 내 코드엔 예외처리도 했고, try-catch도 했는데 왜?

원인: Spring 트랜잭션 전파(Propagation) 구조 이해

핵심 개념

Spring의 기본 트랜잭션 전파 방식은 REQUIRED다. 즉, 이미 트랜잭션이 있으면 그 트랜잭션에 참여(join)한다.

문제는 여기서 발생한다.

[외부 트랜잭션 시작]
  └── 내부 서비스 호출 (REQUIRED → 외부 트랜잭션에 참여)
        └── 내부에서 예외 발생 → 트랜잭션에 rollback-only 마킹
  내부 예외를 try-catch로 잡음
  외부 트랜잭션 커밋 시도
      → BOOM: UnexpectedRollbackException

내부 메서드에서 예외가 발생해서 rollback-only로 마킹됐는데, 외부에서 예외를 잡아버리면 Spring은 "아 괜찮은 거구나"하고 커밋을 시도한다. 하지만 이미 롤백 마킹이 됐기 때문에 UnexpectedRollbackException이 터진다.

레벨별 해결 방법

Level 1 — 기초 (원인 파악부터)

상황 재현 코드:

@Service
@RequiredArgsConstructor
public class OrderService {
    private final PaymentService paymentService;

    @Transactional
    public void placeOrder(OrderRequest request) {
        // 주문 저장 로직...

        try {
            paymentService.processPayment(request.getPaymentInfo()); // 내부 트랜잭션 참여
        } catch (Exception e) {
            log.error("결제 실패: {}", e.getMessage()); // 예외 잡음
            // → 이 시점에 트랜잭션은 이미 rollback-only
        }

        // 커밋 시도 → UnexpectedRollbackException 발생!
    }
}

@Service
public class PaymentService {
    @Transactional // REQUIRED (기본값) → 외부 트랜잭션에 참여
    public void processPayment(PaymentInfo info) {
        // 내부 예외 발생
        throw new PaymentException("카드 한도 초과");
    }
}

트랜잭션 상태 디버깅:

@Transactional
public void placeOrder(OrderRequest request) {
    log.info("트랜잭션 활성: {}", TransactionSynchronizationManager.isActualTransactionActive());
    log.info("롤백 마킹: {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly());
}

Level 2 — 실무 해결책 (전파 방식 변경)

방법 1: REQUIRES_NEW로 별도 트랜잭션 분리

@Service
public class PaymentService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    // ↑ 기존 트랜잭션과 완전히 분리된 새 트랜잭션 시작
    public void processPayment(PaymentInfo info) {
        // 이 트랜잭션이 롤백돼도 외부 트랜잭션에 영향 없음
        paymentRepository.save(/* ... */);
    }
}
⚠️ 주의: REQUIRES_NEW는 별도 DB 커넥션을 사용한다. 커넥션 풀 고갈 위험이 있으니 남발하면 안 된다.

방법 2: noRollbackFor 설정

@Transactional(noRollbackFor = PaymentException.class)
public void processPayment(PaymentInfo info) {
    // PaymentException이 발생해도 롤백하지 않음
}

방법 3: 예외를 잡지 말고 던지기

@Transactional
public void placeOrder(OrderRequest request) {
    try {
        paymentService.processPayment(request.getPaymentInfo());
    } catch (PaymentException e) {
        // 잡지 말고 그냥 던진다
        throw e; // 혹은 새 예외로 래핑
        // → 외부 호출자가 트랜잭션 롤백 처리
    }
}

Level 3 — 고급 (아키텍처 관점에서 설계)

이벤트 기반 분리 (트랜잭션 완료 후 처리)

@Service
@RequiredArgsConstructor
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void placeOrder(OrderRequest request) {
        Order order = orderRepository.save(Order.of(request));

        // 트랜잭션 커밋 후 이벤트 발행
        eventPublisher.publishEvent(new OrderPlacedEvent(order.getId()));
    }
}

@Component
public class PaymentEventHandler {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    // ↑ 외부 트랜잭션 커밋 후에 실행 → 트랜잭션 오염 없음
    public void handleOrderPlaced(OrderPlacedEvent event) {
        paymentService.processPayment(event.getOrderId());
    }
}

Facade 패턴으로 트랜잭션 경계 명확히

@Service
public class OrderFacade {
    // @Transactional 없음 → 트랜잭션 경계 없음

    public void placeOrder(OrderRequest request) {
        orderService.saveOrder(request);        // 각각 독립 트랜잭션
        paymentService.processPayment(request); // 각각 독립 트랜잭션
    }
}

@Service
public class OrderService {
    @Transactional // 이 메서드 범위만 트랜잭션
    public void saveOrder(OrderRequest request) { /* ... */ }
}

상황별 해결 체크리스트

상황권장 방법주의사항
내부 예외가 외부와 독립적으로 처리돼야 할 때REQUIRES_NEW커넥션 풀 사용량 증가
특정 예외는 롤백 안 해도 될 때noRollbackFor데이터 정합성 검토 필요
결제 같은 외부 I/O가 있을 때@TransactionalEventListenerAFTER_COMMIT 타이밍 주의
서비스 레이어 설계를 바꿀 수 있을 때Facade 패턴트랜잭션 경계 재설계 필요
빠르게 임시 수정이 필요할 때예외 재던지기호출부에서 처리 필요

자주 하는 실수

실수 1: try-catch에서 예외를 먹어버리기

// ❌ 잘못된 코드
try {
    innerService.doSomething();
} catch (Exception e) {
    log.error("에러 발생", e);
    // 예외를 삼켜버림 → UnexpectedRollbackException 확정
}

// ✅ 올바른 코드
try {
    innerService.doSomething();
} catch (Exception e) {
    log.error("에러 발생", e);
    throw new BusinessException("처리 실패", e); // 반드시 다시 던지기
}

실수 2: Self-invocation (같은 클래스 내 메서드 호출)

@Service
public class MyService {
    @Transactional
    public void outer() {
        inner(); // 프록시를 거치지 않음 → @Transactional 무시됨
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() { /* ... */ }
    // → REQUIRES_NEW가 적용되지 않아서 여전히 같은 트랜잭션 사용
}

Self-invocation í•´ê²°:

@Service
@RequiredArgsConstructor
public class MyService {
    private final ApplicationContext context;

    public void outer() {
        MyService proxy = context.getBean(MyService.class); // 프록시 직접 가져오기
        proxy.inner();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() { /* ... */ }
}

정리

UnexpectedRollbackException은 트랜잭션 전파를 모르면 반드시 한 번은 만나는 에러다. 핵심은 하나다.

💡 내부 트랜잭션이 롤백 마킹되면, 같은 트랜잭션에 참여한 외부 트랜잭션도 롤백될 수밖에 없다.

해결은 ‫글 두 방향이다.

  • 트랜잭션을 분리한다 (REQUIRES_NEW, Facade 패턴, 이벤트 기반)
  • 예외를 제대로 다룬다 (먹지 말고 던지기, noRollbackFor)

실무에서는 REQUIRES_NEW를 무분별하게 쓰기보다 @TransactionalEventListener나 Facade 패턴으로 설계 단계에서 트랜잭션 경계를 명확히 하는 게 장기적으로 낫다.

화요일

GitHub Actions OOM 에러 해결: JavaScript heap out of memory 완전 정복

TypeScript "Type is not assignable" 에러 완전 정복

🔍 검색 키워드: TypeScript 타입 에러, Type is not assignable to type, TS2322, TS2345, TypeScript 타입 오류 해결, typescript type error 해결

TypeScript 쓰다 보면 한 번쯤은 이 에러를 만난다.

Type 'string' is not assignable to type 'number'.  ts(2322)

처음엔 당황스럽지만, 패턴을 이해하면 금방 잡힌다. 이 글은 자주 나오는 케이스별로 원인과 해결을 정리했다.


에러가 생기는 이유

TypeScript는 정적 타입 언어다. 컴파일 타임에 타입을 검사하기 때문에, 선언된 타입과 실제 할당값이 다르면 에러를 낸다. Java의 컴파일 에러와 같은 맥락이다.


레벨 1 (초보자) — 기본 타입 불일치

증상

let count: number = "5";  // ❌ TS2322

원인

number 타입으로 선언된 변수에 string을 대입했다.

해결

// 방법 1: 타입에 맞는 값으로 수정
let count: number = 5;

// 방법 2: 타입 변환
let count: number = parseInt("5");

// 방법 3: 타입 선언 수정
let count: string = "5";

레벨 2 (중급자) — 함수 파라미터 타입 불일치

증상

function greet(name: string): string {
  return `Hello, ${name}`;
}
greet(123);  // ❌ TS2345

해결

// 방법 1: 올바른 타입으로 전달
greet("홍길동");

// 방법 2: Union 타입 사용
function greet(name: string | number): string {
  return `Hello, ${String(name)}`;
}

레벨 3 (중급자) — Object 타입 구조 불일치

증상

interface User {
  id: number;
  name: string;
  email: string;
}
const user: User = {
  id: 1,
  name: "홍길동"
  // email 누락 → ❌ TS2322
};

해결

// 방법 1: 빠진 프로퍼티 추가
const user: User = { id: 1, name: "홍길동", email: "hong@example.com" };

// 방법 2: 선택적 프로퍼티로 변경
interface User { id: number; name: string; email?: string; }

// 방법 3: Partial 사용
const partialUser: Partial<User> = { id: 1, name: "홍길동" };

레벨 4 (중급자) — 배열/제네릭 타입 불일치

증상

const ids: number[] = [1, 2, "3", 4];  // ❌ TS2322

function first<T>(arr: T[]): T { return arr[0]; }
const result: number = first(["a", "b"]);  // ❌ TS2322

해결

const ids: (number | string)[] = [1, 2, "3", 4];
const result: string = first(["a", "b"]);

레벨 5 (실무자) — null/undefined 처리

증상

function getUser(id: number): User | null {
  return id === 1 ? { id: 1, name: "홍길동", email: "hong@example.com" } : null;
}
const user: User = getUser(999);  // ❌ TS2322: 'User | null'

해결

// 방법 1: 타입에 null 포함
const user: User | null = getUser(999);

// 방법 2: null 체크 후 사용 (Type narrowing)
const maybeUser = getUser(999);
if (maybeUser !== null) {
  const user: User = maybeUser;
}

// 방법 3: Non-null assertion
const user: User = getUser(1)!;

// 방법 4: Nullish coalescing으로 기본값
const user: User = getUser(999) ?? { id: 0, name: "Guest", email: "" };

레벨 6 (고급자) — 타입 추론 문제

증상

const config = { mode: "development" };  // TypeScript가 string으로 추론
function setup(mode: "development" | "production") {}
setup(config.mode);  // ❌ TS2345

해결

// 방법 1: as const로 리터럴 타입 고정
const config = { mode: "development" } as const;

// 방법 2: 명시적 타입 선언
const config: { mode: "development" | "production" } = { mode: "development" };

// 방법 3: 타입 단언
setup(config.mode as "development" | "production");

상황별 체크리스트

상황확인 포인트
변수 할당 에러선언 타입과 값 타입 일치 여부
함수 인자 에러함수 시그니처와 전달값 타입 비교
객체 에러인터페이스 필수 프로퍼티 누락 여부
null 에러strictNullChecks 활성화 여부 확인
리터럴 타입 에러as const 또는 명시적 타입 선언 필요
any 남용any 대신 unknown + 타입 가드 사용

자주 하는 실수 — any로 도배

// ❌ TypeScript 쓰는 의미 없음
const data: any = fetchData();

// ✅ unknown 쓰고 타입 가드로 좁혀라
const data: unknown = fetchData();
if (typeof data === "string") {
  console.log(data.toUpperCase());
}

마무리

TypeScript 타입 에러는 대부분 세 가지다.

  1. 타입을 잘못 선언했거나
  2. 값이 여러 타입이 될 수 있는데 하나만 선언했거나
  3. null/undefined 처리를 안 했거나

에러 메시지를 읽으면 어느 쪽인지 대부분 나온다. ts(숫자) 에러코드로 TypeScript 공식 문서에서 정확한 설명도 찾을 수 있다.

월요일

MySQL 연결 에러 완전 정복: Too many connections, Connection refused, Access denied 해결법

Python SSL 인증서 에러 완전 정복: CERTIFICATE_VERIFY_FAILED 원인과 해결법

🔍 검색 키워드: CERTIFICATE_VERIFY_FAILED · Python SSL 에러 · ssl.SSLCertVerificationError · Python requests SSL 오류 · macOS Python SSL · Python urllib SSL 인증서 오류

Python으로 외부 API 호출하거나 크롤링하다 보면 이 에러 한 번쯤은 만난다.

ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED]
certificate verify failed: unable to get local issuer certificate (_ssl.c:1129)

이거 처음 보면 당황스럽다. 에러 메시지만 봐서는 뭐가 문제인지 감도 안 온다. 원인부터 단계별 해결까지 정리한다.


왜 이 에러가 나는가

Python이 HTTPS 요청을 보낼 때 서버의 SSL 인증서를 검증한다. 이 과정에서 신뢰할 수 있는 루트 인증서(CA) 목록을 참조하는데, 여기에 서버 인증서 체인이 없으면 에러가 난다.

주요 원인은 크게 세 가지다.

  • Python 자체 CA 번들이 오래됨 — macOS에서 Python 3.6+ 공식 배포판은 시스템 인증서를 쓰지 않고 번들로 따로 관리하는데, 이게 업데이트 안 되면 발생한다.
  • 회사 네트워크의 프록시/방화벽 — 기업 환경에서 HTTPS 트래픽을 자체 인증서로 중간에서 가로채는(MITM) 경우, Python이 그 인증서를 모른다.
  • 자체 서명(self-signed) 인증서를 가진 서버 — 내부 개발 서버나 테스트 환경에서 자주 발생.

상황별 체크리스트

상황원인권장 해결책
macOS + Python 공식 설치번들 CA 미업데이트Install Certificates.command 실행
회사 내부 네트워크기업 프록시 인증서기업 CA 인증서 추가
내부 개발 서버self-signed 인증서verify=인증서경로 지정
Docker/CI 환경CA 번들 미포함certifi 패키지 + 환경변수 설정
갑자기 발생 (예전엔 됐는데)Python 또는 OS 업그레이드certifi 재설치

레벨별 해결 방법

초보자 — 일단 돌아가게 만들기 (비추천)

개발 중에 빠르게 확인만 할 때 쓰는 방법이다. 절대 프로덕션에 쓰면 안 된다.

import requests

# SSL 검증 비활성화 — 개발용으로만!
response = requests.get("https://example.com", verify=False)

중급자 — certifi로 CA 번들 업데이트

가장 권장하는 방법이다. certifi는 Mozilla가 관리하는 신뢰할 수 있는 CA 목록을 Python에서 쓸 수 있게 패키징한 라이브러리다.

pip install --upgrade certifi
import requests
import certifi

response = requests.get("https://example.com", verify=certifi.where())
print(response.status_code)

환경변수로 전역 적용 (추천)

# Linux/macOS
export SSL_CERT_FILE=$(python -c "import certifi; print(certifi.where())")
export REQUESTS_CA_BUNDLE=$(python -c "import certifi; print(certifi.where())")

macOS 전용 — Install Certificates.command 실행

# 해당 버전의 Install Certificates 스크립트 실행 (버전에 맞게 경로 수정)
open /Applications/Python\ 3.12/Install\ Certificates.command

고급자 — 기업 CA 인증서 추가

import requests

# 단일 파일
response = requests.get("https://internal.company.com", verify="/path/to/company-ca.crt")
import certifi

with open("/path/to/company-ca.crt", "r") as f:
    company_cert = f.read()

with open(certifi.where(), "a") as bundle:
    bundle.write("\n" + company_cert)

Docker/CI 환경

FROM python:3.12-slim

RUN pip install certifi

ENV SSL_CERT_FILE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
ENV REQUESTS_CA_BUNDLE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
# GitHub Actions
- name: Fix SSL certificates
  run: |
    pip install --upgrade certifi
    echo "SSL_CERT_FILE=$(python -c 'import certifi; print(certifi.where())')" >> $GITHUB_ENV
    echo "REQUESTS_CA_BUNDLE=$(python -c 'import certifi; print(certifi.where())')" >> $GITHUB_ENV

urllib / aiohttp 사용자 참고

import ssl, urllib.request, certifi

ctx = ssl.create_default_context(cafile=certifi.where())
req = urllib.request.Request("https://example.com")
with urllib.request.urlopen(req, context=ctx) as response:
    data = response.read()
import aiohttp, ssl, certifi

async def fetch(url):
    ssl_ctx = ssl.create_default_context(cafile=certifi.where())
    connector = aiohttp.TCPConnector(ssl=ssl_ctx)
    async with aiohttp.ClientSession(connector=connector) as session:
        async with session.get(url) as response:
            return await response.text()

절대 하면 안 되는 것

⚠️ 프로덕션에서 이런 코드 보이면 반드시 수정해야 한다.
# ❌ 절대 하지 말 것
import ssl
ssl._create_default_https_context = ssl._create_unverified_context

# ❌ 이것도 마찬가지
requests.get(url, verify=False)

# ❌ 환경변수로 전체 비활성화
# PYTHONHTTPSVERIFY=0

SSL 검증을 끄는 순간 중간자 공격(MITM)에 완전히 노출된다. 개발 편의를 위해 껐다가 프로덕션에 그대로 배포되는 사고가 실제로 발생한다.


정리

우선순위해결책상황
1순위Install Certificates.commandmacOS + 공식 Python 설치
2순위pip install --upgrade certifi + 환경변수대부분의 환경
3순위기업 CA 인증서 추가회사 내부 네트워크
4순위verify=False로컬 개발 일시적 확인 (프로덕션 절대 불가)
💡 실무에서 이 에러를 자주 만나는 패턴은 Python 버전 업그레이드 직후, 또는 새 팀원이 회사 환경 세팅할 때다. 팀 위키에 이 내용 정리해두면 반복 질문 많이 줄어든다.

Redis 연결 에러 완전 정복: ECONNREFUSED 127.0.0.1:6379 트러블슈팅

🔍 검색 키워드: redis connection refused, redis ECONNREFUSED 6379, redis 연결 안됨, ioredis 연결 에러, spring boot redis 연결 실패, node redis ECONNREFUSED, docker redis 연결 에러

Redis 붙이다가 처음 보는 에러 아니다. 누구나 한 번쯤은 밟는다.

Error: connect ECONNREFUSED 127.0.0.1:6379

이거 뜨면 일단 당황하지 말고 순서대로 확인하면 금방 해결된다. 원인은 대부분 세 가지 중 하나다.


에러가 뜨는 주요 상황

상황에러 메시지
Node.js (ioredis)[ioredis] Unhandled error event: Error: connect ECONNREFUSED 127.0.0.1:6379
Node.js (node-redis)Error: Redis connection to 127.0.0.1:6379 failed - connect ECONNREFUSED
Spring BootUnable to connect to Redis; nested exception is io.lettuce.core.RedisConnectionException
Python (redis-py)redis.exceptions.ConnectionError: Error 111 connecting to localhost:6379.
Docker 컨테이너 간connect ECONNREFUSED 127.0.0.1:6379 (컨테이너 내부에서 호스트 Redis 접근 시도)

레벨 1: 입문자 — Redis가 켜져 있나요?

가장 흔한 원인. Redis 서버가 꺼져 있으면 당연히 붙을 수 없다.

Redis 실행 상태 확인

# 프로세스 확인
ps aux | grep redis-server

# 포트 리스닝 확인
ss -tlnp | grep 6379

# systemd 기반 (Ubuntu/CentOS)
sudo systemctl status redis

Redis 직접 연결 테스트

redis-cli ping
# 정상이면: PONG
# 실패하면: Could not connect to Redis at 127.0.0.1:6379: Connection refused

Redis 실행 방법

# systemd로 시작
sudo systemctl start redis
sudo systemctl enable redis   # 부팅 시 자동 시작

# 백그라운드 실행
redis-server --daemonize yes

레벨 2: 실무자 — 포트/바인딩/방화벽 확인

Redis는 켜져 있는데 연결이 안 되면 이쪽을 본다.

bind 설정 문제

/etc/redis/redis.conf 기본 설정:

# 기본값: 127.0.0.1만 허용 (로컬호스트 전용)
bind 127.0.0.1

# 외부 접속 허용하려면 (주의: 보안 설정 필수)
bind 0.0.0.0

# 변경 후 재시작
sudo systemctl restart redis

방화벽 확인

# UFW (Ubuntu)
sudo ufw status
sudo ufw allow 6379

# 외부에서 포트 테스트
nc -zv <서버IP> 6379

requirepass 설정 시 인증 필요

redis-cli -a yourpassword ping

# 또는 연결 후 AUTH
redis-cli
> AUTH yourpassword
> PING

레벨 3: 고급 — Docker, Kubernetes 환경

이게 제일 헷갈린다. 컨테이너 안에서 127.0.0.1:6379컨테이너 자신을 가리킨다.

Docker Compose로 Redis 연결

# docker-compose.yml
version: '3.8'
services:
  app:
    build: .
    environment:
      - REDIS_HOST=redis      # 127.0.0.1이 아니라 서비스명!
      - REDIS_PORT=6379
    depends_on:
      - redis

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
// Node.js - 잘못된 예
const redis = new Redis({ host: '127.0.0.1', port: 6379 });

// 올바른 예
const redis = new Redis({
  host: process.env.REDIS_HOST || 'redis',
  port: parseInt(process.env.REDIS_PORT || '6379'),
});

호스트 머신의 Redis에 접근하는 경우

services:
  app:
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      - REDIS_HOST=host.docker.internal

언어별 연결 코드 및 에러 핸들링

Node.js — ioredis

const Redis = require('ioredis');

const redis = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD || undefined,
  retryStrategy(times) {
    return Math.min(times * 50, 2000);
  },
  maxRetriesPerRequest: 3,
});

redis.on('error', (err) => console.error('Redis 연결 에러:', err.message));
redis.on('connect', () => console.log('Redis 연결 성공'));

Python — redis-py

import redis, os

r = redis.Redis(
    host=os.getenv('REDIS_HOST', '127.0.0.1'),
    port=int(os.getenv('REDIS_PORT', 6379)),
    password=os.getenv('REDIS_PASSWORD'),
    decode_responses=True,
    socket_connect_timeout=5,
    retry_on_timeout=True,
)

try:
    r.ping()
    print("Redis 연결 성공")
except redis.exceptions.ConnectionError as e:
    print(f"Redis 연결 실패: {e}")

Spring Boot — application.yml

spring:
  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
      password: ${REDIS_PASSWORD:}
      timeout: 5000ms
      lettuce:
        pool:
          max-active: 10
          max-idle: 10
          min-idle: 2

원인별 체크리스트

체크 항목확인 방법조치
Redis 서버 실행 중?ps aux | grep redissystemctl start redis
포트 리스닝 중?ss -tlnp | grep 6379포트 충돌 확인
bind 설정 맞음?redis.conf 확인bind 0.0.0.0
방화벽 열려 있음?ufw status포트 허용
Docker 환경?컨테이너 여부 확인서비스명으로 host 변경
인증 필요?requirepass 설정 확인password 파라미터 추가
TLS 사용 중?Redis 6.0+ 설정 확인tls:// 스킴 및 인증서 설정
원격 서버?네트워크 경로 확인VPN, 보안그룹 확인

자주 하는 실수 TOP 3

1. Docker에서 localhost 씀
컨테이너 안에서 localhost는 컨테이너 자신이다. 다른 컨테이너의 Redis에 붙으려면 서비스명을 써야 한다.

2. 환경변수 안 쓰고 하드코딩
로컬에서 127.0.0.1로 하드코딩해두고 스테이징/프로덕션에 그대로 올리면 터진다. 처음부터 환경변수로 빼두자.

3. Redis 안 뜨고 앱부터 뜸
depends_on은 컨테이너 시작 순서만 보장하지 Redis 준비를 보장하지 않는다. healthcheck를 써야 한다.

redis:
  image: redis:7-alpine
  healthcheck:
    test: ["CMD", "redis-cli", "ping"]
    interval: 10s
    timeout: 5s
    retries: 5

app:
  depends_on:
    redis:
      condition: service_healthy

마무리

Redis 연결 에러의 90%는 위 체크리스트로 해결된다. 나머지 10%는 TLS, 클러스터 모드, Sentinel 설정 같은 고급 주제인데 그건 따로 다루겠다.

에러 메시지를 봤을 때 "Redis가 켜져 있냐 → 주소/포트가 맞냐 → 네트워크가 열려 있냐" 이 순서로만 확인해도 대부분 잡힌다.

금요일

npm install이 안 된다고? 원인 파악 없이 --force 치지 마라

npm install 에러 npm ERR! code ERESOLVE node_modules 삭제 후 재설치 npm ci 차이 package-lock.json 충돌 peer dependency 에러

프로젝트 클론하고 npm install 한 번에 되면 그날은 운이 좋은 날이다. 실무에서 이게 한 번에 되는 경우가 얼마나 되냐면, 팀이 클수록, 프로젝트 오래될수록 확률이 줄어든다.

문제는 에러 메시지를 제대로 읽지 않고 npm install --force 또는 npm install --legacy-peer-deps를 무지성으로 치는 경우다. 이러면 당장은 되는 것처럼 보이지만 나중에 런타임에서 이상한 에러로 돌아온다. 진짜 실무자는 에러 메시지를 읽는다.

에러 메시지별 원인과 해결

ERESOLVE: peer dependency 충돌

npm ERR! code ERESOLVE
npm ERR! ERESOLVE unable to resolve dependency tree
npm ERR!
npm ERR! While resolving: myapp@1.0.0
npm ERR! Found: react@18.2.0
npm ERR!   react@"^18.2.0" from the root project
npm ERR!
npm ERR! Could not resolve dependency:
npm ERR! peer react@"^17.0.0" from some-library@2.1.0

이 에러는 some-library가 React 17을 요구하는데 프로젝트에는 React 18이 깔려 있다는 뜻이다. npm 7 버전부터 peer dependency를 엄격하게 검사하기 시작해서 이 에러가 갑자기 늘었다.

선택지는 세 가지다.

1. 라이브러리를 업데이트한다 (가장 좋음)

npm install some-library@latest

해당 라이브러리가 최신 버전에서 React 18을 지원하면 끝난다. 이게 제일 먼저 시도해야 할 옵션이다.

2. --legacy-peer-deps 옵션 (차선책)

npm install --legacy-peer-deps

npm 6 시절 방식으로 peer dependency를 그냥 무시하고 설치한다. 대부분의 경우 문제없이 동작하지만, 진짜 비호환 라이브러리가 섞이면 런타임 에러가 난다. 팀 전체가 이 옵션을 공유한다면 .npmrc에 박아두는 게 낫다.

# .npmrc
legacy-peer-deps=true

3. --force는 마지막 수단

npm install --force

--force는 캐시도 무시하고 버전 충돌도 무시하고 그냥 밀어붙인다. 개발 의존성이나 급할 때 쓰는 거고, CI/CD에서 이걸 쓰고 있다면 뭔가 잘못된 거다.

ENOENT: node_modules 꼬임

npm ERR! code ENOENT
npm ERR! syscall open
npm ERR! path /project/node_modules/.package-lock.json
npm ERR! errno -2
npm ERR! enoent ENOENT: no such file or directory

node_modules가 중간에 망가진 경우다. 삭제하고 다시 설치하면 된다.

# node_modules 통째로 삭제
rm -rf node_modules package-lock.json

# 클린 설치
npm install

Windows라면:

Remove-Item -Recurse -Force node_modules
Remove-Item package-lock.json
npm install

그냥 node_modules만 지우고 재설치하면 되는데, package-lock.json도 같이 지우는 게 나을 때가 있다. package-lock.json이 실제 package.json과 어긋나 있는 경우에 이 에러가 나기도 해서다. 단, package-lock.json을 지우면 의존성 버전이 최신으로 바뀔 수 있으니 팀 공유 프로젝트에선 조심해야 한다.

EACCES: 권한 문제

npm ERR! code EACCES
npm ERR! syscall mkdir
npm ERR! path /usr/local/lib/node_modules
npm ERR! errno -13
npm ERR! Error: EACCES: permission denied

글로벌 설치할 때 자주 나온다. sudo npm install -g로 해결했다면 잠깐은 괜찮지만, 이 방식이 습관되면 나중에 권한 문제가 더 복잡해진다. 올바른 방법은 npm 기본 디렉토리를 사용자 홈으로 옮기는 거다.

# npm 글로벌 디렉토리를 홈 폴더로 변경
mkdir ~/.npm-global
npm config set prefix '~/.npm-global'

# PATH에 추가 (~/.zshrc 또는 ~/.bashrc)
export PATH=~/.npm-global/bin:$PATH

# 적용
source ~/.zshrc

ETIMEDOUT / ECONNRESET: 네트워크 이슈

npm ERR! code ETIMEDOUT
npm ERR! errno ETIMEDOUT
npm ERR! network request to https://registry.npmjs.org/lodash failed

회사 네트워크나 VPN 환경에서 자주 나온다. 몇 가지 확인 포인트:

# npm 레지스트리 확인
npm config get registry

# 회사 사설 레지스트리 쓰고 있다면
npm config set registry https://your-company-registry.com/

# 원복
npm config set registry https://registry.npmjs.org/

# 프록시 환경이면
npm config set proxy http://proxy.company.com:8080
npm config set https-proxy http://proxy.company.com:8080

타임아웃 에러는 단순히 npm 서버 응답이 느린 경우도 있어서 재시도 먼저 해본다. 그래도 계속 나오면 레지스트리 설정 확인.

npm install vs npm ci — 이 차이 모르는 사람 많다

CI/CD 파이프라인에서 npm install을 쓰고 있다면 npm ci로 바꿔라.

구분npm installnpm ci
package-lock.json 없으면새로 생성에러로 중단
버전 범위(^, ~)최신 버전으로 설치 가능lock 파일 버전 그대로
node_modules있으면 그대로 두고 업데이트무조건 지우고 재설치
용도개발 환경CI/CD, 배포

npm installpackage.json의 버전 범위 안에서 최신 버전을 가져올 수 있다. "react": "^18.0.0"이면 18.x 최신을 잡는다는 얘기다. CI에서 이걸 쓰면 빌드할 때마다 버전이 달라질 수 있다. npm cipackage-lock.json에 적힌 정확한 버전만 설치한다. 재현 가능한 빌드를 원한다면 CI에서 npm ci가 맞다.

자주 보는 실수 모음

package-lock.json을 .gitignore에 넣는 경우

가끔 이렇게 된 프로젝트가 있다. package-lock.json은 반드시 커밋해야 한다. 이게 없으면 팀원마다 설치되는 패키지 버전이 달라지고, "내 로컬에선 되는데 왜 CI가 터지냐"는 상황이 만들어진다.

npm 버전이 팀마다 다른 경우

프로젝트 루트에 .nvmrcpackage.jsonengines 필드로 버전 명시해두는 습관을 들이자.

{
  "engines": {
    "node": ">=18.0.0",
    "npm": ">=9.0.0"
  }
}

yarn/pnpm 섞어쓰기

package-lock.json(npm), yarn.lock(yarn), pnpm-lock.yaml(pnpm)이 동시에 존재하는 프로젝트가 있다. 이 상태로 팀에서 사람마다 다른 패키지 매니저 쓰면 lock 파일 충돌 지옥이 된다. package.jsonpackageManager 필드로 통일해두자.

{
  "packageManager": "npm@10.2.0"
}

정리

npm 에러는 대부분 세 가지 중 하나다: peer dependency 충돌, node_modules 꼬임, 네트워크/권한 문제. 에러 메시지 첫 줄에 나오는 code를 읽으면 원인이 나온다. --force는 진짜 마지막 수단이고, CI에선 npm ci 써라.

Git Merge Conflict 완전 정복 — 겁먹지 말고 읽어봐라

🔍 검색 키워드: git merge conflict 해결, git 충돌 해결, merge conflict 뜨는 이유, rebase conflict, git pull 충돌, git 머지 에러

왜 merge conflict가 생기는가

두 사람이 같은 파일의 같은 줄을 각자 다르게 수정하면 Git은 어느 쪽을 선택해야 할지 모른다. 그래서 멈추고 사람한테 결정을 넘긴다. 그게 전부다. 무서운 게 없다.

레벨 1 — 기초: 충돌 마커 읽기

충돌이 생기면 Git은 파일 안에 이런 마커를 심어준다.

<<<<<<< HEAD
const greeting = "안녕하세요";  // 내 변경사항
=======
const greeting = "Hello";       // 상대방 변경사항
>>>>>>> feature/english-greeting
  • <<<<<<< HEAD ~ ======= : 현재 브랜치(내 것)
  • ======= ~ >>>>>>> : 병합 대상 브랜치(상대 것)

해결 방법은 단순하다. 둘 중 하나를 고르거나, 둘 다 합치거나, 완전히 새로 쓰거나. 마커 3개(<<<<<<<, =======, >>>>>>>)를 모두 제거하고 원하는 최종 코드만 남기면 된다.

# 충돌 파일 확인
git status

# 수동 편집 후
git add src/greeting.js
git commit

레벨 2 — 실무: 자주 마주치는 상황별 대처

상황 1: git pull 했더니 충돌 폭탄

git pull origin main
# CONFLICT (content): Merge conflict in src/api.js
# Automatic merge failed; fix conflicts and then commit the result.
단계명령어설명
1git status충돌 파일 목록 확인
2에디터에서 파일 열기마커 찾아서 수동 편집
3git add <파일>해결된 파일 스테이징
4git commit머지 커밋 생성 (메시지 자동 입력됨)

상황 2: merge 중 충돌, 그냥 포기하고 싶을 때

git merge --abort

--abort 하면 merge 시작 전 상태로 되돌아간다. 깔끔하게 포기할 수 있다.

상황 3: rebase 중 충돌

git rebase main
# CONFLICT (content): Merge conflict in src/user.js

rebase는 커밋 하나씩 재적용하기 때문에 충돌도 커밋 단위로 난다.

# 각 충돌 해결 후
git add src/user.js
git rebase --continue  # 다음 커밋으로 진행

# 포기할 때
git rebase --abort

상황 4: 특정 파일을 그냥 한 쪽으로 덮어쓰고 싶을 때

# 내 것(HEAD) 으로 덮어쓰기
git checkout --ours src/config.js

# 상대 브랜치 것으로 덮어쓰기
git checkout --theirs src/config.js

git add src/config.js

레벨 3 — 고급: 도구 활용 및 예방

VS Code에서 충돌 해결

VS Code는 충돌 파일에 시각적 버튼을 표시해준다.

  • Accept Current Change → HEAD 것 선택
  • Accept Incoming Change → 병합 대상 것 선택
  • Accept Both Changes → 둘 다 유지
  • Compare Changes → diff 보기

터미널보다 훨씬 빠르다. 파일이 많을 때 특히 유용하다.

git mergetool 사용

# VS Code를 mergetool로 설정
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'
git mergetool

Python 프로젝트: requirements.txt 충돌

<<<<<<< HEAD
requests==2.28.0
=======
requests==2.31.0
>>>>>>> feature/update-deps

보통 높은 버전을 선택하되, 호환성 깨지는 메이저 버전 업이면 테스트 먼저.

Node.js: package-lock.json 충돌

package-lock.json 충돌은 직접 해결하려 하지 말고 이게 낫다.

# 충돌난 package-lock.json 삭제 후 재생성
git checkout --theirs package-lock.json
npm install

# 또는 아예 새로 생성
rm package-lock.json
npm install
git add package-lock.json

Java/Spring: application.yml 충돌

server:
  port: 8080         # <-- HEAD vs 8081 충돌 예시
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/dev_db

환경별 설정이 섞이는 케이스. application-dev.yml, application-prod.yml로 분리하면 근본 해결된다.

Nginx 설정 파일 충돌

location /api {
    proxy_pass http://localhost:3000;
    proxy_set_header X-Real-IP $remote_addr;  # incoming 것 추가
}

두 변경사항을 합치는 게 맞다. 헤더 추가된 버전에 로컬 주소를 넣거나, 환경변수로 빼거나.

충돌 자주 나는 상황 진단표

상황원인예방법
pull할 때마다 충돌브랜치가 너무 오래됨자주 rebase/merge
같은 파일 반복 충돌역할 분리 안 됨파일 소유권 명확히
package-lock.json 항상 충돌여러 명이 npm installCI에서만 lock 갱신
config 파일 충돌환경 설정 공유환경별 파일 분리
이진 파일(이미지 등) 충돌.gitattributes 없음이진 파일 전략 설정

.gitattributes로 이진 파일 전략 설정

# 이진 파일은 충돌 없이 무조건 덮어쓰기
*.png binary
*.jpg binary
*.pdf binary

# package-lock.json은 theirs 전략
package-lock.json merge=theirs

실수하기 쉬운 것들

1. 마커 제거 안 하고 커밋

# 커밋 전 마커 잔존 여부 확인
grep -r "<<<<<<< " src/

CI에 충돌 마커 검사 스텝 추가해두면 실수 방지된다.

2. rebase 후 force push

rebase 완료 후 원격에 올릴 때는 --force 대신 이걸 써라.

git push --force-with-lease origin feature/my-branch

남이 push한 게 있으면 실패해서 안전하다.

요약

  • 충돌 마커(<<<<<<<, =======, >>>>>>>) 3개 찾아서 제거하고 원하는 코드 남기면 끝
  • 포기하고 싶으면 git merge --abort 또는 git rebase --abort
  • VS Code mergetool 설정해두면 시간 절약
  • package-lock.json은 직접 고치지 말고 재생성
  • 충돌 자주 나면 브랜치 수명을 짧게 가져가는 게 근본 해결책

Docker 포트 충돌 & 컨테이너 연결 에러 완전 정복

🔍 검색 키워드: Docker 포트 충돌, port is already allocated, docker: Error response from daemon, 컨테이너 연결 안됨, bind failed port already in use, docker ps -a, EADDRINUSE 도커

왜 이 에러가 자꾸 나오냐

도커를 쓰다 보면 십중팔구 이 두 가지를 겪는다.

  1. 컨테이너 띄우려는데 포트가 이미 점유됐다고 막힘
  2. 컨테이너는 실행 중인데 앱끼리 통신이 안 됨

둘 다 "왜?"를 이해하면 해결은 5분이다. 모르고 --force나 재시작만 반복하면 하루 날린다.


1. 포트 충돌 에러

에러 메시지 패턴

Error response from daemon: driver failed programming external connectivity on endpoint myapp
(xxx): Bind for 0.0.0.0:8080 failed: port is already allocated
Error starting userland proxy: listen tcp4 0.0.0.0:3306: bind: address already in use

원인 3가지

원인빈도설명
이전 컨테이너가 죽지 않고 포트 점유 중★★★docker stop 안 하고 그냥 터미널 닫은 경우
호스트 프로세스(MySQL, Nginx 등)가 같은 포트 사용★★☆로컬에 MySQL 깔려있는데 3306 쓰려 할 때
이전 컨테이너가 exited 상태로 포트 홀딩★☆☆docker ps엔 안 보이지만 docker ps -a엔 보임

[초보] 단계별 해결법

1단계: 어떤 프로세스가 포트 쓰는지 확인

# macOS / Linux
lsof -i :8080

# Windows (PowerShell)
netstat -ano | findstr :8080

2단계: 도커 컨테이너 확인

# 실행 중인 컨테이너만
docker ps

# 중단된 것 포함 전체
docker ps -a

# 특정 포트 쓰는 컨테이너 찾기
docker ps --filter "publish=8080"

3단계: 점유 중인 컨테이너 정리

# 특정 컨테이너 중지
docker stop <container_id>

# 중지 + 삭제
docker rm -f <container_id>

# exited 상태 컨테이너 일괄 정리
docker container prune

[중급] 포트 매핑 전략

같은 포트를 써야 하는 서비스가 여러 개라면, 호스트 포트를 다르게 매핑한다.

# 호스트 8081 → 컨테이너 내부 8080
docker run -p 8081:8080 myapp

# 여러 포트 동시 매핑
docker run -p 8080:8080 -p 443:443 myapp

docker-compose.yml에서는:

services:
  app:
    image: myapp
    ports:
      - "8081:8080"  # 호스트:컨테이너
  db:
    image: mysql:8
    ports:
      - "3307:3306"  # 로컬 MySQL과 충돌 방지

[고급] 동적 포트 할당

테스트 환경에서 포트 충돌을 원천 차단하는 방법 — 호스트 포트를 도커가 알아서 비어있는 거 잡게 한다.

# 호스트 포트 미지정 → 임의 할당
docker run -p 0:8080 myapp

# 할당된 포트 확인
docker port <container_id> 8080
# 출력 예: 0.0.0.0:49152

Node.js에서 환경변수로 포트 받아 쓰는 패턴:

// app.js
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

2. 컨테이너 간 통신 안 되는 에러

에러 메시지 패턴

Error: connect ECONNREFUSED 127.0.0.1:3306
getaddrinfo ENOTFOUND db

컨테이너 A에서 컨테이너 B의 localhost로 접속하려 해서 생기는 문제다. 컨테이너끼리 localhost는 공유하지 않는다. 각자 독립된 네트워크 네임스페이스를 가진다.

원인과 해결법 체크리스트

상황잘못된 접근올바른 접근
컨테이너 A → B 접속localhost:3306컨테이너명 또는 서비스명
docker run 단독 실행네트워크 미지정--network 플래그로 같은 네트워크 사용
docker-compose 사용별도 network 정의같은 compose 파일 내면 자동 연결

[초보] docker-compose로 컨테이너 연결

docker-compose를 쓰면 같은 파일 안의 서비스끼리는 서비스명으로 바로 통신된다. 네트워크 설정 따로 안 해도 된다.

# docker-compose.yml
services:
  app:
    image: node:20
    environment:
      DB_HOST: db        # "localhost" 아니고 서비스명 "db"
      DB_PORT: 3306
    depends_on:
      - db

  db:
    image: mysql:8
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: mydb

Node.js에서 DB 연결:

const mysql = require('mysql2/promise');

const pool = mysql.createPool({
  host: process.env.DB_HOST || 'db',  // 서비스명
  port: process.env.DB_PORT || 3306,
  user: 'root',
  password: 'secret',
  database: 'mydb'
});

Python (SQLAlchemy):

import os
from sqlalchemy import create_engine

DATABASE_URL = (
    f"mysql+pymysql://root:secret@"
    f"{os.getenv('DB_HOST', 'db')}:"
    f"{os.getenv('DB_PORT', '3306')}/mydb"
)

engine = create_engine(DATABASE_URL)

[중급] docker run으로 수동 네트워크 연결

docker-compose 없이 컨테이너 여러 개 연결할 때:

# 1. 공용 네트워크 생성
docker network create mynet

# 2. DB 컨테이너를 해당 네트워크에 붙여 실행
docker run -d \
  --name mydb \
  --network mynet \
  -e MYSQL_ROOT_PASSWORD=secret \
  mysql:8

# 3. 앱 컨테이너도 같은 네트워크로 실행
docker run -d \
  --name myapp \
  --network mynet \
  -e DB_HOST=mydb \
  -p 8080:8080 \
  myapp:latest

Java (Spring Boot) application.yml:

spring:
  datasource:
    url: jdbc:mysql://${DB_HOST:mydb}:${DB_PORT:3306}/mydb
    username: root
    password: secret

[고급] 네트워크 디버깅

# 컨테이너가 어떤 네트워크에 붙어있나
docker inspect myapp | grep -A 20 "Networks"

# 특정 네트워크에 연결된 컨테이너 목록
docker network inspect mynet

# 컨테이너 안에서 직접 핑 테스트
docker exec -it myapp ping mydb

# 컨테이너 안에서 포트 열려있나 확인
docker exec -it myapp nc -zv mydb 3306

# 임시 debug 컨테이너로 네트워크 진단
docker run --rm --network mynet nicolaka/netshoot nmap -p 3306 mydb

Nginx 설정에서 upstream을 컨테이너명으로:

# nginx.conf
upstream backend {
    server app:8080;  # 컨테이너/서비스명
}

server {
    listen 80;
    location / {
        proxy_pass http://backend;
    }
}

트러블슈팅 체크리스트

포트 충돌 발생 시

체크 항목명령어
실행 중인 컨테이너 확인docker ps
중단 포함 전체 확인docker ps -a
호스트 포트 점유 확인lsof -i :PORT (mac/linux)
문제 컨테이너 강제 삭제docker rm -f CONTAINER_ID
불필요한 컨테이너 일괄 정리docker container prune
포트 다르게 재매핑docker run -p HOST:CONTAINER

컨테이너 연결 안 될 때

체크 항목명령어
같은 네트워크인지 확인docker network inspect NETWORK
컨테이너명/서비스명으로 접속하는지 확인localhost → 서비스명
컨테이너 내부에서 핑 테스트docker exec -it APP ping DB
포트 열려있나 확인docker exec -it APP nc -zv DB PORT
컨테이너 로그 확인docker logs CONTAINER_ID
네트워크 재생성 후 재연결docker network create + --network

자주 하는 실수 요약

1. docker stop 대신 터미널만 닫는다
→ 컨테이너는 살아서 포트 계속 점유. docker stop 또는 docker-compose down 습관화.

2. 컨테이너 안에서 localhost로 다른 컨테이너 접근
→ 각 컨테이너는 독립 네트워크. 서비스명이나 컨테이너명으로 접근.

3. exited 컨테이너가 포트 잡고 있는 줄 모름
→ docker ps -a로 exited 포함해서 항상 확인.

4. depends_on만 믿고 DB 준비됐다고 착각
→ depends_on은 컨테이너 시작 순서만 보장. DB가 실제로 ready 상태인지는 별개. healthcheck나 wait 로직 필요.

# healthcheck로 DB 준비 확인
services:
  db:
    image: mysql:8
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 10s
      retries: 5

  app:
    depends_on:
      db:
        condition: service_healthy  # DB healthy 확인 후 시작

도커 관련 에러는 대부분 이 두 가지에서 온다. 포트 충돌이면 docker ps -a부터, 연결 안 되면 네트워크와 호스트명부터 확인하면 길을 잃지 않는다.

JWT 에러 완전 정복: TokenExpiredError, invalid signature 원인과 해결법

🔍 검색 키워드: JWT 에러, jwt expired 해결, invalid signature jwt, TokenExpiredError, JWT 트러블슈팅, jwt 토큰 에러, Spring JWT 에러, Node.js jwt 에러, jwt 인증 오류

JWT를 처음 붙이든, 실무에서 수년째 쓰든 어느 날 갑자기 TokenExpiredError 또는 invalid signature가 튀어나온다. 원인은 뻔한데 처음 보면 당황스럽다. 이 글에서 JWT 에러의 종류별 원인과 해결법을 레벨별로 정리한다.


JWT 에러 종류 한눈에 보기

에러 메시지원인긴급도
TokenExpiredError: jwt expired토큰 유효기간 만료보통 (정상 흐름)
JsonWebTokenError: invalid signature키 불일치 또는 토큰 변조높음
JsonWebTokenError: jwt malformed토큰 형식 자체가 잘못됨높음
NotBeforeError: jwt not activenbf 클레임 이전에 사용낮음
JsonWebTokenError: invalid algorithm알고리즘 불일치높음

LEVEL 1 초보자: 기본 개념부터

JWT는 세 파트로 구성된다: Header.Payload.Signature

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9    ← Header (알고리즘, 타입)
.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjox...  ← Payload (클레임: sub, exp, iat 등)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_...   ← Signature (서명)

Signature는 HMAC-SHA256(base64(header) + "." + base64(payload), secretKey)로 만든다.
토큰이 변조되면 서명 검증에서 실패한다. 이것이 JWT 보안의 핵심.

가장 흔한 실수 — 토큰을 Authorization 헤더에 제대로 안 보냄

# 잘못된 예
Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

# 올바른 예
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

LEVEL 2 중급자: 에러별 원인과 해결

TokenExpiredError: jwt expired

토큰 발급 시 설정한 exp(만료시간)가 지났을 때 발생한다. 정상적인 흐름이다.

Node.js (jsonwebtoken)

const jwt = require('jsonwebtoken');
try {
  const decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
  if (err.name === 'TokenExpiredError') {
    return res.status(401).json({ code: 'TOKEN_EXPIRED' });
  }
  if (err.name === 'JsonWebTokenError') {
    return res.status(401).json({ code: 'INVALID_TOKEN' });
  }
}

Java (Spring Boot + jjwt)

public Claims parseToken(String token) {
    try {
        return Jwts.parserBuilder()
            .setSigningKey(secretKey)
            .build()
            .parseClaimsJws(token)
            .getBody();
    } catch (ExpiredJwtException e) {
        throw new CustomException(ErrorCode.TOKEN_EXPIRED);
    } catch (SignatureException e) {
        throw new CustomException(ErrorCode.INVALID_SIGNATURE);
    }
}

Python (PyJWT)

import jwt
try:
    payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
    raise HTTPException(status_code=401, detail="토큰이 만료되었습니다.")
except jwt.InvalidSignatureError:
    raise HTTPException(status_code=401, detail="유효하지 않은 토큰입니다.")

JsonWebTokenError: invalid signature

가장 많은 삽질을 유발하는 에러다. 원인은 크게 세 가지.

원인 1 — 서버 재시작 후 환경변수가 바뀜

echo $JWT_SECRET   # 양쪽 서버에서 값이 같은지 확인

원인 2 — HS256 vs RS256 알고리즘 불일치

// 발급 시 RS256 사용
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });
// 검증 시 반드시 publicKey + 알고리즘 명시
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // ✅
HS256: 대칭키 (같은 secret으로 발급·검증)
RS256: 비대칭키 (privateKey로 발급, publicKey로 검증)

원인 3 — URL 전달 시 토큰이 잘림

const url = `/verify?token=${encodeURIComponent(token)}`;
const token = decodeURIComponent(req.query.token);

JsonWebTokenError: jwt malformed

const authHeader = req.headers.authorization; // "Bearer eyJ..."
const token = authHeader.split(' ')[1]; // Bearer 제거 후 verify
jwt.verify(token, secret);

LEVEL 3 고급자: 실무 패턴

Access Token + Refresh Token 구조

function issueTokens(userId) {
  const accessToken = jwt.sign(
    { sub: userId, type: 'access' },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: '15m' }
  );
  const refreshToken = jwt.sign(
    { sub: userId, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  );
  return { accessToken, refreshToken };
}

Spring Security + JWT 필터 체인

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String bearer = request.getHeader("Authorization");
        if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
            String token = bearer.substring(7);
            try {
                Claims claims = jwtProvider.parseToken(token);
                SecurityContextHolder.getContext().setAuthentication(
                    jwtProvider.getAuthentication(claims));
            } catch (ExpiredJwtException e) {
                response.setStatus(401);
                response.getWriter().write("{"code":"TOKEN_EXPIRED"}");
                return;
            }
        }
        filterChain.doFilter(request, response);
    }
}

상황별 체크리스트

상황확인 항목
invalid signature양쪽 환경의 JWT_SECRET 값이 동일한가?
invalid signature알고리즘이 HS256/RS256으로 일치하는가?
invalid signatureRS256이라면 검증 측에 publicKey를 쓰고 있는가?
jwt malformedBearer prefix를 제거하고 verify 하는가?
jwt malformedURL 전달 시 encodeURIComponent 처리 했는가?
토큰이 금방 만료됨expiresIn 단위: '1h'(1시간) vs 3600(초)
서버 재시작 후 전체 만료환경변수 기반 secret인가?

디버깅 도구

jwt.io — 토큰을 붙여넣으면 header, payload, signature를 즉시 디코딩해준다.

echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig" \
  | cut -d'.' -f2 | base64 --decode 2>/dev/null
🗂 정리

JWT 에러의 90%는 세 가지다: 키 불일치, 알고리즘 불일치, 토큰 만료.
invalid signature가 나오면 키와 알고리즘을 먼저 의심하고, 환경변수가 양쪽에서 같은지 확인하는 것이 제일 빠른 경로다.
TokenExpiredError는 에러가 아니라 정상 흐름이니 클라이언트 리프레시 로직을 갖춰두면 된다.