1. 값 검증: 사용자가 요청을 보냈을 때 올바른 값인지 유효성 검사를 하는 과정
- 서버에서 로직을 처리하기 전에 사용자가 잘못된 데이터를 보냈을 경우 서버에서 로직을 처리하기 전에 사용자에게 에러 메시지를 보여주면 서비스 로직을 실행하지 않으니 시스템을 안정적으로 관리할 수 있습니다.
스프링은 자바 빈 벨리데이션(java bean validation)이라는 API를 제공합니다. 이 API를 사용하면 어노테이션 기반으로 다양한 검증 규칙을 간편하게 사용할 수 있고 입력 데이터의 유효성을 검사할 수 있습니다.
자주 사용하는 자바 빈 벨리데이션
// 문자열을 다룰 때 사용
@NotNull // null 허용하지 않음
@NotEmpty // null, 빈 문자열 또는 공백만으로 채워진 문자열을 허용하지 않음
@NotBlank // null, 빈 문자열 허용하지 않음
@Size(min=?, max=?) // 최소 길이, 최대 길이 제한
@Null // null만 가능
// 숫자를 다룰 때 사용
@Positive // 양수만 허용
@PositiveOrZero // 양수와 0만 허용
@Negative // 음수만 허용
@NegativeOrZero // 음수와 0만 허용
@Min(?) // 최솟값 제한
@Max(?) // 최댓값 제한
// 정규식 관련
@Email // 이메일 형식만 허용
@Pattern(regexp="?") // 직접 작성한 정규식에 맞는 문자열만 허용
값 검증은 어느 계층에서 해도 상관없습니다. 프레젠테이션 계층에 작성하면 불필요한 서비스 로직을 실행하지 않을 수 있고 사용자 요청마다 세부 조건을 적용할 수 있습니다.
상황에 따라 중복 로직이 너무 많이 생기거나 로직을 통일하기 어려우면 퍼시스턴스 계층인 엔티티에 검증 코드를 작성하기도 합니다.
블로그의 글을 생성할 때 필요한 유효성 검사는
1. 제목은 Null이 아니어야 하며 1자 이상 10자이하.
2. 내용은 Null이 아니어야 함.
BlogApiControllerTest.java
@DisplayName("addArticle: 아티클 추가할 때 title이 null이면 실패한다.")
@Test
public void addArticleNullValidation() throws Exception {
// given
final String url = "/api/articles";
final String title = null;
final String content = "content";
final AddArticleRequest userRequest = new AddArticleRequest(title, content);
final String requestBody = objectMapper.writeValueAsString(userRequest);
Principal principal = Mockito.mock(Principal.class);
Mockito.when(principal.getName()).thenReturn("username");
// when
ResultActions result = mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.principal(principal)
.content(requestBody));
// then
result.andExpect(status().isBadRequest());
}
@DisplayName("addArticle: 아티클 추가할 때 title이 10자를 넘으면 실패한다.")
@Test
public void addArticleSizeValidation() throws Exception {
// given
Faker faker = new Faker();
final String url = "/api/articles";
final String title = faker.lorem().characters(11);
final String content = "content";
final AddArticleRequest userRequest = new AddArticleRequest(title, content);
final String requestBody = objectMapper.writeValueAsString(userRequest);
Principal principal = Mockito.mock(Principal.class);
Mockito.when(principal.getName()).thenReturn("username");
// when
ResultActions result = mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.principal(principal)
.content(requestBody));
// then
result.andExpect(status().isBadRequest());
}
addArticleNullValidation()
given: 블로그 글 추가에 필요한 객체를 만듭니다. title은 null값으로 설정합니다.
when: 블로그 글 추가 API에 요청을 보냅니다. 요청 타입은 JSON, given절에서 미리 만들어둔 객체를 요청 본문으로 함께 보냅니다.
then: 응답코드가 400 Bad Reuqest인지 확인합니다.
addArticleSizeValidation()
given: 블로그 글 추가에 필요한 요청 객체를 만듭니다. title에는 11자의 문자가 들어가게 생성합니다.
when: 블로그 글 추가 API에 요청을 보냅니다. 요청 타입은 JSON, given절에서 미리 만들어둔 객체를 요청 본문으로 함께 보냅니다.
then: 응답코드가 400 BadRequest
아직 유효값 검증 로직을 작성하지 않아 테스트는 실패합니다.
AddArticleRequest.java
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class AddArticleRequest {
@NotNull
@Size(min = 1, max = 10)
private String title;
@NotNull
private String content;
public Article toEntity(String author){
return Article.builder()
.title(title)
.content(content)
.author(author)
.build();
}
}
title에는 Null을 허용하지 않는 @NotNull과 @Size 어노테이션을, content에는 @NotNull만 추가했습니다.
BlogApiController.java
@PostMapping("/api/articles")
public ResponseEntity<Article> addArticle(@RequestBody @Validated AddArticleRequest request, Principal principal) {
Article savedArticle = blogService.save(request, principal.getName());
//요청한 자원이 성공적으로 생성되었으며 저장된 블로그 글 정보를 응답 객체에 담아 전송
return ResponseEntity.status(HttpStatus.CREATED)
.body(savedArticle);
}
@Validated 어노테이션은 메서드에 들어오는 파라미터가 유효한 값인지 검증합니다.
이렇게 수정하면 테스트는 정상적으로 작동됩니다.
2. 예외 처리 가이드
BlogService.java의 글을 조회하는 findById()메서드를 살펴보겠습니다.
public Article findById(long id) {
return blogRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("not found : " + id));
}
id를 입력 받아 특정 블로그 글을 찾은 다음 글이 없으면 IllegalArgumentException예외와 함께 not found + ${id}라는 에러 메시지를 함께 보냅니다.
실제 예외가 발생하면 생기는 에러 메시지
{
"timestamp": "2025-02-18To7:28:34.039+00:00", # 예외 발생 시간
"status": 500, # HTTP 상태 코드
"error": "Internal Server Error". # 예외 유형
"path": "/api/articles/123" # 예외가 발생한 요청 경로
}
이 포멧은 스프링 부트에서 기본적으로 제공하는 DefaultErrorAttributes입니다. 여기에 추가 정보를 담고 싶다면 ErrorAttributes를 구현하여 빈으로 등록하면 구현한 ErrorAttributes에 맞게 에러 메시지를 만들 수 있습니다.
ErrorCode.java
@Getter
public enum ErrorCode {
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "E1", "올바르지 않은 입력값입니다."),
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "E2", "잘못된 HTTP 메서드를 호출했습니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E3", "서버 에러가 발생했습니다."),
NOT_FOUND(HttpStatus.NOT_FOUND, "E4", "존재하지 않는 엔티티입니다."),
ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "A1", "존재하지 않는 아티클입니다.");
private final String message;
private final String code;
private final HttpStatus status;
ErrorCode(final HttpStatus status, final String code, final String message) {
this.status = status;
this.code = code;
this.message = message;
}
}
에러 코드를 한 곳에 모아 관리하기 위한 enum입니다.
ErrorResponse.java
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ErrorResponse {
private String message;
private String code;
private ErrorResponse(final ErrorCode code) {
this.message = code.getMessage();
this.code = code.getCode();
}
public ErrorResponse(final ErrorCode code, final String message) {
this.message = message;
this.code = code.getCode();
}
public static ErrorResponse of(final ErrorCode code) {
return new ErrorResponse(code);
}
public static ErrorResponse of(final ErrorCode code, final String message) {
return new ErrorResponse(code, message);
}
}
ErrorAttributes를 데체할 에러 메시지용 객체입니다. 에러 메시지가 포함된 message 필드와 고유 에러 코드인 code 필드를 갖고 있씁니다.
BusinessBaseException.java
public class BusinessBaseException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessBaseException(String message, ErrorCode errorCode) {
super(message);
this.errorCode = errorCode;
}
public BusinessBaseException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
이 클래스는 비즈니스 로직을 작성하다 발생하는 예외를 모아둘 최상위 클래스입니다. BusinessBaseException을 상속받은 구조로 비즈니스 로직관련 예외를 만드는 것입니다.
NotFoundException.java
public class NotFoundException extends BusinessBaseException{
public NotFoundException(ErrorCode errorCode) {
super(errorCode.getMessage(), errorCode);
}
public NotFoundException() {
super(ErrorCode.NOT_FOUND);
}
}
ArticleNotFoundException.java
public class ArticleNotFoundException extends NotFoundException{
public ArticleNotFoundException() {
super(ErrorCode.ARTICLE_NOT_FOUND);
}
}
GlobalExceptionHandler.java
@Slf4j
@ControllerAdvice // 모든 컨트롤러에서 발생하는 예외를 잡아서 처리
public class GlobalExceptionHandler {
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
protected ResponseEntity<ErrorResponse> handle(HttpRequestMethodNotSupportedException e) {
log.error("HttpRequestMethodNotSupportedException", e);
return createErrorResponseEntity(ErrorCode.METHOD_NOT_ALLOWED);
}
@ExceptionHandler(BusinessBaseException.class)
protected ResponseEntity<ErrorResponse> handle(BusinessBaseException e) {
log.error("BusinessBaseException", e);
return createErrorResponseEntity(e.getErrorCode());
}
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handle(Exception e) {
e.printStackTrace();
log.error("Exception", e);
return createErrorResponseEntity(ErrorCode.INTERNAL_SERVER_ERROR);
}
private ResponseEntity<ErrorResponse> createErrorResponseEntity(ErrorCode errorCode) {
return new ResponseEntity<>(
ErrorResponse.of(errorCode),
errorCode.getStatus());
}
}
@ControllerAdvice를 사용해 모든 컨트롤러에서 발생하는 예외를 중앙에서 한꺼번에 처리합니다.
@ExceptionHandler 어노테이션을 사용해 특정 예외 상황에 대해 처리할 수 있습니다.
BlogApiControllerTest.java
@DisplayName("findArticle: 잘못된 HTTP 메서드로 아티클을 조회하려고 하면 조회에 실패한다.")
@Test
public void invalidHttpMethod() throws Exception {
//given
final String url = "/api/articles/{id}";
//when
final ResultActions resultActions = mockMvc.perform(post(url, 1));
//then
resultActions
.andDo(print())
.andExpect(status().isMethodNotAllowed())
.andExpect(jsonPath("$.message").value(ErrorCode.METHOD_NOT_ALLOWED.getMessage()));
}
GET 요청을 처리하는 컨트롤러만 있는 URL에 예외가 발생할 POST 요청을 보냅니다. 테스트 코드를 실행하면 405 응답과 에러메시지를 보내줍니다.
BlogApiControllerTest.java
@DisplayName("findArticle: 존재하지 않는 아티클을 조회하려고 하면 조회에 실패한다.")
@Test
public void findArticleInvalidArticle() throws Exception {
//given
final String url = "/api/articles/{id}";
final long invalidId = 1;
//when
final ResultActions resultActions = mockMvc.perform(get(url, invalidId));
//then
resultActions
.andDo(print())
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value(ErrorCode.ARTICLE_NOT_FOUND.getMessage()))
.andExpect(jsonPath("$.code").value(ErrorCode.ARTICLE_NOT_FOUND.getCode()));
}
3. 댓글 기능 추가
Comment.java
@Table(name="comments")
@EntityListeners(AuditingEntityListener.class)
@Entity
@Getter
@NoArgsConstructor(access= AccessLevel.PROTECTED)
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name="id", updatable = false)
private Long id;
@Column(name="author", nullable = false)
private String author;
@Column(name="content", nullable = false)
private String content;
@CreatedDate
@Column(name="created_at")
private LocalDateTime createdAt;
@ManyToOne
private Article article;
@Builder
public Comment(Article article, String author, String content) {
this.article = article;
this.author = author;
this.content = content;
}
}
@ManyToOne어노테이션은 테이블 간의 다대일 관계를 나타내기 위해 사용합니다. 여러 댓글이 하나의 글을 갖습니다.
반대로 Article에서는 @OneToMany를 사용합니다.
@OneToMany(mappedBy = "article", cascade = CascadeType.REMOVE)
private List<Comment> comments;
@OneToMany 어노테이션에는 mappedBy 속성과 cascade 속성을 추가했습니다. mappedBy는 자식 엔티티가 부모 엔티티를 참조할 때 사용합니다. 자식 엔티티가 article 필드를 사용해 부모 엔티티와의 관계를 나타내는 것을 의미합니다.
cascade 속성은 부모 엔티티를 변경할 때 자식 엔티티에 전파하기 위한 방법 중 삭제에 관련된 설정입니다.
CommentRepository.java
public interface CommentRepository extends JpaRepository<Comment, Long> {
}
AddCommentRequest.java
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class AddCommentRequest {
private Long articleId;
private String content;
public Comment toEntity(String author, Article article) {
return Comment.builder()
.article(article)
.content(content)
.author(author)
.build();
}
}
AddCommentResponse.java
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class AddCommentResponse {
private Long id;
private String content;
public AddCommentResponse(Comment comment) {
this.id = comment.getId();
this.content = comment.getContent();
}
}
BlogService.java
public Comment addComment(AddCommentRequest request, String userName) {
Article article = blogRepository.findById(request.getArticleId())
.orElseThrow(() -> new IllegalArgumentException("not found : " + request.getArticleId()));
return commentRepository.save(request.toEntity(userName, article));
}
addComment()는 댓글 추가를 하는 메서드로 요청 받은 블로그 글 아이디로 블로그 글을 찾습니다. 그 이후에는 댓글 내용, 작성자, 블로그 글을 넘겨주어 commentRepository의 save() 메서드를 호출해 댓글을 생성합니다.
BlogApiController.java
@PostMapping("/api/comments")
public ResponseEntity<AddCommentResponse> addComment(@RequestBody AddCommentRequest request, Principal principal) {
Comment savedComment = blogService.addComment(request, principal.getName());
return ResponseEntity.status(HttpStatus.CREATED).body(new AddCommentResponse(savedComment));
}
/api/comments POST 요청이 오면 글을 삭제하기 위한 addComment()
BlogApiControllerTest.java
@DisplayName("addComment: 댓글 추가에 성공한다")
@Test
public void addComment() throws Exception {
//given
final String url = "/api/comments";
Article savedArticle = createDefaultArticle();
final Long articleId = savedArticle.getId();
final String content = "content";
final AddCommentRequest userRequest = new AddCommentRequest(articleId, content);
final String requestBody = objectMapper.writeValueAsString(userRequest);
Principal principal = Mockito.mock(Principal.class);
Mockito.when(principal.getName()).thenReturn("username");
//when
ResultActions result= mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.principal(principal)
.content(requestBody));
//then
result.andExpect(status().isCreated());
List<Comment> comments = commentRepository.findAll();
assertThat(comments.size()).isEqualTo(1);
assertThat(comments.get(0).getArticle().getId()).isEqualTo(articleId);
assertThat(comments.get(0).getContent()).isEqualTo(content);
}
given: 블로그 글을 생성하고 생성한 블로그 글에 댓글 추가를 저장할 요청 객체를 만듭니다.
when: 댓글 추가 API에 요청을 보냅니다. 이때 요청 타입은 JSON이며, given절에서 미리 만들어둔 객체를 요청 본문으로 함께 보냅니다.
then: 응답 코드가 201 Created인지 확인합니다. Comment를 전체 조회해 크기가 1인지 확인하고 실제로 저장된 데이터와 요청 값을 비교합니다.