본문 바로가기

C#

C# init 접근자를 활용한 불변 객체 설계

init 접근자는 객체 초기화 시점에만 설정을 허용하여 이후에는 변경을 막는 기능입니다. 레코드(record)뿐 아니라 클래스에도 적용할 수 있어 도메인 모델의 안정성과 유지보수성을 높입니다.

1. init 접근자 기본

객체 생성 시점에만 값을 설정하고, 이후에는 컴파일러가 변경을 차단합니다. 아래 예시는 간단한 불변 엔티티입니다.

public class Product
{
    public string Name { get; init; }
    public decimal Price { get; init; }
    public DateTime CreatedAt { get; } = DateTime.UtcNow;
}

var p = new Product { Name = "키보드", Price = 29.99m };
// p.Price = 30m; // 컴파일 오류: init 프로퍼티는 생성 이후 변경 불가

2. 검증 가능한 init

init 접근자에도 본문을 둘 수 있어 초기화 시점에 유효성 검증을 수행합니다. 생성자와 함께 사용하면 명확한 계약을 만들 수 있습니다.

public class User
{
    private string _email = string.Empty;
    public string Email
    {
        get => _email;
        init
        {
            if (string.IsNullOrWhiteSpace(value))
                throw new ArgumentException("Email은 필수입니다", nameof(value));
            _email = value.Trim();
        }
    }

    public string Id { get; init; }
}

var u = new User { Id = "U001", Email = "  a@b.com  " };

3. required와 init로 완전성 보장

C# 11의 required 키워드를 init와 함께 사용하면 필수 프로퍼티 누락을 컴파일 타임에 방지합니다.

public class Account
{
    public required string AccountId { get; init; }
    public required string OwnerName { get; init; }
    public decimal Balance { get; init; } = 0m;
}

// 누락 시 컴파일 오류 발생
var acc = new Account { AccountId = "A-100", OwnerName = "Kim" };

4. 레코드와 with 표현식

레코드는 값 기반 동등성과 with 복사 수정이 자연스럽게 지원되어 불변 모델에 적합합니다.

public record Order(string Id, decimal Amount)
{
    public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
}

var o1 = new Order("O-1", 50m);
var o2 = o1 with { Amount = 99m }; // 새로운 인스턴스 생성

5. 클래스를 위한 With 패턴

클래스에서는 팩토리/복사 메서드로 with 유사 동작을 구현합니다. 내부에서 새 인스턴스를 만들고 init 프로퍼티를 설정합니다.

public class Settings
{
    public string Region { get; init; } = "KR";
    public int TimeoutSeconds { get; init; } = 30;

    public Settings With(Action<Settings> mutate)
    {
        var copy = new Settings
        {
            Region = this.Region,
            TimeoutSeconds = this.TimeoutSeconds
        };
        mutate(copy);
        return copy;
    }
}

var s1 = new Settings();
var s2 = s1.With(s => s.TimeoutSeconds = 60);

6. 컬렉션을 불변으로

프로퍼티가 참조형일 경우 내부 컬렉션까지 불변으로 관리해야 합니다. System.Collections.Immutable을 활용합니다.

using System.Collections.Immutable;

public class Cart
{
    public ImmutableArray<string> Items { get; init; } = ImmutableArray<string>.Empty;
}

var c1 = new Cart { Items = ImmutableArray.Create("A", "B") };
var c2 = new Cart { Items = c1.Items.Add("C") }; // 새 배열 반환

7. 직렬화와 ORM 호환성

System.Text.Json은 init 프로퍼티를 정상적으로 역직렬화합니다(.NET 5+). 레코드/클래스 모두 지원합니다. EF Core 6+에서는 init 사용이 가능하며, 엔티티 변경 추적 시 값 변경이 필요하다면 init 대신 set가 필요한 경우가 있습니다. 값 객체(Value Object)나 읽기 전용 ViewModel에는 init가 적합합니다.

8. 테스트와 유지보수 팁

도메인 규칙을 init 검증에 담아 테스트로 보장합니다. 변경은 with/복사 메서드로만 수행하여 불변 계약을 유지합니다. 필수 값은 required로 강제하고, 컬렉션은 Immutable 계열로 관리합니다.

9. 마이그레이션 체크리스트

setter를 init로 바꿀 때, 변경 로직이 있는지 확인합니다. 외부 바인딩(UI, ORM)이 런타임 변경을 요구하면 init 적용을 피합니다. 직렬화 경로(System.Text.Json/MessagePack 등)에서 역직렬화가 정상 동작하는지 점검합니다.

10. 요약

init 접근자는 설계 시점에 불변성을 강제하여 버그를 줄이고 추론 가능성을 높입니다. record의 with, 클래스의 With 패턴, required와 조합하면 안전하고 실용적인 불변 객체 모델을 구현할 수 있습니다.