Let's Git It 프로젝트에서 팀이 채택한 Repository 구조 설계 방식을 정리합니다.
JPA/QueryDSL에 종속되지 않는 Service를 만들기 위한 4-file DIP 패턴을 다룹니다.
처음 MemberRepository를 이렇게 작성했다.
public interface MemberRepository extends JpaRepository<Member, UUID> {
Optional<Member> findByEmail(String email);
boolean existsByEmail(String email);
}
AuthServiceImpl에서 이렇게 주입받아 사용했다.
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
private final MemberRepository memberRepository; // JPA 직접 의존
}
언뜻 보면 문제없어 보이지만, 여기에는 기술 종속성 문제가 있다.
MemberRepository가 JpaRepository를 상속하는 순간, Service가 JPA라는 기술을 직접 알게 된다.
Service → MemberRepository (extends JpaRepository) → JPA 기술
나중에 JPA를 QueryDSL로 바꾸거나, 특정 조회를 MongoDB로 처리하고 싶다면?
MemberRepository 인터페이스 자체를 수정해야 하고, 이에 의존하는 Service도 영향을 받는다.
팀에서 채택한 구조는 Repository를 4개 파일로 분리한다.
MemberRepository.java ← Service가 의존하는 순수 인터페이스
MemberRepositoryImpl.java ← 구현체 (JPA + QueryDSL 조합)
MemberJpaRepository.java ← Spring Data JPA 인터페이스
MemberDslRepository.java ← QueryDSL 복잡한 조회 (필요 시)
핵심은 Service가 순수 인터페이스에만 의존한다는 것이다.
Service → MemberRepository (순수 인터페이스)
↑
MemberRepositoryImpl (JPA + QueryDSL 실제 구현)
MemberRepository.java — Service가 의존하는 순수 인터페이스
package com.gitcat.letsgitit.domain.member.repository;
import java.util.Optional;
import java.util.UUID;
import com.gitcat.letsgitit.domain.member.entity.Member;
public interface MemberRepository {
Optional<Member> findByEmail(String email);
boolean existsByEmail(String email);
Member save(Member member);
Optional<Member> findById(UUID id);
}
MemberJpaRepository.java — Spring Data JPA
package com.gitcat.letsgitit.domain.member.repository;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import com.gitcat.letsgitit.domain.member.entity.Member;
public interface MemberJpaRepository extends JpaRepository<Member, UUID> {
Optional<Member> findByEmail(String email);
boolean existsByEmail(String email);
}
MemberRepositoryImpl.java — 구현체 (JPA + QueryDSL 조합)
package com.gitcat.letsgitit.domain.member.repository;
import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Repository;
import com.gitcat.letsgitit.domain.member.entity.Member;
import lombok.RequiredArgsConstructor;
@Repository
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepository {
private final MemberJpaRepository memberJpaRepository;
// private final MemberDslRepository memberDslRepository; // QueryDSL 필요 시 추가
// 단순 조회 → JPA 위임
@Override
public Optional<Member> findByEmail(String email) {
return memberJpaRepository.findByEmail(email);
}
@Override
public boolean existsByEmail(String email) {
return memberJpaRepository.existsByEmail(email);
}
@Override
public Member save(Member member) {
return memberJpaRepository.save(member);
}
@Override
public Optional<Member> findById(UUID id) {
return memberJpaRepository.findById(id);
}
}
Service → "이메일로 멤버 찾아줘" (인터페이스에 요청)
↓
Impl → "JPA로 찾아올게" or "QueryDSL로 찾아올게"
Service는 "뭘 해줘" 만 알고, "어떻게 해줄게" 는 Impl이 담당한다.
나중에 JPA를 QueryDSL로 바꾸더라도 Impl만 수정하면 되고 Service는 한 줄도 바뀌지 않는다.
| 변경 전 | 변경 후 | |
|---|---|---|
| Service 의존 대상 | JpaRepository 상속 인터페이스 | 순수 Java 인터페이스 |
| JPA → QueryDSL 변경 시 | Service도 수정 필요 | Impl만 수정 |
| 기술 종속성 | 있음 | 없음 |
| 파일 수 | 1개 | 3~4개 |
이 패턴과 혼동하기 쉬운 개념이 있다.
Repository DIP 패턴 — 기술 독립성
Service → 순수 인터페이스 (JPA/QueryDSL 몰라도 됨)
도메인 간 의존성 규칙 — 도메인 경계 유지
AuthService → MemberRepository (X) : 다른 도메인 Repository 직접 접근
AuthService → MemberService (O) : 서비스를 통해 접근
둘 다 별개의 규칙이다. Repository DIP는 기술 교체에 대한 유연성이고, 도메인 간 의존성은 도메인 경계를 지키는 것이다.
Before: Service → MemberRepository (extends JpaRepository) → JPA 종속
After: Service → MemberRepository (순수 인터페이스)
↑
MemberRepositoryImpl → MemberJpaRepository (JPA)
→ MemberDslRepository (QueryDSL, 필요 시)
처음에는 파일이 늘어나서 번거롭게 느껴질 수 있다. 하지만 프로젝트 규모가 커질수록 기술 교체나 쿼리 최적화가 필요한 순간이 생긴다. 그때 Service를 건드리지 않아도 된다는 게 이 구조의 진짜 장점이다.