스프링 MVC는 HTTP 요청을 받아 컨트롤러로 연결하고, 처리 결과를 뷰나 JSON으로 응답하는 웹 계층의 핵심이다. 분량이 많아 다섯 편으로 나누어, 하나의 게시판·로그인 애플리케이션을 만들어 가며 다룬다.
이 장의 구성
이번 편에서는 스프링의 기반 개념(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에 전달할 데이터를 담는다 |
|
View | Model 데이터로 화면(HTML)을 렌더링한다 | Thymeleaf 템플릿 |
Controller | 요청을 받아 로직을 호출하고, 결과를 Model에 담아 보여 줄 View를 고른다 |
|
@GetMapping("/members")
public String list(Model model) {
model.addAttribute("members", memberService.findAll()); // ← Model: 데이터를 담고
return "members/list"; // ← View: 렌더링할 템플릿을 고른다
} // 요청을 받아 이 흐름을 조율하는 메서드 자체가 ControllerMVC의 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) |
| HTTP 요청을 받아 파라미터를 꺼내고 검증, 서비스에 처리를 위임, 결과를 뷰·JSON으로 응답 | 비즈니스 로직을 직접 구현 |
서비스(Service) |
| 핵심 비즈니스 로직, 여러 리포지토리 조합, 트랜잭션 경계( | 웹(HTTP)·화면 코드에 의존 |
리포지토리(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를 |
생성자 주입 ✅ | 객체 생성 시점에 한 번만 주입, 누락 시 즉시 발견, |
세 가지 방식 코드
@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); // 정상 동작
}생성자 주입의 장점을 정리하면:
테스트가 쉽다 —
new로 직접 만들면서 가짜/목 객체를 인자로 넘길 수 있다. 스프링 없이 순수 자바로 빠르게 단위 테스트한다.누락을 즉시 잡는다 — 의존성이 없으면 객체를 아예 만들 수 없다. 실행 한참 뒤 NPE가 아니라 객체 생성 시점에 바로 실패한다.
불변(
final)이 보장된다 —final필드는 생성자에서 한 번만 값이 설정되고 이후 바뀌지 않는다. 중간에 의존성이 교체될 위험이 없다.순환 참조를 시작 시점에 발견한다 — A↔B가 서로를 생성자로 주입하면 애플리케이션 구동 중 즉시 오류가 나서 설계 문제를 일찍 드러낸다.
생성자 주입 + Lombok
생성자가 하나면 @Autowired를 생략할 수 있다. Lombok의 @RequiredArgsConstructor를 쓰면 final 필드에 대한 생성자가 자동 생성되어 가장 간결하다. 실무에서 가장 많이 쓰는 형태다.
@Controller
@RequiredArgsConstructor // final 필드 생성자 자동 생성 → 생성자 주입
public class MemberController {
private final MemberService memberService;
}