[내일배움캠프] Schedule 과제 트러블슈팅

junsung kim·2026년 4월 14일

Bean Validation 없이 서비스 레이어에서 검증 관리하기 — 엣지케이스 정리와 메서드 분리

스파르타 코딩클럽 일정 관리 과제를 진행하면서,
Bean Validation을 사용하지 않는 조건 하에 Schedule·Comment 서비스의 검증 로직을 어떻게 설계할지 고민했던 과정을 정리한다.


배경

과제 제약 조건은 두 가지였다.

  • Bean Validation 사용 금지 (@NotBlank, @Valid 등)
  • JPA 연관관계 매핑 사용 금지 (@ManyToOne 등)

익숙한 도구 없이 시작하자 검증 코드가 서비스 메서드 안에 뒤섞이기 시작했다.
처음에는 별 생각 없이 각 서비스 메서드 안에 직접 if 블록을 썼는데,
create() 하나만 해도 이런 모습이 됐다.

@Transactional
public ScheduleResponse create(ScheduleRequest.Create request) {
    if (request.title() == null || request.title().isBlank())
        throw new IllegalArgumentException("제목은 필수입니다.");
    if (request.content() == null || request.content().isBlank())
        throw new IllegalArgumentException("내용은 필수입니다.");
    if (request.author() == null || request.author().isBlank())
        throw new IllegalArgumentException("작성자는 필수입니다.");
    if (request.password() == null || request.password().isBlank())
        throw new IllegalArgumentException("비밀번호는 필수입니다.");

    // ... 실제 생성 로직
}

update(), delete()에도 비슷한 블록이 반복되자 두 가지 문제가 눈에 띄었다.

  1. 가독성: 서비스 메서드의 핵심 흐름이 검증 코드에 묻혔다.
  2. 일관성: 예외 타입이 IllegalArgumentException이라 에러 응답 포맷이 다른 예외와 달랐다.

문제 1 — 검증 코드가 비즈니스 로직과 뒤섞인다

상황

create() 메서드는 두 가지 일을 동시에 하고 있었다.

  • 입력값이 유효한지 확인한다 (검증)
  • 유효하다면 일정을 만들어 저장한다 (비즈니스 로직)

단일 책임 원칙(SRP)을 생각하면 검증과 핵심 흐름은 분리되어야 한다.

해결 — validateXxx() 메서드 추출

@Transactional
public ScheduleResponse create(ScheduleRequest.Create request) {
    validateCreate(request);   // 검증은 여기서 끝난다

    Schedule schedule = Schedule.of(
            request.title(),
            request.content(),
            request.author(),
            request.password()
    );
    return ScheduleResponse.from(scheduleRepository.save(schedule));
}

private void validateCreate(ScheduleRequest.Create request) {
    if (isBlank(request.title()))    throw ScheduleException.of(ErrorCode.INVALID_INPUT, "제목은 필수입니다.");
    if (isBlank(request.content()))  throw ScheduleException.of(ErrorCode.INVALID_INPUT, "내용은 필수입니다.");
    if (isBlank(request.author()))   throw ScheduleException.of(ErrorCode.INVALID_INPUT, "작성자는 필수입니다.");
    if (isBlank(request.password())) throw ScheduleException.of(ErrorCode.INVALID_INPUT, "비밀번호는 필수입니다.");
}

private boolean isBlank(String value) {
    return value == null || value.isBlank();
}

create() 메서드는 이제 검증이 통과됐다고 가정하고 핵심 흐름만 담당한다.
validateCreate()가 실패하면 예외가 던져지고 이후 코드는 실행되지 않는다.


문제 2 — 예외 타입이 제각각이라 에러 응답 포맷이 달라진다

상황

IllegalArgumentException을 그대로 쓰면 GlobalExceptionHandler
handleUnexpected() 핸들러에 잡혀 500 응답이 반환된다.
검증 실패는 400이어야 하는데 응답 코드가 틀렸다.

// 의도: 400 Bad Request
// 실제: 500 Internal Server Error (handleUnexpected에 잡힘)
throw new IllegalArgumentException("제목은 필수입니다.");

해결 — ErrorCode 열거형 + BusinessException 계층

// ErrorCode.java
INVALID_INPUT(400, "입력값이 올바르지 않습니다."),

// ScheduleException.of()로 던지면 GlobalExceptionHandler가 400으로 처리
throw ScheduleException.of(ErrorCode.INVALID_INPUT, "제목은 필수입니다.");

BusinessException을 상속한 ScheduleException, CommentException을 쓰면
GlobalExceptionHandlerhandleBusiness() 핸들러 하나에서
상태 코드와 메시지를 일관되게 처리할 수 있다.

@ExceptionHandler(BusinessException.class)
public ResponseEntity<Map<String, String>> handleBusiness(BusinessException e) {
    ErrorCode code = e.getErrorCode();
    Map<String, String> body = new HashMap<>();
    body.put("message", code.getMessage());
    if (e.getDetail() != null) body.put("detail", e.getDetail());
    return ResponseEntity.status(code.getStatus()).body(body);
}

문제 3 — Comment에서 검증 대상이 Schedule과 Comment 두 계층에 걸친다

상황

Comment는 Schedule보다 검증해야 할 항목이 많았다.

검증 항목성격
content, author, password 누락입력값 검증
존재하지 않는 scheduleId비즈니스 규칙 (외부 리소스 확인)
해당 일정 댓글 수 10개 초과비즈니스 규칙 (집계 조건)
commentId가 해당 scheduleId 소속인지보안 검증

이것들을 create() 하나에 모두 쓰면 메서드 길이가 급격히 늘어난다.

해결 — 성격별로 메서드를 분리하고 이름에 의도를 담는다

@Transactional
public CommentResponse create(Long scheduleId, CommentRequest.Create request) {
    validateCreate(request);          // 1. 입력값 검증
    verifyScheduleExists(scheduleId); // 2. 일정 존재 여부
    verifyCommentLimit(scheduleId);   // 3. 10개 제한

    Comment comment = Comment.of(scheduleId, request.content(),
                                 request.author(), request.password());
    return CommentResponse.from(commentRepository.save(comment));
}

update()delete()에는 한 가지 검증이 더 붙는다.

@Transactional
public CommentResponse update(Long scheduleId, Long commentId,
                              CommentRequest.Update request) {
    validateUpdate(request);
    verifyScheduleExists(scheduleId);

    Comment comment = getCommentOrThrow(commentId);
    verifyCommentBelongsToSchedule(comment, scheduleId); // 4. 소속 검증
    comment.update(request.content(), request.author(), request.password());
    return CommentResponse.from(comment);
}

각 메서드의 이름이 의도를 설명하므로 create() 흐름을 읽을 때
내부 구현을 볼 필요가 없다.


문제 4 — verifyCommentBelongsToSchedule를 빠뜨리면 생기는 보안 구멍

상황

URL은 /api/schedules/{scheduleId}/comments/{commentId} 구조다.
commentId만 확인하고 scheduleId 소속 여부를 검증하지 않으면
아래 요청이 통과된다.

PATCH /api/schedules/1/comments/99

scheduleId=1의 댓글이 아닌 commentId=99(다른 일정 소속)를
경로에 조합해도 수정이 성공한다.

해결 — 소속 검증 메서드 추가

private void verifyCommentBelongsToSchedule(Comment comment, Long scheduleId) {
    if (!comment.getScheduleId().equals(scheduleId)) {
        throw CommentException.of(ErrorCode.COMMENT_NOT_FOUND);
    }
}

존재하는 댓글이지만 해당 일정 소속이 아닐 때 404를 반환한다.
403이나 별도 에러 코드를 쓰지 않은 이유는,
소속이 다른 댓글의 존재 자체를 클라이언트에게 알릴 필요가 없기 때문이다.


최종 구조 요약

서비스 메서드 (create / update / delete)
├── validateXxx()             → 입력값 누락·형식 검증 (400)
├── verifyScheduleExists()    → 일정 존재 확인 (404)
├── verifyCommentLimit()      → 댓글 10개 제한 (400)  [생성 전용]
├── getCommentOrThrow()       → 댓글 존재 확인 (404)  [수정·삭제]
└── verifyCommentBelongsToSchedule()  → 소속 검증 (404)  [수정·삭제]

서비스 메서드 자체는 이 흐름을 읽는 목차 역할만 하고,
각 검증의 구체적인 조건은 분리된 메서드 안에 캡슐화된다.


정리

Bean Validation을 쓸 수 없는 상황에서 검증을 서비스에서 직접 관리할 때
어렵게 느껴졌던 부분은 검증의 성격이 다양하다는 점이었다.
단순 null 체크부터 DB 조회가 필요한 비즈니스 규칙, 보안 목적의 소속 검증까지
한 메서드에 섞이면 의도를 읽기 어렵다.

해결 방향은 단순했다.

성격이 다른 검증은 이름이 다른 메서드로 분리한다.

메서드 추출 자체는 간단하지만, 이름에 의도를 담는 것이
코드를 읽는 사람(미래의 나 포함)에게 가장 큰 도움이 됐다.

profile
edit하는 개발자! story 있는 삶

0개의 댓글