Controller에 300줄이 쌓이기 시작했다
처음 Spring Boot로 프로젝트를 시작할 때는 코드가 단순했다. Controller에 비즈니스 로직을 몇 줄 넣으면 됐다. 그런데 기능이 늘어날수록 Controller가 점점 비대해졌다.
@PostMapping("/api/projects")
public ResponseEntity<?> createProject(@RequestBody ProjectCreateDto dto) {
// 유효성 검사
if (dto.getName() == null || dto.getName().isEmpty()) {
return ResponseEntity.badRequest().body("이름이 필요합니다.");
}
// 중복 확인
if (projectRepository.existsByName(dto.getName())) {
return ResponseEntity.status(HttpStatus.CONFLICT).body("이미 존재하는 이름입니다.");
}
// 담당자 조회
Member manager = memberRepository.findById(dto.getManagerId())
.orElse(null);
if (manager == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("담당자를 찾을 수 없습니다.");
}
// 권한 체크
if (!manager.hasRole("MANAGER")) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("담당자 권한이 없습니다.");
}
// 저장
Project project = new Project(dto.getName(), dto.getStatus(), manager);
projectRepository.save(project);
// 알림 발송
notificationService.send(manager.getEmail(), "프로젝트 생성됨");
// 응답
return ResponseEntity.ok(new ProjectDto(project));
}
작동은 한다. 하지만 문제가 있다.
- Controller가 유효성 검사, 비즈니스 로직, 예외 처리, 응답 조립을 전부 한다.
- 같은 예외 메시지가 여러 API에 중복된다.
- 테스트하려면 Controller 전체를 띄워야 한다.
- 새 요구사항이 오면 이 안에 if문이 하나 더 추가된다. 500줄이 되는 건 시간 문제다.
Spring Garden 시리즈의 마지막 글에서는, 이런 코드를 ‘오래 유지할 수 있는 구조’로 정리하는 실무 패턴을 다룬다. Service Layer 설계와 전역 예외 처리, 두 가지가 핵심이다.

Layer 분리: Controller, Service, Repository의 역할
Spring Boot 애플리케이션의 기본 Layer 구조부터 짚고 가자. 새로운 개념은 아니지만, ‘각 Layer가 정확히 뭘 해야 하는가’를 명확히 하면 코드가 달라진다.
| Layer | 해야 할 일 | 하지 말아야 할 일 |
|---|---|---|
| Controller | HTTP 요청/응답 처리, 파라미터 받기, Service 호출 | 비즈니스 로직, DB 접근, 복잡한 검증 |
| Service | 비즈니스 로직, 트랜잭션, 여러 Repository 조합 | HTTP 응답 조립, 외부 API의 입출력 포맷 의존 |
| Repository | DB 접근, 쿼리 | 비즈니스 판단, 예외 변환 |
핵심 원칙: ‘Service는 HTTP를 모르고, Repository는 비즈니스를 모른다.’ 이 원칙을 지키면 Service와 Repository는 HTTP 요청이 아닌 다른 경로(스케줄러, 메시지 큐, 테스트 등)에서도 재사용할 수 있다.
위의 지저분한 Controller를 세 Layer로 나눠보자.
// Controller: HTTP 경계만 담당
@RestController
@RequestMapping("/api/projects")
@RequiredArgsConstructor
public class ProjectController {
private final ProjectService projectService;
@PostMapping
public ProjectDto createProject(@RequestBody @Valid ProjectCreateDto dto) {
return projectService.createProject(dto);
}
}
// Service: 비즈니스 로직
@Service
@RequiredArgsConstructor
@Transactional
public class ProjectService {
private final ProjectRepository projectRepository;
private final MemberRepository memberRepository;
private final NotificationService notificationService;
public ProjectDto createProject(ProjectCreateDto dto) {
// 중복 확인
if (projectRepository.existsByName(dto.getName())) {
throw new DuplicateProjectException(dto.getName());
}
// 담당자 조회
Member manager = memberRepository.findById(dto.getManagerId())
.orElseThrow(() -> new MemberNotFoundException(dto.getManagerId()));
// 권한 체크
if (!manager.hasRole("MANAGER")) {
throw new InsufficientRoleException(manager.getId(), "MANAGER");
}
// 저장
Project project = Project.create(dto.getName(), dto.getStatus(), manager);
projectRepository.save(project);
// 알림 발송
notificationService.send(manager.getEmail(), "프로젝트 생성됨");
return ProjectDto.from(project);
}
}
Controller가 3줄로 줄었다. 검증과 비즈니스 로직은 Service로 이동했고, 유효성 검사는 @Valid 어노테이션으로 Spring에 위임했다. ResponseEntity로 상태 코드를 일일이 조립하지 않는다. 그 이유는 다음 섹션에서 설명한다.
전역 예외 처리: @RestControllerAdvice
위 Service에서 눈치챘을 것이다. 예외를 던질 때 ResponseEntity로 감싸지 않고, 그냥 RuntimeException을 던졌다.
throw new DuplicateProjectException(dto.getName()); throw new MemberNotFoundException(dto.getManagerId()); throw new InsufficientRoleException(manager.getId(), "MANAGER");
이 예외들이 어떻게 HTTP 응답으로 변환되는지가 ‘전역 예외 처리’의 핵심이다.
1단계: 비즈니스 예외 클래스 정의
// 최상위 비즈니스 예외
public abstract class BusinessException extends RuntimeException {
private final HttpStatus status;
private final String errorCode;
protected BusinessException(HttpStatus status, String errorCode, String message) {
super(message);
this.status = status;
this.errorCode = errorCode;
}
public HttpStatus getStatus() { return status; }
public String getErrorCode() { return errorCode; }
}
// 구체적인 비즈니스 예외들
public class DuplicateProjectException extends BusinessException {
public DuplicateProjectException(String name) {
super(HttpStatus.CONFLICT, "PROJECT_DUPLICATE",
"이미 존재하는 프로젝트 이름입니다: " + name);
}
}
public class MemberNotFoundException extends BusinessException {
public MemberNotFoundException(Long id) {
super(HttpStatus.NOT_FOUND, "MEMBER_NOT_FOUND",
"회원을 찾을 수 없습니다: " + id);
}
}
public class InsufficientRoleException extends BusinessException {
public InsufficientRoleException(Long memberId, String requiredRole) {
super(HttpStatus.FORBIDDEN, "INSUFFICIENT_ROLE",
memberId + "에게 " + requiredRole + " 권한이 없습니다.");
}
}
예외 클래스에 ‘HTTP 상태 코드와 에러 코드’를 붙여둔다. 이렇게 하면 예외 자체가 “어떤 HTTP 응답이 나가야 하는가”를 알고 있다.
2단계: 전역 핸들러로 일괄 처리
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 비즈니스 예외: 상태 코드/메시지를 예외 객체에서 가져옴
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) {
log.warn("Business exception: {} - {}", e.getErrorCode(), e.getMessage());
return ResponseEntity.status(e.getStatus())
.body(new ErrorResponse(e.getErrorCode(), e.getMessage()));
}
// 유효성 검증 실패: @Valid에서 발생
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest()
.body(new ErrorResponse("VALIDATION_FAILED", message));
}
// 예상치 못한 예외: 500 에러
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
log.error("Unexpected error", e);
return ResponseEntity.internalServerError()
.body(new ErrorResponse("INTERNAL_ERROR", "서버 오류가 발생했습니다."));
}
}
@Getter
@AllArgsConstructor
public class ErrorResponse {
private String code;
private String message;
}
이제 Service에서 예외를 그냥 던지기만 하면, @RestControllerAdvice가 자동으로 HTTP 응답으로 변환한다.
3단계: Controller는 정상 흐름만
@PostMapping
public ProjectDto createProject(@RequestBody @Valid ProjectCreateDto dto) {
return projectService.createProject(dto);
}
Controller에 try-catch가 하나도 없다. 정상 흐름만 코드에 남고, 예외는 전역 핸들러가 담당한다.
장점과 효과
| Before (Controller에 다 넣기) | After (Layer 분리 + 전역 예외) |
|---|---|
| API마다 try-catch 반복 | 예외는 던지기만, 처리는 한 곳 |
| 에러 메시지가 API마다 제각각 | ErrorResponse 포맷 일관성 |
| Controller 테스트가 무거움 | Service 단위 테스트 가능 |
| 같은 검증 로직 여러 곳에 복사 | Service 메서드에 집중 |
| 상태 코드 실수하기 쉬움 | 예외 객체가 상태 코드 내장 |
프론트엔드 입장에서도 명확해진다. 모든 에러가 같은 형식으로 온다.
{
"code": "PROJECT_DUPLICATE",
"message": "이미 존재하는 프로젝트 이름입니다: 스마트농장"
}
에러 코드(PROJECT_DUPLICATE)를 보고 어떻게 처리할지 판단하고, message는 사용자에게 보여주면 된다.

실무 조언
1. DTO와 Entity는 분리하라
Service 예제에서 Project.create()와 ProjectDto.from(project)를 봤을 것이다. DTO(데이터 전송 객체)와 Entity(JPA 엔티티)는 분리하는 것이 일반적이다.
// DTO: API 경계에서만 사용, JSON 직렬화 대상
public record ProjectDto(Long id, String name, String status, String managerName) {
public static ProjectDto from(Project project) {
return new ProjectDto(
project.getId(), project.getName(), project.getStatus(),
project.getManager().getName()
);
}
}
// Entity: DB와 연결, 비즈니스 로직 포함
@Entity
public class Project {
// ...
public static Project create(String name, String status, Member manager) {
// 생성 시 검증 로직을 엔티티 내부에 둘 수 있다
return new Project(name, status, manager);
}
}
Entity를 그대로 API 응답으로 내려주면, DB 스키마 변경이 곧 API 스펙 변경이 된다. DTO를 통한 분리로 이 결합을 끊는다.
2. 예외는 ‘의미 있는 단위’로 만들어라
모든 경우에 새 예외를 만들 필요는 없다. 하지만 IllegalArgumentException 하나로 모든 걸 던지는 것도 좋지 않다. 프론트엔드가 처리 분기를 해야 하는 단위로 예외를 나누는 것이 기준이다.
- ‘중복 생성, 권한 부족, 존재하지 않음’처럼 프론트가 다르게 처리해야 한다면 별도 예외
- ‘단순 파라미터 오류’는 IllegalArgumentException이나 @Valid로 충분
- ‘시스템 오류(네트워크 실패, DB 장애)’는 일반 Exception으로 500 처리
3. 로깅 레벨을 예외 종류에 맞춰라
| 예외 종류 | 로그 레벨 | 이유 |
|---|---|---|
| BusinessException (4xx) | WARN | 사용자가 잘못한 것, 시스템 문제 아님 |
| 예상치 못한 Exception (5xx) | ERROR | 시스템 문제, 원인 파악 필요 |
| 외부 서비스 호출 실패 | ERROR | 운영 모니터링에 잡혀야 함 |
비즈니스 예외를 ERROR로 찍으면 로그가 의미 없는 경고로 가득 차고, 진짜 시스템 장애 로그가 묻힌다.
4. Service가 너무 커지면 분리하라
Service 하나에 메서드가 20개 넘어가면 분리 신호다.
- 기능 단위로 분리: ProjectQueryService (조회), ProjectCommandService (생성/수정/삭제)
- 도메인 단위로 분리: ProjectService, ProjectAssignmentService, ProjectReportService
정답은 없지만, “이 Service가 한 문장으로 설명되는가?”를 기준으로 판단하면 된다.
5. 트랜잭션 경계는 Service에서
이전 글(06. 트랜잭션과 동시성)에서 다룬 내용이지만 다시 강조한다. @Transactional은 Service 메서드에 붙인다. Controller에 붙이지 않고, Repository에 붙이지 않는다.
@Service
@Transactional(readOnly = true) // 클래스 레벨은 읽기 전용 기본
public class ProjectService {
@Transactional // 쓰기 메서드만 오버라이드
public ProjectDto createProject(ProjectCreateDto dto) {
// ...
}
// 나머지 조회 메서드는 readOnly=true 자동 적용
public List<ProjectDto> getProjects() { ... }
}
마치며:
인증(01~02)부터 시작해서, 쿼리(03), 성능(04), 테스트(05), 트랜잭션(06), 문서화(07), 마이그레이션(08), 환경 분리(09), 그리고 이번 실무 패턴(10)까지 다뤘다. 한 서비스를 제대로 만들고 오래 유지하는 데 필요한 Spring Boot의 핵심 주제들을 한 바퀴 돌았다.
각 글은 독립적으로도 의미가 있지만, 모아서 보면 하나의 메시지가 있다. ‘기능을 만드는 것과, 오래 유지할 수 있게 만드는 것은 다르다.’ 테스트, 트랜잭션, 문서화, 환경 분리, 예외 처리 같은 것들은 당장은 안 해도 되지만, 안 하면 1년 뒤 반드시 대가를 치르게 된다.