프로젝트 CoTe-Coach를 개발하던 중, 분명 간단해 보였던 외부 API 연동이 발목을 잡았다.
나를 괴롭혔던 CORS의 실체와, 수많은 시행착오 끝에 자체 프록시 서버를 구축해 해결한 과정을 아주 상세하게 기록해본다. (내용이 좀 길지만, 저와 같은 삽질을 하는 분들께 도움이 되길 바라며 하나도 빠짐없이 다 적어봤다!)
기획 의도: 외부 API 연동의 필요성
CoTe-Coach 프로젝트의 핵심 기능 중 하나는 사용자의 Solved.ac 티어와 추천 문제를 실시간으로 보여주는 것이다.
사용자가 자신의 현재 위치를 파악하고 동기부여를 얻을 수 있도록 외부 API(Solved.ac) 연동은 필수적이었다. 이를 위해 Solved.ac API를 활용하기로 했다.
초기 구현: corsproxy.io
프로젝트 초기, API 연동을 구현할 때 나는 `corsproxy.io`를 사용했다.
// Axios 인터셉터로 프록시 URL 적용
solvedAcApi.interceptors.request.use((config) => {
const fullUrl = axios.getUri(config);
config.url = `https://corsproxy.io/?url=${encodeURIComponent(fullUrl)}`;
return config;
});
사용한 이유는 별 게 없었다. 별도의 서버 구축이나 복잡한 설정 없이, API URL 앞에 주소만 붙이면 즉시 해결되는 가성비 방식이었기 때문이다. 실제로 개발 초기에는 로컬과 배포 환경 모두에서 티어 정보가 완벽하게 출력되었다.

🚨 문제 발생: "undefined 6"
하지만 배포 후 갑자기 문제가 생겼다.
잘 돌아가던 사이트의 티어 정보가 "undefined 6"으로 표시되기 시작한 것이다. 추천 문제 목록 역시 아예 로딩되지 않았다. 분명 코드를 수정한 게 없는데...😱

브라우저 콘솔을 열어보니 아래와 같은 에러 메시지가 떴다.😪
GET https://corsproxy.io/?url=https%3A%2F%2Fsolved.ac%2Fapi%2Fv3%2Fuser%2Fshow%3Fhandle%3D... 403 (Forbidden)
문제 해결 방식 설명 전 필요한 배경 지식: CORS와 프록시
1. CORS가 대체 뭐야?

CORS는 브라우저가 적용하는 보안 정책이다. 내 앱에서 요청을 보내면, 브라우저가 응답을 받기 전에 "잠깐! 상대방 서버가 너한테 응답 줘도 된다고 허락했어?"라고 확인한다.
출처(Origin)란 프로토콜 + 도메인 + 포트의 조합인데
https://cote.timeqlife.com(내 앱)https://solved.ac(외부 API)
이 둘은 출처가 다르다(Cross-Origin). 그런데 solved.ac 서버는 응답에 "너한테 데이터 줘도 돼"라는 허가 헤더(Access-Control-Allow-Origin)를 안 보내준다. 그러니까 브라우저가 중간에서 차단해버리는 것! ❌
❗ 이때 주의 해야 할 사항: CORS는 서버가 아닌 브라우저가 적용하는 정책이다. 서버끼리의 통신에는 CORS가 없다!
2. 로컬 상식: 로컬에서는 왜 됐을까?
배포 버전과 달리, 로컬에서는 문제없이 사용자 정보가 떴었기에 그 원인도 찾아봤다.
이유는 다음과 같다. 우선 로컬에서는 Vite 설정에 server.proxy라는 걸 넣어뒀다.
// vite.config.ts
proxy: {
'/api/v3': {
target: '[https://solved.ac](https://solved.ac)',
changeOrigin: true,
}
}
이걸 설정하면 브라우저는 localhost:5173/api/v3/...에 요청을 던지는 것으로 착각하고(보안 통과), 실제로는 Vite 개발 서버가 대리인(Proxy)이 되어 대신 solved.ac에 가서 데이터를 가져온다. 하지만 GitHub Pages는 정적 서버라 이런 대리인 기능이 없다. 그래서 배포만 하면 브라우저가 직접 solved.ac에 가려다 차단당하는 거였다.
추가로, 프록시를 이용해 사용자 정보를 가져오는 함수는
/**
* 사용자 정보 가져오기
*/
export const fetchSolvedAcUser = async (handle: string): Promise<SolvedAcUser> => {
try {
if (import.meta.env.PROD) {
return await prodFetch<SolvedAcUser>('/user/show', { handle });
}
const { data } = await solvedAcApi.get('/user/show', { params: { handle } });
return data;
} catch (error: any) {
console.error('[Solved.ac] fetchSolvedAcUser 실패:', {
status: error.response?.status,
message: error.message,
});
throw error;
}
};
`import.meta.env.PROD`를 통해 배포 버전에서만 실행되도록 구현해 두었다.
기술적 도전: 해결 시도 과정
브라우저가 직접 못 가니까, 중간에 대리인(프록시 서버)을 세워야 한다. corsproxy.io도 이미 만들어진 무료 프록시였다.
원래 코드: corsproxy.io — 실패 (403 Forbidden)
// Axios 인터셉터로 프록시 URL 적용
solvedAcApi.interceptors.request.use((config) => {
const fullUrl = axios.getUri(config);
config.url = `https://corsproxy.io/?url=${encodeURIComponent(fullUrl)}`;
return config;
});
- 결과:
403 Forbidden에러. - 원인: 프록시 서버 자체가 solved.ac 도메인으로 가는 요청을 막아두고 있었다. 내 서버가 아니니 해결 방법이 없었다.
시도 1: allorigins.win/raw — 실패 (CORS 차단)
다른 서비스를 찾아 나섰다. allorigins.win의 /raw 엔드포인트를 써봤다.
- 결과: CORS 에러가 떴다.
- 원인 분석:
/raw방식은 프록시가 응답을 원본 그대로 넘겨준다. solved.ac의 응답엔 원래 CORS 헤더가 없으니, 프록시가 넘겨받은 데이터에도 헤더가 없다. 브라우저는 "헤더 없네?" 하고 또 차단해버린 것... 🤯
시도 2 : allorigins.win/get — 실패 (522 서비스 다운)
이번엔 응답을 JSON으로 감싸서 프록시 자체 헤더를 붙여준다는 /get 방식을 썼다.
| 엔드포인트 | 동작 | CORS 헤더 |
| /raw?url= | 원본 응답 그대로 전달 | ❌ 원본 것 그대로 |
/get?url= |
응답을 { contents: "..." } JSON으로 감싸서 반환 |
✅ AllOrigins가 추가 |
- 결과:
net::ERR_FAILED 522에러. - 원인: HTTP 522는 Cloudflare의 "Connection Timed Out"이다. 프록시 서비스 자체가 과부하로 죽어있었다. (공짜의 한계...)
시행착오 요약 정리
| 시도 | 프록시 | 에러 | 실패 원인 |
| 원본 | corsproxy.io |
403 | 프록시가 solved.ac 도메인 차단 |
| 1차 | allorigins.win/raw |
CORS | 원본 응답 그대로 전달 → 헤더 없음 |
| 2차 | allorigins.win/get |
522 | 프록시 서비스 자체 다운 |
핵심 교훈: 무료 CORS 프록시 서비스는 근본적으로 불안정하다. 운영 환경에 쓰기엔 너무 위험하다!
최종 해결: Supabase Edge Function으로 자체 프록시 구축
결국 "내가 직접 프록시 서버를 만들자"로 결론을 내렸다. 이때 내가 눈여겨 봐둔 프록시 구축 방식은 Supabase Edge Function이었다.
Supabase Edge Function 이란, 쉽게 설명하면 "내가 필요할 때만 잠깐 켜서 쓰는 초소형 서버리스 함수"다. 이미 프로젝트에서 Supabase를 쓰고 있었기에 인프라를 추가할 부담이 전혀 없었고, 무엇보다 무료 티어 범위가 넉넉해서 비용 측면에서도 아주 합리적이었다(월 50만건 무료). 별도의 거대한 백엔드 서버를 운영하려면 돈도 관리 리소스도 많이 드는데, Supabase Edge Function은 딱 필요한 로직만 실행하니까 훨씬 효율적이었다.
서버 간 통신은 CORS 제약이 없다는 점을 이용해서, 내가 직접 응답 헤더에 "이거 허용해줄게!"(Access-Control-Allow-Origin: *)라고 딱지를 붙여주는 나만의 전용 대리인을 만들었다. TypeScript를 기본으로 지원하는 Deno 환경이라 설정도 깔끔하고, 유저와 가까운 서버에서 실행되니 속도까지 빨라서 아주 만족스러웠다.
여기에 내가 느낀 Edge Function의 매력을 좀 더 덧붙여보자면 다음과 같다.
1. 서버 간 통신의 마법 (CORS의 구세주) 🛡️ : 브라우저에서 직접 가면 막히지만, 서버끼리의 대화는 제약이 없다는 점을 완벽히 이용했다. Edge Function이 Solved.ac에 대신 가서 데이터를 받아온 뒤, 우리 브라우저에 배달해 주는 든든한 대리인이 되어준다.
2. Edge(가장자리)에서 오는 미친 속도 ⚡ : 이름 그대로 전 세계 도처에 흩어진 서버 중 유저와 가장 가까운 곳에서 실행된다. 덕분에 프록시를 거치는데도 응답 속도가 아주 빠릿빠릿해서 대만족이었다.
3. Deno 런타임의 간결함 🦕 : Node.js와 달리 TypeScript를 기본으로 지원해서 설정이 정말 깔끔했다. 냅다 URL로 모듈을 가져와 쓸 수 있어서 귀찮은 npm install 과정도 필요 없었다!
4. 철저한 보안 🔐 : 아무나 내 프록시를 쓰면 곤란한데, Supabase 보안 시스템과 연동되어 우리가 설정한 Anon Key가 있는 요청만 선택적으로 처리할 수 있어 마음이 놓였다.
기술 스택 선정 이유 정리
| 대안 | 장점 | 단점 | 채택 |
| 무료 프록시 계속 시도 | 즉시 적용 가능 | 불안정, 언제든 중단 가능 | ❌ |
| Cloudflare Worker | 높은 안정성 | 새 인프라 추가 필요 | ❌ |
| 별도 백엔드 서버 | 완전한 제어 | 관리 부담 및 비용 발생 | ❌ |
| Supabase Edge Function | 기존 인프라 활용, 무료, 안정적 | Deno 런타임 학습 | ✅ |
1. 전용 프록시 서버 구현 (서버 사이드)
내가 직접 응답 헤더에 "이거 허용해줄게!"라고 써주면 된다.
// supabase/functions/solved-ac-proxy/index.ts
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
serve(async (req) => {
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
const targetUrl = `https://solved.ac/api/v3${url.searchParams.get("path")}`;
const response = await fetch(targetUrl); // 서버 간 통신은 CORS 제약 없음!
const data = await response.text();
return new Response(data, {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
});
2. 401 Unauthorized 에러 (보안 이슈 해결)
프록시를 배포하고 호출했더니 이번엔 { "code": 401, "message": "Missing authorization header" }가 떴다. 원인은 Supabase 함수의 보안 설정! 아무나 내 프록시를 쓰면 안 되니까, 요청 헤더에 Supabase Anon Key를 꼭 넣어줘야 했다.
// src/api/solvedac.ts (프론트엔드 수정본)
const response = await fetch(
`${SUPABASE_PROXY_URL}?path=${encodeURIComponent(fullPath)}`,
{
headers: {
'Authorization': `Bearer ${import.meta.env.VITE_SUPABASE_ANON_KEY}`,
},
},
);
최종 아키텍처
브라우저(cote.timeqlife.com)가 내 서버에 요청을 보냄
-> Supabase Edge Function (자체 프록시)가 받음
-> CORS 제약 없이 solved.ac로 서버 간 통신 수행
-> solved.ac가 데이터를 응답함
-> Edge Function이 "CORS 허용 헤더"를 붙여서 브라우저에 최종 배달
-> 통과! 👍
회고
오류를 잡는 과정을 통해 단순히 "프록시를 쓰면 된다"는 사고방식보다 특정 방식은 실패하고 다른 방식은 성공하는지를 깊게 이해할 수 있었다.
- 환경 차이 이해: 로컬(Vite Proxy)과 운영(정적 서빙)의 구조적 차이를 완벽히 파악
- 체계적 디버깅: 가설→시도→분석→수정의 반복적 사이클을 통한 문제 해결 능력 증명
- 인프라 설계 판단력: 무료 서비스에 의존하지 않고 자체 인프라를 구축하는 장기적 관점 확보
- 새로운 기술 활용: Supabase Edge Function과 Deno를 처음 써보며 빠르게 적응하여 솔루션 구현
이 문제 때문에 몇 시간이나 허비했지만 해결하고 나니 굉장히 뿌듯하다!
'웹 프로그래밍' 카테고리의 다른 글
| [프론트 CI/CD 스터디 1주차] Github Action 환경에서의 AWS CF, S3 배포 설정 및 배포자동화(React CSR) (1) | 2024.04.07 |
|---|
