웹 개발›Spring MVC

스프링 기반과 DI

스프링 MVC는 HTTP 요청을 받아 컨트롤러로 연결하고, 처리 결과를 뷰나 JSON으로 응답하는 웹 계층의 핵심이다. 분량이 많아 다섯 편으로 나누어, 하나의 게시판·로그인 애플리케이션을 만들어 가며 다룬다.

이 장의 구성

  1. 스프링 기반과 DI ← 현재 편 (MVC 패턴·계층형 아키텍처·핵심 개념·빈·의존성 주입)

  2. 요청과 응답 (요청 매핑·파라미터·응답·Forward/Redirect)

  3. 검증과 쿠키·세션 (Bean Validation·쿠키·세션)

  4. 필터·인터셉터·예외 처리

  5. 파일 업로드·다운로드·페이징

이번 편에서는 스프링의 기반 개념(IoC/DI/AOP)과 의존성 주입을 다룬다. 먼저 이름 그대로의 MVC 패턴과 전체 요청 처리 흐름, 애플리케이션의 계층 구조를 본 뒤 시작한다.

MVC 패턴이란 — Model · View · Controller

Spring MVC의 "MVC"는 화면을 만드는 웹 계층의 책임을 Model · View · Controller 셋으로 나누는 디자인 패턴이다. 화면(View)과 데이터·제어를 분리해 서로 영향 없이 따로 바꿀 수 있게 하는 것이 목적이다.

flowchart LR
    Client([클라이언트]) -->|① 요청| C["Controller<br/>(@Controller)"]
    C -->|② 로직 호출| L[("Service ·<br/>비즈니스 로직")]
    C -->|③ 데이터 담기| M["Model"]
    C -->|④ View 선택| V["View<br/>(Thymeleaf)"]
    M -.->|데이터 제공| V
    V -->|⑤ 완성된 HTML| Client

구성

역할

이 강의에서

Model

View에 전달할 데이터를 담는다

model.addAttribute(...)로 담는 값

View

Model 데이터로 화면(HTML)을 렌더링한다

Thymeleaf 템플릿

Controller

요청을 받아 로직을 호출하고, 결과를 Model에 담아 보여 줄 View를 고른다

@Controller의 메서드

@GetMapping("/members")
public String list(Model model) {
    model.addAttribute("members", memberService.findAll());  // ← Model: 데이터를 담고
    return "members/list";                                   // ← View: 렌더링할 템플릿을 고른다
}   // 요청을 받아 이 흐름을 조율하는 메서드 자체가 Controller

MVC의 Controller와 계층형 아키텍처의 Controller

MVC는 화면을 그리는 관점의 분리(데이터·화면·제어)이고, 바로 아래에서 볼 계층형 아키텍처는 애플리케이션 책임의 분리(웹·로직·데이터)다. 두 곳의 "Controller"는 같은 @Controller를 가리킨다 — MVC에서 화면 흐름을 조율하는 그 컨트롤러가, 계층 구조에서는 비즈니스 로직을 Service에 위임한다. 패턴의 더 자세한 설명과 FrontController·DispatcherServlet 구조는 스프링 소개 — MVC 패턴에서 다룬다.

요청 처리 흐름 — DispatcherServlet

스프링 MVC의 모든 요청은 DispatcherServlet 하나가 가장 먼저 받는다. 이렇게 단일 진입점이 요청을 받아 적절한 컨트롤러로 분배하는 구조를 프론트 컨트롤러(Front Controller) 패턴이라 한다. 개발자는 이 흐름의 부품(컨트롤러)만 작성하고, 분배·뷰 렌더링 같은 공통 처리는 스프링이 맡는다.

sequenceDiagram
    autonumber
    participant B as 브라우저
    participant DS as DispatcherServlet
    participant HM as HandlerMapping
    participant C as Controller
    participant VR as ViewResolver
    participant V as View(Thymeleaf)
    B->>DS: HTTP 요청
    DS->>HM: 요청에 맞는 핸들러 조회
    HM-->>DS: 컨트롤러(핸들러)
    DS->>C: 핸들러 호출 (HandlerAdapter 경유)
    C-->>DS: 모델 + 뷰 이름
    DS->>VR: 뷰 이름 → 뷰 변환
    VR-->>DS: View
    DS->>V: 모델 데이터로 렌더링 요청
    V-->>DS: 완성된 HTML
    DS-->>B: HTTP 응답

구성 요소

역할

DispatcherServlet

모든 요청을 받는 단일 진입점(프론트 컨트롤러). 아래 부품들을 조율

HandlerMapping

요청 URL·메서드에 매핑되는 컨트롤러를 찾음 (→ 4. 요청 매핑)

HandlerAdapter

찾은 컨트롤러를 알맞은 방식으로 호출

Controller

요청을 처리하고 모델과 뷰 이름(또는 JSON 데이터)을 반환

ViewResolver

논리 뷰 이름을 실제 뷰(Thymeleaf 템플릿)로 변환 (→ 6. HTTP 응답)

View

모델 데이터를 채워 최종 HTML을 만든다

@ResponseBody/@RestController라면

컨트롤러가 @ResponseBody로 데이터를 직접 반환하면 ViewResolver·View 단계를 거치지 않고, HttpMessageConverter 가 객체를 JSON 등으로 변환해 응답 바디에 바로 쓴다. (→ 6. HTTP 응답)

컨트롤러·서비스·리포지토리 — 계층형 아키텍처

위 흐름에서 요청은 결국 컨트롤러로 들어온다. 그런데 컨트롤러가 화면 처리부터 비즈니스 로직, DB 접근까지 혼자 다 하면 코드가 뒤엉켜 유지보수가 어렵다. 그래서 스프링 애플리케이션은 보통 역할을 세 계층으로 나눈다.

flowchart LR
    Req[HTTP 요청] --> C["Controller<br/>(웹 계층)"]
    C --> S["Service<br/>(비즈니스 로직)"]
    S --> R["Repository<br/>(데이터 접근)"]
    R --> DB[(DB)]

계층

어노테이션

역할

하면 안 되는 것

컨트롤러(Controller)

@Controller / @RestController

HTTP 요청을 받아 파라미터를 꺼내고 검증, 서비스에 처리를 위임, 결과를 뷰·JSON으로 응답

비즈니스 로직을 직접 구현

서비스(Service)

@Service

핵심 비즈니스 로직, 여러 리포지토리 조합, 트랜잭션 경계(@Transactional)

웹(HTTP)·화면 코드에 의존

리포지토리(Repository)

@Repository

DB 저장·조회 등 데이터 접근(CRUD)

비즈니스 규칙 판단

의존 방향은 컨트롤러 → 서비스 → 리포지토리 → DB 한 방향이다. 각 계층은 바로 아래 계층을 생성자 주입(→ 3. 의존성 주입)으로 받아 쓴다.

@Controller                                   // 웹 계층
@RequiredArgsConstructor
public class MemberController {
    private final MemberService memberService;          // 서비스에 위임

    @GetMapping("/members")
    public String list(Model model) {
        model.addAttribute("members", memberService.findAll());   // 로직은 서비스가 담당
        return "members/list";
    }
}

@Service                                      // 비즈니스 로직 계층
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;    // 리포지토리에 위임

    public List<Member> findAll() {
        return memberRepository.findAll();
    }
}

@Repository                                   // 데이터 접근 계층
@RequiredArgsConstructor
public class MemberRepository {
    private final MemberJpaRepository jpa;

    public List<Member> findAll() {
        return jpa.findAll();                           // 실제 DB 조회
    }
}

왜 계층을 나누나

  • 관심사 분리: 화면(컨트롤러)·로직(서비스)·저장(리포지토리)이 섞이지 않아 읽고 고치기 쉽다.

  • 테스트 용이: 서비스는 가짜 리포지토리만 주입하면 웹·DB 없이 단독 테스트할 수 있다(→ 3. 의존성 주입에서 본 생성자 주입의 이점).

  • 변경 격리: DB 기술이나 화면 방식을 바꿔도 영향이 한 계층에 머문다.

세 어노테이션(@Controller·@Service·@Repository)은 모두 @Component를 포함하므로 2. 스프링 빈으로 자동 등록된다. 이름만 다를 뿐 "어떤 계층인지 의도를 드러내는" 역할이 핵심이다.

이어지는 섹션들은 이 흐름의 각 지점을 다룬다. 큰 그림에서 지금 어디를 보고 있는지 떠올리며 읽으면 이해가 쉽다.


1. 스프링 핵심 개념

개념

의미

IoC (Inversion of Control)

객체의 생성·생명주기 제어권을 개발자가 아닌 스프링 컨테이너가 갖는다.

DI (Dependency Injection)

필요한 객체(의존성)를 컨테이너가 외부에서 주입해 준다.

AOP (Aspect Oriented Programming)

로깅·트랜잭션처럼 여러 곳에 흩어지는 공통 관심사를 분리해 모듈화한다.

개념과 예제 코드는 '스프링 소개'에서

IoC·DI·AOP를 "전통적 자바 코드의 문제 → 스프링의 해결"로 보여주는 자바 레벨 예제는 스프링 소개 — 스프링의 핵심 원리에 정리되어 있다. 이 장에서는 그 원리를 실제 스프링 코드로 어떻게 쓰는지(빈 등록, 주입 방식)에 집중한다.


2. 스프링 빈(Bean)

스프링 컨테이너가 관리하는 객체를 빈이라고 한다. 빈을 등록하는 방법은 두 가지다.

컴포넌트 스캔과 자동 의존관계 설정

  • @Component — 스프링 빈으로 자동 등록한다.

  • @Controller, @Service, @Repository는 내부에 @Component를 포함하므로 모두 컴포넌트 스캔 대상이다.

자바 코드로 직접 빈 등록하기

@Configuration                       // 스프링 컨테이너 설정 클래스
public class AppConfig {

    @Bean                            // 반환 객체를 스프링 빈으로 등록
    public MemberService memberService() {
        return new MemberService(memberRepository());
    }

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }
}
  • @Configuration — 스프링 컨테이너 설정 클래스(클래스에 등록)

  • @Bean — 스프링 컨테이너의 빈으로 등록(메소드에 등록)


3. 의존성 주입(DI)

컨테이너가 빈을 주입하는 방법은 세 가지다. 결론부터 말하면 생성자 주입을 쓴다.

방식

특징

필드 주입

코드가 가장 짧지만 스프링 컨테이너 없이는 의존성을 넣을 수 없어 테스트가 불편하다.

setter 주입

setter를 public으로 열어야 해서 누구나 의존성을 바꿀 수 있고, 불변성이 깨진다.

생성자 주입 ✅

객체 생성 시점에 한 번만 주입, 누락 시 즉시 발견, final 보장, 테스트가 쉽다.

세 가지 방식 코드

@Controller
public class MemberController {
    @Autowired
    private MemberService memberService;   // 필드에 바로 주입
}
@Controller
public class MemberController {
    private MemberService memberService;

    @Autowired
    public void setMemberService(MemberService memberService) {
        this.memberService = memberService;   // public setter로 주입
    }
}
@Controller
public class MemberController {
    private final MemberService memberService;   // final → 불변

    @Autowired   // 생성자가 하나면 생략 가능
    public MemberController(MemberService memberService) {
        this.memberService = memberService;       // 생성 시점에 한 번만 주입
    }
}

필드 주입은 왜 테스트가 불편한가

MemberController를 스프링 없이 순수 단위 테스트하는 상황을 보자. 테스트에서는 진짜 서비스 대신 가짜(FakeMemberService)를 넣고 싶다.

테스트 코드가 처음이라면

@Test·가짜(Fake)·단위 테스트 개념은 테스트 → JUnit 기초와 단위/통합 테스트에서 다룬다. 아래 예제가 낯설면 먼저 그 장을 보고 와도 좋다.

class MemberControllerTest {
    @Test
    void 회원_목록() {
        // 1) 컨트롤러를 직접 만든다 (스프링 컨테이너 X)
        MemberController controller = new MemberController();

        // 2) 가짜 서비스를 넣고 싶은데... 넣을 방법이 없다!
        //    memberService 필드는 private 이고, 생성자도 setter도 없다.

        controller.list(model);   // 💥 memberService == null → NullPointerException
    }
}

필드 주입은 의존성을 넣는 통로(생성자·setter)가 외부에 없다. 그래서 컨테이너가 리플렉션으로 몰래 꽂아주는 것에 의존한다. 스프링 없이 테스트하면 필드가 null로 남아 NPE가 난다. 억지로 넣으려면 아래처럼 리플렉션 헬퍼를 동원해야 한다.

// 필드 주입을 테스트하려면 리플렉션으로 강제 주입 — 번거롭고 캡슐화도 깨진다
ReflectionTestUtils.setField(controller, "memberService", new FakeMemberService());

결국 가벼운 단위 테스트를 포기하고 @SpringBootTest로 컨테이너를 통째로 띄우게 되는데, 이는 느리고 무겁다.

생성자 주입이 좋은 이유

생성자 주입이면 같은 테스트가 이렇게 간단해진다.

@Test
void 회원_목록() {
    // 생성자로 가짜를 그냥 넘기면 끝. 스프링 컨테이너가 필요 없다.
    MemberController controller = new MemberController(new FakeMemberService());

    controller.list(model);   // 정상 동작
}

생성자 주입의 장점을 정리하면:

  1. 테스트가 쉽다 — new로 직접 만들면서 가짜/목 객체를 인자로 넘길 수 있다. 스프링 없이 순수 자바로 빠르게 단위 테스트한다.

  2. 누락을 즉시 잡는다 — 의존성이 없으면 객체를 아예 만들 수 없다. 실행 한참 뒤 NPE가 아니라 객체 생성 시점에 바로 실패한다.

  3. 불변(final)이 보장된다 — final 필드는 생성자에서 한 번만 값이 설정되고 이후 바뀌지 않는다. 중간에 의존성이 교체될 위험이 없다.

  4. 순환 참조를 시작 시점에 발견한다 — A↔B가 서로를 생성자로 주입하면 애플리케이션 구동 중 즉시 오류가 나서 설계 문제를 일찍 드러낸다.

생성자 주입 + Lombok

생성자가 하나면 @Autowired를 생략할 수 있다. Lombok의 @RequiredArgsConstructor를 쓰면 final 필드에 대한 생성자가 자동 생성되어 가장 간결하다. 실무에서 가장 많이 쓰는 형태다.

@Controller
@RequiredArgsConstructor          // final 필드 생성자 자동 생성 → 생성자 주입
public class MemberController {
    private final MemberService memberService;
}

마지막 수정 2026년 10월 22일