
프로젝트 구조를 잡고 나면 그다음으로 자연스럽게 드는 생각이 있습니다.
“그래서 이 파일들 안에는 대체 어떤 코드가 들어가야 하지?”
처음 스프링을 공부할 때는 저도 Controller, Service, Repository를 일단 만들어두고, 필요한 코드를 그때그때 넣으면 된다고 생각했습니다.
그런데 실제로 API를 하나씩 구현해보니 이 세 계층은 단순히 관습적으로 나누는 것이 아니라, 각자 맡아야 하는 역할이 분명히 다르다는 걸 알게 됐습니다.
요청을 받는 곳, 실제 로직을 처리하는 곳, 그리고 DB와 소통하는 곳이 뒤섞이기 시작하면 코드는 금방 복잡해지고, 나중에는 어디를 수정해야 하는지도 헷갈리게 됩니다.
그래서 이번 글에서는 워크북 내용을 바탕으로 Controller, Service, Repository가 각각 어떤 역할을 하는지, 왜 이렇게 나누는지, 그리고 실제로 요청이 들어왔을 때 어떤 흐름으로 동작하는지를 정리해보려고 합니다.
이번 글에서 다룰 내용은 다음과 같습니다.
- Controller는 어떤 역할을 하는가
- Service는 어떤 역할을 하는가
- Repository는 어떤 역할을 하는가
- Request Body를 받을 때 DTO를 왜 사용하는가
- 계층을 나누는 것이 왜 중요한가
- HTTP 요청부터 DB 조회, 응답까지 어떤 흐름으로 이어지는가
왜 Controller, Service, Repository를 나눌까?
스프링 프로젝트를 처음 보면 Controller, Service, Repository라는 이름이 너무 당연하게 등장해서 “원래 이렇게 하는 건가 보다” 하고 넘어가기 쉽습니다.
하지만 이 구조는 단순한 관습이 아니라, 관심사를 분리하기 위한 기본적인 설계 방식이라고 볼 수 있습니다.
예를 들어 사용자의 정보를 조회하는 API가 있다고 해보겠습니다.
이때 한 클래스 안에서
- HTTP 요청을 받고
- 요청값을 검증하고
- 비즈니스 로직을 처리하고
- DB를 조회하고
- 응답 JSON까지 직접 만들기 시작하면
처음에는 빨리 구현되는 것처럼 보여도, 기능이 늘어날수록 수정이 어려워집니다.
반대로 역할을 나누면 흐름이 훨씬 명확해집니다.
- Controller는 요청과 응답을 담당하고
- Service는 실제 비즈니스 로직을 수행하고
- Repository는 DB와의 통신을 담당합니다
즉, 이 구조의 핵심은 코드를 예쁘게 나누는 데 있는 것이 아니라 각 계층이 해야 할 일만 맡도록 만드는 것에 있다고 느꼈습니다.
Controller란?

Controller는 말 그대로 클라이언트의 요청이 가장 먼저 도착하는 계층입니다.
브라우저, 앱, 프론트엔드 서버가 어떤 URI로 요청을 보내면 가장 먼저 그 요청을 받아주는 곳이 바로 Controller입니다.
Controller가 하는 일은 크게 세 가지로 정리할 수 있습니다.
- 클라이언트에게 HTTP 요청을 받는다
- 필요한 값을 꺼내서 Service에 전달한다
- Service가 반환한 결과를 다시 응답으로 돌려준다
즉, Controller의 핵심 역할은 요청과 응답의 입구 역할이라고 볼 수 있습니다.
중요한 점은 Controller가 너무 많은 일을 하면 안 된다는 것입니다.
비즈니스 로직까지 Controller에 들어가기 시작하면 요청 처리 코드와 실제 로직이 섞이게 되고, 나중에는 테스트나 수정도 어려워집니다.
그래서 Controller는 최대한 가볍게 두고, 필요한 값만 정리해서 Service로 넘기는 형태가 가장 깔끔합니다.
Controller에서 자주 보는 어노테이션
스프링에서 Controller를 작성할 때 자주 보는 어노테이션들이 있습니다.

@RestController
@RestController는 JSON 형식의 응답을 반환하는 컨트롤러를 만들 때 사용합니다.
기존의 서버 사이드 렌더링 방식에서는 서버가 HTML을 만들어서 반환하는 경우가 많았기 때문에 @Controller를 사용했습니다.
하지만 요즘처럼 프론트엔드와 백엔드가 분리된 구조에서는 백엔드가 HTML을 직접 조립하기보다 데이터를 JSON 형태로 전달하는 경우가 훨씬 많습니다.
그래서 REST API를 만드는 상황에서는 @Controller와 @ResponseBody가 합쳐진 @RestController를 사용하는 것이 일반적입니다.
이 부분은 처음엔 “그냥 REST API니까 붙이는 어노테이션” 정도로 생각했는데, 정리하면서 보니 결국 응답 형태가 HTML이 아니라 JSON이라는 점을 명확히 드러내는 역할이라고 이해하면 훨씬 쉬웠습니다.
@RequiredArgsConstructor
이 어노테이션은 생성자 주입을 자동으로 만들어주는 역할을 합니다.
예를 들어 Controller에서 Service를 주입받아야 할 때, private final 필드를 선언하면 필요한 생성자를 자동으로 생성해줍니다.
직접 생성자를 작성하지 않아도 되기 때문에 코드가 더 간결해지고, final 필드를 통해 의존성이 고정된다는 점에서도 장점이 있습니다.
@RequestMapping
@RequestMapping("/auth")처럼 사용하면 해당 컨트롤러가 처리할 URI의 공통 경로를 지정할 수 있습니다.
즉, 컨트롤러 내부의 메서드들이 /auth로 시작하는 요청을 처리하도록 만드는 방식입니다.
이렇게 공통 경로를 묶어두면 API 구조를 이해하기도 쉽고, 엔드포인트를 관리하기도 더 편해집니다.
클라이언트의 요청값은 어떻게 받을까?
Controller는 클라이언트가 보내는 여러 종류의 값을 받아야 합니다.
대표적으로는 다음과 같은 방식이 있습니다.
@RequestParam

쿼리 파라미터를 받을 때 사용합니다.
예를 들어 다음과 같은 요청이 있다고 하면
GET /members?page=1
page=1 같은 값을 @RequestParam으로 받을 수 있습니다.
@RequestBody

요청 본문에 담긴 JSON 데이터를 받을 때 사용합니다.
회원가입, 로그인, 게시글 작성처럼 클라이언트가 JSON 데이터를 본문에 담아 보내는 경우에 주로 사용합니다.
@PathVariable

URI 경로 자체에 포함된 값을 받을 때 사용합니다.
예를 들어
GET /members/1
에서 1은 Path Variable로 받을 수 있습니다.
@RequestHeader

헤더 값을 읽을 때 사용합니다.
추후 JWT 토큰 같은 인증 정보를 헤더에서 받을 때 자주 사용하게 됩니다.
이렇게 정리하고 보니 Controller는 단순히 “API 메서드가 있는 클래스”가 아니라, HTTP 요청의 다양한 구성 요소를 읽고 해석하는 계층이라고 볼 수 있었습니다.
Request Body를 받을 때 DTO를 사용하는 이유

Controller에서 @RequestBody를 사용할 때는 보통 DTO를 함께 사용합니다.
개인적으로 이 부분이 꽤 중요하다고 느꼈습니다.
처음에는 그냥 엔티티를 바로 받아도 되지 않을까 생각할 수 있습니다. 하지만 요청을 받을 때 DTO를 사용하면 장점이 분명합니다.
1. 요청 구조를 명확히 할 수 있다
예를 들어 클라이언트가 보내야 하는 값이 stringTest, longTest라고 정해져 있다면, DTO를 통해 어떤 형태의 데이터가 들어와야 하는지 명확하게 표현할 수 있습니다.
즉, 요청 스펙이 코드로 드러납니다.
2. 안정적으로 데이터를 받을 수 있다
서비스 계층은 이미 정해진 구조의 데이터를 전달받게 되므로, 들어오는 요청값을 더 안정적으로 다룰 수 있습니다.
3. 엔티티와 요청 객체를 분리할 수 있다
엔티티는 DB와 매핑되는 객체이고, 요청 DTO는 클라이언트가 보내는 데이터를 담는 객체입니다.
이 둘은 역할이 완전히 다르기 때문에 분리하는 것이 훨씬 안전합니다.
Service란?

Service는 실제 비즈니스 로직이 수행되는 계층입니다.
Controller가 요청을 받았다면, 이제 그 요청값을 넘겨받아 “이 API가 실제로 해야 하는 일”을 처리하는 곳이 바로 Service입니다.
Service가 하는 일은 보통 다음과 같습니다.
- Controller에게 전달받은 데이터를 받는다
- 필요한 비즈니스 로직을 수행한다
- Repository를 통해 DB를 조회하거나 저장한다
- 응답 DTO를 만들어 Controller에 돌려준다
예를 들어 회원 정보를 조회하는 API라면, Service에서는
- 전달받은 ID로 회원을 찾고
- 회원이 없으면 예외를 발생시키고
- 회원이 있으면 응답 DTO로 변환해서 반환하는
방식으로 동작하게 됩니다.
즉, Service는 요청을 직접 받지도 않고, DB를 직접 구현하지도 않지만, 전체 로직의 중심에서 실제 기능을 수행하는 역할을 맡습니다.
응답 DTO와 Converter를 분리하는 이유

Service에서는 종종 응답 DTO를 생성하는 작업도 함께 하게 됩니다.
이때 방법은 크게 두 가지가 있습니다.
- DTO 안에 변환 로직을 넣는 방법
- Converter를 따로 두는 방법
워크북에서는 Converter를 분리하는 방식을 사용하고 있습니다.
예를 들어 Entity를 받아서 Response DTO로 바꿔주는 로직을 Converter에 따로 두는 식입니다.
이 방식의 장점은 역할이 더 분명해진다는 점입니다.
- Service는 로직을 수행하고
- Converter는 객체를 변환하고
- DTO는 데이터를 담는 역할만 합니다
물론 파일이 하나 더 생긴다는 점은 조금 번거로울 수 있습니다. 그래도 프로젝트 규모가 커질수록 변환 로직이 여기저기 퍼지는 것보다 한곳에 모여 있는 편이 훨씬 관리하기 쉽다고 느꼈습니다.
왜 응답도 DTO로 보내는 걸까?
요청을 받을 때 DTO를 쓰는 이유는 어느 정도 직관적입니다. 그런데 응답도 굳이 DTO로 보내야 할까 하는 생각이 들 수 있습니다.
개인적으로는 이 부분도 꽤 중요하다고 느꼈습니다.
예를 들어 Service에서 그냥 문자열이나 엔티티를 그대로 반환한다고 가정해보겠습니다. 그렇게 되면 나중에 응답 형식이 바뀌었을 때 수정 범위가 커질 수 있고, 불필요한 데이터까지 노출될 가능성도 있습니다.
반대로 응답 DTO를 따로 두면
- 클라이언트에게 보여줄 데이터만 선택할 수 있고
- 응답 구조를 명확하게 유지할 수 있고
- 이후 요구사항이 바뀌어도 DTO 기준으로 관리할 수 있습니다
결국 응답 DTO는 단순히 “예쁘게 감싸는 객체”가 아니라, 클라이언트와 주고받는 데이터를 명확히 설계하기 위한 장치라고 이해하는 게 더 맞다고 생각했습니다.
Repository란?
Repository는 DB와 직접 소통하는 계층입니다.
Controller가 요청을 받고, Service가 로직을 수행한다면, 실제로 데이터를 조회하거나 저장하는 일은 Repository가 맡습니다.
스프링에서는 보통 JPA를 사용할 때 JpaRepository를 상속받아 Repository를 선언합니다.
예를 들면 다음과 같은 형태입니다.
public interface MemberRepository extends JpaRepository<Member, Long> {
}
이렇게 선언하면 기본적으로 다음과 같은 메서드들을 사용할 수 있습니다.
- save()
- findById()
- delete()
그리고 필요하다면 메서드 이름 규칙을 활용해 커스텀 조회 메서드도 만들 수 있습니다.
예를 들어 findByEmail() 같은 방식입니다.
처음엔 Repository가 단순히 “DB 관련 코드를 모아두는 곳” 정도로 느껴졌는데, 정리하고 보니 더 정확히는 데이터 접근 책임을 분리하는 계층이라고 보는 게 맞았습니다.
비즈니스 로직이 Service에 있어야 하듯, DB 접근 로직은 Repository에 있어야 책임이 섞이지 않습니다.
Entity와 함께 자주 보이는 어노테이션

Repository를 이해하려면 함께 다니는 JPA 어노테이션들도 어느 정도 익숙해질 필요가 있습니다.
대표적으로는 다음과 같습니다.
@Entity
이 객체가 DB 테이블과 매핑되는 엔티티라는 것을 나타냅니다.
@Table(name = “member”)
어떤 테이블과 연결되는지 명시할 때 사용합니다.
@Id
기본 키를 나타냅니다.
@GeneratedValue(strategy = GenerationType.IDENTITY)
기본 키 생성 전략을 설정할 때 사용합니다.
@Column
컬럼 이름이나 제약 조건 등을 설정할 때 사용합니다.
@Enumerated(EnumType.STRING)
enum 값을 DB에 어떤 방식으로 저장할지 설정합니다.
@Transactional
DB의 생성, 수정, 삭제 작업처럼 트랜잭션이 필요한 작업에서 사용합니다.
이런 어노테이션들을 처음부터 완벽하게 이해하기는 쉽지 않지만, 적어도 Repository 계층은 결국 엔티티를 기반으로 DB와 연결되는 부분이라는 흐름을 잡는 것이 중요하다고 느꼈습니다.
전체 흐름으로 보면 더 이해가 쉽다
Controller, Service, Repository는 따로 보면 각각의 역할이 보이지만, 실제로는 하나의 흐름으로 연결됩니다.
예를 들어 회원 조회 API를 기준으로 보면 대략 이런 순서로 진행됩니다.
- 클라이언트가 HTTP 요청을 보낸다
- Controller가 요청값을 받는다
- Controller가 Service에 필요한 값을 전달한다
- Service가 비즈니스 로직을 수행한다
- 필요하면 Repository를 통해 DB를 조회한다
- Service가 응답 DTO를 만든다
- Controller가 그 결과를 클라이언트에게 반환한다
이 흐름을 이해하고 나니 각 계층이 왜 필요한지 훨씬 명확해졌습니다.
결국 중요한 것은 Controller, Service, Repository라는 이름 자체보다, 요청 처리 → 로직 수행 → 데이터 접근 → 응답 반환의 흐름이 자연스럽게 분리되어 있다는 점이라고 생각합니다.
이번 내용을 정리하며 느낀 점
이번 주차 내용을 정리하면서 가장 크게 느낀 점은 Controller, Service, Repository를 나누는 이유가 단순히 “스프링에서 원래 그렇게 하니까”가 아니라는 점이었습니다.
각 계층은 정말로 맡아야 하는 책임이 다르고, 그 책임이 섞이기 시작하면 코드가 빠르게 복잡해집니다.
특히 인상 깊었던 부분은 두 가지였습니다.
첫 번째는 Controller는 생각보다 많은 일을 하면 안 된다는 점입니다.
요청을 받고 응답을 돌려주는 역할에 집중해야 이후 구조가 깔끔해집니다.
두 번째는 Service와 Repository의 경계를 분명히 해야 한다는 점입니다.
비즈니스 로직과 데이터 접근 로직이 분리되어야 수정도 쉽고, 테스트도 쉬워집니다.
또 DTO를 요청과 응답에 나눠서 사용하는 이유도 조금 더 선명하게 이해할 수 있었습니다.
이전에는 그냥 관습처럼 사용했던 구조였는데, 이번에 정리하면서 보니 결국 이 모든 분리는 유지보수와 협업을 위한 설계라는 생각이 들었습니다.
마무리
이번 글에서는 Controller, Service, Repository가 각각 어떤 역할을 하는지 정리해보았습니다.
처음에는 이 세 계층이 단순히 스프링 프로젝트의 기본 템플릿처럼 느껴질 수 있지만, 실제로는 요청 처리와 비즈니스 로직, 데이터 접근을 분리해서 코드를 더 읽기 쉽고, 수정하기 쉬운 구조로 만들기 위한 장치라고 볼 수 있습니다.
특히 API를 하나 구현하는 과정을 따라가다 보면 Controller는 입력과 출력을 담당하고, Service는 기능의 중심이 되며, Repository는 데이터를 다루는 역할을 맡는다는 점이 자연스럽게 보입니다.
결국 좋은 구조는 거창한 설계보다도 각자 해야 할 일을 정확히 나누는 것에서 시작된다고 생각합니다.
다음 글에서는 이번 흐름에서 한 걸음 더 나아가, 왜 API 응답 형식을 통일해야 하는지와 공통 응답 객체를 어떻게 설계할 수 있는지를 정리해보려고 합니다.