[아이티센 부트캠프] Spring Boot 1

이언덕·2026년 4월 20일

아이티센 부트캠프

목록 보기
62/115
post-thumbnail

스프링과 스프링 부트의 큰 그림

Spring을 처음 볼 때 가장 먼저 잡아야 하는 것은 복잡한 문법이 아니다.
왜 이런 프레임워크가 필요한지, 그리고 왜 Spring Boot가 따로 나왔는지를 먼저 이해해야 한다.
그래야 뒤에서 배우는 IoC, DI, AOP, MVC도 각각 따로 떨어진 개념이 아니라 하나의 흐름으로 이어서 볼 수 있다.


자바만으로도 프로그램은 만들 수 있다.
하지만 프로그램이 커지면 필요한 객체를 직접 만들고 연결하는 코드가 많아지고, 웹 요청을 처리하는 흐름도 복잡해지고, 로깅, 보안, 트랜잭션처럼 여러 곳에서 반복되는 기능도 코드 곳곳에 섞이기 쉽다.
이렇게 되면 한 곳을 고칠 때 다른 곳도 같이 건드려야 하는 일이 많아지고, 코드끼리 강하게 묶여서 관리가 어려워진다.
Spring은 이런 복잡함을 정해진 구조 안에서 정리하게 해 주는 프레임워크라고 이해하면 된다.


먼저 잡아야 할 질문은 세 가지이다.
객체를 누가 만들고 관리하는가, 웹 요청을 어떤 흐름으로 처리하는가, 반복되는 공통 기능을 어떻게 분리하는가이다.
뒤에서 배우는 핵심 개념들은 결국 이 세 가지 질문에 대한 답이다.


스프링이 해결하려는 문제

왜 일반 자바 코드만으로는 점점 복잡해지는가

프로그램이 아주 작을 때는 필요한 객체를 new로 직접 만들고 메서드를 호출해도 큰 문제가 없다.
하지만 기능이 많아지면 어떤 객체가 어떤 객체를 필요로 하는지 관계가 점점 복잡해진다.
한 클래스를 고치면 그 클래스를 사용하던 다른 클래스도 함께 수정해야 하는 일이 자주 생긴다.


웹 프로그램에서는 더 복잡해진다.
브라우저에서 요청이 들어오고, 서버가 그것을 처리하고, 결과를 다시 응답으로 보내는 흐름이 일정한 규칙 없이 흩어지기 쉽다.
여기에 로깅, 보안, 트랜잭션 같은 공통 기능까지 섞이면 핵심 기능과 부가 기능의 경계도 흐려진다.
즉, 문제는 단순히 코드가 길어진다는 것이 아니라 역할이 섞이고, 연결이 복잡해지고, 중복이 늘어난다는 데 있다.


Spring은 무엇을 정리해 주는가

  • 객체를 누가 만들고 관리할지 정리해 준다.
  • 웹 요청이 어디로 들어가고 어떻게 처리될지 구조를 잡아 준다.
  • 여러 곳에서 반복되는 공통 기능을 따로 분리하게 도와준다.

이 세 가지가 뒤에서 배우는 IoC, DI, AOP, MVC로 이어진다.
즉, 각각의 개념이 따로 존재하는 것이 아니라 모두 복잡한 프로그램을 역할별로 나누어 정리하려는 흐름 안에 들어 있다.
그래서 이 큰 그림을 먼저 이해하면 뒤의 세부 개념도 훨씬 덜 낯설다.


Spring, Spring Framework, Spring Boot는 같은 말이 아니다

처음 배우는 단계에서는 이 세 단어가 자주 섞여 보여서 헷갈리기 쉽다.
하지만 완전히 같은 뜻으로 보면 안 된다.


먼저 Spring은 큰 생태계 이름에 가깝다.
즉, Spring Framework, Spring Boot, Spring Data, Spring Security처럼 여러 프로젝트를 아우르는 넓은 이름으로 볼 수 있다.


그중 Spring Framework는 핵심 구조를 제공하는 중심 도구이다.
객체를 관리하고, 웹 요청을 처리하고, 공통 기능을 분리하는 기본 뼈대가 여기에 들어 있다.
즉, 우리가 IoC, DI, AOP, MVC를 배울 때 실제로 중심이 되는 것은 Spring Framework이다.


그리고 Spring Boot는 그 Spring Framework를 더 쉽게 시작하고 실행하게 해 주는 도구이다.
즉, Spring Boot는 Spring을 대신하는 완전히 다른 프레임워크가 아니라, 기존 Spring 구조를 더 편하게 쓰게 해 주는 도구이다.


스프링 프레임워크의 모듈 구성

스프링은 하나의 기능이 아니라 역할별 모듈 구조이다

Spring은 하나의 단일 기능이 아니라 여러 모듈이 역할별로 나뉜 구조이다.
그래서 처음에는 모듈 이름을 모두 외우려 하기보다, 어떤 역할을 맡는 묶음들이 있는지만 보는 편이 훨씬 좋다.
이 단계에서 중요한 것은 이름 암기가 아니라, Spring이 한 덩어리 도구가 아니라는 점을 이해하는 것이다.

Spring Core, Spring Context를 바닥으로 두고, 그 위에 웹 처리, 보안, AOP, 데이터 접근 같은 기능이 역할별로 나뉘어 올라가는 구조를 보여 준다.
즉, Spring은 하나의 거대한 기능이 아니라 필요한 역할을 층층이 나누어 둔 구조이다.


초보자 기준에서 각 모듈은 이렇게 이해하면 된다

  • Spring Core, Spring Context는 객체 관리의 중심 바닥이다.
  • Spring Web MVC는 웹 요청과 응답 흐름을 처리하는 구조이다.
  • Spring AOP는 공통 기능을 분리하는 구조와 연결된다.
  • 데이터 접근 관련 기술은 데이터베이스를 더 편하게 다루는 데 연결된다.
  • Spring Security는 인증과 권한 같은 보안 처리와 연결된다.

여기서는 세부 구현을 다 아는 것이 목적이 아니다.
처음에는 이름을 외우려 하지 말고, 객체 관리, 웹 처리, 보안, 데이터 접근, 공통 기능 분리처럼 역할만 잡아도 충분하다.
즉, 스프링은 역할별 기능이 나뉜 구조이고, 그 중심에는 객체 관리와 웹 처리 구조가 있다.
이렇게 보면 스프링이 왜 기능이 많아 보이는지도 자연스럽게 이해된다.


스프링 부트가 필요한 이유

Spring이 좋은데도 Spring Boot가 따로 필요한 이유

Spring Framework는 이미 객체 관리, 웹 처리, AOP 같은 강력한 구조를 제공한다.
하지만 기능이 많은 만큼 프로젝트를 처음 시작할 때 준비해야 할 설정도 많아진다.
라이브러리 버전을 맞추고, 실행 환경을 잡고, 서버와 관련된 설정을 준비하는 과정이 초보자에게는 꽤 부담이 된다.


처음 배우는 입장에서는 여기서 막히기 쉽다.
분명 구조는 좋은데, 막상 프로젝트를 시작하려고 하면 설정 파일이 많고 준비할 것이 많아서 진입 장벽이 높게 느껴진다.
즉, 문제는 Spring이 나빠서가 아니라 좋은 구조를 쓰기까지의 준비 과정이 꽤 무겁다는 데 있다.


기존 Spring에 무엇이 더해지는가

Spring은 IoC, DI, AOP, MVC 같은 강력한 구조를 제공했지만, 설정 복잡성, 높은 초기 학습 난이도, 의존성 관리, 별도 배포 서버 구성 같은 불편도 함께 있었다.
즉, Spring Boot는 Spring의 핵심 구조를 버린 것이 아니라, 이런 불편을 줄여 더 쉽게 시작하고 실행하게 만든 도구이다.


처음 Spring만 볼 때는 기능이 많고 강력하다는 점이 먼저 보인다.
하지만 실제 프로젝트를 시작하려고 하면 설정할 것이 많고, 라이브러리 버전을 맞추는 일도 쉽지 않다.
여기에 실행을 위한 서버 환경까지 따로 준비해야 하면 초보자 입장에서는 진입 장벽이 더 높아진다.
Spring Boot는 바로 이 시작 단계의 부담을 줄여 주기 위해 나온 것이다.


Spring Framework에 Tomcat, Jetty 같은 실행 환경이 더해지고, 기존 XML 설정이나 @Configuration 부담이 줄어들어 Spring Boot로 이어지는 흐름을 보여 준다.
즉, 복잡한 준비 과정을 줄이고 애플리케이션을 더 빨리 실행할 수 있게 해 주는 방향으로 이해하면 된다.


Spring의 IoC, MVC 구조에 Spring Boot의 설정 자동화와 내장 WAS가 더해져 더 편리한 개발 환경이 되는 흐름을 보여 준다.
즉, Spring Boot는 기존 구조를 버리는 것이 아니라, 기존 구조를 더 쉽게 쓰게 만드는 도구이다.


준비를 쉽게 해 주는 기능

  • starter를 통해 필요한 라이브러리 묶음을 한 번에 가져오기 쉽다.
  • 자동 설정을 통해 기본 실행 환경을 빠르게 준비할 수 있다.

여기서 starter는 중요하다.
처음에는 이 말도 낯설 수 있다.
쉽게 말하면 자주 같이 쓰는 라이브러리들을 미리 묶어 둔 꾸러미에 가깝다.
예를 들어 웹 개발에 필요한 것들을 하나하나 직접 고르지 않고, 필요한 묶음을 한 번에 가져올 수 있게 도와준다.
그래서 준비 단계에서 실수할 수 있는 부분을 줄여 준다.


자동 설정도 같은 맥락이다.
처음부터 모든 설정을 개발자가 일일이 적는 대신, 많이 쓰는 기본값을 먼저 준비해 주기 때문에 시작이 훨씬 가벼워진다.
즉, 초보자는 처음부터 복잡한 설정과 싸우기보다 기본 흐름을 먼저 익히는 데 더 집중할 수 있다.


실행과 배포를 쉽게 해 주는 기능

  • 내장 WAS가 있어서 서버를 따로 크게 준비하지 않아도 바로 실행해 보기 쉽다.
  • 독립 실행 가능한 Jar 형태로 빌드하기 쉬워 배포가 편하다.

여기서 WAS는 웹 애플리케이션 서버, 즉 웹 요청을 받아 자바 웹 프로그램을 실행해 주는 서버 프로그램이다.
기존에는 이런 서버 환경을 따로 준비해야 하는 경우가 많았다.
하지만 Spring Boot는 실행에 필요한 서버 환경을 프로젝트 안에 함께 두는 방식이라서, 초보자도 비교적 쉽게 실행해 볼 수 있다.


이 부분은 준비 단계와 다르다.
준비 단계가 프로젝트 시작을 쉽게 해 주는 쪽이라면, 이 부분은 실행과 배포를 더 간단하게 만드는 쪽이다.
그래서 Spring Boot를 배우면 처음 만드는 것도 편하고, 실행해 보는 것도 훨씬 쉬워진다.


여기서 먼저 정리해야 할 핵심

Spring과 Spring Boot를 어떻게 구분해서 이해하면 되는가

  • Spring은 여러 프로젝트를 아우르는 큰 생태계 이름으로 볼 수 있다.
  • Spring Framework는 객체 관리와 웹 처리 구조를 잡아 주는 중심 프레임워크이다.
  • Spring Boot는 그 구조를 더 쉽게 시작하고 실행하게 해 주는 도구이다.

이 구분이 선명해야 뒤에서 IoC, DI, AOP, MVC를 볼 때 헷갈리지 않는다.
Spring Boot를 별개의 완전히 다른 기술로 보면 흐름이 끊기고, 반대로 둘을 완전히 같은 것으로 보면 왜 Boot가 필요한지도 흐려진다.
그래서 이 단계에서는 구조의 중심은 Spring Framework, 시작과 실행의 편의는 Spring Boot라고 이해하면 가장 안정적이다.


이제 큰 그림은 잡혔다.
다음부터는 이 구조가 실제로 코드 안에서 어떻게 동작하는지를 IoC, DI, AOP로 나누어 보면 된다.




스프링의 핵심 동작 원리

Spring을 제대로 이해하려면 컨트롤러 문법보다 먼저 내부 동작 원리를 잡아야 한다.
여기서 가장 중요한 질문은 세 가지이다.
필요한 객체를 어떻게 연결하는가, 그 연결을 누가 관리하는가, 여러 곳에서 반복되는 공통 기능을 어떻게 분리하는가이다.
이 질문이 각각 DI, IoC, AOP로 이어진다.


초보자 입장에서는 이 부분이 가장 어렵게 느껴질 수 있다.
하지만 반대로 이 부분만 이해하면 뒤의 어노테이션, MVC, 실습 코드가 훨씬 자연스럽게 읽힌다.
즉, 지금 보는 내용은 외워야 하는 이론이 아니라, 왜 Spring 코드가 그런 모양으로 작성되는지 설명해 주는 핵심 구조이다.


의존성 주입

의존성이란 무엇인가

의존성은 어떤 객체가 다른 객체를 필요로 하는 관계를 말한다.
즉, 혼자서 일을 다 하지 못하고 다른 객체의 도움을 받아야 하는 상태이다.
예를 들어 커피를 만드는 객체가 커피 머신 없이 동작할 수 없다면, 커피를 만드는 객체는 커피 머신에 의존한다고 말한다.


이 개념을 먼저 이해해야 하는 이유는 Spring이 결국 객체와 객체의 연결을 다루는 프레임워크이기 때문이다.
즉, 어떤 객체가 무엇을 필요로 하는지 알아야 그다음에 왜 외부에서 넣어 주는 방식이 필요한지도 이해할 수 있다.


직접 만들면 왜 불편한가

처음에는 필요한 객체를 직접 new로 만들면 단순해 보인다.
하지만 이렇게 만들면 사용하는 쪽이 특정 구현 클래스에 강하게 묶이게 된다.
그러면 나중에 부품을 바꾸고 싶을 때 사용하는 코드도 함께 수정해야 한다.


쉽게 말하면 전구를 갈아 끼우는 수준이 아니라, 전구를 바꾸려는데 스위치 배선까지 다시 손봐야 하는 상태가 되는 것이다.
프로그램이 커질수록 이런 수정은 더 자주 일어나고, 수정 범위도 커진다.
그래서 객체를 직접 만드는 방식은 처음에는 쉬워 보여도 규모가 커지면 불편해진다.


커피 머신 예제로 보는 의존성

먼저 에스프레소를 추출하는 객체가 있다고 보자.
그리고 커피 메이커는 이 커피 머신을 사용해서 커피를 만든다고 가정한다.
아래 코드는 커피 메이커가 에스프레소 머신을 직접 만들어 쓰는 가장 단순한 형태이다.

// EspressoMachine.java
public class EspressoMachine {
    public String brew() {
        return "Brewing coffee with Espresso Machine"; // 에스프레소 추출
    }
}
// CoffeeMaker.java
public class CoffeeMaker {
    private EspressoMachine espressoMachine; // 필요한 객체 보관

    public CoffeeMaker() {
        this.espressoMachine = new EspressoMachine(); // 직접 객체 생성
    }

    public void makeCoffee() {
        System.out.println(espressoMachine.brew()); // 커피 머신 사용
    }
}
// Main.java
public class Main {
    public static void main(String[] args) {
        CoffeeMaker coffeeMaker = new CoffeeMaker(); // 커피 메이커 생성
        coffeeMaker.makeCoffee(); // 커피 만들기
    }
}
// 출력결과
// Brewing coffee with Espresso Machine

이 구조에서는 커피 메이커가 에스프레소 머신을 직접 만들고 있다.
즉, 커피 메이커는 에스프레소 머신이라는 특정 구현에 강하게 묶여 있다.
처음에는 별문제 없어 보이지만, 나중에 다른 커피 머신으로 바꾸고 싶으면 커피 메이커 코드도 같이 수정해야 한다.


느슨한 결합이 왜 중요한가

이번에는 드립 커피 머신으로 바꾸고 싶다고 해 보자.
직접 생성 구조라면 CoffeeMaker 안에 들어 있는 EspressoMachine 관련 코드를 모두 DripCoffeeMachine으로 바꿔야 한다.
말로만 보면 감이 잘 안 오기 때문에 코드로 비교해 보는 것이 좋다.


먼저 드립 커피 머신 클래스를 새로 만든다.

// DripCoffeeMachine.java
public class DripCoffeeMachine {
    public String brew() {
        return "Brewing coffee with Drip Coffee Machine"; // 드립 커피 추출
    }
}

이제 CoffeeMaker가 드립 커피 머신을 사용하려면 아래처럼 바뀐다.

// CoffeeMaker.java
public class CoffeeMaker {
    private DripCoffeeMachine dripCoffeeMachine; // 드립 커피 머신으로 변경

    public CoffeeMaker() {
        this.dripCoffeeMachine = new DripCoffeeMachine(); // 드립 커피 머신 직접 생성
    }

    public void makeCoffee() {
        System.out.println(dripCoffeeMachine.brew()); // 드립 커피 머신 사용
    }
}

겉으로 보면 EspressoMachine을 DripCoffeeMachine으로 바꾼 것뿐이다.
하지만 중요한 점은 커피 머신을 바꾸기 위해 CoffeeMaker 코드도 같이 수정했다는 것이다.


CoffeeMaker는 원래 커피를 만드는 역할만 하면 된다.
그런데 지금은 어떤 커피 머신을 직접 만들지도 알고 있다.
그래서 커피 머신이라는 부품이 바뀌면, 그 부품을 사용하는 CoffeeMaker 코드도 함께 흔들린다.
이 상태를 강한 결합이라고 한다.


강한 결합은 한 클래스가 특정 구현 클래스에 직접 묶여 있어서, 구현 클래스가 바뀔 때 사용하는 쪽 코드도 함께 수정되는 상태이다.
반대로 사용하는 쪽은 역할만 알고, 실제 구현은 바깥에서 바꿔 끼울 수 있게 만들면 훨씬 유연해진다.
이 상태를 느슨한 결합이라고 한다.


강한 결합은 부품이 바뀌면 사용하는 쪽도 함께 흔들리는 상태이고, 느슨한 결합은 부품이 바뀌어도 사용하는 쪽의 수정이 적은 상태이다.
그래서 Spring은 객체를 아예 없애는 것이 아니라, 객체 사이의 연결을 더 유연하게 만들려는 방향으로 움직인다.


외부에서 넣어 주는 것이 왜 DI인가

느슨한 결합을 만들려면 공통 역할을 먼저 잡아야 한다.
커피 머신이 무엇이든 결국 커피를 추출하는 기능은 같으므로 interface로 역할을 먼저 정의할 수 있다.
그리고 커피 메이커는 특정 구현을 직접 만들지 않고, 바깥에서 전달받은 커피 머신을 사용하게 만들면 된다.
이렇게 필요한 객체를 직접 만들지 않고 외부에서 넣어 주는 방식이 DI이다.

// CoffeeMachine.java
public interface CoffeeMachine {
    String brew(); // 커피 추출 기능
}
// EspressoMachine.java
public class EspressoMachine implements CoffeeMachine {
    @Override
    public String brew() {
        return "Brewing coffee with Espresso Machine"; // 에스프레소 추출
    }
}
// DripCoffeeMachine.java
public class DripCoffeeMachine implements CoffeeMachine {
    @Override
    public String brew() {
        return "Brewing coffee with Drip Coffee Machine"; // 드립 커피 추출
    }
}
// CoffeeMaker.java
public class CoffeeMaker {
    private CoffeeMachine coffeeMachine; // 역할 타입으로 보관

    public void setCoffeeMachine(CoffeeMachine coffeeMachine) {
        this.coffeeMachine = coffeeMachine; // 외부에서 주입
    }

    public void makeCoffee() {
        System.out.println(coffeeMachine.brew()); // 주입받은 객체 사용
    }
}
// Main.java
public class Main {
    public static void main(String[] args) {
        CoffeeMaker coffeeMaker = new CoffeeMaker(); // 커피 메이커 생성
        coffeeMaker.setCoffeeMachine(new DripCoffeeMachine()); // 필요한 구현 주입
        coffeeMaker.makeCoffee(); // 커피 만들기
    }
}
// 출력결과
// Brewing coffee with Drip Coffee Machine

이제 커피 메이커는 어떤 커피 머신을 직접 만들지 모른다.
그 대신 커피 머신 역할만 알고, 실제 구현은 바깥에서 전달받는다.
그래서 에스프레소 머신을 쓰든 드립 커피 머신을 쓰든 커피 메이커 코드를 크게 고칠 필요가 없어진다.
이 점이 DI의 핵심이다.


즉, DI는 단순히 밖에서 넣어 준다는 문법 이야기가 아니다.
구현 변경이 생겨도 사용하는 쪽의 수정 범위를 줄이고, 객체 사이의 결합을 느슨하게 만드는 구조이다.

IoC, DI, DL 관계와 여러 주입 방식을 함께 보여 준다.
여기서 가장 중요한 점은 실제 코드 작성에서 가장 자주 체감하는 방식이 DI라는 것이다.
즉, 필요한 객체를 직접 만들지 않고 외부에서 받아 쓰는 흐름이 핵심이다.


생성자 주입, 필드 주입, 세터 주입

DI를 실제 코드에 적용하는 방식은 여러 가지가 있다.
가장 자주 보는 것은 생성자 주입, 필드 주입, 세터 주입이다.

  • 생성자 주입은 객체가 만들어질 때 필요한 의존성을 함께 받는 방식이다.
  • 필드 주입은 필드에 바로 의존성을 넣는 방식이다.
  • 세터 주입은 메서드를 통해 나중에 의존성을 넣는 방식이다.

초보자 기준에서는 생성자 주입을 가장 기본으로 이해하면 된다.
객체가 만들어질 때 어떤 의존성이 꼭 필요한지가 가장 분명하게 드러나기 때문이다.
필드 주입은 코드가 짧아 보이지만 의존성이 겉으로 잘 드러나지 않을 수 있다.
세터 주입은 선택적으로 바꿔 넣을 수 있다는 장점이 있다.
즉, 이 세 방식은 문법 차이만 있는 것이 아니라 객체를 언제 어떤 방식으로 연결할지의 차이이다.


제어의 역전과 스프링 컨테이너

IoC란 무엇인가

IoC는 Inversion of Control의 줄임말이다.
한국어로는 제어의 역전이라고 부른다.
이름은 어렵지만 뜻은 단순하다.
원래는 개발자가 직접 객체를 만들고 연결하고 호출하는 흐름을 다 쥐고 있었다.
그런데 Spring에서는 그 제어권의 큰 부분을 프레임워크가 맡는다.
그래서 제어의 방향이 바뀌었다고 해서 제어의 역전이라고 부른다.


일반 자바 코드에서는 개발자가 직접 객체를 만들고 연결한다.

// Main.java
public class Main {
    public static void main(String[] args) {
        CoffeeMachine coffeeMachine = new DripCoffeeMachine(); // 개발자가 직접 생성
        CoffeeMaker coffeeMaker = new CoffeeMaker(); // 개발자가 직접 생성
        coffeeMaker.setCoffeeMachine(coffeeMachine); // 개발자가 직접 연결

        coffeeMaker.makeCoffee(); // 커피 만들기
    }
}
// 출력결과
// Brewing coffee with Drip Coffee Machine

이 구조에서는 객체를 만들고 연결하는 코드가 전부 개발자 코드 안에 있다.
반면 Spring에서는 스프링 컨테이너가 객체를 만들고, 필요한 객체끼리 연결해 준다.


DI가 필요한 객체를 외부에서 넣어 주는 방식이라면, IoC는 그 객체 생성과 연결 흐름을 스프링이 더 중심적으로 관리하는 큰 구조라고 보면 된다.
즉, 초보자 기준에서는 DI는 연결 방식이고, IoC는 그 연결을 포함해 객체 관리의 제어권이 스프링 쪽으로 넘어간 구조라고 이해하면 된다.


Bean과 스프링 컨테이너

Bean은 스프링이 관리하는 자바 객체이다.
완전히 특별한 다른 객체라고 보면 오해하기 쉽다.
그냥 자바 객체인데, 그중에서 스프링 컨테이너가 생성하고 관리하는 객체를 Bean이라고 부른다.


스프링 컨테이너는 이 Bean들을 담아 두고 관리하는 공간이다.
어떤 객체를 먼저 만들지, 어디에 연결할지, 필요한 곳에 무엇을 넣어 줄지 같은 일을 여기서 처리한다.
그래서 IoC를 이해할 때는 Bean과 컨테이너를 따로 떼어 보지 말고 함께 봐야 한다.

메타데이터를 바탕으로 IoC Container가 Bean을 생성하고 관리하는 구조를 보여 준다.
초보자 기준에서는 컨테이너가 객체 창고이자 객체 관리자 역할을 한다고 이해하면 된다.


애노테이션 기반 IoC는 어떻게 동작하는가

Spring Boot에서는 보통 애노테이션으로 IoC를 구현한다.
즉, 어떤 클래스를 스프링이 관리할지 표시하고, 필요한 의존성을 자동으로 연결하게 만드는 방식이다.
이 흐름을 이해하면 Spring 코드가 왜 저런 모양으로 쓰이는지 훨씬 분명해진다.


먼저 커피 머신 역할을 interface로 둔다.

// CoffeeMachine.java
public interface CoffeeMachine {
    String brew(); // 커피 추출 기능
}

그리고 실제 커피 머신 구현 클래스에 @Component를 붙인다.

// EspressoMachine.java
import org.springframework.stereotype.Component;

@Component
public class EspressoMachine implements CoffeeMachine {
    @Override
    public String brew() {
        return "Brewing coffee with Espresso Machine"; // 에스프레소 추출
    }
}
// DripCoffeeMachine.java
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

@Component
@Primary
public class DripCoffeeMachine implements CoffeeMachine {
    @Override
    public String brew() {
        return "Brewing coffee with Drip Coffee Machine"; // 드립 커피 추출
    }
}

@Component는 이 클래스를 스프링이 관리하는 대상으로 등록하겠다는 뜻이다.
더 정확히 말하면, 스프링이 컴포넌트 스캔 범위 안에서 이 클래스를 발견하면 이 클래스로 객체를 만들고 컨테이너 안에 Bean으로 등록한다는 의미이다.


여기서는 EspressoMachine과 DripCoffeeMachine이 둘 다 CoffeeMachine 타입이다.
이렇게 같은 타입의 Bean 후보가 여러 개 있으면 스프링은 무엇을 넣어야 할지 결정하기 어렵다.
그래서 DripCoffeeMachine에 @Primary를 붙여 기본으로 선택할 대상을 정했다.


이제 CoffeeMaker도 스프링이 관리하는 대상으로 등록한다.

// CoffeeMaker.java
import org.springframework.stereotype.Component;

@Component
public class CoffeeMaker {
    private final CoffeeMachine coffeeMachine; // 필요한 Bean 보관

    public CoffeeMaker(CoffeeMachine coffeeMachine) {
        this.coffeeMachine = coffeeMachine; // 스프링이 Bean을 넣어 줌
    }

    public void makeCoffee() {
        System.out.println(coffeeMachine.brew()); // 주입받은 Bean 사용
    }
}

여기서 CoffeeMaker는 CoffeeMachine이 필요하다.
그러면 스프링은 컨테이너 안에서 CoffeeMachine 타입의 Bean을 찾고, 선택된 객체를 CoffeeMaker 생성자에 넣어 준다.
생성자가 하나뿐이면 보통 @Autowired를 생략할 수 있다.


즉, 위 코드에서는 개발자가 직접 new DripCoffeeMachine()을 작성하지 않아도 된다.
스프링 컨테이너가 DripCoffeeMachine 객체를 만들고, CoffeeMaker에 연결해 준다.


이 흐름을 실행해서 확인하고 싶다면 CommandLineRunner를 사용할 수 있다.

// CoffeeRunner.java
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class CoffeeRunner implements CommandLineRunner {
    private final CoffeeMaker coffeeMaker; // CoffeeMaker Bean 보관

    public CoffeeRunner(CoffeeMaker coffeeMaker) {
        this.coffeeMaker = coffeeMaker; // 스프링이 CoffeeMaker Bean 주입
    }

    @Override
    public void run(String... args) {
        coffeeMaker.makeCoffee(); // 애플리케이션 실행 후 호출
    }
}
// 출력결과
// Brewing coffee with Drip Coffee Machine

이 흐름을 풀어 보면 다음과 같다.

  • 스프링이 컴포넌트 스캔 범위 안에서 @Component가 붙은 클래스를 찾는다.
  • EspressoMachine, DripCoffeeMachine, CoffeeMaker, CoffeeRunner 객체를 만든다.
  • 만들어진 객체들을 스프링 컨테이너 안에 Bean으로 보관한다.
  • CoffeeMaker를 만들 때 필요한 CoffeeMachine Bean을 찾아 넣어 준다.
  • CoffeeRunner를 만들 때 필요한 CoffeeMaker Bean을 찾아 넣어 준다.

Bean으로 관리한다는 것은 스프링 컨테이너가 객체의 생성, 보관, 연결을 맡는 흐름에 들어간다는 뜻이다.
그래서 Spring 코드에서는 직접 new로 객체를 만드는 코드가 줄어들고, 필요한 객체는 생성자를 통해 주입받는 구조가 많아진다.


@Component와 @Autowired를 따로 보면

@Component는 등록이다.
즉, 스프링이 이 객체를 관리 대상으로 삼게 만든다.


@Autowired는 연결이다.
즉, 이미 등록되어 있는 Bean 중에서 필요한 것을 찾아 넣어 달라는 뜻이다.


이 둘은 함께 움직인다.
스프링 컨테이너에 Bean으로 등록되어 있지 않은 객체는 자동 주입 대상이 되지 않는다.
그래서 주입을 이해할 때는 항상 먼저 스프링이 이 객체를 관리하고 있는가를 같이 봐야 한다.


다만 생성자가 하나뿐인 경우에는 생성자 주입에서 @Autowired를 생략할 수 있다.
그래서 요즘 예제에서는 필드에 @Autowired를 붙이는 방식보다, 생성자를 통해 의존성을 받는 코드가 더 자주 보인다.


같은 타입 Bean이 여러 개면 어떻게 되는가

CoffeeMachine 구현체가 하나뿐이면 스프링은 그 객체를 바로 넣으면 된다.
하지만 EspressoMachine과 DripCoffeeMachine처럼 같은 CoffeeMachine 타입의 Bean이 여러 개 등록되어 있으면 상황이 달라진다.


스프링은 CoffeeMachine 타입을 보고 주입하려고 한다.
그런데 같은 타입의 후보가 여러 개 있고, 어떤 것을 선택해야 하는지 기준이 없으면 스프링은 무엇을 넣어야 할지 결정하지 못한다.


앞의 예제에서는 DripCoffeeMachine에 @Primary를 붙였기 때문에 기본 선택 대상이 정해져 있었다.
그래서 그 예제는 정상적으로 동작할 수 있다.
하지만 @Primary나 @Qualifier 같은 선택 기준이 없다면 같은 타입 후보가 여러 개일 때 오류가 발생할 수 있다.


즉, 자동 연결이 항상 되는 것은 아니다.
같은 타입의 Bean이 여러 개일 때는 스프링이 어떤 Bean을 선택해야 하는지 알 수 있도록 기준을 정해 주어야 한다.


@Primary, @Qualifier, 여러 개 주입

이럴 때 대표적으로 쓰는 방법이 @Primary와 @Qualifier이다.

// DripCoffeeMachine.java
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

@Component
@Primary
public class DripCoffeeMachine implements CoffeeMachine {
    @Override
    public String brew() {
        return "Brewing coffee with Drip Coffee Machine"; // 드립 커피 추출
    }
}

@Primary는 같은 타입의 후보가 여러 개 있을 때 우선순위를 높여 주는 표시이다.
즉, 특별한 지시가 없으면 이 Bean을 먼저 선택하라는 뜻이다.


더 정확하게 지정하고 싶다면 이름으로 고를 수도 있다.

// DripCoffeeMachine.java
import org.springframework.stereotype.Component;

@Component("dripCoffeeMachine")
public class DripCoffeeMachine implements CoffeeMachine {
    @Override
    public String brew() {
        return "Brewing coffee with Drip Coffee Machine"; // 드립 커피 추출
    }
}
// CoffeeMaker.java
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

@Component
public class CoffeeMaker {
    private final CoffeeMachine coffeeMachine; // 선택된 Bean 보관

    public CoffeeMaker(@Qualifier("dripCoffeeMachine") CoffeeMachine coffeeMachine) {
        this.coffeeMachine = coffeeMachine; // 이름으로 정확히 선택
    }

    public void makeCoffee() {
        System.out.println(coffeeMachine.brew()); // 선택된 Bean 사용
    }
}

@Qualifier는 후보가 여러 개일 때 이름으로 정확히 집어서 선택하는 방식이다.
즉, @Primary가 기본 우선순위라면, @Qualifier는 더 직접적인 지정이라고 보면 된다.


그리고 경우에 따라서는 하나만 고르는 것이 아니라, 같은 타입의 Bean을 여러 개 모두 받을 수도 있다.

// CoffeeMaker.java
import org.springframework.stereotype.Component;
import java.util.List;

@Component
public class CoffeeMaker {
    private final List<CoffeeMachine> coffeeMachines; // 같은 타입 Bean 모두 받기

    public CoffeeMaker(List<CoffeeMachine> coffeeMachines) {
        this.coffeeMachines = coffeeMachines; // 모든 CoffeeMachine Bean 주입
    }

    public void makeCoffee() {
        for (CoffeeMachine coffeeMachine : coffeeMachines) {
            System.out.println(coffeeMachine.brew()); // 모든 Bean 사용
        }
    }
}

즉, 같은 타입 Bean이 여러 개일 때는 하나를 고를 수도 있고, 여러 개를 한 번에 받을 수도 있다.
결국 @Primary, @Qualifier, 리스트 주입은 후보가 여러 개일 때 어떤 기준으로 선택하거나 모아 쓸지를 정하는 도구들이다.
이 부분은 실제 프로젝트에서도 자주 헷갈리는 지점이라서 꼭 같이 알아 두는 것이 좋다.


공통 기능을 따로 빼는 방식

AOP란 무엇인가

AOP는 Aspect Oriented Programming의 줄임말이다.
한국어로는 관점 지향 프로그래밍이라고 부른다.
처음에는 이름이 어려워 보이지만 핵심은 단순하다.
하나의 기능 안에 핵심 기능과 공통 부가 기능이 섞여 있을 때, 부가 기능을 따로 분리해서 관리하는 방식이다.


핵심 기능은 실제 목적 기능이다.
예를 들어 회원 처리, 게시글 처리, 상품 처리처럼 비즈니스 로직이 여기에 들어간다.
반대로 부가 기능은 여러 곳에서 반복되는 기능이다.
예를 들어 로깅, 보안, 트랜잭션 처리가 여기에 들어간다.

하나의 비즈니스 로직 안에 섞여 있던 로깅을 핵심 기능과 분리하는 흐름을 보여 준다.
즉, 핵심 일과 공통 보조 일을 분리하는 것이 AOP의 출발점이다.


AOP는 파일을 나누는 것과 다르다

AOP는 단순히 파일을 여러 개로 나누는 것이 아니다.
Controller, Service, Repository로 파일을 나누는 것은 보통 레이어 분리라고 본다.
레이어 분리는 요청 처리, 핵심 로직, 데이터 접근처럼 역할을 나누는 구조이다.


예를 들어 아래 구조는 AOP가 아니라 레이어드 구조이다.

// MemberController.java
import org.springframework.stereotype.Controller;

@Controller
public class MemberController {
    private final MemberService memberService; // 서비스 의존

    public MemberController(MemberService memberService) {
        this.memberService = memberService; // 생성자 주입
    }
}
// MemberService.java
import org.springframework.stereotype.Service;

@Service
public class MemberService {
    private final MemberRepository memberRepository; // 저장소 의존

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository; // 생성자 주입
    }
}
// MemberRepository.java
import org.springframework.stereotype.Repository;

@Repository
public class MemberRepository {
}

이 구조는 요청을 받는 코드, 실제 처리하는 코드, 데이터베이스에 접근하는 코드를 나눈 것이다.
즉, 기능의 역할을 세로로 나눈 구조라고 보면 된다.


반면 AOP는 여러 기능에 반복해서 들어가는 공통 기능을 따로 빼는 것이다.
회원 기능, 상품 기능, 게시글 기능처럼 서로 다른 기능을 가로질러 반복되는 부가 기능을 따로 관리하는 방식이다.


레이어 분리는 역할별로 코드를 나누는 구조이고, AOP는 여러 역할을 가로질러 반복되는 공통 기능을 분리하는 구조이다.
그래서 Service와 Repository를 나누는 것 자체가 AOP는 아니다.


왜 AOP가 필요한가

예를 들어 회원 등록, 상품 조회, 게시글 작성 메서드마다 실행 시작 로그와 종료 로그를 매번 직접 적는다고 해 보자.
처음에는 가능해 보인다.
하지만 이런 코드가 서비스마다 반복되기 시작하면 핵심 기능보다 공통 기능이 더 눈에 띄게 된다.
코드를 읽기도 불편하고, 로그 형식을 바꿀 때도 여러 곳을 함께 수정해야 한다.


아래 코드는 AOP를 적용하지 않았을 때의 예이다.

// MemberService.java
import org.springframework.stereotype.Service;

@Service
public class MemberService {
    public void join() {
        System.out.println("메서드 실행 시작"); // 반복되는 로그
        System.out.println("회원가입 처리"); // 핵심 기능
        System.out.println("메서드 실행 종료"); // 반복되는 로그
    }
}
// ProductService.java
import org.springframework.stereotype.Service;

@Service
public class ProductService {
    public void findProduct() {
        System.out.println("메서드 실행 시작"); // 반복되는 로그
        System.out.println("상품 조회 처리"); // 핵심 기능
        System.out.println("메서드 실행 종료"); // 반복되는 로그
    }
}

여기서 핵심 기능은 회원가입 처리와 상품 조회 처리이다.
그런데 로그 코드가 여러 메서드에 반복해서 섞여 있다.
이렇게 되면 코드를 읽을 때 핵심 기능이 한눈에 들어오지 않는다.


또 로그 문구를 바꾸고 싶을 때도 문제가 생긴다.
회원 서비스, 상품 서비스, 게시글 서비스에 흩어진 로그 코드를 모두 찾아 수정해야 한다.
즉, 공통 기능이 여러 곳에 복사되어 있으면 수정 지점이 늘어난다.


이때 공통 기능을 따로 빼면 핵심 기능이 더 또렷해진다.
또 공통 기능 수정도 한곳에서 처리하기 쉬워진다.
즉, AOP는 단순히 코드를 예쁘게 정리하는 기술이 아니라, 중복을 줄이고 수정 지점을 한곳으로 모으는 방식이다.

회원 서비스, 커뮤니티 서비스, 상품 서비스 같은 핵심 기능에 로깅, 보안, 트랜잭션이 공통으로 가로질러 적용되는 구조를 보여 준다.
즉, 공통 기능이 여러 서비스에 반복될수록 따로 분리하는 편이 훨씬 자연스럽다.


AOP 적용 전에는 공통 기능이 여러 클래스에 흩어져 있고, 적용 후에는 Aspect로 따로 분리되는 구조를 보여 준다.
즉, 공통 기능을 서비스마다 복사해 넣는 대신, 한곳으로 모아서 관리하는 방식으로 이해하면 된다.


스프링에서 AOP가 동작하는 방식

스프링은 AOP를 적용할 때 프록시 방식을 사용한다.
프록시는 대신 서 주는 객체라고 생각하면 된다.
원본 객체 앞에 대리 객체를 하나 두고, 그 대리 객체가 공통 기능을 먼저 처리한 뒤 원래 메서드로 넘기는 구조이다.


즉, 원본 메서드 안을 직접 뒤섞어 고치는 것이 아니라, 원본 앞단에서 공통 기능을 끼워 넣는 방식이다.
그래서 핵심 기능 코드를 크게 건드리지 않고도 로깅, 보안, 트랜잭션 같은 기능을 추가할 수 있다.

스프링 AOP는 프록시 객체를 만들어 원본 객체 앞에서 공통 기능을 처리한다.
프록시 생성 방식에는 대표적으로 JDK Dynamic Proxy와 CGLIB이 있다.
다만 실제 선택 방식은 인터페이스 유무와 설정에 따라 달라질 수 있다.
초보자 단계에서는 구현 이름보다 먼저 대리 객체가 앞에서 공통 기능을 처리한 뒤 원본으로 넘긴다는 동작 원리를 이해하는 것이 더 중요하다.


실행 시간 측정 예제로 보는 AOP

AOP는 개념만 보면 추상적으로 느껴질 수 있다.
그래서 애노테이션 하나를 붙였을 때 공통 기능이 실제로 끼어드는 예제를 보는 것이 좋다.
아래 예제는 메서드 실행 시간을 재는 @PrintExecutionTime 애노테이션을 만드는 흐름이다.


실제 프로젝트에서 스프링 AOP를 사용하려면 관련 의존성이 필요할 수 있다.
Spring Boot에서는 보통 spring-boot-starter-aop 같은 의존성을 추가한 뒤 사용한다.

// PrintExecutionTime.java
package com.example.demo;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PrintExecutionTime {
}

이 애노테이션은 메서드에 붙여서 사용한다.
즉, 실행 시간을 측정하고 싶은 메서드에 표시를 남기는 역할이다.


이제 실제로 그 표시를 보고 동작하는 Aspect를 만든다.

// PrintExecutionTimeAspect.java
package com.example.demo;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Component
@Aspect
public class PrintExecutionTimeAspect {
    @Around("@annotation(com.example.demo.PrintExecutionTime)")
    public Object printExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis(); // 시작 시각 저장
        Object object = joinPoint.proceed(); // 원래 메서드 실행
        long executionTime = System.currentTimeMillis() - start; // 실행 시간 계산
        System.out.println("executed in " + executionTime + "ms."); // 결과 출력
        return object; // 원래 반환값 전달
    }
}

여기서 @Aspect는 이 클래스가 공통 기능을 다루는 AOP 구현체라는 뜻이다.
@Around는 대상 메서드의 실행 전과 후를 모두 감쌀 수 있다는 뜻이다.
즉, 원래 메서드를 실행하기 전에도 작업할 수 있고, 실행한 뒤에도 작업할 수 있다.


@Around("@annotation(com.example.demo.PrintExecutionTime)")는 com.example.demo.PrintExecutionTime 애노테이션이 붙은 메서드에 이 공통 기능을 적용하겠다는 뜻이다.
여기서 com.example.demo는 예시 패키지명이다.
실제 프로젝트에서는 자신의 프로젝트 패키지 경로에 맞게 적어야 한다.
초보자 단계에서는 특정 애노테이션이 붙은 메서드를 찾아 실행 전후에 공통 기능을 끼워 넣는 구조라고 이해하면 된다.


이제 실행 시간을 재고 싶은 메서드에 애노테이션을 붙이면 된다.

// Pi.java
package com.example.demo;

import org.springframework.stereotype.Component;

@Component
public class Pi {
    @PrintExecutionTime
    public double calculate(int points) {
        int circle = 0; // 원 안의 점 개수
        for (long i = 0; i < points; i++) {
            double x = Math.random() * 2 - 1; // x 좌표
            double y = Math.random() * 2 - 1; // y 좌표
            if (x * x + y * y <= 1) {
                circle++; // 원 안이면 증가
            }
        }
        return 4.0 * circle / points; // 원주율 근삿값 반환
    }
}

실행 흐름을 보려면 Pi의 calculate()를 실제로 호출해야 한다.

// PiRunner.java
package com.example.demo;

import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class PiRunner implements CommandLineRunner {
    private final Pi pi; // Pi Bean 보관

    public PiRunner(Pi pi) {
        this.pi = pi; // 스프링이 Pi Bean 주입
    }

    @Override
    public void run(String... args) {
        pi.calculate(1000); // 실행 시간 측정 대상
        pi.calculate(10000); // 실행 시간 측정 대상
        pi.calculate(100000); // 실행 시간 측정 대상
    }
}
// 출력결과
// executed in 18ms.
// executed in 169ms.
// executed in 1724ms.

출력 시간은 실행 환경마다 달라질 수 있다.
중요한 것은 숫자 자체가 아니라, calculate() 안에 실행 시간 측정 코드가 없는데도 시간이 출력된다는 점이다.


즉, calculate() 안에는 실행 시간 측정 코드가 직접 들어 있지 않다.
그런데도 @PrintExecutionTime 하나만 붙이면 실행 시간 출력이 끼어든다.
초보자 기준에서는 이 지점이 가장 중요하다.
핵심 메서드는 원래 자기 일만 하고 있는데, 공통 기능이 바깥에서 추가된다는 것이 AOP의 핵심이기 때문이다.


@Around, @Before, @After 차이

스프링 AOP에서는 공통 기능을 끼워 넣는 시점을 다르게 잡을 수 있다.
대표적으로 자주 보는 것이 @Around, @Before, @After, @AfterReturning, @AfterThrowing이다.

  • @Around는 실행 전과 후를 모두 감싼다.
  • @Before는 대상 메서드가 실행되기 전에 동작한다.
  • @After는 정상 종료든 예외 발생이든 대상 메서드 실행이 끝난 뒤 동작한다.
  • @AfterReturning은 정상적으로 값을 반환한 뒤 동작한다.
  • @AfterThrowing은 예외가 발생했을 때 동작한다.

즉, 공통 기능을 넣는다는 점은 같지만 언제 끼어들 것인가가 다르다.
같은 AOP라도 실행 전을 다룰지, 실행 후를 다룰지, 정상 반환 뒤를 다룰지, 예외 상황을 다룰지에 따라 역할이 달라진다.
이 차이를 알고 있으면 AOP를 단순히 로깅 기술 정도로만 보지 않고, 실행 흐름을 제어하는 구조로 이해할 수 있다.


여기서 먼저 정리해야 할 핵심

DI, IoC, AOP를 한 번에 묶어 보면

  • DI는 필요한 객체를 밖에서 연결받는 방식이다.
  • IoC는 그 객체 생성과 연결을 스프링이 더 중심적으로 관리하는 큰 구조이다.
  • Bean은 스프링이 관리하는 객체이고, 스프링 컨테이너는 그 Bean을 생성하고 연결하고 관리하는 공간이다.
  • AOP는 여러 곳에서 반복되는 공통 기능을 핵심 기능과 분리해서 적용하는 방식이다.

이 세 개가 합쳐져서 Spring 코드가 더 구조적으로 보이게 된다.
객체는 스프링이 관리하고, 필요한 객체는 주입으로 연결하고, 반복되는 공통 기능은 따로 빼서 적용한다.
즉, Spring의 핵심은 기능을 무작정 많이 제공하는 것이 아니라, 복잡한 프로그램을 역할별로 나누어 정리하게 해 주는 구조에 있다.


이제 객체 연결과 객체 관리, 공통 기능 분리 구조는 잡혔다.
다음부터는 이 구조가 웹 요청 처리 흐름 안에서 어떻게 이어지는지 MVC 구조로 넘어가서 보면 된다.




웹 애플리케이션과 요청 처리 구조

앞에서 Spring이 객체를 어떻게 만들고 연결하는지 봤다면, 이제는 웹 요청이 실제로 어떻게 들어오고 나가는지를 이해해야 한다.
초보자가 스프링을 어렵게 느끼는 이유 중 하나는 브라우저에서 보낸 요청이 서버 안에서 어떤 순서로 처리되는지 한 번에 보이지 않기 때문이다.
이 파트에서는 웹 애플리케이션의 전체 구조, Tier와 Layer의 차이, WAS와 Servlet의 역할, 스프링 MVC 요청 처리 흐름을 차례대로 연결해서 본다.


여기서 중요한 것은 요청 처리 순서만 외우는 것이 아니다.
브라우저 요청이 서버에 들어온 뒤, 그 요청이 DispatcherServlet을 거쳐 Controller, Service, Repository로 이어지는 흐름을 함께 이해해야 한다.
그래야 실제 프로젝트에서 controller, service, repository, domain, core, config 패키지가 왜 나뉘어 있는지도 자연스럽게 보인다.


즉, 지금부터 보는 내용은 단순히 용어를 외우는 구간이 아니다.
나중에 @Controller, @RequestMapping, Model, @RestController, DTO, Entity, Repository가 왜 필요한지 이해하기 위한 흐름을 먼저 잡는 구간이다.


웹 애플리케이션의 전체 구조

웹 애플리케이션은 어떤 구조로 동작하는가

웹 애플리케이션은 브라우저 같은 클라이언트와 서버가 HTTP로 데이터를 주고받으며 동작하는 프로그램이다.
사용자가 주소창에 /members 같은 주소를 입력하거나 버튼을 누르면, 브라우저는 서버로 HTTP Request를 보낸다.
여기서 Request는 요청이라는 뜻이고, 사용자가 무엇을 원하는지 서버에 전달하는 메시지라고 보면 된다.


서버는 요청을 받은 뒤 필요한 처리를 하고 다시 HTTP Response를 돌려준다.
여기서 Response는 응답이라는 뜻이고, 서버가 처리 결과를 클라이언트에게 돌려주는 메시지이다.
응답은 HTML 화면일 수도 있고, JSON 데이터일 수도 있고, 이미지나 파일일 수도 있다.


예를 들어 사용자가 회원 목록 화면을 요청하면 서버는 회원 목록을 보여 주는 HTML을 응답할 수 있다.
반대로 화면이 아니라 데이터만 필요한 요청이라면 서버는 회원 목록 데이터를 JSON 형식으로 응답할 수도 있다.
즉, 웹 애플리케이션에서 서버 응답은 항상 화면만 의미하지 않는다.


여기서 정적 응답과 동적 응답도 구분해야 한다.
정적 응답은 이미 만들어져 있는 HTML, CSS, 이미지 파일처럼 서버가 거의 그대로 보내 주는 응답이다.
동적 응답은 요청에 따라 자바 코드가 실행되고, 필요하면 DB에서 데이터를 조회한 뒤 새로 만들어지는 응답이다.


브라우저는 보통 DB에 직접 접근하지 않는다.
브라우저는 서버에 요청을 보내고, 서버가 필요할 때 DB에 접근한다.
그리고 서버가 조회 결과를 화면이나 데이터 형태로 만들어 브라우저에 돌려준다.


스프링 웹 개발에서는 이 요청이 서버 안에서 바로 Controller로 들어가는 것이 아니다.
먼저 스프링 MVC의 공통 입구 역할을 하는 DispatcherServlet이 요청을 받는다.
그다음 알맞은 Controller를 찾아 요청을 넘기고, Controller는 필요한 Service를 호출하면서 내부 처리 흐름을 이어 간다.

웹 브라우저, 웹 서버, 애플리케이션 서버, 데이터베이스 서버가 연결되어 정적 콘텐츠와 동적 콘텐츠를 처리하는 전체 구조를 보여 준다.
즉, 브라우저가 직접 데이터베이스와 통신하는 것이 아니라, 중간에 서버가 요청을 받아 처리하고 필요할 때 데이터베이스에 접근한다.


사용자는 보통 브라우저 화면만 본다.
하지만 실제로는 브라우저가 요청을 보내고, 서버가 그 요청을 해석하고, 필요한 데이터를 가져오고, 다시 결과를 응답으로 보내는 과정이 뒤에서 계속 일어난다.
그래서 스프링 웹 개발을 이해하려면 화면만 보는 것이 아니라, 요청이 서버 안에서 어떤 객체를 거쳐 이동하는지를 같이 봐야 한다.


먼저 Tier와 Layer를 구분해야 한다

앞에서 본 웹 서버 구조는 실제 위치와 장비를 기준으로 보는 물리 구조에 가깝다.
이제부터 보는 스프링 프로젝트 구조는 누가 어떤 역할을 맡는지를 기준으로 보는 논리 구조에 가깝다.
그래서 여기서는 Tier와 Layer를 먼저 구분해서 보는 것이 중요하다.


Tier와 Layer는 둘 다 구조를 나눈다는 점에서는 비슷해 보인다.
하지만 나누는 기준이 다르다.
Tier는 물리적인 기준으로 나눈 구조이다.
예를 들어 클라이언트 층, 중간 서버 층, 데이터베이스 층처럼 실제 위치나 배포 서버 기준으로 나눈다.


반면 Layer는 논리적인 기준으로 나눈 구조이다.
예를 들어 요청 처리, 기능 처리, 데이터 접근처럼 코드가 맡는 역할 기준으로 나눈다.
즉, Tier는 어디에 배치되어 있는가를 보는 것이고, Layer는 무슨 역할을 하는가를 보는 것이다.


이 차이를 실제 프로젝트에 연결하면 더 쉽게 이해된다.
controller, service, repository, domain 패키지는 보통 같은 스프링 애플리케이션 안에 있다.
물리적으로 다른 서버에 나뉘어 있는 것이 아니다.
하지만 각각 맡는 역할이 다르기 때문에 논리적으로 다른 Layer로 나누어 생각할 수 있다.


따라서 3-Tier와 3-Layer를 완전히 같은 말처럼 보면 안 된다.
3-Tier는 클라이언트, 애플리케이션 서버, 데이터베이스 서버처럼 실제 배치 구조를 보는 말에 가깝다.
3-Layer는 프레젠테이션 계층, 비즈니스 계층, 데이터 접근 계층처럼 코드 역할을 보는 말에 가깝다.


스프링 프로젝트에서 controller, service, repository, domain을 나누는 것은 서버를 나누는 것이 아니라 코드의 책임을 나누는 Layer 구조이다.
이 구분을 먼저 잡아야 뒤에서 레이어드 아키텍처를 볼 때 헷갈리지 않는다.

클라이언트 층, 중간 층, EIS 층으로 나눈 Tier 구조와, 내부의 프레젠테이션 레이어, 비즈니스 로직 레이어, 데이터 액세스 레이어를 함께 보여 준다.
즉, Tier는 물리 구조, Layer는 역할 구조라고 구분하면 된다.


웹 서버와 WAS는 무엇이 다른가

웹 서버와 WAS는 비슷해 보이지만, 처음 이해할 때는 역할을 나누어 보는 편이 쉽다.
개념적으로 웹 서버는 HTML, CSS, 이미지 같은 정적인 자원을 전달하는 쪽에 가깝다.
반면 WAS는 요청에 따라 자바 코드가 실행되어야 하는 동적인 처리를 맡는 쪽에 가깝다.


WAS는 Web Application Server의 줄임말이다.
쉽게 말하면 웹 애플리케이션을 실행해 주는 서버이다.
단순히 파일을 보내는 것이 아니라, 요청에 따라 자바 코드를 실행하고, 필요하면 DB와 통신한 뒤 응답 결과를 만들어 낸다.


자바 웹 애플리케이션에서는 WAS 안에서 Servlet Container가 중요한 역할을 한다.
Servlet Container는 Servlet을 생성하고 관리하며, 요청이 들어왔을 때 알맞은 Servlet을 실행한다.
스프링 MVC에서 모든 요청의 공통 입구 역할을 하는 DispatcherServlet도 결국 Servlet Container 안에서 관리되는 Servlet이다.


처음 스프링 부트를 실행하면 별도로 Tomcat을 설치하지 않았는데도 서버가 실행되는 것처럼 보일 수 있다.
그 이유는 Spring Boot가 보통 내장 Tomcat을 함께 실행하기 때문이다.
여기서 Tomcat은 자바 웹 애플리케이션을 실행하고 Servlet을 관리하는 대표적인 서버 환경으로 이해하면 된다.


즉, 초보자 기준에서는 웹 서버와 WAS를 완벽하게 딱 잘라 외우기보다, 정적인 자원 전달 쪽과 자바 웹 애플리케이션 실행 쪽을 나누어 본다는 감각을 먼저 잡으면 된다.
이 감각이 있어야 뒤에서 Servlet, Servlet Container, DispatcherServlet의 관계도 덜 헷갈린다.

클라이언트, 웹 서버, WAS, Web Container, Servlet, Database로 이어지는 Web Service Architecture를 보여 준다.
즉, 브라우저 요청이 서버 안으로 들어오면 WAS와 Web Container가 동적인 처리를 담당하고, 그 안에서 Servlet이 요청 처리를 맡게 된다.


서블릿, 서블릿 컨테이너, 디스패처 서블릿

Servlet은 무엇인가

Servlet은 자바에서 웹 요청을 처리하기 위한 객체이다.
쉽게 말하면 브라우저가 보낸 요청을 받아서 자바 코드로 처리하고, 응답을 만들어 돌려주는 역할을 한다.
즉, 자바 웹 애플리케이션에서 요청 처리의 기본 단위라고 보면 된다.


일반 Servlet 방식에서는 요청을 처리하기 위해 doGet(), doPost() 같은 메서드를 작성한다.
doGet()은 주로 GET 요청을 처리할 때 사용하고, doPost()는 주로 POST 요청을 처리할 때 사용한다.
요청 정보는 HttpServletRequest로 들어오고, 응답은 HttpServletResponse를 통해 만든다.


이 방식은 자바 웹 요청 처리의 기본 구조를 이해하는 데 중요하다.
하지만 요청이 많아질수록 개발자가 여러 Servlet을 만들고, 각 요청을 직접 연결하고, 요청값을 꺼내고, 응답을 만드는 코드가 많아진다.
그러면 반복 코드가 늘어나고 요청 처리 구조도 복잡해진다.


스프링 MVC는 이런 흐름을 더 편하게 정리한다.
개발자가 일반 Servlet을 여러 개 직접 만들어 요청을 처리하기보다, 스프링이 제공하는 DispatcherServlet을 공통 입구로 두고, 개발자는 보통 Controller 메서드 중심으로 요청을 처리한다.


Servlet은 자바 웹 요청 처리의 기본 단위이고, 스프링 MVC에서는 DispatcherServlet이 대표적인 Servlet로서 모든 요청의 공통 입구 역할을 한다.


Servlet Container는 무엇인가

Servlet Container는 Servlet을 대신 관리해 주는 실행 환경이다.
개발자가 Servlet 객체를 직접 new로 만들고 실행하는 것이 아니다.
Servlet Container가 Servlet 객체를 생성하고, 초기화하고, 요청이 들어오면 호출하고, 애플리케이션이 종료될 때 정리한다.


즉, Servlet은 혼자서 움직이는 객체가 아니다.
항상 Servlet Container 안에서 관리되고 실행된다.
대표적으로 Tomcat은 자바 웹 애플리케이션에서 많이 사용하는 Servlet Container 역할을 한다.


여기서 헷갈리기 쉬운 점은 Servlet Container와 스프링 컨테이너가 다르다는 것이다.
둘 다 컨테이너라는 이름이 붙지만 관리 대상이 다르다.


Servlet Container는 웹 요청을 처리하는 Servlet을 관리한다.
반면 스프링 컨테이너는 Controller, Service, Repository 같은 스프링 Bean을 관리한다.
즉, Servlet Container는 웹 실행 환경에 가깝고, 스프링 컨테이너는 애플리케이션 객체 관리 공간에 가깝다.


정리하면 아래와 같다.

  • Servlet Container는 Servlet을 생성하고 실행하고 관리한다.
  • 스프링 컨테이너는 스프링 Bean을 생성하고 연결하고 관리한다.
  • DispatcherServlet은 Servlet Container 안에서 관리되는 Servlet이다.
  • Controller, Service, Repository는 스프링 컨테이너 안에서 관리되는 Bean이다.

이 구분을 잡아야 Servlet Container, IoC Container, 스프링 컨테이너라는 말이 나와도 덜 헷갈린다.

WAS 안에서 웹 애플리케이션마다 별도의 ServletContext 객체가 생성되고, /mvcedu, /edu처럼 각각 독립된 웹 프로젝트로 구분되는 구조를 보여 준다.
즉, 웹 애플리케이션마다 자기만의 실행 공간이 따로 잡힌다고 이해하면 된다.


DispatcherServlet은 왜 중요한가

스프링 MVC에서는 브라우저에서 들어오는 모든 요청을 먼저 DispatcherServlet이 받는다.


DispatcherServlet은 스프링 MVC의 공통 입구이다.
요청을 먼저 받고, 어떤 컨트롤러가 처리할지 찾고, 실행 결과를 다음 단계로 넘기는 전체 흐름을 관리한다.
그래서 스프링 웹 요청 처리의 시작점은 보통 Controller가 아니라 DispatcherServlet이다.


하지만 DispatcherServlet이 모든 일을 혼자 직접 처리하는 것은 아니다.
DispatcherServlet은 요청 처리의 총괄자에 가깝다.
실제로는 여러 구성요소와 함께 움직인다.


예를 들어 요청이 들어오면 DispatcherServlet은 먼저 HandlerMapping을 통해 어떤 컨트롤러 메서드가 이 요청을 처리할지 찾는다.
그다음 HandlerAdapter를 통해 찾은 컨트롤러 메서드를 실행한다.
컨트롤러 실행 결과가 뷰 이름이면 ViewResolver를 통해 실제 화면 파일을 찾고, 응답 데이터이면 MessageConverter를 통해 응답 본문을 만든다.


정리하면 아래와 같다.

  • DispatcherServlet은 모든 요청의 공통 입구이다.
  • HandlerMapping은 요청을 처리할 컨트롤러 메서드를 찾는다.
  • HandlerAdapter는 찾은 메서드를 실행할 수 있게 연결한다.
  • ViewResolver는 뷰 이름을 실제 화면 파일과 연결한다.
  • MessageConverter는 자바 객체를 응답 데이터 형식으로 바꾼다.

즉, DispatcherServlet은 요청 처리의 중심이지만, 혼자 모든 일을 직접 하는 객체는 아니다.
여러 구성요소에게 일을 나누어 맡기면서 스프링 MVC 요청 흐름을 통일한다.


코드로 먼저 보면

아래 예제는 브라우저에서 /hello 요청이 들어왔을 때 스프링이 어떤 식으로 처리하는지 가장 단순하게 보여 주는 예제이다.

// HelloController.java
@Controller
public class HelloController {
    @GetMapping("/hello") // /hello 요청 연결
    public String hello() {
        return "hello"; // 뷰 이름 반환
    }
}
// hello.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>hello</title>
</head>
<body>
<h1>Hello Spring MVC</h1>
</body>
</html>

브라우저가 /hello를 요청했다고 해서 바로 HelloController로 가는 것이 아니다.
먼저 DispatcherServlet이 요청을 받고, HandlerMapping이 HelloController의 hello() 메서드를 찾고, HandlerAdapter가 그 메서드를 실행한다.


여기서 중요한 점은 return "hello"의 의미이다.
일반 Controller에서 반환하는 "hello"는 브라우저에 그대로 출력할 문자열이 아니다.
스프링은 "hello"를 뷰 이름으로 해석한다.
그리고 ViewResolver가 이 이름을 바탕으로 실제 화면 파일을 찾는다.


Thymeleaf를 사용하는 경우라면 보통 templates/hello.html 같은 파일과 연결된다.
즉, "hello"라는 뷰 이름이 실제 hello.html 화면으로 이어지는 것이다.


같은 String을 반환하더라도 @RestController에서는 의미가 달라진다.
@RestController에서는 반환 문자열이 뷰 이름이 아니라 응답 본문으로 나간다.
이 차이는 뒤에서 일반 Controller와 RestController를 비교하면서 다시 정리한다.


스프링의 레이어드 아키텍처

역할별로 나누면 왜 좋은가

스프링에서는 애플리케이션 코드를 역할에 따라 나누어 관리한다.
이 구조를 레이어드 아키텍처라고 한다.
여기서 Layer는 물리적인 서버 위치가 아니라, 코드가 맡는 역할을 기준으로 나눈 논리적인 구분이다.


현재 프로젝트 구조를 보면 controller, service, repository, domain, core, config 패키지가 분리되어 있다.
이 구조는 파일을 보기 좋게 나누기 위한 장식이 아니다.
각 패키지가 맡는 책임을 분리하기 위한 구조이다.


예를 들어 회원 목록 조회 기능을 만든다고 해 보자.
사용자의 요청을 받는 일, 회원 목록 조회 조건을 판단하는 일, 회원 데이터를 조회하는 일, 응답에 필요한 데이터를 담는 일, 회원 상태 같은 정해진 값을 관리하는 일은 모두 성격이 다르다.
이 모든 일을 한 클래스에 넣으면 처음에는 코드가 짧아 보일 수 있다.
하지만 기능이 커질수록 수정할 위치를 찾기 어려워지고, 한 곳을 고쳤는데 다른 기능까지 영향을 받을 수 있다.


레이어드 아키텍처는 코드를 무조건 많이 나누는 방식이 아니라, 변경 이유가 비슷한 코드끼리 모으고 다른 역할의 코드는 분리하는 방식이다.
그래서 중요한 기준은 “파일이 몇 개냐”가 아니라 “각 코드가 자기 역할만 맡고 있느냐”이다.

프레젠테이션 계층, 비즈니스 계층, 데이터 접근 계층으로 나눈 스프링 레이어드 아키텍처 구조를 보여 준다.
현재 프로젝트에서는 여기에 domain, core, config 같은 패키지까지 함께 보면서 역할을 구분하면 된다.


현재 프로젝트 구조 기준으로 먼저 보기

현재 프로젝트는 기능별로 member 패키지 안에 controller, service, repository를 모두 넣은 구조가 아니다.
사진 기준으로 보면 큰 역할별 패키지를 먼저 나눈 구조에 가깝다.


현재 구조는 아래처럼 볼 수 있다.

// ProjectStructure.txt
com.example.groupbuyingweb
├── config
├── controller
├── core
├── domain
│   ├── dto
│   ├── entity
│   └── enums
├── repository
├── service
└── GroupbuyingwebApplication

각 패키지의 큰 역할은 아래와 같다.

  • config는 프로젝트 설정 코드를 담는다.
  • controller는 요청과 응답을 처리하는 입구 코드를 담는다.
  • core는 여러 기능에서 함께 쓰는 공통 기반 코드를 담는다.
  • domain은 요청, 응답, 저장 데이터, 정해진 값처럼 핵심 데이터 타입을 담는다.
  • repository는 DB 접근 코드를 담는다.
  • service는 실제 기능 처리 흐름을 담는다.

이 구조에서 특히 domain을 조심해서 봐야 한다.
domain 안에는 dto, entity, enums가 함께 들어간다.
하지만 dto가 domain 안에 있다고 해서 DTO가 Entity와 같은 역할을 한다는 뜻은 아니다.


폴더 위치와 객체의 역할은 구분해서 봐야 한다.
현재 프로젝트에서 domain 패키지는 기능 처리 과정에서 사용되는 데이터 타입들을 모아 두는 영역이다.
그 안에서도 dto, entity, enums는 각각 역할이 다르다.


controller 패키지는 요청과 응답의 입구이다

controller 패키지는 사용자의 요청을 받는 클래스를 담는다.
브라우저나 클라이언트가 특정 URL로 요청을 보내면, 스프링은 그 요청을 처리할 Controller 메서드를 찾는다.


Controller는 요청 URL, 요청 방식, 요청값을 받는다.
그리고 직접 모든 일을 처리하지 않고, 필요한 Service를 호출한다.
처리가 끝나면 화면 이름을 반환하거나, 응답 데이터를 클라이언트에게 돌려준다.


예를 들어 /members 요청이 들어왔다고 해 보자.
MemberController는 이 요청을 받고, 회원 목록을 가져오기 위해 MemberService를 호출한다.
회원 목록을 어떤 기준으로 가져올지, 어떤 검증이 필요한지, 어떤 데이터로 응답할지는 Controller가 혼자 다 처리하지 않는다.


Controller를 접수 창구로 생각하면 쉽다.
사용자가 요청을 접수하면, Controller는 그 요청을 실제 처리 부서인 Service에 넘긴다.
그리고 처리 결과를 다시 사용자에게 돌려준다.


정리하면 Controller의 역할은 아래와 같다.

  • 요청 URL을 받는다.
  • 요청 방식을 구분한다.
  • 요청값을 받는다.
  • 필요한 Service를 호출한다.
  • 처리 결과를 화면이나 응답 본문으로 돌려준다.
  • 실제 핵심 처리 규칙이나 DB 접근은 직접 맡지 않는다.

Controller가 너무 많은 일을 직접 처리하면 코드가 무거워진다.
요청 처리 코드, 검증 코드, 조회 조건, 저장 코드가 한곳에 섞이면 나중에 수정하기 어렵다.
그래서 Controller는 요청과 응답의 입구 역할에 집중하는 것이 좋다.


service 패키지는 실제 기능 처리 흐름을 담당한다

service 패키지는 실제 기능 처리 흐름을 담당하는 클래스를 담는다.
여기서 기능 처리 흐름이란 프로그램이 실제로 해야 하는 일을 어떤 순서로 처리할지 정하는 흐름이다.


예를 들어 회원 목록 조회 기능이라면 Service는 회원 목록을 어떤 기준으로 가져올지 판단하고, 필요하면 Repository를 호출한다.
회원가입 기능이라면 아이디 중복 확인, 비밀번호 암호화, 회원 Entity 생성, 저장 요청 같은 흐름이 Service에 들어갈 수 있다.


초보자는 처음에 Service를 단순한 중간 통로로 오해하기 쉽다.
예제 코드에서는 Service가 Repository를 한 번 호출하고 끝나는 것처럼 보일 수 있기 때문이다.
하지만 실제 프로젝트에서는 검증, 조건 판단, 여러 Repository 호출, 도메인 객체 생성, 트랜잭션 처리 같은 흐름이 Service에 모인다.


정리하면 Service의 역할은 아래와 같다.

  • 실제 기능 처리 흐름을 담당한다.
  • 필요한 검증과 조건 판단을 수행한다.
  • 필요한 Entity나 DTO를 사용한다.
  • 필요한 Repository를 호출한다.
  • 여러 작업을 하나의 흐름으로 묶는다.
  • 트랜잭션 처리가 필요한 경우 중심 위치가 될 수 있다.

Service는 단순 전달자가 아니라, 기능이 어떤 순서로 처리되어야 하는지 조정하는 중심 계층이다.
그래서 프로젝트가 커질수록 Service의 역할이 중요해진다.


repository 패키지는 DB 접근을 담당한다

repository 패키지는 DB에 접근하는 클래스를 담는다.
Service가 데이터를 저장하거나 조회해야 할 때 Repository를 호출한다.
Repository는 domain/entity에 있는 Entity를 기준으로 데이터를 저장하거나 조회한다.


예를 들어 회원 목록 조회 기능에서 Service가 회원 데이터가 필요하다고 판단하면 MemberRepository를 호출한다.
그러면 MemberRepository는 실제 저장소에서 회원 데이터를 가져온다.
여기서 저장소는 실제 DB일 수도 있고, 예제에서는 임시 데이터일 수도 있다.


Repository는 데이터 접근을 담당하지만, 비즈니스 판단을 하는 곳은 아니다.
예를 들어 회원을 저장할 수 있는지, 어떤 조건에서 조회해야 하는지, 어떤 예외를 처리해야 하는지는 보통 Service에서 판단한다.
Repository는 그 판단에 따라 실제 데이터를 저장하거나 조회하는 역할에 집중한다.


정리하면 Repository의 역할은 아래와 같다.

  • DB에서 데이터를 조회한다.
  • DB에 데이터를 저장한다.
  • DB 데이터를 수정하거나 삭제한다.
  • Service가 DB 접근 방식을 직접 알지 않아도 되게 한다.
  • Entity를 기준으로 저장소와 데이터를 주고받는다.

Repository와 DAO는 둘 다 데이터 접근을 담당한다는 점에서 비슷하다.
DAO는 Data Access Object의 줄임말로, 전통적인 SQL 중심 구조나 MyBatis 같은 방식에서 자주 볼 수 있다.
JPA를 사용하는 스프링 프로젝트에서는 보통 Repository라는 이름을 많이 사용한다.


초보자 기준에서는 먼저 이렇게 이해하면 된다.
Service는 무엇을 해야 할지 판단하고, Repository나 DAO는 데이터를 어디서 가져오고 어디에 저장할지 담당한다.


domain 패키지는 핵심 데이터 타입을 모아 둔다

domain 패키지는 현재 프로젝트에서 핵심 데이터 타입을 모아 둔 영역이다.
여기에는 dto, entity, enums가 함께 들어간다.
이 구조를 볼 때 가장 중요한 것은 domain을 하나의 호출 계층처럼 오해하지 않는 것이다.


요청 흐름은 보통 Controller → Service → Repository 중심으로 움직인다.
그 과정에서 domain 안에 있는 DTO, Entity, enum이 사용된다.
즉, domain은 Controller 다음에 호출되고 Repository 전에 호출되는 중간 단계가 아니다.
기능 처리 과정에서 필요한 핵심 데이터 타입을 모아 둔 영역이다.


현재 프로젝트 기준으로 보면 아래처럼 이해하면 된다.

  • domain/entity는 DB에 저장되는 핵심 데이터 객체를 담는다.
  • domain/dto는 요청과 응답 데이터를 전달하는 객체를 담는다.
  • domain/enums는 상태나 역할처럼 정해진 값 목록을 담는다.

domain은 하나의 호출 계층이라기보다, 요청 처리 과정에서 사용되는 Entity, DTO, enum 같은 핵심 데이터 타입을 모아 둔 영역이다.
그래서 domain 안에 같이 있더라도 entity, dto, enums의 역할은 반드시 구분해서 봐야 한다.


domain/entity는 저장되는 데이터 객체를 담는다

domain/entity는 DB 테이블과 연결되는 객체를 담는 패키지이다.
JPA를 사용한다면 보통 @Entity가 붙은 클래스가 이 위치에 들어간다.
Entity는 실제 저장 대상이 되는 핵심 데이터 객체이다.


예를 들어 회원 정보를 저장한다면 Member 클래스가 Entity가 될 수 있다.
Member는 회원 아이디, 로그인 아이디, 닉네임 같은 회원 데이터를 가진다.
이 객체는 단순히 화면에 보여 주기 위한 데이터가 아니라, 실제 저장소에 저장되는 데이터 구조와 연결된다.


초보자 기준에서는 Entity를 DB에 저장되는 객체라고 이해하면 된다.
화면에 보여 주기 위한 값만 담는 객체가 아니라, 실제 데이터베이스 테이블과 연결되는 핵심 저장 객체이다.


예시는 아래와 같다.

// Member.java
public class Member {
    private Long id; // 회원 식별자
    private String loginId; // 로그인 아이디
    private String nickname; // 닉네임

    public Member(Long id, String loginId, String nickname) {
        this.id = id; // 식별자 저장
        this.loginId = loginId; // 로그인 아이디 저장
        this.nickname = nickname; // 닉네임 저장
    }

    public Long getId() {
        return id; // 식별자 반환
    }

    public String getLoginId() {
        return loginId; // 로그인 아이디 반환
    }

    public String getNickname() {
        return nickname; // 닉네임 반환
    }
}

이 예제에서는 설명을 단순하게 하기 위해 @Entity 같은 세부 설정은 생략했다.
중요한 점은 Member가 회원이라는 저장 대상 데이터를 표현하는 객체라는 것이다.
실제 JPA 프로젝트에서는 이 클래스에 @Entity, @Id, @Column 같은 매핑 정보가 추가될 수 있다.


domain/dto는 요청과 응답 데이터를 전달한다

domain/dto는 요청과 응답에 필요한 데이터 전달 객체를 담는 패키지이다.
DTO는 Data Transfer Object의 줄임말이다.
뜻 그대로 데이터를 전달하기 위한 객체이다.


여기서 꼭 구분해야 할 점이 있다.
DTO가 domain 패키지 안에 있다고 해서 DTO가 Entity와 같은 역할을 하는 것은 아니다.
Entity는 저장되는 데이터 객체이고, DTO는 데이터를 전달하기 위한 객체이다.


예를 들어 회원 목록 화면에 회원의 모든 정보가 필요하지 않을 수 있다.
화면에는 회원 식별자와 닉네임만 필요할 수 있다.
이때 Member Entity 전체를 그대로 넘기지 않고, 응답에 필요한 값만 담은 MemberResponse DTO를 만들 수 있다.

// MemberResponse.java
public class MemberResponse {
    private Long id; // 응답에 필요한 회원 식별자
    private String nickname; // 응답에 필요한 닉네임

    public MemberResponse(Long id, String nickname) {
        this.id = id; // 식별자 저장
        this.nickname = nickname; // 닉네임 저장
    }

    public Long getId() {
        return id; // 식별자 반환
    }

    public String getNickname() {
        return nickname; // 닉네임 반환
    }
}

DTO는 요청 데이터를 받을 때도 사용하고, 응답 데이터를 보낼 때도 사용할 수 있다.
요청 데이터를 받을 때 쓰는 객체를 Request DTO라고 부를 수 있고, 응답 데이터를 보낼 때 쓰는 객체를 Response DTO라고 부를 수 있다.


정리하면 DTO의 역할은 아래와 같다.

  • 요청 데이터를 받는다.
  • 응답 데이터를 담는다.
  • 화면이나 API에 필요한 값만 전달한다.
  • Entity 전체를 외부에 그대로 노출하지 않도록 도와준다.

DTO가 domain 패키지 안에 있어도 DTO의 역할은 도메인 규칙을 담는 것이 아니라 요청과 응답 데이터를 전달하는 것이다.
그래서 Entity와 DTO는 위치가 가까워 보여도 목적이 다르다.


domain/enums는 정해진 값 목록을 담는다

domain/enums는 정해진 값 목록을 표현하는 타입을 담는 패키지이다.
여기서 enum은 선택지가 정해져 있는 값을 코드로 안전하게 표현할 때 사용한다.


예를 들어 회원 역할이 일반 회원과 관리자처럼 정해져 있다고 해 보자.
이 값을 문자열로 직접 "USER", "ADMIN"처럼 여기저기 쓰면 오타가 날 수 있다.
"ADMN"처럼 잘못 입력해도 단순 문자열이면 컴파일 단계에서 잡기 어렵다.


이럴 때 enum으로 정해진 값 목록을 만들면 사용할 수 있는 값을 제한할 수 있다.

// MemberRole.java
public enum MemberRole {
    USER, // 일반 회원
    ADMIN // 관리자
}

회원 상태도 정해진 값으로 관리할 수 있다.

// MemberStatus.java
public enum MemberStatus {
    ACTIVE, // 활성 회원
    WITHDRAWN // 탈퇴 회원
}

enum을 사용하면 아무 문자열이나 넣는 실수를 줄일 수 있다.
또 코드만 봐도 어떤 값들이 가능한지 알 수 있다.


정리하면 domain/enums의 역할은 아래와 같다.

  • 정해진 값 목록을 관리한다.
  • 상태값, 역할값, 타입값을 표현한다.
  • 문자열 오타로 인한 실수를 줄인다.
  • 사용할 수 있는 값을 코드 수준에서 제한한다.

즉, domain/enums는 회원 역할, 회원 상태처럼 선택지가 정해진 값을 안전하게 관리하기 위한 영역이다.


core 패키지는 공통 기반 코드를 담는다

core 패키지는 여러 기능에서 공통으로 사용하는 기반 코드를 담는 영역으로 볼 수 있다.
특정 기능 하나에만 묶이는 코드가 아니라, 프로젝트 전체에서 함께 쓰이는 코드가 들어갈 수 있다.


예를 들어 공통 응답 형식, 공통 예외 처리, 에러 코드, 공통 상수, 공통 유틸 코드가 core에 들어갈 수 있다.
회원 기능에서도 쓰고, 게시글 기능에서도 쓰고, 다른 기능에서도 함께 쓰는 코드라면 core에 둘 수 있다.


다만 core에 무엇을 넣을지는 프로젝트 규칙에 따라 달라질 수 있다.
중요한 점은 core가 실제 비즈니스 기능 하나를 직접 담당하는 패키지가 아니라는 것이다.
공통 기반을 정리하기 위한 패키지에 가깝다.


정리하면 core의 역할은 아래와 같다.

  • 여러 기능에서 함께 쓰는 공통 코드를 담는다.
  • 공통 응답 형식을 담을 수 있다.
  • 공통 예외와 에러 코드를 담을 수 있다.
  • 공통 유틸이나 상수를 담을 수 있다.
  • 특정 기능 하나의 처리 흐름보다는 프로젝트 기반 코드를 담는다.

즉, core는 기능별 로직보다 공통 기반을 정리하는 영역으로 이해하면 된다.


config 패키지는 설정 코드를 담는다

config 패키지는 프로젝트 설정 코드를 담는 영역이다.
기능을 직접 처리하는 코드라기보다, 프로젝트가 어떤 방식으로 동작할지 환경을 잡아 주는 코드가 들어간다.


예를 들어 웹 설정, 보안 설정, 외부 API 설정, CORS 설정, 직접 등록해야 하는 Bean 설정 등이 들어갈 수 있다.
이 설정들은 회원 목록을 조회한다거나 회원을 저장한다거나 하는 기능 자체를 직접 처리하는 코드는 아니다.
하지만 애플리케이션이 올바르게 동작하려면 필요한 환경 설정이다.


정리하면 config의 역할은 아래와 같다.

  • 프로젝트 설정 코드를 담는다.
  • 웹 관련 설정을 담을 수 있다.
  • 보안 관련 설정을 담을 수 있다.
  • 외부 API 연동 설정을 담을 수 있다.
  • CORS나 Bean 등록 설정을 담을 수 있다.

즉, config는 기능을 직접 수행하는 계층이 아니라, 프로젝트가 어떤 방식으로 동작할지 정해 주는 설정 영역이다.


요청 흐름으로 보면 더 쉽게 이해된다

레이어드 구조는 요청이 들어왔을 때의 이동 흐름으로 보면 더 쉽게 이해된다.
예를 들어 브라우저가 /members라는 주소로 회원 목록을 요청한다고 해 보자.
이 요청은 먼저 DispatcherServlet을 거쳐 알맞은 Controller로 전달된다.


그다음 흐름은 보통 아래처럼 이어진다.

  • Controller가 /members 요청을 받는다.
  • Controller는 직접 DB를 조회하지 않고 Service를 호출한다.
  • Service는 회원 목록 조회에 필요한 처리 흐름을 담당한다.
  • Service는 실제 데이터 조회가 필요할 때 Repository를 호출한다.
  • Repository는 DB에서 Member Entity를 조회한다.
  • 조회된 데이터는 다시 Repository → Service → Controller 순서로 돌아온다.
  • Service는 필요한 경우 Entity를 DTO로 바꾼다.
  • Controller는 결과를 화면에 담거나 응답 본문으로 돌려준다.

이 흐름을 한 줄로 보면 아래와 같다.


브라우저 요청 → DispatcherServlet → Controller → Service → Repository → DB → Repository → Service → Controller → 응답


여기서 domain은 독립적으로 호출되는 중간 계층이 아니다.
domain/entity, domain/dto, domain/enums는 이 요청 흐름 안에서 사용되는 데이터 타입이다.


예를 들어 요청값은 domain/dto의 Request DTO로 받을 수 있다.
저장 대상은 domain/entity의 Entity로 만들 수 있다.
회원 역할이나 회원 상태 같은 값은 domain/enums의 enum으로 표현할 수 있다.
응답값은 다시 domain/dto의 Response DTO로 보낼 수 있다.


요청 흐름은 Controller → Service → Repository 중심으로 움직이고, 그 과정에서 domain 내부 객체들이 사용된다.
이렇게 이해해야 domain을 호출 계층처럼 오해하지 않는다.


Controller가 Repository를 바로 호출하면 왜 좋지 않은가

초보자 입장에서는 Controller에서 바로 Repository를 호출하면 더 간단해 보일 수 있다.
요청을 받자마자 데이터를 조회하면 코드가 짧아 보이기 때문이다.
실제로 아주 단순한 조회라면 동작 자체는 할 수 있다.


하지만 이 방식은 기능이 커질수록 문제가 생긴다.
회원 조회 전에 권한 검사, 검색 조건 처리, 정렬 기준 적용, 예외 처리 같은 로직이 들어가기 시작하면 Controller가 점점 무거워진다.


아래 코드는 Controller가 Repository를 바로 호출하는 예이다.

// BadMemberController.java
@Controller
public class BadMemberController {
    private final MemberRepository memberRepository; // 저장소에 직접 의존

    public BadMemberController(MemberRepository memberRepository) {
        this.memberRepository = memberRepository; // Repository 직접 주입
    }

    @GetMapping("/members")
    public String members(Model model) {
        model.addAttribute("members", memberRepository.findAll()); // Controller가 직접 조회
        return "member/list"; // 뷰 이름 반환
    }
}

이 예제는 단순 조회만 보면 문제없어 보일 수 있다.
하지만 회원 조회 조건이 복잡해지거나, 응답 데이터 변환이 필요하거나, 예외 처리가 추가되면 Controller 안에 여러 역할이 섞이게 된다.


Controller가 무거워지면 요청 처리 코드와 실제 처리 규칙이 섞인다.
그러면 화면 응답 방식을 바꾸고 싶을 때도, 조회 규칙을 바꾸고 싶을 때도 같은 Controller를 계속 수정하게 된다.
즉, 수정 이유가 서로 다른 코드가 한곳에 모이게 된다.


그래서 보통은 Controller가 Repository를 바로 호출하지 않고, 중간에 Service를 둔다.

// MemberController.java
@Controller
public class MemberController {
    private final MemberService memberService; // 서비스 의존

    public MemberController(MemberService memberService) {
        this.memberService = memberService; // 생성자 주입
    }

    @GetMapping("/members")
    public String members(Model model) {
        model.addAttribute("members", memberService.findAll()); // 서비스 호출
        return "member/list"; // 뷰 이름 반환
    }
}

이 구조에서는 Controller가 요청과 응답 흐름에 집중할 수 있다.
회원 목록을 어떤 기준으로 가져올지, 어떤 데이터로 바꿔서 보낼지에 대한 판단은 Service로 넘긴다.
그래서 기능이 커져도 각 계층의 역할이 덜 섞인다.


레이어드 구조를 코드로 보면

아래 예제는 요청이 들어왔을 때 Controller → Service → Repository 순서로 역할이 나뉘는 가장 기본적인 흐름이다.
현재 프로젝트 구조 기준으로 파일 위치를 함께 보면 더 이해하기 쉽다.


예제 파일 위치는 아래처럼 볼 수 있다.

  • Member.java는 domain/entity에 들어간다.
  • MemberResponse.java는 domain/dto에 들어간다.
  • MemberRepository.java는 repository에 들어간다.
  • MemberService.java는 service에 들어간다.
  • MemberController.java는 controller에 들어간다.

먼저 저장 대상이 되는 Member 객체를 본다.

// Member.java
public class Member {
    private Long id; // 회원 식별자
    private String loginId; // 로그인 아이디
    private String nickname; // 닉네임

    public Member(Long id, String loginId, String nickname) {
        this.id = id; // 식별자 저장
        this.loginId = loginId; // 로그인 아이디 저장
        this.nickname = nickname; // 닉네임 저장
    }

    public Long getId() {
        return id; // 식별자 반환
    }

    public String getNickname() {
        return nickname; // 닉네임 반환
    }
}

Member는 회원이라는 저장 대상 데이터를 표현하는 Entity 예시이다.
실제 프로젝트에서 JPA를 사용하면 @Entity가 붙고, DB 테이블과 매핑될 수 있다.


다음은 화면이나 응답에 필요한 데이터만 담는 DTO이다.

// MemberResponse.java
public class MemberResponse {
    private Long id; // 응답에 필요한 회원 식별자
    private String nickname; // 응답에 필요한 닉네임

    public MemberResponse(Long id, String nickname) {
        this.id = id; // 식별자 저장
        this.nickname = nickname; // 닉네임 저장
    }

    public Long getId() {
        return id; // 식별자 반환
    }

    public String getNickname() {
        return nickname; // 닉네임 반환
    }
}

MemberResponse는 응답에 필요한 값만 담는 DTO이다.
여기서는 loginId를 응답에 포함하지 않았다.
이처럼 DTO를 사용하면 화면이나 API에 필요한 값만 골라서 전달할 수 있다.


다음은 데이터를 가져오는 Repository이다.

// MemberRepository.java
@Repository
public class MemberRepository {
    public List<Member> findAll() {
        return List.of(
                new Member(1L, "member1", "이언덕"),
                new Member(2L, "member2", "김스프링")
        ); // 예시 회원 데이터 반환
    }
}

이 예제의 Repository는 실제 DB를 조회하지 않고 예시 데이터를 반환한다.
레이어드 구조의 흐름을 보기 위한 단순 예제이다.
실제 프로젝트에서는 JPA, MyBatis, DAO 등을 사용해서 DB와 연결될 수 있다.


다음은 실제 처리 흐름을 담당하는 Service이다.

// MemberService.java
@Service
public class MemberService {
    private final MemberRepository memberRepository; // 저장소 의존

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository; // 생성자 주입
    }

    public List<MemberResponse> findAll() {
        return memberRepository.findAll()
                .stream()
                .map(member -> new MemberResponse(member.getId(), member.getNickname()))
                .toList(); // Entity를 응답 DTO로 변환
    }
}

MemberService는 Repository에서 Member 목록을 가져온다.
그리고 화면에 필요한 형태인 MemberResponse로 바꾼다.
이 예제에서는 변환 정도만 하지만, 실제 프로젝트에서는 검증, 조건 판단, 여러 저장소 호출, 트랜잭션 처리 같은 흐름이 더 들어갈 수 있다.


마지막은 요청을 받는 Controller이다.

// MemberController.java
@Controller
public class MemberController {
    private final MemberService memberService; // 서비스 의존

    public MemberController(MemberService memberService) {
        this.memberService = memberService; // 생성자 주입
    }

    @GetMapping("/members")
    public String members(Model model) {
        model.addAttribute("members", memberService.findAll()); // 뷰에 전달할 데이터 저장
        return "member/list"; // 뷰 이름 반환
    }
}

이 구조에서 Controller는 요청을 받고, Service는 실제 처리 흐름을 조정하고, Repository는 데이터를 꺼낸다.
Member는 저장 대상인 Entity이고, MemberResponse는 응답 데이터 전달용 DTO이다.


즉, 같은 회원 기능 안에서도 객체마다 역할이 다르다.
Member는 저장되는 데이터이고, MemberResponse는 응답에 필요한 데이터만 담는다.
이 차이를 이해해야 나중에 Entity와 DTO를 섞어 쓰는 실수를 줄일 수 있다.


계층별 어노테이션은 역할을 드러낸다

스프링에서는 계층별 역할을 드러내기 위해 @Controller, @Service, @Repository 같은 어노테이션을 자주 사용한다.
이 어노테이션들은 모두 스프링이 객체를 관리하게 만든다는 점에서는 @Component 계열로 볼 수 있다.
즉, 스프링 컨테이너에 Bean으로 등록되는 대상이 된다.


하지만 이름을 다르게 쓰는 이유가 있다.
각 클래스가 어떤 역할을 맡는지 코드만 봐도 알 수 있게 하기 위해서이다.
@Controller가 붙으면 요청을 받는 계층이라는 뜻이 강하게 드러난다.
@Service가 붙으면 실제 기능 처리 흐름을 담당하는 계층이라는 뜻이 드러난다.
@Repository가 붙으면 데이터 접근 계층이라는 뜻이 드러난다.


정리하면 아래와 같다.

  • @Controller는 웹 요청을 받는 클래스에 붙인다.
  • @Service는 비즈니스 로직과 처리 흐름을 담당하는 클래스에 붙인다.
  • @Repository는 데이터 접근을 담당하는 클래스에 붙인다.

@Repository는 데이터 접근 계층에서 발생하는 예외를 스프링 예외 체계로 바꾸는 기능과도 연결될 수 있다.
다만 초보자 단계에서는 먼저 데이터 접근 역할을 드러내는 표시라고 이해하면 충분하다.


계층별 어노테이션은 단순히 스프링에 등록하기 위한 표시만이 아니라, 이 클래스가 어떤 역할을 맡는지 드러내는 표시이다.
그래서 같은 Bean 등록이라도 역할에 맞는 어노테이션을 붙이면 코드 구조를 훨씬 쉽게 읽을 수 있다.


레이어드 구조에서 헷갈리기 쉬운 점

레이어드 구조를 처음 배울 때는 파일이 나뉘어 있다는 사실만 보고 이해했다고 착각하기 쉽다.
하지만 중요한 것은 파일 개수가 아니라 책임 분리이다.


파일을 많이 나눈다고 무조건 좋은 구조가 되는 것은 아니다.
Controller 안에 요청 처리, 비밀번호 검증, 회원 저장 규칙, DB 조회 코드가 모두 들어 있다면 파일은 하나라서 단순해 보일 수 있다.
하지만 역할은 전부 섞여 있다.


반대로 파일이 여러 개로 나뉘어 있어도 각 파일의 역할이 불분명하면 좋은 구조라고 보기 어렵다.
Service가 아무 이유 없이 모든 일을 떠넘기기만 하거나, Repository가 비즈니스 판단을 하거나, DTO와 Entity를 구분 없이 사용하면 구조가 다시 복잡해진다.


초보자 기준에서는 아래 기준으로 보면 된다.

  • 화면 요청과 응답 흐름은 Controller에서 처리한다.
  • 실제 처리 규칙과 판단은 Service에서 처리한다.
  • 데이터 저장과 조회는 Repository 또는 DAO에서 처리한다.
  • 저장되는 핵심 데이터 객체는 domain/entity에 둔다.
  • 요청과 응답에 필요한 데이터 전달 객체는 domain/dto에 둔다.
  • 상태나 역할처럼 정해진 값 목록은 domain/enums에 둔다.
  • 공통 기반 코드는 core에 둘 수 있다.
  • 설정 코드는 config에 둔다.

이 기준이 모든 프로젝트에서 완벽한 정답이라는 뜻은 아니다.
프로젝트마다 패키지 구조는 달라질 수 있다.
하지만 현재 프로젝트 구조를 읽을 때는 이 기준이 있어야 코드가 덜 헷갈린다.


레이어드 아키텍처의 핵심은 코드를 많이 나누는 것이 아니라, 역할이 다른 코드를 섞지 않는 것이다.


스프링 MVC와 Front Controller

모든 요청을 먼저 하나의 입구에서 받는다

스프링 MVC는 Front Controller 패턴을 사용한다.
Front Controller는 모든 요청을 각각의 컨트롤러가 바로 받는 것이 아니라, 먼저 하나의 공통 입구에서 받는 구조를 말한다.
스프링 MVC에서는 DispatcherServlet이 이 공통 입구 역할을 한다.


여기서 Front Controller는 설계 패턴이고, DispatcherServlet은 그 패턴을 실제로 구현한 스프링의 객체라고 보면 된다.
즉, 둘은 완전히 같은 단어가 아니다.
Front Controller는 구조를 설명하는 말이고, DispatcherServlet은 그 구조에서 앞문 역할을 하는 실제 객체이다.


이 구조가 중요한 이유는 요청 흐름을 통일할 수 있기 때문이다.
모든 요청이 같은 입구를 지나면 공통 처리 규칙을 적용하기 쉽고, 요청 분배도 일정한 방식으로 정리할 수 있다.
그래서 스프링 MVC는 요청 처리 구조가 비교적 일관되게 유지된다.

그림에서 Front Controller라고 표시된 부분이 바로 스프링 MVC의 DispatcherServlet 역할이다.
즉, 여러 컨트롤러 앞에서 모든 요청을 먼저 받는 하나의 공통 입구 역할을 한다.


DispatcherServlet 안에서는 어떤 일이 일어나는가

DispatcherServlet은 스프링 MVC에서 모든 웹 요청 처리 흐름을 총괄하는 중심 객체이다.
요청을 먼저 받고, 어떤 컨트롤러가 처리해야 할지 찾고, 실행 결과를 다음 단계로 넘긴다.


조금 더 자세히 보면 흐름은 아래처럼 움직인다.

  • DispatcherServlet이 요청을 받는다.
  • HandlerMapping이 요청을 처리할 컨트롤러 메서드를 찾는다.
  • HandlerAdapter가 찾은 컨트롤러 메서드를 실행한다.
  • 컨트롤러가 결과를 반환한다.
  • 결과가 뷰 이름이면 ViewResolver를 통해 화면 파일을 찾는다.
  • 결과가 응답 데이터이면 MessageConverter를 통해 응답 본문을 만든다.

즉, DispatcherServlet은 요청을 받은 뒤 흐름을 총괄한다.
하지만 실제로는 HandlerMapping, HandlerAdapter, ViewResolver, MessageConverter 같은 구성요소와 함께 움직인다.


초보자는 이름을 외우는 것보다 먼저 순서를 잡는 것이 중요하다.
입구가 요청을 받는다 → 담당자를 찾는다 → 실행한다 → 결과를 화면이나 데이터 형태로 정리한다 → 응답한다는 흐름만 먼저 잡아도 이후 코드가 훨씬 쉽게 읽힌다.


일반 Controller의 요청 처리 흐름

내부에서는 이런 순서로 움직인다

일반 Controller는 보통 화면 응답을 만들 때 사용한다.
여기서 화면 응답이란 최종적으로 사용자가 볼 HTML 화면을 응답하는 흐름이다.


일반 Controller가 최종 HTML 전체를 직접 문자열로 만들어 내는 것은 아니다.
보통은 어떤 화면을 보여 줄지에 대한 뷰 이름을 반환하고, 화면에 필요한 데이터는 Model에 담는다.
그다음 ViewResolver가 뷰 이름을 실제 화면 파일과 연결한다.


여기서 Model은 컨트롤러가 뷰로 데이터를 넘기기 위해 사용하는 그릇이다.
예를 들어 회원 목록을 화면에 보여 주고 싶으면 Controller는 Model에 회원 목록 데이터를 담고, "member/list" 같은 뷰 이름을 반환한다.


Thymeleaf를 사용하는 경우라면 "member/list"라는 뷰 이름은 보통 templates/member/list.html 같은 파일과 연결될 수 있다.
즉, 컨트롤러가 반환하는 문자열은 최종 응답 문자열이 아니라 화면 파일을 찾기 위한 이름이다.

DispatcherServlet, HandlerMapping, HandlerAdapter, Controller, ViewResolver, View로 이어지는 스프링 MVC의 상세 처리 순서를 보여 준다.
즉, 요청이 들어오면 누가 처리할지 찾고, 실행하고, 화면을 찾아 응답하는 흐름으로 이해하면 된다.


각 구성요소를 아주 쉽게 풀면

일반 Controller의 화면 응답 흐름은 여러 구성요소가 함께 움직인다.
이름이 어렵게 느껴질 수 있지만, 각 역할을 요청 흐름에 맞춰 보면 훨씬 쉽다.

  • HandlerMapping은 누가 처리할지 찾는다.
  • HandlerAdapter는 찾은 컨트롤러 메서드를 실제로 실행할 수 있게 연결한다.
  • Controller는 요청을 처리하고 뷰 이름과 데이터를 준비한다.
  • Model은 뷰에 전달할 데이터를 담는다.
  • ViewResolver는 화면 이름을 실제 화면 파일과 연결한다.
  • View는 최종적으로 사용자에게 보여 줄 HTML 화면을 만든다.

예를 들어 /members 요청이 들어오면 HandlerMapping은 이 요청을 처리할 MemberController.members() 메서드를 찾는다.
HandlerAdapter는 그 메서드를 실행할 수 있게 도와준다.
MemberController는 Model에 회원 목록을 담고 "member/list"라는 뷰 이름을 반환한다.
그다음 ViewResolver가 "member/list"를 실제 화면 파일과 연결한다.


여기서 특히 ViewResolver가 중요하다.
컨트롤러는 보통 화면 이름만 넘기고, 실제 어떤 화면 파일을 연결할지는 ViewResolver가 결정한다.
즉, 컨트롤러와 실제 화면 파일 사이를 이어 주는 중간 연결자라고 보면 된다.


앞에서 본 /hello 예제를 다시 떠올려 보면, 브라우저가 /hello를 요청했을 때 DispatcherServlet이 먼저 받고, HandlerMapping이 HelloController의 hello()를 찾고, HandlerAdapter가 그것을 실행한다.
그리고 "hello"라는 반환값을 ViewResolver가 실제 hello.html과 연결한다.
즉, 짧은 컨트롤러 코드 안에서도 스프링 MVC 내부 흐름이 이미 움직이고 있다.


일반 Controller에서 DispatcherServlet, HandlerMapping, Controller, ViewResolver, View로 이어지는 처리 구조를 보여 준다.
즉, 일반 Controller는 데이터를 Model에 담아 뷰로 넘기고, 그다음 화면을 찾아 응답하는 흐름으로 이해하면 된다.


RestController의 처리 흐름

뷰를 찾지 않고 데이터를 바로 응답한다

RestController는 일반 Controller와 다르게 움직인다.
일반 Controller가 주로 뷰 이름을 반환해 화면을 찾는 흐름이라면, RestController는 반환값을 응답 본문에 바로 담는 흐름이다.


@RestController는 @Controller와 @ResponseBody가 합쳐진 형태로 이해할 수 있다.
@ResponseBody는 반환값을 뷰 이름으로 해석하지 않고 응답 본문에 넣겠다는 뜻이다.
그래서 RestController에서는 메서드가 반환한 값이 화면 이름이 아니라 클라이언트에게 보낼 데이터가 된다.


예를 들어 문자열을 반환하면 그 문자열이 응답 본문으로 나간다.
자바 객체를 반환하면 보통 JSON 같은 형식으로 바뀌어 응답 본문에 담긴다.
이 변환을 담당하는 것이 HttpMessageConverter이다.


MessageConverter는 자바 객체를 브라우저나 클라이언트가 읽을 수 있는 응답 데이터 형식으로 바꿔 주는 도구이다.
쉽게 말하면 응답용 번역기 같은 역할이다.
예를 들어 자바 객체를 JSON 형태로 바꿔서 응답 본문에 담아 주는 일이 여기서 일어난다.

DispatcherServlet부터 HandlerMapping, RestController, MessageConverter, HTTP 응답까지 이어지는 처리 흐름을 보여 준다.
즉, RestController는 뷰를 찾지 않고 데이터를 바로 응답 본문으로 보내는 구조이다.


일반 Controller와 RestController를 코드로 비교하면

일반 Controller와 RestController는 둘 다 메서드에서 String을 반환할 수 있다.
하지만 같은 String이라도 의미가 다르다.
일반 Controller에서 반환하는 String은 보통 뷰 이름이고, RestController에서 반환하는 String은 응답 본문이다.


먼저 일반 Controller에서 화면을 반환하는 예를 보자.

// PageController.java
@Controller
public class PageController {
    @GetMapping("/page-hello") // 화면 요청
    public String hello(Model model) {
        model.addAttribute("msg", "안녕하세요"); // 뷰에 전달할 데이터 저장
        return "hello"; // 뷰 이름 반환
    }
}
// hello.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>hello</title>
</head>
<body>
<h1 th:text="${msg}"></h1>
</body>
</html>

이 예제에서 "hello"는 응답 본문이 아니다.
스프링은 "hello"를 뷰 이름으로 보고, 실제 hello.html 화면 파일을 찾아 응답한다.


이번에는 일반 Controller에서 @ResponseBody를 붙여 데이터를 바로 응답하는 예를 보자.

// BodyController.java
@Controller
public class BodyController {
    @ResponseBody
    @GetMapping("/body-hello") // 데이터 요청
    public String hello() {
        return "안녕하세요"; // 응답 본문으로 반환
    }
}
// 출력결과
// 안녕하세요

일반 Controller라도 @ResponseBody가 붙으면 반환값을 뷰 이름으로 보지 않는다.
반환값을 그대로 응답 본문에 담는다.


마지막으로 RestController를 보자.

// ApiController.java
@RestController
public class ApiController {
    @GetMapping("/api-hello") // 데이터 요청
    public String hello() {
        return "안녕하세요"; // 응답 본문으로 바로 반환
    }
}
// 출력결과
// 안녕하세요

RestController는 모든 메서드에 @ResponseBody가 붙은 것처럼 동작한다고 이해하면 된다.
그래서 반환값을 뷰 이름으로 해석하지 않고 응답 본문으로 보낸다.


객체를 반환하는 예도 볼 수 있다.

// MemberApiController.java
@RestController
public class MemberApiController {
    @GetMapping("/api/members/1") // 회원 데이터 요청
    public MemberResponse member() {
        return new MemberResponse(1L, "이언덕"); // 객체를 응답 데이터로 반환
    }
}
// 출력결과
// {
//   "id": 1,
//   "nickname": "이언덕"
// }

이 예제에서 MemberResponse 객체는 MessageConverter를 통해 JSON 형식으로 바뀌어 응답 본문에 담긴다.
즉, RestController는 화면을 찾는 흐름이 아니라 데이터를 응답하는 흐름이다.


같은 String 반환이라도 일반 Controller에서는 뷰 이름이 될 수 있고, RestController에서는 응답 본문이 된다.
이 차이를 반드시 잡아야 스프링 MVC 요청 흐름을 헷갈리지 않는다.


스프링 MVC 요청 흐름을 한 번 더 정리하기

단계별로 다시 보면

DispatcherServlet이 요청을 받아 HandlerMapping, HandlerAdapter, Controller, ViewResolver, View와 연결되는 스프링 MVC 동작 순서를 단계별로 보여 준다.
즉, 요청이 들어오면 누가 처리할지 찾고, 실행하고, 결과를 화면이나 데이터로 응답하는 흐름이 정해진 구조 안에서 반복된다.


지금까지 본 내용을 합치면 스프링 MVC 요청 흐름은 크게 두 가지로 나누어 볼 수 있다.
하나는 화면을 응답하는 일반 Controller 흐름이고, 다른 하나는 데이터를 응답하는 RestController 흐름이다.


먼저 일반 화면 응답 흐름은 아래처럼 볼 수 있다.


브라우저 요청 → DispatcherServlet → HandlerMapping → HandlerAdapter → Controller → Service → Repository → DB → Repository → Service → Controller → Model → ViewResolver → View → HTML 응답


이 흐름에서 Controller는 요청을 받고 Service를 호출한다.
Service는 필요한 처리 흐름을 담당하고, Repository는 DB에 접근한다.
처리 결과는 다시 Controller로 돌아오고, Controller는 Model에 데이터를 담은 뒤 뷰 이름을 반환한다.
그다음 ViewResolver가 실제 화면 파일을 찾고, 최종 HTML 응답이 만들어진다.


데이터 응답 흐름은 아래처럼 볼 수 있다.


브라우저 요청 → DispatcherServlet → HandlerMapping → HandlerAdapter → RestController → Service → Repository → DB → Repository → Service → RestController → MessageConverter → 응답 본문


이 흐름에서는 뷰를 찾지 않는다.
RestController가 반환한 값은 MessageConverter를 거쳐 응답 본문으로 나간다.
객체를 반환하면 보통 JSON 형식으로 바뀌어 응답된다.


여기서도 domain 내부 객체들이 함께 사용된다.
요청값은 domain/dto의 Request DTO로 받을 수 있고, 저장 대상은 domain/entity의 Entity로 표현할 수 있다.
회원 상태나 역할은 domain/enums의 enum으로 표현할 수 있고, 응답값은 Response DTO로 바꿔 보낼 수 있다.


다만 domain은 흐름 중간에서 독립적으로 호출되는 단계가 아니다.
Controller, Service, Repository 흐름 안에서 사용되는 데이터 타입 영역이다.
이 차이를 알아야 프로젝트 구조를 정확히 읽을 수 있다.


여기서 초보자가 꼭 기억해야 할 것은 이름이 아니라 순서이다.
요청을 받는다 → 처리할 대상을 찾는다 → 실행한다 → 계층별로 역할을 나누어 처리한다 → 결과를 화면이나 데이터로 응답한다는 흐름만 잡아도 이후 코드가 훨씬 쉽게 읽힌다.


여기서 먼저 정리해야 할 핵심

웹 요청 처리 구조를 한 번에 묶어 보면

  • 웹 애플리케이션은 HTTP Request와 HTTP Response로 동작한다.
  • 브라우저는 보통 DB에 직접 접근하지 않고, 서버가 필요할 때 DB에 접근한다.
  • Tier는 물리 구조이고, Layer는 역할 구조이다.
  • 스프링 프로젝트의 controller, service, repository, domain 분리는 서버 분리가 아니라 코드 역할 분리이다.
  • 개념적으로 웹 서버는 정적인 자원 전달 쪽, WAS는 자바 웹 애플리케이션 실행 쪽으로 이해하면 된다.
  • Servlet Container는 Servlet을 관리하고, 스프링 컨테이너는 스프링 Bean을 관리한다.
  • 스프링 MVC에서는 모든 요청을 먼저 DispatcherServlet이 받는다.
  • DispatcherServlet은 HandlerMapping, HandlerAdapter, ViewResolver, MessageConverter와 함께 요청을 처리한다.
  • controller는 요청과 응답의 입구이다.
  • service는 실제 기능 처리 흐름을 담당한다.
  • repository는 DB 접근을 담당한다.
  • domain/entity는 DB 테이블과 매핑되어 저장되는 핵심 데이터 객체를 담는다.
  • domain/dto는 요청과 응답 데이터를 전달하는 객체를 담는다.
  • domain/enums는 상태나 역할처럼 정해진 값 목록을 담는다.
  • core는 공통 기반 코드를 담는다.
  • config는 프로젝트 설정 코드를 담는다.
  • 일반 Controller는 보통 뷰 이름을 반환하고, Model로 화면에 필요한 데이터를 넘긴다.
  • RestController는 뷰를 찾지 않고 응답 본문을 반환한다.
  • MessageConverter는 자바 객체를 JSON 같은 응답 형식으로 바꿔 준다.

이 흐름이 잡히면 나중에 @Controller, @RequestMapping, Model, @RestController, DTO, Entity, Repository가 왜 필요한지 훨씬 쉽게 이해된다.
즉, 지금 이 파트는 단순히 요청 처리 순서를 외우는 구간이 아니라, 스프링 웹 코드가 왜 그런 구조로 짜이는지 이해하는 구간이다.


스프링 웹 요청 흐름과 레이어드 구조는 따로 외우는 것이 아니라, 하나의 요청 처리 흐름 안에서 함께 봐야 한다.
브라우저 요청은 DispatcherServlet을 거쳐 Controller로 들어오고, 실제 기능 처리는 Service, 데이터 접근은 Repository, 데이터 표현은 domain 내부 객체들이 맡는다.
이 전체 흐름이 보이면 스프링 프로젝트 구조가 훨씬 덜 복잡하게 느껴진다.

0개의 댓글