웹 개발을 하다 보면 Google Apps Script(GAS)를 API 서버처럼 활용하고 싶을 때가 많습니다. 하지만 브라우저에서 GAS로 데이터를 보낼 때 마주치는 CORS(Cross-Origin Resource Sharing) 오류와 405 Method Not Allowed 에러는 개발자를 가장 당혹스럽게 만드는 요소 중 하나입니다.
오늘은 GAS에서 발생하는 고질적인 헤더 조작 문제와 OPTIONS 요청 처리 실패의 구조를 파헤치고, Simple Request(단순 요청) 전략을 통해 이 오류들을 깔끔하게 우회하는 방법을 알아보겠습니다. 이 방법은 별도의 프록시 서버 없이도 외부 웹사이트와 GAS 간의 안정적인 통신을 구현하는 가장 확실한 해결책입니다.
1. CORS 오류의 근본 원인과 Preflight 요청의 함정
CORS 오류는 브라우저가 보안을 위해 서로 다른 도메인 간의 데이터 전송을 차단하면서 발생합니다. 특히 GAS 환경에서 문제가 되는 것은 프리플라이트(Preflight) 요청입니다.
브라우저는 복잡한 요청(예: JSON 형식의 POST 요청)을 보낼 때, 실제 데이터를 보내기 전 서버가 안전한지 확인하기 위해 OPTIONS 메서드로 예비 요청을 먼저 보냅니다. 하지만 GAS의 ContentService는 이 OPTIONS 요청을 적절히 처리하거나 사용자 정의 헤더를 설정하는 기능을 지원하지 않습니다. 이 과정에서 서버가 응답을 주지 못해 405 오류나 헤더 미비로 인한 CORS 차단이 발생하는 것입니다.
2. Simple Request 전략: Preflight를 건너뛰는 마법
가장 명쾌한 해결책은 브라우저가 Preflight(OPTIONS 요청)를 보내지 않도록 만드는 것입니다. 이를 Simple Request(단순 요청)라고 부릅니다. 특정 조건을 만족하면 브라우저는 예비 검사 없이 바로 데이터를 전송하며, GAS는 이를 정식 Form Data로 인식하여 자동으로 CORS 허용 응답을 내어줍니다.
Simple Request가 되기 위한 3가지 필수 조건
| 항목 | 필수 조건 | 역할 |
| Method | POST (또는 GET/HEAD) | 복잡한 검사 없이 직진 통과 |
| Headers | Accept, Accept-Language 등 기본 헤더만 사용 | 사용자 정의 헤더 추가 금지 |
| Content-Type | application/x-www-form-urlencoded | 정해진 서류 양식(Form Data)으로 간주 |
주의: 만약
Content-Type을application/json으로 설정한다면 100% 확률로 Preflight가 발생하며, GAS에서는 오류를 뿜어내게 됩니다.
3. 클라이언트와 서버 코드 최적화 방법
Simple Request 전략을 적용하려면 기존의 JSON 통신 방식을 Form Data 방식으로 전환해야 합니다. 아래 표를 통해 변화를 확인해 보세요.
| 구분 | 기존 (CORS 발생 위험) | 수정 (Simple Request 전략) | 효과 |
| 클라이언트 헤더 | Content-Type: application/json | Content-Type: application/x-www-form-urlencoded | OPTIONS 요청 생략 |
| 서버 데이터 읽기 | JSON.parse(e.postData.contents) | e.parameter.prompt | 데이터 파싱 오류 제거 |
클라이언트 (HTML/JavaScript) 구현 예시
데이터를 전송할 때 URLSearchParams를 사용하여 데이터를 인코딩하는 것이 핵심입니다.
const url = 'https://script.google.com/macros/s/YOUR_SCRIPT_ID/exec';
const data = new URLSearchParams();
data.append('prompt', '안녕하세요, openipc.kr 서비스 문의입니다.');
fetch(url, {
method: 'POST',
body: data,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
.then(response = response.text())
.then(result = console.log(result))
.catch(error = console.error('Error:', error));서버 (Apps Script) 구현 예시
전송된 데이터는 e.parameter 객체를 통해 매우 직관적으로 접근할 수 있습니다.
function doPost(e) {
const userPrompt = e.parameter.prompt;
if (!userPrompt) {
return createTextResponse("Error: 요청 본문에 'prompt'가 없습니다.");
}
// 비즈니스 로직 처리 (예: 스프레드시트 기록 등)
return createTextResponse("성공적으로 처리되었습니다!");
}
function createTextResponse(content) {
const output = ContentService.createTextOutput(content);
output.setMimeType(ContentService.MimeType.TEXT);
return output;
}4. FAQ: GAS CORS 오류 관련 자주 묻는 질문
왜 ContentService.createTextOutput에는 setHeader가 없나요?
Apps Script의 ContentService는 보안과 간결함을 위해 설계되었습니다. 헤더를 직접 조작하는 기능을 열어두지 않았기 때문에, 브라우저 표준 정책을 따르는 Simple Request 방식으로 대응하는 것이 가장 안정적인 표준 개발 방식입니다.
Simple Request 전략이 모든 상황을 해결하나요?
대부분의 API 호출이나 폼 전송에서 발생하는 405 오류를 해결합니다. 다만, 반드시 특수한 커스텀 헤더(예: API Key 헤더)를 사용해야 하거나 매우 복잡한 데이터 구조를 보내야 할 때는 프록시 서버를 경유하는 등 추가적인 아키텍처 고민이 필요할 수 있습니다.
e.parameter 대신 e.postData.contents를 쓰면 안 되나요?
e.postData.contents는 주로 JSON 데이터를 담고 있을 때 사용합니다. 하지만 앞서 설명했듯이 JSON 전송은 브라우저의 OPTIONS 요청을 유발하여 CORS 오류로 이어지기 쉽습니다. 안정성을 최우선으로 한다면 Form Data 방식과 e.parameter 조합을 강력히 권장합니다.
5. 최종 해결 체크리스트
안정적인 통신을 위해 배포 전 다음 항목을 마지막으로 확인하세요.
- [ ] 클라이언트:
Content-Type이application/x-www-form-urlencoded인가? - [ ] 클라이언트:
URLSearchParams객체를 사용하여 데이터를 직렬화했는가? - [ ] 서버:
doPost(e)함수에서e.parameter를 사용해 데이터를 읽는가? - [ ] 서버: 응답 시
ContentService.createTextOutput을 사용하는가? - [ ] 배포: 코드를 수정한 후 반드시 새 버전(New Version)으로 재배포했는가?
이 Simple Request 우회법을 적용하면 그동안 여러분을 괴롭혔던 GAS의 CORS 지옥에서 벗어날 수 있습니다. 안정적인 API 통신으로 개발 생산성을 높여보시기 바랍니다.

