안녕하세요 ~ 헤나입니다! 😁
저번에는 [20230217] 우아한 테크코스 5기 LEVEL 1 - 사다리 생성 페어 회고를 작성했고
이번에는 "사다리 생성 1단계 & 2단계" 회고를 진행해보려고 합니다.
😊 : 리뷰어
🥔 : 글쓴이
💡 TDD
사다리 미션을 진행하면서 목표를 잡았던 것은 "TDD로 끝까지 진행"하기입니다.
다음과 같이 생각해야 할 순간들이 있었습니다.
- 테스트 작성 순서
- 테스트 작성 이전 설계
- A 객체 테스트를 위한 B 객체 생성 및 테스트
- 코드 리팩터링 타이밍
- 리팩터링은 어느 수준까지
저는 다음과 같은 순서로 TDD를 진행했습니다.
"객체 생성 예외 테스트 -> 객체 생성 성공 테스트 -> 기능 예외 테스트 -> 기능 성공 테스트 -> 리팩터링"
테스트 순서에 정답은 없습니다. 그래도 한 번 짚고 넘어가야지 마음이 편할 거 같아서 여쭤봤습니다.
🥔 :
tdd 테스트 순서는
객체 생성 예외 테스트 -> 객체 생성 성공 테스트 -> 기능 예외 테스트 -> 기능 성공 테스트 이런 식으로 필요한 순서에서 가장 작은 기능으로 테스트 작성이 진행 됐는데 테스트 작성 순서에 대한 정답이 있는지 궁금합니다.
😊 :
헤나께서는 객체 생성 예외 테스트 -> 객체 생성 성공 테스트 -> 기능 예외 테스트 -> 기능 성공 테스트로 말씀해 주셨는데, 전 그것도 정답이라고 생각합니다. 그럼 객체 생성 성공 테스트 -> 객체 생성 예외 테스트 -> 기능 예외 테스트 -> 기능 성공 테스트는 오답이냐? 그렇지는 않고 이것도 정답입니다 ㅎㅎ스스로 TDD를 통해 실패하는 테스트를 먼저 만들고 성공하는 테스트를 만드는 시행착오를 다양하게 해 보시면서 가장 적합하다고 느껴지는 프로세스가 생기면 그것이 가장 정답이 아닐까 합니다!
저는 TDD를 진행할 때 먼저 실패 테스트를 작성하면서 틀을 잡아갔습니다.
리뷰어를 해주신 제이온이 말씀해 주신 대로 나에게 적합한 프로세스로 진행한 거 같지만 어떤 책임을 가진 객체를 만들 때
기능 성공 테스트를 먼저 작성하고 객체가 무엇이 필요할지 알아가는 것도 좋은 플로우가 될 수 있습니다.
💡 생성자 매개변수 타입
생성자 매개변수 타입은 항상 해당 객체의 필드가 있어야 할까 ? 🤔
해당 객체의 필드의 값이 넘어오지 않아도 상관없습니다 !
저는 생성자에서 무언가 생성 혹은 변환하는 것을 비선호했고
생성자 내부에서 해야 할 일이 아니라 생각하고 완성된 데이터를 받으려 했습니다.
그래서 다음과 같이 Participants 생성자를 작성했습니다.
Participants(참여자)는 List<Name>를 필드로 가지고 있습니다.
저는 당연하게 List<Name> 타입을 인자로 받아서 클래스 필드 names에 주입하려고 했습니다.
public class Participants {
private final List<Name> names;
public Participants(final List<Name> names) {
validateParticipants(names);
this.names = names;
}
// ...
}
LadderController 에서는 이름(List<String>)들을 받아서 List<Name>으로 변환해 주고
Participants의 생성자 인자로 넘겨주고 있습니다.
public class LadderController {
// ...
public void run() {
final Participants participants = repeatAndPrintCause(this::getParticipants);
// ...
}
private Participants getParticipants() {
final List<Name> names = inputView.readNames()
.stream()
.map(Name::new)
.collect(Collectors.toList());
return new Participants(names);
}
Controller에서 데이터를 변환하는 메서드가 있었지만 Participants 내부에 있는 것보다 나은 방식 생각했습니다.
그리고 제이온으로부터 다른 방식을 제안받았습니다.
😊 :
Participants의 생성자에서 List 받아서 처리해 보는 건 어떨까요?
🥔 :
저는 List<String>을 LadderController에서 List<Name>으로 변환하여 넘긴 이유는
Participants(참여자) 가 Name을 생성할 이유는 없다고 생각해서였습니다.
제이온의 커멘트는 Participants 생성자의 인자로 List<String>을 받아서 생성자 내부에서 Name 을 생성하라는 말씀으로 판단했는데 Participants와 Name 연관 관계를 맺고 있으니 Participants 생성자에서 Name을 생성하는 것이 무리는 아니라고 하시는 건지 궁금합니다 🤔
😊 :
안녕하세요 헤나
말씀주신대로 참가자와 이름은 연관 관계가 있고, 실제 세계에 대입해도 참가자(넓게 보면 사람)가 이름을 만드는 것은 자연스러워 보였습니다. 그리고 객체를 생성하는 방식은 여러 개 두어도 무방하며, 지금과 같이 살짝 복잡한 로직이 들어간 객체 생성의 경우 생성자보다는 정적 팩터리 메서드를 이용하는 편입니다!
public class Participants {
private final List<Name> names;
// ...
}
public class Participants {
private final Names names;
public Participants(final List<String> names) {
validateParticipants(names);
this.names = new Names(names);
}
// ...
}
public class Names {
private final List<Name> names;
public Names(final List<String> names) {
validateNames(names);
this.names = createNames(names);
}
private List<Name> createNames(final List<String> names) {
return names.stream()
.map(Name::new)
.collect(Collectors.toList());
}
}
💡 null 체크, Optional
Optional을 이용해서 null 체크를 진행해 보자
😊 :
Optional을 학습해서 적용해 보시면 어떨까 합니다
🥔 :
아래처럼 null이 들어왔을 경우 빈 리스트를 반환하여 isEmpty() 메서드를 이용한 검증 로직으로 변경했습니다.
public Participants(final List<String> nameValues) {
final List<String> names = Optional.ofNullable(nameValues).orElse(List.of());
validateParticipants(names);
this.names = new Names(names);
}
private void validateParticipants(final List<String> names) {
if (names.isEmpty()) {
throw new IllegalArgumentException(PARTICIPANTS_EMPTY_EXCEPTION);
}
}
🥔 :
리스트에 null을 받아오는 경우에는 orElse(List.of())를 이용하여 빈 리스트를 받아왔고
값이 필요한 경우에는 기본 값을 갖도록 했습니다.
이러한 null 체크를 Optional을 이용하여 다른 값으로 대체하여 검증하는 방식으로 진행했습니다.
기존 Participants 객체를 생성할 때 인자가 null인지 검증했고
아래 코드와 같이 if (names == null) 방식을 이용했습니다.
public class Participants {
public Participants(final List<Name> names) {
validateParticipants(names);
this.names = names;
}
private void validateParticipants(final List<Name> names) {
if (names == null) {
throw new IllegalArgumentException(PARTICIPANTS_NULL_EXCEPTION);
}
// ...
}
이미 이상하다고 느끼신 분들이 계실 겁니다.
위에는 Optional을 잘 사용했다고는 하기 힘든, Optional을 남용하고 있다고 할 수도 있습니다.
다음과 같은 코드입니다.
private String validateValue(final String value) {
return Optional.ofNullable(value).orElse("DEFAULT");
}
단순하게 값을 가져오기 위해서 Optional을 이용하고 있다거나
Collection 같은 경우는 비어있는지 확인하는 수단으로 진행할 수 있을 텐데
Optional로 한 번 더 감싸서 비용을 증가시키고 있습니다.
이와 마찬가지로 위 Participants 내부 코드에서 Collection을 Optional로 한 번 감싼 후에 List.of()를 반환하고 있는데
이럴 경우에는 직접 null 인지 비교해 준 후 List.of()를 반환하는 것보다 비용이 비싸므로 상황에 맞게 선택할 수 있어야 합니다.
💡 UnmodifiableList
UnmodifiableList,
[얕은 / 깊은] 방어적 복사
사다리 게임에서 참여자 이름은 입력된 순서대로 출력되어야 합니다.
이러한 이유로 저는 외부에서 데이터 순서를 변경하지 못하게 UnmodifiableList를 사용했습니다.
public List<String> getNames() {
return names.stream()
.map(Name::getValue)
.collect(Collectors.toUnmodifiableList());
}
😊 :
Unmodifiable를 사용하게 되면, 처음 getNames() API를 사용하는 동료 개발자 입장에서 어떠한 단점이 있을까요?
문뜩 Unmodifiable이 무엇인지 명확한 설명이 불가능해서 테스트 코드를 작성하면서 조금 더 알아봤습니다.
🟦 collect(Collectors.toUnmodifiableList())
- collect(Collectors.toUnmodifiableList())는 정렬할 수 없습니다.
- collect(Collectors.toUnmodifiableList())는 데이터를 추가할 수 없습니다.
- collect(Collectors.toUnmodifiableList())는 데이터를 삭제할 수 없습니다.
- collect(Collectors.toUnmodifiableList())는 객체에 접근하여 수정할 수 있습니다.
- collect(Collectors.toUnmodifiableList())는 방어적 복사를 합니다.
- collect(Collectors.toUnmodifiableList())는 깊은 방어적 복사를 하지 않습니다.
제가 사용한 collect(Collectors.toUnmodifiableList())는 다음과 같은 구현 코드를 보실 수 있습니다.
/**
* Returns a {@code Collector} that accumulates the input elements into an
* <a href="../List.html#unmodifiable">unmodifiable List</a> in encounter
* order. The returned Collector disallows null values and will throw
* {@code NullPointerException} if it is presented with a null value.
*
* @param <T> the type of the input elements
* @return a {@code Collector} that accumulates the input elements into an
* <a href="../List.html#unmodifiable">unmodifiable List</a> in encounter order
* @since 10
*/
@SuppressWarnings("unchecked")
public static <T>
Collector<T, ?, List<T>> toUnmodifiableList() {
return new CollectorImpl<>((Supplier<List<T>>) ArrayList::new, List::add,
(left, right) -> { left.addAll(right); return left; },
list -> (List<T>)List.of(list.toArray()),
CH_NOID);
}
이것을 가지고 테스트 코드를 작성해 봤습니다.
class CollectorToUnmodifiableListTest {
@Test
void collectorsToUnmodifiable_정렬_할_수_없다() {
final List<Integer> values = new ArrayList<>(List.of(1, 3, 2));
final List<Integer> newValues = values.stream().collect(Collectors.toUnmodifiableList());
assertThatThrownBy(() -> Collections.sort(newValues))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void collectorsToUnmodifiable_데이터를_추가_할_수_없다() {
final List<Integer> values = new ArrayList<>();
final List<Integer> newValues = values.stream().collect(Collectors.toUnmodifiableList());
assertThatThrownBy(() -> newValues.add(1))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void collectorsToUnmodifiable_데이터를_삭제_할_수_없다() {
final List<Integer> values = (new ArrayList<>());
final List<Integer> newValues = values.stream().collect(Collectors.toUnmodifiableList());
assertThatThrownBy(() -> newValues.remove(1))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void collectorsToUnmodifiable_내부_데이터를_변경_할_수_있다() {
final List<Name> names = new ArrayList<>();
names.add(new Name("헤나"));
final List<Name> newNames = names.stream().collect(Collectors.toUnmodifiableList());
newNames.get(0).setValue("하이에나");
final Name name = newNames.get(0);
assertThat(name).hasFieldOrPropertyWithValue("value", "하이에나");
}
@Test
void collectorsToUnmodifiable_방어적_복사를_한다() {
final List<Name> names = new ArrayList<>();
names.add(new Name("헤나"));
final List<Name> newNames = names.stream().collect(Collectors.toUnmodifiableList());
names.remove(0);
assertThat(newNames).hasSize(1);
}
@Test
void collectorsToUnmodifiable_깊은_방어적_복사를_하지_않는다() {
final List<Name> names = new ArrayList<>();
names.add(new Name("헤나"));
final List<Name> newNames = names.stream().collect(Collectors.toUnmodifiableList());
newNames.get(0).setValue("하이에나");
Name name = names.get(0);
assertThat(name.getValue()).isEqualTo("하이에나");
}
static class Name {
private String value;
public Name(final String value) {
this.value = value;
}
public String getValue() {
return value;
}
public void setValue(final String value) {
this.value = value;
}
}
}
🟦 Collections.unmodifiableList()
- Collections.unmodifiableList()는 정렬할 수 없습니다.
- Collections.unmodifiableList()는 데이터를 추가할 수 없습니다.
- Collections.unmodifiableList()는 데이터를 삭제할 수 없습니다.
- Collections.unmodifiableList()는 객체에 접근하여 수정할 수 있습니다.
- Collections.unmodifiableList()는 방어적 복사를 하지 않습니다.
- Collections.unmodifiableList()는 깊은 방어적 복사를 하지 않습니다.
Collections.unmodifiableList()는 collect(Collectors.toUnmodifiableList())와 비교했을 때 방어적 복사, 추가, 삭제, 정렬이 되지 않습니다.
현재 저의 코드에서 방어적 복사, 정렬이 필요할 수도 있으니 Collections.unmodifiableList()를 사용할 이유는 없을 거 같습니다.
class UnmodifiableTest {
@Test
void unmodifiable_리스트는_정렬_할_수_없다() {
final List<Integer> unmodifiableValues = Collections.unmodifiableList(new ArrayList<>());
assertThatThrownBy(() -> Collections.sort(unmodifiableValues))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void unmodifiable_리스트는_데이터를_추가_할_수_없다() {
final List<Integer> unmodifiableValues = Collections.unmodifiableList(new ArrayList<>());
assertThatThrownBy(() -> unmodifiableValues.add(10))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void unmodifiable_리스트는_데이터를_삭제_할_수_없다() {
final List<Integer> unmodifiableValues = Collections.unmodifiableList(new ArrayList<>());
assertThatThrownBy(() -> unmodifiableValues.remove(10))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void unmodifiable_리스트는_내부_데이터를_변경_할_수_있다() {
final List<Name> names = new ArrayList<>();
names.add(new Name("헤나"));
final List<Name> unmodifiableNames = Collections.unmodifiableList(names);
unmodifiableNames.get(0).setValue("하이에나");
final Name name = unmodifiableNames.get(0);
assertThat(name).hasFieldOrPropertyWithValue("value", "하이에나");
}
@Test
void unmodifiable_리스트는_방어적_복사를_하지_않는다() {
final List<Name> names = new ArrayList<>();
names.add(new Name("헤나"));
final List<Name> newNames = Collections.unmodifiableList(names);
names.remove(0);
assertThat(newNames).hasSize(0);
}
@Test
void unmodifiable_리스트는_깊은_방어적_복사를_하지_않는다() {
final List<Name> names = new ArrayList<>();
names.add(new Name("헤나"));
final List<Name> newNames = names.stream().collect(Collectors.toList());
newNames.get(0).setValue("하이에나");
Name name = names.get(0);
assertThat(name.getValue()).isEqualTo("하이에나");
}
private static class Name {
private String value;
public Name(final String value) {
this.value = value;
}
public String getValue() {
return value;
}
public void setValue(final String value) {
this.value = value;
}
}
}
🟦 new ArrayList<>()
그렇다면 new ArrayList<>()와 UnmodifiableList의 차이점은 뭘까요 ?
- new ArrayList<>()는 정렬할 수 있습니다.
- new ArrayList<>()는 데이터를 추가할 수 있습니다.
- new ArrayList<>()는 데이터를 삭제할 수 있습니다.
- new ArrayList<>()는 객체에 접근하여 수정할 수 있습니다.
- new ArrayList<>()는 방어적 복사를 합니다.
collect(Collectors.toUnmodifiableList())과 비교했을 때 추가, 삭제, 정렬이 가능하다는 점이 있습니다.
ArraytList는 다음과 같은 구현 코드를 가지고 있습니다.
/**
* Constructs a list containing the elements of the specified
* collection, in the order they are returned by the collection's
* iterator.
*
* @param c the collection whose elements are to be placed into this list
* @throws NullPointerException if the specified collection is null
*/
public ArrayList(Collection<? extends E> c) {
Object[] a = c.toArray();
if ((size = a.length) != 0) {
if (c.getClass() == ArrayList.class) {
elementData = a;
} else {
elementData = Arrays.copyOf(a, size, Object[].class);
}
} else {
// replace with empty array.
elementData = EMPTY_ELEMENTDATA;
}
}
마찬가지로 테스트 코드를 작성해서 확인해 보겠습니다.
class ArrayListTest {
@Test
void newArrayList_리스트는_정렬_할_수_있다() {
final List<Integer> values = new ArrayList<>(List.of(1, 3, 2));
final List<Integer> newValues = new ArrayList<>(values);
Collections.sort(newValues);
assertThat(newValues).containsExactlyElementsOf(List.of(1, 2, 3));
}
@Test
void newArrayList_리스트는_데이터를_추가_할_수_있다() {
final List<Integer> values = new ArrayList<>();
final List<Integer> newValues = new ArrayList<>(values);
newValues.add(1);
assertThat(newValues).hasSize(1);
}
@Test
void newArrayList_리스트는_데이터를_삭제_할_수_있다() {
final List<Integer> values = (new ArrayList<>());
final ArrayList<Integer> newValues = new ArrayList<>(values);
newValues.add(0);
newValues.remove(0);
assertThat(newValues).isEmpty();
}
@Test
void newArrayList_리스트는_내부_데이터를_변경_할_수_있다() {
final List<Name> names = new ArrayList<>();
names.add(new Name("헤나"));
final List<Name> newNames = new ArrayList<>(names);
newNames.get(0).setValue("하이에나");
final Name name = newNames.get(0);
assertThat(name).hasFieldOrPropertyWithValue("value", "하이에나");
}
@Test
void newArrayList_리스트는_방어적_복사를_한다() {
final List<Name> names = new ArrayList<>();
names.add(new Name("헤나"));
final List<Name> newNames = new ArrayList<>(names);
names.remove(0);
assertThat(newNames).hasSize(1);
}
@Test
void newArrayList_리스트는_깊은_방어적_복사를_하지_않는다() {
final List<Name> names = new ArrayList<>();
names.add(new Name("헤나"));
final List<Name> newNames = new ArrayList<>(names);
newNames.get(0).setValue("하이에나");
Name name = names.get(0);
assertThat(name.getValue()).isEqualTo("하이에나");
}
private static class Name {
private String value;
public Name(final String value) {
this.value = value;
}
public String getValue() {
return value;
}
public void setValue(final String value) {
this.value = value;
}
}
}
🟦 collect(Collectors.toList())
아래 코드는 스트림을 이용해서 List로 받고 있습니다.
이러한 경우는 단순하게 값을 가져오기만 할까요 ?
public List<String> getNames() {
return names.stream()
.map(Name::getValue)
.collect(Collectors.toList());
}
사실 이 경우에도 방어적 복사가 일어나고 있습니다.
그리고 내부 코드를 보면 ArratList로 새로 생성하고 있는 것도 볼 수 있습니다.
/**
* Returns a {@code Collector} that accumulates the input elements into a
* new {@code List}. There are no guarantees on the type, mutability,
* serializability, or thread-safety of the {@code List} returned; if more
* control over the returned {@code List} is required, use {@link #toCollection(Supplier)}.
*
* @param <T> the type of the input elements
* @return a {@code Collector} which collects all the input elements into a
* {@code List}, in encounter order
*/
public static <T>
Collector<T, ?, List<T>> toList() {
return new CollectorImpl<>((Supplier<List<T>>) ArrayList::new, List::add,
(left, right) -> { left.addAll(right); return left; },
CH_ID);
}
이번에도 테스트를 통해서 확인해 보겠습니다.
class CollectorToListTest {
@Test
void collectorsToList_정렬_할_수_있다() {
final List<Integer> values = new ArrayList<>(List.of(1, 3, 2));
final List<Integer> newValues = values.stream().collect(Collectors.toList());
Collections.sort(newValues);
assertThat(newValues).containsExactlyElementsOf(List.of(1, 2, 3));
}
@Test
void collectorsToList_데이터를_추가_할_수_있다() {
final List<Integer> values = new ArrayList<>();
final List<Integer> newValues = values.stream().collect(Collectors.toList());
newValues.add(1);
assertThat(newValues).hasSize(1);
}
@Test
void collectorsToList_데이터를_삭제_할_수_있다() {
final List<Integer> values = (new ArrayList<>());
final List<Integer> newValues = values.stream().collect(Collectors.toList());
newValues.add(0);
newValues.remove(0);
assertThat(newValues).isEmpty();
}
@Test
void collectorsToList_내부_데이터를_변경_할_수_있다() {
final List<Name> names = new ArrayList<>();
names.add(new Name("헤나"));
final List<Name> newNames = names.stream().collect(Collectors.toList());
newNames.get(0).setValue("하이에나");
final Name name = newNames.get(0);
assertThat(name).hasFieldOrPropertyWithValue("value", "하이에나");
}
@Test
void collectorsToList_방어적_복사를_한다() {
final List<Name> names = new ArrayList<>();
names.add(new Name("헤나"));
final List<Name> newNames = names.stream().collect(Collectors.toList());
newNames.get(0).setValue("하이에나");
names.remove(0);
assertThat(newNames).hasSize(1);
}
@Test
void collectorsToList_깊은_방어적_복사를_하지_않는다() {
final List<Name> names = new ArrayList<>();
names.add(new Name("헤나"));
final List<Name> newNames = names.stream().collect(Collectors.toList());
newNames.get(0).setValue("하이에나");
Name name = names.get(0);
assertThat(name.getValue()).isEqualTo("하이에나");
}
private static class Name {
private String value;
public Name(final String value) {
this.value = value;
}
public String getValue() {
return value;
}
public void setValue(final String value) {
this.value = value;
}
}
}
Collections.unmodifiableList(),
collect(Collectors.toUnmodifiableList()),
new ArrayList(),
collect(Collectors.toList())
4가지 생각났는데
Collections.unmodifiableList()는 방어적 복사가 되지 않으므로 주의하셔야 합니다.
하지만 collect(Collectors.toUnmodifiableList()) 를 이용한다면 이는 방어적 복사가 되고 있음을 인지하고 사용하시면 될 거 같습니다.
🟦 [얕은, 깊은] 방어적 복사
객체를 복사할 때 주의할 점이 있습니다.
바로 얕은 복사와 깊은 복사가 있습니다.
사실 테스트 코드를 보시면 깊은 복사가 되지 않은 것을 확인할 수 있습니다.
collect(Collectors.toList()) 예시 코드를 가져오겠습니다.
@Test
void collectorsToList_방어적_복사를_한다() {
final List<Name> names = new ArrayList<>();
names.add(new Name("헤나"));
final List<Name> newNames = names.stream().collect(Collectors.toList());
newNames.remove(0);
assertThat(names).hasSize(1);
}
@Test
void collectorsToList_깊은_방어적_복사를_하지_않는다() {
final List<Name> names = new ArrayList<>();
names.add(new Name("헤나"));
final List<Name> newNames = names.stream().collect(Collectors.toList());
newNames.get(0).setValue("하이에나");
Name name = names.get(0);
assertThat(name.getValue()).isEqualTo("하이에나");
}
두 테스트 다 names를 복사해서 newNames를 만들고 newNames를 통해서 데이터를 조작합니다.
첫 번째 테스트에서 names의 데이터 개수는 변경되지 않았습니다.
두 번째 테스트에서는 names의 첫 번째 데이터 상태가 변경됐습니다.
이유는 [얕은 / 깊은] 복사 중에 얕은 복사를 했기 때문입니다.
🟦 얕은 복사
먼저 첫 번째 테스트를 보겠습니다.
"헤나"를 names에 추가합니다.
names를 방어적 복사해서 newNames를 만듭니다. (또는 new ArrayList<>())
newNames에 첫 번째 원소를 삭제합니다.
이제 실제 데이터는 다음과 같습니다.
names에는 "헤나"가 존재하지만
newNames에는 "헤나"가 존재하지 않습니다.
그 이유는 collect(Collectors.toList())는 얕은 복사를 해주고 names와 newNames의 주소값이 다르기 때문입니다.
얕은 복사 덕분에 newNames에 있는 원소를 삭제한다고 해도 names에 영향이 가지 않았습니다.
그렇다면 두 번째 테스트에서는 newNames에 있는 "헤나"를 수정했다고 해서 왜 names까지 영향이 갔을까요 ?
🟦 깊은 복사
두 번째 테스트도 똑같이 그림을 통해서 보겠습니다.
names에 "헤나"를 추가합니다.
똑같이 [얕은] 방어적 복사를 해서 newNames를 만듭니다.
이때 "헤나"는 같은 주소를 같은 객체입니다.
newNames에 있는 첫 번째 원소 "헤나"를 "하이에나"로 변경하면 객체 상태가 변경됩니다.
결국 "헤나" 객체는 복사되지 않았고
names라는 틀만 복사되었던 것입니다.
이를 해결하기 위해서는 "헤나" 객체도 복사하면 됩니다.
이제 newNames에 있는 "헤나" 객체의 상태를 변경해도 names에 있는 "헤나" 객체의 상태에 영향을 끼치지 않습니다.
깊은 복사를 진행한 테스트 코드는 다음과 같습니다.
@Test
void collectorsToList_깊은_방어적_복사를_하기_위해_내부_객체도_복사_한다() {
final List<Name> names = new ArrayList<>();
names.add(new Name("헤나"));
final List<Name> newNames = names.stream()
.map(Name::getValue)
.map(Name::new)
.collect(Collectors.toList());
newNames.get(0).setValue("하이에나");
Name name = names.get(0);
assertThat(name.getValue()).isEqualTo("헤나");
}
그러면 다음과 같이 names에 "헤나" 객체의 상태가 변경되는 위험성을 없앨 수 있습니다.
깊은 복사가 좋아 보이지만 새로 생성하는 비용이 있으므로 상황에 맞게 사용해야겠습니다.
💡 TestFixture
자주 사용되는 테스트용 객체
TestFixture
TDD로 진행하다 보니 손이 아파서 TestDummy에 계속 사용할 객체를 모아두고 진행했습니다.
public class TestDummy {
public static final Name NAME_HYENA = new Name("hyena");
public static final Name NAME_ROSIE = new Name("rosie");
public static final Participants PARTICIPANTS_SIZE_2 = new Participants(List.of(NAME_ROSIE, NAME_HYENA));
public static final Height HEIGHT_VALUE_1 = new Height(1);
public static final BooleanGenerator TEST_BOOLEAN_GENERATOR = new BooleanGenerator() {
Deque<Boolean> deque = new ArrayDeque<>(List.of(true, false));
@Override
public boolean generate() {
Boolean polled = deque.pollFirst();
deque.addLast(polled);
return polled;
}
};
public static final LineCreator TEST_LINE_CREATOR = new LineCreator(TestDummy.TEST_BOOLEAN_GENERATOR);
}
😊 :
이렇게 자주 사용되는 테스트용 객체를 따로 분리한 것을 TestFixture라고 합니다!
하지만 현재 모든 테스트용 객체가 한 곳에 모여 있는데, TestFixture도 분리해 보는 건 어떨까요?
나중 되면 온갖 테스트용 객체가 덕지덕지 붙어있어서 구분하기 힘들어집니다 ㅜㅜ
나름 잘 만들었다고 생각했지만 아쉬운 부분들이 보였습니다.
뭉치면 보기도 호출하기도 힘들어지는 상태였습니다.
때문에 저는 TestFixture라는 네이밍을 가지고 여러 객체로 분리했습니다.
🥔 :
TestDummy라는 클래스명을 ...TestFixture으로 변경하여 분리했습니다. 👍
TestFixture를 편리성을 위해서 만들어 놓았는데 만약이라도 해당 객체의 상태가 변경된다면 오류가 발생할 수 있을 거 같습니다.
때문에 메서드 호출 시 새로 생성해서 보내주는 방식으로 구현해 봤습니다!
public abstract class NameFixture {
public static Name getNameRosie() {
return new Name("rosie");
}
public static Name getNameHyena() {
return new Name("hyena");
}
public static Name getNameJayon() {
return new Name("jayon");
}
}
😊 :
훌륭합니다! 제가 우려했던 부분이 TestFixture의 상태인데, 매번 생성하는 방식으로 잘해주셨네요.
다만 메서드 네이밍을 getXXX으로 하기보다는 geneateXXX나 createXXX로 바꾸면 어떨까 합니다.
🥔 :
메서드 네이밍을 createXXX 로 변경해서 계속해서 새로운 객체를 반환한다는 의미를 살리도록 했습니다 !
메서드 호출 시 새로운 객체를 반환하니 상태도 안전하고 메서드 네이밍을 createXXX로 변경해서 여기서 끝인 줄 알았습니다.
public abstract class NamesFixture {
public static Names getNamesSize2() {
return new Names(List.of("rosie", "hyena"));
}
public static Names getNamesSize3() {
return new Names(List.of("rosie", "hyena", "jayon"));
}
}
하지만 지금 코드 같은 경우는 너무 정적이고 다른 이름으로 생성하기 위해서는 메서드를 계속 작성해줘야 합니다.
😊 :
흠 TestFixture를 좀 더 잘 쓰려면 범용적인 메서드가 되어야 할 것 같아요. (이건 정답은 없습니다)
public static Names createNames(int nameSize) {
List<String> names = // TODO: name 값을 랜덤으로 'nameSize'만큼 생성
return new Names(names)
}
😊 :
요런 느낌으로 전체적인 테스트 픽스쳐를 수정해 보면 어떨까요?
name 값은 랜덤이 아니어도 그냥 "name-1", "name-2", ... 이런 식으로 주어도 무방해 보입니다.
🥔 :
아래와 같은 방식으로 size를 넣으면 그에 맞는 크기가 생성될 수 있도록 메서드를 수정했습니다 !
public abstract class NamesFixture {
public static Names createNames(final int size) {
final List<String> nameValues = new ArrayList<>();
for (int count = 0; count < size; count++) {
nameValues.add("name" + count);
}
return new Names(nameValues);
}
}
이런 식으로 점차 TestFixture의 구조를 개선해 나갔습니다. 인자를 받아 동적으로 데이터를 생성할 수 있고 상태에 대해서도 안전합니다. 그래도 아직 반영하지 못한 아쉬운 부분을 제이온이 설명해 주셔서 아래에 어떤 내용인지만 작성하겠습니다.
😊 :
요런 느낌을 의도한 것 맞습니다~
근데 이렇게 되면 사실 new Name() 을 쓰는 거랑 크게 달라지는 게 없어서 주로 디폴트 파라미터 값이 들어간 메서드를 많이 씁니다.
public class MyClass {
public void myMethod(String param1, int param2, boolean param3) {
// Method implementation here
}
public void myMethod(String param1, int param2) {
myMethod(param1, param2, false); // default value for param3
}
// Default value for param2 and param3
public void myMethod(String param1) {
myMethod(param1, 0, false);
}
}
😊 :
이 느낌을 살려서 createName() 을 수행하더라도 뭔가 내부 디폴트 값이 있다면 테스트 코드를 작성하기 좀 더 수훨해집니다!
하지만 이건 선택 사항이라서 헤나께서 고민해 보시고 반영할지 말지 결정하시면 될 듯합니다.
현재 NameFixture 코드는 다음과 같이 구현되어 있는데 디폴트 값을 지정하면 보다 편리하게 사용할 수 있습니다.
public abstract class NameFixture {
public static Name createName(final String value) {
return new Name(value);
}
}
이 글을 보신 여러분은 디폴트 값을 지정해서 이용하시면 좋겠습니다 !
💡 원시 값 포장
원시값을 포장하라
사다리 미션을 진행하면서
사다리 결과를 가져오기 위해서는 다음과 같은 정보가 필요했습니다.
- int, 사다리 가로 크기
- int, 사다리 세로 크기
- int, 참여자 위치
그리고 코드를 구현했을 때 아무리 변수명을 잘 지어도 같은 타입을 가지고 있기 때문에 사용하는데 많이 어려웠습니다.
다음과 같이 가로, 세로를 반대로 넣는 상황이 발생하기도 했습니다.
public class LadderController() {
public void run() {
// ...
final int width = 3;
final int height = 2;
final Lines lines = new LineCreator(height, width);
}
}
public class LineCreator {
public Lines createLines(final int width, final int height) {
// ...
}
}
이러한 불편함을 해소하기 위해 원시값을 포장해서 Width, Height 객체를 만들었습니다.
public class Width {
private final int value;
public Width(final int value) {
validatePositive(value);
this.value = value;
}
public int getValue() {
return value;
}
}
public class Height {
private final int value;
public Width(final int value) {
validatePositive(value);
this.value = value;
}
public int getValue() {
return value;
}
}
잘못된 순서로 넣을 시 컴파일 에러가 일어나 안정성을 높일 수 있었습니다.
public class LadderController() {
public void run() {
// ...
final Width width = new Width(3);
final Height height = new Height(2);
final Lines lines = new LineCreator(width, height);
}
}
public class LineCreator {
public Lines createLines(final Width width, final Height height) {
// ...
}
}
비슷한 예시로
사다리 시작 위치(int)에서 시작해서 사다리 끝 위치(int)를 반환하는 로직이 있습니다.
이 경우에도 마찬가지로 Position 객체를 만들어서 사용했습니다.
public class Position {
private final int value;
public Position(final int value) {
this.value = value;
}
public Position move(final int value) {
return new Position(this.value + value);
}
public int getValue() {
return this.value;
}
}
private Position findNextPosition(final Position column, final Position row) {
// ...
}
findNextPosition 메서드를 가져온 이유가 있습니다.
현재 메서드 내부에서는 3개의 위치(Potision)를 가지게 되는데
원시 값으로 포장했더라도 같은 타입이니 다시 문제가 될 수 있었습니다.
- Position, 참여자 위치
- Position, 사다리 가로 위치
- Position, 사다리 세로 위치
이렇다 보니 위치를 의미하는 Position을 다시 Column, Row, Position으로 나누고 싶었습니다.
같은 의미를 갖는 원시값 포장이자 VO는 여러 개 있어도 될까 ?
위치를 표현할 때 Column, Row, Position 세 가지 객체를 이용하면 훨씬 더 안전하게 사용할 수 있습니다.
하지만 똑같은 원시값을 가지고 있고 세 가지 모두 위치를 표현하는데 과연 괜찮을지 궁금했습니다.
🥔 :
정수형을 감싼 여러 개의 객체, 중복이라고 볼 수 있을지 궁금합니다.
미션 진행 중에 가로길이, 세로 길이, 인덱스와 같은 정수형 숫자를 계속 사용해야 했습니다.
때문에 변수명으로만 구분하면 헷갈려서 값 객체를 만들었습니다.
값 객체를 사용하다 보니 정수형 숫자를 바로 넘길 수 있는 부분에서 값객체로 변환해서 넘기는 일이 생겼습니다.
만약 같은 타입을 계속해서 사용한다면 구현 도중에 잘못 사용하는 부분들이 많았을 거 같습니다. 그러한 부분을 서로 다른 타입의 값객체를 이용해서 보다 정확한 구현이 장점이라고 느꼈습니다.
Width, Height, Position 가 가지고 있는 필드를 보면 int 타입의 필드를 하나 가지고 있습니다.
각기 사용하는 부분들이 다르기 때문에 확실하게 구분할 수 있었지만 더 나아가서 Row, Column 와 같은 객체들도 만들고 싶었습니다.
이런 식으로 계속해서 똑같은 타입의 필드를 같은 객체들이 늘어나는 상황이 좋다고 할 수 있을지 궁금합니다. 🙂
😊 :
Width, Height, Position 값 객체원시 값을 포장했을 때의 장점을 잘 알고 계신 것 같습니다.
이 질문은 바로 답변을 드리기보다는 제가 드리는 질문에 한 번 답변을 하면서 다시 생각해 보시길 바랍니다 ㅎㅎ
아, 물론 이번 미션은 모든 원시 값에 대해 포장을 하는 것이 요구 사항이므로 헤나가 하시고 싶은 대로 극한으로 값 객체로 포장해 보는 걸 추천드립니다!
아직 확실한 정답을 찾지는 못했지만
기존 Position 객체만 사용하기로 했습니다.
비슷한 의미를 가진 Column, Row 객체를 만들어 사용하다 보면 언제 어디서 사용하는지 오히려 더 헷갈릴 수도 있을 거 같다고 판단했습니다.
LEVEL 1 사다리 생성 미션을 진행하면서 생각한 내용을 적다 보니 한 페이지가 너무 길어진 거 같네요.
다음에는 조사한 내용을 포스팅하고 회고에 링크를 다는 방식으로 진행해 보도록 하겠습니다 😅
'👨🚀 우아한테크코스 5기' 카테고리의 다른 글
[20230313] 우아한테크코스 5기 LEVEL 1 - 블랙잭 1단계 & 2단계 회고 (0) | 2023.04.01 |
---|---|
[20230311] 우아한테크코스 5기 LEVEL 1 - 블랙잭 페어 회고 (2) | 2023.03.11 |
[20230217] 우아한 테크코스 5기 LEVEL 1 - 사다리 생성 페어 회고 (0) | 2023.02.18 |
[20230209] 우아한 테크코스 5기 LEVEL 1 - 자동차 경주 1단계 & 2단계 회고 (0) | 2023.02.18 |
[20230207] 우아한테크코스 5기 첫째날 (0) | 2023.02.07 |