Spring Data JPA Part.2

dev_314·2023년 4월 2일
0

JPA - Trial and Error

목록 보기
13/16

사용자 정의 Repository

JpaRepository를 사용하는 상황에서, 다른 종류의 Repository도 같이 사용하고 싶을 수 있다.

다음과 같이 사용할 수 있다.

1. Repository 인터페이스(A) 정의하기

// 순수 JPA로 구현한 Repository도 같이 사용하고 싶은 상황
public interface MemberRepositoryCustom {

    List<Member> findMemberCustom();
}

2. Repository 구현체 만들기 (A의 구현체)

@Repository
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom{

    private EntityManager em;
    
    @Override
    public List<Member> findMemberCustom() {
        return em.createQuery(
                "SELECT m FROM Member m",
                Member.class
        ).getResultList();
    }
}

3. 기존 Repository가 상속받도록 하기 (A 상속 받기)

package doodlin.greeting.test;

import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
	...
}

순수 자바 문법 관점에서는 불가능하지만, Spring Data의 도움을 받아서 MemberRepository에서 MemberRepositoryCustom의 구현체MemberRepositoryImpl에 구현된 내용을 사용할 수 있다.

주의사항

  1. QueryDSL, JDBC Template을 주로 이러한 방식을 통해 사용한다.
  2. 구현체의 이름은 반드시 접미사 Impl로 끝나야 한다.
    • 옵션으로 변경 가능하긴 함
    • 인터페이스 이름은 상관 X
  3. 항상 사용자 정의 레포지토리가 필요한 건 아니다.
    • 그냥 별도의 Repository를 만들어서 Bean으로 등록한 뒤 DI해서 사용해도 된다.
    • 순수 Entity만 다루는 Repository와, API 맞춤 Repository는 분리하여 관리하는게 좋다 (유지보수 측면).

Auditing

Entity 생성 변경 날짜, 생성 변경 인물을 기록하고 싶다.

순수 JPA

@MappedSuperclass
public class BaseEntity {

    @Column(updatable = false)
    private LocalDateTime createdDate;
    private LocalDateTime updatedDate;

    @PrePersist // @PostPersist
    public void prePersist() {
        LocalDateTime now = LocalDateTime.now();
        createdDate = now;
        updatedDate = now;
    }

    @PreUpdate // @PostUpdate
    public void preUpdate() {
        updatedDate = LocalDateTime.now();
    }
}

// Entity
@Entity
public class Member extends BaseEntity {
	...
}

Spring Data

설정

@EnableJpaAuditing
@SpringBootApplication
public class DataJpaApplicaton {
	
    public static void main(String[] args) {...}
    
    @Bean
    public AuditorAware<String> auditorProvider() {
    	return () -> Optional.of(UUID.randomUUID().toString());
    }
}

auditorProvider를 통해 생성자, 변경자 필드에 어떤 값이 들어갈 지 설정할 수 있다.
보통은 Session ID를 넣는다.

Spring 기본 정책상, 생성 당시에 변경 날짜, 변경자도 같이 초기화 하는데, 옵션을 통해 null이 들어가도록 할 수 있다.

@EnableJpaAuditing(modifyOnCreate = false) // (권장 X).

BaseEntity

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;
    
    @CreatedBy
    @Column(updatable = false)
    private String createdBy;
    
    @LastModifiedBy
    private String lastModifiedBy;
}

@EntityListeners(AuditingEntityListener.class)을 별도의 XML로 분리해서 글로벌 설정으로 만들 수 있다.

권장 Tip

생성 날짜는 보통 모든 Entity에 사용되는데, 생성자는 특정 Entity에만 사용된다. 그러므로 보통 다음과 같이 사용한다.

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public class BaseTimeEntity {

	@CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;
    
    @LastModifiedDate
    private LocalDateTime lastModifiedDate;
}

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public class BaseEntity {

	@CreatedBy
    @Column(updatable = false)
    private String createdBy;
    
    @LastModifiedBy
    private String lastModifiedBy;
}

Entity에 따라 원하는 baseEntity를 상속받도록 한다.

Web+ : 도메인 클래스 컨버터

참고: Spring Boot REST API Domain Class Converter

ID로 유저 찾기 API

위 요구사항은 아마 아래처럼 구현할 것이다.

@RestController
@RequiredArgsConstructor
public class MemberController {

	private MemberRepository memberRepositry;

	@GetMapping("/api/members/{id}")
    public MemberDto findMember(@PathVariable("id") int id) {
    	return new MemberDto(memberRepositry.findById(id));
    }
}

Spring Data JPA를 사용하면 다음 처럼 구현할 수 있다.

@RestController
@RequiredArgsConstructor
public class MemberController {

	private MemberRepository memberRepositry;

	@GetMapping("/api/members/{id}")
    public MemberDto findMember(@PathVariable("id") Member member) {
    	return new MemberDto(member);
    }
}

Spring Data JPA가 메서드 시그니처를 보고, DomainNameConverter가 작동해서 Entity를 조회한 뒤 반환하는 것이다.
DomainNameConverter는 내부적으로 Repository를 사용한다.

주의사항

트랜잭션 범위 밖에서 조회가 발생했으므로, 조회한 값을 변경해서는 안 된다.
즉, 조회 용도로만 사용해야 한다.

Web+ : 페이징과 정렬

Spring Data가 제공하는 페이징, 정렬 기능을 Spirng MVC에서 편리하게 사용할 수 있다.

@RestController
@RequiredArgsConstructor
public class MemberController {

	// findAll(Pageable): JpaRepository의 부모 인터페이스에 정의된 메서드
	@GetMapping("/api/members")
    public Page<MemberDto> list(Pageable pageable) {
    	Page<Member> page = memberRepository.findAll(pageable);
        return page.map((m)->new MemberDto(m));
    }
}

페이징 조건을 알아서 Pageable의 구현체 PageRequest로 만들어준다.

다음과 같이 사용 가능하다.

.../api/members -> 모든 멤버 조회
.../api/members?page=0 -> 'page = 0'에 해당하는 멤버 조회 (기본 20, 최대 2000)
.../api/members?page=1&size=3 -> 'page = 1'에 해당하는 멤버 3명 조회
.../api/members?page=1&size=3&sort=id,desc -> 정렬 기준, 방향 지정 가능

커스터마이징

글로벌 설정으로 페이지 사이즈를 지정할 수 있다.

spring:
	data:
    	web:
        	pageable:
	        	default-page-size: 10
                max-page-size: 100
                one-indexed-parameter: true 
                # page 시작 번호를 1로 만들기 (한계 존재)

또는 @PageableDefault Annotation으로 지정 가능하다. (글로벌 설정에 앞선다.)

@RestController
@RequiredArgsConstructor
public class MemberController {

	// findAll(Pageable): JpaRepository의 부모 인터페이스에 정의된 메서드
	@GetMapping("/api/members")
    public Page<MemberDto> list(@PageableDefault(size = 5, sort = "id") Pageable pageable) {
    	Page<Member> page = memberRepository.findAll(pageable);
        return page.map((m)->new MemberDto(m));
    }
}

@Qualifier

페이징 정보가 여러 건인 경우, @Qualifier를 사용해서 페이징 정보를 추가할 수 있다.

.../api/members?member_page=0&order_page=1
@RestController
@RequiredArgsConstructor
public class MemberController {

	// findAll(Pageable): JpaRepository의 부모 인터페이스에 정의된 메서드
	@GetMapping("/api/members")
    public Page<MemberDto> list(
    	@Qualifier("member") Pageable memberPageable,
        @Qualifier("order") Pageable orderPageable
	) {
    	Page<Member> page = memberRepository.findAll(pageable);
        return page.map((m)->new MemberDto(m));
    }
}

JpaRepository 구현체

JpaRepository를 상속받은 인터페이스를 정의하면 Spring Data JPA가 알아서 구현체를 만들어준다.

그 구현체는 바로 SimpleJpaRepository이다.

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
	
    ...
}
  1. @Repository가 붙이있으므로 Spring Bean으로 등록될 뿐만 아니라, Jpa Exception을 Spring의 Excepiton으로 변환한다.

  2. @Transactional(readOnly = true)이므로, 메서드에 따로 @Transactional이 붙어있지 않는 이상, 트랜잭션이 종료되어도 flush를 수행하지 않는다(약간의 성능 이점).

  3. save메서드는 새로운 Entity이면 persist, 아니면 merge를 수행한다.

    • 데이터 변경 목적으로 merge를 사용하면 안된다.
    • Dirty Checking을 통해 데이터를 변경해야 한다.

새로운 Entity 구분

  1. save메서드는 새로운 Entity이면 persist, 아니면 merge를 수행한다.
	@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);
		}
	}

새로운 Entity인걸 어떻게 구분하는 것일까?

// EntityInformation인터페이스 구현체인 AbstractEntityInformation
public boolean isNew(T entity) {
		ID id = getId(entity);
		Class<ID> idType = getIdType();

		// 식별자 타입이 객체일 때, id가 null이면 새로운 Entity
		if (!idType.isPrimitive()) {
			return id == null; 
		}

		// 식별자 타입이 숫자형 Primitive type일 때, id가 0이면 새로운 Entity
		if (id instanceof Number) {
			return ((Number) id).longValue() == 0L;
		}

		throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
	}

문제 상황

@GeneratedValue를 사용할 때, persist가 호출될 때 Id에 값이 자동 할당된다.

다음과 같은 Item Entity가 있다.

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

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

Q: @GeneratedValue을 사용하지 않고, 직접 Id를 할당한 뒤, save를 호출하면 persist될까 아니면 merge될까?

@SpringBootTest
public class MyTest {

	@Autowired
    ItemRepository itemRepositry; // JpaRepository를 상속받은 Repositry

	@Test
	void test() {
		Item item = new Item("A")
        itemRepositry.save(item);
	}
}

id를 em.persist를 통해서가 아닌, 그 전에 강제로 할당했다. 그러므로 save()의 로직에 따라 새로운 Entity로 인식하지 못하고 merge를 수행한다.

merge를 실행했더니 다음과 같은 쿼리가 발생한다.

1. SELECT 쿼리
2. INSERT 쿼리

merge는 기본적으로 값을 업데이트 하는 작업을 수행한다 (정확히는 detached된 entity를 다시 persist). 이를 위해 값을 변경할 데이터를 SELECT쿼리로 불러오는 것이다.

값 변경을 위해서는 Dirty Checking을, 값 저장을 위해선 Persist를 사용해야 한다. Merge는 지양해야 한다.

그런데 위 상황처럼 어쩔 수 없이 ID를 직접 할당해야 하는 상황에서는 어떻게 할까?

Persistable

public interface Persistable<ID> {
	@Nullable
	ID getId();
	boolean isNew();
}
// Entity
@Entity
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<String> {

	@Id
    private String id;
    
    @CreatedDate
    private LocalDateTime createdDate;
    
    public Item (String id) {
    	this.id = id;
    }
    
    @Override
    public String getId() {
        return id;
    }

    // Entity가 새로 만들어졌음을 증명하는 조건을 직접 정의
    @Override
    public boolean isNew() {
        return createdDate == null;
    }
}

Entity가 Persistable를 구현하도록 하면, Entity 마다 isNew조건을 직접 정의할 수 있다.

생성 날짜는 JPA가 persist할 때 자동으로 설정하므로, 보통은 생성 날짜를 기준으로 isNew를 판단하도록 한다.

보통은 BaseEntity를 만들어서 Entity가 상속 받도록 한다. 참고

Projections

Spring Data는 필요한 데이터만 뽑아서 조회할 수 있도록 한다.

Interface Based

Close Projection

// 뽑아올 데이터만 getter를 만들어 준다.
public interface UsernameOnly {
	String getUsername();
}

// 반환형으로 사용하면 된다.
// repository
List<UsernameOnly> members = findByUsername("username");

실제로도 username만 조회하는 SELECT 쿼리가 발생한다.

Spring Data JPA가 인터페이스를 보고 Proxy 기술을 통해 구현체를 만드는 것이다.

Open Projection

Close Projection이 SQL 상으로 정확히 필요한 데이터만 불러오는 것과 달리, @Value + SpEL을 사용하면 더 확장성(?) 있는 조회가 가능하다.

public interface UsernameAndAge {

	@Value("#{target.username + ' ' + target.age}")
	String getUsername();
}

List<UsernameAndAge> members = findByUsername("username");

위 방식을 사용하면 모든 필드를 다루는 SELECT 쿼리가 발생하고, Data JPA Level에서 @Value에 명시된 필드만 가지고 원하는 형태로 값을 반환한다.

Class Based

클래스 기반으로도 만들 수 있다.

public class UsernameOnlyDto {
	
    private final String username;
    
    public UsernameOnlyDto(String username) {
    	this.username = username;
    }
    
    public String getUsername() {
    	return username;
    }
}

List<UsernameOnlyDto> members = findByUsername("username");
  1. 필드명이 아닌 생성자의 파라미터 이름을 기준으로 SQL 쿼리가 발생한다.
  2. 인터페이스 방식이 Proxy 객체를 생성하는 것과 달리, 클래스 방식은 클래스의 객체를 사용한다.

Generic

사용하는 쿼리는 같은데, 원하는 필드만 다를 경우 Generic을 이용할 수 있다.

// repository
<T> List<T> findByUsername(@Param("username") String username, Class<T> type)

// Class
List<UsernameOnlyDto> members = findByUsername("username", UsernameOnlyDto.class);
// Interface
List<UsernameAndAge> members = findByUsername("username", UsernameAndAge.class);

Nested Close Projection

Entity가 중첩된 상황에서 Projection을 사용하면

public interface NestedClosedProj {
	
    String getUsername();
    TeamInfo getTeam();
    
    interface TeamInfo() {
    	String getName();
    }
}

List<NestedClosedProj> members = findByUsername("username", NestedClosedProj.class)

다음과 같은 SQL 쿼리가 발생한다.

SELECT
	u.username
    t.teamId,
    t.teamName,
    t.created_date
    t.updated_date
FROM member AS m LEFT OUTER JOIN ...


Root에 대해서만 최적화가 발생하고, 이하의 Entity에 대해선 전체를 조회한다는 한계가 있다.

조회 대상이 단순한 경우에는 사용해도 괜찮은데, 복잡한 경우에는 QueryDSL로 해결 권장

Native Query

가급적 Native Query는 피하는게 좋다.
사용할 경우에는 Projection와 같이 사용하는게 좋다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    
    @Query(
    	value = "SELECT * from Member WHERE username = ?", 
        nativeQuery = true
	)
    Member findByNative(String username);
}

// 사용
Member member = findByNatvie("name#1");

주의사항

  1. 반환 타입

    • Object[]
    • Tuple
    • DTO (Spring Data의 Projection)
  2. Sort가 정상적으로 작동 안 할 수 있음

    • 코드 레벨에서 직접 정렬 권장
  3. 컴파일 타임에 SQL 문법 확인 불가능

  4. 동적 쿼리 불가능

    • Hibernate에서 가능하긴 함
    • MyBatis 또는 Spring JDBC Template 사용

Native Query + Interface Projection

public interface MemberProjection {
	Long getId();
    String getUsername();
    String getTeamName();
}

// Native Query에 Paging Projection을 사용할 수 있다.
@Query(
	value = "SELECT m.member_id AS id, m.username, t.name AS teamName FROM Member AS m LEFT JOIN Team AS t",
    countQuery = "SELECT COUNT(*) FROM Member",
    nativeQuery = true
)
Page<MemberProjection> findByNavtiveProjection(Pageable pageable);

// 사용
Page<MemberProjection> result = findByNavtiveProjection(PageResult.of(0, 10));
profile
블로그 이전했습니다 https://dev314.tistory.com/

0개의 댓글