무색
기술블로그
에세이
연구
프로덕트
소개

무색

소프트웨어로 비즈니스의 가능성을 만듭니다. 웹·앱 개발, 음성 AI, 자동화 콘텐츠 제작까지 — 기술이 필요한 곳에 무색이 있습니다.

연락처

[email protected]

사업자 정보

상호: 무색

대표: 배성재

사업자등록번호: 577-58-00836

인천광역시 연수구 인천타워대로 323, 에이동 8층 801-802호 AB-132 (송도동, 송도 센트로드)

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관
INCHEON, KR
Post #63 Key Visual — generative style
크로스커팅
2026. 1. 30.

2주간 20개 프로젝트에서 만난 삽질 패턴 총정리

dockerkubernetesdevopstroubleshootinghomelab

2주 동안 20개 프로젝트를 동시에 진행하면서 크고 작은 삽질을 수도 없이 했다. Docker 빌드에서 7개 에러가 연쇄 폭발하고, MetalLB failover가 안 되는 원인을 찾아 ARP 레벨까지 내려가고, subprocess에 환경변수가 줄줄 새는 걸 발견하기도 했다.

돌이켜보면 같은 패턴의 함정에 반복해서 빠졌다. 환경 차이를 무시해서, 기본값을 신뢰해서, 문서에 안 적힌 동작을 몰라서. 이 글은 20개 프로젝트 글감에서 추출한 삽질 패턴을 5가지 카테고리로 정리한 것이다. 나 자신을 위한 체크리스트이기도 하고, 비슷한 스택을 쓰는 분께 시행착오를 줄여드릴 수도 있겠다 싶어 공유한다.

1. Docker / 빌드 문제

"로컬에서 되는데 Docker에서 안 돼요"는 정말 자주 듣는 말이다. Docker 빌드 환경은 로컬 개발 환경의 암묵적 의존성을 가장 빨리 드러내는 리트머스 테스트라서, 최신 스택일수록 예상 못 한 곳에서 터진다.

ESM + CJS 충돌

  • 증상: postcss.config.js에서 ERR_REQUIRE_ESM 에러
  • 원인: "type": "module" 프로젝트에서 CJS 전용 설정 파일 확장자가 .js면 ESM으로 해석됨
  • 해결: postcss.config.js → postcss.config.cjs로 확장자 변경

React 19 전역 JSX 네임스페이스 제거

  • 증상: TypeScript 빌드에서 JSX.IntrinsicElements 타입을 못 찾음
  • 원인: React 19에서 전역 JSX 네임스페이스가 제거됨. 런타임에는 안 보이고 TS 빌드에서만 터지는 함정
  • 해결: React.JSX.IntrinsicElements로 전부 교체

CMS + Docker의 닭-달걀 문제

  • 증상: PayloadCMS가 빌드 시 DB 접속을 시도하면서 빌드 실패
  • 원인: Headless CMS가 generateStaticParams에서 DB 쿼리를 날리는데 Docker 빌드에는 DB가 없음
  • 해결: 이중 방어. force-dynamic으로 정적 생성 비활성화 + generateStaticParams에 try-catch + 더미 PAYLOAD_SECRET ARG 주입
export async function generateStaticParams() {
  try {
    const payload = await getPayload({ config })
    const pages = await payload.find({ collection: 'pages', limit: 100 })
    return pages.docs.map((page) => ({ slug: page.slug }))
  } catch {
    return [] // Docker 빌드 시 DB 없음
  }
}

BuildKit 플러그인 누락

  • 증상: --mount=type=cache가 포함된 Dockerfile 빌드 실패
  • 원인: DinD 환경에서 DOCKER_BUILDKIT=1 활성화했지만 docker-buildx-plugin 미설치
  • 해결: apt-get install docker-ce-cli docker-buildx-plugin 둘 다 설치

DinD에서 actions/checkout 실패

  • 증상: Gitea Actions에서 actions/checkout 액션이 동작 안 함
  • 원인: DinD 사이드카 환경의 파일시스템 구조가 표준 GitHub Actions 러너와 다름
  • 해결: git clone --depth 1 --branch master로 직접 클론

2. K8s 네트워킹 문제

K8s 네트워킹은 추상화 계층이 많아서 문제가 생기면 어느 레이어에서 터진 건지 파악하기가 어렵다. ARP, DNS, 라우팅 규칙까지 내려가야 하는 경우가 꽤 있었다.

MetalLB L2 failover + Cilium ARP 충돌

  • 증상: worker 노드를 끄면 LoadBalancer IP 접근 불가. failover가 안 됨
  • 원인: ServiceL2Status.node 필드가 immutable이라 고착 + Cilium의 lxc* 가상 인터페이스에서 불안정한 ARP 응답
  • 해결: L2Advertisement에 interfaces: [eth0] 추가. 물리 인터페이스에서만 ARP 응답
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: default
  namespace: metallb-system
spec:
  interfaces:
    - eth0  # Cilium lxc* 인터페이스 제외

NFS PV에서 K8s DNS 이름 사용 불가

  • 증상: NFS PV에 K8s 서비스 DNS를 넣었는데 마운트 실패
  • 원인: kubelet이 호스트 레벨에서 NFS를 마운트하기 때문에 K8s 내부 DNS 사용 불가
  • 해결: MetalLB가 할당한 고정 IP를 PV의 nfs.server에 직접 지정

IngressRoute Path vs PathPrefix

  • 증상: 특정 경로는 되는데 하위 경로가 전부 404
  • 원인: Traefik Path()는 정확히 그 경로만 매칭. 하위 경로는 포함 안 됨
  • 해결: PathPrefix()로 변경해서 하위 경로 전부 매칭

cloudflared latest 태그의 아키텍처 함정

  • 증상: exec format error로 cloudflared Pod 시작 실패
  • 원인: latest 태그가 ARM 이미지를 가져옴
  • 해결: 아키텍처를 명시한 태그 사용 (예: 2025.2.1-amd64)

TLS 인증서 크로스 네임스페이스 참조

  • 증상: 다른 네임스페이스의 cert-manager 인증서 Secret을 참조할 수 없음
  • 원인: cert-manager의 Certificate는 같은 네임스페이스에만 Secret을 생성
  • 해결: Cloudflare Tunnel로 K8s 내부 TLS를 없애거나, TLSStore default에 와일드카드 인증서 등록

3. 환경변수 / 프로세스 격리 문제

subprocess를 spawn할 때 환경변수를 명시적으로 제어하지 않으면 보안 사고와 의존성 충돌이 동시에 찾아온다.

VIRTUAL_ENV / PYTHONPATH 상속 충돌

  • 증상: 독립 venv를 가진 worker에서 ModuleNotFoundError
  • 원인: 메인 프로세스의 VIRTUAL_ENV과 PYTHONPATH가 subprocess에 그대로 상속. worker venv 대신 메인 패키지를 import
  • 해결: allowlist 기반 환경변수 필터링. PATH, HOME, CUDA_*, NVIDIA_*만 통과
_WORKER_ENV_ALLOW_PREFIXES = ("CUDA_", "NVIDIA_", "NCCL_", "LC_", "XDG_")
_WORKER_ENV_ALLOW_KEYS = frozenset({
    "PATH", "HOME", "USER", "LOGNAME",
    "LANG", "TMPDIR", "TEMP", "TMP", "SHELL", "TERM",
})

def worker_env() -> dict[str, str]:
    return {
        k: v for k, v in os.environ.items()
        if k in _WORKER_ENV_ALLOW_KEYS or k.startswith(_WORKER_ENV_ALLOW_PREFIXES)
    }

자격증명 누출 방지

  • 증상: GPU_API_TOKEN 같은 민감 정보가 worker subprocess에 노출
  • 원인: subprocess의 기본 동작은 부모 프로세스의 환경변수 전체 상속
  • 해결: blocklist 대신 allowlist 패턴. 새 민감 변수가 추가되어도 자동으로 차단

Claude Code 재귀 호출 방지

  • 증상: Claude Code CLI를 subprocess로 호출할 때 무한 재귀 위험
  • 원인: CLAUDECODE 환경변수가 상속되면 Claude Code가 자기 자신을 다시 호출
  • 해결: Claude CLI 호출용 환경변수에서 CLAUDECODE만 제거하는 별도 로직

4. 인증 / 시크릿 문제

Payload REST API JWT 인증 방식

  • 증상: Authorization: JWT 헤더로 API 호출 시 401
  • 원인: PayloadCMS의 인증 방식이 헤더 방식을 안정적으로 지원하지 않는 경우가 있음
  • 해결: 쿠키 방식(-b "payload-token=<token>") 사용

Postiz API 인증 형식

  • 증상: Postiz Public API에 Bearer 토큰으로 인증하면 403
  • 원인: Postiz가 Bearer prefix 없이 raw API key를 기대함
  • 해결: Authorization 헤더에 raw key만 전송. 엔드포인트도 /public/v1/posts가 정확한 경로

Reverse proxy 뒤의 device pairing

  • 증상: OpenClaw Control UI에서 pairing required 에러
  • 원인: reverse proxy 뒤에서 클라이언트 IP가 Docker gateway IP로 보여서 인증 실패
  • 해결: trustedProxies에 Docker gateway IP 추가 + allowInsecureAuth: true

NFS UID 매핑

  • 증상: K8s Pod에서 NFS에 파일을 쓰면 다른 Pod이나 WSL에서 권한 에러
  • 원인: 각 컨테이너/시스템의 UID가 달라서 파일 소유권이 꼬임
  • 해결: NFS 서버에 all_squash 옵션. 모든 접근을 nobody:nogroup으로 매핑

5. 호환성 / 설정 문제

Helm subchart 서비스 이름

  • 증상: DATABASE_URL의 호스트가 틀려서 DB 접속 실패
  • 원인: Bitnami subchart 서비스 이름이 fullnameOverride가 아닌 릴리스 이름 기반으로 생성
  • 해결: kubectl get svc로 실제 서비스 이름 확인 후 환경변수에 반영

Temporal ConfigMap 마운트 경로 충돌

  • 증상: Temporal 컨테이너가 시작 직후 크래시
  • 원인: ConfigMap을 /etc/temporal에 마운트하면 entrypoint.sh가 덮어써짐
  • 해결: 별도 경로 /etc/temporal-dynamic/에 마운트

Bitnami 이미지 태그 삭제

  • 증상: 어제까지 잘 되던 배포가 이미지 pull 실패
  • 원인: Bitnami가 오래된 이미지 태그를 주기적으로 삭제
  • 해결: Bitnami subchart에는 latest 태그만 안정적. 또는 내부 레지스트리에 미러링

PayloadCMS Lexical heading 태그 중복

  • 증상: 블로그 본문에서 <hh2> 같은 이상한 태그가 렌더링
  • 원인: Lexical heading 노드의 tag 값이 이미 "h2" 형태인데 h${node.tag}로 접두사를 한번 더 붙임
  • 해결: node.tag를 그대로 사용

n8n 메모리와 대용량 파일

  • 증상: PDF 번역이나 동영상 자막 워크플로우에서 n8n OOM 크래시
  • 원인: Base64 인코딩으로 파일 크기 ~2.3배 + prepareBinaryData가 추가 복사 → 원본의 ~4.6배 메모리
  • 해결: fs.writeFileSync로 직접 파일 저장. 대용량 파일은 n8n 우회 구조로 전환

headless Claude Code JSON 파싱

  • 증상: claude -p로 JSON 응답을 받으려는데 result 필드가 비어 있음
  • 원인: --max-turns 1이면 내부 턴 소모로 응답 없이 종료. --json-schema 사용 시 결과가 structured_output 필드에 들어감
  • 해결: --max-turns 2로 설정. structured_output 우선 확인 + result fallback 유지

ArgoCD 알림 폭주

  • 증상: homelab 레포에 push할 때마다 20개+ 앱 알림이 한꺼번에 옴
  • 원인: 글로벌 구독이 mono-repo의 모든 앱에 알림 발송
  • 해결: per-app annotation 방식으로 전환. 필요한 앱에만 구독 annotation 추가

삽질 방지 체크리스트

위 함정들을 피하기 위한 실용적인 체크리스트다.

Docker 빌드 전

  • "type": "module" 프로젝트면 CJS 설정 파일은 전부 .cjs 확장자인가?
  • React 19라면 JSX. → React.JSX. 전환했나?
  • CMS가 빌드 시점에 DB 접속을 시도하는가? try-catch + force-dynamic 적용했나?
  • .dockerignore에 .husky, .claude, tests 등 불필요 파일 추가했나?

K8s 배포 전

  • MetalLB + Cilium 조합이면 L2Advertisement에 interfaces 설정했나?
  • NFS PV의 서버 주소가 K8s DNS가 아닌 고정 IP인가?
  • IngressRoute에서 하위 경로 필요하면 PathPrefix를 쓰고 있나?
  • 컨테이너 이미지 태그에 아키텍처가 명시되어 있나?

프로세스 격리

  • subprocess에 env= 파라미터를 명시적으로 전달하고 있나?
  • allowlist 방식으로 필요한 환경변수만 넘기고 있나?
  • 자격증명이 subprocess에 노출되지 않는지 확인했나?

외부 서비스 연동

  • API 인증 형식을 실제로 테스트했나?
  • Helm subchart의 서비스 이름을 kubectl get svc로 확인했나?
  • ConfigMap 마운트가 기존 파일을 덮어쓰지 않는지 확인했나?
  • 노코드 도구에 대용량 파일을 통과시키려 하지 않는가?

정리하며

20개 프로젝트를 빠르게 진행하면서 깨달은 건, 삽질의 대부분은 환경 차이를 무시하거나 기본 동작을 신뢰해서 생긴다는 점이다. Docker 빌드는 로컬과 다르고, subprocess는 부모 환경을 물려받고, latest 태그는 어제와 다른 이미지를 줄 수 있다.

개별 삽질은 사소해 보여도 패턴을 인식하면 다음번에는 같은 구덩이를 피할 수 있다. 이 체크리스트가 나처럼 여러 기술 스택을 동시에 다루는 분께 조금이라도 도움이 되면 좋겠다.

자주 묻는 질문

Docker 빌드에서 ESM/CJS 충돌이 나는 이유와 해결법은?
package.json에 "type": "module"이 있으면 .js 파일이 ESM으로 해석되어 CJS 설정 파일이 깨집니다. postcss.config.js 같은 파일은 .cjs 확장자로 변경하면 해결됩니다.
subprocess에서 환경변수 노출을 막으려면 어떻게 해야 하나요?
subprocess는 기본적으로 부모 환경변수를 전부 상속합니다. blocklist 대신 allowlist 방식으로 PATH, HOME, CUDA_* 등 필요한 변수만 명시적으로 전달해야 자격증명 누출을 방지할 수 있습니다.
MetalLB L2 모드에서 failover가 안 될 때 확인할 것은?
Cilium과 함께 사용하면 lxc* 가상 인터페이스에서 ARP 충돌이 발생합니다. L2Advertisement에 interfaces: [eth0]을 추가해 물리 인터페이스에서만 ARP 응답하도록 설정하세요.
크로스커팅(17/18)
Prev

RTX 5090 하나로 AI 서비스 6개 돌리기: GPU 공유 전략

Next

python-guide 스킬의 진화: 실전에서 발견한 테스트 누락 패턴