[spring] 처음으로 CRUD를 한다면 참고하기 좋은 글(이 되었으면 좋겠다.)

KWAK-JINHO·2025년 2월 14일
post-thumbnail

드디어 3개월동안의 학습을 거쳐 첫 CRUD를 해보게 되었다.
이번 프로젝트에서는 목표를 참여중인 데브캠프의 파이널 프로젝트를 대비하여 최대한 많은 고민과 경험을 해보는것을 목표로 설정했었다.

그 과정에서 마주했던 고민들을 정리해보는 글이 됨과 동시에, 나처럼 처음 개발을 시작하는 사람들의 고민할 시간을 단축시켜줄 수 있는 글이 됐으면 좋겠다.


프로젝트 Setup

Java 21
Spring 3.4.1
H2(test env), MySQL 8
JPA

버전에 대해서 잘 모르고 최신버전으로 썼는데, java 21 버전에서는 가상스레드가 추가 되었다하니 나중에 사용해봐야겠다.


1. 엔티티와 DTO 불변에 대한 고민

setter에 대한 생각

처음에는 큰 생각없이 엔티티와 DTO 모두 클래스 머리 위에 @Builder 박아놓고 사용했었는데, 이 방식의 문제가 있을 수 있다는것을 알았다.

  • DTO는 데이터를 전달하는 역할만 수행해야 하는데, @Builder를 사용하면 객체를 생성한 후 값을 변경하여 의도와 다르게 사용할 가능성이 있다.
  • @Setter를 사용하면 DTO 를 생성한 후 필드 값을 변경할 수 있어서 원래 설계 의도와 다르게 사용될 가능성이 있다.

정리하자면 객체가 불변하지 못하여 의도가 불명확해진다.( = 유지보수가 힘들어진다)

얼마전, 클린코드라는 책을 보면서 클린코드에 대한 중요성에 대해서 생각해 본적이 있는데 클린코드의 핵심은 개발자 간의 효과적인 의사소통이라고 정리했었다. Setter의 사용을 지양해야 하는이유도 설계의 의도대로 사용하지 않고 변경할 수 있는 가능성을 두지 않기 위해서 중요하다고 생각된다. 대신에 메서드의 이름을 통해 어떤 작업을 수행하는지 명확하게 나타내는 메서드를 사용하는 것이 권장되는 것 같다.

Post 엔티티

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "title", nullable = false)
    private String title;

    @Column(name = "content", nullable = false)
    private String content;

    @Column(name = "createAt", nullable = false)
    private LocalDateTime createAt;

    @Builder
    private Post(
            String title,
            String content
    ) {
        this.title = title;
        this.content = content;
        this.createAt = LocalDateTime.now();
    }

    public void editPost(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

위 코드에 대해 설명하자면

  • 클래스 레벨에 Builder를 달게 되면 모든 필드에 빌더를 생성하게 된다.
  • getter는 JPQL이나 JSON직렬화에서 필요하다.
  • NoArgsConstructor를 private로 선언하게 된다면 JPA가 리플렉션을 사용하여 기본 생성자를 호출하려할 때 접근할 수 없기 때문에 protected로 선언한다.
  • setter를 사용하여 직접 필드를 수정하는 것이 아닌 명확한 의도를 가진 메서드 editPost를 만들어 사용했다. 메서드의 사용은 덤으로 유효성 검증 메서드를 추가하기에도 편하다.

DTO

record는 Java 14에서 처음 도입되었고, 16에서 정식 기능으로 출시 되어 별도의 옵션없이 사용할 수 있게 되었다.
record는 모든 필드를 자동으로 final로 만들어, 생성 후 값을 변경할 수 없게 한다. 또한, getter를 자동으로 생성해주며, 모든 필드를 받는 생성자를 자동으로 제공하기 때문에 Builder 패턴을 사용할 필요가 없다. 따라서 DTO에 record를 사용하는게 매우 찰떡이라고 생각한다.

클라이언트에게 게시글을 생성할 때 받을 값으로 PostCreate DTO를 아래와 같이 만들었다.

public record PostCreate(
        @NotBlank(message = "제목은 필수 입력값 입니다.")
        @Size(min = 1, max = 15, message = "제목은 1글자 이상 15글자 이하여야 합니다.")
        String title,

        @NotBlank(message = "내용은 필수 입력값 입니다.")
        @Size(min = 1, max = 1000, message = "내용은 1자이상 1000자 이하여야 합니다.")
        String content
) {
    public Post toEntity() {
        return Post.builder()
                .title(this.title)
                .content(this.content)
                .build();
    }
}

DTO - Entity Mapper

현재는 record로 클래스를 선언 후 toEntity 메서드를 만들어 DTO를 매핑해 주었는데, mapper를 사용하면 더 효율적일 수 있다고 한다.
크게 리플렉션 기반의 ModelMapper와 MapStruct라는 mapper가 있는데, 이중에 MapStruct는 컴파일 타임에 매핑 코드를 생성하여 안전성이 좋고, 필드이름이 다른 경우에 커스터마이징이 가능하며, 위 경우에는 필드값이 2개뿐이라 코드가 짧지만 코드가 길어지면 하나하나 적어줘야 하는 번거로움을 간단한 인터페이스와 어노테이션만으로 없애줄 수 있다고 한다.

이러한 이유로, DTO의 필드가 많아지는 경우 MapStruct의 사용이 좋은 선택이 될 수 있다.

결론

커뮤니케이션을 위해 명확한 의도를 가진 코드를 작성하자!


2. null과 빈 값 처리에 대한 고민

Java의 창시자인 제임스 고슬링도 null을 만든것에 대해서 실수라고 언급했을 정도로 null은 예상치 못한 버그를 만드는 원인이며, null 체크를 위해 코드를 지저분하게 만든다. 때문에 프로젝트에서 null과 빈값을 안전하게 처리하기 위해 고민했던 방법들을 정리해 보았다.

Bean Validatoin

@NotNull: null 허용하지 않는다.
@NotEmpty: 빈 문자열("")과 null 모두 허용하지 않는다.
@NotBlank: 빈 문자열과 null 그리고 공백 문자열(" ")도 허용하지 않는다.

spring MVC

  1. @RequestParam의 (required = false, defaultValue="default") 설정을 통해 기본값을 줌으로써 null을 방지
  2. @RequestBody 와 @Valid 를 통해 DTO에서 null값을 체크

@JsonInclude

해당 애너체이션은 JSON 직렬화 시 특정 필드를 제외하는 기능을 제공한다. 즉, null값이나 특정 조건을 만족하는 값들을 JSON응답에서 제거할 수 있다.

value = 아래 옵션중 선택
JsonInclude.Include.NON_NULL: null 제외
JsonInclude.Include.NON_EMPTY: null, 빈값 제외

클라이언트가 빈값은 무시해달라고 할 때 이 설정으로 가능하다. 하지만 빈 값자체로도 의미를 가질 수 있기 때문에 생각해보고 결정해야 한다.

Optional

OPtional< T > 를 사용한다면 null값이 있을 때와 없을 때를 나누어서 컨트롤할 수 있다.

public String getUserEmail(User user) {
    if (user != null) {
        return user.getEmail();
    }
    return "unknown";
}

이렇게 if 문으로 처리해줘야 하는것을

public String getUserEmail(User user) {
    return Optional.ofNullable(user)
            .map(User::getEmail)
            .orElse("unknown");
}

이와 같이 optional로 처리할 수 있다.
지금 생각해보니 DB를 조회하는 경우에도 빈리스트를 내려 받아야 하는 경우가 아닐때는 Optional로 반환하는게 안전할 것 같다.

결론

클라이언트에게 받는 모든 입력값은 null이 온다고 가정하고 검증하며, 내가 작성하는 코드에서는 null을 만들지 말자..


3. RESTful한 API에 대한 고민

간단하게 RESTful API에 대해서 설명하자면
API란 A의 요청과 B의 응답을 정하는 약속이라고 할수 있는데
Restful API는 Rest 아키텍쳐 스타일을 따르는 API를 말하는 것으로, 웹의 기본 프로토콜인 HTTP를 사용하여 리소스를 정의하고 조작하는 방식을 규정한다.

응답본문과 상태코드, RESTful과 실용성 사이에서 균형찾기

컨트롤러를 작성하면서 클라이언트가 직관적으로 이해하고 효율적으로 활용할 수 있는 API를 작성하기 위해 어떻게 코드를 짜야하는지에 대해 깊게 고민해보고 싶었다.

처음코드

@PostMapping("/posts")
public void createPost(@RequestBody @Valid PostCreateRequest request) {
    postService.createPost(request);
}

클라이언트에서 JSON요청 본문을 받아 게시글을 작성하게 만들었다.
위코드는 상태코드를 200 OK 또는 204 No Content를 반환한다. 하지만 void반환으로 클라이언트가 요청이 성공했는지 알기위해 추가적인 API 호출을 필요로 할 수도 있고, POST 요청이 성공하면 일반적으로 새로운 리소스가 생성되므로 201 Created를 반환하는 것이 RESTful한 방식이다.

두번째 코드

public ResponseEntity<PostCreateResponse> createPost(@RequestBody @Valid PostCreate request) {
    PostCreateResponse response = postService.createPost(request);
    
    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

ResponseEntity< T >를 사용하여 응답 상태 코드와 함께 본분(body)를 포함하는 응답을 반환할 수 있도록 변경하였다. 또한, PostCreateResponse를 응답 본문으로 제공하여 클라이언트가 생성된 리소스의 정보를 받을 수 있게 하였다.

RESTful API란?

RESTful API는 REST(Representational State Transfer)원칙을 따르는 API를 의미하며 자원(URI), 행위(Verb), 표현(Representations) 으로 구성되어있다. REST는 클라이언트-서버 구조에서 리소스를 URI로 식별하고, HTTP Method(GET,POST,PUT 등)을 사용하여 리소스를 조작하는 방식이다.

HATEOAS(Hypermidia As The Engine Of Application State)

기본적으로 요청에 대해 서버는 응답에 데이터만 클라이언트에게 보내는데, HATEOAS는 응답에 데이터뿐만 아니라 해당 데이터와 관련된 요청에 필요한 URI를 응답에 포함하여 반환함으로써, 클라이언트가 전적으로 서버와 동적인 상호작용이 가능하게 한다는 개념이다.

위 사진은 Richardson Maturity Model로써 각 구현단계별로 아래와 같은 특징을 지니며, 3단계에 도달했을 때를 비로소 완전한 REST API, 즉 HATEOAS를 지칭한다.

레벨 0

  • HTTP를 단순한 전송 메커니즘으로만 사용
  • 대개 단일 엔드포인트(예: /api)에 모든 요청을 POST로 전송
  • URL을 통해 리소스를 식별하지 않고, 요청 본문에 모든 정보를 담아 서버에 전달

레벨 1

  • 다양한 리소스에 대한 개별 URI 사용 (예: /users, /products)
  • 리소스 식별자 도입 (예: /users/123)
  • 리소스 개념은 도입했지만 HTTP 규약을 충분히 활용하지 않음

레벨 2

  • HTTP 메소드를 목적에 맞게 적절히 사용 (GET, POST, PUT, DELETE)
  • 상태 코드를 의미에 맞게 활용 (200, 201, 404, 500 등)
  • 멱등성과 안전성 개념 도입
  • 웹의 인프라(캐싱 등)를 활용 가능

레벨 3

  • 서버는 응답에 데이터뿐만 아니라 해당 데이터와 관련된 요청에 필요한 URI를 포함하여 반환합니다.
  • 클라이언트는 응답에 포함된 링크를 따라 다음 동작을 결정합니다.

요청에 들어온 매개변수를 해당 엔드포인트로 처리결과에 따라 단순히 상태코드만을 던져주던 게시글 작성 코드를 응답헤더에 URI를 던져주게끔 변경해보았다.

이 글에서 요청 헤더와 응답 헤더에 대해서 어떤 포멧으로 데이터가 전달되는지 잘 나와있음으로 참고해보면 좋을 것 같다.

(HATEOAS 적용) 첫번째 방법

고정된 /posts/{id} 형식으로 던져주기

@PostMapping("/posts")  
@ResponseStatus(HttpStatus.CREATED)  
public ResponseEntity<PostCreateResponse> createPost(@RequestBody @Valid PostCreate request) {  
    PostCreateResponse response = postService.createPost(request);  
    
    return ResponseEntity  
            .created(URI.create("/posts/" + response.getId()))  
            .body(response);  
}

created 로 URI Location을 사용하여 응답 헤더에 URI를 던져주어 클라이언트가 별도의 로직없이 URI를 얻을 수 있게 한다.

(HATEOAS 적용) 두번째 방법

spring-boot-starter-web 이 제공하는 유틸리티 클래스. ServletUriComponentsBuilder를 사용한 동적인 URI 제공방식

@PostMapping("/posts") 
@ResponseStatus(HttpStatus.CREATED) 
public ResponseEntity<PostCreateResponse> createPost(@RequestBody @Valid PostCreate request {
	PostCreateResponse response = postService.createPost(request); 

  // 동적으로 URI 생성 
  URI location = ServletUriComponentsBuilder 
      .fromCurrentRequest() // 현재 요청의 URI를 기반으로 URI를 생성
      .path("/{id}") // 현재 URI에 추가 경로를 붙인다
      .buildAndExpand(response.getId()) // 경로 변수 값을 동적으로 채워 URI를 생성
      .toUri(); 

  return ResponseEntity.created(location).body(response); 
}

이 방법의 장점으로는

  • 현재 요청 기반으로 동적 URI 생성
  • 컨텍스트 경로 자동 처리
  • 도메인/포트 변경에 자동 대응

즉, 유지보수에 용이하다고 할 수 있다.

추가적으로는 Spring HATEOAS 라이브러리를 활용하는 방법이 있다.

마지막 수정

@Contorller 애너테이션 대신에 @RestController 애너테이션을 사용하면 @ResponseBody가 자동으로 적용된다. 즉, 메서드가 반환하는 객체는 JSON형태로 직렬화되어 응답 본문에 포함된다. 또한 @ResponseStatus을 사용하면 원하는 상태코드로 응답이 가능하게 하였다.

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public PostCreateResponse createPost(@RequestBody @Valid PostCreate request) {
    return postService.createPost(request);
}

내가 내린 결론은 실용적인 측면에서 불필요한 복잡성을 증가시키며, 실제 개발 환경에서는 RESTful 원칙도 중요하지만 실용성 및 개발의 생산성도 매우 중요하기 때문에 HATEOAS 원칙을 강제할 필요가 없다고 생각되었다.

결론

HATEOAS의 장점으로는 클라이언트가 API구조를 미리 알 필요 없이, 서버 응답의 링크 필드를 따라 탐색할 수 있으며, API가 변경 되더라도 클라이언트는 새로운 엔드포인트를 직접 설정하지 않고, 응답을 기반으로 동작이 가능하다는 점이 있다. 하지만 그만큼 개발의 복잡도가 매우 크게 증가한다는 큰 단점이 있다.

HATEOAS는 찬반 의견이 갈리는 논쟁거리라고 한다. stack overflow의 여러 글들을 보아도 사람들의 의견이 갈리는 것을 볼 수 있다. 종합해보면,

  1. HATEOAS는 복잡하고 구현이 어렵지만, 대규모 서비스에서는 중요한 이점을 제공한다.
  2. 소규모 서비스에서는 HATEOAS가 제공하는 유연성이 불필요한 복잡성일 수 있다.

4. 예외처리에 대한 고민

예외처리를 잘하는것이 곧 코딩을 잘하는 사람이라는 말을 들어와서 여기저기서 학습자료를 찾아보다가 평소 좋아했던 호돌맨님의 강의를 사기로 결심했다!

Springboot는 기본적으로 발생하는 예외 유형에 따라 자동으로 적절한 상태 코드와 응답을 반환한다. 하지만 기본 예외처리는 한계가 있는데,

  • 비지니스 로직에 특화된 예외 처리가 어렵다.
  • 일관된 에러 응답 포멧을 만들기가 어렵다.
  • 세부적인 상황 제어가 제한적이다.

특히 검증 로직에서는 데이터를 꺼내와서 조합하고 검증하는 방식보다는, 명확한 의미를 가진 예외를 던지는 것이 코드의 의도를 더 잘 드러낼 수 있다. 이를 위해 각 도메인별로 검증을 담당하는 validate메서드를 만들고 예외가 발생할 경우 커스텀 예외를 던지도록 구현했다.

구현

Unchecked예외인 RuntimeException을 상속하는 base 예외 클래스를 만들고 여기서 구체적인 예외들이 각자의 상태 코드를 구현(getStatusCode)하게 강제했고, validation Map을 만들어 필드별 유효성 검증 실패 메시지를 저장해 여러 필드에 대한 에러 메시지를 한번에 처리 가능하게 만들었다.

public abstract class BoardApplicationException extends RuntimeException {

    public final Map<String, String> validation = new HashMap<>();
    
    public abstract int getStatusCode();

    public BoardApplicationException(String message) {
        super(message);
    }

    public BoardApplicationException(String message, Throwable cause) {
        super(message, cause);
    }

    public void addValidation(String fieldName, String message) {
        validation.put(fieldName, message);
    }
}

그리고 그 밑에 구체적인 예외 클래스들을 BoardApplicationException를 상속시켜

  • InvalidRequest(400 Bad Request): 잘못된 요청에 대한 처리
  • PostNotFound(404 Not Found): 게시글을 찾을 수 없을 때의 처리

만들어 주었다. 그리고 전역에서 예외 처리를 할 컨트롤러를 @ControllerAdvice로 아래와 같이 만들었다.

@ControllerAdvice
public class ExceptionController {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ResponseBody
    public ErrorResponse invalidRequestHandler(MethodArgumentNotValidException e) {
        ErrorResponse response = ErrorResponse.builder()
                .code("400")
                .message("잘못된 요청입니다.")
                .build();
        for (FieldError fieldError : e.getFieldErrors()) {
            response.addValidation(fieldError.getField(), fieldError.getDefaultMessage());
        }
        return response;
    }

    @ExceptionHandler(BoardApplicationException.class)
    @ResponseBody
    public ResponseEntity<ErrorResponse> boardApplicationException(BoardApplicationException e) {
        int statusCode = e.getStatusCode();

        ErrorResponse body = ErrorResponse.builder()
                .code(String.valueOf(statusCode))
                .message(e.getMessage())
                .validation(e.getValidation())
                .build();
        return ResponseEntity.status(statusCode).body(body);
    }
}

이 구조는 일관된 예외 처리 및 응답 포멧과 내가 원하는대로 여러 커스텀을 가능하게 만들어주는 큰 장점이 있다.


마치며

기본적인 CRUD를 마치고 aws에 GitHub Actions를 이용해 CICD 구축까지 경험해보았다.(해당 글은 추후에 업로드 예정) 앞전에 토이 프로젝트에서는 타임리프로 앞단을 만들다가 정작 스프링공부는 할 시간이 너무 부족했는데 이번 남는시간에(운좋게 설날까지 낑겨서 시간 널널) 부족했던 스프링이나, JPA 기본개념들을 채워넣고 CRUD까지 해볼 수 있어서 너무 좋았다.
이번 프로젝트를 하면서 겪은 대부분의 고민들은 결국 커뮤니케이션을 위한 고민들이였다고 생각한다. 명확한 의도를 가진 코드, 깨끗한 코드를 짜기위해 노력하는 개발자가 좋은 개발자인 것 같다고 생각했다.

별개로 파이널 프로젝트에서는 기업연계로 약 5주간 프론트앤드,디자이너님들과 함께 기업에서 원하는 쇼핑몰을 만들게 되었는데 상품과 주문파트를 맡게되어(시간이 남는다면 실시간 알림까지) 경험해보지 못했던 로그인, 회원가입은 이 게시판에 붙어보아야겠다.


Reference

갓대희님의 "요청 헤더, 응답 헤더" https://goddaehee.tistory.com/169
사바라다님의 자바에서 줄바꿈, 개행의 규칙 https://sabarada.tistory.com/96
호돌맨님의 인프런강의 매우 추천
https://www.inflearn.com/course/%ED%98%B8%EB%8F%8C%EB%A7%A8-%EC%9A%94%EC%A0%88%EB%B3%B5%ED%86%B5-%EA%B0%9C%EB%B0%9C%EC%87%BC/dashboard
호돌맨님 계시는 개발바닥 카톡 오픈채팅방에 오시면 고수님들의 대화를 몰래 엿볼 수 있습니다..

감사합니다. ☺️

profile
매일 더 나은 내가 되자

0개의 댓글