DDD 애그리거트 패턴을 활용한 User 도메인 리팩토링
들어가며
최근 팀 프로젝트에서 User 도메인을 DDD(Domain-Driven Design)의 애그리거트 패턴을 적용하여 리팩토링하는 작업을 진행했습니다. 기존 코드에서 비즈니스 로직이 서비스 레이어에 분산되어 있고, 엔티티 생성이 무분별하게 이루어지는 문제점을 해결하고자 했습니다.
이번 글에서는 팩토리 메서드 패턴과 애그리거트 패턴을 적용하여 더 안전하고 응집도 높은 도메인 모델을 구축한 경험을 공유하겠습니다.
기존 코드의 문제점
1. 무분별한 엔티티 생성
// 기존 코드 - 어디서든 User 생성 가능
User user = new User();
UserRating rating = new UserRating(reviewerId, targetUserId, meetingId, score);
UserFollow follow = new UserFollow(followerId, followingId);
위와 같이 어디서든 new 키워드로 엔티티를 생성할 수 있어 데이터 일관성 보장이 어려웠습니다.
2. 비즈니스 로직의 분산
// UserServiceImpl에서 직접 하위 엔티티 관리
UserRating userRating = new UserRating(reviewerId, targetUserId, meetingId, score);
targetUser.getRatings().add(userRating);
UserFollow userFollow = new UserFollow(followerId, followingId);
follower.getFollowings().add(userFollow);
follower.incrementFollowingCount();
도메인 로직이 서비스 레이어에 흩어져 있어 응집도가 낮고 유지보수가 어려웠습니다.
해결 방안: 애그리거트 패턴 적용
1. 팩토리 메서드 패턴 도입
먼저 User 엔티티의 생성자를 제한하고 팩토리 메서드만을 통해 생성하도록 변경했습니다.
@Table(name = "users")
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 생성자 접근 제한
public class User extends BaseEntity {
// 필드들...
/**
* 일반 회원가입용 사용자 생성
*/
public static User createUser(String nickname, String email, String encodedPassword,
Double latitude, Double longitude) {
User user = new User();
user.nickname = nickname;
user.email = email;
user.password = encodedPassword;
user.latitude = latitude;
user.longitude = longitude;
user.score = 50.0;
user.followingCount = 0;
user.followerCount = 0;
user.categories = new ArrayList<>();
user.followings = new ArrayList<>();
user.ratings = new ArrayList<>();
return user;
}
}
2. 하위 엔티티 생성자 접근 제한
하위 엔티티들의 생성자를 package-private으로 변경하여 User 애그리거트에서만 생성할 수 있도록 제한했습니다.
@Table(name = "user_ratings")
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserRating extends BaseCreateEntity {
// 필드들...
// Package-private 생성자 (User 애그리거트에서만 사용)
UserRating(Long reviewerId, Long targetUserId, Long meetingId, Integer ratingScore) {
this.reviewerId = reviewerId;
this.targetUserId = targetUserId;
this.meetingId = meetingId;
this.ratingScore = ratingScore;
}
}
3. User 애그리거트에서 하위 엔티티 관리
User 클래스에 하위 엔티티를 생성하고 관리하는 메서드들을 추가했습니다.
public class User extends BaseEntity {
/**
* 평가 추가 - UserRating 생성 및 추가
*/
public void addRating(Long reviewerId, Long meetingId, Integer ratingScore) {
UserRating rating = new UserRating(reviewerId, this.id, meetingId, ratingScore);
this.ratings.add(rating);
}
/**
* 팔로우 추가 - 내 팔로잉 수만 증가
*/
public void addFollowing(Long followingId) {
UserFollow userFollow = new UserFollow(this.id, followingId);
this.followings.add(userFollow);
this.incrementFollowingCount();
}
/**
* 팔로우 제거 - 내 팔로잉 수만 감소
*/
public void removeFollowing(Long followingId) {
this.followings.removeIf(follow -> follow.getFollowingId().equals(followingId));
this.decrementFollowingCount();
}
/**
* 팔로워 수 증가 (다른 사용자가 나를 팔로우할 때)
*/
public void increaseFollowerCount() {
this.followerCount++;
}
/**
* 팔로워 수 감소 (다른 사용자가 나를 언팔로우할 때)
*/
public void decreaseFollowerCount() {
this.followerCount--;
}
/**
* 카테고리 전체 교체
*/
public void updateCategories(List<Integer> categoryIds) {
this.categories.clear();
if (categoryIds != null && !categoryIds.isEmpty()) {
for (Integer categoryId : categoryIds) {
UserCategory category = new UserCategory(this.id, categoryId);
this.categories.add(category);
}
}
}
}
4. 서비스 레이어에서 애그리거트 메서드 활용
서비스 레이어에서는 직접 하위 엔티티를 생성하는 대신 User 애그리거트의 메서드를 호출하도록 변경했습니다.
@Service
public class UserServiceImpl implements UserService {
@Transactional
public void followUser(Long followerId, Long followingId) {
// 비즈니스 규칙 검증
if (followerId.equals(followingId)) {
throw new UserException(UserErrorCode.CANNOT_FOLLOW_SELF);
}
User follower = validateAndGetUser(followerId);
User following = validateAndGetUser(followingId);
// 중복 팔로우 확인
boolean alreadyFollowing = follower.getFollowings().stream()
.anyMatch(follow -> follow.getFollowingId().equals(followingId));
if (alreadyFollowing) {
throw new UserException(UserErrorCode.ALREADY_FOLLOWING);
}
// User 애그리거트에 위임
follower.addFollowing(followingId); // 팔로잉 추가 + 카운트 증가
following.increaseFollowerCount(); // 팔로워 카운트만 증가
// 이벤트 발행
publishFollowEvent(followerId, followingId, follower.getNickname());
}
@Transactional
public void createUserRating(Long reviewerId, Long targetUserId, UserRatingCreateRequestDto request) {
// 비즈니스 규칙 검증
validateRatingRules(reviewerId, targetUserId, request);
User targetUser = validateAndGetUser(targetUserId);
// User 애그리거트에 위임
targetUser.addRating(reviewerId, request.meetingId(), request.ratingScore());
recalculateUserScore(targetUserId);
}
}
적용 결과
1. 명확한 책임 분리
- 서비스 레이어: 비즈니스 규칙 검증, 외부 시스템 연동, 이벤트 발행
- User 애그리거트: 도메인 객체 생성, 수정, 삭제 및 데이터 일관성 보장
2. 데이터 일관성 보장
하위 엔티티들이 User 애그리거트를 통해서만 생성되므로 데이터 일관성이 보장됩니다.
3. 응집도 향상
관련된 도메인 로직이 User 클래스 내부에 응집되어 이해하기 쉽고 유지보수가 용이해졌습니다.
4. 안전한 객체 생성
// 불가능 - 컴파일 에러
UserRating rating = new UserRating(...);
UserFollow follow = new UserFollow(...);
// 가능 - 올바른 방법
user.addRating(reviewerId, meetingId, score);
user.addFollowing(followingId);
주의사항
1. JPA 호환성
엔티티 생성자를 PROTECTED로 설정하는 것이 PRIVATE보다 JPA/Hibernate와의 호환성 측면에서 더 안전합니다.
2. 과도한 제약 피하기
도메인 모델의 복잡성과 팀의 개발 생산성을 고려하여 적절한 수준의 제약을 적용해야 합니다.
3. 테스트 코드 작성
package-private 생성자로 인해 테스트 코드 작성 시 고려해야 할 사항들이 있습니다.
마무리
DDD 애그리거트 패턴을 적용하여 User 도메인을 리팩토링한 결과, 코드의 안전성과 유지보수성이 크게 향상되었습니다. 특히 도메인 로직이 응집되고 데이터 일관성이 보장되면서 더 견고한 시스템을 구축할 수 있었습니다.
다만 모든 상황에 이 패턴을 적용하는 것이 정답은 아니며, 프로젝트의 복잡성과 팀의 상황을 고려하여 적절한 수준에서 적용하는 것이 중요하다고 생각합니다.