C#

C# 1.0 - 문법요소

devrabbit22 2025. 9. 21. 04:41

구문

전처리기 지시문

C#의 전처리기 지시문(preprocessor directive)은 특정 소스코드를 상황에 따라 컴파일 과정에서 추가/제거하고 싶을 때 사용한다.

class Program
{
    static void Main(string[] args)
    {
        string txt = Console.ReadLine();
        
        if(string.IsNullOrEmpty(txt) == false)
        {
            Console.WriteLine("사용자 입력: " + txt);
        }
    }
}

Console.ReadLine 메서드는 Enter 키가 눌릴 때까지의 키보드 입력을 받는 역할을 한다.

메서드가 실행되면 콘솔 화면에는 입력을 기다리는 프롬포트가 깜빡이고 키보드로 입력된 내용을 내부적으로 저장해뒀다가 엔터 키가 눌리는 반환값으로 돌려준다.

string IsNullOrEmpty 메서드는 인자로 들어온 string 객체가 null이거나 빈 문자열(" ")을 담고 있으면 true를 반환하고 1개 이상의 문자를 담고 있다면 false를 반홚나다.

위의 코드는 사용자에게서 입력받은 내용인 'test'라면 화면에 '사용자 입력: test' 라는 문자열을 출력하고, 내용 없이 엔터 키만 눌렀다면 아무것도 하지 않고 종료한다.

 

만약 위 프로그램을 사용하는 특정 고객으로부터 압무 입력도 받지 않은 경우에 '입력되지 않음'이라는 메시지를 출력해달라는 요청을 받았을 경우

if(string.IsNullOrEmpty(txt) == false)
{
    Console.WriteLine("사용자 입력: " + txt);
}
else
{
    Console.WriteLine("입력되지 않음");
}

위와 같이 수정하면 된다.

하지만 문제가 발생하는데 모든 고객이 이런 변경을 찬성하지 않았을 경우에 발생한다.

program.cs와 program2.cs로부터 각각 program.exe를 만들어서 원하는 고객에게 전달하게 되는데, 이렇게 소스코드 파일이 나뉘게 되면 여러 가지 문제가 발생한다. 하나의 소스코드에서 변경된 사항을 다른 소스코드에 반영해야 하는 관리상의 부담이 생긴다. 

메서드를 통해 중복 코드를 방지한 것처럼 소스코드 파일 역시 중복되는 것은 지양해야 한다.

바로 이럴 때 사용하는 기법이 #if #endif 전처리기 지시문이다.

class Program
{
    static void Main(string[] args)
    {
        string txt = Console.ReadLine();
        if(string.IsNullOfEmpty(txt) == false)
        {
            Console.WriteLine("사용자 입력: " + txt);
        }
#if OUTPUT_LOG
        else
        {
            Console.WriteLine("입력되지 않음");
        }
#endif
    }
}

OUTPUT_LOG 전처리 상수(preprocessor constant)가 정의되어 있으면 #if #endif 사이의 소스코드를 포함해서 컴파일하게 만들고, 정의되어 있지 않으면 컴파일 과정에서 해당부분을 제거한다.

이를 위해 개발자는 dotnet build시 /p:DefineConstants 옵션을 통해 전처리 상수를 설정할 수 있다.

[OUTPUT_LOG가 정의되지 않은 컴파일]
dotnet build

[OUTPUT_LOG가 정의된 컴파일]
dotnet build "/p:DefineConstants=OUTPUT_LOG"

비주얼 스튜디오 환경에서는 솔루션 탐색기에서 프로젝트 항목을 선택한 후 마우스 우클릭을 누르면 나오는 매누에서 [속성]을 선택한다. [빌드] -> [일반] 항목을 선택하고 '조건부 컴파일 기호 입력 상자에 'OUTPUT_LOG'를 입력하고 우측의 추가버튼을 누른다.

이렇게 전처리기를 이용하면 하나의 소스코드 파일로 여러 가지 상황을 만족하는 프로그램을 만들 수 있다.

#if, #endif가 조건문이기에 이를 보완하기 위한 #else, #elif 지시자도 있다. 

 

전처리기 기호를 dotnet buld의 옵션으로 지정하는 방법 이외에 소스코드에서 직접 지정할 수 있도록 #define 문도 제공되며, 반대로 정의를 취소할 수 있는 #undef 문도 있다. 

#define __X86__
#undef OUTPUT_LOG

class Program
{
    static void Main(string[] args)
    {
#if OUTPUT_LOG
        Console.WriteLine("OUTPUT_LOG가 정의됨");
#else
        Console.WriteLine("OUTPUT_LOG가 정의 안됨);
#endif

#if __X86__
        Console.WriteLine("__X86__ 정의됨);
#elif __X64__
        Console.WriteLine("__X64__ 정의됨);
#else
        Console.WriteLine("아무것도 정의안됨);
#endif
    }
}

#define/#undef문은 반드시 소스코드보다 먼저 나타나야 한다. 위의 소스코드에서 #define 구문을 using 문 다음으로 옮기면 컴파일 할 때 'Cannot define/undefined preprocessor symbols after first token in file' 오류가 발생한다.

이 밖에도 #warning, #error, #line, #region, #endregion, #pragma 지시문 등이 있다.

지역변수의 유효 범위

  • 지역 변수가 정의되면 유효 범위는 변수를 포함하고 있는 블록과 일치한다.
  • 변수가 선언된 블록이 닫히고 나서 코드에서 접근하려고 시도하면 컴파일 시점에 오류가 발생한다.
static void Main(string[] args)
{
    if(true)
    {
        int i = 5;
    }
    
    Console.WriteLine(i);	//error CS0103: 'i' 이름이 현재 컨텍스트에 없습니다.
}

블록은 개발자가 자유롭게 열고 닫을 수 있으며, 중첩된 블록의 경우 부모 블록은 자식의 블록의 유효 범위를 포함한다. 

static void Main(string[] args)
{
    int i = 5;
    {
        int i = 10;	//error CS0136: 'i'라는 지역 변수는 'i'에 다른 의미를 주기 때문에
        			//이 범위에 선언할 수 없다. 이 변수는 이미 '부모 또는 현재' 범위에서
                    //다른 의미를 나타내도록 사용되어 있다.
    }
}

대신 같은 수준의 블록에서는 서로의 유효 범위를 넘어서지 않으므로 다음의 경우 오류가 발생하지 않는다.

static void Main(string[] args)
{
    {
        int i = 5;
    }
    {
        int i = 10;
    }
}

리터럴에도 적용되는 타입

  • 코드 내에서 사용되는 리터럴도 그에 해당하는 타입이 적용된다.
  • 숫자 5는 int 형의 인스턴스이고 값이 고정된 변수처럼 사용될 수 있다. 즉, 숫자 5를 통해서도 System.Int32 타입의 멤버를 그대로 사용할 수 있다.
Console.WriteLine(5.ToString() + 6.ToString());	//출력 결과: 56

문자열도 string 타입의 인스턴스로 취급되어 다음과 같은 호출이 가능하다.

Console.WriteLine("test".ToUpper());	//출력 결과: TEST

특성

  • 소스코드에 주석을 이용해 원하는 정보를 남길 수 있다.
/*
Dummy Class
개발자: ABC
*/
public class Dummy{}

이 정보는 소스코드 파일에만 존재할 뿐, 컴파일러에 의해 빌드 된 후 생성되는 EXE/DLL 파일에는 남지 않는다.

이 문제를 해결할 수 있는 것이 바로 특성(attribute)이다.

 

.NET의 어셈블리 파일에는 해당 어셈블리 스스로를 기술하는 메타데이터가 포함되어 있다.

ex) 어셈블리 내에서 구현하고 있는 타입, 그 타입 내에 구현된 멤버 등의 정보가 메타데이터에 해당된다.

특성은 이런 메타데이터에 함께 포함되며, 원하는 데이터를 보관하는 특성을 자유롭게 정의해서 사용할 수 있다.

enum 타입을 정의하면서 [Flags]라는 특성을 보면 특성 자체도 클래스이다.

[Flags] 특성은 FlagsAttribute라는 클래스로서 마이크로소프트에서 미리 만들어 BCL에 포함해둔 것이다. 당연히 외부 개발자도 특성을 만들 수 있다.

사용자 정의 특성

  • 특성은 System.Attribute를 상속받았다는 점을 제외하고는 여느 클래스와 차이점이 없다. 관례상 특성 클래스의 이름에는 Attribute라는 접미사를 붙인다.
class AuthorAttribute : System.Attribute
{
}

특성을 정의하는 클래스도 여느 클래스와 다름없이 new 연산자로 인스턴스를 만들 수 있다. 하지만 그렇게 쓰는 경우는 거의 없다.

대신 특성 클래스를 인스턴스화할 수 있는 독특한 구문이 제공되는데, 그것이 바로 대괄호([ ])이다.

 

특성은 세 가지 방식으로 적용할수 있다.

[AuthorAttribute]
class Dummy1
{
}
[Author]	//C#에서는 Attribute 접미사를 생략해도 된다.
class Dummy1
{
}
[Author()]	//마치 new Author()처럼 생성자를 표현하는 듯한 구문도 사용할 수 있다.
class Dummy1
{
}

특성 클래스에 매개변수가 포함된 생성자를 추가할 수도 있다.

class AuthorAttribute : System.Attribute
{
    string name;
    
    public AuthorAttribute(string name)
    {
        this.name = name;
    }
}

string 타입의 매개변수를 하나 받는 생성자를 정의했기 때문에 Author 특성을 사용할 때도 문자열을 전달해야 한다.

[Author("Anders)]	//new Author("Anders);와 같은 사용 구문을 연상하면 된다.
class Program
{
    static void Main(string[] args)
    {
    }
}

선택적으로 값을 지정하고 싶다면 특성 클래스의 속성으로 정의하는 편이 좋다고 한다.

ex) 버전 기록을 위해 int 속성을 추가한다.

class AuthorAttribute : System.Attribute
{
    //[생략]
    int _version;
    public int Version
    {
        get { return _version; }
        set { _version = value; }
    }
}

특성을 사용하는 대괄호 구문에서는 Version 속성이 생성자에 명시된 것은 아니므로 별도의 '이름 = 값' 형식으로 전달해야 한다.

[Author("Anders", Version = 1]
class Program
{
    //[생략]
}

코드를 빌드한 후 생성되는 EXE 파일을 JustDecompile 프로그램에서 확인해보면

컴파일된 결과물에도 남아 있는 특성정보를 확인할 수 있다.

특성은 프로그램의 흐름에 직접적인 영향을 끼치지 않으면서 개발자로 하여금 정보를 남길 수 있는 기능을 제공한다.

  • 특성 스스로는 프로그램의 동작 방식에 관여할 수 없지만 Reflection 기술과 결합되면 응용 범위가 확장된다.

특성이 적용될 대상을 제한

[Flags()]
class Program
{
// __생략
}

"Flags" 특성은 선언 형식에서 사용할 수 없다.

'enum' 선언에만 사용할 수 잇습니다. 와 같은 오류 메시지가 발생한다.

Flags 특성에는 enum 타입의 동작 방식을 바꾸는 용도로 사용되기 때문에 class 정의에 사용될 이유가 없다.

.NET에서는 특성의 용도를 제한할 목적으로 System.AttributeUsageAttribute라는 또 다른 특성을 제공한다.

AttributeUsage 특성에는 enum 타입의 AttributeTargets 값을 인자로 받는 생성자가 정의되어 있는데, 바로 이 AttrbuteTargets에 정의된 값을 보면 특성을 적용할 수 있는 대상을 확인할 수 있다.

AttributeTargets 값 의미
Assembly 어셈블리가 대상인 특성
Module 모듈이 대상인 특성
Class class가 대상인 특성
Struct struct가 대상인 특성
Enum enum이 대상인 특성
Constructor 타입의 생성자가 대상인 특성
Method 타입의 메서드가 대상인 특성
Property 타입의 속성이 대상인 특성
Field 타입의 필드가 대상인 특성
Event 타입의 이벤트가 대상인 특성
Interface Interface가 대상인 특성
Parameter 메서드의 매개변수가 대상인 특성
Delegate delegate가 대상인 특성
ReturnValue 메서드의 반환값에 지정되는 특성
GenericParameter C# 2.0에 추가된 제네릭 매개변수에 지정되는 특성
All AttributeTargets에 정의된 모든 대상을 포함한다.
  • 메서드 내부 코드를 제외한 C#의 모든 소스코드에 특성을 부여하는 것이 가능하다.
  • 특성을 정의할 때 AttributeUsage를 지정하지 않으면 기본값으로 AttributeTargets.All이 지정된 것과 같다.
  • Author 특성의 적용 대상을 클래스와 메서드로 제한하고 싶다면 다음과 같이 AttributeUsage 특성을 사용할 수 있다.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
class AuthorAttribute : System.Attribute
{
    //__생략
}

특성을 사용하는 대괄호 구문에는 특성이 적용될 대상(target)을 명시하는 것이 가능하다.

[type: Author("Tester")]
class Program
{
    [method: Author("Tester")]
    static void Main(string[] args)
    {
    }
}

AttributeTargets의 값에 따라 대괄호 안의 대상이 달라진다.

AttributeTargets 값 [ target: ... ]
Assembly assembly
Module module
Class type
Struct type
Enum type
Constructor method
Method method
Property property
Field field
Event event
Interface type
Parameter param
Delegate type
ReturnValue return
GenericParameter typevar

AttributeTargets와 대상 지정

 

일반적으로 대상을 생략하면 특성이 명시된 코드의 유형에 따라 대상이 자동으로 선택된다. 하지만 경우에 따라 반드시 명시해야 하는 특성도 있다.

ex) BCL의 MarshalAS라는 특성은 적용 대상이 Field, Parameter, ReturnValue로 되어 있다. 이 중에서 MarshalAs를 ReturnValue 대상으로 적용하는 경우 대상을 생략하고 MarshalAs 특성을 지정했다고 가정한다.

[MarshalAs(UnmanagedType.14]
static int Main(string[] args)
{
    return 0;
}
  • 컴파일할 때 "MarshalAs 특성은 이 선언 형식에서 사용할 수 없고, field, parma return 선언에만 사용할 수 있다" 는 오류가 발생한다. 
  • 이유: 특성이 적용된 코드가 Main 메서드이기 때문에 자동으로 [method: MarshalAs(...)]로 지정되고, method는 MarshalAs의 대상인 Field, Parameter, ReturnValue에 속하지 않기 떄문이다. 따라서 이런 경우에는 명시적으로 return 값에 적용된다는 의미로 대상을 설정해야 한다.
[return: MarshalAs(UnmanagedType.14)]
static int main(string[] args)
{
    return 0;
}

다중 적용과 상속

  • AttrubuteUsage 특성에는 생성자로 입력받는 AttrubteTargets 말고도 두 가지 속성이 더 제공된다.
속성 타입 속성 이름 의미
bool AllowMulitiple 대상에 동일한 특성이 다중으로 정의 가능(기본값: false)
bool Inherited 특성이 지정된 대상을 상속받는 타입도 자동으로 부모의 특성을 물려받는다. 일반적으로 잘 사용되지 않는다 (기본값: true)

AttrubuteTargets의 두 가지 속성

 

AllowMultiple, Inherited 속성은 AttributeUsage 클래스의 생성자에 매개변수로 정의되어 있지 않고 속성으로만 정의되어 있기 때문에 특성을 적용하는 대괄호 구문에서 '이름 = 값'의 쌍으로 전달한다.

이처럼 다중으로 지정하면 "특성이 중복되었습니다."라는 컴파일 오류가 발생한다.

[Author("Anders", Version = 1)]
[Author("Brad", Version = 2)]
class Program
{
}

 

 

이렇게 동일한 특성을 두 개 이상 지정하려면 클래스의 AttributeUsage 설정에 AllowMultiple 속성을 true로 지정해야 한다.

[AttributeUsage(AttributeTargets.All, AllowMultiple = true)]
class AuthorAttribute : System.Attribute
{
//__생략
}

같은 특성이 아니라면 AllowMultiple 여부에 상관없이 대상 코드에 여러 개의 특성을 지정하는 것이 가능하다.

ex) enum 타입에 Flags와 Autor 특성을 지정하면 다음과 같다.

[Flags]
[Author("Anders")]
enum Days{ /* __ 생략 */}

또는 대괄호 내에 연속해서 정의하는 것도 가능하다.

[Flags, Author("Anders")]
enum Days{/* 생략 */}

Reference

시작하세요! C# 12 프로그래밍 기본 문법부터 실전 예제까지