API 방식(CSR)은 클라이언트와 서버 간의 데이터 통신을 위해 RESTful API 또는 GraphQL을 사용해 클라이언트가 JSON 형식으로 데이터를 요청하고 UI를 동적으로 구성하는 반면, 템플릿 방식(SSR)은 서버에서 미리 HTML을 렌더링하여 클라이언트에 전달한다. API 방식이 보통의 방법이 된 이유는 프로젝트들이 커지면서 React, Vue.js와 같은 모던 프론트엔드 툴들이 API 중심으로 설계되어 유연한 사용자 경험을 제공하고, 마이크로서비스 아키텍처(MSA)와의 호환성 덕분에 서비스 간 독립적인 개발과 배포가 용이하기 때문입니다.
@PostMapping("/api/v1/members")
public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member) {
Long id = memberService.join(member);
return new CreateMemberResponse(id);
}
@Data
static class CreateMemberResponse {
public CreateMemberResponse(Long id) {
this.id = id;
}
private Long id;
}
반환 타입을 객체로 주기 위해 DTO를 컨트롤러 클래스 내부에 만들어 사용한다.
기본적으로 컨트롤러 클래스는 @RestController로 작성되었으며, @ResponseBody(객체 -> json) + @Controller라고 보면 된다. @Controller만 사용되었을 때는 view로직으로 넘어가기 위한 리턴 타입을 정의하게 된다. 예로 String을 받게 되면 template 이름을 나타내어 설정된 경로에 존재하는 템플릿으로 값들이 넘어가지만 RestController는 곧바로 데이터가 전송된다.
이때 우리가 커스텀하게 만든 객체를 반환타입으로 가져간다면 Jackson 라이브러리에 의해 json타입으로 변화되어 넘어간다. 이때 형식은 위와 같다.
단일 객체를 넘긴다면 {}안에 필드를 나열한다. 만약 리스트(여러 객체를 담음)를 반환한다면 []로 반환되고 그 안에 여러 {}가 존재하게 된다.(이는 문제가 된다.)
@RequestBody : json -> 객체 과정을 자동 지원@Valid : Entity 검증 자동문제가 되는 것은 파라미터에 Entity인 Member를 곧장 받아버리는데 있다. 우리는 반환타입을 커스텀한 객체를 사용했다. CreateMemberResponse는 엔티티 Member와 비슷하지만 해당 로직에서 전송할 데이터만을 담기 위해 더 작아진 모습이다.
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
@NotEmpty
private String name;
@Embedded
private Address address;
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
Member엔티티의 name 필드에 검증이 필요하여 @NotEmpty를 붙이고 @Valid로 검증하려고 한다. 이때 문제는 엔티티를 수정했다는 것이다. 엔티티는 프로젝트상에서 전역적이다. 한번 설계해낸 엔티티를 수정하는 것은 이 엔티티를 사용하는 여러 공간에 모두 영향을 주는 것이다. 저장로직 단 하나만을 위해 @NotEmpty를 함부로 다는 것은 좋지 않다.
그러므로 V2는 입력 파라미터로 받는 부분을 엔티티가 아닌 DTO로 바꾸는 것이다.
@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);
}
@GetMapping("/api/v1/members")
public List<Member> membersV1() {
return memberService.findMembers();
}
반환 타입이 위에서 설명했던 리스트이다. 이때 Postman으로 테스트 해보면 json 스펙이 깨지는 것을 볼 수 있다.
[
{
"id": 1,
"name": "userA",
"address": {
"city": "seoul",
"street": "yangcheon",
"zipcode": "12495"
},
"orders": [
{
"id": 1,
"member": {
"id": 1,
"name": "userA",
"address": {
"city": "seoul",
"street": "yangcheon",
"zipcode": "12495"
},
"orders": [
{
"id": 1,
위와 같이 리스트로 처음이 감싸지는 것을 볼 수 있고 orders와 같이 너무 많은 정보가 넘어오게 된다.
역시나 이를 보완하기 위해 커스텀하게 반환 객체를 만들어서 사용해주면 좋다.
@GetMapping("/api/v2/members")
public Result membersV2() {
List<Member> findMembers = memberService.findMembers();
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 T data;
}
@Data
@AllArgsConstructor
static class MemberDto {
private String name;
}
Result라는 객체를 사용하고 MemberDto를 통해 원하는 값만 들어오도록 해서 data 필드에 리스트를 넣어 json 스펙을 유지한 채 반환하도록 만든다.
반환과 입력에서 열심히 DTO를 활용하자.