Repository는 중앙 저장소 마커 인터페이스라는 이름으로 정의되어 있고 해당 인터페이스는 추상 메소드가 아예 존재하지 않는다.
Spring data는 Repository를 활용해서 Bean 등록을 하는데 클래스 경로 스캐닝으로 인터페이스를 발견해서 생성한다. 어플리케이션은 실행시 Repository를 확장한 모든 인터페이스를 찾아줘서 인터페이스만 있어도 SimpleJpaRepository
구현체가 자동 생성된다고 인식할 수 있다.
이 과정에서 추가로 구현체로 상속받는 인터페이스는 앞선 프로젝트에서 다른 조의 트러블 슈팅으로 나왔던 인터페이스 이름 + Impl
로 구성하지 않으면 찾지 못한다는 얘기가 보여 신기했다. 서비스도 해당 규칙으로 구성되는 것으로 보인다.
스캐닝 설정 자체는 @EnableJpaRepositories 를 해줘야 가능한데 Spring boot에선 기본적으로 설정된 상태다.
이를 통해 QueryDslRepository를 따로 POJO 인터페이스를 만들고 Repository / RepositoryImpl 에서 상속받아 구현하면 서비스에서는 Repository의 구체적인 동작을 모르게 만들 수 있다. 나는 여기서 한 술 더 떠서 JpaRepository 파일도 분리해놓고 Jpa의 연관성도 없앴다.
Projection은 DB에서 나온 개념으로 테이블을 출력할 때 Column을 제한적으로 출력하는 용어다.
SELECT * FROM member; // Non-Projection SELECT id, email FROM member; // Projection
여태 DB에서는 모든 Entity를 조회했었지만 특정 요청은 민감 정보같은 특정 정보를 제외한 Column의 정보만 필요로 하게 DTO를 설계할 때도 있었고 그러면 조회할 때마다 모든 Column을 조회할 필요도 없어진다. 그러면 필요한 데이터만 조회하게 만들 수 있다.
그리고 어제 배운대로 Entity는 영속성 컨텍스트의 관리를 받게되고 이 개수가 많아지면 성능 부하도 걸리게 된다.
따라서 Projection을 이용한 커버링 인덱스를 활용해볼 수 있다. (강의에서는 PK를 통해서 조회할 때 빠른 탐색이 가능한 인덱스라고 간단히 설명하고 넘어갔다.)
구체적으로 Projection을 활용할 타이밍은 단순한 조회(Query)에 쓰면 필요한 정보만 조회하기 좋고 쓰기(Command)에는 Entity를 조회해서 JPA의 지원을 받으면 된다. 항상 Projection을 쓸 필요는 없으나 많은 Row를 조회한다면 Projection 사용이 성능 개선으로 작용한다.
Projection 을 이용한 CQRS(Command and Query Responsibility Segregation) 구현
Command 작업(CREATE, UPDATE, DELETE)과 Query 작업(SELET) 의 책임 분리
Projection 을 이용한 CQRS?
본문에서 쓰기(Command) 작업을 수행할 땐 Entity 로 조회하고, 읽기(Query) 작업을 수행할 땐 Projection 을 이용하자고 소개했는데, 여기서 더 나아가면 CQRS 까지 발전시킬 수 있다.
객체 레벨에서 쓰기(Command) 작업에 대한 책임을 가지는 객체와 읽기(Query) 작업에 대한 책임을 가지는 객체를 분리해 CQRS 를 구현할 수 있고, 더 확장해 데이터베이스 자체를 복제(Replication)해 쓰기(Command) 전용 데이터베이스, 읽기(Query) 전용 데이터베이스로 나눠 CQRS 를 구현할 수도 있다.
DTO는 말 그대로 데이터를 옮기기 위해 만들어진 객체고 구체적인 로직이 들어가면 안되고 너무 많은 객체 사이의 이동에서 사용하면 유지보수가 어려워진다.
Projection을 하겠다는 말은 조회할 때 Entity를 쓰지 않겠다고 선언한 것이고 Projection 결과를 담을 객체가 필요해졌으니 그 객체는 DTO가 될 것이다. 구체적으로는 Repository가 반환하는 형태가 DTO가 된다.
JPA에서 Projection은 Closed Projection, Open Projection으로 구분할 수 있다.
강의는 Closed Projection만 다뤘다.
그리고 JPA가 Projection을 구현하는 방법도 2가지로 나뉜다.
interface SimpleMember {
fun getEmail(): String
fun getNickname(): String
}
인터페이스에 Projection 할 Column에 대한 Getter를 정의해서 JPA가 알아서 추론하게 만든다. JPA 는 위 인터페이스에 있는 Getter 들을 통해 Projection 에 필요한 컬럼을 추론한다. 따라서 정확한 컬럼명을 Getter 로 만들어줘야한다.
예를 들어 getNickname2()
와 같이 Column 이름이 Entity와 달라지면 어플리케이션 실행시점에 에러가 발생한다. No property 'nickname2' found for type 'Member'; Did you mean 'nickname’
또 Interface-Based Projection은 별도의 Proxy 객체를 만들고 추후 결과를 받아오는 방식이라서 Class-Based Projection 에 비해 성능이 좋지도 않아 잘 사용하지 않는다.
data class SimpleMember(
val email: String,
val nickname: String
)
구조는 Interface와 동일하지만 Class-Based Projection는 클래스에 Column과 동일한 필드를 만든다. Column이 틀리면 여전히 위에서 말한 에러를 겪을 수 있다.
다만 Column을 추론하는 근거가 필드의 이름이 아니라 생성자의 Parameter 이름이라서 선언과 생성자를 동시에 사용하는 Data class를 사용하면 헷갈리지 않는다.
위에서 DTO로 Projection 하는 방법은 다뤘으니 응용이 필요한 경우가 생길 수도 있다.
@Repository
interface MemberRepository : JpaRepository<Member, Long> {
fun findByEmail(email: String): SimpleMember1?
fun findByEmail(email: String): SimpleMember2?
fun findByEmail(email: String): SimpleMember3?
fun findByEmail(email: String): SimpleMember4?
fun findByEmail(email: String): SimpleMember5?
}
비슷한 조회 API가 많아서 형태가 매번 다른 DTO로 Projection 하고 싶으면 위처럼 되는 일이 생길 수도 있다. 이럴 때 사용할 수 있는게 Dynamic Projection이다.
@Repository
interface MemberRepository : JpaRepository<Member, Long> {
fun <T> findByEmail(email: String, type: Class<T>): T?
}
memberRepository.findByEmail(email, SimpleMember1::class.java)
memberRepository.findByEmail(email, SimpleMember2::class.java)
memberRepository.findByEmail(email, SimpleMember3::class.java)
/* ... */
Generic을 사용하면 type에 따라서 JPA가 맞춰 Projection하게 된다.
Projection DTO를 만드는 방법은 JPA와 동일하다. (Interface, Class)
다만 JPA가 자동으로 추론해주는 것은 아니기에 JPQL 내부에서 명시적으로 Projection DTO를 사용하는 방법이 달라진다.
@Repository
interface MemberRepository : JpaRepository<Member, Long>, MemberQueryDslRepository {
@Query("SELECT m.id as id, m.nickname as nickname FROM Member m WHERE m.email = :email")
fun findByEmail(email: String): SimpleMember?
}
SELECT 절에 Projection할 Column을 명시해주면 되고 주의할 점은 Alias를 통한 Column 재작성이 반드시 필요하다.
@Repository
interface MemberRepository : JpaRepository<Member, Long>, MemberQueryDslRepository {
@Query("SELECT new com.example.membersample.domain.member.entity.projection.SimpleMember(m.id, m.nickname) FROM Member m WHERE m.email = :email")
fun findByEmail(email: String): SimpleMember?
}
SELECT 절에 new DTO()
형태로 생성자를 호출하는 문법을 쓴다. 패키지 경로까지 싸그리 지정해줘야 하는 요상한 모양새가 참 눈에 띈다.
둘 다 까탈스러운 사용법이라 차라리 이럴 바에 QueryDSL을 추천하셨다.
이전에 QueryDSL 챕터에서 간단히 공부한 적 있어 링크를 걸고 다시 살펴보는 정도만 했다.
Setter를 기반한 동작 방식으로 Field를 반드시 var로 설정해야 하고 기본 생성자가 존재해야 한다.
DTO가 불변, Nullable 타입 강제되는 모양새라 이 방법은 권장되지 않는다.
bean 보다 훨씬 DTO의 모양새가 좋아졌지만 이전에 다룬대로 생성자의 위치를 틀리면 안되는 문제가 존재한다.
public static <T> QBean<T> fields(Path<? extends T> type, Expression<?>... exprs) {
return new QBean<T>(type.getType(), true, exprs);
}
이전에 이건 안다뤘었는데 내부적으로 Projections.bean
과 동일하게 동작하는 방법이라 같은 이유로 권장되지 않는다고 한다.
Projection DTO에 해당하는 QClass를 만들어 사용하는 방법이라 완벽해보이지만 DTO에 QueryDSL 의존성이 생기고 다른 계층과 통신에 사용하는 객체이니 Repository의 구체적인 변경이 다른 계층으로 전파될 수 있다.
결론은 반드시 생성자 방식을 사용하되 Projections.constructor, @QueryProjection 중 장/단점을 비교해 Trade-off 하는 것을 추천하셨다.
튜터님은 @QueryProjection 사용이 좀 더 안정적인 느낌을 받았다고 하셨다.
여태 강의랑 다르게 챕터 마무리 시간이 있었다. 이번 강의를 총정리하면 이런 내용을 배웠다.
튜터님은 실제로 SQL에 날아가는 쿼리문을 직접 확인하는게 중요하고 성능 문제를 포함한 여러 문제를 매번 예상하지 말고 눈으로 확인해서 쿼리에 대한 이슈를 미리 생각해보는 습관을 가지라고 추천해주셨다.
Jetbrain에서 개발한 Kotlin 기반의 경량 ORM인 Exposed를 소개해주셨다. (JPA는 무겁다고 하셨다.)
아직 1.0도 나오지 않은 프레임워크지만 Kotlin에서 다루기 호환성이 좋고 코루틴같은 라이브러리도 지원이 잘 된다고 한다.
그리고 Spring Data JPA에서 문제가 되는 부분들을 해결하며 개발중이라서 @Id IDENTITY로 사용하면 Batch Insert (대량 삽입) 같은게 되지 않는 문제가 해결된 상태라고 한다.
1.0 버전 릴리즈는 근시일 내에 만나볼 수 있을 것 같고 실무에서도 필요에 따라 작은 부분부터 교체되는 곳이 있다는데 이전 경험을 떠올려보면 새로운 기술 트렌드로 자리잡고 Kotlin Spring이 강점이 되는 이유로도 남을 수 있을 것 같다.