2025년 오픈소스 C# 버그 Top 10
#c-sharp #pvs-studio #static-analysis #open-source #dotnet
PVS-Studio 팀이 2025년 한 해 동안 오픈소스 C# 프로젝트에서 발견한 가장 흥미로운 버그 10가지를 선정하여 공개했습니다. 이 목록에는 .NET 9, MSBuild, ScottPlot, Lean 트레이딩 엔진 등 주요 프로젝트에서 발견된 결함들이 포함되어 있으며, 복사-붙여넣기 오류부터 LINQ 지연 실행과 캡처 변수의 미묘한 상호작용까지 다양한 유형의 버그가 다뤄집니다. 특히 1위로 선정된 버그는 LINQ의 지연 실행 메서드와 캡처 변수를 함께 사용할 때 발생하는 문제로, 개발자들이 놓치기 쉬운 미묘한 오류를 보여줍니다.
선정 기준
선정된 코드는 다음 기준을 충족해야 합니다:
- 오픈소스 프로젝트에서 발견된 것
- PVS-Studio에 의해 탐지된 것
- 오류를 포함할 가능성이 높은 코드
- 검토할 가치가 있는 흥미로운 코드
- 각 오류가 고유한 것
10위: ToInt64 대신 ToUInt64 사용 오류
.NET 9 검사 관련 기사에서 언급된 오류입니다.
case TypeCode.Int64:
variant = ComVariant.Create(value.ToInt64(ci)); break;
case TypeCode.UInt64:
variant = ComVariant.Create(value.ToInt64(ci)); break; // 오류
- 경고 코드: V3139 - 두 개 이상의 case 분기가 동일한 작업을 수행함
- 문제점:
TypeCode.UInt64case에서ToInt64()대신ToUInt64()메서드를 사용해야 함 - 원인: 복사-붙여넣기 오류로 추정됨
9위: 잘못된 포맷 문자열
Neo 및 NBitcoin 프로젝트 검사에서 발견된 오류입니다.
public override string ToString()
{
var sb = new StringBuilder();
sb.AppendFormat("{1:X04} {2,-10}{3}{4}",
Position,
OpCode,
DecodeOperand());
return sb.ToString();
}
- 경고 코드: V3025 [CWE-685] - 잘못된 포맷. AppendFormat 호출 시 예상되는 포맷 항목 수가 다름
- 문제점 1: 삽입할 인자 수가 포맷 문자열의 플레이스홀더 수보다 적어 예외 발생
- 문제점 2: 플레이스홀더 인덱싱이 0이 아닌 1부터 시작하여 인덱스 4에 해당하는 5번째 인자가 없음
8위: 자기 자신과 비교하는 Equals
Lean 트레이딩 엔진 검사에서 발견된 오류입니다.
public bool Equals(OptionStrategyDefinitionMatch other)
{
var positions = other.Legs.ToDictionary(leg => leg.Position,
leg => leg.Multiplier);
foreach (var leg in other.Legs) // 오류: this.Legs여야 함
{
int multiplier;
if (!positions.TryGetValue(leg.Position, out multiplier))
return false;
if (leg.Multiplier != multiplier)
return false;
}
return true;
}
- 경고 코드: V3192 -
Legs속성이GetHashCode에서 사용되지만Equals에서 누락됨 - 문제점:
other.Legs를 반복하면서 동일한other.Legs에서 파생된 딕셔너리와 비교함 - 수정 방법:
other.Legs를Legs로 교체해야 함
7위: 중복된 Equals 비교
ScottPlot 검사에서 발견된 오류입니다.
public bool Equals(CoordinateRangeMutable? other)
{
if (other is null)
return false;
return Equals(Min, other.Min) && Equals(Min, other.Min); // 오류
}
public override int GetHashCode()
{
return Min.GetHashCode() ^ Max.GetHashCode();
}
- 경고 코드 1: V3192 -
Max속성이GetHashCode에서 사용되지만Equals에서 누락됨 - 경고 코드 2: V3001 -
&&연산자 좌우에 동일한 하위 표현식Equals(Min, other.Min)존재 - 수정 방법:
&&피연산자 중 하나를Equals(Max, other.Max)로 변경해야 함
6위: 비트 연산 트릭
ScottPlot 검사에서 발견된 비트 연산 관련 오류입니다.
public static Interactivity.Key GetKey(this Keys keys)
{
Keys keyCode = keys & ~Keys.Modifiers;
Interactivity.Key key = keyCode switch
{
Keys.Alt => Interactivity.StandardKeys.Alt, // 도달 불가
Keys.Shift => Interactivity.StandardKeys.Shift, // 도달 불가
Keys.Control => Interactivity.StandardKeys.Control, // 도달 불가
// ...
};
}
열거형 값의 이진 표현:
-
Modifiers=0xFFFF0000 -
Shift=0x00010000 -
Control=0x00020000 -
Alt=0x00040000 -
경고 코드: V3202 - 도달 불가능한 코드 탐지됨. case 값이 매치 표현식 범위를 벗어남
-
문제점:
Modifiers에Shift,Control,Alt가 이미 포함되어 있어keys & ~Keys.Modifiers연산 후에는 해당 값들이 나올 수 없음
5위: 박싱 문제
.NET 9 검사에서 발견된 구조체 박싱 관련 오류입니다.
struct StackValue
{
public override bool Equals(object obj)
{
if (Object.ReferenceEquals(this, obj)) // 항상 false
return true;
// ...
}
}
- 경고 코드: V3161 - 값 형식 변수를
ReferenceEquals로 비교하는 것은this가 박싱되므로 올바르지 않음 - 문제점: 구조체에서
ReferenceEquals호출 시this가 박싱되어 힙에 새 참조가 생성되므로 항상false반환 - 결과: 최적화를 의도했으나 오히려 매 호출마다 불필요한 박싱 연산이 발생함
4위: 익명 함수로 이벤트 구독 해제
MSBuild 검사에서 발견된 오류입니다.
private static void SubscribeImmutablePathsInitialized()
{
NotifyOnScopingReadiness?.Invoke();
FileClassifier.Shared.OnImmutablePathsInitialized -= () =>
NotifyOnScopingReadiness?.Invoke(); // 효과 없음
}
- 경고 코드: V3084 - 익명 함수를 사용하여 이벤트에서 구독 해제함. 각 익명 함수 선언마다 별도의 델리게이트 인스턴스가 생성되므로 핸들러가 해제되지 않음
- 문제점: 구독 시와 해제 시 각각 새로운 델리게이트 인스턴스가 생성되어 구독 해제가 실제로 동작하지 않음
3위: 연산자 우선순위 혼란
Neo 및 NBitcoin 프로젝트 검사에서 발견된 오류입니다.
public override int Size => base.Size
+ ChangeViewMessages?.Values.GetVarSize() ?? 0
+ 1 + PrepareRequestMessage?.Size ?? 0
+ PreparationHash?.Size ?? 0
+ PreparationMessages?.Values.GetVarSize() ?? 0
+ CommitMessages?.Values.GetVarSize() ?? 0;
- 경고 코드: V3123 [CWE-783] -
??연산자가 예상과 다르게 동작할 수 있음. 좌측의 다른 연산자보다 우선순위가 낮음 - 문제점:
??연산자가+연산자보다 우선순위가 낮음 - 예시:
base.Size + ChangeViewMessages?.Values.GetVarSize() ?? 0에서ChangeViewMessages가null이면base.Size + null이null이 되어 결과가 항상 0임 - 수정 방법:
(ChangeViewMessages?.Values.GetVarSize() ?? 0)처럼 괄호로 감싸야 함
2위: 패턴 매칭 함정
Files 파일 관리자 검사에서 발견된 오류입니다.
var modeSeparatorWidth =
itemCount is not 0 or 1 // 문제 발생
? _modesHostGrid.Children[1] is FrameworkElement frameworkElement
? frameworkElement.ActualWidth
: 0
: 0;
- 경고 코드: V3207 [CWE-670] -
not 0 or 1논리 패턴이 예상대로 동작하지 않을 수 있음.not패턴이or패턴의 첫 번째 표현식에만 적용됨 - 의도한 의미: x가 0도 1도 아닌 경우
- 실제 의미: C#에서는
x is (not 0) or 1로 해석되어 두 번째 부분이 무의미해짐 - 유사 오류 예시:
list is not null or list.Count == 0과 같은 패턴에서NullReferenceException발생 가능
1위: LINQ 지연 실행과 캡처 변수
Lean 트레이딩 엔진 검사에서 발견된 가장 미묘한 오류입니다.
public void FutureMarginModel_MarginEntriesValid(string market)
{
var lineNumber = 0;
var csv = File.ReadLines(marginFile.FullName)
.Where(x => !x.StartsWithInvariant("#")
&& !string.IsNullOrWhiteSpace(x))
.Skip(1)
.Select(x =>
{
lineNumber++; // 지연 실행됨
// ...
});
lineNumber = 0; // foreach 전에 초기화
foreach (var line in csv)
{
lineNumber++; // 여기서도 증가
// ...
}
}
- 경고 코드: V3219 -
lineNumber변수가 지연 실행되는 LINQ 메서드에서 캡처된 후 변경됨. 메서드 실행 시 원래 값이 사용되지 않음 - 문제점:
Select는 지연 실행 메서드이므로 델리게이트 코드가Select호출 시가 아닌 컬렉션 반복 시 실행됨csv컬렉션 반복 중lineNumber가 델리게이트 내부와foreach내부에서 모두 증가하여 각 반복마다 2씩 증가함- 개발자는 델리게이트가 루프 진입 전에 실행될 것으로 예상했으나 실제로는 그렇지 않음
결론
PVS-Studio 팀이 선정한 2025년 가장 흥미로운 C# 버그 10가지를 살펴보았습니다. 프로젝트에 유사한 문제가 있는지 확인하려면 정적 분석기를 사용하는 것이 권장됩니다.