[Spring Boot] JPA - Dto와 Entity를 분리해서 사용하기

yunSeok·2024년 7월 10일
0

Spring Boot

목록 보기
3/4

🙂 Dto와 Entity를 분리해야 하는 이유?


Entity만 사용시 문제점

보안 문제

Entity는 데이터베이스 테이블과 직접 매핑되어 있어, 불필요하거나 민감한 정보까지 모두 노출될 수 있습니다.

순환 참조 문제

Entity가 서로를 참조하는 양방향 관계일 경우, JSON 직렬화 과정에서 무한 루프가 발생할 수 있습니다. (서로를 계속 참조함..)

유지 보수 문제

Entity 구조 변경이 곧바로 API 응답 구조의 변경으로 이어져, API 유지 보수가 어려워집니다. ( ex. 필드 이름이 변경될 경우 )

성능 이슈

엔티티를 직접 반환하면 엔티티에 존재하는 모든 데이터가 반환되기 때문에 사용자가 필요로 하는 데이터만 전송하기 어려움이 있습니다.

유연성 저하

표현 계층(API)과 영속성 계층(데이터베이스)이 강하게 결합되어 유연성이 떨어집니다.


해결방법

DTO를 사용해 엔티티의 변경으로 인한 영향을 최소화 하며, 클라이언트에게 필요한 데이터만 전달할 수 있게 해야합니다.
따라서 Entity 클래스와 DTO 클래스를 분리해 View Layer와 DB Layer를 설계해야 합니다.

위 그림과 같이 View와 통신하는 DTO 클래스를 따로 구성해야 합니다.
(한 개의 DTO를 사용하거나 두 개의 DTO(Request/ Response)를 사용하게됩니다.)

1. 한 개의 DTO

장점

  • 코드 간결성
    클래스 수가 줄어들어 코드베이스가 단순해집니다.

  • 유지보수 용이성
    변경 사항을 한 곳에서 관리할 수 있습니다.

  • 일관성
    입력과 출력 구조가 동일할 때 유용합니다. (간단한 CRUD 작업시)

단점

  • 유연성 부족
    입력과 출력 요구사항이 다를 때 제약이 있습니다.

  • 보안 위험
    클라이언트에 민감한 정보가 노출될 수 있습니다.

2. 두 개의 DTO (Request/ Response)

장점

  • 유연성
    입력과 출력 구조를 독립적으로 설계할 수 있습니다.

  • 보안 강화
    민감한 정보를 입력 DTO에만 포함시킬 수 있습니다.

  • 명확한 인터페이스
    API의 입출력 구조를 명확히 정의할 수 있습니다.

  • 유효성 검증
    입력 DTO에 특화된 유효성 검증을 적용할 수 있습니다.

  • 명확한 기능 분리
    입력 처리와 출력 생성 로직을 명확히 구분할 수 있습니다.

  • 성능 최적화
    필요한 데이터만 전송하여 네트워크 부하를 줄일 수 있습니다.

단점

  • 복잡성 증가
    관리해야 할 클래스 수가 증가합니다. (설계가 복잡하다..)

📌 본 포스트는 (Request/ Response) 두 개의 DTO를 작성합니다!


🙂 DTO와 Entity 구분

Dto와 Entity란? (간단 정리)

  • DTO(Data Transfer Object) : 클라이언트와 서버 간 데이터 전송을 전송을 위한 객체
  • Entity : 데이터베이스에 저장되는 데이터 객체로, 데이터베이스와 직접적으로 매핑되는 객체

Entity

JPA가 관리하는 데이터베이스 테이블과 매핑되는 객체로, 테이블에 존재하는 컬럼들을 필드로 가지는 객체입니다.
(DB의 테이블과 1:1로 매핑되며, 테이블이 가지지 않는 컬럼을 필드로 가져서는 안 됩니다.)

Entity는 데이터베이스 영속성(persistent)의 목적으로 사용되는 객체이기 때문에 요청(Request)이나 응답(Response) 값을 전달하는 클래스로 사용하는 것은 좋지 않습니다.

📌 영속성 : 영구적으로 저장되어 지속적으로 접근 가능한 상태

또 많은 클래스와 로직들이 Entity 클래스와 연결되어 동작하기 때문에 Entity 클래스가 변경되면 여러 클래스에 영향을 줄 수 있습니다.

따라서, Entity에서는 setter 메서드의 사용을 지양해야 합니다.
setter 사용시 변경되지 않는 인스턴스에 대해서도 접근 가능해지기 때문에 객체의 일관성, 안전성을 보장하기 힘들어집니다. (영속성이 깨지게된다.)

그렇기 때문에 Entity에는 setter 대신 Constructor(생성자) 또는 Builder를 사용해야 한다. (불변 객체로 만들면 데이터 영속성을 보장할 수 있습니다.)

✅ Builder 사용시

Builder를 사용하면 필요한 데이터만 처리할 수 있게 됩니다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "post")
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private long id;

    @Column(name = "writer")
    private String writer;

    @Column(name = "title")
    private String title;

    @Column(name = "content")
    private String content;

    @Column(name = "post_regdate")
    private LocalDateTime postRegdate;


    @Builder
    public Post(String writer, String title, String content, LocalDateTime postRegdate) {
        this.writer = writer;
        this.title = title;
        this.content = content;
        this.postRegdate = postRegdate;
    }

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

}

엔티티의 불변성을 유지하기 위해 @NoArgsConstructor(access = AccessLevel.PROTECTED) 설정하여 외부에서 기본 생성자를 통한 객체 생성을 제한합니다.

DTO

DTO는 계층간 데이터를 주고 받을 때 데이터를 전달하기 위한 객체로 JSON serialization과 같은 직렬화에도 사용됩니다.

주로, DAO에서 DB 처리 로직을 숨기고 DTO를 이용해 데이터를 내보내는 용도로 활됩니다.

Entity 대신 DTO를 사용해서 데이터를 교환하며, Controller 외에도 여러 레이어 사이에서 DTO를 사용할 수 있지만 주로 View와 Controller 사이에서 데이터를 주고받을 때 사용됩니다.

Request DTO

  1. 글 저장 DTO
@Getter
@NoArgsConstructor
public class PostSaveRequestDto {

    private String writer;
    private String title;
    private String content;
    private LocalDateTime postRegdate;

    @Builder
    public PostSaveRequestDto(String writer, String title, String content, LocalDateTime postRegdate) {
        this.title = writer;
        this.content = title;
        this.content = content;
        this.postRegdate = postRegdate;
    }

    public Post toEntity() {
        return Post.builder()
                .writer(writer)
                .title(title)
                .content(content)
                .postRegdate(postRegdate)
                .build();
    }

}
  1. 글 수정 DTO
@Getter
@NoArgsConstructor
public class PostUpdateRequestDto {
    private String title;
    private String content;

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

Response DTO

@Getter
@NoArgsConstructor
public class PostResponseDto {
    private Long id;
    private String writer;
    private String title;
    private String content;
    private LocalDateTime postRegdate;

    //repository 를 통해 조회한 entity 를 dto 로 변환 용도
    @Builder
    public PostResponseDto(Post post) {
        this.id = post.getId();
        this.writer = post.getWriter();
        this.title = post.getTitle();
        this.content = post.getContent();
        this.postRegdate = post.getPostRegdate();

    }

}

VO (Value Object)

  • VO는 값 자체를 표현하는 객체입니다. VO는 객체들의 주소값이 달라도 두 VO 인스턴스가 같은 값을 가지면 동등하다고 간주됩니다.

  • 불변성(Immutability)을 가집니다. 따라서 VO는 생성 후 그 상태를 변경할 수 없습니다.
    모든 필드는 final로 선언되며, setter 메서드를 제공하지 않습니다.

  • getter 메서드와 비즈니스 로직은 포함할 수 있고, 또한 필요한 경우 값 비교를 위해 equals()와 hashCode() 메서드를 오버라이딩 해줘야 합니다.

  • 내용물이 값 자체를 의미하기 때문에 값을 변경해야 할 경우, 새로운 VO 인스턴스를 생성합니다.


🙂 Repository

public interface PostRepository extends JpaRepository<Post, Long> {
    // JpaRepository를 상속받으면 기본적인 CRUD 메서드가 자동으로 생성됩니다.
}

🙂 Service

@Service
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;

    @Transactional
    public void savePost(PostSaveRequestDto requestDto) {
        Post post = requestDto.toEntity();
        Post savedPost = postRepository.save(post);
    }

    @Transactional
    public void updatePost(Long id, PostUpdateRequestDto requestDto) {
        Post post = postRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("id = "+ id + " 게시글이 없습니다."));

        post.update(requestDto.getTitle(), requestDto.getContent());
    }

    @Transactional
    public PostResponseDto getPostById(Long id) {
        Post entity = postRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));

        return new PostResponseDto(entity);
    }

    // 리스트 반환 1
    @Transactional
    public List<PostResponseDto> getPostList() {
        List<Post> postList = postRepository.findAll();

        return postList.stream()
                .map(PostResponseDto::new)
                .collect(Collectors.toList());
    }

    /*
    // 리스트 반환 2
    // List.copyOf() 사용 (자바 10)
    // 반환된 리스트를 직접 수정할 수 없어, 불변성(immutability)이 보장된다.
    @Transactional(readOnly = true)
    public List<PostResponseDto> getPostList() {
        List<Post> postList = postRepository.findAll();
        List<PostResponseDto> dtoList = new ArrayList<>(postList.size());

        for (Post post : postList) {
            PostResponseDto dto = new PostResponseDto(post);
            dtoList.add(dto);
        }


        return List.copyOf(dtoList);
    }

    // 리스트 반환 3
    //List.copyOf()를 사용할 필요 없이 Stream.toList() 메소드 사용 (자바 16)
    @Transactional(readOnly = true)
    public List<PostResponseDto> getPostList() {
        return postRepository.findAll().stream()
                .map(PostResponseDto::new)
                .toList();
    }
    */
}

스프링부트 3.3.1 version
Spring Data JPA에서 제공하는 주요 메소드들을 알려드리겠습니다 :)
(버전에 따라 약간씩 차이가 있을 수 있습니다.)

Method 정리

1. 저장 및 수정

// 단일 엔티티를 저장하거나 수정
postRepository.save(E entity);
// 여러 엔티티를 한 번에 저장하거나 수정
postRepository.saveAll(Iterable<?> entities);

E : 엔티티 타입입니다.
Iterable : S 타입의 요소를 순회하는 List, Set 등을 의미

2. 조회

// id로 엔티티를 조회
postRepository.findById(ID id);
// 모든 엔티티를 조회
postRepository.findAll();
// id 목록에 해당하는 모든 엔티티를 조회
postRepository.findAllById(Iterable<ID> ids);
// 정렬 조건을 적용하여 모든 엔티티를 조회
postRepository.findAll(Sort sort); 
// 페이징 조건을 적용하여 엔티티를 조회
postRepository.findAll(Pageable pageable); 

ID : 엔티티의 기본 키

3. 존재 여부 확인

// id를 가진 엔티티가 존재하는지 확인
postRepository.existsById(ID id);

4. 개수 조회

// 엔티티의 총 개수를 반환
postRepository.count();

5. 삭제

// id에 해당하는 엔티티를 삭제
postRepository.deleteById(ID id);
// 주어진 엔티티를 삭제
postRepository.delete(E entity);
// 모든 엔티티를 삭제
postRepository.deleteAll();
// 티티 컬렉션의 모든 엔티티를 삭제
postRepository.deleteAll(Iterable<? extends T> entities);
// ids 목록에 해당하는 모든 엔티티를 삭제
postRepository.deleteAllById(Iterable<? extends ID> ids);

🙂 Controller

@RestController
@RequiredArgsConstructor
@RequestMapping("/posts")
public class PostController {

    private final PostService postService;

    // 글 등록
    @PostMapping
    public ResponseEntity<Post> savePost(@RequestBody PostSaveRequestDto requestDto) {
        postService.savePost(requestDto);

        //return ResponseEntity.status(HttpStatus.CREATED).build();
        return new ResponseEntity<>(HttpStatus.CREATED);
    }

    // 글 수정
    @PatchMapping("/{idx}")
    public ResponseEntity<Post> updatePost(@RequestParam Long id, @RequestBody PostUpdateRequestDto requestDto) {
        postService.updatePost(id, requestDto);
        return new ResponseEntity<>(HttpStatus.OK);
    }

    // 글 조회
    @GetMapping("/{idx}")
    public ResponseEntity<PostResponseDto> getPostById(@RequestParam Long idx) {
        PostResponseDto responseDto = postService.getPostById(idx);
        return ResponseEntity.ok(responseDto);
    }

    // 전체 글 조회
    @GetMapping
    public ResponseEntity<List<PostResponseDto>> getPostList() {
        List<PostResponseDto> postList = postService.getPostList();
        return ResponseEntity.ok(postList);
    }
}

🙂 번외..

생성과 업데이트시 데이터를 반환하는 로직
(Service, Controller 변경)

Service

@Service
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;

    @Transactional
    public Post savePost(PostSaveRequestDto requestDto) {
        Post post = requestDto.toEntity();
        return postRepository.save(post);
    }

    @Transactional
    public Post updatePost(Long id, PostUpdateRequestDto requestDto) {
        Post post = postRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("id = "+ id + " 게시글이 없습니다."));

        post.update(requestDto.getTitle(), requestDto.getContent());
        return post;
    }

    @Transactional
    public PostResponseDto getPostById(Long id) {
        Post entity = postRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));

        return new PostResponseDto(entity);
    }

    // 리스트 반환 
    @Transactional
    public List<PostResponseDto> getPostList() {
        List<Post> postList = postRepository.findAll();

        return postList.stream()
                .map(PostResponseDto::new)
                .collect(Collectors.toList());
    }

}

Cotroller

@RestController
@RequiredArgsConstructor
@RequestMapping("/posts")
public class PostController {

    private final PostService postService;

    @PostMapping
    public ResponseEntity<Post> savePost2(@RequestBody PostSaveRequestDto requestDto) {
        Post savedPost = postService.savePost2(requestDto);

        //return ResponseEntity.status(HttpStatus.CREATED).body(savedPost).;
        return new ResponseEntity<>(savedPost, HttpStatus.CREATED);
    }

    // 글 수정
    @PatchMapping("/{id}")
    public ResponseEntity<Post> updatePost2(@PathVariable Long id, @RequestBody PostUpdateRequestDto requestDto) {
        Post updatedPost = postService.updatePost2(id, requestDto);

        //return ResponseEntity.status(HttpStatus.OK).body(updatedPost).;
        return new ResponseEntity<>(updatedPost, HttpStatus.OK);
    }

    // 글 조회
    @GetMapping("/{idx}")
    public ResponseEntity<PostResponseDto> getPostById(@RequestParam Long idx) {
        PostResponseDto responseDto = postService.getPostById(idx);
        return ResponseEntity.ok(responseDto);
    }

    // 전체 글 조회
    @GetMapping
    public ResponseEntity<List<PostResponseDto>> getPostList() {
        List<PostResponseDto> postList = postService.getPostList();
        return ResponseEntity.ok(postList);
    }
}

0개의 댓글

관련 채용 정보