문제의 코드

추상 클래스에서 턴 목록을 생성하는 코드를 작성했다. 하위 클래스가 CreateTurns()를 구현하면, 생성자에서 이를 호출해 _turns를 초기화하는 구조다.

public abstract class Round
{
    private readonly List<Turn> _turns;
 
    protected Round()
    {
        _turns = CreateTurns();
    }
 
    public IReadOnlyList<Turn> Turns => _turns;
 
    protected abstract List<Turn> CreateTurns();
}

얼핏 보면 자연스러운 설계다. 그런데 JetBrains 인스펙터가 이런 경고를 띄운다:

Virtual member call in constructor

생성자에서 virtual 또는 abstract 멤버를 호출하고 있습니다.

추상 클래스에서 추상 메서드를 쓰는 건 당연한 일 아닌가? 이게 왜 경고인지 처음엔 납득이 안 됐다.

왜 문제인가 - 객체 초기화 순서

핵심은 C#의 생성자 실행 순서에 있다.

C#에서 상속 관계의 객체를 생성하면, 생성자는 항상 base → derived 순서로 실행된다. 반면 virtual/abstract 메서드 호출은 항상 가장 하위 클래스의 override로 dispatch된다.

이 두 규칙이 합쳐지면 문제가 생긴다. 다음 하위 클래스를 보자:

public class BossRound : Round
{
    private readonly int _bossHp;
 
    public BossRound(int bossHp)
    {
        _bossHp = bossHp;  // ② 나중에 실행됨
    }
 
    protected override List<Turn> CreateTurns()
    {
        // ① Round 생성자에서 먼저 호출됨
        // 이 시점에 _bossHp는 아직 0 (default)
        return Enumerable.Range(0, _bossHp)
            .Select(_ => new Turn())
            .ToList();
    }
}

실행 순서를 따라가 보면:

  1. new BossRound(10) 호출
  2. Round 생성자 실행 → CreateTurns() 호출
  3. virtual dispatch에 의해 BossRound.CreateTurns() 실행
  4. 이 시점에 BossRound 생성자는 아직 실행 전_bossHp0
  5. 빈 리스트가 반환됨
  6. BossRound 생성자 실행 → _bossHp = 10 할당 (이미 늦음)

초기화되지 않은 객체의 메서드를 호출하는 셈이다. 10을 넘겼는데 턴이 0개 생성되고, 예외도 발생하지 않는다. 조용히 잘못된 상태가 만들어진다.

해결 - Lazy Initialization

가장 깔끔한 해결은 지연 초기화다. 생성자에서 호출하지 않고, 실제로 접근할 때 초기화한다:

public abstract class Round
{
    private List<Turn>? _turns;
 
    public IReadOnlyList<Turn> Turns => _turns ??= CreateTurns();
 
    protected abstract List<Turn> CreateTurns();
}

??= (null-coalescing assignment) 연산자 덕분에 Turns에 처음 접근하는 시점에 CreateTurns()가 호출된다. 이 시점이면 하위 클래스의 생성자까지 전부 완료된 상태이므로, 모든 필드가 정상적으로 초기화되어 있다.

readonly를 포기해야 하지만, 외부에는 IReadOnlyList<Turn>으로만 노출되므로 불변성은 유지된다.

정리

이 패턴이 까다로운 이유는 당장은 문제가 없을 수 있다는 점이다. 하위 클래스에서 자기 필드를 참조하지 않는 한 정상 동작한다. 하지만 누군가 나중에 필드를 사용하는 override를 작성하는 순간, 예외 없이 잘못된 값이 들어가는 버그가 생긴다.

JetBrains 인스펙터가 이 경고를 띄우는 이유가 바로 이것이다 - 지금의 코드가 아니라 미래의 확장에서 깨질 수 있는 구조를 경고하는 것이다.