0. 비지니스 로직이 Domain Model에 위치하는 것이 좋은 이유.
0-1. 캡슐화에 도움이 된다
- 객체지향의 핵심중 하나인 캡슐화는 하나의 객체가 상태와 그 상태를 다루는 행동을 동시에 가지는 특징을 말한다.
- 캡슐화를 통해 외부에서 보내는 메시지에 대해서 객체가 자율적으로 행동하기 때문에 의존성을 줄이고 변경에 유리한 코드를 작성할 수 있다.
- 비지니스 로직은 결국 entity, vo의 상태를 다루는 것인데 이를 그 객체 내의 메서드로 다루는 것이 아니라 외부인 service에서 다룬다면 캡슐화를 효과적으로 이루고 있다고 말하기 힘들다.
0-2. 재사용성
- domain model에 비지니스 로직을 메서드로 분리하여 적고 이를 service에서 조합하여 호출하는 것과 하나의 service메서드에 절차지향적으로 작성되는 비지니스 로직은 재사용성면에서 큰 차이가 난다.
0-3. 테스트 용이성
- domain model을 테스트하는 것이 service layer를 테스트하는 것보다 쉽고 빠르다.
- Service를 테스트하려면 테스트 환경 조성이 더 까다롭고 테스트 수행시간도 오래걸린다.
1. 리팩토링 수행
리팩토링 전
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class MatchService {
private finalMatchRepositorymatchRepository;
private finalUserRepositoryuserRepository;
private finalMemberRepositorymemberRepository;
private finalActivityRepositoryactivityRepository;
private finalMemberMappermemberMapper;
@Transactional
public ResponseEntity<Void> makeReview(String username, String matchId,
MatchReviewRequest request) {
verifyCallerParticipatedInMatch(username,matchId);
User caller = userRepository.findByUsername(username)
.orElseThrow(() ->
new NoEntityException(ErrorCode.ENTITY_NOT_FOUND));
verifyReviewedMemberInMatchAndNotCaller(matchId,request.getMemberReviewDtos().stream()
.map(MemberReviewDto::getNickname)
.collect(Collectors.toList()), caller);
Match match = matchRepository.findByApiId(matchId)
.orElseThrow(() ->
new NoEntityException(ErrorCode.ENTITY_NOT_FOUND));
match.review(caller.getMember());
Member memberReviewAuthor = memberRepository.findByNickname(username)
.orElseThrow(() ->
new NoEntityException(ErrorCode.ENTITY_NOT_FOUND));
activityRepository.save(
Activity.of(memberReviewAuthor,
match.getContentCategory(),
ActivityMatchScore.MAKE_MATCH_REVIEW));
List<Activity> activities =request.getMemberReviewDtos().stream()
.map((memberReviewDto) -> {
Member member = memberRepository.findByNickname(memberReviewDto.getNickname())
.orElseThrow(() ->
new NoEntityException(ErrorCode.ENTITY_NOT_FOUND));
return Activity.of(member, match.getContentCategory(),
memberReviewDto.getActivityMatchScore());
})
.collect(Collectors.toList());
activityRepository.saveAll(activities);
return ResponseEntity.ok().build();
}
private void verifyReviewedMemberInMatchAndNotCaller(StringmatchId,List<String>nicknames, Usercaller) {
Match match = matchRepository.findByApiId(matchId)
.orElseThrow(() ->
new NoEntityException(ErrorCode.ENTITY_NOT_FOUND));
Set<Member> memberSet = match.getMatchMembers()
.stream()
.map(MatchMember::getMember)
.collect(Collectors.toSet());
memberRepository.findAllByNicknameIn(nicknames)
.forEach((member) -> {
if (!memberSet.contains(member)) {
throw new BusinessException(ErrorCode.REVIEWED_MEMBER_NOT_IN_MATCH);
}else if(caller.getMember().equals(member)){
throw new BusinessException(ErrorCode.REVIEWING_SELF);
}
});
}
private void verifyCallerParticipatedInMatch(Usercaller, StringmatchId) {
Match match = matchRepository.findByApiId(matchId)
.orElseThrow(() ->
new NoEntityException(ErrorCode.ENTITY_NOT_FOUND));
if (!caller.getUserRoles().stream()
.map(UserRole::getRole)
.map(Role::getValue)
.collect(Collectors.toSet())
.contains(RoleEnum.ROLE_ADMIN) &&
!match.getMatchMembers().stream()
.map(MatchMember::getMember)
.collect(Collectors.toSet())
.contains(caller.getMember())) {
throw new BusinessException(ErrorCode.NOT_MATCH_PARTICIPATED);
}
}
}
리팩토링 후
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class MatchService {
private final MatchRepository matchRepository;
private final UserRepository userRepository;
private final MemberRepository memberRepository;
private final ActivityRepository activityRepository;
private final MemberMapper memberMapper;
@Transactional
public List<Activity> makeReview(String username, String matchId,
MatchReviewRequest request) {
Match match = matchRepository.findByApiId(matchId)
.orElseThrow(() ->
new NoEntityException(ErrorCode.ENTITY_NOT_FOUND));
User reviewingUser = getUserByUsernameOrException(username);
Map<String, ActivityMatchScore> nicknameActivityScoreMap = request.getMemberReviewDtos().stream()
.collect(Collectors.toMap(MemberReviewDto::getNickname,
MemberReviewDto::getActivityMatchScore));
List<Member> reviewedTargets = memberRepository.findAllByNicknameIn(
new ArrayList<>(nicknameActivityScoreMap.keySet()));
if (reviewedTargets.size() != nicknameActivityScoreMap.size()) {
throw new NoEntityException(ErrorCode.ENTITY_NOT_FOUND);
}
List<Activity> createdActivities = match.makeReview(reviewingUser.getMember(),
reviewedTargets.stream()
.collect(Collectors.toMap(m -> m,
m -> nicknameActivityScoreMap.get(m.getNickname()))));
return activityRepository.saveAll(createdActivities);
}
}
@Builder(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Table(name = "MATCHS", uniqueConstraints = {
@UniqueConstraint(name = "API_ID_UNIQUE", columnNames = {"apiId"}),
})
@Entity
public class Match extends BaseEntity {
public List<Activity> makeReview(Member reviewer, Map<Member, ActivityMatchScore> reviewedMemberScoreMap) {
verifyMemberParticipatedInMatchOrAdmin(reviewer);
verifyReviewedMemberIsInMatchAndNotReviewer(reviewer, reviewedMemberScoreMap.keySet());
matchMembers.stream()
.filter(mm ->
mm.getMember().equals(reviewer))
.findAny()
.orElseThrow(() ->
new IllegalArgumentException("해당 매치에 참여한 멤버가 아닙니다."))
.updateisReviewedToTrue();
List<Activity> activities = new ArrayList<>();
Activity reviewerActivity = Activity.of(reviewer,
contentCategory,
ActivityMatchScore.MAKE_MATCH_REVIEW);
activities.add(reviewerActivity);
reviewedMemberScoreMap.forEach((member, score) -> {
Activity activity = Activity.of(member,
contentCategory,
score);
activities.add(activity);
});
return activities;
}
public void verifyMemberParticipatedInMatchOrAdmin(Member member) {
if (member.getUser().hasRole(RoleEnum.ROLE_ADMIN)){
return ;
}
if (!matchMembers.stream()
.map(MatchMember::getMember)
.collect(Collectors.toSet())
.contains(member)) {
throw new BusinessException(ErrorCode.NOT_MATCH_PARTICIPATED);
}
}
public Boolean isMemberReviewingBefore(Member member){
return getMatchMembers().stream()
.filter((mm) ->
member.equals(mm.getMember())
).findFirst()
.orElseThrow(() ->
new IllegalArgumentException("해당 매치에 참여한 멤버가 아닙니다."))
.getIsReviewed();
}
private void verifyReviewedMemberIsInMatchAndNotReviewer(Member reviewer, Set<Member> reviewedMemberSet) {
Set<Member> memberSet = this.getMatchMembers()
.stream()
.map(MatchMember::getMember)
.collect(Collectors.toSet());
reviewedMemberSet
.forEach((member) -> {
if (!memberSet.contains(member)) {
throw new BusinessException(ErrorCode.REVIEWED_MEMBER_NOT_IN_MATCH);
}else if(reviewer.equals(member)){
throw new BusinessException(ErrorCode.REVIEWING_SELF);
}
});
}
}
@Builder.Default
@OneToMany(mappedBy = "match", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
private List<MatchMember> matchMembers = new ArrayList<>();
@Builder.Default
@OneToMany(mappedBy = "match", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
private List<MatchConditionMatch> matchConditionMatches = new ArrayList<>();
2. 비지니스 로직이 domain에 작성되려면 어떤 노력을 해야할까
- ORM에 대해서 이야기 할 때 DB 테이블 내의 레코드를 마치 객체지향 프로그래밍언어에서 자료구조에 들어있는 객체처럼 다룰 수 있는 매핑 기술로 이야기한다.
- 비지니스 로직이 domain model에 담기기 위해서는 DB내의 데이터를 직접 dao를 통해 접근 하는 것은 최소화 되고 Entity, VO를 객체 자체로 처리하면 DB에 반영되는 형태로 이루어 져야한다.
2-1.update시 영속성 컨텍스트의 Dirty Checking활용
- 즉, update 쿼리나 EntityManager.merge를 사용하지말고 객체의 값을 바꾸기만 하면된다.
2-2.Select 시 객체 그래프 탐색을 활용.
- 조회 시 select쿼리를 작성할 수도 있지만 객체 그래프 탐색을 하면 외래키로 지정된 컬럼에 where절 조건을 부여하여 조회할 수 있음.
2-3.Insert, Delete시 cascade option활용.
- 해당 객체 뿐만 아니라 연관된 객체를 save, delete해야할 때 cascade option을 활용하면 entity의 메서드에서는 핵심 객체만 return 하는 식으로 비지니스로직을 작성할 수 있다.
- 기존 코드에서는 Service Layer에서 Match, MatchConditionMatch, MatchMember등을 생성한다.
@Transactional
public EmailDto<MatchOnlyIdResponse> completeArticle(String username, String articleId) {
Match match = matchRepository.save(
Match.of(MatchStatus.MATCHED, article.getContentCategory(), MethodCategory.MANUAL,
article, article.getParticipantNum()));
matchConditionMatchRepository.saveAll(article.getArticleMatchConditions().stream()
.map(arm ->
MatchConditionMatch.of(match, arm.getMatchCondition()))
.collect(Collectors.toList()));
matchMemberRepository.saveAll(article.getArticleMembers().stream()
.map(am ->
MatchMember.of(match, am.getMember(), am.getIsAuthor()))
.collect(Collectors.toList()));
}
- 하지만 리팩토링 된 다음 코드는 completeArticleWhenMatchDecided라는 entity의 메서드에서 모든 객체를 생성하고 생성한 객체를 return 한다.
- cascade persist가 되어있기 때문에 match만 save하면 나머지 객체도 save된다.
@Transactional
public EmailDto<MatchOnlyIdResponse> completeArticle(String username, String articleId) {
Match match = article.completeArticleWhenMatchDecided();
matchRepository.save(match);
}
public Match completeArticleWhenMatchDecided() {
verifyDeleted();
verifyCompleted();
this.isComplete = true;
Match match = Match.of(MatchStatus.MATCHED, contentCategory,
MethodCategory.MANUAL,
this, participantNum);
articleMatchConditions
.forEach(arm ->
MatchConditionMatch.of(match, arm.getMatchCondition()));
articleMembers
.forEach(am ->
MatchMember.of(match, am.getMember(), am.getIsAuthor()));
return match;
}
3. 2의 방법의 문제점과 쿼리를 직접 작성하는 것이 나을 수도 있는 경우-성능 문제 때문에 발생
- 2의 방식으로 코드를 작성한다면 영속성 컨텍스트를 활용하여 DB를 마치 Collection처럼 생각하여 코드를 작성할 수 있다.
- 하지만 성능 문제 때문에 꼭 쿼리를 작성해야하는 경우가 있다.
3-1. update시 영속성 컨텍스트의 Dirty Checking을 활용해 변경해야하는 Entity가 많은 경우
- update해야하는 객체가 많을 경우 update쿼리가 너무 여러번 생성됨.
해결법
- bulkUpdate쿼리를 직접 작성한다. 이 경우 영속성 컨텍스트와 무관한것을 주의한다.
- 따라서 안전하게 EntityManger의 flush, clear를 호출해준다.
3-2. Select 시 객체 그래프 탐색을 활용해서 검색할 때 조회 조건을 주어야하는 경우.
- 그래프 탐색은 조회 조건을 줄 수 없기 때문에 특정 Entity만 필요한 경우 모두 조회해온 다음에 Application Server에서 필터링을 해야한다.
- 메모리에 로딩되는 객체도 부담되고 연산도 추가적으로 수행해야하는 문제가 있다.
해결법