9 분 소요

📌 Topic

  • Web 확장 - 도메인 클래스 컨버터
  • Web 확장 - 페이징과 정렬
  • 스프링 데이터 JPA 구현체 분석
  • 새로운 엔티티를 구별하는 방법

01. Web 확장 - 도메인 클래스 컨버터

HTTP 파라미터로 넘어온 엔티티의 아이디로 엔티티를 찾아서 바인딩해주는 기술.

01-1. 도메인 클래스 컨버터 사용 전

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @PostConstruct
    public void init() {
        memberRepository.save(new Member("userA"));
    }

    // AS-IS
    @GetMapping("/members/{id}")
    public String findMember(@PathVariable("id") Long id) {
        Member member = memberRepository.findById(id).get();
        return member.getUserName();
    }
}

기본적으로 우리가 ID 기반 API를 설계 할때의 소스는 위와 같다.
파라미터로 특정 Id 값을 받은 후 해당 값을 서비스 레이어에 전달하여 값을 조회.

01-2. 도메인 클래스 컨버터 사용 후

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @PostConstruct
    public void init() {
        memberRepository.save(new Member("userA"));
    }

    // AS-IS : 도메인 클래스 컨버터 사용 전
    @GetMapping("/members/{id}")
    public String findMember(@PathVariable("id") Long id) {
        Member member = memberRepository.findById(id).get();
        return member.getUserName();
    }

    // TO-BE : 도메인 클래스 컨버터 사용 후
    // @Pathvariable 다음에 id가 아닌 Member 엔티티 매핑
    @GetMapping("/members2/{id}")
    public String findMember2(@PathVariable("id") Member member) {
        return member.getUserName();
    }
}
  • HTTP 요청은 ID를 받지만 도메인 클래스 컨버터가 중간에 동작하여 회원 엔티티 객체 반환
  • 도메인 클래스 컨버터는 리포지토리를 사용하여 엔티티를 찾아 반환
  • 스프링 데이터 JPA가 기본적으로 제공해주는 기능
  • 도메인 클래스 컨버터를 사용하는 것은 권장하지 않음

도메인 클래스 컨버터를 사용하여 엔티티를 받는 경우 단순 조회용으로만 사용해야 한다. (트랜잭션이 없는 범위에서 엔티티를 조회, 엔티티를 변경해도 DB 반영이 되지 않는다)

02. Web 확장 - 페이징과 정렬

스프링 데이터 JPA는 페이징과 정렬을 웹에서 간편하게 사용하기 위한 다양한 기능을 제공한다.

02-1. 페이징 API 생성

package study.datajpa.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import study.datajpa.entity.Member;
import study.datajpa.repository.MemberRepository;

import javax.annotation.PostConstruct;

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    // 스프링 애플리케이션 로드 시점에 최초 1번 실행
    @PostConstruct
    public void init() {
        for (int i = 0; i < 100; i++) {
            memberRepository.save(new Member("user" + i, i)); // userName, age
        }
    }

    @GetMapping("/members")
    public Page<Member> list(Pageable pageable) {
        return memberRepository.findAll(pageable);
    }
}

참고 : 스프링 데이터 JPA 페이징과 정렬

파라미터로 Pageable 인터페이스를 받고 반환 값으로 Page 인터페이스를 반환 해주는 API를 생성 하였다. 또한 현재 findAll(pageable) 메서드를 호출하고 있는데 해당 메서드를 한번 확인 해보자.

@NoRepositoryBean
public interface PagingAndSortingRepository<T, ID> extends CrudRepository<T, ID> {

	Iterable<T> findAll(Sort sort);

	Page<T> findAll(Pageable pageable);
}

스프링 데이터 JPA는 기본적인 CRUD 기능에 더하여 페이징, 정렬 기능을 제공한다고 이전에 언급한 적이 있다. findAll() 메서드도 마찬가지로 Pageable 인터페이스를 파라미터로 받아 처리 해주는 역할을 하는 함수인데 어떤 결과가 나오는지 확인 해보자.

{
  "content": [
    {
      "createdDate": "2022-06-24T22:08:47.68",
      "lastModifiedDate": "2022-06-24T22:08:47.68",
      "createdBy": "6fd9f291-7fc8-40f3-adda-a81478e63fdb",
      "lastModifiedBy": "6fd9f291-7fc8-40f3-adda-a81478e63fdb",
      "id": 1,
      "userName": "user0",
      "age": 0,
      "team": null
    },
    {
      "createdDate": "2022-06-24T22:08:47.745",
      "lastModifiedDate": "2022-06-24T22:08:47.745",
      "createdBy": "97130486-d014-4a7d-aa80-fff7c08dfaf9",
      "lastModifiedBy": "97130486-d014-4a7d-aa80-fff7c08dfaf9",
      "id": 2,
      "userName": "user1",
      "age": 1,
      "team": null
    },
    {
      "createdDate": "2022-06-24T22:08:47.752",
      "lastModifiedDate": "2022-06-24T22:08:47.752",
      "createdBy": "aa7de174-d066-417d-80d0-57e8aabc536b",
      "lastModifiedBy": "aa7de174-d066-417d-80d0-57e8aabc536b",
      "id": 3,
      "userName": "user2",
      "age": 2,
      "team": null
    }
    //..중략
  ],
  "pageable": {
    "sort": {
      "empty": true,
      "sorted": false,
      "unsorted": true
    },
    "offset": 0,
    "pageNumber": 0,
    "pageSize": 20,
    "unpaged": false,
    "paged": true
  },
  "last": false,
  "totalPages": 5,
  "totalElements": 100,
  "size": 20,
  "number": 0,
  "sort": {
    "empty": true,
    "sorted": false,
    "unsorted": true
  },
  "first": true,
  "numberOfElements": 20,
  "empty": false
}

Pageable 객체를 파라미터로 받은 호출 결과는 다음과 같다. 여기서 우리가 짚고 넘어가야 하는 부분은 페이징에 대한 어떠한 설정 값을 지정하지 않았음에도 디폴트(default) 값으로 20개의 데이터와 추가적인 페이징 관련 정보가 출력 되었다는 점이다.

💡 어떻게 바인딩이 되었는가?

컨트롤러에서 HTTP 파라미터들이 바인딩 될 때 Pageable이 존재하면 PageRequest라는 객체를 생성해서 값을 채운 후 파라미터에 값을 셋팅(Injection) 해준다.

그렇다면 이번에는 정렬, 사이즈에 대한 옵션을 쿼리 스트링 값으로 지정 해보자.
또한 스프링 데이터 JPA의 페이징은 ‘0’ 부터 시작한다는 점도 까먹지 말자

localhost:8080/members?page=0
localhost:8080/members?page=0&size=10
localhost:8080/members?page=0&size=10&sort=id,desc
localhost:8080/members?page=0&size=10&sort=id,desc&sort=userName,asc
  1. 첫 번째 페이지의 데이터를 출력
  2. 첫 번째 페이지의 데이터를 출력하는데 10개만
  3. 첫 번째 페이지의 데이터를 출력하는데 10개만 id 내림차순
  4. 첫 번째 페이지의 데이터를 출력하는데 10개만 id 내림차순 이름 오름차순

02-2. 페이지 정리

  • page
    • 현재 페이지, 0부터 시작
  • size
    • 한 페이지에 노출할 데이터 건수
  • sort
    • 정렬 조건(기본: asc)

02-3. 페이지 디폴트 값을 변경하고 싶다면?

글로벌 설정과 컨트롤러 어노테이션을 사용하는 방식이 존재

# application.yml
spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/datajpa
    username: sa
    password:
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true # format query
  data:
    web:
      pageable:
        default-page-size: 10 # (추가) default page size
        max-page-size: 200 # (추가) default max page size

logging.level:
  org.hibernate.SQL: debug
# org.hibernate.type: trace
  • 페이지 기본 출력 값은 10으로 설정
  • 페이지 최대 데이터 값은 200으로 설정

다음으로는 설정 파일이 아닌 컨트롤러에서 직접 어노테이션을 사용하여 사이즈를 조절하는 방식이다.

@RestController
@RequiredArgsConstructor
public class Member {

    private final MemberRepository memberRepository;

    @PostConstruct
    public void init() {
        for (int i = 0; i < 100; i++) {
            memberRepository.save(new Member("user" + i, age)); // userName, age
        }
    }

    // 페이징 처리 + 전체 회원 조회
    public Page<Member> findMemberByPage(@PageableDefault(size = 10)Pageable pageable) {
        return memberRepository.findAll(pageable);
    }
}
  • 컨트롤러에 직접 적용하는 것이 설정 파일보다 우선순위가 높다
  • 기본 값으로 10개의 데이터를 반환 해주는 상황

02-4. 접두사

/members?member_page=0&order_page=1
public String list(
    @Qualifier("member") Pageable memberPageable,
    @Qualifier("order") Pageable orderPageable, ....
)

한 페이지에 페이징 처리가 둘 이상이라면 접두사를 사용한다

  • 페이징 정보가 둘 이상이면 접두사로 구분
  • @Qualifier에 접두사명 추가 “{접두사명}_xxx”
  • 필요할 때 사용하면 된다

02-5. Page 내용을 DTO로 변환

중요) 절대 엔티티를 컨트롤러에서 반환 하면 안된다

우선 엔티티를 외부에 노출하면 안되는 이유는 엔티티를 변경 하였을 때 API 스펙이 변경되는 문제가 발생하기 때문이다. 예를 들어 해당 API를 사용하는 다양한 서비스가(지니 뮤직, 지니 GIGA 지니) 있다고 가정을 해보자. 프론트앤드 개발자 입장에서는 단순히 API 규격서(Swagger, asciidoc)를 보고 서비스 로직을 작성을 하는데 API 스펙이 변하면 추가적인 side effect가 발생하기 때문에 엔티티 반환은 절대 안된다.

즉, API 설계를 할 때는 항상 엔티티를 DTO로 변환하여 반환 해주어야 한다는 의미다.

@GetMapping("/members")
public Page<MemberDto> list(@PageableDefault(size = 5) Pageable pageable) {
    // Member Entity -> Member DTO
    Page<Member> memberPagingList = memberRepository.findAll(pageable);
    Page<MemberDto> memberList  = memberPagingList.map(m -> new MemberDto(m.getId(), m.getUserName(), null));
    return memberList;

    // Member Entity -> Member DTO
    // return memberRepository.findAll(pageable)
    //                        .map(m -> new MemberDto(m.getId(), m.getUserName(), null));
}
@Data
public class MemberDto {

    private Long id;
    private String userName;
    private String teamName;

    public MemberDto(Long id, String userName, String teamName) {
        this.id = id;
        this.userName = userName;
        this.teamName = teamName;
    }

    // MemberDto -> Member 엔티티를 바라본다
    public MemberDto(Member member) {
        this.id = member.getId();
        this.userName = member.getUserName();
    }
}
  • Member 엔티티를 MemberDto로 변환
  • 실제 반환되는 데이터는 엔티티가 아닌, DTO가 반환 됨
  • DTO는 Entity를 바라봐도 되지만, 엔티티는 DTO를 바라보지 않는 것이 좋음

02-6. 페이징을 1부터 시작하고 싶은 경우

  1. Pageable, Page를 사용하지 않고 직접 정의(구현)하여 사용
  2. spring.data.web.pageable.one-indexed-parameters를 true로 설정
spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/datajpa
    username: sa
    password:
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
  data:
    web:
      pageable:
        default-page-size: 10
        max-page-size: 200
        one-indexed-parameters: true # 1부터 시작하도록 지정
{
  "content": [
    {
      "id": 1,
      "userName": "user0",
      "teamName": null
    },
    {
      "id": 2,
      "userName": "user1",
      "teamName": null
    }
  ],
  "pageable": {
    "sort": {
      "empty": true,
      "sorted": false,
      "unsorted": true
    },
    "offset": 0,
    "pageNumber": 0,
    "pageSize": 5,
    "paged": true,
    "unpaged": false
  },
  "last": false,
  "totalElements": 100,
  "totalPages": 20,
  "size": 5,
  "number": 0,
  "sort": {
    "empty": true,
    "sorted": false,
    "unsorted": true
  },
  "first": true,
  "numberOfElements": 5,
  "empty": false
}
  • 하지만 해당 옵션을 사용하는 경우 pageable 값이 맞지 않는다
  • 웬만하면 page 0부터 시작하는 것을 권장

03. 스프링 데이터 JPA 분석

이번 시간에는 스프링 데이터 JPA의 동작 메커니즘에 대해 분석 해보자.

03-1. 스프링 데이터 JPA 구현체 분석

  • 스프링 데이터 JPA가 제공하는 공통 인터페이스 구현체
  • org.springframework.data.jpa.repository.support.SimpleJpaRepository

SimpleJpaRepository

@Repository
@Transactional(readonly = true)
public class SimpleJpaRepository<T, ID> ... {

    @Transactional
    public <S extends T> S save(entity) {
        if (entityInformation.isNew(entity)) {
            em.persist(entity);
            return entity;
        } else {
            return em.merge(entity);
        }
        //...
    }
}
  • @Repository 적용
    • JPA 예외를 스프링이 추상화한 예외로 변환
  • @Transactional 트랜잭션 적용

    • JPA의 모든 변경은 트랜잭션 안에서 동작
    • 스프링 데이터 JPA는 변경(등록, 수정, 삭제) 메서드를 트랜잭션 처리
    • 서비스 계층에서 트랜잭션을 시작하지 않으면 리파지토리에서 트랜잭션 시작
    • 서비스 계층에서 트랜잭션을 시작하면 리파지토리는 해당 트랜잭션을 전파 받아 사용
    • 이러한 이유로 스프링 데이터 JPA를 사용할 때 트랜잭셕 없이 등록 변경이 가능했음
  • @Transactional(readOnly = true)

    • 데이터를 단순히 조회만 하고 변경하지 않는 트랜잭션에서 readOnly = true
      옵션을 사용하면 플러시를 생략하여 약간의 성능 향상을 얻을 수 있음
    • 조회의 경우 DB 값을 변경하는 것이 아니기에 위와 같이 최적화가 가능
  • save() 메서드
/*
  * (non-Javadoc)
  * @see org.springframework.data.repository.CrudRepository#save(java.lang.Object)
  */
@Transactional
@Override
public <S extends T> S save(S entity) {

    Assert.notNull(entity, "Entity must not be null.");

    if (entityInformation.isNew(entity)) {
        em.persist(entity);
        return entity;
    } else {
        return em.merge(entity);
    }
}

참고 : Spring Data JPA를 통한 CRUD 방법에 관하여..(feat 변경감지)

  • 새로운 엔티티인 경우 persist
  • 새로운 엔티티가 아닌 경우 merge 수행
    • DB에서 값을 가져와서 해당 값을 파라미터로 받은 값으로 교체
    • 준영속 상태의 엔티티를 영속 상태로 변경할 때 사용이 되는 기능
    • DB select(조회)가 한 번 나가는게 단점
    • 가급적이면 merge를 사용하면 안되고, 변경 감지를 사용해야 한다

즉, 영속화 객체(Transient) 객체인 경우는 persist를 호출하고, 준영속 객체(detached)인 경우에는 기본적으로 merge를 호출하는 메커니즘을 가지고 있다. 중요한 부분은 persist, merge 후에 웬만하면 파라미터로 받은 값을 사용하지 않고 return 된 값을 사용하는 습관을 기르는 것이 좋다.

04. 새로운 엔티티를 구별하는 방법

이전 시간에 save() 메서드를 호출하는 경우
새로운 엔티티는 persist, 기존 엔티티는 merge가 된다 하였다.
이번 시간에는

04-1. 엔티티 구별 기본 전략

/*
  * (non-Javadoc)
  * @see org.springframework.data.repository.CrudRepository#save(java.lang.Object)
  */
@Transactional
@Override
public <S extends T> S save(S entity) {

    Assert.notNull(entity, "Entity must not be null.");

    if (entityInformation.isNew(entity)) {
        em.persist(entity);
        return entity;
    } else {
        return em.merge(entity);
    }
}
  • 식별자가 객체일 때 null로 판단
  • 식별자가 자바 기본 타입일 때 ‘0’으로 판단
  • Persistable 인터페이스를 구현해서 판단 로직 변경 가능

04-2. Item 엔티티 및 리포지토리 생성

persist, merge 테스트를 위해 엔티티, 리포지토리, 테스트코드 작성

package study.datajpa.entity;

import lombok.Getter;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
@Getter
public class Item {

    @Id
    @GeneratedValue
    private Long id;

}
  • @GeneratedValue persist를 하는 순간 셋팅이 된다
package study.datajpa.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import study.datajpa.entity.Item;

public interface ItemRepository extends JpaRepository<Item, Long> {

}
package study.datajpa.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import study.datajpa.entity.Item;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class ItemRepositoryTest {

    @Autowired ItemRepository itemRepository;

    @Test
    public void save() {
        //given
        Item item = new Item();
        itemRepository.save(item);

        //when

        //then
    }
}
  • 앞서 말했지만 @Transactional 어노테이션이 없어도 insert가 된다
  • save() 메서드 내부에 해당 어노테이션이 존재함

04-3. 문제점

만약 Item 엔티티의 기본키 값을 String으로 설정하는 경우 어떻게 될까?

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Item {

    @Id // @GeneratedValue
    private String id;

    public Item(String id) {
        this.id = id;
    }
}
  • @GeneratedValue 어노테이션을 잠시 주석 처리
  • id 값을 기본 타입으로 설정 후 생성자로 값을 셋팅 한다
@Test
public void save() {
    //given
    Item item = new Item("A");
    itemRepository.save(item);

    //when

    //then
}
  • 생성자의 인수로 ‘A’를 셋팅
  • 여기서 문제가 발생한다, 아래 내용을 한번 확인 해보자

스프링 데이터 JPA의 save는 새로운 엔티티인지 그렇지 않은지에 따라서 persist, merge 메서드를 호출한다 하였다. 조건은 식별자가 null인 경우 새로운 객체, 식별자가 기본 타입인 경우 기존에 존재하는 엔티티로 판단, 하지만 현재 ID값을 String으로 선언하였고 값까지 셋팅 해주었기 때문에 merge 메서드가 호출이 되는 상황이 발생한다.

@GeneratedValue를 사용 할 수 없는 상황이라면?

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Item implements Persistable<String> {

    @Id // @GeneratedValue
    private String id;

    @CreatedDate
    private LocalDateTime createdDate;

    public Item(String id) {
        this.id = id;
    }

    // Persistable의 isNew() 메서드를 재정의하여 사용
    @Override
    public boolean isNew() {
        return createdDate == null;
    }
}
  • Persistable 인터페이스를 구현해 사용하면 된다
  • createdDate가 null인 경우(미존재 엔티티), 아닌 경우(존재 엔티티)로 구분

참고 자료

댓글남기기