Kotlin + Spring Boot 입문(4/6)
Kotlin/Spring

Spring Boot 핵심 개념 - MVC, 3계층, IoC, DI

Spring Boot MVC 패턴과 3계층 아키텍처, IoC, DI, 인터페이스 추상화까지 핵심 개념을 한 번에 이해한다.

2026-03-18
14 min read
#Spring Boot#Kotlin#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계층 각각에 붙이는 어노테이션:

어노테이션계층의미
@RestControllerPresentationREST API Controller
@ServiceBusiness비즈니스 로직 클래스
@RepositoryData AccessDB 접근 클래스

프로젝트 구조

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() // 내가 직접 만들고
    // 다 쓰면 내가 직접 정리
}

문제가 있다. UserControllerUserService를 직접 만드니까, 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)
}

UserControllerUserService가 어떻게 만들어졌는지 전혀 모른다. 그냥 받아서 쓸 뿐이다.

세 개념의 관계

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로 분리
ControllerPresentation Layer. HTTP 요청/응답 담당
ServiceBusiness Layer. 비즈니스 로직 담당
RepositoryData Access Layer. DB 접근 담당
IoC객체 생성과 관리를 Spring에게 맡기는 원칙
BeanSpring이 IoC 컨테이너에서 관리하는 객체
DIIoC 컨테이너의 빈을 필요한 곳에 주입하는 방법
인터페이스 추상화구현 교체·테스트를 유연하게 만드는 설계

시리즈: Kotlin + Spring Boot 입문

  1. 개발 환경 설치 - IntelliJ, JDK, 첫 Spring Boot 실행
  2. Kotlin 문법 - 변수, 함수, 클래스, null 처리
  3. Spring Boot 핵심 개념 - MVC, 3계층, IoC, DI ← 현재 글
  4. REST API 만들기 - Controller, Service, Repository
  5. JPA와 데이터베이스 연동