C++에서 C#으로 넘어올 때 주의할 점
C++만 쓰다가 가끔 C#을 다뤄야 할 때가 있는데, 이럴 때마다 “왜 안 되지?” 싶어서 시간을 낭비하는 경우가 종종 생긴다.
2026년 수정 안내 원래 이 글은 “C++였다면 이렇게 될 것”이라며 비교 코드를 실었는데, 그 C++ 코드도 사실 같은 버그를 갖고 있었다(
auto가 값을 복사한다). 그리고 “C#의ref가 원소에 다시ref로 접근하지 못해서”라는 원인 설명도 핵심을 비껴갔다. 진짜 원인은ServerStatus가 struct(값 타입) 라는 데 있다. 바로잡는다.
// 이 코드도 사실 버그가 있다. auto가 값을 복사하기 때문에
// serverStatus는 리스트 원소의 복사본이고, 그걸 고쳐도 원본은 그대로다.
public void CheckAllServers( List<ServerStatus>& list )
{
for ( auto serverStatus : list )
serverStatus.status = CheckSocket( serverStatus.ip, serverStatus.port );
}
의도한 대로 원본을 고치려면 C++에서도 참조로 받아야 한다.
public void CheckAllServers( List<ServerStatus>& list )
{
for ( auto& serverStatus : list ) // & 하나 차이지만 의미가 완전히 다르다
serverStatus.status = CheckSocket( serverStatus.ip, serverStatus.port );
}
C#에서 겪었던 것과 똑같이, auto(값 복사)로 순회하면 원본이 바뀌지 않는다는 사실을 C++를 오래 쓴 사람도 종종 놓친다. 아래 C# 코드가 번거로웠던 이유도, 사실 이것과 같은 원인이었다.
public void CheckAllServers( ref List<ServerStatus> list )
{
for ( int i = 0; i < list.Count; ++i )
{
ServerStatus serverStatus = list[ i ];
serverStatus.status = CheckSocket( serverStatus.ip, serverStatus.port );
list[ i ] = serverStatus;
}
}
이렇게 사용해야한다.
진짜 원인 : ref가 아니라 struct
원래 이 번거로움의 원인을 “ref로 받은 리스트의 원소는 다시 ref로 접근할 수 없기 때문”이라고 적었는데, 핵심을 비껴간 설명이었다. 진짜 원인은 ServerStatus가 struct(값 타입)라는 것이다.
C#에서 List<T>의 인덱서 list[i]는, T가 값 타입이면 원소의 복사본을 돌려준다. 그래서 serverStatus.status = ...로 복사본을 고쳐도 리스트 안의 원본은 그대로다. 위 코드가 다시 list[i] = serverStatus;로 대입하는 이유가 바로 이것 — 고친 복사본을 원본 자리에 되돌려 넣는 것이다.
그리고 이건 ref 파라미터와는 무관하다. List<T>는 애초에 참조 타입이므로, 메서드 인자에 ref를 붙이지 않아도 리스트 자체는 같은 객체를 가리킨다.
// ref 없이도 동일하게 동작한다. List<T> 자체가 이미 참조 타입이기 때문.
public void CheckAllServers( List<ServerStatus> list )
{
for ( int i = 0; i < list.Count; ++i )
{
ServerStatus serverStatus = list[ i ];
serverStatus.status = CheckSocket( serverStatus.ip, serverStatus.port );
list[ i ] = serverStatus;
}
}
만약 class였다면
ServerStatus를 struct 대신 class로 선언했다면, list[i]가 돌려주는 것이 참조이므로 처음 시도했던 것과 같은 형태가 그대로 동작한다.
public class ServerStatus { public string ip; public int port; public int status; }
public void CheckAllServers( List<ServerStatus> list )
{
foreach ( var serverStatus in list ) // 참조를 순회
serverStatus.status = CheckSocket( serverStatus.ip, serverStatus.port ); // 원본이 바로 바뀐다
}
즉 번거로움의 정체는 “C#의 참조 처리 방식”이 아니라 struct와 class 중 무엇을 골랐는가였다.
참고 : .NET 5 이상이라면
CollectionsMarshal.AsSpan(list)를 쓰면 List<T>의 원소를 ref로 직접 다룰 수 있어, struct를 유지하면서도 복사-수정-재대입 없이 고칠 수 있다. CollectionsMarshal은 .NET 5 BCL에 추가된 API라 C# 언어 버전이 아니라 대상 런타임이 .NET 5 이상인지가 기준이다. (C# 8은 .NET Core 3.x 시절 언어라 그 자체로는 이 API를 쓸 수 없고, 반대로 프로젝트의 언어 버전을 낮게 잡아도 런타임이 .NET 5 이상이면 쓸 수 있다 — 언어 버전과 대상 프레임워크는 별개의 설정이다.)
foreach ( ref var serverStatus in CollectionsMarshal.AsSpan( list ) )
serverStatus.status = CheckSocket( serverStatus.ip, serverStatus.port );
정리
C#의 참조 방식이 C++보다 불편한 것이 아니라, struct(값 타입)와 class(참조 타입) 중 어느 것을 쓸지가 동작을 결정한다는 점이 C++에서 넘어올 때 가장 헷갈리는 지점이다. C++의 class/struct는 문법적으로 거의 동일하지만(참고: 클래스와 구조체의 차이), C#은 이 둘이 값 타입/참조 타입이라는 근본적으로 다른 의미를 가진다.
댓글 남기기