Showing posts with label SpringFramework. Show all posts
Showing posts with label SpringFramework. Show all posts

Wednesday, August 9, 2023

누구나 쉽게 이해할 수 있는 Spring의 Bean 이야기

1장. Spring Bean이란 무엇인가?

Spring Bean은 Spring Framework에서 객체를 관리하는 컨테이너의 기본 단위입니다. Spring Bean이 등록되면, Spring IoC(제어 역전) 컨테이너에 의해 생성, 조립, 관리되는 객체가 됩니다. 여기서 IoC는 객체 생성과 그 객체의 멤버 변수, 메소드 호출을 스프링이 대신 관리함으로써 개발자가 직접 객체를 생성해서 사용할 필요가 없어지는 개념입니다.

Spring Bean은 주로 다음과 같은 장점을 제공합니다:

  • 개발자의 코드를 모듈로 분리하여 코드의 재사용성을 향상시킵니다.
  • 의존성 주입(Dependency Injection)을 통해 간의 낮은 결합도를 유지시킵니다.
  • 스프링의 AOP(관점 지향 프로그래밍)와 함께할 수 있어, 로깅, 트랜잭션 관리, 보안 등의 다양한 기능을 구현하는 것이 용이합니다.

스프링 빈을 생성하는법은 주로 다음 세 가지 가 있습니다:

  1. XML 기반 설정: Spring의 초기 방식이며, Bean을 정하는 데 사용되는 전용 설정 파일 beandef.xml을 사용합니다.
  2. 설정 클래스를 이용한 방법: Java 기반 설정으로 @Configuration과 @Bean 어노이션을 사용하여 스프링 빈을 설정합니다.
  3. 선언적 구성: @Component, @Service, @Repository, @Controller 같은 어노테이션을 사용하여 스프링 빈으로 정의하는 방식입니다.

본문의 다음 장에서는의 생명 주기, 빈 스코프와 빈 간의 연관관계 설정 방법에 대해 자세히 설명합니다.

2장. Spring Bean의 생명주기

Spring Bean의 생명주기는 객체의 생성부터 소멸까지의 여러 단계로 나뉩니다. 이러한 생명주기를 정확하게 이해하고 관리하면 응용 프로그램의 전반적인 성능과 메모리 관리에 도움이 됩니다.

다음은 Spring Bean 생명주기의 주요 단계입니다:

  1. Bean 생성: 스프링 컨테이너가 Bean 정의를 읽어들여 객체를 생성합니다.
  2. Bean 초기화: 의존성 주입 및 사용자 정의 초기화 메소드가 실행됩니다.
  3. Bean 사용: 애플리케이션에서 Bean을 사용하여 요청을 처리합니다.
  4. Bean 소멸: 스프링 컨테이너가 종료되면서 Bean 객체의 소멸 메서드가 호출됩니다.

Bean 초기화와 소멸에 관련된 두 가지 주요 방법을 사용할 수 있습니다:

  • init-method 및 destroy-method 속성을 사용한 방법: 이 방식은 XML 기반 설정에서 주로 사용되며, Bean 정의에서 해당 속성을 지정하여 초기화 및 소멸 메서드를 설정합니다.
  • @PostConstruct 및 @PreDestroy 어노테이션을 사용한 방법: Java 기반 설정에서 이러한 어노테이션을 사용하여 초기화 및 소멸 메서드를 정의할 수 있습니다.

또한, 스프링 Application Context의 다양한 이벤트를 감지하여 빈 생명주기를 처리하는 방법도 있습니다. 이러한 이벤트 처리는 ApplicationContextAware, BeanFactoryAware, InitializingBean, DisposableBean 등의 인터페이스를 구현하여 가능합니다.

생명주기를 지정하고 제어하는 것은 Bean이 동작하는 순서를 정확하게 파악하고, 공유 리소스 및 연결 해제 등의 메모리 처리를 개선하여 애플리케이션의 전반적인 성능에 도움이 됩니다.

3장. Spring Bean의 스코프

Spring Bean의 스코프는 Bean 객체의 생존 범위와 공유 수준을 결정합니다. 스코프에 따라 스프링 컨테이너는 각 Bean 요청 시 동일한 인스턴스를 반환하거나 새로운 인스턴스를 생성할 수 있습니다. 스코프의 종류를 이해하고 적절한 스코프를 설정하는 것은 리소스 사용, 성능 최적화, 상황에 맞는 공유 수준 설정에 중요한 역할을 합니다.

Spring Framework에서는 다음의 주요 스코프를 제공합니다:

  • Singleton (기본 스코프): 스프링 컨테이너는 각 빈 식별자에 대해 한 개의 인스턴스만 생성하며, 애플리케이션 전체에서 공유됩니다. 이 스코프는 애플리케이션 내 특정 Bean이 단 하나만 필요할 때 사용합니다.
  • Prototype: 빈 요청시마다 새로운 인스턴스를 생성합니다. Prototype 스코프는 Bean이 사용되는 상황이나, 특정 기간 동안 변경되는 경우에 적합합니다. 요청자마다 독립적인 개체가 필요한 경우 이 스코프를 사용합니다.
  • Request: 웹 애플리케이션에서 각 HTTP 요청에 따라 독립적인 Bean이 생성됩니다. 요청이 끝나면 Bean은 소멸됩니다.
  • Session: 스프링 컨테이너는 웹 애플리케이션 내에서 각 사용자 세션에 대해 독립적인 빈 인스턴스를 생성합니다.
  • Application: 웹 애플리케이션의 ServletContext에 저장되는 Bean입니다. 애플리케이션의 모든 사용자가 공유하고 있습니다.

스코프를 설정하는 방식은 다음과 같습니다:

  1. XML 기반 설정: Bean 정의에서 'scope' 속성을 지정합니다. 예시: <bean id="myBean" class="com.example.MyBean" scope="singleton">
  2. Java 기반 설정: @Scope 어노트를 사용하여 클래스 또는 메서드에 스코프를 지정합니다. 예시: @Scope("singleton")

적절한 스코프를 선택하고 사용하는 것은 애플리케이션의 전반적인 동작, 메모리 사용 및 사용자 경험에 큰 영향을 미칩니다.

4장. Spring Bean 간의 연관 관계 설정하기

Spring Bean 간의 연관 관계 설정은 주로 의존성 주입(Dependency Injection, DI)을 통해 이루어집니다. 이를 통해 개발자는 객체의 생성과 사용을 스프링에 미룰 수 있으며, 각 컴포넌트 간의 결합도를 낮출 수 있습니다. 의존성 주입은 주로 생성자 주입, 세터 메소드 주입 및 필드 주입 방식으로 구현됩니다.

1. 생성자 주입(Constructor Injection): 객체 생성 시 생성자의 매개변수를 통해 의존성을 주입하는 방식입니다. 이 방법은 빈 생성시 충분한 정보를 가지고 있고 의존성이 변경되지 않을 것으로 예상될 때 사용합니다.


public class FooService {
    private final BarService barService;

    public FooService(BarService barService) {
        this.barService = barService;
    }
}

2. 세터 메소드 주입(Setter Injection): 객체 생성 후 필요한 의존성을 세터 메소드를 통해 주입합니다. 이 방법은 의존성이 객체의 생성 이후 변경될 수 있을 때 사용합니다.


public class FooService {
    private BarService barService;

    public void setBarService(BarService barService) {
        this.barService = barService;
    }
}

3. 필드 주입(Field Injection): 객체의 필드에 직접 의존성을 주입하세요. 이 방법은 생성자가 많은 필드를 가지거나 의존성이 동적으로 변경될 때 사용합니다.


public class FooService {
    @Autowired
    private BarService barService;
}

또한, 스프링에서 의존성 주입을 관리하는데 사용되는 주요 어노테이션은 다음과 같습니다.

  • @Autowired: 스프링 컨테이너에서 자동으로 의존성을 주입하도록 지시하는 어노테이션입니다.
  • @Inject: @Autowired와 유사하게 작동하지만, Java 표준인 JSR-330에 속한 어노테이션입니다.
  • @Resource: 클래스 이름과 이름으로 빈을 주입합니다. 자바 표준인 JSR-250에 속한 어노테이션입니다.

적절한 연관 관계 설정은 애플리케이션의 유지 보수, 성능 및 확장성에 큰 영향을 미칩니다. 구성 요소 간의 강력한 결합을 피하고, 필요한 경우 직접 의존성을 생성 및 관리하는 대신 스프링의 DI 기능을 활용할 수 있습니다.

だれでも簡単に理解できるSpringのBeanについてのガイド

Chapter 1: Spring Beanとは何ですか?

Spring Beanは、Springフレームワークでのオブジェクト管理の基本単位です。Spring Beanが登録されると、SpringのIoC(Inversion of Control)コンテナによって作成、組み立て、管理されるオブジェクトになります。IoCは、オブジェクトの作成やメンバ変数、メソッドへの呼び出しをSpringによって管理し、開発者が直接オブジェクトを作成する必要がなくなるコンセプトです。

Spring Beanの主な利点は次のとおりです。

  • 開発者のコードをモジュール化し、コードの再利用性を向上させます。
  • 依存性の注入(DI)を介してコンポーネント間の低い結合を維持します。
  • Spring AOP(アスペクト指向プログラミング)と組み合わせることで、ロギング、トランザクション管理、セキュリティなどの各種機能の実装を促進します。

Spring Beanを作成する方法は主に3つあります。

  1. XMLベースの設定:Springで最初に使用された方法で、専用の設定ファイルbeandef.xmlを使用してBeanを定義します。
  2. 設定クラスベースの方法:Javaベースの設定で、@Configurationと@Beanアノテーションを使用してSpring Beanを設定します。
  3. 宣言的設定:@Component、@Service、@Repository、@Controllerなどのアノテーションを使って、Spring Beanを定義する方法です。

次の章では、Spring Beanのライフサイクル、Beanスコープ、およびBean間の関連を設定する方法について詳しく説明します。

Chapter 2: Spring Bean ライフサイクル

Spring Bean のライフサイクルは、オブジェクトの作成から破棄までのいくつかの段階から構成されています。このライフサイクルを正確に理解し、管理することで、アプリケーション全体のパフォーマンスやメモリ管理が向上することがあります。

以下は、Spring Bean ライフサイクルの主な段階です。

  1. Bean の作成:Spring コンテナは、Bean の定義を読み込み、オブジェクトを作成します。
  2. Bean の初期化:依存関係の注入とユーザー定義の初期化メソッドが実行されます。
  3. Bean の使用:アプリケーションは、Bean を使用してリクエストを処理します。
  4. Bean の破棄:Spring コンテナがシャットダウンすると、Bean の破棄メソッドが呼び出されます。

Beanの初期化と破棄に関連した主要な方法は2つあります。

  • init-method 属性と destroy-method 属性を使用する方法:この方法は、主に XML ベースの構成で使用され、Bean の定義で属性が指定され、初期化メソッドと破棄メソッドが設定されます。
  • @PostConstructアノテーションと@PreDestroyアノテーションを使用する方法:Javaベースの構成では、これらのアノテーションを利用して初期化メソッドと破棄メソッドを定義できます。

また、Spring Application Context 内でさまざまなイベントを検出して Bean ライフサイクルを処理する方法もあります。このイベント処理は、ApplicationContextAware インターフェース、BeanFactoryAware インターフェース、InitializingBean インターフェース、DisposableBean インターフェースなどを実装することによって実現できます。

ライフサイクルを明示的に指定し、制御することで、Beanが動作する順序を正確に理解し、共有リソースや接続の解放などのメモリー処理を改善し、アプリケーションのパフォーマンスに全体的に貢献します。

Chapter 3: Spring Bean スコープ

Spring Bean のスコープは、オブジェクトの寿命や共有レベルを決定します。スコープに応じて、Spring コンテナは、Bean のリクエストごとに同じインスタンスまたは新しいインスタンスを返すことができます。スコープの種類を理解し、適切なスコープを設定することは、リソース利用、パフォーマンス最適化、および特定の状況での適切な共有レベルを決定する上で重要な役割を果たします。

Spring フレームワークは、以下の主なスコープを提供しています。

  • シングルトン(デフォルトスコープ):Spring コンテナは、各 Bean 識別子に対して単一のインスタンスを作成し、アプリケーション全体で共有されます。このスコープは、アプリケーション内で特定の Bean が1つだけ必要な場合に使用されます。
  • プロトタイプ:Bean のリクエストごとに新しいインスタンスが作成されます。プロトタイプスコープは、Bean が異なる方法で使用されるまたは特定の期間内で変更される状況に適しています。このスコープは、各リクエスタに独立したオブジェクトが必要な場合に使用されます。
  • リクエスト:Webアプリケーションで、各HTTPリクエストに対して独立したBeanが作成されます。リクエストが終了すると、Beanは破棄されます。
  • セッション:Webアプリケーションで、Springコンテナは各ユーザーセッションごとに独立したBeanインスタンスを作成します。
  • アプリケーション:このBeanはWebアプリケーションのServletContextに保存され、アプリケーションのすべてのユーザーに共有されます。

スコープを設定する方法は次のとおりです。

  1. XMLベースの構成:Bean定義で'scope'属性を指定します。例:<bean id="myBean" class="com.example.MyBean" scope="singleton">
  2. Javaベースの構成:@Scopeアノテーションを使用して、クラスまたはメソッドのスコープを指定します。例:@Scope("singleton")

適切なスコープを選択し使用することは、アプリケーション全体の動作、メモリ使用量、ユーザーエクスペリエンスに大きな影響を与えます。

Chapter 4: Spring Beans 間の関係の設定

Spring Beans 間の関係の設定は、主に Dependency Injection(DI)を通じて実現されます。これにより、開発者はオブジェクトの作成および使用を Spring に委譲し、コンポーネント間のカップリングを軽減できます。依存関係の注入は、主にコンストラクタ注入、セッターメソッド注入、フィールド注入を使用して実装されます。

1. コンストラクタ注入:オブジェクトを作成するときにコンストラクタのパラメータを通じて依存関係が注入されます。このアプローチは、Bean が作成時に十分な情報を持っており、依存関係が変更されることが予想されない場合に使用されます。


public class FooService {
    private final BarService barService;

    public FooService(BarService barService) {
        this.barService = barService;
    }
}

2. セッターメソッド注入:オブジェクトの作成後、セッターメソッドを経由して依存関係が注入されます。この方法は、オブジェクトが作成された後に依存関係を変更できる場合に使用されます。


public class FooService {
    private BarService barService;

    public void setBarService(BarService barService) {
        this.barService = barService;
    }
}

3. フィールド注入:依存関係がオブジェクトのフィールドに直接注入されます。この方法は、コンストラクタが多数のフィールドを持っている場合や、依存関係を動的に変更する必要がある場合に使用されます。


public class FooService {
    @Autowired
    private BarService barService;
}

また、Spring で依存関係の注入を管理するために使用される主要なアノテーションは以下のとおりです。

  • @Autowired:Spring コンテナに自動的に依存関係を注入するよう指示するアノテーション。
  • @Inject:@Autowired と同様の機能を持つこのアノテーションは、Java 標準の JSR-330 の一部です。
  • @Resource:クラス名と名前に基づいて Bean を注入する。このアノテーションは、Java標準の JSR-250の一部です。

関連の正しい設定は、アプリケーションのメンテナンス、パフォーマンス、およびスケーラビリティに大きな影響を与えます。コンポーネント間の緊密なカップリングを避けることで、必要に応じて直接依存関係を作成および管理する代わりに、Spring の DI 機能を活用することができます。

A Comprehensive Guide to Understanding Spring Beans for Everyone

Chapter 1: What is a Spring Bean?

A Spring Bean is the basic unit of object management in the Spring Framework. When a Spring Bean is registered, it becomes an object that is created, assembled, and managed by the Spring IoC (Inversion of Control) container. IoC is a concept where the creation of objects and the calls to their member variables and methods are managed by Spring, eliminating the need for developers to create objects directly.

Spring Beans primarily provide the following advantages:

  • They separate developer code into modules, improving code reusability.
  • They maintain low coupling between components through dependency injection (DI).
  • They facilitate the implementation of various functions, such as logging, transaction management, and security, when used in conjunction with Spring AOP (Aspect-Oriented Programming).

There are mainly three ways to create Spring Beans:

  1. XML-based configuration: This is the initial method used by Spring that relies on a dedicated configuration file, beandef.xml, to define Beans.
  2. Configuration class-based method: This Java-based configuration uses @Configuration and @Bean annotations to configure Spring Beans.
  3. Declarative configuration: This method defines Spring Beans through annotations such as @Component, @Service, @Repository, and @Controller.

In the following chapters, we will discuss the lifecycle of Spring Beans, Bean scope, and how to set up relationships between Beans in detail.

Chapter 2: Spring Bean Lifecycle

The lifecycle of a Spring Bean consists of several stages, from object creation to destruction. Accurately understanding and managing this lifecycle can help improve the overall performance and memory management of an application.

The following are the main stages of the Spring Bean lifecycle:

  1. Bean creation: The Spring container reads the Bean definition and creates an object.
  2. Bean initialization: Dependency injection and user-defined initialization methods are executed.
  3. Bean usage: The application uses the Bean to handle requests.
  4. Bean destruction: The Bean's destruction method is called as the Spring container shuts down.

There are two primary methods related to Bean initialization and destruction:

  • Using the init-method and destroy-method attributes: This method is mainly used in XML-based configurations, where the attributes are specified in the Bean definition to set the initialization and destruction methods.
  • Using @PostConstruct and @PreDestroy annotations: In Java-based configurations, these annotations can be used to define the initialization and destruction methods.

Additionally, there are ways to handle the Bean lifecycle by detecting various events in the Spring Application Context. This event handling is made possible by implementing interfaces like ApplicationContextAware, BeanFactoryAware, InitializingBean, and DisposableBean.

Specifying and controlling the lifecycle helps accurately understand the sequence in which Beans operate, improves memory handling such as shared resources and connection releases, and overall contributes to the performance of the application.

Chapter 3: Spring Bean Scopes

The scope of a Spring Bean determines the lifespan and sharing level of its objects. Depending on the scope, the Spring container can return either the same instance or a new instance for each Bean request. Understanding the types of scopes and setting the appropriate scope play an important role in resource utilization, performance optimization, and determining the appropriate sharing level for a given situation.

The Spring Framework provides the following main scopes:

  • Singleton (default scope): The Spring container creates a single instance for each Bean identifier, which is shared across the entire application. This scope is used when only one specific Bean is needed within the application.
  • Prototype: A new instance is created for each Bean request. The Prototype scope is suitable for situations where Beans are used differently or change over a specific period. This scope is used when independent objects are needed for each requester.
  • Request: In a web application, an independent Bean is created for each HTTP request. The Bean is destroyed when the request ends.
  • Session: The Spring container creates independent Bean instances for each user session in the web application.
  • Application: This Bean is stored in the ServletContext of a web application and is shared by all the users of the application.

The methods for setting scope are as follows:

  1. XML-based configuration: Specify the 'scope' attribute in the Bean definition. Example: <bean id="myBean" class="com.example.MyBean" scope="singleton">
  2. Java-based configuration: Use the @Scope annotation to specify the scope for a class or method. Example: @Scope("singleton")

Selecting and using the appropriate scope has a significant impact on the overall operation, memory usage, and user experience of an application.

Chapter 4: Setting Relationships between Spring Beans

Setting relationships between Spring Beans is primarily achieved through Dependency Injection (DI). This allows developers to delegate object creation and usage to Spring, reducing coupling between components. Dependency injection is typically implemented using constructor injection, setter method injection, and field injection.

1. Constructor Injection: Dependencies are injected through the constructor's parameters when creating objects. This approach is used when beans have sufficient information during creation and dependencies are not expected to change.


public class FooService {
    private final BarService barService;

    public FooService(BarService barService) {
        this.barService = barService;
    }
}

2. Setter Method Injection: Dependencies are injected via setter methods after object creation. This method is used when dependencies can change after an object is created.


public class FooService {
    private BarService barService;

    public void setBarService(BarService barService) {
        this.barService = barService;
    }
}

3. Field Injection: Dependencies are directly injected into an object's fields. This method is used when constructors have numerous fields or when dependencies need to change dynamically.


public class FooService {
    @Autowired
    private BarService barService;
}

Additionally, the main annotations used for managing dependency injection in Spring are as follows:

  • @Autowired: An annotation that directs the Spring container to automatically inject dependencies.
  • @Inject: Similar in function to @Autowired, this annotation is part of the Java standard JSR-330.
  • @Resource: Injects a bean based on the class name and name. This annotation is part of the Java standard JSR-250.

Properly configuring relationships has a significant impact on the maintenance, performance, and scalability of an application. By avoiding tight coupling between components, you can leverage Spring's DI functionality, rather than directly creating and managing dependencies when necessary.

Wednesday, June 14, 2023

Springトランザクション伝播の挙動と実践的活用法

現代のエンタープライズアプリケーション開発において、データの整合性を維持することは最も重要な課題の一つです。データベーストランザクションは、この整合性を保証するための基本的なメカニズムであり、関連する一連の操作がすべて成功するか、すべて失敗するかのいずれかであることを保証する「All or Nothing」の原則に基づいています。Spring Frameworkは、この複雑なトランザクション管理を簡素化し、開発者がビジネスロジックに集中できるようにする、強力かつ柔軟な宣言的トランザクション管理機能を提供します。

Springのトランザクション管理の中核をなすのが、@Transactionalアノテーションです。このアノテーション一つで、メソッドの実行をトランザクションのコンテキスト内で管理できます。しかし、その真の力を引き出すためには、トランザクション伝播(Transaction Propagation)の概念を深く理解することが不可欠です。トランザクション伝播とは、あるトランザクションメソッドが別のトランザクションメソッドを呼び出した際に、トランザクションのコンテキストをどのように引き継ぐか、あるいは新しく生成するかを定義するルールセットです。この設定を誤ると、予期せぬデータ不整合やパフォーマンスの低下、さらにはデバッグの困難なバグを引き起こす可能性があります。

本稿では、Springが提供する7つのトランザクション伝播設定について、単なる機能の羅列に留まらず、それぞれの設定がどのようなシナリオで有効に機能するのか、具体的なコード例とユースケースを交えながら徹底的に解説します。さらに、@Transactionalアノテーションを利用する上で陥りがちな罠や、パフォーマンスを最適化するためのベストプラクティスについても掘り下げていきます。この記事を読み終える頃には、あなたは自信を持って、アプリケーションの要件に最適なトランザクション伝播設定を選択し、堅牢で信頼性の高いシステムを構築できるようになるでしょう。

第1章 トランザクションの基礎とSpringの抽象化レイヤー

トランザクション伝播の詳細に入る前に、まずデータベーストランザクションの基本原則であるACID特性と、Springがどのようにしてトランザクション管理を抽象化しているのかを理解することが重要です。

ACID特性:データ整合性の礎

トランザクションが保証すべき4つの特性は、その頭文字をとってACIDとして知られています。

  • 原子性(Atomicity): トランザクションに含まれるすべての操作は、完全に実行されるか、あるいは全く実行されないかのどちらかであることが保証されます。一部だけが成功する、という中途半端な状態は許されません。
  • 一貫性(Consistency): トランザクションの前後で、データベースの状態は一貫した(矛盾のない)状態に保たれます。例えば、銀行の口座振替トランザクション後、送金元と送金先の合計残高は変わらない、といった制約が維持されます。
  • 独立性(Isolation): 複数のトランザクションが同時に実行された場合でも、各トランザクションは他のトランザクションの影響を受けずに独立して実行されているように見えます。これにより、ダーティリードやファントムリードといった問題を防ぎます。独立性のレベルは、分離レベル(Isolation Level)によって調整可能です。
  • 永続性(Durability): 一度コミットされたトランザクションの結果は、システム障害が発生しても失われることはありません。変更は永続的なストレージに記録されます。

これらの特性をアプリケーションコードで直接管理するのは非常に複雑です。Springは、この複雑さを隠蔽し、開発者がより高いレベルでトランザクションを扱えるようにする抽象化レイヤーを提供します。

Springの宣言的トランザクション管理

Springは、AOP(Aspect-Oriented Programming)を活用して、宣言的なトランザクション管理を実現しています。開発者はビジネスロジックを実装したメソッドに@Transactionalアノテーションを付与するだけで、Springがメソッドの開始前にトランザクションを開始し、メソッドの正常終了時にコミット、例外発生時にロールバックするという一連の処理を自動的に行ってくれます。

この仕組みの裏側では、以下のコンポーネントが連携して動作しています。

  • PlatformTransactionManager: トランザクションを実際に管理するインターフェースです。使用する永続化技術(JDBC, JPA, JTAなど)に応じて、DataSourceTransactionManagerJpaTransactionManagerといった具体的な実装クラスが利用されます。
  • TransactionDefinition: トランザクションの属性(伝播設定、分離レベル、読み取り専用フラグ、タイムアウトなど)を定義するインターフェースです。@Transactionalアノテーションの各属性が、この定義に対応します。
  • TransactionStatus: 特定のトランザクションの現在の状態を表し、トランザクションをプログラム的に制御(例: setRollbackOnly())するために使用できます。

開発者が@Transactionalを付与したメソッドを呼び出すと、Spring AOPがその呼び出しをインターセプトし、PlatformTransactionManagerを通じてTransactionDefinitionに基づいたトランザクションを開始します。そして、メソッドの実行を監視し、結果に応じてコミットまたはロールバックを決定します。この抽象化により、私たちはトランザクションの伝播という、より高度な制御に集中することができるのです。

第2章 7つのトランザクション伝播設定の詳細解説

Springは7つのトランザクション伝播設定を提供しており、それぞれが異なる挙動を示します。ここでは、各設定の意味、動作シナリオ、そして具体的なユースケースをコード例とともに詳しく見ていきましょう。

1. PROPAGATION_REQUIRED (デフォルト)

動作: 既存のトランザクションが存在すればそれに参加し、存在しなければ新しいトランザクションを開始します。

解説: 最も広く使われる、基本となる伝播設定です。この設定により、一連のビジネスロジック(例: 注文の受付、在庫の更新、請求書の発行)を単一の大きなトランザクションとしてまとめることができます。もし、呼び出し元にトランザクションがなければ、そのメソッドがトランザクションの起点となります。

ユースケース:

  • 標準的なサービスレイヤーのメソッド。
  • 複数のデータ更新処理をアトミックに行う必要がある場合。
@Service
public class OrderService {

    @Autowired
    private ProductService productService;
    @Autowired
    private InvoiceService invoiceService;

    // このメソッドが呼び出された時、トランザクションがなければ新規作成される
    @Transactional(propagation = Propagation.REQUIRED)
    public void placeOrder(OrderData orderData) {
        // 注文データをDBに保存
        saveOrder(orderData);

        // 在庫更新処理を呼び出す(既存のトランザクションに参加)
        productService.decreaseStock(orderData.getProductId(), orderData.getQuantity());

        // 請求書作成処理を呼び出す(既存のトランザクションに参加)
        invoiceService.createInvoice(orderData);
    }
}

@Service
public class ProductService {
    @Transactional(propagation = Propagation.REQUIRED) // 呼び出し元のトランザクションに参加
    public void decreaseStock(Long productId, int quantity) {
        // 在庫を減らすロジック
    }
}

この例では、OrderService.placeOrderが呼び出されると新しいトランザクションが開始されます。その後、productService.decreaseStockinvoiceService.createInvoiceが呼び出されても、新しいトランザクションは作成されず、placeOrderが開始したトランザクションにそのまま参加します。もしcreateInvoiceで例外が発生すれば、saveOrderdecreaseStockの処理もすべてロールバックされます。

2. PROPAGATION_SUPPORTS

動作: 既存のトランザクションが存在すればそれに参加し、存在しなければトランザクションなしで実行されます。

解説: この設定は、トランザクションが必須ではないが、もしトランザクションのコンテキスト内で呼び出された場合には、その一部として動作してほしい、というような読み取り専用のロジックに適しています。トランザクションなしで実行される場合、メソッド内での各DB操作は自動コミットモードで動作します。

ユースケース:

  • 主に読み取り処理を行うが、一貫性のある読み取りが求められる場合もあるメソッド。
  • トランザクションコンテキストの有無に依存しないユーティリティ的なメソッド。
@Service
public class ProductQueryService {

    // トランザクションがあってもなくても動作する
    @Transactional(propagation = Propagation.SUPPORTS, readOnly = true)
    public Product findProductById(Long productId) {
        // 商品を検索するロジック
        // もし呼び出し元がトランザクション内であれば、そのトランザクションのスナップショットからデータを読み取る
        // トランザクションがなければ、単純にDBにクエリを発行する
        return productRepository.findById(productId).orElse(null);
    }
}

findProductByIdメソッドは、トランザクションを伴う一連の処理(例えば、在庫を更新する前に商品の存在を確認する)の一部として呼び出されることもあれば、単に商品情報を表示するためにトランザクションなしで呼び出されることもあります。SUPPORTSは、このような両方のシナリオに柔軟に対応できます。

3. PROPAGATION_MANDATORY

動作: 既存のトランザクションが存在しなければなりません。存在しない場合は例外(IllegalTransactionStateException)がスローされます。

解説: このメソッドが単独で呼び出されることは想定されておらず、必ず何らかのトランザクションの一部として実行されるべきだ、という強い制約を課すために使用します。これにより、設計上の意図を明確にし、誤った使い方を防ぐことができます。

ユースケース:

  • 必ず他のトランザクションメソッドから呼び出されることを前提とした、内部的なヘルパーメソッド。
  • データの整合性を保つために、トランザクションが絶対に必要となる更新処理。
@Service
public class OrderValidationService {

    // このメソッドは必ずトランザクション内で呼び出される必要がある
    @Transactional(propagation = Propagation.MANDATORY)
    public void validateOrder(OrderData orderData) {
        // 在庫チェックや顧客情報の検証など、一貫性のあるデータセットに対して実行する必要がある検証ロジック
        if (!isStockAvailable(orderData)) {
            throw new InsufficientStockException("在庫が不足しています");
        }
    }
}

@Service
public class OrderService {

    @Autowired
    private OrderValidationService validationService;

    @Transactional(propagation = Propagation.REQUIRED)
    public void placeOrder(OrderData orderData) {
        // OK: REQUIREDトランザクション内でMANDATORYメソッドを呼び出す
        validationService.validateOrder(orderData);
        // ...注文処理...
    }

    public void tryToValidateWithoutTransaction(OrderData orderData) {
        // NG: トランザクションがない状態で呼び出すとIllegalTransactionStateExceptionが発生
        validationService.validateOrder(orderData);
    }
}

4. PROPAGATION_REQUIRES_NEW

動作: 常に新しい、独立したトランザクションを開始します。もし既存のトランザクションが存在する場合、そのトランザクションは一時的に中断(suspend)されます。

解説: 呼び出し元のトランザクションの結果に影響されず、自身の処理結果を独立してコミットまたはロールバックさせたい場合に使用します。これは非常に強力ですが、データベース接続を2つ消費し、ロックの競合を引き起こす可能性があるため、慎重に使用する必要があります。

ユースケース:

  • メインの処理が失敗しても、必ず記録を残したい監査ログ(Audit Log)の保存。
  • トランザクションの結果に関わらず送信する必要がある通知処理(ただし、DB操作を伴う場合)。

@Service
public class OrderService {

    @Autowired
    private AuditLogService auditLogService;

    @Transactional(propagation = Propagation.REQUIRED)
    public void processOrder(OrderData orderData) {
        try {
            // 注文処理
            // ...
            if (someConditionFails) {
                throw new OrderProcessingException("注文処理に失敗しました");
            }
            // 処理成功をログに記録
            auditLogService.logSuccess("Order " + orderData.getId() + " processed.");
        } catch (Exception e) {
            // 処理失敗をログに記録
            auditLogService.logFailure("Order " + orderData.getId() + " failed: " + e.getMessage());
            throw e; // 例外を再スローして、processOrderトランザクションをロールバックさせる
        }
    }
}

@Service
public class AuditLogService {

    @Autowired
    private AuditLogRepository auditLogRepository;

    // 呼び出し元のトランザクションとは無関係に、常に新しいトランザクションで実行
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logSuccess(String message) {
        auditLogRepository.save(new AuditLog("SUCCESS", message));
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logFailure(String message) {
        auditLogRepository.save(new AuditLog("FAILURE", message));
    }
}

上記の例では、processOrderメソッド内で例外が発生し、そのトランザクションがロールバックされたとしても、logFailureメソッドはREQUIRES_NEWで実行されているため、そのトランザクションは独立してコミットされます。結果として、注文処理の失敗という事実が監査ログに確実に記録されます。

5. PROPAGATION_NOT_SUPPORTED

動作: トランザクションなしで実行されます。もし既存のトランザクションが存在する場合、そのトランザクションは一時的に中断されます。

解説: トランザクションを全く必要としない処理、あるいはトランザクション内で実行されると問題を引き起こす可能性のある処理に使用します。例えば、長時間にわたる読み取り処理がデータベースのロックを不必要に保持し続けるのを防ぐためなどに利用できます。

ユースケース:

  • 大量のデータを読み込んでレポートを生成するなど、トランザクションの保護が不要なバッチ処理。
  • 外部の非トランザクショナルなリソース(例: メールサーバー、ファイルシステム)との連携。
@Service
public class ReportService {

    // このメソッドはトランザクションを中断して実行される
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void generateLargeReport() {
        // 何万件ものデータをDBから読み出す処理
        // トランザクション内で実行すると、長時間DBリソースを占有してしまう可能性がある
        List<Data> allData = dataRepository.findAll();
        // ...レポート生成ロジック...
    }
}

6. PROPAGATION_NEVER

動作: トランザクションなしで実行されなければなりません。もし既存のトランザクションが存在する場合、例外(IllegalTransactionStateException)がスローされます。

解説: NOT_SUPPORTEDと同様に非トランザクショナルで実行しますが、こちらはより厳格で、トランザクションコンテキスト内での呼び出しを明確に禁止します。特定のメソッドが絶対にトランザクション内で実行されてはならないことを保証するための設定です。

ユースケース:

  • データベースの状態に依存しない、純粋な計算処理やキャッシュ操作。
  • 開発者が誤ってトランザクション内で呼び出すことを防ぎたいメソッド。

@Service
public class CacheService {

    // トランザクション内で呼び出されると例外が発生する
    @Transactional(propagation = Propagation.NEVER)
    public void refreshAllCaches() {
        // キャッシュをリフレッシュする処理。
        // この処理がDBトランザクションの一部である意味はなく、むしろ混乱を招く可能性があるため、
        // 意図しない呼び出しを防ぐためにNEVERを指定する。
    }
}

7. PROPAGATION_NESTED

動作: 既存のトランザクションが存在する場合、そのトランザクション内に「ネストされたトランザクション」を開始します。存在しない場合はREQUIREDと同様に新しいトランザクションを開始します。

解説: これが最も興味深く、かつ誤解されやすい伝播設定です。ネストされたトランザクションは、JDBCのセーブポイント(Savepoint)機能を利用して実現されます。親トランザクションとは独立してロールバックできますが、コミットは親トランザクションがコミットされるまで保留されます。親トランザクションがロールバックされると、ネストされたトランザクションのコミット結果も一緒にロールバックされます。

REQUIRES_NEWとの違い:

  • 物理トランザクション: REQUIRES_NEWは完全に新しい物理トランザクションを開始します(DB接続も別)。一方、NESTEDは同じ物理トランザクション内でセーブポイントを作成するだけです。
  • コミットのタイミング: REQUIRES_NEWは即座にコミットできますが、NESTEDのコミットは親トランザクションのコミットまで遅延されます。
  • 独立性: REQUIRES_NEWは親トランザクションのロールバックに影響されませんが、NESTEDは親がロールバックすれば自身もロールバックされます。

ユースケース:

  • 大規模なトランザクション内で、一部の処理の失敗は許容しつつ、メインの処理は継続したい場合。
  • オプションの更新処理など、失敗しても全体をロールバックする必要がない操作。
@Service
public class RegistrationService {

    @Autowired
    private UserService userService;
    @Autowired
    private PointService pointService;

    @Transactional(propagation = Propagation.REQUIRED)
    public void registerUser(UserData userData) {
        // ユーザー情報を保存(必須処理)
        userService.createUser(userData);

        try {
            // 新規登録ポイントを付与(オプション処理)
            // この処理が失敗しても、ユーザー登録自体は成功させたい
            pointService.grantInitialPoints(userData.getUserId());
        } catch (PointServiceException e) {
         

스프링 트랜잭션의 동작 원리와 전파 속성 심층 분석

엔터프라이즈 애플리케이션 개발에서 데이터의 일관성과 무결성을 보장하는 것은 시스템의 신뢰도를 결정하는 가장 중요한 요소 중 하나입니다. 여러 데이터베이스 연산이 하나의 논리적인 작업 단위로 묶여 모두 성공하거나 모두 실패해야 하는 상황은 비일비재하며, 이를 '트랜잭션(Transaction)'이라 부릅니다. 스프링 프레임워크는 이러한 트랜잭션 관리를 매우 효율적이고 유연하게 처리할 수 있는 강력한 기능을 제공하여 개발자가 비즈니스 로직에 더 집중할 수 있도록 돕습니다. 하지만 이 편리함 이면에는 반드시 이해해야 할 정교한 동작 원리가 숨어있습니다.

특히 여러 서비스 메소드가 연달아 호출되는 복잡한 시나리오에서, 각 메소드가 기존 트랜잭션에 어떻게 참여할지를 결정하는 '트랜잭션 전파(Transaction Propagation)'는 시스템의 동작 방식을 완전히 바꿀 수 있는 핵심 개념입니다. 단순히 @Transactional 어노테이션을 붙이는 것만으로는 충분하지 않습니다. 이 글에서는 스프링 트랜잭션의 내부 동작 원리부터 시작하여, 7가지 트랜잭션 전파 속성을 실제 사용 사례와 함께 깊이 있게 분석하고, 자주 발생하는 문제점과 해결 방안까지 포괄적으로 다룰 것입니다.

트랜잭션, 왜 필요한가? - ACID 원칙의 중요성

스프링의 트랜잭션 관리 기능을 논하기 전에, 데이터베이스 트랜잭션의 근간을 이루는 ACID 원칙을 되짚어볼 필요가 있습니다. ACID는 신뢰할 수 있는 트랜잭션 시스템이 갖추어야 할 4가지 핵심 속성을 의미합니다.

  • 원자성 (Atomicity): 트랜잭션에 포함된 모든 작업은 전부 성공적으로 실행되거나, 혹은 단 하나라도 실패할 경우 전부 실행되지 않은 상태로 되돌아가야 합니다. 'All or Nothing'의 원칙입니다. 예를 들어, A 계좌에서 B 계좌로 돈을 이체하는 작업은 'A 계좌 출금'과 'B 계좌 입금'이라는 두 가지 연산으로 구성됩니다. 출금은 성공했지만 입금이 실패했다면, 출금 작업까지 모두 취소(롤백)되어야 데이터의 정합성이 유지됩니다.
  • 일관성 (Consistency): 트랜잭션이 성공적으로 완료되면, 데이터베이스는 항상 일관된 상태를 유지해야 합니다. 즉, 트랜잭션 실행 전과 후에 데이터베이스의 제약 조건이나 규칙(예: 기본 키, 외래 키, 도메인 제약 등)이 위반되지 않아야 합니다. 계좌 이체 예에서 총액은 이체 전후로 동일해야 한다는 규칙이 여기에 해당합니다.
  • 격리성 (Isolation): 여러 트랜잭션이 동시에 실행될 때, 각 트랜잭션은 다른 트랜잭션의 작업에 영향을 받지 않고 독립적으로 실행되는 것처럼 보여야 합니다. 이를 통해 동시성 문제를 방지할 수 있습니다. 격리 수준(Isolation Level)에 따라 그 정도가 달라지며, 이는 성능과 데이터 일관성 사이의 트레이드오프 관계를 가집니다.
  • 지속성 (Durability): 성공적으로 완료된 트랜잭션의 결과는 시스템에 영구적으로 저장되어야 합니다. 즉, 트랜잭션이 커밋(commit)된 후에는 시스템 장애(예: 정전, 서버 다운)가 발생하더라도 그 결과는 손실되지 않아야 합니다.

스프링은 이러한 ACID 원칙을 개발자가 쉽게 적용할 수 있도록 추상화된 트랜잭션 관리 기능을 제공합니다. 이를 통해 개발자는 복잡한 트랜잭션 처리 코드를 직접 작성하는 대신, 비즈니스 요구사항에 맞는 트랜잭션 속성을 선언적으로 정의할 수 있습니다.

스프링이 트랜잭션을 다루는 방식: AOP와 프록시

스프링의 선언적 트랜잭션 관리의 핵심에는 관점 지향 프로그래밍(Aspect-Oriented Programming, AOP)이 있습니다. 개발자가 서비스 객체의 메소드에 @Transactional 어노테이션을 추가하면, 스프링은 해당 객체에 대한 프록시(Proxy) 객체를 생성합니다. 실제 실행 시에는 원본 객체 대신 이 프록시 객체가 호출됩니다.

프록시 객체는 @Transactional이 붙은 메소드가 호출될 때, 메소드 실행 전후로 다음과 같은 트랜잭션 관련 부가 기능을 수행합니다.

  1. 메소드 호출 가로채기: 클라이언트가 서비스 메소드를 호출하면, 프록시 객체가 이 호출을 먼저 받습니다.
  2. 트랜잭션 시작: 프록시는 @Transactional 어노테이션의 속성(전파, 격리 수준 등)을 확인하고, 필요하다면 데이터베이스 커넥션을 얻어 트랜잭션을 시작합니다. (auto-commit = false)
  3. 실제 메소드 호출: 트랜잭션이 시작된 후, 원본 객체의 실제 비즈니스 로직 메소드를 호출합니다.
  4. 트랜잭션 결정: 메소드 실행이 성공적으로 완료되면, 프록시는 트랜잭션을 커밋(commit)합니다. 만약 메소드 실행 중 예외(기본적으로 RuntimeException 또는 Error)가 발생하면, 트랜잭션을 롤백(rollback)합니다.
  5. 결과 반환: 트랜잭션 처리가 완료된 후, 결과를 클라이언트에게 반환합니다.

이러한 프록시 방식 때문에 발생하는 중요한 제약사항이 바로 'Self-Invocation(자기 호출)' 문제입니다. 같은 클래스 내에서 @Transactional이 붙지 않은 메소드가 @Transactional이 붙은 다른 메소드를 호출하는 경우, 프록시를 거치지 않고 원본 객체의 메소드가 직접 호출(this.anotherMethod())됩니다. 그 결과, 트랜잭션 기능이 전혀 동작하지 않게 됩니다. 이는 스프링 AOP의 고질적인 한계이므로, 트랜잭션이 필요한 메소드는 별도의 서비스 빈(Bean)으로 분리하여 의존성 주입을 통해 호출하는 것이 일반적인 해결책입니다.

@Transactional 어노테이션 상세 분석

@Transactional 어노테이션은 단순히 트랜잭션을 적용하는 것 이상의 세밀한 제어를 위한 여러 속성을 제공합니다. 이들을 올바르게 이해하고 사용하는 것이 중요합니다.

  • isolation (격리 수준): 트랜잭션의 격리성을 어느 수준으로 유지할지 설정합니다. 스프링은 데이터베이스 표준 격리 수준인 READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE을 지원합니다. 격리 수준이 높아질수록 데이터 일관성은 향상되지만 동시 처리 성능은 저하될 수 있으므로, 애플리케이션의 특성에 맞는 적절한 수준을 선택해야 합니다.
  • readOnly: 트랜잭션을 읽기 전용으로 설정합니다. 이를 true로 설정하면 스프링과 하부 ORM(JPA 등)은 해당 트랜잭션 내에서 데이터 변경 작업이 없을 것으로 간주하고 다양한 최적화를 수행합니다(예: 더티 체킹(dirty checking) 생략, 플러시 모드 변경). 성능 향상을 위해 조회 기능에는 적극적으로 사용하는 것이 좋습니다.
  • rollbackFor / noRollbackFor: 스프링의 기본 롤백 정책을 재정의합니다. 기본적으로 스프링은 RuntimeExceptionError에 대해서만 롤백을 수행하며, 체크 예외(Checked Exception)가 발생하면 커밋을 시도합니다. 특정 체크 예외 발생 시에도 롤백을 원한다면 rollbackFor = BusinessException.class와 같이 지정할 수 있고, 반대로 특정 런타임 예외에 대해 롤백을 원치 않는다면 noRollbackFor = SpecificRuntimeException.class와 같이 설정할 수 있습니다.
  • timeout: 지정된 시간(초 단위) 내에 트랜잭션이 완료되지 않으면 롤백을 수행합니다. 장시간 실행될 수 있는 작업이 시스템 전체에 영향을 주는 것을 방지하기 위해 사용됩니다.

그리고 가장 중요하고 복잡한 속성이 바로 다음에 다룰 propagation(전파)입니다.

7가지 트랜잭션 전파(Propagation) 속성 심층 탐구

트랜잭션 전파 속성은 하나의 트랜잭션 컨텍스트 내에서 다른 트랜잭션 메소드가 호출될 때, 새로 호출된 메소드가 기존 트랜잭션에 어떻게 참여할지를 결정하는 규칙입니다. 스프링은 7가지의 전파 옵션을 제공하며, 각 옵션은 뚜렷한 목적과 사용 사례를 가집니다.

이해를 돕기 위해, 외부 서비스를 호출하는 `OuterService`와 그에 의해 호출되는 `InnerService`의 두 가지 컴포넌트가 있다고 가정하고 각 속성을 살펴보겠습니다.

1. PROPAGATION_REQUIRED (기본값)

가장 널리 사용되는 기본 전파 속성입니다.

  • 동작 방식:
    • 기존 트랜잭션이 있는 경우: 해당 트랜잭션에 참여(join)합니다. 즉, 동일한 물리적 트랜잭션의 일부가 되어 같은 커넥션을 공유하고, 하나의 작업 단위로 묶입니다. 내부 메소드에서 발생한 예외는 외부 메소드의 트랜잭션에도 영향을 주어 전체 롤백을 유발할 수 있습니다.
    • 기존 트랜잭션이 없는 경우: 새로운 트랜잭션을 생성합니다.
  • 사용 사례: 대부분의 비즈니스 로직에 적합합니다. 하나의 비즈니스 요구사항을 처리하기 위해 여러 메소드가 연계되어 동작할 때, 이들을 단일 트랜잭션으로 묶어 원자성을 보장하고자 할 때 사용합니다. 예를 들어 '주문 생성' 서비스가 '재고 감소'와 '결제 기록' 서비스를 순차적으로 호출하는 경우가 이에 해당합니다.

// OuterService
@Service
@RequiredArgsConstructor
public class OuterService {
    private final InnerService innerService;

    @Transactional // (propagation = Propagation.REQUIRED)
    public void processOrder() {
        // ... 주문 관련 데이터 처리 ...
        innerService.updateStock(); // 기존 트랜잭션에 참여
    }
}

// InnerService
@Service
public class InnerService {
    @Transactional // (propagation = Propagation.REQUIRED)
    public void updateStock() {
        // ... 재고 관련 데이터 처리 ...
        // 여기서 예외 발생 시 processOrder()의 작업까지 모두 롤백됨
    }
}

2. PROPAGATION_REQUIRES_NEW

이름에서 알 수 있듯이, 항상 새로운 트랜잭션을 생성하는 강력한 옵션입니다.

  • 동작 방식:
    • 기존 트랜잭션이 있는 경우: 기존 트랜잭션을 일시 중단(suspend)시키고, 완전히 새로운 물리적 트랜잭션을 시작합니다. 내부 메소드의 트랜잭션은 외부 트랜잭션과 독립적으로 커밋 또는 롤백됩니다. 내부 트랜잭션이 완료된 후, 중단되었던 외부 트랜잭션이 다시 재개(resume)됩니다.
    • 기존 트랜잭션이 없는 경우: 새로운 트랜잭션을 생성합니다. (REQUIRED와 동일)
  • 사용 사례: 외부 트랜잭션의 성공 여부와 관계없이, 특정 작업의 결과를 반드시 독립적으로 처리해야 할 때 사용합니다. 대표적인 예로, 시스템 작업의 성공/실패 여부를 기록하는 '감사 로그(Audit Log)'가 있습니다. 비즈니스 로직(외부 트랜잭션)이 실패하여 롤백되더라도, "실패했다"는 사실 자체는 로그 테이블에 반드시 기록되어야 하기 때문입니다.

// OuterService
@Service
@RequiredArgsConstructor
public class OuterService {
    private final AuditService auditService;

    @Transactional
    public void importantBusinessLogic() {
        try {
            // ... 중요한 비즈니스 로직 수행 ...
            if (someConditionIsBad) {
                throw new BusinessException("비즈니스 로직 실패");
            }
            auditService.logSuccess("작업 성공");
        } catch (BusinessException e) {
            auditService.logFailure("작업 실패: " + e.getMessage());
            throw e; // 예외를 다시 던져 외부 트랜잭션 롤백
        }
    }
}

// AuditService
@Service
public class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logSuccess(String message) {
        // 로그 저장 로직. 이 트랜잭션은 항상 커밋된다.
    }
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logFailure(String message) {
        // 로그 저장 로직. importantBusinessLogic이 롤백되더라도
        // 이 로그는 DB에 저장된다.
    }
}

3. PROPAGATION_NESTED

REQUIRES_NEW와 혼동하기 쉽지만, 동작 방식에 중요한 차이가 있는 고급 옵션입니다.

  • 동작 방식:
    • 기존 트랜잭션이 있는 경우: 중첩된 트랜잭션(nested transaction)을 시작합니다. 이는 물리적으로는 외부 트랜잭션과 동일한 커넥션을 사용하지만, 논리적으로는 독립된 트랜잭션처럼 동작합니다. JDBC의 세이브포인트(Savepoint) 기능을 사용하여 구현됩니다. 내부 트랜잭션에서 롤백이 발생하면, 외부 트랜잭션 전체가 롤백되는 것이 아니라 해당 트랜잭션이 시작되기 직전의 세이브포인트까지만 롤백됩니다. 외부 트랜잭션은 이 롤백 사실을 인지하고 다른 로직을 수행하거나 계속 진행할 수 있습니다.
    • 기존 트랜잭션이 없는 경우: 새로운 트랜잭션을 생성합니다. (REQUIRED와 동일)
  • REQUIRES_NEW와의 핵심 차이점:
    • 물리적 트랜잭션: REQUIRES_NEW는 별개의 커넥션을 사용하는 두 개의 물리적 트랜잭션을 만들지만, NESTED는 하나의 물리적 트랜잭션 내에서 동작합니다.
    • 롤백의 영향: NESTED의 내부 트랜잭션은 외부 트랜잭션에 종속적입니다. 따라서 외부 트랜잭션이 롤백되면, 내부 트랜잭션의 결과도 함께 롤백됩니다. 반면 REQUIRES_NEW는 완전히 독립적이므로 외부 트랜잭션의 롤백이 내부 트랜잭션에 영향을 주지 않습니다.
  • 사용 사례: 복잡한 비즈니스 로직 내에서 특정 부분의 작업만 독립적으로 롤백하고 싶을 때 유용합니다. 예를 들어, 여러 상품을 장바구니에 담는 과정에서 특정 상품의 재고가 부족하여 해당 상품 추가만 실패 처리하고 나머지 상품은 정상적으로 처리하고 싶을 때 사용할 수 있습니다.
  • 주의사항: 모든 데이터베이스(JDBC 드라이버)가 세이브포인트 기능을 지원하는 것은 아니므로, 사용 전 호환성 확인이 필수적입니다.

4. PROPAGATION_SUPPORTS

트랜잭션을 강제하지 않고, 유연하게 지원하는 모드입니다.

  • 동작 방식:
    • 기존 트랜잭션이 있는 경우: 해당 트랜잭션에 참여합니다. (REQUIRED와 동일)
    • 기존 트랜잭션이 없는 경우: 트랜잭션 없이 메소드를 실행합니다.
  • 사용 사례: 해당 메소드가 반드시 트랜잭션을 필요로 하지는 않지만, 트랜잭션 컨텍스트 내에서 호출될 경우 원자성을 보장받고 싶을 때 사용합니다. 주로 조회(read) 관련 로직이나, 데이터 변경이 일어나지 않는 유틸리티성 메소드에 적합합니다.

5. PROPAGATION_MANDATORY

반드시 기존 트랜잭션 내에서 실행되어야 함을 강제하는 속성입니다.

  • 동작 방식:
    • 기존 트랜잭션이 있는 경우: 해당 트랜잭션에 참여합니다.
    • 기존 트랜잭션이 없는 경우: 예외(IllegalTransactionStateException)를 발생시킵니다.
  • 사용 사례: 해당 메소드가 독립적으로 호출되어서는 안 되고, 반드시 더 큰 서비스의 트랜잭션 흐름의 일부로서만 실행되어야 함을 명확히 하고 싶을 때 사용됩니다. 아키텍처적으로 특정 메소드의 역할을 강제하는 데 도움이 됩니다.

6. PROPAGATION_NOT_SUPPORTED

트랜잭션 컨텍스트 밖에서 실행되도록 보장합니다.

  • 동작 방식:
    • 기존 트랜잭션이 있는 경우: 기존 트랜잭션을 일시 중단하고, 트랜잭션 없이 메소드를 실행합니다. 메소드 실행이 완료되면 중단되었던 트랜잭션을 재개합니다.
    • 기존 트랜잭션이 없는 경우: 트랜잭션 없이 메소드를 실행합니다.
  • 사용 사례: 트랜잭션과 무관한 작업을 수행할 때 유용합니다. 예를 들어, 트랜잭션 내에서 오랜 시간이 걸리는 외부 API를 호출하거나, 대용량 파일 I/O 작업을 수행하는 경우, 해당 작업 시간 동안 데이터베이스 커넥션을 불필요하게 점유하는 것을 방지할 수 있습니다.

7. PROPAGATION_NEVER

가장 엄격한 비-트랜잭션 속성입니다.

  • 동작 방식:
    • 기존 트랜잭션이 있는 경우: 예외(IllegalTransactionStateException)를 발생시킵니다.
    • 기존 트랜잭션이 없는 경우: 트랜잭션 없이 메소드를 실행합니다.
  • 사용 사례: 특정 메소드가 절대로 트랜잭션 내에서 호출되어서는 안 된다는 것을 시스템적으로 보장하고 싶을 때 사용합니다. 예를 들어, 데이터베이스의 현재 상태를 모니터링하는 등의 특수한 목적을 가진 메소드에 적용할 수 있습니다.

트랜잭션 관리 시 흔히 겪는 문제와 해결책

스프링 트랜잭션을 사용하면서 개발자들이 자주 겪는 몇 가지 함정이 있습니다.

  • @Transactional의 적용 범위: 앞서 언급했듯, 프록시 방식으로 동작하기 때문에 public 메소드에만 적용됩니다. private, protected, 또는 package-private 메소드에 어노테이션을 붙여도 트랜잭션은 적용되지 않으며, 스프링은 이에 대한 어떠한 경고나 오류도 발생시키지 않으므로 각별한 주의가 필요합니다.
  • 체크 예외(Checked Exception)와 롤백: 스프링의 기본 롤백 정책은 언체크 예외(RuntimeException의 하위 클래스)와 Error에 대해서만 작동합니다. 만약 서비스 로직에서 체크 예외(예: IOException, SQLException)를 잡아서 던질 경우, 트랜잭션은 롤백되지 않고 커밋됩니다. 이를 방지하려면 @Transactional(rollbackFor = Exception.class)와 같이 롤백 정책을 명시적으로 지정해야 합니다.
  • try-catch 블록의 오용: @Transactional 메소드 내부에서 예외를 try-catch로 잡고 아무런 처리를 하지 않으면, 스프링 AOP 프록시는 예외 발생을 감지할 수 없습니다. 따라서 프록시는 메소드가 정상 종료된 것으로 판단하고 트랜잭션을 커밋하게 됩니다. 예외를 잡았다면, 반드시 다시 던지거나(throw e;) TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();를 호출하여 프로그래밍 방식으로 롤백을 명시해야 합니다.

결론: 올바른 트랜잭션 전략의 중요성

스프링의 @Transactional은 매우 강력하고 편리한 도구이지만, 그 내부 동작 원리와 다양한 속성을 정확히 이해하지 못하면 예기치 않은 데이터 부정합 문제를 야기할 수 있습니다. 특히 트랜잭션 전파는 여러 서비스가 상호작용하는 복잡한 애플리케이션의 동작을 정의하는 핵심적인 요소입니다.

단순히 기본값인 REQUIRED만 사용하는 것을 넘어, 각 비즈니스 시나리오의 요구사항을 분석하고 그에 가장 적합한 전파 속성(REQUIRES_NEW, NESTED, NOT_SUPPORTED 등)을 선택하는 것이 견고하고 신뢰성 높은 시스템을 구축하는 지름길입니다. 이 글에서 다룬 내용들을 바탕으로 자신의 애플리케이션에 적용된 트랜잭션 전략을 다시 한번 점검하고, 더 정교하게 다듬어 나가는 계기가 되기를 바랍니다.

Navigating Transactional Boundaries in Spring

In the world of enterprise application development, maintaining data integrity is paramount. A single inconsistent record or a partially completed operation can lead to corrupted data, financial loss, and a loss of user trust. At the heart of data integrity lies the concept of a transaction—a sequence of operations performed as a single logical unit of work. The Spring Framework provides a powerful, abstract model for transaction management that liberates developers from platform-specific APIs and allows them to focus on business logic. A cornerstone of this model is the concept of transaction propagation, which defines the behavior of a method when it is called within the context of an existing transaction. Understanding these propagation rules is not merely an academic exercise; it is essential for building robust, predictable, and scalable applications.

This exploration delves deep into the mechanics of Spring's transaction management, focusing on the nuanced behaviors of its seven propagation settings. We will move beyond simple definitions to examine the underlying principles, practical use cases, and common pitfalls associated with each, enabling you to make informed decisions about how your application's transactional boundaries are defined and managed.

The Foundation: How Spring Manages Transactions

Before dissecting the specific propagation levels, it's crucial to understand the mechanism Spring employs to enforce transactional behavior: Aspect-Oriented Programming (AOP). When you annotate a public method with @Transactional, Spring doesn't modify your class's bytecode directly. Instead, at runtime, it creates a proxy object that wraps your bean. This proxy intercepts calls to the annotated methods.

The process looks like this:

  1. A client calls a method on your service bean (e.g., myService.doWork()).
  2. The call is intercepted by the Spring-generated proxy.
  3. The proxy's transactional advice kicks in. It checks the @Transactional annotation's attributes (like propagation, isolation level, etc.).
  4. Based on these attributes, the advice decides whether to start a new transaction, join an existing one, or suspend it. It interacts with the configured PlatformTransactionManager (e.g., JpaTransactionManager, DataSourceTransactionManager) to manage the underlying physical transaction (like a JDBC connection).
  5. After beginning the transaction, the proxy invokes the actual method on your target bean (myService.doWork()).
  6. When the method completes, the proxy intercepts the return. If the method finished successfully, the transactional advice commits the transaction. If it threw an exception that triggers a rollback (by default, any RuntimeException or Error), the advice rolls the transaction back.

This proxy-based approach has a critical implication known as the "self-invocation trap." If a @Transactional method calls another public @Transactional method within the same class (e.g., using this.anotherMethod()), the second method call will not be intercepted by the proxy. The call is a direct internal method invocation on the target object itself, bypassing the transactional advice. Consequently, the transaction settings of the second method will be completely ignored. This is a common source of confusion and bugs for developers new to the Spring ecosystem.

A Deep Dive into Propagation Behaviors

Transaction propagation defines how a transactional method behaves when called from a context that may or may not already have an active transaction. Spring offers seven distinct propagation levels, each designed for a specific scenario.

1. `PROPAGATION_REQUIRED`

This is the default and most widely used propagation level. It's the workhorse of Spring transaction management, embodying the principle of "participate if possible, create if necessary."

  • Behavior: If an active transaction exists when the method is called, the method will join that existing transaction. It becomes part of the same logical unit of work. If no transaction is active, Spring will create a new one. The transaction is committed or rolled back when the outermost method that initiated it completes.
  • When to Use: This is the ideal choice for the vast majority of business service methods. It ensures that a series of related operations (e.g., find a user, update their profile, save the changes) are all executed within a single, atomic transaction. If a high-level service method initiates a transaction, all subsequent calls to other `REQUIRED` methods will simply participate in it.
@Service
public class OrderService {

    @Autowired
    private InventoryService inventoryService;
    
    @Autowired
    private OrderRepository orderRepository;

    @Transactional(propagation = Propagation.REQUIRED) // Or just @Transactional
    public void placeOrder(OrderData data) {
        // This method starts a new transaction if none exists.
        Order order = createOrderFromData(data);
        orderRepository.save(order);
        
        // This call will join the existing transaction.
        inventoryService.decreaseStock(data.getProductId(), data.getQuantity());
    }
}

@Service
public class InventoryService {

    @Autowired
    private ProductRepository productRepository;

    @Transactional(propagation = Propagation.REQUIRED)
    public void decreaseStock(Long productId, int quantity) {
        // This method executes within the transaction started by placeOrder().
        Product product = productRepository.findById(productId).orElseThrow();
        if (product.getStock() < quantity) {
            // This exception will cause the entire transaction (including the order save) to roll back.
            throw new InsufficientStockException("Not enough stock for product " + productId);
        }
        product.setStock(product.getStock() - quantity);
        productRepository.save(product);
    }
}

2. `PROPAGATION_REQUIRES_NEW`

This propagation level ensures that a method always executes in its own, new, independent transaction.

  • Behavior: When a method with `REQUIRES_NEW` is called, Spring will always create a new transaction for it. If an existing transaction is active, it is suspended. The new, inner transaction proceeds independently. When the inner method completes, its transaction is committed or rolled back. Afterward, the suspended outer transaction resumes.
  • Key Distinction: A rollback of the inner transaction does not affect the outer transaction. However, if the outer transaction rolls back after the inner one has successfully committed, the changes from the inner transaction are *also* rolled back (assuming they are within the same database). The two transactions are physically separate but often logically linked at the database level.
  • When to Use: Use this with caution. It's suitable for operations that must be committed regardless of the outcome of the calling transaction. Common examples include auditing, logging, or updating a monitoring system. For instance, you might want to log a record of an attempted operation even if the operation itself fails and rolls back.
@Service
public class MainBusinessService {

    @Autowired
    private AuditService auditService;
    
    @Transactional(propagation = Propagation.REQUIRED)
    public void performCriticalOperation(Data data) {
        try {
            // Main business logic...
            if (someConditionFails) {
                throw new BusinessException("Operation failed");
            }
            // ... more logic
        } finally {
            // This call will start a NEW transaction.
            // It will commit even if the outer transaction rolls back.
            auditService.logAttempt(data, "SUCCESS");
        }
    }
}

@Service
public class AuditService {

    @Autowired
    private AuditRepository auditRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAttempt(Data data, String status) {
        // This method executes in its own transaction.
        // If the main operation throws an exception and rolls back, this audit log will still be saved.
        AuditRecord record = new AuditRecord(data.getId(), status, LocalDateTime.now());
        auditRepository.save(record);
    }
}

3. `PROPAGATION_NESTED`

This propagation offers a more granular control over rollbacks within a larger transaction, behaving like a sub-transaction.

  • Behavior: If an active transaction exists, `NESTED` creates a nested transaction. This is not a truly independent transaction like `REQUIRES_NEW`. Instead, it typically maps to JDBC savepoints. The nested transaction can be rolled back independently without affecting the outer transaction. However, if the outer transaction rolls back, the effects of the nested transaction are also rolled back, even if it was committed. If no transaction exists, it behaves exactly like `REQUIRED` (it creates a new transaction).
  • Database Support: This is a critical point. `NESTED` propagation is not universally supported. It relies on the underlying `PlatformTransactionManager` and JDBC driver's ability to handle savepoints. If the driver or manager doesn't support savepoints, using `NESTED` may result in an exception or may silently fall back to `REQUIRED` behavior.
  • When to Use: It's useful for complex business logic where a large operation consists of several steps, some of which may fail but should not cause the entire operation to fail. For example, processing a batch of records where you want to try saving each record, log any failures, and continue with the rest of the batch, only rolling back the save for the single failed record.
@Service
public class BatchProcessingService {

    @Autowired
    private RecordProcessor recordProcessor;
    
    @Autowired
    private ReportRepository reportRepository;

    @Transactional
    public void processBatch(List<Record> records) {
        int successCount = 0;
        int failureCount = 0;
        
        for (Record record : records) {
            try {
                // Each call to processRecord will start a nested transaction (savepoint).
                recordProcessor.processRecord(record);
                successCount++;
            } catch (ValidationException e) {
                // The nested transaction for this specific record will be rolled back.
                // The main transaction remains active.
                failureCount++;
                log.error("Failed to process record " + record.getId(), e);
            }
        }
        
        // The outer transaction commits the successfully processed records and the final report.
        ProcessingReport report = new ProcessingReport(successCount, failureCount);
        reportRepository.save(report);
    }
}

@Service
public class RecordProcessor {

    @Autowired
    private RecordRepository recordRepository;

    @Transactional(propagation = Propagation.NESTED)
    public void processRecord(Record record) {
        // ... validation logic ...
        if (!record.isValid()) {
            throw new ValidationException("Invalid record data");
        }
        record.setProcessed(true);
        recordRepository.save(record);
    }
}

4. `PROPAGATION_SUPPORTS`

This propagation level is ambivalent about transactions. It will participate if one is available but is perfectly happy to run without one.

  • Behavior: If an active transaction exists, the method joins it. If no transaction is active, the method executes non-transactionally. This means that each individual database statement within the method will be auto-committed if running outside a transaction, and no rollback is possible.
  • When to Use: This is suitable for business logic that can benefit from being part of a transaction but doesn't strictly require it. For example, a read-only method that fetches data. If called from a transactional context, it will see uncommitted data from that transaction (ensuring consistency). If called from a non-transactional context, it will simply fetch the latest committed data from the database.

5. `PROPAGATION_NOT_SUPPORTED`

This propagation actively avoids running within a transaction.

  • Behavior: If an active transaction exists, it is suspended, and the method executes non-transactionally. After the method completes, the suspended transaction is resumed. If no transaction was active, it simply executes non-transactionally.
  • When to Use: This is for operations that should never be part of a transaction. For example, calling a legacy system, interacting with a non-transactional resource, or executing a long-running, read-only process that might hold a database connection for too long if it were part of a transaction.

6. `PROPAGATION_MANDATORY`

As its name implies, this level requires an existing transaction.

  • Behavior: If an active transaction exists, the method joins it. If no transaction is active, Spring throws an IllegalTransactionStateException.
  • When to Use: This is not commonly used but can serve as an explicit assertion. It allows a service method to declare that it is designed purely to be a participant in a transaction initiated by a caller and should never be invoked on its own. It's a way to enforce a certain application architecture.

7. `PROPAGATION_NEVER`

This is the strict opposite of `MANDATORY`.

  • Behavior: The method must be executed without a transaction. If an active transaction exists, Spring throws an IllegalTransactionStateException.
  • When to Use: This is also rarely used. It can be used to ensure that certain utility or low-level data access logic is never accidentally called from within a business transaction, perhaps to prevent locking issues or to interact with a system that is incompatible with transactions.

Beyond Propagation: Fine-Tuning with Other Attributes

The @Transactional annotation is more than just propagation. It offers several other attributes to precisely control transactional behavior.

  • isolation: Defines the isolation level of the transaction, which controls how concurrent transactions interact with each other and see each other's data. Spring supports standard levels like READ_COMMITTED (default for most databases), READ_UNCOMMITTED, REPEATABLE_READ, and SERIALIZABLE. Choosing the right level is a trade-off between data consistency and performance.
  • readOnly: A powerful hint for the persistence provider. When set to true, it signals that the transaction will not perform any write operations. The underlying provider (e.g., Hibernate) can perform significant optimizations, such as skipping dirty checks on entities and setting the JDBC connection to a read-only mode, which can improve performance. It is a best practice to mark all purely data-retrieval methods with @Transactional(readOnly = true).
  • timeout: Specifies the time in seconds that this transaction is allowed to run before it is automatically rolled back by the transaction infrastructure. This is a crucial safety mechanism to prevent runaway transactions from holding database locks and resources indefinitely.
  • rollbackFor and noRollbackFor: These attributes provide fine-grained control over the rollback policy. By default, Spring only rolls back transactions for unchecked exceptions (those that extend RuntimeException) and Error. It does not roll back for checked exceptions (those that extend Exception but not RuntimeException). You can use rollbackFor = {MyCheckedException.class} to force a rollback on a specific checked exception, or noRollbackFor = {SomeUncheckedException.class} to prevent a rollback for a specific runtime exception.

Conclusion: A Deliberate Approach to Transactionality

Spring's declarative transaction management is a feature of profound utility, but its power lies in its details. Simply applying @Transactional everywhere is not a strategy; it's a potential source of subtle and hard-to-diagnose bugs. A robust application is built by deliberately choosing the right propagation level for each business operation. Use REQUIRED as your default for cohesive units of work. Employ REQUIRES_NEW with surgical precision for independent, auxiliary tasks like auditing. Consider NESTED for complex, multi-stage operations that require partial rollbacks. And leverage the non-transactional propagation levels to explicitly define boundaries where transactions are not wanted or needed. By mastering these nuances and understanding the underlying proxy-based mechanism, you can build applications that are not only functionally correct but also resilient, performant, and whose transactional behavior is clear, predictable, and maintainable.

Thursday, August 16, 2018

컴파일은 되는데 왜 실행은 안 될까? Maven과 NoClassDefFoundError의 깊은 관계 파헤치기

소프트웨어 개발의 여정에서 우리는 종종 예상치 못한 암초를 만납니다. 특히 Java와 Spring Framework, 그리고 Maven을 함께 사용하는 복잡한 생태계에서는 더욱 그렇습니다. 개발자의 심장을 철렁하게 만드는 수많은 예외와 에러 메시지 중에서도 java.lang.NoClassDefFoundError는 단연 으뜸가는 '배신자'로 꼽힙니다. 아무런 코드 변경 없이, 어제까지 멀쩡하게 실행되던 애플리케이션이 오늘 갑자기 이 에러를 뿜어내며 멈춰 설 때의 당혹감은 겪어보지 않은 사람은 이해하기 어렵습니다.

이 에러의 가장 악랄한 점은 컴파일 시점에는 아무런 문제를 일으키지 않는다는 것입니다. 우리의 똑똑한 IDE(IntelliJ, Eclipse 등)와 Maven 컴파일러는 모든 것이 정상이라고 말합니다. "BUILD SUCCESS"라는 청신호를 보고 안심하며 애플리케이션을 실행하는 순간, 우리는 차갑게 거절당합니다. 이 글은 바로 이 미스터리한 에러, `NoClassDefFoundError`의 본질을 파헤치고, 특히 Maven 프로젝트에서 이 문제가 발생하는 근본적인 원인과 체계적인 해결책, 그리고 나아가 이를 예방하기 위한 고급 전략까지 깊이 있게 다루고자 합니다.

NoClassDefFoundError: 이름 뒤에 숨겨진 진실

문제 해결의 첫걸음은 문제의 정확한 이해입니다. 많은 개발자들이 `NoClassDefFoundError`를 `ClassNotFoundException`과 혼동하곤 하지만, 이 둘은 발생 원인과 시점이 명확히 다른, 전혀 별개의 문제입니다.

결정적 차이점: ClassNotFoundException vs. NoClassDefFoundError

  • ClassNotFoundException (체크 예외, Checked Exception):

    이 예외는 이름 그대로 '클래스를 찾지 못했을 때' 발생합니다. 주로 동적으로 클래스를 로드하려 시도할 때 발생하며, 대표적인 예시로는 Class.forName("com.example.MyClass"), ClassLoader.loadClass() 호출 등이 있습니다. JDBC 드라이버를 로드하거나 리플렉션을 사용할 때 자주 마주칩니다. 이는 런타임에 특정 이름의 클래스 파일(`.class`)을 클래스패스(Classpath) 상에서 능동적으로 찾으려 했으나 실패했음을 의미합니다. 개발자는 이 예외를 `try-catch` 블록으로 반드시 처리해야 하는 체크 예외(Checked Exception)입니다.

  • NoClassDefFoundError (에러, Unchecked Error):

    이것이 오늘 우리의 주인공입니다. 이 에러는 상황이 좀 더 복잡합니다. Java Virtual Machine(JVM)이 특정 클래스를 로드하려고 시도했는데, 컴파일 시점에는 분명히 해당 클래스의 존재를 확인했지만, 정작 런타임에 해당 클래스의 정의를 찾을 수 없을 때 발생합니다. 즉, JVM은 "이 클래스가 필요하다는 건 알고 있는데, 막상 사용하려고 보니 실체가 사라졌네?"라고 말하는 것입니다. 이는 일반적으로 클래스패스가 꼬였거나, 의존성 라이브러리가 누락되었거나, 버전이 충돌할 때 발생하며, 개발자가 직접 처리하기보다는 환경 설정이나 빌드 스크립트의 문제를 해결해야 하는 경우가 대부분입니다.

핵심은 이것입니다: ClassNotFoundException은 '찾으려 했으나 없었다'이고, `NoClassDefFoundError`는 '분명히 있었는데 없어졌다'는 뉘앙스의 차이입니다. 이 차이를 인지하는 것만으로도 문제의 원인에 훨씬 더 가깝게 다가갈 수 있습니다.

Maven의 그림자: 편리함 뒤에 숨은 '전이 의존성'의 함정

그렇다면 왜 이 '있었는데 없어진' 현상은 Maven 프로젝트에서 유독 빈번하게 발생할까요? 해답은 Maven의 가장 강력한 기능이자 동시에 가장 큰 골칫거리인 전이 의존성(Transitive Dependencies)에 있습니다.

전이 의존성이란 무엇인가?

Maven은 `pom.xml` 파일에 우리가 필요로 하는 라이브러리(의존성)를 선언하면, 해당 라이브러리가 필요로 하는 다른 라이브러리들까지 알아서 연쇄적으로 다운로드하여 클래스패스에 포함해 줍니다. 예를 들어, 우리가 'Spring-Web' 라이브러리를 `pom.xml`에 추가했다고 가정해 봅시다.


    org.springframework.boot
    spring-boot-starter-web

이 `spring-boot-starter-web`은 내부적으로 `spring-webmvc`, `spring-web`, `tomcat-embed-core`, `jackson-databind` 등 수많은 다른 라이브러리들을 필요로 합니다. 우리는 단지 `spring-boot-starter-web` 하나만 선언했을 뿐인데, Maven은 이 모든 하위 라이브러리들을 자동으로 클래스패스에 추가해 줍니다. 이것이 바로 전이 의존성입니다. 이 기능 덕분에 개발자는 복잡한 의존성 관계를 일일이 신경 쓰지 않아도 되어 매우 편리합니다.

함정의 시작: 보이지 않는 연결고리

편리함에는 대가가 따르는 법입니다. `NoClassDefFoundError`는 이 보이지 않는 연결고리가 끊어질 때 발생합니다.

다음과 같은 시나리오를 상상해 봅시다.

  1. 내 프로젝트(`MyProject`)는 외부 라이브러리 `A` 버전 1.0에 의존합니다.
  2. 라이브러리 `A` 버전 1.0은 내부적으로 `org.apache.httpcomponents:httpclient` 라이브러리 버전 4.5.2에 의존합니다.
  3. 이 의존성 관계 (`MyProject` → `A:1.0` → `httpclient:4.5.2`) 덕분에, 내 프로젝트에서는 `httpclient` 라이브러리의 클래스(예: `org.apache.http.impl.client.HttpClients`)를 자유롭게 사용할 수 있었습니다. 컴파일도 잘 되고, 실행도 잘 됩니다.
  4. 어느 날, 라이브러리 `A`의 버그 수정 및 기능 개선이 이루어진 버전 1.1이 릴리스되었습니다. 우리는 `pom.xml`에서 `A`의 버전을 1.0에서 1.1로 업데이트했습니다.
  5. 문제 발생! 라이브러리 `A`의 개발자들은 버전 1.1을 만들면서 더 이상 `httpclient`를 사용하지 않거나, 다른 HTTP 클라이언트 라이브러리(예: `OkHttp`)로 교체했습니다.
  6. 이제 의존성 관계는 `MyProject` → `A:1.1` → (더 이상 `httpclient` 없음) 으로 변경되었습니다.

이 상황에서 어떤 일이 벌어질까요?

  • 컴파일 시점: 대부분의 IDE는 Maven 의존성을 캐싱하고 있거나, 다른 라이브러리를 통해 여전히 `httpclient`가 클래스패스에 남아있을 수 있습니다. 혹은 이미 컴파일된 `.class` 파일들이 남아있어 IDE는 문제를 인지하지 못할 수 있습니다. 그래서 컴파일은 여전히 성공합니다.
  • 런타임 시점: Maven이 빌드한 최종 결과물(JAR 또는 WAR)에는 더 이상 `httpclient` 라이브러리가 포함되지 않습니다. 애플리케이션이 실행되다가 `httpclient`의 클래스를 사용하려는 코드에 도달하는 순간, JVM은 해당 클래스의 정의를 찾을 수 없다는 `NoClassDefFoundError`를 발생시키고 즉시 종료됩니다.

이것이 바로 "어제는 됐는데 오늘은 안 되는" 현상의 가장 흔한 원인입니다. 우리는 코드를 한 줄도 바꾸지 않았습니다. 단지 의존성 라이브러리의 버전을 올렸을 뿐인데, 그 라이브러리의 내부 사정이 바뀌면서 내 프로젝트가 간접적으로 의존하던 다른 라이브러리가 사라져 버린 것입니다.

체계적인 문제 해결 가이드: 원인 분석부터 해결까지

이제 `NoClassDefFoundError`의 원흉이 '사라진 전이 의존성'이라는 강력한 심증을 갖게 되었습니다. 이제 탐정처럼 증거를 수집하고 범인을 확정해야 합니다. 다음은 이 문제를 해결하기 위한 체계적인 접근 방법입니다.

1단계: 범인 식별 - 어떤 클래스가 사라졌는가?

가장 먼저 할 일은 에러 로그를 정확히 읽는 것입니다. `NoClassDefFoundError` 메시지에는 사라진 클래스의 전체 패키지 경로(Fully Qualified Class Name)가 명시되어 있습니다.

Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/http/conn/SchemePortResolver
    at com.example.myproject.service.MyHttpService.execute(MyHttpService.java:42)
    ...
Caused by: java.lang.ClassNotFoundException: org.apache.http.conn.SchemePortResolver
    ...

위 로그에서 우리는 `org.apache.http.conn.SchemePortResolver` 라는 클래스가 문제의 원인임을 명확히 알 수 있습니다. 이 클래스 이름이 우리의 첫 번째 단서입니다.

2단계: 용의선상 좁히기 - Maven 의존성 트리 분석

이제 사라진 클래스가 어떤 라이브러리에 속해 있는지, 그리고 그 라이브러리가 왜 클래스패스에서 제외되었는지 추적해야 합니다. 이때 가장 강력한 도구는 Maven의 `dependency:tree` 명령어입니다. 이 명령어는 현재 프로젝트의 모든 의존성 관계를 나무 구조로 시각화하여 보여줍니다.

터미널이나 명령 프롬프트에서 프로젝트의 루트 디렉토리로 이동한 후, 다음 명령어를 실행해 보세요.

mvn dependency:tree

결과가 너무 길다면, 특정 라이브러리를 필터링해서 볼 수 있습니다. 1단계에서 찾은 클래스 이름으로 어떤 라이브러리에 속하는지 유추하거나 검색(예: "SchemePortResolver maven dependency")하여 라이브러리의 `groupId` 또는 `artifactId`를 알아냅니다. `org.apache.http...`로 시작했으니, `httpcomponents`나 `httpclient`와 관련 있을 가능성이 높습니다.

# httpclient 라이브러리가 포함된 경로를 모두 보여줘!
mvn dependency:tree -Dincludes=org.apache.httpcomponents:httpclient

이 명령의 결과는 두 가지 중 하나일 것입니다.

  1. 아무것도 출력되지 않거나, 원하는 버전이 없는 경우: 이는 `httpclient` 라이브러리가 현재 프로젝트의 의존성 그래프에 아예 존재하지 않는다는 의미입니다. 전이 의존성이 사라졌을 가능성이 100%에 가깝습니다.
  2. 원치 않는 버전이 표시되거나 `(omitted for conflict)`가 표시된 경우: 이는 여러 라이브러리가 각기 다른 버전의 `httpclient`를 요구하고 있어서 Maven의 의존성 해결(Dependency Mediation) 규칙에 따라 특정 버전이 선택되고 나머지는 제외되었음을 의미합니다. Maven은 기본적으로 '가장 가까운 정의(Nearest Definition)' 규칙을 따릅니다. 즉, 의존성 트리에서 더 상위에(더 가깝게) 선언된 버전이 선택됩니다. 이로 인해 내가 필요로 하는 클래스가 없는 구버전이 선택되었을 수 있습니다.

이 분석을 통해 우리는 "왜" 클래스가 사라졌는지에 대한 구체적인 증거를 확보할 수 있습니다.

3단계: 범인 검거 및 사건 종결 - 해결책 적용하기

원인 분석이 끝났다면 해결은 비교적 간단합니다. 몇 가지 효과적인 해결책이 있습니다.

해결책 1: 명시적 의존성 선언 (가장 확실하고 직접적인 방법)

전이 의존성의 가장 큰 문제는 '암시적'이라는 점입니다. 이 관계를 '명시적'으로 바꿔주면 문제가 해결됩니다. 즉, 내 프로젝트가 직접 사용하는 라이브러리는 전이 의존성에 기대지 말고, `pom.xml`에 직접, 명시적으로 선언하는 것입니다.

앞선 예시에서 `org.apache.httpcomponents:httpclient`가 문제였다면, `pom.xml`의 `` 섹션에 다음과 같이 직접 추가합니다.


<dependencies>
    ...
    
    <dependency>
        <groupId>org.apache.httpcomponents</groupId>
        <artifactId>httpclient</artifactId>
        <version>4.5.13</version> 
    </dependency>
    ...
</dependencies>

이렇게 하면, 다른 라이브러리가 `httpclient`를 포함하든 안 하든, 내 프로젝트는 항상 명시된 4.5.13 버전을 직접 의존하게 됩니다. 이것은 마치 "다른 사람이 뭘 가져오든 상관없이, 나는 내 가방에 직접 `httpclient` 4.5.13 버전을 챙기겠다"고 선언하는 것과 같습니다. 이는 가장 간단하면서도 확실하게 `NoClassDefFoundError`를 해결하는 방법입니다.

해결책 2: 의존성 범위(Scope) 확인

때로는 라이브러리가 클래스패스에 포함되긴 하지만, 잘못된 `scope`로 선언되어 런타임에 사용할 수 없는 경우가 있습니다. Maven 의존성에는 다음과 같은 주요 `scope`가 있습니다.

  • `compile`: 기본값. 컴파일, 테스트, 런타임 등 모든 클래스패스에 포함됩니다.
  • `provided`: JDK나 WAS(웹 애플리케이션 서버) 같은 컨테이너가 런타임에 제공할 것으로 기대하는 의존성입니다. 컴파일과 테스트 시점에는 사용되지만, 최종 빌드 결과물에는 포함되지 않습니다. (예: `servlet-api`)
  • `runtime`: 컴파일 시점에는 필요 없지만, 실행 시점에 필요한 의존성입니다. (예: JDBC 드라이버 구현체)
  • `test`: 테스트 컴파일 및 실행 시에만 필요한 의존성입니다. (예: `JUnit`, `Mockito`)

만약 `httpclient`가 어떤 이유로든 `<scope>test</scope>`나 `<scope>provided</scope>`로 선언되어 있다면, 실제 애플리케이션을 실행할 때는 클래스패스에서 제외되어 `NoClassDefFoundError`를 유발할 수 있습니다. `mvn dependency:tree` 결과에서 각 의존성의 scope도 함께 확인하는 습관이 중요합니다.

해결책 3: 버전 충돌 해결을 위한 `<dependencyManagement>`

대규모 프로젝트나 마이크로서비스 아키텍처에서는 여러 모듈/서비스에서 동일한 라이브러리의 다른 버전을 사용하려다 충돌이 발생하는 경우가 비일비재합니다. 이때는 `<dependencyManagement>` 태그를 사용하는 것이 매우 효과적인 예방책이자 해결책입니다.

프로젝트의 최상위 `pom.xml` (부모 POM)에 `<dependencyManagement>` 섹션을 만들고, 사용할 라이브러리와 그 버전을 중앙에서 관리할 수 있습니다.


<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>4.5.13</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.13.3</version>
        </dependency>
        ...
    </dependencies>
</dependencyManagement>

이렇게 중앙에서 버전을 선언해두면, 각 하위 모듈의 `pom.xml`에서는 버전 번호 없이 의존성을 선언할 수 있습니다.


<dependencies>
    <dependency>
        <groupId>org.apache.httpcomponents</groupId>
        <artifactId>httpclient</artifactId>
        
    </dependency>
</dependencies>

`<dependencyManagement>`는 일종의 '권장 버전 목록'과 같습니다. 실제로 의존성을 추가하지는 않지만, 만약 어떤 모듈이 해당 의존성을 추가한다면 여기에 명시된 버전을 사용하도록 강제하거나 기본값으로 제공합니다. 이를 통해 프로젝트 전체에 걸쳐 의존성 버전의 일관성을 유지하고, 예기치 않은 버전 충돌로 인한 `NoClassDefFoundError`를 사전에 방지할 수 있습니다.

예방을 위한 고급 전략과 좋은 습관

문제가 터진 뒤에 해결하는 것도 중요하지만, 더 현명한 개발자는 문제가 발생할 소지를 미리 줄입니다.

1. Maven Enforcer 플러그인 활용

`maven-enforcer-plugin`은 빌드 과정에서 특정 규칙을 강제하여 잠재적인 문제를 조기에 발견하도록 도와주는 강력한 도구입니다. 예를 들어, 다음과 같은 규칙을 설정할 수 있습니다.

  • `requireUpperBoundDeps`: 의존성 버전 충돌이 발생했을 때, 항상 더 높은 버전을 사용하도록 강제하여 하위 호환성 문제를 줄입니다.
  • `bannedDependencies`: 보안 취약점이 있거나 사용하지 말아야 할 특정 라이브러리의 사용을 금지합니다.
  • `dependencyConvergence`: 프로젝트 내 모든 모듈이 동일한 버전의 의존성을 사용하도록 강제합니다. 버전 충돌을 근본적으로 막아줍니다.
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-enforcer-plugin</artifactId>
            <version>3.1.0</version>
            <executions>
                <execution>
                    <id>enforce-versions</id>
                    <goals>
                        <goal>enforce</goal>
                    </goals>
                    <configuration>
                        <rules>
                            <dependencyConvergence />
                        </rules>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

이 플러그인을 설정해두면 `mvn clean install` 같은 빌드 명령어 실행 시 규칙 위반이 발견되면 빌드를 즉시 실패시켜 문제를 강제로 인지하게 만듭니다. "런타임에 죽느니 빌드타임에 죽는 게 낫다"는 격언을 실천하는 셈입니다.

2. 로컬 저장소 정리 및 IDE 재동기화

때로는 문제의 원인이 코드나 `pom.xml`이 아니라, 오염된 로컬 Maven 저장소(`.m2/repository`)나 IDE의 캐시 문제일 수 있습니다. 다음과 같은 "리프레시" 작업들을 시도해볼 수 있습니다.

  • IDE의 Maven 프로젝트 새로고침: IntelliJ에서는 Maven 패널의 'Reload All Maven Projects' 버튼을, Eclipse에서는 프로젝트 우클릭 후 'Maven' → 'Update Project'를 실행합니다.
  • 로컬 저장소의 특정 라이브러리 삭제: 문제가 되는 라이브러리(예: `.m2/repository/org/apache/httpcomponents/httpclient`) 폴더를 직접 삭제한 후, 다시 빌드하여 Maven이 깨끗한 상태로 다시 다운로드하게 합니다.
  • 궁극의 방법, 전체 로컬 저장소 삭제: 최후의 수단으로 `.m2/repository` 폴더 전체를 삭제하고 프로젝트를 처음부터 다시 빌드할 수 있습니다. (주의: 인터넷 연결이 필요하며 모든 의존성을 다시 다운로드하므로 시간이 오래 걸릴 수 있습니다.)

결론: Maven과의 건강한 관계 맺기

java.lang.NoClassDefFoundError는 단순한 코딩 실수가 아니라, 우리가 구축하고 있는 소프트웨어의 구조와 환경에 대한 깊은 이해를 요구하는 신호입니다. 특히 Maven과 같은 의존성 관리 도구의 편리함에 익숙해져 그 내부 동작 원리를 간과했을 때 이 에러는 어김없이 찾아옵니다.

이 글에서 우리는 `NoClassDefFoundError`가 `ClassNotFoundException`과 어떻게 다른지, Maven의 전이 의존성이라는 편리한 기능이 어떻게 예기치 않은 함정이 될 수 있는지를 살펴보았습니다. 그리고 `mvn dependency:tree`를 통한 체계적인 원인 분석, 명시적 의존성 선언과 `<dependencyManagement>`를 통한 문제 해결 및 예방 전략까지 구체적으로 다루었습니다.

이제 `NoClassDefFoundError`를 마주했을 때 더 이상 당황하지 마십시오. 그것은 우리를 좌절시키기 위한 에러가 아니라, 우리 프로젝트의 의존성 구조를 더 건강하고 견고하게 만들 기회를 주는 스승과도 같습니다. 의존성을 명시적으로 관리하고, 그 관계를 투명하게 들여다보는 습관을 통해 우리는 이 예측 불가능한 '배신자'를 가장 신뢰할 수 있는 '동료'로 만들 수 있을 것입니다.

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 연동을 넘어, 프로젝트에서 발생하는 모든 종류의 정적 리소스 관련 문제를 해결하는 데 훌륭한 밑거름이 될 것입니다.