본문 바로가기
미니 프로젝트/이노베이션 6주차

이노베이션 6주차 1 - API, ApiResponse<T>, Querydsl

by 구너드 2023. 7. 16.

5주차까지는 스프링을 공부하는 사람들과 함께 주특기 주차를 진행했다. 주특기 주차가 종료된 후 이제는 프론트엔드와 함께 협업하는 주차로 새롭게 프로젝트를 시작하게 되었다.

 

https://www.notion.so/SA-c79312255ae84b0f8ce26bb8a981e2c0

 

팔팔하조 미니 프로젝트 (SA)

프로젝트 설명

www.notion.so

항상 프로젝트를 시작할 때마다 노션 쓰는 게 제일 힘들었는데, 다행히 채연님이 프로젝트와 관련된 노션을 깔끔하게 만들어주셨다. 내가 노션 만들 때는 유사 그림판이었는데, 나는 계획을 시각화하고 디자인하는 쪽에는 영 소질이 없는 것 같다


기본 와이어 프레임


API


고려한 점

1. 첫 협업이다 보니까 API 작성 면에서 많이 미숙했던 것 같다. 보다 프론트엔드에서 알기 쉬운 설명과 함께 각 API 기능들을 구체적으로 적어주는 습관을 가져야겠다고 생각하게 됐다.

 

2. 기본기 느낌이 가까웠던 주특기 주차를 바탕으로 보다 심화적인 기능들을 시도해보고 싶었다. 특히 게시판에 있어 기본적인 기능이라고 할 수 있는 이미지 업로드, 페이징, 검색 기능을 구현해보는 게 좋은 경험이 될 것 같았다. 이에 대해 필요한 개념들을 찾아본 결과, S3, Multipart / Page 와 Slice / Querydsl 이 필요하다는 결론을 내릴 수 있었다. 기본적인 CRUD는 최대한 빠른 시간 내에 구현하고 해당 기능들을 중점적으로 공부하고자 했다.

 

3. 구글링을 통해서 단순히 ResponseEntity<> 또는 Dto 만을 프론트에게 전달해주는 것은 프론트엔드 입장에서 상당히 불친절한 데이터라 느낄 수 있을 것 같았다. 따라서 요청 성공여부, 실제 데이터, 에러 메세지(에러가 없다면 null) 을 하나의 response로 응답해주는 것이 협업에 있어 좋을 것 같았다.

 

4. 연관관계에 대해서도 고민을 꽤 했다. 양방향이 불가피한 엔티티들은 양방향을 부여해야겠지만, 단방향만으로도 구현할 수 있다면 이를 활용해보고자 했다.


<3. 구체적인 정보를 제시하는 Response>

기존에는 단순히 Dto만 응답했는데 요청 성공 여부, Dto, 에러 여부와 에러 메세지를 통합해서 프론트엔드에게 전송하고자 했다.

 

@Getter
@NoArgsConstructor
public class ApiResponse<T>{

    private boolean success;
    private T data;
    private ErrorResponse error;

    public ApiResponse(boolean success, T data, ErrorResponse error) {
        this.success = success;
        this.data = data;
        this.error = error;
    }
}

제네릭을 이용해 응답에 담길 성공 여부와 데이터, 에러를 종합하는 ApiResponse<T>를 생성했다. 이를 기반으로 앞으로 모든 Controller는 각 엔티티들의 Dto가 아닌 ApiResponse<> 를 반환할 것이다.

 

@Getter
@NoArgsConstructor
public class ResponseUtils {

    public static <T> ApiResponse<T> ok(T response) {
        return new ApiResponse<>(true,response,null);
    }

    public static ApiResponse<?> error(String message, int status) {
        return new ApiResponse<>(false, null, new ErrorResponse(message, status));
    }

    public static ApiResponse<?> tokenError(ErrorCodeEnum errorCodeEnum) {
        return new ApiResponse<>(false, null, new ErrorResponse(errorCodeEnum));
    }
}

ApiResponse에 실제 값들을 담을 수 있는 ResponseUtil 클래스를 만들고 정적 메서드로 기능들을 작성했다.

 

1.ResponseUtil 클래스의 객체를 직접 생성하지 않아도, 해당 클래스의 메서드를 직접 호출할 수 있다는 점

 

2. 요청 성공 여부, Dto, 에러 여부와 같이 입력 값을 기반으로 결과를 반환하는 기능을 가지기 때문에 ResponseUtils 는 상태를 유지할 필요가 없다. 즉 메서드 내부에서 클래스의 인스턴스 변수에 접근하거나 수정하는 것이 아닌 해당 인스턴스의 변수들을 담아주는 기능을 할 뿐이다. 인스턴스 변수에 특별한 접근이 없기 때문에 정적으로 선언할 수 있다는 점

 

3. 정적 메서드로 만들어진 클래스는 해당 클래스 이름을 통해 그 기능을 명확하게 알 수 있고, 필요하다면 static import를 통해 코드를 보다 간결하게 유지할 수 있다는 점

 

4. 인스턴스의 생성이 불필요하므로 해당 기능과 관련된 객체 생성으로 인해 발생할 수 있는 객체 관리, 메모리 사용이 줄어들어 사용에 있어 부담이 줄어든다는 점

 

이러한 점에 기반하여 ResponseUtils를 정적으로 선언하여 그 기능을 사용하였다.

 

@Getter
@NoArgsConstructor
public class ErrorResponse {

    private String message;
    private int status;

    public ErrorResponse(ErrorCodeEnum errorCodeEnum) {
        this(errorCodeEnum.getMessage(), errorCodeEnum.getStatus());
    }
}

가독성, 확장성, 재사용성과 모듈화의 이점이 있기 때문에 Enum으로 선언한 ErrorCode를 바탕으로 예외들에 대해 정보를 제공하는 ErrorResponse 클래스. 

 

    @PutMapping("/{postId}")
    public ApiResponse<?> modifyPost(@PathVariable Long postId,
                                     @RequestBody PostRequestDto postRequestDto,
                                     @AuthenticationPrincipal UserDetailsImpl userDetailsImpl) {
        return ok(postService.updatePost(postId, postRequestDto, userDetailsImpl.getUser()));
    }

    @DeleteMapping ("/{postId}")
    public ApiResponse<?> removePost(@PathVariable Long postId,
                                     @AuthenticationPrincipal UserDetailsImpl userDetailsImpl) {
        return ok(postService.deletePost(postId, userDetailsImpl.getUser()));
    }

이러한 코드들을 통해 보다 일관적인 데이터 응답 방식을 구성하여 프론트엔드에게 전송할 수 있다는 점은 꽤 마음에 들었다.


<2. Slice, Querydsl>

검색은 기본적으로 게시글의 제목과 작성자를 바탕으로 기능하는 방식을 구상하였다. 어떤 값에 null이 들어와도 다른 기준을 바탕으로, 혹은 두 값 모두 null이라면 모든 게시글을 반환하는 로직이 필요했다. JPQL 이나 네이티브쿼리를 작성하기에는 작성 과정에 있어 많이 헷갈리고, 컴파일 시점에서 에러를 잡을 수 없다는 큰 단점이 있어 Querydsl을 택했다. 

 

추가적으로 프론트엔드에서는 페이지 방식보다는 무한스크롤 방식을 이용해 게시판을 구성하고 싶어했고 이 부분을 수용하여 Page 가 아닌 Slice를 이용하여 해당 데이터들을 전송하고자 했다.

 

https://goonerd.tistory.com/entry/JPA-7-%EC%8A%A4%ED%94%84%EB%A7%81-%EB%8D%B0%EC%9D%B4%ED%84%B0-JPA

 

JPA 7 - 스프링 데이터 JPA

기존에 스프링 빈, MVC를 공부했을 때 Repository 계층의 CRUD를 모두 직접 코드로 구현했었다. 하지만 이노베이션 캠프에서 사용하는 Repository는 단순히 JpaRepository를 상속받는 인터페이스만으로 구현

goonerd.tistory.com

Page와 Slice에 대해서는 해당 글 참조

 

@RequiredArgsConstructor
public class PostRepositoryCustomImpl implements PostRepositoryCustom{

    private final JPAQueryFactory query;

    @Override
    public Slice<PostResponseDto> serachPostBySlice(PostSearchCondition condition, Pageable pageable) {
        List<PostResponseDto> result = query
                .select(new QPostResponseDto(
                        post.id,
                        post.title,
                        post.username,
                        post.content,
                        post.createdAt,
                        post.image,
                        post.liked,
                        post.disliked
                ))
                .from(post)
                .where(
                        usernameEq(condition.getUsername()),
                        titleEq(condition.getTitle()))
                .orderBy(post.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize() + 1)
                .fetch();

        return checkEndPage(pageable, result);
    }

    private BooleanExpression usernameEq(String usernameCond) {
        return hasText(usernameCond) ? post.username.eq(usernameCond) : null;
    }

    private BooleanExpression titleEq(String titleCond) {
        return hasText(titleCond) ? post.title.eq(titleCond) : null;
    }

    private static SliceImpl<PostResponseDto> checkEndPage(Pageable pageable, List<PostResponseDto> content) {
        boolean hasNext = false;
        if (content.size() > pageable.getPageSize()) {
            hasNext = true;
            content.remove(pageable.getPageSize());
        }
        return new SliceImpl<>(content, pageable, hasNext);
    }
}

QuerydslConfig 라는 클래스를 생성하여 JPAQueryFactory를 Bean으로 등록하고 이를 활용하여 쿼리를 작성하기 시작했다. 간단한 검색 기능이라 크게 어려움은 없었다. Slice 특성상 주어진 limit에 + 1을 해야 다음 페이지 여부를 확인할 수 있고 limit는 글로벌 설정으로 미리 합의된 6개의 데이터로 설정했다. BooleanExpression 타입으로 where문의 조건들을 작성했는데 프로젝트 이후에 시간이 남게 된다면 해당 조건들과 추가적으로 작성하게 될 수 있는 조건들을 조합해 다양한 방식의 검색기능들을 구현해보고 싶어 이를 염두에 두었다.

처음엔 단순히 Post 엔티티를 찾고 이를 반복문을 사용하여 Dto에 값을 담아줬는데 @QueryProjections를 사용하여 직접 select절에서 해당 값들을 Dto에 담아주게 되었다. 전체적인 게시판을 생각하면서 모든 게시글을 조회할 때 댓글까지 보이는 상태로 게시물 전체조회를 할 필요가 없다고 생각해서 comment를 제외한 다른 값들을 담는 Dto를 사용하는 게 좋겠다고 생각을 했고 이를 바탕으로 @QueryProjections를 사용하게 되었다.

 

@Repository
public interface PostRepository extends JpaRepository<Post, Long>, PostRepositoryCustom{

    @Query("select p from Post p left join fetch p.commentList cl where p.id = :postId")
    Optional<Post> findDetailPost(@Param("postId") Long postId);
}

상세 페이지에서의 dto 사용은 PostRepository에서 직접 jpql의 fetch join을 이용해 상세 페이지에서는 해당 post에 작성된 댓글까지 보이게끔 작성하였다