C#, .NET 위에서 뭐가 다른가
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 class에 init 대신 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에서 결이 다르다