Skip to content

Board Service 구현 및 단위 테스트 #19

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 12 commits into from
Jan 29, 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
Copy link
Member

Choose a reason for hiding this comment

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

💊 클래스 이름을 BoardDetailReadModel 또는 BoardDetail로 제안드립니다.

클래스 이름에서 변하는 부분과 반복되는 부분의 배치를 다음 패턴으로 권하고 있습니다.

  • {바뀌는 부분} + {바뀌지 않는 부분}
    • ex) BoardDetail + ReadModel
  • {바뀌는 부분 1} + {바뀌지 않는 부분} + {바뀌는 부분 2}
    • ex) Board + ReadModel + Detail

지금 클래스 이름은 {바뀌는 부분} + {바뀌지 않는 부분} + {바뀌는 부분} + {바뀌지 않는 부분}으로, 클래스 이름을 인식할 때 다소간 혼란이 될 수도 있다고 생각합니다.

또는 Summary, Detail 등 자주 사용되는 조회 모델에서 -ReadModel 접두사 제거를 고려할 수 있습니다.

SummaryDetail은 자주 사용되는 관례로 인식하여 이름에서 조회 모델임을 알아볼 수도 있다고 생각합니다.
따라서 Application 레이어에서는 매번 이름에 -ReadModel 접두사를 붙이지 않는 것을 고려할 수 있습니다.

단, 다른 도메인 모델이 -Summary, -Detail로 명명되는 케이스와 네이밍에서 중복될 여지를 고려해야 합니다.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package me.nettee.board.application.model;

import java.time.Instant;
import me.nettee.board.application.domain.type.BoardStatus;

public record BoardReadDetailModel(
Long id,
String title,
String content,
BoardStatus status,
Instant createdAt,
Instant updatedAt,
Instant deletedAt
Copy link
Member

@merge-simpson merge-simpson Jan 25, 2025

Choose a reason for hiding this comment

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

deletedAt 필드는 제외하기로 하였습니다.

조회 모델에 추가하여도 무시됩니다.
따라서 오해가 없도록 삭제하는 것이 좋습니다.

) {}
Copy link
Member

Choose a reason for hiding this comment

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

status 필드는 목록 조회에서도 필요해 보입니다.

목록 조회에 사용되는 Summarystatus 필드를 포함하는 것이 좋아 보입니다.

Copy link
Member

Choose a reason for hiding this comment

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

💊 클래스 이름을 BoardSummary 또는 BoardSummaryReadModel로 제안드립니다.

이유는 위 BoardDetail 명명 제안의 이유와 같습니다.

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package me.nettee.board.application.model;

import java.time.Instant;

public record BoardReadSummaryModel(
Long id,
String title,
String content,
Instant createdAt,
Instant updatedAt
){}
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package me.nettee.board.application.port;

import java.util.Optional;
import me.nettee.board.application.domain.Board;

public interface BoardCommandPort {

Optional<Board> findById(Long id);

Board create(Board board);

Board update(Board board);

void delete(Long id);

void delete(Board id);
Copy link
Member

@merge-simpson merge-simpson Jan 25, 2025

Choose a reason for hiding this comment

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

💬 아이디 타입 대신 도메인 모델 객체를 넘기는 이유가 설명되면 좋겠습니다.

기존 라이브 코드리뷰 코멘트를 참고하여 배경이 되는 다음 내용을 숙지하시고, 혹시나 이 외에 추가적인 이유로 Board 객체를 넘겨야 하는지 살펴 보면 좋겠습니다!

  • Adapter에서는 비즈니스로직의 '의도'를 담지 않습니다. (Board 객체가 ~할 때만 삭제할 수 있는 등)
  • Adapter에 삭제를 명령하면, 어댑터는 순수하게 삭제를 수행해야 합니다.
  • Soft Delete는 Adapter에서 수행하지 않고, delete 메서드가 실제 하드 딜리트를 뜻해야 합니다.
  • Soft Delete용 메서드는 새로 추가되어야 합니다. (status 업데이트용 메서드)

관련 논의

같은 이슈의 이 코멘트에 담겨 있습니다.

}
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
package me.nettee.board.application.port;

import me.nettee.board.application.domain.Board;
import java.util.Optional;
import java.util.Set;
import me.nettee.board.application.domain.type.BoardStatus;
import me.nettee.board.application.model.BoardReadDetailModel;
import me.nettee.board.application.model.BoardReadSummaryModel;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

import java.util.Optional;

public interface BoardQueryPort {

Page<Board> findAll(Pageable pageable);
Optional<BoardReadDetailModel> findById(Long id);

Optional<Board> findById(Long id);
Page<BoardReadSummaryModel> findByStatusesList(Pageable pageable, Set<BoardStatus> statuses);
Copy link
Member

Choose a reason for hiding this comment

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

💊 일반적인 파라미터 입력 순서에 맞게 statuses, pageable 순서가 어떨까 합니다.

Port는 반드시 Spring Data JPA의 스펙을 준수할 필요가 없지만, 일반적으로 사람들에게 익숙한 관례에 맞게 순서를 바꾸는 것이 좋다고 생각합니다.

그 외 메서드 선언 순서, read model 등의 적용은 잘되어 있어서 잘 신경써 주신 것 같습니다! 👍


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package me.nettee.board.application.service;

import lombok.RequiredArgsConstructor;
import me.nettee.board.application.domain.Board;
import me.nettee.board.application.model.BoardReadDetailModel;
import me.nettee.board.application.port.BoardCommandPort;
import me.nettee.board.application.port.BoardQueryPort;
import me.nettee.board.application.usecase.BoardCreateUseCase;
import me.nettee.board.application.usecase.BoardDeleteUseCase;
import me.nettee.board.application.usecase.BoardUpdateUseCase;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class BoardCommandService implements BoardCreateUseCase, BoardUpdateUseCase, BoardDeleteUseCase {

private final BoardCommandPort boardCommandPort;

public Board createBoard(Board board) {
return boardCommandPort.create(board);
}

public Board updateBoard(Board board) {
return boardCommandPort.update(board);
}

public void deleteBoard(Long id) {
Board board = boardCommandPort.findById(id).orElseThrow(
() -> new IllegalArgumentException("게시글을 찾을 수 없습니다."));

board.softDelete();

boardCommandPort.delete(board);
Copy link
Member

@merge-simpson merge-simpson Jan 25, 2025

Choose a reason for hiding this comment

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

❗ Soft Delete로 변경하는 것이 좋습니다.

포트 및 어댑터의 delete 함수는 실제로 함수의 이름이 지시하는 대로, '삭제' 명령을 수행하기로 결정하였습니다. 즉, Repository Port 및 Adapter의 delete 함수는 Hard Delete를 지시합니다. 마치 JPA Repository의 delete 함수와 동일한 동작으로 이해하고 사용해야 합니다. 이름이 그렇게 시키고 있기 때문이며, 애플리케이션의 기획을 '어댑터에서 자의적으로 해석하여' 소프트 딜리트로 처리해서는 안 된다는 관점입니다.

'기획상' 또는 '서버의 정책상' 소프트 딜리트를 수행할지 여부는 애플리케이션 계층에서 결정하는 것이 좋다는 논의가 있었습니다.

라이브 코드리뷰 내용을 참고하세요!

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package me.nettee.board.application.service;

import java.util.Set;
import lombok.RequiredArgsConstructor;
import me.nettee.board.application.domain.type.BoardStatus;
import me.nettee.board.application.model.BoardReadDetailModel;
import me.nettee.board.application.model.BoardReadSummaryModel;
import me.nettee.board.application.port.BoardQueryPort;
import me.nettee.board.application.usecase.BoardReadByStatusesUseCase;
import me.nettee.board.application.usecase.BoardReadUseCase;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class BoardQueryService implements BoardReadUseCase, BoardReadByStatusesUseCase {

private final BoardQueryPort boardQueryPort;

@Override
public BoardReadDetailModel getBoard(Long id) {
return boardQueryPort.findById(id).orElseThrow(
() -> new IllegalArgumentException("게시글을 찾을 수 없습니다."));
}

@Override
public Page<BoardReadSummaryModel> findByStatuses(Set<BoardStatus> statuses, Pageable pageable) {
return boardQueryPort.findByStatusesList(pageable, statuses);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
public interface BoardCreateUseCase {

Board createBoard(Board board);

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
public interface BoardDeleteUseCase {

void deleteBoard(Long id);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package me.nettee.board.application.usecase;

import java.util.Set;
import me.nettee.board.application.domain.type.BoardStatus;
import me.nettee.board.application.model.BoardReadSummaryModel;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface BoardReadByStatusesUseCase {

Page<BoardReadSummaryModel> findByStatuses(Set<BoardStatus> statuses, Pageable pageable);

}

Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
package me.nettee.board.application.usecase;

import me.nettee.board.application.domain.Board;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import me.nettee.board.application.model.BoardReadDetailModel;

public interface BoardReadUseCase {

Board getBoard(Long id);
BoardReadDetailModel getBoard(Long id);

Page<Board> findGeneralBy(Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
public interface BoardUpdateUseCase {

Board updateBoard(Board board);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package me.nettee.board.application.service

import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.should
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import me.nettee.board.application.domain.Board
import me.nettee.board.application.port.BoardCommandPort

class BoardCommandServiceTest : FreeSpec({

val boardCommandPort = mockk<BoardCommandPort>()
val boardCommandService = BoardCommandService(boardCommandPort)

"BoardCommandService" - {
"create" {
// given
var board = Board()
every {
boardCommandPort.create(board)
} returns board

// when
val result = boardCommandService.createBoard(board)

// then
result shouldBe board
verify { boardCommandPort.create(board) }
}

"update" {
// given
val board = Board()
every {
boardCommandPort.update(board)
} returns board

// when
val result = boardCommandService.updateBoard(board)

// then
result shouldBe board
verify { boardCommandPort.update(board) }
}

"delete" {
// given
val boardId = 1L
every {
boardCommandPort.delete(boardId)
} returns Unit

// when
boardCommandService.deleteBoard(boardId)

// then
verify { boardCommandPort.delete(boardId) }
}
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package me.nettee.board.application.service

import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
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.PageImpl
import org.springframework.data.domain.PageRequest
import java.util.*

class BoardQueryServiceTest : FreeSpec({

val boardQueryPort = mockk<BoardQueryPort>() // mocking
val boardQueryService = BoardQueryService(boardQueryPort) // 주입

"BoardQueryService" - {
"findById" - {
"존재하는 ID로 조회한다." {
// given
val boardId = 1L
val expectedBoard = Board.builder().id(boardId).title("test title").build()
every {
boardQueryPort.findById(boardId)
} returns Optional.of(expectedBoard)

// when
val result = boardQueryService.getBoard(boardId)

// then
result shouldBe expectedBoard
verify { boardQueryPort.findById(boardId) }
}

"존재하지 않는 ID로 예외를 발생시킨다." {
// given
val boardId = 1L
every {
boardQueryPort.findById(boardId)
} returns Optional.empty()

// when
val result = runCatching {
boardQueryService.getBoard(boardId) // Optional이 비어있을 때, orElseThrow()에서 예외가 발생한다.
}

// then
result.isFailure shouldBe true
verify { boardQueryPort.findById(boardId) }
}
}

"findByStatuses" - {
"상태 목록으로 조회 시, 페이징 조회한다." {
// given
val statuses = listOf(BoardStatus.ACTIVE, BoardStatus.SUSPENDED)
val pageable = PageRequest.of(0, 10)
val boards = listOf(
Board.builder().id(1L).title("Active Board").status(BoardStatus.ACTIVE).build(),
Board.builder().id(2L).title("Suspended Board").status(BoardStatus.SUSPENDED).build()
)
val expectedPage = PageImpl(boards, pageable, boards.size.toLong())
every {
boardQueryPort.findByStatusesList(pageable, statuses)
} returns expectedPage

// when
val result = boardQueryService.findByStatuses(pageable, statuses)

// then
result shouldBe expectedPage
verify { boardQueryPort.findByStatusesList(pageable, statuses) }
}

"빈 상태 목록으로 조회 시, 빈 페이지를 조회한다." {
// given
val statuses = emptyList<BoardStatus>()
val pageable = PageRequest.of(0, 10)
val boards: List<Board> = emptyList()
val expectedPage = PageImpl(boards, pageable, 0)
every {
boardQueryPort.findByStatusesList(pageable, statuses)
} returns expectedPage

// when
val result = boardQueryService.findByStatuses(pageable, statuses)

// then
result shouldBe expectedPage
verify { boardQueryPort.findByStatusesList(pageable, statuses) }
}
}
}
})