본문 바로가기
Programming/Study

계층별로 분리해서 DTO를 사용하기로 한 이유와 결과

by JKROH 2023. 12. 13.
반응형

  우리는 DTO를 통해 각 계층별 데이터의 전송을 수행한다. 클라이언트에서 서버로 보내는 정보를 JSON 데이터 타입으로 활용하기 위해 DTO를 사용하기도 하고, API 계층에서 애플리케이션 계층으로 정보를 넘겨줄 때 DTO를 사용하기도 한다. 아니면, 클라이언트에서 서버로 보내준 DTO를 그대로 애플리케이션 계층까지 내리는 경우도 있다. 이번에 헥사고날 아키텍처를 프로젝트에 적용해보는 시도를 하면서, DTO에 대해 고민해봤고 그 생각을 좀 정리해보고자 한다.

 

 DTO의 역할

 위에서는 언급하지 않았지만, DTO의 역할과 책임에 대해서도 DTO는 Data Transfer Object의 줄임말이다. 말 그대로 '데이터를 전송하는 객체'다. 즉, DTO가 지닌 책임은 데이터의 전송이다. 그런 DTO에게 데이터의 검증까지 맡기는 것이 옳을까?

 

 내가 생각했을 땐 아니다. 그 이유는 데이터의 검증을 왜 하는지에 있다. 데이터의 검증을 하는 이유는 '그것이 요구사항이기 때문'이다. 예를 들어, '회원 가입 시 비밀번호에 공백이 입력되면 안된다'는 요구사항이 있다고 하자. 입력된 값에 대한 검증은 DTO에서 담당할 수도 있고, 다른 객체에서 담당할 수도 있다. 이 검증 책임이 어디에 있어야하는지를 결정하기 위해서는 검증을 왜 하는지를 다시 생각해봐야 한다. 우리가 요구사항이라는 문제를 해결하는 영역은 도메인 영역이다. 그러나 DTO는 도메인 객체가 아니다. 그래서 DTO는 검증 책임을 지면 안된다.

 

 동시에, DTO에 검증의 책임을 물린다면 이는 단일 책임의 원칙을 위반하게 된다. DTO의 역할이 뭔가? 데이터를 담아서 이를 서로 다른 계층에 전달해주는 것이 DTO의 역할이다. 그런데, 어떤 검증 요구사항이 추가되었다고 DTO를 수정하는 것이 옳을까? 예를 들어서 '비밀번호에 연속된 숫자가 포함되면 안된다'는 요구사항이 추가되었을 때, 우리는 도메인 영역을 건드려야지 DTO를 건드리면 안된다는 것이다.

 

 결국 검증을 하는 이유는 이것이 요구사항이기 때문이고, 그렇기 때문에 검증의 책임은 도메인 영역에서 져야한다.(그리고 사실 DTO에 Object라는 이름이 붙기는 했지만, 이게 객체인지에 대한 의문도 있다. 그냥 자료구조 아닌가? 흠...)

 하나의 DTO만 쓰는 방식

 다시 본론으로 돌아와보자. 기존의 프로젝트 구조에선 클라이언트에서 넘겨준 DTO를 그대로 애플리케이션 레이어까지 넘겨줬다. 그럼에도 큰 문제는 없었다. 서비스 객체에 모든 책임을 넘겼기 때문이다. 컨트롤러는 요청을 받으면 일단 dto를 그대로 서비스 객체에 던지고 서비스 객체에서 모든 작업을 마친 뒤 최종적으로 응답용 dto까지 만들어서 컨트롤러에 넘겨줬다.

서비스 vs 컨트롤러

 

  문제는 이렇게 모든 책임을 넘기고 나니 계층 경계가 모호해졌다. 클라이언트에서 넘겨주는 정보를 DTO로 변환하는 과정과 클라이언트에 다시 넘겨줄 정보를 DTO로 변환하는 과정이 각각 API 계층과 애플리케이션 계층으로 나뉘었다. DTO 클래스를 두어야 하는 계층은 어디이며, 이렇게 변환을 수행하는 과정은 어디서 수행해야 하는지 자체가 애매해졌다. 계층 분리에 대한 생각이 별로 없었을 때는 그냥 그러려니 했는데, 이번에 아키텍처를 공부하면서 계층과 영역의 분리에 좀 집착해보려고 하니, 이 구조가 너무 마음에 안들었다.

 

 또한 서비스 객체는 레포지토리에 엔티티 객체를 그대로 넘겨줬는데, 엔티티 객체는 모든 멤버가 가변적이었기 때문에 이 역시 불안했다. 데이터를 전송하는 과정에서 어떤 변수가 발생할지 모르는데 가변 객체를 그대로 넘겨준다라... DTO는 불변으로 만들고 싶었다.

 

 계층별 DTO를 쓰는 방식

 그래서 계층별 DTO를 나눴다. 헥사고날 아키텍처의 경우 Controller(Input Adapter)가 Input Port에 의존한다. 그리고 내부 비즈니스 영역에서 해당 Port를 구현한다. 즉 비즈니스 영역은 Port를 알 수 밖에 없다. 동시에 비즈니스 영역은 Output Port에 의존하고, Repository(Output Adpater)가 Output Port를 구현한다.

 

 나는 Port에 집중했다. 비즈니스 영역에선 Input, Output 두 Port를 모두 알고 있을 수밖에 없다. 그럼 Port영역에 DTO를 두면 되지 않을까? 라는 생각이었다. 그렇게 DTO를 구분했다. 아래는 자유 게시판 등록을 위해 필요한 DTO들이다.

public final class FreeBoardApiDto {
    @Getter
    @Builder
    public final static class Post {
        private final String title;
        private final String content;
    }
}

/////////////////////////

@Getter
public final class PostFreeBoardCommand {
    private final Member writer;
    private final String title;
    private final String content;

    private PostFreeBoardCommand(Member writer, String title, String content) {
        this.writer = writer;
        this.title = title;
        this.content = content;
    }

    public static PostFreeBoardCommand of(Member writer, String title, String content) {
        return new PostFreeBoardCommand(writer, title, content);
    }
}

/////////////////////////

@Getter
public final class PostFreeBoardResponse {
    private final long boardId;
    private final String title;
    private final String content;
    private final int viewCount;
    private final LocalDateTime createdAt;
    private final LocalDateTime modifiedAt;
    private final WriterInfo writerInfo;

    private PostFreeBoardResponse(long boardId, String title, String content, int viewCount, LocalDateTime createdAt, LocalDateTime modifiedAt, WriterInfo writerInfo) {
        this.boardId = boardId;
        this.title = title;
        this.content = content;
        this.viewCount = viewCount;
        this.createdAt = createdAt;
        this.modifiedAt = modifiedAt;
        this.writerInfo = writerInfo;
    }

    public static PostFreeBoardResponse of(long boardId, String title, String content, int viewCount, LocalDateTime createdAt, LocalDateTime modifiedAt, WriterInfo writerInfo) {
        return new PostFreeBoardResponse(boardId, title, content, viewCount, createdAt, modifiedAt, writerInfo);
    }
}

/////////////////////////

@Getter
public final class SaveFreeBoardQuery {
    private final long writerId;
    private final String title;
    private final String content;

    private SaveFreeBoardQuery(long writerId, String title, String content) {
        this.writerId = writerId;
        this.title = title;
        this.content = content;
    }

    public static SaveFreeBoardQuery of(long writerId, String title, String content){
        return new SaveFreeBoardQuery(writerId, title, content);
    }
}

/////////////////////////

@Getter
public final class FreeBoardQueryResult {
    private final long writerId;
    private final long boardId;
    private final String title;
    private final String content;
    private final LocalDateTime createdAt;
    private final LocalDateTime modifiedAt;
    private final int viewCount;

    private FreeBoardQueryResult(long writerId, long boardId, String title, String content, LocalDateTime createdAt, LocalDateTime modifiedAt, int viewCount) {
        this.writerId = writerId;
        this. boardId = boardId;
        this.title = title;
        this.content = content;
        this.createdAt = createdAt;
        this.modifiedAt = modifiedAt;
        this.viewCount = viewCount;
    }

    public static FreeBoardQueryResult of(long writerId, long boardId, String title, String content, LocalDateTime createdAt, LocalDateTime modifiedAt, int viewCount) {
        return new FreeBoardQueryResult(writerId, boardId, title, content, createdAt, modifiedAt, viewCount);
    }
}

 

 게시판 - 사용자 간 연관관계를 id를 사용한 약한 연관관계로 바꾸며, DTO에 담겨야하는 정보들이 조금씩 바뀌었다. 그래서 각 계층별로 요청 - 응답의 DTO가 각각 생겼다. FreeBoardQueryResult의 경우에는 Get 이나 Put 요청을 보낼 때도 사용할 것이라 하나의 객체로 설정했다.

 

 이렇게 계층별 DTO를 분리하니, 각 영역에서 불필요하게 다른 영역에 대한 의존이 발생하지 않게 수정되었다. 또한 모든 DTO클래스를 final클래스로 사용함으로써 데이터 전송 간에 발생할 수 있는 데이터 변경의 가능성을 많이 줄일 수 있게 되었다.

 

 이번에 DTO를 어떻게 사용해야 하나를 고민하는 시간은 꽤 의미있다고 생각한다. 데이터 클래스를 사용하는 방법, 계층 간 관심 영역의 분리 등에 대해 연쇄적으로 생각해볼 수 있었다. 추후 다른 기능들을 들여오면서 어떻게 바뀔지는 모르겠는데, 최대한 다른 영역에 관심을 갖는 계층은 애플리케이션 계층으로 한정하고 외부 영역과 도메인 영역은 자기가 할 일만 하는 구조를 유지하도록 노력해보자.

반응형

댓글