벌써 설 연휴의 마지막 날, 맥주의 달 2월이 찾아왔습니다. 하지만 세상 살이는 더욱 팍팍해져가는 요즘입니다. N사 블로그 스타일 인사말로 시작한 이유는 이 시리즈를 왜 작성하는지 설명을 좀 하고싶기 때문입니다.
SSAFY에서 프로젝트를 진행하면서 기존에 그저 배운대로 작성하는 코드, 그저 관행으로 넘어갔던 혹은 이렇게 하니까 되더라~ 하던 코드들을 하나씩 따져보는 중입니다.
물론 개발 속도가 현저하게 느려지긴 했지만 지금 아니면 언제 이런 경험을 해볼까 싶어서 기억해두면 좋을 내용을 기록 해두기 위해 작성하는 시리즈입니다.
왜 이렇게 서두가 기냐?
➡️ 주관이 매우 많습니다. 다른 의견이 있다면 모두 환영합니다!
➡️ 타인에겐 자잘한 무언가일 수 있기에 원하는 정보가 아닐수도 있어요! 그래도 최선을 다해 작성할테니 많은 정보를 얻어가셨으면 좋겠습니다.
DAO, DTO, VO 모두 자주 사용하는 용어지만 정확한 정의가 가능한가요?
개발하며 3-Layer-Architecture를 자주 접하고 있습니다. 주요 관심사에 맞게 계층을 분리한 후 각자의 계층(레이어)은 자신에게 맞는 역할만 수행함으로써 응집도⬆️, 결합도⬇️ 결과를 얻을 수 있습니다. 그러면 계층 간 통신은 누가 담당할까요?
Data Transfer Object를 직역하면 데이터 전송 객체입니다. 현재 개발 중인 프로젝트에서는 DTO 디렉토리에 다양한 DTO 클래스를 관리하고 있습니다. 이 전송 객체를 Service
와 Controller
에서 데이터를 주고받는 역할로 사용하고 있습니다. 즉, 계층 간 통신을 담당하는 객체를 DTO로 사용 중입니다.
@Getter
public class BookmarkCreateDto {
private String name;
private String description;
// private AccessLevel accessLevel;
private String accessLevel;
}
제가 만든 Dto 클래스인데 아무 로직이 없습니다. 이 내용이 이 글을 관통하는 주제이기 때문에 후에 자세히 살펴보겠습니다.
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 등) 위해 직렬화 필요 | 내부 값 표현을 위한 객체로 직렬화 불필요 |
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는 가급적 쿼리 실행에 집중을 하는 것이 좋습니다.
@Getter
public class BookmarkCreateDto {
private String name;
private String description;
// private AccessLevel accessLevel;
private String accessLevel;
}
이전에 본 코드에서
@Getter
는 있지만 왜@Setter
는 없을까요? 또AccessLevel
은 왜 주석처리하고String
으로 수정했을까요?
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 클래스를 생성하는 것이 좋다 생각합니다.
enum
이제 이 글을 작성한 이유인 enum
입니다. 현재 진행 중인 프로젝트에서 AccessLevel
을 enum
으로 관리 중인데 이 값을 String
자료형으로 DTO을 작성하면서 이게 어떻게 되지? 에서 시작된 글입니다.
우선 enum
을 왜 사용하게 되는지 부터 보면, 주로 특정한 값으로 제한할 때 사용합니다. 예제인 접근 권한의 경우 전체 공개
,친구 공개
,비공개
세가지로만 이루어져야 하는데 이를 단순히 String
으로 관리하게 되면 publik
같은 오타가 발생할 때를 대비할 수 없습니다.
위와 같은 이유로 enum
을 DTO에도 적용을 하려했고 실제로 DTO에 enum
을 사용해도 JSON 데이터 값이 enum
값과 일치하면 그에 맞는 객체로 바인딩이 됩니다. ➡️ 이게 왜 되는걸까요????????
StringToEnumConverterFactory.java
Spring Boot가 기본 제공하는 enum 컨버터 클래스입니다. Enum.valueOf()
메서드를 통해 알맞게 매핑해주는 클래스인데 이 코드는 다음과 같은 이유로 완벽한 해결책은 아닙니다.
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
라이브러리enum.name()
으로 직렬화/역직렬화가 가능합니다.Jackson
기본 설정 상 enum
의 내용을 직접 JSON에 노출하진 않기 때문에 클라이언트에게 enum
의 내용을 전달하고 싶다면 커스텀 Serializer를 구현하거나 @JsonValue
를 사용해야합니다.DTO에 enum을 사용할 수 있도록 기능 개발이 어느 정도 되어 있지만 과연 이게 맞는 방법인지는 고민이 좀 필요합니다.
여기서부터는 아주 주관적인 사족입니다.
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는 데이터 전달용, 도메인은 비즈니스 로직 담당 이라는 원칙을 지키면서 선택을 고민해보는 것도 개발의 재미 중 하나인 것 같습니다.
말이 길었지만, 코드 한 줄에 자승자박 그만하고 빨리 개발 호다닥 끝내고 고민을 해야겠습니다 🙌
좋은 글이네요. 감사합니다.