[Spring Data JPA Tutorial] 13. Response 전용 DTO 클래스 이용하기

2022. 4. 18. 17:04Spring/Spring Data JPA Tutorial

반응형
  1. Response 전용 DTO 정의의 필요성
  2. 사전 작업
  3. Response 용 DTO 정의
  4. 조회 api 수정
    1. user 전체 조회 api
    2. user 단건 조회 api
    3. store 전체 조회 api
    4. store 단건 조회 api
  5. Response 결과

1. Response 전용 DTO 정의의 필요성

LazyInitializationException 해결하기 2. @OneToMany 시간 때에, @OneToMany 연관관계를 알아보기 위해, User 엔티티의 stores 필드에는 @JsonIgnore설정을 제거했고, Store 엔티티에서는 순환참조 에러를 방지하기 위해 user 필드값에 @JsonIgnore 설정을 추가했습니다.

02-5

04-1

그러나, 기존의 방식은 몇가지 문제점이 있습니다.

  1. User 조회 api에서 stores 정보를 response에 포함시키고 싶고, Store 조회 api에서도 user 정보를 포함시키고 싶다면?
  2. api마다 DateTime 포맷을 달리하고 싶다면?
  3. 특정 api에서 포함시키지 않고 싶은 필드값이 있다면?

이러한 문제점에 의해, Entity클래스를 Response에 그대로 담아서 보내는 것은 권장하지 않습니다.

연관관계 필드값을 response에 포함시켜야하는 경우가 있고, 포함시키지 않아도 되는 경우가 있는데, Entity 클래스를 그대로 Response에 담게되면 여러 경우의 수를 나타내기 어렵습니다.

따라서, 요구하는 API 스펙에 맞춰 별도의 DTO 클래스를 만드는것이 장기적으로 볼 때 좋습니다.


아래의 3가지를 유의하여 진행하면 됩니다.

  1. Response로 쓰일 DTO 클래스 정의
  2. Service 메서드에서 기존의 Entity 객체를 map을 이용하여 DTO 클래스로 변환하여 return타입에 설정
  3. Controller에서 return할 객체에는 Entity 객체는 포함되지 않아아함

2. 사전 작업

Entity를 그대로 내보낼일은 없을 예정이며, EAGER fetch를 원할 경우에는 @EntityGraph나 fetch join을 이용합니다.

연관관계 필드값에는 모두 @JsonIgnore를 붙이고, fetch타입은 LAZY로 설정합니다.

@NoArgsConstructor
@DynamicInsert @DynamicUpdate
@Getter
@Entity
@Table(name = "user", indexes = {@Index(name = "UK_USER_EMAIL", columnList = "email", unique = true)})
@Where(clause = "active = true")
public class User implements Serializable {

    ...

    @JsonIgnore
    @OneToMany
    @JoinColumn(name = "user_id")
    @Setter private List<Store> stores = new ArrayList<>();

}

@OneToMany 연관관계의 기본 fetchType은 LAZY이므로 별도로 정의할 필요없습니다.


@NoArgsConstructor
@DynamicInsert
@DynamicUpdate
@Getter
@Entity
public class Store {

    ...

    @JsonIgnore
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "FK_USER_STORE"))
    private User user;

}

3. Response 용 DTO 정의

3.1. UserData

user 전체 조회시에는 stores 정보는 보여주지 말고 user 에 대한 일부 간소한 내용만 보여주도록 하고,
user 단건 조회시에 stores 및 기타 정보를 보여주도록 UserData DTO를 정의합니다.

@Data
@NoArgsConstructor
public class UserData {

    @Data
    @NoArgsConstructor
    public static class UserSimple {
        private Long id;
        private String type;
        private String email;
        private String name;

        public UserSimple(me.jiniworld.demo.domain.entity.User u) {
            this.id = u.getId();
            this.type = u.getType();
            this.email = u.getEmail();
            this.name = u.getName();
        }
    }

    @Data
    public static class User extends UserSimple {
        private String sex;
        private String birthDate;
        private String phoneNumber;
        private List<StoreData.StoreSimple> stores;

        public User(User u) {
            super(u);
            this.sex = u.getSex();
            this.birthDate = u.getBirthDate();
            this.phoneNumber = u.getPhoneNumber();
            this.stores = u.getStores().stream().map(StoreData.StoreSimple::new).collect(Collectors.toList());
        }
    }
}

3.2. StoreData

store 전체 조회시에는 user 정보는 포함하지 않고, store 단건 조회시에만 user 정보를 포함합니다.

@Data
@NoArgsConstructor
public class StoreData {

    @Data
    @NoArgsConstructor
    public static class StoreSimple {
        private Long id;
        private String name;
        private String industry;

        public StoreSimple(Store s) {
            this.id = s.getId();
            this.name = s.getName();
            this.industry = s.getIndustry();
        }
    }

    @Data
    public static class Store extends StoreSimple {
        private UserData.UserSimple user;
        public Store(Store s) {
            super(s);
            this.user = new UserData.UserSimple(Optional.ofNullable(s.getUser()).orElse(null));
        }

    }
}

4. 조회 api 수정

수정해야할 api들은 조회 api들입니다.

수정이나 추가, 삭제 api들은 모두 BaseResponse를 return타입으로 갖기 때문에 별도로 수정이 필요하지 않습니다.

4.1. user 전체 조회 api

4.1.1. 수정 전

// Service
public List<User> selectAll() {
    List<User> users = userRepository.findAll();
    users.stream()
            .forEach(user -> user.getStores().stream()
                    .filter(store -> store != null)
                    .forEach(store -> store.getName()));
    return users;
}
// Controller
@GetMapping("")
public DataResponse<List<User>> selectAll() {
    List<User> users = userService.selectAll();
    return new DataResponse<>(users);
}

4.1.2. 수정 후

변경되는 user 전체조회 api에서는, stores 를 조회할 필요가 없기 때문에 stores정보를 stream().forEach를 이용하여 조회하는 부분을 제거합니다.

.stream().map()을 이용하여, User 엔티티를 UserData.UserSimple로 변환합니다.

// Service
public List<UserData.UserSimple> selectAll() {
    return userRepository.findAll()
            .stream().map(UserData.UserSimple::new).collect(Collectors.toList());
}
// Controller
@GetMapping("")
public DataResponse<List<UserData.UserSimple>> selectAll() {
    List<UserData.UserSimple> users = userService.selectAll();
    return new DataResponse<>(users);
}

4.2. user 단건 조회 api

4.2.1. 수정 전

// Service
public User select(Long id) {
    User user = userRepository.findDistinctWithStoresById(id)
            .orElseThrow(() -> new InvalidInputException(MessageUtils.INVALID_USER_ID));
    return user;
}
// Controller
@GetMapping("/{id}")
public DataResponse<User> select(@PathVariable("id") long id) {
    return new DataResponse<>(userService.select(id));
}

4.2.2. 수정 후

@OneToMany 연관관계인 stores 필드값은 @EntityGraph를 이용하여 EAGER방식으로 가져올 경우, JPA 특성상 user 정보를 중복적으로 가져오게 됩니다(query 상으로)

때문에, 굳이 @EntityGraph를 이용하여 User 객체를 가져올 필요 없고, LAZY 방식으로 가져오도록 합니다.

또, @Transactional로 감싸져있는 Service 메서드 내에서는 연관관계 설정된 필드값을 언제든 읽어들일 수 있기 떄문에, UserData.User 생성자 정의 중에 문제 없이 이용할 수 있습니다.

// Service
public UserData.User select(Long id) {
    User user = userRepository.findById(id).orElseThrow(() -> new InvalidInputException(MessageUtils.INVALID_USER_ID));
    return new UserData.User(user);
}
// Controller
@GetMapping("/{id}")
public DataResponse<UserData.User> select(@PathVariable("id") long id) {
    return new DataResponse<>(userService.select(id));
}

4.3. store 전체 조회 api

4.3.1. 수정 전

// Service
public List<Store> select() {
    List<Store> stores = storeRepository.findDistinctWithUserBy();
    return stores;
}
// Controller
@GetMapping("")
public DataResponse<List<User>> selectAll() {
    List<User> users = userService.selectAll();
    return new DataResponse<>(users);
}

4.3.2. 수정 후

store 전체 조회에서는 user 정보가 필요하지 않기 때문에 @EntityGraph를 이용하여 left outer join을 할 필요가 없습니다.
따라서, findAll() 메서드를 이용하여 stores 정보를 조회합니다.

// Service
public List<StoreData.StoreSimple> select() {
    List<Store> stores = storeRepository.findAll();
    return stores.stream().map(StoreData.StoreSimple::new).collect(Collectors.toList());
}
// Controller
@GetMapping("")
public DataResponse<List<StoreData.StoreSimple>> selectAll() {
    List<StoreData.StoreSimple> stores = storeService.select();
    return new DataResponse<>(stores);
}

4.4. store 단건 조회 api

4.4.1. 수정 전

// Service
public Store select(Long id) {
    Store store = storeRepository.findDistinctWithUserById(id).orElse(null);
    return store;
}
// Controller
@GetMapping("/{id}")
public DataResponse<User> select(@PathVariable("id") long id) {
    return new DataResponse<>(userService.select(id));
}

4.4.2. 수정 후

@ManyToOne 연관관계인 user 필드값은 @EntityGraph를 이용하여 EAGER방식으로 가져올 경우, 더욱 쿼리 성능을 높일 수 있습니다.

// Service
public StoreData.Store select(Long id) {
    Store store = storeRepository.findDistinctWithUserById(id)
            .orElseThrow(() -> new InvalidInputException(MessageUtils.INVALID_STORE_ID));
    return new StoreData.Store(store);
}
@GetMapping("/{id}")
public DataResponse<UserData.User> select(@PathVariable("id") long id) {
    return new DataResponse<>(userService.select(id));
}

5. Response 결과

5.1. user 전체 조회

GET /api/users
{
  "result": "SUCCESS",
  "reason": "",
  "data": [
    {
      "id": 1,
      "type": "BASIC",
      "email": "jini@jiniworld.me",
      "name": "지니",
      "sex": "F",
      "birthDate": "19920201",
      "phoneNumber": "01011112222"
    },
    {
      "id": 2,
      "type": "OWNER",
      "email": "2han@appleboxy.xyz",
      "name": "이한",
      "sex": "M",
      "birthDate": "19880702",
      "phoneNumber": "01088887777"
    },
    {
      "id": 3,
      "type": "OWNER",
      "email": "coco@jiniworld.me",
      "name": "코코",
      "sex": "F",
      "birthDate": "19950705",
      "phoneNumber": "01044447777"
    }
  ]
}

5.2. user 단건 조회

GET /api/users/1
{
  "result": "SUCCESS",
  "reason": "",
  "data": {
    "id": 1,
    "type": "BASIC",
    "email": "jini@jiniworld.me",
    "name": "지니",
    "sex": "F",
    "birthDate": "19920201",
    "phoneNumber": "01011112222",
    "stores": [
      {
        "id": 1,
        "name": "우리의 시간",
        "industry": "카페"
      },
      {
        "id": 2,
        "name": "에머이",
        "industry": "베트남음식"
      }
    ]
  }
}

5.3. stores 전체 조회

GET /api/stores
{
  "result": "SUCCESS",
  "reason": "",
  "data": [
    {
      "id": 1,
      "name": "우리의 시간",
      "industry": "카페"
    },
    {
      "id": 2,
      "name": "에머이",
      "industry": "베트남음식"
    },
    {
      "id": 3,
      "name": "한잔의 추억",
      "industry": "술집"
    },
    {
      "id": 4,
      "name": "할리스",
      "industry": "카페"
    }
  ]
}

5.4. stores 단건 조회

GET /api/stores/1
{
  "result": "SUCCESS",
  "reason": "",
  "data": {
    "id": 1,
    "name": "우리의 시간",
    "industry": "카페",
    "user": {
      "id": 1,
      "type": "BASIC",
      "email": "jini@jiniworld.me",
      "name": "지니"
    }
  }
}

++ tip

04-2

Swagger 에서 Schemas 표시를 제외하고 싶다면 springdoc.swagger-ui.default-models-expand-depth 값을 -1로 설정해주면 됩니다.

springdoc:
  swagger-ui:
    default-models-expand-depth: -1

위의 설정을 추가하면 아래와 같이 Schema 탭이 제거됩니다.

04-3


GitHub에서 demo 프로젝트(v1.0.9)를 다운받아 볼 수 있습니다.

728x90
반응형