Friday, August 10, 2018

Spring MVC와 FCM 연동, firebase-messaging-sw.js 404 에러의 근본 원인과 해결 전략

Spring MVC 프레임워크를 기반으로 구축된 웹 애플리케이션에 Firebase Cloud Messaging(FCM)을 통한 푸시 알림 기능을 도입하는 것은 사용자 참여를 유도하고 중요한 정보를 적시에 전달하는 강력한 방법입니다. 그러나 이 연동 과정에서 많은 개발자들이 'firebase-messaging-sw.js' 파일에 대한 404 Not Found 에러라는 예상치 못한 장벽에 부딪히게 됩니다. 이 에러는 단순한 파일 경로 문제처럼 보이지만, 사실은 Spring MVC의 핵심 동작 원리와 웹 표준인 서비스 워커(Service Worker)의 고유한 특성이 충돌하면서 발생하는 문제입니다. 이 문제를 해결하지 못하면 서비스 워커가 정상적으로 등록되지 않고, 결과적으로 FCM 토큰을 발급받을 수 없어 푸시 알림 기능 전체가 마비됩니다.

본문에서는 이 404 에러가 발생하는 근본적인 원인을 Spring의 DispatcherServlet과 서비스 워커의 스코프(Scope) 개념을 통해 심층적으로 분석하고, 이를 해결하기 위한 실용적이고 체계적인 방법을 제시합니다. 단순히 코드 한 줄을 복사-붙여넣기 하는 수준을 넘어, 왜 이러한 설정이 필요한지를 이해함으로써 향후 발생할 수 있는 유사한 정적 리소스 관련 문제에 대해서도 유연하게 대처할 수 있는 능력을 기르는 것을 목표로 합니다.

1. 문제의 발단: 서비스 워커와 FCM의 특별한 관계

404 에러를 이해하기 위해서는 먼저 FCM이 왜 `firebase-messaging-sw.js`라는 특정 파일을 요구하는지, 그리고 이 파일의 정체인 '서비스 워커'가 무엇인지 알아야 합니다.

1.1. 서비스 워커(Service Worker)란 무엇인가?

서비스 워커는 웹 브라우저가 웹 페이지와는 별개의 백그라운드 스레드에서 실행하는 자바스크립트 파일입니다. 웹 페이지나 사용자의 직접적인 상호작용 없이도 작동할 수 있다는 것이 가장 큰 특징이며, 이를 통해 다음과 같은 강력한 기능들을 구현할 수 있습니다.

  • 푸시 알림(Push Notifications): 서버로부터 메시지를 수신하여 웹 페이지가 닫혀 있거나 비활성 상태일 때도 사용자에게 알림을 표시할 수 있습니다. 이것이 바로 FCM의 핵심 기능입니다.
  • 백그라운드 동기화(Background Sync): 네트워크 연결이 불안정할 때 사용자의 요청을 큐에 저장해 두었다가, 연결이 복구되면 자동으로 서버에 전송합니다.
  • 오프라인 지원(Offline First): 네트워크 요청을 가로채고 캐시된 리소스를 대신 제공함으로써, 오프라인 상태에서도 애플리케이션의 일부 기능이 작동하도록 만들 수 있습니다.

FCM은 이 서비스 워커를 활용하여 브라우저가 서버로부터 푸시 메시지를 수신하고 처리하는 '창구' 역할을 하도록 만듭니다. `firebase-messaging-sw.js` 파일이 바로 그 창구의 역할을 하는 스크립트입니다.

1.2. 왜 `firebase-messaging-sw.js`는 반드시 최상위(Root) 경로에 있어야 할까?

Firebase 공식 문서와 대부분의 예제에서는 `firebase-messaging-sw.js` 파일을 프로젝트의 루트 디렉토리에 위치시키라고 강력하게 권고합니다. 이는 서비스 워커의 스코프(Scope)라는 매우 중요한 제약 조건 때문입니다.

서비스 워커의 스코프는 해당 워커가 제어할 수 있는 URL의 범위를 결정합니다. 이 스코프는 서비스 워커 파일이 위치한 경로를 기준으로 설정됩니다. 예를 들어,

  • 서비스 워커가 /firebase-messaging-sw.js에 위치하면, 스코프는 https://your-domain.com/가 되어 웹사이트의 모든 페이지(/, /mypage, /articles/123 등)를 제어할 수 있습니다. 따라서 어떤 페이지를 방문 중이든 푸시 알림을 받을 수 있습니다.
  • 만약 서비스 워커가 /js/firebase-messaging-sw.js에 위치한다면, 스코프는 기본적으로 https://your-domain.com/js/로 제한됩니다. 이 경우, 사용자가 /js/ 하위 경로가 아닌 //mypage 같은 다른 페이지에 있다면 서비스 워커는 해당 페이지를 제어할 수 없어 푸시 알림이 정상 동작하지 않습니다.

이러한 이유로 FCM은 애플리케이션 전체에 걸쳐 푸시 알림을 안정적으로 수신하기 위해 서비스 워커 파일을 반드시 최상위 경로에 등록하도록 요구하는 것입니다. 클라이언트 측 자바스크립트에서는 다음과 같이 서비스 워커를 등록하게 됩니다.


// 클라이언트 측 메인 JavaScript 파일 (e.g., app.js)

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/firebase-messaging-sw.js') // <-- 바로 이 경로!
    .then(function(registration) {
      console.log('Service Worker 등록 성공, 스코프: ', registration.scope);
      // 이 시점에서 FCM 토큰 요청 로직이 실행됩니다.
    }).catch(function(err) {
      console.log('Service Worker 등록 실패: ', err);
    });
}

문제는 바로 여기서 시작됩니다. 브라우저는 명시적으로 /firebase-messaging-sw.js라는 URL에 GET 요청을 보내지만, Spring MVC 애플리케이션은 이 요청을 어떻게 처리해야 할지 모릅니다.

2. Spring MVC의 구조적 한계: DispatcherServlet의 오해

이제 시선을 백엔드, 즉 Spring MVC 애플리케이션으로 돌려보겠습니다. 왜 Spring은 브라우저의 /firebase-messaging-sw.js 요청에 404로 응답하는 것일까요?

2.1. 모든 것을 가로채는 문지기, DispatcherServlet

Spring MVC의 심장에는 DispatcherServlet이라는 프론트 컨트롤러(Front Controller)가 있습니다. `web.xml` 또는 Java 기반 설정에서 이 서블릿은 보통 /라는 URL 패턴에 매핑됩니다. 이는 클라이언트로부터 들어오는 모든 요청(정적 리소스 요청 포함)이 일단 DispatcherServlet을 통과해야 함을 의미합니다.

요청을 받은 DispatcherServlet의 주된 임무는 다음과 같습니다.

  1. 요청 URL을 분석하여 이 요청을 처리할 적절한 핸들러(일반적으로 @Controller 어노테이션이 붙은 클래스의 @RequestMapping, @GetMapping 등)를 찾습니다.
  2. 적합한 핸들러를 찾으면, 해당 메소드를 실행합니다.
  3. 핸들러가 없으면, DispatcherServlet은 이 요청을 처리할 방법을 모르므로 "나에게는 이 URL을 처리할 컨트롤러가 등록되어 있지 않아"라는 의미로 404 Not Found 응답을 반환합니다.

이 메커니즘은 동적 요청(예: /users/list)을 처리하는 데는 매우 효율적이지만, 정적 리소스(.js, .css, .png 파일 등) 요청에는 문제를 일으킵니다. 브라우저가 `https://your-domain.com/firebase-messaging-sw.js`를 요청하면, 이 요청 역시 DispatcherServlet이 가로챕니다. 그리고 애플리케이션 내에 @GetMapping("/firebase-messaging-sw.js")와 같은 핸들러 메소드가 정의되어 있지 않으므로, 당연히 404 에러를 반환하는 것입니다. 웹 애플리케이션의 `webapp` 루트에 `firebase-messaging-sw.js` 파일을 물리적으로 위치시켜도 DispatcherServlet의 처리 우선순위에 밀려 파일이 직접 서빙되지 못합니다.

3. 해결의 실마리: Spring MVC에게 리소스 경로 알려주기

이 문제를 해결하기 위한 핵심 아이디어는 DispatcherServlet에게 "이 특정 URL 패턴으로 들어오는 요청은 컨트롤러를 찾지 말고, 내가 지정해주는 실제 파일 경로에서 리소스를 찾아 직접 전달해줘"라고 알려주는 것입니다. Spring MVC는 이를 위한 우아한 방법을 제공하며, XML 기반 설정과 Java 기반 설정 두 가지 모두 가능합니다.

3.1. 파일 준비 및 배치

솔루션을 적용하기 전에 먼저, 우리의 `firebase-messaging-sw.js` 파일을 프로젝트의 정적 리소스 폴더에 배치해야 합니다. 일반적으로 Maven/Gradle 표준 디렉토리 구조에서는 src/main/webapp/resources/와 같은 경로를 사용합니다. 유지보수의 편의성을 위해 하위 디렉토리를 만들어도 좋습니다. 예를 들어, src/main/webapp/resources/js/firebase-messaging-sw.js 와 같이 배치하겠습니다.

`firebase-messaging-sw.js`의 최소 내용:


// FCM SDK 스크립트 임포트
importScripts('https://www.gstatic.com/firebasejs/9.6.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/9.6.1/firebase-messaging-compat.js');

// Firebase 앱 초기화. 클라이언트 측에서 사용한 설정과 동일해야 합니다.
const firebaseConfig = {
  apiKey: "YOUR_API_KEY",
  authDomain: "YOUR_AUTH_DOMAIN",
  projectId: "YOUR_PROJECT_ID",
  storageBucket: "YOUR_STORAGE_BUCKET",
  messagingSenderId: "YOUR_MESSAGING_SENDER_ID",
  appId: "YOUR_APP_ID"
};

firebase.initializeApp(firebaseConfig);

// Messaging 인스턴스 가져오기
const messaging = firebase.messaging();

// (선택사항) 백그라운드에서 메시지를 처리하는 핸들러
messaging.onBackgroundMessage(function(payload) {
  console.log('[firebase-messaging-sw.js] Received background message ', payload);

  const notificationTitle = payload.notification.title;
  const notificationOptions = {
    body: payload.notification.body,
    icon: '/resources/images/fcm-icon.png' // 알림에 표시될 아이콘 경로
  };

  self.registration.showNotification(notificationTitle, notificationOptions);
});

이제 이 파일을 웹 브라우저가 /firebase-messaging-sw.js 라는 URL로 요청했을 때 찾을 수 있도록 Spring 설정을 변경해 보겠습니다.

3.2. 방법 1: XML 기반 설정 (`servlet-context.xml`)

전통적인 XML 기반의 Spring MVC 프로젝트에서는 서블릿 컨텍스트 설정 파일(보통 `servlet-context.xml` 또는 `dispatcher-servlet.xml`)을 수정하여 리소스 매핑을 추가합니다.

이 파일에서 <mvc:resources> 태그를 사용합니다. 이 태그는 특정 URL 패턴(mapping 속성)을 특정 물리적 위치(location 속성)에 매핑하는 역할을 합니다.


<?xml version="1.0" encoding="UTF-8"?>
<beans:beans xmlns="http://www.springframework.org/schema/mvc"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:beans="http://www.springframework.org/schema/beans"
	xmlns:context="http://www.springframework.org/schema/context"
	xsi:schemaLocation="http://www.springframework.org/schema/mvc https://www.springframework.org/schema/mvc/spring-mvc.xsd
		http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd
		http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">

	
	
	
	<annotation-driven />

	
	<resources mapping="/resources/**" location="/resources/" />

	
	<resources mapping="/firebase-messaging-sw.js" location="/resources/js/" />
    
	

</beans:beans>

여기서 중요한 점은 `mapping`과 `location`의 의미를 정확히 이해하는 것입니다.

  • mapping="/firebase-messaging-sw.js": 이는 브라우저가 요청하는 URL 경로입니다. 클라이언트의 navigator.serviceWorker.register('/firebase-messaging-sw.js') 코드와 정확히 일치해야 합니다.
  • location="/resources/js/": 이는 실제 파일이 위치한 src/main/webapp 내부의 경로입니다. Spring은 이 경로에서 `firebase-messaging-sw.js` 파일을 찾아 요청에 대한 응답으로 제공합니다.

이 설정을 추가하면, `/firebase-messaging-sw.js`에 대한 GET 요청이 들어왔을 때 DispatcherServlet은 컨트롤러를 찾는 대신, 이 매핑 규칙을 발견하고 `webapp/resources/js/` 폴더에서 파일을 찾아 서비스하게 됩니다. 결과적으로 404 에러는 사라지고 200 OK 응답과 함께 파일의 내용이 전달됩니다.

3.3. 방법 2: Java 기반 설정 (`WebMvcConfigurer`)

최근의 Spring Boot 또는 XML-less Spring MVC 프로젝트에서는 Java 클래스를 통해 설정을 구성합니다. 이 경우 WebMvcConfigurer 인터페이스를 구현하여 리소스 핸들러를 등록할 수 있습니다. 이 방법이 더 유연하고 타입-세이프하여 권장됩니다.

설정 클래스(예: `WebConfig.java`)를 만들고 addResourceHandlers 메소드를 오버라이드합니다.


import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@EnableWebMvc // Spring MVC를 활성화합니다. (Spring Boot에서는 자동 설정되는 경우가 많음)
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {

        // 일반적인 정적 리소스 처리 (예: /resources/**)
        registry.addResourceHandler("/resources/**")
                .addResourceLocations("/resources/");

        // ★★★ 핵심 해결책 ★★★
        // '/firebase-messaging-sw.js' URL 요청을 
        // '/resources/js/' 디렉토리와 매핑합니다.
        registry.addResourceHandler("/firebase-messaging-sw.js")
                .addResourceLocations("/resources/js/");
    }

    // ... 기타 설정 (CORS, Interceptors 등) ...
}

Java 기반 설정 역시 XML과 동일한 원리로 작동합니다. addResourceHandler()에 전달하는 인자는 URL 패턴(mapping)이고, addResourceLocations()에 전달하는 인자는 실제 파일 위치(location)입니다. Spring Boot를 사용하는 경우, 기본적으로 `src/main/resources/static` 경로에 대한 리소스 핸들러가 등록되어 있으므로 해당 경로에 파일을 두면 별도 설정 없이 /firebase-messaging-sw.js로 접근이 가능할 수도 있습니다. 하지만 명시적으로 설정하는 것이 프로젝트 구조의 의도를 명확하게 하고 예기치 않은 동작을 방지하는 좋은 습관입니다.

4. 추가적인 함정 및 문제 해결 팁

리소스 핸들러 설정으로 대부분의 404 에러는 해결되지만, 실제 개발 환경에서는 다른 변수들이 문제를 일으킬 수 있습니다.

4.1. 브라우저 캐시와 기존 서비스 워커

가장 흔한 함정 중 하나는 브라우저 캐시입니다. 이전에 404 에러를 경험했다면 브라우저는 이 실패한 요청을 캐싱할 수 있습니다. 또한, 잘못된 경로로 서비스 워커 등록을 시도했던 이력이 남아있을 수도 있습니다.

  • 해결책 1 (강력 새로고침): Chrome 개발자 도구를 열어둔 상태에서 새로고침 버튼을 마우스 오른쪽 버튼으로 클릭하고 '캐시 비우기 및 강력 새로고침'을 선택합니다.
  • 해결책 2 (서비스 워커 수동 제거): Chrome 개발자 도구의 'Application' 탭으로 이동하여 왼쪽 메뉴에서 'Service Workers'를 선택합니다. 현재 등록된 서비스 워커가 보인다면 'Unregister' 버튼을 눌러 수동으로 제거한 후 페이지를 다시 로드합니다. 'Update on reload' 체크박스를 활성화하면 개발 중에 편리합니다.

4.2. Spring Security와의 충돌

프로젝트에 Spring Security가 적용되어 있다면, 인증되지 않은 접근을 차단하는 과정에서 `/firebase-messaging-sw.js` 요청이 막힐 수 있습니다. 서비스 워커 파일은 로그인을 하지 않은 상태에서도 접근이 가능해야 하므로, 보안 설정에서 이 경로를 예외 처리해야 합니다.


// SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                // ★★★ 이 경로에 대한 접근을 모두 허용 ★★★
                .antMatchers("/firebase-messaging-sw.js").permitAll() 
                .antMatchers("/resources/**", "/login").permitAll()
                .anyRequest().authenticated()
                .and()
            .formLogin()
                // ...
            .and()
            .logout()
                // ...
            ;
    }
}

.antMatchers("/firebase-messaging-sw.js").permitAll() 설정을 통해 Spring Security가 해당 경로의 요청을 인증 검사 없이 통과시키도록 명시해야 합니다.

4.3. 확인 절차

모든 설정이 완료되었다면, 다음 절차를 통해 연동 성공 여부를 확인할 수 있습니다.

  1. Spring 애플리케이션을 재시작합니다.
  2. FCM 초기화 코드가 포함된 웹 페이지에 접속합니다.
  3. 브라우저 개발자 도구의 'Console' 탭을 엽니다. 'Service Worker 등록 성공' 로그가 보이고 404 에러가 없어야 합니다. 잠시 후 FCM 토큰이 콘솔에 출력되어야 합니다.
  4. 'Application' 탭 > 'Service Workers'에서 `firebase-messaging-sw.js`가 'activated and is running' 상태로 표시되는지 확인합니다.
  5. 'Application' 탭 > 'Manifest'에서 웹 앱 매니페스트가 정상적으로 로드되는지도 확인하는 것이 좋습니다.

이 모든 과정이 성공적으로 끝났다면, 이제 발급받은 FCM 토큰을 Spring 백엔드로 전송하여 데이터베이스에 저장하고, Firebase Admin SDK를 사용하여 해당 토큰으로 푸시 메시지를 보내는 다음 단계로 나아갈 수 있습니다.

결론: 현상을 넘어 원리를 이해하는 개발

Spring MVC 환경에서 `firebase-messaging-sw.js` 404 에러는 단순한 파일 누락 문제가 아니라, Spring MVC의 요청 처리 메커니즘과 서비스 워커의 스코프 제약 조건이라는 두 가지 기술의 특성이 교차하며 발생하는 필연적인 문제입니다. 이 문제의 해결 과정은 결국 'DispatcherServlet이 가로챈 정적 리소스 요청을 어떻게 올바르게 처리하도록 위임할 것인가'라는 Spring MVC의 근본적인 질문에 답하는 것과 같습니다.

<mvc:resources> 태그나 WebMvcConfigureraddResourceHandlers 메소드를 통해 가상의 URL 경로와 실제 파일 위치를 매핑해주는 것은 이 문제에 대한 표준적이고 가장 올바른 해결책입니다. 이를 통해 우리는 애플리케이션의 파일 구조를 깔끔하게 유지하면서도, 서비스 워커의 루트 경로 요구사항을 충족시킬 수 있습니다. 나아가 이 경험은 FCM 연동을 넘어, 프로젝트에서 발생하는 모든 종류의 정적 리소스 관련 문제를 해결하는 데 훌륭한 밑거름이 될 것입니다.


0 개의 댓글:

Post a Comment