우선 API를 만들기 위해서는 총 3개의 클래스가 필요하다.
Dto
: Request 데이터 수신Controller
: API 요청 수신Service
: 트랜잭션, 도메인 기능 간 순서 보장여기서 주의할 점은 Service
는 비지니스 로직을 처리하지 않는다는 것이다. 즉, 트랜잭션, 도메인 간 순서 보장의 역할만 수행한다.
그렇다면 비지니스 로직은 어디에서 처리해야 할까?
@Controller
), JSP, Freemarker 등@Filter
), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice
) 등@Service
에 사용되는 서비스 영역@Transactional
이 사용되어야 하는 영역@Entity
가 사용된 영역잠시 Spring 웹 계층에 대해 살펴보았다. 이 중 비지니스 처리를 담당하는 곳은 어디일까? 바로 Domain이다.
왜 Service에서 처리하지 않고 Domain에서 처리하는 것일까?
사실, 기존에는 Service
가 비지니스 처리를 담당했었다. 이 방식을 트랜잭션 스크립트라고 하는데, 모든 로직을 서비스 클래스 내부에서 처리한다. 이 방식은 구현이 매우 쉽다는 것이 최대 장점이다. 그 이유는 구현 방법이 단순하기 때문이다.
그러나 이 방식은 비즈니스 로직이 복잡해지면 난잡한 코드를 만들게 된다. 애초에 도메인에 대한 분석 및 설계 개념이 약하기 때문에 코드의 중복 발생을 막기 어려워진다. 또한, 이 방식을 사용 시 쉬운 개발에 익숙해지므로, 공통된 코드를 공통 모듈로 분리하지 않고 복사&붙이기 방식으로 중복 코드를 만드는 유혹에 빠지기 쉽다.
반면에 도메인 모델은 객체 지향에 기반한 재사용성, 확장성, 그리고 편리한 유지 보수 등의 장점이 있다. 일단 도메인 모델을 구축하면 언제든지 재사용 가능하다. 또한, 상속/인터페이스, 더 나아가 컴포넌트 개념을 바탕으로 도메인 모델을 개발하게 되면 무한한 확장성을 갖게 된다. 또한 이런 점들은 빠른 개발에 일조한다.
이러한 이유 때문에 도메인에서 비즈니스 로직을 처리하는 방식을 택한 것이다.
궁금증도 해결했으니 이제 CRUD 기능을 만들어보자. 이번 시간에는 등록(Create)과 수정(Update)을 구현해볼 것이다.
@RequiredArgsConstructor
@RestController
public class BoardController {
private final BoardService boardService;
@PostMapping("/board")
public Long create(@RequestBody BoardCreateRequestDto requestDto) {
return boardService.create(requestDto);
}
}
@RequiredArgsConstructor
@Service
public class BoardService {
private final BoardRepository boardRepository;
@Transactional
public Long create(BoardCreateRequestDto requestDto) {
return boardRepository.save(requestDto.toEntity()).getId();
}
Spring에서는 Bean을 주입받는 방식들이 다음과 같이 존재한다.
◾ @Autowired ◾ setter ◾ 생성자
여기서 @Autowired
는 사용법이 제일 간단하나 권장되지 않는다. 대신 가장 권장되는 방식은 생성자 주입인데, 이유는 다음과 같다.
그렇다면 생성자는 어떻게 생성할까?
바로 롬복의 @RequiredArgsConstructor
로 해결할 수 있다. 이 어노테이션은 final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해준다.
여기서 생성자 대신 롬복 어노테이션을 사용한 이유는 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위함이다.
자, 이제 Controller와 Service에서 사용할 DTO 클래스를 생성해보자.
@Getter
@NoArgsConstructor
public class BoardCreateRequestDto {
private Member member;
private String title;
private String content;
@Builder
public BoardCreateRequestDto(Member member, String title, String content) {
this.member = member;
this.title = title;
this.content = content;
}
public Board toEntity() {
return Board.builder()
.member(member)
.title(title)
.content(content)
.build();
}
}
위의 코드를 보면 Entity 클래스와 거의 유사하다는 것을 알 수 있다. 그럼에도 불구하고 DTO 클래스를 추가로 생성한 이유는 다음과 같다.
Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스이며, 이를 기준으로
이 때문에 Entity 클래스가 변경되면 여러 클래스에 영향을 끼치게 된다.
사소한 기능 변경들은 자주 일어나는데, 이를 위해 Entity 클래스를 변경하는 것은 문고리를 교체하기 위해 문 자체를 뜯어고치는 것과 같다.
반면에 Request와 Response용 DTO는 View를 위한 클래스로, 자주 변경되어도 데이터베이스 자체에 영향을 주지 않는다.
따라서 Entity 클래스와 Controller에서 쓸 DTO는 꼭 분리해서 사용해야 한다.
@RequiredArgsConstructor
@RestController
public class BoardController {
...
@PutMapping("/board/{id}")
public Long update(@PathVariable Long id, @RequestBody BoardUpdateRequestDto requestDto) {
return boardService.update(id, requestDto);
}
}
@Getter
@NoArgsConstructor
public class BoardUpdateRequestDto {
private String title;
private String content;
@Builder
public BoardUpdateRequestDto(String title, String content) {
this.title = title;
this.content = content;
}
}
public class Board {
...
public void update(String title, String content) {
this.title = title;
this.content = content;
}
@RequiredArgsConstructor
@Service
public class BoardService {
...
@Transactional
public Long update(Long id, BoardUpdateRequestDto requestDto) {
Board board = boardRepository.findById(id)
.orElseThrow(() -> new
IllegalArgumentException("해당 게시글이 존재하지 않습니다."));
board.update(requestDto.getTitle(),
requestDto.getContent());
return id;
}
위의 코드를 살펴보면 update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없다. 그럼에도 불구하고 update가 가능한 이유는 다음과 같다.
- JPA의 영속성 컨텍스트
- 영속성 컨텍스트 = 엔티티를 영구 저장하는 환경
- JPA의 엔티티 매니저(EntityManager)가 활성화된 상태로 트랜잭션 내에서 데이터베이스의 데이터를 가져옴 → 영속성 컨텍스트 유지 상태
영속성 컨텍스트가 유지된 상태에서 해당 데이터의 값을 변경할 경우, 트랜잭션이 종료되는 시점에 해당 테이블에 변경분을 반영한다.
그 결과 Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없는 것이다.
📖 참고
@Builder 어노테이션 사용한 Board 클래스에서 Board.builder() 이렇게 불러왔을때 Non-static method 'builder()' cannot be referenced from a static context 오류뜨는데 어떻게 하신건가요