4.4 리포지토리(Repository) 구현 가이드

4.4 리포지토리(Repository) 구현 가이드

리포지토리(Repository) 구현 가이드

개요

리포지토리는 도메인 객체의 저장소 역할을 하며, 도메인 계층과 인프라스트럭처 계층 사이의 중요한 추상화를 제공합니다. 이 가이드에서는 효과적인 리포지토리 구현 방법을 설명하고, 실제 프로젝트에서 활용할 수 있는 구체적인 예시를 제공합니다.

리포지토리의 기본 원칙

1. 컬렉션 메타포

리포지토리는 메모리 상의 컬렉션처럼 동작해야 합니다. 이는 도메인 객체를 저장하고 검색하는 일관된 인터페이스를 제공하는 것을 의미합니다:

public interface Repository<ID, T extends AggregateRoot<ID>> {
    // 기본적인 CRUD 작업을 위한 메서드들
    Result<T> findById(ID id);
    Result<List<T>> findAll();
    Result<T> save(T entity);
    void delete(T entity);
    boolean existsById(ID id);
}

이러한 기본 인터페이스를 바탕으로 도메인별 리포지토리를 정의할 수 있습니다:

public interface UserRepository extends Repository<UserId, User> {
    // 특정 도메인에 필요한 추가 메서드들
    Result<Member> create(Member member);
    Result<Librarian> create(Librarian librarian);
    boolean existsByEmail(Email email);
    Result<Optional<User>> findByEmail(Email email);
}

2. 도메인 중심 인터페이스

리포지토리 인터페이스는 도메인 개념을 중심으로 설계되어야 합니다:

public interface MemberRepository extends UserRepository {
    // 도메인 개념을 반영하는 메서드명과 파라미터
    Result<List<Member>> findByMembershipTier(MembershipTier tier);
    Result<List<Member>> findOverdueMembers();
    Result<Integer> countActiveMembers();
}

3. Result 타입을 활용한 오류 처리

리포지토리 작업의 성공/실패를 명확하게 표현하기 위해 Result 타입을 사용합니다:

public interface LoanRepository extends Repository<LoanId, Loan> {
    Result<Loan> findActiveLoan(Member member, Book book) {
        try {
            // 실제 조회 로직
            return Result.success(loan);
        } catch (DataAccessException e) {
            return Result.failure(ErrorCodes.LOAN_NOT_FOUND);
        }
    }

    Result<List<Loan>> findOverdueLoans(LocalDateTime criteria);
}

구현 가이드라인

1. JPA 기반 구현

JPA를 사용하는 리포지토리 구현의 예시입니다:

@Repository
public class JpaUserRepository implements UserRepository {
    private final EntityManager em;
    private final JpaUserMapper mapper;

    public JpaUserRepository(EntityManager em, JpaUserMapper mapper) {
        this.em = Objects.requireNonNull(em, "EntityManager must not be null");
        this.mapper = Objects.requireNonNull(mapper, "Mapper must not be null");
    }

    @Override
    @Transactional(readOnly = true)
    public Result<User> findById(UserId id) {
        try {
            UserEntity entity = em.find(UserEntity.class, id.toString());
            if (entity == null) {
                return Result.failure(ErrorCodes.USER_NOT_FOUND, id);
            }
            return Result.success(mapper.toDomain(entity));
        } catch (Exception e) {
            return Result.failure(ErrorCodes.DATABASE_ERROR, e.getMessage());
        }
    }

    @Override
    @Transactional
    public Result<Member> create(Member member) {
        try {
            UserEntity entity = mapper.toEntity(member);
            em.persist(entity);
            return Result.success(mapper.toDomain(entity));
        } catch (PersistenceException e) {
            return Result.failure(ErrorCodes.USER_CREATION_FAILED, e.getMessage());
        }
    }

    @Override
    @Transactional(readOnly = true)
    public boolean existsByEmail(Email email) {
        String jpql = "SELECT COUNT(u) > 0 FROM UserEntity u WHERE u.email = :email";
        return em.createQuery(jpql, Boolean.class)
                .setParameter("email", email.toString())
                .getSingleResult();
    }
}

2. 매퍼 구현

도메인 객체와 영속성 객체 사이의 변환을 담당하는 매퍼:

@Component
public class JpaUserMapper {
    public UserEntity toEntity(User user) {
        UserEntity entity = new UserEntity();
        entity.setId(user.getId().toString());
        entity.setUsername(user.getUsername().toString());
        entity.setEmail(user.getEmail().toString());
        entity.setFullName(user.getFullName().toString());
        
        if (user instanceof Member member) {
            entity.setUserType(UserType.MEMBER);
            entity.setMembershipTier(member.getMembershipTier().name());
        } else if (user instanceof Librarian) {
            entity.setUserType(UserType.LIBRARIAN);
        }
        
        return entity;
    }

    public User toDomain(UserEntity entity) {
        UserId id = UserId.from(entity.getId());
        Username username = Username.from(entity.getUsername());
        Email email = Email.from(entity.getEmail());
        FullName fullName = FullName.of(entity.getFullName()).orElseThrow();
        HashedPassword password = HashedPassword.from(entity.getPassword());

        return switch (entity.getUserType()) {
            case MEMBER -> new Member(id, username, password, email, fullName,
                    MembershipTier.valueOf(entity.getMembershipTier()));
            case LIBRARIAN -> new Librarian(id, username, password, email, fullName);
        };
    }
}

3. 영속성 모델 구현

JPA 엔티티 클래스의 예시:

@Entity
@Table(name = "users")
public class UserEntity {
    @Id
    private String id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(name = "full_name", nullable = false)
    private String fullName;

    @Enumerated(EnumType.STRING)
    @Column(name = "user_type", nullable = false)
    private UserType userType;

    @Column(name = "membership_tier")
    private String membershipTier;

    // getters, setters, etc.
}

고급 구현 패턴

1. 명세(Specification) 패턴

복잡한 검색 조건을 캡슐화하기 위한 명세 패턴의 구현:

public interface UserSpecification {
    Predicate toPredicate(Root<UserEntity> root, 
                         CriteriaBuilder cb);
}

public class ActiveMemberSpecification implements UserSpecification {
    @Override
    public Predicate toPredicate(Root<UserEntity> root, CriteriaBuilder cb) {
        return cb.and(
            cb.equal(root.get("userType"), UserType.MEMBER),
            cb.equal(root.get("status"), UserStatus.ACTIVE)
        );
    }
}

// 리포지토리에서의 사용
public Result<List<User>> findAll(UserSpecification spec) {
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<UserEntity> query = cb.createQuery(UserEntity.class);
    Root<UserEntity> root = query.from(UserEntity.class);
    
    query.where(spec.toPredicate(root, cb));
    
    try {
        List<UserEntity> entities = em.createQuery(query).getResultList();
        return Result.success(entities.stream()
                .map(mapper::toDomain)
                .collect(Collectors.toList()));
    } catch (Exception e) {
        return Result.failure(ErrorCodes.QUERY_EXECUTION_FAILED);
    }
}

2. 페이징과 정렬

대량의 데이터를 효율적으로 처리하기 위한 페이징과 정렬 구현:

public interface UserRepository {
    Result<Page<User>> findAll(Pageable pageable);
    
    default Result<Page<User>> findAll(int page, int size, Sort sort) {
        return findAll(PageRequest.of(page, size, sort));
    }
}

@Repository
public class JpaUserRepository implements UserRepository {
    @Override
    public Result<Page<User>> findAll(Pageable pageable) {
        try {
            TypedQuery<UserEntity> query = createPaginatedQuery(pageable);
            List<UserEntity> entities = query.getResultList();
            long total = countTotal();
            
            List<User> users = entities.stream()
                    .map(mapper::toDomain)
                    .collect(Collectors.toList());
                    
            return Result.success(new PageImpl<>(users, pageable, total));
        } catch (Exception e) {
            return Result.failure(ErrorCodes.QUERY_EXECUTION_FAILED);
        }
    }
}

3. 캐시 적용

성능 향상을 위한 캐시 레이어 구현:

@Repository
public class CachingUserRepository implements UserRepository {
    private final UserRepository delegate;
    private final Cache<UserId, User> cache;

    @Override
    public Result<User> findById(UserId id) {
        User cached = cache.get(id);
        if (cached != null) {
            return Result.success(cached);
        }
        
        return delegate.findById(id)
                .onSuccess(user -> cache.put(id, user));
    }

    @Override
    @CacheEvict(key = "#result.id")
    public Result<User> save(User user) {
        return delegate.save(user);
    }
}

테스트 전략

1. 단위 테스트

리포지토리 구현을 독립적으로 테스트:

@DataJpaTest
class JpaUserRepositoryTest {
    @Autowired
    private EntityManager em;
    
    @Autowired
    private JpaUserMapper mapper;
    
    private UserRepository repository;
    
    @BeforeEach
    void setUp() {
        repository = new JpaUserRepository(em, mapper);
    }
    
    @Test
    @DisplayName("이메일로 사용자를 찾을 수 있어야 함")
    void shouldFindUserByEmail() {
        // Given
        Member member = TestMemberBuilder.aDefaultMember().build();
        repository.create(member);
        
        // When
        Result<Optional<User>> result = repository
                .findByEmail(member.getEmail());
        
        // Then
        assertThat(result.isSuccess()).isTrue();
        assertThat(result.getValue())
                .isPresent()
                .get()
                .extracting(User::getEmail)
                .isEqualTo(member.getEmail());
    }
}

2. 통합 테스트

실제 데이터베이스와의 상호작용 테스트:

@SpringBootTest
class UserRepositoryIntegrationTest {
    @Autowired
    private UserRepository repository;
    
    @Test
    @DisplayName("회원 등급 변경이 데이터베이스에 반영되어야 함")
    void shouldUpdateMembershipTier() {
        // Given
        Member member = TestMemberBuilder.aDefaultMember().build();
        member = repository.create(member).getValue();
        
        // When
        member.upgradeTier(MembershipTier.GOLD);
        Result<Member> result = repository.save(member);
        
        // Then
        assertThat(result.isSuccess()).isTrue();
        Member saved = repository.findById(member.getId())
                .getValue();
        assertThat(saved.getMembershipTier())
                .isEqualTo(MembershipTier.GOLD);
    }
}

모범 사례

1. 트랜잭션 관리

적절한 트랜잭션 범위 설정:

@Repository
public class JpaUserRepository implements UserRepository {
    @Override
    @Transactional(readOnly = true)  // 읽기 전용 트랜잭션
    public Result<User> findById(UserId id) {
        // 구현
    }

    @Override
    @Transactional  // 쓰기 가능한 트랜잭션
    public Result<User> save(User user) {
        // 구현
    }
}

2. 예외 처리

일관된 예외 변환과 처리:

@Repository
public class JpaUserRepository implements UserRepository {
    private Result<User> handleDataAccessException(DataAccessException e) {
        return switch (e) {
            case DataIntegrityViolationException dive ->
                Result.failure(ErrorCodes.DUPLICATE_ENTRY);
            case JpaObjectRetrievalFailureException jorfe ->
                Result.failure(ErrorCodes.ENTITY_NOT_FOUND);
            default ->
                Result.failure(ErrorCodes.DATABASE_ERROR, e.getMessage());
        };
    }
}

3. 성능 최적화

N+1 문제 해결을 위한 조인 페치 사용:

@Repository
public class JpaUserRepository implements UserRepository {
    @Override
    public Result<List<Member>> findByMembershipTier(MembershipTier tier) {
        String jpql = """
            SELECT DISTINCT m FROM Member m
            LEFT JOIN FETCH m.loans l
            WHERE m.membershipTier = :tier
            """;
            
        try {
            List<MemberEntity> entities = em.createQuery(jpql, MemberEntity.class)
                    .setParameter("tier", tier)
                    .getResultList();
                    
            return Result.success(entities.stream()
                    .map(mapper::toDomain)
                    .collect(Collectors.toList()));
        } catch (Exception e) {
            return Result.failure(ErrorCodes.QUERY_EXECUTION_FAILED);
        }
    }

    // 배치 처리를 위한 청크 단위 조회
    public Result<List<Member>> findByMembershipTierInChunks(
            MembershipTier tier, int chunkSize) {
        String jpql = """
            SELECT m FROM Member m
            WHERE m.membershipTier = :tier
            ORDER BY m.id
            """;

        try {
            return Result.success(
                em.createQuery(jpql, MemberEntity.class)
                    .setParameter("tier", tier)
                    .setMaxResults(chunkSize)
                    .getResultList()
                    .stream()
                    .map(mapper::toDomain)
                    .collect(Collectors.toList())
            );
        } catch (Exception e) {
            return Result.failure(ErrorCodes.QUERY_EXECUTION_FAILED);
        }
    }

    // 동적 쿼리 생성을 위한 Criteria API 활용
    public Result<List<Member>> findBySearchCriteria(UserSearchCriteria criteria) {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<MemberEntity> query = cb.createQuery(MemberEntity.class);
        Root<MemberEntity> root = query.from(MemberEntity.class);
        
        List<Predicate> predicates = new ArrayList<>();
        
        if (criteria.getMembershipTier() != null) {
            predicates.add(cb.equal(
                root.get("membershipTier"), 
                criteria.getMembershipTier())
            );
        }
        
        if (criteria.getStatus() != null) {
            predicates.add(cb.equal(
                root.get("status"), 
                criteria.getStatus())
            );
        }
        
        if (!predicates.isEmpty()) {
            query.where(predicates.toArray(new Predicate[0]));
        }
        
        try {
            List<MemberEntity> entities = em.createQuery(query).getResultList();
            return Result.success(entities.stream()
                    .map(mapper::toDomain)
                    .collect(Collectors.toList()));
        } catch (Exception e) {
            return Result.failure(ErrorCodes.QUERY_EXECUTION_FAILED);
        }
    }
}

4. 감사(Audit) 지원

엔티티의 생성 및 수정 이력을 추적하기 위한 감사 기능 구현:

@Getter
@MappedSuperclass
public abstract class AuditableEntity {
    @CreatedDate
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(nullable = false)
    private LocalDateTime updatedAt;

    @CreatedBy
    @Column(nullable = false, updatable = false)
    private String createdBy;

    @LastModifiedBy
    @Column(nullable = false)
    private String lastModifiedBy;
}

@Entity
@EntityListeners(AuditingEntityListener.class)
public class UserEntity extends AuditableEntity {
    // 기존 필드들...
}

5. 낙관적 락(Optimistic Locking) 구현

동시성 제어를 위한 낙관적 락 처리:

@Entity
public class UserEntity {
    @Version
    private Long version;
    
    // 기존 필드들...
}

@Repository
public class JpaUserRepository implements UserRepository {
    @Override
    @Transactional
    public Result<User> save(User user) {
        try {
            UserEntity entity = mapper.toEntity(user);
            em.merge(entity);  // 낙관적 락 검사 수행
            return Result.success(mapper.toDomain(entity));
        } catch (OptimisticLockException e) {
            return Result.failure(ErrorCodes.CONCURRENT_MODIFICATION);
        } catch (Exception e) {
            return Result.failure(ErrorCodes.DATABASE_ERROR);
        }
    }
}

6. 이벤트 발행

도메인 이벤트 발행을 위한 AOP 기반 구현:

@Aspect
@Component
public class DomainEventPublisher {
    private final ApplicationEventPublisher publisher;

    @AfterReturning(
        pointcut = "execution(* com.example.repository.*Repository.save(..))",
        returning = "result"
    )
    public void publishEvents(JoinPoint jp, Result<?> result) {
        if (result.isSuccess() && result.getValue() instanceof AggregateRoot<?> root) {
            root.getDomainEvents().forEach(publisher::publishEvent);
            root.clearDomainEvents();
        }
    }
}

고려사항과 주의점

1. 성능과 확장성

  • N+1 쿼리 문제를 피하기 위해 적절한 페치 전략을 사용하세요
  • 대량 데이터 처리 시 배치 처리와 청크 단위 처리를 고려하세요
  • 캐시 전략을 신중하게 선택하고 구현하세요

2. 테스트 용이성

  • 테스트를 위한 인메모리 구현체를 제공하세요
  • 통합 테스트에서 실제 데이터베이스를 사용하세요
  • 동시성 이슈를 테스트할 수 있는 방법을 제공하세요

3. 유지보수성

  • 일관된 명명 규칙을 사용하세요
  • 복잡한 쿼리는 별도의 쿼리 객체로 분리하세요
  • 적절한 로깅과 모니터링을 구현하세요

결론

리포지토리 계층은 도메인 모델과 영속성 계층을 연결하는 중요한 역할을 합니다. 효과적인 리포지토리 구현을 위해서는:

  1. 도메인 중심의 인터페이스 설계
  2. 적절한 추상화 수준 유지
  3. 성능과 확장성 고려
  4. 테스트 용이성 확보
  5. 유지보수성 향상을 위한 설계

이러한 원칙들을 고려하면서 구현하면, 견고하고 유지보수하기 쉬운 리포지토리 계층을 만들 수 있습니다.