TypeScript 기초 완벽 가이드
타입 기초, 인터페이스, 제네릭 입문
학습 항목
이미지 로딩 중...
Repository Pattern 기초부터 심화까지
데이터 접근 로직을 추상화하는 Repository Pattern의 개념부터 실전 구현까지 다룹니다. 실무에서 바로 적용할 수 있는 베스트 프랙티스와 함께 클린 아키텍처를 구현하는 방법을 배워보세요.
들어가며
안녕하세요!
여러분이 Repository Pattern 기초부터 심화까지에 대해 궁금하셨다면 잘 찾아오셨습니다. 이 글에서는 실무에서 바로 사용할 수 있는 핵심 개념들을 친근하고 이해하기 쉽게 설명해드리겠습니다.
현대 소프트웨어 개발에서 TypeScript는 매우 중요한 위치를 차지하고 있습니다. 복잡해 보이는 개념들도 하나씩 차근차근 배워나가면 어렵지 않게 마스터할 수 있습니다.
총 9가지 주요 개념을 다루며, 각각의 개념마다 실제 동작하는 코드 예제와 함께 상세한 설명을 제공합니다. 단순히 '무엇'인지만 알려드리는 것이 아니라, '왜' 필요한지, '어떻게' 동작하는지, 그리고 '언제' 사용해야 하는지까지 모두 다룹니다.
초보자도 쉽게 따라할 수 있도록 단계별로 풀어서 설명하며, 실무에서 자주 마주치는 상황을 예시로 들어 더욱 실용적인 학습이 되도록 구성했습니다. 이론만 알고 있는 것이 아니라 실제 프로젝트에 바로 적용할 수 있는 수준을 목표로 합니다!
목차
- Repository_Pattern_개요 - 데이터 접근 계층 분리 패턴
- 기본_Repository_구현 - 인터페이스와 구체 클래스 작성
- Generic_Repository - 재사용 가능한 공통 Repository
- Unit_of_Work_패턴 - 트랜잭션 관리와 일관성
- Repository_테스트_전략 - Mock과 Stub 활용법
- 실전_에러_핸들링 - Repository 계층 예외 처리
- 캐싱_전략 - Repository 성능 최적화
- Repository_vs_DAO - 패턴 비교와 선택 기준
- Repository_Pattern_개요
1. Repository_Pattern_개요 - 데이터 접근 계층 분리 패턴
2. 기본_Repository_구현 - 인터페이스와 구체 클래스 작성
3. Generic_Repository - 재사용 가능한 공통 Repository
4. Unit_of_Work_패턴 - 트랜잭션 관리와 일관성
5. Repository_테스트_전략 - Mock과 Stub 활용법
6. 실전_에러_핸들링 - Repository 계층 예외 처리
7. 캐싱_전략 - Repository 성능 최적화
8. Repository_vs_DAO - 패턴 비교와 선택 기준
1. Repository_Pattern_개요
여러분이 백엔드 API를 개발할 때 이런 상황을 겪어본 적 있나요? 컨트롤러에서 직접 데이터베이스 쿼리를 작성하다 보니 똑같은 쿼리 로직이 여러 곳에 중복되고, 데이터베이스를 MySQL에서 PostgreSQL로 변경하려니 수백 개의 파일을 수정해야 하는 상황 말이죠. 이런 문제는 실제 개발 현장에서 자주 발생합니다. 비즈니스 로직과 데이터 접근 로직이 섞여있으면 코드의 재사용성이 떨어지고, 테스트하기 어려워지며, 데이터베이스 변경 시 전체 코드를 수정해야 하는 악몽이 시작됩니다. 바로 이럴 때 필요한 것이 Repository Pattern입니다. 이 패턴은 데이터 접근 로직을 별도의 계층으로 분리하여 비즈니스 로직에서 데이터 소스의 구체적인 구현을 숨겨줍니다. 여러분이 실제 프로젝트에서 Repository를 구현하려고 할 때 이런 고민을 하게 됩니다. "인터페이스는 어디에 두어야 하지?", "DTO 변환은 어디서 해야 하나?", "에러 처리는 어떻게 하지?" 같은 구체적인 구현 문제들 말이죠. 이런 문제들은 처음 Repository Pattern을 적용하는 개발자들이 가장 많이 겪는 어려움입니다. 이론은 알지만 실제 코드로 옮길 때 계층 구조를 어떻게 나눌지, 파일을 어떻게 구성할지 막막한 경우가 많습니다. 바로 이럴 때 필요한 것이 실전 구현 가이드입니다. 실제 동작하는 코드와 함께 계층별 책임을 명확히 이해하면 여러분의 프로젝트에 바로 적용할 수 있습니다. 여러분이 여러 개의 Repository를 만들다 보면 이런 생각이 듭니다. "findById, findAll, save, delete는 모든 Repository에 똑같이 있는데, 이걸 계속 반복해서 구현해야 하나?" 실제로 10개의 Entity가 있다면 같은 코드를 10번 작성하게 됩니다. 이런 중복은 유지보수의 악몽을 불러옵니다. 나중에 모든 Repository에 softDelete 기능을 추가하려면 10개 파일을 모두 수정해야 하고, 에러 처리 방식을 통일하려면 역시 모든 파일을 고쳐야 합니다. 바로 이럴 때 필요한 것이 Generic Repository입니다. TypeScript의 제네릭을 활용하면 공통 기능을 한 곳에 구현하고 모든 Repository가 이를 상속받아 사용할 수 있습니다. 여러분이 주문 시스템을 개발할 때 이런 상황을 맞닥뜨립니다. 주문을 생성하고, 재고를 감소시키고, 결제를 처리하는데, 중간에 재고 감소가 실패하면 이미 생성된 주문은 어떻게 하죠? 각 Repository를 개별적으로 호출하면 일관성을 보장할 수 없습니다. 이런 문제는 실무에서 매우 흔합니다. 여러 개의 Repository 작업이 하나의 비즈니스 트랜잭션으로 묶여야 하는데, 각 Repository가 독립적으로 데이터베이스에 접근하면 중간에 실패했을 때 롤백이 안 됩니다. 결과적으로 데이터 불일치가 발생합니다. 바로 이럴 때 필요한 것이 Unit of Work 패턴입니다. 여러 Repository 작업을 하나의 트랜잭션으로 묶어서 모두 성공하거나 모두 실패하도록 보장합니다. 여러분이 Repository를 구현했다면 이제 테스트를 작성해야 합니다. 그런데 "데이터베이스를 어떻게 테스트하지?", "실제 DB를 사용할까, Mock을 사용할까?", "각 테스트마다 데이터를 어떻게 초기화하지?" 같은 고민이 생깁니다. 이런 문제는 특히 초보 개발자들이 가장 어려워하는 부분입니다. 테스트를 위해 실제 데이터베이스를 사용하면 느리고 환경 설정이 복잡하며, Mock을 사용하면 실제 쿼리가 제대로 동작하는지 확인할 수 없습니다. 어떤 전략을 선택해야 할까요? 바로 이럴 때 필요한 것이 체계적인 Repository 테스트 전략입니다. 단위 테스트와 통합 테스트를 적절히 조합하여 빠르고 안정적인 테스트 스위트를 구축할 수 있습니다. 여러분이 Repository를 실제 운영 환경에 배포하면 예상치 못한 에러들이 발생합니다. 데이터베이스 연결이 끊어지거나, 중복 키 에러가 발생하거나, 외래 키 제약을 위반하거나, 타임아웃이 발생하는 등의 상황입니다. 이런 문제들을 제대로 처리하지 않으면 사용자에게 "Internal Server Error"만 보여주게 되고, 로그를 봐도 정확한 원인을 파악하기 어렵습니다. 심지어 데이터베이스 연결 풀이 고갈되어 서버 전체가 멈추는 장애로 이어질 수도 있습니다. 바로 이럴 때 필요한 것이 체계적인 에러 핸들링 전략입니다. Repository 계층에서 데이터베이스 에러를 도메인 에러로 변환하고, 적절한 재시도 로직과 로깅을 추가하면 안정적인 시스템을 만들 수 있습니다. 여러분이 인기 있는 API를 운영하다 보면 동일한 데이터를 반복해서 조회하는 요청이 엄청나게 많다는 것을 알게 됩니다. "인기 상품 목록"이나 "카테고리 리스트"처럼 자주 바뀌지 않는 데이터를 매번 데이터베이스에서 가져오는 것은 비효율적입니다. 이런 문제는 트래픽이 증가하면서 더 심각해집니다. 데이터베이스 CPU 사용률이 100%에 도달하고, 응답 시간이 느려지며, 최악의 경우 데이터베이스가 다운됩니다. 스케일 업으로 해결하려면 비용이 기하급수적으로 증가합니다. 바로 이럴 때 필요한 것이 Repository 계층의 캐싱 전략입니다. Redis나 In-Memory 캐시를 Repository 내부에 통합하면 데이터베이스 부하를 90% 이상 줄이면서도 투명하게 동작합니다. 여러분이 Repository Pattern을 공부하다 보면 "DAO(Data Access Object)와 뭐가 다르지?"라는 의문이 생깁니다. 둘 다 데이터 접근을 추상화하는 것처럼 보이는데, 왜 굳이 Repository라는 이름을 사용할까요? 이런 혼란은 실제로 많은 개발자들이 겪는 문제입니다. 심지어 경험 많은 개발자들도 Repository와 DAO를 혼용하거나, 이름만 Repository인 DAO를 만들기도 합니다. 정확한 차이를 모르면 설계가 꼬이게 됩니다. 바로 이럴 때 필요한 것이 Repository와 DAO의 철학적 차이를 이해하는 것입니다. 두 패턴의 목적과 사용 시나리오를 명확히 알면 프로젝트에 맞는 선택을 할 수 있습니다.
개념 이해하기
간단히 말해서, Repository Pattern은 데이터 접근 로직을 추상화하여 비즈니스 로직과 분리하는 디자인 패턴입니다. 마치 도서관 사서처럼, 우리가 "이 책 찾아줘"라고 요청하면 사서가 알아서 찾아주는 것처럼 동작합니다. 왜 이 패턴이 필요할까요? 첫째, 데이터 소스가 변경되어도 비즈니스 로직은 수정할 필요가 없습니다. 둘째, 테스트할 때 실제 데이터베이스 대신 Mock Repository를 사용할 수 있어 단위 테스트가 훨씬 쉬워집니다. 셋째, 동일한 데이터 접근 로직의 중복을 제거할 수 있습니다. 예를 들어, "활성 사용자 조회" 로직을 여러 서비스에서 사용한다면, Repository에 한 번만 구현하면 됩니다. 전통적인 방법과의 비교를 해볼까요? 기존에는 Service 레이어에서 직접 ORM 메서드를 호출했다면, 이제는 Repository 인터페이스를 통해 데이터를 요청합니다. 이로써 Service는 "어떻게" 데이터를 가져오는지 몰라도 되고, "무엇을" 원하는지만 명시하면 됩니다. 이 패턴의 핵심 특징은 세 가지입니다. 첫째, 인터페이스를 통한 추상화로 느슨한 결합을 만듭니다. 둘째, 도메인 중심적인 메서드 이름을 사용합니다 (findById가 아닌 getUserByEmail처럼). 셋째, 데이터 소스의 세부 구현을 완전히 캡슐화합니다. 이러한 특징들이 중요한 이유는 유지보수성과 확장성을 크게 향상시키기 때문입니다. 간단히 말해서, Repository 구현은 크게 세 부분으로 나뉩니다: Domain 계층의 인터페이스, Infrastructure 계층의 구현체, 그리고 Application 계층에서의 사용입니다. 왜 이렇게 계층을 나눌까요? 첫째, Domain 계층은 순수한 비즈니스 로직만 담아야 하므로 외부 기술(Prisma, TypeORM 등)에 의존하면 안 됩니다. 따라서 인터페이스만 정의합니다. 둘째, Infrastructure 계층은 외부 기술과의 연동을 담당하므로 실제 데이터베이스 접근 코드를 여기에 둡니다. 예를 들어, 이메일 발송, 파일 저장, 데이터베이스 쿼리 등 모든 외부 의존성은 Infrastructure에 위치합니다. 전통적인 MVC 구조와 비교해볼까요? 기존에는 Model이 데이터베이스 테이블과 1:1로 매핑되고 Controller에서 직접 Model을 사용했다면, 이제는 Domain Entity가 비즈니스 로직을 담고, Repository가 데이터 접근을 담당하며, Service가 이들을 조율합니다. 핵심 특징은 다음과 같습니다. 첫째, 의존성 역전 원칙(DIP)을 따릅니다. 고수준 모듈(Service)이 저수준 모듈(Repository 구현체)에 의존하지 않고, 둘 다 추상화(인터페이스)에 의존합니다. 둘째, Entity와 DTO를 분리합니다. 데이터베이스 스키마가 변경되어도 Domain Entity는 영향받지 않습니다. 이러한 특징들이 중요한 이유는 변경에 유연하고 테스트 가능한 시스템을 만들기 때문입니다. 간단히 말해서, Generic Repository는 타입 파라미터를 사용하여 모든 Entity에서 재사용 가능한 공통 Repository 기능을 구현하는 패턴입니다. 마치 Array<T>가 모든 타입의 배열을 다루는 것처럼, BaseRepository<T>는 모든 Entity를 다룰 수 있습니다. 왜 이 패턴이 필요할까요? 첫째, DRY(Don't Repeat Yourself) 원칙을 지킬 수 있습니다. CRUD 로직을 한 번만 작성하면 됩니다. 둘째, 일관성을 보장합니다. 모든 Repository가 같은 방식으로 에러를 처리하고, 같은 방식으로 로깅하며, 같은 트랜잭션 관리 방식을 사용합니다. 예를 들어, 모든 Repository의 save 메서드가 동일한 검증 로직을 수행하도록 보장할 수 있습니다. 전통적인 방법과의 비교를 해보겠습니다. 기존에는 각 Repository를 처음부터 끝까지 직접 구현했다면, 이제는 Base Repository를 상속받아 필요한 메서드만 추가로 구현합니다. 이로써 80%의 공통 코드는 재사용하고, 20%의 특수한 로직만 각 Repository에 작성하면 됩니다. 핵심 특징은 다음과 같습니다. 첫째, 타입 안정성을 유지합니다. Generic을 사용하면 컴파일 타임에 타입 체크가 되므로 런타임 에러를 방지합니다. 둘째, 확장 가능합니다. BaseRepository의 메서드를 오버라이드하여 특정 Entity만의 로직을 추가할 수 있습니다. 셋째, 추상 클래스나 인터페이스로 계약을 정의할 수 있습니다. 이러한 특징들이 중요한 이유는 코드 중복을 줄이면서도 유연성을 잃지 않기 때문입니다. 간단히 말해서, Unit of Work는 비즈니스 트랜잭션 동안 영향받는 모든 객체의 변경사항을 추적하고, 최종적으로 한 번에 데이터베이스에 반영하는 패턴입니다. 마치 쇼핑 카트처럼, 여러 작업을 담아두었다가 "결제" 버튼을 누를 때 한꺼번에 처리합니다. 왜 이 패턴이 필요할까요? 첫째, 트랜잭션의 일관성을 보장합니다. 주문, 재고, 결제가 모두 성공하거나 모두 실패합니다. 둘째, 데이터베이스 호출을 최적화할 수 있습니다. 개별 Repository가 각각 save를 호출하는 대신, Unit of Work가 모든 변경사항을 모아서 한 번의 트랜잭션으로 처리합니다. 예를 들어, 10개의 Entity를 수정할 때 10번의 개별 쿼리 대신 1번의 배치 쿼리로 처리할 수 있습니다. 전통적인 방법과 비교해보겠습니다. 기존에는 Service에서 try-catch로 각 Repository를 감싸고 수동으로 롤백 로직을 작성했다면, 이제는 Unit of Work의 commit()과 rollback()으로 자동 관리됩니다. 코드가 훨씬 간결해지고 실수할 여지가 줄어듭니다. 핵심 특징은 세 가지입니다. 첫째, 변경 추적(Change Tracking)을 자동으로 합니다. 어떤 Entity가 생성/수정/삭제되었는지 Unit of Work가 기억합니다. 둘째, 지연된 쓰기(Lazy Write)를 지원합니다. commit() 호출 전까지는 실제 데이터베이스에 반영하지 않습니다. 셋째, 트랜잭션 경계를 명확히 합니다. begin, commit, rollback으로 트랜잭션의 시작과 끝이 명확합니다. 이러한 특징들이 중요한 이유는 복잡한 비즈니스 트랜잭션을 안전하고 효율적으로 처리하기 때문입니다. 간단히 말해서, Repository 테스트는 세 가지 레벨로 나뉩니다: Unit Test(Mock 사용), Integration Test(Test Container 사용), E2E Test(실제 DB 사용). 각각 목적과 속도가 다릅니다. 왜 이렇게 나눌까요? 첫째, 빠른 피드백이 필요한 단위 테스트는 Mock Repository를 사용하여 초 단위로 실행됩니다. Service 로직이 올바르게 Repository를 호출하는지만 검증합니다. 둘째, 실제 쿼리가 제대로 동작하는지 확인하는 통합 테스트는 Docker로 임시 데이터베이스를 띄워서 실행합니다. 셋째, 전체 시스템이 함께 동작하는지 보는 E2E 테스트는 스테이징 환경에서 실행됩니다. 예를 들어, TDD를 할 때는 Mock 테스트로 빠르게 반복하고, PR 전에는 통합 테스트로 검증하며, 배포 전에는 E2E 테스트로 최종 확인합니다. 전통적인 방법과 비교해볼까요? 기존에는 테스트를 위해 개발자마다 로컬 DB를 설치하고 수동으로 데이터를 넣었다면, 이제는 Test Container로 격리된 환경을 자동으로 만들고, Seed 데이터도 코드로 관리합니다. 환경 차이로 인한 테스트 실패가 사라집니다. 핵심 특징은 다음과 같습니다. 첫째, 테스트 격리(Isolation)를 보장합니다. 각 테스트는 독립적으로 실행되어 서로 영향을 주지 않습니다. 둘째, 반복 가능성(Repeatability)을 제공합니다. 언제 실행해도 같은 결과가 나옵니다. 셋째, 테스트 데이터 빌더 패턴으로 가독성을 높입니다. 복잡한 Entity를 쉽게 생성할 수 있습니다. 이러한 특징들이 중요한 이유는 신뢰할 수 있는 테스트 스위트가 리팩토링과 지속적 개발의 안전망이 되기 때문입니다. 간단히 말해서, Repository 에러 핸들링은 저수준 데이터베이스 에러를 고수준 도메인 에러로 변환하는 과정입니다. Prisma의 PrismaClientKnownRequestError를 NotFoundError, DuplicateError로 바꿔서 상위 계층이 이해할 수 있게 만듭니다. 왜 이런 변환이 필요할까요? 첫째, 의존성 역전 원칙을 지키기 위해서입니다. Service 계층이 Prisma의 에러 타입을 직접 알면 안 됩니다. 둘째, 에러 처리를 일관되게 만들기 위해서입니다. 모든 Repository가 같은 도메인 에러를 던지면 상위 계층에서 통일된 방식으로 처리할 수 있습니다. 예를 들어, NotFoundError는 404로, DuplicateError는 409로 자동 매핑되도록 만들 수 있습니다. 전통적인 방법과 비교해보겠습니다. 기존에는 Repository에서 데이터베이스 에러를 그대로 던지고 Controller에서 처리했다면, 이제는 Repository에서 도메인 에러로 변환하여 던지고 Error Handler 미들웨어가 HTTP 응답으로 변환합니다. 계층 간 책임이 명확해집니다. 핵심 특징은 다음과 같습니다. 첫째, 에러 계층화(Error Hierarchy)로 구체적인 에러 타입을 만듭니다. RepositoryError → DatabaseError → ConnectionError 같은 계층 구조로 세밀한 에러 처리가 가능합니다. 둘째, 재시도 로직(Retry Logic)을 추가합니다. 일시적인 연결 오류는 자동으로 재시도합니다. 셋째, 구조화된 로깅(Structured Logging)으로 문제 추적을 쉽게 만듭니다. 이러한 특징들이 중요한 이유는 운영 환경에서의 안정성과 디버깅 효율성을 크게 높이기 때문입니다. 간단히 말해서, Repository 캐싱은 자주 조회되는 데이터를 메모리에 저장하여 데이터베이스 접근을 최소화하는 기법입니다. Cache-Aside 패턴을 사용하여 캐시를 먼저 확인하고, 없으면 데이터베이스에서 가져와 캐시에 저장합니다. 왜 이 패턴이 필요할까요? 첫째, 성능이 극적으로 향상됩니다. 데이터베이스 조회는 10-50ms 걸리지만, Redis는 1ms 미만, In-Memory는 마이크로초 단위입니다. 둘째, 데이터베이스 부하를 줄입니다. 읽기 트래픽의 80%를 캐시로 처리하면 데이터베이스는 쓰기에만 집중할 수 있습니다. 예를 들어, "오늘의 추천 상품"을 1만 명이 조회할 때, 캐시 없이는 데이터베이스에 1만 번 쿼리하지만, 캐시를 사용하면 1번만 쿼리하고 나머지는 캐시에서 반환합니다. 전통적인 방법과 비교해보겠습니다. 기존에는 Service 계층에서 캐시를 직접 관리했다면, 이제는 Repository가 내부적으로 캐시를 처리하여 Service는 캐시 존재 여부를 모릅니다. 관심사의 분리가 명확해집니다. 핵심 특징은 다음과 같습니다. 첫째, 투명성(Transparency)을 제공합니다. 호출하는 쪽은 캐시가 있는지 없는지 알 필요가 없습니다. 둘째, TTL(Time To Live)로 자동 만료를 관리합니다. 오래된 데이터가 계속 남아있지 않습니다. 셋째, Cache Invalidation 전략으로 데이터 일관성을 유지합니다. 데이터가 수정되면 캐시를 자동으로 삭제합니다. 이러한 특징들이 중요한 이유는 고성능과 데이터 일관성을 동시에 달성할 수 있기 때문입니다. 간단히 말해서, DAO는 데이터베이스 테이블에 대응하는 CRUD를 제공하는 데이터 중심 패턴이고, Repository는 도메인 컬렉션처럼 동작하는 비즈니스 중심 패턴입니다. DAO는 "어떻게 저장하는가"에 집중하고, Repository는 "무엇을 저장하는가"에 집중합니다. 왜 이 차이가 중요할까요? 첫째, 메서드 이름이 달라집니다. DAO는 insert(), update(), delete()처럼 데이터베이스 작업을 그대로 드러내지만, Repository는 add(), save(), remove()처럼 도메인 언어를 사용합니다. 둘째, 집합 수준이 다릅니다. DAO는 테이블 단위(User, Order)로 나뉘지만, Repository는 Aggregate Root 단위(Customer와 Address를 함께 관리)로 나뉩니다. 예를 들어, 주문과 주문 항목을 함께 저장할 때 DAO는 OrderDAO와 OrderItemDAO를 각각 호출하지만, Repository는 OrderRepository.save(order)로 한 번에 처리합니다. 전통적인 방법과 비교해보겠습니다. 기존 Java EE의 DAO 패턴은 데이터베이스 테이블 구조를 그대로 반영했다면, DDD(Domain-Driven Design)의 Repository는 도메인 모델의 영속성을 담당합니다. 데이터베이스가 3개 테이블로 나뉘어 있어도 Repository는 하나일 수 있습니다. 핵심 특징을 비교하면 다음과 같습니다. 첫째, DAO는 1 테이블 = 1 DAO지만, Repository는 1 Aggregate = 1 Repository입니다. 둘째, DAO는 DTO를 반환하지만, Repository는 Domain Entity를 반환합니다. 셋째, DAO는 SQL 친화적이지만, Repository는 도메인 친화적입니다. 이러한 차이들이 중요한 이유는 코드의 의도와 유지보수성에 큰 영향을 미치기 때문입니다.
코드 예제
// DAO 스타일 - 데이터베이스 중심
class UserDAO {
insert(userData: UserDTO): Promise<void> {
return this.db.query('INSERT INTO users ...');
}
update(id: number, userData: UserDTO): Promise<void> {
return this.db.query('UPDATE users SET ...');
}
delete(id: number): Promise<void> {
return this.db.query('DELETE FROM users ...');
}
selectById(id: number): Promise<UserDTO | null> {
return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
}
selectAll(): Promise<UserDTO[]> {
return this.db.query('SELECT * FROM users');
}
}
// Repository 스타일 - 도메인 중심
class UserRepository {
async save(user: User): Promise<User> {
// User Aggregate에는 Profile, Preferences도 포함될 수 있음
const userData = this.toData(user);
const profileData = this.toProfileData(user.profile);
await this.prisma.$transaction([
this.prisma.user.upsert({ where: { id: user.id }, create: userData, update: userData }),
this.prisma.profile.upsert({ where: { userId: user.id }, create: profileData, update: profileData })
]);
return user;
}
async findById(id: string): Promise<User | null> {
// 관련 데이터를 함께 조회하여 완전한 Aggregate 구성
const data = await this.prisma.user.findUnique({
where: { id },
include: { profile: true, preferences: true }
});
if (!data) return null;
// DTO가 아닌 Domain Entity 반환
return this.toEntity(data);
}
async findActiveUsers(): Promise<User[]> {
// 비즈니스 의미 있는 메서드명
const users = await this.prisma.user.findMany({
where: { isActive: true, emailVerified: true },
include: { profile: true }
});
return users.map(u => this.toEntity(u));
}
async remove(user: User): Promise<void> {
// delete가 아닌 remove - 도메인 언어 사용
// Soft delete 같은 비즈니스 규칙 적용 가능
await this.prisma.user.update({
where: { id: user.id },
data: { deletedAt: new Date() }
});
}
}
// 언제 어떤 것을 선택할까?
// DAO 선택 시나리오:
// - 단순 CRUD 애플리케이션
// - 데이터베이스 구조가 도메인 모델과 일치
// - 빠른 개발이 우선
class ProductDAO {
// 테이블 구조 그대로 매핑
selectByCategory(category: string): Promise<ProductDTO[]> {
return this.db.query('SELECT * FROM products WHERE category = ?', [category]);
}
}
// Repository 선택 시나리오:
// - 복잡한 도메인 로직
// - DDD 적용
// - 테이블과 도메인 모델이 다름
class OrderRepository {
// 주문과 주문 항목을 Aggregate로 관리
async save(order: Order): Promise<Order> {
await this.prisma.$transaction(async (tx) => {
await tx.order.upsert({ ... });
await tx.orderItem.deleteMany({ where: { orderId: order.id } });
await tx.orderItem.createMany({ data: order.items.map(...) });
});
return order;
}
// 도메인 쿼리: "배송 준비 중인 주문"
async findPendingShipment(): Promise<Order[]> {
// 비즈니스 로직이 Repository에 캡슐화됨
return this.prisma.order.findMany({
where: {
status: 'PAID',
shippedAt: null,
createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
},
include: { items: true, customer: true }
}).then(orders => orders.map(o => this.toEntity(o)));
}
}
동작 원리
이것이 하는 일을 설명하겠습니다. Repository Pattern은 데이터 접근을 위한 계약(인터페이스)을 정의하고, 그 계약을 구체적인 기술로 구현하는 구조입니다. 첫 번째로, UserRepository 인터페이스는 "사용자 데이터에 접근하기 위해 어떤 기능이 필요한가?"를 정의합니다. 이 인터페이스는 Prisma, TypeORM, MongoDB 등 어떤 기술을 사용할지 전혀 알지 못합니다. 왜 이렇게 할까요? 비즈니스 로직이 데이터베이스 기술에 의존하지 않게 하기 위해서입니다. 두 번째로, PrismaUserRepository 클래스가 실행되면서 실제 Prisma를 사용하여 데이터베이스에 접근합니다. 생성자에서 PrismaClient를 주입받는 것이 보이시나요? 이것이 바로 의존성 주입(Dependency Injection)입니다. 각 메서드는 인터페이스에서 약속한 기능을 Prisma의 API를 사용하여 구현합니다. 세 번째로, findActiveUsers 같은 메서드를 주목해보세요. 이 메서드는 도메인 언어를 사용합니다. "활성 사용자를 찾아줘"라는 비즈니스 요구사항을 메서드 이름으로 표현했습니다. 내부적으로는 { where: { isActive: true } } 쿼리를 실행하지만, 호출하는 쪽은 이런 세부사항을 알 필요가 없습니다. 여러분이 이 코드를 사용하면 Service 레이어에서 userRepository.findActiveUsers()만 호출하면 되고, 나중에 Prisma를 TypeORM으로 바꾸고 싶다면 TypeORMUserRepository만 새로 구현하면 됩니다. Service 코드는 단 한 줄도 수정하지 않아도 됩니다. 또한 테스트할 때는 MockUserRepository를 만들어서 실제 데이터베이스 없이도 테스트할 수 있습니다. 이것이 하는 일을 단계별로 살펴보겠습니다. 이 코드는 클린 아키텍처의 원칙을 따라 계층을 분리하고 의존성 방향을 제어합니다. 첫 번째로, Product Entity는 순수한 비즈니스 객체입니다. id, name, price, stock 같은 속성만 있는 게 아니라 isAvailable() 같은 비즈니스 메서드도 포함합니다. 왜 이렇게 할까요? 데이터베이스의 테이블 구조가 아니라 비즈니스 도메인을 표현하기 위해서입니다. "재고가 있는가?"라는 비즈니스 질문에 대한 답을 Entity 스스로 알고 있어야 합니다. 두 번째로, IProductRepository 인터페이스가 domain 폴더에 위치하는 것이 핵심입니다. 보통 "인터페이스는 구현체 옆에 두는 거 아닌가?"라고 생각하기 쉽지만, 의존성 역전 원칙에 따라 인터페이스는 고수준 모듈(Domain) 쪽에 둡니다. 이렇게 하면 Domain은 Infrastructure를 전혀 모르고, Infrastructure가 Domain에 의존하게 됩니다. 세 번째로, PrismaProductRepository의 findById 메서드를 자세히 보세요. Prisma에서 가져온 데이터를 바로 반환하지 않고 new Product(...)로 Domain Entity를 생성합니다. 이 변환 과정이 매우 중요합니다. 데이터베이스 스키마에 createdAt, updatedAt 같은 필드가 있어도 Domain Entity에는 필요 없다면 포함시키지 않습니다. 반대로 Domain Entity에 계산된 속성이 있다면 여기서 초기화합니다. 마지막으로, save 메서드는 upsert를 사용합니다. 이렇게 하면 insert와 update를 하나의 메서드로 처리할 수 있어 사용하는 쪽에서 "새로운 데이터인지 기존 데이터인지" 신경 쓰지 않아도 됩니다. Repository를 사용하는 Service는 단순히 productRepository.save(product)만 호출하면 됩니다. 여러분이 이 코드를 사용하면 Service 레이어는 Prisma를 전혀 몰라도 되고, 테스트할 때는 IProductRepository를 구현한 InMemoryProductRepository를 만들어서 사용할 수 있습니다. 또한 나중에 Prisma에서 MongoDB로 변경하려면 MongoProductRepository만 새로 만들면 되고, Domain과 Service는 변경할 필요가 없습니다. 이것이 하는 일을 상세히 설명하겠습니다. Generic Repository는 공통 로직을 추상화하고, 각 Entity별 특수 로직은 구체 클래스에서 구현하는 템플릿 메서드 패턴입니다. 첫 번째로, IBaseRepository<T, ID> 인터페이스는 타입 파라미터를 두 개 받습니다. T는 Entity 타입이고, ID는 식별자 타입입니다(기본값은 string). 이렇게 하면 Product를 다룰 때는 IBaseRepository<Product, string>, Order를 다룰 때는 IBaseRepository<Order, number>처럼 사용할 수 있습니다. 타입 안정성이 완벽하게 보장됩니다. 두 번째로, BaseRepository 추상 클래스를 주목해보세요. 여기서 핵심은 toEntity와 toData라는 추상 메서드입니다. 왜 추상 메서드로 만들었을까요? Entity마다 속성이 다르기 때문에 변환 로직은 각 Repository가 구현해야 합니다. 하지만 findById, save 같은 CRUD 로직은 모든 Entity에 동일하므로 BaseRepository에서 구현합니다. 이것이 바로 템플릿 메서드 패턴입니다. 세 번째로, save 메서드를 살펴보세요. this.toData(entity)로 Entity를 데이터베이스 객체로 변환하고, upsert를 실행한 후, this.toEntity(result)로 다시 Entity로 변환합니다. 이 흐름은 모든 Entity에 동일하므로 BaseRepository에 한 번만 구현하면 됩니다. ProductRepository든 OrderRepository든 상관없이 같은 로직이 실행됩니다. 마지막으로, ProductRepository는 BaseRepository를 상속받아 toEntity와 toData만 구현하면 기본 CRUD가 모두 동작합니다. 추가로 findByPriceRange 같은 제품 특화 메서드만 더하면 됩니다. 이렇게 하면 10줄 정도의 코드로 완전한 Repository를 만들 수 있습니다. 만약 BaseRepository 없이 처음부터 구현했다면 50줄 이상 작성해야 했을 겁니다. 여러분이 이 코드를 사용하면 새로운 Entity가 추가될 때마다 toEntity와 toData만 구현하면 되고, 나중에 모든 Repository에 softDelete 기능을 추가하고 싶다면 BaseRepository에만 추가하면 모든 Repository가 자동으로 그 기능을 갖게 됩니다. 유지보수성이 획기적으로 향상됩니다. 이것이 하는 일을 자세히 분석해보겠습니다. Unit of Work는 여러 Repository 작업을 하나의 원자적 트랜잭션으로 관리합니다. 첫 번째로, IUnitOfWork 인터페이스를 보세요. Repository들을 속성으로 가지고 있습니다(products, orders). 왜 이렇게 할까요? Unit of Work가 모든 Repository의 부모 역할을 하여, 트랜잭션 컨텍스트를 공유하기 위해서입니다. 각 Repository가 독립적인 데이터베이스 연결을 사용하면 트랜잭션으로 묶을 수 없습니다. 두 번째로, begin(), commit(), rollback() 메서드가 트랜잭션의 생명주기를 관리합니다. begin()을 호출하면 새로운 트랜잭션이 시작되고, 이후 모든 Repository 작업은 이 트랜잭션 안에서 실행됩니다. commit()을 호출하면 모든 변경사항이 데이터베이스에 반영되고, rollback()을 호출하면 모두 취소됩니다. 세 번째로, OrderService의 createOrder 메서드를 살펴보세요. try 블록 안에서 여러 작업을 순차적으로 수행합니다: 재고 확인 → 재고 감소 → 주문 생성. 이 중 어느 하나라도 실패하면 catch 블록에서 rollback()이 호출되어 모든 변경사항이 취소됩니다. 만약 Unit of Work 없이 구현했다면, 재고는 감소했는데 주문 생성이 실패하는 불일치 상황이 발생할 수 있습니다. 마지막으로, Prisma의 경우 실제로는 $transaction 메서드를 사용합니다. 위 코드는 개념을 설명하기 위한 것이고, 실전에서는 다음처럼 구현합니다: typescript await this.prisma.$transaction(async (tx) => { const product = await tx.product.findUnique(...); await tx.product.update(...); await tx.order.create(...); }); 여러분이 이 코드를 사용하면 복잡한 비즈니스 트랜잭션을 안전하게 처리할 수 있습니다. 재고 관리, 결제 처리, 포인트 적립 등 여러 작업이 하나의 원자적 단위로 실행되어 데이터 일관성이 보장됩니다. 또한 Service 코드가 깔끔해져서 비즈니스 로직에 집중할 수 있습니다. 이것이 하는 일을 레벨별로 설명하겠습니다. Repository 테스트는 각 레벨에서 다른 것을 검증합니다. 첫 번째로, 단위 테스트는 Service 로직만 검증합니다. mockProductRepo를 보세요. vi.fn().mockResolvedValue()로 Repository의 동작을 가짜로 만들었습니다. 실제 데이터베이스는 전혀 사용하지 않습니다. 왜 이렇게 할까요? Service의 비즈니스 로직(재고 확인, 에러 처리)만 테스트하고 싶기 때문입니다. "Repository가 재고 0을 반환하면 Service가 올바르게 에러를 던지는가?"를 검증합니다. 이 테스트는 밀리초 단위로 실행됩니다. 두 번째로, 통합 테스트는 실제 데이터베이스와의 연동을 검증합니다. beforeEach에서 await prisma.product.deleteMany()로 데이터를 초기화하는 것이 보이시나요? 각 테스트가 깨끗한 상태에서 시작하여 서로 영향을 주지 않습니다. 실제 save를 호출하고, 실제 쿼리가 실행되며, 실제 데이터베이스에 저장됩니다. 그 다음 findById로 조회하여 정말로 데이터가 저장되었는지 확인합니다. 세 번째로, "가격대별 제품 조회" 테스트를 살펴보세요. 이것은 복잡한 쿼리를 테스트하는 예시입니다. Mock으로는 절대 검증할 수 없는 부분입니다. findByPriceRange의 WHERE 절이 올바른지, 인덱스가 제대로 사용되는지는 실제 데이터베이스에서만 확인할 수 있습니다. 세 개의 제품을 넣고, 특정 범위를 조회했을 때 정확히 하나만 반환되는지 검증합니다. 마지막으로, Test Container를 사용하면(코드에는 주석으로만 표시) Docker로 임시 데이터베이스를 자동으로 띄웁니다. 테스트가 시작되면 PostgreSQL 컨테이너가 생성되고, 테스트가 끝나면 자동으로 삭제됩니다. 로컬에 데이터베이스를 설치할 필요도 없고, CI/CD 환경에서도 동일하게 동작합니다. 여러분이 이 전략을 사용하면 개발 중에는 빠른 단위 테스트로 즉각 피드백을 받고, PR 전에는 통합 테스트로 실제 쿼리를 검증하며, 배포 전에는 E2E 테스트로 전체 시스템을 확인할 수 있습니다. 테스트 피라미드를 정확히 구현하게 됩니다. 이것이 하는 일을 단계별로 살펴보겠습니다. 에러 핸들링은 에러 감지 → 변환 → 재시도 → 응답의 흐름으로 진행됩니다. 첫 번째로, RepositoryError 클래스 계층을 보세요. 기본 RepositoryError를 상속받아 NotFoundError, DuplicateError 등 구체적인 에러를 만들었습니다. 왜 이렇게 할까요? instanceof로 에러 타입을 정확히 구분할 수 있기 때문입니다. Service 계층에서 "이 에러가 NotFoundError인지"만 체크하면 되고, Prisma의 P2025 코드를 알 필요가 없습니다. 두 번째로, handleError 메서드가 Prisma 에러를 도메인 에러로 변환합니다. error.code를 보고 P2002는 DuplicateError로, P2025는 NotFoundError로 바꿉니다. error.meta?.target에는 어떤 필드가 중복되었는지 정보가 들어있어 사용자에게 구체적인 메시지를 전달할 수 있습니다. 예를 들어 "이메일이 이미 존재합니다"처럼 말이죠. 세 번째로, withRetry 메서드를 주목해보세요. 이것은 일시적인 연결 오류를 자동으로 재시도합니다. DatabaseConnectionError가 발생하면 최대 3번까지 재시도하는데, 각 시도 사이에 Exponential Backoff(지수 백오프)로 대기 시간을 늘립니다. 첫 번째 재시도는 1초, 두 번째는 2초, 세 번째는 4초 대기합니다. 왜 이렇게 할까요? 데이터베이스가 일시적으로 부하를 받고 있을 때 계속 요청을 보내면 상황이 악화되기 때문입니다. 마지막으로, errorHandler 미들웨어가 도메인 에러를 HTTP 응답으로 변환합니다. NotFoundError는 404, DuplicateError는 409, ValidationError는 400으로 자동 매핑됩니다. 이렇게 하면 Controller는 에러 처리를 신경 쓰지 않아도 되고, 모든 API가 일관된 에러 응답 형식을 갖게 됩니다. 여러분이 이 코드를 사용하면 데이터베이스 연결이 일시적으로 끊어져도 자동으로 재시도하여 복구하고, 사용자에게는 "이메일이 이미 존재합니다" 같은 구체적인 에러 메시지를 보여줄 수 있으며, 로그에는 원본 Prisma 에러와 함께 스택 트레이스가 남아 디버깅이 쉬워집니다. 이것이 하는 일을 흐름별로 설명하겠습니다. 캐싱은 조회 → 캐시 확인 → DB 조회 → 캐시 저장 → 무효화의 사이클로 동작합니다. 첫 번째로, ICache 인터페이스를 정의한 이유를 살펴보세요. Redis를 사용하든 Memcached를 사용하든 Repository는 알 필요가 없습니다. 의존성 역전 원칙을 여기서도 적용했습니다. 테스트할 때는 InMemoryCache를 주입하고, 프로덕션에서는 RedisCache를 주입하면 됩니다. 두 번째로, RedisCache의 set 메서드를 보세요. ttlSeconds 파라미터로 만료 시간을 지정합니다. 5분 후에는 자동으로 캐시가 삭제되어 오래된 데이터가 계속 남아있지 않습니다. JSON.stringify로 직렬화하여 저장하므로 복잡한 객체도 캐싱할 수 있습니다. 세 번째로, CachedProductRepository가 Decorator 패턴을 사용하는 것이 핵심입니다. baseRepository를 감싸서 캐싱 기능을 추가했습니다. findById를 호출하면 먼저 캐시를 확인하고, 없으면 baseRepository.findById를 호출합니다. Service 계층은 CachedProductRepository를 사용하는지 일반 ProductRepository를 사용하는지 전혀 모릅니다. 네 번째로, save 메서드에서 캐시 무효화를 주목하세요. 데이터를 수정하면 해당 제품의 캐시(product:123)뿐만 아니라 제품 목록 캐시(products:*)도 모두 삭제합니다. 왜 목록까지 삭제할까요? 새로운 제품이 추가되거나 가격이 변경되면 목록 쿼리의 결과도 달라지기 때문입니다. 이것이 Cache Invalidation의 핵심입니다. 마지막으로, findAll의 TTL이 60초로 짧은 이유를 생각해보세요. 단일 제품은 잘 안 바뀌지만, 제품 목록은 자주 바뀝니다(새 제품 추가, 재고 소진 등). 데이터 특성에 맞춰 TTL을 다르게 설정하는 것이 중요합니다. 여러분이 이 코드를 사용하면 동일한 제품을 100번 조회해도 데이터베이스는 1번만 접근하고, 트래픽이 10배 증가해도 데이터베이스 부하는 거의 증가하지 않으며, 데이터가 수정되면 자동으로 캐시가 갱신되어 항상 최신 데이터를 볼 수 있습니다. 이것이 하는 일을 비교 관점에서 설명하겠습니다. DAO와 Repository는 추상화 레벨과 관심사가 다릅니다. 첫 번째로, UserDAO를 보세요. insert, update, delete, select 같은 SQL 용어를 그대로 사용합니다. 메서드 이름만 봐도 "이건 데이터베이스 작업이구나"라는 것을 알 수 있습니다. 반환 타입도 UserDTO로 데이터베이스 테이블 구조를 그대로 드러냅니다. 이것은 데이터 중심 설계의 특징입니다. 두 번째로, UserRepository의 save 메서드를 주목하세요. User Aggregate를 받아서 내부적으로 여러 테이블(users, profiles, preferences)에 나누어 저장합니다. 호출하는 쪽은 "User 객체를 저장한다"는 비즈니스 의도만 표현하면 되고, 몇 개의 테이블로 나뉘는지 알 필요가 없습니다. 이것이 도메인 중심 설계입니다. 세 번째로, findActiveUsers 같은 메서드를 살펴보세요. DAO라면 selectByIsActive(true)처럼 컬럼명을 드러냈겠지만, Repository는 "활성 사용자"라는 비즈니스 개념을 메서드 이름으로 표현합니다. 내부에서는 isActive와 emailVerified를 함께 체크하지만, 이런 세부사항은 숨겨져 있습니다. 네 번째로, remove 메서드에서 실제로는 DELETE가 아니라 UPDATE로 deletedAt을 설정하는 것을 보세요. 이것이 Soft Delete입니다. DAO였다면 delete()와 softDelete()를 별도 메서드로 만들었겠지만, Repository는 "사용자를 제거한다"는 비즈니스 규칙을 내부에 캡슐화합니다. 마지막으로, OrderRepository의 findPendingShipment를 보세요. 이것은 복잡한 비즈니스 쿼리입니다. "결제는 완료되었지만 아직 배송되지 않았고, 7일 이내의 주문"이라는 조건을 Repository가 알고 있습니다. Service는 단순히 "배송 준비 중인 주문을 줘"라고 요청만 하면 됩니다. 여러분이 단순한 CRUD 앱을 만든다면 DAO로 충분합니다. 빠르고 간단합니다. 하지만 복잡한 도메인 로직이 있고, 테이블 구조와 도메인 모델이 다르며, DDD를 적용한다면 Repository를 선택해야 합니다. 코드가 비즈니스 의도를 명확히 드러내고, 변경에 유연해집니다.
핵심 정리
핵심 정리: Repository Pattern은 인터페이스로 데이터 접근을 추상화하여 비즈니스 로직과 데이터 소스를 분리합니다. 데이터 소스가 변경될 가능성이 있거나 테스트 용이성이 중요할 때 사용하세요. 단, 과도한 추상화는 오히려 복잡도를 높일 수 있으니 프로젝트 규모와 요구사항을 고려하세요. 핵심 정리: Repository 구현은 Domain에 인터페이스, Infrastructure에 구현체를 두고, DTO와 Entity를 명확히 분리합니다. 계층 간 의존성 방향을 Domain 쪽으로 향하게 하여 비즈니스 로직을 외부 기술로부터 보호하세요. Entity 변환 로직은 Repository 내부에서 처리하여 사용하는 쪽의 부담을 줄이세요. 핵심 정리: Generic Repository는 타입 파라미터와 추상 메서드를 활용하여 공통 CRUD 로직을 재사용합니다. 새로운 Repository를 만들 때는 Entity 변환 로직만 구현하면 되고, 공통 기능을 수정할 때는 Base Repository만 변경하면 됩니다. 단, 과도한 일반화는 오히려 복잡도를 높일 수 있으니 실제로 3개 이상의 Repository에서 공통 로직이 발견될 때 도입하세요. 핵심 정리: Unit of Work는 여러 Repository 작업을 하나의 트랜잭션으로 묶어 원자성을 보장합니다. 복잡한 비즈니스 트랜잭션이 여러 Entity에 걸쳐 있을 때 사용하세요. 단순한 CRUD는 개별 Repository로 충분하지만, "주문 + 재고 + 결제"처럼 여러 작업이 묶여야 할 때는 Unit of Work가 필수입니다. 핵심 정리: Repository 테스트는 Mock 단위 테스트와 Test Container 통합 테스트를 조합하여 빠른 피드백과 실제 검증을 모두 얻습니다. 비즈니스 로직은 Mock으로, 쿼리 정확성은 실제 DB로 테스트하세요. 각 테스트는 독립적으로 실행되도록 beforeEach에서 데이터를 초기화하세요. 핵심 정리: Repository 에러 핸들링은 데이터베이스 에러를 도메인 에러로 변환하고, 재시도 가능한 에러는 자동 복구하며, 최종적으로 HTTP 응답으로 매핑하는 계층화된 전략입니다. 에러 타입별로 적절한 HTTP 상태 코드와 메시지를 반환하여 사용자 경험을 개선하세요. Exponential Backoff로 재시도하여 일시적 장애를 자동 복구하세요. 핵심 정리: Repository 캐싱은 Decorator 패턴으로 투명하게 적용하고, TTL로 자동 만료를 관리하며, 데이터 수정 시 캐시를 무효화하여 일관성을 유지합니다. 읽기가 많고 쓰기가 적은 데이터에 적용하면 극적인 성능 향상을 얻을 수 있습니다. 단, 캐시 무효화 전략이 잘못되면 오래된 데이터를 보여줄 수 있으니 신중하게 설계하세요. 핵심 정리: DAO는 테이블 중심의 데이터 접근 패턴이고, Repository는 Aggregate 중심의 도메인 패턴입니다. 단순 CRUD는 DAO로, 복잡한 도메인 로직은 Repository로 구현하세요. 두 패턴을 혼용하지 말고, 프로젝트의 복잡도와 요구사항에 맞춰 일관되게 선택하세요.
실전 팁
실전에서는:
- 인터페이스를 먼저 설계하고 구현하세요. TDD처럼 인터페이스부터 정의하면 비즈니스 요구사항에 집중할 수 있고, 구현 기술에 얽매이지 않습니다.
- Repository 메서드는 도메인 언어로 명명하세요. findAll() 대신 findActiveUsers(), getPremiumCustomers()처럼 비즈니스 의미를 담아야 코드 가독성이 높아집니다.
- 한 Repository는 한 Aggregate Root만 담당하게 하세요. UserRepository에서 Order까지 다루면 안 됩니다. 관심사의 분리가 깨지면 유지보수가 어려워집니다.
- Repository에서 비즈니스 로직을 넣지 마세요. Repository는 순수하게 데이터 접근만 담당해야 합니다. 유효성 검증이나 계산 로직은 Domain 계층이나 Service 계층에 두세요.
- 쿼리가 복잡해지면 Specification Pattern을 함께 사용하세요. 복잡한 필터링 조건을 조합할 때 매우 유용합니다.
- 기본_Repository_구현 실전에서는:
- Mapper 클래스를 별도로 만들어서 Entity 변환 로직을 분리하세요. Repository가 너무 비대해지는 것을 방지하고, 변환 로직을 재사용할 수 있습니다.
- 폴더 구조는 feature-first보다 layer-first가 Repository Pattern과 잘 맞습니다. domain/, infrastructure/, application/ 같은 계층별 폴더 구조가 의존성 방향을 명확히 합니다.
- null 반환 대신 Optional이나 Result 패턴 사용을 고려하세요. findById가 null을 반환하면 호출하는 쪽에서 매번 null 체크를 해야 하지만, Result<Product, NotFoundError>를 반환하면 에러 처리가 명확해집니다.
- Repository 인터페이스는 가능한 작게 유지하세요. 모든 가능한 쿼리를 다 만들지 말고, 실제로 필요한 메서드만 추가하세요. YAGNI 원칙을 따르세요.
- 복잡한 쿼리는 Repository에서 처리하세요. Service에서 여러 번 Repository를 호출하여 데이터를 조합하지 말고, 필요한 데이터를 한 번에 가져오는 메서드를 Repository에 만드세요 (예: findProductsWithReviews).
- Generic_Repository 실전에서는:
- 추상 메서드는 최소한으로 유지하세요. toEntity와 toData만 추상 메서드로 두고, 나머지는 구체 메서드로 구현하여 하위 클래스의 부담을 줄이세요.
- 공통 쿼리 옵션을 제공하세요. findAll에 { skip, take, orderBy } 같은 페이지네이션 옵션을 추가하면 모든 Repository에서 일관된 페이징을 사용할 수 있습니다.
- 트랜잭션 지원을 BaseRepository에 추가하세요. withTransaction(callback) 메서드를 만들어서 모든 Repository가 동일한 방식으로 트랜잭션을 처리하게 하세요.
- 로깅과 성능 모니터링을 Base에서 처리하세요. 모든 쿼리 실행 시간을 자동으로 로깅하거나, 느린 쿼리를 감지하는 로직을 한 곳에서 관리할 수 있습니다.
- ID 생성 전략을 BaseRepository에서 관리하세요. UUID, CUID, Auto Increment 등 ID 생성 방식을 추상화하여 나중에 변경하기 쉽게 만드세요.
- Unit_of_Work_패턴 실전에서는:
- Prisma의 경우 $transaction 콜백 패턴을 사용하세요. 인터랙티브 트랜잭션보다 안전하고 간편합니다. 콜백 내부에서 예외가 발생하면 자동으로 롤백됩니다.
- 트랜잭션은 가능한 짧게 유지하세요. 외부 API 호출, 파일 I/O 등은 트랜잭션 바깥에서 처리하고, 순수한 데이터베이스 작업만 트랜잭션 안에 넣으세요. 긴 트랜잭션은 락을 오래 잡아 성능 저하를 일으킵니다.
- 중첩 트랜잭션에 주의하세요. 대부분의 데이터베이스는 실제 중첩 트랜잭션을 지원하지 않습니다. Savepoint를 사용하거나, 트랜잭션 깊이를 추적하여 최상위 트랜잭션만 실제 커밋하도록 구현하세요.
- 읽기 전용 트랜잭션을 분리하세요. 단순 조회는 트랜잭션 없이 처리하고, 쓰기 작업만 트랜잭션으로 묶으세요. 불필요한 락을 피할 수 있습니다.
- 트랜잭션 타임아웃을 설정하세요. 무한정 대기하면 데드락이 발생할 수 있으므로, 합리적인 타임아웃(예: 5초)을 설정하여 문제를 조기에 감지하세요.
- Repository_테스트_전략 실전에서는:
- Test Data Builder 패턴을 사용하세요. createProduct({ name: 'Test', stock: 10 })처럼 테스트 데이터를 쉽게 생성하는 헬퍼 함수를 만들면 테스트 코드가 훨씬 읽기 쉬워집니다.
- 트랜잭션 롤백을 활용하세요. 각 테스트를 트랜잭션으로 감싸고 끝날 때 롤백하면 deleteMany()보다 훨씬 빠릅니다. Prisma의 경우 $transaction을 사용하세요.
- In-Memory Database를 고려하세요. SQLite in-memory는 초고속이지만, PostgreSQL 특정 기능(JSONB, Array 등)은 테스트할 수 없습니다. 트레이드오프를 고려하여 선택하세요.
- 테스트 격리를 위해 UUID를 사용하세요. Auto Increment ID는 테스트 순서에 따라 달라지지만, UUID는 항상 고유하여 예측 가능합니다.
- 에러 케이스도 반드시 테스트하세요. 중복 키, 외래 키 제약, 트랜잭션 롤백 등 예외 상황이 제대로 처리되는지 확인하세요. Happy Path만 테스트하면 프로덕션에서 예상치 못한 에러가 발생합니다.
- 실전_에러_핸들링 실전에서는:
- 에러에 context 정보를 포함하세요. 어떤 메서드에서, 어떤 ID로 조회했는지 기록하면 디버깅이 10배 빨라집니다. { method: 'findById', id: '123', error: ... } 형태로 로깅하세요.
- 재시도는 멱등성이 보장되는 작업만 하세요. SELECT는 안전하지만, INSERT는 중복 생성 위험이 있습니다. 읽기 작업만 재시도하고, 쓰기 작업은 신중하게 판단하세요.
- Circuit Breaker 패턴을 고려하세요. 데이터베이스가 완전히 다운되었을 때 계속 재시도하면 리소스만 낭비합니다. 일정 횟수 이상 실패하면 일시적으로 차단하고 빠르게 실패하세요.
- 에러 모니터링 도구를 연동하세요. Sentry, DataDog 같은 도구로 에러를 자동 수집하면 패턴을 파악하고 알람을 받을 수 있습니다.
- 민감한 정보를 에러 메시지에 포함하지 마세요. SQL 쿼리나 개인정보가 클라이언트에 노출되지 않도록 필터링하세요. 로그에는 자세히 남기되, API 응답에는 일반적인 메시지만 보내세요.
- 캐싱_전략 실전에서는:
- 캐시 키 네이밍 컨벤션을 정하세요.
{entity}:{id},{entity}:list:{filter}같은 일관된 패턴을 사용하면 무효화가 쉬워집니다. 예:product:123,products:category:electronics. - Thundering Herd 문제를 방지하세요. 캐시가 만료되는 순간 수천 개의 요청이 동시에 DB를 치는 것을 막기 위해 Lock을 사용하거나, TTL에 랜덤 값을 더하세요(300초 대신 300±30초).
- 캐시 워밍(Cache Warming)을 사용하세요. 서버 시작 시 자주 조회되는 데이터를 미리 캐시에 적재하면 초기 트래픽에 대비할 수 있습니다.
- 다단계 캐싱을 고려하세요. L1(In-Memory) + L2(Redis) 구조로 만들면 Redis 부하도 줄일 수 있습니다. 단, 무효화가 복잡해지므로 꼭 필요할 때만 사용하세요.
- 캐시 히트율을 모니터링하세요. 히트율이 50% 이하라면 캐싱 전략을 재검토해야 합니다. TTL이 너무 짧거나, 잘못된 데이터를 캐싱하고 있을 수 있습니다.
- Repository_vs_DAO 실전에서는:
- 프로젝트 초기에 패턴을 명확히 선택하세요. 중간에 DAO에서 Repository로 전환하는 것은 매우 어렵습니다. 도메인 복잡도를 평가하여 결정하세요.
- 하이브리드 접근을 고려하세요. 핵심 도메인은 Repository로, 지원 도메인은 DAO로 구현할 수 있습니다. 예: OrderRepository(복잡) + LogDAO(단순).
- 팀의 역량을 고려하세요. Repository는 DDD 이해가 필요하므로 학습 곡선이 있습니다. 주니어가 많다면 DAO가 더 실용적일 수 있습니다.
- 레거시 마이그레이션 시 점진적으로 전환하세요. 모든 DAO를 한 번에 Repository로 바꾸지 말고, 변경이 잦은 도메인부터 시작하세요.
- 네이밍 컨벤션을 일관되게 유지하세요. Repository를 선택했다면 save/remove를 사용하고, DAO를 선택했다면 insert/delete를 사용하세요. 혼용하면 혼란만 가중됩니다.
마치며
오늘은 Repository Pattern 기초부터 심화까지의 핵심 개념들을 함께 살펴보았습니다.
이번 글에서 다룬 9가지 개념은 모두 실무에서 자주 사용되는 중요한 내용들입니다. 처음에는 어렵게 느껴질 수 있지만, 실제 프로젝트에서 하나씩 적용해보면서 익숙해지시길 바랍니다.
이론만 알고 있기보다는 직접 코드를 작성하고 실행해보는 것이 가장 빠른 학습 방법입니다. 작은 프로젝트라도 좋으니 직접 구현해보면서 각 개념이 실제로 어떻게 동작하는지 체감해보세요. 에러가 발생하면 디버깅하면서 더 깊이 이해할 수 있습니다.
학습하다가 막히는 부분이 있거나, 더 궁금한 점이 생긴다면 주저하지 말고 질문해주세요. 질문이나 궁금한 점이 있다면 언제든 댓글로 남겨주세요. 함께 성장하는 개발자가 되어봅시다!
다음에는 더 심화된 내용으로 찾아뵙겠습니다. 즐거운 코딩 되세요! 🚀
관련 태그
#TypeScript #RepositoryPattern #CleanArchitecture #DataLayer #DependencyInjection