Vue·IBSheet 엑셀 다운로드 CORS 오류: 200 OK부터 프록시 경로 재작성까지
Vue 화면에 IBSheet를 연결해 사용하던 중, 서버에 배포했을 때는 되던 엑셀 다운로드가 로컬에서만 실패했다. 일반 조회가 아니라 Down2Excel을 호출할 때 문제가 나타났고, 서버에서는 조회 결과를 반환하는 것처럼 보였다. 개발자 도구에서 확인한 실제 POST 요청의 상태도 200 OK였다.
그런데 화면에서는 다음 오류가 나타났다.
Cross-Origin Resource Sharing error: MissingAllowOriginHeader
응답에는 Content-Disposition이 있었고, Access-Control-Expose-Headers: Content-Disposition도 있었다. @CrossOrigin 설정과 응답 헤더를 직접 넣는 방법까지 시도했지만 문제가 그대로라는 상황이었다. 이후 로컬 프록시를 적용하자 이번에는 /api가 붙은 경로를 백엔드가 찾지 못하는 문제가 이어졌다.
이 글은 그 확인 과정을 따라가며 HTTP 응답 성공, 브라우저의 응답 접근 허용, 프록시 경로 변환, 실제 파일 저장 성공을 나누어 설명한다. 확인되지 않은 단일 원인을 확정하거나 최종 해결을 선언하는 글은 아니다. 같은 문제를 만났을 때 어떤 증거를 보고 다음 단계로 넘어갈지 정리한 실전 점검 기록이다.
핵심부터 말하면,
200 OK는 CORS 통과나 정상 엑셀 파일을 보장하지 않는다. 또한 프록시의rewrite가 정상 작동해도 브라우저 Network에는/api가 남는다. 백엔드에 전달된 경로는 별도로 확인해야 한다.
목차
- 먼저 고정할 사실과 아직 모르는 것
- 200 OK인데 브라우저가 실패하는 이유
- 파일명 헤더와 출처 허용 헤더는 다르다
- IBSheet 다운로드가 어떤 방식인지 확인하기
- 로컬과 배포 환경의 요청 경로 비교
- Vite 프록시와 경로 재작성 설정
- Vue CLI를 사용하는 경우
- IBSheet의 실제 다운로드 주소 연결하기
- rewrite했는데도 /api가 보이는 이유
- 같은 증상이 남을 때 점검할 순서
- 백엔드 CORS와 응답 초기화 점검
- 폼 전송 전환을 만능 해결책으로 보지 않기
- 진단 명령과 성공 판정 기준
- 이번 과정에서 남긴 교훈
1. 먼저 고정할 사실과 아직 모르는 것
이번 사례에서 확인된 요청 정보는 다음과 같다.
Host: localhost:8070
Origin: http://localhost:3031
Referer: http://localhost:3031/
프런트 화면의 출처는 http://localhost:3031, 요청 대상은 localhost:8070이었다. 추가로 확인된 사실은 다음과 같다.
- 배포 환경에서는 엑셀 다운로드가 됐다.
- Vue 로컬 실행에서는
Down2Excel호출 때 오류가 났다. - 상태가
200 OK인 요청은 사전 요청인OPTIONS가 아니라 실제POST였다. Content-Disposition: attachment;filename=aaaa.xlsx;가 응답에 있었다.Access-Control-Expose-Headers: Content-Disposition도 있었다.- 실제 Origin에 맞춘
@CrossOrigin과 헤더 직접 설정을 시도했다는 확인이 있었다. - 프록시 적용 후
/api경로 문제가 제기됐고, 재작성 설정 뒤에도 그 경로가 보인다는 상황까지 이어졌다.
반면 IBSheet의 정확한 버전, 플러그인 설정, 리다이렉트 전체 경로, 백엔드가 받은 최종 URI, 응답 초기화 호출 여부는 확인되지 않았다. 마지막 /api를 브라우저에서 봤는지 백엔드에서 봤는지도 확정되지 않았다.
이 차이가 중요하다. “헤더가 사라졌으니 필터가 범인이다”와 “필터 또는 응답 초기화 때문에 헤더가 바뀌었는지 확인한다”는 전혀 다른 진단이다. 전자는 결론이고, 후자는 아직 검증할 가설이다.
이하 코드의 /download/excel은 설명용 백엔드 경로다. 실제 프로젝트의 컨트롤러 매핑, 컨텍스트 경로 또는 IBSheet 서버 모듈 경로로 바꿔야 한다.
2. 200 OK인데 브라우저가 실패하는 이유
다운로드를 한 덩어리의 성공·실패로 보면 원인이 쉽게 섞인다. 아래 네 가지는 각각 확인해야 한다.
- 요청이 서버에 도착했는가?
- 서버가 어떤 상태 코드와 본문으로 응답했는가?
- 브라우저가 그 응답을 JavaScript에 전달하도록 허용했는가?
- 받은 파일이 실제로 저장되고 정상적으로 열리는가?
200 OK는 두 번째 단계의 HTTP 상태다. 서버가 응답한 뒤 CORS 검사에 실패하면 Fetch/XHR을 사용하는 화면 코드에는 네트워크 오류로 전달될 수 있다. Network에 상태가 보여도 JavaScript가 내용을 읽을 수 없다는 뜻이다. Fetch 표준의 CORS 검사
또한 200으로 받은 본문이 엑셀이라고 단정할 수 없다. 로그인 페이지로 이동한 결과일 수도 있고, 서버 모듈이 오류를 HTML로 출력했을 수도 있다. 파일명 헤더만 미리 설정한 뒤 파일 생성이 실패하는 경우도 구분해야 한다.
따라서 점검할 것은 상태 코드뿐 아니라 실제 요청 메서드, 최종 URL, 응답 Content-Type, 리다이렉트, 파일의 열림 여부다. IBSheet 역시 서버 모듈 디버깅 문서에서 실제 요청 URL과 서버 측 예외를 확인하도록 안내한다. IBSheet 서버 모듈 오류 점검
이번 경우 실제 POST 200까지 확인했으므로 그 요청이 “사전 요청에서 막혀 아예 전송되지 않았다”고 설명할 수는 없다. 그렇다고 관련된 모든 요청에 대해 프리플라이트 문제가 없다고 일반화할 수도 없다. 버튼 한 번으로 여러 요청이 생기는지, 오류가 어느 요청에 연결돼 있는지부터 맞춰야 한다.
참고로 사전 요청은 엑셀의 용량이 커서 발생하는 것이 아니다. 메서드, 요청 헤더, 요청 Content-Type 등이 조건을 결정한다. 예를 들어 교차 출처 JSON POST나 사용자 지정 요청 헤더는 프리플라이트를 유발할 수 있다. MDN CORS 안내
3. 파일명 헤더와 출처 허용 헤더는 다르다
이 사례에서 가장 먼저 분리해야 했던 부분이다. 이름이 비슷해 보여도 각각 다른 질문에 답한다.
Content-Disposition: 파일을 어떻게 취급할 것인가
Content-Disposition: attachment; filename="report.xlsx"
응답을 첨부 파일로 취급하고 파일명을 제안하는 메타데이터다. 다른 출처의 JavaScript에 응답을 공개한다는 뜻은 아니다. MDN Content-Disposition
Access-Control-Expose-Headers: 어떤 응답 헤더를 읽게 할 것인가
Access-Control-Expose-Headers: Content-Disposition
CORS를 통과한 응답에서 화면 코드가 파일명 헤더를 읽게 한다. 노출되지 않은 헤더는 Fetch의 headers.get()에서 null로 보일 수 있다. 이것이 곧 요청 전체의 CORS 실패를 의미하지는 않는다. MDN Access-Control-Expose-Headers
이미 이 설정이 있는 상태라면 같은 헤더를 반복해서 추가하기보다 MissingAllowOriginHeader가 가리키는 응답의 출처 허용을 확인해야 한다.
Access-Control-Allow-Origin: 어느 출처에 응답을 공개할 것인가
Access-Control-Allow-Origin: http://localhost:3031
요청의 Origin과 서버의 허용 정책이 맞아야 한다. 프런트 주소와 백엔드 주소를 혼동해 localhost:8070을 넣는 것이 아니다. 이번 요청의 출처는 localhost:3031이었다. MDN Allow-Origin 누락 오류
실제 응답에서 이 헤더가 없으면, Expose-Headers가 있다고 해결되지 않는다. 쿠키 등 credentials를 포함한 교차 출처 요청이라면 Access-Control-Allow-Credentials: true도 필요하며, 허용 출처에 *를 쓸 수 없다. Fetch 표준의 credentials와 CORS
여기서도 코드에 설정이 있다는 사실과 최종 응답에 유효한 헤더가 있다는 사실은 다르다. 응답을 만든 경로가 설정 대상인지, 인증 리다이렉트를 거쳤는지, 프록시나 다운로드 유틸리티에서 헤더가 변경됐는지 확인해야 한다.
4. IBSheet 다운로드가 어떤 방식인지 확인하기
IBSheet의 함수 이름만 보고 전송 방식을 단정하면 안 된다. 이번 사례에서 Down2Excel이라는 표현이 사용됐지만, 이것만으로 버전과 내부 요청 구현을 확정할 수는 없다.
IBSheet8 공식 지원 문서에 따르면 서버 모듈을 사용하는 엑셀 전송의 기본 방식은 iframe을 대상으로 하는 form submit이다. reqHeader나 useXhr 설정에 따라 XHR 방식이 사용될 수 있다. 설치 버전이 다르면 지원 옵션과 기본 동작도 확인해야 한다. IBSheet8 전송 방식과 iframe 관련 안내
먼저 개발자 도구를 열고, 기존 네트워크 기록을 비운 뒤 다운로드 버튼을 한 번만 누른다. Network의 Type과 Initiator, 요청 URL을 함께 확인한다.
- fetch/xhr 요청이라면 응답 접근에 대한 CORS 검사가 직접적인 점검 대상이다.
- document/iframe 요청이라면 프레임 차단 메시지와 뒤이어 실행되는 스크립트 요청도 확인한다.
- 여러 요청이 생긴다면 다운로드 POST와 오류가 난 요청이 같은지 구분한다.
Refused to display ... in a frame과 MissingAllowOriginHeader는 같은 오류가 아니다. 전자는 프레임 표시 정책, 후자는 교차 출처 응답 접근과 연결된다. 원인에 맞는 설정을 찾아야 한다.
인증을 위해 추가한 헤더가 전송 방식을 바꿨을 가능성도 살펴볼 수 있지만, 확인 없이 reqHeader를 제거해서는 안 된다. 그 헤더가 인증이나 CSRF 보호에 필요한 값일 수 있기 때문이다.
5. 로컬과 배포 환경의 요청 경로 비교
출처는 스킴, 호스트, 포트의 조합이다. 이번처럼 호스트가 둘 다 localhost여도 포트가 다르면 다른 출처다. localhost와 127.0.0.1도 브라우저에서 같은 호스트 이름으로 취급되지 않는다. MDN 동일 출처 정책
배포 환경에서 잘 된다는 사실은 중요한 비교 자료이지만, “운영이면 CORS가 필요 없다”는 뜻은 아니다. 배포 환경도 프런트와 API 출처가 다르면 CORS 정책을 확인해야 한다.
이번에 살펴볼 구조는 로컬 개발 서버가 다운로드 요청을 대신 전달하는 방식이다. 브라우저가 다른 서버에 직접 접속하는 것이 아니라, 자신과 같은 출처의 개발 서버에 요청한다.
| 확인 위치 | 요청 주소 |
|---|---|
| 브라우저의 프런트 화면 | http://localhost:3031 |
| 브라우저가 호출할 경로 | /api/download/excel |
| 프록시가 보낼 대상 | http://localhost:8070 |
| 백엔드가 받을 경로 | /download/excel |
여기서 /api는 로컬 프록시를 선택하기 위한 접두사이고, 백엔드에는 없는 경로라고 가정한다. 실제 백엔드가 /api/download/excel을 받는다면 접두사를 제거하면 안 된다.
이 구조는 브라우저 보안 검사를 끄거나 속이는 작업이 아니다. 브라우저의 요청 대상과 백엔드로 중계되는 대상을 분리하는 것이다. 프록시는 별도의 서버 구성 요소이며, 인증과 권한 검사는 여전히 필요하다.
6. Vite 프록시와 경로 재작성 설정
다음은 기존 Vue 프로젝트의 vite.config.ts에 병합할 예시다. 플러그인, 별칭, 빌드 설정 등 다른 설정은 유지한다. /api/로 시작하는 요청만 잡도록 범위를 좁혔다.
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: 'localhost',
port: 3031,
strictPort: true,
proxy: {
'/api/': {
target: 'http://localhost:8070',
changeOrigin: true,
rewrite: path => path.replace(/^\/api(?=\/)/, ''),
},
},
},
})
Vite는 경로 접두사에 맞는 요청을 target으로 전달하고, rewrite로 전달할 경로를 바꿀 수 있다. 포트가 다른 번호로 자동 변경되는 것을 막기 위해 예시에는 strictPort도 넣었다. Vite 서버 옵션
각 값은 다음처럼 읽으면 된다.
/api/: 프런트에서 프록시를 이용할 요청 경로의 시작 부분.target: Vue 개발 서버에서 접속 가능한 백엔드 주소. 별도의/api를 중복해서 넣지 않는다.changeOrigin: 대상 서버에 보낼 Host 헤더를 맞추는 옵션. 이것만으로 브라우저의 CORS 정책이 해제되는 것은 아니다.rewrite: 대상 서버로 전달하기 전에/api부분을 제거한다.
changeOrigin의 정확한 범위는 사용하는 프록시 라이브러리의 옵션을 따른다. 요청 Origin까지 마음대로 신뢰해도 된다는 설정으로 해석하면 안 된다. Vite가 사용하는 프록시 라이브러리 옵션
작성한 경계 조건은 /apiary/...처럼 이름만 비슷한 경로를 실수로 처리하지 않기 위한 것이다. 이 예시에서는 /api 단독 호출을 다루지 않고 /api/ 아래 요청만 처리한다. 다운로드 주소도 이 규칙과 맞춰야 한다.
브라우저에서는 다음처럼 호출한다.
// 화면의 출처가 http://localhost:3031일 때
const excelEndpoint = '/api/download/excel'
반대로 아래 주소로 요청하면 8070 백엔드에 직접 접속하므로 3031 개발 서버의 프록시를 거치지 않는다.
const directBackendEndpoint = 'http://localhost:8070/download/excel'
엄밀히 말하면 절대 URL 자체가 문제인 것은 아니다. http://localhost:3031/api/download/excel처럼 프런트 개발 서버를 향한 절대 URL도 그 프록시로 들어갈 수 있다. 다만 환경마다 주소를 고정하지 않도록 루트 상대 경로를 사용하는 편이 관리하기 쉽다.
설정 파일 변경 후에는 개발 서버를 다시 실행해 새 설정과 포트를 확인한다. 실제 스크립트는 package.json을 기준으로 선택한다.
# Vite 프로젝트의 실행 스크립트가 dev인 경우
npm run dev
프로젝트가 Docker나 WSL 안에서 실행된다면 target의 localhost가 어디를 가리키는지도 확인한다. 그 주소는 브라우저가 아니라 Vue 개발 서버 프로세스가 실행되는 환경을 기준으로 해석된다. 주소를 임의로 바꾸기 전에 실행 위치와 백엔드 리스닝 주소를 맞춰보는 것이 먼저다.
7. Vue CLI를 사용하는 경우
Vue CLI 프로젝트라면 vite.config.ts가 아니라 vue.config.js의 devServer.proxy를 사용한다. Vite의 rewrite와 이 환경의 pathRewrite를 섞지 않는다. Vue CLI는 유지보수 모드이지만, 기존 프로젝트의 문제를 고치기 위해 빌드 도구부터 바꿀 필요는 없다. Vue CLI 설정 문서
module.exports = {
devServer: {
host: 'localhost',
port: 3031,
proxy: {
'/api/': {
target: 'http://localhost:8070',
changeOrigin: true,
pathRewrite: {
'^/api(?=/)': '',
},
},
},
},
}
이 예시는 Vue CLI의 객체형 프록시 설정 기준이다. webpack-dev-server를 직접 설정하는 다른 버전의 프로젝트에 같은 모양을 무조건 복사하지 말고, 설치된 빌드 도구의 문법을 확인한다. 경로 재작성 정규식과 쿼리 문자열 처리는 http-proxy-middleware 문서에서도 확인할 수 있다.
프록시 규칙을 수정한 뒤에는 기존 개발 서버 프로세스를 종료하고 프로젝트의 실제 실행 명령으로 다시 시작한다.
# Vue CLI 프로젝트의 실행 스크립트가 serve인 경우
npm run serve
8080이나 다른 포트에서 별도 개발 서버가 떠 있다면, 수정한 프로젝트가 아니라 다른 프로세스에 접속하고 있지는 않은지도 확인한다. 브라우저 주소와 터미널에 표시된 실행 주소가 같아야 한다.
8. IBSheet의 실제 다운로드 주소 연결하기
일반 조회에서 사용하는 Axios 인스턴스의 baseURL을 바꿨다고 IBSheet가 자체적으로 보내는 요청 주소까지 바뀌는 것은 아니다. 엑셀 플러그인이나 공통 설정이 별도 주소를 사용할 수 있다.
다음 코드는 IBSheet8 서버 모듈 방식의 초기화 설정 예시다. 기존 options가 이미 존재하고, 시트를 생성하기 전에 적용한다. 나머지 Cfg와 Export 항목은 유지한다.
// IBSheet8 예시: 시트 생성 전, 기존 options에 병합
options.Cfg = {
...options.Cfg,
Export: {
...options.Cfg?.Export,
Down2ExcelUrl: '/api/download/excel',
},
}
기존 다운로드 호출에 필요한 옵션과 인증 설정은 그대로 유지한다. 호출 형태를 보여주기 위한 최소 예시는 다음과 같다.
// IBSheet8 예시. 실제 프로젝트의 기존 옵션을 유지할 것.
sheet.down2Excel({
downCols: 'Visible',
downRows: 'Visible',
})
IBSheet8 공식 문서는 개별 다운로드 엔드포인트에 Cfg.Export.Down2ExcelUrl을 사용하고, 공통 처리 디렉터리에는 Cfg.Export.Url과 Ext를 사용하는 예시를 제시한다. 두 설정을 같은 의미로 섞지 않는다. IBSheet8 서버 모듈 구현 가이드
/download/excel은 IBSheet가 보내는 데이터를 처리할 수 있는 프로젝트의 기존 서버 엔드포인트로 대체해야 한다. 임의의 JSON API에 주소만 연결하면 IBSheet 전송 계약이 맞지 않을 수 있다. 위 코드도 이미 생성된 시트의 설정을 반드시 즉시 바꾼다는 뜻은 아니다.
사용 중인 것이 IBSheet7 또는 별도 래퍼라면 이 예제를 그대로 넣지 않는다. 현재 버전의 설정과 래퍼가 실제로 생성한 요청을 대조해야 한다. 함수 이름만 대문자에서 소문자로 바꾸는 것도 해결책이 아니다.
프로젝트 내 설정 후보를 찾을 때는 값 전체를 외부에 공유하지 말고 우선 파일 위치부터 확인할 수 있다.
rg -l 'Down2Excel|down2Excel|Down2ExcelUrl|useXhr|reqHeader' src public
src, public이 없는 프로젝트라면 실제 소스 디렉터리로 바꾼다. 공통 설정이 나중에 덮어쓰는 경우도 있으므로, 검색 결과보다 최종 Network 요청 URL을 확인하는 것이 결정적이다.
9. rewrite했는데도 /api가 보이는 이유
이번 확인 과정의 마지막 질문은 “재작성을 설정했는데도 /api가 붙어서 온다”였다. 이때 가장 중요한 질문은 어디에서 그 경로를 확인했는가다.
브라우저 Network에서 봤다면
http://localhost:3031/api/download/excel
이 주소가 남는 것은 정상이다. 브라우저가 요청한 주소는 프런트 개발 서버의 /api/download/excel이다. 경로 재작성은 그 뒤에 개발 서버가 백엔드로 중계할 때 적용된다. 브라우저를 새 URL로 이동시키는 리다이렉트가 아니다.
따라서 Network에서 /api가 없어지기를 기다리며 설정을 계속 바꾸면, 정상 동작하는 규칙까지 망가뜨릴 수 있다.
백엔드 접근 로그에서 봤다면
기대하는 결과는 아래와 같다. 이는 이번 프로젝트에서 실제 관측한 로그가 아니라 확인해야 할 형태다.
POST /download/excel
백엔드에서 실제로 POST /api/download/excel이 확인된다면 그때는 재작성 규칙, 설정 로딩, 중복 프록시, 실제 요청 대상을 조사한다. 브라우저에 보이는 경로와 백엔드 로그는 서로 다른 구간을 보여준다.
짧은 예시로 경로 변환을 직접 확인할 수도 있다.
const rewrite = path => path.replace(/^\/api(?=\/)/, '')
console.log(rewrite('/api/download/excel'))
// /download/excel
console.log(rewrite('/api/download/excel?format=xlsx'))
// /download/excel?format=xlsx
console.log(rewrite('/apiary/download/excel'))
// /apiary/download/excel
마지막 예시는 이름이 비슷한 경로를 잘못 잘라내지 않는지 확인한다. 다만 이 문자열 테스트가 실제 Vite나 IBSheet의 실행 성공을 입증하는 것은 아니다. 실제 중계 여부는 반드시 양쪽 요청을 확인해야 한다.
Vite에서 전달 경로를 임시로 확인하려면
앞에서 작성한 프록시 규칙 안에 다음 configure 항목을 임시로 추가할 수 있다. 기존 항목을 대체하는 전체 설정이 아니라 진단용 추가 코드다. 프로젝트에서 사용하는 프록시 버전의 이벤트 지원도 확인한다.
configure(proxy) {
proxy.on('proxyReq', (proxyReq, req) => {
const upstreamPath = proxyReq.path.split('?')[0]
// 이 예시의 다운로드 경로만 확인한다.
if (
upstreamPath === '/download/excel' ||
upstreamPath === '/api/download/excel'
) {
console.info('[excel-proxy]', {
method: req.method,
upstreamPath,
})
}
})
},
출력 위치는 브라우저 콘솔이 아니라 Vite가 실행 중인 터미널이다. 프록시의 요청 이벤트와 경로 옵션은 프록시 구현 문서에서 확인할 수 있다.
쿼리 문자열, Cookie, Authorization, 전체 요청 본문은 출력하지 않는다. IBSheet 다운로드 요청에는 화면 데이터가 들어갈 수 있으므로 “디버깅을 위해 전체 로그를 남기자”는 접근은 피한다. 확인이 끝나면 임시 로그를 제거한다.
10. 같은 증상이 남을 때 점검할 순서
설정을 추가해도 문제가 남는다면 아래 순서대로 좁히는 편이 빠르다. 여러 계층을 한 번에 수정하면 어떤 변경이 효과가 있었는지 알기 어렵다.
1단계: 브라우저가 아직 8070으로 직접 요청하는가
Network의 Request URL이 http://localhost:8070/...이면 3031 프록시를 거치지 않았다. IBSheet 설정, 래퍼 함수, 공통 설정, 환경별 API 주소를 다시 확인한다.
이 경우 rewrite 정규식을 더 바꾸는 것은 도움이 되지 않는다. 요청 자체가 해당 프록시에 도착하지 않기 때문이다.
2단계: 요청이 3031로 가지만 경로가 규칙과 다른가
예시 규칙은 /api/로 시작하는 요청만 받는다. 다운로드가 /excel/..., /download/..., /API/...로 호출되면 같은 규칙에 포함되지 않는다.
SPA 개발 서버가 매칭되지 않은 경로에 프런트의 HTML을 돌려주는지도 확인한다. 상태가 200이고 본문이 index.html이라면 엑셀 생성 서버에 도달하지 않았을 수 있다. 이때도 CORS 설정부터 추가할 상황은 아니다.
3단계: 백엔드가 기대하는 실제 경로와 다른가
예를 들어 백엔드 컨텍스트 경로가 /service이고 컨트롤러가 /download/excel을 받는다면 실제 요청은 /service/download/excel이어야 한다.
브라우저 요청 경로: /api/service/download/excel
재작성 후 전달 경로: /service/download/excel
/api를 제거할지 유지할지는 이름 취향이 아니라 백엔드의 실제 매핑에 달려 있다. target에 경로를 넣고 클라이언트에도 같은 경로를 넣어 중복되지 않았는지도 살펴본다.
4단계: 개발 서버와 설정 파일이 맞는가
vite.config.ts를 수정했는데 vue-cli-service serve로 실행하고 있다면 그 설정이 적용되지 않는다. 모노레포에서는 실행한 작업 디렉터리, --config 지정 여부, 사용하는 환경 파일도 함께 확인한다.
설정 파일을 읽는지 확인하려고 로그를 넣을 때는 고정 문구만 사용한다. 환경 변수 전체를 출력할 필요는 없다.
5단계: 로그인 리다이렉트로 프록시 밖으로 나갔는가
첫 요청은 3031로 보냈어도 백엔드가 다른 출처의 로그인 페이지로 리다이렉트할 수 있다. Network의 Preserve log를 켜고 리다이렉트와 최종 요청을 확인한다.
파일명 헤더, 인증 쿠키, CSRF 토큰, SameSite·Secure 조건은 각각 별도 문제다. 프록시를 사용했다고 인증 정책이 자동으로 충족되는 것은 아니다.
또한 개발 프록시가 전달한 요청에 대해 백엔드의 CORS 필터나 Origin 검사 자체가 403을 반환할 수도 있다. 이 경우 “브라우저에서는 같은 출처인데 왜 403이지?”라고 보고, 해당 응답을 생성한 필터와 허용 정책을 확인한다. 보호 기능을 무조건 제거하는 방식으로 처리하지 않는다.
6단계: 배포 경로까지 로컬 설정으로 바꾸지는 않았는가
Vite·Vue CLI의 개발 프록시는 운영 웹 서버 설정을 대체하지 않는다. 배포 서버가 /api/...를 처리하지 않는데 클라이언트 주소만 그 경로로 바꾸면 로컬은 되고 배포 환경이 깨질 수 있다.
가능하면 개발·배포에서 클라이언트가 쓰는 경로를 일관되게 유지하고, 각 서버가 그 경로를 적절히 중계하도록 맞춘다. 기존 배포 라우팅을 바꿀 수 없다면 프로젝트가 이미 사용하는 환경별 API 주소 설정을 활용한다. 이 글의 로컬 설정을 운영 Nginx에 그대로 넣을 수는 없다.
11. 백엔드 CORS와 응답 초기화 점검
개발 프록시가 유용하더라도 백엔드 설정이 완벽하다고 결론 낼 수는 없다. 다른 출처에서 직접 API를 사용하는 구조라면 서버의 CORS 정책도 맞아야 한다.
CORS 설정은 실제 다운로드 매핑에 적용해야 한다
아래는 로컬 프로파일에서만 적용하는 Spring MVC 예시다. 프로젝트의 기존 CORS 설정과 중복해서 등록하지 말고 해당 정책에 필요한 부분을 병합한다.
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@Profile("local")
public class LocalCorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/download/**")
.allowedOrigins("http://localhost:3031")
.allowedMethods("POST")
.allowedHeaders(
"Content-Type",
"Authorization",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN")
.exposedHeaders("Content-Disposition")
.allowCredentials(true);
}
}
경로·메서드·헤더 목록은 실제 요청에 맞춘다. 예시의 allowCredentials(true)는 쿠키 인증 등이 필요한 경우를 가정했으며, 필요하지 않다면 생략한다. local 프로파일이 실제 활성화돼 있는지도 확인한다. Spring MVC CORS 설정
Spring Security를 사용한다면 기존 SecurityFilterChain에서 CORS 연동을 확인한다. 아래는 기존 체인에 추가할 부분이지, 인증·인가 설정을 교체하는 전체 코드가 아니다.
// import org.springframework.security.config.Customizer;
// 기존 SecurityFilterChain 구성 내부
http.cors(Customizer.withDefaults());
사전 요청에는 보통 로그인 세션 쿠키가 없으므로 CORS 처리가 인증 검사보다 앞서 이뤄져야 한다. 실제 연결되는 CORS 설정 소스와 필터 체인을 점검한다. 이 문제를 이유로 CSRF를 끄거나 모든 경로를 permitAll()로 바꾸지는 않는다. Spring Security CORS 통합
flush가 CORS 헤더를 지우는 것은 아니다
"엑셀 스트림을 flush해서 Spring이 CORS 헤더를 붙일 기회를 놓쳤다"는 설명을 일반적인 원리로 받아들이면 안 된다. Spring MVC는 실제 요청을 핸들러에 넘기기 전에 CORS 검증과 응답 헤더 처리를 수행한다. 따라서 출력 스트림 사용만으로 어노테이션이 무효가 된다고 단정할 수 없다. Spring CORS 처리 과정
응답 조작 메서드는 다음처럼 구분한다.
response.reset(): 본문 버퍼뿐 아니라 상태 코드와 헤더도 초기화한다.response.resetBuffer(): 본문 버퍼만 비우며 상태 코드와 헤더는 유지한다.response.flushBuffer(): 현재 응답을 내보내고 커밋한다.- 출력 스트림의
flush(): 응답을 커밋할 수 있지만, 이미 설정된 헤더를 지우는 동작은 아니다.
커밋 후 초기화를 시도하면 예외가 발생한다. 반대로 필요한 헤더를 설정하기 전에 응답을 커밋했다면 뒤늦은 헤더 변경을 기대할 수 없다. Jakarta ServletResponse API
조사할 가설은 “다운로드 유틸리티가 CORS 처리 후 reset()을 호출했는가”다. 실제 코드와 호출 순서로 확인해야 한다. 이번 사례에서 그 호출이 확인된 것은 아니다.
기존 메서드에 임시 진단을 넣을 수 있다면, 유틸리티 호출 전후에 다음과 같은 상태만 비교할 수 있다.
// 기존 메서드의 response와 logger를 사용한다.
// 다운로드 유틸리티 호출 전후에 같은 방식으로 비교한다.
log.debug("excelResponse committed={}, allowOriginPresent={}",
response.isCommitted(),
response.containsHeader("Access-Control-Allow-Origin"));
헤더 값 전체나 인증 정보를 출력하지 않아도 설정 여부의 변화는 확인할 수 있다. 다만 이 로그는 해당 시점의 서버 내부 상태이며, 브라우저에 도착한 최종 응답과는 추가 대조가 필요하다.
원인 확인 없이 reset()을 전부 resetBuffer()로 바꾸는 것도 피한다. 기존 상태 코드나 헤더를 지우려던 의도가 있을 수 있으므로 해당 다운로드 응답 생성 과정을 검토해야 한다. CORS 설정 역시 어노테이션·필터·리버스 프록시에 계속 덧붙이는 대신, 어느 계층이 응답 정책을 책임지는지 정리한다.
12. 폼 전송 전환을 만능 해결책으로 보지 않기
일반 HTML 폼 전송과 JavaScript가 응답을 읽는 Fetch/XHR은 브라우저에서 다르게 취급된다. 그렇다고 “폼으로 바꾸면 모든 보안을 무시하고 무조건 다운로드된다”고 설명할 수는 없다.
특히 다음 조건이 남는다.
- 폼은 Fetch처럼 임의의 Authorization 헤더를 자유롭게 붙이는 수단이 아니다.
- 기존 JSON 요청을 일반 폼으로 바꾸면 서버가 받는 데이터 형식이 달라질 수 있다.
- 일반 POST 폼의 기본 인코딩은
application/x-www-form-urlencoded다.multipart/form-data는 별도 설정이다. - 인증 쿠키, CSRF 검증, iframe 표시 정책, CSP, 다운로드 관련 브라우저 정책은 여전히 작동한다.
- 타 출처 프레임의 내용을 JavaScript로 읽는 데에는 동일 출처 정책의 제한이 남는다.
따라서 폼 전송은 서버 계약과 화면 동작에 맞춰 선택할 다운로드 방식이지, CORS 헤더가 잘못된 상태를 무조건 해결하는 버튼은 아니다. HTML 폼 전송 표준, MDN 동일 출처 정책
이번처럼 IBSheet 자체가 버전과 옵션에 따라 이미 폼 전송을 사용할 수 있는 상황에서는, 먼저 현재 방식부터 확인해야 한다. XHR 인증 헤더를 지우거나 X-Frame-Options를 전역 해제하는 식의 변경은 진단 단계에서 할 일이 아니다.
fetch(..., { mode: 'no-cors' })도 파일 다운로드용 해결책이 아니다. 응답이 opaque가 되면 JavaScript에서 헤더나 본문을 정상적인 다운로드 데이터처럼 읽을 수 없다. MDN Fetch Request mode
13. 진단 명령과 성공 판정 기준
여기서는 로컬 개발 서버만 대상으로 한다. 운영 데이터가 필요한 요청을 임의로 반복 실행하거나, 브라우저에서 복사한 쿠키·토큰을 터미널 기록과 채팅에 그대로 남기지 않는다.
CORS 사전 요청에 대한 응답 확인
백엔드가 CORS 요청을 어떻게 판단하는지 HTTP 수준에서 확인하는 예시다. Access-Control-Request-Headers 값은 실제 브라우저 요청에 맞춘다. 아래는 JSON POST를 가정한다.
curl --silent --show-error --max-time 15 \
--request OPTIONS \
'http://localhost:8070/download/excel' \
--header 'Origin: http://localhost:3031' \
--header 'Access-Control-Request-Method: POST' \
--header 'Access-Control-Request-Headers: content-type' \
--dump-header - \
--output /dev/null
명령은 서버가 보내는 헤더를 보여줄 뿐이다. curl은 브라우저의 CORS 접근 제한을 그대로 집행하지 않으므로, 응답이 출력됐다고 브라우저 테스트를 통과한 것은 아니다. 출력에 Set-Cookie 등 민감한 값이 포함된다면 공유 전 제거한다.
실제 POST가 이미 발생한 이번 상황에서 이 명령만 반복하는 것도 충분하지 않다. 현재 실패한 POST의 실제 응답을 보는 것이 우선이고, 별도로 필요한 프리플라이트 정책을 확인할 때 사용한다.
실제 다운로드 요청은 기존 정상 요청 계약을 유지한다
다운로드 API가 IBSheet 데이터, 인증, CSRF 값을 요구한다면 빈 curl -X POST로 검증할 수 없다. 기존 화면에서 같은 조건과 테스트 데이터를 사용해 재현하고 아래를 기록한다.
- 브라우저가 호출한 주소가 3031 개발 서버 아래인가?
- 프록시가 실제 다운로드 요청을 받았는가?
- 백엔드가 받은 경로가 실제 매핑과 일치하는가?
- 응답이 로그인·오류 HTML이 아니라 의도한 파일인가?
- CORS 또는 프레임 차단 오류가 사라졌는가?
- 파일명이 기대한 값이고, 내려받은 파일이 실제로 열리는가?
- 한글·빈 데이터·다수 행에서도 내용과 서식이 요구사항에 맞는가?
- 일반 조회와 배포 환경의 다운로드가 계속 정상인가?
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet는 XLSX에서 확인할 대표적인 MIME 타입이다. 서버가 application/octet-stream을 사용할 수도 있으므로 Content-Type 하나만으로 실패를 단정하지 않고, 실제 파일과 서버 계약을 함께 확인한다. IANA XLSX 미디어 타입 등록
변경 결과를 남길 최소 기록
아래 값은 완료 결과가 아니라 작업자가 채워야 하는 점검 양식이다.
브라우저 요청 URL:
브라우저 요청 유형(fetch/xhr/document 등):
실제 POST 상태:
백엔드 수신 경로:
최종 응답 Content-Type:
리다이렉트 유무:
현재 오류 메시지:
파일 저장 및 열기 결과:
일반 조회 회귀 테스트 결과:
배포 환경 회귀 테스트 결과:
이 정도만 남겨도 “아직 안 된다”는 보고를 어느 구간에서 멈췄는지 설명하는 보고로 바꿀 수 있다. 계정명, 고객 데이터, 엑셀 본문, 인증 헤더는 이 기록에 필요하지 않다.
14. 이번 과정에서 남긴 교훈
이번 문제에서 도움이 된 것은 새로운 CORS 설정을 계속 찾는 일이 아니라, 이미 확인한 사실을 다음 판단에 반영하는 일이었다.
첫째, Content-Disposition과 Expose-Headers가 이미 있었다면 그 설명에 다시 머물지 않아야 한다. 파일명 읽기와 출처 허용은 다른 단계다.
둘째, 실제 요청 Origin과 설정을 대조해야 한다. 하지만 3031로 맞췄다는 확인을 받은 뒤에도 단순 포트 오타를 계속 원인으로 삼을 수는 없다.
셋째, 실제 POST 200이라는 증거가 나왔다면 그 요청이 사전 요청 단계에서 막혔다고 설명하면 안 된다. 동시에 200을 파일 완성의 증거로 확대해서도 안 된다.
넷째, @CrossOrigin과 직접 헤더 설정을 넣었다는 사실만으로 헤더 유실의 원인을 특정할 수 없다. 실제 응답, 호출된 매핑, 응답 초기화, 필터 순서를 확인해야 한다.
다섯째, /api가 여전히 보인다는 말에는 반드시 관측 위치가 따라와야 한다. 브라우저에서 보이는 /api는 정상일 수 있지만, 제거하기로 한 접두사가 백엔드에서도 보인다면 재작성 문제다.
현재 확인 범위는 프록시와 경로 재작성을 점검하는 단계까지다. 백엔드 수신 URI와 최종 파일 열기를 확인해야 해결 완료로 기록할 수 있다. 그 결과를 생략한 채 “이 한 줄로 해결했다”고 마무리하는 것보다, 다음 확인 지점을 명확히 남기는 편이 재현과 유지보수에 도움이 된다.
같은 증상을 다시 만난다면, 먼저 아래 세 질문부터 확인하면 된다.
- 지금 오류는 어느 요청에서 발생했는가?
- 브라우저와 백엔드는 각각 어떤 주소를 보고 있는가?
- HTTP 응답을 받은 것과 실제 파일이 정상인 것을 따로 확인했는가?
이 세 가지가 정리되면 로컬 CORS 오류, 다운로드 전송 방식, 프록시 미적용, 경로 불일치를 서로 다른 문제로 다룰 수 있다.