[Network] CORS, 프록시, 런타임 env 문제로 403/401/500이 발생한 이유
서론
운영 환경에서 터지는 에러는 종종 하나의 원인으로 설명되지 않는다.
loslung의 배포 히스토리에서도 403, 401, 500이 각각 따로 보였지만, 실제로는 CORS, 프록시 경로, 런타임 환경변수가 얽혀 있었다.
이번 글에서는 2026년 4월 2일 PR #8과 2026년 4월 6일 PR #15을 중심으로, 왜 이런 문제가 동시에 발생했는지 정리한다.
1. 403의 시작점은 CORS 설정이었지만, 끝은 아니었다
운영 배포 후 POST 요청에서 403이 발생했다.
처음에는 단순히 “허용 도메인이 빠졌나?” 정도로 볼 수 있지만, 실제 수정은 두 층으로 나뉘었다.
백엔드 허용 도메인 추가
먼저 docker-compose.yml에 운영 도메인과 내부 통신 경로를 환경변수로 명시했다.
1
2
environment:
LOSLUNG_CORS_ALLOWED_ORIGINS: ${LOSLUNG_CORS_ALLOWED_ORIGINS:-https://loslung.kro.kr,http://frontend:3000,http://backend:8080}
이 수정은 기본적인 CORS 허용 범위를 맞추기 위한 것이었다.
서버 간 호출에서 Origin 제거
하지만 그것만으로는 끝나지 않았다.
Next.js 서버가 Spring Boot로 요청을 보낼 때 Origin 헤더가 남아 있으면, 브라우저 요청이 아닌데도 CORS 판단에 걸릴 수 있었다.
그래서 fetchBackend()에서 아래처럼 Origin 헤더를 제거했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function buildHeaders(initHeaders: HeadersInit | undefined, token: string | null) {
const headers = new Headers(initHeaders);
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (token) {
headers.set("Authorization", `Bearer ${token}`);
} else {
headers.delete("Authorization");
}
// 서버 간 통신에서 CORS 차단 방지
headers.delete("Origin");
return headers;
}
이 수정은 꽤 중요했다.
문제를 “허용 도메인 부족”으로만 본 게 아니라, 브라우저 요청과 서버 간 요청을 같은 방식으로 다루고 있었다는 점까지 같이 정리했기 때문이다.
2. /api/market 500은 env 주입 시점 문제였다
2026년 4월 6일 PR #15에서는 /api/market이 500을 뱉는 문제가 수정되었다.
원인은 LOSTARK_API_KEY를 읽는 코드 자체보다, 그 코드가 실행되는 시점에 있었다.
기존에는 다음처럼 revalidate = 600이 설정되어 있었는데, 이 경우 빌드 시점 동작과 런타임 환경변수 접근이 꼬일 여지가 있었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export async function GET() {
const apiKey = process.env.LOSTARK_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: "API key not configured" },
{ status: 500 },
);
}
}
핵심은 dynamic = "force-dynamic"으로 바꿔, 이 Route Handler가 빌드 시점이 아니라 런타임 기준으로 환경변수를 읽게 만든 것이다.
같은 이유로 메인 페이지도 런타임 env 접근이 필요해 force-dynamic이 추가되었다.
3. 같은 /api/*라도 모두 백엔드로 보내면 안 됐다
/api/market은 이름만 보면 일반 백엔드 API처럼 보이지만, 실제로는 Next.js Route Handler가 처리하는 경로였다.
그런데 Apache 설정이 이를 구분하지 못하고 일반 /api/* 규칙에 따라 Spring Boot로 보내고 있었다.
그래서 프록시 규칙을 아래처럼 세분화했다.
1
2
3
4
5
6
7
8
9
ProxyPass /api/me/ http://127.0.0.1:3100/api/me/
ProxyPassReverse /api/me/ http://127.0.0.1:3100/api/me/
ProxyPass /api/market http://127.0.0.1:3100/api/market
ProxyPassReverse /api/market http://127.0.0.1:3100/api/market
ProxyPass /api/health http://127.0.0.1:3100/api/health
ProxyPassReverse /api/health http://127.0.0.1:3100/api/health
ProxyPass /api/ http://127.0.0.1:8100/api/
ProxyPassReverse /api/ http://127.0.0.1:8100/api/
이제야 아래 구조가 명확해졌다.
/api/me/*,/api/market,/api/health는 프런트엔드- 그 외
/api/*는 백엔드
즉, /api/*라는 공통 prefix만 믿고 한 프로세스로 몰아버리면 안 되는 구조였다.
이 문제에서 배운 점
이번 403/401/500 묶음 문제에서 가장 크게 배운 것은 아래 세 가지였다.
403은 CORS 도메인 설정만의 문제가 아닐 수 있다.500은 코드 버그보다 env를 읽는 시점 문제일 수 있다.401은 인증 실패라기보다 잘못된 프로세스로 라우팅된 결과일 수 있다.
결국 에러 코드만 보면 전혀 다른 문제처럼 보이지만, 실제로는 모두 “이 요청이 누구 책임인가?”를 제대로 나누지 못했을 때 생긴 문제였다.
정리
loslung의 운영 이슈를 다시 보면, 네트워크 문제는 단순히 “프록시 설정”이나 “CORS 설정” 하나로 끝나지 않았다.
- 서버 간 통신에서
Origin을 어떻게 다룰지 - 런타임 env를 언제 읽을지
- 어떤
/api/*를 프런트가 처리하고 어떤/api/*를 백엔드가 처리할지
이 세 가지가 맞물려 있었고, 하나라도 흐릿하면 다른 계층의 에러처럼 증상이 튀어나왔다.
운영에서 네트워크 문제를 볼 때는 에러 코드보다 먼저, 요청 경로와 실행 시점을 그려보는 습관이 훨씬 중요하다는 걸 다시 배웠다.