개요
스프링 데이터 JPA를 쓰다 보면, 거의 무조건 엔티티를 저장하는 save 메서드를 사용하신 적이 있으실 것입니다.
그런데 save의 반환 타입으로는 어떤 것을 써야 할지 고민이 들었던 적이 있으신가요? 오늘은 어쩌면 무의식적으로 넘겨버리기만 했던 반환 타입에 대해 글을 써 보고자 합니다.
save 메서드 소개
우선, 스프링 데이터 JPA에 작성되어 있는 save 메서드는 아래 구조로 되어 있습니다.
@NoRepositoryBean
public interface CrudRepository<T, ID> extends Repository<T, ID> {
<S extends T> S save(S entity);
...
}
첫 번째로, JpaRepository<T, ID> (인터페이스)는 따라가다 보면 CrudRepository<T, ID> (인터페이스)를 상속받습니다.
그리고 이 JpaRepository<T, ID> 인터페이스는 JpaRepositoryImplementation<T, ID> 인터페이스가 상속받으며, 이것의 구현체로 SimpleJpaRepository<T, ID>가 있습니다.
@Repository
@Transactional(
readOnly = true
)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
...
@Transactional
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null");
if (this.entityInformation.isNew(entity)) {
this.entityManager.persist(entity);
return entity;
} else {
return this.entityManager.merge(entity);
}
}
...
}
구현체와 인터페이스를 보면, 메서드의 인자로 넘긴 S 타입을 반환함을 알 수 있습니다.
Repository의 반환 타입으로 가능한 것
이 글에서 사용할 Repository는 스프링 데이터 JPA를 그대로 사용하는 것이 아니라, 아래처럼 도메인에서 사용할 Repository 인터페이스를 만들고 구현체를 정의할 때 스프링 데이터 JPA를 필드로 가진 구조입니다.
게시글 (Board)에 대한 C/R 코드를 예로 들겠습니다!
BoardRepository 코드
public interface BoardRepository {
[Board | void] save(final Board board);
Optional<Board> findById(final Long id);
List<Board> findAll();
}
스프링 데이터 JPA 코드
public interface BoardJpaRepository extends JpaRepository<Board, Long> {
...
}
BoardJpaRepositoryImpl 코드 (구현체)
@RequiredArgsConstructor
@Repository
public class JpaBoardRepositoryImpl implements BoardRepository {
private final BoardJpaRepository boardJpaRepository;
@Override
public [Board | void] save(final Board board) {
...
}
@Override
public Optional<Board> findById(final Long id) {
return boardJpaRepository.findById(id);
}
@Override
public List<Board> findAll() {
return boardJpaRepository.findAll();
}
}
위의 코드를 참고하면, 반환 타입으로 가능한 것은 크게 엔티티를 반환하느냐, void를 반환하느냐로 나눌 수 있습니다.
각 방식을 선택했을 때 어떠한 특징이 있는지 알아보겠습니다!
1. 메서드의 인자로 받은 엔티티 타입 자체를 반환하는 방법 (영속화된 엔티티 반환)
의도
스프링 데이터 JPA의 반환 타입을 그대로 계승하기 위함입니다.
장점
- 영속화된 Entity를 반환하기에 Service에서도 Entity, Long (= id) 타입을 반환할 때 id가 null이 아닙니다.
- Service에서 반환할 타입에 대한 제약이 없습니다. (Service에서 Controller에게 어떤 값을 제공하든지 문제가 없습니다.)
- 테스트 시 id 값을 꺼낼 수 있으므로 테스트 작성이 편리합니다.
단점
- void를 반환할 때 보다 책임 (영속화)에만 집중한다는 의도가 흐려집니다.
예시 - 구현 코드
도메인 코드는 아래처럼 되어 있습니다. (최소한의 코드 - 리포지토리가 void 타입일 때에도 동일)
// import 표현은 생략
@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String content;
public Board(final String title, final String content) {
this.title = title;
this.content = content;
}
}
컨트롤러 코드는 아래처럼 되어 있습니다.
// import 표현은 생략
@RequiredArgsConstructor
@RequestMapping("/boards")
@RestController
public class BoardController {
private final BoardService boardService;
@PostMapping
public ResponseEntity<BoardWriteResponse> write(@RequestBody @Valid final BoardWriteRequest request) {
Board writeBoard = boardService.write(request);
BoardWriteResponse response = BoardWriteResponse.from(writeBoard);
return ResponseEntity.ok(response);
}
@GetMapping("/{id}")
public ResponseEntity<BoardFindResponse> findBoardById(@PathVariable final Long id) {
Board findBoard = boardService.findById(id);
BoardFindResponse response = BoardFindResponse.from(findBoard);
return ResponseEntity.ok(response);
}
@GetMapping
public ResponseEntity<List<BoardFindResponse>> findAllBoards() {
List<BoardFindResponse> responses = boardService.findAllBoards()
.stream()
.map(BoardFindResponse::from)
.toList();
return ResponseEntity.ok(responses);
}
}
서비스 코드는 아래와 같습니다. 서비스에서는 글 저장 시 엔티티를 반환하는 것으로 가정하겠습니다.
코드를 보면 서비스에서 영속화된 엔티티를 반환하거나 영속화된 id를 반환하는 데 제약이 없음을 알 수 있습니다.
만약 리포지토리가 void 타입이라면 서비스에서 엔티티를 반환하거나 id를 반환하고자 할 때에는 id가 저장되지 않은 것이 컨트롤러에 전달될 것입니다.
// import 표현은 생략
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class BoardService {
private final BoardRepository boardRepository;
@Transactional
public Board write(final BoardWriteRequest request) {
Board newBoard = new Board(request.title(), request.content());
return boardRepository.save(newBoard); // 서비스에서 영속화된 엔티티를 반환하거나 영속화된 id를 반환하는 데 제약이 없습니다!
}
public Board findById(final Long id) {
return boardRepository.findById(id)
.orElseThrow(BoardNotFoundException::new);
}
public List<Board> findAllBoards() {
return boardRepository.findAll();
}
}
예시 - 테스트 코드
서비스 코드에서 제약이 없음을 알아봤으니, 테스트 코드는 어떻게 작성할지 보겠습니다. (테스트 격리를 위한 어노테이션들을 작성한 게 있는데, 이것에 대해서는 추후 작성하겠습니다!)
아래는 Repository에 대한 테스트 코드입니다.
// import 표현은 생략
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@SuppressWarnings("NonAsciiCharacters")
@DataJpaTest
@CleanDatabase // 테스트 격리 어노테이션
public class JpaBoardRepositoryTest {
@Autowired
private BoardJpaRepository boardJpaRepository;
@Test
void 게시글을_저장한다() {
// given
Board writeBoard = BoardFixtures.게시글_id_없음(); // 서비스에서 생성한 Board는 id가 없습니다.
Long expectedId = 1L;
// when
Board saveBoard = boardJpaRepository.save(writeBoard);
// then
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(saveBoard.getId()).isEqualTo(expectedId);
softly.assertThat(saveBoard).usingRecursiveComparison()
.ignoringFields("id")
.isEqualTo(writeBoard);
});
}
...
}
리포지토리에서 반환한 Board는 영속화된 게시글이기 때문에, 기존 writeBoard에서 id가 작성되어 있지 않더라도 saveBoard의 id 값이 expectedId와 같음을 검증해 볼 수 있습니다.
JPA 환경에서의 테스트이기에, Repository에서 void로 반환하더라도 위 테스트는 통과하게 됩니다. EntityManager가 persist 함에 따라 영속성 컨텍스트에 저장되어 id를 할당받을 수 있기 때문입니다.
writeBoard와는 id가 null/1L로 다르기 때문에 id를 제외한 나머지 속성에 대해서 같은지 비교함으로써 검증해야 합니다.
Service에 대한 테스트는 아래와 같습니다.
// import 표현은 생략
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@SuppressWarnings("NonAsciiCharacters")
public class BoardServiceTest {
private BoardService boardService;
private BoardRepository boardRepository;
@BeforeEach
void init() {
boardRepository = new BoardFakeRepository(); // HashMap<Long, Board>를 가진 테스트 리포지토리
boardService = new BoardService(boardRepository);
}
@Test
void 게시글을_저장한다() {
// given
String title = "default title";
String content = "default content";
BoardWriteRequest request = new BoardWriteRequest(title, content);
Long expectedId = 1L;
// when
Board writeBoard = boardService.write(request);
// then
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(writeBoard.getId()).isEqualTo(expectedId);
softly.assertThat(writeBoard.getTitle()).isEqualTo(title);
softly.assertThat(writeBoard.getContent()).isEqualTo(content);
});
}
...
}
리포지토리에서와 마찬가지로 id에 대한 실제 값을 검증할 수 있습니다.
2. void를 반환하는 방법
의도
Repository가 데이터베이스에 영속화하는 것에만 집중시키기 위함입니다.
장점
Repository가 가진 책임 (영속화)에만 집중할 수 있습니다.
단점
- Service에서 Entity, Long (= id) 타입을 반환시킬 경우 id가 null로 됩니다. (서비스 테스트 한정, 실제 프로덕션에서는 JPA를 활용할 경우 id를 받을 수 있습니다.)
- Service에서 void로 반환할 수는 있으나, 그렇게 되면 Controller에서 저장된 Entity에 대한 아무 정보도 얻을 수 없습니다. (이는 Service에서 void를 반환할 때의 단점이기도 합니다.)
- 테스트 시 불편함이 따릅니다. id값을 꺼내면 null이 되기 때문입니다. 따라서 findAll(), verify() 등으로 검증을 해야 합니다.
예시 - 구현 코드
도메인, 컨트롤러 코드는 변함이 없습니다.
서비스 코드는 아래처럼 작성됩니다.
// import 표현은 생략
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class BoardService {
private final BoardRepository boardRepository;
@Transactional
public Board write(final BoardWriteRequest request) {
Board newBoard = new Board(request.title(), request.content());
boardRepository.save(newBoard);
return newBoard; // JPA에서는 save 시 persist를 한 뒤에 id가 저장되기 때문에 save 이후 newBoard를 전달하면 id가 있습니다.
}
...
}
예시 - 테스트 코드
앞선 리포지토리에서의 테스트 (JpaBoardRepositoryTest)에서 작성하였듯, Repository에서 save의 반환 타입을 엔티티로 하든지 void로 하든지에 상관없이 Repository에 대한 테스트는 문제없이 통과하게 됩니다. 이는 JPA가 가진 특성 (save 시 persist/merge가 발생하고, 이후 영속성 컨텍스트에 id를 가진 채로 보관) 때문입니다.
문제는 서비스 테스트 코드입니다. 서비스 테스트 코드는 JPA를 사용하지 않는, HashMap을 사용하는 BoardFakeRepository를 사용합니다. 그렇기 때문에 영속성 컨텍스트의 도움을 받지 못하며, 아래의 BoardFakeRepository를 보면 인자로 받은 Board의 id에 대해서는 id 값이 저장되지 않는 점을 알 수 있습니다. (그렇다고 id를 set 하기 위한 Setter 메서드를 만든다면, 테스트를 위해 메서드를 추가하는 것이니 이는 지양해야 합니다.)
// import 표현은 생략
// 서비스의 기능 검증에만 집중하기 위해 JPA를 사용하지 않는 임의의 테스트 리포지토리를 사용합니다.
public class BoardFakeRepository implements BoardRepository {
private final static HashMap<Long, Board> store = new HashMap<>();
private Long id = 1L;
@Override
// 만약 Board를 리턴했다면 id가 저장된 newBoard를 리턴할 것입니다.
public void save(final Board board) {
Board newBoard = Board.builder()
.id(id)
.title(board.getTitle())
.content(board.getContent())
.build();
store.put(id, newBoard);
id++;
}
...
}
그래서 아래처럼 예외가 발생합니다.
findAll로 저장되었는지 검증함으로써 우회적으로 확인할 수 있습니다.
@Test
void 게시글을_저장한다() {
// given
String title = "default title";
String content = "default content";
BoardWriteRequest request = new BoardWriteRequest(title, content);
// Long expectedId = 1L;
int expectedSize = 1;
// when
Board writeBoard = boardService.write(request);
// then
assertSoftly(softly -> {
// softly.assertThat(writeBoard.getId()).isEqualTo(expectedId);
softly.assertThat(boardService.findAllBoards()).hasSize(expectedSize);
softly.assertThat(writeBoard.getTitle()).isEqualTo(title);
softly.assertThat(writeBoard.getContent()).isEqualTo(content);
});
}
...
}
추가: Service에서 저장 시 반환 타입
Service에서도 반환 타입에 대한 고민이 생깁니다. 저장된 (영속화된) 엔티티를 반환해야 할까요, 아니면 id (Long) 타입을 반환하는 게 좋을까요? 이 또한 각기 장단점이 있습니다. (어떤 분들은 DTO로 반환하는 방식으로 사용하시기도 하는데, DTO를 반환하는 방식은 결국 표현 계층에 대해 의존되므로 적합하지 않다는 생각을 가지고 있습니다. 따라서 엔티티/Long 반환 방식의 차이에 대해서만 작성하겠습니다.)
엔티티를 반환하는 경우
장점
- 표현 영역에서 id 말고도 다른 값 (제목, 글 등)을 필요로 할 경우 엔티티에서 이끌어낼 수 있습니다.
단점
- 명령과 조회의 분리 (아래 원칙)의 의도에 맞지 않습니다.
id (Long)를 반환하는 경우
장점
- 명령과 조회의 분리 (CQRS: Command and Query Responsibility Segregation) 원칙을 지키는 데 적합합니다. 조회를 위한 최소한의 정보만 제공할 수 있습니다.
단점
- 서비스에서 id만을 반환한다면, 표현 영역 (컨트롤러) 또한 id만을 제공하게 됩니다.
결론
두 방식 모두 가능한 방식이고, 그렇기 때문에 정확히 어떤 것이 정답이다!라는 것은 내리기 힘든 문제 같습니다. (원래 개발은 명확히 정해진 정답이 잘 존재하지 않기도 하죠..)
개인적으로는 책임의 영역을 조금 흐릴 수는 있어도 테스트에도 적합한 엔티티를 반환하는 방법이 더 마음에 드는데, 이러한 두 방식의 차이점을 근거로 팀원 분들과 논의해 가며 실제 프로젝트에서는 어떤 방식으로 할지 결정해야 할 것 같습니다.
서비스에서의 반환 방식 또한 논의가 필요한 부분입니다. 저장된 후 API로 응답할 때 id만 있어도 괜찮을지, 아니면 부가적인 속성 (게시글의 경우 제목, 글 등)이 더 필요한지에 따라 달라질 것 같습니다.
'🤔 고민점' 카테고리의 다른 글
getter와 setter는 어디에 두는 게 좋을까? (1) | 2023.11.15 |
---|