5.05 리포지토리 구현
사용자 도메인의 리포지토리는 User 애그리거트와 그 하위 타입인 Member와 Librarian의 영속성을 담당합니다. 이 섹션에서는 리포지토리의 인터페이스 설계부터 구현까지 상세히 살펴보겠습니다.
UserRepository 인터페이스
package net.badnom.library.user.domain.user;
public interface UserRepository extends Repository<UserId, User> {
Result<Member> create(Member member);
Result<Librarian> create(Librarian librarian);
boolean existsByEmail(Email email);
boolean existsByUsername(Username username);
Result<Optional<User>> findByEmail(Email email);
Result<Optional<User>> findByUsername(Username username);
}JPA 엔티티 구현
package net.badnom.library.user.infrastructure.persistence;
@Entity
@Table(name = "users")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "user_type")
public abstract 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 = "status", nullable = false)
private UserStatus status;
// getters, setters
}
@Entity
@DiscriminatorValue("MEMBER")
public class MemberEntity extends UserEntity {
@Enumerated(EnumType.STRING)
@Column(name = "membership_tier")
private MembershipTier membershipTier;
// getters, setters
}
@Entity
@DiscriminatorValue("LIBRARIAN")
public class LibrarianEntity extends UserEntity {
@Column(name = "employee_id", unique = true)
private String employeeId;
// getters, setters
}JPA 기반 리포지토리 구현
package net.badnom.library.user.infrastructure.persistence;
@Repository
@RequiredArgsConstructor
public class JpaUserRepository implements UserRepository {
private final EntityManager em;
private final UserMapper mapper;
@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 {
MemberEntity entity = mapper.toEntity(member);
em.persist(entity);
return Result.success(mapper.toDomain(entity));
} catch (PersistenceException e) {
if (isUniqueConstraintViolation(e)) {
return Result.failure(ErrorCodes.DUPLICATE_USER);
}
return Result.failure(ErrorCodes.DATABASE_ERROR, e.getMessage());
}
}
@Override
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();
}
private boolean isUniqueConstraintViolation(PersistenceException e) {
return e.getCause() instanceof SQLIntegrityConstraintViolationException;
}
}도메인-영속성 매퍼
package net.badnom.library.user.infrastructure.persistence;
@Component
public class UserMapper {
public UserEntity toEntity(User user) {
if (user instanceof Member member) {
return toMemberEntity(member);
} else if (user instanceof Librarian librarian) {
return toLibrarianEntity(librarian);
}
throw new IllegalArgumentException("Unknown user type: " + user.getClass());
}
public User toDomain(UserEntity entity) {
return switch (entity) {
case MemberEntity m -> toMemberDomain(m);
case LibrarianEntity l -> toLibrarianDomain(l);
default -> throw new IllegalArgumentException(
"Unknown entity type: " + entity.getClass());
};
}
private MemberEntity toMemberEntity(Member member) {
MemberEntity entity = new MemberEntity();
entity.setId(member.getId().toString());
entity.setUsername(member.getUsername().toString());
entity.setEmail(member.getEmail().toString());
entity.setFullName(member.getFullName().toString());
entity.setMembershipTier(member.getCurrentTier());
entity.setStatus(member.getStatus());
return entity;
}
private Member toMemberDomain(MemberEntity entity) {
return new Member(
UserId.from(entity.getId()),
Username.from(entity.getUsername()),
HashedPassword.from(entity.getPassword()),
Email.from(entity.getEmail()),
FullName.from(entity.getFullName()),
entity.getMembershipTier()
);
}
// Librarian 매핑 메서드들...
}동시성 제어
package net.badnom.library.user.infrastructure.persistence;
@Entity
@Table(name = "users")
public abstract 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);
entity = em.merge(entity); // 버전 체크 수행
return Result.success(mapper.toDomain(entity));
} catch (OptimisticLockException e) {
return Result.failure(ErrorCodes.CONCURRENT_MODIFICATION);
} catch (PersistenceException e) {
return Result.failure(ErrorCodes.DATABASE_ERROR);
}
}
}도메인 이벤트 처리
package net.badnom.library.user.infrastructure.persistence;
@Aspect
@Component
@RequiredArgsConstructor
public class DomainEventPublisher {
private final ApplicationEventPublisher publisher;
@AfterReturning(
pointcut = "execution(* net.badnom.library.user.infrastructure.persistence.*Repository.save(..))",
returning = "result"
)
public void publishEvents(JoinPoint jp, Result<?> result) {
if (result.isSuccess() && result.getValue() instanceof User user) {
user.getDomainEvents().forEach(publisher::publishEvent);
user.clearDomainEvents();
}
}
}패키지 구조
net.badnom.library.user
├── domain
│ └── user
│ └── UserRepository
└── infrastructure
└── persistence
├── entity
│ ├── UserEntity
│ ├── MemberEntity
│ └── LibrarianEntity
├── mapper
│ └── UserMapper
└── repository
└── JpaUserRepository리포지토리 구현 시 고려할 주요 사항들:
- 도메인 객체와 영속성 객체의 명확한 분리
- Result 타입을 통한 명시적 오류 처리
- 트랜잭션 관리와 동시성 제어
- 도메인 이벤트의 적절한 발행
- 성능 최적화 (N+1 문제 방지 등)
- 명확한 패키지 구조와 책임 분리
이러한 구현을 통해 도메인 모델의 영속성을 효과적으로 관리하면서도, 도메인 로직과 기술적인 구현 사이의 명확한 경계를 유지할 수 있습니다.