본문 바로가기
Programming/Clean Code

불변 객체는 어떻게 만들까

by peter paak 2020. 6. 10.
728x90

클린코드 8기 과정을 들으면서 많은 분들의 코드로 부터 많은 부분을 배울 수 있었습니다.
그중에서 제 눈에 가장 많이 보였던 것이 바로 불변객체였습니다.

사실 스프링부트의 개발에 익숙하다 보니 Entity와 Dto를 생각없이 만들고 있다는 생각이 많이 들었습니다.
앞으로의 미션도 많이 남았기에 한번 정리해보도록 하겠습니다. TDD를 배우고 있는 입장에서 불변객체를 생성하는 과정을 TDD로 살펴보도록 하겠습니다.

불변객체란?

불변 객체는 객체가 가지고 있는 값(state)이 변하지 않는 객체입니다.

public class Person {

    private String name;

    public Person(String name) {
        this.name = name;
    }
}

Person 나은이 = new Person("나은이")

위의 Person 클래스는 우리가 일반적으로 볼 수 있는 객체의 모습을 가집니다. getter와 setter를 통해 외부에서 필드를 가져오고 재할당할 수 있습니다. 우리가 이 클래스를 불변객체로 만들게 되면 나은이라는 객체를 생성했을 때, Person이 가지고 있는 name이라는 변수 값이 외부에서 변하지 않도록 만듭니다. 객체의 경우는 객체의 참조값이 아니라 객체 내부의 값이 변하지 않는다는 것입니다. 우리는 예제를 통해서 순서대로 어떻게 이런 일반 객체를 불변 객체로 만드는지 살펴보겠습니다.

먼저 불변객체가 어떤 순서로 생성되는지 살펴보겠습니다.

불변객체 생성방법

불변객체를 생성하는 방법은 아래와 같습니다.

  1. 필드에 private 접근자 추가
  2. 필드에 final 키워드 추가
  3. setter 메소드 사용 금지
  4. 생성자로 필드 초기화
  5. (참고) 객체 필드 참조 초기화
  6. (참고) unmodifiableList 사용
public final class Name {

    private final String name;

    public Name(String name) {
        this.name = name;
    }
}

클래스의 모든 필드에 private 접근자와 final 키워드를 붙혀 줍니다. 그러면 자연스럽게 생성자에서 필드를 초기화 할 수 있도록 강제됩니다. 외부에서 필드를 변경하지 못하도록 setter 메소드 또한 사용하지 못하도록 강제됩니다. 일반적인 원시타입의 필드를 만든다면 1~4단계로 충분합니다. 부가적으로 객체 타입의 필드의 경우에는 객체 필드의 참조를 초기화 하고 getter 메소드에 unmodifiableList를 사용하여 외부로 필드를 노출 시키지 못하게 강제할 수 있습니다.

이제부터 TDD 사이클을 통해 일반적인 객체에서 불변객체로 만들어 보도록 하겠습니다.

TDD 사이클은 테스트를 실패 > 리펙토링 > 성공 시키는 단순한 사이클을 가집니다. 간단하게 실패하는 테스트를 만들고 코드를 리펙토링하여 성공시키고 다시 리펙토링하는 사이클을 가지고 있습니다. 테스트 코드에 익숙하지 않으시다면 assertThat 내부의 코드를 System.out.print로 찍어서 확인해보시면 실제 코드가 어떻게 작동하는지 확인 할 수 있습니다.

예제로 살펴보는 불변객체 만들기

public class Person {

    public String name;
    public List<Child> children;

    public Person(String name, List<Child> children) {
        this.name = name;
        this.children = children;
    }
}

먼저 Person 객체를 생성해보겠습니다. Person이라는 객체는 이름과 자식을 가질 수 있습니다. 매우 간단한 형태의 클래스입니다.

먼저 이 클래스의 문제점을 보자면, 필드의 접근자가 public으로 되어 있습니다. 객체는 최대한 자신의 상태를 숨겨야 합니다. 외부에서 객체의 상태값을 바꿀 수 있다면 여기 저기서 상태값을 바꿀 수 있게 됩니다. 그렇게 되면 변경해야 될 지점들이 늘어나고 흔히 말하는 결합도가 높아지는 상태가 됩니다.

public class PersonTest {

    @Test
    public void create() {
        List<Child> children = new ArrayList<>();
        children.add(new Child("나은이"));
        children.add(new Child("건후"));
        Person 박주호 = new Person("박주호", children);

        박주호.name = "파추호";

        assertEquals(박주호.name,"파추호");
    }
}

테스트를 해보면 박주호라는 이름이 파추호로 바뀌게 됩니다. 자신의 이름을 자신이 바꾼 것이 아니라 외부의 테스트에 의해 마음대로 바꿔지게 되버린 상황이 됩니다. 그럼 위의 불변객체를 만드는 순서에 따라 개선 해보도록 하겠습니다.

1. 필드에 private 접근자 추가

public class Person {

    private String name;
    private List<Child> children;

    public Person(String name, List<Child> children) {
        this.name = name;
        this.children = children;
    }
}

필드에 private 접근자를 추가함으로써 외부에서 직접 필드를 변경할 수 없도록 하였습니다. 이제 오직 필드값을 변경할 수 있는 방법은 객체의 생성자setter 메소드에 의해서만 변경이 가능합니다. 이제 테스트를 실행 해보겠습니다.

테스트를 실행하기도 전에 컴파일 에러가 발생하게 됩니다. 즉, 외부에서는 접근하지 못하는 상태입니다. 테스트를 리펙토링하여 성공시켜보도록 하겠습니다.

public class PersonTest {

    @Test
    public void 첫번째_private() {
        List<Child> children = new ArrayList<>();
        children.add(new Child("나은이"));
        children.add(new Child("건후"));
        Person 박주호 = new Person("박주호", children);

        박주호.setName("파추호");

        assertEquals(박주호.name,"파추호");
    }
}

필드에 바로 접근하는 대신 setName이라는 setter 메소드를 통해서 박주호에서 파추호로 이름을 바꾸었습니다. 아까와 다르게 이번에는 박주호라는 객체가 자신의 메소드를 사용하여 스스로 이름(필드)를 바꾸었다는 것을 알 수 있습니다.

하지만, 불변객체에서는 외부에서 상태값을 바꿀 수 없기 때문에 setter 메소드가 있으면 안됩니다. 다음의 단계를 지나고 천천히 살펴보겠습니다.

2. 필드에 final 키워드 추가

이제 모든 필드에 final 키워드를 추가합니다.

final 키워드는 오직 한번만 할당할 수 있도록 강제합니다. 즉, 초기화 이후 변하지 않도록 강제합니다. 그래서 final 키워드가 있다면 생성자에서 항상 초기화를 해주어야 됩니다. 원시타입인 경우 초기화 되면 변하지 않으며, 객체타입인 경우는 그의 상태는 변할 수 있지만 참조값은 변하지 않습니다.

public class Person {

    private final String name;
    private final List<Child> children;

    public Person(String name, List<Child> children) {
        this.name = name;
        this.children = children;
    }
}

그렇다면 불변 객체를 만든다고 했으면서 객체의 상태값은 변하는 것 아냐? 라고 반문하실 수 있습니다. 객체가 가지고 있는 상태에 대해서도 불변으로 만들려면 어떻게 해야 할까요? 이는 다음 단계에서 설명하도록 하겠습니다. 일단 먼저 테스트를 살펴보겠습니다.

setName 메소드를 사용할 수 없다는 에러를 발생시킵니다. final 키워드를 사용하게 되면 setter 메소드를 사용하지 못하도록 강제합니다. 그래서 setter 메소드를 만들면 아래와 같은 컴파일 에러를 던지게 됩니다.

3. setter 메소드 사용 금지

setter 메소드를 사용하지 않습니다.

setter 메소드는 외부로 부터 객체의 내부 상태를 바꾸게 할 수 있습니다. 그러므로 불변객체에서는 setter를 사용하지 않음으로서 생성자만이 유일하게 필드를 바꿀 수 있도록 강제합니다.

final 키워드를 사용하게 되면 setter 메소드를 사용하지 못하도록 강제합니다. 그래서 setter 메소드를 만들면 아래와 같은 컴파일 에러를 던지게 됩니다.

앞의 예에서 final 키워드를 입력하면 자연스럽게 setter 메소드를 강제할 수 있습니다. final 키워드를 사용함으로서 불변객체를 자연스럽게 만들 수 있게 됩니다.

4. 생성자로 필드 초기화

클래스의 생성자로 필드를 초기화 해줍니다.

setter 메소드가 없는 상태에서 생성자는 필드를 초기화 시켜주는 유일한 방법이 됩니다. 객체를 생성하는데 생성자를 만들어야 된다는 것이 당연하게 들리실 것입니다. 아래의 사진을 한번 보겠습니다.

생성자를 생성하지 않으면 위와 같이 초기화되지 않았다는 에러를 보여줍니다. 이는 final 키워드에 의해 발생됩니다. 앞서 말씀드렸듯이 final 키워드는 한번 초기화가 되면 변하지 않습니다. 그러면 초기화 시킬 시점이 필요한데 이 시점이 바로 생성자가 됩니다.

정리하자면 final 키워드는 두가지 역할을 하게 됩니다.

  1. 생성자 초기화
  2. setter 메소드 금지

지금까지 불변객체를 생성하는 방법에 대해서 살펴 보았습니다. Person이라는 객체의 name이라는 원시타입의 문자열을 변하지 않도록 강제 할 수 있었습니다. private final을 필드에 추가하고 setter를 사용하지 않음으로서 불변객체를 만들 수 있었습니다.

5. 객체 필드 참조 초기화

그렇다면 타입이 List은 children의 상태도 불변일까요? 테스트로 한번 살펴보겠습니다.

@Test
public void 세번째_참조() {
    List<Child> children = new ArrayList<>();
    children.add(new Child("나은이"));
    children.add(new Child("건후"));
    Person 박주호 = new Person("박주호", children);

    children.add(new Child("박진우"));

    assertEquals(박주호.getChildren().size(),2);
}

박주호라는 Person의 children 필드에 박진우라는 이름을 가진 Child 객체를 추가해보도록 하겠습니다(박진우는 박주호님의 셋째 아들입니다. 팬으로써 이렇게 축하드리네요). 앞서 List에 final 키워드를 붙혔으니 children의 상태가 변하지 않을 것이라 예상됩니다. 즉, 나은이와 건후 두명이 있으니 결과값도 2가 될 것입니다.

테스트가 깨집니다. 어떻게 된 것일까요?

앞 서 final 키워드를 설명할 때, 객체타입인 경우는 그의 값은 변할 수 있지만 참조값은 변하지 않습니다. 라고 설명한 바가 있습니다. 즉, children의 주소는 변하지가 않습니다. 여전히 같은 주소를 바라보기 때문에 Child를 추가할 수 있습니다.

List<Child> children = new ArrayList<>();
    children.add(new Child("나은이"));
    children.add(new Child("건후"));
Person 박주호 = new Person("박주호", children);
public Person(String name, List<Child> children) {
    this.name = name;
    this.children = children;
}

테스트에서 children을 생성하고 Person의 생성자에 할당을 합니다. 생성자 내부에서 this.children에 그대로 테스트에서 넘어온 children 객체를 할당합니다. 즉 this.children이 런타임에 넘어온 children으로 초기화합니다. 이제 this.children의 참조값은 테스트에서 넘오온 children을 가리키게 되고 변하지 않게 됩니다. 하지만 여전히 참조값은 외부 환경인 테스트에서 children이라는 객체로 접근 가능하기 때문에 테스트의 children을 값을 바꾸게 되면, this.children의 값도 함께 바뀌게 됩니다(시각적으로 바뀐 것이지 같은 객체를 바라보고 있습니다). 사실 setter 메소드가 없기 때문에 더 이상 내부의 참조값을 변화시킬 방법은 없게됩니다.

@Test
public void 세번째_참조() {
    List<Child> children = new ArrayList<>();
    children.add(new Child("나은이"));
    children.add(new Child("건후"));
    System.out.println(children);                     // [Child{name='나은이'}, Child{name='건후'}]

    Person 박주호 = new Person("박주호", children);
    System.out.println(박주호);                       // Person{name='박주호', children=[Child{name='나은이'}, Child{name='건후'}]}

    children.add(new Child("박진우"));
    System.out.println(children);                     // [Child{name='나은이'}, Child{name='건후'}, Child{name='박진우'}]

    assertEquals(박주호.getChildren().size(),3);
    System.out.println(박주호);                       // Person{name='박주호', children=[Child{name='나은이'}, Child{name='건후'}, Child{name='박진우'}]}
}

로그를 찍어보면 조금 더 명확하게 확인 할 수 있습니다. 2번째 로그에서 Person 생성자는 테스트에서 생성한 children으로 초기화 되었습니다. 3번째 로그에서 테스트의 children 객체에 Child가 추가되었고 Person의 this.children은 테스트의 children을 참조하므로 같은 값을 출력하게 됩니다.

그렇다면 객체의 값 또한 불변으로 바꾸려면 어떻게 해야 할까요?

생성자에서 새로운 객체로 감싸 초기화하면 됩니다.

public class Person {

    private final String name;
    private final List<Child> children;

    public Person(String name, List<Child> children) {
        this.name = name;
        this.children = new ArrayList<>(children);
    }
}

생성자에서 초기화 할 때, 넘어온 children 객체를 새로운 객체로 만들어 주면 됩니다. 이제 테스트의 children 객체는 Person 생성자의 children 객체는 서로 다른 주소를 바라보게 됩니다.

@Test
public void 세번째_참조() {
    List<Child> children = new ArrayList<>();
        children.add(new Child("나은이"));
        children.add(new Child("건후"));
    Person 박주호 = new Person("박주호", children);

    children.add(new Child("박진우"));

    assertEquals(박주호.getChildren().size(),2);
}

테스트가 통과하게 됩니다.

@Test
public void 세번째_참조() {
    List<Child> children = new ArrayList<>();
    children.add(new Child("나은이"));
    children.add(new Child("건후"));
    System.out.println(children);                   // [Child{name='나은이'}, Child{name='건후'}]

    Person 박주호 = new Person("박주호", children);
    System.out.println(박주호);                     // Person{name='박주호', children=[Child{name='나은이'}, Child{name='건후'}]}

    children.add(new Child("박진우"));
    System.out.println(children);                   // [Child{name='나은이'}, Child{name='건후'}, Child{name='박진우'}]

    assertEquals(박주호.getChildren().size(),2);
    System.out.println(박주호);                     // Person{name='박주호', children=[Child{name='나은이'}, Child{name='건후'}]}
}

3번째 로그를 살펴보면 테스트의 children에 새로운 Child가 할당이 되었지만, Person 객체의 children은 테스트의 children 참조와 다른 내부에서만 참조값을 알기 때문에 초기화 했을 당시와 같은 children=[Child{name='나은이'}, Child{name='건후'}] 를 가지게 됩니다.

이제 객체 필드에 대한 불변성을 만족 할 수 있게 되었습니다. 이제 초기화 당시의 객체 값이 불변 상태가 되게 되었습니다. 내부의 children의 값은 외부에서 컨트롤 할 수 없는 상태가 되었습니다.

하지만 외부에서 내부의 children 객체를 받아올 수 있다면 값을 바꿀 수 있지 않을까요? 한 번 시도해 보겠습니다. 아래의 테스트를 실행해보겠습니다.

@Test
public void 네번째_getChildren() {
    List<Child> children = new ArrayList<>();
        children.add(new Child("나은이"));
        children.add(new Child("건후"));
    Person 박주호 = new Person("박주호", children);

    박주호.getChildren().add(new Child("박진우"));

    assertEquals(박주호.getChildren().size(),3);
}

우리는 getter 메소드로 내부의 필드를 가져 올 수 있습니다. 원시타입의 필드는 외부에서 바꾸어도 참조값이 다르기 때문에 바꿔지지 않습니다. 하지만 객체는 어떨까요? getChildren으로 가져온 내부의 children은 여전히 내부의 children입니다. 즉 참조값이 그대로 이므로 이 객체에 Child를 더해준다면 우리는 외부에서 내부의 필드를 바꿀 수 있게 됩니다.

6. unmodifiableList 사용

getter 메소드로 collection을 바꾸지 못하도록 막을 수 있습니다.

public class Person {

    private final String name;
    private final List<Child> children;

    public Person(String name, List<Child> children) {
        this.name = name;
        this.children = new ArrayList<>(children);
    }

    public List<Child> getChildren() {
        return Collections.unmodifiableList(children);
    }
}

우리는 List라는 collection을 필드로 사용하였기 때문에 unmodifiableList를 사용하여 getter를 사용하지 못하도록 막을 수 있습니다. 테스트를 다시 한번 실행해보도록 하겠습니다

@Test
public void 네번째_unmodifiableList() {
    List<Child> children = new ArrayList<>();
    children.add(new Child("나은이"));
    children.add(new Child("건후"));
    Person 박주호 = new Person("박주호", children);

    박주호.getChildren().add(new Child("박진우"));

    assertEquals(박주호.getChildren().size(),2);
}

getter 메소드가 실행이 되는 부분에서, UnsupportedOperationException을 발생시킵니다. 즉, Collections.unmodifiableList(children); 로 getter 메소드를 사용하지 못하도록 강제함으로서 객체 내부의 필드를 외부로 꺼내지 못하도록 강제할 수 있습니다.

이제 정말 우리는 불변객체를 만들었다 라고 할 수 있습니다. 물론 한가지 방법이 더 존재하지만 이 정도로 충분하다고 생각합니다.

정리하자면

  1. 필드에 private 접근자 추가
  2. 필드에 final 키워드 추가
  3. setter 메소드 사용 금지
  4. 생성자로 필드 초기화
  5. (참고) 객체 필드 참조 초기화
  6. (참고) unmodifiableList 사용
public class Person {

    private final String name;
    private final List<Child> children;

    public Person(String name, List<Child> children) {
        this.name = name;
        this.children = new ArrayList<>(children);
    }

    public List<Child> getChildren() {
        return Collections.unmodifiableList(children);
    }
}

마무리

평소에 깊게 생각해보지 못했던 부분이지만 클린코드 수강 이후 많이 보다보니 부족함을 많이 느꼈습니다. 한편으로 무언가 모호한 개념이 TDD (TDD라고도 할 수 없지만..) 사이클을 통해서 점진적으로 발전해 나가고 머리속으로 정리되는 것을 보면서 앞으로의 공부 방향을 이런 스타일로 가져가는 것도 나쁘지 않다는 생각이 들었습니다.

긴 글 읽어주셔서 감사합니다!

728x90