Go 웹 서버에서 커스텀 CORS 미들웨어 구축하기
Daniel Hayes
Full-Stack Engineer · Leapcell

소개
현대의 웹에서는 웹 애플리케이션이 서로 다른 도메인에 호스팅된 리소스와 상호 작용하는 것이 점점 더 보편화되고 있습니다. app.example.com에서 제공되는 프론트엔드 애플리케이션이 api.example.com의 백엔드 서비스에 API 호출을 하거나, 서드파티 서비스와 통합하는 것을 생각해 보세요. 이러한 일반적인 시나리오는 웹 브라우저가 적용하는 근본적인 보안 메커니즘인 동일 출처 정책(Same-Origin Policy)을 즉시 떠올리게 합니다. 이 정책은 보안에 중요하지만, 합법적인 교차 출처 상호 작용을 방해하여 좌절스러운 "CORS에 의해 차단됨" 오류를 유발할 수 있습니다. 이를 극복하기 위해 서버가 교차 출처 요청에 대한 권한을 명시적으로 부여할 수 있도록 하는 교차 출처 리소스 공유(CORS) 메커니즘이 도입되었습니다. 많은 Go 웹 프레임워크가 내장된 CORS 솔루션을 제공하지만, CORS 미들웨어를 수동으로 이해하고 구현하는 것은 작동 방식을 귀중하게 통찰하고, 더 나은 유연성을 제공하며, 애플리케이션의 요구 사항에 맞게 보안 정책을 정확히 조정하는 데 필수적입니다. 이 문서는 Go 웹 서버에서 CORS 미들웨어를 수동으로 구현하고 구성하는 과정을 안내합니다.
사용자 정의 CORS 미들웨어 이해 및 구현
코드에 들어가기 전에, 토론에 필수적인 CORS와 관련된 몇 가지 핵심 개념을 정의해 봅시다.
핵심 용어
- 동일 출처 정책 (SOP): 한 출처에서 로드된 문서나 스크립트가 다른 출처의 리소스와 상호 작용하는 방식을 제한하는 근본적인 보안 개념입니다. 출처는 URL의 프로토콜, 호스트, 포트로 정의됩니다.
- 교차 출처 요청: 요청하는 문서의 출처와 다른 출처의 리소스에 대해 이루어지는 모든 요청입니다.
- CORS (Cross-Origin Resource Sharing): 웹 브라우저와 서버가 웹 페이지가 교차 출처 요청을 허용할지 여부를 결정하는 데 사용하는 HTTP 헤더 집합입니다.
- 사전 요청 (Preflight Request): 특정 "복잡한" 요청(예:
GET,HEAD,POST외의 HTTP 메서드나 간단한 콘텐츠 유형, 또는 사용자 정의 헤더를 사용한 요청)의 경우, 브라우저는 실제 요청 전에 서버에 자동 "사전 요청"OPTIONS요청을 보냅니다. 이 사전 요청은 후속 실제 요청에 대해 서버가 허용하는 CORS 정책을 확인합니다. - CORS 헤더:
Access-Control-Allow-Origin: 리소스에 액세스할 수 있는 출처를 나타냅니다. 특정 출처 또는 모든 출처를 위한*가 될 수 있습니다.Access-Control-Allow-Methods: 교차 출처 요청에 허용되는 HTTP 메서드를 지정합니다 (예:GET,POST,PUT,DELETE).Access-Control-Allow-Headers: 실제 요청에서 사용할 수 있는 HTTP 헤더 목록입니다.Access-Control-Allow-Credentials: 브라우저가 교차 출처 요청과 함께 자격 증명(쿠키, HTTP 인증)을 보낼지 여부를 나타냅니다. 지원되는 경우true로 설정해야 합니다.Access-Control-Expose-Headers: 서버가 프론트엔드 JavaScript 코드에 노출할 수 있도록 브라우저가 허용하는 헤더를 화이트리스트에 포함할 수 있도록 합니다.Access-Control-Max-Age: 사전 요청 결과의 캐시 지속 시간을 나타냅니다.
CORS 미들웨어의 원칙
CORS 미들웨어는 개념적으로 들어오는 HTTP 요청과 애플리케이션의 핸들러 사이에 위치합니다. 주요 책임은 요청, 특히 Origin 헤더를 검사하고, 미리 정의된 규칙에 따라 응답에 적절한 CORS 헤더를 추가하는 것입니다. 또한 OPTIONS 사전 요청을 명시적으로 처리해야 합니다.
사용자 정의 CORS 미들웨어 구현
유연한 CORS 미들웨어를 Go로 만들어 봅시다. 허용된 출처, 메서드 및 헤더를 관리하기 위한 구성 구조체를 정의합니다.
package main import ( "log" "net/http" "strings" "time" ) // CORSConfig는 CORS 미들웨어의 구성을 보유합니다. type CORSConfig struct { AllowedOrigins []string AllowedMethods []string AllowedHeaders []string ExposedHeaders []string AllowCredentials bool MaxAge time.Duration // Access-Control-Max-Age 헤더 지속 시간 } // NewCORSConfig는 기본 CORS 구성을 생성합니다. func NewCORSConfig() *CORSConfig { return &CORSConfig{ AllowedOrigins: []string{"*"}, // 기본적으로 모든 출처 허용 (운영 환경에서는 주의) AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization"}, ExposedHeaders: []string{}, AllowCredentials: false, MaxAge: 10 * time.Minute, } } // CORSMiddleware는 CORS 기능을 제공하기 위해 다른 http.Handler를 래핑하는 http.Handler입니다. func CORSMiddleware(config *CORSConfig, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { or := r.Header.Get("Origin") if origin == "" { // CORS 요청이 아닙니다. 통과시킵니다. next.ServeHTTP(w, r) return } // 출처가 허용되는지 확인합니다. isOriginAllowed := false if len(config.AllowedOrigins) == 1 && config.AllowedOrigins[0] == "*" { isOriginAllowed = true // 모든 출처 허용 } else { for _, allowed := range config.AllowedOrigins { if allowed == origin { isOriginAllowed = true break } } } if !isOriginAllowed { // 출처가 허용되지 않으면 CORS 헤더를 추가하지 않고 요청을 거부합니다. log.Printf("CORS: Origin '%s' not allowed.", origin) w.WriteHeader(http.StatusForbidden) return } // Access-Control-Allow-Origin 헤더를 추가합니다. if len(config.AllowedOrigins) == 1 && config.AllowedOrigins[0] == "*" { w.Header().Set("Access-Control-Allow-Origin", "*") } else { w.Header().Set("Access-Control-Allow-Origin", origin) } // 자격 증명이 허용되는 경우 헤더를 설정합니다. if config.AllowCredentials { w.Header().Set("Access-Control-Allow-Credentials", "true") } // 사전 요청 OPTIONS 요청을 처리합니다. if r.Method == http.MethodOptions { // Access-Control-Allow-Methods 헤더를 추가합니다. w.Header().Set("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", ")) // 요청된 헤더를 기반으로 Access-Control-Allow-Headers 헤더를 추가합니다. requestedHeaders := r.Header.Get("Access-Control-Request-Headers") if requestedHeaders != "" { allowedRequestHeaders := make([]string, 0) for _, reqHeader := range strings.Split(requestedHeaders, ",") { reqHeader = strings.TrimSpace(reqHeader) for _, allowedConfigHeader := range config.AllowedHeaders { if strings.EqualFold(reqHeader, allowedConfigHeader) { allowedRequestHeaders = append(allowedRequestHeaders, reqHeader) break } } } if len(allowedRequestHeaders) > 0 { w.Header().Set("Access-Control-Allow-Headers", strings.Join(allowedRequestHeaders, ", ")) } else { // 요청된 헤더가 명시적으로 허용되지 않으면 헤더 없이 응답 (브라우저가 거부) log.Printf("CORS: No requested headers allowed for origin '%s'. Requested: %s", origin, requestedHeaders) w.WriteHeader(http.StatusForbidden) return } } else { // 특정 헤더가 요청되지 않은 경우, 구성된 허용 헤더만 보냅니다. w.Header().Set("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", ")) } // Access-Control-Max-Age 설정 if config.MaxAge > 0 { w.Header().Set("Access-Control-Max-Age", string(config.MaxAge/time.Second)) } // 사전 요청에 204 No Content로 응답합니다. w.WriteHeader(http.StatusNoContent) return } // 실제 요청의 경우, 구성된 경우 노출 헤더를 설정합니다. if len(config.ExposedHeaders) > 0 { w.Header().Set("Access-Control-Expose-Headers", strings.Join(config.ExposedHeaders, ", ")) } // 요청을 다음 핸들러로 전달합니다. next.ServeHTTP(w, r) }) }
CORS 미들웨어 로직 설명:
- 요청 출처 확인: 먼저
Origin헤더를 확인합니다. 누락된 경우 교차 출처 요청이 아니므로 CORS 헤더 없이 다음 핸들러로 처리가 계속됩니다. - 허용 출처 검증:
Origin헤더를config.AllowedOrigins와 비교합니다.*가 지정된 경우 모든 출처가 허용됩니다. 그렇지 않으면 특정 출처가 허용 목록에 있는지 확인합니다. 허용되지 않으면http.StatusForbidden으로 요청이 거부됩니다. Access-Control-Allow-Origin: 이 헤더는 구성에 따라 요청에서 가져온 특정 허용Origin또는*로 설정됩니다.Access-Control-Allow-Credentials:config.AllowCredentials가true인 경우 이 헤더가 추가됩니다.Access-Control-Allow-Origin: *는Access-Control-Allow-Credentials: true와 함께 사용할 수 없습니다. 현재Access-Control-Allow-Origin로직은 자격 증명이 허용되는 경우 특정origin을 설정하여 이를 처리합니다.- 사전 요청
OPTIONS요청: 요청 메서드가OPTIONS인 경우:Access-Control-Allow-Methods는config.AllowedMethods를 기반으로 설정됩니다.- 클라이언트에서
Access-Control-Request-Headers를 읽습니다. 그런 다음 요청된 각 헤더가config.AllowedHeaders에 있는지 확인하고Access-Control-Allow-Headers응답 헤더를 허용된 요청 헤더만 포함하여 구성합니다. 사용자 정의 헤더가 요청되었지만 허용되지 않으면 사전 요청이 실패합니다. Access-Control-Max-Age는config.MaxAge를 기반으로 설정됩니다.- 미들웨어는
http.StatusNoContent(204)로 응답하고 사전 요청은 본문을 기대하지 않으므로 요청을 종료합니다.
- 실제 요청: 다른 HTTP 메서드(실제 요청)의 경우, 구성된 경우
Access-Control-Expose-Headers가 설정되어 클라이언트 측 JavaScript가 특정 응답 헤더에 액세스할 수 있습니다. 그런 다음 실제 비즈니스 로직을 위해next핸들러로 요청을 전달합니다.
Go 서버에 미들웨어 통합
실제 API 핸들러와 함께 이 CORSMiddleware를 사용하는 예는 다음과 같습니다.
package main import ( "encoding/json" "fmt" "log" "net/http" "time" ) // MyHandler는 시연을 위한 간단한 핸들러입니다. func MyHandler(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"message": "Hello from Go API!"}) return } if r.Method == http.MethodPost { var data map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&data); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } log.Printf("Received POST data: %+v", data) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "received", "data": fmt.Sprintf("%+v", data)}) return } http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } func main() { // CORS 구성 corsConfig := NewCORSConfig() corsConfig.AllowedOrigins = []string{ "http://localhost:3000", // 예제 프론트엔드 출처 "http://127.0.0.1:3000", } corsConfig.AllowedMethods = []string{"GET", "POST", "PUT", "DELETE"} corsConfig.AllowedHeaders = []string{"Content-Type", "Authorization"} // 사용자 정의 Authorization 헤더 허용 corsConfig.AllowCredentials = true // 쿠키/인증 헤더 허용 corsConfig.MaxAge = 1 * time.Hour // 사전 요청을 1시간 동안 캐시 // 새 ServeMux 생성 mux := http.NewServeMux() // 핸들러에 CORS 미들웨어 적용 // 순서가 중요합니다: CORS 미들웨어는 실제 핸들러를 래핑해야 합니다. corsHandler := CORSMiddleware(corsConfig, http.HandlerFunc(MyHandler)) mux.Handle("/api/data", corsHandler) // 데모 목적으로 선택적 적용을 위한 다른 핸들러 (CORS 미들웨어 없음) mux.HandleFunc("/public", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "This is a public endpoint, no CORS applied here.") }) log.Println("Server starting on port 8080...") if err := http.ListenAndServe(":8080", mux); err != nil { log.Fatalf("Server failed: %v", err) } }
main 함수에서:
CORSConfig를 초기화하고 사용자 정의합니다. 운영 환경에서는AllowedOrigins가*가 아닌 특정 프론트엔드 도메인이어야 합니다. 프론트엔드가 교차 출처 요청에서 쿠키 또는 HTTP 인증 헤더를 보내야 하는 경우AllowCredentials를true로 설정해야 합니다.mux(라우터)를 생성합니다.- 사용자 정의 구성을 사용하여
CORSMiddleware로MyHandler를 래핑합니다. corsHandler를/api/data에 대한 요청을 처리하도록 등록합니다. 이는/api/data에 대한 모든 요청이 먼저 CORS 로직을 통과함을 의미합니다.
애플리케이션 시나리오
- 단일 페이지 애플리케이션 (SPA): 프론트엔드(예: React, Angular, Vue)가 한 도메인에서 실행되고 별도의 백엔드 API에서 데이터를 가져오는 일반적인 시나리오입니다.
- 마이크로 서비스: 서비스가 더 큰 분산 시스템 내에서 다른 도메인 또는 포트와 통신할 때.
- 서드파티 API 통합: 프론트엔드가 다른 도메인의 API를 호출하는 경우 해당 API의 서버는 CORS를 구현해야 합니다. 반대로 API가 서드파티에서 사용되는 경우 이 기능이 필요합니다.
- 개발 환경: 종종 개발 서버가 개발 백엔드와 다른 포트에서 실행되어 CORS가 필요합니다.
결론
Go 웹 서버에서 CORS 미들웨어를 수동으로 구현하고 구성하면 웹 보안 및 기능의 중요한 측면인 교차 출처 리소스 공유에 대한 세밀한 제어를 제공합니다. 사전 요청 및 특정 HTTP 헤더와 같은 CORS의 핵심 개념을 이해하고 미들웨어를 신중하게 제작함으로써 개발자는 애플리케이션이 서로 다른 출처와 안전하고 효과적으로 통신하도록 보장할 수 있습니다. 이 접근 방식은 유연성을 제공하고 이해를 심화시켜 애플리케이션의 고유한 요구 사항에 맞게 조정된 복잡한 교차 출처 문제를 해결할 수 있도록 하여 견고하고 안전한 웹 상호 작용을 보장합니다.

