[아이티센 부트캠프] 빌더 패턴 & 필터 & 인터셉터

이언덕·2026년 5월 14일

아이티센 부트캠프

목록 보기
94/115
post-thumbnail

Builder Pattern

Builder Pattern은 복잡한 객체 생성 과정을 단계별로 나누고, 어떤 값을 넣었는지 코드에서 잘 보이게 만드는 생성 방식이다.
객체를 만들 때 필요한 값이 많아지면 생성자만으로는 코드가 복잡해진다.
Builder Pattern은 이 문제를 줄이기 위해 사용한다.


여기서 객체는 클래스를 바탕으로 실제로 만들어진 사용 대상이다.
예를 들어 학생 한 명을 표현하려면 번호, 이름, 학년, 전화번호 같은 값이 필요하다.
이 값들을 Student 클래스 구조에 맞게 넣어서 만든 실제 결과가 학생 객체다.


즉, Student 클래스는 학생 객체를 만들기 위한 설계도이고, new Student(...) 또는 Builder를 통해 만들어진 결과물이 실제 학생 객체다.
이 차이를 먼저 알아야 Builder Pattern이 왜 객체 생성 방식인지 이해할 수 있다.


Builder Pattern이란

객체 생성을 단계별로 나누는 방식이다

Builder Pattern은 객체를 만드는 과정을 한 번에 처리하지 않고, 여러 단계로 나누는 방식이다.
객체를 만들 때 필요한 값을 메서드로 하나씩 받은 뒤, 마지막에 build()를 호출해서 최종 객체를 만든다.


여기서 pattern은 자주 반복되는 문제를 해결하기 위해 정리된 설계 방식이라고 이해하면 된다.
Builder Pattern은 여러 설계 방식 중에서도 객체를 만드는 문제를 다루는 생성 패턴에 속한다.
생성 패턴은 객체를 어떤 방식으로 만들지에 집중하는 설계 방식이다.


Builder Pattern의 핵심은 값을 모으는 준비 단계와 실제 객체가 만들어지는 단계를 나누는 것이다.
값을 모으는 과정은 Builder가 맡는다.
실제로 사용할 객체는 마지막 build()가 호출될 때 만들어진다.


이 말은 Builder 자체가 최종 결과물이 아니라는 뜻이다.
Builder는 객체를 만들기 전에 필요한 값을 잠시 담아 두는 준비 도구다.
최종 객체는 build()를 호출해야 만들어진다.


수제 햄버거 주문처럼 값을 선택해서 조립한다

Builder Pattern은 수제 햄버거를 주문하는 상황으로 이해하면 쉽다.
햄버거를 만들 때 빵, 패티, 치즈, 토마토, 소스 같은 재료가 있을 수 있다.
하지만 모든 사람이 같은 재료를 넣는 것은 아니다.


어떤 사람은 치즈를 빼고 싶을 수 있다.
어떤 사람은 토마토를 빼고 싶을 수 있다.
또 어떤 사람은 소스를 추가하고 싶을 수도 있다.


객체 생성도 이와 비슷하다.
어떤 객체는 반드시 들어가야 하는 값이 있고, 상황에 따라 넣어도 되고 안 넣어도 되는 값이 있다.
이런 선택값이 많아지면 생성자만으로 객체를 만들기 어렵고 읽기도 불편해진다.


Builder Pattern은 이런 선택값들을 메서드로 하나씩 받아서 객체를 조립한다.
그래서 어떤 값을 넣었는지 코드에서 바로 확인할 수 있다.


생성자에 값을 한 번에 넣는 방식과 다르다

일반적으로 객체를 만들 때는 생성자를 많이 사용한다.
생성자는 객체가 처음 만들어질 때 필요한 값을 받아서 객체 내부에 저장하는 역할을 한다.


예를 들어 학생 객체를 만들 때 생성자를 사용하면 아래처럼 값을 한 번에 넣는다.

// ConstructorStudentExample.java
Student student = new Student(123456789, "둘리", "Senior", "010-5555-5555"); // 생성자에 값을 순서대로 전달

이 방식은 간단하지만, 값이 많아지면 각 값이 어떤 의미인지 바로 보기 어렵다.
123456789가 학생 번호인지, "Senior"가 학년인지 코드를 읽는 사람이 직접 추측해야 한다.


특히 같은 타입의 값이 여러 개 있으면 더 위험하다.
예를 들어 name, grade, phoneNumber가 모두 문자열이면 순서를 바꿔 넣어도 문법 오류가 나지 않을 수 있다.
하지만 객체 안에는 잘못된 값이 저장된다.

// WrongConstructorOrderExample.java
Student student = new Student(123456789, "Senior", "둘리", "010-5555-5555"); // 이름과 학년 순서가 바뀐 잘못된 예

위 코드는 문법만 보면 실행될 수 있다.
하지만 "Senior"가 name에 들어가고, "둘리"가 grade에 들어갈 수 있다.
이런 문제는 코드가 길어질수록 찾기 어렵다.


반면 Builder Pattern은 아래처럼 값의 이름에 해당하는 메서드를 호출하면서 객체를 만든다.

// StudentBuilderUsageExample.java
Student student = new StudentBuilder() // 값을 담을 Builder 객체 생성
        .id(123456789) // 학생 번호 저장
        .name("둘리") // 이름 저장
        .grade("Senior") // 학년 저장
        .phoneNumber("010-5555-5555") // 전화번호 저장
        .build(); // Student 객체 최종 생성

이 코드는 어떤 값이 어디에 들어가는지 메서드 이름으로 바로 보인다.
값의 순서를 외우는 방식이 아니라, 값의 이름을 보면서 객체를 만드는 방식이다.


기본예제 직접 만든 StudentBuilder로 객체 생성하기

예제에서 만들 객체 구조 먼저 보기

이번 예제의 목표는 학생 한 명을 표현하는 Student 객체를 만드는 것이다.
학생 한 명을 표현하려면 학생 번호, 이름, 학년, 전화번호가 필요하다.


이 예제에서 사용할 값은 다음과 같다.

  • id는 학생 번호를 저장한다.
  • name은 학생 이름을 저장한다.
  • grade는 학생 학년을 저장한다.
  • phoneNumber는 학생 전화번호를 저장한다.

이 값들을 한 번에 생성자에 넣을 수도 있다.
하지만 이번 예제에서는 값을 바로 Student 생성자에 넣지 않고, StudentBuilder에 먼저 하나씩 저장한 뒤 마지막에 Student 객체를 만든다.


전체 코드로 흐름 확인하기

먼저 전체 코드를 보면 Builder Pattern의 구조가 한눈에 보인다.
코드는 크게 세 부분으로 나뉜다.

  • Student 클래스는 최종으로 만들어질 객체의 설계도다.
  • StudentBuilder 클래스는 Student 객체를 만들기 전에 값을 모아 두는 준비 객체다.
  • StudentBuilderExample 클래스는 실제로 Builder를 사용해서 객체를 만들고 출력한다.
// StudentBuilderExample.java
class Student {
    private int id; // 학생 번호
    private String name = "아무개"; // 기본 이름
    private String grade = "freshman"; // 기본 학년
    private String phoneNumber = "010-0000-0000"; // 기본 전화번호
    public Student(int id, String name, String grade, String phoneNumber) {
        this.id = id; // 전달받은 학생 번호 저장
        this.name = name; // 전달받은 이름 저장
        this.grade = grade; // 전달받은 학년 저장
        this.phoneNumber = phoneNumber; // 전달받은 전화번호 저장
    }
    @Override
    public String toString() {
        return "Student { " +
                "id='" + id + '\'' +
                ", name=" + name +
                ", grade=" + grade +
                ", phoneNumber=" + phoneNumber +
                " }"; // 객체 정보를 문자열로 반환
    }
}
class StudentBuilder {
    private int id; // Student에 넣을 학생 번호를 임시 저장
    private String name; // Student에 넣을 이름을 임시 저장
    private String grade; // Student에 넣을 학년을 임시 저장
    private String phoneNumber; // Student에 넣을 전화번호를 임시 저장
    public StudentBuilder id(int id) {
        this.id = id; // Builder에 학생 번호 저장
        return this; // 다음 메서드를 이어서 호출하기 위해 자기 자신 반환
    }
    public StudentBuilder name(String name) {
        this.name = name; // Builder에 이름 저장
        return this; // 다음 메서드를 이어서 호출하기 위해 자기 자신 반환
    }
    public StudentBuilder grade(String grade) {
        this.grade = grade; // Builder에 학년 저장
        return this; // 다음 메서드를 이어서 호출하기 위해 자기 자신 반환
    }
    public StudentBuilder phoneNumber(String phoneNumber) {
        this.phoneNumber = phoneNumber; // Builder에 전화번호 저장
        return this; // 다음 메서드를 이어서 호출하기 위해 자기 자신 반환
    }
    public Student build() {
        return new Student(id, name, grade, phoneNumber); // 모아 둔 값으로 Student 객체 생성
    }
}
public class StudentBuilderExample {
    public static void main(String[] args) {
        Student student = new StudentBuilder() // Builder 객체 생성
                .id(123456789) // 학생 번호 설정
                .name("둘리") // 이름 설정
                .grade("Senior") // 학년 설정
                .phoneNumber("010-5555-5555") // 전화번호 설정
                .build(); // Student 객체 최종 생성
        System.out.println(student); // 생성된 Student 객체 출력
    }
}
// 출력결과
// Student { id='123456789', name=둘리, grade=Senior, phoneNumber=010-5555-5555 }

이 코드는 StudentBuilder에 값을 하나씩 저장한 다음, 마지막에 build()를 호출해서 Student 객체를 생성한다.
출력 결과는 최종적으로 만들어진 Student 객체의 필드 값이 제대로 들어갔다는 것을 보여준다.


Student 클래스는 최종 객체의 설계도다

Student 클래스는 최종적으로 만들어질 학생 객체의 구조를 정의한다.
즉, 학생 객체가 어떤 값을 가질 수 있는지 정해 두는 클래스다.


Student 클래스 안에는 id, name, grade, phoneNumber 필드가 있다.
필드는 객체 안에 저장되는 값이라고 이해하면 된다.


name, grade, phoneNumber에는 기본값이 들어가 있다.
하지만 이 예제에서는 생성자로 "둘리", "Senior", "010-5555-5555" 값이 전달된다.
그래서 기본값은 그대로 사용되지 않고, 생성자로 전달된 값으로 덮어써진다.


다만 이 예제의 StudentBuilder에는 name, grade, phoneNumber의 기본값이 따로 들어가 있지 않다.
그래서 값을 생략한 채 build()를 호출하면 Student의 기본값이 유지되는 것이 아니라 null이 전달될 수 있다.
이번 예제는 모든 값을 직접 설정하는 흐름을 보여주는 예제다.


선택값을 생략해도 기본값을 유지하고 싶다면 StudentBuilder 쪽에도 기본값을 두거나, build() 안에서 기본값을 처리해야 한다.
이 점을 구분해야 Builder Pattern이 선택값을 다룰 수 있다는 말과 이 예제 코드의 실제 동작을 헷갈리지 않는다.


여기서 중요한 점은 Student 클래스가 직접 값을 단계별로 받는 것이 아니라는 점이다.
Student 클래스는 생성자를 통해 한 번에 값을 받는다.
실제로 값을 하나씩 받는 역할은 StudentBuilder가 담당한다.


Student 생성자는 아래 역할을 한다.

  • id 값을 받아 학생 번호 필드에 저장한다.
  • name 값을 받아 이름 필드에 저장한다.
  • grade 값을 받아 학년 필드에 저장한다.
  • phoneNumber 값을 받아 전화번호 필드에 저장한다.

생성자 안에서 사용한 this.id = id는 현재 객체의 id 필드에 매개변수 id 값을 저장한다는 뜻이다.
여기서 매개변수는 메서드나 생성자가 외부에서 전달받는 값을 의미한다.


StudentBuilder 클래스는 값을 임시로 모아 둔다

StudentBuilder 클래스는 최종 객체가 아니다.
Student 객체를 만들기 전에 필요한 값을 임시로 저장하는 준비 객체다.


StudentBuilder 안에도 id, name, grade, phoneNumber 필드가 있다.
이 필드들은 최종 Student 객체의 필드가 아니라, Student를 만들기 전에 잠시 값을 담아 두는 공간이다.


예를 들어 .name("둘리")를 호출하면 바로 Student 객체의 name에 "둘리"가 들어가는 것이 아니다.
먼저 StudentBuilder 안의 name 필드에 "둘리"가 저장된다.
그 뒤 마지막에 build()가 호출되면, StudentBuilder에 모아 둔 값들을 Student 생성자에 전달한다.


Builder는 최종 객체를 바로 만드는 것이 아니라, 최종 객체를 만들기 위한 값을 먼저 모아 두는 역할을 한다.
이 차이를 알아야 build()가 왜 마지막에 필요한지 이해할 수 있다.


return this가 있어야 메서드를 계속 이어 쓸 수 있다

StudentBuilder의 id(), name(), grade(), phoneNumber() 메서드는 모두 마지막에 return this를 한다.
여기서 this는 현재 사용 중인 StudentBuilder 객체 자기 자신을 의미한다.


예를 들어 아래 흐름을 생각하면 된다.

  • new StudentBuilder()로 StudentBuilder 객체를 만든다.
  • .id(123456789)를 호출하면 id 값이 저장된다.
  • id() 메서드는 return this로 같은 StudentBuilder 객체를 다시 돌려준다.
  • 그래서 바로 뒤에 .name("둘리")를 이어서 호출할 수 있다.

즉, return this가 있기 때문에 아래처럼 메서드를 줄줄이 이어 쓸 수 있다.

// MethodChainingFlowExample.java
Student student = new StudentBuilder() // Builder 객체 생성
        .id(123456789) // id 저장 후 같은 Builder 반환
        .name("둘리") // name 저장 후 같은 Builder 반환
        .grade("Senior") // grade 저장 후 같은 Builder 반환
        .phoneNumber("010-5555-5555") // phoneNumber 저장 후 같은 Builder 반환
        .build(); // 모아 둔 값으로 Student 생성

이런 방식을 method chaining이라고 한다.
method chaining은 메서드 호출 결과로 자기 자신을 반환해서, 다음 메서드를 계속 이어서 호출하는 방식이다.


return this가 없으면 .id().name().grade()처럼 이어 쓰는 구조를 만들 수 없다.
그래서 직접 Builder 클래스를 만들 때는 값을 저장한 뒤 return this를 반환하는 구조가 중요하다.


build는 최종 객체를 만드는 마지막 단계다

build()는 Builder Pattern에서 가장 마지막에 호출되는 메서드다.
이 메서드가 호출되기 전까지는 StudentBuilder 안에 값만 모여 있을 뿐, 최종 Student 객체는 만들어지지 않는다.


build() 안에서는 아래 코드가 실행된다.

// StudentBuildMethodExample.java
public Student build() {
    return new Student(id, name, grade, phoneNumber); // Builder에 저장된 값으로 Student 객체 생성
}

이 코드는 StudentBuilder 안에 저장된 id, name, grade, phoneNumber 값을 꺼내서 Student 생성자에 전달한다.
그 결과 실제로 사용할 수 있는 Student 객체가 만들어진다.


정리하면 build()의 역할은 다음과 같다.

  • Builder에 임시 저장된 값을 꺼낸다.
  • 그 값을 Student 생성자에 전달한다.
  • 완성된 Student 객체를 만들어 반환한다.

build()가 반환하는 것은 StudentBuilder가 아니라 Student 객체다.
그래서 Student student = new StudentBuilder()...build();처럼 최종 결과를 Student 타입 변수에 저장할 수 있다.


실행 흐름을 순서대로 정리하기

이 예제의 실행 흐름은 아래 순서로 진행된다.

  • new StudentBuilder()가 실행되어 값을 담을 준비 객체가 만들어진다.
  • .id(123456789)가 실행되어 Builder 안에 학생 번호가 저장된다.
  • .name("둘리")가 실행되어 Builder 안에 이름이 저장된다.
  • .grade("Senior")가 실행되어 Builder 안에 학년이 저장된다.
  • .phoneNumber("010-5555-5555")가 실행되어 Builder 안에 전화번호가 저장된다.
  • .build()가 실행되어 Student 객체가 만들어진다.
  • System.out.println(student)가 실행되어 toString() 결과가 출력된다.

흐름을 한 줄로 정리하면 다음과 같다.


Builder 생성 → 값 하나씩 저장 → build() 호출 → Student 객체 생성 → 출력 확인


이 예제를 통해 알 수 있는 핵심은 분명하다.
Builder Pattern은 값을 단계별로 모은 뒤, 마지막에 완성된 객체를 만들어 반환하는 객체 생성 방식이다.
생성자처럼 값을 한 번에 넣는 방식보다 코드가 길어질 수는 있지만, 어떤 값이 어디에 들어가는지 훨씬 분명하게 보인다.


Builder Pattern이 필요한 이유

객체를 만들 때 값이 많아지면 코드가 헷갈린다

기본예제에서 확인한 것처럼 객체를 만들 때 값이 많아지면 생성자 방식은 읽기 어려워진다.
생성자는 값을 정해진 순서대로 전달하기 때문이다.


특히 String처럼 같은 타입의 값이 여러 개 있을 때는 더 조심해야 한다.
순서가 바뀌어도 문법 오류가 나지 않을 수 있고, 실행 후에 잘못된 데이터가 들어간 것을 뒤늦게 발견할 수 있다.


Builder Pattern은 값의 순서가 아니라 값의 이름을 보면서 객체를 만들 수 있게 해준다.
그래서 객체 생성 코드가 길어져도 어떤 값이 어디에 들어가는지 확인하기 쉽다.


선택값이 많아지면 생성자가 지저분해진다

객체를 만들 때 모든 값이 항상 필요한 것은 아니다.
어떤 값은 반드시 있어야 하고, 어떤 값은 상황에 따라 없어도 된다.


예를 들어 학생 객체에서 id와 name은 꼭 필요할 수 있다.
하지만 phoneNumber는 나중에 입력할 수도 있다.
또 어떤 화면에서는 grade만 필요하고, 다른 화면에서는 phoneNumber까지 필요할 수도 있다.


이런 상황을 생성자로만 처리하려고 하면 생성자를 여러 개 만들어야 할 수 있다.
이를 constructor overloading, 즉 같은 이름의 생성자를 매개변수 구성이 다르게 여러 개 만드는 방식이라고 한다.


생성자가 많아지면 아래 문제가 생긴다.

  • 어떤 생성자를 써야 하는지 헷갈린다.
  • 생성자마다 값의 순서를 다시 확인해야 한다.
  • 필드가 추가될 때 생성자도 같이 수정해야 한다.
  • 코드가 길어지고 유지보수가 어려워진다.

여기서 유지보수는 코드를 나중에 고치거나 기능을 추가하는 작업을 의미한다.
객체 생성 코드가 복잡하면 작은 필드 하나를 추가할 때도 여러 생성자와 호출 코드를 함께 확인해야 한다.


생성자 방식과 Builder 방식은 목적이 다르다

생성자 방식이 항상 나쁜 것은 아니다.
값이 적고 구조가 단순하면 생성자 방식이 더 간단할 수 있다.


하지만 값이 많거나 선택값이 많은 객체라면 Builder Pattern이 더 읽기 쉽다.
특히 객체를 만들 때 어떤 값이 들어가는지 코드에서 바로 드러나야 한다면 Builder Pattern이 유리하다.


정리하면 다음과 같다.

  • 생성자 방식은 값을 정해진 순서대로 한 번에 넣는다.
  • Builder Pattern은 값을 이름이 있는 메서드로 하나씩 넣는다.
  • 생성자 방식은 값이 적을 때 간단하다.
  • Builder Pattern은 값이 많거나 선택값이 많을 때 읽기 쉽다.

Builder Pattern은 생성자를 무조건 대체하는 방식이 아니다.
값이 많거나 선택값이 많아 객체 생성 코드가 복잡해질 때, 객체 생성 흐름을 더 읽기 쉽게 만들기 위해 사용하는 방식이다.
다음 구간에서는 직접 Builder 클래스를 만들지 않고, Lombok의 @Builder를 사용해서 같은 흐름을 더 짧게 작성하는 방법을 확인한다.




Lombok Builder

Lombok Builder는 직접 작성해야 하는 Builder 코드를 Lombok이 대신 만들어 주는 방식이다.
앞에서 직접 만든 StudentBuilder는 구조를 이해하기에는 좋지만, 실제로 매번 직접 작성하기에는 반복 코드가 많다.


Lombok은 반복되는 Java 코드를 애노테이션으로 줄여 주는 라이브러리다.
여기서 애노테이션은 코드 위에 붙여서 특정 기능을 적용하라고 알려 주는 표시라고 이해하면 된다.
Lombok의 @Builder를 사용하면 builder() 메서드, 필드 값을 받는 메서드, 마지막에 객체를 완성하는 build() 메서드가 자동으로 만들어진다.


Lombok Builder는 Builder Pattern의 동작 원리를 없애는 것이 아니라, 반복되는 Builder 작성 코드를 자동으로 줄여 주는 방식이다.
그래서 Lombok Builder를 이해하려면 먼저 직접 만든 Builder의 흐름을 알고 있어야 한다.


Lombok Builder란

직접 Builder 클래스를 만들 때 반복 코드가 많다

직접 Builder 클래스를 만들면 객체 생성 흐름이 눈에 잘 보인다.
하지만 필드가 많아질수록 작성해야 하는 코드도 많아진다.


앞에서 만든 StudentBuilder를 떠올려 보면, id, name, grade, phoneNumber 필드마다 값을 받는 메서드를 직접 작성해야 했다.
각 메서드 안에서는 값을 저장하고, 다음 메서드를 이어서 호출할 수 있도록 return this를 반복해서 작성했다.
마지막에는 모아 둔 값으로 실제 객체를 만드는 build() 메서드도 직접 작성했다.


즉, 직접 만든 Builder는 아래 작업을 개발자가 모두 작성해야 한다.

  • 값을 임시로 저장할 Builder 필드를 만든다.
  • 필드마다 값을 받는 메서드를 만든다.
  • 각 메서드에서 값을 저장한다.
  • 각 메서드에서 return this를 반환한다.
  • 마지막에 build() 메서드로 최종 객체를 생성한다.

이 구조는 이해에는 좋지만, 실무 코드에서 객체마다 매번 직접 작성하면 코드가 길어진다.
필드가 하나 추가되면 Builder 필드와 메서드도 같이 추가해야 한다.
그래서 반복되는 작성 부담이 생긴다.


@Builder가 자동으로 만들어주는 것

Lombok의 @Builder는 이런 반복 코드를 자동으로 만들어 준다.
개발자는 클래스 위에 @Builder를 붙이고, 객체에 필요한 필드만 작성하면 된다.


그러면 컴파일 과정에서 Lombok이 내부적으로 Builder 구조를 만들어 준다.
여기서 컴파일은 사람이 작성한 코드를 컴퓨터가 실행할 수 있는 형태로 바꾸는 과정이라고 이해하면 된다.


@Builder가 만들어 주는 대표적인 코드는 다음과 같다.

  • builder() 메서드
  • 필드명과 같은 설정 메서드
  • 값을 임시로 저장하는 내부 Builder 클래스
  • 마지막에 객체를 만드는 build() 메서드

예를 들어 Person 클래스에 name, age, gender 필드가 있다면 Person.builder()로 시작할 수 있다.
그리고 .name("둘리"), .age("10"), .gender("man")처럼 필드명과 같은 메서드로 값을 넣을 수 있다.
마지막에는 .build()를 호출해서 Person 객체를 만든다.


@Builder를 사용해도 객체 생성 흐름은 직접 만든 Builder와 같다.
달라지는 것은 개발자가 Builder 클래스를 직접 작성하지 않는다는 점이다.


직접 만든 Builder와 Lombok Builder는 같은 흐름을 가진다

직접 만든 Builder와 Lombok Builder는 코드 작성 방식이 다를 뿐, 객체가 만들어지는 흐름은 같다.
둘 다 값을 하나씩 설정하고, 마지막에 build()를 호출해서 최종 객체를 만든다.


직접 만든 Builder는 아래처럼 사용했다.

// ManualStudentBuilderUsageExample.java
Student student = new StudentBuilder() // 직접 만든 Builder 객체 생성
        .id(123456789) // 학생 번호 설정
        .name("둘리") // 이름 설정
        .grade("Senior") // 학년 설정
        .phoneNumber("010-5555-5555") // 전화번호 설정
        .build(); // Student 객체 생성

Lombok Builder도 모양은 거의 같다.
차이점은 new StudentBuilder()처럼 직접 만든 Builder 클래스를 호출하지 않고, 클래스에서 자동으로 제공되는 builder() 메서드로 시작한다는 점이다.

// LombokPersonBuilderUsageExample.java
Person person = Person.builder() // Lombok이 만들어 준 Builder 시작
        .name("둘리") // 이름 설정
        .age("10") // 나이 설정
        .gender("man") // 성별 설정
        .job("가정부") // 직업 설정
        .birthday("1983.04.22") // 생일 설정
        .address("쌍문동") // 주소 설정
        .build(); // Person 객체 생성

이 두 코드는 모두 Builder Pattern의 흐름을 따른다.
값을 한 번에 생성자에 넣는 것이 아니라, 이름이 있는 메서드로 하나씩 넣는다.
그리고 마지막에 build()로 실제 객체를 만든다.


정리하면 직접 만든 Builder와 Lombok Builder의 차이는 다음과 같다.

  • 직접 만든 Builder는 Builder 클래스를 개발자가 직접 작성한다.
  • Lombok Builder는 @Builder가 Builder 클래스를 자동으로 만들어 준다.
  • 직접 만든 Builder와 Lombok Builder 모두 값을 하나씩 설정하고 build()로 객체를 만든다.

이제 실제 Person 예제로 @Builder가 어떤 코드를 줄여 주는지 확인한다.


기본예제 Lombok @Builder로 Person 객체 생성하기

Person 클래스 구조 먼저 보기

이번 예제의 목표는 Lombok의 @Builder를 사용해서 Person 객체를 만드는 것이다.
Person은 사람 한 명의 정보를 담는 객체라고 보면 된다.


이 예제에서 사용할 값은 다음과 같다.

  • name은 이름을 저장한다.
  • age는 나이를 저장한다.
  • gender는 성별을 저장한다.
  • job은 직업을 저장한다.
  • birthday는 생일을 저장한다.
  • address는 주소를 저장한다.

이 예제에서는 age도 String 타입으로 작성한다.
나이로 계산을 하는 예제가 아니라, Builder로 값이 전달되는 흐름을 확인하는 예제이기 때문이다.
실제 프로젝트에서 나이를 숫자 계산에 사용해야 한다면 int 같은 숫자 타입을 사용할 수 있다.


직접 Builder를 만들었다면 이 필드마다 값을 받는 메서드를 직접 작성해야 한다.
하지만 이 예제에서는 @Builder를 사용하기 때문에 별도의 PersonBuilder 클래스를 직접 작성하지 않는다.


전체 코드로 흐름 확인하기

먼저 전체 코드를 보면 Lombok Builder의 사용 흐름이 보인다.
코드는 크게 두 부분으로 나뉜다.

  • Person 클래스는 최종으로 만들어질 객체의 설계도다.
  • LombokPersonBuilderExample 클래스는 Person.builder()로 객체를 만들고 출력한다.
// LombokPersonBuilderExample.java
import lombok.AccessLevel; // 접근 제어 수준을 지정할 때 사용
import lombok.AllArgsConstructor; // 모든 필드를 받는 생성자를 자동 생성
import lombok.Builder; // Builder 코드를 자동 생성
import lombok.ToString; // 객체 정보를 문자열로 출력할 수 있게 생성
@Builder // PersonBuilder와 builder(), build() 흐름 자동 생성
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 모든 필드를 받는 생성자를 private으로 생성
@ToString // 출력할 때 필드 값을 확인할 수 있게 toString 자동 생성
class Person {
    private final String name; // 이름
    private final String age; // 나이
    private final String gender; // 성별
    private final String job; // 직업
    private final String birthday; // 생일
    private final String address; // 주소
}
public class LombokPersonBuilderExample {
    public static void main(String[] args) {
        Person person = Person.builder() // Lombok이 만들어 준 Builder 시작
                .name("둘리") // 이름 설정
                .age("10") // 나이 설정
                .gender("man") // 성별 설정
                .job("가정부") // 직업 설정
                .birthday("1983.04.22") // 생일 설정
                .address("쌍문동") // 주소 설정
                .build(); // Person 객체 최종 생성
        System.out.println(person); // 생성된 Person 객체 출력
    }
}
// 출력결과
// Person(name=둘리, age=10, gender=man, job=가정부, birthday=1983.04.22, address=쌍문동)

이 코드는 Person.builder()로 Builder 흐름을 시작한다.
그 다음 필드명과 같은 메서드로 값을 하나씩 넣고, 마지막에 build()를 호출해서 Person 객체를 만든다.
출력 결과를 보면 Person 객체에 값이 제대로 들어간 것을 확인할 수 있다.


@Builder는 Builder 코드를 자동 생성한다

@Builder는 Builder Pattern에 필요한 반복 코드를 자동으로 만들어 주는 애노테이션이다.
@Builder를 붙이면 개발자가 직접 PersonBuilder 클래스를 만들지 않아도 된다.
Lombok이 내부적으로 PersonBuilder와 비슷한 구조를 만들어 준다.


그래서 아래 코드가 가능해진다.

// PersonBuilderCallExample.java
Person person = Person.builder() // Builder 객체를 얻는다
        .name("둘리") // name 값을 Builder에 저장한다
        .age("10") // age 값을 Builder에 저장한다
        .build(); // 저장된 값으로 Person 객체를 만든다

이 코드에서 Person.builder()는 Builder 객체를 가져오는 시작점이다.
.name("둘리")와 .age("10")은 Builder 안에 값을 저장한다.
마지막 .build()는 모아 둔 값으로 Person 객체를 만든다.


@Builder는 객체 생성 흐름을 바꾸는 것이 아니라, 직접 작성해야 할 Builder 코드를 자동으로 만들어 주는 역할을 한다.
그래서 @Builder를 사용할 때도 Builder가 값을 모으고, build()가 최종 객체를 만든다는 흐름은 그대로 유지된다.


@AllArgsConstructor는 전체 필드를 받는 생성자를 만든다

@AllArgsConstructor는 모든 필드를 매개변수로 받는 생성자를 자동으로 만들어 주는 애노테이션이다.
여기서 AllArgs는 모든 인자를 뜻한다.
인자는 메서드나 생성자에 전달되는 값이라고 이해하면 된다.


Person 클래스에는 name, age, gender, job, birthday, address 필드가 있다.
@AllArgsConstructor를 사용하면 이 모든 값을 받는 생성자가 자동으로 만들어진다.
@Builder는 마지막에 객체를 만들 때 이 전체 필드 생성자를 이용할 수 있다.


예제에서는 아래처럼 작성했다.

// PersonAllArgsConstructorExample.java
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 모든 필드를 받는 생성자를 private으로 생성
class Person {
    private final String name; // 이름
    private final String age; // 나이
    private final String gender; // 성별
    private final String job; // 직업
    private final String birthday; // 생일
    private final String address; // 주소
}

access = AccessLevel.PRIVATE는 생성자의 접근 범위를 private으로 제한한다는 뜻이다.
private은 클래스 바깥에서 직접 사용할 수 없게 막는 접근 제한자다.


이렇게 하면 외부에서 new Person(...)처럼 생성자를 직접 호출하기보다, Person.builder()를 통해 객체를 만들도록 유도할 수 있다.
즉, 객체 생성 방식을 Builder 흐름으로 통일하기 쉬워진다.


@Builder도 결국 생성자를 통해 객체를 만든다

@Builder를 사용하면 new Person(...) 코드를 직접 쓰지 않아도 된다.
그래서 초보자는 @Builder가 생성자 없이 객체를 만드는 것처럼 오해할 수 있다.


하지만 실제 흐름은 그렇지 않다.
Builder가 값을 모은 뒤, 마지막 build() 단계에서 그 값들을 가지고 Person 객체를 만든다.
이때 전체 필드를 받는 생성자가 사용될 수 있다.


정리하면 흐름은 다음과 같다.

  • Person.builder()로 Builder를 가져온다.
  • .name(), .age() 같은 메서드로 값을 저장한다.
  • .build()를 호출한다.
  • build()가 모아 둔 값으로 Person 객체를 만든다.

즉, @Builder는 생성자를 없애는 기능이 아니다.
객체 생성 코드를 직접 생성자 호출 방식으로 쓰지 않게 도와주는 기능이다.


@ToString은 출력 확인을 쉽게 해준다

@ToString은 객체 정보를 문자열로 확인할 수 있게 toString() 메서드를 자동으로 만들어 주는 애노테이션이다.
객체를 출력할 때 내부 값이 제대로 들어갔는지 확인하려면 toString()이 필요하다.


@ToString이 없으면 객체를 출력했을 때 사람이 읽기 어려운 형태가 나올 수 있다.
하지만 @ToString을 사용하면 아래처럼 필드 값이 보이는 형태로 출력된다.

// PersonToStringOutputExample.java
System.out.println(person); // Person 객체의 필드 값을 문자열로 출력
// 출력결과
// Person(name=둘리, age=10, gender=man, job=가정부, birthday=1983.04.22, address=쌍문동)

이 출력 결과를 보면 name, age, gender, job, birthday, address 값이 모두 들어간 것을 확인할 수 있다.
그래서 예제에서는 Builder로 만든 객체가 의도한 값으로 생성되었는지 쉽게 확인할 수 있다.


Lombok Builder가 필요한 이유

반복되는 Builder 코드를 줄일 수 있다

직접 Builder 클래스를 만들면 객체 생성 흐름은 잘 보인다.
하지만 필드가 많아질수록 반복 코드도 많아진다.


예를 들어 Person에 필드가 6개 있으면, 직접 Builder를 만들 때도 6개의 필드를 임시 저장해야 한다.
그리고 각 필드마다 값을 받는 메서드도 만들어야 한다.
각 메서드에서는 값을 저장하고 return this를 반환해야 한다.


Lombok Builder를 사용하면 이런 반복 코드를 직접 작성하지 않아도 된다.
개발자는 클래스에 @Builder를 붙이고, 실제 객체를 만들 때 Person.builder()를 사용하면 된다.


정리하면 Lombok Builder가 줄여 주는 코드는 다음과 같다.

  • 내부 Builder 클래스 작성
  • 필드별 설정 메서드 작성
  • return this 반복 작성
  • build() 메서드 작성

코드가 짧아지면 핵심 필드와 객체 생성 흐름이 더 잘 보인다.
그래서 반복적인 Builder 코드가 많은 상황에서는 Lombok Builder가 편리하다.


코드가 짧아져도 동작 원리는 같다

Lombok Builder를 사용하면 직접 만든 Builder 코드가 눈에 보이지 않는다.
하지만 코드가 사라진 것이 아니라, Lombok이 컴파일 과정에서 대신 만들어 주는 것이다.


따라서 동작 원리는 직접 만든 Builder와 같다.

  • builder()로 Builder 객체를 가져온다.
  • .name(), .age() 같은 메서드로 값을 저장한다.
  • build()로 최종 객체를 만든다.

Lombok Builder는 코드를 짧게 만들어 주지만, Builder Pattern의 기본 흐름은 그대로 유지한다.
그래서 Lombok Builder를 사용할 때도 Builder가 값을 모으고 build()가 객체를 만든다는 구조를 기억해야 한다.


Lombok Builder를 사용할 때 주의할 점

Lombok Builder는 반복 코드를 줄여 주지만, 모든 문제를 자동으로 해결해 주는 것은 아니다.
특히 필수값 검증은 조심해야 한다.


예를 들어 Person 객체를 만들 때 name과 age가 꼭 필요하다고 생각해 보자.
하지만 일반적인 @Builder 사용만으로는 개발자가 name이나 age를 빼고 build()를 호출하는 것을 강하게 막기 어렵다.
값을 넣지 않으면 해당 필드는 null이 될 수 있다.


그래서 필수값이 있는 객체라면 아래 내용을 따로 고민해야 한다.

  • 어떤 값이 반드시 필요한지 정한다.
  • 필수값이 빠졌을 때 예외를 발생시킬지 정한다.
  • 객체가 만들어지기 전에 검증할 방법을 준비한다.

@Builder는 객체 생성 코드를 편하게 만들어 주는 도구다.
하지만 어떤 값이 필수인지, 잘못된 값이 들어오면 어떻게 막을지는 개발자가 직접 설계해야 한다.
다음 구간에서는 이 문제를 해결하기 위해 필수 파라미터를 먼저 받는 Builder 방식을 확인한다.




필수 파라미터 Builder

필수 파라미터 Builder는 객체를 만들 때 반드시 필요한 값을 먼저 받도록 제한하는 방식이다.
일반적인 Lombok Builder는 값을 선택적으로 넣을 수 있어서 편리하지만, 꼭 필요한 값까지 빠뜨릴 수 있다는 문제가 있다.


예를 들어 사람 정보를 담는 Person 객체를 만들 때 이름과 나이는 반드시 필요하다고 생각해 보자.
그런데 일반 @Builder만 사용하면 name이나 age를 넣지 않고도 build()를 호출할 수 있다.
이렇게 되면 객체는 만들어지지만, 중요한 값이 null인 상태가 될 수 있다.


필수 파라미터 Builder의 핵심은 객체 생성 흐름을 시작할 때 반드시 필요한 값을 먼저 받게 만드는 것이다.
필수값을 먼저 받은 뒤, 나머지 선택값은 기존 Builder 방식처럼 메서드 체이닝으로 이어서 설정한다.


필수 파라미터 Builder란

반드시 필요한 값과 선택값을 구분하는 방식이다

객체를 만들 때 모든 값의 중요도가 같은 것은 아니다.
어떤 값은 객체가 의미를 가지려면 반드시 있어야 하고, 어떤 값은 상황에 따라 없어도 된다.


예를 들어 Person 객체에서 name과 age가 필수값이라고 생각해 보자.
이름이 없는 사람 정보나 나이가 없는 사람 정보는 현재 예제에서 완성된 객체로 보기 어렵다.
반면 gender, job, birthday, address는 상황에 따라 나중에 넣거나 생략할 수도 있다.


이처럼 필수값과 선택값을 나누면 객체 생성 흐름이 더 안전해진다.
반드시 필요한 값은 객체 생성 시작 단계에서 먼저 받고, 선택값은 필요할 때만 이어서 설정할 수 있기 때문이다.


정리하면 값의 역할은 아래처럼 나눌 수 있다.

  • name은 반드시 필요한 이름 값이다.
  • age는 반드시 필요한 나이 값이다.
  • gender는 선택적으로 넣을 수 있는 성별 값이다.
  • job은 선택적으로 넣을 수 있는 직업 값이다.
  • birthday는 선택적으로 넣을 수 있는 생일 값이다.
  • address는 선택적으로 넣을 수 있는 주소 값이다.

이렇게 구분해 두면 어떤 값이 없으면 객체를 만들면 안 되는지 더 분명해진다.


일반 @Builder의 한계

일반 @Builder는 객체 생성 코드를 짧고 읽기 쉽게 만들어 준다.
하지만 기본적으로 모든 필드를 선택값처럼 다룰 수 있다.


예를 들어 일반 @Builder만 사용하는 구조라면 아래처럼 name과 age를 빼고도 build()를 호출할 수 있다.

// GeneralBuilderMissingRequiredValueExample.java
Person person = Person.builder() // 일반 Builder 시작
        .gender("woman") // 선택값만 설정
        .job("가정부친구") // 선택값만 설정
        .build(); // name과 age 없이 객체 생성 시도

이 코드는 일반 @Builder만 사용할 때 생길 수 있는 문제를 보여주는 예시다.
문법적으로는 작성될 수 있지만, name과 age가 꼭 필요한 값이라면 문제가 된다.
객체는 만들어졌지만 중요한 값이 빠진 상태가 되기 때문이다.


일반 @Builder는 객체 생성 코드를 편하게 만들어 주지만, 필수값 누락을 자동으로 완벽하게 막아 주지는 않는다.
그래서 필수값이 있는 객체라면 객체를 만들기 전에 검증하는 흐름이 필요하다.


필수값을 먼저 받는 builder 메서드가 필요하다

필수값 누락을 줄이려면 객체 생성 흐름을 시작하는 지점에서 필수값을 먼저 받게 만들면 된다.
이때 사용하는 방식이 builder(String name, String age)처럼 필수값을 매개변수로 받는 builder 메서드다.


여기서 매개변수는 메서드가 외부에서 전달받는 값을 의미한다.
즉, builder(String name, String age)는 builder를 시작하려면 name과 age를 반드시 전달하라는 뜻이다.


기본 builder()는 아무 값 없이 시작할 수 있다.
반면 필수 파라미터를 받는 builder(String name, String age)는 시작할 때부터 필수값을 넣어야 한다.
이번 예제에서는 객체 생성 시작점을 필수값을 먼저 받는 방식으로 바꾸기 위해 builder(String name, String age)를 직접 작성한다.


흐름은 아래와 같다.

  • Person.builder("또치", "10")으로 필수값을 먼저 전달한다.
  • builder 메서드 안에서 name과 age가 null인지 확인한다.
  • 값이 없으면 예외를 발생시켜 객체 생성 흐름을 막는다.
  • 값이 있으면 name과 age가 미리 저장된 Builder 객체를 반환한다.
  • 이후 gender, job, birthday, address 같은 선택값을 이어서 설정한다.

이 구조를 사용하면 필수값과 선택값의 역할이 코드에서 더 분명하게 드러난다.


기본예제 필수 파라미터를 받는 builder 메서드 만들기

Person 클래스 구조 먼저 보기

이번 예제의 목표는 name과 age를 먼저 받은 뒤, 나머지 값을 선택적으로 이어서 설정하는 Person 객체를 만드는 것이다.
Person은 사람 한 명의 정보를 담는 객체다.


이 예제에서 사용할 필드는 다음과 같다.

  • name은 이름을 저장한다.
  • age는 나이를 저장한다.
  • gender는 성별을 저장한다.
  • job은 직업을 저장한다.
  • birthday는 생일을 저장한다.
  • address는 주소를 저장한다.

이 예제에서도 age는 String 타입으로 작성한다.
나이를 계산하는 예제가 아니라, 필수값이 Builder로 먼저 전달되는 흐름을 확인하는 예제이기 때문이다.
실제 프로젝트에서 나이를 계산해야 한다면 int 같은 숫자 타입을 사용할 수 있다.


전체 코드로 흐름 확인하기

먼저 전체 코드를 보면 필수 파라미터 Builder의 구조가 보인다.
코드는 크게 세 부분으로 나뉜다.

  • Person 클래스는 최종으로 만들어질 객체의 설계도다.
  • builder(String name, String age)는 필수값을 먼저 받는 메서드다.
  • RequiredPersonBuilderExample 클래스는 필수값과 선택값을 설정한 뒤 객체를 출력한다.
// RequiredPersonBuilderExample.java
import lombok.AccessLevel; // 접근 제어 수준을 지정할 때 사용
import lombok.AllArgsConstructor; // 모든 필드를 받는 생성자를 자동 생성
import lombok.Builder; // Builder 코드를 자동 생성
import lombok.ToString; // 객체 정보를 문자열로 출력할 수 있게 생성
@Builder // PersonBuilder와 builder(), build() 흐름 자동 생성
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 모든 필드를 받는 생성자를 private으로 생성
@ToString // 출력할 때 필드 값을 확인할 수 있게 toString 자동 생성
class Person {
    private final String name; // 필수값: 이름
    private final String age; // 필수값: 나이
    private final String gender; // 선택값: 성별
    private final String job; // 선택값: 직업
    private final String birthday; // 선택값: 생일
    private final String address; // 선택값: 주소
    public static PersonBuilder builder(String name, String age) {
        if (name == null || age == null) { // 필수값 누락 검사
            throw new IllegalArgumentException("필수 파라미터 누락"); // 잘못된 생성 흐름 중단
        }
        return new PersonBuilder().name(name).age(age); // 필수값이 미리 들어간 Builder 반환
    }
}
public class RequiredPersonBuilderExample {
    public static void main(String[] args) {
        Person person = Person.builder("또치", "10") // 필수값 먼저 전달
                .gender("woman") // 선택값 설정
                .job("가정부친구") // 선택값 설정
                .birthday("1983.xx.xx") // 선택값 설정
                .address("아프리카") // 선택값 설정
                .build(); // Person 객체 최종 생성
        System.out.println(person); // 생성된 Person 객체 출력
    }
}
// 출력결과
// Person(name=또치, age=10, gender=woman, job=가정부친구, birthday=1983.xx.xx, address=아프리카)

이 코드는 Person.builder("또치", "10")으로 필수값을 먼저 전달한다.
그 다음 gender, job, birthday, address를 선택적으로 설정한다.
마지막에 build()를 호출하면 필수값과 선택값이 함께 들어간 Person 객체가 만들어진다.


builder(String name, String age)는 필수값을 먼저 받는다

일반 Lombok Builder는 보통 Person.builder()처럼 아무 값 없이 시작한다.
하지만 이 예제에서는 Person.builder("또치", "10")처럼 name과 age를 먼저 전달한다.


이렇게 하면 객체 생성 흐름을 시작하는 순간 필수값이 들어왔는지 확인할 수 있다.
name과 age가 없으면 Builder를 계속 진행하지 못하게 막을 수 있다.


중요한 코드는 아래 부분이다.

// RequiredBuilderMethodExample.java
public static PersonBuilder builder(String name, String age) {
    if (name == null || age == null) { // name 또는 age가 없으면
        throw new IllegalArgumentException("필수 파라미터 누락"); // 예외 발생
    }
    return new PersonBuilder().name(name).age(age); // 필수값 저장 후 Builder 반환
}

이 메서드의 반환 타입은 PersonBuilder다.
PersonBuilder는 @Builder가 만들어 주는 Builder 클래스라고 이해하면 된다.
개발자가 직접 전체 PersonBuilder 클래스를 작성하지 않아도, @Builder 덕분에 사용할 수 있다.


이 메서드는 Person 객체를 바로 반환하지 않는다.
대신 name과 age가 먼저 들어간 PersonBuilder를 반환한다.
그래야 뒤에서 .gender(), .job(), .birthday(), .address() 같은 선택값 설정을 이어서 할 수 있다.


null 검사를 하는 이유

null은 값이 없다는 뜻이다.
객체를 만들 때 필수값이 null이면 객체는 만들어져도 정상적인 데이터라고 보기 어렵다.


예를 들어 name이 null이면 이름이 없는 사람 객체가 만들어진다.
현재 예제에서는 이름과 나이가 반드시 있어야 한다고 정했기 때문에, 이런 객체가 만들어지기 전에 막아야 한다.


그래서 builder(String name, String age) 안에서 아래 검사를 한다.

// RequiredValueNullCheckExample.java
if (name == null || age == null) { // 둘 중 하나라도 값이 없으면
    throw new IllegalArgumentException("필수 파라미터 누락"); // 객체 생성 흐름 중단
}

여기서 ||는 또는이라는 뜻이다.
즉, name이 null이거나 age가 null이면 조건이 참이 된다.


IllegalArgumentException은 잘못된 인자가 들어왔을 때 발생시키는 예외다.
여기서 인자는 메서드에 전달된 값을 의미한다.
즉, 필수로 넣어야 할 name 또는 age가 잘못 들어왔으니 예외를 발생시키는 것이다.


현재 예제는 null 여부만 검사한다.
만약 빈 문자열인 ""도 막고 싶다면 name.isBlank() 같은 조건을 추가로 검사해야 한다.
이 부분은 필수값을 어디까지 엄격하게 볼지에 따라 달라진다.


필수값 검사는 잘못된 객체가 만들어진 뒤에 문제를 찾는 것이 아니라, 객체가 만들어지기 전에 흐름을 막기 위한 장치다.
이렇게 하면 값이 빠진 객체가 프로그램 안에서 돌아다니는 상황을 줄일 수 있다.


미리 값이 들어간 Builder 객체를 반환한다

필수값 검사가 끝나면 아래 코드가 실행된다.

// RequiredBuilderReturnExample.java
return new PersonBuilder().name(name).age(age); // 필수값을 저장한 Builder 반환

이 코드는 새 PersonBuilder 객체를 만든 뒤, name(name)과 age(age)를 먼저 호출한다.
즉, 반환되는 Builder에는 이미 name과 age가 저장된 상태다.


그렇기 때문에 아래처럼 이어서 선택값을 설정할 수 있다.

// RequiredBuilderChainingExample.java
Person person = Person.builder("또치", "10") // name과 age가 저장된 Builder 반환
        .gender("woman") // 선택값 추가
        .job("가정부친구") // 선택값 추가
        .birthday("1983.xx.xx") // 선택값 추가
        .address("아프리카") // 선택값 추가
        .build(); // 최종 Person 객체 생성

여기서 중요한 점은 Person.builder("또치", "10")의 결과가 최종 Person 객체가 아니라는 점이다.
이 호출의 결과는 필수값이 저장된 PersonBuilder다.
그래서 뒤에 .gender(), .job() 같은 메서드를 계속 이어서 호출할 수 있다.


마지막에 build()를 호출해야 최종 Person 객체가 만들어진다.
이 흐름은 일반 Builder와 같지만, 시작할 때 필수값을 먼저 검사한다는 점이 다르다.


실행 흐름을 순서대로 정리하기

이 예제의 실행 흐름은 아래 순서로 진행된다.

  • Person.builder("또치", "10")가 호출된다.
  • builder(String name, String age)가 name과 age를 먼저 받는다.
  • name 또는 age가 null인지 검사한다.
  • 값이 정상이라면 new PersonBuilder().name(name).age(age)를 반환한다.
  • 반환된 Builder에 gender, job, birthday, address를 이어서 저장한다.
  • build()가 호출되어 최종 Person 객체가 만들어진다.
  • System.out.println(person)가 실행되어 toString() 결과가 출력된다.

흐름을 한 줄로 정리하면 다음과 같다.


필수값 전달 → 필수값 검사 → 필수값이 들어간 Builder 반환 → 선택값 추가 → build() 호출 → Person 객체 생성


이 예제를 통해 알 수 있는 핵심은 분명하다.
필수 파라미터 Builder는 반드시 필요한 값을 먼저 받고, 선택값은 뒤에서 유연하게 이어서 설정하는 방식이다.


필수 파라미터 Builder가 필요한 이유

필수값 누락을 빠르게 막을 수 있다

객체를 만들 때 필수값이 빠진 상태로 객체가 생성되면 나중에 더 큰 문제가 생길 수 있다.
예를 들어 이름이 없는 사람 객체가 만들어지고, 그 객체가 여러 메서드에 전달되면 어디에서 문제가 시작됐는지 찾기 어려워진다.


필수 파라미터 Builder는 객체 생성 시작 단계에서 필수값을 먼저 확인한다.
그래서 값이 잘못 들어오면 바로 예외를 발생시켜 흐름을 멈춘다.


이 방식은 잘못된 객체가 만들어진 뒤에 문제를 찾는 것보다 안전하다.
문제가 생기는 위치가 builder(String name, String age) 안으로 좁혀지기 때문이다.


선택값은 유연하게 받을 수 있다

필수값을 먼저 받는다고 해서 모든 값을 한 번에 넣어야 하는 것은 아니다.
필수값만 먼저 받고, 나머지 선택값은 기존 Builder 방식처럼 이어서 넣을 수 있다.


예를 들어 name과 age는 먼저 넣고, gender, job, birthday, address는 필요한 경우에만 추가한다.
이렇게 하면 필수값의 안전성과 선택값의 유연성을 함께 가져갈 수 있다.


정리하면 다음과 같다.

  • 필수값은 builder(String name, String age)에서 먼저 받는다.
  • 필수값이 없으면 예외를 발생시킨다.
  • 선택값은 메서드 체이닝으로 이어서 설정한다.
  • 마지막에 build()로 최종 객체를 만든다.

필수 파라미터 Builder는 Builder Pattern의 장점인 읽기 쉬운 객체 생성 흐름을 유지하면서, 필수값 누락 문제를 줄이기 위한 방식이다.
다음 구간에서는 객체 생성 방식에서 벗어나, 요청 흐름 앞단에서 공통 작업을 처리하는 Filter를 확인한다.




Filter

Filter는 요청이 Spring MVC의 핵심 흐름으로 들어가기 전후에 공통 작업을 처리하는 기능이다.
여기서 공통 작업은 여러 요청에서 반복해서 필요한 작업을 의미한다.
예를 들어 요청 로그 출력, 인코딩 처리, 보안 관련 검사처럼 여러 Controller에 반복해서 들어갈 수 있는 작업이 여기에 해당한다.


Filter를 이해할 때 가장 중요한 점은 위치다.
Filter는 DispatcherServlet에 요청이 도착하기 전과 응답이 나간 뒤에 동작한다.
그래서 Spring MVC 내부의 Controller보다 앞단에서 요청을 먼저 확인할 수 있다.


Filter란

DispatcherServlet에 도착하기 전후로 동작한다

Filter는 Client의 요청이 DispatcherServlet에 전달되기 전후에 실행된다.
DispatcherServlet은 Spring MVC에서 요청을 가장 먼저 받아 어떤 Controller로 보낼지 결정하는 핵심 객체다.


그런데 Filter는 이 DispatcherServlet보다 먼저 요청을 만난다.
그래서 요청이 Controller로 가기 전에 공통 작업을 처리할 수 있다.
응답이 다시 Client에게 돌아갈 때도 Filter를 다시 지나갈 수 있다.


요청 흐름을 단순하게 보면 다음과 같다.

  • Client가 요청을 보낸다.
  • 요청이 Servlet Container에 들어온다.
  • Filter가 요청을 먼저 처리한다.
  • DispatcherServlet으로 요청이 전달된다.
  • 이후 Controller 흐름으로 이어진다.
  • 응답이 돌아올 때도 Filter를 거쳐 나간다.

Filter는 Tomcat 같은 Servlet Container 영역에서 먼저 동작하고, DispatcherServlet으로 요청이 들어가기 전후에 공통 작업을 처리한다.


Filter는 Spring MVC 흐름보다 앞에서 동작한다

Filter는 Spring MVC의 Controller 흐름보다 앞에서 동작한다.
정확히 말하면 요청이 DispatcherServlet에 들어가기 전에 Servlet Container의 Filter Chain을 먼저 지난다.


여기서 Servlet Container는 Tomcat처럼 Servlet 요청과 응답을 실행하고 관리하는 환경이라고 이해하면 된다.
반대로 Spring Container는 Controller, Service, Repository 같은 Spring 객체를 생성하고 관리하는 영역이다.


이 예제에서는 @Component를 사용하기 때문에 Filter 객체가 Spring Bean으로 등록된다.
하지만 실행 위치는 여전히 DispatcherServlet보다 앞단이다.
즉, Spring이 객체 등록을 도와줄 수는 있지만, 요청 처리 흐름에서 Filter가 실행되는 위치는 Spring MVC 내부 Controller 앞이라고 이해해야 한다.


이 차이를 알아야 Filter와 뒤에서 배울 Interceptor를 구분할 수 있다.
Filter는 DispatcherServlet 앞에서 동작한다.
Interceptor는 DispatcherServlet을 지난 뒤, Controller 실행 전후에 동작한다.


Filter는 Spring Bean으로 등록될 수 있지만, 요청 처리 위치는 DispatcherServlet 앞단이다.
이 부분이 Filter와 Interceptor를 구분할 때 중요하다.


Filter가 처리하기 좋은 작업

Filter는 요청이 Controller에 도착하기 전에 먼저 실행된다.
그래서 여러 요청에 공통으로 필요한 작업을 한 곳에서 처리할 수 있다.


Filter에 어울리는 작업은 다음과 같다.

  • 모든 요청에 대한 로그를 남기는 작업
  • 문자 인코딩을 맞추는 작업
  • 공통 보안 검사
  • 요청이나 응답을 공통으로 가공하는 작업
  • Spring MVC의 Controller 흐름에 들어가기 전에 먼저 처리해야 하는 작업

이런 작업을 모든 Controller에 직접 넣으면 코드가 반복된다.
반복 코드가 많아지면 수정할 때 여러 파일을 고쳐야 한다.
Filter를 사용하면 이런 공통 작업을 한 곳으로 분리할 수 있다.


기본예제 TestFilter1 등록하기

TestFilter1 코드 구조 먼저 보기

이번 예제의 목표는 요청이 들어올 때와 요청 처리가 끝난 뒤에 로그를 출력하는 Filter를 만드는 것이다.


이 예제에서 중요한 코드는 doFilter() 메서드다.
doFilter()는 실제 요청과 응답이 지나가는 핵심 메서드다.
그리고 그 안에 있는 chain.doFilter(request, response)가 다음 단계로 요청을 넘기는 기준점이다.


예제에서 확인할 요소는 다음과 같다.

  • @Component는 Filter 객체를 Spring Bean으로 등록하는 역할을 한다.
  • @Slf4j는 로그를 출력할 수 있게 해준다.
  • @Order는 Filter 실행 순서를 정한다.
  • doFilter()는 요청과 응답이 지나가는 메서드다.
  • chain.doFilter()는 다음 Filter 또는 최종 요청 대상으로 요청을 넘긴다.

Filter는 요청 전 작업과 요청 후 작업을 한 메서드 안에서 나눠 작성할 수 있다.
이때 나누는 기준이 바로 chain.doFilter()다.


전체 코드로 흐름 확인하기

먼저 전체 코드를 보면 Filter가 어떤 구조로 작성되는지 확인할 수 있다.
이 예제는 요청이 들어오면 Filter에서 요청 전 로그를 출력하고, chain.doFilter()로 다음 요청 흐름을 진행한 뒤, 요청 처리가 끝나면 다시 Filter에서 요청 후 로그를 출력한다.


출력 결과까지 흐름이 맞으려면 테스트용 Controller에서도 로그를 하나 출력해야 한다.
그래야 chain.doFilter() 전후로 어떤 일이 일어나는지 정확히 확인할 수 있다.

// TestFilterExample.java
import jakarta.servlet.Filter; // Filter 기능을 구현하기 위한 인터페이스
import jakarta.servlet.FilterChain; // 다음 단계로 요청을 넘기기 위한 객체
import jakarta.servlet.ServletException; // Servlet 처리 중 예외 표현
import jakarta.servlet.ServletRequest; // 요청 정보를 담는 객체
import jakarta.servlet.ServletResponse; // 응답 정보를 담는 객체
import lombok.extern.slf4j.Slf4j; // 로그 출력을 위한 Lombok 애노테이션
import org.springframework.core.annotation.Order; // Filter 실행 순서를 지정
import org.springframework.stereotype.Component; // Spring Bean으로 등록
import java.io.IOException; // 입출력 예외 표현
@Component // Filter를 Spring이 관리하는 객체로 등록
@Slf4j // log.info()를 사용할 수 있게 설정
@Order(2) // Filter가 여러 개일 때 실행 순서 지정
public class TestFilterExample implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        log.info("[필터1] 요청 자원 수행 전"); // 다음 단계로 가기 전 실행
        chain.doFilter(request, response); // 다음 Filter 또는 DispatcherServlet 흐름으로 이동
        log.info("[필터1] 요청 자원 수행 후"); // 요청 처리가 끝난 뒤 실행
    }
}
// TestFilterController.java
import lombok.extern.slf4j.Slf4j; // 로그 출력을 위한 Lombok 애노테이션
import org.springframework.web.bind.annotation.GetMapping; // GET 요청 매핑
import org.springframework.web.bind.annotation.RestController; // REST Controller 등록
@RestController // 문자열 응답을 바로 반환하는 Controller
@Slf4j // Controller에서도 로그 출력
public class TestFilterController {
    @GetMapping("/filter-test") // 테스트용 요청 주소
    public String filterTest() {
        log.info("[컨트롤러] 요청 처리"); // Controller 실행 확인
        return "filter test"; // 응답 본문 반환
    }
}
// 출력결과
// [필터1] 요청 자원 수행 전
// [컨트롤러] 요청 처리
// [필터1] 요청 자원 수행 후

이 코드에서 가장 중요한 부분은 chain.doFilter(request, response)다.
chain.doFilter()가 실행되기 전에는 요청이 다음 단계로 넘어가기 전이다.
chain.doFilter()가 실행되면 다음 Filter 또는 DispatcherServlet 쪽으로 요청이 넘어간다.
그 이후 DispatcherServlet이 요청을 처리할 Controller를 찾고, TestFilterController의 filterTest()가 실행된다.
Controller 처리가 끝나면 다시 chain.doFilter() 다음 줄로 돌아와 요청 후 로그가 출력된다.


@Component로 Filter를 Bean으로 등록한다

@Component는 이 클래스를 Spring이 관리하는 객체로 등록하겠다는 뜻이다.
Spring이 관리하는 객체를 Bean이라고 한다.


여기서 Bean은 개발자가 직접 매번 new로 만들지 않아도, Spring이 대신 생성하고 관리하는 객체라고 이해하면 된다.
Filter 클래스에 @Component를 붙이면 Spring이 이 객체를 등록하고 요청 흐름에서 사용할 수 있게 한다.


다만 Filter의 실행 위치 자체는 Spring MVC 내부 Controller 쪽이 아니라 Servlet Container의 Filter Chain 쪽이다.
그래서 Filter는 Spring Bean으로 등록될 수 있지만, 동작 위치는 DispatcherServlet보다 앞이라고 이해해야 한다.


Filter는 Bean으로 등록될 수 있지만, 요청 처리 위치는 DispatcherServlet 앞단이다.
이 부분이 Filter와 Interceptor를 구분할 때 중요하다.


@Order로 Filter 실행 순서를 정한다

@Order는 여러 Filter가 있을 때 실행 순서를 정하는 애노테이션이다.
요청은 여러 Filter를 순서대로 지나갈 수 있다.
이런 구조를 Filter Chain이라고 한다.


Filter Chain은 말 그대로 Filter들이 사슬처럼 연결되어 있는 구조다.
요청이 들어오면 첫 번째 Filter를 지나고, 그 다음 Filter를 지나고, 마지막으로 실제 요청 대상까지 이동한다.
응답이 돌아올 때는 반대 흐름으로 다시 지나간다.


Filter Chain에서는 요청이 여러 Filter를 순서대로 통과하고, 응답은 다시 반대 방향으로 돌아온다.


예를 들어 보안 검사 Filter, 인코딩 처리 Filter, 로그 출력 Filter가 함께 있다면 어떤 Filter가 먼저 실행될지 정해야 한다.
이때 @Order를 사용해 순서를 지정할 수 있다.


@Order는 숫자가 작을수록 먼저 실행된다.
예를 들어 @Order(1)이 붙은 Filter는 @Order(2)가 붙은 Filter보다 먼저 요청을 처리한다.
응답이 돌아올 때는 요청이 들어간 순서와 반대로 빠져나온다.


doFilter가 실제 요청과 응답을 처리한다

doFilter()는 Filter에서 가장 중요한 메서드다.
요청이 Filter를 지나갈 때 실제로 실행되는 메서드이기 때문이다.


doFilter()는 크게 세 부분으로 나눠서 이해하면 된다.

  • chain.doFilter() 전에 작성한 코드는 요청이 다음 단계로 가기 전에 실행된다.
  • chain.doFilter()는 다음 Filter 또는 최종 요청 대상으로 요청을 넘긴다.
  • chain.doFilter() 뒤에 작성한 코드는 요청 처리가 끝나고 응답이 돌아올 때 실행된다.

예제 코드에서 이 흐름은 아래처럼 볼 수 있다.

// FilterDoFilterFlowExample.java
log.info("[필터1] 요청 자원 수행 전"); // 요청 전 작업
chain.doFilter(request, response); // 다음 단계로 요청 전달
log.info("[필터1] 요청 자원 수행 후"); // 응답이 돌아온 뒤 작업

이 구조 때문에 Filter는 요청 전 작업과 응답 후 작업을 모두 처리할 수 있다.
요청이 들어갈 때 한 번만 실행되는 것이 아니라, chain.doFilter()를 기준으로 앞뒤 흐름을 나눠서 볼 수 있다.


chain.doFilter가 요청 흐름의 기준점이다

chain.doFilter(request, response)는 현재 Filter에서 다음 단계로 요청을 넘기는 코드다.
다음 단계는 다른 Filter일 수도 있고, 마지막 요청 대상인 DispatcherServlet일 수도 있다.


만약 chain.doFilter()를 호출하지 않으면 요청은 다음 단계로 넘어가지 않는다.
즉, Controller까지 도착하지 못할 수 있다.
그래서 일반적인 Filter에서는 특별히 요청을 막아야 하는 상황이 아니라면 chain.doFilter()를 호출해야 한다.


정리하면 chain.doFilter()의 역할은 다음과 같다.

  • 현재 Filter 다음 단계로 요청을 넘긴다.
  • 다음 Filter가 있으면 다음 Filter를 실행한다.
  • 더 이상 Filter가 없으면 DispatcherServlet으로 요청을 보낸다.
  • 이후 응답이 돌아오면 chain.doFilter() 다음 줄부터 다시 실행된다.

chain.doFilter()는 요청을 계속 진행시킬지 결정하는 핵심 코드다.
요청 전후 작업을 나누는 기준이기 때문에 Filter를 이해할 때 반드시 잡아야 한다.


Filter가 필요한 이유

공통 작업을 Controller마다 반복하지 않기 위해 사용한다

웹 애플리케이션에서는 여러 요청에 반복해서 필요한 작업이 많다.
예를 들어 모든 요청에 로그를 남기거나, 요청 문자 인코딩을 맞추거나, 공통 보안 검사를 해야 할 수 있다.


이런 코드를 모든 Controller 메서드에 직접 넣으면 문제가 생긴다.
같은 코드가 여러 곳에 반복되고, 수정할 때도 여러 파일을 고쳐야 한다.
그리고 Controller는 원래 처리해야 할 핵심 기능보다 부가 작업 코드로 복잡해진다.


Filter를 사용하면 이런 공통 작업을 Controller 바깥으로 분리할 수 있다.
그래서 Controller는 실제 요청 처리에 집중하고, 공통 전처리와 후처리는 Filter가 맡게 된다.


Spring MVC의 Controller 흐름에 들어가기 전에 처리해야 하는 작업에 적합하다

Filter는 DispatcherServlet보다 먼저 동작한다.
따라서 요청이 Spring MVC의 Controller로 들어가기 전에 처리해야 하는 작업과 잘 맞는다.


예를 들어 다음과 같은 작업은 Filter에 두기 좋다.

  • 모든 요청에 대한 공통 로그
  • 문자 인코딩 처리
  • 공통 보안 검사
  • 요청 또는 응답의 공통 가공
  • Spring MVC로 들어오기 전 먼저 걸러야 하는 작업

Filter는 요청 흐름의 가장 앞쪽에서 동작한다.
그래서 전체 요청에 넓게 적용할 공통 작업을 처리하는 데 적합하다.
다음 구간에서는 Spring MVC 내부에서 Controller 실행 전후에 동작하는 Interceptor를 확인한다.




Interceptor

Interceptor는 DispatcherServlet을 지난 요청이 Controller에 도착하기 전후에 공통 작업을 처리하는 기능이다.
여기서 Interceptor는 중간에서 가로채는 역할을 한다고 이해하면 된다.
요청이 바로 Controller로 가기 전에 먼저 확인하거나, Controller 실행 후에 공통 처리를 추가할 수 있다.


앞에서 본 Filter가 DispatcherServlet 앞단에서 동작했다면, Interceptor는 DispatcherServlet 이후의 Spring MVC 흐름 안에서 동작한다.
Interceptor는 Controller 실행 전후를 기준으로 공통 작업을 넣고 싶을 때 사용한다.
그래서 로그인 여부 확인, 권한 확인, 특정 URL 요청 검사 같은 작업과 잘 어울린다.


Interceptor란

Controller 호출 전후로 동작한다

Interceptor는 요청이 Controller에 도착하기 전과 Controller가 실행된 후에 동작할 수 있다.
Controller는 실제 요청을 처리하는 메서드를 가진 클래스다.
예를 들어 /interceptor-test 요청이 들어오면 그 요청을 처리할 Controller 메서드가 실행된다.


그런데 Interceptor를 등록해 두면 요청이 Controller로 바로 가지 않는다.
먼저 Interceptor의 preHandle()이 실행되고, 그다음 Controller가 실행된다.
Controller 실행이 끝난 뒤에는 postHandle()과 afterCompletion() 같은 후처리 메서드가 실행될 수 있다.


요청 흐름을 단순하게 보면 다음과 같다.

  • Client가 요청을 보낸다.
  • 요청이 DispatcherServlet에 도착한다.
  • HandlerMapping이 실행할 Controller를 찾는다.
  • 등록된 Interceptor가 먼저 실행된다.
  • Controller가 요청을 처리한다.
  • Controller 실행 후 Interceptor가 다시 후처리한다.
  • 응답이 Client에게 돌아간다.

Interceptor는 DispatcherServlet 이후의 Spring MVC 흐름 안에서 동작하고, Controller 호출 전후와 요청 완료 후에 각각 다른 메서드로 공통 작업을 처리한다.


Interceptor는 Spring 내부에서 동작한다

Interceptor는 Spring MVC 내부 흐름에서 동작한다.
정확히 말하면 요청이 DispatcherServlet에 들어온 뒤, HandlerMapping이 실행할 Controller를 찾은 다음에 Interceptor가 개입한다.


여기서 HandlerMapping은 요청 주소와 실행할 Controller 메서드를 연결해 주는 역할을 한다.
예를 들어 /interceptor-test 요청이 들어오면 이 요청을 어떤 Controller 메서드가 처리해야 하는지 찾아준다.


Filter는 Servlet Container의 Filter Chain에서 먼저 동작했다.
반면 Interceptor는 Spring MVC가 어떤 Controller를 실행할지 찾은 뒤에 동작한다.
그래서 Interceptor는 Spring MVC 흐름에 더 가까운 공통 처리에 적합하다.


Filter는 DispatcherServlet 앞에서 동작하고, Interceptor는 DispatcherServlet 뒤에서 Controller 전후로 동작한다.
이 위치 차이를 알아야 두 기능을 헷갈리지 않는다.


Interceptor가 처리하기 좋은 작업

Interceptor는 Controller 실행 전후에 동작한다.
그래서 특정 요청이 Controller로 들어가기 전에 검사해야 하는 작업과 잘 맞는다.


Interceptor에 어울리는 작업은 다음과 같다.

  • 로그인 여부 확인
  • 권한 확인
  • 특정 URL 요청 검사
  • Controller 실행 전 요청 정보 확인
  • Controller 실행 후 공통 후처리

예를 들어 마이페이지 요청은 로그인한 사용자만 접근해야 한다.
이때 모든 마이페이지 Controller 메서드마다 로그인 확인 코드를 넣으면 코드가 반복된다.
Interceptor를 사용하면 특정 URL 패턴에 대해 로그인 확인을 공통으로 처리할 수 있다.


기본예제 HandlerInterceptor 구조 확인하기

HandlerInterceptor 코드 구조 먼저 보기

Interceptor를 만들려면 HandlerInterceptor를 구현하면 된다.
여기서 구현은 이미 정해진 규칙에 맞춰 필요한 메서드를 작성한다는 뜻이다.


HandlerInterceptor에서 중요한 메서드는 세 가지다.

  • preHandle()은 Controller 실행 전에 동작한다.
  • postHandle()은 Controller 실행 후에 동작한다.
  • afterCompletion()은 요청 처리가 끝난 뒤 동작한다.

이 세 메서드는 실행 시점이 다르다.
그래서 같은 Interceptor 안에서도 요청 전 처리, 요청 후 처리, 최종 마무리 처리를 나눠서 작성할 수 있다.


전체 코드로 구조 확인하기

먼저 Interceptor 클래스 구조를 코드로 확인한다.
이 코드는 Interceptor의 각 메서드가 언제 실행되는지 로그로 확인하기 위한 기본 구조다.
다만 이 클래스만 작성한다고 바로 실행되는 것은 아니다.
Interceptor는 다음 구간에서 WebMvcConfig에 등록해야 실제 요청 흐름에 적용된다.

// TestInterceptorExample.java
import jakarta.servlet.http.HttpServletRequest; // HTTP 요청 정보를 담는 객체
import jakarta.servlet.http.HttpServletResponse; // HTTP 응답 정보를 담는 객체
import lombok.extern.slf4j.Slf4j; // 로그 출력을 위한 Lombok 애노테이션
import org.springframework.lang.Nullable; // null이 들어올 수 있음을 표시
import org.springframework.web.method.HandlerMethod; // Controller 메서드 정보를 담는 객체
import org.springframework.web.servlet.HandlerInterceptor; // Interceptor 기능을 구현하기 위한 인터페이스
import org.springframework.web.servlet.ModelAndView; // View와 Model 정보를 담는 객체
@Slf4j // log.info()를 사용할 수 있게 설정
public class TestInterceptorExample implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        log.info("[인터셉터] preHandle - Controller 실행 전"); // Controller 실행 전 로그
        if (handler instanceof HandlerMethod handlerMethod) { // Controller 메서드인지 확인
            log.info("[인터셉터] 실행할 메서드: {}", handlerMethod.getMethod().getName()); // 실행 메서드 이름 확인
        }
        return true; // true이면 다음 단계로 진행
    }
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
                           @Nullable ModelAndView modelAndView) throws Exception {
        log.info("[인터셉터] postHandle - Controller 실행 후"); // Controller 실행 후 로그
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
                                @Nullable Exception ex) throws Exception {
        log.info("[인터셉터] afterCompletion - 요청 완료 후"); // 요청 완료 후 로그
    }
}

이 코드는 preHandle(), postHandle(), afterCompletion()의 구조를 보여준다.
실제 실행 순서는 Interceptor를 등록한 뒤 요청을 보내야 확인할 수 있다.


preHandle은 Controller 실행 전에 동작한다

preHandle()은 Controller가 호출되기 전에 실행된다.
그래서 요청을 계속 진행할지, 중간에서 막을지를 결정할 수 있다.


preHandle()의 반환 타입은 boolean이다.
boolean은 참 또는 거짓을 표현하는 타입이다.
여기서는 true를 반환하면 다음 단계로 진행하고, false를 반환하면 요청 흐름을 중단한다.


정리하면 preHandle()의 반환값은 아래처럼 이해하면 된다.

  • true를 반환하면 Controller로 요청이 계속 진행된다.
  • false를 반환하면 Controller가 실행되지 않는다.

예를 들어 로그인하지 않은 사용자가 마이페이지에 접근했다면 preHandle()에서 로그인 여부를 확인한 뒤 false를 반환할 수 있다.
그러면 Controller까지 요청이 가지 않게 막을 수 있다.


preHandle()의 세 번째 매개변수인 handler는 실행될 요청 처리 대상을 의미한다.
보통 Controller의 메서드 정보가 들어올 수 있다.
그래서 필요하면 어떤 Controller 메서드가 실행될 예정인지 확인할 수도 있다.


postHandle은 Controller 실행 후에 동작한다

postHandle()은 Controller가 실행된 뒤에 호출된다.
즉, 요청을 처리할 Controller 메서드가 실행된 후에 추가로 처리할 작업이 있을 때 사용할 수 있다.


postHandle()에는 ModelAndView라는 값이 전달될 수 있다.
ModelAndView는 화면에 보여 줄 데이터와 이동할 화면 정보를 함께 담는 객체다.


다만 최근에는 Rest API처럼 JSON 데이터를 바로 반환하는 @RestController를 많이 사용한다.
이 경우에는 화면을 만들기 위한 ModelAndView를 직접 다루는 일이 적다.
그래서 Rest API 중심의 Controller에서는 postHandle()을 자주 사용하지 않을 수도 있다.


또한 Controller나 그 아래 로직에서 예외가 발생하면 postHandle()은 호출되지 않을 수 있다.
그래서 반드시 실행되어야 하는 마무리 작업은 postHandle()보다 afterCompletion()에 두는 것이 더 적합하다.


중요한 점은 postHandle()이 Controller 실행 후 작업이라는 점이다.
요청 전 검사는 preHandle()이 맡고, Controller 실행 후 후처리는 postHandle()이 맡는다고 구분하면 된다.


afterCompletion은 요청이 끝난 뒤 동작한다

afterCompletion()은 요청 처리가 끝난 뒤 실행된다.
이름 그대로 요청 처리의 마지막 단계에 가깝다.


afterCompletion()은 요청 중 사용한 자원을 정리하거나, 최종 로그를 남길 때 사용할 수 있다.
예를 들어 요청 처리 시간이 얼마나 걸렸는지 기록하거나, 예외가 발생했는지 확인하는 작업을 넣을 수 있다.


afterCompletion()의 마지막 매개변수인 Exception ex는 요청 처리 중 발생한 예외 정보를 받을 수 있다.
예외가 없으면 null일 수 있다.
그래서 예외가 발생했을 때만 별도 로그를 남기고 싶다면 ex를 확인하면 된다.


afterCompletion()은 요청 처리의 마지막 정리 작업을 넣기 좋은 메서드다.
preHandle()은 시작 전 검사, postHandle()은 Controller 실행 후 작업, afterCompletion()은 최종 마무리 작업으로 구분하면 된다.


기본예제 WebMvcConfig에서 Interceptor 등록하기

WebMvcConfig 코드 구조 먼저 보기

Interceptor 클래스만 만들어서는 요청 흐름에 자동으로 적용되지 않는다.
어떤 요청에 이 Interceptor를 적용할지 등록해야 한다.


Spring MVC에서는 WebMvcConfigurer를 구현한 설정 클래스에서 addInterceptors() 메서드를 사용해 Interceptor를 등록할 수 있다.
여기서 설정 클래스는 애플리케이션의 동작 방식을 정하는 클래스라고 이해하면 된다.


이번 예제에서는 아래 흐름을 확인한다.

  • 앞에서 만든 TestInterceptorExample을 사용한다.
  • WebMvcConfig에서 Interceptor를 등록한다.
  • 전체 요청에 Interceptor를 적용한다.
  • /interceptor-exclude 요청은 Interceptor 적용 대상에서 제외한다.
  • 테스트용 Controller에서 요청 처리 로그를 출력한다.
  • 로그 순서로 실행 흐름을 확인한다.


전체 코드로 흐름 확인하기

이번에는 실제로 Interceptor가 실행되도록 설정 클래스와 테스트용 Controller를 함께 작성한다.
코드는 파일별로 나누어 보는 것이 좋다.

// WebMvcConfig.java
import org.springframework.context.annotation.Configuration; // 설정 클래스 등록
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; // Interceptor 등록 객체
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; // Spring MVC 설정 확장
@Configuration // Spring 설정 클래스로 등록
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new TestInterceptorExample()) // 등록할 Interceptor 지정
                .addPathPatterns("/**") // 전체 URL에 Interceptor 적용
                .excludePathPatterns("/interceptor-exclude"); // 제외할 URL 지정
    }
}
// InterceptorTestController.java
import lombok.extern.slf4j.Slf4j; // 로그 출력을 위한 Lombok 애노테이션
import org.springframework.web.bind.annotation.GetMapping; // GET 요청 매핑
import org.springframework.web.bind.annotation.RestController; // REST Controller 등록
@RestController // 문자열 응답을 바로 반환하는 Controller
@Slf4j // Controller에서도 로그 출력
public class InterceptorTestController {
    @GetMapping("/interceptor-test") // Interceptor가 적용되는 요청 주소
    public String interceptorTest() {
        log.info("[컨트롤러] 요청 처리"); // Controller 실행 확인
        return "interceptor test"; // 응답 본문 반환
    }
    @GetMapping("/interceptor-exclude") // Interceptor가 제외되는 요청 주소
    public String interceptorExclude() {
        log.info("[컨트롤러] 제외 요청 처리"); // 제외 요청 실행 확인
        return "interceptor exclude"; // 응답 본문 반환
    }
}
// 출력결과
// [인터셉터] preHandle - Controller 실행 전
// [인터셉터] 실행할 메서드: interceptorTest
// [컨트롤러] 요청 처리
// [인터셉터] postHandle - Controller 실행 후
// [인터셉터] afterCompletion - 요청 완료 후
// 제외 URL 요청 출력결과
// [컨트롤러] 제외 요청 처리

첫 번째 출력 결과는 /interceptor-test 요청을 보냈을 때의 흐름이다.
/interceptor-test는 /interceptor-exclude가 아니므로 addPathPatterns("/**")에 의해 Interceptor가 적용된다.
먼저 preHandle()이 실행되고, 그 다음 Controller가 실행된다.
Controller 처리가 끝난 뒤 postHandle()이 실행되고, 마지막으로 afterCompletion()이 실행된다.


두 번째 출력 결과는 /interceptor-exclude 요청을 보냈을 때의 흐름이다.
이 요청은 excludePathPatterns("/interceptor-exclude")에 등록되어 있으므로 Interceptor 로그가 출력되지 않는다.
Controller의 제외 요청 로그만 출력된다.


addInterceptor로 Interceptor 객체를 등록한다

addInterceptor()는 어떤 Interceptor를 요청 흐름에 넣을지 정하는 메서드다.
예제에서는 아래처럼 TestInterceptorExample 객체를 등록했다.

// AddInterceptorExample.java
registry.addInterceptor(new TestInterceptorExample()) // 등록할 Interceptor 지정
        .addPathPatterns("/**"); // 전체 URL에 Interceptor 적용

이 코드는 TestInterceptorExample을 Spring MVC 요청 흐름에 넣겠다는 뜻이다.
이렇게 등록해야 preHandle(), postHandle(), afterCompletion()이 실제 요청에서 실행된다.


Interceptor를 등록하지 않으면 TestInterceptorExample 클래스를 만들어도 요청 흐름에 적용되지 않는다.
즉, 클래스 작성과 등록은 서로 다른 단계다.


addPathPatterns로 적용할 URL을 정한다

addPathPatterns()는 Interceptor를 적용할 요청 주소를 정하는 메서드다.
예제에서는 /**를 사용해서 전체 요청에 Interceptor를 적용했다.


/**는 모든 하위 경로를 포함한다는 뜻이다.
즉, /interceptor-test, /mypage/profile, /admin/users 같은 요청이 모두 적용 대상이 될 수 있다.


이 기능은 특정 영역이나 전체 요청에 공통 작업을 적용할 때 유용하다.
예를 들어 /mypage/** 요청에만 로그인 검사를 적용하거나, /admin/** 요청에만 관리자 권한 검사를 적용할 수 있다.


excludePathPatterns로 제외할 URL을 정한다

excludePathPatterns()는 Interceptor 적용 대상에서 제외할 요청 주소를 정하는 메서드다.
전체 요청에 Interceptor를 적용하되, 일부 요청은 제외하고 싶을 때 사용한다.


예를 들어 로그인 검사를 하는 Interceptor가 있다고 생각해 보자.
이때 로그인 페이지 자체까지 로그인 검사를 적용하면 문제가 생긴다.
로그인하려고 들어갔는데 로그인하지 않았다는 이유로 계속 막힐 수 있기 때문이다.


그래서 로그인 페이지, 회원가입 페이지, 정적 자원 같은 요청은 제외 경로로 등록하는 경우가 많다.
예제에서는 /interceptor-exclude를 제외 경로로 등록했다.


정리하면 경로 설정은 아래처럼 이해하면 된다.

  • addPathPatterns()는 Interceptor를 적용할 주소를 정한다.
  • excludePathPatterns()는 적용 대상 중에서 제외할 주소를 정한다.

이 두 메서드를 함께 사용하면 필요한 요청에만 Interceptor를 적용할 수 있다.


Interceptor가 필요한 이유

Controller에 들어가기 전 요청을 검사할 수 있다

Interceptor의 가장 큰 장점은 Controller 실행 전에 요청을 검사할 수 있다는 점이다.
특히 preHandle()은 Controller가 실행되기 전에 동작하므로, 요청을 계속 진행할지 중단할지 결정하기 좋다.


예를 들어 로그인 확인이 필요한 요청이 있다고 생각해 보자.
로그인하지 않은 사용자가 접근했다면 preHandle()에서 로그인 여부를 확인한 뒤 false를 반환할 수 있다.
그러면 Controller까지 요청이 가지 않게 막을 수 있다.
로그인한 사용자라면 true를 반환해서 Controller로 요청을 계속 진행시킬 수 있다.


이런 코드를 모든 Controller에 직접 작성하지 않아도 된다.
Interceptor를 등록해 두면 특정 URL 패턴에 대해 공통으로 요청 검사를 처리할 수 있다.


URL 단위로 공통 작업을 적용하기 쉽다

Interceptor는 addPathPatterns()와 excludePathPatterns()로 적용할 경로를 정할 수 있다.
그래서 요청 주소 단위로 공통 작업을 적용하거나 제외하기 쉽다.


예를 들어 다음처럼 나눌 수 있다.

  • /mypage/**에는 로그인 확인을 적용한다.
  • /admin/**에는 관리자 권한 확인을 적용한다.
  • /login, /signup, /css/**는 검사 대상에서 제외한다.

이렇게 하면 Controller는 실제 기능 처리에 집중할 수 있다.
로그인 확인, 권한 확인, 공통 로그 같은 부가 작업은 Interceptor에서 관리할 수 있다.


Interceptor는 Spring MVC 흐름 안에서 동작하기 때문에 Controller와 가까운 요청 검사에 적합하다.
다음 구간에서는 앞에서 배운 Filter와 Interceptor를 함께 비교해서, 어떤 작업을 어디에 두는 것이 좋은지 정리한다.




Filter와 Interceptor 차이

Filter와 Interceptor는 둘 다 여러 요청에서 반복되는 공통 작업을 처리할 때 사용한다.
하지만 두 기능은 같은 위치에서 동작하지 않는다.


Filter와 Interceptor의 가장 큰 차이는 요청을 가로채는 위치다.
Filter는 DispatcherServlet에 요청이 들어가기 전에 먼저 동작한다.
Interceptor는 DispatcherServlet을 지난 뒤, Controller가 실행되기 전후에 동작한다.


그래서 둘 다 공통 작업을 처리할 수 있지만, 어떤 작업을 어디에 둘지는 요청 흐름에서의 위치를 기준으로 판단해야 한다.


Filter와 Interceptor 차이 정리

관리되는 컨테이너가 다르다

Filter는 Servlet Container 영역에서 동작한다.
여기서 Servlet Container는 Tomcat처럼 Servlet 요청과 응답을 실행하고 관리하는 환경이다.


Interceptor는 Spring Container 영역에서 동작한다.
Spring Container는 Controller, Service, Repository 같은 Spring 객체를 생성하고 관리하는 영역이다.


즉, Filter는 Spring MVC의 핵심 요청 처리 흐름보다 앞에서 동작하고, Interceptor는 Spring MVC 내부 흐름에서 동작한다.


차이를 표로 보면 다음과 같다.

Filter는 Servlet Container 영역에서 동작하고, Interceptor는 Spring Container 영역에서 동작한다.
둘 다 공통 작업을 처리하지만, 요청을 가로채는 위치와 적합한 작업이 다르다.


Spring 예외 처리 적용 여부가 다르다

Filter는 DispatcherServlet보다 앞에서 동작한다.
그래서 Spring MVC 내부의 예외 처리 흐름과는 거리가 있다.


여기서 Spring MVC의 예외 처리 흐름은 Controller나 Service에서 발생한 예외를 Spring이 정해진 방식으로 처리하는 구조를 의미한다.
예를 들어 @ControllerAdvice나 예외 처리 설정은 보통 Spring MVC 요청 처리 흐름 안에서 동작한다.


반면 Interceptor는 DispatcherServlet 이후의 Spring MVC 흐름 안에서 동작한다.
그래서 Controller 실행 전후 흐름과 더 가깝고, afterCompletion()에서는 요청 처리 중 발생한 예외 정보도 확인할 수 있다.


정리하면 다음과 같다.

  • Filter는 Spring MVC 예외 처리 흐름 바깥쪽에 가깝다.
  • Interceptor는 Spring MVC 흐름 안에서 동작한다.
  • Interceptor의 afterCompletion()은 예외 정보를 확인하는 마무리 작업에 사용할 수 있다.

이 차이 때문에 Filter는 요청이 Spring MVC 내부로 들어가기 전의 넓은 공통 처리에 적합하고, Interceptor는 Controller와 가까운 요청 검사와 후처리에 적합하다.


Request와 Response 객체를 다루는 범위가 다르다

Filter는 ServletRequest와 ServletResponse를 직접 받아 처리한다.
그래서 요청이나 응답 객체를 앞단에서 공통으로 다루는 작업에 적합하다.


예를 들어 요청 문자 인코딩을 맞추거나, 요청과 응답을 감싸서 공통 처리를 하는 작업은 Filter 쪽에서 다루기 좋다.
Filter는 요청이 DispatcherServlet에 들어가기 전부터 요청과 응답을 만지기 때문이다.


Interceptor도 HttpServletRequest와 HttpServletResponse를 매개변수로 받을 수 있다.
하지만 역할의 중심은 요청과 응답 객체 자체를 감싸거나 바꾸는 것보다, Controller 실행 전후에 요청을 검사하고 공통 작업을 넣는 데 있다.


이 단계에서는 아래처럼 이해하면 된다.

  • Filter는 요청과 응답 객체를 더 앞단에서 공통으로 다루는 데 적합하다.
  • Interceptor는 Controller 실행 전후에 요청을 검사하고 흐름을 제어하는 데 적합하다.

그래서 요청 자체를 넓게 가공해야 하면 Filter를 먼저 떠올리고, 특정 Controller 요청을 검사해야 하면 Interceptor를 먼저 떠올리면 된다.


실행 위치가 다르다

요청이 들어오는 순서를 기준으로 보면 Filter가 먼저 실행된다.
요청은 먼저 Servlet Container의 Filter Chain을 지나고, 그다음 DispatcherServlet으로 들어간다.


Interceptor는 DispatcherServlet이 요청을 받은 뒤에 실행된다.
정확히는 HandlerMapping이 어떤 Controller를 실행할지 찾은 뒤, Controller 실행 전후에 Interceptor가 동작한다.


흐름을 단순하게 정리하면 다음과 같다.

  • Client가 요청을 보낸다.
  • Filter가 먼저 요청을 처리한다.
  • DispatcherServlet이 요청을 받는다.
  • HandlerMapping이 실행할 Controller를 찾는다.
  • Interceptor의 preHandle()이 실행된다.
  • Controller가 실행된다.
  • Interceptor의 postHandle()과 afterCompletion()이 실행된다.
  • 응답이 다시 Filter를 거쳐 나간다.

요청이 들어갈 때는 Filter가 Interceptor보다 먼저 실행되고, 응답이 돌아올 때는 Filter의 후처리가 마지막에 실행된다.
이 흐름을 알아야 로그 출력 순서를 보고 어디에서 실행된 코드인지 이해할 수 있다.


사용할 작업이 다르다

Filter는 Spring MVC의 Controller 흐름에 들어가기 전에 처리해야 하는 작업과 잘 맞는다.
모든 요청을 넓게 대상으로 잡고, 요청 자체를 먼저 검사하거나 가공할 때 사용하기 좋다.


Filter에 어울리는 작업은 다음과 같다.

  • 모든 요청에 대한 공통 로그
  • 문자 인코딩 처리
  • 공통 보안 검사
  • 요청 또는 응답 객체의 공통 가공
  • Spring MVC의 Controller 흐름에 들어가기 전에 먼저 걸러야 하는 작업

Interceptor는 Controller와 가까운 위치에서 요청을 검사할 때 사용하기 좋다.
특정 URL 패턴에 대해 로그인 여부나 권한을 검사하고, Controller 실행 전후에 공통 작업을 넣을 수 있다.


Interceptor에 어울리는 작업은 다음과 같다.

  • 로그인 여부 확인
  • 권한 확인
  • 특정 URL 요청 검사
  • Controller 실행 전 요청 정보 확인
  • Controller 실행 후 공통 후처리

정리하면 Filter는 더 앞단의 넓은 공통 처리에 적합하다.
Interceptor는 Spring MVC 내부에서 Controller와 가까운 요청 검사에 적합하다.


기본예제 Filter와 Interceptor 실행 순서 확인하기

예제에서 확인할 흐름 먼저 보기

이번 예제의 목표는 Filter, Interceptor, Controller가 함께 있을 때 어떤 순서로 실행되는지 확인하는 것이다.


이전 구간에서 Filter는 DispatcherServlet 앞에서 동작한다고 정리했다.
그리고 Interceptor는 DispatcherServlet 이후, Controller 실행 전후에 동작한다고 정리했다.


이번 예제에서는 로그를 통해 이 순서를 직접 확인한다.
확인할 흐름은 다음과 같다.

  • Filter 요청 전 로그가 먼저 출력된다.
  • Interceptor의 preHandle() 로그가 출력된다.
  • Controller 로그가 출력된다.
  • Interceptor의 postHandle() 로그가 출력된다.
  • Interceptor의 afterCompletion() 로그가 출력된다.
  • Filter 요청 후 로그가 마지막에 출력된다.

이 순서를 보면 요청이 들어갈 때와 응답이 돌아올 때의 흐름을 함께 이해할 수 있다.


전체 코드로 흐름 확인하기

이번 예제는 Filter, Interceptor, WebMvcConfig, Controller를 함께 사용한다.
코드는 파일별로 나누어 보는 것이 좋다.
그래야 어떤 클래스가 어떤 역할을 하는지 헷갈리지 않는다.

// RequestOrderFilter.java
import jakarta.servlet.Filter; // Filter 기능을 구현하기 위한 인터페이스
import jakarta.servlet.FilterChain; // 다음 단계로 요청을 넘기기 위한 객체
import jakarta.servlet.ServletException; // Servlet 처리 중 예외 표현
import jakarta.servlet.ServletRequest; // 요청 정보를 담는 객체
import jakarta.servlet.ServletResponse; // 응답 정보를 담는 객체
import lombok.extern.slf4j.Slf4j; // 로그 출력을 위한 Lombok 애노테이션
import org.springframework.core.annotation.Order; // Filter 실행 순서 지정
import org.springframework.stereotype.Component; // Spring Bean으로 등록
import java.io.IOException; // 입출력 예외 표현
@Component // Filter 객체 등록
@Slf4j // log.info() 사용
@Order(1) // 숫자가 작을수록 먼저 실행
public class RequestOrderFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        log.info("[필터] 요청 전"); // DispatcherServlet으로 가기 전 실행
        chain.doFilter(request, response); // 다음 Filter 또는 DispatcherServlet 흐름으로 이동
        log.info("[필터] 요청 후"); // 응답이 돌아올 때 실행
    }
}
// RequestOrderInterceptor.java
import jakarta.servlet.http.HttpServletRequest; // HTTP 요청 정보를 담는 객체
import jakarta.servlet.http.HttpServletResponse; // HTTP 응답 정보를 담는 객체
import lombok.extern.slf4j.Slf4j; // 로그 출력을 위한 Lombok 애노테이션
import org.springframework.lang.Nullable; // null 가능 표시
import org.springframework.web.servlet.HandlerInterceptor; // Interceptor 기능 구현
import org.springframework.web.servlet.ModelAndView; // View와 Model 정보
@Slf4j // log.info() 사용
public class RequestOrderInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        log.info("[인터셉터] preHandle"); // Controller 실행 전
        return true; // Controller로 요청 진행
    }
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
                           @Nullable ModelAndView modelAndView) throws Exception {
        log.info("[인터셉터] postHandle"); // Controller 실행 후
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
                                @Nullable Exception ex) throws Exception {
        log.info("[인터셉터] afterCompletion"); // 요청 완료 후
    }
}
// RequestOrderWebMvcConfig.java
import org.springframework.context.annotation.Configuration; // 설정 클래스 등록
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; // Interceptor 등록 객체
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; // Spring MVC 설정 확장
@Configuration // Spring MVC 설정 클래스
public class RequestOrderWebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new RequestOrderInterceptor()) // Interceptor 등록
                .addPathPatterns("/order-test"); // 테스트 URL에만 적용
    }
}
// RequestOrderController.java
import lombok.extern.slf4j.Slf4j; // 로그 출력을 위한 Lombok 애노테이션
import org.springframework.web.bind.annotation.GetMapping; // GET 요청 매핑
import org.springframework.web.bind.annotation.RestController; // REST Controller 등록
@RestController // 문자열 응답을 바로 반환하는 Controller
@Slf4j // Controller에서도 로그 출력
public class RequestOrderController {
    @GetMapping("/order-test") // 실행 순서 확인용 요청 주소
    public String orderTest() {
        log.info("[컨트롤러] 요청 처리"); // Controller 실행 확인
        return "order test"; // 응답 본문 반환
    }
}
// 출력결과
// [필터] 요청 전
// [인터셉터] preHandle
// [컨트롤러] 요청 처리
// [인터셉터] postHandle
// [인터셉터] afterCompletion
// [필터] 요청 후

출력 결과를 보면 Filter의 요청 전 로그가 가장 먼저 출력된다.
그 다음 Interceptor의 preHandle()이 실행되고, 이후 Controller가 요청을 처리한다.
Controller 처리가 끝나면 postHandle()과 afterCompletion()이 실행된다.
마지막으로 응답이 돌아오면서 Filter의 요청 후 로그가 출력된다.


Filter가 먼저 실행되는 이유

Filter가 먼저 실행되는 이유는 요청이 DispatcherServlet에 도착하기 전에 Servlet Container의 Filter Chain을 먼저 지나기 때문이다.


즉, Filter는 Spring MVC가 어떤 Controller를 실행할지 찾기 전 단계에서 동작한다.
그래서 모든 요청에 대해 넓게 공통 처리를 적용하기 좋다.


예제 출력 결과에서도 이 흐름이 보인다.

// FilterFirstOutputExample.java
// [필터] 요청 전
// [인터셉터] preHandle

[필터] 요청 전이 [인터셉터] preHandle보다 먼저 출력된다.
이것은 Filter가 Interceptor보다 앞단에서 실행된다는 뜻이다.


Interceptor가 Controller 전에 실행되는 이유

Interceptor는 DispatcherServlet 이후의 Spring MVC 흐름에서 동작한다.
요청이 들어오면 HandlerMapping이 실행할 Controller를 찾고, 그 뒤에 Interceptor의 preHandle()이 실행된다.


preHandle()이 true를 반환하면 요청은 Controller로 계속 진행된다.
false를 반환하면 Controller는 실행되지 않는다.


예제 출력 결과에서는 아래 순서를 볼 수 있다.

// InterceptorBeforeControllerOutputExample.java
// [인터셉터] preHandle
// [컨트롤러] 요청 처리

[인터셉터] preHandle이 먼저 출력되고, 그 다음 [컨트롤러] 요청 처리가 출력된다.
이것은 Interceptor가 Controller 실행 전에 요청을 검사할 수 있다는 뜻이다.


응답이 돌아올 때 실행 순서가 반대로 보이는 이유

요청이 들어갈 때는 Filter → Interceptor → Controller 순서로 진행된다.
하지만 응답이 돌아올 때는 안쪽에서 바깥쪽으로 빠져나온다.


그래서 Controller 실행 후에는 먼저 Interceptor의 후처리 메서드가 실행된다.
그다음 가장 바깥쪽에 있던 Filter의 chain.doFilter() 다음 줄로 돌아와 요청 후 로그가 출력된다.


예제 출력 결과에서는 아래 순서를 볼 수 있다.

// ResponseReturnOrderExample.java
// [컨트롤러] 요청 처리
// [인터셉터] postHandle
// [인터셉터] afterCompletion
// [필터] 요청 후

Filter의 요청 후 로그가 가장 마지막에 출력되는 이유는 Filter가 요청 흐름의 바깥쪽에 있기 때문이다.
요청이 들어갈 때는 가장 먼저 실행되지만, 응답이 돌아올 때는 가장 마지막에 후처리를 한다.


Filter는 요청 흐름의 바깥쪽에 있고, Interceptor는 Controller에 더 가까운 안쪽에 있다.
그래서 요청이 들어갈 때와 응답이 돌아올 때의 실행 순서가 다르게 보인다.


어떤 작업을 Filter에 두고 어떤 작업을 Interceptor에 둘지 판단하기

Filter에 두기 좋은 작업

Filter는 DispatcherServlet 앞에서 동작한다.
따라서 Spring MVC의 Controller 흐름에 들어가기 전에 처리해야 하는 작업과 잘 맞는다.


Filter에 두기 좋은 작업은 다음과 같다.

  • 모든 요청에 대한 공통 로그
  • 문자 인코딩 처리
  • 공통 보안 검사
  • 요청 또는 응답 객체의 공통 가공
  • Spring MVC의 Controller 흐름에 들어가기 전 먼저 걸러야 하는 작업

예를 들어 요청 문자 인코딩을 맞추는 작업은 특정 Controller에만 필요한 작업이 아니다.
전체 요청에 공통으로 적용되는 작업이다.
이런 경우에는 Filter가 더 적합하다.


Interceptor에 두기 좋은 작업

Interceptor는 DispatcherServlet 이후, Controller 실행 전후에 동작한다.
그래서 특정 Controller 요청과 가까운 공통 검사에 적합하다.


Interceptor에 두기 좋은 작업은 다음과 같다.

  • 로그인 확인
  • 권한 확인
  • 특정 URL 요청 검사
  • Controller 실행 전 요청 정보 확인
  • Controller 실행 후 공통 후처리

예를 들어 마이페이지 요청에 로그인 검사를 적용해야 한다면 Interceptor가 적합하다.
/mypage/** 같은 URL 패턴에만 적용하고, /login이나 /signup 같은 요청은 제외할 수 있기 때문이다.


선택 기준은 요청 흐름에서의 위치다

Filter와 Interceptor 중 무엇을 사용할지는 “어느 위치에서 처리해야 하는 작업인가”를 기준으로 판단하면 된다.


정리하면 다음과 같다.

  • 요청이 Spring MVC의 Controller 흐름에 들어가기 전에 처리해야 하면 Filter가 적합하다.
  • Controller 실행 전후에 처리해야 하면 Interceptor가 적합하다.
  • 전체 요청에 넓게 적용할 작업이면 Filter가 적합하다.
  • 특정 URL 패턴 중심의 검사라면 Interceptor가 적합하다.

Filter와 Interceptor는 경쟁 관계가 아니다.
둘 다 공통 작업을 처리하기 위한 기능이지만, 동작 위치와 사용 목적이 다르다.
이 차이를 이해하면 공통 로직을 더 알맞은 위치에 배치할 수 있다.
다음 구간에서는 이 흐름을 더 넓게 확장해서 Spring MVC 전체 처리 흐름 안에서 Filter, Interceptor, Controller, Service, Repository가 어떻게 이어지는지 정리한다.




Spring MVC 전체 처리 흐름

Spring MVC 전체 처리 흐름은 Client가 보낸 요청이 서버 안에서 어떤 순서로 이동하는지 정리하는 구간이다.
앞에서 Filter와 Interceptor를 따로 봤다면, 이번에는 이 둘을 Controller, Service, Repository 흐름까지 이어서 한 번에 연결한다.


Spring MVC 요청 흐름을 이해하면 내가 작성한 코드가 요청 처리 과정의 어느 위치에서 실행되는지 알 수 있다.
그래서 Filter, Interceptor, Controller, Service, Repository의 역할을 따로 외우는 것이 아니라 하나의 흐름으로 이해할 수 있다.


Spring MVC 전체 처리 흐름이란

요청이 들어오는 전체 순서

Spring MVC에서 요청은 바로 Controller로 들어가지 않는다.
Client가 요청을 보내면 서버 안의 여러 단계를 거쳐서 Controller까지 도착한다.


전체 흐름을 크게 보면 다음과 같다.

  • Client가 HTTP Request를 보낸다.
  • Tomcat 같은 WAS가 요청을 받는다.
  • 요청은 설정에 따라 Filter를 먼저 지날 수 있다.
  • 정적 자원 요청이면 HTML, CSS, JS 같은 파일이 DispatcherServlet까지 가지 않고 처리될 수 있다.
  • Spring MVC가 처리해야 하는 요청이면 DispatcherServlet이 요청을 받는다.
  • HandlerMapping이 실행할 Controller를 찾는다.
  • Interceptor가 Controller 실행 전후에 동작한다.
  • HandlerAdapter가 실제 Controller 메서드를 실행한다.
  • Controller는 필요한 경우 Service를 호출한다.
  • Service는 비즈니스 로직을 처리한다.
  • Repository는 데이터 저장소와 연결되는 역할을 한다.
  • 처리 결과가 다시 응답으로 돌아간다.

요청은 Filter를 거쳐 DispatcherServlet으로 들어가고, 이후 HandlerMapping, Interceptor, HandlerAdapter, Controller, Service, Repository 흐름으로 이어진다.


각 단계의 역할을 나눠서 이해한다

전체 흐름을 처음 보면 복잡해 보일 수 있다.
하지만 각 단계가 맡은 역할을 하나씩 나누면 이해하기 쉽다.


Client는 요청을 보내는 쪽이다.
브라우저, 모바일 앱, 다른 서버 프로그램이 Client가 될 수 있다.


Tomcat은 서버에서 요청을 받는 실행 환경이다.
Spring Boot를 실행하면 내부적으로 Tomcat이 함께 실행되어 웹 요청을 받을 수 있다.


Filter는 DispatcherServlet에 요청이 들어가기 전후에 공통 작업을 처리한다.
요청 로그, 인코딩, 보안 관련 검사처럼 전체 요청에 넓게 적용할 작업에 적합하다.


DispatcherServlet은 Spring MVC 요청 처리의 중심이다.
요청을 직접 처리하는 것이 아니라, 어떤 Controller가 처리해야 하는지 찾고 실행 흐름을 연결한다.


HandlerMapping은 요청 주소와 Controller 메서드를 연결해 준다.
예를 들어 /mvc-flow-test 요청이 들어오면 이 요청을 처리할 메서드를 찾는다.


Interceptor는 Controller 실행 전후에 공통 작업을 처리한다.
로그인 확인, 권한 확인, 특정 URL 검사처럼 Controller와 가까운 요청 검사에 적합하다.


HandlerAdapter는 찾은 Controller 메서드를 실제로 실행해 주는 역할을 한다.
개발자가 예제 코드에서 직접 작성하는 클래스는 아니지만, DispatcherServlet이 찾은 Controller 메서드를 실행할 때 내부적으로 사용된다.
DispatcherServlet이 요청 처리 전체를 조율하고, HandlerAdapter가 실제 실행을 도와준다고 보면 된다.


Controller는 요청을 받아 필요한 처리를 시작하는 진입점이다.
하지만 복잡한 비즈니스 로직을 전부 Controller에 넣지는 않는다.
필요한 처리는 Service에게 넘긴다.


Service는 핵심 비즈니스 로직을 처리한다.
예를 들어 회원 가입, 주문 처리, 게시글 등록 같은 실제 기능 흐름이 여기에 들어간다.


Repository는 데이터 저장소와 연결되는 역할을 한다.
DB에서 데이터를 조회하거나 저장하는 작업을 담당한다.


정리하면 Controller는 요청을 받고, Service는 핵심 로직을 처리하고, Repository는 데이터 저장소와 연결된다.
이 흐름을 알면 각 코드를 어느 위치에 작성해야 하는지도 더 쉽게 판단할 수 있다.


Filter와 Interceptor 위치를 다시 확인한다

Filter와 Interceptor는 둘 다 공통 작업을 처리하지만 위치가 다르다.
이 차이는 전체 흐름 안에서 보면 더 분명해진다.


Filter는 DispatcherServlet 앞에 있다.
그래서 요청이 Spring MVC의 Controller 흐름에 들어가기 전 공통 처리를 맡기 좋다.


Interceptor는 DispatcherServlet 뒤에 있다.
정확히는 HandlerMapping이 실행할 Controller를 찾은 뒤, Controller 실행 전후에 동작한다.


흐름을 짧게 정리하면 다음과 같다.

  • Filter는 DispatcherServlet 앞에서 동작한다.
  • Interceptor는 DispatcherServlet 뒤에서 동작한다.
  • Controller는 실제 요청을 처리하는 진입점이다.
  • Service는 핵심 기능 로직을 처리한다.
  • Repository는 데이터 저장소와 연결된다.

Filter와 Interceptor를 구분할 때는 “어느 위치에서 요청을 가로채는가”를 기준으로 보면 된다.
위치를 기준으로 보면 어떤 공통 작업을 어디에 둬야 하는지도 자연스럽게 정리된다.


기본예제 요청 흐름을 로그로 확인하기

예제 목표

이번 예제의 목표는 요청 하나가 들어왔을 때 Filter, Interceptor, Controller, Service, Repository가 어떤 순서로 실행되는지 로그로 확인하는 것이다.


실제 프로젝트에서는 요청 하나가 들어오면 여러 계층을 거쳐 처리된다.
이 예제에서는 복잡한 DB 연결까지 하지 않고, 각 계층에서 로그를 출력해서 흐름만 확인한다.


확인할 흐름은 다음과 같다.

  • Filter 요청 전 로그가 출력된다.
  • Interceptor의 preHandle() 로그가 출력된다.
  • Controller 로그가 출력된다.
  • Service 로그가 출력된다.
  • Repository 로그가 출력된다.
  • Interceptor의 postHandle() 로그가 출력된다.
  • Interceptor의 afterCompletion() 로그가 출력된다.
  • Filter 요청 후 로그가 출력된다.

이 순서를 보면 요청이 들어갈 때의 흐름과 응답이 돌아올 때의 흐름을 함께 이해할 수 있다.


전체 코드로 흐름 확인하기

이번 예제는 파일을 나누어 보는 것이 좋다.
각 파일이 요청 처리 흐름에서 맡는 위치가 다르기 때문이다.


먼저 Filter는 요청이 DispatcherServlet으로 들어가기 전후에 실행된다.

// MvcFlowFilter.java
import jakarta.servlet.Filter; // Filter 기능을 구현하기 위한 인터페이스
import jakarta.servlet.FilterChain; // 다음 단계로 요청을 넘기는 객체
import jakarta.servlet.ServletException; // Servlet 처리 예외
import jakarta.servlet.ServletRequest; // 요청 정보 객체
import jakarta.servlet.ServletResponse; // 응답 정보 객체
import lombok.extern.slf4j.Slf4j; // 로그 출력용 Lombok 애노테이션
import org.springframework.core.annotation.Order; // Filter 실행 순서 지정
import org.springframework.stereotype.Component; // Spring Bean 등록
import java.io.IOException; // 입출력 예외
@Component // Filter를 Bean으로 등록
@Slf4j // log.info() 사용
@Order(1) // 숫자가 작을수록 먼저 실행
public class MvcFlowFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        log.info("[Filter] 요청 전"); // DispatcherServlet으로 가기 전 실행
        chain.doFilter(request, response); // 다음 단계로 요청 전달
        log.info("[Filter] 요청 후"); // 응답이 돌아올 때 실행
    }
}

다음은 Interceptor다.
Interceptor는 Controller 실행 전후에 동작한다.

// MvcFlowInterceptor.java
import jakarta.servlet.http.HttpServletRequest; // HTTP 요청 정보
import jakarta.servlet.http.HttpServletResponse; // HTTP 응답 정보
import lombok.extern.slf4j.Slf4j; // 로그 출력용 Lombok 애노테이션
import org.springframework.lang.Nullable; // null 가능 표시
import org.springframework.web.servlet.HandlerInterceptor; // Interceptor 구현 인터페이스
import org.springframework.web.servlet.ModelAndView; // Model과 View 정보
@Slf4j // log.info() 사용
public class MvcFlowInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        log.info("[Interceptor] preHandle"); // Controller 실행 전
        return true; // Controller로 요청 진행
    }
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
                           @Nullable ModelAndView modelAndView) throws Exception {
        log.info("[Interceptor] postHandle"); // Controller 실행 후
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
                                @Nullable Exception ex) throws Exception {
        log.info("[Interceptor] afterCompletion"); // 요청 완료 후
    }
}

Interceptor는 클래스만 만든다고 자동으로 적용되지 않는다.
WebMvcConfig에서 어떤 요청에 적용할지 등록해야 한다.

// MvcFlowWebMvcConfig.java
import org.springframework.context.annotation.Configuration; // 설정 클래스 등록
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; // Interceptor 등록 객체
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; // Spring MVC 설정 확장
@Configuration // Spring 설정 클래스
public class MvcFlowWebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new MvcFlowInterceptor()) // Interceptor 등록
                .addPathPatterns("/mvc-flow-test"); // 테스트 URL에만 적용
    }
}

이제 실제 요청을 받는 Controller를 작성한다.
Controller는 요청을 받고, 필요한 처리를 Service에 맡긴다.

// MvcFlowController.java
import lombok.RequiredArgsConstructor; // final 필드 생성자 자동 생성
import lombok.extern.slf4j.Slf4j; // 로그 출력용 Lombok 애노테이션
import org.springframework.web.bind.annotation.GetMapping; // GET 요청 매핑
import org.springframework.web.bind.annotation.RestController; // REST Controller 등록
@RestController // 문자열 응답을 바로 반환
@RequiredArgsConstructor // final 필드 생성자 자동 생성
@Slf4j // log.info() 사용
public class MvcFlowController {
    private final MvcFlowService mvcFlowService; // Service 의존성
    @GetMapping("/mvc-flow-test") // 요청 주소 매핑
    public String mvcFlowTest() {
        log.info("[Controller] 요청 처리"); // Controller 실행 확인
        return mvcFlowService.process(); // Service 호출 후 결과 반환
    }
}

Service는 핵심 처리 흐름을 담당한다.
여기서는 Repository를 호출해서 데이터를 가져오는 흐름만 확인한다.

// MvcFlowService.java
import lombok.RequiredArgsConstructor; // final 필드 생성자 자동 생성
import lombok.extern.slf4j.Slf4j; // 로그 출력용 Lombok 애노테이션
import org.springframework.stereotype.Service; // Service Bean 등록
@Service // Service 계층 Bean 등록
@RequiredArgsConstructor // final 필드 생성자 자동 생성
@Slf4j // log.info() 사용
public class MvcFlowService {
    private final MvcFlowRepository mvcFlowRepository; // Repository 의존성
    public String process() {
        log.info("[Service] 비즈니스 로직 처리"); // Service 실행 확인
        return mvcFlowRepository.findMessage(); // Repository 호출
    }
}

Repository는 데이터 저장소와 연결되는 계층이다.
이 예제에서는 실제 DB 대신 문자열을 반환해서 흐름만 확인한다.

// MvcFlowRepository.java
import lombok.extern.slf4j.Slf4j; // 로그 출력용 Lombok 애노테이션
import org.springframework.stereotype.Repository; // Repository Bean 등록
@Repository // Repository 계층 Bean 등록
@Slf4j // log.info() 사용
public class MvcFlowRepository {
    public String findMessage() {
        log.info("[Repository] 데이터 조회"); // Repository 실행 확인
        return "mvc flow test"; // 테스트 응답 데이터 반환
    }
}
// 출력결과
// [Filter] 요청 전
// [Interceptor] preHandle
// [Controller] 요청 처리
// [Service] 비즈니스 로직 처리
// [Repository] 데이터 조회
// [Interceptor] postHandle
// [Interceptor] afterCompletion
// [Filter] 요청 후

출력 결과를 보면 요청이 어떤 순서로 이동했는지 한눈에 볼 수 있다.
요청이 들어갈 때는 Filter가 먼저 실행되고, 그다음 Interceptor, Controller, Service, Repository 순서로 이어진다.
응답이 돌아올 때는 Interceptor의 후처리 메서드가 실행되고, 마지막에 Filter의 요청 후 로그가 출력된다.


요청이 들어갈 때의 흐름

요청이 들어갈 때는 바깥쪽에서 안쪽으로 들어간다.
가장 먼저 Filter가 실행되고, 그 다음 Spring MVC 내부 흐름으로 들어간다.


예제 출력 결과에서 요청이 들어가는 흐름은 다음과 같다.

// RequestFlowOutputExample.java
// [Filter] 요청 전
// [Interceptor] preHandle
// [Controller] 요청 처리
// [Service] 비즈니스 로직 처리
// [Repository] 데이터 조회

Filter는 DispatcherServlet 앞에서 실행된다.
Interceptor의 preHandle()은 Controller 실행 전에 실행된다.
그 뒤 Controller가 Service를 호출하고, Service가 Repository를 호출한다.


이 흐름을 한 줄로 정리하면 다음과 같다.


Filter → DispatcherServlet → Interceptor → Controller → Service → Repository


응답이 돌아올 때의 흐름

응답이 돌아올 때는 안쪽에서 바깥쪽으로 나온다.
Repository에서 조회한 결과가 Service로 돌아가고, Service 결과가 Controller로 돌아간다.


그 다음 Controller 처리가 끝났으므로 Interceptor의 후처리 메서드가 실행된다.
마지막으로 Filter의 chain.doFilter() 다음 줄로 돌아와 요청 후 로그가 출력된다.


예제 출력 결과에서 응답이 돌아오는 흐름은 다음과 같다.

// ResponseFlowOutputExample.java
// [Interceptor] postHandle
// [Interceptor] afterCompletion
// [Filter] 요청 후

여기서 Filter의 요청 후 로그가 마지막에 찍히는 이유는 Filter가 요청 흐름의 가장 바깥쪽에 있기 때문이다.
들어갈 때는 가장 먼저 실행되지만, 나올 때는 가장 마지막에 실행된다.


전체 흐름 정리

Spring MVC 전체 요청 흐름은 단순히 Controller 하나만 보는 것이 아니다.
요청은 여러 단계를 지나고, 각 단계는 맡은 역할이 다르다.


전체 흐름을 다시 정리하면 다음과 같다.

  • Filter는 DispatcherServlet 앞에서 공통 작업을 처리한다.
  • DispatcherServlet은 요청 처리 흐름을 조율한다.
  • HandlerMapping은 실행할 Controller를 찾는다.
  • Interceptor는 Controller 실행 전후에 공통 작업을 처리한다.
  • HandlerAdapter는 찾은 Controller 메서드를 실행한다.
  • Controller는 요청을 받고 Service를 호출한다.
  • Service는 핵심 비즈니스 로직을 처리한다.
  • Repository는 데이터 저장소와 연결된다.

요청 처리 흐름을 이해하면 공통 로직은 Filter나 Interceptor에 두고, 실제 기능 흐름은 Controller, Service, Repository로 나눠 작성할 수 있다.
이렇게 역할을 나누면 코드가 덜 복잡해지고, 수정할 위치도 더 분명해진다.

0개의 댓글