- Published on
Go Fiber에서 CORS 해결하기 (부제: 삽질 후기)
- Authors
- Name
- 신주용
CORS
CORS(Cross-Origin Resource Sharing)는 브라우저의 보안 정책으로 인해 발생하는 문제입니다1. 우리가 웹 사이트를 들어가면 브라우저는 그 웹 사이트를 보여주기 위해 HTML, JavaScript, CSS, 사진, 영상 등 여러 데이터를 서버로부터 받아오는데, 이런 데이터는 출처가 다르더라도 받아오는 것을 허용합니다. 그렇기 때문에 우리가 외부 라이브러리를 가져다 쓸 수 있는 것입니다.
하지만 API 호출 같은 경우에는 Same Origin 정책이 기본입니다. 그러므로 호출하려는 API의 호스트가 다르다면 해당 데이터는 받지 않습니다. 다른 출처에서 오는 데이터는 위험할 수 있으니 브라우저에서 보여주지 않는다는 것이죠.
<button
onClick={() => {
axios.get('http://localhost:8081/api/v1').then((resp) => {
console.log(resp)
})
}}
>
test
</button>
웹을 개발하게 되면 Django와 같이 자체 템플릿 언어가 있어서 프론트엔드와 백엔드를 모두 지원하는 큰 프레임워크도 있지만 대부분은 프론트는 React, Vue.js 같은 FE 프레임워크를 사용해 개발하고, 백엔드는 다른 언어로 따로 개발하는 경우가 많습니다. 그러면 보통 FE를 리액트로 개발하면 localhost:3000
으로 개발 서버가 실행될 것이고, 백엔드는 보통 localhost:8000
, localhost:8080
을 사용하는 경우가 많습니다. 그런데 분명 둘 다 제 컴퓨터에서 서버를 실행해서 둘 다 localhost
인데 왜 다른 출처라고 오류를 보여주는 걸까요?
우리가 사용한 localhost:8080
URL 중 프로토콜(http, https), 호스트(localhost, github.io), 포트(80, 443, 8000) 이 세 개를 포함한 부분을 출처(Origin)라고 부릅니다. 때문에 브라우저는 포트 번호만 달라도 다른 출처라고 인식하게 됩니다.
이 문제는 데이터의 출처를 비교하고 차단하는 브라우저의 동작 방식 때문에 발생하지만 해결은 서버쪽에서 해줘야 됩니다. 요청을 보낸 브라우저의 주소(localhost:3000 또는 FE 배포 서버 주소)가 허용되는 주소라고 서버 응답 헤더의 Access-Control-Allow-Origin
에 표시해주면 브라우저에서 이를 비교해 문제 없이 넘어갈 수 있습니다. CORS에 대한 더 자세한 내용은 전문적으로 설명해주는 블로그가 많으니 참고하시면 좋을 것 같습니다.
Go Fiber에서 CORS 문제 해결 방법
CORS 문제를 해결하는 방법은 의외로 간단합니다. Spring, Express, FastAPI, Fiber 등 각 언어마다 잘 만들어진 웹 프레임워크가 있고, 이를 사용하여 개발하고 있다면 이미 많은 개발자들이 고생한 문제이기 때문에 이미 미들웨어 형태로 만들어져 있을 것입니다. 제가 지금 사용 중인 Go 웹 프레임워크 Fiber에서도 이미 잘 만들어진 CORS middleware가 있고, 아래 예시와 같이 몇 줄만 추가하면 문제를 해결할 수 있습니다2.
// Initialize default config
app.Use(cors.New())
// Or extend your config for customization
app.Use(cors.New(cors.Config{
AllowOrigins: "https://gofiber.io, https://gofiber.net",
AllowHeaders: "Origin, Content-Type, Accept",
}))
다른 프레임워크도 해결 방법은 비슷합니다. Node.js의 Express나 Python Django도 미들웨어로 CORS를 다룹니다.
본편보다 더 긴 후기
단순히 API 서버를 단독으로 실행했다면 사실 해결이 그렇게 어렵지는 않습니다. 위에서 소개한 방법과 같은 미들웨어가 잘 갖춰져 있기 때문입니다. 앞서 말했듯 localhost:3000
을 Allow Origins 목록에 추가해주면 끝입니다.
app := fiber.New()
app.Use(cors.New(cors.Config{
AllowOrigins: "https://prod-server.com, http://localhost:3000",
}))
삽질의 시작
최근에 FE를 개발할 떄는 React를 많이들 사용하는 것 같습니다. 리액트는 포트를 따로 설정하지 않았다면 3000번을 기본 포트로 사용합니다. 그러니 localhost:3000은 너무 흔한 것 같고, AllowOrigins에 등록하면 보안적으로 문제가 되지 않을까? 싶었습니다. (배포 서버에는 로컬호스트를 추가하지 않는 단순한 해결 방법이 있는데... 내가 왜 그랬을까...😂)
토큰을 확인하면 되지 않을까?
얼마 전에 JWT를 사용한 로그인 기능을 구현해서 그런지 토큰을 활용한 방법이 제일 먼저 떠올랐습니다. 만약 현재 브라우저가 특정한 토큰을 헤더에 포함한 채로 API를 호출하면 응답 헤더의 Access-Control-Allow-Origin
으로 "https://prod-server.com, http://localhost:3000"
을 반환해주고 토큰이 없다면 "https://prod-server.com"
만 반환하는 것이죠.
이를 위해서는 CORS 미들웨어에서 요청 헤더를 확인하고, 토큰을 검증하는 로직이 추가적으로 들어가야 하므로 Fiber CORS 미들웨어 문서를 자세히 읽어봤습니다. Fiber CORS 미들웨어에는 AllowOriginsFunc
라는 기능이 있어 origin에 따라 추가적인 로직 실행을 할 수 있습니다.
app.Use(cors.New(cors.Config{
AllowOriginsFunc: func(origin string) bool {
return os.Getenv("ENVIRONMENT") == "development"
},
}))
하지만 Fiber에서 요청 헤더를 파싱하거나 응답 헤더를 다루려면 fiber.Ctx
가 필요한데, 이 함수는 요청 헤더의 origin
문자열만을 인자로 받아오므로 요청 컨텍스트나 반환을 마음대로 다룰 수 없다는 단점이 있었습니다.
그래서 대신 CORS 미들웨어 바로 다음에 동작하는 사용자 정의 미들웨어를 구현해봤습니다. Fiber에서 미들웨어는 자신의 로직을 실행한 결과에 문제가 없으면 c.Next()
를 호출해 다음 미들웨어나 비즈니스 로직으로 진행하고, 문제가 생기면 끊을 수 있습니다. 그래서 다음과 같이 구현하였습니다.
origins := "https://prodserver"
if FeDevHost != "" {
origins += ", " + FeDevHost
}
app.Use(cors.New(cors.Config{
AllowOrigins: origins,
}))
app.Use(func(c *fiber.Ctx) error {
h := c.GetReqHeaders()
if FeDevHost != "" && CorsSecret != "" {
if h["Origin"] == FeDevHost && h["X-Custom-Origin-Token"] == CorsSecret {
return c.Next()
}
}
c.Response().Header.Set("Access-Control-Allow-Origin", "")
return c.SendStatus(fiber.StatusNoContent)
})
이렇게 구현을 했을 때 제가 생각했던 대로 동작하는 것을 볼 수 있었습니다.
복잡한 개발 환경
문제는 다른 데서 발생했습니다. 심지어 하나도 아니고 여러 개...
- swagger docs 페이지를 못 들어가게 되었습니다. swagger docs 페이지는 API 호출이 아니라 브라우저에서 URL을 입력하여 접속해야 되는데, 이 때는 제가 정의한 토큰을 헤더에 추가하기가 어렵기 때문입니다.
- traefik 뒤로 API 서버가 들어가 있으므로 FE 개발 서버 주소와는 관계 없이 API 서버 입장에서는 traefik이 요청을 보낸 것처럼 보였습니다.
- 추가로 이 글 내용과는 거리가 멀지만 traefik에서 tls를 적용하여 사용 중이었는데, 이쪽 설정을 제가 약간 잘못 해둔 것 같았습니다. 브라우저 콘솔에서 self-signed certificate 관련 오류가 보였습니다.
MVP, 롤백
이번 프로젝트에서 traefik에 대해 잘 모름에도 불구하고 사용했던 이유는 이전에 개발 서버를 관리할 때 방화벽이나 포트 관리에 신경을 덜 썼다가 코인 채굴 스크립트를 몰래 실행하는 공격을 당한 적이 있었기 떄문입니다. 그래서 리버스 프록시를 사용하면 외부로 오픈하는 포트를 제한하고, 프록시 단에서 tls를 설정하여 보안을 향상할 뿐 아니라 프록시 뒷단의 서버끼리는 http로 통신하여 불필요한 암호화 연산을 줄여 성능상 이점이 있으므로 사용을 한 것이었습니다.
다만, 현재 과정에서 필요한가?
MVP(Minimum Viable Product)라는 개발 방법론이 있습니다. 이는 "고객이 느끼는 문제에 대한 해결책(가설)이 유효한지 검증할 수 있는 최소한의 기능만을 지원하는 제품"을 빠르게 만들고 배포하여 실제 고객이 이 제품을 필요로 하는지를 검증하는 방법입니다3. 문제 상황을 다루면서 고민을 하던 중에 이 방법론에 대한 생각이 들었습니다.
지금 하는 프로젝트는 제가 갖고 있던 아이디어를 실제로 구현해보고 사용해보는 것이 목표인 토이 프로젝트입니다. 물론 보안, 성능, 아키텍쳐가 완벽한 프로젝트를 만드는 것이 좋겠죠. 하지만 아이디어 검증을 위한 코어 로직도 아직 완성 안 된 상태에서 traefik의 configuration이나 동작 원리에 대해 공부하는 것은 우선 순위가 낮다고 판단되었습니다.
그래서 traefik을 들어내기로 결정했습니다. 제가 지금 우선해야 할 일은 CORS 문제를 해결해서 FE에서 API 호출 결과를 가져다 쓸 수 있도록 하고, 생각했던 기능을 실제로 실행해볼 수 있는 프로그램이지 보안적으로, 구조적으로 완벽한 제품이 아니라고 판단했기 때문입니다. 약간 아쉽긴 하지만, Git 커밋 기록이 있으니 나중에 다시 살릴 때는 조금 더 빠르게 할 수 있겠죠.
배운 점
이번 문제 해결 과정에서 토이 프로젝트는 완성도도 중요하지만 MVP를 구현하는 것이 우선이고 다듬는 것은 출시 후 피드백을 받아 점진적으로 개선해 나가는 것이 좋다는 것을 배웠습니다. 물론 이전에 공격을 겪어봐서 그런 문제를 다시 안 겪으려고 이것 저것 신경 쓴 과거의 제가 이해는 되지만, 이번에는 욕심이 조금 과했지 않나 싶습니다. 그래도 이러한 과정이 제가 더 나은 개발자로 성장하는 데 필요한 과정이라고 생각합니다.
그리고 조금 더 생각해봤는데 이번에 시도한 방법이 아주 의미없는 삽질은 아니었던 것 같습니다. 제가 임의로 적용한 토큰을 CORS를 해결하는 데 사용한 것은 조금 부적절한 것 같으나, 일반적으로 REST API 호출 가능한 유저를 판별하는 API_KEY를 검증하는 구조에 그대로 적용 가능하므로 아주 필요없는 경험은 아니었던 것 같습니다. 😂
Prerequisites: 이 글에서 언급되었으나 깊게 설명하지 않은 내용입니다.
- HTTP Req/Resp Header
- Middleware
- MVP (Minimum Viable Product)
- Reverse Proxy
- TLS
Footnotes
인파. "🌐 악명 높은 CORS 개념 & 해결법 - 정리 끝판왕 👏." Inpa Dev 👨💻💻. https://inpa.tistory.com/entry/WEB-📚-CORS-💯-정리-해결-방법-👏 (accessed Sep. 03, 2023). ↩
Fiber. "CORS." gofiber.io. https://docs.gofiber.io/api/middleware/cors/ (accessed Sep. 03, 2023). ↩
Create Owner. "기획자로서 알아야 하는 MVP 개발 방법론." 요즘IT. https://yozm.wishket.com/magazine/detail/1770/ (accessed Sep. 05, 2023). ↩