Spring Boot 3에서 Controller/Service/Repository를 깔끔하게 분리하는 기준과 DTO/엔티티 경계, 서비스 레이어 규칙을 실무 관점에서 정리합니다.
도입 (문제 상황)
기능이 몇 개 없을 때는 Controller에서 Repository를 바로 호출해도 잘 돌아가지만, 요구사항이 늘어나면 “이 로직은 어디에 둬야 하지?”가 빠르게 문제가 됩니다. 특히 DTO와 엔티티를 섞어 쓰기 시작하면, 작은 변경에도 여러 계층이 같이 흔들리면서 유지보수가 어려워져요.
핵심 개념: Spring Boot 레이어드 아키텍처에서 “경계”를 지키는 기준
Controller/Service/Repository를 나누는 목적은 “코드를 예쁘게 분류”하는 게 아니라 변경의 파급 범위를 줄이는 것입니다. 각 계층이 책임을 넘지 않도록 경계를 세워두면, 요구사항이 커져도 구조가 무너지지 않습니다.
Spring Boot Controller 책임: HTTP를 도메인 호출로 번역하기
Controller는 웹 어댑터입니다. 아래 역할에 집중하면 됩니다.
- 요청을 DTO로 받기(@RequestBody, @ModelAttribute)
- 검증(Validation)과 인증/인가 결과를 바탕으로 서비스 호출
- 서비스 결과를 응답 DTO로 변환해 반환
- HTTP 상태 코드/헤더/에러 응답 형태 결정
반대로, Controller가 하면 곤란한 일은 다음과 같습니다.
- 트랜잭션 경계 관리(대부분 Service에서)
- 비즈니스 규칙(“재고가 0이면 주문 불가” 같은 규칙)
- JPA 엔티티를 그대로 응답으로 내보내기(지연 로딩, 순환 참조, 내부 필드 노출 위험)
Spring Boot Service 책임: 유스케이스(업무 흐름)와 규칙의 집합
Service는 “무엇을 할지”를 표현하는 계층입니다. 흔히 유스케이스 단위로 메서드가 생기고, 내부에서 여러 Repository/외부 연동을 조합합니다.
- 비즈니스 규칙과 흐름(유스케이스) 구현
- 트랜잭션 경계 설정(@Transactional)
- 도메인 모델(엔티티) 조작 및 상태 변경
- 외부 시스템 호출이 있다면 실패/재시도/보상 같은 정책을 한 곳에 모으기
Service 레이어 규칙을 한 문장으로 정리하면 이렇습니다.
“Controller는 얇게, Service는 규칙을 갖고, Repository는 저장만 한다.”
Spring Boot Repository 책임: 저장소 접근을 캡슐화하기
Repository는 데이터 접근을 숨기는 레이어입니다.
- 엔티티 단위 CRUD, 조회 쿼리 제공
- 쿼리 최적화(fetch join, projection 등) 책임
- 비즈니스 규칙을 넣지 않기(“삭제 가능 여부 판단” 같은 로직은 Service로)
DTO/엔티티 경계: “택배 상자(DTO)”와 “실제 상품(엔티티)”를 섞지 않기
DTO는 계층 사이를 오가는 “운반용 상자”이고, 엔티티는 “실제 도메인 상태”입니다. 상자(DTO)를 그대로 창고(Repository/JPA)에 넣거나, 상품(엔티티)을 그대로 고객(HTTP 응답)에 보내면 문제가 생깁니다.
- 엔티티를 API 응답으로 직접 노출하면
- 내부 필드가 새는 보안 이슈
- LAZY 로딩으로 N+1/예외가 발생
- 양방향 연관관계로 JSON 무한 루프
- 반대로 요청 DTO를 엔티티처럼 여기면
- 검증/정규화/규칙 적용이 누락되기 쉽고
- “API 스펙 변경”이 “도메인 변경”으로 전염됩니다
한눈에 비교: 계층별 책임과 금지사항
| 계층 | 주 책임 | 주로 다루는 타입 | 하면 안 되는 것(대표) |
|---|---|---|---|
| Controller | HTTP ↔ 유스케이스 연결 | Request/Response DTO | 비즈니스 규칙, 엔티티 직접 반환 |
| Service | 유스케이스/규칙/트랜잭션 | 엔티티, 도메인 값 객체 | HTTP 세부사항(상태코드 등), DB 쿼리 최적화 세부 |
| Repository | 데이터 접근/쿼리 | 엔티티, Projection | 비즈니스 규칙, DTO 중심 설계(무분별한 DTO 저장) |
코드 예제: 회원 가입/조회로 보는 Controller-Service-Repository 분리 (Spring Boot 3.x)
아래 예제는 “회원 가입”과 “회원 단건 조회”를 통해 DTO/엔티티 경계와 서비스 규칙을 보여줍니다. 그대로 복붙해서 실행할 수 있게 H2 기준으로 구성했습니다.
// build.gradle (Gradle Groovy)
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.2'
id 'io.spring.dependency-management' version '1.1.6'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain { languageVersion = JavaLanguageVersion.of(17) }
}
repositories { mavenCentral() }
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') { useJUnitPlatform() }
# application.yml
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
format_sql: true
h2:
console:
enabled: true
logging:
level:
org.hibernate.SQL: debug
1) 엔티티: 도메인 상태와 규칙은 엔티티/서비스에서 관리
package com.example.demo.member;
import jakarta.persistence.*;
@Entity
@Table(name = "members", uniqueConstraints = {
@UniqueConstraint(name = "uk_member_email", columnNames = "email")
})
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String email;
@Column(nullable = false, length = 30)
private String name;
protected Member() { }
private Member(String email, String name) {
this.email = email;
this.name = name;
}
public static Member create(String email, String name) {
// 엔티티는 "상태를 가진 객체"이므로 생성 시점 불변조건을 지키는 데 유리합니다.
return new Member(email, name);
}
public Long getId() { return id; }
public String getEmail() { return email; }
public String getName() { return name; }
}
2) DTO: API 스펙은 DTO가 책임지고, 엔티티와 분리
package com.example.demo.member.api;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class MemberDtos {
public record CreateRequest(
@NotBlank @Email String email,
@NotBlank @Size(max = 30) String name
) {}
public record CreateResponse(Long id) {}
public record DetailResponse(Long id, String email, String name) {}
}
3) Repository: 저장/조회만 담당
package com.example.demo.member;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
boolean existsByEmail(String email);
}
4) Service: 유스케이스 + 규칙 + 트랜잭션 경계
package com.example.demo.member;
import com.example.demo.member.api.MemberDtos;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Transactional
public MemberDtos.CreateResponse signUp(MemberDtos.CreateRequest req) {
// 비즈니스 규칙: 이메일은 유일해야 한다
if (memberRepository.existsByEmail(req.email())) {
throw new IllegalArgumentException("이미 사용 중인 이메일입니다.");
}
Member saved = memberRepository.save(Member.create(req.email(), req.name()));
return new MemberDtos.CreateResponse(saved.getId());
}
@Transactional(readOnly = true)
public MemberDtos.DetailResponse getMember(Long id) {
Member member = memberRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다. id=" + id));
// 엔티티를 직접 반환하지 않고 응답 DTO로 변환
return new MemberDtos.DetailResponse(member.getId(), member.getEmail(), member.getName());
}
}
5) Controller: HTTP 처리 + DTO 바인딩/검증 + 서비스 호출
package com.example.demo.member.api;
import com.example.demo.member.MemberService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
@RestController
@RequestMapping("/api/members")
public class MemberController {
private final MemberService memberService;
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@PostMapping
public ResponseEntity<MemberDtos.CreateResponse> signUp(@Valid @RequestBody MemberDtos.CreateRequest req) {
MemberDtos.CreateResponse res = memberService.signUp(req);
return ResponseEntity
.created(URI.create("/api/members/" + res.id()))
.body(res);
}
@GetMapping("/{id}")
public ResponseEntity<MemberDtos.DetailResponse> getMember(@PathVariable Long id) {
return ResponseEntity.ok(memberService.getMember(id));
}
}
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
요청 흐름 다이어그램(Controller/Service/Repository)
sequenceDiagram
participant C as "Controller"
participant S as "Service"
participant R as "Repository"
participant DB as "Database"
C->>S: "DTO로 요청 전달"
S->>R: "엔티티 조회/저장 요청"
R->>DB: "SQL 실행"
DB-->>R: "결과 반환"
R-->>S: "엔티티 반환"
S-->>C: "응답 DTO 반환"
C-->>C: "HTTP 응답 생성"
Controller는 HTTP를 처리하고, Service는 규칙/흐름을, Repository는 DB 접근을 담당한다는 흐름을 보여줍니다.
[IMAGE_PROMPT] 위치: "요청 흐름 다이어그램" 바로 아래 alt: Controller-Service-Repository 책임 분리 개념도 prompt: clean minimal tech blog style white background layered architecture diagram showing Controller Service Repository Database with arrows, emphasize responsibilities separation, no dense text style: diagram [/IMAGE_PROMPT]
실무 팁
💡 실무에서는: “Service가 비대해질 때” 유스케이스 단위로 쪼개 보세요
MemberService에 모든 회원 관련 기능을 넣기 시작하면 금방 1,000줄이 됩니다. 이때는MemberSignUpService,MemberQueryService처럼 유스케이스/관심사 기준으로 분리하면 테스트도 쉬워지고 충돌도 줄어듭니다.- 단, 너무 잘게 쪼개서 “서비스가 서비스 호출” 형태로 꼬이면 오히려 추적이 어려워질 수 있으니, 팀의 복잡도에 맞는 적정 분리가 중요합니다.

💡 실무에서는: DTO↔엔티티 매핑 위치를 팀 규칙으로 고정해 두는 게 효과가 큽니다
- 권장: Controller는 요청 DTO를 그대로 Service로 전달, Service에서 엔티티 생성/변환을 담당(유스케이스 규칙과 함께 관리).
- 조회 성능이 중요할 때는 Repository에서 Projection으로 DTO를 바로 조회하는 전략도 가능하지만, 이 경우 “읽기 모델”로 명확히 구분해 두지 않으면 계층 경계가 흐려지기 쉽습니다(예:
MemberQueryRepository/MemberReadModel같은 네이밍).
핵심 요약: Controller는 HTTP에 집중하고, Service는 유스케이스/규칙/트랜잭션을 책임지며, Repository는 저장소 접근만 담당합니다.
핵심 요약: DTO와 엔티티 경계를 지키면 API 변경이 도메인/DB까지 번지는 일을 줄일 수 있습니다.
핵심 요약: 서비스가 커지면 “도메인 기준”이 아니라 “유스케이스 기준”으로 분리해 보세요.
다음 글: #14 @Transactional 제대로 쓰기(전파/읽기전용)