본문으로 건너뛰기
2026. 7. 12
© WONKOOK LEE

스프링(Spring)은 프론트엔드의 무엇과 닮았을까요?

스프링의 개념들을 프론트엔드 개념과 나란히 놓은 그림

백엔드 레포에서 @RestController가 붙은 코틀린 파일을 처음 열었을 때, 코드 자체는 이상하리만치 익숙한데 코드를 감싼 용어들이 낯설었습니다. 의존성 주입(DI), 제어의 역전(IoC), 관점 지향(AOP), 컨테이너, 빈(Bean) — 필기 노트를 아무리 다시 읽어도 자바·백엔드 진영의 단어로만 설명되어 있어 좀처럼 손에 잡히지 않았습니다.

그런데 하나씩 정체를 풀어보니 전부 프론트엔드에서 이미 하던 것들이었습니다. 인터페이스로 결합도를 낮추고, 공통 관심사를 인터셉터로 모으고, 보일러플레이트를 스타터로 자동화하는 일 — 이름만 다른 같은 이야기였습니다.

스프링의 개념들은 프론트엔드의 무엇에 대응하고, 왜 하필 그렇게 생겼을까요?


이 글에서 다루는 내용

이 글은 스프링(Spring)을, 그리고 백엔드 자체를 처음 마주한 프론트엔드 개발자를 독자로 상정합니다. 스프링을 쓰는 백엔드 코드를 열어 무슨 말인지 읽어내고 필요하면 고칠 수 있게 되는 것을 목표로, 핵심 개념을 처음부터 끝까지 훑습니다 — 빈과 컨테이너, 의존성 주입과 제어의 역전, 관점 지향, 요청이 처리되는 길, 데이터와 트랜잭션, 설정과 프로파일, 테스트, 그리고 이 모든 것을 묶어주는 스프링 부트까지. 새 개념이 나올 때마다 프론트엔드에서 이미 쓰고 있는 개념(React, TypeScript, 데코레이터, 라우터, ORM, .env, Vitest)에 대응시켜 설명하겠습니다. 자바나 코틀린 문법을 몰라도 따라올 수 있도록 코드는 최소한으로만 쓰고 각 조각의 뜻을 짚어가겠습니다.





1. 스프링은 무엇을 하는 프레임워크인가

스프링은 자바·코틀린 진영에서 사실상 표준으로 쓰이는 애플리케이션 프레임워크입니다. 흔히 웹 프레임워크로 알려져 있지만, 그 핵심은 뒤에서 다룰 IoC 컨테이너이고 웹 기능(Spring MVC)은 그 위에 얹힌 한 부분입니다. 기능 목록으로 소개할 수도 있지만, 그보다 먼저 이 프레임워크가 붙들고 있는 목표 하나를 잡는 편이 이해에 훨씬 도움이 됩니다.

코드를 느슨하게 결합(loose coupling)하여, 변화에 유연하고 테스트하기 쉬운 구조를 만든다.

스프링이 제공하는 거의 모든 장치는 이 한 문장을 실현하기 위한 도구입니다. 프론트엔드 개발자에게 이 목표는 전혀 새롭지 않습니다. React에서 화면을 레고 블록 같은 컴포넌트로 잘게 쪼개고, 각 컴포넌트가 서로의 내부를 모른 채 props라는 계약으로만 대화하게 만드는 이유가 바로 이것이기 때문입니다.

스프링은 같은 발상을 백엔드의 비즈니스 로직에 적용합니다. 결제, 정산, 알림 같은 핵심 로직을 독립된 블록으로 만들어 조립하고, 블록끼리 강하게 얽매이지 않게 합니다. 이렇게 느슨하게 결합해두면 두 가지가 자연스럽게 따라옵니다.

  • 재사용성: 특정 기능 블록만 떼어 다른 곳에서 다시 쓰기 쉬워집니다.
  • 테스트 용이성: 검증하려는 블록만 떼어내 가짜 객체(mock)로 주변을 채우고 단위 테스트를 돌릴 수 있습니다.

프론트엔드에서 잘 쪼갠 컴포넌트가 스토리북에 독립적으로 올라가고 테스트하기 쉬운 것과 정확히 같은 이점입니다. 결국 스프링이 말하는 "좋은 구조"는 우리가 컴포넌트 설계에서 추구하던 그 구조입니다.


Recap

스프링은 웹 프레임워크로 알려져 있지만 실제로는 IoC 컨테이너를 핵심으로 한 애플리케이션 프레임워크이고, 그 궁극적인 목표는 코드를 느슨하게 결합하여 변화에 유연하고 테스트하기 쉬운 구조를 만드는 것입니다. 이는 React에서 화면을 독립된 컴포넌트로 쪼개고 props 계약으로만 대화하게 하는 발상과 같습니다. 느슨한 결합은 재사용성과 테스트 용이성으로 이어지며, 이 글의 나머지 개념들은 모두 이 목표를 실현하기 위한 장치입니다.




2. 빈(Bean)과 컨테이너, 그리고 어노테이션

스프링을 이해하는 가장 좋은 출발점은 빈(Bean)과 컨테이너(container)입니다. 앞으로 나올 거의 모든 개념이 이 둘 위에 서 있기 때문입니다.


빈은 스프링이 대신 관리하는 객체입니다

빈(Bean)이란 우리가 직접 new로 만들지 않고, 스프링 컨테이너에 맡겨 대신 생성·관리하게 한 객체입니다. 그리고 그 빈들을 담아두고, 필요할 때 꺼내주고, 서로 연결해주는 커다란 상자가 컨테이너(스프링에서는 ApplicationContext라고 부릅니다)입니다.

그러면 어떤 객체가 빈이 될까요? 클래스 위에 어노테이션(annotation)을 붙여 "이건 스프링이 관리해줘"라고 표시합니다. 표시의 종류는 역할에 따라 나뉩니다.

어노테이션붙이는 대상
@Component특별한 역할이 없는 일반적인 빈
@Service비즈니스 로직을 담는 빈
@RepositoryDB 접근을 담당하는 빈
@Controller · @RestController웹 요청을 받는 빈

@Service, @Repository, @Controller는 사실 @Component의 특수화된 형태입니다. 기능은 거의 같고, 이름으로 "이 클래스가 어떤 층에 속하는지"를 드러내는 의미가 큽니다. 스프링은 애플리케이션이 시작될 때 패키지를 훑어(컴포넌트 스캔, component scan) 이 어노테이션이 붙은 클래스를 모두 찾아 빈으로 등록합니다.


어노테이션은 데코레이터입니다

여기서 프론트엔드 개발자가 붙잡아야 할 대응이 하나 있습니다. 스프링 코드가 온통 @ 기호로 뒤덮여 있어 낯설게 느껴지는데, 이 어노테이션은 프론트엔드의 데코레이터(decorator)와 같은 문법적 장치입니다. 클래스나 함수 위에 얹어 "이건 이런 것이다" 하고 메타데이터와 동작을 부여하는 방식이 똑같습니다.

Angular를 써봤다면 @Injectable(), @Component()가 바로 떠오를 것이고, NestJS의 @Controller()·@Get()은 스프링에서 그대로 가져온 것이나 다름없습니다. 즉 우리는 이 표기법을 이미 알고 있었습니다.


기본 스코프는 싱글턴입니다

컨테이너에 등록된 빈은 기본적으로 싱글턴(singleton) 으로 관리됩니다. 애플리케이션 전체에서 딱 하나만 만들어 두고 모두가 그 하나를 공유한다는 뜻입니다. 요청이 올 때마다 서비스 객체를 새로 만드는 게 아니라, 앱이 뜰 때 만들어 둔 인스턴스를 계속 재사용합니다.

프론트엔드로 옮기면, 모듈 스코프에 인스턴스를 하나 만들어 앱 전체가 import해 쓰는 것 — 예컨대 apiClient 하나를 만들어 모든 곳에서 공유하는 패턴 — 과 같습니다.


Recap

빈(Bean)은 우리가 직접 new하지 않고 스프링 컨테이너(ApplicationContext)에 맡겨 관리하는 객체이고, @Component·@Service·@Repository·@Controller 같은 어노테이션을 붙이면 컴포넌트 스캔을 통해 빈으로 등록됩니다. 이 어노테이션은 프론트엔드의 데코레이터와 같은 장치라 이미 익숙한 표기법이며, 등록된 빈은 기본적으로 싱글턴으로 앱 전체가 공유합니다. 다음 장에서는 이 빈들을 서로 어떻게 연결하는지를 봅니다.




3. 의존성 주입(DI)과 제어의 역전(IoC)

컨테이너가 빈들을 관리한다면, 그 빈들을 서로 이어 붙이는 방식이 의존성 주입(DI, Dependency Injection)과 제어의 역전(IoC, Inversion of Control)입니다. 이름은 둘이지만 한 몸으로 움직이는 개념입니다.


문제는 new에서 시작합니다

어떤 컴포넌트가 다른 객체를 필요로 할 때, 그 객체를 내부에서 직접 생성하면 어떤 일이 벌어지는지부터 봅니다.

// 컨트롤러가 필요한 구현체를 내부에서 직접 만들어 끌어안는다
@RestController
class NoDiController {
// 구체적인 구현체(MyServiceImpl)에 직접 의존한다 — 결합도가 극도로 높다
private val service: MyService = MyServiceImpl()

@GetMapping("/hello")
fun getHello(): String = service.getHello()
}

이 코드는 MyServiceImpl이라는 구체적인 구현체를 컨트롤러가 직접 new로 붙들고 있습니다. 문제는 두 가지입니다.

  • 교체가 어렵습니다. 나중에 MyServiceImplAdvancedServiceImpl로 바꾸려면 컨트롤러 코드 자체를 열어 고쳐야 합니다.
  • 테스트가 사실상 불가능합니다. 단위 테스트에서 service를 가짜 객체로 갈아끼울 방법이 없습니다. 진짜 DB나 네트워크를 타는 구현체가 컨트롤러 안에 못 박혀 있기 때문입니다.

프론트엔드로 옮기면, 컴포넌트가 자기 안에서 new ApiClient()를 직접 만들어 쓰는 것과 같습니다. 그 컴포넌트를 테스트하려는 순간 진짜 네트워크를 타게 되어 손을 쓸 수 없게 되는, 익숙한 그 곤란함입니다.


주입받으면 달라집니다

DI는 이 관계를 뒤집습니다. 컴포넌트는 "나는 이런 인터페이스가 필요하다"고 선언만 하고, 실제 알맹이는 바깥에서 넣어(inject) 줍니다.

// 1. 알맹이(구현체)는 스프링이 관리하도록 등록만 해둔다 (2장에서 본 빈 등록)
@Service
class MyServiceImpl : MyService {
override fun getHello(): String = "Hello"
}

// 2. 컨트롤러는 인터페이스만 바라보고, 생성자로 주입받는다
@RestController
class DiController(
private val myService: MyService // 스프링이 알아서 MyServiceImpl을 넣어준다
) {
@GetMapping("/hello")
fun getHello(): String = myService.getHello()
}

이제 컨트롤러는 MyService라는 인터페이스만 알 뿐, 그 뒤에 어떤 구현체가 서 있는지 모릅니다. 구현체가 어떻게 바뀌든 컨트롤러 코드는 한 줄도 바뀌지 않습니다. 테스트할 때는 진짜 구현체 대신 껍데기뿐인 MockMyService를 생성자에 넣어주면 그만입니다.

프론트엔드에서 props로 모킹된 함수를 넘겨 컴포넌트를 테스트하는 것과 같은 이치입니다. 컴포넌트가 콜백을 직접 만들지 않고 부모에게서 받아 쓰듯, 컨트롤러도 의존 객체를 스스로 만들지 않고 바깥에서 받습니다.


"역전"된 것은 무엇인가

여기서 IoC의 "역전"이 무엇을 뒤집었는지가 드러납니다. 원래는 내가 필요한 객체를 언제 어떻게 만들지 통제했습니다(new MyServiceImpl()). DI에서는 그 통제권이 스프링 컨테이너(container)로 넘어갑니다. 객체를 만들고, 서로 연결하고, 수명을 관리하는 일을 프레임워크가 대신하는 것 — 이 통제권의 이동이 바로 제어의 역전입니다. DI는 그 역전을 구현하는 구체적인 방법입니다.

React로 비유하면, 우리가 컴포넌트를 new로 인스턴스화하지 않고 그저 <MyComponent />라고 선언하면 React 런타임이 알아서 생성·마운트·소멸을 관리해주는 것과 결이 같습니다. 만드는 주체가 내가 아니라 프레임워크라는 점에서 이미 우리는 IoC 안에 살고 있었던 셈입니다.


Recap

의존성 주입(DI)은 컴포넌트가 필요한 객체를 내부에서 new로 직접 만들지 않고, 인터페이스만 선언한 뒤 바깥에서 주입받게 하는 방식입니다. 이렇게 하면 구현체 교체가 자유롭고 테스트에서 가짜 객체로 갈아끼우기 쉬워집니다. 객체의 생성·연결·수명을 스프링 컨테이너가 대신 통제하게 되는 이 통제권의 이동이 제어의 역전(IoC)이며, React가 컴포넌트의 생명주기를 관리하고 우리가 props로 의존을 넘기는 것과 같은 발상입니다.




4. 관점 지향 프로그래밍(AOP)

로깅, 권한 검사, 트랜잭션 관리(DB 작업의 성공·실패를 묶어 처리) 같은 기능은 어떤 API를 만들든 공통으로 따라붙습니다. 이런 부가 기능을 비즈니스 로직 한복판에 일일이 끼워 넣으면, 정작 중요한 로직(예: 급여 정산 계산)이 로그 코드와 권한 코드에 파묻혀 지저분해집니다. 이렇게 여러 곳에 흩어지는 공통 관심사를 횡단 관심사(cross-cutting concerns)라 부르고, 이것을 한곳에 모아 모듈화하는 기법이 관점 지향 프로그래밍(AOP, Aspect-Oriented Programming)입니다.


이미 쓰고 있는 개념입니다

프론트엔드 개발자에게 AOP는 Axios 인터셉터(interceptor)나 고차 컴포넌트(HOC, Higher-Order Component)와 개념적으로 같습니다.

Axios 인터셉터를 떠올려 보면, 우리는 모든 요청에 토큰을 붙이거나 모든 응답의 에러를 공통 처리하는 코드를 각 API 호출부에 흩뿌리지 않습니다. 인터셉터 한곳에 모아두고, 실제 요청 코드는 자기 일에만 집중하게 둡니다. AOP가 하려는 일이 정확히 이것입니다.

  • 핵심 관점: 진짜 비즈니스 로직 (예: 급여를 정산한다)
  • 부가 관점: 모든 API에 공통으로 필요한 기능 (예: 이 요청 처리에 몇 ms 걸렸는지 로그를 남긴다, 권한을 확인한다)

원리: 앞에 대리자를 세운다

AOP가 특별한 것은, 기존 비즈니스 로직 코드는 단 한 줄도 건드리지 않는다는 점입니다. 이를 실현하는 방법은 여러 가지인데, 스프링은 그중 프록시 패턴(proxy pattern)을 씁니다. 진짜 객체 앞에 대리자(proxy) 객체를 하나 세워두고, 요청이 진짜 객체에 닿기 전과 후에 부가 기능을 대신 실행하는 방식입니다. (AspectJ처럼 컴파일·로드 시점에 바이트코드를 직접 고쳐 넣는 위빙(weaving) 방식도 있습니다.)

요청 ──▶ [프록시] ──(부가 기능: 로그·권한)──▶ [진짜 객체: 비즈니스 로직] ──▶ 응답

Axios 인터셉터가 우리 요청 코드를 수정하지 않고 요청과 응답 사이에 끼어드는 것, HOC가 원본 컴포넌트를 감싸(wrapping) 원본을 고치지 않은 채 기능을 덧입히는 것과 정확히 같은 구조입니다. 스프링은 이 대리자 객체를 런타임에 자동으로 만들어 진짜 객체 대신 세워둡니다. 뒤에서 볼 @Transactional이 바로 이 방식으로 동작합니다.


Recap

관점 지향 프로그래밍(AOP)은 로깅·권한·트랜잭션처럼 여러 곳에 흩어지는 횡단 관심사를 한곳에 모아 모듈화하는 기법입니다. 비즈니스 로직 코드를 수정하지 않고, 스프링에서는 진짜 객체 앞에 프록시(대리자)를 세워 부가 기능을 앞뒤로 실행합니다. 이는 프론트엔드의 Axios 인터셉터가 요청·응답 사이에 끼어들거나, HOC가 원본 컴포넌트를 감싸 기능을 덧입히는 것과 같은 발상입니다.




5. 요청은 어떻게 처리되는가: 웹 레이어

지금까지 @RestController@GetMapping을 코드에서 보기만 했는데, 이제 요청이 실제로 어떻게 이 함수까지 도달하는지 따라가 보겠습니다. 서두에서 "회사의 @RestController 파일을 열어보라"고 했으니, 그 파일이 무슨 일을 하는지가 이 장의 주제입니다.


모든 요청은 한 문 앞에 줄을 섭니다

스프링으로 들어오는 모든 HTTP 요청은 먼저 DispatcherServlet이라는 단일 진입점을 거칩니다. 이 하나의 문이 요청을 받아 "이건 누가 처리해야 하지?"를 판단하고 알맞은 담당자에게 넘깁니다. 이런 구조를 프론트 컨트롤러(front controller) 패턴이라 부릅니다.

프론트엔드로 옮기면 이것은 라우터입니다. React Router나 Express의 앱 인스턴스가 들어온 경로를 보고 알맞은 컴포넌트·핸들러로 넘기는 것과 똑같은 역할을, 서버 앞단에서 DispatcherServlet이 합니다.


라우팅은 어노테이션으로 선언합니다

DispatcherServlet은 요청의 URL과 HTTP 메서드를 보고, 그에 맞는 어노테이션이 붙은 컨트롤러 메서드로 요청을 넘깁니다. 이 매핑이 곧 라우팅입니다.

@RestController
@RequestMapping("/users") // 이 컨트롤러의 공통 경로 접두사
class UserController(
private val userService: UserService, // 3장의 DI로 주입받는다
) {
// GET /users/42
@GetMapping("/{id}")
fun getUser(@PathVariable id: Long): UserResponse =
userService.findById(id)

// POST /users (요청 본문의 JSON이 CreateUserRequest 객체로 변환되어 들어온다)
@PostMapping
fun createUser(@RequestBody request: CreateUserRequest): UserResponse =
userService.create(request)
}

여기서 반환값(UserResponse)은 스프링이 알아서 JSON으로 직렬화해 HTTP 응답 본문에 실어 보냅니다. @RestController가 바로 "반환값을 그대로 응답 본문으로 만들라"는 의미이기 때문입니다. (전통적인 @Controller는 반환값을 렌더링할 뷰(HTML 템플릿)의 이름으로 해석합니다. API 서버를 만들 때는 대부분 @RestController를 씁니다.)

요청에서 값을 꺼내는 어노테이션들도 프론트엔드의 익숙한 개념에 그대로 대응됩니다.

스프링하는 일Express로 치면
@PathVariable경로의 일부(/users/{id})를 꺼냄req.params
@RequestParam쿼리스트링(?page=2)을 꺼냄req.query
@RequestBody요청 본문(JSON)을 객체로 변환req.body
메서드의 반환값JSON 응답 본문으로 변환res.json(...)

@RestController가 붙은 파일은 결국 Express의 라우트 핸들러 모음이나 Next.js의 route handler와 같은 것 — 경로별로 "이 요청이 오면 이 함수를 실행한다"를 모아둔 곳입니다.


Recap

스프링으로 들어오는 모든 요청은 DispatcherServlet이라는 단일 진입점(프론트 컨트롤러)을 거쳐, URL과 HTTP 메서드에 맞는 컨트롤러 메서드로 라우팅됩니다. @GetMapping·@PostMapping이 경로를, @PathVariable·@RequestParam·@RequestBody가 요청에서 값을 꺼내고, 메서드의 반환값은 JSON 응답으로 변환됩니다. 이는 React Router·Express가 경로를 알맞은 핸들러로 넘기고 req.params·req.body로 값을 꺼내 res.json으로 응답하는 흐름과 정확히 같습니다.




6. 데이터 다루기: JPA와 트랜잭션

대부분의 백엔드가 결국 하는 일은 데이터베이스와 대화하는 것입니다. 스프링에서 가장 흔한 방식이 Spring Data JPA입니다.


JPA는 백엔드의 ORM입니다

JPA(Java Persistence API)는 자바·코틀린 객체와 DB 테이블을 매핑해주는 표준이고, 그 대표 구현체가 하이버네이트(Hibernate)입니다. 역할로만 보면 프론트엔드에서 만난 Prisma나 TypeORM과 같은 ORM입니다 — SQL을 직접 쓰는 대신 객체를 다루면 라이브러리가 SQL로 번역해줍니다.

// 이 클래스는 DB 테이블의 한 행(row)에 대응된다는 표시
@Entity
class User(
@Id @GeneratedValue
val id: Long = 0,
val email: String, // 각 필드가 곧 테이블의 컬럼이 된다
val name: String,
)

@Entity가 붙은 클래스는 테이블, 필드는 컬럼에 대응됩니다. Prisma 스키마에서 모델 하나를 정의하는 것과 같은 자리입니다.


리포지토리는 인터페이스만 선언합니다

DB를 실제로 읽고 쓰는 코드는 놀랍게도 인터페이스를 선언하기만 하면 됩니다. 구현체는 스프링이 런타임에 자동으로 만들어 빈으로 등록해줍니다.

interface UserRepository : JpaRepository<User, Long> {
// 메서드 이름의 규칙만으로 쿼리가 자동 생성된다 (SELECT ... WHERE email = ?)
fun findByEmail(email: String): User?
}

JpaRepository를 상속하는 것만으로 findById, save, delete 같은 기본 CRUD가 공짜로 딸려 옵니다. 게다가 findByEmail처럼 이름만 규칙에 맞게 지으면 그에 맞는 쿼리를 스프링이 만들어줍니다. 우리가 직접 구현하지 않은 인터페이스가 동작하는 이유 역시 앞서 본 컨테이너와 DI 덕분입니다.


@Transactional은 AOP의 대표 사례입니다

주문을 하나 처리할 때 재고를 줄이고, 결제를 기록하고, 배송을 예약한다고 해봅시다. 이 중 하나라도 실패하면 앞의 작업들도 전부 없던 일로 되돌려야 합니다. 이렇게 여러 DB 작업을 "전부 성공 아니면 전부 취소(롤백)"로 묶는 것이 트랜잭션이고, 스프링에서는 메서드 위에 @Transactional 한 줄로 표현합니다.

@Service
class OrderService(/* ... */) {

@Transactional // 이 메서드 안의 DB 작업을 하나로 묶는다 — 중간에 예외가 나면 전부 롤백
fun placeOrder(command: PlaceOrder) {
reduceStock(command)
recordPayment(command)
scheduleDelivery(command)
}
}

그런데 이 어노테이션 한 줄이 어떻게 "성공하면 커밋, 실패하면 롤백"을 알아서 해줄까요? 바로 4장에서 본 AOP입니다. 스프링은 OrderService 앞에 프록시를 세워, 메서드 실행 전에 트랜잭션을 열고 정상 종료되면 커밋, 예외가 던져지면 롤백을 대신 수행합니다. 트랜잭션 관리라는 횡단 관심사가 비즈니스 로직에서 완전히 분리되어 어노테이션 한 줄로 붙는 것 — AOP가 실무에서 가장 빛나는 순간입니다.


Recap

Spring Data JPA는 객체와 테이블을 매핑하는 ORM으로, 프론트엔드의 Prisma·TypeORM에 해당합니다. @Entity로 테이블을, JpaRepository를 상속한 인터페이스로 DB 접근을 선언하면 CRUD와 이름 규칙 기반 쿼리를 자동으로 얻습니다. 여러 DB 작업을 전부 성공 또는 전부 롤백으로 묶는 트랜잭션은 @Transactional 한 줄로 표현되며, 그 내부 동작은 4장에서 본 AOP 프록시로 구현됩니다.




7. 설정과 프로파일

DB 주소, 포트, 외부 API 키처럼 환경마다 달라지는 값은 코드에 박아두면 안 됩니다. 스프링 부트는 이런 값을 application.yml(또는 application.properties)이라는 중앙 설정 파일에 모읍니다.

# application.yml
server:
port: 8080
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb

프론트엔드의 .env에 해당하지만, 평평한 키·값이 아니라 계층 구조를 갖는다는 점이 다릅니다. 이렇게 모아둔 값은 코드에서 꺼내 쓸 수 있습니다.

@Service
class PaymentService(
// 설정 파일의 값을 주입받는다 (개별 값)
@Value("\${payment.api-key}") private val apiKey: String,
) { /* ... */ }

개별 값은 @Value로, 서로 관련된 값 묶음은 @ConfigurationProperties로 타입이 있는 객체에 한 번에 담아 쓰는 것이 보통입니다.


프로파일: 환경별로 설정을 가른다

개발·스테이징·운영 환경은 DB도 키도 다릅니다. 스프링은 이를 프로파일(profile) 로 가릅니다. application-dev.yml, application-prod.yml처럼 환경별 파일을 두고, 앱을 실행할 때 어떤 프로파일을 쓸지 지정하면 그에 맞는 설정이 얹힙니다.

프론트엔드의 .env.development / .env.production을 빌드·실행 모드에 따라 갈아 끼우는 것과 정확히 같은 발상입니다.


Recap

환경마다 달라지는 값은 application.yml이라는 중앙 설정 파일에 모으며, 이는 계층 구조를 갖는 .env에 해당합니다. 설정값은 @Value(개별 값)나 @ConfigurationProperties(묶음)로 코드에 주입받습니다. 개발·운영 환경을 가르는 프로파일(application-dev.yml 등)은 프론트엔드에서 .env.development·.env.production을 갈아 끼우는 것과 같은 방식입니다.




8. 테스트

1장에서 "느슨한 결합의 목적은 테스트 용이성"이라 했고, 3장에서 "DI 덕분에 가짜 객체로 갈아끼울 수 있다"고 했습니다. 그 약속이 실제로 어떻게 지켜지는지가 이 장입니다. 앞서 스타터 표에서 본 spring-boot-starter-test가 JUnit·Mockito 등 테스트 도구를 한데 묶어 제공합니다.

  • @SpringBootTest: 실제 스프링 컨테이너를 통째로 띄워서 하는 통합 테스트입니다. 앱 전체를 실행해 검증하므로 든든하지만 무겁습니다. 프론트엔드에서 앱 전체를 마운트해 돌리는 통합 테스트에 해당합니다.
  • MockMvc: 진짜 서버(네트워크)를 띄우지 않고 HTTP 요청을 흉내 내 컨트롤러를 검증합니다. supertest나 MSW로 요청을 흉내 내 라우트를 테스트하는 것과 같습니다.
  • @MockitoBean(예전 이름은 @MockBean): 컨테이너의 특정 빈을 가짜로 교체합니다. 예를 들어 진짜 결제 API를 호출하는 빈을 가짜로 바꿔두면, 실제 결제 없이 로직만 검증할 수 있습니다. jest.mock·vi.mock으로 의존성을 교체하는 것과 같은 자리입니다.

가짜 빈을 이렇게 쉽게 끼워 넣을 수 있는 것은, 처음부터 각 컴포넌트가 의존을 직접 new하지 않고 주입받도록(DI) 설계되어 있기 때문입니다. 1장의 "테스트하기 쉬운 구조"가 여기서 결실을 맺습니다.


Recap

spring-boot-starter-test는 JUnit·Mockito 등을 묶어 제공합니다. @SpringBootTest는 컨테이너를 띄운 통합 테스트, MockMvc는 서버 없이 HTTP 요청을 흉내 낸 컨트롤러 테스트(≈ supertest), @MockitoBean은 특정 빈을 가짜로 교체하는 장치(≈ vi.mock)입니다. 이 모든 테스트 편의가 가능한 근본 이유는 앞서 본 DI 설계이며, 1장의 "테스트하기 쉬운 구조"라는 목표가 여기서 실현됩니다.




9. 스프링 프레임워크와 스프링 부트

여기까지 빈·DI·AOP·웹·데이터·설정·테스트를 하나씩 봤습니다. 마지막으로, 이 모든 것을 실제 프로젝트로 묶어 손쉽게 시작하게 해주는 것이 스프링 부트(Spring Boot)입니다. 실무에서 프로젝트를 열면 대개 만나는 이름도 "스프링"이 아니라 "스프링 부트"입니다.


스프링 부트가 없던 시절

과거의 스프링 프레임워크는 스무 개 남짓한 모듈로 이루어진 크고 무거운 도구 모음이었습니다. 웹 기능을 하려면 Spring MVC를, DB를 연결하려면 Spring JDBC나 ORM 모듈을 일일이 가져와 서로 호환되는 버전으로 맞추고, 방대한 설정을 손수 작성해야 했습니다. 이 귀찮은 설정 과정을 알아서 자동화(auto-configuration)해주는 해결사가 바로 스프링 부트입니다.

프론트엔드로 옮기면 이 그림이 아주 익숙합니다. 웹팩·바벨·린터·개발 서버를 서로 호환되는 버전으로 일일이 맞추기 버거워서, 우리는 Next.jsVite 같은 스타터·보일러플레이트를 쓰기 시작했습니다. 스프링 부트는 백엔드 진영의 그 스타터 보일러플레이트에 해당합니다.


스타터(Starter): 검증된 세트를 한 번에

스프링 부트의 핵심 도구는 스타터(Starter)입니다. spring-boot-starter-... 모듈 하나만 가져오면, 그 목적에 필요한 라이브러리들이 호환성이 검증된 버전 세트로 함께 딸려 옵니다. 개별 라이브러리 버전을 손으로 맞출 일이 사라지는 것입니다.

백엔드 코드의 build.gradle 파일에서 아래 의존성들을 만나면, 각각 이런 역할을 하는구나 하고 읽으시면 됩니다. 이제 각 스타터가 다루는 개념은 앞 단원에서 이미 만난 것들입니다.

스타터 이름역할이 글에서 다룬 곳 · FE로 치면
spring-boot-starter-webREST API 서버의 핵심 (라우팅, HTTP 요청 처리, 내장 웹서버)5장 · 서버 라우터 + 요청 핸들링
spring-boot-starter-data-jpa코드로 DB를 다루게 해주는 ORM6장 · Prisma, TypeORM
spring-boot-starter-security인증·인가와 라우트 접근 제어인증 미들웨어
spring-boot-starter-test단위·통합 테스트 도구 모음8장 · Jest, Vitest

정리해 보면, 스프링 부트가 하는 일은 프론트엔드에서 스타터가 하는 일과 같습니다 — 설정과 버전 맞추기라는 반복 작업을 걷어내고, 곧바로 비즈니스 로직에 집중하게 해줍니다.


Recap

스프링 프레임워크는 스무 개 남짓한 모듈로 이루어져 설정이 번거로웠고, 그 설정을 자동화(auto-configuration)해주는 것이 스프링 부트입니다. 스프링 부트의 스타터(spring-boot-starter-...)는 목적에 맞는 라이브러리들을 호환 검증된 버전 세트로 한 번에 가져와, 버전을 손으로 맞추는 수고를 없애줍니다. 이는 프론트엔드에서 Next.js·Vite 같은 스타터 보일러플레이트가 도구 체인 설정을 대신 걷어내 주는 것과 같은 역할입니다.




FE ↔ 스프링 대응표

이 글에서 다룬 대응을 한자리에 모읍니다.

스프링 개념FE에서 가장 가까운 것대응의 핵심
느슨한 결합 · 컴포넌트 아키텍처잘게 쪼갠 React 컴포넌트독립된 블록의 조립, 높은 테스트 용이성
빈(Bean) · 컨테이너모듈 스코프의 공유 싱글턴 (예: apiClient)프레임워크가 관리하는 객체
어노테이션 (@Service 등)데코레이터 (Angular · NestJS)얹어서 메타데이터·동작을 부여
의존성 주입(DI)props로 넘기는 의존·콜백스스로 만들지 않고 바깥에서 받는다
제어의 역전(IoC) · 컨테이너컴포넌트 생명주기를 관리하는 React 런타임생성·연결·수명의 통제권이 프레임워크로
관점 지향(AOP) · 프록시Axios 인터셉터 · 고차 컴포넌트(HOC)원본을 고치지 않고 공통 관심사를 감싼다
DispatcherServlet · 라우팅React Router · Express 앱요청을 알맞은 핸들러로 넘기는 단일 문
@RestController 핸들러Express 라우트 핸들러 · Next.js route handler경로별 처리 함수의 모음
JPA · @EntityPrisma · TypeORM객체와 테이블을 매핑하는 ORM
@Transactional(AOP로 구현된) 트랜잭션 경계전부 성공 아니면 전부 롤백
application.yml · 프로파일.env · .env.production환경별 설정 분리
@SpringBootTest · MockMvc · @MockitoBeanVitest 통합 테스트 · supertest · vi.mockDI가 열어주는 테스트 용이성
스프링 부트 · 스타터Next.js · Vite 보일러플레이트설정·버전 맞추기 자동화

용어만 자바·백엔드 진영의 단어를 쓸 뿐, "유지보수하기 좋게 모듈화하고, 인터페이스로 결합도를 낮추며, 테스트하기 쉽게 만든다"는 핵심은 프론트엔드와 완벽하게 맞닿아 있습니다. 이제 회사 코드베이스에서 @RestController가 붙은 파일 하나를 열어보시면, 위 구조가 어떻게 구현되어 있는지 조금 더 또렷하게 보이실 것입니다.




References

스프링 공식 문서

  1. Spring Framework — Core Technologies (IoC Container & Beans)
  2. Spring Framework — Aspect Oriented Programming with Spring
  3. Spring Framework — Web on Servlet Stack (DispatcherServlet)
  4. Spring Data JPA — Reference Documentation
  5. Spring Framework — Transaction Management
  6. Spring Boot — Reference Documentation
  7. Spring Boot — Testing
  8. Spring Boot Starters 목록

개념 배경

  1. Martin Fowler — Inversion of Control Containers and the Dependency Injection pattern
  2. Martin Fowler — Inversion of Control
  3. Wikipedia — Cross-cutting concern
  4. Wikipedia — Front controller pattern


좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자