static변수를 overriding한다면?

방금 신기한 이슈를 맞이했습니다.
부모클래스 A에서
protected static List<(string filter, int count)> filterList {get;set;}
을 선언해두고,
자식클래스 B,C,D에서 override하고 메모리를 할당했습니다. 아래처럼요
filterList = new List<(string filter, int count)>() { ("B", 1) };
filterList = new List<(string filter, int count)>() { ("C", 2) };
filterList = new List<(string filter, int count)>() { ("D", 3) };

그리고 B와 C 인스턴스를 병렬적으로 동시에 생성하여 처리하니까, filterList를 호출할 때 C 인스턴스에서 B의 filterList 값을 사용하더라구요!

혹시 static이 메모리에 어떻게든 영향을 준 것인가 싶어서 static을 지우고 실행하였더니 위 이슈가 발생하지 않았습니다!!

이 현상이 논리적으로 납득이 되시는 분은 시끄럽게 일어나주시길 부탁드립니다 :pray:

3 Likes

흐음… 어디서부터 태클을 걸어야 하는지 10분간 멍하니 있었는데요

static을 쓰지 않는게 좋겠네요.

8 Likes

일종의 동시성 문제로 보입니다.

아시다시피, 각 스레드가 실행하는 순서는 알 수 없습니다.
이는 각 파생 객체가 생성되는 순서를 예측할 수 없다는 의미이고, 언제 할당된 것을 언제 사용할 지 알 수 없다는 의미입니다.

그런데, 디버그 모드로 실행하면, 스레드의 실행 순서가 고착되는 현상 같은 게 있습니다. 그래서, 규칙성이 있어 보이는 착시(C가 항상 B의 값을 사용)가 있을 수 있습니다.

릴리스 모드로 빌드하고 여러 번 실행하면 그런 규칙성이 누그러진다는 점을 발견할 수 있을 것입니다.

만약, 병렬 실행하지 않는다면, 초기화/읽는 순서 대로 읽혀질 것입니다.
그런데, 변수가 static 이라, 여전히 동시성 문제와 비슷한 양상을 보일 것입니다.

B b;
C c;
D d;

b = new();
var val = B.filterList[0].count; // ?;

c = new();
val = B.filterList[0].count; // ?;

d = new();
val = B.filterList[0].count; // ?;

static 변수는 OOP 입장에서는 토끼굴입니다.
어떤 놈이 들어 가서, 어떤 놈이 나올 지 알 수가 없습니다.

가급적 사용하지 않는 것이 좋습니다.
(static 메서드는 해당되지 않습니다.)

6 Likes

재미있어 보여서 조금 실험해봤습니다. 말씀하신 상황만 놓고 보면 대략 다음과 같은 코드를 만든 상황인 것 같은데요,

public class A
{
	public static List<(string filter, int count)>? filterList { get; set; }
	public virtual void Test() { }
}

public class B : A
{
	public override void Test()
		=> filterList = new List<(string filter, int count)>() { ("B", 1) };
}

public class C : A
{
	public override void Test()
		=> filterList = new List<(string filter, int count)>() { ("C", 2) };
}

public class D : A
{
	public override void Test()
		=> filterList = new List<(string filter, int count)>() { ("D", 3) };
}

그리고 각 클래스의 Test 메서드를 호출한 후 서로 인스턴스가 다르게 할당되는가 확인해봤을 때, A, B, C, D가 모두 같은 인스턴스를 사용하는 것으로 나왔습니다.

static class Program
{
	static void Main()
	{
		A a = new A();
		
		// This method does not allocate filterList instance
		a.Test();

		$"A != null: {A.filterList != null} | B != null: {B.filterList != null} | C != null: {C.filterList != null}".Dump();
		object.ReferenceEquals(A.filterList, B.filterList).Dump();
		object.ReferenceEquals(A.filterList, C.filterList).Dump();
		object.ReferenceEquals(A.filterList, D.filterList).Dump();
		object.ReferenceEquals(B.filterList, A.filterList).Dump();
		object.ReferenceEquals(B.filterList, C.filterList).Dump();
		object.ReferenceEquals(B.filterList, D.filterList).Dump();
		object.ReferenceEquals(C.filterList, A.filterList).Dump();
		object.ReferenceEquals(C.filterList, B.filterList).Dump();
		object.ReferenceEquals(C.filterList, D.filterList).Dump();
		object.ReferenceEquals(D.filterList, A.filterList).Dump();
		object.ReferenceEquals(D.filterList, B.filterList).Dump();
		object.ReferenceEquals(D.filterList, C.filterList).Dump();
		foreach (var i in Enumerable.Range(0, 10).AsParallel())
		{
			B b = new B();
			b.Test();
			$"A != null: {A.filterList != null} | B != null: {B.filterList != null} | C != null: {C.filterList != null}".Dump();
			object.ReferenceEquals(A.filterList, B.filterList).Dump();
			object.ReferenceEquals(A.filterList, C.filterList).Dump();
			object.ReferenceEquals(A.filterList, D.filterList).Dump();
			object.ReferenceEquals(B.filterList, A.filterList).Dump();
			object.ReferenceEquals(B.filterList, C.filterList).Dump();
			object.ReferenceEquals(B.filterList, D.filterList).Dump();
			object.ReferenceEquals(C.filterList, A.filterList).Dump();
			object.ReferenceEquals(C.filterList, B.filterList).Dump();
			object.ReferenceEquals(C.filterList, D.filterList).Dump();
			object.ReferenceEquals(D.filterList, A.filterList).Dump();
			object.ReferenceEquals(D.filterList, B.filterList).Dump();
			object.ReferenceEquals(D.filterList, C.filterList).Dump();

            // C나 D에 filterList를 할당한 적이 없음에도 이미 할당이 된 상태이며 전부 같은 reference로 나오게 됩니다.

			C c = new C();
			c.Test();
			$"A != null: {A.filterList != null} | B != null: {B.filterList != null} | C != null: {C.filterList != null}".Dump();
			object.ReferenceEquals(A.filterList, B.filterList).Dump();
			object.ReferenceEquals(A.filterList, C.filterList).Dump();
			object.ReferenceEquals(A.filterList, D.filterList).Dump();
			object.ReferenceEquals(B.filterList, A.filterList).Dump();
			object.ReferenceEquals(B.filterList, C.filterList).Dump();
			object.ReferenceEquals(B.filterList, D.filterList).Dump();
			object.ReferenceEquals(C.filterList, A.filterList).Dump();
			object.ReferenceEquals(C.filterList, B.filterList).Dump();
			object.ReferenceEquals(C.filterList, D.filterList).Dump();
			object.ReferenceEquals(D.filterList, A.filterList).Dump();
			object.ReferenceEquals(D.filterList, B.filterList).Dump();
			object.ReferenceEquals(D.filterList, C.filterList).Dump();
		}
	}
}

알아차리기 어렵고 미묘한 부분이긴 한데, 결국 static 필드나 속성은 현재 선언된 클래스 뿐만 아니라, 자식 클래스에 대해서까지도 유일성이 보장되는 특성이 있는 것으로 보입니다. 그래서 말씀해주신대로 static을 지우면 매번 인스턴스화 대상이 되므로 결과가 달라지게 되는 것이겠고요!

잘 알고 쓰는 것이라면 괜찮겠지만, 잘못 이해하고 쓰면 큰 부작용을 만들 수 있는 코드가 되겠습니다.

혹시 잘못 접근한 부분이 있을 경우 피드백 주시면 감사하겠습니다!

6 Likes

static으로 선언된 멤버나 속성은 선언된 형식 자체에 할당됩니다.
상속 받은 클래스들에 선언된 것이 아니라 결국은 A 클래스에만 할당된다고 볼 수 있을 것입니다.

그런데, 꼼수로 제네릭을 살짝 이용하면 원하시는 결과를 얻을 수도 있을 것 같습니다.
제네릭 형식 파라미터에 따라 해당 형식이 서로 다른 형식이 되기 때문에,
즉, 서로 다른 형식이라면 독립된 영역에 새로운 static 멤버나 속성이 할당되게 될 것입니다.

class A<T> where T : A<T>
{
    public static List<(string filter, int count)> filterList { get; set; }
    public virtual void InitFilterList() { }
}

class B : A<B>
{
    public override void InitFilterList()
        => filterList = new List<(string filter, int count)>() { ("B", 1) };
}

class C : A<C>
{
    public override void InitFilterList()
        => filterList = new List<(string filter, int count)>() { ("C", 1) };
}

class Program
{
    static void Main(string[] args)
    {
        new B().InitFilterList();
        new C().InitFilterList();

        var b = B.filterList;
        var c = C.filterList;

        foreach (var item in b)
            Console.WriteLine(item);
        foreach (var item in c)
            Console.WriteLine(item);
    }
}

한번 실행해 보시면 B와 C의 filterList에 서로 다른 값이 들어있을 것입니다.

음… 이 방법은 어디까지나 그냥 꼼수입니다.

4 Likes

@Vagabond-K 님 말씀대로 제네릭을 사용하지 않는 한 static 멤버는 늘 유일성을 보장합니다. 말씀하신 방법처럼 사용하면 동작 결과를 예측하기 쉽지 않을 것입니다.

만약 각 클래스별로 하나의 필터리스트만을 공유해야 하는 상황이라면 @Vagabond-K 님의 솔루션대로 제네릭을 통하거나, 클래스의 Type을 key로 갖는 딕셔너리를 선언해서 이용하는 게 어떨까 싶네요.

2 Likes

다들 잘 설명해주셨으니, 추가적으로 아래 내용 한번 읽어보시면 좋을 것 같습니다.


image

이 이슈는 논리적으로 설명될 수 있습니다. static 키워드를 사용하면 해당 필드는 클래스 자체의 인스턴스가 아니라 클래스 수준에서 공유됩니다. 따라서, filterListA 클래스의 모든 인스턴스가 공유하는 하나의 필드가 됩니다.

즉, B, C, D 클래스의 인스턴스가 각각 filterList를 설정하면, 사실상 같은 필드를 서로 다른 값으로 덮어쓰게 됩니다. 이로 인해 filterList 값이 예기치 않게 다른 인스턴스에서 설정한 값으로 바뀌는 현상이 발생할 수 있습니다.

filterListstatic으로 선언한 이유가 모든 인스턴스에서 동일한 값을 공유하기 위해서라면 올바른 동작이지만, 각 인스턴스별로 독립적인 값을 갖기를 원한다면 static 키워드를 제거하는 것이 맞습니다.

아래는 코드로 설명한 예시입니다:

class A
{
    protected List<(string filter, int count)> filterList { get; set; }
}

class B : A
{
    public B()
    {
        filterList = new List<(string filter, int count)>() { ("B", 1) };
    }
}

class C : A
{
    public C()
    {
        filterList = new List<(string filter, int count)>() { ("C", 2) };
    }
}

class D : A
{
    public D()
    {
        filterList = new List<(string filter, int count)>() { ("D", 3) };
    }
}

이제 각 인스턴스는 filterList의 독립적인 인스턴스를 가지게 되어, 병렬적으로 처리해도 다른 인스턴스의 값이 섞이는 일이 발생하지 않습니다.

따라서, 이 현상은 static 키워드로 인한 공유 메모리 때문이었으며, static을 제거함으로써 해결할 수 있었습니다.

1 Like

으아니 어떻게 마무리할까 고민하고 있었는데 정리 감사합니다^^
제가 아래 서툴게 정리해둔 글도 있으니 참고해주세요!

오해는 퍼지기 전에 빨리 막는 것이 좋으니 우선 블로그 글을 잠시 내려두시고 2~3일 정도 더 학습하신 후에 다시 정리해보세요.

첫 번째로 예제 코드로 적으신 내용은 static 프로퍼티에 할당(assign) 해준 것입니다.
오버라이드는 재정의로 부모의 참조에서 자식의 동작에 접근할 수 있도록 하는 것입니다. 그래서 위에 rkttu 님의 예처럼 프로퍼티에 override 키워드를 사용하거나 메서드에 사용합니다.
상속받은 클래스를 생성하면 base 키워드로 부모에 접근이 가능하듯 자식과 부모의 동작이 별개로 존재하게 됩니다.

두 번째로 static 멤버는 어셈블리가 로드되면(대충 main 직전에) 사용하는 클래스들의 static 멤버에 접근할 수 있습니다. static 클래스가 아니어도 마찬가지입니다. 인스턴스를 생성하는 것과 관계 없습니다.
new B(); new C(); 없이 접근이 가능합니다. 즉 main 코드 실행 전에 결정된 상태입니다.

static 필드는 첫 메모리 값을 고정하는 것이 아닙니다. 클래스의 멤버인가, 인스턴스의 멤버인가의 차이입니다. static 멤버를 인스턴스와 구분하기 위해 클래스 멤버라고도 불렀습니다.

MS 문서 링크를 첨부한 곳을 보시면 이런 내용이 있습니다.

클래스 인스턴스에는 클래스의 모든 인스턴스 필드에 대한 별도 복사본이 포함되지만 각 static 필드의 복사본은 한 개만 있습니다.

분리해서 이해하기 위해 이렇게 접근해보세요.

  1. Program 클래스에서 생성자를 지우고 실행해보세요.
  2. 클래스 A 에 static 멤버 프로퍼티 int를 포함해서 만들고, B에서 A의 static 멤버를 바꾸고 C에서 A의 static 멤버를 바꾸시면 공유된 멤버에 대해 마지막 사용된 결과를 확인할 수 있습니다. 메모리 영역이 공유된다고 생각하세요.

override 는 이 문제와 관계가 없으니 나중에 따로 학습하세요.

3 Likes

스태틱 변수는 인스턴스가 단 한개뿐이라서 그렇게 되는거아닐까요? 싱글턴패턴처럼요?

1 Like

다른 이야기기만 궁금했는데 4o이건 GPT-4o 인건가요?? 항상 이미지로 올라와서 여쭤봐요

맞습니다~ ㅋ

1 Like

고정버튼이 있다면 고정하고 싶은 글이네요.
정확한 표현 지적에 정말 감사합니다. :+1:
결자해지의 마음으로 다시 한 번 정리해봅니다.

먼저 override 키워드는 말씀해주신대로 재정의입니다. 제가 여기서 필드를 override했다고 하려면 아래와 같이 작성해야 했을 겁니다.

class A
{
   virtual protected List<(string filter, int count)> filterList { get; set; }
}

class B : A
{
   protected override List<(string filter, int count)> filterList { get; set; }
   public B()
   {
       filterList = new List<(string filter, int count)>() { ("B", 1) };
   }
}

그리고 애초에 추상키워드와 정적키워드는 나란히 선언에 사용할 수 없습니다.
추상적인 멤버에게 메모리를 할당하는 건 말이 안되니까요.
제가 이 글에서 의도한 filterList는 기본클래스의 static 멤버변수 입니다.

두 번째로, 제가 정말 오해했던 부분은 static이 첫 메모리 값을 고정한다고 생각한 부분입니다. 하나의 메모리를 공유할 뿐, 첫 메모리라는 건 잘못된 표현입니다. B 생성 시 filterList("b", 1)가 저장되어있고 C 생성 시 ("c", 1)가 저장되어 있는 것이 그 증거죠.

제가 이 이슈를 흥미롭게 느꼈던 이유는 static으로 선언한 상태에서 B,C,D 인스턴스를 병렬 실행하니 결론적으로 마치 B에 C를 할당한 것처럼 의도하지 않은 값이 나왔기 때문입니다. 마법같아 보였던 이 현상을 @jinho 님의 말을 인용하여 한문장으로 말하면 static필드는 클래스 멤버니까! 라고 답변할 수 있겠습니다.

2 Likes

virtual은 abstract와 조금 다른 의미를 갖습니다.
virtual은 가상, abstract는 추상이라고 하는데 가상은 추상과 다르게 공간을 가지고 있습니다. 재정의할 수 있도록 선언하는 목적의 키워드입니다.
자바는 기본적으로 virtual 로 동작하죠.

그런데 말씀하신대로 static 과 동작하지 않네요.
Overridable property cannot be static
static 멤버를 재정의하는 것이 불가능하도록 언어를 설계한 것입니다.
virtual은 메모리에 할당할 수 있지만 protected static virtual 이 가능하고 상속받는 클래스들에서 override 한다면 클래스들이 메모리에 올라오면서 헬게이트가 열려서 override 막아놓은 것 같습니다.

2 Likes

그렇네요! 제네릭을 사용하면 런타임 입장에서 보면 인자를 다르게 지정할 때 마다 새로운 타입이 새롭게 늘어나는 효과로 간주되겠군요. :smiley:

2 Likes

이 부분은 const 와 혼동하신 것 같습니다.

static 멤버는 최초로 접근할 때 초기화(결정)됩니다.
근본적으로는 인스턴스가 생성될 때 초기화됨 혹은 application 생애 주기가 끝나기 전에는 초기화됨을 보장하는 것 뿐입니다.

즉, new B(); 하는 순간 결정되는 것이죠.

이에 반해 const 는 컴파일러의 의해 intern pool 에 생성되는 것이니, Main 이전에 결정된다는 표현이 맞습니다.

보통 이 둘을 개별적으로 사용할 때는 차이가 두드러지지 않으나,

const 멤버에 static 값 할당 => 에러
static 멤버에 const 값 할당 => 에러 없음.

을 통해 확인할 수 있습니다.

1 Like

저는 이 것을 다르게 알고 있습니다.

intern pool은 리터럴 문자열을 보관하는 별도의 Heap으로 알고 있습니다.
즉 string 타입만 저장한다는 것이지요.

const는 메모리가 따로 할당되지 않고 컴파일 시점에 코드가 다 바뀌어서 하드코딩식으로 박히는 것으로 알고 있습니다.

혹시 말씀하신 것에 대한 관련 자료가 있으실까요?

4 Likes

질문에 이미 답이 있는 듯합니다.

소스 코드에 박혀있는 문자열은 힙에 초기화 되는 것이죠.
Main 이전에.

다만, 질문의 List 가 참조 자료형이라, const 로 heap에 초기화(결정)되는 참조 자료형은 string 이 유일하기에 intern pool 얘기를 꺼낸 것입니다.

const List<object> 라는 것이 애초에 존재하지 않으니까요.

1 Like

const 와 혼동한 내용은 아닙니다.
CLR 을 2.0 기준으로 배우고 static 관련 스펙 변경을 따로 봐두지 않았습니다.
3.5까지 변화가 없었고 4.0부터 static 필드 접근할 때 초기화되는 것으로 지연을 지원하네요.
static 에 다 때려박으면 메모리 낭비일텐데 싶어서 지연하는 옵션이 있지 않을까 싶지만 메서드 외에 거의 사용하지 않아서 넘겼는데 언어 스펙에 적용되었네요.

혹시 관심있으신 분을 위해 참고 링크
https://csharpindepth.com/Articles/BeforeFieldInit

2 Likes

그 역사까지는 몰랐는데, 이참에 알게 되었습니다.

저는 단순히 static 으로 인한 자원 낭비 때문에, 초기화 시점을 지연시켰구나… 라고 추측만 했는데, 실제로 그런 거라니, 내심 뿌듯합니다.

2 Likes