"로그인이 안 돼요" — 이 한 마디가 4시간의 디버깅 여정을 시작시켰다


🎯 미션: 운영 서버에 프론트엔드 포함 전체 배포

EcoNiPass 탄소관리 플랫폼을 운영 서버에 처음 배포하는 날이었다. 백엔드(FastAPI), 프론트엔드(React), Keycloak 인증서버까지 풀셋.

계획은 심플했다:

로컬에서 빌드 → GHCR에 푸시 → 서버에서 Pull → docker-compose up

"30분이면 되겠지?" — 이 생각이 모든 삽질의 시작이었다.


🧱 1차 벽: ARM64에서 amd64 이미지를 빌드하라고?

Mac M2(ARM64)에서 운영 서버(amd64)용 Docker 이미지를 빌드해야 한다. docker buildx로 크로스 플랫폼 빌드를 하면 되지.

docker buildx build --platform linux/amd64 -f Dockerfile.frontend --push .

결과:

env: can't execute 'node': Exec format error

QEMU 에뮬레이션으로 ARM64 위에서 amd64 바이너리를 돌리는 건데, npm의 style-dictionary 패키지가 네이티브 node를 직접 호출하면서 터진 것.

💡 해결: "빌드는 로컬에서, 이미지는 복사만"

# Dockerfile.frontend.prebuilt
FROM nginx:alpine
COPY deploy/nginx/frontend.conf /etc/nginx/conf.d/default.conf
COPY econipass-frontend-develop/build /usr/share/nginx/html
EXPOSE 80

로컬 Mac에서 npm run build로 빌드 → 결과물만 nginx 이미지에 복사. QEMU가 node를 돌릴 필요가 없다.

인사이트: 크로스 플랫폼 빌드에서 네이티브 바이너리 문제가 나면, "빌드 분리" 패턴을 생각하자. 빌드는 호스트에서, 이미지는 결과물만.


🧱 2차 벽: 로그인하면 500 에러 (feat. Keycloak URL)

서버에 배포 완료. 프론트엔드 화면 잘 뜬다. 로그인 버튼을 누르면...

POST /v1/auth/login → 500 Internal Server Error

"시스템 에러가 발생했습니다" 😱

디버깅 과정

# 컨테이너 로그 확인
docker logs econipass-backend --tail 100

# 발견: user_id = '' (빈 문자열)
[cached since 186s ago] ('',)
user_repository.py L.1525 No rows found.

user_id가 빈 문자열? JWT 토큰 디코딩이 실패하는 건가?

그런데 .env 파일을 확인하면:

KEYCLOAK_BASE_URL=http://econipass-keycloak:8080  ← 올바른 값

컨테이너 안에서 확인하면:

$ docker exec econipass-backend env | grep KEYCLOAK_BASE_URL
KEYCLOAK_BASE_URL=http://localhost:8080  ← ?!?!?!

.env 파일은 맞는데, 컨테이너 환경변수가 다르다!

원인

.env 파일이 컨테이너가 시작된 후에 수정되었다.

컨테이너 생성: 15:38:19  (이때 .env 로딩)
.env 수정:    15:39:00  (이미 늦었다...)

Docker Compose의 env_file컨테이너 생성 시점에만 읽는다. 런타임에 파일을 수정해도 반영 안 됨.

💡 해결

docker compose -f docker-compose.prod.yml up -d --force-recreate backend

--force-recreate로 컨테이너를 완전히 재생성하면 새로운 .env를 읽는다.

인사이트: .env 수정 후에는 반드시 --force-recreate. 단순 restart로는 환경변수가 갱신되지 않는다.


🧱 3차 벽: 또 500 에러 (feat. Redis 인증)

Keycloak URL 문제를 고쳤다. 다시 로그인을 시도하면...

POST /v1/auth/login → 500 Internal Server Error

"또?!" 🤦

이번에는 로그가 다르다:

CRITICAL main.py L.543 -> Error: Authentication required.

"Authentication required"... Keycloak 인증 문제인가? 아니, Keycloak 로그인은 200 OK로 성공하고 있다:

HTTP Request: POST http://econipass-keycloak:8080/realms/econipass/... "HTTP/1.1 200 OK"

그렇다면 대체 뭐가 "Authentication required"를 던지는 거지?

범인: Redis

# 테스트
$ docker exec econipass-backend python -c "
import redis
r = redis.Redis(host='172.18.0.1', port=6379)
r.ping()
"

# 결과
redis.exceptions.AuthenticationError: Authentication required.

Redis에 비밀번호가 걸려 있었다! 기존 서버에서 운영 중인 Redis(querypie-redis-1)가 --requirepass Querypie1!로 설정되어 있었는데, 백엔드의 RedisAdapter는 비밀번호를 지원하지 않았다.

💡 해결

# config.py
KVS_PASSWORD: str = os.getenv("KVS_PASSWORD", "")

# redis_adapter.py
self.client = redis.Redis(
    host=redis_host,
    port=redis_port,
    password=redis_password or None,  # 빈 문자열이면 None
    decode_responses=True,
)

개발 환경(비밀번호 없음)과 운영 환경(비밀번호 있음)을 모두 지원하도록 os.getenv에 기본값 빈 문자열 폴백.

인사이트: "Authentication required"가 어디서 오는지 모를 때, Keycloak이 아니라 Redis일 수 있다. 에러 메시지만 보고 범인을 단정하지 말 것.


🎉 결과: 드디어 로그인 성공

$ curl -X POST http://localhost:8000/v1/auth/login \
    -H 'Content-Type: application/json' \
    -d '{"login_id": "econipass_support@wingarc.com", "password": "password123"}'

# HTTP 200 + access_token + id_token 🎊

3번의 500 에러를 뚫고 드디어 로그인이 된다.


📝 오늘의 핵심 교훈 5가지

1. 크로스 플랫폼 빌드: "빌드 분리" 패턴

ARM64에서 amd64 이미지를 빌드할 때 네이티브 바이너리 문제가 나면, 호스트에서 빌드하고 결과물만 이미지에 복사하자. QEMU에 의존하지 마라.

2. .env 수정 ≠ 컨테이너 반영

Docker Compose의 env_file컨테이너 생성 시점에만 읽힌다. .env 수정 후에는 반드시 --force-recreate.

3. "Authentication required"의 진짜 범인

같은 에러 메시지라도 원인이 다를 수 있다. Keycloak인지 Redis인지 DB인지, 스택 트레이스를 끝까지 읽어라.

4. GitHub Actions 없이 배포하기

소규모 팀이라면 CI/CD 파이프라인 없이 deploy.sh 하나로 충분하다:

로컬 테스트 → docker buildx → GHCR 푸시 → SSH로 pull & restart

복잡한 인프라보다 실행 가능한 단순함이 낫다.

5. 외부 서비스 공유 시 네트워크 설정

기존에 운영 중인 DB/Redis를 새 서비스에서 쓰려면 Docker의 외부 네트워크를 활용하자:

networks:
  external_services:
    external: true
    name: pgvector_default  # 기존 네트워크 이름

같은 Docker 네트워크에 있으면 컨테이너 이름으로 통신 가능.


🔗 배포 아키텍처 최종본

[Mac M2]                   [GHCR]              [운영 VM]
    │                        │                    │
    ├─ npm build (로컬)      │                    │
    ├─ docker buildx ───────▶│                    │
    │  --platform amd64      │◀── docker pull ───┤
    │                        │                    │
    └─ SSH rsync (.env) ────┼──────────────────▶│
                             │           docker-compose up
                             │                    │
                             │     ┌──────────────┤
                             │     │  :8000 백엔드 │
                             │     │  :3000 프론트  │
                             │     │  :8080 Keycloak│
                             │     └──────────────┘

마무리

운영 배포는 항상 **"이것만 하면 되지"**로 시작해서 **"대체 왜 안 되는 거야"**로 끝난다.

오늘의 3연타 500 에러도 마찬가지였다. 하지만 각각의 에러를 풀어가면서 Docker 네트워크, 환경변수 라이프사이클, Redis 인증 메커니즘을 제대로 이해하게 됐다.

삽질이 곧 학습이다. 다만, 같은 삽질을 두 번 하지 않으려면 기록하자. 이 블로그처럼. ✍️

다음 목표: SSL 인증서 설정 + MCP 서버 실전 테스트