각종 후기/우아한테크코스

[우아한 테크코스 3기] LEVEL 1 회고 (38일차)

제이온 (Jayon) 2021. 3. 11.

 

 

안녕하세요? 제이온입니다.

 

오늘은 루터 회관에 가서 크루들과 함께 한 주제에 대해 토론도 해 보고, 혼자 책을 읽으며 포스팅을 작성하였습니다.

 

 

방어적 복사의 맹점

흔히 우리는 필드에 있는 리스트를 초기화 해 주기 위하여 다음과 같이 생성자에서 방어적 복사를 취합니다.

 

 

public class Test {
	final List<Integer> numbers;
    
    public Test(final List<Integer> numbers) {
    	this.numbers = new ArrayList<>(numbers);
    }
}

 

 

인자로 넘어온 리스트를 곧바로 초기화하는 것이 아니라, 새롭게 메모리를 할당하여 이전과의 참조를 끊는 방식으로 방어적 복사를 사용합니다. 이 기법을 사용하지 않는다면, 인자로 넘어온 numbers의 요소가 바뀌었을 때 Test 클래스의 numbers의 요소도 영향을 받습니다. 이 부분은 제가 예제 코드로 자세히 설명하겠습니다.

 

 

public class Card {

    int number;

    public Card(int number) {
        this.number = number;
    }
   
}

 

 

간단하게 필드로 number를 갖는 Card 클래스를 정의해 보겠습니다. 이제, 이것이 담긴 리스트를 가지고 여러 가지 실험을 할 것입니다.

 

 

    @Test
    @DisplayName("ArrayList 복사 확인")
    void copy() {
        final List<Card> cards = new ArrayList<>(Arrays.asList(new Card(0), new Card(1)));
        System.out.println("복사하기 전 리스트의 요소");
        cards.forEach(card -> System.out.println(card.number));
        System.out.println();

        final List<Card> copyCards = cards;
        System.out.println("얕은 복사한 후 리스트의 요소");
        copyCards.forEach(card -> System.out.println(card.number));
        System.out.println();

        cards.add(new Card(2));
        System.out.println("얕은 복사 후 리스트의 요소2");
        copyCards.forEach(card -> System.out.println(card.number));
        System.out.println();
    }

 

 

처음에 cards에 숫자가 0, 1인 카드를 넣습니다. 그리고 단순히 '='을 통해서 얕은 복사를 한 copyCards를 얻어냅니다. 이후에, 복사 전 리스트인 cards의 숫자가 2인 카드를 넣습니다. 그렇다면, 얕은 복사를 한 copyCards의 요소는 어떻게 될까요?

 

 

 

 

 

 

얕은 복사는 단순히 주소만 공유하는 것이므로 복사 전 리스트의 요소 변화가 복사 후 리스트에도 연쇄적으로 발생하게 됩니다. 따라서, 아래 코드처럼 방어적 복사를 해야 합니다.

 

 

    @Test
    @DisplayName("ArrayList 복사 확인")
    void copy() {
        final List<Card> cards = new ArrayList<>(Arrays.asList(new Card(0), new Card(1)));
        System.out.println("복사하기 전 리스트의 요소");
        cards.forEach(card -> System.out.println(card.number));
        System.out.println();

        final List<Card> copyCards = cards;
        System.out.println("얕은 복사한 후 리스트의 요소");
        copyCards.forEach(card -> System.out.println(card.number));
        System.out.println();

        cards.add(new Card(2));
        System.out.println("얕은 복사한 후 리스트의 요소2");
        copyCards.forEach(card -> System.out.println(card.number));
        System.out.println();

        final List<Card> copyCards2 = new ArrayList<>(cards);
        cards.add(new Card(3));
        System.out.println("참조를 끊고 복사한 후 리스트의 요소");
        copyCards2.forEach(card -> System.out.println(card.number));
        System.out.println();
    }

 

 

copyCards2는 방어적 복사를 통해 cards의 요소를 가져왔습니다. 여기서 cards에 숫자가 3인 카드를 추가한다면, copyCards2의 요소는 어떻게 될까요?

 

 

 

 

 

 

이렇게 독립된 주소를 가리키기 때문에 이전 리스트에 새로운 요소가 추가되었다하더라도 copyCards2에는 영향을 주지 않습니다. 그래서 웬만하면 인자로 리스트를 받아와서 필드 리스트를 초기화할 때는 방어적 복사를 하는 것이 좋습니다.

 

 

그런데, 저는 오늘 조엘과 방어적 복사를 이야기하다가 맹점이 있다는 것을 찾았습니다. 바로, 복사하기 전 리스트에 요소를 추가하는 것이 아니라, 특정 요소의 필드값을 변경하면 문제가 생긴다는 것입니다. 예시를 봅시다.

 

 

    @Test
    @DisplayName("ArrayList 복사 확인")
    void copy() {
        final List<Card> cards = new ArrayList<>(Arrays.asList(new Card(0), new Card(1)));
        System.out.println("복사하기 전 리스트의 요소");
        cards.forEach(card -> System.out.println(card.number));
        System.out.println();

        final List<Card> copyCards = cards;
        System.out.println("얕은 복사한 후 리스트의 요소");
        copyCards.forEach(card -> System.out.println(card.number));
        System.out.println();

        cards.add(new Card(2));
        System.out.println("얕은 복사한 후 리스트의 요소2");
        copyCards.forEach(card -> System.out.println(card.number));
        System.out.println();

        final List<Card> copyCards2 = new ArrayList<>(cards);
        cards.add(new Card(3));
        System.out.println("참조를 끊고 복사한 후 리스트의 요소");
        copyCards2.forEach(card -> System.out.println(card.number));
        System.out.println();

        // ArrayList를 생성할 때, 얕은 복사로 요소를 복사해서 문제가 발생함.
        cards.get(0).number = 100;
        System.out.println("참조를 끊고 복사한 후 리스트의 요소2");
        copyCards2.forEach(card -> System.out.println(card.number));
        System.out.println();
    }

 

 

위와 같이 cards의 0번째 카드 숫자를 100으로 바꿔보았습니다. 그렇다면, 참조를 끊았다고 판단한 copyCards2의 첫 번째 요소는 어떻게 될까요?

 

 

 

 

 

 

다음과 같이 copyCards2의 첫 번째 카드 숫자가 100으로 바뀐 것을 알 수 있습니다. 왜 이러한 일이 발생하는 것일까요? 그것은 ArrayList 코드에서 요소를 복사할 때는 얕은 복사를 하기 때문입니다.

 

 

    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;
        }
    }

 

 

elementData에 할당을 할 때, 단순히 '='을 통해서 얕은 복사를 하는 것을 알 수 있습니다. (참고로, Arrays.copyOf()도 얕은 복사입니다.) 따라서, primitive 타입이 아닌 참조 객체를 리스트의 요소를 사용할 때는 그 객체를 불변으로 만드는 것이 좋다고 할 수 있습니다.

 

 

그렇다면, 특정 객체를 깊은 복사를 어떻게 할까요? 바로, Cloneable 인터페이스를 상속받아서 메소드를 재정의하는 것입니다.

 

 

    @Test
    public void cloneTest() {
        final Card card = new Card(3);
        try {
            final Card copyCard = card.clone();
            System.out.println(copyCard.number);
            card.number = 2;
            System.out.println(copyCard.number);
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
        }
    }

    class Card implements Cloneable {

        int number;

        Card(int number) {
            this.number = number;
        }

        @Override
        protected Card clone() throws CloneNotSupportedException {
            return (Card) super.clone();
        }
    }

 

 

이렇게 clone() 메소드를 재정의한 다음, cloneTest()에 나와있는 코드대로 작성하면 됩니다. 처음의 복사하기 전 카드의 숫자가 3이었고, 복사를 한 이후에 복사하기 전 카드가 2로 바뀌었습니다. 여기서 우리는 출력을 하였을 때, 둘 다 3이 나오길 원합니다. 

 

 

 

 

 

 

잘 나오는 것을 알 수 있습니다.

 

 

DTO에 관한 오해

어플리케이션을 개발할 때, 출력 뷰에 객체를 넘겨주고 싶다면 그 객체는 불변을 보장해야 합니다. 하지만, 해당 객체가 가변이라면, getter만 존재하는 DTO에 출력하고 싶은 데이터만 넣어준 다음에 출력 뷰에 넘겨주어야 합니다. 그래서 저는 DTO는 무조건 불변 객체라고 생각하였습니다.

 

하지만, 조엘은 setter가 없는 DTO가 불변이라고 알려주었습니다. 즉, DTO는 원래 getter만 존재하는 것이 아니라 setter도 함께 존재하는 것이죠.

 

또한, 조엘의 리뷰어 님께서는 레벨1 동안에는 서비스 레이어나 DTO에 대해서는 적용하지 말라고 조언해 주셨습니다. 왜냐하면, 이 두 가지 개념은 스프링에서 자연스럽게 익히게 될 것이고 지금은 괜히 구조를 복잡하게 만드는 이유때문이었죠. 따라서, 저도 언제 DTO를 사용해야 하는지만 알고 넘어가기로 판단하였습니다.

 

 

상태 패턴

오늘은 SOLID의 리스코프 치환 원칙과 인터페이스 분리 원칙, 그리고 디자인 패턴의 템플릿 메소드 패턴에 대해서 포스팅을 작성하였습니다. 그리고 상태 패턴까지 글을 쓰고 싶었으나, 생각보다 헷갈린 부분이 있어서 개념을 가볍게 익히고 제리에게 이해가 안 가는 부분을 질문하였습니다.

 

제가 읽었던 책에서는 전략 패턴과 상태 패턴을 모두 반복된 'if ~ else' 구문을 추상화한다는 느낌으로 설명해 주었기 때문에 차이를 느끼기 어려웠습니다. 그래서 언제 전략 패턴을 쓰고, 언제 상태 패턴을 쓰는지 이해가 가지 않았죠.

 

제리는 오늘 하루 종일 상태 패턴을 공부하면서 느낀 점을 저에게 알려주었습니다. 정말 간단하게 말하자면, 전략 패턴은 한 번 인스턴스가 생성된 이후 상태의 변화가 없을 때 사용하고, 상태 패턴은 한 번 인스턴스가 생성된 이후 상태가 여러 번 변할 때 사용한다는 것입니다. 물론, 전략 패턴도 상태의 변화가 일어날 수 있습니다. 하지만, 처음 배우는 입장에서는 위 방식으로 이해하는 편이 개념을 잡기 좋았던 것 같습니다.

 

나머지 자세한 내용은 내일 제가 상태 패턴 포스팅을 작성하고, 전략 패턴과 상태 패턴의 차이에 관해서도 글을 쓰려고 합니다.

 

 

정리

오늘은 아직 블랙잭 2단계 미션의 피드백이 안 와서 자습을 진행하였습니다. 그리고 그 속에서 모르는 점은 크루들과 토론하면서 정말 의미있는 시간이었다고 생각합니다. 그리고 상태 패턴을 공부하였으니, 추후 블랙잭 미션이 완전히 merge가 된다면 이 패턴을 적용하여 리팩토링해 볼 계획입니다.

댓글

추천 글