Spring Boot 핵심 개념 - MVC, 3계층, IoC, DI
Spring Boot MVC 패턴과 3계층 아키텍처, IoC, DI, 인터페이스 추상화까지 핵심 개념을 한 번에 이해한다.
Spring Boot란?
Spring은 서버 프로그램을 만들 때 쓰는 프레임워크다. 프레임워크란 "자주 필요한 기능을 미리 만들어둔 도구 모음"이다.
Spring Boot는 Spring을 더 쉽게 쓸 수 있게 만든 버전이다. 복잡한 설정 없이 바로 시작할 수 있다.
[Spring Boot 서버]
├── 요청 받기 (HTTP 서버 내장)
├── 요청을 적절한 코드로 연결
├── 데이터베이스 연동
└── 응답 만들어서 돌려주기
위 기능들을 직접 만들 필요 없이, Spring Boot가 다 해준다. 우리는 "어떤 요청이 왔을 때 어떤 데이터를 돌려줄지"만 작성하면 된다.
MVC 패턴
Spring Boot는 MVC(Model-View-Controller) 패턴을 기반으로 한다.
MVC는 코드를 역할에 따라 3가지로 나누는 설계 방식이다.
| 이름 | 역할 | Spring Boot에서 |
|---|---|---|
| Model | 데이터와 비즈니스 로직 | data class, Service, Repository |
| View | 사용자에게 보여주는 화면 | REST API에서는 JSON 응답 |
| Controller | 요청을 받아 Model과 View를 연결 | @RestController |
REST API에서의 MVC
REST API — URL과 HTTP 메서드(GET, POST, DELETE 등)로 요청을 구분하고, 결과를 JSON으로 돌려주는 방식. 자세한 내용은 4편에서 다룬다.
웹 화면을 렌더링하는 전통적인 MVC와 달리, REST API 서버에서는 View 대신 JSON 데이터를 응답으로 돌려준다.
클라이언트(앱/웹)
│
│ GET /users/1
▼
[Controller] ← 요청을 받아서 어떤 Model을 쓸지 결정
│
▼
[Model] ← 데이터 조회/처리
│
▼
[Controller] ← 처리 결과를 받아서
│
▼
JSON 응답 ← View 역할 (화면 대신 데이터)
│
▼
클라이언트(앱/웹)
Spring에서 @RestController는 응답을 HTML이 아닌 JSON으로 자동 변환해준다.
3계층 아키텍처 (3-Layer Architecture)
MVC를 서버 내부 구조에 적용한 것이 3계층 아키텍처다. Model 영역을 Service와 Repository로 더 세분화한 구조다.
┌──────────────────────────────────────────┐
│ Presentation Layer (표현 계층) │
│ Controller │
│ - HTTP 요청/응답 처리 │
│ - 파라미터 유효성 1차 검사 │
├──────────────────────────────────────────┤
│ Business Layer (비즈니스 계층) │
│ Service │
│ - 실제 비즈니스 로직 │
│ - 여러 Repository 조합 │
│ - 트랜잭션 관리 │
├──────────────────────────────────────────┤
│ Data Access Layer (데이터 접근 계층) │
│ Repository │
│ - DB 조회/저장/수정/삭제 │
│ - SQL 또는 JPA 사용 │
└──────────────────────────────────────────┘
│
▼
Database
왜 3개로 나누는가?
하나의 클래스에 모든 코드를 넣으면 어떻게 될까?
// 나쁜 예 - 하나의 클래스에 다 몰아넣기
@RestController
class UserController {
@GetMapping("/users/{id}")
fun getUser(@PathVariable id: Long): User {
// 1. DB 연결
// 2. SQL 실행
// 3. 비즈니스 로직 (이메일 마스킹, 권한 체크 등)
// 4. 응답 변환
// 이 함수가 수백 줄이 된다
}
}
이렇게 쓰면 문제가 많다:
- "DB 관련 코드가 어디 있지?" 찾기 어렵다
- 로직 하나 수정하면 다른 기능에 영향을 줄 수 있다
- 같은 로직이 여러 Controller에 중복된다
3계층으로 나누면:
// Presentation Layer
@RestController
class UserController(private val userService: UserService) {
@GetMapping("/users/{id}")
fun getUser(@PathVariable id: Long) = userService.getUser(id)
// Controller는 짧고 단순하게 유지
}
// Business Layer
@Service
class UserService(private val userRepository: UserRepository) {
fun getUser(id: Long): User {
// 비즈니스 로직만 여기에
return userRepository.findById(id)
?: throw NotFoundException("유저 없음")
}
}
// Data Access Layer
@Repository
class UserRepository {
fun findById(id: Long): User? { /* DB 접근만 여기에 */ }
}
각 계층이 자신의 역할만 담당한다. DB 쿼리를 바꾸려면 Repository만, 비즈니스 로직을 바꾸려면 Service만 수정하면 된다.
계층 간 규칙
계층은 반드시 위에서 아래 방향으로만 호출한다.
Controller → Service → Repository ✅ 올바른 방향
Repository → Service ❌ 하위가 상위를 호출하면 안 됨
Controller → Repository ❌ 계층을 건너뛰면 안 됨
Repository가 Service를 호출하거나, Controller가 Repository를 직접 호출하면 계층 분리의 의미가 없어진다.
어노테이션(@)이란?
코드 위에 붙이는 "메모" 같은 것이다. Spring에게 이 클래스/함수의 역할을 알려준다.
@RestController // "이 클래스는 Presentation Layer의 Controller다"
@RequestMapping("/api") // "이 클래스는 /api 경로를 담당한다"
class UserController {
@GetMapping("/hello") // "GET /api/hello 요청을 이 함수가 처리한다"
fun hello(): String {
return "안녕하세요!"
}
}
3계층 각각에 붙이는 어노테이션:
| 어노테이션 | 계층 | 의미 |
|---|---|---|
@RestController | Presentation | REST API Controller |
@Service | Business | 비즈니스 로직 클래스 |
@Repository | Data Access | DB 접근 클래스 |
프로젝트 구조
3계층 아키텍처를 반영한 실제 프로젝트 구조다.
src/main/kotlin/com/example/myapp/
├── MyAppApplication.kt
│
├── controller/ ← Presentation Layer
│ └── UserController.kt
│
├── service/ ← Business Layer
│ └── UserService.kt
│
├── repository/ ← Data Access Layer
│ └── UserRepository.kt
│
├── entity/ ← DB 테이블 대응 클래스
│ └── User.kt
│
└── dto/ ← 요청/응답 데이터 구조
├── CreateUserRequest.kt
└── UserResponse.kt
entity는 DB 테이블과 1:1로 대응하는 클래스, dto(Data Transfer Object)는 요청이나 응답에 사용하는 클래스다. 이 둘을 분리하는 이유는 DB 구조와 API 응답 구조가 달라질 수 있기 때문이다.
IoC (Inversion of Control, 제어의 역전)
일반적인 코드에서는 내가 필요한 객체를 직접 만들고 관리한다.
// 일반적인 방식 - 개발자가 직접 제어
class UserController {
val userService = UserService() // 내가 직접 만들고
val userRepository = UserRepository() // 내가 직접 만들고
// 다 쓰면 내가 직접 정리
}
문제가 있다. UserController가 UserService를 직접 만드니까, UserService 생성 방식이 바뀌면 UserController도 수정해야 한다. 클래스가 많아질수록 이런 의존 관계가 복잡하게 얽힌다.
IoC는 이 제어권을 개발자가 아닌 프레임워크(Spring)에게 넘기는 것이다.
일반 방식: 개발자가 객체를 만들고 → 사용하고 → 정리한다
IoC 방식: Spring이 객체를 만들고 → 개발자에게 넘겨주고 → Spring이 정리한다
"제어가 역전됐다"는 뜻이 바로 이것이다. 내가 당기는 게 아니라 Spring이 밀어준다.
빈(Bean)과 IoC 컨테이너
Spring이 만들고 관리하는 객체를 빈(Bean) 이라고 한다. 이 빈들을 모아두는 공간이 IoC 컨테이너다.
@Service, @Repository, @RestController 등의 어노테이션을 붙이면 Spring이 서버 시작 시에 해당 클래스의 빈을 자동으로 생성해서 컨테이너에 보관한다.
@Service
class UserService { ... } // Spring이 이 클래스의 객체를 만들어서 컨테이너에 보관
서버 시작
│
▼
Spring이 어노테이션 붙은 클래스들을 전부 스캔
│
▼
객체를 생성해서 IoC 컨테이너에 보관
│
▼
필요한 곳에 자동으로 꺼내서 주입
빈은 기본적으로 딱 하나만 만들어진다. 여러 Controller가 같은 Service를 쓰더라도 객체는 하나를 공유한다.
DI (Dependency Injection, 의존성 주입)
IoC의 구체적인 구현 방식이다. Spring이 IoC 컨테이너에 보관된 빈을 필요한 곳에 주입해준다.
@RestController
class UserController(
private val userService: UserService // Spring이 컨테이너에서 꺼내서 여기에 주입
) {
fun getUser(id: Long) = userService.getUser(id)
}
UserController는 UserService가 어떻게 만들어졌는지 전혀 모른다. 그냥 받아서 쓸 뿐이다.
세 개념의 관계
IoC : "객체 생성과 관리를 Spring에게 맡긴다"는 원칙
Bean : Spring이 IoC 컨테이너에서 관리하는 객체
DI : IoC를 실현하는 구체적인 방법 (빈을 필요한 곳에 주입)
인터페이스 추상화
DI와 인터페이스를 함께 쓰면 더 강력해진다.
지금까지는 구체적인 클래스를 직접 주입받았다.
@RestController
class UserController(
private val userService: UserService // 구체 클래스
)
이렇게 하면 UserService를 다른 구현으로 바꾸려면 Controller 코드를 수정해야 한다.
인터페이스를 사용하면 달라진다.
// 인터페이스 - "무엇을 할 수 있는지"만 정의
interface UserService {
fun getUser(id: Long): User
fun createUser(request: CreateUserRequest): User
}
// 실제 구현체
@Service
class UserServiceImpl(
private val userRepository: UserRepository
) : UserService {
override fun getUser(id: Long) = userRepository.findById(id)
?: throw IllegalArgumentException("유저 없음")
override fun createUser(request: CreateUserRequest) =
userRepository.save(request.name, request.email)
}
Controller는 인터페이스 타입으로 주입받는다.
@RestController
class UserController(
private val userService: UserService // 인터페이스 타입
) {
fun getUser(id: Long) = userService.getUser(id)
}
Spring이 UserService 인터페이스를 구현한 빈(UserServiceImpl)을 자동으로 찾아서 주입해준다.
왜 인터페이스를 쓰는가?
테스트할 때
// 실제 DB를 쓰지 않는 테스트용 구현체
class FakeUserService : UserService {
override fun getUser(id: Long) = User(id, "테스트유저", "test@test.com")
override fun createUser(request: CreateUserRequest) = User(1, request.name, request.email)
}
// 테스트에서는 FakeUserService를 주입
val controller = UserController(FakeUserService())
Controller 코드를 전혀 수정하지 않고 가짜 Service를 꽂아서 테스트할 수 있다.
구현체를 교체할 때
// 기존: 직접 만든 Repository 사용
@Service
class UserServiceImpl(private val userRepository: UserRepository) : UserService
// 변경: 외부 API를 쓰는 구현체로 교체
@Service
class ExternalUserServiceImpl(private val apiClient: UserApiClient) : UserService
Controller는 UserService 인터페이스만 바라보고 있기 때문에, 구현체가 바뀌어도 Controller 코드는 수정할 필요가 없다.
정리하면
인터페이스 : "무엇을 할 수 있는지" 약속 (계약)
구현체 : 그 약속을 실제로 이행하는 클래스
DI : Spring이 적절한 구현체를 찾아서 주입
→ 사용하는 쪽(Controller)은 인터페이스만 알면 된다
→ 구현체가 바뀌어도 사용하는 쪽 코드는 그대로
실무에서는
간단한 프로젝트에서는 인터페이스 없이 구체 클래스를 바로 주입하는 경우도 많다. 규모가 커지거나 테스트가 중요한 프로젝트일수록 인터페이스 분리가 빛을 발한다.
정리
| 개념 | 설명 |
|---|---|
| MVC | 코드를 Model/View/Controller로 나누는 설계 패턴 |
| 3계층 아키텍처 | Presentation / Business / Data Access로 분리 |
| Controller | Presentation Layer. HTTP 요청/응답 담당 |
| Service | Business Layer. 비즈니스 로직 담당 |
| Repository | Data Access Layer. DB 접근 담당 |
| IoC | 객체 생성과 관리를 Spring에게 맡기는 원칙 |
| Bean | Spring이 IoC 컨테이너에서 관리하는 객체 |
| DI | IoC 컨테이너의 빈을 필요한 곳에 주입하는 방법 |
| 인터페이스 추상화 | 구현 교체·테스트를 유연하게 만드는 설계 |
시리즈: Kotlin + Spring Boot 입문
- 개발 환경 설치 - IntelliJ, JDK, 첫 Spring Boot 실행
- Kotlin 문법 - 변수, 함수, 클래스, null 처리
- Spring Boot 핵심 개념 - MVC, 3계층, IoC, DI ← 현재 글
- REST API 만들기 - Controller, Service, Repository
- JPA와 데이터베이스 연동