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. 유지보수성
- 일관된 명명 규칙을 사용하세요
- 복잡한 쿼리는 별도의 쿼리 객체로 분리하세요
- 적절한 로깅과 모니터링을 구현하세요
결론
리포지토리 계층은 도메인 모델과 영속성 계층을 연결하는 중요한 역할을 합니다. 효과적인 리포지토리 구현을 위해서는:
- 도메인 중심의 인터페이스 설계
- 적절한 추상화 수준 유지
- 성능과 확장성 고려
- 테스트 용이성 확보
- 유지보수성 향상을 위한 설계
이러한 원칙들을 고려하면서 구현하면, 견고하고 유지보수하기 쉬운 리포지토리 계층을 만들 수 있습니다.