문제의 코드
추상 클래스에서 턴 목록을 생성하는 코드를 작성했다. 하위 클래스가 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();
}
}실행 순서를 따라가 보면:
new BossRound(10)호출Round생성자 실행 →CreateTurns()호출- virtual dispatch에 의해
BossRound.CreateTurns()실행 - 이 시점에
BossRound생성자는 아직 실행 전 →_bossHp는0 - 빈 리스트가 반환됨
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 인스펙터가 이 경고를 띄우는 이유가 바로 이것이다 - 지금의 코드가 아니라 미래의 확장에서 깨질 수 있는 구조를 경고하는 것이다.