비지니스 로직을 Service에서 Domain Model로 옮기기(2)

taehee kim·2023년 3월 28일
0

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()));
        // request의 nickname이 member에 없는 경우
        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());
        //리뷰 작성자 매칭 참여 여부 true로 변경
        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;
    }
    /**
     * 자기가 참여한 매치인지 확인
     * @param member
     */
    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();
    }

    /**
     * 리뷰 대상자가 자기 자신이 아니고 매칭에 포함되어있는지 확인
     * @param reviewer
     * @param reviewedMemberSet
     */
    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에서 필터링을 해야한다.
  • 메모리에 로딩되는 객체도 부담되고 연산도 추가적으로 수행해야하는 문제가 있다.

해결법

  • Select 쿼리를 작성한다.
profile
Fail Fast

0개의 댓글