C# 6.0 완벽 가이드/ 진단과 코드 계약

Contents

  • 뭔가 잘못되었을 때문 문제를 진단하는데 도움이 되는 정보를 확인하는 것이 중요하다.
    • 이때 IDE나 디버거가 크게 도움이 되지만 그런 도구는 개발 도중에나 사용할 수 있다. 일단 응용 프로그램을 배포/설치하고 나면 응용 프로그램 자신이 진단(diagnostic) 정보를 수집해서 기록해야 한다.
    • 이를 지원하기 위해 .NET Framework는 진단 정보 기록, 응용 프로그램 행동방식 감시, 실행시점 오류 검출을 위한 일단의 수단을 제공하며, 가능한 경우 응응 프로그램과 디버깅 도구를 연동하는 수단들도 제공한다.
  • .NET Framework는 또한 코드 계약(code contracts)을 강조하는 수단도 제공한다. .NET Framework 4.0에서 도입된 코드 계약 기능을 이용하면 메서드가 일단의 상호 의무조항들을 점검해서 만일 의무조항 위반 사항이 있으면 실행을 일찍 실패하게 만들 수 있다.
  • 이 장에 나오는 형식들은 주로 System.Diagnostics 이름공간과 System.Diagnostics.Contracts 이름공간에 정의되어 있다.

조건부 컴파일

  • 전처리기 지시자(preprocessor directive)들을 이용해서 C# 코드 안의 임의의 구역을 조건부로 컴파일할 수 있다. 전처리기 지시자는 # 기호로 시작하는 특별한 명령으로, 컴파일러에게 코드의 컴파일 방식에 관한 지시를 전달하는 역할을 한다.(그리고 C#의 코드 구성요소와는 달리 하나의 전처리기 지시자 문장은 반드시 코드 한 줄을 차지한다)
    • 논리적으로 전처리기 지시자로 시작하는 문장(줄여서 전처리기 지시문)은 실제 컴파일 작업이 일어나기 전에 실행된다. (실제로는 C# 컴파일러가 어휘 분석 단계에서 전처리기 지시자들을 처리한다).
    • 조건부 컴파일을 위한 전처리기 지시자들은 #if, #else, #endif, #elif이다.
  • #if 지시자는 컴파일러에게 지정된 전처리기 기호가 정의되어 있는 경우에만 그 다음 코드 구역을 컴파일하라고 지시한다.
    • 전처리기 기호는 코드 안에서 #define 지시자로 정의할 수도 있고 컴파일러 옵션으로 정의할 수도 있다. #define으로 정의된 기호는 해당 파일 안에서만 효력을 가지지만, 컴파일러 옵션으로 정의된 기호는 어셈블리 전체에 적용된다.
#define TESTMDOE  // #define 지시문은 파일의 처음 부분에 있어야 한다. 기호 이름은 대문자로만 구성하는 것이 관례이다.

using System;

class Program
{
  static void Main()
  {
    #if TESTMODE
      Console.WriteLine("시험 모드");
    #endif
  }
}

  • 이 코드의 첫 행을 삭제하면 컴파일러는 Console.WriteLine 호출문을 컴파일에서 제외한다 (마치 주석인 것처럼). 따라서 실행 파일에 Console.WriteLine 호출 코드가 포함되지 않는다.
  • #else 지시문은 C#의 else 절에 해당한다. #elif는 #else 다음에 #if를 쓴 것과 같ㅌ다.
    • ||, &&, ! 연산자는 각각 논리합(OR), 논리곱(AND), 부정(NOT) 연산을 수행한다.
#if TESTMODE && !PLAYMODE // TESTMODE가 정의되어 있고 PLAYMODE가 정의되어 있지 않으면
  ...
  • 이런 전처리기 지시문의 표현식이 통상적인 C# 표현식과는 무관하다는 점을 명심해야 한다. 특히 전처리기 기호들은 C#의 변수와 그 어떤 관계도 없다.
  • 어셈블리 전체에 적용할 기호를 정의할 때는 컴파일러 옵션 /define을 이용해야 한다.
csc Program.cs /define:TESTMODE,PLAYMODE
  • Visual Studio에서는 프로젝트 속성 대화상자에서 조건부 컴파일 기호를 정의할 수 있다.
  • 어셈블리 수준에서 정의한 기호를 특정 파일에서만 정의되지 않은 상태로 만들고 싶다면 #undef 지시자를 사용하면 된다.

조건부 컴파일 대 정적 변수 플래그

  • 앞의 예제를 다음과 같이 정적 필드를 이용해서 구현할 수도 있다.
static internal bool TestMode = true;

static void Main()
{
  if (TestMode)  Console.WriteLine("시험 모드");
}
  • 이러면 실행시점에서 시험 모드를 켜고 끌 수 있다는 장점이 생긴다. 그렇다고 조건부 컴파일이 쓸모가 없는 것은 아니다. 조건부 컴파일 기능은 변수 플래그로는 불가능한 일을 할 수 있는 능력을 갖고 있다. 예컨대 다음과 같다.
    • 특성을 조건부로 포함시킨다.
    • 변수의 선언 형식을 바꾼다.
    • using 문의 이름공간이나 형식을 조건부로 변경한다. 예컨대 다음과 같다.
using TestType = 
  #if V2
    MyCompany.Widgets.GadgetV2;
  #else
    MyCompany.Widgets.Gadget;
  #endif
  • 심지어 코드의 기존 버전과 새 버전을 조건에 따라 선택한다거나 코드를 여러 .NET Framework 버전에 대해 컴파일 할 수 있는 형태로 작성한다거나 가능한 환경에서는 최신의 .NET Framework 기능을 사용하게 하는 등의 본격적인 리팩터링을 조건부 컴파일 지시문으로 수행하는 것이 가능하다.
  • 조건부 컴파일의 또 다른 장점은 실제 설치 환경에는 없는 어셈블리의 형식들을 디버깅 코드에서 참조할 수 있다는 것이다.

Conditional 특성

  • Conditional 특성은 주어진 기호가 정의되어 있지 않으면 특정 클래스나 메서드의 모든 호출을 무시하라고 컴파일러에게 지시한다.
  • 이 기능이 어떤 쓸모가 있는지 보여주는 예로 다음과 같이 상태 정보를 기록하는 메서드가 있다고 하자.
static void LogStatus(string msg)
{
  string logFilePath = ...
  System.IO.File.AppendAllText(logFilePath, msg + "\r\n");
}
  • 그리고 이 메서드를 LOGGINGMODE 기호가 정의되어 있을 때만 실행하고 싶다고 하자. 첫 번째 해법은 LogStatus 호출문을 #if 지시문으로 감싸는 것이다.
#if LOGGINGMODE
LogStatus("Message Headers: " + GetMsgHeaders());
#endif
  • 이렇게 하면 원하는 결과를 얻게 되지만, 모든 LogStatus 호출문에 #if를 붙이는 것은 지루한 일이다. 또 다른 방법은 LogStatus 메서드 안에 #if 지시문을 넣는 것이다. 그러나 LogStatus를 다음과 같은 형태로 호출한다면 문제의 여지가 있다.
LogStatus("Message Headers: " + GetComplexMessageHeaders());
  • 로그가 기록되지는 않는다고 해도 GetComplexMessageHeaders 메서드는 항상 호출되며, 그 메서드가 시간이 오래 걸리는 일을 수행한다면 응용 프로그램의 성능이 떨어질 수 있다.
  • 다행히 첫 해법의 가능성과 둘째 해법의 편리함을 합치는 것이 가능하다. Conditional 특성을 LogStatus 메서드에 붙이면 된다.
[Conditional("LOGGINGMODE")]
static void LogStatus(string msg)
{
  ...
}
  • 이렇게 하면 컴파일러는 LogStatus 호출문들이 마치 #if LOGGINGMODE 지시문으로 둘러싸였다고 간주한다. 만일 LOGGINGMODE 기호가 정의되어 있지 않으면 모든 LogStatus 호출이 컴파일에서 완전히 제외된다.
    • 물론 호출시 인수로 지정된 표현식들의 평가도 전혀 일어나지 않는다. (따라서 부수 효과가 있는 표현식을 인수로 지정한다고 해도 부수 효과는 발생하지 않는다).
    • 이런 기능은 LogStatus 정의와 LogStatus 호출문이 서로 다른 어셈블리에 있어도 작동한다.
  • [Conditional]의 또 다른 장점은 호출되는 메서드를 컴파일할 떄가 아니라 호출하는 코드를 컴파일 할 때 조건 점검이 일어난다는 것이다. 이 덕분에 LogStatus 같은 메서드를 담은 라이브러리를 한 가지 버전만 작성해서 빌드할 수 있다.
  • 실행시점에서는 Conditional 특성이 무시된다. Conditional은 전적으로 컴파일러에 대한 지시일 뿐이다.

Conditional 특성의 대안

  • Conditional 특성은 실행시점에서 어떤 기능성을 동적으로 켜거나 끄는 용도로는 무용지물이다. 그런 경우에는 변수 플래그 기반 접근방식을 사용해야 한다.
    • 그런 접근방식을 제대로 사용하려면 앞의 조건부 로깅 메서드에서 보았던 인수 표현식 평가 문제를 우아하게 해결할 수 있어야 한다. 다음 코드는 그런 문제를 해결하는 한 예를 보여준다.
using System;
using System.Linq;

class Program
{
  public static bool EnableLogging;

  static void LogStatus (Func<string> message)
  {
    string logFilePath = ...
    if (EnableLogging)
      System.IO.File.AppendAllText(logFilePath, message() + "\r\n");
  }
}
  • 람다식을 사용한 덕분에 이 메서드를 호출하는 구문이 그리 복잡해지지 않았다.
LogStatus(() => "Message Headers: " + GetComplexMessageHeaders());
  • EnableLogging이 false이면 GetComplexMessageHeaders는 전혀 평가되지 않는다.

Debug 클래스와 Trace 클래스

  • Debug과 Trace는 기본적인 로깅 기능과 단언(assertion) 기능을 제공하는 정적 클래스들이다. 이 두 클래스는 매우 비슷하다. 주된 차니는 용도이다. 기본적으로 Debug 클래스는 디버그 빌드에 사용하도록 만들어진 것이고, Trace 클래스는 디버그 빌드와 릴리스 빌드 모두에 사용하도록 만들어진 것이다. 이를 위해,
    • Debug 클래스의 모든 메서드에는 [Conditional[“DEBUG”)] 특성이 붙어 있고,
    • Trace 클래스의 모든 메서드에는 [Conditional[“TRACE”)] 특성이 붙어 있다.
  • 따라서 DEBUG나 TRACE 기호가 정의되어 있지 않으면 컴파일러는 Debug나 Trace의 모든 메서드 호출을 무시한다.
    • 기본적으로 Visual Studio는 프로젝트의 디버그 구성에서 DEBUG와 TRACE를 모두 정의하고, 릴리스 구성에서는 TRACE 기호만 정의한다.
  • Debug 클래스와 Trace 클래스 둘 다 Write, WriteLine, WriteIf 메서드는 제공한다. 기본적으로 이들은 디버거의 출력 창에 메시지를 보낸다.
Debug.Write("Data");
Debug.WriteLine(23 * 34);
int x = 5, y = 3;
Debug.WriteIf(x > y, "x가 y보다 큼");
  • Trace 클래스는 TraceInformation과 TraceWarning, TraceError라는 메서드도 제공한다. 이 메서드들과 Write류 메서드들은 활성 TraceListener에 따라 다른 식으로 행동한다.

Fail 메서드와 Assert 메서드

  • Debug 클래스와 Trace 클래스 둘 다 Fail과 Assert라는 메서드들을 제공한다.
  • Fail은 메시지를 Debug나 Trace 클래스의 Listeners 컬렉션에 있는 TraceListener 인스턴스들에 보낸다.
    • TraceListener는 기본적으로는 그 메시지를 디버그 출력 창에 기록하고 메시지 대화상자도 띄운다.
Debug.Fail("data.txt 파일이 없습니다!");
  • 대화상자는 사용자가 해당 문제점을 무시하거나, 실행을 취소하거나 다시 시도할 수 있는 옵션들을 제공한다. 실행을 다시 시도하는 경우 디버거가 해당 프로세스에 붙는다. 따라서 이 옵션은 문제점을 즉시 진단하는데 유용하다.
  • Assert는 첫 인수(bool)가 false면 둘째 인수로 Fail을 호출한다. 어떤 조건을 지정해서 Assert를 호출한다는 것은 그 조건이 반드시 참이어야 함을 단언하는(assert) 역할을 한다. 만일 그 단언이 참이 아니라면 코드에 버그가 있는 것이다.
    • 둘째 인수는 생략할 수 있다.
Debug.Assert(File.Exists("data.txt", "data.txt 파일이 없습니다!");
var result = ...
Debug.Assert(result != null);
  • Write, Fail, Assert 메서드들은 메시지와 함께 메시지의 범주를 뜻하는 또 다른 string 인수를 받는 중복적재 버전들도 제공한다. 범주 문자열은 디버그 출력을 분류하고 처리할 때 유용하다.
  • Assert를 이용한 단언 대신, 해당 조건이 참이 아닐 때 예외를 던질 수도 잇다. 다음 예처럼 메서드 인수가 유효하지 않을 때 흔히 그런 방법을 사용한다.
public void ShowMessage(string message)
{
  if (message == null) throw new ArgumentNullException("message");
  ...
}
  • 그러나 이런 단언은 무조건 컴파일된다는 단점이 있다. 또한 Assert 보다 덜 유연하다. Assert에서는 TraceListener를 이용해서 단언 실패 처리 방식ㅇ르 제어할 수 있지만, 이 방법에서는 그럴 수 없다.
    • 게다가 엄밀히 말해서 이런 기법은 사실 단언이 아니다. 단언은 만일 참이 아니라면 현재 메서드의 코드에 뭔가 버그가 있다는 뜻이 되는 어떤 조건을 명시하는 것이다. 그러나 인수 점검 결과에 따라 예외를 던지는 것은 호출자의 코드에 어떤 버그가 있음을 뜻한다.

TraceListener 클래스

  • Debug 클래스와 Trace 클래스에는 Listeners라는 속성이 있다. 이 속성은 TraceListener 인스턴스들의 정적 컬렉션이다. 각 TraceListener 인스턴스는 Write나 Fail, Trace 메서드가 보낸 메시지를 처리하는 임무를 수행한다.
  • 두 클래스 모두 기본적으로 Listeners 컬렉션에는 기본 청취자(DefaultTraceListener)가 하나 들어 있다. 이 기본 청취자의 핵심 기능은 다음 두 가지이다.
    • 현재 프로세스가 Visual Studio 같은 디버거에 연결되어 있으면 메시지를 디버그 출력 창에 기록하고, 그렇지 않으면 메시지를 무시한다.
    • Fail 메서드 호출시 (또는 단언 실패시) 사용자에게 실행 계속, 취소, 재시도를 묻는 대화상자를 띄운다(디버거 부착 여부와는 무관하게 항상)
  • 만일 메시지를 이와는 다른 식으로 처리하고 싶다면, 기본 청취자를 제거하고 다른 추적 청취자를 추가하면 된다. 이를 위해 TraceListener를 상속해서 독자적인 추적 청취자 클래스를 작성할 수도 있고, 다음과 같이 미리 정의된 형식 중 하나를 사용할 수도 있다.
    • TextWriterTraceListener는 메시지를 Stream 또는 TextWriter에 기록하거나 파일에 추가한다.
    • EventLogTraceListener는 메시지를 Windows 이벤트 로그에 기록한다.
    • EventProviderTraceListener는 메시지를 Windows Vistar 이후 버전의 Windows용 이벤트 추적(Event Tracing for Windows, ETW) 하위 시스템에 기록한다.
    • WebPageTraceListener는 메시지를 ASP.NET 웹 페이지에 기록한다.
  • 그 밖에 TextWriterTraceListener를 상속한 ConsoleTraceListener, DelimitedListTraceListener, XmlWriterTraceListener, EventSchemaTraceListener 도 잇다.
    • 이 청취자 중 Fail 호출시 대화상자를 띄우는 것은 하나도 없다. 오직 DefaultTraceListener만 그런 식으로 작동한다.
  • 다음 예제는 Trace의 기본 청취자를 제거하고 다른 청취자 세 개를 추가한다. 하나는 메시지를 파일에 추가하고 또 하나는 콘솔에, 다른 하나는 Windows 이벤트 로그에 기록한다.
// 기본 청취자를 제거한다.
Trace.Listeners.Clear();

// 메시지를 trace.txt 파일에 기록하는 청취자를 추가한다.
Trace.Listeners.Add(new TextWriterTraceListener("trace.txt"));

// 콘솔의 출력 스트림을 얻고 그것을 하나의 청취자로서 추가한다.
System.IO.TextWriter tw = Console.Out;
Trace.Listeners.Add(new TextWriterTraceListener(tw));

// Windows 이벤트 로그용 이벤트 출처를 설정하고 청취자를 생성, 추가한다.
// CreateEventSource를 호출하려면 관리자 권한이 필요하다.
// 대체로 이 부분은 응용 프로그램 설치 시 처리한다.
if (!EventLog.SourceExists("DemoApp"))
  EventLog.CreateEventSource("DemoApp", "Application");

Trace.Listeners.Add(new EventLogTraceListener("DemoApp"));
  • 응용 프로그램 구성 파일을 통해서 청취자들을 추가하는 것도 가능하다. 그러면 응용 프로그램을 빌드한 후 검사자들이 추적 기능을 설정할 수 있으므로 편리하다.
  • Windows 이벤트 로그의 경우 Write나 Fail, Assert 메서드가 기록한 메시지는 항상 Windows 이벤트 뷰어에서 ‘정보’ 메시지로 표시된다. 그러나 TraceWarning 메서드나 TraceError 메서드로 기록한 메시지는 경고 또는 오류로 표시된다.
  • TraceListener에는 TraceFilter 형식의 Filter라는 속성이 있다. 이를 통해서 청취자에 기록할 메시지들을 걸러낼 수 있다. 이 속성에는 미리 정의된 파생 클래스(EventTypeFilter나 SourceFilter)의 인스턴스를 사용할 수도 있고 또는 TraceFilter 클래스를 상속한 클래스를 직접 작성해서 ShouldTrace 메서드를 적절히 재정의해도 된다. 이를 이용해서 이를테면 메시지들을 범주별로 걸러내는 등의 처리가 가능하다.
  • TraceListener에는 IndentLevel 속성과 IndentSize 속성도 있다. 이들은 들여쓰기와 추가 자료 기록을 위한 TraceOutputOptions 속성을 제어한다.
TextWriterTraceListener tl = new TextWriterTraceListener(Console.Out);
tl.TraceOutputOptions = TraceOptions.DateTime | TraceOptions.Callstack;
  • TraceOuputOptions는 Trace 메서드 호출시 작용한다.

청취자 배출 및 종료

  • TextWriterTraceListener 같은 청취자들은 메시지를 스트림에 기록하는데, 기본적으로 스트림 출력에는 버퍼링(캐싱)이 적요오딘다. 이는 특히 다음 두 가지를 의미한다.
    • 메시지가 출력 스트림이나 파일에 즉시 나타나지 않을 수 있다.
    • 응용 프로그램이 끝나기 전에 청취자를 반드시 닫거나 적어도 청취자의 내용을 배출해야(flush) 한다. 그렇게 하지 않으면 버퍼에 남아 있던 내용이 사라질 수 있다(기본적으로 파일 기록시 버퍼는 최대 4k이다.)
  • Trace 클래스와 Debug 클래스는 정적 Close 메서드와 Flush 메서드를 제공한다. 이들은 모든 청취자에 대해 Close 또는 Flush를 호출한다. (그러면 바탕 기록자나 스트림에 대해 Close나 Flush가 호출된다) Close는 암묵적으로 Flush를 호출하고, 파일 핸들들을 닫고, 해당 파일이나 스트림에 더 이상 자료를 기록할 수 없도록 설정한다.
  • 일반적인 원칙으로 Close는 응용 프로그램 종료 전에 한 번 호출하고, Flush는 현재 메시지 자료가 확실하게 기록되게 하고 싶을 때마다 호출하면 된다. 물론 이는 스트림 또는 파일 기반 청취자를 사용할 때의 이야기이다.
  • Trace와 Debug는 또한 AutoFlush라는 속성도 제공한다. 이 속성을 true로 설정하면 메시지를 기록할 때마다 자동으로 Flush가 호출된다.
    • 파일이나 스트림 기반 청취자를 사용할 때는 항상 Debug나 Trace의 AutoFlush를 true로 설정하는 것이 좋다. 그렇게 하지 않으면 예외가 제대로 처리되지 않거나 어떤 치명적인 오류가 발생했을 때 적어도 4KB의 진단 정보가 유실될 수 있다.

코드 진단 개요

  • 앞에서 단언(assertion)이라는 개념을 언급했다. 단언은 프로그램의 특정 지점에서 반드시 만족해야 하는 조건이다. 만일 그 조건이 참이 아니어서 단언이 실패한다면, 코드에 뭔가 버그가 있는 것이다. 따라서 단언 실패 시에는 일반적으로 디버거를 띄우거나(디버그 빌드) 예외를 던진다.(릴리스 빌드)
  • 단언은 ‘만일 뭔가 잘못되었다면, 문제의 근원에서 멀어지기 전에 일찍 실패하는 것이 최선’이라는 일반적인 원리를 따른다. 대체로 유효하지 않은 자료를 가지고 실행을 계속하는 것보다는 일찍 실패하는 것이 낫다.
  • 역사적으로 C# 프로그램에서 단언을 강제하는 방법은 다음 두 가지였다.
    • Debug나 Trace의 Assert 메서드를 호출한다.
    • 예외를 던진다(ArgumentNullException 같은)
  • .NET Framework 4.0에서 코드 계약(code contracts)이라는 새로운 기능이 등장했다. 코드 계약은 위의 두 접근방식을 하나의 통합된 시스템으로 대체한다. 코드 계약 시스템을 이용하면 단순한 단언은 물론이고 좀 더 강력한 계약 기반 단언도 적용할 수 있다.
  • 코드 계약은 Eiffel 프로그래밍 언어의 계약에 의한 설계(Design by Contract) 원리에서 파생된 것이다.
    • 계약에 의한 설계 원리를 따르는 프로그램에서 함수들은 상호 의무 및 혜택 체계를 통해서 상호작용한다.
    • 본질적으로 하나의 함수는 클라이언트가 반드시 만족해야 하는 전제조건(precondition)들과 함수 반환 시 클라이언트 쪽에서 반드시 참이 되는 사후조건(postcondition)들을 정의한다.

코드 계약을 사용하는 이유

  • 이해를 돕기 위한 예로 목록에 없는 항목만 목록에 추가하는 메서드를 생각해 보자. 이 메서드에는 전제조건이 2개, 사후조건이 1개 있다.
public static bool AddIfNotPresent<T> (IList<T> list, T item)
{
  Contract.Requires(list != null);  // 전제조건
  Contract.Requires(!list.IsReadOnly);  // 전제조건
  Contract.Ensures(list.Contains(item));  // 사후조건

  if (list.Contains(item)) return false;
  list.Add(item);
  return true;
}
  • 전제조건들은 Contract.Requires 메서드로 정의한다. 이들은 메서드 시작 시점에서 점검된다. 사후조건은 Contract.Ensures 메서드로 정의한다. 이들은 해당 호출 지점에서 점검되는 것이 아니라 실행이 메서드를 벗어날 때 점검된다.
  • 전제조건과 사후조건은 단언처럼 작동한다. 지금 예에서 이들은 다음과 같은 오류들을 잡아낸다.
    • 호출자가 널 또는 읽기 전용 목록으로 이 메서드를 호출했음.
    • 메서드에 버그가 있어서 목록에 항목을 추가하지 못했음.
  • 전제조건과 사후조건은 메서드의 시작 지점에 두는 것이 바람직하다. 이러한 제약은 좋은 설계에 도움이 된다. 일단 계약 조건들을 먼저 지정한 후 메서드 본문을 작성하면 코딩 과정에서 실수를 저질렀을 때 계약 위반이 발생해서 버그를 빨리 잡아낼 수 있다.
  • 더 나아가서 이 조건들은 해당 메서드에 대한 발견 가능한 계약을 형성한다. AddIfNotPresent는 소비자에게 다음을 공시한다.
    • 이 메서드를 호출하려면 반드시 널이 아닌 쓰기 가능 목록을 넘겨 주어야 합니다.
    • 메서드가 반환되면 그 목록에는 당신이 지정한 항목이 들어 있을 것입니다.
  • 이 사실들이 어셈블리의 XML 문서화 파일에 기록되게 할 수도 있다. (Visual Studio에서는 프로젝트 속성 창의 Code Contracts 탭에서 ‘Contract Reference Assembly’를 Build로 설정하고 “Emit contracts into XML doc file”을 체크하면 된다) 그러면 SandCastle 같은 도구로 프로그램을 문서화할 때 계약의 세부사항을 문서에 포함할 수 있다.
  • 또한 코드 계약을 사용하면 정적 계약 검증 도구로 프로그램의 정확성을 검증할 수 있게 된다.
    • 예컨대 값이 널일 수도 있는 변수로 AddIfNotPresent를 호출하는 프로그램에 대해 정적 검증 도구는 적절한 경고를 출력한다. 즉, 프로그램을 실행하기도 전에 잠재적인 문제를 발견할 수 있는 것이다.
  • 코드 계약의 또 다른 장점은 사용하기가 쉽다는 것이다.
    • 지금 예제의 경우 사후 조건을 메서드 시작 시점에서 지정하는 것이 메서드의 두 반환 지점에서 따로 점검하는 것보다 코딩하기 쉽다. 계약은 또한 객체 불변식(object invariant)을 지원한다. 이 역시 코드의 중복을 줄여주고 단언을 좀 더 견고하게 강제하는 수단이다.
  • 계약 조건들을 인터페이스의 멤버나 추상 메서드에 지정할 수도 있다. 통상적인 유효성 검증 접근방식들에서는 이런 일이 불가능하다. 또한 가상 메서드에 지정한 계약 조건들을 그 파생 클래스에서 실수로 수정할 수 없다는 점도 코드 계약의 장점이다.
  • 코드 계약에는 계약 위반 처리 방식을 Debug.Assert나 예외 발생 기법보다 더 쉽게, 그리고 더 다양하게 커스텀화 할 수 있다는 장점도 있다. 그리고 계약 위반이 항상 기록되게 하는 것도 가능하다. 심지어 계약 위반 예외를 호출 스택의 상위에 있는 예외 처리부가 삼킨 경우에도 위반 사항이 기록되게 할 수 있다.
  • 코드 계약을 사용하는 것의 한 단점은 .NET Framework의 코드 계약 구현이 컴파일 이후에 어셈블리를 변조하는 이진 실행 파일 변환기(binary rewriter) 줄여서 이진 변환기에 의존한다는 점이다. 이 때문에 빌드 공정이 느려질 뿐만 아니라 C# 컴파일러 호출에 의존하는 서비스들(명시적으로 호출하든, CSharpCodeProvider 클래스를 거치든)의 작동을 복잡하게 만든다.
  • 코드 계약을 강제하면 실행 시점의 성능이 하락할 수 있지만, 이 문제는 릴리스 빌드에서 계약 점검의 규모를 줄여서 수월하게 완화할 수 있다.
  • 코드 계약의 또 다른 한계는 보안에 민감한 점검을 강제하는데 사용할 수 없다는 점이다. 그런 점검을 실행시점에서 우회할 수도 있기 때문이다.(ContractFailed 이벤틀르 처리해서)

계약의 기초

  • 코드 계약은 전제조건, 사후조건, 단언, 객체 불변식으로 구성된다. 이들은 모두 발견 가능한(discoverable) 단언들이다. 차이점은 검증 시점이다.
    • 전제조건은 함수 시작 시 검증된다.
    • 사후조건은 함수 반환 직전에 검증된다.
    • 단언은 코드 안의 해당 단언문 위치에서 검증된다.
    • 객체 불변식은 클래스의 모든 공용 함수의 실행 이후에 검증된다.
  • 코드 계약은 Contract 클래스의 메서드(정적 메서드)들을 호출하는 문장들로만 구성된다. 이 덕분에 코드 계약은 언어에 독립적이다.
  • 코드 계약은 메서드 뿐만 아니라 생성자, 속성, 인덱서, 연산자 같은 다른 함수들에도 사용할 수 있다.

컴파일

  • Contract 클래스의 거의 모든 메서드에는 [Conditional(“CONTRACTS_FULL”)] 특성이 부여되어 있다.
    • 따라서 CONTRACT_FULL 이라는 기호를 정의하지 않으면 계약 코드가 컴파일에서 제외된다.
    • Visual Studio의 경우 프로젝트 속성창의 Code Contract 탭에서 계약 점검을 활성화하면 이 기호가 자동으로 정의된다.
  • CONTRACT_FULL 기호를 해제하면 모든 계약 점검이 비활성화될 것 같지만, 사실은 그렇지 않다. Requires<TException> 조건들은 여전히 적용된다.
    • Requires<TException>을 사용하는 코드의 계약들을 완전히 비활성화하는 유일한 방법은 CONTRACT_FULL은 정의된 채로 두고 Code Contracts 탭에서 ‘Contract Reference Assembly’를 ‘(none)’으로 설정해서 이진 변환기가 계약 코드를 모두 제거하게 하는 것이다.

이진 실행 파일 변환기

  • 계약이 담긴 코드를 컴파일한 후에는 이진 변환기 ccrewrite.exe를 실행해야 한다. (Visual Studio에서는 코드 계약 점검이 활성화되어 있으면 이 과정이 자동으로 실행된다)
    • 이진 실행 파일 변환기는 사후 조건들(그리고 객체 불변식들)을 적절한 장소로 이동하고, 재정의된 메서드들의 조건들과 객체 불변식들을 점검하는 코드를 추가하고, Contract 메서드 호출문들을 실행시점 계약 클래스에 있는 메서드들을 호출하는 코드로 대체한다.
    • 다음은 앞의 예제를 이 도구가 변환한 결과를 묘사한(단순화되었음) 것이다.
static bool AddIfNotPresent<T> (IList<T> list, T item)
{
  __ContractsRuntime.Requires(list != null);
  __ContractsRuntime.Requires(!list.IsReadOnly);
  bool result;
  if (list.Contains(item))
    result = false;
  else
  {
    list.Add(item);
    result = true;
  }
  __ContractsRuntime.Ensures(list.Contains(item));  // 사후조건
  return result;
}
  • 이진 변환기를 제대로 실행하지 못하면 Contract가 __ContractsRuntime으로 대체되지 않으며, 실행시 Contract의 메서드들은 예외를 던진다.
  • __ContractsRuntime 형식은 기본적인 실행시점 계약 클래스이다. 필요하다면 실행시점 계약 클래스를 직접 작성해서 적용할 수도 있다. 컴파일러 옵션 /rw나 Visual Studio 프로젝트 속성 창의 Code Contracts 탭의 설정을 통해서 그 클래스를 지정하면 된다.
  • __ContractsRuntime은 이진 변환기(.NET Framework의 표준적인 일부가 아니다)와 함께 제공되는 것이므로, 이진 변환기는 실제로 __ContractsRuntime 클래스를 독자의 어셈블리에 주입한다. 코드 계약이 활성화된 어셈블리를 분해(디스어셈블)해 보면 이 클래스의 코드를 볼 수 있다.

계약 위반시의 행동 방식 옵션

  • 이진 변환기는 함수나 호출자가 계약 조건을 만족하지 못했을 때 대화상자를 띄울 것인지 아니면 ContractException 예외를 던질 것인지를 결정하는 옵션도 제공한다.
    • 대체로 디버그 빌드에서는 전자를, 릴리스 빌드에서는 후자를 사용한다.
    • 후자를 활성화하려면 이진 벼놘기를 실행할 때 /thrownfailure를 지정해야 한다. Visual Studio에서는 프로젝트 속성 창의 Code Contracts 탭에서 “Assert on Comtract Failure”를 체크하면 된다.

순수성

  • 계약 메서드들(Requires, Assumes, Assert)을 호출할 때 인수로 전달하는 표현식에서 호출하는 모든 함수는 반드시 순수해야 한다.
    • 여기서 함수가 순수하다는 것은 부수 효과가 없다는 뜻이다. (아주 간단히 말하면 순수 함수는 그 어떤 필드의 값도 변경하지 않아야 한다.)
    • 그리고 인수 표현식에서 호출하는 모든 함수가 순수하다는 점을 이진 변환기에 알려 주어야 하는데, 그러려면 그런 함수에 [Pure] 특성을 부여해야 한다.
[Pure]
public static bool IsValidUri (string uri) { ... }
  • 이렇게 하면 다음과 같은 조건이 유효하게 된다.
Contract.Requires(IsValidUri(uri));
  • 계약 검증 도구들은 모든 속성 조회(get) 접근자가 순수하다고 가정한다. 또한 string, Contract, Type, System.IO.Path를 비롯한 몇몇 .NET Framework 형식들의 모든 C# 연산자(+, *, % 등)와 LINQ의 질의 연산자들도 순수하다고 가정한다.
    • [Pure] 특성이 부여된 대리자를 통해서 호출되는 메서드들 역시 순수하다고 가정한다. (예컨대 Comparison<T>와 Predicate<T> 대리자들이 이 특성이 부여되어 있다)

전제조건

  • 코드 계약의 전제조건을 정의하는데 사용하는 메서드는 Contract.Requires, Contract.Requires<TException>, Contract.EndContractBlock이다.

Contract.Requires 메서드

  • 함수 시작에서 Contract.Requires를 호출하면 전제조건이 강제된다.
static string ToProperCase(string s)
{
  Contract.Requires(!string.IsNullOrEmpty(s));
}
  • 전제조건은 단언과 비슷하지만, 함수에 관한 ‘발견 가능한 사실’을 생성한다는 장점이 있다. 예컨대 문서화 도구나 정적 점검 도구는 컴파일된 코드에서 그러한 사실을 추출해서 활용한다. (예컨대 프로그램에서 ToProperCase를 널이나 빈 문자열로 호출하려는 코드를 찾아내서 알려준다)
  • 전제조건의 더욱 중요한 장점은 부모 클래스의 가상 메서드에 전제조건들이 있을 때 파생 클래스가 그 메서드를 재정의해도 부모의 전제조건들이 여전히 강제 된다는 점이다. 또한 인터페이스 멤버들에 정의된 전제조건들은 암묵적으로 구체 클래스의 구현들에 주입된다.
  • 코드 계약은 함수와 호출자 사이의 계약이므로 함수의 전제조건들이 호출자가 접근할 수 없는 멤버들에 접근하는 것은 바람직하지 않다. 함수 자체보다 접근이 더 제한된 멤버들을 읽거나 호출하는 전제조건은 호출 계약을 강제하는 것이 아니라 객체의 내부 상태를 검증하는 것일 가능성이 크다. 그런 경우에는 전제조건이 아니라 단언을 사용해야 한다.
  • 하나의 함수에 여러 개의 전제조건이 있을 수 있다. 즉, 다양한 전제조건을 정의하기 위해 함수의 시작에서 Contract.Requires를 얼마든지 여러 번 호출할 수 있다.

전제조건에서 무엇을 점검할 것인가?

  • Code Contracts 개발팀의 지침에 따르면, 바람직한 전제조건은 다음과 같은 요건을 갖추어야 한다.
    • 클라이언트가 손쉽게 검증할 수 있도록 긍정적인 조건이어야 한다.
    • 메서드 자체와 접근 제한이 같거나 덜한 자료와 함수에만 의존해야 한다.
    • 위반이 곧 버그를 뜻하는 것이어야 한다.
  • 마지막 요건이 뜻하는 바 하나는, 클라이언트가 계약 위반 예외를 구체적으로 ‘잡으려’ 해서는 안된다는 것이다. (이 원칙의 강제를 돕기 위해, 계약 위반에 해당하는 ContractException 형식은 호출자에게 노출되지 않는 내부 형식으로 되어 있다.)
    • 계약조건의 실패는 반드시 일반적인 예외 안전망(여기에는 응용 프로그램의 종료도 포함된다)을 통해서 처리해야 할 버그를 뜻한다. 다른말로 하면 전제조건 위반을 실행 흐름의 제어나 기타 조건부 작업에 사용하는 것은 코드 계약을 잘못 사용하는 것이다.
    • 계약을 위반했는데 프로그램이 멀쩡하게 잘 실행된다면 그것은 계약이 아니다.
  • 다음은 이점으로부터 끌어낸 전제조건과 단언(예외 발생)의 선택에 관한 조언이다.
    • 만일 조건 실패가 항상 클라이언트의 버그를 뜻한다면 전제조건을 선호하라
    • 만일 조건 실패가 비정상적 조건을 뜻하며 그것이 클라이언트의 버그일 수도 있고 아닐 수도 있다면 예외를 던지는 것이 낫다.
  • 이해를 돕는 예로 Int32.Parse 함수를 우리가 직접 작성한다고 하자. 입력 문자열이 널이라는 것은 항상 클라이언트 쪽의 버그라고 가정하는 것이 합당하므로, 널 점검을 하나의 전제조건으로 두는 것이 바람직하다.
public static int Parse(string s)
{
  Contract.Requires(s != null);
  ...
}
  • 다음으로 입력 문자열이 숫자들과 +, – 같은 기호들로만 이러우져 있는지(그리고 그 기호들이 적절한 자리에 있는지) 점검해야 한다.
    • 그런데 그런 조건을 호출자가 항상 지켜야 한다고 요구하는 것은 호출자에게 너무 큰 부담을 지우는 것이므로, 이를 전제조건으로 두는 것은 바람직하지 않다.
    • 대신 메서드 안에서 직접 점검해서 위반 시 FormatException 예외(호출자가 잡을 수 있는)를 던지도록 한다.
  • 다음으로 멤버 접근성 문제에 관한 예를 보자.
    • 다음은 IDisposable 인터페이스를 구현하는 형식에서 흔히 볼 수 있는 형태의 코드이다.
public void Foo()
{
  if (_isDisposed)  // _isDisposed는 전용 필드라고 가정
    throw new ObjectIDsposedException("...");
  ...
}
  • 이 점검을 전제조건으로 두려면, _isDisposed를 호출자도 접근할 수 있게 만들어야 한다. (이를테면 공용으로 읽을 수 있는 속성이 되도록 클래스를 리팩토링 하는 등)
  • 마지막으로 File.ReadAllText 메서드를 생각해 보자. 다음은 전제조건의 부적절한 용법일 수 있다.
public static sring ReadAllText(string path)
{
  Contract.Requires(File.Exists(path));
  ...
}
  • 이유는 호출자가 이 메서드를 호출하기 전에 파일의 존재 여부를 확실하게 파악하기 힘들기 때문이다. (파일 존재 점검과 메서드 호출 사이에 파일이 삭제될 수도 있다) 따라서 이 조건은 구식으로, 즉 잡을 수 있는 FileNotFoundException을 던지는 방식으로 강제하는 것이 바람직하다.

Contract.Requires<TException> 메서드

  • 코드 계약 기능은 .NET Framework 버전 1.0부터 확립된, .NET 생태계 전반에 깊게 새겨진 다음과 같은 오류 점검 패턴과 충돌한다.
static void SetProgress(string message, int percent)  // 고전적인 접근방식
{
  if (message == null)
    throw new ArgumentNullException("message");

 if (percent < 0 || percent > 100)
    throw new ArgumentOutOfRangeException("percent");
  ...
}

static void SetProgress(string message, int percent)  // 현대적인 접근방식
{
  Contract.Requires(message != null);
  Contract.Requires(percent >= 0 && percent <= 100);
  ...
}
  • 고전적 인수 점검을 강제하는 커다란 어셈블리가 이미 갖추어진 상태에서 전제조건을 이용하는 새로운 메서드를 작성하면, 인수 점검과 관련해서 어떤 메서드는 Argument… 예외를 던지지만 또 어떤 메서드는 ContractException을 던지는 등으로 라이브러리의 일관성이 깨진다. 한 가지 해결책은 기존의 모든 메서드를 계약을 사용하도록 갱신하는 것이지만, 여기에는 다음과 같은 두 가지 문제점이 있다.
    • 시간이 많이 걸린다.
    • 이 메서드가 ArgumentNullException 같은 형식의 예외를 던지리라는 가정에 의존하는 호출자들이 있을 수 있다 (이는 거의 항상 나쁜 설계를 뜻하지만, 어쨌든 현실적으로 그런 호출자들이 존재한다)
  • 더 나은 해결책은 Contract.Requires의 제네릭 버전을 호출하는 것이다. 제네릭 버전은 계약 위반 시 던질 예외의 형식을 지정할 수 있다.
Contract.Requires<ArgumentNullException>(message != null, "message");
Contract.Requires<ArgumentOutOfRangeException>(percent >= 0 && percent <= 100, "percent");
  • 이렇게 하면 메서드가 겉으로 보기에 구식의 인수 점검 방식처럼 행동하면서도 코드 계약의 장점(간결함, 인터페이스 지원, 암묵적 문서화, 정적 점검, 실행시점 커스텀화)을 얻을 수 있다.
  • 지정된 예외는 이진 변환기 실행시 /throwonfailure를 지정(Visual Studio에서는 ‘Assert on Contract Failure’의 체크를 해제)한 경우에만 던져진다. 그렇게 하지 않으면 그냥 대화상자가 나타난다.
  • 또한 이진 변환기 실행 시 계약 점검 수준을 ReleaseRequires로 지정할 수도 있다. 그러면 제네릭 Contract.Requires<TException> 호출들만 남고 다른 모든 계약 점검은 사라진다. 결과적으로 어셈블리는 예전과 같은 방식으로 행동하게 된다.

Contract.EndContractBlock 메서드

  • Contract.EndContractBlock 메서드를 이용하면 .NET Framework 4.0 이전에 작성된 코드를 리팩터링 하지 않고도 전통적인 인수 점검 코드에 코드 계약의 장점을 도입할 수 있다. 그냥 인수 점검을 수행한 후에 이 메서드를 호출하기만 하면 된다.
static void Foo (string name)
{
  if (name == null) throw new ArgumentNullException("name");
  Contract.EndContractBlock();
  ...
}
  • 그러면 이진 변환기는 이 코드를 논리적으로 다음에 해당하는 코드로 변환한다.
static void Foo (string name)
{
  Contract.Requires<ArgumentNullException>(name != null, "name");
  ...
}
  • EndContractBlock 호출 이전의 코드는 반드시 다음과 같은 형태의 간단한 문장이어야 한다.
if <조건> throw <표현식>;
  • 전통적인 인수 점검을 코드 계약 호출과 섞어 쓸 수도 있다. 코드 계약 호출을 인수 점검 뒤에 넣어야 한다는 조건만 지키며 ㄴ된다.
static void Foo (string name)
{
  if (name == null) throw new ArgumentNullException("name");
  Contract.Requires(name.Length >= 2);
  ...
}
  • 모든 계약 강제 메서드 호출은 암묵적으로 계약 블록의 끝으로 간주된다.
  • 이 방법의 핵심은 메서드 시작 부분에 하나의 구역(‘계약 블록’)을 정의하고 그 구역에 있는 모든 if 문이 계약의 일부임을 이진 변환기가 알 수 있게 한다는 것이다.
    • 임의의 계약 강제 메서드를 호출하면 암묵적으로 그러한 계약 블록이 연장되므로 Contract.Ensures 같은 메서드를 호출한다면 굳이 EndContractBlock을 호출하지 않아도 된다.

전제조건과 가상 메서드 재정의

  • 가상 메서드를 재정의할 때 전제조건을 추가할 수는 없다. 전제조건을 추가한다는 것은 계약을 더 제한적으로 만드는 계약 변경을 의미하는데, 계약이 그렇게 변하면 다형성의 원칙들이 깨지기 때문이다.
    • 엄밀히 말하면 메서드 재정의 시 계약을 더 느슨하게 만드는 변경은 허용할 수도 있었다. 그러나 C# 설계자들은 언어가 더 복잡해지는 대신 얻을 수 있는 이득이 그리 크지 않다는 이유로 그런 변경 역시 허용하지 않기로 했다.
  • 이진 변환기는 기반 클래스 메서드의 전제조건들이 파생 클래스에서 항상 강제됨을 보장한다. 특히 재정의된 메서드가 기반 메서드를 호출하지 않더라도 기반 메서드의 전제조건들이 강제된다.

사후조건

Contract.Ensures 메서드

  • Contract.Ensures는 하수조건, 즉 메서드 종료시 반드시 참이어야 하는 조건을 강제한다.
static bool AddIfNotPresent<T> (IList<T> list, T item)
{
  Contract.Requires(list != null);
  Contract.Ensures(list.Contain(item));
  if (list.Contains(item)) return false;
  list.Add(item);
  return true;
}
  • 이진 변환기는 사후조건들을 메서드의 종료 지점으로 옮긴다. 사후조건들은 메서드가 일찍 반환되어도 점검된다.
    • 단, 처리되지 않은 예외 때문에 메서드 실행이 일찍 종료되는 경우에는 점검되지 않는다.
  • 전제조건은 호출자의 실수를 검출하기 위한 것이지만, 사후조건은 함수 자체의 오류를 검출하기 위한 것이다. (이 점은 단언과 상당히 비슷하다.)
    • 따라서 사후조건 표현식에서는 객체의 전용 상태에 접근해도 된다.

사후조건과 스레드 안전성

  • 다중 스레드를 사용하는 상황에서는 사후조건이 덜 유용할 수 있다. 예컨대 다음과 같이 List<T>를 스레드에 안전하게 감싸는 클래스의 두 메서드를 생각해 보자.
public class THreadSafeList<T>
{
  List<T> _list = new List<T>();
  object _locker = new object();

  public bool AddIfNotPresent (T item)
  {
    Contract.Ensures(_list.Contains(item));
    lock(_locker)
    {
      if (_list.Contains(item)) return false;
      _list.Add(item);
      return true;
    }
  }

  public void Remove (T item)
  {
    lock (_locker)
      _list.Remove(item);
  }
}
  • AddIfNotPresent 메서드의 사후조건들은 자물쇠가 풀린 후에 점검된다. 그런데 만일 자물쇠 해제시점과 점검 시점 사이에 다른 어떤 스레드가 Remove를 호출해서 그 항목을 제거하면 점검이 실패하게 된다. 현재로서는 그런 조건들을 사후조건이 아니라 단언으로 강제하는 것 말고는 이 문제를 피해 가는 방법이 없다.

Contract.EnsuresOnThrow<TException> 메서드

  • 종종 메서드 실행 시 특정 형식의 예외가 던져짐을 강제해야 하는 경우가 있다. 즉, 특정 형식의 예외 발생 여부가 사후조건인 것이다. EnsuresOnThrow 메서드가 바로 사후조건을 강제하는 역할을 한다.
Contract.EnsuresOnThrow<WebException> (this.ErrorMessage != null);

Contract.Result<T>와 Contract.ValueAtReturn<T> 메서드

  • 사후조건은 함수의 실행이 끝난 후에 평가되므로, 사후조건에서 함수의 반환값을 점검하고 싶은 것은 자연스러운 요구이다. Contract.Result<T>가 바로 그런 경우에 필요한 메서드이다.
Random _random = new Random();
int GetOddRandomNumber()
{
  Contract.Ensures(Contract.Result<int>() % 2 == 1);
  return _random.Next(100) * 2 + 1;
}
  • 이와 비슷한 메서드로 Contract.ValueAtReturn<T>는 ref나 out 매개변수의 최종값을 돌려준다.

Contract.OldValue<T> 메서드

  • Contract.OldValue<T> 메서드는 메서드 매개변수의 원래 값을 돌려준다. 사후조건은 함수의 끝에서 점검되므로, 사후조건 표현식 안의 매개변수는 함수 본문이 수정한 값을 담고 있다. 따라서 사후조건에서 매개변수의 원래 값이 관여하는 조건을 점검하려면 이 메서드가 필요하다.
  • 예컨대 다음 메서드의 사후조건은 항상 실패한다.
static string Middle (string s)
{
  Contract.Requires(s != null && s.Length >= 2);
  Contract.Ensures(Contract.Result<string>().Length < s.Length);
  s = s.Substring(1, s.Length - 2);
  return s.Trim();
}
  • 제대로 하려면 코드를 다음과 같이 바꾸어야 한다.
static string Middle (string s)
{
  Contract.Requires(s != null && s.Length >= 2);
  Contract.Ensures(Contract.Result<string>().Length < Contract.OldValue(s).Length);
  s = s.Substring(1, s.Length - 2);
  return s.Trim();
}

사후조건과 가상 메서드 재정의

  • 재정의된 메서드에서 기반 메서드에 정의된 사후조건을 바꾸거나 무효화 할 수는 없다. 그러나 새로운 사후조건을 추가할 수는 있다.
    • 이진 변환기는 기반 메서드의 사후조건들이 반드시 점검되도록 강제한다. 심지어는 재정의된 메서드에서 기반 구현을 호출하지 않는 경우에도 그렇다.
  • 방금 말한 이유로 가상 메서드의 사후조건에서는 전용 멤버에 접근하지 말아야 한다. 이진 변환기는 그런 전용 멤버 접근 코드를 파생 클래스에도 주입할 것이므로, 결과적으로 실행 시점 오류가 발생한다.

단언과 객체 불변식

  • 전제조건과 사후조건 외에 코드 계약 API는 단언과 객체 불변식(object invariant)을 정의하는 수단들도 제공한다.

단언

Contract.Assert 메서드

  • 함수의 어느 곳에서도 Contract.Assert를 호출해서 어떤 조건을 단언할 수 있다. 선택적인 둘째 인수에 단언 실패 시의 오류 메시지를 지정할 수 있다.
...
int x = 3;
...
Contract.Assert(x == 3);  // x가 3이 아니면 단언에 시래한다.
Contract.Assert(x == 3, "x가 반드시 3이어야 함");
...
  • 이런 단언문들은 이진 변환기가 특별히 변환하지 않고 그대로 둔다. Debug.Assert 대신 Contract.Assert를 사용하는 것이 바람직한 이유는 두 가지이다.
    • 코드 계약 기능이 제공하는 좀 더 유연한 실패 처리 메커니즘들을 활용할 수 있다.
    • Contract.Assert 위반 여지가 있는 코드를 정적 점검 도구로 잡아낼 수 있다.

Contract.Assume 메서드

  • 가정(assumption)을 표현하는 메서드인 Contract.Assume은 실행시점에서 Contract.Assert와 정확히 동일하게 작동한다. 단, 정적 점검 도구는 이들을 단언과 조금 다르게 취급한다. 정적 점검 도구들은 단언은 깐깐하게 검증하려 들지만, 가정들에 대해서는 아무런 문제도 제기하지 않는다.
  • 단언 중에는 정적으로는 검증할 수 없는 것들이 있는데, 그런 단언에 대해 정적 점검 도구가 마치 양치기 소년처럼 엉뚱하게 경보를 발동하는 경우가 있다. 그런 단언을 가정으로 바꾸면 정적 점검 도구가 조용해진다.

객체 불변식

  • 하나의 클래스에 대해 하나 이상의 객체 불변식(object invariant) 메서드를 지정할 수 있다. 그런 메서드는 클래스의 모든 공용(public) 함수의 끝에서 자동으로실행된다. 이를 통해서 객체의 불변식, 즉 객체가 일관된 내부 상태를 유지하고 있음을 듯하는 조건을 단언할 수 있다.
  • 한 클래스에 여러 개의 객체 불변식을 둘 수 있게 한 것은 부분 클래스에서도 객체 불변식이 잘 작동하게 하기 위한 것이다.
  • 객체 불변식 메서드를 정의할 때는 매개변수와 반환값이 없는 메서드를 작성하고 [ContractInvariantMethod] 특성을 부여한다. 그 메서드 안에서 원하는 불변식 조건을 Contract.Invariant 메서드 호출로 표현하면 된다.
class Test
{
  int _x, _y;
  [ContractInvariantMethod]
  void ObjectInvariant()
  {
    Contract.Invariant(_x >= 0);
    Contract.Invariant(_y >= _x);
  }

  public int X { get { return _x; } set { _x = value; } }
  public void Test1() { _x = -3; }
  void Test2() { _x = -3; }
}
  • 이진 변환기는 X 속성과 Test1, Test2 메서드를 논리적으로 다음과 같은 형태로 바꾼다.
public int X { get { return _x; } set { _x = value; } }
public void Test1() { _x = -3; }
void Test2() { _x = -3; }
  • 객체 불변식들이 객체가 유효하지 않은 상태로 진입하는 일을 방지하지는 않는다. 이들은 단지 그런 일이 발생하는 조건을 검출할 뿐이다.
  • Contract.Invariant는 Contract.Assert와 상당히 비슷하되, [ContractInvariantMethod] 특성이 붙은 메서드에서만 호출할 수 있다는 특징이 있다.
    • 마찬가지로 객체 불변식 메서드([ContractInvariantMethod] 특성이 붙은)는 오직 Contract.Invariant 호출문들로만 구성되어야 한다.
  • 파생 클래스가 새로운 객체 불변식 메서드를 도입하는 것도 가능하다. 그러면 기반 클래스의 불변식 메서드와 함께 그 불변식 메서드도 함께 점검된다. 물론 그러한 점검은 오직 공용 멤버가 호출된 후에 일어난다는 점을 기억해야 한다.

인터페이스와 추상 메서드의 코드 계약

  • 코드 계약의 한 가지 강력한 기능은 인터페이스 멤버와 추상 메서드에 계약 조건들을 부여할 수 있다는 것이다. 이진 실행 파일 변환기는 그런 조건들을 자동으로 구체 구현 클래스의 멤버들에 주입한다.
  • 인터페이스와 추상 메서드에 코드 계약을 적용할 때는 특별한 메커니즘을 이용해서 개별적인 계약 클래스를 인터페이스나 추상 메서드에 연관시킨다. 실제 계약 조건들은 그 계약 클래스의 메서드들로 정의한다. 예컨대 다음과 같다.
[ContractClass (typeof(ContractForITest))]
interface ITest
{
  int Process (string s);
}

[ContractClassFor(typeof(ITest))]
sealed class ContractForITest : ITest
{
  int ITest.Process(string s)  // 반드시 명시적 구현을 사용해야 함.
  {
    Contract.Requires(s != null);
    return 0;  // 컴파일러를 만족시키기 위한 명목상의 값
  }
}
  • ITest.Process 구현의 반환값은 그냥 컴파일러를 만족시키기 위한 것일 뿐이다. 그 return 문은 어차피 실행되지도 않는다. 이진 변환기는 이 메서드에서 계약 조건들만 추출해서 ITest.Process의 실제 구현에 주입한다. 사실 계약 클래스 자체는 아예 인스턴스화 되지 않는다. (따라서 계약 클래스의 생성자들을 작성했다고 해도 그 생성자들은 실행되지 않는다.)
  • 계약 코드 블록 안에서 인터페이스의 다른 멤버들을 좀 더 쉽게 참조하기 위해 임시 변수를 사용하는 것이 허용된다. 예컨대 ITest 인터페이스에 string 형식의 Message라는 속성을 추가했다고 할 때, ITest.Process를 다음과 같이 작성할 수 있다.
int ITest.Process(string s) 
{
  ITest test = this;
  Contract.Requires(s != this.Message);
  ...
}
  • 이것이 다음 코드보다 쓰기도, 읽기도 쉽다.
Contract.Requires(s != ((ITest)this).Message);
  • Message를 반드시 명시적으로 구현해야 하므로, 그냥 this.Message라고 하면 안 된다.) 추상 클래스를 위한 계약 클래스를 작성하는 것도 한 가지만 빼면 지금까지 말한 것과 정확히 같다. 한 가지는 계약 클래스를 sealed 대신 abstract로 선언해야 한다는 것이다.

계약 위반 처리 방식

  • 이진 변환기의 /thrownfailure 옵션을 이용해서 메서드나 호출자가 계약 조건을 만족하지 않았을 떄의 처리 방식을 지정할 수 있다.
  • /thrownfailure를 지정하지 않거나 ‘Assert on Contract Failure’를 체크하면 계약 조건 실패 시 대화상자가 나타난다. 그 대화상자는 실행을 취소하거나 오류를 무시하거나 디버거로 진입하는 옵션들을 제공한다.
    • 이와 관련해서 주의할 사항이 두 가지 있다.
      • 만일 CLR이 다른 호스트 프로그램(이를테면 SqlServer나 Exchange) 안에서 실행되는 중이면, 대화상자가 뜨는 대신 호스트의 상향 보고 방침(escalation policy)이 발동한다.
      • 그렇지 않지만 어떤 잉로 현재 프로세스가 사용자에게 대화상자를 띄울 수 없으면 Environment.FailFast 메서드가 호출된다.
  • 디버그 빌드에서는 대화상자를 띄우는 방식이 유용한데, 이유는 두 가지이다.
    • 계약 위반(계약 조건 실패) 상황을 즉석에서, 프로그램을 다시 실행하지 않고도 진단하고 디버깅하기 편하다. 이 대화상자는 Visual Studio가 ‘첫째 예외’ 발생 시 실행을 중단(break)하도록 설정되어 있지 않아도 나타난다. 그리고 일반적인 예외와는 달리 계약 위반은 거의 항상 코드에 버그가 있음을 뜻한다.
    • 다음처럼 스택의 더 상위에 있는 호출자가 예외를 ‘삼켜 버리는’ 경우에도 계약 위반 상황을 개발자가 알 수 있다.
try
{
  // 계약 조건을 위반하는 어떤 메서드를 호출
}
catch { }
  • 대부분의 시나리오에서 이 코드는 안티패턴으로 간주된다. 이런 패턴은 작성자가 결코 예상하지 못한 조건들을 포함한 계약 위반을 보이지 않게 만들기 때문이다.
  • /thrownfailure를 지정하거나 Visual Studio에서 ‘Assert on Contract Failure’의 체크를 해제하면, 계약 위반 시 ContractException 예외가 발생한다. 이 방식은 다음과 같은 시나리오에 유용하다.
    • 릴리스 빌드에서 해당 예외를 스택 위쪽으로 떠오르게 해서, 예기치 못한 다른 모든 예외와 같은 방식으로 (이를테면 최상위 예외 처리부가 해당 오류를 기록하거나, 사용자에게 알려서 보고하게 하는 등) 처리한다.
    • 오류 기록 공정이 자동화된 단위 검사(unit testing) 환경에서 해당 예외가 자동으로 기록되게 한다.
  • ContractException은 공용 형식이 아니라서 catch 블록에서 이 예외를 구체적으로 지정할 수는 없다. 이를 공용 형식으로 두지 않은 것은 애초에 ContractException을 특정해서 잡을 이유가 없기 떄문이다. 이 예외는 일반적인 최종 예외 처리부에서 잡도록 만들어진 것이다.

ContractFailed 이벤트

  • 프로그램이 계약을 위반하면 CLR은 다른 행동을 취하기 전에 먼저 Contract.ContractFailed라는 이벤트를 발동한다. 프로그램에서 이 이벤트를 처리하는 경우 이벤트 처리부에 전달되는 이벤트 객체로부터 오류의 세부사항을 알아낼 수 있다. 도한 이벤트 처리부에서 SetHandled를 호출함으로써 이후에는 해당 ContractException이 던져지지 않게 할 수 있다.
  • 이 이벤트를 처리하는 것은 /thrownfailure 옵션ㅇ르 지정했을 떄 특히나 유용하다. 조금 전에 설명했듯이 호출 스택 상위에 있는 코드가 예외들을 삼켜버리는 경우에도 이벤트 처리부를 이용해서 모든 계약 위반 상황을 기록할 수 있기 때문이다. 좋은 예가 자동화된 단위 검사이다.
Contract.ContractFailed += (sender, args) =>
{
  string failureMessage = args.FailureKind + ": " + args.Message;
  // 단위 검사 프레임워크를 이용해서 failureMessage를 기록한다.
  // ...
  args.SetUnwind();
}
  • 이 이벤트 처리부는 모든 계약 위반을 기록하되, 이벤트 처리부의 실행이 끝난 후에 ContractException 예외가 통상적인 방식으로 작동하게 허용한다. 또한 처리부 끝에서 SetUnwind를 호출하는데, 이렇게 하면 다른 이벤트 구독자의 모든 SetHandled 호출의 효과를 무력화한다. 다른 말로 하면 모든 이벤트 처리부가 실행된 후 ContractException이 항상 발생하게 한다.

계약 조건 안의 예외

  • 계약 조건 자체에서 예외가 발생하면, 그 예외는 다른 예외와 마찬가지 방식으로 전파된다. 이는 /thrownfailure 지정 여부와는 무관하다. 다음 메서드는 주어진 문자열 인수가 널이면 NullReferenceException을 던딘다.
string Test (string s)
{
  Contract.Requires(s.Length > 0);
  ...
}
  • 이 전제조건에는 결함이 있다. 다음과 같이 지정했어야 한다.
Contract.Requires(!string.IsNullOrEmpty(s));

계약의 선택적 강제

  • 이진 변환기는 계약 점검 일부 또는 전부를 생략할 수 있는 두 개의 옵션을 제공한다. 하나는 /publicsurface 이고 또 하나는 /level이다. Visual Studio에서는 프로젝트 속성 창의 ‘Code Contracts’ 탭에 해당 옵션들이 있다. /publicsurface를 지정하면 이진 변환기는 공용 멤버들의 계약만 점검한다. /level 옵션으로는 점검 수준을 뜻하는 정수를 지정하는데, 다음과 같은 값들을 사용할 수 있다.
    • 0 (None)
      • 모든 계약 점검을 생략한다.
    • 1 (ReleaseRequires)
      • Contract.Requires<TException>의 제네릭 버전에 대한 호출들만 활성화한다.
    • 2 (Preconditions)
      • 모든 전제조건을 활성화한다(수준 1의 전제조건들과 보통의 전제조건들)
    • 3 (Pre and Post)
      • 수준 2 점검 더하기 사후조건들을 활성화한다.
    • 4 (Full)
      • 수준 3 점검 더하기 객체 불변식들과 단언들을 활성화 한다.
  • 일반적으로 디버그 빌드에서는 수준 4를 적용해서 모든 계약 점검을 활성화 한다.

릴리스 빌드에서의 계약

  • 릴리스 빌드를 만들 때는 흔히 다음 두 접근방식 중 하나를 선택한다.
    • 안전성을 우선시해서 모든 계약 점검을 활성화한다.
    • 성능을 우선시해서 모든 계약 점검을 비활성화한다.
  • 다수가 사용할 공용 라이브러리의 두 번째 접근방식을 적용하면 문제가 될 수 있다.
    • 한 예로 독자가 L이라는 라이브러리를 계약 점검들을 모두 비활성화해서 릴리스 모드로 빌드한 후 배포했다고 하자.
    • 어떤 클라이언트가 라이브러리 L을 참조하는 프로젝트 C를 디버그 모드에서 빌드한 경우 어셈블리 C에서 L의 멤버를 잘못 호출해도 계약 위반은 발생하지 않는다. 그런 상황에서는 클라이언트가 L을 정확히 사용하는지 확인할 수 있도록 L의 계약 일부를 강제하는 것이 바람직하다.
    • 구체적으로 말해서 L의 공용 멤버들에 대한 전제조건들을 클라이언트가 잘 지키는지 확인할 수 있어야 한다.
  • 가장 간단한 해결책은 /publicsurface를 지정해서 공용 계약만 점검하게 하고, 점검 수준(/level)은 2(Preconditions) 또는 1(ReleaseRequires)로 설정하는 것이다.
    • 그러면 필수적인 전제조건들이 활성화되어서 클라이언트가 자신의 실수를 확인할 수 있게 된다. 또한 공용 멤버들에 대한 전제조건들만 점검하므로 성능상의 비용도 크지 않다.
  • 그러나 성능상의 비용을 전혀 치르지 않고 싶은 극단적인 경우도 발생할 수 있다. 그런 경우에는 호출 지점 점검이라는 좀 더 수고로운 접근방식을 취해야 한다.

호출 지점 점검

  • 호출 지점 점검(call-site checking)을 활성화하면 전제조건 검증이 호출되는 메서드 내부가 아니라 호출 지점, 즉 그 메서드를 호출하는 코드에서 수행된다. 이 접근방식을 사용하면 라이브러리 L의 클라이언트가 디버그 구성에서 전제조건들을 스스로 검증할 수 있으므로 방금 말한 문제점이 해결된다.
  • 호출 지점 점검을 활성화하려면 먼저 개별적인 계약 참조 어셈블리를 빌드해야 한다. 계약 참조 어셈블리는 참조하는 어셈블리에 대한 전제조건들만 담고 있는 보조 어셈블리이다. 그런 어셈블리를 만드는 방법은 두 가지 이다. 하나는 명령 줄 도구 ccrefgen을 이용하는 것이고, 또 하나는 Visual Studio에서 다음과 같은 단계들을 거치는 것이다.
    1. 참조되는 라이브러리(지금 예에서의 L)의 릴리스 구성에서 프로젝트 속성 창 ‘Code Contracts’ 탭의 ‘Perform Runtime Contract Checking’을 해제하고 ‘Contract Reference Assembly’에서 ‘Build’를 선택한다. 이렇게 하면 프로젝트를 빌드할 때 계약 참조 어셈블리(확장자는 .contracts.dll)가 함께 생성된다.
    2. 라이브러리 L을 참조하는 어셈블리의 릴리스 구성에서 모든 계약 점검을 비활성화 한다.
    3. 라이브러리 L을 참조하는 어셈블리의 디버그 구성에서 ‘Call-site Requires Checking’을 체크한다.
  • 셋째 단계는 이진 변환기(ccrewrite)를 /callsiterequires 옵션을 주어서 실행하는 것에 해당한다. 그러면 이진 변환기는 계약 참조 어셈블리에서 전제조건들을 읽어서 참조하는 어셈블리의 호출 지점들에 주입한다.

정적 계약 점검

  • 코드 계약 기능을 이용하면 정적 계약 점검이 가능해진다. 정적 계약 점검에서는 개별적인 도구로 계약 조건들을 점검해서 프로그램을 실행하기 전에 잠재적인 버그를 찾아낼 수 있다. 예컨대 정적 계약 점검 도구는 다음과 같은 코드에 대해 경고를 발생한다.
static void Main()
{
  string message = null
  WriteLine(message);  // 정적 점검 도구가 이 코드에 대해 경고를 발생한다.
}

static void WriteLine(string s)
{
  COntract.Requires(s != null);
  Console.WriteLine(s);
}
  • Microsoft는 cccheck라는 정적 계약 점검 도구를 제공한다. 이를 명령줄에서 직접 실행할 수도 있고, Visual Studio의 프로젝트 속성 창에서 해당 옵션을 체크해서 자동으로 실행되게 할 수도 있다.
  • 정적 점검이 작동하려면 메서드에 전제조건들과 사후조건들을 추가해야 할 수도 있다. 간단한 예로 정적 점검 도구는 다음과 같은 코드에 대해 경고를 발생한다. (주석은 cccheck의 경고 메시지)
static void WriteLine(string s, bool b)
{
  if (b)
    WriteLine(s);  // Warning: requires unproven
}

static void WriteLine(string s)
{
  Contract.Requires(s != null);
  Console.WriteLine(s);
}
  • 경고 메시지는 요구사항(전제조건)이 증명되지 않았다는 뜻이다. 첫 메서드는 매개변수가 널이 아니어야 하는 메서드를 호출하므로, 해당 인수가 널이 아님을 증명해야 이 경고가 사라진다. 다음과 같이 전제조건을 추가하면 된다.
static void WriteLine(string s, bool b)
{
  Contract.Requires(s != null);
  if (b)
    WriteLine(s);  // ok
}

ContractVerification 특성

  • 정적 계약 점검을 제대로 활용하는 가장 쉬운 방법은 프로젝트 초기부터 적용하는 것이다. 프로젝트 중간부터 사용하면 경고가 너무 많이 나와서 질릴 수 있다.
  • 정적 계약 점검을 기존 코드 기반에 적용해야 하는 상황이라면 처음에는 프로그램의 일분에만 선택적으로 적용하는 것이 도움이 된다. 이때 필요한 것이 ContractVerification 특성이다.
    • 이 특성은 어셈블리 수준에서 적용할 수도 있고 형식이나 멤버 수준에서 적용할 수도 있다. 이를 여러 수준에서 적용한 경우, 더 큰 범위의 것이 우선시 된다. 즉, 특정한 클래스 하나에만 정적 계약 점검을 적용하고 싶다면 어셈블리 수준부터 내려오면서 점검들을 비활성화해야 한다. 어셈블리 수준의 점검을 비활성화는 코드는 다음과 같다.
[assembly: ContractVerification(false)]
  • 그런 다음 해당 클래스에만 점검을 활성화하면 된다.
[ContractVerification(true)]
class Foo { ... }

Baselines 옵션

  • 기존 코드 기반에 정적 계약 점검을 적용하는 또 다른 전략은 Visual Studio의 Code Contracts 탭에서 Baseline 옵션을 체크해서 점검 도구를 실행하는 것이다. 그러면 모든 경고가 해당 옵션에 지정한 XML 파일에 기록된다. 이후 정적 점검을 실행하면 점검 도구는 그 파일에 있는 모든 경고를 무시한다. 즉 독자가 새로 작성한 코드 때문에 생긴 메시지들만 나타난다.

SuppressMessage 특성

  • 정적 점검 도구에게 특정 종류의 경고들을 무시하라고 알려줄 수도 있다. SuppressMessage 특성이 그런 용도로 쓰인다.
[SuppressMessage("Microsoft.Contracts", 경고_종류)]
  • 여기서 경고_종류에 사용할 수 이는 값은 다음과 같다.
Requires Ensures Invariant NonNull DivByZero MinValueNegation ArrayCreation ArrayLowerBound ArrayUpperBound
  • 이 특성은 개별 형식에 적용할 수도 있고 어셈블리 수준에서 적용할 수도 있다.

디버거 통합

  • 종종 응용 프로그램을 디버거와 연동해서 실행하는 것이 도움이 되기도 한다. 개발 도중에는 주로 IDE의 디버거를 그런 용도로 사용한다. 그러나 응용 프로그램이 일단 배치(deployment)되었다면, 디버거는 다음 중 하나일 가능성이 있다.
    • DbgCLR
    • WinDbg나 Cordbg, Mdbg 같은 저수준 디버깅 도구
  • DbgCLR은 Visual Studio에서 디버거만 남기고 모든 것을 제거한 것에 해당한다.

디버거 부착 및 중단

  • System.Diagnostics의 정적 클래스 Debugger는 멤버 Break, Launch, Log, IsAttached를 통해서 디버거 연동을 위한 기본 기능을 제공한다.
  • 응용 프로그램을 디버깅하려면 먼저 디버거를 응용 프로그램에 붙여야(attach) 한다.
    • IDE에서 응용 프로그램을 시작하면 자동으로 디버거가 붙는다. 그러나 응용 프로그램을 IDE 안에서 디버그 모드로 실행하는 것이 불편하거나 불가능할 때가 종종 있다.
    • 이 경우 한 가지 해결책은 응용 프로그램을 보통의 방법으로 실행한 후 디버거를 직접 응응 프로그램의 프로세스에 연결하는 것이다. Visual Studio의 경우 ‘디버그’ 메뉴에서 ‘프로세스에 연결’을 선택하면 된다. 그러나 이렇게 하면 IDE에서 응용 프로그램 소스 코드에 설정한 중단점(breakpoint)들이 작동하지 않는다.
  • 해결책은 응용 프로그램 자신이 Debugger.Break를 호출하는 것이다. 이 메서드는 디버거를 띄워서 현재 프로세스에 부착하고 그 지점에서 실행을 정지한다.(Launch도 같은 일을 하지만, 실행을 정지하지는 않는다.) 일단 디버거가 붙으면 Log 메서드를 이용해서 디버거의 출력 창에 직접 메시지를 기록할 수 있다. 현재 프로세스가 디버거와 연결되었는지는 IsAttached 속성으로 알 수 있다.

디버거 관련 특성들

  • DebuggerStepThrough 특성과 DebuggerHidden 특성은 디버거에게 특정 메서드나 생성자, 클래스의 단계별 실행(single-stepping)을 처리하는 방법에 대한 힌트를 제공한다.
  • DebuggerStepThrough는 디버거에게 사용자 상호작용 없이 함수를 단계별로 실행하라고 요청한다. 이 특성은 자동으로 생성된 메서드들과 실제 작업을 다른 어딘가에 있는 메서드로 위임하는 프록시 메서드들에 유용하다.
    • 후자의 경우 ‘진짜’ 메서드 안에 중단점이 설정되어 있어도 디버거가 표시하는 호출 스택에는 여전히 프록시 메서드가 나타난다. 이를 피하려면 프록시 메서드에 DebuggerHidden 특성을 적용하면 된다. 다음처럼 프록시 메서드에 이 두 특성을 모두 적용하면 개발자는 부차적인 구현 세부사항보다는 응용 프로그램의 논리를 디버깅하는데 집중할 수 있다.
[DebuggerStepThrough, DebuggerHidden]
void DoWorkProxy()
{
  // ... 설정 ...
  DoWork();
  // ... 정리 ...
}

void DoWork () { ... }  // 실제 메서드...

프로세스와 프로세스 스레드

  • Process.Start를 이용해서 새 프로세스를 띄우는 방법을 앞서 설명했다. Process 클래스는 같은 컴퓨터나 심지어는 원격 컴퓨터에서 실행되는 다른 프로세스를 조사하거나 상호작용하는 수단들도 제공한다. Windows 스토어 앱에서는 Process 클래스를 사용할 수 없음을 주의하라.

실행 중인 프로세스 조사

  • Process.GetProessXXX 메서드들은 주어진 이름 또는 프로세스 ID에 해당하는 하나의 프로세스를 나타내는 Process 인스턴스를 돌려주거나 현재 컴퓨터 또는 지정된 컴퓨터에서 실행되는 모든 프로세스에 관한 정보를 담은 Process 컬렉션을 돌려준다. 여기에는 관리되는 프로세스뿐만 아니라 비관리 프로세스들도 포함된다.
    • 각 Process 인스턴스에는 프로세스 이름, ID, 우선순위, 메모리-CPU 사용량, 창 핸들 같은 신원 정보 또는 통계 정보에 연결된 다양한 속성이 있다.
    • 다음 예는 현재 컴퓨터에서 실행 중인 모든 프로세스를 열거한다.
foreach (Process p in Process.GetProcesses())
using (p)
{
  Console.WriteLine(p.ProcessName);
  Console.WriteLine("PID: " + p.Id);
  Console.WriteLine("메모리: " + p.WorkingSet64);
  Console.WriteLine("스레드: " + p.Threads.Count);
}
  • Process.GetCurrrentProcess는 현재 프로세스를 돌려준다. 또 다른 응용 프로그램 도메인들을 생성한 경우, 모든 도메인은 같은 프로세스를 공유한다.
  • 주어진 Process 인스턴스에 대해 Kill 메서드를 호출하면 해당 프로세스가 종료된다.

프로세스 안의 스레드 조사

  • Process.Threads 속성을 이용해서 프로세스의 스레드들을 열거하는 것도 가능하다. 그런데 이 속성이 System.Threading.Thread 객체를 돌려주지는 않는다. 대신 이 속성은 동기화 작업이 아니라 스레드 관리 작업을 위해 만들어진 ProcessThread 객체를 돌려준다.
    • ProcessThread 객체는 바탕 스레드에 관한 진단 정보를 제공한다. 또한 스레드의 우선순위나 프로세서 친화도(affinity) 같은 스레드의 특정 측면을 이 객체를 이용해서 변경하는 것도 가능하다.
public void EnumerateThreads (Process p)
{
  foreach (ProcessThread pt in p.Threads)
  {
    Console.WriteLine(pt.Id);
    Console.WriteLine("상태: " + pt.ThreadState);
    Console.WriteLine("우선순위: " + pt.PriorityLevel);
    Console.WriteLine("시작 시간: " + pt.StartTime);
    Console.WriteLine("CPU 시간: " + pt.TotalProcessorTime);
  }
}

StackTrace와 StackFrame 클래스

  • StackTrace와 StackFrame 클래스는 실행 호출 스택에 대한 읽기 전용 시각을 제공한다. 이 두 클래스는 표준 데스크톱 .NET Framework의 일부이다.
    • 이들을 이용해서 현재 스레드나 현재 프로세스의 다른 스레드, 또는 Exception 객체의 스택 궤적(stack trace)을 얻을 수 있다.
    • 그런 정보는 주로 진단 작업에 유용하지만 일종의 ‘핵(hack)’으로서 프로그래밍에 써먹을 여지도 있다. StackTrace는 전체 호출 스택을 나타내고 StackFrame은 그 스택 안의 개별 메서드 호출을 나타낸다.
  • 인수 없이 또는 bool 인수 하나만 지정해서 StackTrace 인스턴스를 생성하면 현재 스레드의 호출 스택의 스냅숏을 얻게 된다.
    • bool 인수를 true로 지정하면 StackTrace는 확장자가 .pdb(이 이름은 project debug를 줄인 것이다)인 프로젝트 디버그 파일을 읽어 들인다.
    • 그러면 호출의 파일 이름, 행 번호, 열 오프셋 같은 정보까지 얻을 수 있다. 프로젝트 디버그 파일은 /debug 옵션을 주어서 프로젝트를 빌드하면 생성된다.
  • StackTrace 인스턴스를 얻었다면 GetFrame을 호출해서 특정 프레임 하나에 대한 StackFrame 인스턴스를 얻거나 GetFrames를 호출해서 모든 프레임을 열거할 수 있다.
static void Main() { A(); }
staic void A() { B(); }
staic void B() { C(); }
staic void C()
{
  StackTrace s = new StackTrace(true);

  Console.WriteLine("총 프레임 수: " + s.FrameCount);
  Console.WriteLine("현재 메서드: " + s.GetFrame(0).GetMethod().Name);
  Console.WriteLine("호출한 메서드: " + s.GetFrame(1).GetMethod().Name);
  Console.WriteLine("진입 메서드: " + s.GetFrame(s.FrameCount-1).GetMethod().Name);
  Console.WriteLine("호출 스택: ");
  foreach (StackFrame f in s.GetFrames())
    Console.WriteLine("파일: " + f.GetFileName() + " 행: " + f.GetFileLineNumber() + " 열: " + f.GetFileColumnNumber() + " 오프셋: " + f.GetILOffset() + " 메서드: " + f.GetMethod().Name);
}
  • 다음은 이 코드의 출력 예이다.
총 프레임 수: 4
현재 메서드: C
호출한 메서드: B
진입 메서드: Main
호출 스택:
  파일: C:\Test\Program.cs 행: 15 Col: 4 오프셋: 7 메서드: C
  파일: C:\Test\Program.cs 행: 12 Col: 22 오프셋: 6 메서드: B
  파일: C:\Test\Program.cs 행: 11 Col: 22 오프셋: 6 메서드: A
  파일: C:\Test\Program.cs 행: 10 Col: 25 오프셋: 6 메서드: Main
  • IL 오프셋은 다음번에 실행될 명령의 오프셋을 뜻한다. 현재 실행 중인 명령의 오프셋이 아니다. 이상하게도 보통의 경우 행 번호와 열 번호(.pdb 파일이 있는 경우)는 현재 실행 지점의 것들이다.
    • 이런 현상이 생기는 이유는 이렇다. CLR은 IL 오프셋으로부터 행 번호와 열 번호를 계산할 때 실제 실행 지점을 최선을 다해서 추론한다. 컴파일러는 그런 추론이 가능한 형태로 IL을 생성하는데 예를 들어 필요하다면 IL 스트림에 nop(no-operation; 무연산) 명령들을 삽입한다.
    • 그러나 최적화를 활성화해서 프로젝트를 빌드하면 nop 명령들의 삽입이 비활성화되며, 그러면 스택 궤적이 다음번에 실행될 문장의 행, 열 번호를 표시하게 될 수 있다. 더 나아가서 메서드 전체를 생략하는 등의 또 다른 최적화 기법들이 적용되면 유용한 스택 궤적을 얻는 것이 더욱 어려워진다.
  • 전체 스택 궤적에 대한 필수 정보를 얻는 좀 더 간단한 방법은 StackTrace에 대해 ToString을 호출하는 것이다. 그러면 다음과 같은 형태의 텍스트를 얻을 수 있다.
at DebutTest.Program.C(): in C:\Test\Program.cs:line 16
at DebutTest.Program.B(): in C:\Test\Program.cs:line 12
at DebutTest.Program.A(): in C:\Test\Program.cs:line 11
at DebutTest.Program.Main(): in C:\Test\Program.cs:line 10
  • 다른 스레드의 스택 궤적을 얻으려면 StackTrace 인스턴스를 생성할 때 해당 Thread 객체를 생성자의 한 인수로 지정해야 한다. 다른 스레드의 스택 궤적은 이를테면 프로그램 프로파일링에 유용하지만, 스택 궤적을 조회하는 동안 반드시 그 스레드의 실행을 정지해야 한다는 점을 주의해야 한다.
  • Exception 객체에서도 스택 궤적(그 예외가 발생하기까지의 과정을 알려주는)을 얻을 수 있다. 해당 Exception을 StackTrace의 생성자에 넘겨주면 된다.
  • Exception에는 이미 StackTrace라는 속성이 있지만, 이 속성은 StackTrace 객체가 아니라 문자열을 돌려준다.
    • 응용 프로그램을 이미 배치한 후에는 예외를 로그에 기록하는데에는 그 문자열보다 StackTrace 객체가 훨씬 유용하다. StackTrace 객체가 있으면 예외 발생 지점의 행, 열 번호 뿐만 아니라 IL 오프셋도 기록할 수 있다. 그리고 IL 오프셋과 ildasm이 있으면 메서드 안에서 오류가 발생한 구체적인 지점을 알아낼 수 있다.

Windows 이벤트 로그

  • Win32 플랫폼은 Windows 이벤트 로그라는 형태로 중앙 집중적 로깅 메커니즘을 제공한다.
  • EventLogTraceListener 이벤트를 등록한 경우, 앞에서 살펴본 Debug, Trace 클래스는 Windows 이벤트 로그에 로그를 기록한다. 더 나아가서 EventLog 클래스를 이용하면 Trace나 Debug를 사용하지 않고 Windows 이벤트 로그에 직접 로그를 기록할 수 있다. 또한 이 클래스는 이벤트 자료를 읽거나 감시하는데도 사용할 수 있다.
  • Windows 서비스 응용 프로그램은 뭔가 잘못되었을 때 사용자에게 진단 정보가 기록된 특별한 파일의 위치를 알려주는 대화상자를 표시할 수 없으므로, 해당 정보를 Windows 이벤트 로그에 기록하는 것이 합당하다. 또한 서비스 프로그램들은 Windows 이벤트 로그에 정보를 기록하는 것이 관례이므로 서비스가 문제를 일으켰을 때 관리자는 자연스럽게 Windows 이벤트 로그부터 점검할 것이다.
    • Windows 스토어 앱에서는 EventLog 클래스를 사용할 수 없다.
  • 표준 Windows 이벤트 로그는 다음 3가지이다.
    • 응용 프로그램 로그
    • 시스템 로그
    • 보안 로그
  • 대부분의 응용 프로그램은 보통의 경우 응용 프로그램 로그에 로그 메시지를 기록한다.

이벤트 로그 기록

  • Windows 이벤트 로그에 메시지를 기록하는 과정은 다음과 같다.
    1. 세 가지 표준 이벤트 로그 중 하나를 선택한다. 보통은 응용 프로그램 로그를 사용한다.
    2. 로그 출처(log source)의 이름을 정한다. 필요하면 새로 만든다.
    3. 표준 로그 이름과 로그 출처, 메시지 자료로 EventLog.WriteEntry를 호출한다.
  • 로그 출처의 이름은 독자의 응용 프로그램에서 기록한 로그들을 쉽게 알아볼 수 있는 문자열이면 된다. 로그 출처를 사용하려면 먼저 출처를 생성, 등록해야 한다. CreateEventSource 메서드가 그러한 기능을 제공한다. 그런 다음에는 WriteEntry를 호출하면 된다.
const string SourceName = "MyCompany.WidgetServer";

// CreateEventSource에는 관리자 권한이 필요하다. 일반적으로 이 부분은 응용 프로그램 설치 과정에서 처리한다.
if (!EventLog.SourceExist(SourceName))
  EventLog.CreateEventSource(SourceName, "Application");

EventLog.WriteEntry(SourceName, "서비스 시작됨. 적용된 구성 파일: ...", EventLogEntryType.Information);
  • 로그 항목의 종류를 나타내는 EventLogEntryType 열거형에는 Information 외에 Warning, Error, SuccessAudit, FailureAudit 라는 멤버가 있다.
    • 로그 항목 종류마다 Windows 이벤트 뷰어에 표시되는 아이콘이 다르다. Event.LogWriteEntry에는 로그의 범주와 이벤트 ID(둘 다 호출자가 정하는 정수), 선택적인 이진 자료를 받도록 중복적재된 버전들도 있다.
  • CreateEventSource에는 컴퓨터의 이름을 지정할 수 있는 버전이 있다. 권한이 충분하다면, 그 버전을 이용해서 다른 컴퓨터의 이벤트 로그에 로그 메시지를 기록할 수 있다.

이벤트 로그 읽기

  • 이벤트 로그를 읽을 때는 읽고자 하는 로그의 이름으로 EventLog 클래스를 인스턴스화 한다. 이때 로그가 있는 다른 컴퓨터의 이름을 지정할 수도 있다. EventLog 인스턴스를 얻었으면, 컬렉션 속성인 Entries를 통해서 각 로그 항목에 접근할 수 있다.
EventLog log = new EventLog("Application");

Console.WriteLine("전체 항목 수: " + log.Entries.Count);

EventLogEntry last = log.Entries[log.Entries.Count - 1];
Console.WriteLine("색인: " + last.Index);
Console.WriteLine("출처: " + last.Source);
Console.WriteLine("종류: " + last.EntryType);
Console.WriteLine("시간: " + last.TimeWritten);
Console.WriteLine("메시지: " + last.Message);
  • 또한 정적 메서드 EventLog.GetEventLogs를 이용하면 현재 컴퓨터 또는 다른 컴퓨터에 있는 모든 로그를 열거할 수 있다. (관리자 권한이 필요함)
foreach(EventLog log in EventLog.GetEventLogs())
  Console.WriteLine(log.LogDisplayName);
  • 이 코드는 적어도 응용 프로그램, 보안, 시스템을 출력한다.

이벤트 로그 감시

  • EntryWritten 이벤트에 등록하면 Windows 이벤트 로그에 로그 항목이 기록될 때마다 그 사실을 통지받을 수 있다.
    • 이러한 로그 감시 기능은 지역 컴퓨터의 이벤트 로그들에 대해 작동한다. 또한 로그를 기록한 응용 프로그램이 어떤 것이냐와는 무관하게 모든 로그 항목에 대해 EntryWritten 이벤트가 발동한다.
  • 로그 감시를 활성화하는 과정은 다음과 같다.
    1. EventLog 인스턴스를 생성해서 EnableRaisingEvents 속성을 true로 설정한다.
    2. EntryWritten 이벤트에 대한 처리부를 등록한다.
  • 예를 들면 다음과 같다.
static void Main()
{
  using (var log = new EventLog("Application"))
  {
    log.EnableRaisingEvents = true;
    log.EntryWritten += DisplayEntry;
    Console.ReadLine();
  }
}

static void DisplayEntry(object sender, EntryWrittenEventArgs e)
{
  EventLogEntry entry = e.Entry;
  Console.WritLine(entry.Message);
}

성능 카운터

  • 지금까지 논의한 로깅 메커니즘은 미래의 분석을 위한 정보를 갈무리하는데 유용하다. 그러나 응용 프로그램의 (또는 시스템 전체의) 현재 상태를 파악하려면 좀 더 실시간적인 접근방식이 필요하다. 이러한 요구를 위해 Win32는 성능 감시 기반구조를 제공한다.
    • 그 기반구조는 운영체제와 응용 프로그램들이 노출하는 일단의 성능 카운터(performance counter)들과 그 카운터들을 실시간으로 감시하는데 쓰이는 Microsoft 관리 콘솔(Miscrosoft Management Console, MMC) 스냅인들로 구성된다.
  • 성능 카운터들은 여러 범주로 분류된다. 이를테면 ‘System’, ‘Processor’, ‘.NET CLR 메모리’ 같은 범주들이 있다. GUI 도구들은 이 범주들을 ‘성능 개체(performance object)’라고 부르기도 한다.
    • 각 범주에는 시스템이나 응용 프로그램의 한 측면을 반영하는 서로 연관된 일단의 성능 카운터들이 속한다.
    • 예컨대 ‘.NET CLR Memory’ 범주에는 “% Time in GC”, “#Bytes in All Heaps”, “Allocated bytes/sec” 같은 성능 카운터들이 포함된다.
  • 한 범주의 인스턴스가 여러 개일 수도 있다.  ‘Processor’ 범주가 그러한 예이다. 이 범주의 “% Processor Time” 성능 카운터는 CPU 활용도를 감시하기 위한 것인데, 다중 프로세서 컴퓨터에서는 CPU 마다 이 범주의 인스턴스가 있어서 각 CPU의 활용도를 따로 감시할 수 있다.
  • 성능 카운터나 범주에 접근하려면 접근 대상에 따라서는 지역 또는 원격 컴ㅍ터에 대한 관리자 권한이 필요할 수 있다.

사용 가능한 성능 카운터 열거

  • 다음은 현재 컴퓨터에 있는 모든 성능 카운터를 열거하는 예제이다. 여러 인스턴스가 존재하는 경우에는 인스턴스마다 카운터들을 열거한다.
PerformanceCounterCategory[] cats = PerformanceCounterCategory.GetCategories();

foreach (PerformanceCounterCategory cat in cats)
{
  Console.WriteLine("범주: " + cat.CategoryName);

  string[] instances = cat.GetInstanceNames();
  if (instances.Length == 0)
  {
    foreach (PerformanceCOunter ctr in cat.GetCounters())
      Console.WriteLine("카운터: " + ctr.CounterName);
  }
  else
  {
    foreach (string instance in instances)
    {
      Console.WriteLine("인스턴스: " + instance);
      if (cat.InstanceExists(instance))
        foreach (PerformanceCounter ctr in cat.GetCounters(instance))
          Console.WriteLine("카운터: " + ctr.CounterName);
    }
  }
}
  • 이 코드는 10,000줄 이상의 결과를 출력한다. 또한 PerformanceCounterCategory.InstaceExists의 구현이 그리 횽ㄹ적이지 않기 때문에 실행을 완료하기까지 시간이 꽤 걸린다. 실제 시스템에서는 카운터들에 관한 자세한 정보는 꼭 필요할 때만 조회하는 것이 바람직하다.
  • 다음 예제는 LINQ 질의를 이용해서 .NET 성능 카운터들을 조회하고 그 결과를 XML 파일에 기록한다.
var x = 
  new XElement("counters",
    from PerformanceCounterCategory cat in
      PerformanceCounterCategory.GetCategories()
    where cat.CategoryName.StartsWith(".NET")
    let instances = cat.GetInstanceNames()
    select new XElement("category",
      new XAttribute("name", cat.CategoryName),
      instance.Length == 0
      ?
        from c in cat.GetCounters()
        select new XElement("counter", new XAttribute("name", c.CounterName))
      :
        from i in instances
        select new XElement("instance", new XAttribute("name", i),
          !cat.InstanceExists(i)
          ?
            null
          :
            from c in cat.GetCounters(i)
            select new XElement("counter", new XAttribute("name", c.CounterName))
        )
  )
);
x.Save("counters.xml");

성능 카운터 자료 읽기

  • 성능 카운터의 값을 읽을 때는 PerformanceCounter 객체를 적절히 생성해서 NextValue 메서드나 NextSample 메서드를 호출한다. NextValue는 단순한 float 값을 돌려주지만 NextSample은 좀 더 자세한 정보를 담은 CounterSample 객체를 돌려준다. 이 객체에는 이를테면 CounterFrequency, TimeStamp, BaseValue, RawValue 같은 속성들이 있다.
  • PerformanceCounter의 생성자는 범주 이름과 카운터 이름을 받는다. 또한 선택적인 셋째 인수를 통해서 원하는 인스턴스를 지정할 수도 있다. 예컨대 다음은 현재 컴퓨터에 있는 모든 CPU의 활용도를 표시하는 코드이다.
using (PerformanceCounter pc = new PerformanceCounter("프로세서", "% 프로세서 시간", "_전체"))
  Console.WriteLine(pc.NextValue());
  • 다음 예는 현재 프로세스의 ‘실제(real)’, 즉 전용(private) 메모리 소비량을 표시한다.
string procName = Process.GetCurrentProcess().ProcessName;
using (PerformanceCounter pc = new PerformanceCounter("프로세서", "전용 바이트 수", procName))
  Console.WriteLine(pc.NextValue());
  • PerformanceCounter는 ValueChanged 같은 이벤트를 제공하지 않으므로 성능 카운터가 변했을 때 통지를 받고 싶다면 폴링(polling)을 이용해야 한다. 즉, 주기적으로 값을 점검해야 한다.
  • 다음 예는 무한 루프로 200ms마다 카운터 값을 점검하되, EventWaitHandle 인스턴스를 통해서 종료 신호를 받으면 루프를 벗어난다.
static void Monitor(string category, string counter, string instance, EventWaitHandle stopper)
{
  if (!PerformanceCounterCategory.Exists(category))
    throw new InvalidOperationException("해당 범주 없음");

  if (!PerformanceCounterCategory.CounterExists(counter, category))
    throw new InvalidOperationException("해당 카운터 없음");

  if (instance == null) instance = "";
  if (instance != "" && !PerformanceCounterCategory.InstanceExists(instance, category))
    throw new InvalidOperationException("해당 인스턴스 없음");

  float lastValue = 0f;
  using (PerformanceCounter pc = new PerformanceCounter(category, counter, instance))
    while(!stopper.WaitOne(200, false))
    {
      float value = pc.NextValue();
      if (value != lastValue)
      {
        Console.WriteLine(value);
        lastValue = value;
      }
    }
}
  • 다음은 이 메서드를 이용해서 프로세서와 하드 디스크 활동을 동시에 감시하는 방법을 보여주는 예이다.
static void Main()
{
  EventWaitHandle stopper = new ManualResetEvent(false);

  new Thread(() =>
    Monitor("프로세서", "% 프로세서 시간", "_전체", stopper)
  ).Start();

  new Thread(() =>
    Monitor("LogicalDisk "% 유휴 시간", "C:", stopper)
  ).Start();

  Console.WriteLine("감시 중 - 아무 키나 누르면 종료됩니다");
  Console.ReadKey();
  stopper.Set();
}

커스텀 성능 카운터 작성과 성능 자료 기록

  • 성능 카운터 자료를 기록하려면 먼저 성능 범주와 카운터를 만들어야 한다. 다음 예에서 보듯이 성능 범주와 그에 속한 카운터들을 하나의 단계에서 모두 만들어야 한다.
string category = "견과류 껍데기(Nutshell) 감시";

// 이 범주에 두 개의 카운터를 만들려고 한다.
string eatenPerMin = "지금까지 먹은 마카다미아 개수";
string tooHard = "너무 단단해서 못 깐 마카다미아 개수";

if (!PerformanceCounterCategory.Exists(category))
{
  CounterCreationDataCollection cd = new CounterCreateionDataCollection();

  cd.Add(new CounterCreationData(eatenPerMin, "분당 먹은 마카다미아 개수(까는 시간 포함)", PerformanceCounterType.NumberOfItems32));
  cd.Add(new CounterCreationData(tooHard, "아무리 해도 까지지 않은 마카다미아 개수", PerformanceCounterType.NumberOfItems32));

  PerformanceCounterCategory.Create(category, "Test Category", PerformanceCounterCategoryType.SingleInstance, cd);
}
  • 이제 Windows 성능 모니터의 ‘카운터 추가’ 대화상자에서 새 카운터들을 볼 수 있다. (아래 그림)

  • 나중에 같은 범주에 또 다른 카운터들을 추가하려면 먼저 PerformanceCounterCategory.Delete로 기존 범주를 삭제한 후 범주를 다시 만들어서 모든 카운터를 함께 추가해야 한다.
  • 성능 카운터 추가와 삭제에는 관리자 권한이 필요하다. 이 때문에 그런 작업은 응용 프로그램을 설치할 때 함께 진행하는 것이 일반적이다.
  • 일단 카운터를 만들었다면 다음으로 할 일은 카운터 값을 갱신하는 것이다.
    • PerformanceCounter를 적절히 인스턴스화하고 ReadOnly 속성을 false를 설정한 후 RawValue 속성에 원하는 값을 설정하면ㅁ 된다. 또는 Increment 메서드나 IncrementBy 메서드를 이용해서 기존 값을 갱신할 수도 있다.
string category = "견과류 껍데기(Nutshell) 감시";
string eatenPerMin = "먹은 마카다미아 개수";

using (PerformanceCounter pc = new PerformanceCouter(category, eatenPerMin, ""))
{
  pc.ReadOnly = false;
  pc.RawValue = 1000;
  pc.Increment();
  pc.IncrementBy(10);
  Console.WriteLine(pc.NextValue());  // 1011
}

Stopwatch 클래스

  • Stopwatch 클래스는 실행 시간을 손쉽게 측정할 수 있는 메커니즘을 제공한다. Stopwatch는 운영체제와 하드웨어가 제공하는 가장 높은 해상도의 시간 측정 메커니즘을 사용하는데, 일반적으로 그런 메커니즘의 해상도는 1ms 미만이다(반면 DateTime.Now와 Environment.TickCount의 해상도는 15ms 정도이다)
  • Stopwatch를 사용하려면 먼저 StartNew 메서드를 호출한다. 이 메서드는 Stopwatch 인스턴스를 생성해서 시간 측정을 시작한다 (또는 Stopwatch 인스턴스를 직접 생성한 후 Start를 호출해도 된다) Stopwatch 인스턴스의 Elapsed 속성은 측정이 시작된 후 흐른 시간을 담은 TimeSpan 객체를 돌려준다.
Stopwatch s = Stopwatch.StartNew();
System.IO.File.WriteAllText("text.txt", new string('*', 3000000));
Console.WriteLine(s.Elapsed);  // 00:00:01.4322661
  • Stopwatch는 또한 ElapsedTicks라는 속성도 제공하는데, 이 속성은 측정 시작 후 흐른 틱(tick)들의 개수에 해당하는 long 값을 돌려준다. 이 틱수를 Stopwatch.Frequecy로 나누면 초 단위 시간이 나온다. 그 외에 ElapsedMilliseconds라는 속성이 있는데, 많은 경우 이 속성이 사용하기가 가장 편리하다.
  • Stop 메서드를 호춢하면 Elapsed와 ElapsedTicks가 고정된다. Stopwatch가 ‘돌아가는’ 도중에 어떤 실질적인 배경 작업이 진행되는 것은 아니므로 성능 때문에 굳이 Stop을 호출할 필요는 없다.
[ssba]

The author

지성을 추구하는 디자이너/ suyeongpark@abyne.com

댓글 남기기

This site uses Akismet to reduce spam. Learn how your comment data is processed.