불변 객체를 만드는 네 가지 습관

Frog Lemon·2025년 4월 7일
0

Java

목록 보기
3/3
post-thumbnail

불변객체라는 말을 우아한테크코스에서 처음 듣게 되었다

처음엔 "도대체 그게 왜 중요한 거지?🙄"라는 생각이 들었다.
왜 객체의 상태를 바꾸면 안 되는 걸까?
직접 코드를 구현하고, 다양한 상황을 실험해보면서, 불변 객체가 왜 필요하고 어떤 문제를 막아주는지 조금씩 체감할 수 있었다.
이 글에서는 그 과정을 통해 내가 깨달은 불변 객체의 필요성에 대해 이야기해보려 한다.


💡불변 객체란?

불변 객체(Immutable Object) 란 한 번 생성되면 내부 상태가 절대 변하지 않는 객체를 말한다.
즉, 객체가 생성된 이후에는 어떤 방식으로도 그 속성 값을 바꿀 수 없다.

이러한 특성 덕분에, 불변 객체는 사용하는 입장에서 믿고 쓸 수 있는 신뢰성 있는 객체가 된다.
상태가 바뀌지 않기 때문에 언제, 어디서, 어떤 상황에서 사용해도 예측 가능한 동일한 동작을 보장한다.

여기서 "예측 가능한 동작"이란
이 객체는 어떤 코드에서 사용하든 항상 같은 값을 리턴하고, 갑작스러운 변경이 발생하지 않는다는 의미다..

예를 들어, 아래와 같은 코드가 있다고 해보자.

Crew meringue = new Crew("머랭", "WANNI"); 
System.out.println(meringue.getName()); 

만약 위의 Crew 객체가 불변 객체라면,
어떤 메서드, 어떤 클래스, 어떤 스레드에서 호출하더라도 meringue.getName()은 항상 "머랭"을 반환한다.

하지만 Crew 객체가 가변 객체라면?
외부에서 객체의 상태를 바꿀 수 있게 되면, 협업 중 누군가 실수로 값을 바꾸거나, 멀티스레드 환경에서 예상치 못한 변경이 일어나 버그의 원인이 될 수도 있다.


💡불변 객체를 만드는 4가지 원칙

불변 객체를 만들기 위해선 몇 가지 원칙을 지켜야 한다.
이 네가지 원칙이 잘 어우러질때 비로소 완전한 불변 객체가 완성된다.

각 원칙은 다음과 같다.
1️⃣ 필드는 private으로 선언해 외부 접근을 막자.
2️⃣ setter는 지양하자.
3️⃣ 값의 재할당을 막기 위해 final을 붙이자.
4️⃣ 컬렉션이나 참조 타입은 방어적 복사를 활용하자.

아래 코드는 불변 객체를 만들기 위한 4가지 원칙을 전혀 지키지 않은 사례다.
이 코드가 어떤 문제를 만들 수 있는지 각 단계별로 살펴보고, 어떻게 개선할 수 있는지도 함께 알아보자.

  • Crew 클래스: 크루원들의 이름과 그룹을 저장하는 클래스.
  • Team 클래스: 크루들을 관리하는 일급 컬렉션
public class Crew {
   	public String name;
    public String group;

    public Crew(String name,String group) {
        this.name = name;
        this.group = group;
    }
    
    public String getName() {
        return name;
    }

    public String getGroup() {
        return group;
    }
    
    public void setName(String name) {
        this.name = name;
    }

    public void setGroup(String group) {
        this.group = group;
    }
}
public class Team {
    public List<Crew> crews;

    public Team(List<Crew> crews) {
        this.crews = crews;
    }

    public List<Crew> getCrews() {
        return crews;
    }
}

🔻필드는 private으로 선언해 외부 접근을 막자.

만약 각 필드가 private으로 선언되지 않는다면 무슨 문제가 생길 수 있을까?
객체지향적 관점에서 보면, 캡슐화(encapsulation) 원칙이 깨진다는 문제가 있다.

위키백과에서는 캡슐화를 다음과 같이 정의한다:

객체의 속성(data fields)과 행위(methods)를 하나로 묶고, 실제 구현 내용 일부를 외부에 감추어 은닉한다.

private 접근 지정자는 클래스 내부에서만 접근이 가능하다.
반면 protectedpublic은 클래스 외부에서도 필드에 접근할 수 있기 때문에, 내부 구현이 노출되고 데이터가 외부에서 쉽게 변경될 수 있다.

아래 예제를 통해 문제 상황을 확인해보자.

    @DisplayName("크루원의 이름을 확인한다.")
    @Test
    void test() {
        Crew moco = new Crew("모코", "WANNI");
        moco.name = "레몬";
        moco.group = "NEO";

        SoftAssertions.assertSoftly(softly -> {
            softly.assertThat(moco.name).isEqualTo("레몬");
            softly.assertThat(moco.group).isEqualTo("NEO");
        });
    }

Crew 클래스의 필드가 public으로 선언되어 있기 때문에,
외부 코드에서 name과 group을 마음대로 바꿀 수 있다.
이로 인해 객체의 신뢰성일관성이 무너진다.

✅ 해결 방법은 간단하다.

필드를 private으로 선언하자.
이렇게 하면 객체의 내부 상태가 외부에서 직접 보이거나 변경되는 일을 막을 수 있고, 객체지향의 핵심 원칙인 캡슐화를 자연스럽게 지킬 수 있다.

private String name;
private String group;

애초에 컴파일 단계에서 에러가 발생하여 잘못된 접근을 사전에 차단할 수 있다.


🔻Setter는 지양하자.

앞서 필드를 private으로 선언했으니, 더 이상 값을 수정할 수 없을까?
그렇지 않다. setter 메서드가 존재한다면, 외부에서는 여전히 객체의 내부 상태를 마음대로 바꿀 수 있다.

    @DisplayName("setter로 크루원의 정보를 바꾼다.")
    @Test
    void test() {
        Crew moco = new Crew("모코", "WANNI");
        moco.setName("레몬");
        moco.setGroup("NEO");

        SoftAssertions.assertSoftly(softly -> {
            softly.assertThat(moco.getName()).isEqualTo("레몬");
            softly.assertThat(moco.getGroup()).isEqualTo("NEO");
        });
    }

가장 중요한 문제는, 필드를 protectedpublic으로 선언했을 때와 마찬가지로,
객체가 생성된 이후에도 마음만 먹으면 내부 상태를 얼마든지 바꿀 수 있다는 점이다.

이 문제를 해결하려면,
모든 상태를 생성자에서 한 번만 초기화하고, 이후에는 절대 수정할 수 없도록 setter 자체를 만들지 말자.

이렇게 하면 객체는 항상 일정한 상태를 유지하며, 예측 가능하고 안전한 구조를 가질 수 있다.

public class Crew {
    private String name;
    private String group;

    public Crew(String name,String group) {
        this.name = name;
        this.group = group;
    }

    public String getName() {
        return name;
    }

    public String getGroup() {
        return group;
    }
}

🔻값의 재할당을 막기 위해 final을 붙이자.

그렇다면 final의 역할은 무엇일까?
가장 핵심적인 기능은 한 번 할당된 값을 다시는 바꿀 수 없게 만드는 것, 즉 재할당을 막는 것이다
때문에 변경되지 않을 거란걸 명시적으로 표현할떄, 특히 협업 때 쓰는게 좋다

예를들어 아래와 같이 number라는 변수에 값을 할당하고 final을 붙이면

final int number = 1;

이제 이 변수에는 다시 값을 넣을 수 없다.
즉, 다음과 같은 코드는 컴파일 오류를 발생시킨다:

number = 2; //컴파일 에러

이처럼 final을 사용하면 변수나 필드가 다른 값으로 바뀌는 실수를 방지할 수 있다.
클래스의 필드에 final을 붙이는 이유도 같은 맥락으로, 객체의 상태를 의도하지 않은 변경으로부터 보호하기 위함이다.

💡그렇다면 객체 앞에 final이 붙는다면?

 final List<Crew> crews = new ArrayList<>();
 final Crew crew = new Crew("레몬","WANNI");

약 crews 리스트에 crews.add()를 하면 값이 들어갈까? final이라 명시되었기에 약간의 고민이 생길것이다.
또한 player안의 필드값을 변경할 수 있을까?

결론부터 말하자면, 가능하다.

그 이유는 final의 역할이 "재할당을 막는 것" 이기 때문이다.
즉, crews라는 변수에 다른 리스트를 새로 할당하는 건 불가능하지만,
리스트 내부의 요소를 추가하거나 수정하는 건 가능하다.

위에서 말한 것처럼

crews = new ArrayList<>()
crew = new Crew("머랭","NEO")

위의 코드와 처럼 재할당을 하려고 하는것을 방지하기 위해 명시적으로 쓰인다.


🔻컬렉션이나 참조 타입은 방어적 복사를 활용하자.

public class Team {
    private final List<Crew> crews;

    public Team(final List<Crew> crews) {
        this.crews = crews; // 외부 리스트를 그대로 참조
    }

    public List<Crew> getCrews() {
        return crews; // 내부 리스트를 그대로 노출
    }
}

이 코드는 우리가 흔히 볼 수 있는 아주 평범한 구조다.
겉보기엔 private 필드에 setter도 없고, 나름 캡슐화가 잘 되어 있는 것처럼 보인다.
그래서 방어적 복사의 개념을 처음 접하기 전에는 이 코드의 문제점을 쉽게 눈치채기 어렵다.

하지만 아래 예시를 보면서 왜 방어적 복사가 필요한지 알아보자.

List<Crew> crewList = new ArrayList<>();
crewList.add(new Crew("레몬", "WANNI"));

Team team = new Team(crewList);

이 코드는 Crew 객체를 생성해서 리스트에 담고, 그 리스트를 이용해 Team 객체를 만든다.
여기까지는 전혀 문제 없어 보인다. 충분히 자연스럽고, 흔히 사용하는 방식이다.

⚠️그런데 여기서 문제가 발생할 수 있다.

// 외부에서 원본 리스트 수정
crewList.add(new Crew("브리", "NEO"));

// 내부 리스트에 직접 접근하여 조작
team.getCrews().add(new Crew("솔라", "NEO"));

//출력
System.out.println(team.getCrews());
/*
[
예상 출력 값: 
  Crew{name='레몬', team='WANNI'},
  Crew{name='브리', team='NEO'},
  Crew{name='솔라', team='NEO'}
]
*/

위 코드에서는 Team 객체가 생성된 이후에도 외부에서 얼마든지 내부 상태를 바꿀 수 있는 문제가 발생한다.

  • 첫 번째 줄에서는 Team이 생성된 이후, 원래 넘겼던 crewList를 직접 수정함으로써 내부 데이터에 영향을 준다.

  • 두 번째 줄에서는 getCrews()를 통해 내부 리스트에 접근하고, 그대로 .add()를 호출해서 새로운 객체를 추가하고 있다.

이 두 줄만으로 Team의 내부 상태는 언제든지 변경 가능해졌고, 이는 불변성을 완전히 깨트리는 심각한 설계적 결함이다.

더욱 심각한 상황도 있다. 만약 실수로 데이터를 삭제한다면 어떻게 될 것인가.

crewList.clear(); // 외부에서 리스트를 삭제

team.getCrews().clear(); // 내부 리스트를 직접 삭제

System.out.println(team.getCrews()); // []

아무리 Team 객체 내부에서 crews를 private final로 선언했다고 해도, 내부 리스트가 외부에 노출되어 있거나 방어적 복사가 되지 않았다면, 이렇게 외부에서 .clear()를 호출함으로써 내부 데이터를 완전히 지워버릴 수 있다.

🚨이러한 문제를 방지하기 위해서는 다음과 같은 방어적 복사 전략이 반드시 필요하다.

[수정된 Team 클래스]

public class Team {
    private final List<Crew> crews;

    public Team(final List<Crew> crews) {
        this.crews = new ArrayList<>(crews); // 방어적 복사
    }

    public List<Crew> getCrews() {
        return Collections.unmodifiableList(crews); // 불변 리스트로 노출
    }
}

⭕방어적 복사의 장점

  1. Team객체를 만들 때 생성자를 통해 외부에서 받은 리스트를 직접 필드로 저장하지 않고, 리스트 복사본을 만들어 참조를 끝는다.
    외부에서 원본 리스트를 건드려도 Team 객체 내부 리스트영향을 받지 않음내부 데이터 보호.

  2. 필드의 crews를 반환시 Collections의 unmodifiableList를 사용하여 불변 리스트로 반환한다.
    외부에서 getCrews()로 받은 리스트를 조작하려고 해도 UnsupportedOperationException 발생 → 불변성 유지.

❌방어적 복사의 단점

매번 복사본을 만들기 때문에 성능 이슈가 발생 가능하다. (특히 리스트가 클때)


✅ 마무리하며...

불변 객체는 처음엔 낯설 수 있지만, 객체의 상태가 예측 가능하고 안정적이라는 점에서 큰 장점을 가진다. 특히 협업이 잦고, 사이드 이펙트에 민감한 환경일수록 불변 객체는 강력한 무기가 된다.

방어적 복사final, private, setter 지양과 같은 원칙을 차근차근 적용하다 보면 어느새 견고하고 유지보수하기 쉬운 코드를 만들 수 있게 될 것이다!

"불변 객체는 객체를 믿고 쓸 수 있게 만드는 가장 단단한 약속이다."

profile
노력과 끈기를 추구합니다. 레몬이 좋아!

0개의 댓글