레포지토리 길들이기: Thin Repository, Rich Service

6

이 글은 제가 근무했던 스타트업과 외주 프로젝트에서 다양한 코드 베이스를 관리하면서 데이터 접근 계층과 비즈니스 로직 사이의 경계에 대해 고민했던 내용입니다. 개인 프로젝트나 제게 결정권이 있는 프로젝트에서 시행착오를 거치며 발견한 실용적인 패턴을 공유합니다.

레포지토리는 얇게, 서비스는 풍부하게

마틴 파울러가 제시한 레포지토리 패턴의 핵심은 "데이터 접근 계층을 컬렉션처럼 다루기"다. DB 쿼리나 ORM 같은 저수준 데이터 접근 세부사항(페이지네이션, 정렬, 조인 등)을 감추고, 마치 메모리에 있는 객체 컬렉션을 다루듯이 단순한 인터페이스로 상호작용하라는 것으로 나는 이해했다.

하지만 문제는 이 패턴을 어떻게 구현하느냐다. 내가 주장하는 방식은 'Thin Repository, Rich Service' 접근법으로, 레포지토리는 최대한 단순하고 범용적인 데이터 접근 인터페이스만 제공하고, 비즈니스 개념과 관련된 로직은 모두 서비스 레이어에 두는 것이다. 이 방식이 코드 중복을 줄이고 변경에 더 유연하게 대응할 수 있다고 생각한다.

흔히 볼 수 있는 상황은...

(1) 서비스 레이어에 쿼리가 직접 들어가 있는 경우

// 데이터 접근 계층 분리 없이 서비스에서 직접 쿼리
class UserService {
  async getActiveUser(email: string) {
    return this.prisma.user.findFirst({
      where: {
        email,
        status: 'ACTIVE',
        lastLoginAt: {
          gt: dayjs().subtract(30, 'day').toDate()
        }
      }
    })
  }
}

이 방식의 문제점:

  1. 같은 쿼리를 여러 곳에서 복사-붙여넣기 하게 된다 (DRY 원칙 위반)
  2. DB 스키마가 바뀌면 여러 곳을 수정해야 한다 (그리고 하나씩 꼭 빼먹기 쉬움)
  3. 데이터 접근 로직을 테스트하기 어렵다 (특히 모킹할 때 넘 빡셈. 근데 스타트업에서 테스트 코드 작성할 일이 잘 없긴 하지만..)

(2) 레포지토리는 있지만 비즈니스 로직이 뒤섞여 있는 경우

class UserRepository {
 async findActiveUsersByGroup(groupId: string) {
    const thirtyDaysAgo = dayjs().subtract(30, 'day').toDate();

    const users = await this.prisma.user.findMany({
      where: {
        groupId,
        status: 'ACTIVE',
        deletedAt: null,
        lastLoginAt: {
          gt: thirtyDaysAgo
        }
      }
    });

    return users;
  }
}

이 방식의 문제점:

  1. "활성 사용자"라는 비즈니스 개념이 데이터 접근 계층에 침투한다 (관심사 분리 위반)
  2. 같은 데이터를 다른 맥락에서 보려면 새 메서드를 계속 추가해야 한다 (레포지토리 비만 발생)
  3. 비즈니스 요구사항이 바뀔 때마다 레포지토리 코드를 수정해야 한다 (e.g. "활성"의 정의가 30일→90일로 바뀌면?)
  4. findActiveUsers, findPremiumCustomers, findUnverifiedAccounts처럼 메소드들이 우후죽순 늘어남 (6개월 후 어떤 메소드가 있는지 기억 못함)
  5. 일괄 변경이 필요할 때 (특히 typeorm 쿼리 빌더로 짜여진 경우) 놓치는 부분이 발생 (귀신 같이 하나는 빠짐)

그래서 제안하는 방식은...

(1) 우선 데이터 접근 계층은 확실히 분리하자

class UserRepository {
  async findOne(options: FindOneOptions<User>): Promise<User> {
    return this.prisma.user.findFirst(options);
  }

  async findMany(options: FindManyOptions<User>): Promise<User[]> {
    return this.prisma.user.findMany(options);
  }
}

이렇게 하면:

  1. 레포지토리는 순수한 데이터 접근 관심사만 갖는다 (깔끔한 관심사 분리)
  2. 비즈니스 로직의 변경이 데이터 접근 계층에 영향을 주지 않는다 (변경의 영향 범위가 좁아짐)
  3. 도메인별로 다른 검증 로직을 가질 수 있다 (확장성 향상)
  4. 테스트도 쉬워진다 (모킹이 간단해짐)

(2) 비즈니스 로직은 서비스 레이어에서 구현

class UserService {
  async getActiveUsers(groupId: string): Promise<User[]> {
    // 비즈니스 로직은 서비스에서 정의하되,
    // 실제 필터링은 쿼리 레벨에서 처리
    const activeUserQuery = {
      groupId,
      status: 'ACTIVE',
      lastLoginAtGt: dayjs().subtract(30, 'day').toDate()
    };

    return this.userRepository.findMany(activeUserQuery);
  }
}

이렇게 하면:

  1. 비즈니스 로직("활성 사용자"의 정의)은 여전히 서비스 레이어에서 관리 (한 곳에서만 수정하면 됨)
  2. 실제 필터링은 DB 레벨에서 일어나서 성능도 확보 (메모리에 다 로드하고 필터링하는 것보다는..)
  3. Repository는 여전히 순수하게 유지 (쿼리 실행만 담당하고 비즈니스 로직은 모름)

(3) 물론 성능이 중요한 경우는 예외를 둔다

class UserRepository {
 // 성능이 중요한 특수 케이스는 레포지토리에 두기도 함
  async getTotalSpendingByGroupForActiveUsers(groupId: string) {
    // 그룹별 활성 사용자의 총 지출액을 집계하는 복잡한 쿼리
    return this.prisma.transaction.groupBy({
      by: ['userId'],
      where: {
        user: {
          groupId,
          status: 'ACTIVE',
          lastLoginAt: {
            gt: dayjs().subtract(30, 'day').toDate()
          }
        },
        createdAt: {
          gt: dayjs().subtract(6, 'month').toDate()
        }
      },
      _sum: {
        amount: true
      },
      orderBy: {
        _sum: {
          amount: 'desc'
        }
      }
    });
  }
}

이런 상황에서 내 가이드라인은..

내 경험상 이런 특수 쿼리는 다음 조건을 만족할 때만 레포지토리에 직접 넣는 것이 좋다:

  1. 성능 향상이 확실히 필요하고 효과가 명확한 경우 (여러 조인과 집계를 한 쿼리로 처리하는 등)
  2. 범용 메소드로는 해결하기 어려운 복잡한 쿼리가 필요한 경우 (다중 조인과 GROUP BY를 조합한 통계 쿼리 등. 주로 어드민 기능 구현 시에 많이 발생)
  3. 팀원들에게 왜 이렇게 했는지 명확하게 설명할 수 있는 경우 (PR에 설명하고 동의를 얻을 정도로)
실제 사례를 들어보자. 2주 전에 "활성 사용자"의 정의가 30일에서 90일로 바뀌어야 했다. 이 때:
  1. 일반적인 레포지토리 패턴을 따른다면: UserServicegetActiveUserQuery() 한 곳만 수정했으면 끝.
  2. 비즈니스 로직이 레포지토리에 침투한 경우: findActiveUsers(), findActiveUsersByGroup(), getActiveUserStats(), countActiveUsersInCampaign() 등 8개의 메소드를 찾아다니며 수정해야 했다. 그리고 당연히 한 개는 빼먹어서 데이터 불일치 이슈가 발생했다.
특히 여러 개발자가 함께 일하는 환경에서는 "활성 사용자"처럼 자주 쓰이는 비즈니스 개념이 일관되게 적용되어야 하는데, 이런 정의가 여러 레포지토리 메소드에 흩어져 있으면 일관성을 유지하기 정말 어렵다. 새로운 팀원이 왔을 때는 더 심각해진다. 솔직히 말하자면, 많은 개발자들이 기존 코드베이스를 탐색하고 이해하는 것을 꺼린다. 그냥 새로 만드는 게 더 빠르다고 생각해서 기존 코드를 보고 또 다른 findSomethingActiveUser() 메소드를 만들어버리기 일쑤다. 그 결과 같은 기능을 하는 코드가 여러 버전으로 존재하게 되고 일관성은 산으로 간다.

결론

'레포지토리는 얇게, 서비스는 풍부하게'라는 접근법은 기존 레포지토리 패턴을 특정 방향으로 확장한 실용적 원칙이다. 복잡한 비즈니스 개념은 서비스 레이어에서 정의하고, 레포지토리는 순수한 데이터 접근 역할만 담당하게 하는 것이다.

이 방식의 핵심은 레포지토리 메소드 수를 최소화하고 범용적인 인터페이스만 유지함으로써, 비즈니스 로직이 변경될 때 서비스 레이어만 수정하면 되도록 하는 것이다. 이는 레포지토리 계층의 비대화와 중복 코드를 효과적으로 방지한다. 기본적으로 레포지토리는 얇게 유지하되, 명확한 이유가 있을 때만 예외를 허용하는 것. 이게 내가 실제 프로젝트들을 통해 발견한 실용적인 접근법이다.

이 글을 쓰기 전에 비슷한 접근법에 대한 다른 개발자들의 생각을 찾아봤다. 생각보다 많지는 않았지만, 비슷한 문제의식을 가진 글들이 있었다. Matías Navarro-Carter의 The Repository Pattern Done Right는 PHP/Doctrine 환경에서 같은 결론에 도달했다 — 레포지토리를 immutable collection처럼 다루고, 특정 비즈니스 개념에 종속된 메소드는 전부 없애라는 것이다.

2026. 3. 추가 — 이 글을 쓴 뒤 여러 면접에서 이 패턴에 대해 질문을 받았다. 그중 한 면접에서는 이 글을 띄워놓고 질문을 해왔다. 정독한 듯했다. 부끄러웠지만 이야기는 재밌었다. 그쪽 팀은 AI 프렌들리하게 아예 서비스 레이어에서 바로 ORM 쿼리를 사용한다고 했다. "추상화 레이어가 많을수록 AI한테 컨텍스트를 더 많이 줘야 하고, 토큰 낭비다. Repository 인터페이스 맞추느라 시간 쓰는 것보다 그냥 서비스에서 Prisma 직접 쓰고 AI한테 맡기는 게 빠릅니다." 내가 그럼 유닛 테스트가 힘들지 않냐고 반문했더니, "유닛 테스트로 Repository mock하는 거, 결국 실제 동작이랑 다를 수 있잖아요. E2E가 진짜 동작을 보장합니다." 어차피 유닛 테스트보다 full E2E 테스트가 중요하다고 생각하는 입장이라 그것들이 잘 이뤄지게 하는 데만 집중한다고 했다. 수긍이 갔다. 그리고 1년도 채 지나지 않은 지금, 그때 그 면접관의 말대로 이 패턴을 적용한 내 보일러플레이트가 AI와의 작업에서 오히려 마찰을 빚고 있다. 결국 내가 만드는 모든 프로젝트에서 이 정책을 한 발 물러서야 했다.