5.05 리포지토리 구현

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

리포지토리 구현 시 고려할 주요 사항들:

  1. 도메인 객체와 영속성 객체의 명확한 분리
  2. Result 타입을 통한 명시적 오류 처리
  3. 트랜잭션 관리와 동시성 제어
  4. 도메인 이벤트의 적절한 발행
  5. 성능 최적화 (N+1 문제 방지 등)
  6. 명확한 패키지 구조와 책임 분리

이러한 구현을 통해 도메인 모델의 영속성을 효과적으로 관리하면서도, 도메인 로직과 기술적인 구현 사이의 명확한 경계를 유지할 수 있습니다.