도메인 코드에 클린 아키텍쳐 적용하기

jonghyun.log·2023년 5월 23일
2

spring

목록 보기
5/7
post-custom-banner

이전 글에서 단일 모듈이었던 기존 프로젝트를 멀티 모듈 프로젝트로 바꾸었는데요.
이번 글부터 실제 코드를 하나씩 뜯어 고치는 시간을 가져보겠습니다.

Chooz 프로젝트는 유저들이 투표를 올리고 올린 투표에 대해 유저들이 투표를 하고
투표한 사람들의 정보로 통계를 내고 특정 주제에 대해 특정 mbti, 나이, 성별등 특정 범주에 속하는 사람들이 어떻게 투표했는지를 확인할 수 있는 커뮤니티 프로젝트 입니다.

이 프로젝트에서 제가 담당한 부분은 크게 유저관련 기능과 투표기능인데요.

제가 짠 코드를 하나씩 부검하고 뜯어고쳐보는 시간을 가져보겠습니다.

기존 코드 살펴보기

우선 기존 코드를 먼저 살펴보도록 하겠습니다.
기존에는 단일 모듈 코드였기에 user라는 패키지 안에 controller, service ,dto , domain , enums라는 패키지를 만들고 관련 코드를 각 패키지안에서 관리하는 구조를 가지고 있는데요.

이를 각 모듈에서 관리하기 위해 바로 이전글에서 단일 모듈이었던 프로젝트를 멀티 모듈로 바꾸는 과정을 진행했습니다. 하나씩 옮겨가면서 리팩토링을 진행해보도록 하겠습니다.

User 도메인 구현하기

이번 글에서는 User 기능의 비즈니스 로직과 관련 코드들을 도메인 모듈로 마이그레이션을 진행하려고 합니다.

우선 기존 서비스 코드부터 보도록 하겠습니다.

기존 코드 User 도메인 - UserService 코드

@RequiredArgsConstructor(access = AccessLevel.PUBLIC)
@Service
@Transactional
public class UserService {
    private final UserRepository userRepository;
    private final CategoryRespository categoryRespository;
    private final VoteRepository voteRepository;
    private final VoteResultRepository voteResultRepository;

    private final BookmarkRepository bookmarkRepository;


    public Long registerUser(SignUpRequest signUpRequestDto) throws Exception{
      ...
    }

    public void addUserInfo(AddInfoRequest addInfoRequest, Long userId) throws NotFoundException{
        ...
    }


    /**
     * 외부 api 호출해서 랜덤닉네임을 생성
     * @return GetUserNickNameRequest
     */
    public GetUserNickNameRequest getUserNickName() {
       ...
    }


    /**
     * 유저 정보 기입 이후에 호출되며 유저가 관심이 있는 카테고리를 추가 해주는 메서드
     */
    public void addInterestCategory(List<Category> lists, Long userId) throws NotFoundException{
        ...
    }
    
    
    public Map<String, Long> getMyPageCount(Long userId) {
		...
    }



    public User updateUser(Long userId, String nickname, String image, MBTI mbti, List<Category> categoryList) throws NotFoundException {
        ...
    }
    
    ... 나머지는 코드 생략
   }

유저 도메인에서 구현하고 있는 요구사항으로는

  1. 회원 가입
  2. 기존 회원에 새로운 정보 추가
  3. 유저 닉네임 자동으로 생성
  4. 유저의 관심 카테고리 추가
  5. 내가 쓴 투표 글 정보 조회
  6. 유저 정보 업데이트

가 있습니다.

기존 서비스 코드의 문제점

여러 비즈니스 로직을 하나의 서비스안에 다 때려박아서 사용하고 있습니다.
하나의 서비스가 너무 많은 책임을 맡고 있기 때문에 다음과 같은 문제점이 생깁니다.

  1. 서비스 객체가 슈퍼 객체가 되어 너무 많은 책임을 수행합니다.
  2. 웹 프레젠테이션 레이어의 많은 컨트롤러들이 하나의 서비스 객체를 의존하게 됩니다.
  3. 하나의 서비스가 너무 많은 의존성을 가지게 됩니다.

이 문제들로 인하여 유지보수가 어렵게 되고.. 테스트도 힘들게 되며.. 코드 수정할 부분을 찾기에 어려워지고..
코드 수정시 수정의 여파가 다른곳으로 무분별하게 퍼져나갈 가능성이 농후합니다.
더불어 개발자의 퇴근시간도 늦어지게 되지요 악!

서비스 비즈니스 로직 분리하기

제가 이번에 도메인을 정리하고자 하는 목표는 다음과 같습니다.

  1. 서비스의 비즈니스 로직을 도메인의 User 엔티티안에 캡슐화하기
  2. 거대한 서비스를 여러개의 서비스로 분할하기
  3. 도메인 <-> 레포지토리 의존성 역전하기

1. 서비스의 비즈니스 로직을 도메인의 User 엔티티안에 캡슐화하기

기존 서비스 코드안에는 여러 책임들이 혼재되어 있는데요.

  • 입력을 받는다.
  • 비즈니스 규칙을 검증한다.
  • 비즈니스 규칙을 수행한다.
  • 모델 상태를 조작한다.
  • 출력을 반환한다.

저는 이러한 책임중 비즈니스 규칙을 수행한다. 라는 책임이 서비스 코드를 더 복잡하게 만드는 원인이라고 생각했습니다.

보통의 서비스에서는 비즈니스 규칙을 수행하는 것이 가장 복잡한 경우가 대부분입니다.
다른 책임들 보다 무거운 비즈니스 규칙을 수행한다. 라는 책임을 따로 분리하고
서비스에서는 비교적 가벼운 나머지 책임을 수행하면 서비스 객체가 보다 가벼워질 것으로 생각했습니다.

하여, 이것을 위한 객체가 필요하다 생각해 도메인의 핵심이 되는 객체를 따로 만들고
그 객체 안에 비즈니스 로직과 관련 상태 들을 넣고 관리하겠습니다.

도메인에서 사용할 User 엔티티

package kr.co.chooz.user.domain;

import kr.co.chooz.user.dto.GeneralSignupInfo;
import lombok.Builder;

@Builder
public class User {

    private Long id;
    private String name;
    private String email;
    private String password;
    private String providerId;
    private ProviderType provider;
    private RoleType role;
    private Integer age;
    private GenderType gender;
    private MbtiType mbti;


    public User(String name, String email, String password, String providerId) {
        this.name = name;
        this.email = email;
        this.password = password;
        this.providerId = providerId;
    }

    public User(GeneralSignupInfo signupInfo) {
        this.name = signupInfo.getName();
        this.email = signupInfo.getEmail();
        this.password = signupInfo.getPassword();
    }

    public User(String providerId, ProviderType providerType) {
        this.providerId = providerId;
        this.provider = providerType;
    }
}

우선 도메인에서만 사용할 User 엔티티를 만들었습니다.
앞으로 도메인의 User 객체에 서비스 코드의 비즈니스 로직을 넣어서 관리할 예정입니다.
도메인에서 User 관련 정보를 사용할때는 도메인 User 객체를 통해 관리하게 됩니다.

참고) 도메인 엔티티와 JPA 엔티티의 구분

// 기존 User 엔티티 코드
public class User extends BaseTimeEntity {
    @Id
    @GeneratedValue
    @Column(name = "USER_ID")
    private Long id;

    @Column
    private String nickname;

    @Column
    private String email;

    private String imageUrl;

    private String password;

    @Enumerated(EnumType.STRING)
    private Providers provider;    // oauth2를 이용할 경우 어떤 플랫폼을 이용하는지

    private String providerId;  // oauth2를 이용할 경우 아이디값

    @Enumerated(EnumType.STRING)
    private Role role;

    private Integer age;

    @Enumerated(EnumType.STRING)
    private Gender gender;

    @Enumerated(EnumType.STRING)
    private MBTI mbti;

    @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST, orphanRemoval = true)
    @JoinColumn(name = "USER_ID")
    private List<CategoryEntity> categoryLists = new ArrayList<>();

    @Column(name = "modified_MBTI_Date")
    private LocalDateTime modifiedMBTIDate;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "user", cascade = CascadeType.REMOVE)
    private List<Bookmark> bookmarkList = new ArrayList<>();

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "user", cascade = CascadeType.REMOVE)
    private List<CommentEmotion> commentEmotionList = new ArrayList<>();
    
    ... 나머지 코드 생략
 }

기존 코드에서는 JPA에서 사용하는 엔티티 객체와 도메인에서 사용하는 엔티티 객체를 하나였습니다.
그 때문에 User 비즈니스 로직에는 필요없는 JPA의 연관관계 객체까지 User에 들어가 있어 User 객체가 매우 무거운 객체가 되어 있었지요.

크게는 아래의 두가지 점이 발목을 잡았습니다.

  • User 객체를 변환해서 사용할 때도 연관관계에 들어있는 객체들도 같이 변환해주는 작업이 필요했습니다.
  • 사용하는 User 객체가 User 도메인만으로 캡슐화가 되어있지 못한 상황이기에 비즈니스 로직을 User 객체에 담아서 관리하지 못하였습니다.

하지만, 이전 글에서 도메인을 가지고 있는 core 모듈과 db-storage 모듈을 분리함으로써 각 계층에서만 사용하도록 엔티티를 분리해서 사용했기에 User 객체에 비즈니스 로직을 넣어놓고 관리하기에 용이한 상황이 되었습니다.

이렇게 함으로써 애플리케이션의 비즈니스 로직을 담당하는 core 모듈은 외부의 JPA와 같은 상황에 오염되지 않는 코드를 만들 수 있습니다.

2. 거대한 서비스를 여러개의 서비스로 나누자

기존 User 서비스에는 모든 유저 관련한 서비스 요구사항이 모여 있었습니다.

  1. 회원 가입
  2. 기존 회원에 새로운 정보 추가
  3. 유저 닉네임 자동으로 생성
  4. 유저의 관심 카테고리 추가
  5. 내가 쓴 투표 글 정보 조회
  6. 유저 정보 업데이트

여기서 관련한 기능별로 묶어서

SignUpServiceManageServiceMyPageService
회원가입기존 회원에 새로운 정보 추가내가 쓴 투표 글 정보 조회
X유저 정보 업데이트X
X유저 닉네임 자동으로 생성X

회원가입은 소셜로그인, 로컬로그인 두 가지로 나뉘어서 진행되고 자체적으로 커다란 기능인 것 같아서 따로 분리를 하고
기존 회원에 새로운 정보 추가, 유저 정보 업데이트 ,유저 닉네임 자동으로 생성 을 묶어서 유저 정보를 관리하는 기능을 담당하는 서비스로 넣었습니다.
마지막으로, 마이페이지에 들어가는 기능을 따로 정리했습니다.

분리된 서비스 코드

@Service
@RequiredArgsConstructor
public class SignupService {
	
    private final UserRepository userRepository;
	
    
    public Long registerUser(SignUpRequest signUpRequestDto) throws Exception{
      ...
    }
}
@RequiredArgsConstructor(access = AccessLevel.PUBLIC)
@Service
public class ManageService {

    private final UserRepository userRepository;
    
    
    public void addUserInfo(AddInfoRequest addInfoRequest, Long userId) throws NotFoundException{
		...
    }

    /**
     * 외부 api 호출해서 랜덤닉네임을 생성
     * @return GetUserNickNameRequest
     */
    public GetUserNickNameRequest getUserNickName() {
		...
    }


    /**
     * 유저 정보 기입 이후에 호출되며 유저가 관심이 있는 카테고리를 추가 해주는 메서드
     */
    public void addInterestCategory(List<Category> lists, Long userId) throws NotFoundException{
    	...
    }
}
@RequiredArgsConstructor(access = AccessLevel.PUBLIC)
@Service
public class MyPageService {

    private final UserRepository userRepository;
    private final VoteRepository voteRepository;
    private final VoteResultRepository voteResultRepository;
    private final BookmarkRepository bookmarkRepository;

    public Map<String, Long> getMyPageCount(Long userId) {
		...
    }
}

기존 서비스 코드 대비 장점

1. 코드 분석의 용이함

하나의 거대한 서비스를 분리함으로써 보다 유지보수하기에 좋은 코드가 되었습니다.
회원가입 메서드를 찾으려면 이제는 SignUpService를 찾으면 되고 서비스 코드의 긴 스크롤을 내려서 찾을 필요가 없습니다.
그냥, 적당한 길이의 클래스 코드 안에서 찾고자 하는 메서드를 찾으면 됩니다.

2. 의존성 분할

기존 서비스 코드 의존성 구조

기존 서비스 코드는 하나의 서비스안에 너무 많은 의존성을 의존하고 있었습니다.
따라서 관련 의존성 코드가 변경됨에 따라 필연적으로 서비스에 너무 많은 변경이 생기는 코드가 됩니다.

분할된 서비스 코드 의존성 구조

각 서비스 별로 사용하는 의존관계를 나눠서 사용함으로써 의존 코드의 변경에 따른 코드의 변경을 비교적 줄여볼 수 있습니다.

3. 서비스, 레포지토리 간의 의존성을 역전시키기 - CORE(도메인) 모듈 격리

방금 전에 서비스를 나눔에 따라 의존성 그에 맞게 의존성도 사용하는 곳에 맞게 나누는 작업을 진행했습니다.
각 코드의 의존성이 줄어들고 의존성 코드의 변화에 따른 변화의 가능성도 줄어든 것도 사실입니다.
하지만, 여전히 레포지토리 코드가 변경되면 도메인 코드에도 변경 사항이 생긴다는 사실은 변하지 않았습니다.

해서 이번에는 서비스와 레포지토리의 의존성을 역전시키고 더 나아가 CORE 모듈이 외부의 코드를 격리시키는 작업을 하도록 하겠습니다.

계층간 의존성 역전시키는 방법

바로 이전 포스팅(모듈간 의존성 역전시키는 방법)에서 관련 주제를 다뤄봤으니
이번에는 이전 포스팅에서 다루지 못한
실제 JPA를 사용하는 상황에서 모듈간 의존성 역전시키는 방법에 대해 알아보겠습니다.

이전 글에서는

1.의존하는 클래스를 인터페이스로 바꾼다.
2.인터페이스를 구현하는 구현체를 만든다.
3.의존하는 인터페이스와 그 구현체의 위치를 적절한 곳에 위치시킨다.

위 세가지 방식으로 서로 다른 계층의 의존성을 역전시키는 방법을 다뤘는데요.
실제 JPA를 사용하는 상황에서는 위의 상황을 적용하기 애매한 점이 존재합니다.

바로 보통 JPA를 사용할 때 레포지토리를 JpaRepository를 구현하는 인터페이스로 만들고 그 구현체를 스프링 컨테이너로 부터 DI 받아서 사용하게 되는데요.

이 점 때문에 위의 의존성 역전의 3가지 방식을 따르기 애매한 상황이 일어납니다.

public class UserService {
    private final UserRepository userRepository;
    ...
}
public interface UserRepository extends JpaRepository<User, Long> {
	...
}

엥 이미 인터페이스로 의존하고 있다고 이보시오!

그렇다면 어떻게 해야 하는걸까요?

도메인 모듈과 JPA 모듈의 의존성 역전

모듈간의 의존성을 역전시키는 방법은 위에서 서술한 3가지의 방법을 이용합니다.
하지만 Spring Data JPA 사용의 특수성과 모듈의 캡슐화를 단단하게 하기 위해서는 새로운 객체와 인터페이스가 협력에 참여해야합니다.

바로, 서비스레포지토리 사이에 각 모듈의 경계에 해당하는 클래스와 인터페이스를 따로 두어서 각 모듈의 경계에서 의존성 역전을 진행하면 되는 것이죠.

백문이 불여일타! 이니 실제 예시로 보겠습니다.

여기서는 새로운 개념이 두가지 등장합니다. 바로 portadpater 입니다.

기존의 코드에서는 서비스와 레포지토리가 서로 직접 맞닿아있어 모듈간의 느슨한 결합이 어려웠습니다.

이점에 착안하여 서로의 모듈의 경계 지점에서 상호작용하는 책임을 각각 portadpater로 위임하게 됩니다. 여기서 모듈이 서로 상호작용하게 되는것이죠.

코드로 확인하기

UserService

// 도메인 모듈 내부 서비스
@RequiredArgsConstructor(access = AccessLevel.PUBLIC)
@Service
public class UserService{

    private final UserPersistencePort userPersistencePort; // 원래 UserRepository로 의존하던 것을 port 인터페이스로 변경!
	
    public void addUserInfo(AddInfoRequest addInfoRequest, Long userId) throws NotFoundException{
    	userPersistencePort.existsById(userId);
        ...
    }
}

UserPersistencePort

// 도메인 모듈 내부의 port 인터페이스
public interface UserPersistencePort {

    public boolean isUserExist(Long userId);
}

도메인 내부에서는 기존 서비스에서 UserReopsitory에서 의존하던 부분을 포트 인터페이스로 변경해주었습니다.
포트 인터페이스가 말 그대로 port 가 되어 도메인 모듈과 외부의 모듈사이의 연결점이 됩니다.

UserPersistenceAdapter

//JPA 모듈 내부의 어뎁터 클래스 - 포트 인터페이스를 상속

@RequiredArgsConstructor
public class UserPersistenceAdapter implements UserPersistencePort {

    private final UserRepository userRepository;


    @Override
    public boolean isUserExist(Long userId) {
        return userRepository.existsById(userId);
    }
}
//JPA 모듈 내부의 레포지토리 인터페이스
public interface UserRepository extends JpaRepository<UserJpaEntity, Long> {
    boolean existsByProviderId(String providerId);
}

JPA 모듈 내부의 어뎁터 클래스가 도메인 내부의 포트 인터페이스를 상속하고 있습니다.

이렇게 만들면 두가지 현상이 일어나게 되는데요

  1. 도메인 모듈의 경계port interfacestorage:db-jpa 모듈의 경계인 adpter class 사이에서 의존성 역전이 일어나게 됩니다.

  2. 도메인 모듈JPA 모듈은 서로간에 캡슐화가 일어나게 되어 port의 명세를 변경하지 않는 이상 JPA 모듈 내부의 레포지토리를 아무리 변경하던 서비스 코드에는 변경이 일어나지 않습니다.

이렇게 함으로써 도메인 모듈에서는 외부의 모듈의 의존성을 모르더라도 개발이 가능해지는 마법이 일어나게 되는거죠.

즉, 의존성 역전의 3가지 스텝

의존하는 클래스를 인터페이스로 바꾼다.
인터페이스를 구현하는 구현체를 만든다.
의존하는 인터페이스와 그 구현체의 위치를 적절한 곳에 위치시킨다.

은 그대로 유지하되, 의존성 역전이 일어나는 곳을 다른곳에 위임함으로써 느슨한 결합이 가능하게 됩니다.

배운점

  • 도메인 엔티티 객체로 비즈니스 로직을 캡슐화하자 -> 특정 책임을 최적화 객체에 위임하자
  • 거대한 서비스 클래스는 나누자 -> 거대한 책임을 분할하자
  • 각 모듈의 경계는 port 인터페이스와 adpter 클래스를 활용하자

이렇게 3가지에 대해 새롭게 알게되었습니다.

유지 보수가 용이한 코드를 위해서는 각 객체의 책임을 세세하게 나누어서 특정 책임에 최적화된 객체에게 할당해주는게 중요한 것 같습니다.

post-custom-banner

0개의 댓글