devseop08 님의 블로그

[OOP] 불변 클래스 본문

Language/Java

[OOP] 불변 클래스

devseop08 2025. 9. 5. 17:30
  • 한 번 생성된 객체의 상태(필드 값)가 절대 변하지 않는 클래스를 의미
  • 즉, 생성 시점에 정의된 값이 프로그램 종료까지 절대 변경되지 않음을 보장

불변 클래스 특징

  • 장점
  1. 스레드 안정성(Thread-Safety): 상태가 변하지 않으므로 여러 스레드가 동시에 접근해도 동기화 불필요
  2. 안정성 & 단순성: 외부 코드에서 객체 내부 상태를 바꿀 수 없으므로 예측 가능한 동작 보장
  3. 함수형 프로그래밍 스타일과 궁합: 데이터가 변경 불가능하기 때문에 변경 대신 새 객체를 생성 -> side effect 최소화
  4. 다른 클래스에서의 방어적 복사 불필요
  5. Hash 기반 컬렉션에 적합: HashMap, HashSet 등에서 key로 사용 시 값이 변하지 않으므로 안전.
  • 단점
  1. 객체가 갖는 값마다 매번 새로운 객체가 필요하기 때문에 메모리 누수와 성능 저하 발생

불변 클래스 예시

  1. 대표적인 자바 불변 클래스

    • String
    • Integer, Long, BigDecimal 등 Wrapper 클래스들
    • LocalDate, LocalDateTime
  2. 직접 구현

public final class Person {
    private final String name;
    private final int age;

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

    // getter만 제공, setter 미제공
    public String getName(){ return name; }
    public int getAge() { return age; }
}

불변 클래스 설계 원칙

  1. final 클래스로 선언 => 상속 금지(상태 변경 위험 방지)
  2. 모든 필드를 private final로 선언
  3. setter 메서드(접근 메서드) 제공 금지
  4. 변경 가능한 객체(Mutable object)를 필드로 가질 경우 해당 필드에 대한 방어적 복사 필요
public final class Student {
    private final String name;
    private final Date birth; // Date는 mutable 클래스

    public Student(String name, Date birth) {
        this.name = name;
        this.birth = new Date(birth.getTime()); // 방어적 복사 
    }

    public Date getBirth() {
        return new Date(birth.getName()); // 방어적 복사본 반환
    }
}
  1. equals, hashCode, toString 메서드 오버라이딩 고려

불변 클래스 vs 가변 클래스

구분 불변 클래스 가변 클래스
상태 변경 불가능 가능
스레드 안전 기본적으로 안전 별도 동기화 필요
메모리 사용 새로운 객체 계속 생성 같은 객체 재사용
예시 String, Integer, LocalDate StringBuilder, ArrayList

record

record는 JDK 16부터 정식으로 도입된 새로운 타입의 클래스으로, 불변 객체를 간단하게 정의할 수 있도록 설계된 문법적 편의 구문

  • 데이터 전용 클래스를 쉽게 작성할 수 있도록 지원
  • record 키워드로 선언
  • 자동 생성: private final 필드, 생성자, equals(), hashCode(), toString() 메서드
  • 보일러 플레이트 코드, 즉 불필요하게 반복 작성되는 코드를 줄여준다

record 사용 예시

public record Point(int x, int y) {}

class Main {
    public static void main(String[] args) {
        Point p1 = new Point(10, 20);
        Point p2 = new Point(10, 20);

        System.out.println(p1);
        System.out.println(p1.equals(p2));
        System.out.println(p1.hashCode());
    }
}

record 특징

  • 불변성: 필드가 private final로 고정되므로 객체의 상태가 변경되지 않는다.
  • 상속 제한: 다른 클래스를 상속할 수도 없고, 다른 클래스에 상속될 수도 없다.
  • 인터페이스 구현 가능: 클래스처럼 인터페이스는 구현 가능하다.
  • 필드 추가 불가: 자동 생성된 필드 외엔 인스턴스 필드 직접 선언 불가능, static 필드는 가능
  • 명시적 선언 시에도 equals, hashCode, toString 기본적으로 생성

sealed class: 봉인된 클래스

  • sealed는 어떤 클래스나 인터페이스를 상속/구현할 수 있는 타입을 제한한다.
  • 상속 가능한 "하위 클래스 목록"을 명시적으로 선언해야 한다.
  • 계층 구조를 명확히 제한하고, 유지 보수성과 안전성을 확보할 수 있다.
public sealed class Shape permits Circle, Rectangle, Square {
    ...
}

sealed class의 하위 클래스 선택지

  • sealed 클래스를 상속하거나 sealed 인터페이스를 구현하는 클래스는 반드시 다음 세 가지 중 하나를 선택해야 한다.
  1. final: 더 이상 상속할 수 없음(계층 닫음)
public final class Circle extends Shape {}
  1. sealed: 다시 제한을 걸어 특정 클래스에만 상속 허용
public sealed class Rectangle extends Shape permits FilledReatangle {}
  1. non-sealed: 상속 제한 없음 => 일반 클래스처럼 누구나 상속 가능
public non-sealed class Square extends Shape {}

sealed class 사용 예시

public sealed interface Payment permits CardPayment, CashPayment { }

public final class CardPayment implements Payment {}

public non-sealed class CashPayment implements Payment {}

sealed class 장점

  • 패턴 매칭과 결합해 사용하면 컴파일러가 타입 체계의 완전성을 보장
static String describe(Shape s) {
    return switch(s) {
        case Circle c -> "Circle";
        case Rectangle r -> "Rectangle";
        case Square s1 -> "Square";
    }; // switch 구문에 모든 하위 클래스가 다 나열 되어야 컴파일 가능 
}
  • 유지 보수성 향상: 새로운 하위 클래스를 추가할 때, 상위 클래스의 permits 목록을 반드시 수정해야 한다. => 계층 구조가 명확해진다.
  • 안전한 상속 모델 제공: 불필요한 상속 구조의 확산을 방지

sealed 인터페이스와 record 조합

public sealed interface Shape permits Circle, Rectangle, Square {
    double area();
}

public record Circle(double radius) implements Shape {
    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

public record Rectangle(double width, double height) implements Shape {
    @Override
    public double area() {
        return width * height;
    }
}

public record Square(double side) implements Shape {
    @Override
    public double area() {
        return side * side;
    }
}
  • 보일러 플레이트 제거: record가 getter/생성자/equals/hashCode/toString 자동 생성

  • 계층 안전성 확보: sealed가 상속 가능한 하위 타입을 고정

  • 패턴 매칭 완전성: switch문에서 누락된 타입이 있으면 컴파일 에러 발생