서론
Jpa를 사용하면 DB 중심의 개발만 가능하다?
Spring 백엔드 개발자라면 SpringDataJpa 를 무조건 사용해보는데 나 같은 초보가 SpringDataJpa를 사용하면 SpringData가 제공하는 유용한 기능을 많이 활용 못하는 경우가 많다.
나 같은 경우도 SpringDataJpa를 사용하면서 JPA의 DB 테이블 매핑과 지연로딩, SpringDataJpa 의 JPA를 유용하게 사용하는 쿼리 매서드만을 생각하고 공부했다.
SpringData 와 Jpa는 서버 개발자가 DB의 패러다임과 객체지향의 패러다임의 차이를 해소하고자 많은 기능을 제공한다. 하지만, 정작 Jpa와 SpringDataJpa 가 제공하는 유용한 기능과 핵심 컨셉을 배제하며 공부와 개발을 했다.
이번 포스팅은 SpringDataJpa 공식문서를 보면서 SpringData 의 핵심 개념과 어떻게 개발하면 SpringData를 사용하면서 유지보수 하기 좋은 코드를 작성하는지 공부한 것을 다뤄볼 예정이다.
이걸 읽는 독자중에 SpringDataJpa를 사용만 해봤지 원리를 잘 모르거나 SpringDataJpa를 사용하는데 더 나은 구조 혹은 코드에 관심이 가는 사람에게 도움이 됐으면 한다.
SpringData 뜯어보기
SpringData는 JPA만 있는게 아니다!
다들 RDBMS 뿐 아니라 Redis 나 MongoDB, Elasticsearch 와 같은 기술 스택을 자기 서비스에 접목 시켜봤을 것이다.
SpringDataJpa 뿐 아니라 SpringDataRedis ,… 등이 전부 존재하고 쉽게 라이브러리 의존만 한다면 간단한 메서드만을 사용해서 개발한 경험이 다들 있을 것이다.
SpringData 마커 인터페이스 Repository , CrudRepository
2개의 프로젝트를 만들고 각 프로젝트의 SpringDataJpa 와 SpringDataRedis 에 대한 의존성 세팅과 config 구성을 하고 다음과 같이 Repository 를 만들어보자
public interface PersonRepository extends CrudRepository<Person, String> {
}
마법 같게도 똑같은 인터페이스를 상속 받았지만 SpringDataJpa를 의존한 프로젝트는 RDBMS를 액세스하는 메서드가 SpringDataRedis를 의존한 프로젝트는 Redis 를 액세스하는 메서드가 실행된다.
이렇게 마법 같은 현상은 SpringData 가 다른 모든 외부 Infra 가 있더라도 동일한 인터페이스를 통해서 액세스하기 위해 추상화한 마커 인터페이스가 핵심이다.
SpringData 가 제공하는 마커 인터페이스는 Repository, CrudRepository 가 있고 추가로 비동기, 배치 같은 Repository도 제공한다.
Repository 인터페이스를 상속받은 인터페이스는 런타임 시에 SpringData 가 메서드로 되어있는 내역을 Infra 에 맞게 쿼리를 생성해준다
우리가 SpringDataJpa를 사용하면서 findById를 정의만 해주면 select * from Person where id = ? 를 만들어주는 것과 같다.
CrudRepository 는 Repository를 상속받는 인터페이스이다. 따라서 Repository 처럼 메서드를 정의하면 쿼리 메서드 형태로 인식해서 쿼리를 생성해주고 추가로 개발자들이 많이 사용하는 CRUD 메서드를 추가로 정의한다.
public interface PersonRepository extends CrudRepository<Person, String> {
List<Person> findByFirstname(String firstname);
}
한 모듈에 2개 이상의 infra 가 있다면?
위의 가정은 각 프로젝트에 각각 다른 SpringData 인프라 의존을 한 경우였다.
한 프로젝트에서 2개 이상의 infra가 존재할 때 SpringData는 어떤 infra query를 오버라이딩 해줄까? 만약 infra가 변경된다면 코드를 다 뜯어 고쳐야 하나? 고친다면 도메인 계층까지 side effect가 가해질 텐데…
와 같은 고민과 걱정이 생기는 것이 당연하다.
다행이도 SpringData는 이러한 걱정을 제거해준다.
SpringData가 infra를 선택하는 기준
만약 SpringDataJpa , SpringDataRedis 둘 다 의존했다 가정해보자
public interface PersonRepository extends Repository<Person, String> {
}
위처럼 구성했을 때 PersonRepository는 어떤 Infra 쿼리가 생길까?
SpringData는 이렇게 공용 인터페이스를 사용하는데 여러 의존이 생겨 어떤 쿼리를 만들지 모를 때 여러가지 방법으로 쿼리를 생성한다.
- 엔티티 파싱
- Jpa는 @Entity 를 MongoDB나 ElasticSearch 는 @Document 어노테이션을 사용해 엔티티를 지정한다.
- Spring Data 의 각 라이브러리는 이러한 어노테이션을 인식하여 쿼리를 정한다
- basePackages로 스코프 제한:
- basePackages를 Config 설정하여 특정 패키지 위치의 리포지터리에 대한 인프라 쿼리를 지정할 수 있다.
- @EnableJpaRepositories(basePackages = "com.example.jpa") @EnableMongoRepositories(basePackages = "com.example.mongo")
- 모듈 전용 인터페이스 상속: JpaRepository, MongoRepository 등
- 모듈 전용 인터페이스를 상속하면 된다.
- 모듈 전용 인터페이스는 CrudRepository 를 상속하고 더 infra 특성에 맞는 메서드를 지원해준다.
- 하지만 infra 변경에 side effect 가 커지는 것은 단점이다.
쿼리를 내가 만들고 싶어요!
SpringData 는 마커 인터페이스를 상속하는 인터페이스의 구현체를 런타임에 생성해준다.
하지만 커스텀을 하고 싶을 수 있다.
@NoRepositoryBean
interface MyBaseRepository<T, ID> extends Repository<T, ID> {
Optional<T> findById(ID id);
<S extends T> S save(S entity);
}
와 같이 @NoRepositoryBean 을 사용하면 SpringData 가 해당 인터페이스를 구현하지 않는다.
SpringData 사용해서 infra 변경에 자유로워 지기
최근에 원래 사용하던 MySQL에서 다른 인프라로 변경했던 도메인이 있었다.
이번 공부는 아무 생각없이 SpringDataJpa 사용하면 JpaRepository 를 사용해야지!로 인식하던 난 왜 인터페이스이지만 이렇게 강한 결합이 생기지? 이 결합은 프레임워크에 의존적이니까 어쩔 수 없는건가? 에 대한 질문으로 시작했다.
모든 것은 트레이드 오프다 Jpa 메서드를 많이 제공해주고 RDBMS로 영원히 변경될 일이 없다면 굳이 공용 인터페이스를 사용 안하고 JpaRepository 를 사용하면 된다.
하지만 변경될 수도 있고 굳이 JpaRepository 가 제공해주는 메서드가 필요없다면 공용 인터페이스로 변화에 대응하자
SpringDataJpa 활용
SpringDataJpa 잘 사용해서 도메인에 집중하자
개발을 하면서 DB 관련 코드와 도메인 관련 코드를 분리하고 싶은 욕구가 생겼다.
이유는 DB 관련 코드(JPA 관련 코드 또한)가 많아질 수록 도메인 코드의 가독성이 많이 저하됐었다.
어떻게하면 코드의 가독성이 좋아질지 더 도메인을 잘 투영할지 유지보수가 좋아질지 많이 고민했고 여러가지 방안들을 참고도하고 생각도하며 적용해왔다.
지금 상태 지식에서 가장 좋은 방안들 중에 SpringDataJpa를 활용하여 해소하는 방법에 대해 조금만 다루겠다.
- JPA 관련 방법론, QueryDsl 사용 방법은 제외하고 오로지 SpringData로 가능한 영역으로만 다룬다.
query Method
SpringDataJpa 에서 쿼리를 생성하는 방법은 2가지가 존재한다.
- Query Method
- @Query 로 JPQL 혹은 Native 쿼리 사용
Query Method 를 사용하면 간편하지만 다음과 같은 제약이 있다.
- 원하는 키워드가 메서드 이름 파서에서 지원되지 않는 경우
- 메서드 이름이 지나치게 길어지는 경우
Query Method 는 정말 간편한 쿼리 생성 추상화 도구이다. 하지만 Join과 같은 키워드들 중에 지원이 안되는 기능이 있기 때문에 그때는 Jpql 혹은 Native query를 사용해야 한다.
JPQL 을 사용하는 방법
@Query 로 Repository에 구현해도 되는데 @NamedQuery를 사용해서 구현해도 된다. 뭘 사용하든 상관없는데 개인적으로
@Query로 Repository 에 작성하는게 나은 것 같다.
문자열의 타입 안정성과 Where condition 의 재사용이 문제라면 QueryDsl 같은 라이브러리를 알아보자
Fetch Join은 @EntityGraph 도 방법이다
DTO를 조회하지 않고 엔티티를 조회하는 경우에는 JPQL 로 Join을 사용했다가 N+1 이 생길 수 있다.
따라서 Fetch Join을 많이 사용하는데 @EntityGraph 로 Fetch 전략으로 제어 가능하다.
@Entity
@NamedEntityGraph(
name = "User.withRoles",
attributeNodes = {
@NamedAttributeNode("roles")
}
)
public class User {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Role> roles = new ArrayList<>();
}
public interface UserRepository extends JpaRepository<User, Long> {
// 방법 1: NamedEntityGraph 사용
@EntityGraph(value = "User.withRoles", type = EntityGraph.EntityGraphType.LOAD)
Optional<User> findByName(String name);
// 방법 2: 직접 속성 지정
@EntityGraph(attributePaths = {"roles"})
List<User> findAll();
}
- attributePaths = fetch join할 속성 이름 배열
- EntityGraphType
- FETCH : 지정한 속성만 EAGER, 나머지는 LAZY
- LOAD : 지정한 속성 EAGER + 엔티티 매핑 기본 전략 유지
EntityGraph를 사용할때 기존 findByName 이랑 충돌이 될 수 있다
보통은 findWithXxxById(...) 처럼 이름을 달리해서 2개 메소드 두는거 같다.
엔티티만 조회할 것인가?
때로는 엔티티 전체가 아니라 특정 속성만을 추출해서 부분 뷰(Partial View)를 가져오고 싶을 때가 있다.
이때 가장 로우한 방식은 다음과 같이 @Query를 사용하는 것이다.
@Query("SELECT new com.example.NamesOnly(u.firstname, u.lastname) FROM User u WHERE u.lastname = :lastname")
List<NamesOnly> findByLastname(String lastname);
이 방법을 사용하면 단일 엔티티 혹은 join으로 여러가지 엔티티의 부분 뷰를 추출할 수 있다.
더 간편한 방법으로는 SpringData 가 제공하는 프로젝션 타입을 지정하는 것이다.
interface NamesOnly {
String getFirstname();
String getLastname();
}
그리고 이를 리포지토리에 사용하면:
interface PersonRepository extends Repository<Person, UUID> {
Collection<NamesOnly> findByLastname(String lastname);
}
인터페이스를 런타임시에 상속하는 클래스를 만들어 주입해준다.
interface PersonSummary {
String getFirstname();
String getLastname();
AddressSummary getAddress();
interface AddressSummary {
String getCity();
}
}
재귀적 중첩도 가능하다
record NamesOnly(String firstname, String lastname) {}
record DTO 형태로도 가능하다
interface NamesOnly {
@Value("#{target.firstname + ' ' + target.lastname}")
String getFullName();
}
이런식으로 프로젝션의 결과를 JPA에서 가공할 수 있는데 위 방법은 select * 형태로 쿼리가 나가서 최적화가 되지 않는다.
이유는 컴파일 시에 필요한 어트리뷰트를 알 수 없기 때문이다.
interface NamesOnly {
String getFirstname();
String getLastname();
default String getFullName() {
return getFirstname() + " " + getLastname();
}
}
위와 같은 방식으로 해결할 수 있다.
interface NamesOnly {
Optional<String> getFirstname();
}
당연히 Null 래퍼 클래스인 Optional 도 지원한다
Fragment 방식으로 Repository 구현 메서드를 분리시킬 수 있다.
Spring Data 에서 제공하는 기존 혹은 새로 선언하는 메서드들로 부족할 수 있다 예를 들어 QueryDsl 같은 구현체들이 그 예가 될 수 있다.
이때 Spring Data 에서는 Fragment 방식을 지원하는데 사실 우리는 Fragment 방식을 동작하는 것을 인지하고 있다
public interface UserRepository extends Repository<User, Long> {
User save(User user);
}
위처럼 선언했을 때 save 구현체는 무엇인가?
바로 SimpleJpaRepository 다
어떻게 SimpleJpaRepository 구현체들이 런타임시에 UserRepository로 조합할 수 있을까?
그것은 Spring AOP 를 사용한 것이다(정확히는 내부 바인딩 라이브러리를 이용해서)
런타임시에 SpringData는 Repository 관련 인터페이스(Repository,Crud, JpaRepository 등등)을 상속 받은 인터페이스를 확인하고 해당 인터페이스를 구현하는 구현 프록시 객체를 생성한다.
그리고 해당 객체가 save 호출시 SimpleJpaRepository 를 호출한다(delegate)
이러한 방식을 Fragment 방식이라고 하고 우리는 이 방식을 직접 사용할 수 있다.
단계별 구현
커스텀 인터페이스 정의
interface CustomizedUserRepository {
void someCustomMethod(User user);
}
구현체 작성 (Impl postfix 필수)
class CustomizedUserRepositoryImpl implements CustomizedUserRepository {
@Override
public void someCustomMethod(User user) {
// custom logic
}
}
Repository에 조합
interface UserRepository
extends CrudRepository<User, Long>, CustomizedUserRepository {
}
위와 같이 하면 CrudRepository 메서드도 CustomizedUserRepository 의 메서드도 각각 다른 클래스(SimpleJpaRepository , CustomizedUserRepositoryImpl ) 들의 메서드 로직까지 상속받는 것 처럼 보인다.
Impl postfix 는 필수이고 물론 이 postfix 도 커스텀 가능하다
JPA 엔티티가 이벤트 발행할 수 있다?
SpringDataJPA 공식 문서를 보면 DDD 관련 설명이 엄청 자주 나온다.
JPA, SpringDataJpa 를 사용하는 것이 DB 패러다임을 객체지향 패러다임으로 전환할 수 있음을 강하게 시사한다고 이해했다.
실제로 DDD에서는 Aggregate Root에서 이벤트를 발행하는 것이 좋은 설계로 봐서 그런지 SpringDataJpa도 해당 기능을 엄청 간편하게 제공해준다.
class Order {
private final List<Object> domainEvents = new ArrayList<>();
public void complete() {
// 비즈니스 로직
domainEvents.add(new OrderCompletedEvent(this));
}
@DomainEvents
Collection<Object> domainEvents() {
return domainEvents;
}
@AfterDomainEventPublication
void clearDomainEvents() {
domainEvents.clear();
}
}
@AfterDomainEventPublication
- 이벤트가 Spring ApplicationEventPublisher를 통해 발행된 직후 호출됨.
- 보통 이벤트 리스트를 비워주는 용도로 사용.
이벤트 발행 시점
Spring Data Repository의 다음 메서드 실행 시, 자동으로 이벤트가 발행됨:
꼭 다음 메서드를 실행시켜야 하고 실행 안한다면 이벤트가 발행되지 않는다.
- save(…), saveAll(…)
- delete(…), deleteAll(…), deleteAllInBatch(…), deleteInBatch(…)
❌ deleteById(…)는 Aggregate 인스턴스를 로드하지 않을 수도 있어 이벤트 발행 불가.
맺음말
JPA와 SpringDataJpa를 사용하면서 어떻게 하면 좋은 코드를 작성하는 것일까 질문이 생겨서 시작한 공식문서 뜯어보기를 하고 엄청 많은 인사이트를 얻었다 (Spring 공식 문서는 goat다)
공식문서 중에 가장 개발 취향에 맞았던 내용이 가득해서 읽기 시작하고 한번도 안멈추고 정독했다.
이번 포스팅은 공식 문서를 읽으면서 오? 이거 프로젝트에 접목해봐야지 나 내가 이걸 알았다면 더 코드가 깔끔했을텐데 하면서 읽었던 내용을 담았다.
확실히 공식문서라 TMI도 가득했지만 SpringData 핵심 컨셉은 진짜 SpringData에 대한 시각을 바꿔주는데 많은 도움을 줬다.
'백엔드 > JPA' 카테고리의 다른 글
| [JPA] JPA N+1 문제 발생 원인과 해결 방법 (0) | 2024.05.14 |
|---|---|
| [JPA] 엔티티 그래프 (0) | 2024.05.10 |
| [JPA] Context 주기와 트랜잭션 주기 그리고 OSIV (0) | 2024.05.08 |
