이번 미션에서 추가된 요구사항 중 하나가 "모든 원시 값과 문자열을 포장한다"였다.
사다리 타기 미션을 예로 들면 사다리에 필요한 참여자 이름, 결과이름(상품명), 높이를 각각 String, String, int 타입으로 받아오는데
이걸 원시값으로 사용하지 않고 Participant, Prize, Height로 포장하면 되겠다! 생각했다.
그렇게 코드 구현을 하고 비즈니스 로직을 작성했다. 로직을 수행하고 나온 결과를 출력하기 위해 view로 데이터를 전달하려는데
이건 Participant 자체를 전달해야 할까 아니면 DTO에 담아서 보내야 할까 고민이 됐다.
기존에 알고 있던 VO와 DTO의 역할을 생각하면 아래와 같이 진행되어야 한다.
VO로 원시 값을 포장해서 사용하고 -> View에 값을 전하기 위해 DTO로 변환하고 -> 이걸 view에 전달해서 사용한다.
VO를 DTO로 변환화는 과정이 불필요하게 느껴지기도 했다.
(name 하나만 있는 객체를 다시 name 필드만 가진 객체에 담아서 보낸다?)
이 부분에 대해 한참 고민하게 되었다. 이번 기회를 통해 VO와 DTO에 대해 다시 한번 정리하기로 했다.
❯ VO(Value Object)
< Martin Fowler의 블로그 글 중에서 >
When programming, I often find it's useful to represent things as a compound.
-> 프로그래밍을 할 때, 사물을 복합체로 표현하는 것이 유용하다는 것을 종종 발견한다.
Therefore I find it useful to think of two classes of object: value objects and reference objects, depending on how I tell them apart. I need to ensure that I know how I expect each object to handle equality and to program them so they behave according to my expectations.
-> 객체를 어떻게 구분하는지에 따라 값 객체와 참조 객체 두 가지 클래스를 생각해 보는 것이 유용하다. 각 객체가 동등성을 어떻게 다루는지 알고, 내 예상에 맞게 동작할 수 있도록 프로그래밍해야 한다.
VO는 말 그대로 값을 가진 객체를 의미한다.
대표적으로 예를 드는 것이 "돈"이다. 돈의 일련번호(주소)가 달라도 금액(값)이 같으면 같은 것으로 인지한다.
(일련번호가 달라서 이걸로는 계산이 안돼요!! 하는 경우는 없다.)
그렇다면 VO는 어떤 특성을 갖는지 알아보자.
1. 불변값이다.
VO는 값을 나타내는 객체라고 하였다. 그래서 값이 변경되면 사실상 다른 객체가 되는 것이다.
(만약 가지고 있는 돈의 금액을 마음대로 바꿀 수 있다면?? 🤷🏻♀️ 그러면 좋겠다)
따라서 처음 생성될 때 값이 정해지고, 그 이후에 값을 변경할 수 있는 수정자(Setter)가 없어야 한다.
만약 값의 변경이 필요한 경우라면 생성자를 통해 객체를 새로 생성해서 재할당해야 한다.
예를 들어, 사다리 타기 결과(상품명)를 String으로 받아온다고 생각해 보자.
이걸 Prize라는 의미 있는 이름을 가진 클래스로 포장하고 setter가 있다고 가정해 보자.(가변상태)
public class Prize {
private String prizeName;
public Prize(String prizeName) {
this.prizeName = prizeName;
}
// 만약 setter가 존재한다면
public void setPrizeName(String name) {
this.prizeName = name;
}
}
...
public static void main(String[] args) {
Prize first = new Prize("노트북");
Prize second = first;
System.out.println("first = " + first.getPrizeName());
System.out.println("second = " + second.getPrizeName());
// 2등의 상품을 변경하면 1등의 상품도 같이 변경된다.
second.setPrizeName("노트");
System.out.println("first = " + first.getPrizeName());
System.out.println("second = " + second.getPrizeName());
}
/*
* 출력 결과
* first = 노트북
* second = 노트북
* first = 노트
* second = 노트
*/
만약 1등과 2등의 상품이 "노트북"으로 똑같았다고 생각해 보자.
어차피 같은 값을 나타낸다면 1등과 2등 상품으로 같은 객체를 넣어도 되지 않는가?
그런데 여기서 만약 예산 부족으로 2등의 상품이 노트로 바뀌었다고 가정해 보자.
setter를 통해 값을 변경하게 되면 의도치 않게 1등의 상품도 변경되는 것을 확인할 수 있다.
이처럼 VO의 값을 변경할 수 있고 여러 곳에서 사용되고 있을 경우에는 의도치 않은 객체 변경이 발생할 수도 있다.
따라서 VO는 값을 바꿀 수 없는 불변 객체 이어야 한다.
public class Prize {
private final String prizeName;
public Prize(String prizeName) {
this.prizeName = prizeName;
}
}
...
public static void main(String[] args) {
Prize first = new Prize("노트북");
Prize second = first;
System.out.println("first = " + first.getPrizeName());
System.out.println("second = " + second.getPrizeName());
// 2등의 상품을 변경하려면 생성자를 통해 객체를 새로 생성한다.
second = new Prize("노트")
System.out.println("first = " + first.getPrizeName());
System.out.println("second = " + second.getPrizeName());
}
/*
* 출력 결과
* first = 노트북
* second = 노트북
* first = 노트북
* second = 노트
*/
2. 동등성 비교를 한다.
위에서 언급했듯이 VO는 값을 통해 구분된다. 말 그대로 값이 같으면 같다고 판단한다.
여기에서 동일성 비교와 동등성 비교를 구분할 수 있다.
- 동일성(Identity) 비교: 객체나 값이 완전히 동일한지 비교한다. 메모리 내 주소값이 같은지 비교한다.
- 동등성(Equality) 비교: 객체의 내용이 동일한지 비교한다. 논리적 값(내용)이 같은지 비교한다.
그렇다면 동일한 prize 이름을 생성해서 동일성, 동등성을 비교하면 어떻게 나오는지 코드를 통해 확인해 보자.
public static void main(String[] args) {
Prize first = new Prize("노트북");
Prize second = new Prize("노트북");
boolean isIdentity = (first == second);
System.out.println("Check Identity = " + isIdentity);
boolean isEqual = first.equals(second);
System.out.println("Check Equality = " + isEqual);
}
/*
* 출력 결과
* Check Identity = false -> 다른 주소값을 가지므로 동일성은 false이다.
* Check Equality = false -> 값이 같으니 동등성이 true이어야하지만 아직 확인이 불가하다.
*/
예? 값이 같으면 같다면서요?
-> 값이 같다고 VO 자체가 같다는 걸 아무 처리도 하지 않은 상태에서 알 수는 없다.
이를 위해 필요한 것이 equals()와 hashCode()를 오버라이딩 하는 것이다. 값이 같으면 같다고 판단할 수 있도록 작성한다.
public class Prize {
private final String prizeName;
public Prize(String prizeName) {
this.prizeName = prizeName;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Prize prize = (Prize) o;
return Objects.equals(prizeName, prize.prizeName);
}
@Override
public int hashCode() {
return Objects.hash(prizeName);
}
}
그런데 값만 비교하면 되지 왜 hashCode()도 재정의하나요?
이건 이펙티브 자바 아이템 11(equals를 재정의하려거든 hashCode도 재정의하라)을 참고해 보았다.
- equals(Object)가 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야 한다. (Object 명세 발췌)
HashMap이나 HashSet과 같이 hash 값을 사용하는 곳에서 문제가 생길 수 있다. (같은 값인데 다른 해시값을 나타내므로!)
만약 equals()만 재정의하고 hashCode()를 재정의하지 않는다면, 아래와 같이 같은 값에 대해서도 다른 해시값을 갖게 되고 HashMap을 통해 값을 찾으려고 하면 제대로 된 값을 찾아올 수 없다.
public static void main(String[] args) {
Map<Prize, Integer> example = new HashMap<>();
example.put(new Prize("일등"), 1000);
int prizePrice = example.get(new Prize("일등"));
System.out.println("prizePrice = " + prizePrice);
}
-> 키의 해시값을 통해 값을 찾아오는데 new Prize("일등")에 해당하는 값이 없으므로 에러가 발생한다.
다시 hashCode()를 재정의하고 코드를 실행하니 키 값을 정상적으로 찾아오는 것을 확인할 수 있었다.
public static void main(String[] args) {
Map<Prize, Integer> example = new HashMap<>();
example.put(new Prize("일등"), 1000);
int prizePrice = example.get(new Prize("일등"));
System.out.println("prizePrice = " + prizePrice);
}
/*
* 출력 결과
* prizePrice = 1000
*/
마지막으로, equals() 재정의를 통해 동등성 비교도 제대로 수행하는지 알아보자.
마찬가지로 동일성 비교는 false이지만 동등성 비교는 true로 제대로 수행되고 있는 것을 알 수 있다.
public static void main(String[] args) {
Prize first = new Prize("노트북");
Prize second = new Prize("노트북");
System.out.println("firstHash = " + first);
System.out.println("secondHash = " + second);
boolean isIdentity = (first == second);
System.out.println("Check Identity = " + isIdentity);
boolean isEqual = first.equals(second);
System.out.println("Check Equality = " + isEqual);
}
/*
* 출력 결과
* firstHash = model.prize.Prize@2b47560
* secondHash = model.prize.Prize@2b47560
* Check Identity = false
* Check Equality = true
*/
여기서 알게 된 추가 팁! 나는 Identity를 비교하면서 주소값을 직접적으로 확인할 수 있는 방법은 없을까? 고민했었다.
그런데 자바는 객체 레퍼런스(메모리 주소)를 직접적으로 확인하는 것을 공식적으로 지원하지는 않는다고 한다.
객체 캡슐화와 보안을 위한 설계 원칙 중 하나라고 한다.
이때 대신 참조할 수 있는 것이 해시코드로 위 출력 결과에서 @이후에 적혀있는 부분이다.
처음에는 저 부분이 주소값을 나타내는 부분인줄알고 firstHash와 secondHash가 다르게 나오고 Check Identity가 false일줄 알았는데
hashCode()재정의를 하고나니 firstHash와 secondHash가 같은 값이 나오면서 Check Identity가 false로 나왔다.
뭐지? 해시코드를 재정의한다고 주소가 같아지는건 아닐텐데? 하면서 찾아보다보니 저 부분이 주소를 나타내고 있는게 아닌걸 알게됐다.
최종적으로 hashCode() 재정의를 통해 같은 값을 가지면 같은 해시코드를 갖도록 수정되었다.
(hashCode 재정의 전에는 값은 같지만 다른 해시코드가 출력되었었다.)
3. 유효성 검사와 같은 자체 로직을 가질 수 있다.
만약 prizeName의 길이가 1글자 미만 5글자 이상 이어야 한다면?
inputView나 다른 도메인 로직에서 이를 관리하기보다는 Prize 자체에서 관리하는 것이 합리적이다.
유효하지 않은 값으로 VO를 생성할 수 없다. 따라서 VO 유효성 검사는 생성 시간에 이루어져야 한다.
public class Prize {
private final String prizeName;
public Prize(String prizeName) {
validator(prizeName);
this.prizeName = prizeName;
}
private void validator(String prizeName) {
if (prizeName == null) {
throw new IllegalArgumentException("결과 이름은 null일 수 없다.");
}
if (prizeName.isEmpty() || prizeName.isBlank() || prizeName.length() > Constant.STEP_LENGTH) {
throw new IllegalArgumentException("결과 이름은 1 ~ " + Constant.STEP_LENGTH + " 길이의 문자이어야합니다.");
}
}
}
❯ DTO(Data Transfer Object)
데이터를 담아 전송하는 객체를 의미한다. 계층 간에 데이터를 넘겨줄 때 사용한다.
MVC 패턴을 예로 들면, model에서 생성된 결과(데이터)를 DTO에 담아 view로 넘겨주게 된다.
넘겨주는 것을 담당하는 것은 컨트롤러이다. 결국, view와 model은 서로의 존재를 몰라도 데이터를 주고받을 수 있다.
DTO는 VO와는 달리 getter/setter를 포함할 수 있다.
대신 이 외의 비즈니스 로직은 포함하지 않는다. 목적 그대로 "데이터를 전달" 하기 위해 사용되는 객체이다.
(과제를 구현하다가 DTO 클래스 내부에 검색하는 로직이 있어 model 쪽으로 다시 분리하기도 했다.)
그래서 데이터를 전달할 때에는 PrizeDto를 사용했다.
대신, 아래와 같은 일반 DTO 클래스를 생성하지 않고 java 16부터 정식 도입된 record를 사용했다.
public class PrizeDto {
private String prizeName;
public PrizeDto(String prizeName) {
this.prizeName = prizeName;
}
public String getPrizeName() {
return prizeName;
}
public void setPrizeName(String prizeName) {
this.prizeName = prizeName;
}
}
record를 적용한 DTO 예시
public record PrizeName(String prizeName) {
public PrizeName(Prize prize) {
this(prize.getPrizeName());
}
}
record가 뭔데?
필드 유형과 이름만 필요한 불변 데이터 클래스이다.
필드를 기반으로 자동으로 생성자, equals, hashCode, toString, getter 등을 제공한다.
덕분에 중복 코드를 줄여 코드를 간결하게 만들 수 있다.
record의 필드는 final로 선언되며 생성자에서 초기화되면 수정할 수 없다. 따라서 외부에서의 값 변경을 막아 안정적인 데이터 전달이 가능해진다.
-> Record에 대한 부분은 추후 추가로 정리할 예정이다.
결론
구분 | VO | DTO |
용도 | 값을 나타내는데 사용 | 데이터를 전달하는데 사용 |
가변/불변 | 불변 | 가변(setter가 있는 경우)/불변(setter가 없는 경우) |
로직 | 자체 유효성 검증 등 로직 가능 | getter, setter외에 로직 불가 |
기타 특징 | equals(), hashCode()를 재정의 해주어야함 |
이제 VO, DTO에 대한 내용은 알겠다.
그러면 VO도 결국 데이터를 가진 도메인이니까 DTO에 필요한 데이터만 담아서 전달하는 게 맞는 걸까?
이 부분은 혼자 많은 고민을 했으나 도저히 혼자서는 답을 찾기가 어려웠다.
(아무리 찾아봐도 두 개를 비교한 내용은 많았는데 어떻게 사용할지에 대한 내용은 없거나, 혹은 VO를 DTO처럼 사용해도 된다 / 안된다 의견이 너무 다양했다.)
그래서 이 부분은 리뷰어분께도 한번 의견을 여쭤보았다. 리뷰어분의 의견을 정리해 보면 아래와 같았다.
개발자에 따라 많이 차이가 나는 부분이라고 생각한다. 레이어 간의 데이터 교환은 DTO를 사용하는 것이 가장 안전한 방법이나 모든 코드를 그렇게 관리하면 너무 많은 DTO가 생기게 되고, 오히려 관리범위를 넘어서서 관리가 안 되는 상황이 생길 수 있다.
VO는 불변이어서 외부에서 변경이 불가하기에 안전하게 값으로 사용할 수 있다. 모든 값을 DTO로 변환해서 사용하면 물론 좋겠지만, 관리비용을 고려해 그냥 사용해야 할 수도 있다.
이는 상황에 따라 달라질 수 있으므로 여러 가지 트레이드오프를 고려하여 스스로 판단해서 사용하는 것이 중요할 것이다.
지난번 static때와 마찬가지로 "정답"은 없다.
본인이 생각하기에 VO를 DTO로 변환해서 전달하는 것이 합리적이라고 생각된다면 해당 방식을,
혹은 불필요한 과정이라고 생각되면 해당 객체에서 값을 가져와서 사용하는 것을 고려할 수 있다.
(실제 내 과제에서는 굳이 DTO로 감싸지 않고 값을 가져오는 게 더 나았을 것 같다.
굳이 String name 필드 하나 있는 것을 VO로 감싸고 DTO로 변환하면서 로직이 복잡해졌다.)
결국 이 글 마저 "될 수도 있고 안될 수도 있다"는 결론에 도달하게 된 것 같아 개운하지는 않지만, 프로그래밍이라는 것이 그런 것 같다.
본인 스스로 코드에 대한 철학을 가지고 어떤 상황에서 어떤 방식을 채택할지 계속해서 고민해야 한다.
언젠가는 "이럴 땐 이 방식을 써야겠다!" 스스로 생각하고 판단할 수 있는 개발자가 되고 싶다.
참고자료
이펙티브 자바 - 조슈아 블로크 저
'우아한테크코스 > 레벨 1 - Java' 카테고리의 다른 글
[GitHub] 코드리뷰가 익숙하지 않은 분들을 위한 GitHub에서 코드리뷰 하는법 (0) | 2024.03.30 |
---|---|
[JAVA] 상태 패턴(State Pattern) with BlackJack (21) | 2024.03.19 |
[JAVA] 캐싱 활용하기 (0) | 2024.03.11 |
[JAVA] static 키워드와 static factory method (4) | 2024.02.29 |
[JAVA] 함수형 인터페이스 개념과 예시 (0) | 2024.02.21 |