빠르게 개선할 수 있는 50가지 C#/.NET 코드 해킹 | Aliaksandr Marozka

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과 분석기 활성화. 코드 변경량이 줄어들고, 버그가 감소하며, 온콜 대응이 편안해집니다.

참고 자료

2개의 좋아요