회원 등록 API
※ 참고
패키지 분리
일반적으로 템플릿 엔진을 사용하여 랜더링하는 Controller와 API 스타일의 Controller를 분리한다.
왜냐하면 공통으로 예외처리를 할 때 패키지나 구성단위를 기준으로 공통 처리를 하는데 api와 화면의 공통 처리 요소가 다르다.
예를 들어 화면의 경우 에러가 발생하면 공통 에러 html 페이지를 반환하고 API의 경우 공통 에러 JSON을 반환한다.
회원 등록 API - V1
package jpabook.jpashop.api;
import javax.validation.Valid;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.service.MemberService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController // @Controller + @ResponseBody
@RequiredArgsConstructor
public class MemberApiController {
private final MemberService memberService;
@PostMapping("/api/v1/members")
public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member){
Long id = memberService.join(member);
return new CreateMemberResponse(id);
}
@Data
static class CreateMemberResponse{
private Long id;
public CreateMemberResponse(Long id) {
this.id = id;
}
}
}
엔티티를 @RequestBody에 직접 매핑했다.
문제점
- 엔티티에 프레젠테이션 계층을 위한 검증 로직이 추가된다.
일반적으로 Controller까지 프레젠테이션 계층이라고 한다. - 엔티티에 API 검증을 위한 로직이 들어간다.(ex: @NotEmpty ...)
- 예를들어, name필드를 어떤 API에서는 NotEmpty하게 사용하고 싶어하지만, 또 다른 API에서는Empty역시 허용할 수 있게되면 문제가 될 수 있다.
- 실무에서는 회원 엔티티를 위한 API가 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 모든 요구사항을 넣어줄 순 없다.
- 최종접속시간이 추가되고, 어떤 하나의 API때문에 요금정산 수수료 정산율이 추가되고, 하다보면 끝이 없다.
- (중요) 엔티티가 변경되면 API의 스펙이 변한다.
- Member 엔티티의 name 필드가 username이 되는순간 해당 엔티티를 사용하는 API는 모두 변경되야 한다!!
결론
- API 요청 스펙에 맞추어 별도의 DTO를 파라미터로 받는다
회원 가입 API- V2
엔티티 대신 DTO를 RequestBody에 매핑했다.
package jpabook.jpashop.api;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.service.MemberService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
@RestController
@RequiredArgsConstructor
public class MemberApiController {
private final MemberService memberService;
@PostMapping("/api/v2/members")
public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request){
Member member = new Member();
member.setName(request.getName());
Long id = memberService.join(member);
return new CreateMemberResponse(id);
}
@Data
static class CreateMemberResponse{
private Long id;
public CreateMemberResponse(Long id) {
this.id = id;
}
}
@Data
static class CreateMemberRequest {
@NotEmpty
private String name;
}
}
- CreateMemberRequest DTO를 Member 엔티티 대신 RequestBody와 매핑한다.
- 엔티티와 프레젠테이션 계층이 분리되었다.
- 엔티티와 API 스펙을 명확하게 분리할 수 있다.
- 엔티티가 변경되어도 API스펙이 변경되지 않는다.
정리
- 실무에서는 API스펙에 엔티티가 노출되어서는 안된다. 그렇기 때문에 각각에 API에 맞는 DTO를 만들어서 엔티티와 분리시키는게 중요하다.
- 엔티티를 그대로 쓸 경우의 장점은 별도의 DTO 를 만들 필요 없다는 것으로 아주 조금 간편해진다는 것 뿐이다.
회원 수정 API
package jpabook.jpashop.api;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.service.MemberService;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
@RestController
@RequiredArgsConstructor
public class MemberApiController {
private final MemberService memberService;
@PutMapping("/api/v2/members/{id}")
public UpdateMemberResponse updateMemberV2(
@PathVariable("id")Long id,
@RequestBody @Valid UpdateMemberRequest request){
memberService.update(id, request.getName());
Member findMember = memberService.findOne(id);
return new UpdateMemberResponse(findMember.getId(),findMember.getName());
}
@Data
static class UpdateMemberRequest{
private String name;
}
@Data
@AllArgsConstructor
static class UpdateMemberResponse{
private Long id;
private String name;
}
}
- RequestBody에 사용할 DTO들은 해당 컨트롤러에서만 사용한다면 굳이 외부로 빼지않고 INNER CLASS로 만들어도 된다.
※ 참고
회원 수정 API updateMemberV2 은 회원 정보를 부분 업데이트 한다. 여기서 PUT 방식을 사용 했는데, PUT은 전체 업데이트를 할 때 사용하는 것이 맞다. 부분 업데이트를 하려면 PATCH를 사용하거나 POST를 사용하는 것이 REST 스타일에 맞다
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {
@Transactional
public void update(Long id, String name) {
Member member = memberRepository.findOne(id);
member.setName(name);
}
}
- update 메소드에서 update한 Member를 그대로 반환해줘도 된다. (영속성 컨텍스트가 끊어진 Member이므로 반환해도 상관은 없다.)
- 그러나 update라는 행위는 엔티티를 변경하는 변경성 메소드이다. 이때 member를 바로 반환하면 결국 id를 갖고 member를 조회하는 꼴이 되어버린다.
- 그래서 커맨드와 쿼리를 철저히 분리한다는 정책에 의해서 update와 같은 커맨드성 로직에서는 가급적이면 비즈니스 로직 종료 후 그대로 끝내거나 식별자(ID)정도만 반환하는 것이 좋다. 만약 반환 받은 측에서 해당 엔티티가 필요하다면 해당 식별자 등을 이용해 다시 Service단에서 조회해서 가져오는 방법을 사용한다.
※ 참고
변경 감지
트랜잭션이 시작되면서 처음엔 Member 엔티티가 없으므로 DB에서 영속성 컨텍스트로 저장 후 반환.
이때 영속 상태인 Member를 setName()으로 값을 바꿔준다.메소드가 종료되면 Spring AOP가 동작하면서 @Transactional에 의해서 트랜잭션 관련되 AOP가 끝나는 시점에 트랜잭션 commit이 된다.그러면 JPA가 영속성 컨텍스트 flush 및 DB로 commit을 진행한다.
회원 조회 API
회원조회 V1
@GetMapping("/api/v1/members")
public List<Member> membersV1(){
return memberService.findMembers();
}
응답 값으로 엔티티를 직접 외부에 노출했다.
문제점
- 엔티티에 프레젠테이션 계층을 위한 로직이 추가된다. (@JsonIgnore)
- Spring은 기본적으로 jackson을 사용하기 때문에 @JsonIgnore를 통해 엔티티 단에서 원하지 않은 정보를 뺄수 있다.
- 그렇다면 다른 API를 만들 때 뺐던 정보가 필요하다면??
- 기본적으로 엔티티의 모든 값이 노출된다.
- 응답 스펙을 맞추기 위해 로직이 추가된다. (@JsonIgnore, 별도의 뷰 로직 등등)
- 실무에서는 같은 엔티티에 대해 API가 용도에 따라 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 프레젠테이션 응답 로직을 담기는 어렵다
- 엔티티가 변경되면 API스펙이 변한다.(name → username)
- 추가로 컬렉션을 직접 반환하면 향후 API스펙 변경의 확장성이 몹시 떨어진다.
(별도의 Result클래스 생성으로 해결 → ex: Response Object)- 만약 API 스팩에서 "Count" : {}를 추가해달라고 하면 이미 컬렉션이 반환되고 있는 Json에서 변경은 불가능하다.
결론
- API응답 스펙에 맞춰 별도의 DTO를 반환한다.
※ 참고
엔티티를 외부에 노출하지 말자!!
실무에서는 member 엔티티의 데이터가 필요한 API가 계속 증가하게 된다. 어떤 API는 name 필드가 필요 하지만, 어떤 API는 name 필드가 필요없을 수 있다. 결론적으로 엔티티 대신에 API 스펙에 맞는 별도의 DTO를 노출해야 한다.
회원 조회 API - V2
@GetMapping("/api/v2/members")
public Result memberV2(){
List<Member> findMembers = memberService.findMembers();
List<MemberDto> collect = findMembers.stream()
.map(m -> new MemberDto(m.getName()))
.collect(Collectors.toList());
return new Result(collect.size(), collect);
}
@Data
@AllArgsConstructor
static class Result<T>{
private int count;
private T data;
}
@Data
@AllArgsConstructor
static class MemberDto{
private String name;
}
- 엔티티를 DTO로 변환해서 반환한다.
- 엔티티가 변해도 API스펙이 변경되지 않는다.
- 추가로 Result class로 컬렉션을 감싸서 향후 필요한 필드를 추가할 수 있다.
- List DTO로 json 배열로 반환되기 때문에 확장성이 떨어진다. 그러므로 Result class와 같은 컬렉션으로 감싸서 반환하는 것이 훨씬 유연하다.