Spring/그 외

Dto, Controller, Service

ummchicken 2023. 1. 7. 18:52

출처 : 이동욱 개발자님 [스프링 부트와 AWS로 혼자 구현하는 웹 서비스]

 

 

내가 보려고 쓰는 Spring 프로젝트 구조

 

 


 

 

모든 응답 Dto는 DTO 패키지에 추가

 

 

등록 / 수정 / 조회 API 만들기

 

API 를 만들기 위해 총 3개의 클래스가 필요하다.

  1. Request 데이터를 받을 Dto
  2. API 요청을 받을 Controller
  3. 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service

 

 

🚨 오해

Servuce에서 비즈니스 로직을 처리해야 한다.

 

 

하지만, 전혀 그렇지 않다.

 

그럼?

 

💡 Service는 트랜잭션, 도메인 간 순서 보장의 역할만 한다.

 

 

그럼 비즈니스 로직은 누가 처리하나?

 

 

Spring 웹 계층

 

 

간단히 각 영역을 소개하자면 다음과 같다.

 

 

Web Layer
흔히 사용하는 컨트롤러(@Controller)와 JSP / Freemarker 등의 뷰 템플릿 영역이다.
이외에도 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice) 등 
외부 요청과 응답에 대한 전반적인 영역을 이야기한다.

 

Service Layer
@Service에 사용되는 서비스 영역이다.
일반적으로 Controller와 Dao의 중간 영역에서 사용된다.
@Transactional이 사용되어야 하는 영역이기도 하다.

 

Repository Layer
Database와 같이 데이터 저장소에 접근하는 영역이다.
Dao(Data Access Object) 영역으로 이해하면 쉽다.

 

Dtos
Dto(Data Transfer Object)는 계층 간에 데이터 교환을 위한 객체이다.
예를 들어, 
뷰 템플릿 엔진에서 사용될 객체나 
Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기한다.

 

Damain Model
도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 
공유할 수 있도록 단순화 시킨 것
이를테면 택시 앱이라고 하면 
배차, 탑승, 요금 등이 모두 도메인이 될 수 있다.
@Entity가 사용된 영역 역시 도메인 모델이라고 이해하면 된다.
다만, 무조건 데이터베이스의 테이블과 관계가 있어야만 하는 것은 아니다.
VO처럼 값 객체들도 이 영역에 해당하기 때문이다.

 

 

 

Web(Controller), Service, Repository, Dto, Domain, 

이 5가지 레이어에서 비지니스 처리를 담당해야 할 곳은 어딜까?

→ 💡 바로 Domain이다!

 

 

 

만약, 기존에 서비스로 처리하던 방식으로 한다면?

모든 로직이 서비스 클래스 내부에서 처리 된다면?

→ 서비스 계층이 무의미하며, 

객체란 단순히 데이터 덩어리 역할만 하게 된다.

 

 

 

반면, 도메인 모델에서 처리할 경우

@Transactional
public Order cancelOrder(int orderId) {
	
    // 1)
    Order order = orderRepository.findById(orderId);
    Billing billing = billingRepository.findbyOrderId(orderId);
    Delivery delivery = deliveryRepository.findByOrderId(orderId);
    
    // 2 ~ 3)
    delivery.cancel();
    
    // 4)
    order.cancel();
    billing.cancel();
    
    return order;
}


/**
* 1) 데이터베이스로부터 
* 주문정보(Orders), 결제정보(Billing), 배송정보(Delivery) 조회
* 
* 2) 배송 취소를 해야 하는지 확인
* 
* 3) 배송 중이라면, 배송 취소로 변경
* 
* 4) 각 테이블에 취소 상태 Update
*/
order, billing, delivery가 각자 본인의 취소 이벤트 처리를 하며, 
서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해 준다.

 

 

 

 

 

[PostsApiContoller.java]

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;

    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto) {
        return postsService.save(requestDto);
    }
}
💡 @Autowired가 없는 이유?

스프링에선 Bean을 주입받는 방식들은 다음과 같다.
1. @Autowired
2. setter
3. 생성자

이 중 가장 권장하는 방식이 생성자로 주입받는 방식이다.
(@Autowired는 권장하지 않음)

즉, 생성자로 Bean 객체를 받도록 하면 @Autowired와 동일한 효과를 볼 수 있다.

그럼 생성자는 어디 있을까?
💡 @RequiredArgsConstructor에서 해결해 준다.
final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해준다.

 

 

 

[PostsService.java]

@RequiredArgsConstructor
@Service
public class PostsService {

    private final PostsRepository postsRepository;

    @Transactional
    public Long save(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }
}

 

 

 

이제는 Controller와 Service에서 사용할 Dto 클래스를 생성해보자.

 

[PostsSaveRequestDto.java]

@Getter
@NoArgsConstructor
public class PostsSaveRequestDto {

    private String title;
    private String content;
    private String author;

    @Builder
    public PostsSaveRequestDto(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }

    public Posts toEntity() {
        return Posts.builder()
                .title(title)
                .content(content)
                .author(author)
                .build();
    }
}
💡 여기서 Entity 클래스와 거의 유사한 형태임에도
Dto 클래스를 추가로 생성했다.

하지만, 절대로 Entity 클래스를 Request / Response 클래스로 사용해서는 안 된다.

Entity 클래스가 변경되면 여러 클래스에 영향을 끼치지만, 
Request와 Response용 Dto는 View를 위한 클래스라 자주 변경이 필요하다.

View Layer와 DB Layer의 역할 분리를 철저하게 하는 게 좋다.

실제로 Controller에서 결과값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하다.

꼭 [Entity 클래스]와 [Controller에서 쓸 Dto]는 분리해서 사용해야 한다.

 

 

※ 참고 [Posts.java]

@Getter 
@NoArgsConstructor 
@Entity 
public class Posts {

    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY) 
    private Long id;

    @Column(length = 500, nullable = false)  
    private String title;

    @Column(columnDefinition = "Text", nullable = false)
    private String content;

    private String author;

    @Builder 
    public Posts(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }
}

 

 

 


 

 

등록 기능을 완성했으니 수정 / 조회 기능을 만들어보자.

 

 

수정

[PostsApiController.java]

@RequiredArgsConstructor
@RestController
public class PostsApiController {
	
    // ...
    
    @PutMapping("/api/v1/posts/{id}")
    public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
        return postsService.update(id, requestDto);
    }
    
}

 

 

[PostsUpadteRequestDto.java]

@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {

    private String title;
    private String content;

    @Builder
    public PostsUpdateRequestDto(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

 

 

[Posts.java]

public class Posts {

	// ...
    
    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

 

 

[PostsService.java]

@RequiredArgsConstructor
@Service
public class PostsService {
	
    // ...
    
    @Transactional
    public Long update(Long id, PostsUpdateRequestDto requestDto) {
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id));

        posts.update(requestDto.getTitle(), requestDto.getContent());

        return id;
    }
    
}
💡 update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없다.

이게 가능한 이유는 JPA의 영속성 컨텍스트 때문이다.

※ 영속성 컨텍스트란?
엔티티를 영구 저장하는 환경이다.
JPA의 핵심 내용은 
엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다.

JPA의 엔티티 매니저가 활성화된 상태로(Spring Data Jpa를 쓴다면 기본 옵션)
트랜잭션 안에서 데이터베이스에 데이터를 가져오면 
이 데이터는 영속성 컨텍스트가 유지된 상태이다.

이 상태에서 해당 데이터의 값을 변경하면 
트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다.
즉, Entity 객체의 값만 변경하면 
별도로 Update 쿼리를 날릴 필요가 없다.
이 개념을 더티 체킹이라고 한다.
💡 더티 체킹 (Dirty Checking)이란?
→ Transaction 안에서 엔티티의 변경이 일어나면, 
변경 내용을 자동으로 데이터베이스에 반영하는 JPA의 특징이다.

변경 감지해서 DB에 반영한다.

※ 데이터베이스에 변경 데이터를 저장하는 시점
1. Transaction Commit
2. EntityManager Flush
3. JPQL 사용

JPA에서는 트랜잭션이 끝나는 시점에 
변화가 있는 모든 엔티티 객체를 데이터베이스에 자동으로 반영해준다.

출처 : https://velog.io/@jiny/JPA-%EB%8D%94%ED%8B%B0-%EC%B2%B4%ED%82%B9Dirty-Checking-%EC%9D%B4%EB%9E%80

 

 

 


 

 

조회

[PostsApiController.java]

@RequiredArgsConstructor
@RestController
public class PostsApiController {
	
    // ...
    
    @GetMapping("/api/v1/posts/{id}")
    public PostsResponseDto findById (@PathVariable Long id) {
        return postsService.findById(id);
    }
    
}

 

 

[PostsResponseDto.java]

@Getter
public class PostsResponseDto {

    private Long id;
    private String title;
    private String content;
    private String author;

    public PostsResponseDto(Posts entity) {
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.content = entity.getContent();
        this.author = entity.getAuthor();
    }
}
PostsResponseDto는 Entity의 필드 중 일부만 사용하므로 
생성자로 Entity를 받아 필드에 값을 넣는다.

굳이 모든 필드를 가진 생성자가 필요하진 않으므로 
Dto는 Entity를 받아 처리한다.

 

 

[PostsService.java]

@RequiredArgsConstructor
@Service
public class PostsService {
	
    // ...
    
    public PostsResponseDto findById(Long id) {
        Posts entity = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id));

        return new PostsResponseDto(entity);
    }
    
}