https://medium.com/net-code-chronicles/csharp-dotnet-code-hacks-50-d51099beab02
C#/.NET 코드 품질을 빠르게 향상시키는 50가지 핵
csharp #dotnet #programming #aspnet-core #code-quality
C#/.NET 개발자라면 코드 리뷰에서 지적받기 전에 알아야 할 실용적인 코드 개선 기법 50가지를 소개합니다. 파일 범위 네임스페이스와 타겟 타입 new를 도입하여 소규모 라이브러리에서 약 200줄을 줄였으며, 읽기 전용 엔드포인트에 AsNoTracking()을 적용하여 CPU 사용량을 약 20% 절감하고 GC 부담을 감소시켰습니다. 언어 구문, 가드 절, 비동기/동시성, LINQ와 컬렉션, I/O와 HTTP/JSON, EF Core 데이터 접근, ASP.NET Core DI와 미들웨어, 테스팅과 도구 품질 등 8개 테마별로 구성되어 있습니다. 작은 습관들이 모여 더 깔끔한 코드, 적은 버그, 편안한 온콜 대응으로 이어집니다.
1. 언어 및 구문 개선 (Language & Syntax Wins)
C# 언어의 최신 기능을 활용하여 코드를 더 간결하고 명확하게 작성할 수 있습니다.
1) using 선언 사용
블록 대신 using 선언을 사용하면 스코프가 좁아지고 중첩이 줄어듦.
using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
var line = await reader.ReadLineAsync();
2) 파일 범위 네임스페이스 (C# 10+)
들여쓰기가 줄어들고 의미는 동일함.
namespace MyApp.Models;
public sealed record UserId(Guid Value);
3) 타겟 타입 new
타입이 명확할 때 노이즈를 줄임.
Dictionary<string, int> scores = new();
4) 레코드의 with 표현식
불변 업데이트가 더 읽기 좋아짐.
public record User(string Name, int Age);
var older = user with { Age = user.Age + 1 };
5) switch 표현식
긴 if 체인보다 switch 표현식이 더 나음.
string ToEmoji(LogLevel level) => level switch
{
LogLevel.Trace => "·",
LogLevel.Debug => "•",
LogLevel.Information => "i",
LogLevel.Warning => "!",
LogLevel.Error => "",
_ => "?"
};
6) 패턴 매칭: is not null
if (obj is not null)
{
// safe to use
}
7) 기본 생성자 (C# 12)
작은 타입에 적합함.
public class Clock(ILogger<Clock> log)
{
public DateTimeOffset Now() => DateTimeOffset.UtcNow;
}
8) 필수 멤버 (C# 11)
public sealed class AppOptions
{
public required string ApiBaseUrl { get; init; }
public int TimeoutSeconds { get; init; } = 30;
}
9) 컬렉션 표현식 (C# 12)
int[] odds = [1, 3, 5, 7];
10) readonly 구조체
숨겨진 복사본으로 인한 예기치 않은 동작을 방지함.
public readonly record struct Money(decimal Amount, string Currency);
실제 프로젝트 경험: 파일 범위 네임스페이스와 타겟 타입 new로 전환하여 소규모 라이브러리에서 약 200줄을 줄였으며, 코드 리뷰가 더 쉬워짐.
2. 가드 절과 빠른 실패 (Guard Clauses & Fail Fast)
조기에 검증하고 빠르게 실패하는 패턴을 적용하면 코드 안정성이 향상됩니다.
11) 작은 가드 헬퍼
public static class Guard
{
public static T NotNull<T>(T? value, string name) where T : class
=> value ?? throw new ArgumentNullException(name);
}
var client = Guard.NotNull(httpClient, nameof(httpClient));
12) 생성자에서 조기 검증
public sealed class EmailService(string apiKey)
{
private readonly string _apiKey = !string.IsNullOrWhiteSpace(apiKey)
? apiKey : throw new ArgumentException("apiKey required");
}
13) 예외보다 TryParse 선호
if (!Guid.TryParse(idText, out var id)) return BadRequest("Invalid id");
14) 예외 필터로 한 번만 로깅
try
{
await work();
}
catch (Exception ex) when (Log(ex))
{
// filter always returns false, so this never runs
}
static bool Log(Exception ex) { /* log */ return false; }
15) API에서 ProblemDetails 반환
app.MapGet("/users/{id}", async (string id, IUserRepo repo) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.Problem("Invalid id", statusCode: 400);
var user = await repo.Find(guid);
return user is null ? Results.NotFound() : Results.Ok(user);
});
3. 비동기 및 동시성 (Async & Concurrency)
비동기 코드 작성 시 올바른 패턴을 적용하면 안정성과 성능이 향상됩니다.
16) 라이브러리 코드에서 async void 금지
호출자가 await하고 오류를 처리할 수 있도록 Task를 사용해야 함.
17) CancellationToken 존중
public async Task<User?> Find(Guid id, CancellationToken ct)
=> await _db.Users.FindAsync([id], ct);
18) 라이브러리에서 ConfigureAwait(false) 사용
await stream.WriteAsync(buffer, ct).ConfigureAwait(false);
19) 스트림에 IAsyncEnumerable 사용
await foreach (var line in ReadLines(path, ct))
{
// process
}
static async IAsyncEnumerable<string> ReadLines(string p, [EnumeratorCancellation] CancellationToken ct)
{
using var r = new StreamReader(File.OpenRead(p));
while (!r.EndOfStream)
yield return (await r.ReadLineAsync(ct))!;
}
20) SemaphoreSlim으로 병렬 작업 제한
var gate = new SemaphoreSlim(4);
await Parallel.ForEachAsync(items, async (x, ct) =>
{
await gate.WaitAsync(ct);
try { await Process(x, ct); }
finally { gate.Release(); }
});
21) 생산자/소비자에 Channel 사용
var channel = Channel.CreateUnbounded<string>();
_ = Task.Run(async () =>
{
await foreach (var m in channel.Reader.ReadAllAsync())
Console.WriteLine(m);
});
await channel.Writer.WriteAsync("Hello");
22) 정기 작업에 PeriodicTimer 사용
var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(ct))
{
await DoWork(ct);
}
23) 핫 경로에 ValueTask 선호
결과가 자주 캐시되거나 동기적일 때만 사용함.
public ValueTask<User?> GetCached(Guid id)
=> _cache.TryGetValue(id, out var u)
? new(u) : new(_repo.Find(id));
24) 진정한 병렬성을 위해 Task.WhenAll 사용
var tasks = urls.Select(DownloadAsync);
var pages = await Task.WhenAll(tasks);
교훈: 예전의 “fire-and-forget” 메서드가 예외를 삼켜서 나중에 워커가 다운됨. Channel과 단일 컨슈머로 전환하여 백프레셔를 수정하고 오류를 가시화함.
4. LINQ 및 컬렉션 (LINQ & Collections)
LINQ와 컬렉션을 효율적으로 사용하면 성능과 가독성이 향상됩니다.
25) 이중 열거 방지
var list = source.ToList();
if (list.Count == 0) return;
// use list multiple times safely
26) Count() > 0 대신 Any() 사용
if (users.Any()) { /* ... */ }
27) 딕셔너리에서 TryGetValue 선호
if (map.TryGetValue(key, out var value))
{
// use value
}
28) 배칭에 Chunk 사용 (.NET 6+)
foreach (var batch in items.Chunk(100))
{
await SaveBatch(batch);
}
29) DistinctBy와 MaxBy (.NET 6+)
var latestPerUser = events
.GroupBy(e => e.UserId)
.Select(g => g.MaxBy(e => e.Timestamp));
30) 읽기 전용에 ImmutableArray 선호
ImmutableArray<string> roles = users
.Select(u => u.Role).ToImmutableArray();
31) 파싱에 Span/Memory 사용
ReadOnlySpan<char> s = "2025-11-04";
if (DateOnly.TryParse(s, out var date)) { /* fast */ }
5. I/O, HTTP 및 JSON
네트워크와 파일 I/O를 효율적으로 처리하는 방법입니다.
32) HttpClient는 항상 팩토리로 재사용
builder.Services.AddHttpClient("github", c =>
{
c.BaseAddress = new Uri("https://api.github.com/");
c.DefaultRequestHeaders.UserAgent.ParseAdd("my-app");
});
app.MapGet("/stars", async (IHttpClientFactory f) =>
{
var client = f.CreateClient("github");
return await client.GetStringAsync("repos/dotnet/runtime");
});
33) AOT 속도를 위해 JsonSerializerContext 사용
[JsonSerializable(typeof(User))]
public partial class AppJsonContext : JsonSerializerContext { }
var json = JsonSerializer.Serialize(user, AppJsonContext.Default.User);
34) 큰 페이로드에 JSON 스트리밍 사용
await using var stream = response.Content.ReadAsStream();
var result = await JsonSerializer.DeserializeAsync<Result>(stream, ct: ct);
35) API에서 DateTime보다 DateTimeOffset 선호
public record Post(string Title, DateTimeOffset PublishedAt);
36) Path.Combine과 Path.GetTempPath() 사용
var path = Path.Combine(Path.GetTempPath(), "report.csv");
6. EF Core 및 데이터 접근
Entity Framework Core를 효율적으로 사용하는 방법입니다.
37) DbContext를 스코프로 유지
builder.Services.AddDbContext<AppDb>(opt =>
opt.UseNpgsql(connString));
38) 엔티티가 아닌 컬럼만 프로젝션
var dto = await db.Users
.Where(u => u.Id == id)
.Select(u => new UserDto(u.Id, u.Name))
.SingleOrDefaultAsync(ct);
39) 읽기 전용에 AsNoTracking() 사용
var posts = await db.Posts.AsNoTracking()
.Where(p => p.Published)
.ToListAsync(ct);
40) 쿼리에서 취소 처리
await db.SaveChangesAsync(ct);
도움이 된 이유: 읽기가 많은 엔드포인트를 AsNoTracking()으로 전환하여 CPU 사용량을 약 20% 줄이고 GC 부담을 감소시킴.
7. ASP.NET Core, DI 및 미들웨어
ASP.NET Core 애플리케이션을 효율적으로 구성하는 방법입니다.
41) 간단한 엔드포인트에 Minimal API 사용
var app = WebApplication.CreateBuilder(args).Build();
app.MapGet("/health", () => Results.Ok(new { ok = true }));
app.Run();
42) 검증이 포함된 타입화된 옵션
builder.Services.AddOptions<AppOptions>()
.BindConfiguration("App")
.ValidateDataAnnotations()
.Validate(o => Uri.IsWellFormedUriString(o.ApiBaseUrl, UriKind.Absolute),
"ApiBaseUrl must be absolute").ValidateOnStart();
43) 멀티 클라이언트 설정을 위한 Keyed Services (.NET 8+)
builder.Services.AddKeyedSingleton<IStorage>("s3", new S3Storage());
44) 헬스 체크와 준비 상태 확인
builder.Services.AddHealthChecks().AddDbContextCheck<AppDb>();
app.MapHealthChecks("/healthz");
45) 스코프를 사용한 구조화된 로깅
using (_log.BeginScope(new { OrderId = id }))
{
_log.LogInformation("Processing order");
}
46) 속도 제한 미들웨어 (.NET 7+)
builder.Services.AddRateLimiter(o =>
{
o.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
RateLimitPartition.GetFixedWindowLimiter("global", _ =>
new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromSeconds(1)
}));
});
app.UseRateLimiter();
8. 테스팅, 도구 및 품질
코드 품질을 보장하기 위한 도구와 기법입니다.
47) nullable과 분석기 활성화
<!-- Directory.Build.props -->
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
</PropertyGroup>
</Project>
48) 위험한 리팩토링을 위한 골든 마스터
// serialize output before change, compare after change
var before = JsonSerializer.Serialize(await RunOld());
var after = JsonSerializer.Serialize(await RunNew());
Assert.Equal(before, after);
49) CI에서 dotnet format 실행
- name: Format
run: dotnet tool restore && dotnet format --verify-no-changes
50) BenchmarkDotNet으로 벤치마킹
[MemoryDiagnoser]
public class ParseBench
{
[Benchmark] public int ParseInt() => int.Parse("123");
[Benchmark] public bool TryParseInt() => int.TryParse("123", out _);
}
흔히 발생하는 실수 (Common Pitfalls)
- Random 사용: 호출마다 new Random()을 생성하면 반복값이 발생함. 정적 단일 Random 또는 암호화용 RandomNumberGenerator를 사용해야 함
- 로그에 로컬 시간 사용: 어디서나 UTC를 사용하고, UI에서만 변환해야 함
- DateTime.Now로 시간 측정: 지속 시간에는 Stopwatch를 사용해야 함
- 무분별한 catch: 로깅 없이 catch (Exception)을 사용하면 실제 문제가 숨겨짐
- DTO 드리프트: API에서 EF 엔티티를 직접 반환하면 스키마와 클라이언트가 결합됨. DTO로 프로젝션해야 함
결론: 작은 개선 50가지가 빠르게 누적됨
품질 향상을 위해 전체 재작성이 필요하지 않습니다. 위 핵들 중 다섯 가지를 선택하여 이번 주에 적용하면 됩니다: 가드 절, AsNoTracking, HttpClientFactory, Count() 대신 Any(), nullable과 분석기 활성화. 코드 변경량이 줄어들고, 버그가 감소하며, 온콜 대응이 편안해집니다.