실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 - 인프런 | 강의
스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., 본 강의는 자바 백엔드 개발의 실전 코스에 있는 활용2 강의 입니다. 스프링 부트와 J
www.inflearn.com
강의를 들으며 생각 정리 + "자바 ORM 표준 JPA 프로그래밍" 책 참고
앞서 개발한 웹 애플리케이션은 타임리프 뷰 템플릿을 사용한 서버 사이드 렌더링 기법이다. 즉, 서버 측에서 화면을 렌더링해서 클라이언트에 보내준다.
서버 사이드 렌더링 기법은 백엔드 개발자라면 반드시 익혀야 한다. 그러나 이번에는 Rest API 기법을 알아볼 것이다. Rest API는 서버 측에서는 화면을 렌더링하는 것이 아닌 화면을 구성하는데 필요한 데이터(JSON 등)를 클라이언트로 전송하고 화면을 렌더링하는 역할은 클라이언트에 맡기는 것이다. 즉, 클라이언트 사이드 렌더링을 하는 것이다.
최근에는 이처럼 프론트, 백 개발자가 협업하는 Rest API 기법이 많이 사용되기 때문에 역시 필수적으로 알아둬야 하는 기법이다. 지금부터 API 통신에 대해서 알아보자.
(코드는 지금까지 사용했던 코드를 그대로 사용한다.)
+) 앞으로의 내용은 스프링 MVC 개념이 많이 사용되기 때문에 스프링 MVC에 대한 이해가 필요하다.
회원 등록 API
회원 등록을 API 방식으로 처리해보자.
기존 방식
1. 클라이언트(HTML Form을 통해 요청 파라미터 전송)
2. 서버(회원 등록 후 화면 렌더링해서 전송)
3. 클라이언트(받은 화면 정보를 그대로 출력)
API 방식
1. 클라이언트(회원 정보를 json 형식으로 전송)
2. 서버(회원 등록 후 등록된 회원 정보를 json으로 전송)
3. 클라이언트(받은 회원 정보를 기반으로 화면 렌더링)
여기서는 1번 2번 과정만 알아볼 것이다. 3번 과정은 프론트 개발 분야이기 때문에 관심이 있다면 React, Vue.js 등을 알아보자.
1번 2번 과정의 경우 Postman으로 테스트할 것이다.
postman 설치 (https://www.getpostman.com)
</api/MemberApiController.java>
+) 기존 뷰 템플릿 컨트롤러와 API 전용 컨트롤러는 다른 패키지에 관리하는 것이 좋다. (공통 처리 관점)
@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
static class CreateMemberResponse {
private Long id;
public CreateMemberResponse(Long id) {
this.id = id;
}
}
}
<v1>
<@RestController>
@RestController는 @Controller에 @ResponseBody를 더한 애노테이션이라고 보면 된다. 반환한 객체를 일정 형식(여기서는 json)에 맞춰 HTTP 메시지 바디에 등록해 클라이언트에 전송한다.
<@RequestBody>
클라이언트에서 json 형식으로 데이터가 들어왔다고 가정한다. json 형식의 데이터를 @RequetBody가 알아서 해당 객체의 프로퍼티를 확인하여 알맞은 필드에 데이터 값을 저장해준다.
<@Valid>
요청 데이터를 받을 때 발생하는 문제 처리를 위한 애노테이션이다. 여기서는 회원 이름 중복 문제를 처리할 것이다. Member 엔티티 name 필드에 @NotEmpty를 추가하면 회원 이름 중복 시 오류를 출력해준다.
</domain/Member.java>
@NotEmpty
private String name;
<CreateMemberResponse>
리턴용 객체이다. 객체에 데이터를 저장하고 리턴해주면 알아서 json 형식으로 변경해 클라이언트에 보내준다.
<결과>
Postman을 사용해 json을 전송해보자.(위가 전송, 아래가 반환 값)
-> 서버 측에서는 객체로 데이터를 받아 로직 수행 후 객체를 리턴하지만 실제로 데이터 통신 시 json 형태로 주고 받는 것을 볼 수 있다.
<v1 방식(엔티티를 Request Body에 직접 매핑)의 문제점>
1. 엔티티에 API 검증을 위한 로직이 들어간다. (@NotEmpty)
-> 화면 렌더링 계층에서 필요한 로직이 핵심 엔티티에 포함되면 안된다. 엔티티는 핵심 비즈니스 로직만을 담고 있어야 한다.
2. 실무에서는 회원 엔티티를 위한 API가 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 모든 요청 요구사항을 담기는 어렵다.
-> 엔티티가 API에 의존할 수 있다.
3. 엔티티가 변경되면 API 스펙이 변한다.
-> 만약 회원 엔티티의 필드 name을 username으로 변경했다면 API 요청 데이터 역시 "name":"hello"가 아닌 "username":"hello"가 되야 한다. 엔티티 변경에 따라 API도 변해야 하는 번거로움이 있다.
결론 : 엔티티와 API가 변경에 대해서 서로 영향을 주지 않는 것이 좋다.
-> API 요청 스펙에 맞추어 별도의 DTO를 파라미터로 받자.
</api/MemberApiController.java - 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);
}
@Data
static class CreateMemberRequest {
@NotEmpty
private String name;
}
CreateMemberRequest를 Member 엔티티 대신에 RequestBody와 매핑한다.
1. 엔티티와 화면 렌더링 계층을 위한 로직을 분리할 수 있다.
-> @NotEmpty를 DTO에 붙일 수 있다
2. 엔티티와 API 스펙을 명확하게 분리할 수 있다.
-> Member 엔티티에 대한 다양한 API에 대해서도 전용 DTO를 만들면 되기 때문에 엔티티를 수정할 일이 없다.
3. 엔티티가 변해도 API 스펙이 변하지 않는다.
-> Member 엔티티의 name을 username으로 변경해도 DTO에서 name으로 받고 Member의 username으로 넘겨주면 된다. 즉, 중간 처리 과정이 있기 때문에 엔티티는 엔티티대로 자유롭게 변경해도 된다.
<정리>
다양한 종류의 데이터를 담고 있는 여러 API들을 각각에 알맞은 DTO에 담고 컨트롤러에서 DTO 데이터를 알맞게 엔티티에 전달하는 방식을 사용해야 한다.
+) 참고 : 이너 클래스
예제에서는 DTO를 이너 클래스로 사용했다. 물론 따로 파일을 만들어서 DTO를 관리해도 좋다. 따로 파일을 만들게 되면 다른 컨트롤러에서도 자유롭게 사용할 수 있다. 이너 클래스의 경우 해당 클래스 안에서만 한정적으로 사용한다는 의미를 부여할 수 있다.
또한 이너 클래스는 항상 static 클래스를 사용해야 한다. CreateMemberRequest의 경우처럼 외부에서 생성하는 경우 static 클래스가 아니면 생성할 수 없기 때문이다. (이 밖에 여러 이점들이 있으니 꼭 static을 사용하자)
회원 수정 API
</api/MemberApiController.java - 회원 수정 추가>
@PatchMapping("/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(id, findMember.getName());
}
@Data
static class UpdateMemberRequest {
private String name;
}
@Data
@AllArgsConstructor
static class UpdateMemberResponse {
private Long id;
private String name;
}
<회원 수정도 요청, 응답용 DTO를 추가>
+) 참고 : 롬복을 사용할 때, 핵심 엔티티에는 보통 @Getter만 사용하는 것이 좋지만, 이처럼 단순 데이터 전송용 DTO에는 @Data같은 애노테이션을 자유롭게 사용해도 좋다.
<@PatchMapping>
회원 정보의 일부를 수정하는 것이기 때문에 Patch 메서드를 사용한다.
<수정 메서드>
</service/MemberService.java - update 메서드 추가>
@Transactional
public void update(Long id, String name) {
Member member = memberRepository.findOne(id);
member.setName(name);
}
데이터 수정은 이렇게 영속성 컨텍스트 변경 감지 기능을 사용하는 것이 좋다.
merge 메서드는 모든 필드 값을 전부 변경해야 하기 때문에 변경되지 않는 필드들은 null 값을 갖게 된다.
+) update와 find 분리
코드를 보면 변경 감지로 엔티티를 수정한 뒤, 다시 id 값을 통해 엔티티를 조회한다.
memberService.update(id, request.getName());
Member findMember = memberService.findOne(id);
update 메서드에서 Member 엔티티를 반환해도 되지만, 이러면 update 메서드가 엔티티를 조회한다는 성격도 갖고 있다.
변경 메서드와 조회 메서드를 명확하게 분리해서, 유지보수를 쉽게 하기 위해 위와 같은 로직을 사용했다.
회원 조회 API
</api/MemberApiController.java - 회원 조회 API v1 추가>
@GetMapping("/api/v1/members")
public List<Member> membersV1() {
return memberService.findMembers();
}
v1 : 응답 값으로 엔티티를 직접 외부에 노출
<문제점>
1. 응답 스펙을 맞추기 위해 로직이 추가된다.
-> 만약 조회시 Member와 연관관계인 Order를 노출하고 싶지 않다면 Order 필드에 @JsonIgnore 로직을 추가해야 한다.
2. 실무에서는 같은 엔티티에 대해 API가 용도에 따라 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 응답 로직을 담기는 어렵다.
-> 어떤 API는 Order를 노출하고 싶다면? -> 복잡해진다.
3. 컬렉션을 직접 반환하면 향후 API 스펙을 변경하기 어렵다.
-> List<Member>를 그대로 반환하면 다음과 같이 [ ]로 둘러싸인 배열 형태로 반환된다.
[
{
"id": 1,
"name": "member1",
"address": {
"city": "Seoul",
"street": "123",
"zipcode": "12345"
},
"orders": []
},
...
]
이런 형태는 [ ]로 스펙이 굳어버려 확장을 할 수 없다. 만약 컬렉션 크기인 count를 데이터에 추가하고 싶다면 위 경우 불가능하다.
이처럼 엔티티를 직접 반환하면 또다시 문제점들이 생긴다. 또한, 엔티티 전부가 아닌 필요한 정보만을 반환하는 경우가 많기 때문에 별도 DTO를 사용하는 것이 맞다.
</api/MemberApiController.java - 회원 조회 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);
}
@Data
@AllArgsConstructor
static class Result<T> {
private T data;
}
@Data
@AllArgsConstructor
static class MemberDto {
private String name;
}
엔티티의 name만을 반환한다고 하자.
<MemberDto>
엔티티를 DTO로 변환해서 반환한다. (List<Member> -> List<MemberDto>)
<Result<T>>
이제 List<MemberDto>에는 보내고자 하는 데이터가 들어가 있다. 그러나 앞서 말한 컬렉션을 직접 반환하면 이후에 확장하기 어렵다는 문제점으로 인해 추가로 Result 클래스로 컬렉션을 감싸서 반환해준다.
<결과>
{
"data": [
{
"name": "member1"
},
{
"name": "member2"
}
]
}
이처럼 Result로 감쌌기 때문에 [ ]로 닫힌 형태가 아닌 { [ ] } 형태이기 때문에 만약 count 값을 추가한다고 하면
@Data
@AllArgsConstructor
static class Result<T> {
private int count;
private T data;
}
{
"count" : 4
"data": [
{
"name": "member1"
},
{
"name": "member2"
}
]
}
이렇게 쉽게 추가할 수 있다.
결론 : API 개발은 DTO가 필수!
'java > jpa' 카테고리의 다른 글
[JPA] API 개발 고급 - 지연 로딩과 조회 성능 최적화 (0) | 2021.05.18 |
---|---|
[JPA] API 개발 고급 - 준비 (0) | 2021.05.18 |
[JPA] 웹 계층 개발 (0) | 2021.04.20 |
[JPA] 주문 도메인 개발 (0) | 2021.02.14 |
[JPA] 상품 도메인 개발 (0) | 2021.02.13 |