Tuesday, December 15, 2020

Flutter 웹 배포 후 만나는 404 에러, Go 서버로 깔끔하게 해결하는 실전 가이드

Flutter를 사용해 아름답고 기능적인 웹 애플리케이션을 완성하고, 설레는 마음으로 서버에 배포했습니다. 메인 페이지는 잘 열립니다. 그런데 특정 상세 페이지의 URL(https://my-awesome-app.com/details/123)을 복사해서 친구에게 공유하거나, 브라우저 주소창에 직접 입력하고 엔터를 치는 순간, 차가운 '404 Not Found' 페이지가 우리를 맞이합니다. 로컬 개발 환경에서는 아무 문제 없이 잘 동작하던 라우팅이 왜 배포만 하면 말썽을 부리는 걸까요? 이 문제는 거의 모든 Flutter 웹 개발자가 한 번쯤 마주치는 통과의례와도 같습니다.

이 글에서는 바로 이 지긋지긋한 404 에러의 근본적인 원인을 싱글 페이지 애플리케이션(Single Page Application, SPA)의 동작 원리부터 차근차근 파헤쳐 봅니다. 그리고 단순히 문제를 해결하는 것을 넘어, 가볍고 강력하며 배포가 놀랍도록 간편한 Go 언어를 활용하여 우리만의 맞춤 웹 서버를 구축하는 방법을 A부터 Z까지, 실전 코드와 함께 상세히 안내합니다. 이 가이드를 끝까지 따라오시면, 더 이상 404 에러 앞에서 당황하지 않고 어떤 URL로든 당당하게 진입할 수 있는 견고한 Flutter 웹 서비스를 운영할 수 있게 될 것입니다.

1. 404 에러는 왜 우리를 괴롭히는가? SPA 라우팅의 비밀

문제의 원인을 정확히 알려면 먼저 현대적인 웹 애플리케이션의 주류인 SPA가 어떻게 화면을 그리고 페이지를 이동하는지, 즉 '라우팅' 방식을 이해해야 합니다. Flutter 웹 또한 SPA의 한 종류이며, 라우팅 방식은 크게 두 가지로 나뉩니다.

1.1. 해시(#) 라우팅: 구시대의 유물이지만 여전히 동작하는 방식

초기 SPA에서 널리 사용되던 방식입니다. URL이 다음과 같은 형태를 띱니다.

  • https://my-awesome-app.com/#/
  • https://my-awesome-app.com/#/profile
  • https://my-awesome-app.com/#/settings

이 방식의 핵심은 URL에 포함된 해시(#) 기호입니다. 웹 브라우저는 서버에 리소스를 요청할 때 URL의 해시(#)와 그 뒤에 오는 모든 문자열을 제외하고 보냅니다. 즉, 위 세 가지 URL 모두 브라우저는 서버에 오직 https://my-awesome-app.com/ 이라는 주소 하나만을 요청하게 됩니다.

서버는 이 요청을 받고 항상 최상위 파일인 index.html을 응답으로 보내줍니다. 브라우저가 이 index.html을 받아서 열면, 그 안에 포함된 Flutter 앱(자바스크립트 코드)이 실행됩니다. 이때 Flutter의 라우터가 비로소 URL의 해시 뒷부분(/profile, /settings 등)을 인식하고, 그에 맞는 위젯을 화면에 그려주는 것입니다. 모든 페이지 이동이 서버와의 통신 없이 클라이언트(브라우저) 측에서 일어나기 때문에 '싱글 페이지' 애플리케이션이라고 부릅니다.

이 방식은 서버 설정이 매우 간단하다는 장점이 있지만, URL이 깔끔하지 않고 검색 엔진 최적화(SEO)에 불리하다는 명백한 단점이 있습니다.

1.2. Path 라우팅 (Clean URL): 현대적인 접근과 새로운 골칫거리

이러한 단점을 극복하기 위해 등장한 것이 Path 라우팅 방식입니다. Flutter에서는 url_strategy 패키지를 사용하여 매우 간단하게 적용할 수 있습니다. Path 라우팅을 적용하면 URL에서 해시(#)가 사라지고 우리가 흔히 보는 깔끔한 주소가 됩니다.

  • https://my-awesome-app.com/
  • https://my-awesome-app.com/profile
  • https://my-awesome-app.com/settings

훨씬 보기 좋고, 사용자 친화적이며, SEO에도 유리합니다. 하지만 이 방식이 바로 404 에러의 주범입니다.

상황을 시뮬레이션해 봅시다.

  1. 사용자가 주소창에 https://my-awesome-app.com/profile을 입력하고 엔터를 누릅니다.
  2. 브라우저는 이 주소를 보고 'my-awesome-app.com' 서버에 있는 /profile이라는 파일 혹은 디렉터리를 요청합니다.
  3. 서버는 자신의 파일 시스템을 탐색합니다. 하지만 우리가 flutter build web으로 생성한 빌드 폴더 안에는 index.html, main.dart.js, 각종 에셋 파일 등은 있지만, profile이라는 이름의 파일이나 폴더는 존재하지 않습니다.
  4. 요청한 리소스를 찾지 못한 서버는 "그런 파일은 존재하지 않습니다"라는 의미로 404 Not Found 에러를 브라우저에 반환합니다.

이것이 바로 문제의 핵심입니다. 서버는 /profile이 Flutter 앱 내부에서 처리해야 할 '가상의 경로'라는 사실을 알지 못하고, 그저 '존재하지 않는 파일'로 취급해버리는 것입니다. 로컬 개발 서버(flutter run -d chrome)는 이러한 SPA의 특성을 이해하고 모든 경로 요청을 앱으로 전달해주기 때문에 문제가 발생하지 않았던 것입니다.

2. 해답은 서버에 있다: 모든 길은 `index.html`로 통하게 하라

원인을 알았으니 해결책은 명확해집니다. 우리는 웹 서버를 "똑똑하게" 만들어서, 특정 파일(main.dart.js, my-image.png 등)을 직접 요청하는 것이 아니라면, 어떤 경로(/profile, /settings, /a/b/c 등)로 요청이 들어오든 무조건 index.html 파일을 반환하도록 설정해야 합니다.

이렇게 하면 브라우저는 어떤 URL로 접속하든 일단 index.html을 받게 되고, 그 안에 담긴 Flutter 앱이 실행됩니다. 실행된 앱은 전체 URL을 보고 자신이 담당해야 할 경로를 파악하여 올바른 페이지를 렌더링하게 됩니다. 서버는 길을 잃은 모든 요청을 Flutter 앱의 정문인 index.html로 안내하는 '안내원' 역할을 하게 되는 것입니다. 이 기술을 일반적으로 'URL 리라이팅(Rewriting)' 또는 'fallback 설정'이라고 부릅니다.

3. 왜 하필 Go 언어일까?

이러한 URL 리라이팅 기능은 대부분의 웹 서버가 지원합니다. 웹 서버의 대명사인 Nginx나 Apache는 물론, Node.js 기반의 Express, Java의 Spring Boot, Python의 Django/Flask 등 어떤 기술 스택을 사용하더라도 관련 설정을 통해 문제를 해결할 수 있습니다.

그렇다면 왜 이 글에서는 Go 언어를 선택했을까요? 여러 가지 매력적인 이유가 있습니다.

  • 가벼움과 고성능: Go는 컴파일 언어로서 매우 빠른 실행 속도를 자랑하며, 메모리 사용량이 적어 저사양 서버에서도 부담 없이 운영할 수 있습니다. 간단한 정적 파일 서빙 및 리다이렉션 기능에는 차고 넘치는 성능입니다.
  • 단일 바이너리(Single Binary) 배포: Go의 가장 큰 매력 중 하나입니다. go build 명령어를 실행하면 운영체제에 맞는 단 하나의 실행 파일이 생성됩니다. 복잡한 의존성 라이브러리나 런타임을 별도로 설치할 필요 없이, 이 파일 하나만 서버에 복사해서 실행하면 끝입니다. Docker 이미지를 만들 때도 소스코드 없이 이 바이너리 파일 하나만 복사하면 되므로 매우 가볍고 효율적인 배포가 가능합니다.
  • 단순하고 강력한 표준 라이브러리: 별도의 웹 프레임워크를 설치하지 않고도 Go의 내장 표준 라이브러리(net/http)만으로도 충분히 강력하고 안정적인 웹 서버를 구축할 수 있습니다. 코드가 간결하고 이해하기 쉽습니다.
  • 새로운 기술 학습의 즐거움: 기존에 익숙한 기술 스택을 사용하는 것도 좋지만, 이번 기회에 Go 언어처럼 새롭고 유망한 기술을 학습하고 프로젝트에 적용해보는 것은 개발자로서 큰 성장 동력이 될 수 있습니다.

이러한 장점들 덕분에 Go는 Flutter 웹 앱을 위한 경량 프록시 서버나 정적 파일 서버를 구축하는 데 아주 훌륭한 선택지가 됩니다.

4. 실전! Go 언어로 Flutter 웹 서버 구축하기

이제 이론은 충분히 다뤘으니, 직접 코드를 작성하며 우리만의 웹 서버를 만들어 보겠습니다. 최종 목표는 다음과 같은 기능을 가진 서버를 만드는 것입니다.

  1. Flutter 웹 빌드 결과물이 담긴 폴더(예: build/web)를 지정한다.
  2. 들어온 요청 URL에 해당하는 실제 파일(main.dart.js, assets/icon.png 등)이 존재하면 해당 파일을 서빙한다.
  3. 만약 해당하는 파일이 없다면(/profile, /settings/etc 등), index.html 파일을 대신 서빙한다.

4.1. 준비물

  • Go 언어 설치: Go 공식 홈페이지(https://go.dev/doc/install)를 참고하여 자신의 운영체제에 맞게 설치합니다.
  • Flutter 웹 빌드 파일: 프로젝트 루트에서 다음 명령어를 실행하여 웹용 빌드 파일을 생성합니다.
    flutter build web --web-renderer canvaskit
    (--web-renderer 옵션은 필요에 따라 html 또는 skwasm 등으로 변경할 수 있습니다.)

4.2. 프로젝트 구조

작업을 위해 간단한 폴더 구조를 만듭니다. Flutter 프로젝트 루트에 `server`와 같은 폴더를 만들고 그 안에 Go 코드를 작성하거나, 완전히 별개의 폴더에서 작업해도 무방합니다. 중요한 것은 Go 서버가 Flutter 빌드 결과물 폴더의 위치를 알 수 있어야 한다는 점입니다.


my_awesome_project/
├── build/
│   └── web/          <-- Flutter 웹 빌드 결과물
│       ├── assets/
│       ├── index.html
│       └── main.dart.js
│       └── ...
├── lib/
├── ... (플러터 프로젝트 파일들)
└── server/
    └── main.go       <-- 우리가 작성할 Go 웹 서버 코드

4.3. 핵심 코드 작성 및 분석

이제 `server/main.go` 파일을 생성하고 다음 코드를 작성합니다. 이 코드는 앞서 설명한 SPA 서버의 핵심 로직을 담고 있습니다.

Go 언어로 작성된 SPA 웹 서버 코드 예시

Go 언어로 구현한 SPA Fallback 로직이 포함된 웹 서버 코드

위 이미지의 코드를 텍스트로 옮기고, 더 명확하고 실용적으로 다듬으면 다음과 같습니다. 프로젝트 구조에 맞게 `staticPath`를 상대 경로로 수정했습니다.


package main

import (
	"log"
	"net/http"
	"os"
	"path/filepath"
)

// spaHandler는 SPA(Single Page Application)를 위한 커스텀 HTTP 핸들러입니다.
// 요청된 경로에 파일이 없으면 지정된 indexPath(예: index.html)를 대신 서빙합니다.
type spaHandler struct {
	staticPath string // 정적 파일(Flutter 빌드 결과물)이 있는 디렉터리 경로
	indexPath  string // SPA의 진입점 파일 이름 (보통 "index.html")
}

// ServeHTTP는 http.Handler 인터페이스를 구현하는 메소드입니다.
// 모든 HTTP 요청에 대해 이 메소드가 호출됩니다.
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// 요청된 URL 경로를 가져옵니다. 예: "/", "/profile", "/assets/logo.png"
	path := r.URL.Path

	// 요청 경로에 해당하는 파일 시스템 상의 전체 경로를 만듭니다.
	// 예를 들어 요청이 "/profile"이면, 파일 경로는 "build/web/profile"이 됩니다.
	filePath := filepath.Join(h.staticPath, path)

	// 해당 경로의 파일 정보를 가져옵니다.
	// os.Stat는 파일이나 디렉터리가 존재하지 않으면 에러를 반환합니다.
	info, err := os.Stat(filePath)

	// 1. 파일이 존재하지 않거나 (IsNotExist 에러)
	// 2. 경로는 존재하지만 파일이 아닌 디렉터리인 경우
	// 이 두 가지 경우는 Flutter 라우터가 처리해야 할 가상 경로로 간주합니다.
	if os.IsNotExist(err) || info.IsDir() {
		// fallback으로 지정된 index.html 파일을 클라이언트에게 전송합니다.
		http.ServeFile(w, r, filepath.Join(h.staticPath, h.indexPath))
		return // 처리가 끝났으므로 함수를 종료합니다.
	}

	// 위 조건에 해당하지 않는 경우, 즉 요청한 경로에 실제 파일이 존재하는 경우입니다.
	// (예: /main.dart.js, /assets/my-image.png 등)
	// 이 경우에는 내장 FileServer를 사용해 해당 파일을 직접 서빙합니다.
	http.FileServer(http.Dir(h.staticPath)).ServeHTTP(w, r)
}

func main() {
	// Flutter 웹 빌드 결과물이 있는 상대 경로를 지정합니다.
	// 이 Go 프로그램을 실행하는 위치를 기준으로 경로를 설정해야 합니다.
	// 위 프로젝트 구조 예시에서는 Go 파일이 server/에 있으므로 ../build/web 입니다.
	const staticPath = "../build/web"

	// SPA 핸들러 인스턴스를 생성합니다.
	// 정적 파일 경로와 진입점 파일(index.html)을 알려줍니다.
	spa := spaHandler{staticPath: staticPath, indexPath: "index.html"}

	// 새로운 HTTP 요청 라우터(Mux)를 생성합니다.
	router := http.NewServeMux()
	
	// 모든 경로("/")에 대한 요청을 우리가 만든 spa 핸들러가 처리하도록 등록합니다.
	router.Handle("/", spa)

	// 서버가 사용할 포트를 지정합니다.
	port := "8080"
	log.Printf("서버가 시작되었습니다. http://localhost:%s 에서 확인하세요.", port)

	// 지정된 포트에서 웹 서버를 시작합니다.
	// 에러가 발생하면 로그를 출력하고 프로그램을 종료합니다.
	if err := http.ListenAndServe(":"+port, router); err != nil {
		log.Fatal("서버 시작 실패: ", err)
	}
}

코드 상세 분석

  • spaHandler 구조체: 서버의 동작에 필요한 정보(정적 파일 경로, 인덱스 파일 이름)를 담는 그릇입니다. 이렇게 구조체로 만들면 코드가 더 체계적이고 재사용하기 좋아집니다.
  • ServeHTTP 메소드: 이 메소드 덕분에 spaHandlerhttp.Handler 인터페이스를 만족하게 되어, Go의 HTTP 서버가 요청을 처리할 객체로 사용할 수 있습니다. 모든 로직의 심장부입니다.
  • os.Stat(filePath): 이 함수가 바로 이 서버의 핵심입니다. 요청받은 경로에 실제로 파일이나 폴더가 디스크에 존재하는지 확인합니다.
  • if os.IsNotExist(err) || info.IsDir(): 마법이 일어나는 부분입니다. os.Stat 결과 파일이 존재하지 않거나(IsNotExist), 경로가 존재하긴 하지만 그게 파일이 아닌 폴더인 경우, "아, 이건 Flutter 앱이 처리해야 할 가상 주소구나!"라고 판단합니다.
  • http.ServeFile(...): 위 조건이 참일 때, 즉 가상 주소일 때, 모든 것을 무시하고 index.html 파일을 서빙합니다.
  • http.FileServer(...): 위 조건이 거짓일 때, 즉 /main.dart.js처럼 실제로 존재하는 파일을 요청했을 때, Go의 내장 파일 서버가 해당 파일을 정확히 찾아 서빙합니다.
  • main() 함수: 서버의 설정을 초기화하고 실행하는 진입점입니다. http.ListenAndServe 함수가 실제로 웹 서버를 구동시킵니다.

4.4. 플러터 앱과 Go 서버 연동하기

이제 모든 준비가 끝났습니다. 서버를 실행하고 결과를 확인해 봅시다.

  1. Flutter 앱 빌드: 아직 빌드하지 않았다면, Flutter 프로젝트 루트에서 flutter build web 명령을 실행하여 build/web 디렉터리를 생성하거나 업데이트합니다.
  2. url_strategy 설정 확인: Flutter 앱의 main() 함수에 setUrlStrategy(PathUrlStrategy())가 적용되어 있는지 다시 한번 확인합니다.
    
    import 'package:flutter_web_plugins/url_strategy.dart';
    
    void main() {
      // 웹 URL에서 #을 제거해줍니다.
      setUrlStrategy(PathUrlStrategy());
      runApp(const MyApp());
    }
            
  3. Go 서버 실행: 터미널에서 server 디렉터리로 이동한 후, 다음 명령어를 실행하여 Go 서버를 시작합니다.
    
    cd server
    go run main.go
            
    터미널에 "서버가 시작되었습니다. http://localhost:8080 에서 확인하세요." 라는 메시지가 나타나면 성공입니다.
  4. 결과 확인: 웹 브라우저를 열고 다음 주소들을 차례로 접속해 보세요.
    • http://localhost:8080 : 메인 페이지가 정상적으로 표시되어야 합니다.
    • http://localhost:8080/profile : (앱에 /profile 라우트가 있다면) 404 에러 없이 프로필 페이지가 바로 표시되어야 합니다.
    • http://localhost:8080/some/deep/non-existent/path : 404 에러 대신 앱의 기본 페이지 또는 Not Found 처리 위젯이 표시되어야 합니다.

이제 어떤 경로로 접속하더라도 우리를 괴롭히던 서버의 404 페이지 대신, 우리의 Flutter 앱이 모든 요청을 우아하게 처리하는 것을 확인할 수 있습니다.

5. 다른 대안은 없나요? - Nginx, Firebase Hosting 설정 예시

Go로 직접 서버를 만드는 것은 매우 강력하고 유연한 방법이지만, 상황에 따라 이미 사용 중인 인프라를 활용하는 것이 더 효율적일 수 있습니다. 대표적인 두 가지 대안의 설정법을 간단히 소개합니다.

5.1. 웹 서버의 표준, Nginx 설정

Nginx를 웹 서버로 사용하고 있다면, 서버 설정 파일(보통 /etc/nginx/sites-available/default)에 다음과 같은 location 블록을 추가하면 됩니다.


server {
    listen 80;
    server_name my-awesome-app.com;

    # Flutter 빌드 결과물의 경로
    root /var/www/my_awesome_project/build/web;
    index index.html;

    location / {
        # 1. 요청된 URI($uri)에 해당하는 파일이 있는지 확인
        # 2. 파일이 없으면 디렉터리($uri/)가 있는지 확인
        # 3. 둘 다 없으면 /index.html을 내부적으로 반환
        try_files $uri $uri/ /index.html;
    }
}

핵심은 try_files $uri $uri/ /index.html; 지시어입니다. 이 한 줄이 우리가 Go 코드로 구현했던 로직과 정확히 동일한 역할을 수행합니다.

5.2. 가장 쉬운 방법, Firebase Hosting

Firebase Hosting은 SPA 배포를 매우 간단하게 만들어주는 훌륭한 서비스입니다. 프로젝트 루트에 있는 firebase.json 설정 파일에 다음과 같이 `rewrites` 규칙을 추가하기만 하면 됩니다.


{
  "hosting": {
    "public": "build/web",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
}

"source": "**"는 모든 경로의 요청을 의미하고, "destination": "/index.html"는 해당 요청을 /index.html로 보내라는 의미입니다. Firebase가 알아서 실제 파일이 있는 경우는 해당 파일을, 없는 경우는 index.html을 서빙해줍니다.

이 외에도 Netlify, Vercel, AWS S3/CloudFront, Github Pages(약간의 트릭 필요) 등 대부분의 최신 호스팅 서비스 및 클라우드 플랫폼에서 유사한 URL 리라이팅 기능을 제공합니다.

마무리하며: 404를 넘어 완전한 웹 앱으로

Flutter 웹의 404 에러는 단순히 버그가 아니라, 클라이언트 측 라우팅을 사용하는 SPA와 전통적인 파일 기반의 서버가 만나는 지점에서 발생하는 자연스러운 현상입니다. 이 문제의 근본 원인을 이해하는 것은 견고한 웹 애플리케이션을 구축하는 데 매우 중요한 첫걸음입니다.

이 글에서 우리는 Go 언어를 사용하여 가볍고, 빠르며, 배포가 지극히 간편한 우리만의 맞춤 서버를 구축함으로써 이 문제를 해결했습니다. 단일 바이너리로 컴파일되는 Go 서버는 Docker 환경이나 미니멀한 서버 환경에서 특히 빛을 발하며, 여러분의 Flutter 웹 앱에 훌륭한 파트너가 되어줄 것입니다.

이제 여러분은 404의 공포에서 벗어났습니다. 여기서 멈추지 말고, 오늘 만든 Go 서버 코드에 로깅 기능을 추가하거나, Let's Encrypt를 이용해 HTTPS를 적용하는 등 자신만의 필요에 맞게 서버를 더욱 발전시켜 나가 보세요. 하나의 문제를 해결하는 과정에서 또 다른 새로운 기술을 배우고 성장하는 것, 그것이야말로 개발의 진정한 즐거움일 것입니다.


0 개의 댓글:

Post a Comment