헥사고날 아키텍처(Hexagonal Architecture) & 멀티모듈(Multi Module) 프로젝트에서 도메인 주도설계 구현 및 경험에 대한 고찰

msung99·2023년 8월 1일
6
post-thumbnail

현재 포스트는 예쁜 블로그 테마 Haon Blog 로 이전되었습니다. 지금보다 훨씬 예쁜 뷰에서 가독성 있는 글을 읽을 수 있습니다 🙂


프로젝트 개발 환경 및 동기

현재 TiTi 프로젝트의 백엔드 프로젝트는 헥사고날 아키텍처(Hexagonal Architecture)멀티모듈(Multi Module) 프로젝트에 기반하여 도메인 서비스가 개발되고 있습니다. 7월말까지 제가 초기 도메인 주도 환경을 구축을 시도한 경험에 대하여 고민한 흔적과 코드 설계 및 작성을 회고하고자 이렇게 포스팅을 작성합니다.

유의할점은, 현재 작성한 아키텍처 및 멀티모듈 구조는 추후 개정될 프로젝트 구조와 많이 달라질 가능성이 큽니다. 페어분에게 코드 리뷰를 추후에 더 자세히 받기로 했는데, 실제 유니콘 스타트업에서 본인이 배운것과 구조 및 환경이 많이 다르다고 합니다.

또한 현 포스팅에서는 분량상 모든 코드와 설계방식에 대해 자세한 내용을 다루지 못합니다. 때문에 애플리케이션 계층의 도메인 서비스를 중점으로 어떻게 프로젝트를 구성했는지를 핵심으로 다루고자 합니다.


데이터베이스 중심 설계에서 벗어나기 위한 노력

[Clean Architecture] 클린 아키텍처에서는 전통적 계층구조의 의존성 문제를 어떻게 해결했을까? 에서도 다루었듯이, 계층간에 매우 강한 결합도와 의존성이 생겨버리면 스파케티 코드레거시 코드 는 등장하는것이 뻔했습니다. 조금만 비즈니스 정책 및 데이터 처리 코드가 추가되더라도 영속성 계층 이 꽤 비대해지는 현상이 나타나버리며, 이를 데이터베이스 주도 설계라고 했습니다.

즉, 정작 매우 중요한 도메인 서비스 및 유즈케이스 가 계속 변경되고 훼손되버리므로 하나의 로직 및 정책이 추가되더라도 그 파급력은 엄청 심해지는 것입니다.

일단 데이터베이스에 의존해서라도 서비스를 개발해보자 😓

하지만 지금껏 항상 데이터베이스를 가장 먼저 설계한 후, 개발을 진행하는 방식이 너무나도 익숙했습니다. 아키텍처 설계는 저에게나 너무나도 어렵고, 장벽이 높은 환경이였죠. 많은 고민을 하다가, 우선 제가 맡은 도메인 서비스를 모놀리틱(Monolithic) 으로 먼저 개발해본 후에 아키텍처로 변환하는 작업으로 최선을 다해봤습니다.

그렇게 시작된 모놀리틱(Monolithic) 도메인 개발

아래와 같이 초기 도메인 개발이 시작되었습니다. 여러 도메인중에 가장 중요한 도메인에 대한 코드인데, 요청으로 전달받은 모든 데이터 리스트에 대해 업로드를 수행하는 비즈니스 로직입니다. 이 안에는 보시듯이 다양한 타입의 데이터를 여러 메소드에 걸쳐서 업로드를 수행하면서, 각 메소드에는 저희가 정한 비즈니스 정책들이 흐르고 있습니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class SyncService {
    private final SyncDao syncDao;
    private final DailyTaskRepository dailyTaskRepository;
    private final TaskHistoryRepository taskHistoryRepository;
    private final DailyRepository dailyRepository;
    private final TaskRepository taskRepository;
    private final UserRepository userRepository;
    private final TimeLineRepository timeLineRepository;

    @Transactional(timeout = 10)
    public void syncDailys(List<DailyUploadRequest> requests, Long user_id){
        User user = userRepository.findById(user_id).orElseThrow(
                () -> new BaseExceptionResponse(ErrorBaseResponseCode.USER_NOT_EXIST));

        for(DailyUploadRequest request : requests){
            if(request.getStatus() != null)
                if(request.getStatus().equals("uploaded"))
                    continue;
            Daily daily = uploadDailys(request, user); // 충돌정책에 따라 새로운 Daily 또는 기존 DB 의 Daily 리턴
            uploadTasksAndDailyTasks(request, user, daily);
            uploadTimeLines(request, user, daily);
            uploadTaskHistorys(request, user, daily);
        }
    }
    
   // ... (이하 코드 생략)

멀티모듈(Multi Module) 구조

멀티모듈 프로젝트 단위로 분할된 아키텍처 계층

이제 본격적인 개발이 시작되었습니다. 멀티 모듈(Multi Module) 이란 무엇이고, 왜 써야할까? 에서도 다룬바가 있는데, 멀티모듈은 여러 프로젝트 각각을 하나의 모듈(서브 프로젝트) 로 모듈화 시키는 방식입니다. 저희 프로젝트의 초기 멀티모듈 프로젝트 셋팅은 어댑터, API, 애플리케이션 , 데이터 , 도메인 계층으로 분할했습니다. 헥사고날 아키텍처의 도매인 설계 구조를 고려하여, 초기에 팀원이 이렇게 설계한것으로 판단했습니다.


애플리케이션 레이어 개발

언제나, 항상 헥사고날 아키텍처에서는 애플리케이션 계층에 대한 모든것이 훼손되지 않고 비즈니스 로직이 유지되도록 설계되어야 합니다. 정확히 어떻게 설계되어야한다는 정의는 없겠지만, 적어도 이 아키텍처 환경에서는 유즈케이스, 도메인 엔티티를 비롯한 비즈니스 정책 및 로직이 보존되어야하는 것은 매우 중요한 설계 방식이라고 이해했습니다.

유즈케이스(UseCase) 정의

[Hexagonal Architecture] 헥사고날 아키텍처로 어떻게 유지.보수 가능한 소프트웨어를 개발할까? 에서부터 학습을 시작한 것을 기반으로, 각 멀티모듈별 프로젝트 구성을 파악한 후 가장먼저 애플리케이션 프로젝트유즈케이스 를 구현했습니다.

public interface SyncUploadAllUseCase {
    void syncDailys(List<SyncUploadCommand> dailyUploadRequest);
}

위는 앞서 모놀리틱 개발에서 소개드렸던 업로드에 대한 유즈케이스입니다. 이떄 SyncUploadCommand인커밍 어댑터(컨트롤러) 로 부터 전달받는 입력모델(Input Model) 입니다.

유즈케이스(UseCase) : 도메인 서비스 구현

이어서 위 유즈케이스에 대한 구현으로, 인터페이스에 대한 syncDailys 메소드를 오버라이드 했습니다. 또한 영속성 어댑터와 연결될 아웃고잉 포트(Outgoing Port) 로 LoadDailyPort 포트를 선언한 것을 확인할 수 있습니다.

사실 초기 설계시에는 이 하나의 도메인 서비스 안에서 Daily, Task, DailyTask, TaskHistory, TimeLine 등 다양한 데이터에 대한 업로드 로직을 모두 처리했었습니다.

그러나 가독성도 떨어지며 각 도메인 엔티티별 업로드에 대한 비즈니스 로직 및 정책이 세분화되지 못하고 혼동된다고 판단되어서, 최대한 잘게잘게 분리해줘서 아래처럼 유즈케이스를 최대한 세분화하고, 각 유즈케이스별로 도메인 서비스를 구현하도록 했습니다. 그러고 해당 도메인 서비스들을 아래처럼 final 필드로 활용하게 되었습니다.

@Service
@RequiredArgsConstructor
public class SyncUploadAllService implements SyncUploadAllUseCase {
    private final UploadDailyService syncDailyService;
    private final UploadDailyTaskService syncDailyTaskService;
    private final UploadTimeLineService syncTimeLineService;
    private final LoadDailyPort loadDailyPort;

    @Override
    @Transactional(timeout = 10)
    public void syncDailys(List<SyncUploadCommand> dailyUploadRequest) {
        User user = loadDailyPort.loadUserByUserId(dailyUploadRequest.get(0).getUser_id());

        for(SyncUploadCommand request : dailyUploadRequest){
            if(request.getStatus() != null)
                if(request.getStatus().equals("uploaded"))
                    continue;

            Daily daily = syncDailyService.uploadDailys(request, user);
            syncDailyTaskService.uploadTasksAndDailyTasksWithHistory(request, user, daily);
            syncTimeLineService.uploadTimeLines(request, daily);
        }
    }
}

어댑터(Adapter) 레이어 개발

다음으론 영속성 어댑터에 대한 개발 내용입니다. 애플리케이션의 각 도메인 서비스에서 final 필드로 선언하고 있는 아웃고잉 포트들을 각 어댑터들이 구현하고 있게됩니다. 어댑터를 어떤 방식으로 구현하고 설계할지는 정말 다양한 방법이 있겠지만, 저의 경우는 각 도메인 엔티티별로 영속성 어댑터를 구현해줬습니다.

DailyPersistenceAdapter

여러 어댑터중에서 일부 영속성 어댑터에 대한 구현 내용을 살펴보면, 아래와 같이 애플리케이 계층에 존재했던 다양한 아웃고잉 포트에 대해서 구현을 진행했습니다.

@Component
@RequiredArgsConstructor
public class DailyPersistenceAdapter implements DeleteDailyPort, LoadDailyPort, SaveDailyPort, UpdateDailyPort {
    private final DailyRepository dailyRepository;
    private final DailyConverter dailyConverter;
    private final UserConverter userConverter;
    private final UserRepository userRepository;

    @Override
    public User loadUserByUserId(Long user_id) {
        UserEntity userEntity = userRepository.findById(user_id).orElseThrow(() -> new RuntimeException());
        return userConverter.convertToDomain(userEntity);
    }

    @Override
    public Daily save(Daily dailyReq, User userReq) {
        UserEntity user = userConverter.convertToUserEntity(userReq);
        DailyEntity daily = DailyEntity.builder().maxTime(dailyReq.getMaxTime()).day(dailyReq.getDay()).user(user).build();
        DailyEntity newDaily = dailyRepository.save(daily);
        return dailyConverter.convertToDomain(newDaily);
    }
    // ... (이하 코드 생략)
}

Data, Common 레이어

다음으로 Data, Common 계층에 대한 구현은 간단히 설명만 짚고 넘어가겠습니다. 우선 Data 레이어의 경우는 실제 DB 과 소통할 Spring Data JPA 의 Repository 및 @Entity 기반의 데이터베이스 엔티티를 정의했습니다.

또한 Common 계층에는 모든 멀티모듈 프로젝트(계층) 에서 공유할 사항들로 구성했습니다. 예를들어 @RestControllerAdivce 를 통한 전역 예외처리등은 모두 이 프로젝트에 구성해줬습니다.


위 아키텍처 설계가 유지.보수에 도움이 된걸까? 🤔

이렇게까지 코드 구현 및 과정들에 대해서 일렬적으로 나열을 진행해봤었는데, 이 방식이 과연 대규모 설계 및 장기 프로젝트에서 정말 도움이 되는 레이어드 아키텍처 설계 방식인 것인지에 대해서 다시 생각해봤습니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class SyncService {
    private final SyncDao syncDao;
    private final DailyTaskRepository dailyTaskRepository;
    private final TaskHistoryRepository taskHistoryRepository;
    private final DailyRepository dailyRepository;
    private final TaskRepository taskRepository;
    private final UserRepository userRepository;
    private final TimeLineRepository timeLineRepository;

    @Transactional(timeout = 10)
    public void syncDailys(List<DailyUploadRequest> requests, Long user_id){
        User user = userRepository.findById(user_id).orElseThrow(
                () -> new BaseExceptionResponse(ErrorBaseResponseCode.USER_NOT_EXIST));

        for(DailyUploadRequest request : requests){
            if(request.getStatus() != null)
                if(request.getStatus().equals("uploaded"))
                    continue;
            Daily daily = uploadDailys(request, user); // 충돌정책에 따라 새로운 Daily 또는 기존 DB 의 Daily 리턴
            uploadTasksAndDailyTasks(request, user, daily);
            uploadTimeLines(request, user, daily);
            uploadTaskHistorys(request, user, daily);
        }
    }
    
   // ... (이하 코드 생략)

위 코드는 앞서 살펴봤던 모놀리틱 방식의 서비스 로직입니다. 겉보기에는 아무문제없이 설계가 잘 된것으로 보입니다.

private Daily uploadDailys(DailyUploadRequest uploadRequest, User user) {
        Integer maxTime = uploadRequest.getMaxTime();
        LocalDateTime day = uploadRequest.getDay();

        if(uploadRequest.getId() == null) { // 최초 동기화 시도 Daily 인 경우
            if(!needToChangeDuplicateDays(uploadRequest, user)){ // 충돌이 발생 안한경우
                Daily daily = Daily.builder().maxTime(maxTime).day(day).user(user).build();
                return dailyRepository.save(daily); // 새롭게 Daily 를 CREATE
            } else { // 충돌이 발생해서, 새로운 Daily 로 덮어씌워야 하는 경우
                // 충돌이 발생한 기존 DB 의 Daily 데이터 삭제
                Daily daily = dailyRepository.findById(Long.valueOf(uploadRequest.getId())).get();
                List<DailyTask> dailyTasks = dailyTaskRepository.findDailyTaskByDaily(daily);
                dailyTaskRepository.deleteAllInBatch(dailyTasks); // DailyTask 삭제
                taskHistoryRepository.deleteTaskHistoriesByDaily(daily);  // TaskHistory 삭제
                timeLineRepository.deleteTimeLineByDaily(daily); // timeLine 삭제
                dailyRepository.delete(daily); // daily 삭제

                // 새로운 Daily 를 생성하여 기존의 Daily 를 덮어씌우기
                Daily newDaily = Daily.builder().maxTime(maxTime).day(day).user(user).build();
                return dailyRepository.save(newDaily);
            }
        } else { // 동기화 이력이 있는경우
            Daily daily = dailyRepository.findById(Long.valueOf(uploadRequest.getId())).orElseThrow( // UPDATE 내용 작성
                    () -> new BaseExceptionResponse(ErrorBaseResponseCode.DAILY_UPLOAD_FAILURE));

            List<DailyTask> dailyTasks = dailyTaskRepository.findDailyTaskByDaily(daily);
            dailyTaskRepository.deleteAllInBatch(dailyTasks); // DailyTask 삭제
            taskHistoryRepository.deleteTaskHistoriesByDaily(daily); // TaskHistory 삭제
            // timeLine 은 업데이트로 최신화

            daily.updateDay(day);
            daily.updateMaxTIme(maxTime);
            return daily;
        }
    }

하지만, 과장된 상황을 가정하여 현 프로젝트에 데이터베이스 설계나 스택에 변동이 생긴 상황을 가정해봅시다.

예를들어 IDENTITY 전략을 사용하다가 Table 전략을 사용한다거나, Spring Data JPA 만의 스택에 의존하다가 JdbcTemplate, MyBatis 등 다양한 스택을 섞어쓰거나, 또는 아예 전환해야하는 상황을 가정해보죠. 그렇다면 위 코드들은 대용량의 수정작업이 일어나야하며, 테스트 또한 장기간에 걸쳐서 시도해야할것입니다. 결국 도메인 계층이 보존되지 못함으로인해 서비스의 코드들이 스파게티 코드가 된 현상입니다.

이 상황이 바로 데이터베이스 주도 설계 의 문제점이자, 한계입니다. 만약 위 코드를 레이어드별로 세분화하고, 애플리케이션 계층에는 비즈니스 정책만을 포함하고 있다면 이들은 수정될일이 없죠. 또한 이들이 활용하고 있는 인커밍/아웃고잉 포트에 대한 구현인 어댑터들만 수정작업이 일어나면 될 것입니다.


마치며

아키텍처 설계가 아직 정말 낯설기만하고, 많이 어려운 것 같네요 😓 하지만 좋은 설계에 가까워지기위해 최대한 노력해봤으며, 어떻게 되었던간에 레거시코드를 지양하는 새로운 관점들로 깊게 고민해봤던 흔적들을 다시 한번 회고해봤습니다.

현재 계속 이어질 티티 프로젝트에서도 아직 배워나가야 할 부분들이 정말 많다고 느껴지네요! 부족한만큼 더욱이 열심히 공부해나가야 할 것 같습니다 😎

profile
블로그 이전했습니다 🙂 : https://haon.blog

1개의 댓글

comment-user-thumbnail
2023년 8월 1일

정리가 잘 된 글이네요. 도움이 됐습니다.

답글 달기