API 개발 기본
회원 관련 API
-
RestAPI 사용 → @RestController = @Component(Spring Bean으로 등록) + @Responsebody(JSON 이나 XML로 데이터를 바로 보내는 용도)
- 회원 등록 API
- [Version 1] Entity 그대로 데이터를 받아오는 Version (회원 등록) →
public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member)
- @PostMapping
- @RequestBody : Json으로 온 HttpBody를 Member에 mapping(바인딩) 시켜줌 (즉, Json을 Member Entity로 바꿔준다 생각하면 됨)
- @Valid : 해당 객체에 @NotEmpty가 있다면 그 부분에 대해서 validation을 진행해주는 것
-
Postman과 같은 API 사용 앱을 통해 Json형태로 해당 Entity에 맞는 형식으로 데이터를 보낼 수 있음. ver1에선 이렇게 받은 Entity를 memberservice를 통해서 저장까지 진행.
- 하지만 항상 강조하지만, 화면 자체에서 주고 받는 데이터는 Entity 그 자체이면 안됨! → 보안문제가 있을 수도 있고, 각 API 스펙에 맞는 Object가 필요함 (용도에 맞게 쓸 수 있는 객체인 DTO가 필요!)
- API 스펙에 의한 별도의 DTO를 사용하자!
- DTO: Data Transfer Object
- [Version 2] Entity를 사용하지 않고 DTO를 사용하여 데이터를 받아옴 (회원 등록) →
public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request)
- DTO 사용
- 데이터를 받아 올 때 →
CreateMemberRequest request
- 데이터를 반환 해 줄 때 →
CreateMemberResponse
@Data // @Getter + @Setter + ... static class CreateMemberRequest { // DTO @NotEmpty private String name; }
- 데이터를 받아 올 때 →
- DTO를 통해 받아 오면, 그 DTO의 값을 새로운 Entity에 심어준 후 그 Entity를 EntityManger를 통해 저장하면 됨!
- API 스펙에 맞게 DTO을 사용하면 유지보수에 큰 장점이 있음!
- DTO 사용
- [Version 1] Entity 그대로 데이터를 받아오는 Version (회원 등록) →
- 회원 수정 API
- REST API 의 Put을 사용 →
@PutMapping("/api/v2/members/{id}")
- url을 통해 값을 받아옴 →
@PathVariable("id") Long id
- Request, Response Data 모두 Entity가 아닌, DTO를 사용.
- Request :
@RequestBody @Valid UpdateMemberRequest request
-
Response :
@Data @AllArgsConstructor // 모든 파라미터를 받는 생성자 생성 static class UpdateMemberResponse{ private Long id; private String name; }
- Request :
- 변경 감지를 통해 회원 수정 진행 → Entitiy Manger가 관리 하는 영속성 컨텍스트 안에서 정보를 수정!
- REST API 의 Put을 사용 →
- 회원 조회 API
- [Version 1] Entity 그대로 데이터를 보내는 Version (회원 조회) - Entity 를 그대로 반환 →
public List<Member> membersV1()
- @GetMapping 사용 →
@GetMapping("/api/v1/members")
- Member Entity에서의 orders에 @JsonIgnore 사용 : Entity를 API스펙에 맞추어, 보내지 않을 데이터(요소)에 설정해주는 것. (얘는 Json으로 보내지 않겠다!, JsonIgnore을 사용하지 않으면 API가 요구하는 스펙에 상관없이 해당 Entity의 모든 정보를 보내지게 됨.) → But, 다른 API 스펙에 맞지 않게됨. (실무에서는 같은 엔티티에 대해 API가 용도에 따라 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 프레젠테이션 응답 로직을 담기는 어렵다.) → DTO사용 필요!
- 이처럼 Entity를 직접 보내지게 된다면 보안 문제든, 쓸데없는 데이터가 보내지는 등, 좋지 않은 현상이 발생함! → DTO를 통해서 데이터를 보내줘야 함!
- @GetMapping 사용 →
- [Version 2] Entity를 사용하지 않고 DTO를 사용하여 데이터를 보내는 Version (회원 조회) - DTO 사용 ->
public Result membersV2()
- Response: DTO 사용
- Result
(Respone DTO) - 제네릭 사용.
-
한번 감싸주기 위함. 그냥 MemberList를 반환하면 JSON으로 list만 반환 되므로 유연성이 떨어짐 (추후에 count등 부가적인 데이터를 추가하기 위해선 감싸줘야함)
{ "count" : 3, //이렇게 부가적인 데이터를 넣어주기 위함 "data" : [ { "id" : 1, "name" : "PSI" }, { "id" : 2, "name" : "PHB" } ] }
@Data @AllArgsConstructor static class Result<T>{ private int count; private T data; }
- 기본적으로 Entity를 조회 한 후에, 그 각 Entity를 DTO로 변환하여 반환하는 것.
List<MemberDto> collect = findmembers.stream().map(m -> new MemberDto(m.getName())).collect(Collectors.*toList*());
return new Result(collect.size(), collect);
- DTO를 통해 정말 노출할 것들만 노출하는 것!
- [Version 1] Entity 그대로 데이터를 보내는 Version (회원 조회) - Entity 를 그대로 반환 →
Init DB
- 개발단계에서, 실제 App단에서 어떻게 진행되는지 Test할 때, 데이터를 계속해서 Create-Drop하기에, 애초에 시작할 때 부터 Data를 init해주는 것. (Entity의 요소들이 계속해서 수정되기에 create-drop 해주어야 됨)
- init DB를 실행하는 class를 component로 등록해줌. → application안에서 동작하는 것이기에, Spring Bean으로 등록해줘야 함. →
@Component
,@RequiredArgsConstructor
public class InitDb
- Spring Bean
- Spring Bean으로 등록되자마자 DB를 저장하게 됨. () →
@PostConstruct
: 스프링 빈에 등록되면 그 후 바로 실행되는 구문 (인스턴스 할당 없이 실행되는 생성자) - EntityManger를 사용하는 InitService Component를 injection 받음. (InitService는 InitDB에서만 사용하기에 내장 class로 선언해줌.)
static class InitService
- @Component, @Transactional annotation 필수. (Service의 역할)
- EntityManger를 injection 받음.
- 실제 em을 통한 영속성 관리 (persist, 조회, …)가 진행되는 곳
- 굳이 postconstruct에 넣지 않고 service를 통해 em관리를 하는 이유 : postconstruct에서 직접적으로 transactional을 먹일 수 없기에.
- TIP! ctrl + alt + M → method 추출
API 개발 고급 (Part 1, 주문 조회 API)
- 목표 : FethType.LAZY (지연로딩)일 때의 조회 성능 최적화! (xToOne)
- 주문 조회 API
- 주문 + 배송정보 + 회원을 조회하는 API
- RestAPI 사용 → @RestController = @Component(Spring Bean으로 등록) + @Responsebody(JSON 이나 XML로 데이터를 바로 보내는 용도)
- 지연 로딩에 의한 성능저하를 해결하는 방향으로.
- xToOne (FetchType.LAZY) 과 관련된 조회 성능을 올리는 방법
- 즉, Order에서 Member와 Delivery 정보까지 받아오기
- Part 1 (xToOne)
- Order ↔ Member : ManyToOne
- Order ↔ Delivery : OneToOne
- Part 2(xToMany, Collection 조회)
- Order ↔ OrderItems : OneToMany
- Part 1 (xToOne)
[Version1] 엔티티를 그대로 직접 노출
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1()
: Entity를 그대로 직접 반환- @JsonIgnore 없이 그대로 모든 Order를 조회하면 Member와 Delivery, OrderItems는 양방향 관계이기에 무한루프에 빠지게 됨. Order→ Member → Order → Member → …
- 즉, 줄 중 하나는 JsonIgnore을 통해 Json으로 반환될 때 연결을 끊어줘야됨. (Order 조회 이므로 Member에서의 Order를 참조하는 부분에 @JsonIgnore 추가해주기!)
-
But @JsonIgnore 로 인한 프록시 문제가 발생함.
-
Hibernate5Module()
: Json이 프록시 상태의 Entity를 무시하도록 설정하기 (프록시 상태의 Entity값을 null로 설정해줌.) (Entity에 해당하는 것!, 즉 DTO를 사용할 때는 사용할 수 없음) → HibernateModule을 사용하는 것이 아닌 DTO를 사용하는 것이 최고! → 즉, Entity를 직접 노출하지 말고 DTO를 사용하자!@Bean // 프록시와 관련된 데이터를 처리하기 위한 모듈 설정 및 등록 Hibernate5Module hibernate5Module() { Hibernate5Module hibernate5Module = new Hibernate5Module(); // hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true); // 강제 지연 로딩 설정 -> 프록시 상태가 아닌 실제 Entity를 가져올 수 있도록 하는 것. -> query가 무지막지하게 나감(성능 저하!) return hibernate5Module; }
- 직접 Lazy 강제 초기화
- 각 order에서 member의 값에 강제로 접근하여 member를 프록시 상태에서 벗어나게끔. →
order.getMember().getName();
order.getDelivery().getAddress();
- 원리 : member의 값을 가져오라는 명령이 없으면 프록시 상태로 두지만, member의 일부 값을 가져오라는 명령이 주어지면, member의 실제 값을 알아야 하기 때문에, em을 통해서 해당 Entity를 실제로 가져오게 됨 → Lazy 강제 초기화. (getname을 하면 member의 실제 값을 보내줘야되기 때문에 DB에 쿼리를 날리는 것!)
- 각 order에서 member의 값에 강제로 접근하여 member를 프록시 상태에서 벗어나게끔. →
- 그럼 FetchStyle.Eager로 사용하면 안됨? → 똑같은 문제가 발생하며, 또 다른 문제가 발생할 가능성도 큼. 또한, 다른 API스펙에 맞추기 어려움. (ex, 난 Order만 필요한데, Eager땜시 order와 관련된 Member, Delivery 등등 스펙에 맞지 않는 데이터도 보내줌! → 성능문제의 원인)
-
- 하지만 당연히 이렇게 Entity를 직접 노출하는 것은 유지 보수, 보안에 있어서 치명적이게 됨! → DTO 사용!
[Version 2] Entity를 DTO로 변환해서 반환
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2()
: DTO로 변환해서 반환- Entity값을 DTO로 변환해주어 반환 →
List<SimpleOrderDto> result = orders.stream().map(o -> new SimpleOrderDto(o)).collect(Collectors.*toList*());
- API가 요구하는 스펙은 주문ID, 주문자 이름, 주문지, 주문시간 이라고 가정. → DTO을 해당 스펙에 맞게 설정해야 됨.
- DTO (Entity를 생성자로 받아와 Entity의 값들을 얻어옴)
- 이때 Order Entity의 Member는 Lazy로 프록시상태. → getName()을 통해 접근하여 실제 Entity를 가져옴
- 똑같이 Order Entity의 Delivery는 Lazy로 프록시상태. → getAddress()을 통해 접근하여 실제 Entity를 가져옴
@Data public class SimpleOrderDto { // Data의 스펙을 명확하게 규정해야됨. -> 내가 받을 것, 또 내가 보내줄 것을 명확하게 규정해야됨 (이런 이유때문에 DTO를 사용하는 것) private Long orderId; private String name; private LocalDateTime orderDate; private OrderStatus orderStatus; private Address address; // DTO는 Entity를 참조해도 괜찮음 public SimpleOrderDto(Order o) { orderId = o.getId(); **name = o.getMember().getName();** // Lazy 강제 초기화 및 변수 설정 -> 여기서 조회 qeury가 나가는 것 (Lazy니깐!) orderDate = o.getOrderDate(); orderStatus = o.getStatus(); **address = o.getDelivery().getAddress();** // Lazy 강제 초기화 및 변수 설정 -> 여기서 조회 qeury가 나가는 것 (Lazy니깐!) } }
- 하지만 ver2 는 order에 따른 member와 delivery를 조회하기 위해 query가 너무 많이 나감! → 성능저하의 원인
- 해당 문제가 1+N 문제!!
- 1 : 모든 Order를 조회하기 위해 하나의 쿼리가 날라가지만
- N : 1을 통해 얻어온 Order List에서의 각각의 Order는 또 member를 참조하고 있고 Delivery를 참조하고 있음! 그에 맞는 쿼리를 또 날려줘야 함!
- 흐름을 통해 이해 → 만약 OrderList에 Order가 2개가 있다면, 이 2개의 Order를 모두 조회하기 위한 첫번째 query 1가 나감. 해당 query를 통해 받아온 OrderList의 각 Order에 접근하는데 첫번째 Order에 접근했을 때, member와 delivery를 참조하고 있기 때문에 이에 대한 member와 delivery를 가져오는 2개의 query가 나감. 또 다음 Order에 접근할 때도 똑같이 member와 delivery를 가져오기 위해 2개의 query가 또 나감. 즉, 1 + 2 + 2 개의 쿼리가 나가는 꼴. → 코드 흐름대로 이해하면 됨!
- ver1도 똑같은 성능저하를 얻게됨. ( → getName(), getAddress(), …)
- 해당 문제를 해결해야 됨! (굉장히 복잡한 비지니스 구조라고 생각해보면 이는 명백한 성능 저하의 원인이 될 수 있음!)
- 이는 fetch join을 통해서 해결 가능! (join 개념)
[Version 3] Fetch Join을 통한 성능 최적화 (DTO)
@GetMapping("/api/v3/simple-orders")
- Repository에서 Order 들을 조회할 때, fetch join을 사용하는 것!
- 테이블을 한번에 묶어서(join) 조회하는 것 → query 하나로 Order, Member, Delivery 를 한번에 조회할 수 있음! (성능 개선)
-
프록시로 채우는 것이 아닌, 실제 Entity로 채워버리는 것. (즉, 조회할 때부터 Lazy 강제 초기화를 해버리는 것)
public List<Order> findAllWithMemberDelivery() { return em.createQuery( "select o from Order o " + "join fetch o.member m " + "join fetch o.delivery d", Order.class) .getResultList(); // join fetch는 해당 연관관계의 Table을 그냥 합쳐서 한번에 다 가져오는 것 // 프록시로 채우는 것이 아닌, 실제 Entity로 채워버리는 것. (즉, Lazy를 무시하는 것 -> Lazy를 무시하는 것이지 Eager가 아님) }
- JPA는 ORM이기에 이렇게 한번에 묶은 Fetch Join을 사용하게 되면 그대로 Order Entity와 그 묶여진 테이블이 매핑되어 Order.getMember.getName() 과 같이 Spring Boot에서 정의한 변수명 그대로 접근하여 가져올 수 있음.
[Version 4] DTO로 바로 반환
- Entity로써 반환하는 것이 아닌 DTO 자체로 바로 반환
- 불러와야 하는 데이터 Col의 수가 너무나도 많을 때 사용. But, 그렇게 자주 사용하지는 않음.
- 또한, Ver 3까지만 해도 어느정도의 성능 최적화는 가능! (웬만하면 Ver 3을 쓰자~)
-
DTO 클래스로 조회
public List<OrderSimpleQueryDto> findByDto() { //JPA의 Entity조회가 아닌 DTO를 조회는 것이므로 fetch는 사용 불가 return em.createQuery( "select new jpabook.jpashop.repository.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address) " + // o 자체로 넣을 수 없음 -> o는 객체가 아닌, 식별자이기에. (DB입장) -> 그래서 각 colum을 넣어줘야됨. "from Order o " + "join o.member m " + "join o.delivery d", OrderSimpleQueryDto.class) .getResultList(); }
- JPA의 Entity조회가 아닌 DTO를 조회는 것이므로 fetch는 사용 불가 → join 사용. (차피 DTO에 원하는 Col들을 지정해서 넣어줄 것이라 상관없음) (Fecth Join은 ORM과 관련되어 있음!)
- 생성자 안에 Order Object 즉, o 자체로 넣을 수 없음 -> o는 객체가 아닌, 식별자이기에. (DB입장) -> 그래서 각 colum을 넣어줘야됨.
-
API가 원하는 스펙으로 DTO를 설정.
@Data public class OrderSimpleQueryDto { // Data의 스펙을 명확하게 규정해야됨. -> 내가 받을 것, 또 내가 보내줄 것을 명확하게 규정해야됨 (이런 이유때문에 DTO를 사용하는 것) private Long orderId; private String name; private LocalDateTime orderDate; private OrderStatus orderStatus; private Address address; // DTO는 Entity를 참조해도 괜찮음! public OrderSimpleQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) { // 각 order에 대한 member와 delivery를 serach하기 위한 query가 추가적으로 발생함 this.orderId = orderId; this.name = name; this.orderDate = orderDate; this.orderStatus = orderStatus; this.address = address; } }
- 위에서 봤던 것들 처럼 기존에 사용할 수 있는 JPA의 장점들이 많이 사라지며, 코드 또한 복잡해짐. So, 웬만하면 Ver 3를 통해 간단히 최적화를 진행하되, Col이 너무 많은 데이터에서 적은 Col만을 조회할 것이라면 DTO로 조회하는 것도 좋은 선택.
- SELECT 절에서 원하는 데이터를 직접 선택하므로 DB 애플리케이션 네트웍 용량 최적화(생각보다 미비)
- 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점
- So, 보통 이런 API 스펙에 맞게 조회 및 반환하는 Ver4 같은 경우 조회 로직 (리포지토리) 자체를 기존 리포지토리와 구분해서 다른 package에 만들어 놓음. (유지 보수를 위함!)