카테고리 없음

spring-boot(13)

rjqnrdl83 2025. 2. 18. 00:20

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인지 확인하고 실제로 저장된 데이터와 요청 값을 비교합니다.