이번 강의는 API 개발과 성능 최적화에 초점을 맞춘 강의다. 최근에는 주로 REST API로 개발하기 때문에 이전 [스프링 부트와 JPA 활용1] 편에서 구현해 둔 핵심 기능을 REST API로 구현해 보고, 스프링 부트와 JPA를 사용할 때의 API 개발 실무 노하우를 학습한다.
참고
템플릿 엔진을 사용해 렌더링 하는 Controller와 API 스타일의 Controller를 둘 다 사용한다면 패키지를 분리하는 게 낫다.
- 예외 처리 등을 공통으로 하는 경우가 많은데, 화면의 경우 템플릿 엔진에서 문제가 생기면 공통 에러 화면(HTML)이 나오게 된다. 그러나 API의 경우는 공통 에러용 JSON API 스펙이 나가야 한다.
- 따라서 공통 처리 같은 관점에서 조금씩 차이가 발생하기 때문에 패키지를 분리해 두는 게 더 편하다.
이번 섹션의 결론은 하나다. 핵심 엔티티를 파라미터로 노출하거나 반환하지 말고 꼭 DTO를 사용하자.
1. 회원 등록 API
등록 V1
요청 값으로 Member 엔티티를 RequestBody에 직접 매핑하는 방식을 사용한다. 코드로 보면 아래와 같다.
@RestController
@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
@AllArgsConstructor
static class CreateMemberResponse {
private Long id;
}
}
위와 같이 핵심 엔티티를 파라미터에 노출하는 방식을 사용하면 다양한 문제점이 생긴다.
- 파라미터를 보면, Member 엔티티를 RequestBody로 받아오고 @Valid 애노테이션을 통해 검증한다.
- 따라서 API 요구사항에 따라 Member 엔티티의 필드에 @NotEmpty 같은 검증 로직을 추가해야 한다.
- 실무에서는 Member 엔티티를 위한 API가 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 모든 요청 요구사항을 담기는 어렵다.
- ex. 어떤 API에선 회원 이름이 NULL이면 안 되고, 어떤 API에선 NULL이어야 될 수도 있다.
- 엔티티에 프레젠테이션 계층을 위한 로직이 추가된다.
- 엔티티가 변경되면 API 스펙이 변한다.
- ex. Member 엔티티에서 name이 username으로 변경되면 API 스펙 자체가 변경돼야 한다.
@Entity
@Getter @Setter
public class Member { // 엔티티 직접 반환 X
@Id
@GeneratedValue
@Column(name = "member_id")
private Long id;
@NotEmpty // NULL 금지 -> 다른 API에서는 NULL로 써야 될 수도 있어서 문제가 됨
private String name; // name이 username으로 바뀌면 API 스펙 자체가 변경돼야 함
@Embedded
private Address address;
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
등록 V2
따라서 엔티티 자체를 노출하기보단, API 요청 스펙에 맞춰 별도의 DTO를 파라미터로 받는 방식을 사용해야 한다. 아래 코드를 보면 CreateMemberRequest(DTO)를 Member 엔티티 대신 RequestBody와 매핑한다. 이 방식을 사용하면,
- 엔티티와 프레젠테이션 계층을 위한 로직을 분리할 수 있다.
- 엔티티와 API 스펙을 명확하게 분리할 수 있다.
- 요청 DTO인 CreateMemberRequest class와 응답 DTO인 CreateMemberResponse class에 받아올 값과 응답할 값을 표시한다. 추가하거나 삭제할 게 있더라도 DTO를 수정하면 된다. 따라서 엔티티가 변해도 API 스펙이 변하지 않는다.
@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 CreateMemberRequest {
@NotEmpty
private String name;
}
@Data
@AllArgsConstructor
static class CreateMemberResponse {
private Long id;
}
}
참고
실무에서는 엔티티를 API 스펙에 노출하면 안 된다.
2. 회원 수정 API
회원 수정도 회원 등록과 동일하게 DTO를 요청 파라미터에 매핑한다. 이때, 변경 감지(Dirty Checking)를 사용해 데이터를 수정하도록 MemberService에 업데이트 비즈니스 로직을 추가한다.
@RestController
@RequiredArgsConstructor
public class MemberApiController {
private final MemberService memberService;
...
/**
* 수정 API
*/
@PostMapping("/api/v2/members/{id}")
public UpdateMemberResponse updateMemberV2(@PathVariable("id") Long id,
@RequestBody @Valid UpdateMemberRequest request) {
memberService.update(id, request.getName());
Member findMember = memberService.findMember(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;
}
}
///
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
...
/**
* 회원 수정
*/
@Transactional
public void update(Long id, String name) {
Member member = memberRepository.findOne(id);
member.setName(name);
}
}
참고
HTTP Method 중 PUT은 전체 업데이트를 할 때 사용하는 게 맞다. 부분 업데이트를 하려면 PATCH나 POST를 사용하는 게 REST 스타일에 맞다.
3. 회원 조회 API
조회 V1
응답 값으로 엔티티 리스트를 직접 외부에 노출한다. 코드로 보면 아래와 같다.
@RestController
@RequiredArgsConstructor
public class MemberApiController {
private final MemberService memberService;
...
//조회 V1: 안 좋은 버전, 모든 엔티티가 노출
//@JsonIgnore -> 이건 정말 최악, api가 이거 하나인가! 화면에 종속적이지 마라!
@GetMapping("/api/v1/members")
public List<Member> membersV1() {
return memberService.findMembers();
}
}
위와 같은 방식을 사용하면 다양한 문제점이 생긴다.
- 기본적으론 엔티티의 모든 값이 노출되기 때문에 응답 스펙을 맞추려면 엔티티의 필드에 @JsonIgnore 애노테이션을 붙이거나 별도의 뷰 로직을 만들어야 한다. 이때, @JsonIgnore을 사용하면 다른 API에서도 해당 필드를 응답으로 보낼 수 없다는 큰 문제가 생긴다.
- 추가로, 컬렉션을 직접 반환하면 향후 API 스펙을 변경하기가 어렵다는 문제도 있다.
- 반환 결과를 보면 List만 보이기 때문에 List 이외에 다른 필드(ex. count)를 추가할 수 없게 된다.
- 회원 등록에서 적은 문제점도 똑같이 반영된다.
@Entity
@Getter @Setter
public class Member { // 엔티티 직접 반환 X
...
@JsonIgnore // JSON에서 무시(제외) -> 다른 API에서도 못 쓰는 문제
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
참고
엔티티를 외부에 노출하지 말자.
실무에서는 Member 엔티티의 데이터가 필요한 API가 계속해서 증가한다. 어떤 API는 name 필드가 필요하지만, 어떤 API는 name 필드가 필요 없을 수 있다. 결론적으로 엔티티 대신에 API 스펙에 맞는 별도의 DTO를 노출해야 한다.
조회 V2
응답 값으로 엔티티가 아닌 별도의 DTO를 사용한다. DTO에 List<Member> 컬렉션을 추가하기보단, Result 클래스로 컬렉션을 감싸 향후 필요한 필드를 추가할 수 있도록 구현해야 한다. 이 방식을 사용하면, 엔티티가 변해도 API 스펙이 변경되지 않는다.
@RestController
@RequiredArgsConstructor
public class MemberApiController {
private final MemberService memberService;
...
@GetMapping("/api/v2/members")
public Result memberV2() {
List<Member> findMembers = memberService.findMembers();
// 엔티티 -> DTO 변환
List<MemberDto> collect = findMembers.stream()
.map(m -> new MemberDto(m.getName()))
.collect(Collectors.toList());
return new Result(collect);
}
@Data
@AllArgsConstructor
static class Result<T> {
// private int count;
private T data;
}
@Data
@AllArgsConstructor
static class MemberDto {
private String name;
}
}