진행중인 웹커뮤니티 프로젝트의 기능 구현이 어느정도 완료되었습니다.
이제 일련의 서비스 단위별로 예외처리를 진행해보려고 합니다.
이 포스팅에서는 댓글서비스에서 진행한 예외처리에 대해서 다루고자 합니다.
먼저 컨트롤러를 살펴봅니다.
@PostMapping
public ResponseEntity<?> addComment(@RequestBody CommentRequest commentRequest) {
try {
CommentResponse savedComment =
commentService.addComment(commentRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(savedComment);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
}
}
Post요청을 처리하는 메서드입니다.
@RequestBody어노테이션이 HTTP요청의 body를 자바 객체로 자동 변환합니다.
현재는 단순히, 서비스레이어의 메서드를 호출하고 성공하면 201코드와 저장된 댓글을 json으로 리턴하고, 실패하면 400코드와 오류 메세지를 반환합니다.
저는 이 프로세스의 시작지점인 입력값부터 검증하려고 합니다.
사실 저는 이러한 데이터 검증을 그저 그동안 하던 대로, 엔티티 클래스에 적용해놓았었습니다.
@Entity
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String userIp;
@NotNull(message = "닉네임이 입력되지 않았습니다...")
@Size(min = 2, max = 8, message = "닉네임은 2~8글자 사이여야 합니다.")
private String nickname;
@NotNull(message = "내용이 입력되지 않았습니다...")
@Size(max = 5000, message = "댓글은 최대 5000자까지 입력 가능합니다.")
@Column(length = 5000) // 데이터베이스 컬럼 길이도 설정
private String text;
@NotNull(message = "비밀번호가 입력되지 않았습니다...")
@Size(min = 4, max = 15, message = "비밀번호는 4~15 사이로만 입력 가능합니다. ")
private String password;
...
}
하지만 다음과 같은 의견을 보고 DTO로 옮기게 되었습니다.
“유효성 검사는 가능한 한 데이터의 진입점(예: 컨트롤러)에서 수행되어야 합니다. 이는 잘못된 데이터가 시스템 깊숙이 전파되는 것을 방지하고, 잘못된 데이터로 인한 오류를 조기에 발견할 수 있도록 합니다. DTO에서의 유효성 검사는 입력 데이터의 구조와 형식을 검증하는 반면, 도메인 모델에서는 보다 복잡한 비즈니스 규칙과 무결성을 검증합니다.”
public record CommentRequest(
@NotNull(message = "닉네임이 입력되지 않았습니다...")
@Size(min = 2, max = 8, message = "닉네임은 2~8글자 사이여야 합니다.")
String nickname,
@NotNull( message = "내용이 입력되지 않았습니다...")
@Size(max = 5000, message = "댓글은 최대 5000자까지 입력 가능합니다.")
String text,
@NotNull(message = "비밀번호가 입력되지 않았습니다...")
@Size(min = 4, max = 15, message = "비밀번호는 4~15 사이로만 입력 가능합니다. ")
String password,
@NotNull
Long targetId,
@NotNull
Target.TargetType type,
Boolean isDeleted
){}
저는 DTO로 record객체를 사용했지만 일반적인 class에서도 동일합니다.
이렇게 옮기고 나니 아차 싶었습니다.
저는 CommentRequest라는 객체를 댓글을 저장하는 데에도, 불러오는 데에도, 수정하는데에도 사용하는데 닉네임과 본문 등은 댓글을 불러오는데에는 필요가 없었기 때문입니다.
엔티티에 저러한 notNull어노테이션을 지정했을때에는 데이터베이스에 저장하는 경우만 가정하기때문에 큰 문제가 없었던 것이었습니다.
어떤 방법이 있을까…
CreateCommentDTO, ReadCommentDTO를 따로 만드는 방법은 제가 좋아하는 방식이 아닙니다.
하지만 곧 어떤 방법을 알아냅니다.
Bean Validation에서는 그룹(groups)을 정의하여, 검증 규칙을 그룹별로 적용할 수 있습니다. 이 방법을 사용하면, 하나의 DTO에 여러 작업에 대한 검증 규칙을 정의할 수 있으며, 실행 시에는 특정 그룹의 검증 규칙만 적용됩니다.
@NotNull(groups = {CreateGroup.class, UpdateGroup.class}, message = "닉네임이 입력되지 않았습니다...")
@Size(min = 2, max = 8, message = "닉네임은 2~8글자 사이여야 합니다.")
String nickname,
**CreateGroup.class**와 같은 그룹은 검증 그룹을 정의하기 위해 사용되는 마커 인터페이스(marker interface)로, Bean Validation에서 사용되는 커스텀 그룹입니다. 이 인터페이스들은 별도의 메서드를 포함하지 않으며, 검증 규칙을 그룹화하는 데 사용됩니다. 각 검증 그룹은 서로 다른 검증 시나리오를 대표하며, 특정 그룹을 지정하여 그룹에 속한 검증 규칙만 실행할 수 있습니다.
그저 역할을 구분하는데만 쓰인다는 것입니다.
저는 dto패키지 아래에 validation패키지를 새로 만들고 다음과 같은 인터페이스들을 정의했습니다.
public interface CreateGroup {
// 이곳에는 메서드나 필드를 정의하지 않습니다.
//인터페이스의 이름 자체가 검증 그룹을 식별하는 데 사용됩니다.
}
public interface UpdateGroup {
}
public interface ReadGroup {
}
그러면 이러한 검증그룹을 어떻게 적용할 수 있을까요?
CommentController 내에서 해당 그룹을 지정하여 유효성 검증을 수행할 수 있도록 @Validated 어노테이션을 사용해야 합니다. @Validated 어노테이션은 @Valid 어노테이션과 유사하지만, 검증 그룹을 지정하는 기능을 추가로 제공합니다.
@PostMapping
public ResponseEntity<?> addComment(
HttpServletRequest request,
@Validated(CreateGroup.class) @RequestBody CommentRequest commentRequest) {
// 메서드 구현...
}
@GetMapping
public ResponseEntity<?> getComments(
@Validated(ReadGroup.class) @ModelAttribute CommentRequest commentRequest,
Pageable pageable) {
// 메서드 구현...
}
@PatchMapping
public ResponseEntity<?> updateOrDeleteComment(
@Validated(UpdateGroup.class) @RequestBody CommentRequest commentRequest) {
// 메서드 구현...
}
이제 그룹별로 검증이 달리 적용됩니다.
그렇다면 검증에 실패했을 경우는 어떻게 처리해야 할까요?
**@Validated**를 사용하면 Spring MVC가 메서드 파라미터의 유효성 검사를 자동으로 수행하고, 유효성 검사에 실패하면 **MethodArgumentNotValidException**을 발생시킵니다.
이 예외를 catch로 잡아도 되겠지만, 모든 메서드에 이 예외를 처리하는 코드를 작성해야하는 일이 일어날 것입니다.
저는 한 클래스를 추가로 만들고, @ControllerAdvice 어노테이션을 달아 이 문제를 해결하고자 합니다.
@ControllerAdvice 어노테이션은 스프링 프레임워크의 컨트롤러 계층에서 발생하는 예외를 전역적으로 처리하는 메커니즘을 제공합니다. 이 어노테이션을 사용함으로써, 개별 컨트롤러 내에서 예외 처리 로직을 중복으로 작성할 필요 없이, 애플리케이션 전반에 걸쳐 일관된 예외 처리 방식을 적용할 수 있습니다. **@ControllerAdvice**가 적용된 클래스는 애플리케이션의 모든 컨트롤러에 대한 예외 처리, 데이터 바인딩, 모델 속성 추가 등의 작업을 담당할 수 있습니다.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
return ResponseEntity.badRequest().body(errors);
}
}
handleValidationExceptions메서드는 MethodArgumentNotValidException 타입의 예외를 처리합니다.
메서드는 예외 인스턴스(ex)로부터 BindingResult 객체를 얻어내고, 이 객체에 포함된 모든 에러(getAllErrors)를 순회합니다. 각 에러는 FieldError 타입으로 캐스팅될 수 있으며, 이를 통해 발생한 에러의 필드명(getField)과 에러 메시지(getDefaultMessage)를 알 수 있습니다.
필드명과 에러 메시지를 매핑하여 Map<String, String> 타입의 객체에 저장합니다. 이 맵은 유효성 검증 실패에 대한 세부 정보를 클라이언트에 전달하기 위한 응답 본문으로 사용됩니다.
마지막으로, **ResponseEntity.badRequest().body(errors)**를 통해 HTTP 상태 코드 400(Bad Request)과 함께 에러 세부 정보를 포함한 응답을 클라이언트에 반환합니다. 이를 통해 클라이언트는 어떤 입력 값들이 유효성 검사를 통과하지 못했는지, 그리고 구체적인 에러 메시지는 무엇인지 파악할 수 있습니다.
입력값 검증과 예외처리에 대한 포스트는 여기까지 작성하도록 하겠습니다.