5.2 프로젝트 구성
프로젝트 빌드 시 하나의 EXE 또는 DLL 파일이 만들어짐(프로젝트는 VS의 소스코드 관리를 위해 도입했으며 한 프로젝트는 여러 소스코드를 담는다.)
csproj 파일 내용은 XML 마크업언어를 따른다.
[XML]
<, > 꺽쇠 괄호는 태그라 한다. <Project> </Project> 전자는 열림태그, 후자는 닫힘 태그.
태그 사이에는 또 다른 태그가 올 수 있으며 이는 자식 태그라한다.
솔루션 파일은 한 개 이상의 프로젝트들의 모음이다.
5.2.1 다중 소스코드 파일
앞 장에서 하나의 파일(Program.cs)만 갖고 실습했지만 여러개의 파일을 이용하는 것도 가능하다.
예제 5.3 Program.cs
LogWriter logWriter = new();
logWriter.Write("start");
예제 5.4 LogWriter.cs
// See https://aka.ms/new-console-template for more information
class LogWriter
{
public void Write(string txt)
{
Console.WriteLine(txt);
}
}
5.2.2 라이브러리
라이프러리 파일 생성 시 파일로 저장될 때 확장자인 DLL이 붙는다.
Console.WriteLine의 Console타입은 System.Console.dll 파일에 포함된 것으로 라이브러리를 사용해온 셈이다.
닷넷 런타임이 설치되면 일부 라이브러리가 함께 컴퓨터에 설치되는데 이것들을 BCL(Base Class Library) 또는 FCL(Framework Class Library)이라고 한다. 모두 ms에서 만든 라이브러리이다.
이 떄 Prgoram.cs에서 LogWriter를 불러올 수 없다. 기본적으로 클래스는 internal타입으로 같은 어셈블리(EXE 또는 DLL)내에서만 기능이 사용되기 때문이다. 따라서 LogWriter.cs와 그 타입을 사용하는 Program.cs파일이 같은 EXE/DLL로 묶일 때는 문제가 안되나 별도의 DLL에 담김으로써 다른 EXE/DLL에서 그 기능을 가져다 쓸 수없다. 따라서 라이브러리에서 특정 기능을 노출시키고 싶으면 접근제한자를 public을 바꾸자
그리고 해당 LogWriter를 참조하면 실행이 된다.
LogWriter.dll 파일은 C#컴파일러가 알수 없으니 직접 참조해줘야한다.
DLL 파일을 만드는 이유: 프로그램 하나를 만들기 위해 수백 개의 소스코드 파일이 생성되고 그 중 수십 개의 파일이 다른 프로그램에서도 재사용할 수있다는 상황을 가정해보면, 다른 프로그램을 만들 때 기존의 프로젝트로부터 수십개의 파일을 골라서 재 사용하는 것 자체가 불편하다. 그 수십개의 소스코드 파일을 담은 1개의 DLL 파일을 재 사용하는 것이 낫다. 그리고 컴파일 시간도 문제가 된다. 매번 수십 개의 파일을 함께 컴파일하기보다 이미 컴파일된 DLL 파일을 참조하는 것이 개발 생산성 측면에서 좋다.
프로젝트 템플릿 이름 | 유형 | 의미 |
WPF 애플리케이션 | EXE | WPF 응용 프로그램을 위한 템플릿 |
콘솔 앱 | EXE | 실행 시 명령 프롬프트에서 실행되는 응용 프로그램을 만들도록 옵션이 설정됨. |
클래스 라이브러리 | DLL | 라이브러리(DLL) 유형의 프로젝트 템플릿 |
ConsoleApp1 프로젝트가 Microsoft.NETCore.App 과 LogWriter 클래스라이브러리 를 의존하고 있다.
5.2.2.3 NuGet 패키지 참조
DLL을 직접 참조하는 경우는 점점 줄어들고 있는 추세며 팀 내 만든 프로젝트면 프로젝트 참조를 하고 외부 개발자가 만든 든 라이브러리의 경우 nuget 패키지를 참조하면 된다.
eg.
using NAudio.Wave;
string mp3Path = @"C:\Program Files (x86)\Kakao\KakaoTalk\resource\sound\face_talk_wait_1.mp3";
AudioFileReader audioFile = new(mp3Path);
WaveOutEvent outputDevice = new();
outputDevice.Init(audioFile);
outputDevice.Play();
while(outputDevice.PlaybackState == PlaybackState.Playing)
{
Thread.Sleep(1000);
}
5.2.3 디버그 빌드와 릴리스 빌드
컴파일 오류(Complie-time error): 문법 오류(syntax error)이며, 컴파일러의 오류 메시지 내용에 따라 올바른 문법으로 변경하면 해결.
실행 시 오류(Run-time error): 정상적으로 컴파일된 프로그램이나 실행 시점에 오류가 발생하는 것으로 논리 오류(logical error)등의 원인으로 발생.
작성 코드가 그대로 기계어로 생성되지 않는다. 이유는 컴파일러 제작자들이 개발자가 작성한 코드를 가장 빠른 속도 또는 가장 작은 용량의 프로그램으로 번역하는 최적화(optimization) 처리를 하기 때문이다. 이런 식으로 최적화를 허용하는 빌드를 릴리스(Release) 빌드라 하고 그렇지 않은 경우를 디버그(Debug) 빌드라 한다.
5.2.4.1 DEBUG, TRACE 전처리 상수
디버그 빌드와 릴리스 빌드를 할 때 비주얼 스튜디오에선느 자동으로 관리되는 전처리 상수
표 5.7 빌드에 따라 정의되는 기본 전처리 상수값
빌드옵션 | 전처리상수 | |
DEBUG | TRACE | |
디버그 | O | O |
릴리스 | X | O |
TRACE 상수는 항상 정의되지만, DEBUG 상수는 디버그 빌드에서만 정의된다.
#if DEBUG
Console.WriteLine("디버그 빌드");
#endif
DEBUG 모드일때만 해당 코드 실행됨.
유사한 기능을 Conditional 특성으로 구현 가능하다. Conditional 특성은 클래스와 메서드에 적용 가능하고 적용된 클래스와 메서드를 사용하는 코드는 Conditional 특성의 생성자로 전달된 전처리 상수가 정의 돼있는 경우만 EXE/DLL 실행파일에 포함된다. 즉 #if / #endif 전처리 지시자가 불필요하다.
예제 5.9 #if/#endif 전처리기 대신 Conditional 특성 사용
using System.Diagnostics;
OutputText();
[Conditional("DEBUG")]
static void OutputText()
{
Console.WriteLine("디버그 빌드");
}
#릴리즈 모드 일땐 출력 x
#디버그 모드 출력:
디버그 빌드
DEBUG/TRACE 말고도 직접 전처리 상수를 정의해 이를 Conditional 특성에 전달 할 수 있다.
5.2.4.2 Debug 타입과 Trace 타입
BCL의 하나인 System.Runtime.dll과 System.Diagnostics.TraceSource.dll에는 System.Diagnostics 네임스페이스 아래에 Debug와 Trace 타입이 각각 정의 돼있으며 이들은 WriteLine 메서드를 제공해준다.
using System.Diagnostics;
Console.WriteLine("사용자 화면 출력");
Debug.WriteLine("디버그 화면 출력 - Debug");
Trace.WriteLine("디버그 화면 출력 - Trace");
위 코드는 '사용자 화면 출력' 만 출력되는 것 같지만 output창에 디버그 모드일땐 2개 모두(Debug, Trace), 릴리즈 모드 일때 Trace 만 출력된다.
5.3 예외
5.3.1 예외타입 ( p339)
그림 5.19 예외타입의 상속 구조
직접 새로운 예외타입을 만드는 것도 가능하며 System.Exception을 최소한 상속받음으로써 예외를 직접 정의할 수 있다. 단지 다음과 같은 기준이 제시된다.
1. 응용 프로그램 개발자가 정의하는 예외는 System.Exception을 상속받은 System.ApplicationException을 상속 받는다.
2. 접미사로 Exception을 클래스명에 추가한다.
3. CLR에서 미리 정의된 예외는 System.SystemException을 상속받는다.
이 규칙에 강제성이 부여된 것은 아니며, ms에서도 내부적으로 CLR의 일부 예외를 ApplicationException 타입에서 상속받아 정의 했다. 또한 닷넷 가이드라인 문서에서 ApplicationException의 의미가 퇴색됨으로써 System.Exception에 직접 상속 받도록 권장 하고 있다.
System.Exception 타입은 기본적으로 예외 정보를 구할 수 있는 속성과 메서드 제공.
표 5.9 System.Exception 타입의 주요 멤버
멤버 | 타입 | 설명 |
Message | 인스턴스 프로퍼티 | 예외를 설명하는 메시지를 반환 |
Source | 인스턴스 프로퍼티 | 예외를 발생시킨 응용프로그램의 이름 반환 |
StackTrace | 인스턴스 프로퍼티 | 예외가 발생된 메서드의 호출 스택을 반환 |
ToString | 인스턴스 메서드 | Message, StackTrace 내용을 포함하는 문자열을 반환 |
표 5.10 System.IndexOutOfRangeException에서의 멤버 값.
Message: "Index was outside the bounds of the array"
Source: "ConsoleApp1"
StackTrace: "at Program.Main(String[] args) in c:\temp\ConsoleApp1\Program.cs:line 9
ToString: System.IndexOutOfRangeException: Index was outside the bounds of the array. at Program.Main(String[] args) in c:\temp\ConsoleApp1\Program.cs:line 9
5.3.2 예외 처리기
try/ catch / finally( try 블록 내에서 예외가 발생하는 것과 상관없이 언제나 실행되는 finally)
finally 블록은 자원을 해제하는 코드를 넣어두는 용도로 적합하다.
예를 들어 try 블록에서 파일을 열었을 때 finally 블록이 없다면 열린 파일을 닫기위해 try와 catch 블록에 모두 파일을 다는 코드를 넣어야 한다. 그러나 finally 블록을 사용 시 finally에만 넣으면 코드가 간결해진다.
** C# 6.0에서는 예외 처리 뿐만 아니라 필터 기능도 지원(11.8 예외 필터 참고)
CLR은 catch 구문의 타입을 발생한 예외와 순서대로 비교하므로 상속관계를 고려해 예외 타입을 지정해야한다.
예쩨 5.13, System.Exception이 맨 위에 있으면 모든 예외가 System.Exception으로 형 변환 가능하므로 다음의 catch 블록에 있는 코드는 결코 실행되지 않는다.
int divisor = 0;
string txt = null;
try
{
Console.WriteLine(txt.ToUpper()); // System.NullReferenceException 예외 발생
int quotient = 10 / divisor;
}
catch (System.Exception)
{
Console.WriteLine("예외가 발생하면 언제나 실행된다");
}
catch (System.NullReferenceException) // 컴파일 에러
{
Console.WriteLine("어떤 예외가 발생해도 실행되지 않는다");
}
catch (System.DivideByZeroException) // 컴파일 에러
{
Console.WriteLine("어떤 예외가 발생해도 실행되지 않는다");
}
상위 예외처리를 먼저 사용시 하위 예외처리 코드 작성해도 컴파일 에러를 발생시켜서 하위부터 쓰라고 컴파일러가 알려줌.
int divisor = 0;
string txt = null;
try
{
Console.WriteLine(txt.ToUpper()); // System.NullReferenceException 예외 발생
int quotient = 10 / divisor;
}
catch (System.NullReferenceException e) // 컴파일 에러
{
Console.WriteLine(e.Message);
Console.WriteLine(e.Source);
Console.WriteLine(e.StackTrace);
Console.WriteLine("==========================");
Console.WriteLine(e.ToString());
}
catch (System.DivideByZeroException) // 컴파일 에러
{
Console.WriteLine("어떤 예외가 발생해도 실행되지 않는다");
}
catch (System.Exception)
{
Console.WriteLine("예외가 발생하면 언제나 실행된다");
}
##출력
Object reference not set to an instance of an object.
ConsoleApp3
at Program.<Main>$(String[] args) in C:\Users\asd57\source\repos\ConsoleApp3\ConsoleApp3\Program.cs:line 6
==========================
System.NullReferenceException: Object reference not set to an instance of an object.
at Program.<Main>$(String[] args) in C:\Users\asd57\source\repos\ConsoleApp3\ConsoleApp3\Program.cs:line 6
C:\Users\asd57\source\repos\ConsoleApp3\ConsoleApp3\bin\Debug\net8.0\ConsoleApp3.exe (process 26252) exited with code 0.
Press any key to close this window . . .
e 변수 이용해 해당 예외 타입이 제공하는 모든 멤버에 접근해서 정보를 가져올 수 있다. 이 기록을 잘활용하면 디버깅을 쉽게 할 수 있다.
5.3.3 호출스택
CLR은 예외 객체에서 Stack-Trace 속성을 통해 호출 스택을 제공한다.
5.3.4 예외발생
예외 처리 뿐만 아니라 예외를 임의로 발생시키는 것도 가능하며 이를 예외 발생이라 한다. -> throw 예약어.
예제 5.14 throw 를 이용한 예외 발생
string txt = Console.ReadLine();
if (txt != "123")
{
ApplicationException ex = new("틀린 암호");
throw ex;
}
Console.WriteLine("올바른 암호");
조건문에서 비밀번호가 잘 못될 경우 ApplicationException 객체를 생성해 throw를 통해 예외를 발생시켰다.
CLR로부터 전달받은 예외 객체를 throw에 전달하는 것도 가능하다.
try
{
string txt = null;
Console.WriteLine(txt.ToUpper());
}
catch(System.Exception ex)
{
throw ex;
}
catch 블록 내에 있는 throw는 예외 객체 없이 단독으로 사용 가능하다.
try
{
string txt = null;
Console.WriteLine(txt.ToUpper());
}
catch(System.Exception)
{
throw;
}
throw ex와 throw 의 표현 차이는?
throw 단독으로 사용하는 것이 좋다. 이유는 throw를 단독으로 사용한 경우 예외를 발생시킨 호출 스택이 모두 출력된다. 반면, throw ex를 한 경우 실제 예외가 발생한 호출 스택은 없어지고 throw ex코드가 발생한 지점부터 호출 스택이 남는다는 차이가 발생한다. 따라서 throw를 단독으로 사용하면 오류의 원인을 좀 더 쉽게 파악 가능하다.
catch에서throw로 던지면 어디로 그 예외 처리가 가는거야?
catch 블록 안에서 throw를 하면 그 예외는 다시 바깥쪽으로 전파돼요.
그럼 그 예외는 어디로 가냐면...
📌 throw 이후 예외의 흐름
- 현재 try-catch 블록에서 예외를 잡았다가
- throw;를 하면
- **바로 바깥쪽 호출자(또는 더 상위 호출자)**에게 예외가 전달돼요.
즉, 예외가 계속 위로 올라가면서 (stack unwinding)
그 예외를 처리할 수 있는 try-catch가 있을 때까지 계속 전파돼요.
🔁 예제: 예외의 전파 흐름
Main();
void Main()
{
try
{
DoSomething();
}
catch (Exception ex)
{
Console.WriteLine("Main에서 예외 처리함: " + ex.Message);
}
}
void DoSomething()
{
try
{
ThrowException();
}
catch (Exception)
{
Console.WriteLine("DoSomething에서 예외 잡음, 하지만 다시 던질게!");
throw; // 여기서 예외를 다시 던짐
}
}
void ThrowException()
{
throw new Exception("문제가 생겼어요!");
}
출력:
DoSomething에서 예외 잡음, 하지만 다시 던질게!
Main에서 예외 처리함: 문제가 생겼어요!
📌 실행 흐름
- ThrowException() → 예외 발생
- DoSomething() → catch에서 예외를 잡음
- throw;로 예외를 다시 던짐
- Main() → 바깥쪽 catch에서 예외를 최종 처리함
💡 정리
- catch에서 throw; 하면, 예외가 위로 다시 전달됨
- 그 예외는 바깥쪽 호출자의 try-catch로 이동함
- 최종적으로 아무도 예외를 처리하지 않으면 → 프로그램이 비정상 종
5.3.5 사용자 정의 예외 타입
예외도 타입이라 개발자가 직접 클래스를 만들어 예외를 커스터 마이징 할 수 있다.
System.Exception을 부모로 두는 것을 권장한다.
예제 5.16 사용자 정의 예외를 사용
string txt = Console.ReadLine();
if(txt !="123")
{
InvalidPasswordException ex = new("틀린암호");
throw ex;
}
Console.WriteLine("올바른 암호");
class InvalidPasswordException : Exception
{
public InvalidPasswordException(string msg) : base(msg) { }
}
사용자 정의 예외를 지원하나 사용빈도는 적다. 이유는 예제 5.16과 5.14의 효과가 다르지 않기 때문이다. 오히려 사용자 정의 예외처리를 만드는데 번거로울 수 있다.
5.3.6 올바른 예외 처리
예외 처리는 어떤 경우에 발생시키는게 적절할까?
bool LogText(string txt)
{
if (txt == null)
{
return false; // 잘못된 txt인자이므로 false 반환.
}
Console.WriteLine(txt.ToUpper());
return true; // 정상 동작을 했다는 의미에서 true 반환
}
void LogTextWithException(string txt)
{
if(txt == null)
{
// txt인자가 null이면 안 되므로 예외 발생
throw new ArgumentNullException("txt");
}
Console.WriteLine(txt.ToUpper());
}
첫번째 메서드는 전달된 인자가 NULL이면 더 이상 아무 동작않고 제어를 반환한다. 반면 두번 째 메서드는 예외처리를 진행하고 있으며, 호출 스택의 상위 메서드에서 try/catch를 수행하지 않고 있다면 프로그램이 비정상적으로 종료된다는 위험이 있다. 그래서 프로그램을 지속적으로 실행하고자 반드시 try/catch를 지정해야한다.
첫번째 실행된 메서드의 반환값이 false이면 더는 실행하지 못하게 막는 상황을 가정해 다음과 같은 코드를 작성한다.
if(LogText(aText) == false)
{
return;
}
if(LogText(bText) == false)
{
return;
}
if(LogText(cText) == false)
{
return;
}
반면 예외를 발생시키는 코드의 경우 좀 더 간결하게 사용 가능하다.
try
{
LogTextWithException(aText);
LogTextWithException(bText);
LogTextWithException(cText);
}
catch(ArgumentNullException)
{
}
LogText메서드는 false 반환값을 무시해도 쓰는데 아무런 지장이 없다. 강제성이 없어 개발자가 무심코 다음과 같이 사용해도
LogText(aText);
LogText(bText);
LogText(cText);
이를 막을 방법이 없다. 전자상거래 사이트에서 물건을 결제하는 코드에 위와 같은 실수가 있다면, 결제 과정에서 실패했음에도 반환값 처리를 소홀히 해서 물건을 발송하는 코드가 실행되는 문제가 발생한다. 예외처리를 진행했다면 물건이 무료로 발송되는 최악의 경우는 막을 수 있었다.
try
{
LogTextWithException(aText); // 여기서 예외 발생 시 곧바로 catch 문 이동
LogTextWithException(bText); // 여기서 예외 발생 시 곧바로 catch 문 이동
LogTextWithException(cText);
}
catch (ArgumentNullException)
{
}
예외처리를 오남용하면 습관적으로 예외 처리가 낳는 부정적인 결과인 '예외를 먹는(swallowing exceptions)' 상황이 있을 수 있다. 프로그램에 문제가 분명 나타났는데 예외처리로 인해 외부에 아무런 문제 현상이 나타나지 않는 것을 의미한다.
예외 처리를 이렇게 해버리면 결국 오류를 나타내는 반환 값을 무시하는 방식과 다를게 없다. 따라서 try / catch는 스레드 단위마다 단 한번만 전역적으로 적용해야 한다.
그 밖의 코드에서 예외처리가 발생하면 try/catch를 하더라도 catch에 정확한 예외 타입을 지정하는 것을 원칙으로 하며 finally를 통해 자원수거를 자유롭게 할 수 있다.
하지만 예외에 대한 처리는 매우 무겁다.
for(int idx = 0; idx < 100000; ++idx)
{
try
{
int j = int.Parse("53");
}
catch (System.FormatException)
{
}
}
"53"이 정상적인 문자열이므로예외가 발생하지 않는다. 10만번 반복 수행 시간을 측정하면 10밀리초도 안되어 완료 되는 것을 확인 할 수 있다. 하지만 문자열이 숫자 형식이 아닌 경우 System.FormationException 예외를 발생시키며 10만번의 예외가 발생하면 프로그램 실행시간이 3초가 넘게 걸린다.
CLR 입장에서 예외 처리를 위한 실행해야 할 내부코드가 늘어나기 때문에 처리 시간이 그만큼 늘어난다.
이러한 문제점을 개선하고자 MS는 Parse 메서드를 out인자를 사용해 개선한 TryParse 메서드를 BCL에 포함시켰다.
이 메서드는 문자열이 숫자로 바꿀 수 있는 경우에만 out 형식의 인자에 숫자값을 담고, 메서드 실행이 성공했는지 여부만 반환할 뿐 예외는 발생시키지 않는다.
for (int idx = 0; idx < 100000; ++idx)
{
int j;
bool success = int.TryParse("5T", out j);
}
TryParse로 바뀐 메서드는 문자열 값에 상관없이 10밀리초 내로 수행된다.
예외처리 규칙
1. 적어도 공용(public) 메서드에 한해서는 인자값이 올바른지 확인하고, 올바른 인자가 아니라면 예외를 발생시킨다.
2. 예외를 범용적으로 catch하는 것은 스레드마다 하나만 둔다. 그 외에는 catch 구문에 반드시 예외 타입을 적용한다.
3. try/finally 조합은 언제든 사용 가능하다.
4. 성능상 문제가 발생할 경우, 즉 호출 시 예외가 대량으로 발생하는 메서드가 있다면, 예외처리가 없는 메서드를 함께 제공한다. (예, TryParse)
5.4 힙과 스택
프로그램 실행 시 코드는 메모리에 적재되며 메모리는 코드와 데이터로 채워진다.
힙과 스택은 데이터를 위한 메모리이다.
5.4.1 스택
스택은 스레드가 생성되면 기본적으로 1MB 용량으로 스레드마다 할당되며 자료구조에서 다루는 스택과 동작 방식이 같다.
스택 공간을 활용해 스레드는 메서드의 실행, 해당 메서드로 전달하는 인자, 메서드 내에서 사용되는 지역 변수를 처리한다. 메서드를 호출하기 전과 호출 후의 스택에는 변함이 없다. 스택은 그것이 속한 스레드가 메서드를 호출할 때마다 증가하고 줄어드는 과정을 반복한다. 스택 자료구조 하나만으로 인자 전달과 지역변수, 메서드의 실행 흐름을 제어 할 수 있다.
** 메서드 호출과 스택의 관계는 실제 동작에 차이가 있지만 동작이 개념적으로 이러하다는 것만 참고하자.
** C# 컴파일러는 적절한 IL 코드만 구성할 뿐 실제 스택 처리와 같은 기계어 코드는 JIT 컴파일러에 의해 생성됨.
5.4.1.1 스택 오버 플로
스택은 기본적으로 1MB 공간만 스레드에 할당되는데 1MB 용량은 상대적으로 클수도 작을 수도 있다.
예제 5.17에서는 Sum메서드는 한번 호출할 때마다 16바이트 정도의 스택이 사용된다.
class Program
{
static void Main(string[] args)
{
int result = Sum(5, 6);
}
private static int Sum(int v1, int v2)
{
int sum = InnerSum(v1, v2);
return sum;
}
private static int InnerSum(int v1, int v2)
{
int sum = v1 + v2;
return sum;
}
}
Main에 있는 Sum은 InnerSum 메서드를 호출한다. 2개의 메서드 모두 2개의 인자를 받고 1개의 지역 변수를 사용하고 있으므로 각 호출마다 16바이트 스택 메모리를 소비한다. Main -> Sum -> InnerSum 호출이 중첩되었기 때문에 InnerSum 코드를 실행하는 동안 스택에 32바이트가 점유된 것이다.
각 메서드 실행( Sum(5,6); ), 매개변수(v1, v2), 지역변수(sum)
스택은 메서드 호출이 깊어질 수록 스택 사용량도 늘어난다. 메서드 콜 스택이 1MB용량이 넘는 경우를 스택 오버플로(stack overflow)가 발생했다고 한다.
이 예외는 try/catch 유무에 상관없이 비정상적으로 종료된다.
이러한 문제는 대체로 재귀호출에서 문제가 발생된다.
5.4.1.2 재귀 호출
recursion(1);
void recursion(int n)
{
Console.WriteLine(n);
recursion(++n); // 후위 증가연산자인 n++ 하면 계속 1이 출력됨.
}
스택오버플로우 에러가 나지만, Release 빌드로 하면 tail call 최적화로 예외가 발생하지 않는다.
재귀 호출이 스택오버 플로 문제를 야기한다면 스택 용량을 늘리거나, 재귀 호출코드를 재귀를 사용하지 않는 코드로 바꾸는게 좋다.
5.4.2 힙
힙은 별도로 명시하지 않을 시 CLR에서 관리 힙(managed heap)으로 불린다. 관리 힙은 CLR의 GC(Garbage Collector)가 메모리를 할당/해제 관리를 한다.
new로 할당된 모든 참조형 객체는 힙에 할당되며 메모리 해제는 명시하지 않고 GC가 자동으로 해준다.
힙이 많이 사용되면 GC가 그 만큼 더 자주 동작하여 프로그램은 빈번하게 실행이 중지되어 심각한 성능 문제를 겪을 수 있다.
5.4.2.1 박싱/언박싱
박싱(boxing): 값 형식 -> 참조형식 변환
언박싱(unboxing): 참조형식 -> 값 형식 변환
변환 과정은 object 타입과 System.ValueType을 상속받은 값 형식의 인스턴스를 섞어쓴느 경우에 발생.
int a = 5;
object obj = a; // 박싱: 값 형식인 int를 참조 형식인 object에 대입
int b = (int)obj; // 언박싱: 참조 형식인 object를 값 형식인 int에 대입
1. int a = 5; 코드에서 지역변수 a는 스택 메모리가 할당되고 5라는 값이 들어간다.
2. object obj = a; 코드에서 지역변수 obj는 스택메모리에 할당되지만, object가 참조형이기에 힙에도 메모리가 할당 되고 변수 a의 값이 들어간다. 즉 박싱이 발생하여서 obj 지역변수는 힙에 할당된 주소를 가리킨다.
3. int b = (int) obj; 코드에서 지역변수 b는 스택메모리에 b영역이 있고 , 힙 메모리에 있는 값을 스택 메모리로 복사한다.(언박싱)
값 형식을 object로 형 변환하는 것을 힙에 메모리를 할당하는 작업을 동반한다.
using System.Runtime.Intrinsics;
int a = 5;
int b = 6;
int c = GetMaxValue(a, b);
int GetMaxValue(object v1, object v2)
{
int a = (int)v1;
int b = (int)v2;
if(a >= b)
{
return a;
}
return b;
}
GetMaxValue의 v1, v2 매개변수는 object 참조형으로 힙에 메모리를 할당하고, 전달된 a, b의 값을 복사한다. 박싱이 발생한 것이다.v1, v2가 int형이었다면 스택의 값 복사만으로 끝날 수 있었으나, 박싱으로 인해 관리 힙을 사용하게 됐고, 이는 GC의 관리 영역으로 들어왔다. 박싱을 과다하게 발생시키지 않게끔 해야 성능이 떨어지는 것을 막을 수 있다.
5.4.2.2 가비지 수집기
CLR의 힙은 세대(generation)로 나뉘어 관리된다. 처음 new로 할당된 객체는 0세대이다.
Program pg = new();
Console.WriteLine(GC.GetGeneration(pg));
#출력
0
처음 할당된 객체는 모두 0세대이다. 0세대 객체의 총 용량이 일정 크기를 넘어가면 GC는 가비지 수집을 한다. 사용되지 않는 0세대 객체가 있으면 없애고, 그 시점에도 사용되고 있는 객체는 1세대로 승격한다.
프로그램이 실행되면서 이러한 가비지 수집 작업은 반복되고 1세대로 승격된 객체의 총 용량도 일정 크기를 넘어가면, GC는 0세대와 1세대에 모두 가비지 수집을 한다. 1세대의 객체가 그 시점에도 사용되고 있으면 2세대로 승격한다. 프로그램이 실행되면서 2세대로 승격된 객체의 총 용량도 일정 크기를 넘어가면 GC는 0세대 ~ 2세대에 걸쳐 모든 객체를 가비지로수집한다. 그러나 이번에는 2세대의 객체가 계속 사용되어도 3세대로 승격되는게 아니다. CLR의 세대는 2세가 끝이며 이후 2세대의 메모리 공간은 시스템이 허용하는 한 계속 커지게 된다.
object pg = new();
Console.WriteLine(GC.GetGeneration(pg)); // 출력: 0
GC.Collect(); // GC 수집 수행
Console.WriteLine(GC.GetGeneration(pg)); // 출력: 1
GC.Collect();
Console.WriteLine(GC.GetGeneration(pg)); // 출력: 2
GC.Collect();
Console.WriteLine(GC.GetGeneration(pg)); // 출력: 2
**지역변수는 명시적으로 null을 대입하지 않는 한 메서드가 끝날 때 까지 유효하므로 Main 메서드가 반환될때까지는 살아 있게 된다.
null을 참조하는 스택 변수가 있다면 GC는 해당 변수들이 기존에 해당 객체를 힙에서 관리하는 것을 제거시킨다. 동시에 함께 살아남은 객체를 1세대로 승격시킨다.
GC 후 메모리 상에서 지워진 객체들은 비워지고 메모리 상에 객체 순서가 조정된다. 즉 가비지 수집이 발생하면 기존 객체의 주소가 바뀐다. 바뀐 주솟값은 이들을 참조하는 스택 변수에 그대로 반영된다.
객체를 루트 참조(root reference)라 한다. 가비지 수집에서 살아남을 수 있는 객체란 다른 말로 루트 참조가 있다는 것을 의미하며 루트 참조가 사라지면 다음 번 GC에서 해당 객체는 제거된다.
5.4.2.3 전체 가비지 수집
표 5.11 GC.Collect의 세대별 가비지 수집 기능
메서드 | 인자 | 수집대상 |
GC.Collect(int generation) | 0 | 0세대 힙만을 가비지 수집 |
1 | 0과 1세대 힙만을 가비지 | |
2 | 0,1,2세대 전체에 걸쳐 가비지 수집 |
MS에서는 GC.Collect 메서드를 명시적으로 호출해 가비지 수집하는 것을 권장하지 않는다. 가끔 많은 메모리 공간을 차지하는 객체를 생성 할 경우 그것을 강제로 가비지 수집하는 목적으로 사용하곤 한다.
5.4.2.4 대용량 객체 힙
가비지 수집에 따라 살아남은 객체는 다음 세대로 이동하지만, 대용량 객체에그는 부담이 된다. 가비지 수집마다 대용량 메모리를 이동할 경우 GC입장에선 매우 큰 성능 손실이다. 이를 대비해 CLR은 일정 크기 이상의 객체는 별도로 대용량 객체 힙(LOH: Large Object Heap)이라는 특별한 힙에 생성한다.
**객체 크기가 85,000바이트 이상인 경우 LOH에 할당된다. 이 크기는 내부적으로 정의된 것이므로 MS에 의해 언제든 바뀔 수 있다.
LOH에 할당된 객체는 가비지 수집이 발생해도 메모리 주소가 바뀌지 않는다. 이로인해 LOH에 객체를 생성/해제 시 필연적으로 메모리 파편화(fragmentation) 현상이 발생한다.
예.
총 100mb 용량의 LOH (20MB, 40MB, 40MB(Free)) 에서 시간이 흘러 20MB 객체가 필요없어지고 GC가 발생하면 (Free, 40MB, Free) 총 60MB의 여유 공간이 있음에도 50MB 객체를 생성하려고 시도하면 연속적으로 할당 할 수 있는 공간이 없어서 메모리가 부족하다는 오류가 발생한다. 이에 따라 용량을 크게 차지하는 객체는 주의깊게 사용해야한다.
LOH의 다른 특징은 힙에 생성된 객체는 초기부터 2세대에 해당되며 이로 인해 Full GC가 발생하지 않는 한 LOH의 객체는 수집과정을 거치지 않는다.
5.4.2.5 자원해제
GC 동작에 따른 객체 소멸 시점은 개발자가 알 수 없다.
예제 5.19 열려 있는 파일 자원
FileCreate();
Console.WriteLine("파일이 열려 있습니다.");
Console.ReadLine();
void FileCreate()
{
FileStream fs = new FileStream("output.log", FileMode.Create);
}
해당 프로그램이 실행된 상태에서 output.log 파일은 삭제 되지 않는다. FileStream 객체가 여전히 관리 힙에 남아 있는 상태이고, 그 파일을 독점적으로 소유하고 있어 잠겨있기 때문이다.
이때문에 명시적인 자원 해제가 필요한 클래스를 만드는 개발자들은 Close 같은 이름의 멤버 메서드를 함께 제공한다.
FileCreate();
Console.WriteLine("파일이 열려 있습니다.");
Console.ReadLine();
void FileCreate()
{
FileStream fs = new FileStream("output.log", FileMode.Create);
fs.Close();
}
CLOSE는 자원 해제와 관련이 있지만, 자원 해제 그 자체를 대표한다고 보긴 어렵습니다.
좀 더 정확하게 설명해볼게요 👇
🔍 CLOSE란?
- 일반적으로 CLOSE는 파일, 네트워크 소켓, DB 연결 등 외부 자원(리소스) 을 닫는 함수나 메서드 이름으로 쓰입니다.
- 예: file.Close(), stream.Close(), connection.Close() 등
- CLOSE는 연결을 끊고, 내부 버퍼를 플러시하거나 마무리 작업을 수행합니다.
❗ 하지만 Close()가 자원 해제를 완전히 책임지진 않음
- 닫는다고 해서 메모리까지 해제된다는 보장은 없습니다.
- C#에서는 IDisposable.Dispose()가 자원 해제를 정식으로 명시하는 메서드입니다
using (var fs = new FileStream("test.txt", FileMode.Open))
{
// 파일 사용
} // 여기서 Dispose()가 자동 호출되어 자원이 해제됨
- 이때 내부적으로는 Dispose()가 호출되고,
- Dispose() 안에서 Close()를 호출하는 구조입니다.
public void Dispose()
{
Close(); // 자원을 해제하는 핵심 로직
}
결론
🎯 결론
구분 | 의미 | 자원 해제 대표? |
Close() | 외부 자원(파일, 연결 등)을 닫는 행위 | ❌ 일부만 해제 (Dispose 전체를 대표하진 않음) |
Dispose() | 자원 해제의 공식 방법 (IDisposable 인터페이스) | ✅ 자원 해제의 대표 |
ms는 자원해제가 필요하다고 판단되는 모든 객체는 개발자로 하여금 IDisposable 인터페이스를 상속받도록 권장한다.
해당 인터페스에 정의 된 메서드는 Dispose() 단 하나 뿐이다.
public interface IDisposable
{
void Dispose();
}
FileStream 객체도 IDisposable을 상속 받고 있으며, 따라서 Dispose 메서드를 구현하고 있다.
이 떄문에 Close 대신 Dispose 메서드를 호출해도 동일하게 파일이 닫힌다.
FileCreate();
Console.WriteLine("파일이 열려 있습니다.");
Console.ReadLine();
void FileCreate()
{
FileStream fs = new FileStream("output.log", FileMode.Create);
fs.Dispose();
}
자원해제를 명시해야할 것이 있다면 IDisposable 인터페이스를 구현하는 게 좋다.
객체를 메모리에서 해제 하기 위해 Dispose를 하기 전, 예외가 발생하면 Dispose 메서드가 호출되지 않은 상태에서 정상적으로 자원 회수가 안된다.
이에 try/finally 를 이용해 Dispose를 호출하는 것이 관례다.
예제 5.21 try/finally를 이용한 Dispose 메서드 호출
FileLogger log = null;
try
{
log = new FileLogger("sample.log");
log.Write("Start");
log.Write("End");
}
finally
{
log.Dispose();
}
위 try/finally를 대신해 using 예약어를 사용 할 수 있다.
using(FileLogger log = new FileLogger("sample.log"))
{
log.Write("Start");
log.Write("End");
}
이 때 Dispose 메서드의 호출이생략 됐는데, using은 괄호 안에서 생성된 객체의 Dispose 메서드를 블록이 끝나는 시점에 자동을 호출하는 역할을 한다.
using 예약어는 try/finally/Dispose에 대한 간편 표기법으로 예제 5.21과 완전히 동일하게 번역된다.
5.4.2.6 종료자
종료자(finalizer): 객체가 관리 힙에서 제거 될 때 호출되는 메서드. 클래스와 동일한 이름에서 앞에 ~(틸드: tilde) 기호만 붙인다.
class test
{
~test()
{
}
}
관리 힙에 할당된 객체의 루트 참조가 없어지면 언젠가는 GC 실행으로 메모리가 반드시 해제되지만, '비관리 메모리'에 할당되는 메모리 자원, 또는 윈도우 운영체제와 연동되는 핸들(HANDLE)과 같은 자원은 GC 관리 범위를 벗어나므로 개발자가 직접 해제를 담당해야 한다.
예제 5.22 잘못된 비관리 메모리 사용 예
using System.Diagnostics;
using System.Runtime.InteropServices;
while (true)
{
UnmanagedMemoryManager m = new();
m = null;
GC.Collect(); //GC 강제 수행
//현재 프로세스가 사용하는 메모리 크기 출력
Console.WriteLine(Process.GetCurrentProcess().PrivateMemorySize64);
}
class UnmanagedMemoryManager
{
IntPtr pBuffer;
public UnmanagedMemoryManager()
{
//AllocCoTaskMem 메서드는 비관리 메모리를 할당.
pBuffer = Marshal.AllocCoTaskMem(4096 * 1024); // 의도적으로 4MB 할당
}
}
실행 시 OutOfMemoryException 예외가 발생한다. (x64가 아닌 x86(32 bit)로 실행해야 빨리 끝남)
UnmanagedMemoryManger 클래스는 생성자에서 4MB 크기의 비관리 메모리를 할당하는데, 이렇게 할당된 메모리는 GC의 관리 힙에 위치하지 않기 때문에 Main 메서드의 while 루프에서 강제로 GC.Collect 를 호출하더라도 수거되지 않는다. 따라서 위처럼 32비트 프로세스의 사용 가능한 2GB 메모리 용량을 모두 소진해 OutOfMemoryException 예외가 발생한다.
이에 직접적으로 개발자가 비관리 메모리를 해제하는 코드를 구현해야한다. FreeCoTaskMem 메서드를 통해 AllocCoTaskMem으로 할당한 메모리를 해제할 수 있다.
using System.Diagnostics;
using System.Runtime.InteropServices;
while (true)
{
using (UnmanagedMemoryManager m = new())
{
}
//현재 프로세스가 사용하는 메모리 크기 출력
Console.WriteLine(Process.GetCurrentProcess().PrivateMemorySize64);
}
class UnmanagedMemoryManager : IDisposable
{
IntPtr pBuffer;
public UnmanagedMemoryManager()
{
//AllocCoTaskMem 메서드는 비관리 메모리를 할당.
pBuffer = Marshal.AllocCoTaskMem(4096 * 1024); // 의도적으로 4MB 할당
}
public void Dispose()
{
Marshal.FreeCoTaskMem(pBuffer);
}
}
[개발자가 명시적으로 Dispose 메서드를 실수로 호출하지 못한 경우]
Dispose를 호출하지 않았음에도 클래스에 포함된 종료자 덕분에 메모리 부족 현상을 겪지 않기 위해선 다음과 같이 코드를 수정 할 수 있다.
예제5.24 종료자에서 자원 해제를 추가로 담당
using System.Diagnostics;
using System.Runtime.InteropServices;
while (true)
{
UnmanagedMemoryManager m = new();
m = null;
GC.Collect(); // GC로 인해 종료자가 호출되므로 비관리 메모리도 해제됨.
Console.WriteLine(Process.GetCurrentProcess().PrivateMemorySize64);
}
class UnmanagedMemoryManager : IDisposable
{
IntPtr pBuffer;
bool _disposed;
public UnmanagedMemoryManager()
{
//AllocCoTaskMem 메서드는 비관리 메모리를 할당.
pBuffer = Marshal.AllocCoTaskMem(4096 * 1024); // 의도적으로 4MB 할당
}
public void Dispose()
{
if(_disposed == false)
{
Marshal.FreeCoTaskMem(pBuffer);
_disposed = true;
}
}
~UnmanagedMemoryManager() // 종료자: 가비지 수집이 되면 호출된다.
{
Dispose();
}
}
종료자는 GC가 동작한다면 호출되는 것이 보장된다.
개발자가 Dispose 메서드의 호출 코드를 잊어버렸더라도 GC가 발생할 때까지 시간은 걸리겠지만, 종료자에 정의된 자원 해제 코드가 언젠가 실행되어 메모리 누수 현상을 방지한다.
즉 종료자란 클래스를 만든 개발자가 해당 클래스를 사용하는 개발자의 실수를 예상하고 방어적인 차원에서 자원 해제 코드를 넣어 두는 곳이 종료자다.
GC가 종료자를 어떻게 다루는지 코드를 통해 알아보자.
class Program
{
static void Main(String[] args)
{
CreateProgramInstance();
// A지점: 이 시점에 첫 번째 GC가 수행되었다고 가정
Console.ReadLine();
// B지점: 이 시점에 두 번째 GC가 수행되었다고 가정
Console.ReadLine();
}
private static void CreateProgramInstance()
{
Program pg = new();
}
~Program()
{
Console.WriteLine("GCed");
}
}
CreateProgramInstance 메서드 내의 Program 타입의 생성자 호출 시 CLR은 pg 객체를 관리 힙에 생성과 동시에 종료 큐(finalization queue)라는 내부 자료구조에 객체를 등록한다.
~Program() 종료자를 정의해 두었기 떄문이다.
그림 5.22 종료자를 구현한 객체 pg가 new로 할당 된 경우
이후 CreateProgramInstance 메서드를 벗어나 실행이 A지점에 왔을 때 GC가 수행되었다하면, 객체 pg는 메서드의 범위를 벗어났기에 정리 대상이 된다. 실제로 객체 pg가 종료자가 없는 객체였다면, GC에 의해 관리 힙에서 바로 없어진다.
그러나 종료자가 있기에 객체 생성 시에 종료 큐에 등록되었던 참조로 인해 GC는 A지점에서 다음과 같이 pg 객체를 처리한다.
1) 0세대 관리 힙에 있던 pg객체가 종료 큐의 루트 참조로 인해 정리되지 못하고 살아남아 GC +1세대가 된다.
2) 종료 큐에 있던 pg 객체의 참조를 제거하고 별도의 Freachable 큐에 또 다시 객체를 보관해 둔다.
첫번쨰 GC 수행으로 그림 5.23의 상황으로 바뀐다. 동시에 Freachable 큐에 들어온 객체를 CLR에 의해 미리 생성해 둔 스레드가 꺼내고 그것의 종료자 메서드를 호출해 준다. 이 스레드는 Freachable 큐에 항목이 들어올 때 마다 해당 객체를 꺼내서 종료자를 실행하는 역할만 담당하는 특수 목적의 스레드다.
따라서 아래와 같이 Freachable 큐는 다시 비어있는 상태로 바뀐다.
종료자를 가졌던 객체는 종료자를 가지지 않았던 일반 객체와 같은 상황으로 바뀐다. 이후 B지점에서 다시 한번 GC가 동작하면 객체 pg는 관리 힙에서 제거된다.
정리하면 종료자가 구현된 클래스는 GC에게 더 많은 일을 시킨다. 이때문에 특별한 이유가 없으면 종료자를 추가하지 않는 것을 권장한다.
종료자는 개발자가 Dispose 메서드를 명시적으로 호출헀다면 굳이 호출 될 필요가 없다. Dispose가 호출된 객체는 GC가 그 객체를 관리 힙에서 제거하는 과정에서 종료 큐에 대한 고려를 하지 않아도 된다.
MS에서는 이처럼 명시적인 자원해제가 됐다면 종료 큐에서 객체를 제거하는 GC.SuppressFinalize 메서드를 제공한다. 이 메서드를 통해 Dispose와 종료자를 다음과 같이 재정의할 수 있다.
예제 5.25 Dispose를 호출한 경우 종료자가 불리지 않도록 변경한 객체
using System.Runtime.InteropServices;
class UnmanagedMemoryManager : IDisposable
{
private bool _disposed;
IntPtr pBuffer;
public void Dispose(bool disposing)
{
if(_disposed == false)
{
Marshal.FreeCoTaskMem(pBuffer);
_disposed = true;
}
if(disposing == false)
{
//disposing이 false인 경우란 명시적으로 Dispose()를 호출 한 경우다.
// 따라서 종료 큐에서 자신을 제거해 GC으 ㅣ부담을 줄인다.
GC.SuppressFinalize(this);
}
}
public void Dispose()
{
Dispose(false);
}
~UnmanagedMemoryManager()
{
Dispose(true);
}
}
객체를 new로 생성하면 종료 큐에 객체가 추가되지만, 개발자가 Dispose 메서드를 호출 한 경우 GC.SuppressFinalize가 실행됨으로써 종료 큐에서 제거된다. 따라서 종료자가 정의되지 않은 객체와 동일한 상태로 바뀌기 때문에 결과적으로 GC의 부담을 덜어준다.
예제 5.25코드는 재사용 가능한 코드로 비관리 자원을 해제해야할 상황이 생기면 예제 5.25코드에서 Marshal.FreeCoTaskMem 메서드를 호출하는 부분만 코드 교체해서 사용하면 된다.
정리
c# 1.0
전처리기 지시문 | #if / #else #elif / #endif #define #undef |
연산자 | 시프트 연산자: <<, >> 비트 논리 연산자: &, |, ^, ~ 포인터 연산자: &, * |
예약어 | -checked, unchecked - params - extern, unsafe, fixed - stackalloc - internal - try, catch, throw, finally - using |
그 외
- 라이브러리 프로젝트 생성
- 디버그/ 릴리스 모드와 플랫폼 구문
- 예외
- 힙과 스택의 차이
- 가비지 수집기의 이해
'C#(.Net)' 카테고리의 다른 글
[시작하세요 C# 12 프로그래밍 ] #6 BCL - 02 (6.4~6.6) (0) | 2025.04.05 |
---|---|
[시작하세요 C# 12 프로그래밍 ] #6 BCL - 01 (6~6.3) (0) | 2025.03.30 |
[시작하세요 C# 12 프로그래밍 ] #5 C# 1.0 완성하기 #2 (0) | 2025.03.22 |
[시작하세요 C# 12 프로그래밍 ] #5 C# 1.0 완성하기 #1 (0) | 2025.03.21 |
[시작하세요 C# 12 프로그래밍 ] #4 CSharp 객체 지향 문법 3 (1) | 2025.03.19 |