Service Layer와 예외 처리: 코드를 오래 유지하는 실무 패턴

📖 21min read

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 설계와 전역 예외 처리, 두 가지가 핵심이다.

Controller가 모든 걸 하지 않고, 역할을 나누면 코드가 오래 산다.

Layer 분리: Controller, Service, Repository의 역할

Spring Boot 애플리케이션의 기본 Layer 구조부터 짚고 가자. 새로운 개념은 아니지만, ‘각 Layer가 정확히 뭘 해야 하는가’를 명확히 하면 코드가 달라진다.

Layer해야 할 일하지 말아야 할 일
ControllerHTTP 요청/응답 처리, 파라미터 받기, Service 호출비즈니스 로직, DB 접근, 복잡한 검증
Service비즈니스 로직, 트랜잭션, 여러 Repository 조합HTTP 응답 조립, 외부 API의 입출력 포맷 의존
RepositoryDB 접근, 쿼리비즈니스 판단, 예외 변환

핵심 원칙: ‘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는 사용자에게 보여주면 된다.

예외는 Service에서 ‘던지기만’ 하고, 포장은 @RestControllerAdvice가 담당한다.

실무 조언

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년 뒤 반드시 대가를 치르게 된다.

댓글 남기기