반응형

[PART1.C# 런타임과 .NET 플랫폼 기초(6/11)] 프로그램 진입점 — Main 메서드와 top-level statements

운영체제와 CLR이 우리 코드를 어디서부터 실행하는가 / static void Main 의 의미 / C# 9 최상위 문이 컴파일러에 의해 <Main>$ 로 변환되는 과정 / 진입점은 왜 단 하나여야 하는가


1. [문제 제기] 내 코드는 도대체 "어디서부터" 실행되는가

Unity 로 게임 개발을 시작하면 새로운 스크립트를 만들 때마다 Start()Update() 가 자동으로 생겨납니다. 처음에는 "내가 아무것도 호출하지 않았는데 왜 Start() 가 불리지?" 라는 의문이 생기지 않습니다. 그냥 그렇게 동작하니까요.

그런데 Unity 를 잠시 내려놓고 순수한 C# 콘솔 앱을 만들어 보면 이야기가 달라집니다. 최근 Visual Studio 에서 콘솔 앱 템플릿을 열면 다음과 같은 단 한 줄짜리 파일이 생성됩니다.

C#
Console.WriteLine("Hello, World!");

클래스도 메서드도 없습니다. 반면 오래된 책이나 강의에는 다음과 같이 나와 있습니다.

C#
class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Hello, World!");
    }
}

두 코드 모두 정상적으로 실행됩니다. 여기서 신입 개발자의 머릿속에는 세 가지 의문이 연달아 떠오릅니다.

  1. "한 줄짜리 코드는 Main 이 없는데 어떻게 실행되지?"
  2. "이 둘 중 뭘 써야 하지? 성능이나 구조가 다른가?"
  3. "만약 한 프로젝트에 Main 이 여러 개 있으면 어떻게 되지?"

이 세 질문은 결국 하나의 근본적인 주제로 모입니다 — 프로그램의 진입점(entry point). 운영체제가 당신의 .exe 를 더블클릭하거나 dotnet run 을 실행했을 때, CLR(Common Language Runtime, .NET 프로그램을 실행하는 런타임 엔진)이 가장 먼저 찾아가서 실행하는 "시작 지점" 입니다.

진입점을 정확히 이해하지 못하면 다음과 같은 상황에서 바로 벽에 부딪힙니다.

  • 유닛 테스트 프로젝트와 본체 프로젝트가 섞여서 Main 이 두 개가 되는 순간 빌드 오류 발생
  • dotnet run -- --env=dev 같은 명령줄 인수를 읽으려는데 어디로 들어오는지 모름
  • async/await 코드를 최상단에서 쓰고 싶은데 어떻게 시작해야 할지 모름
  • Unity 에서 "왜 이 프로젝트는 Main 이 없는데도 빌드되지?" 라는 의문

이 글에서는 전통적인 static void Main 과 C# 9 의 최상위 문(top-level statements) 을 IL(Intermediate Language, 컴파일러가 C# 을 변환해 만든 중간 언어 바이트코드) 수준까지 열어보며, 둘이 런타임에서는 실질적으로 같은 것 이라는 결론을 직접 증명하겠습니다.


2. [개념 정의] 진입점은 운영체제와 CLR 이 만나는 "약속된 문패"

진입점을 아파트 택배 기사에 비유하기

아파트 택배 기사가 동호수를 모르면 집에 갈 수 없습니다. 반드시 "101동 1201호" 처럼 약속된 위치가 있어야 합니다. 프로그램도 똑같습니다. 운영체제는 어셈블리(assembly, .exe 나 .dll 같은 .NET 실행 단위) 안에 수백 개의 메서드가 있어도, 그중 단 하나 — 진입점 만 호출합니다. 나머지 메서드는 진입점이 시작한 뒤 코드 흐름을 따라가며 필요할 때 호출됩니다.

그리고 이 "어느 메서드가 진입점인가" 정보는 어셈블리 안에 명시적으로 기록 되어 있습니다. IL 에서는 .entrypoint 라는 한 줄짜리 지시어가 그 표식입니다. CLR 은 어셈블리를 로드한 직후 .entrypoint 가 붙은 메서드를 찾아 그걸 호출합니다.

진입점 호출 흐름

운영체제 (OS)

가장 단순한 진입점 코드

아래는 static void Main 을 사용한 가장 단순한 진입점입니다. 이 코드가 IL 수준에서 어떻게 변환되는지 직접 확인하겠습니다.

C#
// 파일: Program.cs — "Classic Main" 버전
using System;

namespace ClassicMain
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello from classic Main!");
        }
    }
}
static — 정적 키워드 (static modifier) 인스턴스(객체) 없이 클래스 이름만으로 호출할 수 있는 멤버임을 나타냅니다. 프로그램 시작 시점에는 어떤 객체도 메모리에 만들어져 있지 않기 때문에, 진입점은 반드시 static 이어야 합니다.
예시: Program.Main(args) 처럼 new 없이 바로 호출 가능

dotnet build 로 컴파일한 뒤 ilspycmd -il ClassicMain.dll 로 IL 을 꺼내면 다음과 같습니다.

IL
.class private auto ansi beforefieldinit ClassicMain.Program
    extends [System.Runtime]System.Object
{
    // Methods
    .method private hidebysig static
        void Main (
            string[] args
        ) cil managed
    {
        // ★ 이 메서드가 진입점이라고 CLR 에게 알리는 지시어
        .maxstack 8
        .entrypoint

        IL_0000: ldstr "Hello from classic Main!"           // 문자열 상수를 평가 스택에 올림
        IL_0005: call void [System.Console]System.Console::WriteLine(string) // Console.WriteLine 호출
        IL_000a: ret                                         // 메서드 반환
    } // end of method Program::Main
}

이 IL 에서 핵심은 다음 세 줄입니다.

  • .entrypoint — 이 메서드가 어셈블리의 유일한 진입점임을 선언합니다. CLR 은 이 지시어를 따라 ClassicMain.Program::Main 을 실행 시작점으로 삼습니다.
  • private hidebysig static void MainMain 은 접근 제한자와 상관없이 CLR 이 특수 규칙으로 찾아 호출합니다. 그래서 public 이 없어도 동작합니다.
  • string[] args — 명령줄 인수를 받는 파라미터입니다. 사용하지 않더라도 선언해 둘 수 있고, CLR 은 인수가 없어도 빈 배열 new string[0] 을 넣어 호출합니다 — 즉 args 는 절대 null 이 되지 않습니다.

쉬운 설명 한 줄 요약

진입점은 "OS 가 CLR 에게 '여기서부터 시작해라' 라고 가리키는 문패" 이며, C# 에서 그 문패는 기본적으로 static Main 메서드 위에 붙습니다.

3. [내부 동작] 최상위 문은 컴파일러가 <Main>$ 로 펼쳐주는 문법적 설탕

C# 9 이전: 단순한 코드도 4층 건물

C# 9 이전까지 "Hello World" 한 줄을 찍으려면 다음이 모두 필요했습니다.

  1. using System; (네임스페이스 임포트)
  2. class Program (클래스 선언)
  3. static void Main(string[] args) (정적 메서드 선언)
  4. Console.WriteLine(...) (실제 하고 싶은 일)

진짜 하고 싶은 일은 4 번 한 줄뿐인데, 1~3 번은 "입장 절차" 였습니다. 입문자가 매번 "왜 클래스를 만들어야 하죠? static 은 뭐예요?" 를 물어야 한다는 건, 언어 입장에서 좋지 않은 시작 경험이었습니다.

C# 9 최상위 문의 등장

C# 9 (2020 년 .NET 5 와 함께) 부터는 프로젝트 단 한 파일에 한해 클래스·메서드 선언을 생략하고 바로 문장을 쓸 수 있게 되었습니다.

C#
// 파일: Program.cs — "Top-level statements" 버전
using System;

Console.WriteLine("Hello from top-level!");
Console.WriteLine($"args.Length = {args.Length}");

보이는 건 using 하나와 실행문 두 줄뿐입니다. 그런데 여기서 주의 깊게 볼 부분이 있습니다 — args 라는 변수가 어디서도 선언되지 않았는데 그냥 사용되고 있습니다.

컴파일러는 최상위 문을 어떻게 변환하는가

답은 하나입니다. 컴파일러가 내부적으로 Program 클래스와 진입점 메서드를 자동으로 합성합니다. 최상위 문은 런타임에 새로운 기능이 추가된 것이 아니라, 순수하게 컴파일러 수준의 문법적 설탕(syntactic sugar) 입니다.

이 변환을 SVG 로 정리하면 다음과 같습니다.

개발자가 작성한 소스 (Program.cs)

실제 IL 증거 — 결정적인 차이는 메서드 이름

Top-level 버전을 컴파일해서 IL 을 추출하면 다음과 같습니다. 바로 위 Classic Main 버전과 같은 줄을 비교해 보세요.

IL
.class private auto ansi beforefieldinit Program
    extends [System.Runtime]System.Object
{
    // ★ 컴파일러가 이 클래스를 자동 생성했다는 증거
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
        01 00 00 00
    )
    // Methods
    .method private hidebysig static
        void '<Main>$' (                                     // ★ 메서드 이름이 '<Main>$'
            string[] args                                    // ★ args 매개변수는 그대로 유지
        ) cil managed
    {
        .maxstack 3
        .entrypoint                                          // ★ Classic Main 과 동일한 진입점 표식
        .locals init (
            [0] valuetype [System.Runtime]
                System.Runtime.CompilerServices.DefaultInterpolatedStringHandler
        )

        IL_0000: ldstr "Hello from top-level!"
        IL_0005: call void [System.Console]System.Console::WriteLine(string)

        // 이하는 $"args.Length = {args.Length}" 보간 문자열 처리
        IL_000a: ldloca.s 0
        IL_000c: ldc.i4.s 14
        IL_000e: ldc.i4.1
        IL_000f: call instance void DefaultInterpolatedStringHandler::.ctor(int32, int32)
        IL_0014: ldloca.s 0
        IL_0016: ldstr "args.Length = "
        IL_001b: call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
        IL_0020: ldloca.s 0
        IL_0022: ldarg.0                                    // ★ ldarg.0 = args 로드
        IL_0023: ldlen                                       // 배열 길이
        IL_0024: conv.i4
        IL_0025: call instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)
        IL_002a: ldloca.s 0
        IL_002c: call instance string DefaultInterpolatedStringHandler::ToStringAndClear()
        IL_0031: call void [System.Console]System.Console::WriteLine(string)
        IL_0036: ret
    } // end of method Program::'<Main>$'
}

두 IL 의 구조적 비교

항목 Classic Main 버전 Top-level statements 버전
클래스 이름 ClassicMain.Program (사용자 정의) Program (컴파일러 자동 생성)
클래스 특성 없음 [CompilerGenerated] 붙음
메서드 이름 Main <Main>$ — 꺽쇠·$ 로 충돌 방지
접근 제한자 private hidebysig static private hidebysig static — 동일
매개변수 string[] args string[] args — 동일
.entrypoint 표식 있음 있음 — 동일
반환 타입 void (return 없음) void (return 없음)
args 접근 명시적 파라미터 IL 에서는 ldarg.0 으로 동일하게 접근

결정적 결론: 두 방식은 소스 문법만 다를 뿐, IL 단에서는 동일한 .entrypoint 지시어가 붙은 static 메서드 입니다. CLR 입장에서는 뭐가 됐든 "진입점" 이라는 같은 정보만 봅니다. 그래서 성능 차이는 정확히 0 입니다.

<Main>$ 라는 이름의 의미

C# 식별자 규칙상 <$ 는 변수·메서드 이름에 사용할 수 없습니다. 컴파일러는 일부러 사용자가 쓸 수 없는 문자를 이름에 섞어 사용자가 작성한 Main 과 절대 충돌하지 않는 이름 을 만들어 냅니다. 같은 어셈블리에 사용자가 자기 Main 메서드를 따로 가지고 있어도 이름이 겹치지 않게 하기 위함입니다 (단, 실제로는 최상위 문이 있으면 기존 Main 은 무시되고 경고가 발생합니다).

이 명명 패턴은 C# 컴파일러가 자동 생성하는 다른 요소들 — 람다의 백킹 필드, iterator 의 상태 머신 클래스, async 메서드의 MoveNext 를 담는 클래스 등 — 에도 동일하게 적용되는 관례입니다.


4. [실전 적용] 언제 Classic Main 을 쓰고 언제 Top-level 을 쓰는가

사용 기준: 프로젝트 규모와 의도

런타임 성능이 같다면 선택은 순전히 가독성·의도 전달 의 문제입니다. 다음 기준을 제안합니다.

상황 추천 이유
새 콘솔 앱, Main 외에 정적 헬퍼가 거의 없음 Top-level 의도된 간결함. 입문자 친화적
여러 클래스가 얽힌 대규모 앱 Classic Main Program 클래스에 DI 컨테이너 구성·생명주기 훅 같은 부가 로직을 걸기 쉬움
초보 교육용 스니펫 Top-level 클래스·static 개념을 나중으로 미룰 수 있음
기업 내부 규칙·코드 스타일이 정해진 프로젝트 규칙을 따름 일관성 우선
명시적 -main:ClassName 으로 진입점을 선택해야 하는 경우 Classic Main Top-level 은 단일 파일 제약이 있어 선택 대상이 될 수 없음

Before/After: 콘솔 유틸리티 스크립트

Unity 와 별개로, 팀에서 자주 쓰는 에셋 전처리 스크립트를 가정합시다. 텍스처 폴더에서 PNG 개수를 세는 간단한 유틸입니다.

Before — Classic Main (군더더기 있음)

C#
// 파일: CountPngs.cs
using System;
using System.IO;

namespace AssetTools
{
    class CountPngs
    {
        static int Main(string[] args)
        {
            if (args.Length < 1)
            {
                Console.Error.WriteLine("Usage: countpngs <folder>");
                return 1;
            }

            var count = Directory.GetFiles(args[0], "*.png", SearchOption.AllDirectories).Length;
            Console.WriteLine($"PNG count: {count}");
            return 0;
        }
    }
}

After — Top-level (군더더기 제거)

C#
// 파일: Program.cs
using System;
using System.IO;

if (args.Length < 1)
{
    Console.Error.WriteLine("Usage: countpngs <folder>");
    return 1;
}

var count = Directory.GetFiles(args[0], "*.png", SearchOption.AllDirectories).Length;
Console.WriteLine($"PNG count: {count}");
return 0;

이 After 코드의 IL 을 뽑으면 진입점 메서드 시그니처가 자동으로 int <Main>$(string[] args) 로 합성됩니다. 컴파일러는 소스에 return 0;, return 1; 이 있는 것을 보고 반환 타입을 int 로 유추합니다.

IL
.method private hidebysig static
    int32 '<Main>$' (
        string[] args
    ) cil managed
{
    .maxstack 8
    .entrypoint

    // ... Directory.GetFiles 호출 ...
    IL_0020: ret         // int 값을 스택에 남기고 반환
}

즉, 반환 타입도 컴파일러가 알아서 선택합니다. 개발자는 "필요하면 return 쓰면 되고, 아니면 안 써도 된다" 수준으로만 알면 됩니다.

await 가 포함된 최상위 문

최상위 문에 await 가 한 번이라도 쓰이면, 컴파일러는 진입점 시그니처를 async Task 또는 async Task<int> 로 합성합니다.

C#
using System;
using System.Net.Http;
using System.Threading.Tasks;

using var client = new HttpClient();
var html = await client.GetStringAsync("https://example.com");
Console.WriteLine($"Length: {html.Length}");

이 코드의 IL 진입점 시그니처는 async Task <Main>$(string[] args) 가 되며, CLR 은 반환된 Task 가 완료될 때까지 프로세스를 유지합니다. C# 7.1 이전에는 반드시 Main 안에서 .GetAwaiter().GetResult() 로 직접 동기 대기해야 했던 보일러플레이트가 사라진 것입니다.

Unity 에서의 진입점 — 우리 Main 은 어디 갔을까

Unity 는 .NET 콘솔 앱이 아닙니다. 정확히 말하면 Unity 빌드 산출물은 "당신이 만든 .exe" 가 아니라 "Unity 엔진이 만든 .exe + 당신이 만든 C# 스크립트를 담은 어셈블리(DLL)" 입니다.

즉 다음이 맞는 설명입니다.

  • 진짜 진입점 은 Unity 엔진(C++ 로 작성됨) 안에 있습니다. 거기에 Main 이 존재합니다.
  • Unity 엔진은 당신의 Assembly-CSharp.dll클래스 라이브러리로 취급 해 로드합니다. 그래서 Unity 프로젝트의 C# 스크립트에는 Main 을 쓰지 않습니다.
  • 엔진은 게임 루프 중 특정 시점에 리플렉션 으로 MonoBehaviour 를 상속한 스크립트의 Awake(), OnEnable(), Start(), Update(), FixedUpdate(), LateUpdate(), OnDestroy() 를 찾아 호출합니다.
C#
using UnityEngine;

public class Player : MonoBehaviour
{
    // Unity 엔진이 "적절한 시점" 에 호출합니다 — 마치 작은 Main 들입니다.
    void Awake() { /* 스크립트 인스턴스 로드 직후 1회 */ }
    void Start() { /* 첫 프레임 렌더 직전 1회 */ }
    void Update() { /* 프레임마다 */ }
    void OnDestroy() { /* GameObject 가 파괴될 때 1회 */ }
}

이 메서드들이 IL 에서 특별한 .entrypoint 표식을 달고 있는 건 아닙니다. 그냥 평범한 인스턴스 메서드이고, Unity 엔진이 이름으로 찾아서 호출 할 뿐입니다 (정확히는 IL2CPP 빌드에서는 사전 링크 시점에 바인딩이 고정됩니다).

Unity 프로젝트에서 Main 을 직접 써야 할 때는? 극히 드뭅니다. 별도 콘솔 툴(에셋 빌드 스크립트, 서버 브릿지 등)을 같은 솔루션에 담을 때만 필요합니다.

섹션 요약

  • 런타임 성능은 Classic Main 과 Top-level 이 동일 — 선택은 스타일의 문제
  • 컴파일러가 return·await 유무로 반환 타입을 자동 선택
  • Unity 안에서는 Main 대신 라이프사이클 메서드가 "각자의 진입점" 역할

5. [함정과 주의사항] 흔한 실수 ❌ → ✅ 패턴

함정 1 — 프로젝트에 Main 이 두 개 (진입점 모호성)

유닛 테스트 초보자가 자주 겪습니다. 메인 앱 프로젝트와 간단한 테스트 앱이 한 .csproj 에 섞여 있을 때 발생합니다.

❌ 잘못된 코드

C#
// 파일: App.cs
class App
{
    static void Main() { System.Console.WriteLine("App"); }
}

// 파일: Test.cs
class Test
{
    static void Main() { System.Console.WriteLine("Test"); }
}

빌드하면 다음 오류가 납니다.

error CS0017: 'App.Main(string[])' 및 'Test.Main(string[])' 등을 포함하여 프로그램에 두 개 이상의 진입점이 정의되어 있습니다.
컴파일러 옵션으로 진입점을 포함하는 형식을 지정하십시오.

✅ 올바른 해결 — .csprojStartupObject 지정

<!-- MyApp.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <StartupObject>App</StartupObject>   <!-- ★ 어느 클래스의 Main 을 쓸지 명시 -->
  </PropertyGroup>
</Project>

CLI 로 빌드할 때는 -main:App (csc) 또는 /p:StartupObject=App (dotnet build) 로 동등하게 지정합니다. IL 은 달라지지 않으며, 선택되지 않은 Main.entrypoint 표식만 떼어집니다.

함정 2 — 최상위 문을 두 파일에 나눠 쓴다

❌ 잘못된 코드

C#
// 파일 A: Main.cs
using System;
Console.WriteLine("Main A");
C#
// 파일 B: Other.cs
using System;
Console.WriteLine("Main B");

컴파일 오류:

error CS8802: 한 번에 하나의 컴파일 단위만 최상위 문을 가질 수 있습니다.

✅ 올바른 해결

최상위 문은 프로젝트 전체에서 정확히 한 파일에만 허용됩니다. 여러 시작 모드가 필요하면 최상위 문 파일에서 args 를 분기하거나, 일반 static 메서드로 옮기세요.

C#
// 파일: Program.cs
using System;
using MyApp;

if (args.Length > 0 && args[0] == "b")
{
    Mode.RunB();
}
else
{
    Mode.RunA();
}

IL 에서는 여전히 <Main>$ 하나만 남습니다. Mode.RunA·Mode.RunB 는 그 안에서 호출되는 평범한 정적 메서드일 뿐입니다.

함정 3 — 최상위 문 + Classic Main 을 같이 쓴다

❌ 잘못된 코드

C#
// Program.cs — 최상위 문 있음
using System;
Console.WriteLine("Top-level");

// 그리고 같은 파일 또는 같은 프로젝트에...
class Program
{
    static void Main() { Console.WriteLine("Classic"); }
}

결과:

warning CS7022: 진입점이 'Program.Main()' 대신 'Program.<Main>$(string[])' 에 있습니다. 'Main' 메서드는 무시됩니다.

빌드는 되지만 당신이 작성한 Main호출되지 않습니다. Top-level 이 승리합니다. 의도하지 않은 침묵 동작이 가장 위험합니다 — 실행해 보고 "왜 내 Main 이 안 돌지?" 로 시간을 낭비하게 됩니다.

✅ 올바른 해결

최상위 문을 쓰기로 했으면 사용자 Main 은 완전히 제거합니다. 혹은 두 모드가 필요하다면 Classic Main 한쪽만 선택하고 -main: 으로 진입점을 명시합니다.

함정 4 — args.Length == 0 인데 args[0] 접근

❌ 잘못된 코드

C#
// args 가 비어 있으면 IndexOutOfRangeException
Console.WriteLine($"First: {args[0]}");

✅ 올바른 해결

C#
// 최상위 문에서도 Classic Main 에서도 동일
if (args.Length == 0)
{
    Console.Error.WriteLine("사용법: myapp <입력 파일>");
    return 1;
}

Console.WriteLine($"First: {args[0]}");
return 0;

args 는 절대 null 이 될 수 없다는 점은 꼭 기억하세요. 그래서 args is null 체크는 불필요하고, 길이 검사만 하면 됩니다. IL 상으로는 ldarg.0 으로 배열을 로드한 뒤 ldlen 으로 길이를 얻는 단순한 두 명령어입니다.

함정 5 — Unity 에서 Main 을 만든다

❌ 잘못된 코드

C#
// Assets/Scripts/Program.cs — Unity 프로젝트 안
using UnityEngine;

public class Program
{
    public static void Main()
    {
        Debug.Log("Start here?");  // 절대 호출되지 않습니다
    }
}

Unity 는 스크립트 어셈블리를 클래스 라이브러리 로만 취급하므로 Main 을 호출하지 않습니다. 이 메서드는 .entrypoint 표식이 붙지도 않고, 엔진의 리플렉션 목록에도 없어서 그냥 죽은 코드가 됩니다.

✅ 올바른 해결

C#
using UnityEngine;

public class GameBootstrap : MonoBehaviour
{
    // RuntimeInitializeOnLoadMethod: 씬 로드 직전 또는 직후 자동 호출
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void Init()
    {
        Debug.Log("Unity 의 '진입점' 역할을 하는 RuntimeInitialize 훅");
    }

    void Awake()  { /* GameObject 에 붙었을 때 */ }
    void Start()  { /* 첫 프레임 직전 */ }
}

Unity 에서 "앱 시작 시 한 번만 실행" 을 원하면 [RuntimeInitializeOnLoadMethod] 가 정답입니다. 이는 엔진이 리플렉션으로 해당 특성을 찾아 호출하는 훅이며, IL 수준에서는 평범한 static 메서드 + 특성 메타데이터 일 뿐입니다.


6. [C# 버전별 변화] 진입점의 발전사

C# 버전 .NET 버전 추가된 진입점 기능
C# 1.0 .NET Framework 1.0 (2002) static void Main(), static int Main(), string[] args 버전
C# 7.1 .NET Core 2.0 (2017) async Task Main, async Task<int> Main — await 직접 사용 가능
C# 9.0 .NET 5.0 (2020) Top-level statements — 클래스·Main 선언 생략 가능

C# 7.1 이전 → 이후: async Main 등장

Before — C# 7.0 이하 (async Main 불가)

C#
using System;
using System.Net.Http;
using System.Threading.Tasks;

class Program
{
    static void Main(string[] args)
    {
        // ❌ Main 은 async 가 될 수 없다 → await 를 쓰려면 직접 동기 대기
        var task = DownloadAsync();
        task.GetAwaiter().GetResult();   // 보일러플레이트. 예외가 AggregateException 으로 감싸지는 함정도 있음.
    }

    static async Task DownloadAsync()
    {
        using var client = new HttpClient();
        var html = await client.GetStringAsync("https://example.com");
        Console.WriteLine($"Length: {html.Length}");
    }
}

After — C# 7.1 이상 (async Task Main)

C#
using System;
using System.Net.Http;
using System.Threading.Tasks;

class Program
{
    // ✅ async Main 직접 사용
    static async Task Main(string[] args)
    {
        using var client = new HttpClient();
        var html = await client.GetStringAsync("https://example.com");
        Console.WriteLine($"Length: {html.Length}");
    }
}

두 방식의 IL 을 비교하면, After 버전의 Main 은 IL 상 평범한 메서드가 아니라 상태 머신 클래스 (<Main>d__0) 를 만들어 MoveNext() 로 돌리는 구조 로 바뀝니다. .entrypoint 가 붙은 Main 은 상태 머신을 기동시키는 wrapper 가 되고, 실제 본문은 MoveNext 안에 들어갑니다. 그래도 CLR 입장에선 여전히 "Task 반환값을 기다려 주는" 동일한 진입점 호출입니다.

C# 9 이전 → 이후: Top-level statements 등장

Before — C# 8 이하

C#
using System;

namespace HelloApp
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello");
        }
    }
}

해당 IL:

IL
.class private auto ansi beforefieldinit HelloApp.Program
    extends [System.Runtime]System.Object
{
    .method private hidebysig static
        void Main (string[] args) cil managed
    {
        .maxstack 8
        .entrypoint
        IL_0000: ldstr "Hello"
        IL_0005: call void [System.Console]System.Console::WriteLine(string)
        IL_000a: ret
    }
}

After — C# 9 이상

C#
using System;

Console.WriteLine("Hello");

해당 IL:

IL
.class private auto ansi beforefieldinit Program
    extends [System.Runtime]System.Object
{
    .custom instance void [System.Runtime]CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 )
    .method private hidebysig static
        void '<Main>$' (string[] args) cil managed
    {
        .maxstack 8
        .entrypoint
        IL_0000: ldstr "Hello"
        IL_0005: call void [System.Console]System.Console::WriteLine(string)
        IL_000a: ret
    }
}

본문 IL (ldstr/call/ret) 은 세 줄이 정확히 같다. 차이는 오직:

  • 클래스 이름 (HelloApp.Program vs Program)
  • 클래스 위의 CompilerGenerated 특성 유무
  • 메서드 이름 (Main vs <Main>$)

성능을 고려해 어느 쪽을 쓸지 고민할 필요가 전혀 없다는 점을 이 비교가 결정적으로 보여줍니다.


7. [정리] 이것만 기억하면 됩니다

  • "진입점" 은 IL 의 .entrypoint 지시어로 표시되는 단 하나의 메서드 — OS 가 CLR 에게 "여기서 시작해라" 하고 가리키는 문패입니다.
  • Classic static void Main(string[] args) 는 C# 1.0 이후 줄곧 표준 진입점 형태입니다. void/int/async Task/async Task<int> 네 가지 시그니처가 허용됩니다.
  • C# 9 최상위 문은 문법적 설탕 입니다. 컴파일러가 [CompilerGenerated] class Program { static void <Main>$(string[] args) { ... } } 를 자동 합성합니다. 런타임 성능·동작은 Classic Main 과 완전히 동일합니다.
  • 메서드 이름 <Main>$ 의 꺽쇠와 $사용자 식별자와 충돌을 피하기 위한 컴파일러 관례 — 사용자가 직접 작성할 수 없는 이름을 의도적으로 씁니다.
  • 진입점은 단 하나 여야 합니다. 여러 Main 후보가 있으면 .csproj<StartupObject> 또는 -main: 으로 지정합니다. 최상위 문은 프로젝트당 한 파일만 허용됩니다.
  • args 는 절대 null 이 될 수 없습니다 — 인수가 없으면 빈 배열이 넘어옵니다. 길이 검사만 하면 됩니다.
  • Unity 에서는 Main 을 쓰지 않습니다. 진짜 진입점은 Unity 엔진 내부에 있고, 개발자는 Awake()/Start()/Update() 같은 라이프사이클 훅이나 [RuntimeInitializeOnLoadMethod] 로 코드를 심습니다.

체크리스트

  • [ ] 새 콘솔 앱을 만들 때 Top-level 과 Classic Main 중 의도적으로 선택했는가
  • [ ] 최상위 문은 프로젝트 내 단 한 파일 에만 있는가
  • [ ] args[0] 접근 전에 길이 검사 를 넣었는가
  • [ ] 종료 코드가 필요하면 return 0/return 1 을 사용했는가 (컴파일러가 반환 타입을 알아서 선택)
  • [ ] Unity 스크립트에 Main 메서드를 만들어 두고 "왜 실행 안 되지?" 하고 있지 않은가
반응형

+ Recent posts