DTO와 관련된 생각

1. 서론

회사에서 프로젝트를 진행하다보면 계층 간 데이터 교환을 위해 기본적으로 DTO를 생성하여 사용한다. 회사에서는 보통 미리 정의된 편의성을 갖춘 DTO를 정의해 사용한다. 다만, 일부 프로젝트에서는 프리랜서가 참여하여 해당 프리랜서가 제로 베이스부터 시작하여 단기 프로젝트를 마무리하고, 이후 정직원이 이어받아 유지보수를 하는 경우가 있다.

이 때, 코드를 살펴보면 대부분 DTO로 getter/setter가 정의된 DTO를 사용한다. 하지만, 유지보수를 진행하다보면 요구사항은 항상 바뀌기 마련이고 정도가 심하면, 데이터베이스 기준으로 작성된 데이터 전달용 DTO들은 정의된 변수에 무색하게 모든 case를 커버할 수 있게 extend 되거나, 변수 개수가 늘어나게 된다. 이는 프로젝트를 진행하다보면, 번거로운 문제가 발생했다.

회사에 정의된 DTO 객체는 Map형태로 된 객체로 필요한 모든 변수를 Map 변수에 넣어 사용한다. 이 경우에는 모든 케이스를 간단하게 커버가 가능하기 때문에 개발하는데는 편하지만, 애초에 Map형태로 정의되어 있다보니 효율성에 문제가 있고, 컴파일 에러 발생시 문제를 찾기 힘들다.

여러 글을 찾아보면 DTO클래스를 사용하는 것이 좋다고는 하지만 이론 상으로 좋다고 해도 실제 현업에서 사용할 때 코드 상이 아닌 업무 적으로 불편한 점이 있다고는 해도 과언이 아닐 듯 싶다.

이에 따라 DTO를 어떻게 사용하는 것이 가장 합리적이고 좋은 것인가를 알아보고자 한다.

2. 샘플 프로젝트 생성

이미지

위와 같이 샘플 프로젝트를 생성했다. 실제 프로젝트에서는 더 정의되어 있고, 복잡하게 사용하긴 하지만 기본적으로 정의해놓고 테스트를 진행하면서 디벨롭 하고자 한다.

3. 각 객체의 장단점

Map 대신 Dto 클래스를 사용해야 하는 이유는 검색해보면 다음과 같은 대표적인 몇가지 이유가 있다.

  • DTO 클래스는 컴파일 에러를 유발하지 않는다.
  • MAP은 String을 키 값으로 사용하기 때문에, 오타가 발생하는 경우 파악하기 힘들다.
  • MAP은 가독성이 떨어진다. 코드가 실행되는 중간에 어떤 값이 변형되고 변조되는지 파악하기 힘들다.
  • MAP은 타입 캐스팅 비용이 발생한다. value에 기본적으로 Object가 최상위 클래스이기에, 형변환을 해주어야 한다.
  • MAP은 불변성을 확보할 수 없다. 중간에 변경이 일어날 여지가 더 많다.

어느 정도 일리가 있는 말 들이다. 본인도 이러한 이유 때문에 에러를 겪고도 꽤 오랜시간이 걸려 못 찾은 적이나, 개발할 때도 다른 사람이 짜놓은 코드에서 이러한 문제를 본 적이 있다.

[ 경험한 문제 ]

name값이 없는 에러가 발생하여 찾아보니 map.put(“name”,value); 가 아닌 map.put(“names”,value); 를 하고 있었다. typecast DB에러가 나서 살펴보니 map에 int로 담긴게 아닌 String으로 담긴 값으로 DB조회를 시도했다. 분명 메소드 시작 전에는 map에 세션밖에 없었고, 메소드 실행 과정에서는 해당 map에 조회한 list를 담는 작업이었지만 실행 후 map을 보니 오만가지 값들이 전부 들어와 있었다.

사실 위에서 경험한 문제들이 더 위에서 언급한 DTO를 사용해야 하는 이유에 부합한다고 볼 수 있겠다. 하지만 저런 경험 외에도 이런 경험을 했다.

[ Map을 사용하였을 때 장점? ]

통계를 내기 위한 DB 테이블에서 항목들을 가져올 때, 항목이 4,50개 정도 되는 쿼리를 작성하여 맵으로 가져오면 해당 맵에 알아서 담겨져오기 때문에(Mybatis parameterType에 map을 지정함으로써) 항목이 많은 경우 사용하기 편했다. 필요한 값들이 4,5개 되는 테이블에서 참조하여 구성해야 하는 경우. map으로 작성하면 편했다. 파라미터가 추가되어야 하는 경우, 그냥 가져와서 넣으면 된다. 급하게 필요한 작업이 있는 경우, 사용하기 편했다.

타입 캐스팅 비용에 조차도 신경을 써야 하는 대형 프로젝트나 효율을 강조한 프로젝트라면 모르겠지만, 위 장점을 보았을 때는 MAP의 단점을 커버하지 않나… 라는 생각을 한다. 사실 MAP의 단점도 왠만하면 휴먼에러인 경우가 많기 때문이다. 달리 말하면, 휴먼에러를 조금이나마 방지하기 위해 DTO 클래스를 통해 효율을 높이고자 하는 것일 수 있으나, 휴먼에러를 막자고 DTO클래스를 정의하다보면 어마어마한 노가다 작업이 되기 때문이다.

그러다 만약, 급한 요청이나 작업을 해야되는 날이 온다면? 혹은 사업 자체가 유지보수 성을 띄거나 비슷한 경우라면? DTO 하나를 새로 정의해야 하거나 아니면 기존 DTO 클래스에 값을 추가해야 하는데 … 비슷한 DTO가 많아서 다 추가해야된다거나?

이런 경우에는 사실 DTO 클래스를 쓴다고 해도 불편한 점이 더 많을 것이라고 생각한다. 물론 프로그램이 아닌 작업자 입장에서.

그렇다면 이러한 경우를 대비하여 어떻게 데이터를 전달할 것인가 대해 생각을 해보자면, 우선적으로 확장을 하거나 새로 정의하여 보통 사용을 한다. 예를 들어보자.

4. 확장 가능성

Dto Class

이미지

가장 간단하게 할 수 있는 방법은 위와 같다. 공통적으로 쓰이는 DTO 클래스를 확장하여 포함 시키는 것이다. 이렇게 되면 많이 쓰이는 항목들을 편하게 사용할 수 있다. 경험한 프로젝트에서는 보통 세션 값이나 항상 필요한 메뉴 관련 값 등을 저렇게 확장 시켜서 사용했었다.

Map Class

이미지

Map Class 같은 경우, 일부만 가져왔는데 다음과 같이 사용했다. 우선 기본적으로 LinkedHashMap을 extends 하여 사용했으며 맵 타입에 어떤 값이 들어올 지 모르기 때문에, 보통 저렇게 타입별로 get / put 할 수 있도록 따로 정의를 하여 사용했다.

즉, 맵을 활용하면서 맵에 넣고 가져오는데에 필요한 메소드만 정의하여 사용을 하였다. Object형태로 무조건 받는 것이 아닌 하나의 중간 과정을 더 추가했다고 보면 되겠다. 이렇게 되면 컴파일 단계에서 타입 캐스팅 혹은 타입으로 인한 컴파일 에러가 발생활 확률은 조금이나마 줄어든다.

확장의 경우에는 사실 맵 쪽이 더 좋다고 보여지는데, 그 이유는 애초에 확장한 것이 기본적으로 자바에서 제공하는 클래스이며, 데이터를 넣고 가져오는 과정 사이에 원하는대로 입맛에 맞는 코드를 넣을 수 있다는 점이다.

위에서는 추가하지 않았지만, 배열을 파라미터로 받아서 JSON으로 리턴해 준다던가, 특정 값이 들어오면 다른 형태로 아예 변환 후에 리턴한다던가 같은 공통에서 쓸법한 것들을 추가해 놓아도 상관 없다.

반면, DTO 클래스의 경우에는 안그래도 많은 getter/setter에 무언가 가져올 때 원하는 형태로 가져와야 한다면 getter 메소드를 따로 정의해야 하고, 만약 이렇게 리턴되어야 하는 값이 공통인 것 같으면서도 아닌 (3,4개 dto 클래스에서만 사용하는경우) 경우에는 따로따로 작업을 해주어야 한다.

그렇게 하기 싫다고 하면 결국 따로 정의한 메소드를 extends하거나 구현부를 추가해주어야 하니, 여간 까다롭지 않을 수 없다.

5. 요구사항의 변경 또는 케이스에 맞게 대처 가능한지 유연성

그렇다면, 작업하는 상황에서의 예를 들어볼까 한다.

  1. 기존에 특정 테이블을 가져올 때 사용하는 DTO 클래스가 있습니다.
package com.dto.demo.lib;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class testDto extends commonDto {
    private String email;
    private String phone;

    private String info1;
    private String info2;
    private String info3;
    private String info4;
    private String info5;
    private String info6;
    private String info7;
    private String info8;
    private String info9;
    private String info10;
}

여기서 info21~30을 추가해주세요! 하면, 단순히 21~30 항목을 추가하면 끝이지만, 이렇게 바뀔 수도 있다.

testDto2의 11~20 항목을 같이 가져와 주세요!

잉? 항목 추가? 근데 다른 dto에 있는 값을 가져와야 한다? 어떻게 할까?

package com.dto.demo.lib;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class testDto extends commonDto {
    private String email;
    private String phone;

    private String info1;
    private String info2;
    private String info3;
    private String info4;
    private String info5;
    private String info6;
    private String info7;
    private String info8;
    private String info9;
    private String info10;

    private testDto2 testDto2;
}

이런 식으로 구성하면 괜찮을까? testDto 내부에 testDto2 객체가 존재하는 형태로? 이렇게 되면 세팅할때는 아래와 같이 해주어야 된다.

@Service
public class dtoService {
    public testDto getData() {
        testDto dto = new testDto();
        dto.setId("testId");
        dto.setName("testName");
        dto.setEmail("test@test.com");

        testDto2 dto2 = new testDto2();
        dto2.setInfo11("11");
        dto2.setInfo12("12");
        dto2.setInfo13("13");
        dto2.setInfo14("14");
        dto2.setInfo15("15");
        dto2.setInfo16("16");
        dto2.setInfo17("17");
        dto2.setInfo18("18");
        dto2.setInfo19("19");
        dto2.setInfo20("20");

        dto.setTestDto2(dto2);
        return dto;
    }
}

이런 식으로, 우선 testDto를 가져오고, testDto2를 가져와서 세팅 후, testDto에 넣는 것. 하지만 보통 Dto 클래스를 구성하는 경우 만약 RDB에서 데이터를 가져온다고 하면, mybatis 내에서 해당 클래스를 resultType으로 정의하고 가져오기 때문에 저런식으로 구성하려면 DB에 쿼리를 2번이나 날려야 한다.

그럼 당연히 아래와 같은 형식으로 바뀐다.

package com.dto.demo.lib;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class testDto extends commonDto {
    private String email;
    private String phone;

    private String info1;
    private String info2;
    private String info3;
    private String info4;
    private String info5;
    private String info6;
    private String info7;
    private String info8;
    private String info9;
    private String info10;

    // private testDto2 testDto2;

    private String info11;
    private String info12;
    private String info13;
    private String info14;
    private String info15;
    private String info16;
    private String info17;
    private String info18;
    private String info19;
    private String info20;
}

그냥 추가되어야 하는 항목들을 testDto에 정의해버리면 된다! 그러면 DB 쿼리 1번으로도 항목을 다 가져올 수 있다. 그럼 끝? 아니다.

그럼 이 info 11~20항목을 testDto 에서도 가지고 있고, testDto2에서도 가지고 있다. 만약 DB 테이블 기준으로 Dto를 잡았는데 이렇게 공유가 되는 항목이 있다? 그럼 이렇게 추가되다 보면 나중에는 dto의 기준이 애매해지는 상황이 발생한다.

결국 저렇게 계속 요구사항이 들어와서 추가추가 하다보면 결국 dto는 N개 지만, 다 형태가 비슷비슷 해져버리는 상황이 오게 된다. 자주 사용하던 작업자는 알 수도 있겠지. 하지만 이걸 다른 사람이나 새로운 인원이 보게된다면? 어질어질 하다.

결국 이렇게 복잡한 상황에서 추가 요구사항이 자주 들어온다면 dto 클래스는 비효율적이 될 수도 있다는 생각이 든다. 표현해야 하는 데이터가 늘어 날 수록 dto는 점점 복잡해지고, 정의는 애매해진다. 결국 나중에는 마구잡이로 사용하게 되어 의미가 없어진다는게 최종 생각이다.

특히나 최근 경험했던 프로젝트에서도 분명 dto는 명확하게 테이블 기준으로 3개가 나뉘어져 있는데 막상 까보면 3개다 항목이 비슷비슷 해서 오히려 다른 항목이 뭐가 있는지 찾기도 힘들다. 근데 비즈니스 로직을 보면 어떤 부분에서는 A를, 어떤 부분에서는 B를 쓰길래 그럼 결과 값이 다른가? 하고 보면 막상 결과에서 다른 항목은 2,3개 뿐.

뭐야 왜 결과는 비슷한데 다른 Dto를 사용하지? 하고 히스토리를 까보면 애초에 다른 Dto가 맞는데 수정하다보니 그렇게되는 것이었다.

반면, Map의 경우라면 그냥 가져와서 넣어주기만 하면된다. 넣어준다고 하기도 뭣한게 보통 resultType에 선언되어 있으면 RDB에서 가져오는 경우 그냥 거기에 넣어서 가져오기 때문이다.

package com.dto.demo.service;

import com.dto.demo.lib.map;

import org.springframework.stereotype.Service;

@Service
public class mapService {

    public map getData() {
        map m = new map();
        m.put("id", "testId");
        m.put("name", "testName");
        m.put("email", "test@test.com");
        // 추가되었어요!
        m.put("info11", "info11");
        m.put("info12", "info12");
        m.put("info13", "info13");
        m.put("info14", "info14");
        m.put("info15", "info15");
        m.put("info16", "info16");
        m.put("info17", "info17");
        m.put("info18", "info18");
        m.put("info19", "info19");
        m.put("info20", "info20");
        return m;
    }
}

샘플이기 때문에 그냥 put을 했지만. 어찌됐든 그냥 가져와서 넣기만 하면 된다. 꺼낼때도 마찬가지고. 결국 작업자가 보는 입장에서는 map으로 작업하는게 더 유연하고 편하다고 생각한다.

6. 그럼 Map을 무조건 써야 하는 것인가?

다음은 dto class와 map을 사용한 서비스에서 데이터를 가져오는 부분을 호출 했을 때의 작업 시간 차이이다.

이미지

근소한 차이이긴 하지만, dto보다 map 쪽 데이터를 호출하는데 더 많은 시간이 소요됐다. 단순 String 값을 set하고 가져오는데에도 이정도의 차이가 있는데, 만약 로직이 들어간 부분에서 데이터 객체를 사용한다고 가정하면 어마어마한 차이가 있을 것이다.

그래도 사람이 봤을 때는 얼마 차이 안난다고 생각할 수 있지만 말이다. 조금 더 판을 키워보자.

이번에는 각 데이터별로 10만건을 세팅하여 테스트를 해보았다

이미지

이번에는 차이가 확실히 나는 것으로 나타났다. 사실 테스트에서 보여지는 것은 단순히 String 값으로 세팅을 하고 반복을 돌리는 것이므로 실제 서비스에서 데이터 호출 전/후에 어떤 작업을 하는지에 따라서도 더 차이가 있을 것이다.

단순 테스트에서도 이정도 차이를 보이는데, 실제 서비스에서는 많은 데이터를 오고가는 작업을 할 때는 그 차이가 더 명확하게 벌어질 것으로 판단된다.

이번에는 둘을 합쳐서 테스트 해보았다. 공통 정보인 세션정보 비슷한 것을 commonDto로 빼고, dto 클래스 내부에 map을 가지고 있는 형태로 만들어 테스트를 진행했다.

이미지

이미지

혼합하여 사용한 것은 실행 속도가 제일 빠른 것으로 나타났다. 물론 아주 미세한 차이긴 하지만 말이다.

이렇게 사용하게되면 RDB 서비스를 기준으로 보았을 때, 세션 값이나 공통 정보들을 dto 클래스에 set을 하게 되고, DB조회 결과 값은 map 클래스로 받아 가지고 있게 될 것 이다. 여러 처리 방법이 있겠지만, 이렇게 처리하는 방법도 나름 합리적이라는 생각이 든다.

왜냐하면, 공통으로 사용하는 정보들 외에 조회되는 데이터들을 가져와서 사용할때 일일히 세팅해주고 하는 것보다는 그냥 map 형태로 처리해서 넘기면 편하기 때문이다. 대신, 변조가 일어나면 안되는 값들에 대해서는 dto 클래스를 통해 잘못된 값이 set 되는 것을 방지 할 수 있기 때문이다.

하지만 여전히 위험성은 남아있다. 왜냐하면 map으로 사용하는 값들을 꺼내서 사용하거나 넣는 작업이 제일 많을 것이기 때문에 map이 가지고 있는 문제점은 여전히 안고 있다고 볼 수 있다.

이렇게 생각하면 각 타입들이 가지는 문제들을 가져가면서 결국 속도만 잡기 위한 방법이라는 생각이 든다.

… 제일 안좋은 케이스라고 볼 수도 있겠다.

7. 결론

결론은 상황에 따라 어떤 타입을 쓸 지 결정이 되어야 하는 것으로 생각한다. 그 상황은 다음과 같이 나눌 수 있다

  1. 개발 시, 요구사항의 변경이 많고 유지보수 성이 짙은 프로젝트이다.
  2. 데이터를 조회할 때 복잡한 조인이나 서브쿼리 등으로 인해 정의된 테이블 외의 정보들을 많이 조회한다.
    (화면에 표현할 정보들이 많고, 복잡하다)
  3. 데이터 요청 시, 넘어오는 파라미터가 많으며 입력 값에 대한 검증 작업과 추가 작업이 많다.

Map을 사용하는 것이 낫다!

  1. 개발 시, 이미 Fix된 항목이 많으며 한 번 개발하면 변경이 적은 프로젝트이다.
  2. 데이터를 조회할 때 지정된 항목들을 가져오는 것이 일반적이다.
    (화면에 표현할 정보들이 정해져있고, 단순하다)
  3. 데이터 요청 시, 넘어올 파라미터가 정해져 있으며 범위가 지정되어 있고 해당 파라미터를 그대로 사용하는 작업이 많다.

DTO 클래스를 사용하는 것이 낫다!

결국 각 타입에 따라 장단점은 명확히 나뉘며 둘 중의 하나의 타입이 무조건 좋다! 라고는 할 수 없다는게 결론이다. 즉, 해당 타입을 언제 어디서 사용할 지는 프로젝트에 따라 다르며, 그 성격에 따라 수월하게 개발 진행이 가능한 타입을 선택하는 것이 맞다고 본다.

실제로 맵을 사용해야할 것 같은 프로젝트에 DTO 클래스를 사용하여 현재는 무슨일이 일어났냐 하면…

거의 대다수의 DTO 클래스가 몇몇 값을 제외하곤 전부 동일한 멤버변수를 가지고 있다. 동일한 의미를 갖는 멤버 변수임에도 특정 DTO 클래스에서는 타입이 다르다. 위 상태에서 견디지 못한 인원이 담당한 서비스에서는 MAP을 사용해버렸다. 연계가 되는 상황에서는 DTO에서 값을 뽑아다가 MAP으로 재정의하여 넘겨주어야 한다. 정말 끔직하기 그지 없는 상황이다. 결론적으로 위 프로젝트는 막바지를 향해 가고 있으나… 문제가 발생하면 정말 건드리기 싫은 프로젝트가 되어버렸다.

결론은, 무조건 한 쪽이 좋은 것은 아니니 상황에 따라 판단하여 개발하는 것이 좋다.

그래서 위와 같은 고통은 겪지 않기를…


© 2024. Chiptune93 All rights reserved.