0521

BE

백엔드에서 LookupService란 무엇일까?

| 서론

안녕하세요, 팡일입니다.

백엔드 코드를 살펴보다 보면 UserLookupService, DeviceLookupService와 같이 LookupService라는 이름이 붙은 클래스를 종종 발견하게 됩니다.

처음에는 특별한 기능을 제공하는 서비스처럼 보이지만, 일반적으로 LookupService데이터를 찾거나 존재 여부를 확인하는 조회 로직을 담당하는 서비스를 의미합니다.

다만 NestJS나 Spring 같은 프레임워크에서 공식적으로 정해 놓은 개념은 아닙니다. 프로젝트의 구조와 팀의 명명 규칙에 따라 역할이 조금씩 달라질 수 있습니다.

그렇다면 일반적인 ServiceRepository가 있는데도 LookupService를 별도로 사용하는 이유는 무엇일까요?

| LookupService를 사용하게 된 이유

작은 프로젝트에서는 하나의 서비스가 생성, 조회, 수정, 삭제를 모두 담당해도 큰 문제가 없습니다.

TypeScript
class UserService {
  create() {}
  findById() {}
  update() {}
  delete() {}
}

하지만 프로젝트가 커지면 다른 도메인의 서비스에서도 사용자 정보를 조회해야 하는 일이 많아집니다.

예를 들어 주문을 생성하는 OrderService에서 사용자가 실제로 존재하는지 확인해야 한다고 가정해보겠습니다.

TypeScript
class OrderService {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly orderRepository: OrderRepository,
  ) {}

  async createOrder(userId: number) {
    const user = await this.userRepository.findOne({
      where: { id: userId },
    });

    if (!user) {
      throw new NotFoundException('사용자를 찾을 수 없습니다.');
    }

    // 주문 생성
  }
}

이 방식 자체가 잘못된 것은 아닙니다. 하지만 주문뿐만 아니라 결제, 배송, 알림 서비스에서도 사용자를 확인해야 한다면 동일한 조회와 예외 처리 코드가 반복될 수 있습니다.

TypeScript
const user = await this.userRepository.findOne({
  where: { id: userId },
});

if (!user) {
  throw new NotFoundException('사용자를 찾을 수 없습니다.');
}

이러한 공통 조회 로직을 한곳에 모으기 위해 LookupService를 사용할 수 있습니다.

TypeScript
class UserLookupService {
  constructor(
    private readonly userRepository: UserRepository,
  ) {}

  async findByIdOrThrow(id: number) {
    const user = await this.userRepository.findOne({
      where: { id },
    });

    if (!user) {
      throw new NotFoundException('사용자를 찾을 수 없습니다.');
    }

    return user;
  }
}

이제 OrderService는 사용자를 어떻게 조회하고, 조회에 실패했을 때 어떤 예외를 발생시킬지 직접 알 필요가 없습니다.

TypeScript
class OrderService {
  constructor(
    private readonly userLookupService: UserLookupService,
    private readonly orderRepository: OrderRepository,
  ) {}

  async createOrder(userId: number) {
    const user =
      await this.userLookupService.findByIdOrThrow(userId);

    // 주문 생성
  }
}

이처럼 LookupService는 여러 서비스에서 반복되는 조회 로직을 재사용하고, 특정 데이터를 찾는 방법을 한곳에서 관리하기 위해 사용됩니다.

| LookupService가 담당하는 역할

LookupService는 주로 다음과 같은 역할을 담당합니다.

  • ID, 이메일, 코드 등의 조건으로 데이터 조회

  • 특정 데이터의 존재 여부 확인

  • 데이터가 없을 경우 공통 예외 처리

  • 삭제되거나 비활성화된 데이터 제외

  • 여러 Repository 또는 외부 API를 이용한 데이터 탐색

  • 다른 도메인에서 공통으로 사용하는 조회 로직 제공

예를 들어 UserLookupService 는 상황에 따라 여러 형태의 메서드를 제공할 수 있습니다.

TypeScript
class UserLookupService {
  async findById(id: number) {
    return this.userRepository.findOne({
      where: { id },
    });
  }

  async findByIdOrThrow(id: number) {
    const user = await this.findById(id);

    if (!user) {
      throw new NotFoundException('사용자를 찾을 수 없습니다.');
    }

    return user;
  }

  async existsByEmail(email: string) {
    return this.userRepository.exists({
      where: { email },
    });
  }
}

findById는 사용자가 없을 경우 null을 반환하고, findByIdOrThrow는 사용자가 없으면 예외를 발생시킵니다. 이처럼 조회 목적에 따라 메서드를 구분하면 사용하는 쪽에서도 결과를 명확하게 예상할 수 있습니다.

| Repository와는 무엇이 다를까?

RepositoryLookupService는 모두 데이터를 조회한다는 점에서 비슷해 보이지만, 담당하는 역할에는 차이가 있습니다.

Repository데이터베이스와 직접 통신하는 계층입니다.

TypeScript
const user = await userRepository.findOne({
  where: { id: userId },
});

반면 LookupServiceRepository를 이용해 데이터를 조회한 뒤, 애플리케이션에서 필요한 규칙을 적용합니다.

TypeScript
async findActiveUserByIdOrThrow(id: number) {
  const user = await this.userRepository.findOne({
    where: {
      id,
      status: UserStatus.ACTIVE,
    },
  });

  if (!user) {
    throw new NotFoundException(
      '활성화된 사용자를 찾을 수 없습니다.',
    );
  }

  return user;
}

정리하면 Repository는 데이터를 어떻게 가져올지에 집중하고, LookupService는 애플리케이션에서 어떤 데이터를 유효한 대상으로 판단할지까지 다룰 수 있습니다.

| 일반 Service를 직접 사용하면 안 될까?

다른 서비스에서 UserService를 직접 주입받아 사용자 정보를 조회할 수도 있습니다.

TypeScript
class OrderService {
  constructor(
    private readonly userService: UserService,
  ) {}
}

하지만 UserService가 회원가입, 정보 수정, 탈퇴, 인증 등 많은 기능을 담당하고 있다면 주문 서비스는 사용자 조회 하나를 위해 지나치게 많은 책임을 가진 서비스에 의존하게 됩니다.

또한 여러 서비스가 서로를 주입받기 시작하면 의존 관계가 복잡해지거나 순환 참조가 발생할 가능성도 있습니다.

text
UserService → OrderService
OrderService → UserService

조회 역할만 담당하는 UserLookupService를 따로 두면 다른 도메인은 필요한 조회 기능에만 의존할 수 있습니다.

text
OrderService → UserLookupService → UserRepository
PaymentService → UserLookupService → UserRepository

이를 통해 서비스 간 의존성을 조금 더 단순하게 유지하고, 각 서비스의 책임도 명확하게 나눌 수 있습니다.

다만 LookupService를 만든다고 해서 순환 참조가 자동으로 해결되는 것은 아닙니다. 도메인 구조 자체가 서로 강하게 얽혀 있다면 모듈의 책임과 의존 방향을 함께 점검해야 합니다.

| QueryService와는 무엇이 다를까?

프로젝트에 따라 LookupService 대신 QueryService라는 이름을 사용하기도 합니다. 두 이름의 역할이 완전히 동일한 프로젝트도 있지만, 다음과 같이 구분할 수도 있습니다.

  • LookupService: 내부 로직에서 필요한 단일 데이터나 존재 여부 조회

  • QueryService: 목록, 검색, 페이지네이션 등 API 응답에 필요한 조회 결과 구성

  • CommandService: 생성, 수정, 삭제처럼 데이터 상태를 변경하는 작업

예를 들어 주문을 생성하기 전에 사용자를 확인하는 로직은 LookupService가 담당하고, 사용자 목록 화면에 필요한 데이터를 구성하는 로직은 QueryService가 담당하도록 나눌 수 있습니다.

TypeScript
// 내부 비즈니스 로직에서 사용
userLookupService.findByIdOrThrow(userId);

// 사용자 목록 API에서 사용
userQueryService.getUserList(query);

이 구분 역시 정해진 표준은 아닙니다. 중요한 것은 이름 자체보다 프로젝트 안에서 각 서비스의 역할이 일관되게 유지되는 것입니다.

| LookupService가 항상 필요할까?

LookupService가 유용하다고 해서 모든 데이터 조회를 반드시 별도의 서비스로 분리해야 하는 것은 아닙니다.

프로젝트 규모가 작고 조회 로직이 단순하거나, 특정 조회가 한 곳에서만 사용된다면 기존 서비스나 Repository를 사용하는 편이 더 간결할 수 있습니다.

반대로 다음과 같은 상황에서는 LookupService 분리를 고려해볼 수 있습니다.

  • 같은 조회 로직이 여러 서비스에서 반복될 때

  • 데이터가 없을 때의 예외 처리를 통일해야 할 때

  • 다른 도메인에서 특정 데이터를 자주 확인해야 할 때

  • 하나의 Service가 너무 많은 책임을 담당하고 있을 때

  • 조회 조건에 비활성화, 삭제 여부 등 공통 규칙이 포함될 때

결국 LookupService는 반드시 사용해야 하는 구조가 아니라, 프로젝트가 커지면서 반복되는 조회 로직과 복잡한 의존 관계를 정리하기 위한 하나의 설계 방법입니다.

| 결론

LookupService는 간단히 말해 여러 곳에서 공통으로 사용하는 데이터 조회 및 확인 로직을 모아둔 서비스입니다.

작은 프로젝트에서는 하나의 Service가 모든 기능을 담당해도 괜찮지만, 규모가 커질수록 동일한 조회와 예외 처리 코드가 여러 곳에서 반복될 수 있습니다. 이때 조회 책임을 LookupService로 분리하면 코드 중복을 줄이고, 각 서비스가 필요한 기능에만 의존하도록 만들 수 있습니다.

다만 LookupService는 프레임워크에서 정한 공식적인 개념이 아닙니다. 따라서 실제 프로젝트에서 해당 이름을 발견했다면 이름만으로 역할을 단정하기보다, 어떤 Repository를 사용하고 어떤 메서드를 제공하는지 확인하는 것이 가장 정확합니다.