본문 바로가기
Programming/Study

DB Entity -> Domain Entity로의 전환은 어느 계층에서 담당하는 것이 옳을까

by JKROH 2024. 2. 14.
반응형

 헥사고날 아키텍처를 괜히 적용했나 싶다. 고민해야 할 영역이 지나치게 많아진 느낌이다. 지금 내 수준에서 이런 문제들에 대한 최선의 답안을 찾아내는 것이 가능할까? 싶은데, 뭐 그렇다고 고민하는 시간이 의미없는 것은 아니니까 일단은 생각해볼만 하다고 여겨지는 영역에 대해서는 최선의 답을 찾아내고자 한다.

 

 기존의 ORM 전략을 사용한 프로젝트에서는 데이터베이스 계층에서 사용되는 엔티티가 곧 도메인 엔티티의 역할을 함께 했다. 이번 프로젝트에서는 ORM을 사용하지 않고 데이터베이스 엔티티와 도메인 엔티티를 분리했다. 외부 데이터베이스와 내부 도메인 로직은 확실히 분리하고자 하였고, 임피던스 불일치 문제 같은 데이터베이스 레벨의 문제가 도메인 로직에 스며드는 것을 방지하고자했다.

 

 그러나 결국 서버는 데이터베이스에 저장된 데이터를 사용해야한다. 따라서 데이터베이스에 저장된 애트리뷰트, 즉 데이터베이스 엔티티를 가져온 다음, 도메인 로직을 수행하기 위한 도메인 엔티티로 변환하는 과정이 반드시 필요하다는 것이다. 기존에는 해당 과정을 애플리케이션 계층에서 담당했다.

@Override
@Transactional
public SingleFreeBoardCommandResponse getFreeBoard(GetFreeBoardCommand getFreeBoardCommand) {
    FreeBoardEntityQueryResult selectQueryResult = loadFreeBoardPort.loadFreeBoardById(getFreeBoardCommand.getFreeBoardId());
    Member writer = loadMemberPort.findMemberById(selectQueryResult.getWriterId());
    FreeBoard freeBoard = FreeBoard.builder()
            .id(selectQueryResult.getId())
            .writer(writer)
            .title(selectQueryResult.getTitle())
            .content(selectQueryResult.getContent())
            .viewCount(selectQueryResult.getViewCount())
            .createdAt(selectQueryResult.getCreatedAt())
            .modifiedAt(selectQueryResult.getModifiedAt()).build();
    freeBoard.viewed();

    SaveFreeBoardQuery saveFreeBoardQuery = SaveFreeBoardQuery.of(
            writer.getMemberId(),
            writer.getNickName(),
            freeBoard.getId(),
            freeBoard.getTitle(),
            freeBoard.getContent(),
            freeBoard.getViewCount(),
            freeBoard.getCreatedAt(),
            freeBoard.getModifiedAt());

    saveFreeBoardPort.save(saveFreeBoardQuery);

    return SingleFreeBoardCommandResponse.of(freeBoard.getId(),
            freeBoard.getTitle(),
            freeBoard.getContent(),
            freeBoard.getViewCount(),
            freeBoard.getCreatedAt(),
            freeBoard.getModifiedAt(),
            WriterInfo.of(writer.getMemberId(), writer.getNickName()));
}

 

 위에서 예시로 가져온 코드는 자유 게시글을 하나 읽어오는 코드인데, Output Port를 통해 읽어온 정보에는 FreeBoard 객체 인스턴스를 생성하기 위해 필요한 정보들이 담겨있고, 애플리케이션 계층인 Service에서 해당 정보들을 통해 FreeBoard 객체를 생성한다. 이렇게 로직을 작성한 이유는 아래와 같다.

  • 기본적인 의존 방향은 Domain <- Application <- Adpater의 방향으로 흐른다. 덜 중요한 계층에서 더 중요한 계층으로 의존한다.
  • Domain 계층에 의존하는 계층은 Application 계층으로 한정한다. Domain 계층에서 변경이 발생할 때, 변경이 전파되는 영역이 Application계층으로 한정되게 한다.

 위와 같은 생각을 기반으로, Domain엔티티를 만드는 역할을 Application 계층에서 담당하고 있었다. 이렇게 기반을 잡고 프로그램을 구현하고 있었는데, 좀 지나치게 코드 중복이 많이 발생하는 것 같았다. 예를 들어, 자유게시글을 읽던, 수정하던 자유 게시글을 읽어오는 과정은 반드시 필요하다. 그럼 QueryResult를 바탕으로 FreeBoard를 만드는 코드가 중복해서 발생한다. Builder를 이용해서 객체를 만드는 코드는 따지자면 한 줄 짜리 코드긴 하지만, 가독성을 위해 인자별로 줄나눔을 하다보면 결국 눈에 들어오는 코드는 굉장히 길게 느껴진다. 고민은 여기서 시작된다. '그냥 Adapter에서 Domain 엔티티를 만들어서 반환하면 안되나?'

 

 Domain 엔티티를 Adapter에서 반환하면 코드 자체가 많이 줄어든다. 여러 Service 클래스에 나뉘어져있던 Builder코드가 Mapper에 한정된다. 이제 내가 나름의 기준으로 잡은 이유들을 뒤집어 볼 차례다.

 

 먼저 첫 번째 기준인 의존 방향의 문제다. 의존 방향을 위와 같이 잡은 이유는 상술한 바와 같이 덜 중요한 계층이 더 중요한 계층에 의존하게 만들고 싶었기 때문이다. 더 중요한 계층이 덜 중요한 계층에 의존하면, 의존 전파가 기이한 방향으로 일어난다. 덜 중요한 녀석을 수정했는데, 더 중요한 녀석이 거기에 따라 움직여야한다. 말 그대로 배보다 배꼽이 더 큰 구조가 되는 것이다. Adapter는 분명 Application보다 덜 중요하다. Application 계층은 굳이 구분해놨을 뿐이지 사실 Domain계층과 별반 차이가 없다. 그래서 Adapter는 Application에 의존한다.

 

 그런데, Domain보다 덜 중요한 것은 Application이나 Adapter나 마찬가지다. 그러니까, Adapter -> Domain으로 직접 의존해도 '대원칙인 덜 중요한 계층이 더 중요한 계층에 의존한다를 어기지는 않는다'는 것이다. 또한, 어쩔 수 없이 변환을 담당하는 객체는 데이터베이스 엔티티와 도메인 엔티티를 모두 알 수밖에 없다. 이러한 것을 최소화하기 위해 Application -> Adapter로 넘어가는 DTO에 인스턴스를 생성하는데 필요한 데이터들을 담아 넘기는 형태를 만들었지만, 그 과정에서 완성된 코드가 오히려 퀄리티가 더 떨어진다면 그냥 둘 다 알게 하는 대신 해당 객체에서는 도메인 기능을 사용하지 못하게 제약을 걸어두는 것이 더 낫지 않을까.

 

 두 번째 기준인 변경 전파는 오히려 쉽게 뒤집을 수 있었다. 예를 들어, 게시글을 추천 / 비추천 하는 기능이 추가된다고 해보자. 당연히 도메인 엔티티에도 Like / Unlike 같은 VO가 추가될 것이고, 이들을 저장해야하니 테이블에도 수정이 생긴다. DTO로 아무리 정보를 다르게 넘겨준다 한들 수정사항이 발생할 수밖에 없는 것이다.

 

 이렇게 기준을 뒤집고 코드를 수정하면 위에서 봤던 게시글 조회 코드는 아래와 같이 수정될 수 있다.

    @Override
    @Transactional
    public SingleFreeBoardCommandResponse getFreeBoard(GetFreeBoardCommand command) {
        FreeBoard freeBoard = loadFreeBoardPort.loadFreeBoardById(command.getFreeBoardId());
        freeBoard.viewed();
        saveFreeBoardPort.save(freeBoard);

        return SingleFreeBoardCommandResponse.of(freeBoard.getId(),
                freeBoard.getTitle(),
                freeBoard.getContent(),
                freeBoard.getViewCount(),
                freeBoard.getCreatedAt(),
                freeBoard.getModifiedAt(),
                WriterInfo.of(freeBoard.getWriterId(), freeBoard.getWriterNickName()));
    }

 

 길고 길던 변환 로직이 생략되고, 대신 도메인 객체를 읽어와서 조회된다는 로직만 남았다. 이렇게 코드를 작성하는 것이 각 객체에 맞게 역할을 분배하는 형태라고도 생각된다. 다른 기능들에 해당 리팩토링을 진행한 내용은 프로젝트 카테고리에서 다뤄보자.

반응형

댓글