
스파르타 코딩클럽 일정 관리 과제를 진행하면서,
Bean Validation을 사용하지 않는 조건 하에 Schedule·Comment 서비스의 검증 로직을 어떻게 설계할지 고민했던 과정을 정리한다.
과제 제약 조건은 두 가지였다.
@NotBlank, @Valid 등)@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()에도 비슷한 블록이 반복되자 두 가지 문제가 눈에 띄었다.
IllegalArgumentException이라 에러 응답 포맷이 다른 예외와 달랐다.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()가 실패하면 예외가 던져지고 이후 코드는 실행되지 않는다.
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을 쓰면
GlobalExceptionHandler의 handleBusiness() 핸들러 하나에서
상태 코드와 메시지를 일관되게 처리할 수 있다.
@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);
}
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() 흐름을 읽을 때
내부 구현을 볼 필요가 없다.
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 조회가 필요한 비즈니스 규칙, 보안 목적의 소속 검증까지
한 메서드에 섞이면 의도를 읽기 어렵다.
해결 방향은 단순했다.
성격이 다른 검증은 이름이 다른 메서드로 분리한다.
메서드 추출 자체는 간단하지만, 이름에 의도를 담는 것이
코드를 읽는 사람(미래의 나 포함)에게 가장 큰 도움이 됐다.