JPA Repository의 Custom Repository를 무조건 사용해야 할까?

Glen·2024년 3월 5일
3

배운것

목록 보기
34/37

서론

Spring과 관계형 데이터베이스를 사용하여 개발한다면 JPA 또한 사용할 것이다.

그리고 JPA를 사용한다면 Spring Data JPA 또한 사용할 것이다.

JPA를 사용하면 생성, 수정, 삭제와 같은 Command 로직을 SQL로 구현하지 않고, Java(또는 Kotlin) 코드로 구현할 수 있기에 가독성과 유지보수성이 높은 프로그램을 개발할 수 있다.

하지만 조회와 같은 Query 로직을 구현할 때 JPA를 사용하면 오히려 가독성과 유지보수성이 떨어지는 결과를 맞이한다.

특히 페이징, 동적 쿼리와 같은 기능을 구현할 때 JPA만 사용해서는 답이 없는 환경에 놓이게 된다.

따라서 JPA를 사용하며 조회 로직을 구현할 때는 주로 QueryDSL을 사용하여 개발한다.

그리고 QueryDSL을 사용하면 주로 Custom Repository를 만들어 사용한다.

하지만 굳이 Custom Repository를 만들지 않더라도 QueryDSL을 사용할 수 있는데, 왜 Custom Repository를 주로 만들어 사용할까?

Custom Repository를 사용하지 않으면 안 될까?

본론

Spring Data JPA는 인터페이스에 추상 메서드만 정의하면 메서드의 이름을 분석하여 자동으로 쿼리를 만들어 사용할 수 있게 해준다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByNickname(String nickname); // Member의 nickname 필드로 조회
}

하지만 여러 조건이 포함되거나, 조회를 위해 동적 쿼리를 작성해야 할 경우 이러한 방법으로 해결할 수 없다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    // 메서드 길이가 길어진다.
    Optional<Member> findByLastnameAndFirstname(String lastname, String firstname);

    // 필터에 따라 키워드에 대한 조건을 변경하고 싶다.
    // 하지만 컴파일 에러가 발생한다.
    List<Member> findByFilter(String filter, String keyword);
}

메서드의 길이가 길거나, 메서드의 이름으로 쿼리를 작성하기 난감한 경우에는 @Query 어노테이션을 사용하여 직접 쿼리를 작성할 수 있다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    // JPQL 문법을 사용해야 한다. native SQL을 작성하려면 nativeQuery = true를 사용하면 된다.
    @Query("select m from Memberm where m.lastName = :lastName and m.firstName = :firstName")
    Optional<Member> findByName(String lastName, String firstName);
}

하지만 동적 쿼리의 경우 @Query 어노테이션으로 해결할 수 없다.

또한 JOIN 문이 많아질 경우, 문자열 기반의 @Query 어노테이션은 가독성과 유지보수성이 낮아지게 된다.

@Query의 경우 문자열이기 때문에 실제 쿼리가 날아갈 때 문제가 발생할 수 있는게 아니냐 할 수 있지만, 잘못된 필드를 입력하면 컴파일 시점에 SemanticException 예외가 발생하고, 테스트 코드로 충분히 커버할 수 있다고 생각하기에 문제가 아니라고 생각한다.

따라서 직접 쿼리 로직을 구현해야 하는데, Repository가 인터페이스이기 때문에 구현 메서드를 정의하려면 구현체를 만들어야 한다.

하지만 구현체는 Spring Data JPA에서 인터페이스를 기반으로 만들어 주기 때문에 구현체를 만들 수 없다.

Custom Repository

이러한 문제를 해결하기 위해 Custom Repository를 만들어 사용한다.

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

public interface MemberRepositoryCustom {

    List<Member> findByFilter(String filter, String keyword);
}

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    public MemberRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em); 
    }

    @Override
    public List<Member> findByFilter(String filter, String keyword) {
         ...
    }
}

Custom Repository는 위와 같이 인터페이스를 만들고, 기존 Repository에서 상속한 뒤, Custom Repository 인터페이스의 구현체를 만들면 된다. 이때 구현체의 이름은 반드시 Impl으로 끝나야한다.

그리고 작성한 구현 메서드는 Spring Data JPA가 자동으로 기존 Repository에서 사용할 수 있게 해준다.

@Service
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional(readOnly=true)
    public List<MemberResponse> findByFilter(String filter, String keyword) {
        ...
        List<Member> members = memberRepository.findByFilter(filter, keyword);
        ...
    }
}

아마 실제 구현체는 스프링이 이렇게 만들어주지 않을까 추측한다.

public class JpaMemberRepository implements MemberRepository {

    private final MemberRepositoryCustom memberRepositoryCustom;

    @Override
    public List<Member> findByFilter(String filter, String keyword) {
         return memberRepositoryCustom.findByFilter(filter, keyword);
    }
}

Custom Repository의 함정

인터페이스를 만들고, 구현 클래스를 만드는 것이 약간 번거롭긴 하지만, Custom Repository를 사용하면 특정 라이브러리에 의존적인 구체 클래스에 대한 의존을 가지게 하지 않고 인터페이스에 의존적인 구조를 가지게 할 수 있다.

하지만 코드만 봤을 때 그렇지, 실제 동작은 과연 인터페이스를 의존했다고 볼 수 있을까?

실제 동작은 Spring Data JPA가 Impl으로 끝나는 구현체를 자동으로 등록하여 대신 실행시켜 주는 것으로 볼 수 있다.

여기서 중요한 것은 바로 자동으로 등록이 된다는 것인데, 이는 인터페이스를 의존하도록 설계했어도 변경에 취약한 구조를 가지게 한다.

QueryDSL말고 다른 라이브러리를 사용한 구현체로 변경하고 싶어도 반드시 Impl이 붙은 클래스만 허용되기 때문에 기존에 작성된 코드를 지워야한다.

즉, Custom Repository를 사용하는 것은 인터페이스에 의존해서 확장성 있는 구조로 설계하는 것이 아니라, Spring Data JPA를 사용할 때 발생하는 문제를 다른 방법으로 회피하는 것에 가깝다.

또한 Custom Repository는 주로 QueryDSL을 사용하기 위해 쓰이는데, QueryDSL을 사용할 때는 조회 로직을 처리할 때 사용되므로 굳이 엔티티를 반환하지 않고 DTO를 주로 반환한다.

public interface MemberRepositoryCustom {

    List<MemberResponse> findByFilter(String filter, String keyword);
}

또한 이렇게 반환된 DTO는 서비스 레이어에서 사용되지 않고 바로 Controller로 반환할 수 있다.

@Service
public class MemberService {

    private final MemberRepositort memberRepository;

    @Transactional(readOnly=true)
    List<MemberResponse> findByFilter(String filter, String keyword) {
        return memberRepository.findByFilter(filter, keyword);
    }
}

이러면 조회 시 굳이 서비스 레이어에서 엔티티를 다시 DTO로 변환할 필요 없이 Repository에서 반환된 DTO를 넘겨주면 된다.

하지만 이렇게 되면 도메인 레이어인 Repository가 View에 의존적인 DTO를 의존하게 되므로 레이어드 아키텍처 관점에서 잘못된 구조를 가지게 된다.

의존의 방향이 잘못되어도 시스템이 동작하는 데 문제는 없지만, 시스템이 성장하는 데 있어 발목을 잡을 확률이 높아진다.

또한 DTO를 반환하지 않더라도 인터페이스에 구현해야 할 메서드가 많아지면서 Repository의 책임이 눈에 보이지 않게 늘어나게 된다.

public interface MemberRepositoryCustom {

    List<Member> findByFilter(String filter, String keyword);

    List<Member> findByFoo(String foo);

    List<Member> findByBar(String bar);

    ...
}

// MemberRepository의 추상 메서드는 하나도 없지만, 보이지 않는 MemberRepositoryCustom의 추상 메서드의 수만큼 숨겨져있다.
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
    
}

테스트를 위해 MemberRepository를 Fake로 구현하는 경우 MemberRepositoryCustom의 추상 메서드를 모두 구현해야 한다.

public class MemoryMemberRepository implements MemberRepositoryCustom {

    @Override
    List<Member> findByFilter(String filter, String keyword) {
        ...
    }

    @Override
    List<Member> findByFoo(String foo) {
        ...
    }

    @Override
    List<Member> findByBar(String bar) {
        ...
    }

    ...
}

그렇다면 Custom Repository 말고 어떻게 사용해야 할까?

나의 기준은 도메인을 반환하고, 많은 JOIN 문이 들어가거나 분기가 필요하다면 Custom Repository를 만들어 해결하는 것이 좋다고 생각한다.

하지만 그 외 상황이면 별도의 Repository 구체 클래스를 만들어 사용하는 것이 오히려 시스템의 가독성과 유지보수성을 높이는 설계가 아닐까 한다.

class MemberQueryDSLRepository {

    private final JPAQueryFactory queryFactory;

    ...
}

결론

Custom Repository의 사용은 최소로 해야 한다.

추상 메서드 또는 @Query 어노테이션을 사용한 방법으로 해결할 수 없는 쿼리를 작성해야 하는 경우에만 Custom Repository를 사용하는 것이 시스템이 성장하는 데 있어 발목을 잡는 일이 없을 것이다.

예전 영한 님의 강의를 보며 JPA를 학습했을 때 무조건 Custom Repository를 만들어야 하는 고정 관념이 생겨, 아무 생각 없이 그냥 Custom 인터페이스와 Impl 클래스를 만들었던 것 같다. 😂

profile
꾸준히 성장하고 싶은 사람

0개의 댓글