[PART10.예외 처리 기본(3/9)] throw — 예외를 어떻게, 왜 그렇게 던져야 하는가
throw new와 nameof / throw vs throw ex / innerException 체인 / throw expression / Unity 비용까지
목차
이 글이 답하는 질문
throw new ArgumentNullException(nameof(arg))처럼 매개변수 이름을 굳이nameof()로 넘기는 이유는 무엇인가요.catch안에서throw;와throw ex;는 무엇이 다른가요. IL 레벨에서는 어떤 명령어로 컴파일되나요.- 하위 예외를 잡아서 상위 예외로 다시 던질 때
innerException인자를 채워주면 무엇이 보존되나요. - C# 7부터 가능해진
throw 식(throw expression)은 어떤 자리에 쓸 수 있나요. - Unity 모바일 게임 핫패스에서
throw가 비싸다는 말은 어떤 비용을 가리키나요.
1. 문제 제기 — 잘못 던지면 디버깅이 안 된다
플레이어 인벤토리에 아이템을 추가하는 메서드를 작성한다고 해봅시다. Unity 신입 개발자가 처음 짜는 방어 코드는 보통 이렇게 생겼습니다.
public void AddItem(Item item)
{
if (item == null)
{
throw new Exception("아이템이 없습니다"); // 1번 문제
}
try
{
_slots.Add(item);
}
catch (Exception ex)
{
Debug.LogError(ex.Message);
throw ex; // 2번 문제
}
}
이 코드는 컴파일되고, 일단 작동합니다. 하지만 출시 빌드에서 다음 두 가지 문제가 동시에 터집니다.
- 크래시 리포트에
System.Exception: 아이템이 없습니다만 찍힙니다. 어느 슬롯의 어떤 인자가 문제였는지 알 수 없습니다. - 스택 트레이스의 시작점이
AddItem메서드 내부의throw ex줄로 바뀌어 있습니다._slots.Add가 진짜로 어디서 어떤 이유로 실패했는지 흔적이 사라졌습니다.
throw 한 줄을 어떻게 쓰느냐가 운영 단계의 디버깅 비용을 결정합니다. 이 글은 throw 구문의 표준적인 사용법, IL 레벨의 동작 차이, Unity 환경에서의 비용까지 한 번에 정리합니다.
관찰 사실: 위 두 문제는 모두 Roslyn 분석기 규칙에 등록되어 있습니다.Exception직접 던지기는CA2201,throw ex;는CA2200으로 빌드 경고가 발생합니다. 이 글에서 사용한RethrowWithEx샘플도 빌드 시CA2200경고가 그대로 출력됐습니다.
2. 개념 정의 — throw new 의 표준 형태
비유: 사고 신고 전화
throw 는 "여기서 문제가 났다" 고 신고하는 것과 같습니다. 신고가 의미 있으려면 다음 세 가지가 필요합니다.
- 무엇이 문제인지 분류: 화재인지, 도난인지 — 예외 타입(
ArgumentNullException,IOException…) - 어떤 상황인지 설명: 사람이 다쳤다 — 메시지(
message) - 이전에 다른 사고가 있었는지: 화재가 누전 때문이었다 — 원인 예외(
innerException)
C# 의 표준 예외 클래스는 이 세 정보를 받는 생성자를 가지고 있습니다.
시각화: 예외 객체의 정보 구조

표준 생성자 4종
throw new 뒤에 붙는 생성자는 예외 클래스마다 모양이 다르지만, 표준 가이드는 아래 네 가지를 갖추라고 권장합니다.
// 1) 기본 생성자
throw new InvalidOperationException();
// 2) 메시지만 — 가장 흔한 형태
throw new InvalidOperationException("플레이어가 로그인 상태가 아닙니다");
// 3) 메시지 + 원인 예외 — 예외 체인 구성
throw new RepoException("프로필 저장 실패", ioEx);
// 4) ArgumentException 계열 — 매개변수 이름 명시
throw new ArgumentNullException(nameof(item), "item은 null일 수 없습니다");
nameof()— 식별자 이름 연산자 (nameof operator) 변수·매개변수·타입·멤버의 이름을 컴파일 타임에 문자열로 변환한다. 결과는 단순한 문자열 리터럴이지만 컴파일러가 식별자를 추적하므로 리팩터링 도구가 이름을 바꿀 때 함께 갱신된다.
예시:nameof(item)→ 컴파일 후"item"으로 박힌다. 매개변수 이름을target으로 바꾸면 IDE가nameof(item)도nameof(target)으로 자동 변경한다.
ArgumentNullException 의 첫 번째 인자 이름이 paramName 임에 주의합니다. 메시지가 아니라 매개변수 이름을 받는 자리입니다. 이걸 모르고 throw new ArgumentNullException("item이 null이다") 처럼 쓰면, 디버깅할 때 Parameter name: item이 null이다 라는 어색한 출력이 찍힙니다.
IL 로 확인하는 throw new
위 4번 형태(throw new ArgumentNullException(nameof(item))) 가 IL 로 어떻게 컴파일되는지 봅니다.
public static void AddItem(object item)
{
if (item == null)
throw new ArgumentNullException(nameof(item));
}
.method public hidebysig static void AddItem (object item)
{
IL_0000: ldarg.0
IL_0001: brtrue.s IL_000e // item이 null이 아니면 점프 → 정상 흐름
IL_0003: ldstr "item" // nameof(item) 결과 — 단순 문자열로 박힘
IL_0008: newobj instance void [System.Runtime]System.ArgumentNullException::.ctor(string)
IL_000d: throw // 새로 만든 예외 객체를 던진다
IL_000e: ret
}
핵심은 두 가지입니다.
nameof(item)은 IL 에서 그냥ldstr "item"입니다. 런타임에 어떤 비용도 들지 않는 컴파일 타임 상수입니다.throwopcode 는 스택 맨 위에 있는 객체를 던집니다.newobj로 만든 예외 객체가 그 자리에 올라와 있습니다.
3. 내부 동작 — throw 와 rethrow 는 다른 IL 명령어다
비유: 사건 기록을 새로 쓰는가, 이어 쓰는가
catch 블록에서 잡은 예외를 다시 던지는 두 가지 방법이 있습니다.
| 방식 | 비유 |
|---|---|
throw; (객체 생략) |
같은 사건 기록부에 "여기까지 처리하지 못했다" 한 줄을 더 적는다 |
throw ex; (객체 명시) |
새 사건 기록부를 만들고 처음부터 다시 쓴다 |
후자는 처음 사건이 어디서 시작됐는지 흔적을 지웁니다. 운영 환경에서 이 정보가 사라지면 버그를 못 잡습니다.
시각화: 두 경로의 IL 차이

Before / After: 같은 C# — 다른 IL
같은 동작처럼 보이는 두 메서드를 컴파일해 봅니다.
public static void RethrowOriginal()
{
try { Inner(); }
catch (Exception)
{
throw; // 객체 생략
}
}
public static void RethrowWithEx()
{
try { Inner(); }
catch (Exception ex)
{
throw ex; // 객체 명시
}
}
/il-analysis 결과 (Release 빌드, .NET 9):
.method public hidebysig static void RethrowOriginal () cil managed
{
.try
{
IL_0000: call void RethrowSample::Inner()
IL_0005: leave.s IL_000a
}
catch [System.Runtime]System.Exception
{
IL_0007: pop // 스택의 예외 객체 버림 (이름이 없으니까)
IL_0008: rethrow // ★ rethrow 명령어 — 원본 스택 트레이스 유지
}
IL_000a: ret
}
.method public hidebysig static void RethrowWithEx () cil managed
{
.try
{
IL_0000: call void RethrowSample::Inner()
IL_0005: leave.s IL_0008
}
catch [System.Runtime]System.Exception
{
IL_0007: throw // ★ throw 명령어 — 새 시작점으로 기록
}
IL_0008: ret
}
핵심은 IL 명령어 자체가 다르다는 점입니다.
rethrow는catch핸들러 안에서만 합법인 특수 명령어입니다. CLR(Common Language Runtime, .NET 의 가상 머신) 에게 "지금 처리 중인 예외의 상태를 그대로 유지한 채 위로 전파해라" 라고 지시합니다. 스택 트레이스의 시작점이 보존됩니다.throw는 임의 위치에서 호출 가능한 일반 명령어입니다. 스택 위에 있는 예외 객체를 새로 던지는 것으로 처리하므로, CLR 은 이 시점부터 스택 트레이스를 다시 기록합니다.
정리:throw;와throw ex;는 C# 문법상 한 글자 차이지만 IL opcode 가 다른 별개 동작입니다. 거의 모든 경우throw;만 사용해야 합니다.
예외적으로 throw ex 가 정당한 경우
원본 예외를 의도적으로 폐기하고 새 컨텍스트로 다시 시작하고 싶을 때만 정당화됩니다. 그러나 이 경우조차 보통 throw new XxxException("...", ex) 처럼 원본을 innerException 으로 넣는 쪽이 더 안전합니다. 다음 섹션에서 다룹니다.
4. 실전 적용 — innerException 으로 원인 체인 만들기
비유: 영수증 묶음
게임 서버 호출이 실패했을 때, 클라이언트는 보통 "네트워크 오류가 발생했습니다" 정도로 사용자에게 보여줍니다. 하지만 개발자가 디버깅할 때는 "TLS 핸드셰이크 타임아웃" 같은 저수준 정보가 필요합니다. innerException 은 영수증을 모두 클립으로 묶어 보관하는 역할을 합니다 — 표면적인 메시지는 단순하게, 내부 정보는 그대로.
시각화: 예외 체인 펼치기

Before — 원인을 잃어버리는 변환
저수준 예외를 잡은 뒤 사용자 친화적인 메시지로 바꿔서 다시 던지고 싶다고 합시다. 신입이 자주 쓰는 패턴입니다.
public static void Save(string path, string data)
{
try
{
File.WriteAllText(path, data);
}
catch (IOException)
{
throw new RepoException("저장 실패"); // 원본 IOException은 사라진다
}
}
이렇게 하면 호출자는 "저장 실패" 라는 문자열만 받습니다. 디스크가 가득 찼는지, 권한이 없는지, 파일이 잠겨 있는지 알 길이 없습니다.
After — innerException 으로 보존
public class RepoException : Exception
{
public RepoException(string message, Exception inner) : base(message, inner) { }
}
public static void Save(string path, string data)
{
try
{
File.WriteAllText(path, data);
}
catch (IOException ioEx)
{
throw new RepoException("저장 실패", ioEx); // ioEx를 InnerException으로 보존
}
}
IL 로 확인
/il-analysis 결과:
.method public hidebysig static void Save (string path, string data) cil managed
{
.try
{
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: call void [System.Runtime]System.IO.File::WriteAllText(string, string)
IL_0007: leave.s IL_0016
}
catch [System.Runtime]System.IO.IOException
{
IL_0009: stloc.0 // 잡은 ioEx를 로컬에 저장
IL_000a: ldstr "저장 실패" // 메시지 적재
IL_000f: ldloc.0 // ★ ioEx를 두 번째 인자로 적재
IL_0010: newobj instance void RepoException::.ctor(string, class [System.Runtime]System.Exception)
IL_0015: throw // 새 예외 던지기 (원본은 InnerException 안에)
}
IL_0016: ret
}
newobj 가 (string, Exception) 시그니처의 생성자를 호출한 점에 주목합니다. C# 컴파일러가 Exception(string, Exception) 베이스 생성자를 호출하도록 묶어주므로, RepoException 인스턴스의 .InnerException 프로퍼티에 ioEx 가 자동으로 들어갑니다. 호출자가 ex.ToString() 을 찍으면 두 예외의 스택 트레이스가 모두 출력됩니다.
AggregateException — 여러 예외를 한 번에 묶기
Task.WhenAll 처럼 여러 작업이 동시에 실패할 수 있는 자리에서 .NET 은 모든 예외를 AggregateException.InnerExceptions 컬렉션에 담아 던집니다. Unity 에서 멀티 에셋을 비동기로 로드하다가 일부가 실패한 경우가 대표적입니다.
try
{
await Task.WhenAll(LoadAsset(a), LoadAsset(b), LoadAsset(c));
}
catch (AggregateException ae)
{
foreach (var inner in ae.Flatten().InnerExceptions)
{
Debug.LogError(inner); // 실패한 작업마다 한 번씩
}
}
Flatten() 은 AggregateException 안에 다시 AggregateException 이 중첩된 경우를 한 단계로 평탄화합니다. await 키워드로 받는 경우 보통 첫 번째 예외만 다시 던지지만, Task.WaitAll 처럼 동기 대기를 쓰면 AggregateException 그대로가 던져집니다.
5. 함정과 주의사항
❌ 함정 1 — throw new Exception(...)
Exception 자체를 직접 던지면 호출자는 의미 있는 분류로 잡을 수 없습니다.
// ❌ 호출자가 catch (Exception) 으로만 받을 수 있다 — 다른 Exception 과 구분 불가
throw new Exception("로그인 실패");
// ✅ 적절한 표준 예외 타입 또는 도메인 전용 타입을 던진다
throw new InvalidOperationException("이미 로그인된 상태입니다");
throw new AuthenticationException("토큰이 만료되었습니다");
이 패턴은 Roslyn 기본 분석기에서 CA2201: 예약된 예외 타입을 발생시키지 마세요 경고를 띄웁니다. 빌드 로그를 무시하지 마세요.
❌ 함정 2 — nameof() 없이 문자열로 매개변수 이름
// ❌ 매개변수 이름이 "item"에서 "newItem"으로 바뀌면, "item"은 자동으로 안 바뀐다
public void AddItem(Item item)
=> _slots.Add(item ?? throw new ArgumentNullException("item"));
// ✅ nameof를 쓰면 IDE 리팩터링이 함께 갱신한다
public void AddItem(Item item)
=> _slots.Add(item ?? throw new ArgumentNullException(nameof(item)));
문자열 리터럴은 컴파일러가 식별자로 인식하지 않으므로 리네이밍에서 누락됩니다. 코드 리뷰에서 "이 문자열이 진짜 매개변수 이름과 같은가" 를 매번 확인할 수도 없습니다.
❌ 함정 3 — throw ex; 의 스택 트레이스 리셋
이 글의 핵심 함정입니다. 앞에서 IL 차이를 보였으므로 한 번 더 강조만 합니다.
// ❌ 원본 위치 정보 손실 — IL 의 throw opcode 로 컴파일됨
catch (Exception ex)
{
Log(ex);
throw ex;
}
// ✅ rethrow opcode 로 컴파일되어 원본 보존
catch (Exception ex)
{
Log(ex);
throw;
}
// ✅ 새 컨텍스트가 필요하면 innerException 으로 원본을 묶는다
catch (Exception ex)
{
throw new ApplicationStartupException("초기화 실패", ex);
}
❌ 함정 4 — 사용자 정의 예외 네이밍
// ❌ Exception 접미사 누락
public class InvalidLogin : Exception { }
// ❌ Error 접미사 — .NET 컨벤션 위반
public class LoginError : Exception { }
// ✅ 모든 사용자 정의 예외는 Exception 접미사
public class InvalidLoginException : Exception { }
또한 모든 사용자 정의 예외는 System.Exception 또는 더 적합한 표준 예외(예: ArgumentException) 를 상속해야 합니다. ApplicationException 은 .NET 초기에 권장됐지만 현재는 직접 상속 권장 대상이 아닙니다.
❌ 함정 5 — Unity 핫패스에서 throw
Update(), FixedUpdate() 처럼 매 프레임 호출되는 메서드에서 정상적인 분기 대신 예외를 던지는 패턴은 비용이 큽니다.
// ❌ 인벤토리 조회 실패를 예외로 처리
void Update()
{
try
{
var item = _inventory.Get(itemId); // 없으면 KeyNotFoundException
Render(item);
}
catch (KeyNotFoundException) { }
}
// ✅ TryXxx 패턴 — 성공 여부를 bool 로 반환
void Update()
{
if (_inventory.TryGet(itemId, out var item))
{
Render(item);
}
}
비용에 대해서는 다음 섹션에서 별도로 다룹니다.
6. C# 버전별 변화 — throw 식의 등장
C# 6 까지: throw 는 문(statement) 이었다
throw 는 식이 아닌 문이었기 때문에, 식이 와야 하는 자리(?:, ??, expression-bodied 멤버 등)에 직접 쓸 수 없었습니다.
// C# 6 이하: if-else 또는 임시 변수가 필요했다
public class Player
{
private readonly string _name;
public Player(string name)
{
if (name == null)
throw new ArgumentNullException(nameof(name));
_name = name;
}
}
C# 7.0 — throw 식 도입
C# 7.0 부터 throw 가 식 위치에서도 사용 가능해졌습니다. 가장 자주 쓰이는 자리는 다음 세 곳입니다.
??— 널 병합 연산자 (Null-coalescing operator) 왼쪽 피연산자가 null이 아니면 그 값을 반환하고, null이면 오른쪽 피연산자 값을 반환한다.
예시:var x = name ?? "Unknown";— name 이 null이면 "Unknown" 이 대입된다.
// 1) null-coalescing 우측에서 throw
public Player(string name) => _name = name ?? throw new ArgumentNullException(nameof(name));
// 2) 삼항 조건 연산자(?:) 분기에서 throw
string Describe(int hp) => hp >= 0 ? $"HP: {hp}" : throw new ArgumentOutOfRangeException(nameof(hp));
// 3) expression-bodied 멤버 본문에서 throw
public string Name => _name ?? throw new InvalidOperationException("이름이 초기화되지 않았다");
IL 로 확인 — throw 식의 컴파일 결과
name ?? throw new ArgumentNullException(nameof(name)) 가 IL 로 어떻게 풀리는지 봅니다.
public ThrowExpression(string name)
=> _name = name ?? throw new ArgumentNullException(nameof(name));
.method public hidebysig specialname rtspecialname instance void .ctor (string name)
{
IL_0000: ldarg.0
IL_0001: call instance void [System.Runtime]System.Object::.ctor()
IL_0006: ldarg.0
IL_0007: ldarg.1
IL_0008: dup // name을 복제 (스택에 두 번)
IL_0009: brtrue.s IL_0017 // null이 아니면 점프 → stfld로 저장
IL_000b: pop // null인 경우 스택의 사본 제거
IL_000c: ldstr "name" // nameof(name)
IL_0011: newobj instance void [System.Runtime]System.ArgumentNullException::.ctor(string)
IL_0016: throw // 예외 발생
IL_0017: stfld string ThrowExpression::_name
IL_001c: ret
}
dup / brtrue.s 트릭으로 name 을 한 번만 평가하면서 null 검사를 수행합니다. 결과적으로 if (name == null) throw ... 패턴과 IL 시퀀스가 거의 같습니다 — throw 식은 가독성을 위한 문법 설탕이지 별도 런타임 비용을 만들지 않습니다.
C# 10 / 11 — 헬퍼 메서드의 도입
C# 의 변화는 아니지만 .NET 6 (C# 10 시기) 부터 ArgumentNullException.ThrowIfNull(arg) 헬퍼가 추가됐고, .NET 8 (C# 12 시기) 에서는 ArgumentException.ThrowIfNullOrEmpty(arg) 등이 표준 라이브러리에 들어왔습니다.
// 표준 헬퍼 사용 — null 체크 한 줄
public void AddItem(Item item)
{
ArgumentNullException.ThrowIfNull(item); // .NET 6+
_slots.Add(item);
}
이 메서드는 내부에서 [CallerArgumentExpression] 을 활용해 매개변수 이름을 자동으로 채워주므로 nameof() 를 직접 쓸 필요도 없습니다. Unity 의 경우 사용 가능 여부는 사용 중인 .NET API 호환성 레벨(netstandard2.1 이상이면 ThrowIfNull 사용 가능, .NET 6 런타임이 적용된 환경에서 안정적) 에 따라 달라지므로 프로젝트 설정을 확인합니다.
7. Unity 모바일에서 throw 의 비용
비용의 정체
throw 한 번이 비싸다고 말할 때, 실제로 무엇이 비싼지를 알아야 회피 패턴을 정당화할 수 있습니다. 추정이 아닌 관찰된 사실 기준으로 정리합니다.
| 비용 | 무엇이 일어나는가 |
|---|---|
| 예외 객체 할당 | 예외도 일반 객체처럼 관리 힙(managed heap, GC가 회수하는 메모리 영역) 에 할당된다. 매 프레임 던지면 GC 압박 발생. |
| 스택 트레이스 캡처 | throw 시점에 CLR / IL2CPP 가 현재 호출 스택의 메서드 / 라인 번호를 메타데이터에서 읽어 문자열을 빌드한다. 깊은 호출 스택일수록 비싸다. |
| 스택 되감기 | 일치하는 catch 가 나올 때까지 호출 프레임을 역방향으로 탐색하며 finally 를 실행한다. CPU 시간 소모. |
| JIT/AOT 동작 차이 | Unity 의 IL2CPP(IL을 C++로 변환해 정적 컴파일하는 백엔드) 환경에서도 예외 처리 자체는 무료가 아니다. 특히 모바일은 메모리 압박이 크다. |
회피 패턴 — Try 패턴
표준 라이브러리는 같은 동작에 대해 두 종류 API 를 제공하는 경우가 많습니다 — int.Parse vs int.TryParse, Dictionary.this[key] vs Dictionary.TryGetValue. 핫패스에서는 Try 쪽을 선택합니다.
// ❌ 정상 흐름의 일부로 예외 사용
public int ToScore(string raw)
{
try { return int.Parse(raw); }
catch (FormatException) { return 0; }
}
// ✅ TryParse — 실패가 흔한 경로면 예외를 만들지 않는다
public int ToScore(string raw)
=> int.TryParse(raw, out var n) ? n : 0;
실전 가이드
- 예외는 "예외적인" 상황에만: 정말로 정상 실행을 계속할 수 없는 경우 — 필수 데이터 손상, 인증 실패, 외부 자원 사용 불가 — 에 한정합니다.
- 사용자 입력 / 정상적으로 빈 결과는 예외가 아니다: 빈 문자열, 없는 키 조회, 검색 결과 없음 같은 케이스는 분기로 처리합니다.
- 핫패스 회피:
Update,FixedUpdate, 매 프레임 호출되는 콜백, 입력 처리 루프에서는throw가 발생하면 안 됩니다. Profiler 로 확인 가능한 GC.Alloc 으로 잡힙니다. - 로그는
Debug.LogException(ex): 메시지만 찍지 않고 예외 객체 전체를 넘기면 Unity 콘솔이 InnerException 체인까지 자동으로 펼칩니다.
추정: Unity 의 IL2CPP 가 특정 패턴(예: 깊은 가상 호출 안의 throw) 에서 추가 부하를 보일 가능성이 있다는 보고가 있지만, 정확한 수치는 Profiler 로 직접 측정해야 합니다. 이 글은 일반적 원칙만 제시합니다.
8. 정리 — 체크리스트
throw 한 줄을 쓸 때 머릿속으로 다음 질문을 차례로 확인하면 됩니다.
- [ ] 던지는 예외 타입이 호출자가 의미 있게 분류해서 잡을 수 있는 종류인가? (
Exception직접 X) - [ ]
ArgumentException계열을 던질 때nameof()로 매개변수 이름을 넘겼는가? - [ ] 메시지가 디버거가 아닌 동료 개발자에게 의미를 전달하는가? — "오류 발생" X
- [ ] 다른 예외를 잡아 변환해 던지는 거라면 원본을
innerException인자로 묶었는가? - [ ]
catch안에서 다시 던지는 거라면throw;인가?throw ex;인가? — 거의 항상throw; - [ ] 사용자 정의 예외라면 클래스 이름이
Exception으로 끝나는가? - [ ] C# 7+ 환경이면
??/?:/ expression-bodied 멤버에서throw식 활용을 고려했는가? - [ ] 매 프레임 호출되는 코드면
throw가 아니라 Try 패턴 / 분기로 대체할 수 있는가? - [ ] 단순 null 검사는
ArgumentNullException.ThrowIfNull헬퍼를 쓸 수 있는가? (.NET 6+)
이 체크리스트를 코드 리뷰에 그대로 붙여 넣어도 됩니다. throw 한 줄이 쌓이면 운영 단계의 디버깅 효율이 결정됩니다.
'C# 기초' 카테고리의 다른 글
| [PART10.예외 처리 기본(5/9)] using 문과 IDisposable 기초 — 비관리 리소스를 안전하게 닫는 약속 (0) | 2026.05.05 |
|---|---|
| [PART10.예외 처리 기본(4/9)] 자주 만나는 예외 — 이름만이라도 기억 (0) | 2026.05.05 |
| [PART10.예외 처리 기본(2/9)] 예외 계층과 catch 순서 — 왜 구체적인 것을 위에, 일반적인 것을 아래에 두는가 (0) | 2026.05.05 |
| [PART10.예외 처리 기본(1/9)] try · catch · finally — 예외가 터져도 무너지지 않는 코드 (2) | 2026.05.05 |
| [PART9.컬렉션 기본 사용법(8/8)] 배열 vs List<T> — 언제 무엇을 쓰는가 (0) | 2026.05.04 |
