Skip to content

Driven(query) 구현 #25

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ dependencies {
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
annotationProcessor("jakarta.annotation:jakarta.annotation-api")


// lombok test
testCompileOnly("org.projectlombok:lombok")
testAnnotationProcessor("org.projectlombok:lombok")
Expand All @@ -77,8 +78,6 @@ dependencies {
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.mockk:mockk:1.13.12")
testImplementation(kotlin("script-runtime"))
testCompileOnly("org.projectlombok:lombok") // 테스트 의존성 추가
testAnnotationProcessor("org.projectlombok:lombok") // 테스트 의존성 추가
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.3")
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package me.nettee.board.adapter.driven.mapper;

import me.nettee.board.adapter.driven.persistence.entity.BoardEntity;
import me.nettee.board.application.domain.Board;
import org.mapstruct.Mapper;

import java.util.Optional;
import me.nettee.board.application.model.BoardReadDetailModel;
import me.nettee.board.application.model.BoardReadSummaryModel;

// Entity <-> Domain 매핑 클래스
@Mapper(componentModel = "spring")
public interface BoardEntityMapper {
Board toDomain(BoardEntity boardEntity);
BoardReadDetailModel toBoardReadDetailModel(BoardEntity boardEntity);
BoardReadSummaryModel toBoardReadSummaryModel(BoardEntity boardEntity);
BoardEntity toEntity(Board board);

default Optional<BoardReadDetailModel> toOptionalBoardReadDetailModel(BoardEntity boardEntity) {
return Optional.ofNullable(toBoardReadDetailModel(boardEntity));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package me.nettee.board.adapter.driven.persistence;

import me.nettee.board.adapter.driven.persistence.entity.BoardEntity;
import me.nettee.board.application.domain.type.BoardStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Set;

public interface BoardJpaRepository extends JpaRepository<BoardEntity, Long> {
Page<BoardEntity> findByStatusIn(Set<BoardStatus> statuses, Pageable pageable);
Page<BoardEntity> findByStatus(BoardStatus status, PageRequest pageRequest);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 전체적으로 가독성이 매우 좋고, 잘 정리된 코드입니다.

앞으로 추가적인 논의가 생긴다면 반영하게 될 수도 있지만,
더 구체적인 다음 단계 작업은 멀티모듈 프로젝트 때 담길 것 같습니다. 👍

예를 들면, 페이지를 생성할 때 테이블의 크기를 이대로 조회해서 만드는 것 대신,
더 빠른 조회를 지원하는 여러 단계의 아이디어 중 무엇을 할지 논의하며 리서치할 수 있습니다.

Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package me.nettee.board.adapter.driven.persistence;

import me.nettee.board.adapter.driven.mapper.BoardEntityMapper;
import me.nettee.board.adapter.driven.persistence.entity.BoardEntity;
import me.nettee.board.adapter.driven.persistence.entity.QBoardEntity;
import me.nettee.board.application.domain.Board;
import me.nettee.board.application.domain.type.BoardStatus;
import me.nettee.board.application.port.BoardQueryPort;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.stereotype.Repository;
import me.nettee.board.application.model.BoardReadDetailModel;
import me.nettee.board.application.model.BoardReadSummaryModel;

import java.util.Optional;


@Repository
public class BoardQueryAdapter extends QuerydslRepositorySupport implements BoardQueryPort {
private final BoardEntityMapper boardEntityMapper;
QBoardEntity boardEntity = QBoardEntity.boardEntity;

public BoardQueryAdapter(BoardEntityMapper boardEntityMapper) {
super(BoardEntity.class);
this.boardEntityMapper = boardEntityMapper;
}

@Override
public Optional<BoardReadDetailModel> findById(Long id) {
return boardEntityMapper.toOptionalBoardReadDetailModel(
getQuerydsl().createQuery()
.select(boardEntity)
.from(boardEntity)
.where(
boardEntity.id.eq(id)
).fetchOne()
);
}

@Override
public Page<BoardReadDetailModel> findAll(Pageable pageable) {
// 기본 쿼리 생성
var query = getQuerydsl().createQuery()
.select(boardEntity)
.from(boardEntity)
.where();

// pageable 정렬 조건 적용
Copy link
Contributor

@github-insu github-insu Jan 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private method로 작성하는 방식도 생각해볼 수 있을 것 같습니다.
findyByStatusesList 메서드와 중복되는 부분인 것 같습니다. 이 부분을 "정렬 조건 적용"이라는 private method로 작성하고 findAll 메서드와 findByStatusesList 메서드에 사용하는 방식도
생각해볼 수 있을 것 같습니다.

pageable.getSort().forEach(order -> {
if (order.isAscending()) {
query.orderBy(boardEntity.createdAt.asc()); // createdAt 필드를 기준으로 오름차순 정렬
} else {
query.orderBy(boardEntity.createdAt.desc()); // createdAt 필드를 기준으로 내림차순 정렬
}
});

var result = query
.offset(pageable.getOffset()) // 현재 페이지의 오프셋 설정
.limit(pageable.getPageSize()) // 페이지 크기 설정
.fetch(); // 쿼리 실행

var totalCount = getQuerydsl().createQuery()
.select(boardEntity.count())
.from(boardEntity)
.where();

return PageableExecutionUtils.getPage(
result.stream().map(boardEntityMapper::toBoardReadDetailModel).toList(),
pageable,
totalCount::fetchOne
);
}

@Override
public Page<BoardReadSummaryModel> findByStatusesList(Pageable pageable, java.util.Set<me.nettee.board.application.domain.type.BoardStatus> statuses) {
// 기본 쿼리 생성
var query = getQuerydsl().createQuery()
.select(boardEntity)
.from(boardEntity)
.where(boardEntity.status.in(statuses));

// pageable 정렬 조건 적용
pageable.getSort().forEach(order -> {
if (order.isAscending()) {
query.orderBy(boardEntity.createdAt.asc()); // createdAt 필드를 기준으로 오름차순 정렬
} else {
query.orderBy(boardEntity.createdAt.desc()); // createdAt 필드를 기준으로 내림차순 정렬
}
});

var result = query
.offset(pageable.getOffset()) // 현재 페이지의 오프셋 설정
.limit(pageable.getPageSize()) // 페이지 크기 설정
.fetch(); // 쿼리 실행

var totalCount = getQuerydsl().createQuery()
.select(boardEntity.count())
.from(boardEntity)
.where(boardEntity.status.in(statuses));

return PageableExecutionUtils.getPage(
result.stream().map(boardEntityMapper::toBoardReadSummaryModel).toList(),
pageable,
totalCount::fetchOne
);
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 컨플릭트 해결을 응원하겠습니다.

원래 동일 driven 어댑터 내에서 공통으로 작업되는 파일들은 별도 브랜치 생성 또는 각 브랜치 간 병합으로
동일 driven 어댑터를 사용하는 여러 브랜치에서 작업을 공유해야 합니다.

서로 다른 브랜치가 동일한 이름으로 새 파일을 만들어서 비슷한 코드를 작성하면 많은 컨플릭트를 해결해야 할 수 있습니다.
(게다가 눈으로 보며 하나씩 코드를 살리며 작업해야 합니다.)

이번에는 그 파일의 개수와 규모가 조금 클 수 있어서 도움이 필요하다면
팀원들에게 이야기 부탁드립니다!

@silberbullet 님께서 도와주시기로 하셨지만, 컨디션이 좋지 않으셔서 다른 팀원들이 함께 참여하는 게 좋아 보입니다.

Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package me.nettee.board.adapter.driven.persistence.entity;

import jakarta.persistence.*;
import lombok.*;
Comment on lines +3 to +4
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 와일드카드(*) 임포트를 사용하지 않습니다.

구글의 Java 코드 스타일 가이드에서 다음 임포트 종류는 사용하지 않거나 제한적으로만 사용합니다.

  • 와일드카드 임포트 (*)
  • 정적 와일드카드 임포트 (import static ~.*)
  • 정적 임포트 (제한적으로 사용)

예를 들어, 유틸리티 함수 등 자주 사용되거나 팀에서 수용할 수 있는 정도에서는 정적 임포트를 사용합니다.

테스트 코드에서는 정적 임포트, 와일드카드 임포트, 정적 와일드카드 임포트를 모두 사용할 수 있습니다.

import me.nettee.board.application.domain.type.BoardStatus;
import org.hibernate.annotations.DynamicUpdate;

import java.time.Instant;

@Getter
@Builder(toBuilder = true)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@DynamicUpdate
@Entity(name = "board")
public class BoardEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;

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

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

@Enumerated(EnumType.STRING)
@Column(name = "status")
private BoardStatus status;

@Column(name = "created_at")
private Instant createdAt;

@Column(name = "updated_at")
private Instant updatedAt;

// 삭제
public void softDelete() {
this.updatedAt = Instant.now();
this.status = BoardStatus.REMOVED;
}
}
2 changes: 0 additions & 2 deletions src/main/java/me/nettee/board/application/domain/Board.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ public class Board {

private Instant updatedAt;

private Instant deletedAt;

@Builder(
builderClassName = "updateBoardBuilder",
builderMethodName = "prepareUpdate",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package me.nettee.board.application.exception;

import me.nettee.common.exeption.ErrorCode;
import org.springframework.http.HttpStatus;

public enum BoardCommandErrorCode implements ErrorCode {
// ⬇️ 인증 쪽 에러코드 제공받아 쓸 것이냐
// UNAUTHORIZED("로그인이 필요한 기능입니다.", HttpStatus.UNAUTHORIZED),
// ⬇️ 조회 쪽 에러코드 제공받아 쓸 것이냐, 아니면 Master DB 핸들 시 사용할 용도로 여기 따로 둘 거냐.
BOARD_NOT_FOUND("게시물을 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
BOARD_GONE("더 이상 존재하지 않는 게시물입니다.", HttpStatus.GONE),
BOARD_FORBIDDEN("권한이 없습니다.", HttpStatus.FORBIDDEN),
DEFAULT("게시물 조작 오류", HttpStatus.INTERNAL_SERVER_ERROR);

private final String message;
private final HttpStatus status;

BoardCommandErrorCode(String message, HttpStatus status) {
this.message = message;
this.status = status;
}

@Override
public String defaultMessage() {
return message;
}

@Override
public HttpStatus defaultHttpStatus() {
return status;
}

@Override
public BoardCommandException defaultException() {
return new BoardCommandException(this);
}

@Override
public BoardCommandException defaultException(Throwable cause) {
return new BoardCommandException(this, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package me.nettee.board.application.exception;

import me.nettee.common.exeption.CustomException;
import me.nettee.common.exeption.ErrorCode;

public class BoardCommandException extends CustomException {
// (intellij) Ctrl + O
// (eclipse or sts) Alt Shift S => generate constructors

public BoardCommandException(ErrorCode errorCode) {
super(errorCode);
}

public BoardCommandException(ErrorCode errorCode, Throwable cause) {
super(errorCode, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package me.nettee.board.application.exception;

import me.nettee.common.exeption.ErrorCode;
import org.springframework.http.HttpStatus;

public enum BoardQueryErrorCode implements ErrorCode {
// ⬇️ 인증 쪽 에러코드 제공받아 쓸 것이냐
// UNAUTHORIZED("로그인이 필요한 기능입니다.", HttpStatus.UNAUTHORIZED),
// ⬇️ 조회 쪽 에러코드 제공받아 쓸 것이냐, 아니면 Master DB 핸들 시 사용할 용도로 여기 따로 둘 거냐.
BOARD_NOT_FOUND("게시물을 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
BOARD_GONE("더 이상 존재하지 않는 게시물입니다.", HttpStatus.GONE),
BOARD_FORBIDDEN("권한이 없습니다.", HttpStatus.FORBIDDEN),
DEFAULT("게시물 조작 오류", HttpStatus.INTERNAL_SERVER_ERROR);

private final String message;
private final HttpStatus status;

BoardQueryErrorCode(String message, HttpStatus status) {
this.message = message;
this.status = status;
}

@Override
public String defaultMessage() {
return message;
}

@Override
public HttpStatus defaultHttpStatus() {
return status;
}

@Override
public BoardCommandException defaultException() {
return new BoardCommandException(this);
}

@Override
public BoardCommandException defaultException(Throwable cause) {
return new BoardCommandException(this, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package me.nettee.board.application.exception;

import me.nettee.common.exeption.CustomException;
import me.nettee.common.exeption.ErrorCode;

public class BoardQueryException extends CustomException {
public BoardQueryException(ErrorCode errorCode) {
super(errorCode);
}

public BoardQueryException(ErrorCode errorCode, Throwable cause) {
super(errorCode, cause);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package me.nettee.board.application.port;


import java.util.Optional;
import java.util.Set;
import me.nettee.board.application.domain.type.BoardStatus;
Expand All @@ -11,7 +12,7 @@
public interface BoardQueryPort {

Optional<BoardReadDetailModel> findById(Long id);

Page<BoardReadDetailModel> findAll(Pageable pageable);
Page<BoardReadSummaryModel> findByStatusesList(Pageable pageable, Set<BoardStatus> statuses);

}
20 changes: 20 additions & 0 deletions src/main/java/me/nettee/common/exeption/CustomException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package me.nettee.common.exeption;

public class CustomException extends RuntimeException {
protected ErrorCode errorCode;

public CustomException(ErrorCode errorCode) {
// ErrorCode의 기본 메시지를 RuntimeException에 전달하여 예외 메시지를 설정
super(errorCode.defaultMessage());
this.errorCode = errorCode;
}

public CustomException(ErrorCode errorCode, Throwable cause) {
super(errorCode.defaultMessage(), cause);
this.errorCode = errorCode;
}

public ErrorCode getErrorCode() {
return errorCode;
}
}
11 changes: 11 additions & 0 deletions src/main/java/me/nettee/common/exeption/ErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package me.nettee.common.exeption;

import org.springframework.http.HttpStatus;

public interface ErrorCode {
String name(); // enum default method
String defaultMessage();
HttpStatus defaultHttpStatus();
RuntimeException defaultException();
RuntimeException defaultException(Throwable cause);
}
Loading