CS 기초 노트

C#, .NET 위에서 뭐가 다른가

2026. 07. 13.
CSLanguageC#.NETCLR

C#은 Java와 태생이 비슷하다. 정적 타입 · OOP · GC 있는 매니지드 런타임. 그래서 문법만 보면 Java와 겹치는 부분이 많고, 다른 건 CLR과 언어 설계 방향이다.

이 글은 그 관점에서 항목별로 짧게 정리한다.

  • 언어 : 기본 문법 · 값/참조 타입 · property/nullable/pattern · record · LINQ · Exception
  • 런타임 : CLR 메모리 구조 · GC
  • 동시성 : async/await · Task · ASP.NET Core에서의 요청 처리

1. C# 기본 문법

Java와 인상은 비슷한데, 최근 문법이 훨씬 짧다.

namespace Shop;
 
public class Order
{
    public long Id { get; }
    public OrderStatus Status { get; private set; }
 
    public Order(long id)
    {
        Id = id;
        Status = OrderStatus.Created;
    }
 
    public void Pay()
    {
        if (Status != OrderStatus.Created) throw new InvalidOperationException();
        Status = OrderStatus.Paid;
    }
}

Java와 다른 첫인상.

- 파일 최상단에 file-scoped namespace (namespace Shop;)
- getter/setter를 property 문법으로 (get; private set;)
- 컨벤션이 PascalCase (메서드도 Pay(), 프로퍼티도 Id)
- Program.cs는 top-level statements로 main 없이 바로 시작 가능

Program.cs가 이렇게 된다.

// Main 메서드 없이 그냥
Console.WriteLine("hello");
var app = WebApplication.Create(args);
app.MapGet("/", () => "ok");
app.Run();

2. 값 타입 vs 참조 타입

C#에서 항상 붙어다니는 구분이다.

class  → 참조 타입 (Heap)
struct → 값 타입   (Stack 또는 포함된 곳에 인라인)

간단한 예.

struct Point { public int X; public int Y; }
class  Node  { public int Value; }
 
var p1 = new Point { X = 1, Y = 2 };
var p2 = p1;         // 값 복사
p2.X = 99;           // p1.X는 그대로 1
 
var n1 = new Node { Value = 1 };
var n2 = n1;         // 참조 복사
n2.Value = 99;       // n1.Value도 99

박싱/언박싱

값 타입을 object로 담으면 힙에 새 객체가 만들어진다.

int i = 42;
object boxed = i;    // 박싱 (힙 할당 발생)
int j = (int)boxed;  // 언박싱

실무 감각.

- DTO / 도메인 객체는 대부분 class
- 좌표·색상·아주 작은 값은 struct
- struct는 16바이트 이하 권장 (그 이상이면 복사 비용이 커짐)
- Java의 int/Integer 오토박싱과 개념은 같지만, C#은 사용자 정의 struct에도 적용

3. Property / Nullable / Pattern Matching

C#만의 문법 세트.

Property

public class User
{
    public long Id { get; init; }              // 생성 시에만 세팅 가능
    public string Name { get; set; } = "";     // 기본값
    public int Age { get; private set; }        // 외부는 읽기만
}

Nullable Reference Types

Kotlin의 String?와 결국 같은 목적이다.

#nullable enable
 
string  a = "hi";     // null 불가
string? b = null;     // null 가능
 
a.Length;             // OK
b.Length;             // 경고 (null 가능성)
b?.Length;            // OK, null이면 null
b!.Length;            // null 아님을 단언 (틀리면 NPE)
b ?? "default";       // null이면 오른쪽

Kotlin과 달리 컴파일러 경고 수준으로 강제한다 (프로젝트 옵션으로 켜기).

Pattern Matching

when처럼 쓰는 switch expression.

var label = shape switch
{
    Circle c        => $"circle r={c.Radius}",
    Rectangle r     => $"rect {r.W}x{r.H}",
    _               => "unknown"
};

is 패턴과 조합.

if (obj is User { Age: >= 20 } user)
{
    Console.WriteLine(user.Name);
}

4. record

C# 9에서 들어온 값 객체 전용 타입. Kotlin의 data class와 거의 같은 자리.

public record User(long Id, string Name, int Age);

컴파일러가 자동으로 만들어주는 것.

- Equals / GetHashCode  (값 비교)
- ToString              (User { Id = 1, Name = ... })
- Deconstruct           (구조 분해)
- with 표현식           (부분 수정 후 새 객체)

with 사용.

var u1 = new User(1, "sangeok", 30);
var u2 = u1 with { Age = 31 };   // Id/Name은 그대로

구조 분해.

var (id, name, age) = u1;

record는 기본이 불변이다. 가변 값 객체가 필요하면 record classinit 대신 set을 쓰거나, 최근에는 record struct도 있다.

Java record      : 가장 미니멀한 불변 값 객체
Kotlin data      : var 필드 허용, copy(...)로 부분 수정
C# record        : 기본 불변 + with 표현식, record struct도 있음

5. LINQ

C# 진영의 Stream/Lambda 자리.

var names = users
    .Where(u => u.Age >= 20)
    .Select(u => u.Name)
    .ToList();

쿼리 문법도 있다 (같은 코드).

var names = (from u in users
             where u.Age >= 20
             select u.Name).ToList();

핵심 개념.

1. IEnumerable<T> : 지연 평가되는 시퀀스
2. Lambda         : u => u.Name 같은 익명 함수
3. Expression Tree: LINQ to SQL/EF에서 SQL로 번역되기 위한 표현식

컬렉션 → 컬렉션 변환에서 특히 강력.

var byUser = orders
    .GroupBy(o => o.UserId)
    .ToDictionary(g => g.Key, g => g.ToList());

주의할 점.

- LINQ는 지연 평가 (ToList / ToArray 호출 시점에 실제 실행)
- EF Core의 IQueryable은 SQL로 번역 → 서버에서 실행
- IEnumerable로 실수로 캐스팅하면 in-memory 실행되어 성능 폭탄

6. Exception / using

Java의 Checked/Unchecked 구분은 C#에는 없다. 전부 unchecked.

public string Read(string path)
{
    // throws 선언 필요 없음
    return File.ReadAllText(path);
}

try / catch / finally는 Java와 거의 동일.

try
{
    Do();
}
catch (IOException e) when (e.HResult != 0)   // 필터 조건
{
    Log(e);
}
finally
{
    Cleanup();
}

using / IDisposable

Java의 try-with-resources 자리.

using (var stream = File.OpenRead(path))
{
    // 블록 벗어나면 자동 Dispose()
}
 
// C# 8+ using declaration
using var stream = File.OpenRead(path);
// 함수 끝에서 Dispose()

실무 감각.

- 라이브러리 경계에서 도메인 예외로 감싸는 패턴은 Java와 동일
- ASP.NET Core에서는 ExceptionHandler 미들웨어로 최종 처리
- Checked 없어서 시그니처가 깔끔하지만, 문서/코드로 예외를 밝혀야 함

7. CLR 메모리 구조

CLR은 JVM과 큰 그림이 비슷하다.

┌─────────────────────────────────────────┐
│ Method Table / Type Info                │  ← 타입 정보, static 필드
├─────────────────────────────────────────┤
│ Managed Heap                            │
│   ├─ Gen 0                              │  ← 새로 만든 객체
│   ├─ Gen 1                              │  ← Gen 0에서 살아남은 것
│   ├─ Gen 2                              │  ← 오래 산 것
│   └─ LOH (Large Object Heap)            │  ← 85KB 이상 객체
├─────────────────────────────────────────┤
│ Stack (스레드마다 1개)                  │  ← 지역 변수, 값 타입, 참조
└─────────────────────────────────────────┘

핵심 감각.

- class 인스턴스는 Heap
- struct는 Stack (또는 다른 객체 안에 인라인)
- 큰 배열이나 문자열은 LOH로 간다 (85KB 기준)
- Heap이 부족하면 OutOfMemoryException
- Stack이 깊어지면 StackOverflowException

JVM과 대응.

JVM Metaspace         ↔ CLR Method Table
JVM Young Generation  ↔ CLR Gen 0 / Gen 1
JVM Old Generation    ↔ CLR Gen 2
JVM PermGen           ↔ (없음, CLR은 다른 구조)
없음                  ↔ CLR LOH (큰 객체 전용)

8. GC 기본

CLR GC는 JVM처럼 세대별 GC다.

기본 아이디어.

객체 대부분은 "만들자마자 죽는다" (Weak Generational Hypothesis)
→ Gen 0을 자주, Gen 2를 가끔

전형적인 흐름.

1. 객체 생성 → Gen 0
2. Gen 0 GC → 살아남으면 Gen 1로 승격
3. Gen 1 GC → 살아남으면 Gen 2로 승격
4. Gen 2 GC → Full GC (비쌈)
5. 85KB 이상은 처음부터 LOH → 압축 잘 안 됨

GC 모드.

- Workstation GC : 단일 UI/데스크톱, 짧은 pause 우선
- Server GC      : 서버 워크로드, CPU 코어당 힙과 GC 스레드 (기본 ASP.NET Core)
- Background GC  : Gen 2 GC를 백그라운드에서 진행 (기본)

실무에서 알아둘 것.

- 서버 앱은 대부분 Server GC + Background GC
- LOH는 압축이 잘 안 되므로 큰 배열을 자주 만들면 fragmentation
- 큰 버퍼는 ArrayPool<T>.Shared로 재사용
- 메모리 누수는 GC로 해결 안 됨 (참조가 살아있는 게 문제)
- dotnet-counters, dotnet-dump, PerfView로 진단

9. async / await 와 Task

C# 동시성의 심장.

문제 인식은 Java/Kotlin과 같다.

스레드는 비싸다 → DB나 외부 API 대기 동안 스레드가 붙잡힘
→ 스레드를 놓고 나중에 재개할 수 있으면 좋겠다

C#의 답은 async/await + Task.

public async Task<User> LoadUserAsync(long id)
{
    var profile = _userApi.GetAsync(id);       // Task<Profile>
    var orders  = _orderApi.FindByUserAsync(id); // Task<List<Order>>
 
    await Task.WhenAll(profile, orders);
    return new User(profile.Result, orders.Result);
}

핵심 감각.

- Task<T> : "언젠가 T가 나오는 작업" (Kotlin의 Deferred, Java의 CompletableFuture)
- async   : "이 메서드는 await할 수 있음" 이라는 표시
- await   : 결과가 올 때까지 "이 스레드는 놓고" 재개

컴파일러가 하는 일은 Kotlin suspend와 비슷하다.

async 메서드는 상태 머신으로 변환됨
- 각 await 지점을 경계로 상태 분할
- await에서 스레드를 놓고, 완료 콜백에서 재개

그래서 겉으로는 순차 코드지만, 스레드를 붙잡지 않는다.

주의할 점.

- async void는 예외 잡기가 어렵다 → 이벤트 핸들러 외엔 피하기
- Task.Result / Task.Wait()는 데드락 위험 (특히 UI/SynchronizationContext)
- ConfigureAwait(false)는 라이브러리에서, 앱 코드에선 신경 덜 써도 됨 (ASP.NET Core는 SynchronizationContext 없음)
- CPU 바운드는 Task.Run으로 별도 스레드로 넘기고, IO는 그냥 await

10. ASP.NET Core에서 요청이 처리되는 방식

Spring MVC(Tomcat)와 다르게, ASP.NET Core는 처음부터 비동기 파이프라인으로 설계됐다.

┌─────────────┐   HTTP 요청   ┌──────────────────┐
│  Client     │ ────────────▶ │ Kestrel (커널 IO)│
└─────────────┘               └────────┬─────────┘
                                       │
                             Task<Response> 하나로 시작
                                       │
                              ┌────────▼─────────┐
                              │  Middleware      │
                              │  Pipeline        │
                              │  (Auth, Routing, │
                              │   Endpoint, ...) │
                              └────────┬─────────┘
                                       │
                              Controller / Minimal API
                                       │
                                    응답

핵심.

- 요청 하나가 스레드 하나를 "점유"하지 않는다
- await 지점마다 스레드는 풀로 돌아감 → 다른 요청을 처리
- ThreadPool은 IOCP 기반이라 소수의 스레드가 많은 요청을 소화
- Thread-per-request가 아니라서, 요청 스코프 상태는 AsyncLocal<T>로 흐름을 따라감

Spring/Java와 감각 비교.

Spring MVC (Tomcat)      : Thread-per-request (Java 21 Virtual Thread로 완화)
Spring WebFlux           : 이벤트 루프 + Mono/Flux
ASP.NET Core             : Task + async/await, 기본이 비동기

HttpContext는 요청마다 살아있고, DI 컨테이너의 scoped 서비스가 그 위에 얹혀 있다. Spring의 RequestScope와 자리가 같다.


정리

  • C#은 언어 문법 + CLR 런타임 + async 모델을 세트로 봐야 실무 판단이 붙는다
  • 언어 : property/nullable/pattern, record, LINQ, Exception (Checked 없음)
  • 런타임 : CLR Heap/Stack + 세대별 GC(Gen 0/1/2 + LOH), Server GC가 서버 기본
  • 동시성 : Task + async/await로 스레드를 붙잡지 않는 순차 코드
  • ASP.NET Core는 처음부터 비동기 파이프라인이라 Thread-per-request 가정이 없다
  • Java와 큰 그림은 겹치지만, property·record·async/await·CLR GC에서 결이 다르다