Repository Pattern, 도메인을 저장소로부터 지키는 인터페이스

Repository Pattern의 인터페이스 추상화·인프라 어댑터 분리 구조와 DAO·Active Record 대비 차이, 테스트 전략, 점진적 도입 절차를 정리한다.

2026-08-13 · 최초 발행 2025-12-03

저장소를 다양한 방식으로 다루는 이유

같은 데이터 접근 문제를 두고 DAO, Active Record, Repository는 서로 다른 답을 낸다. DAO는 데이터 접근 자체를 중심에 둔 인터페이스라 저장소 API에 가깝게 설계되고, Active Record는 엔티티가 자기 자신의 저장·조회를 직접 책임진다. Repository Pattern은 이 둘과 달리 도메인 관점의 컬렉션 추상화다 — Aggregate Root 단위의 저장·조회·삭제 연산을 인터페이스로 제공하되, 실제 구현(RDB, NoSQL, 외부 API)은 인프라 계층에 격리한다. 결합도를 낮추고 테스트 가능성을 확보하며, 구현을 교체할 여지를 남기고, 도메인 언어로 의도를 표현하는 것이 목적이다.

인터페이스는 도메인 언어로, 구현은 어댑터로

인터페이스 설계의 원칙은 명확하다. save, findById, findBySpec 같은 메서드는 Aggregate Root 단위의 의미적 연산이어야 하고, 반환 타입은 도메인 엔티티·값 객체여야 한다 — 인프라 타입이 인터페이스 밖으로 새어 나가면 그 순간 추상화는 깨진다. 메서드 명세 자체도 유비쿼터스 언어를 반영하고, 부수효과와 트랜잭션 기대치를 문서화해야 호출하는 쪽이 오해하지 않는다.

구현체는 DB(JPA/SQL), NoSQL, 검색엔진, 외부 API 등 어댑터로 분리한다. ORM 매핑이나 데이터 매퍼 같은 매핑 계층과 로컬·Redis 캐시 계층을 조합해 설계하고, Unit of Work와 연계해 트랜잭션 경계를 관리하며 낙관적·비관적 락 전략을 상황에 맞게 고른다.

조회는 규칙과 함께, 테스트는 계약으로

조회 전용 쿼리와 도메인 규칙을 포함한 조회를 분리하는 데는 Specification Pattern이 쓰인다. 페이징·정렬·N+1 회피를 위한 fetch 전략, 배치 로딩, 슬라이스 조회를 설계하고, CQRS로 분리할 경우 CommandRepository와 ReadModel/QueryRepository를 구분한다.

테스트는 세 층으로 쌓는다. In-memory fake·stub으로 단위 테스트를 하고, 계약(Contract) 테스트로 여러 구현체가 같은 규약을 지키는지 확인해 구현 상호교체성을 보장한다. 통합 테스트는 Testcontainers로 실제 DB·캐시를 띄워 행위를 검증하며, 동시성·타임아웃·장애 주입 같은 경계 조건도 시나리오에 포함시킨다.

운영에서는 캐시 일관성과 장애 내성이 관건

캐시 일관성 전략(Write-through, Cache-aside)과 TTL·버전 관리를 설계하고, 분산 트랜잭션을 피하기 위해 Outbox나 Transactional Messaging을 도입한다. 재시도와 멱등성 키로 일시적 오류에 대한 회복력을 확보하는 것도 운영 단계의 몫이다.

전자상거래 주문 도메인에 적용하면

OrderRepositorysave, findById, findByBuyerIdAndPeriod, cancel 같은 도메인 언어 기반 인터페이스로 설계한다. 재고 감소 경쟁은 낙관적 락으로 처리하고, 실패하면 재시도나 보상 트랜잭션으로 넘어간다. 결제·배송 바운디드 컨텍스트와의 연동은 Outbox를 통한 이벤트 발행으로 이뤄진다.

CQRS·육각형 아키텍처와 결합하면

Command 측 Repository는 불변 조건 검증과 트랜잭션 경계 유지에 집중하고, Query 측은 읽기 최적화 뷰·검색엔진·캐시를 활용하는 QueryRepository로 분리한다. 포트-어댑터 구조를 취하면 단일 DB에서 읽기 복제본이나 검색엔진으로 구현체를 교체하기가 수월해진다.

캐시 계층화와 레거시 마이그레이션

복수 저장소를 쓸 때는 1차 캐시(세션/컨텍스트) → 2차 캐시(분산) → DB 순의 다단계 조회를 두고, 캐시 미스가 나면 DB 조회 후 캐시를 갱신하되 실패 시 폴백 경로를 마련한다. 장애 국면에서는 캐시를 회로 차단(Circuit Breaker)으로 격리한다. 레거시 마이그레이션에서는 Repository 인터페이스를 고정한 채 어댑터만 레거시 DB에서 신규 마이크로서비스로 점진 교체하고, 데이터 이중화 기간에는 읽기 분산과 최종 일관성 보장 메커니즘을 함께 운영한다.

처리 흐름

도메인 검증트랜잭션 시작캐시 조회HitMiss엔티티 로드/저장 완료커밋 성공예외 발생'애플리케이션 서비스'에서'Order 처리' 요청(입력)'OrderRepository' 인터페이스호출(처리)'Unit of Work' 경계 설정(처리)'캐시' Hit/Miss 판단(처리)'도메인 엔티티' 반환(출력)'DB 어댑터(JPA/SQL)'조회/저장(처리)'도메인 이벤트' 발행/Outbox기록(출력)'롤백 오류 매핑' 수행(오류처리)

저장 충돌은 낙관적 락 예외로 감지해 재시도하거나 사용자에게 피드백하고, 외부 API 실패는 타임아웃 감지 후 폴백·보상 트랜잭션과 멱등 키로 대응한다.

접근 방식 비교

구분 직접 DAO/SQL Repository Pattern Active Record
성능 최고 성능 달성 가능하나 서비스 계층 중복·분산 발생 추상화 오버헤드 소폭 존재, 일관된 튜닝 지점 확보 단순 CRUD는 빠름, 복잡 도메인에서 비효율
확장성 저장소 교체 비용 큼 어댑터 교체 용이, 수평 확장 친화 프레임워크 종속성 증가
일관성 트랜잭션 경계 분산 위험 서비스 경계에서 일관된 트랜잭션 관리 도메인 규칙 누수 가능
안정성 개발자 편차에 크게 의존 계약 기반 테스트로 품질 균질화 엔티티-영속성 결합으로 오류 전파
운영 편의 SQL 산재, 변경 영향도 파악 어려움 인터페이스 중심 변경 관리 용이 마이그레이션 난이도 상

코드로 보는 최소 구현

환경은 Java 17+, Spring Boot 3.3+, Spring Data JPA, H2(Local)를 전제로 한다.

도메인 인터페이스는 인프라를 전혀 언급하지 않는다.

// domain/OrderRepository.java
package com.example.domain;

import java.util.Optional;

public interface OrderRepository {
    Order save(Order order);
    Optional<Order> findById(OrderId id);
}

도메인 엔티티는 JPA 애노테이션을 붙이더라도 도메인 규칙을 스스로 담고 있다.

// domain/Order.java
package com.example.domain;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(name = "orders")
public class Order {
    @EmbeddedId
    private OrderId id;

    @Version
    private long version;

    private String buyerId;
    private Instant createdAt;

    protected Order() {} // for JPA

    public Order(OrderId id, String buyerId) {
        this.id = id;
        this.buyerId = buyerId;
        this.createdAt = Instant.now();
    }

    // getter/setter 생략
}

인프라 어댑터가 인터페이스를 구현하고, Spring Data JPA에 위임한다.

// infra/JpaOrderRepositoryAdapter.java
package com.example.infra;

import com.example.domain.Order;
import com.example.domain.OrderId;
import com.example.domain.OrderRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public class JpaOrderRepositoryAdapter implements OrderRepository {

    private final SpringDataOrderJpa jpa;

    public JpaOrderRepositoryAdapter(SpringDataOrderJpa jpa) {
        this.jpa = jpa;
    }

    @Override
    public Order save(Order order) {
        return jpa.save(order);
    }

    @Override
    public Optional<Order> findById(OrderId id) {
        return jpa.findById(id);
    }
}

// infra/SpringDataOrderJpa.java
package com.example.infra;

import com.example.domain.Order;
import com.example.domain.OrderId;
import org.springframework.data.jpa.repository.JpaRepository;

interface SpringDataOrderJpa extends JpaRepository<Order, OrderId> {}

실행하려면 application.ymlspring.jpa.hibernate.ddl-auto=create, spring.datasource.url=jdbc:h2:mem:testdb를 지정하면 된다.

모범사례와 트레이드오프

트랜잭션 경계는 애플리케이션 서비스 계층에서 관리하고 Repository는 영속성 연산에만 집중하는 편이 낫다. 복잡한 쿼리와 도메인 규칙은 Specification으로 규격화해 분리하고, N+1 회피 전략을 명시한다. 캐시는 일관성 정책과 TTL·버전, 불변 스냅샷, 멱등성 토큰을 함께 갖춰야 한다.

반대급부도 있다. 과도한 추상화는 학습 곡선과 성능 오버헤드를 유발하고, 데이터베이스 고유 기능(윈도우 함수, 힌트) 활용이 제약되므로 필요하면 전용 쿼리 포트를 따로 둬야 한다. Active Record와 혼용하면 책임 경계가 흐려지므로 아키텍처 가이드와 코드 리뷰를 강화해야 한다.

이렇게 표준화한 팀에서는 신규 기능 개발 리드타임이 1530% 단축되고, 계약 테스트 도입 시 회귀 결함이 20% 이상 줄며, 스텁·페이크 활용으로 단위 테스트 커버리지가 1020%p 개선되는 경향이 관찰된다. 다만 이 수치는 팀·도메인·도구 성숙도에 따라 달라지므로, 도입 전 기준선을 측정하고 도입 후 회귀 비교를 거쳐야 신뢰할 수 있다. 저장소 교체나 캐시 도입 시에는 변경 범위가 최소화되고, 트래픽 급증 상황에서는 읽기 전용 Repository를 추가해 수평 확장하기가 쉬워진다.

점진적으로 들여오는 절차

대상 Aggregate를 먼저 선정해 인터페이스 초안과 도메인 언어를 정립한다. 다음으로 DB 어댑터 1종을 구현하고 계약 테스트를 작성한다. 캐시와 폴백 경로를 추가한 뒤 장애·지연 주입 테스트를 거치고, 읽기·쓰기를 CQRS로 분리해 읽기 모델을 최적화하며 관찰지표를 설정한다. 마지막으로 레거시와 신규를 병행하는 단계적 마이그레이션을 진행하면서 성능·비용·품질 지표를 모니터링한다.

Repository Pattern도메인주도설계테스트전략CQRS육각형아키텍처