[Spring] DTO를 어떻게 다룰까?

cup-wan·3일 전
2

D3V

목록 보기
1/1
post-thumbnail


벌써 설 연휴의 마지막 날, 맥주의 달 2월이 찾아왔습니다. 하지만 세상 살이는 더욱 팍팍해져가는 요즘입니다. N사 블로그 스타일 인사말로 시작한 이유는 이 시리즈를 왜 작성하는지 설명을 좀 하고싶기 때문입니다.
SSAFY에서 프로젝트를 진행하면서 기존에 그저 배운대로 작성하는 코드, 그저 관행으로 넘어갔던 혹은 이렇게 하니까 되더라~ 하던 코드들을 하나씩 따져보는 중입니다.
물론 개발 속도가 현저하게 느려지긴 했지만 지금 아니면 언제 이런 경험을 해볼까 싶어서 기억해두면 좋을 내용을 기록 해두기 위해 작성하는 시리즈입니다.
왜 이렇게 서두가 기냐?
➡️ 주관이 매우 많습니다. 다른 의견이 있다면 모두 환영합니다!
➡️ 타인에겐 자잘한 무언가일 수 있기에 원하는 정보가 아닐수도 있어요! 그래도 최선을 다해 작성할테니 많은 정보를 얻어가셨으면 좋겠습니다.

DTO / VO / DAO

DAO, DTO, VO 모두 자주 사용하는 용어지만 정확한 정의가 가능한가요?

개발하며 3-Layer-Architecture를 자주 접하고 있습니다. 주요 관심사에 맞게 계층을 분리한 후 각자의 계층(레이어)은 자신에게 맞는 역할만 수행함으로써 응집도⬆️, 결합도⬇️ 결과를 얻을 수 있습니다. 그러면 계층 간 통신은 누가 담당할까요?

DTO

Data Transfer Object를 직역하면 데이터 전송 객체입니다. 현재 개발 중인 프로젝트에서는 DTO 디렉토리에 다양한 DTO 클래스를 관리하고 있습니다. 이 전송 객체를 ServiceController에서 데이터를 주고받는 역할로 사용하고 있습니다. 즉, 계층 간 통신을 담당하는 객체를 DTO로 사용 중입니다.

@Getter
public class BookmarkCreateDto {
    private String name;
    private String description;
    // private AccessLevel accessLevel;
    private String accessLevel;
}

제가 만든 Dto 클래스인데 아무 로직이 없습니다. 이 내용이 이 글을 관통하는 주제이기 때문에 후에 자세히 살펴보겠습니다.

VO

Value Object는 도메인에서 특정 의미를 갖는 값을 불변 객체로 표현하는 역할입니다.
불변성(Immutability)은 파이썬을 배우시는 분들에겐 더욱 쉽게 느껴질 것 같은데 mutable/immutable의 그 불변성과 유사합니다. 생성 후 상태가 변하지 않도록 설계하는 것이 VO의 역할이자 특징인 불변성입니다. 또한 동등성(Equality)을 id가 아닌 값 자체로 판단하고 도메인 로직을 조금 포함해도 괜찮은 것이 특징입니다.
종종 DTO와 혼동하기 쉬운 개념이기에 DTO와 VO의 차이점을 정리해보겠습니다.

구분 DTO (Data Transfer Object) VO (Value Object)
주요 목적 계층 간 또는 시스템 간 데이터 전송 도메인 내에서 의미 있는 값을 표현
불변성 불변일 수도 있고 가변 객체도 가능 불변(Immutable) 객체로 설계
비즈니스 로직 포함 여부 거의 포함하지 않음 (데이터 전송 목적) 유효성 검사, 계산 등의 도메인 로직 포함 가능
사용 위치 Controller ↔ Service, API 통신 Service Layer, 도메인 모델
동등성 비교 객체의 참조(메모리 주소) 비교 객체의 내부 값이 같으면 동일한 객체로 간주
직렬화 필요 여부 외부 통신(JSON, XML 등) 위해 직렬화 필요 내부 값 표현을 위한 객체로 직렬화 불필요

DAO

Data Access Object라는 이름에 맞게 DAO는 데이터베이스에 접근하기 위한 목적을 가진 객체입니다. 흔히 @Repository 어노테이션으로 Bean에 등록해 관리하는 그 클래스가 DAO라 불립니다. 운영 중인 DB와 직접적인 접근(쿼리 실행, 트랜잭션 처리 등)을 담당합니다.

@Repository
public class UserDao {
    
    @Autowired
    private JdbcTemplate jdbcTemplate;

    public User findById(Long id) {
        String sql = "SELECT * FROM users WHERE id = ?";
        return jdbcTemplate.queryForObject(sql, new Object[]{id}, (rs, rowNum) -> {
            User user = new User();
            user.setId(rs.getLong("id"));
            user.setName(rs.getString("name"));
            user.setEmail(rs.getString("email"));
            return user;
        });
    }

    public int save(User user) {
        String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
        return jdbcTemplate.update(sql, user.getName(), user.getEmail());
    }

    // update, delete 등 다른 메서드...
}

JdbcTemplate을 사용하는 DAO 예제 코드입니다.
트랜잭션 처리를 담당한다 하였으나 보통 Service에서 담당하고 DAO는 가급적 쿼리 실행에 집중을 하는 것이 좋습니다.

DTO에 enum?

@Getter
public class BookmarkCreateDto {
    private String name;
    private String description;
    // private AccessLevel accessLevel;
    private String accessLevel;
}

이전에 본 코드에서 @Getter는 있지만 왜 @Setter는 없을까요? 또 AccessLevel은 왜 주석처리하고 String으로 수정했을까요?

DTO와 Setter

DTO에 @Setter를 사용하는 것이 일반적이지 않다 생각합니다. 가장 큰 이유는 다른 곳에서 DTO를 수정 가능성이 생기고 캡슐화가 깨지는 결과로 이어지기 때문입니다. 불변 객체로 사용한다고 DTO가 VO가 되는 것은 아닙니다 (역할이 다름).

public class UserDto {
    private final String name;
    private final String email;

    public UserDto(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public String getName() { return name; }
    public String getEmail() { return email; }
}

Setter 없이 final 키워드 + 생성자를 통해 API 응답 DTO에서 JSON 직렬화 이후 불변성을 유지하는 코드입니다.
DTO를 수정해야하는 경우에는 새로운 DTO 클래스를 생성하거나 @JsonIgnoreProperties를 사용하는 방법, @Builder + @Builder.Defalt를 사용하는 방법이 있습니다.
저는 주로 API 안전성을 위해 새로운 DTO 클래스를 생성하는 것이 좋다 생각합니다.

DTO와 enum

이제 이 글을 작성한 이유인 enum입니다. 현재 진행 중인 프로젝트에서 AccessLevelenum으로 관리 중인데 이 값을 String 자료형으로 DTO을 작성하면서 이게 어떻게 되지? 에서 시작된 글입니다.

우선 enum을 왜 사용하게 되는지 부터 보면, 주로 특정한 값으로 제한할 때 사용합니다. 예제인 접근 권한의 경우 전체 공개,친구 공개,비공개 세가지로만 이루어져야 하는데 이를 단순히 String으로 관리하게 되면 publik 같은 오타가 발생할 때를 대비할 수 없습니다.

enum을 DTO에서 표현하기

위와 같은 이유로 enum을 DTO에도 적용을 하려했고 실제로 DTO에 enum을 사용해도 JSON 데이터 값이 enum값과 일치하면 그에 맞는 객체로 바인딩이 됩니다. ➡️ 이게 왜 되는걸까요????????

  • StringToEnumConverterFactory.java

Spring Boot가 기본 제공하는 enum 컨버터 클래스입니다. Enum.valueOf() 메서드를 통해 알맞게 매핑해주는 클래스인데 이 코드는 다음과 같은 이유로 완벽한 해결책은 아닙니다.

  1. 대.소문자 구분 없이 enum 변환을 하고 싶은 경우
  2. 특정 에러 처리(default value, logging)을 하고 싶은 경우
  3. 커스텀 로직을 적용해야 하는 경우
final class StringToEnumConverterFactory implements ConverterFactory<String, Enum> {

	@Override
	public <T extends Enum> Converter<String, T> getConverter(Class<T> targetType) {
		return new StringToEnum(ConversionUtils.getEnumType(targetType));
	}


	private static class StringToEnum<T extends Enum> implements Converter<String, T> {

		private final Class<T> enumType;

		StringToEnum(Class<T> enumType) {
			this.enumType = enumType;
		}

		@Override
		@Nullable
		public T convert(String source) {
			if (source.isEmpty()) {
				// It's an empty enum identifier: reset the enum value to null.
				return null;
			}
			return (T) Enum.valueOf(this.enumType, source.trim());
		}
	}

}
  • Jackson 라이브러리
    Jackson을 사용한다면 JSON 직렬화/역직렬화를 자동으로 처리하기 때문에 enum.name()으로 직렬화/역직렬화가 가능합니다.
    하지만 Jackson 기본 설정 상 enum의 내용을 직접 JSON에 노출하진 않기 때문에 클라이언트에게 enum의 내용을 전달하고 싶다면 커스텀 Serializer를 구현하거나 @JsonValue를 사용해야합니다.

DTO에 enum을 사용할 수 있도록 기능 개발이 어느 정도 되어 있지만 과연 이게 맞는 방법인지는 고민이 좀 필요합니다.

enum을 DTO로 활용하는 것이 맞을까?

여기서부터는 아주 주관적인 사족입니다.
DTO에 enum을 사용한다는 의미는 검증 로직을 DTO에 부여하는 느낌을 지울 수 없습니다.
또한 도메인 노출, 호환성 이슈, 변환 로직 생략 등으로 인해 enum에 수정 사항이 있을 때 해당 모든 DTO 코드가 깨질 위험이 생깁니다.
String으로 받고 Service/Domain에서 enum으로 변환한다면 계층 분리가 더욱 명확해지고 도메인 노출을 막으며 확장에 유연해진다는 것이 제 생각입니다.
물론, 각 방법에 모두 장단점이 있지만 Layer의 책임 분리 입장에선 DTO에 enum을 사용하는 것은 지양해야하는 방법이지 않을까 생각합니다.


정리

  • DTO: 계층 간 데이터 전송을 담당하는 객체.
    가급적 불변 객체로 유지하고, @Setter를 최소화하여 캡슐화를 깨뜨리지 않도록 주의
    변경 사항이 많아지는 경우에는 새로운 DTO 버전을 만들어서 API 안정성을 보장

  • VO: 도메인 내에서 의미 있는 값을 불변 객체로 표현.
    값 자체로 동등성을 판단하며, 유효성 검사나 간단한 비즈니스 로직 포함 가능

  • DAO: 데이터베이스 접근 로직을 캡슐화.
    쿼리나 ORM을 통해 DB와 직접 통신하며, Service Layer에서 트랜잭션 처리

  • DTO와 enum:
    바로 enum을 DTO에 넣으면 편리하지만, 도메인 노출·호환성 문제 등 리스크가 존재
    String으로 받고 Service/도메인 레이어에서 변환하면 계층 분리가 명확해지고 확장성도 높아짐
    결국 팀과 프로젝트 상황, API 변경 주기 등을 고려해 선택하는 것이 가장 중요

개발에 정답은 없는 것 같습니다. 다만, DTO는 데이터 전달용, 도메인은 비즈니스 로직 담당 이라는 원칙을 지키면서 선택을 고민해보는 것도 개발의 재미 중 하나인 것 같습니다.

말이 길었지만, 코드 한 줄에 자승자박 그만하고 빨리 개발 호다닥 끝내고 고민을 해야겠습니다 🙌


출처

How to serialize and deserialize enums with jackson
DTO

profile
아무것도 안해서 유죄 판결 받음

1개의 댓글

comment-user-thumbnail
약 7시간 전

좋은 글이네요. 감사합니다.

답글 달기

관련 채용 정보