[third tool] ADR-014 아키텍처 트레이드오프 -- 왜 Repository를 4개로 쪼갰는가

junsung kim·2026년 3월 23일

[project]- thirdTool

목록 보기
22/29

목차

왜 Repository를 4개로 쪼갰는가 — ADR-014 아키텍처 트레이드오프 회고

ThirdTool 프로젝트에서 CardRepository, CardRepositoryAdapter, CardRepositoryCustom, CardJpaRepositoryImpl 4계층 구조를 채택한 배경과, 단순한 JpaRepository 직접 주입 방식과의 트레이드오프를 정리한 글입니다.


배경

ThirdTool은 스페이스드 리피티션(Spaced Repetition) 학습 앱으로,
Card 도메인이 서비스 핵심입니다. 초기에는 CardJpaRepository를 Service에서 직접 주입해서 사용하는 방식이 직관적이었습니다.
그런데 QueryDSL 동적 쿼리가 들어오고, 멀티 모듈 전환 가능성이 생기면서 구조 안정성의 의문이 생겼습니다.

결론적으로 채택한 구조는 아래 4계층입니다.

CardRepository          ← 의존성 역전용 포트 인터페이스
CardRepositoryAdapter   ← CardRepository 구현체, 실제 JPA 호출 담당
CardRepositoryCustom    ← QueryDSL 동적 쿼리 연결 지점
CardJpaRepositoryImpl   ← CardRepositoryCustom 구현체

이 선택이 어떤 문제를 풀고, 어떤 비용을 지불하는지 트레이드오프 관점에서 돌아봅니다.


이전 방식 — Service가 JpaRepository를 직접 주입

@Service
public class CardQueryService {
    private final CardJpaRepository cardJpaRepository; // JPA 직접 주입

    public Card getActiveCard(Long cardId) {
        return cardJpaRepository.findByIdWithKeywords(cardId); // JPA 메서드 직접 호출
    }
}

이 방식의 강점은 단순함입니다. (이게 처음엔 정석인줄 알았는데....ㅎ)

  • 파일이 적고, 연결 고리가 눈에 바로 보입니다.
  • Spring Data JPA의 자동 구현 덕에 findByXxx() 네이밍만으로 쿼리가 만들어집니다.
  • 초기 개발 속도가 빠릅니다.

그런데 프로젝트가 커지면서 다음 문제들이 수면 위로 올라왔습니다.

문제 1. Service가 JPA 구현 세부사항에 직접 의존

findByIdWithKeywords()페치 조인이라는 JPA 구현 세부사항입니다. 이 메서드 이름이 Service 코드에 박히면, 나중에 "페치 조인 대신 별도 쿼리 2번으로 바꾸자"고 결정했을 때 Service도 같이 수정해야 합니다. (유지보수 관점에서 자꾸만 애매하게 걸렸던게 컸다.)

인터페이스(What)와 구현(How)이 Service 안에서 뒤섞이는 상황입니다.

문제 2. 단위 테스트에서 DB가 필요해짐

CardJpaRepository는 Spring Data JPA가 런타임에 프록시로 생성합니다. 이를 Mock으로 대체하려면 @DataJpaTest@SpringBootTest처럼 Spring Context를 띄워야 하고, 테스트 비용이 올라갑니다.

// CardJpaRepository를 직접 주입하면 단위 테스트에서 이렇게 해야 함
@DataJpaTest // Spring Context 필요 — 느리고 무겁다
class CardQueryServiceTest { ... }

문제 3. 멀티 모듈 전환 시 JPA 의존성이 상위 레이어로 침투

도메인/애플리케이션 모듈과 인프라 모듈을 분리할 때, Service가 CardJpaRepository를 직접 알고 있으면 애플리케이션 모듈에 JPA 의존성이 올라옵니다. 이는 "기술 의존성은 인프라 레이어에 가두자"는 원칙을 깨뜨립니다.


이후 방식 — 포트-어댑터 구조

전체 연결 구도

[Service 레이어]
  CardQueryService
  CardCommandService
       │
       │ 의존 (인터페이스만 앎)
       ▼
[CardRepository] ← 포트 인터페이스
  save()
  findById()
  findAllByDeckIdAndDeletedFalse()
  searchCards()
       │
       │ 구현
       ▼
[CardRepositoryAdapter] ← @Repository 어댑터
  └── CardJpaRepository 주입 후 각 포트 메서드 → JPA 호출로 변환
        ├── findByIdWithKeywords()    ← JPQL 페치 조인
        ├── findAllByDeckId...()      ← JPA 네이밍 쿼리
        └── searchCards()             ← QueryDSL (Impl 위임)

CardRepository — 포트 인터페이스

public interface CardRepository {
    Card save(Card card);
    Optional<Card> findById(Long id);
    List<Card> findAllByDeckIdAndDeletedFalse(Long deckId);
    Page<Card> searchCards(CardSearchCondition condition, Pageable pageable);
}

JPA도, QueryDSL도 이 인터페이스에는 등장하지 않습니다. Service가 의존하는 계약(Contract)만 정의합니다.

CardRepositoryAdapter — 어댑터

@Repository
@RequiredArgsConstructor
public class CardRepositoryAdapter implements CardRepository {

    private final CardJpaRepository cardJpaRepository;

    @Override
    public Optional<Card> findById(Long id) {
        return cardJpaRepository.findByIdWithKeywords(id); // 구현 세부사항은 여기서 결정
    }

    @Override
    public Page<Card> searchCards(CardSearchCondition condition, Pageable pageable) {
        return cardJpaRepository.searchCards(condition, pageable); // QueryDSL로 위임
    }
}

Service는 CardRepository라는 인터페이스만 알고, Spring DI가 런타임에 CardRepositoryAdapter를 주입합니다. Service는 이 사실을 모르고, 알 필요도 없습니다.

CardRepositoryCustom + CardJpaRepositoryImpl — QueryDSL 연결

이 두 개는 의존성 역전이 목적이 아닙니다. Spring Data JPA의 기술적 제약을 해결하기 위한 구조입니다.

JpaRepository는 기본 CRUD와 네이밍 쿼리만 자동 구현합니다. QueryDSL 동적 쿼리를 추가하려면 Custom 인터페이스 + Impl 네이밍 규칙을 따라야 Spring이 자동으로 감지해서 연결해줍니다.

// Spring이 "CardJpaRepository명 + Impl" 규칙으로 자동 감지
public class CardJpaRepositoryImpl implements CardRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<Card> searchCards(CardSearchCondition condition, Pageable pageable) {
        // QueryDSL 동적 쿼리
        List<Card> content = queryFactory
            .selectFrom(card)
            .where(
                keywordContains(condition.getKeyword()),
                deckIdEq(condition.getDeckId())
            )
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
        // ...
    }
}

트레이드오프 정리

관점직접 주입 방식포트-어댑터 방식
코드 양적음 (파일 2개)많음 (파일 4개)
초기 개발 속도빠름느림
Service의 기술 의존JPA에 직접 결합인터페이스에만 결합
단위 테스트Spring Context 필요Mock 주입으로 충분
구현 교체 비용Service도 수정 필요Adapter만 수정
멀티 모듈 확장성JPA 의존이 상위 침투인프라 모듈에 격리
구조 이해 난이도낮음높음 (패턴 이해 필요)

언제 포트-어댑터가 유효한가

  • QueryDSL 등 커스텀 쿼리가 많을 때: 기술 세부사항이 Service에 노출되는 면적이 커집니다.
  • 단위 테스트를 DB 없이 돌리고 싶을 때: Mock 교체가 인터페이스 덕에 자연스럽습니다.
  • 멀티 모듈 전환 가능성이 있을 때: 도메인/애플리케이션 모듈이 JPA를 몰라도 됩니다.
  • 구현 전략이 바뀔 가능성이 있을 때: 페치 조인 → 별도 쿼리, JPA → MyBatis 등 전환 비용을 줄입니다.

언제 직접 주입이 더 나은가

  • 프로젝트 규모가 작고 팀이 혼자일 때
  • 단위 테스트보다 통합 테스트 중심으로 운영할 때
  • 레이어 분리보다 개발 속도가 우선일 때

결론

포트-어댑터 구조는 "지금 당장의 복잡함"을 지불하고 "미래의 변경 비용"을 낮추는 선택입니다.

ThirdTool 기준으로는 QueryDSL 동적 쿼리가 이미 들어와 있고, 멀티 모듈 전환 가능성도 열어두고 싶었기 때문에 이 방향이 적합하다고 판단했습니다.

다만 솔직히 말하면, 1인 프로젝트 초기에 파일 4개를 만드는 게 오버엔지니어링처럼 느껴지는 순간도 있었습니다. 그 불편함 자체가 이 구조의 비용이고, 그 비용이 납득 가능한지 팀 상황에 맞게 따져봐야 한다는 게 제 결론입니다.

ADR(Architecture Decision Record)로 이 결정을 문서화해두면, 나중에 "왜 이렇게 만들었지?"라는 질문에 빠르게 답할 수 있습니다. 의사결정의 맥락을 코드가 아닌 문서에 남기는 습관, 추천합니다.


ThirdTool ADR 시리즈는 계속됩니다.

최종 수정일: 2026-03-23

profile
edit하는 개발자! story 있는 삶

0개의 댓글