[프로그래밍 노트] 성능 분석하기 (2): CPU Profiler

변경 내역

  1. 2006.07.30 작성.

이 글은 월간 마이크로소프트웨어(일명 마소) 2006년 8월호 프로그래밍 노트 칼럼에 기고한 글입니다. 물론 구성이나 내용 상의 차이가 있을 수 있습니다.

프로필러는 작동시의 프로그램의 동작, 그 중에서도 함수호출의 빈도와 소요시간을 측정하는 성능분석도구이다. 프로필러는 기록된 사건의 흐름이나 관찰된 사건의 통계 요약을 출력한다. 프로필러는 하드웨어 인터럽트, 코드 계측(code instrumentation), 운영체제 훅(hook), 그리고 성능카운터 등의 다양한 기술을 활용하여 데이터를 수집한다.

Performance analysis, Wikipedia

최재훈 | 임백준씨의 글에서는 자신감과 자연스러움이 배어 나오는데 필자 자신의 글을 읽으면서 어색함을 감출 수 없다. 기술 전달에만 집착한 나머지 독자에겐 재미없는 글이 된 것은 아닌지 걱정된다. 보다 읽을 맛 나는 글을 쓰기 위해 노력해야겠다.

지난 시간에는 마이크로소프트사가 제공하는 CLR Profiler의 기본적인 사용법과 CLR(Common Language Runtime)의 메모리 운영방식에 대해 간략하게 알아봤다. CLR Profiler가 제공하는 가비지 수집 회수, 시간의 흐름에 따른 메모리 사용량의 변화 등의 다양한 정보를 활용하여 성능상의 병목구간을 찾아낼 수 있었다.

7월 컬럼이 메모리 운영방식에 초점을 맞추었다면, 이번 기사는 사람들이 "성능분석"하면 직관적으로 떠올리는 CPU 프로파일링을 해보려 한다. CPU 프로파일링은 응용프로그램의 각 구간이 전체실행시간 중 몇 %를 차지하는지 분석함으로써 병목구간이 어디인지 알아보는 것이다. 필자는 몇 년 전 데이터 구조를 수강할 때 time() 함수를 main() 함수의 처음과 끝 부분에 놓고, 삽입 정렬, 선택 정렬, 퀵 정렬 등 여러 가지 정렬 알고리즘의 성능을 비교해보기도 했다. 이것은 가장 간단하면서도 흔하게 접할 수 있는 형태의 CPU 프로파일링 방식이다.

<리스트 1> 7월 컬럼 중 오타

필자가 지난 기사(성능 분석하기(1) – CLR Profiler)를 검토해 보니 잘못 기재된 내용이 있었다. 다음과 같은 오타가 있었다.

  1. <리스트 1> "가비지 콜렉터의 상태 변화"의 가운데 그림을 보면 Mark라고 표시되어 있다. 그 오른쪽 그림에는 원래 Sweep이라는 글이 적혀 있었는데, 편집과정에서 누락되었다.
  • 지난 기사에선 <리스트 1>의 가운데 그림이 (2), 그 아래 그림은 (3)으로 표시되었다. 하지만 이것은 틀린 것이고, 가운데 그림 중 왼쪽(Mark)은 (2)이고 오른쪽(Sweep)은 (3)이다. 마지막 그림은 (4)이다.

    다시 말하자면, Gen 0 Collection 화살표 전까지가 그림 (1)이다. Gen 0 Collection와 Create an object 사이의 두 그림이 각각 (2), (3)이다. 마지막으로 Create an object 후가 그림 (4)이다.

  • 소제목 CLR Profiler의 결과를 분석해보자 중 다음과 같은 문장이 있다. "단순한 계산으로는 최소한 회의 가비지 수집이 일어났음을 알 수 있다." 이 문장은 "단순한 계산으로는 최소한 867,019,216/291,759 = 2970회의 가비지 수집이 일어났을 것임을 알 수 있다."로 수정해야 한다.

  • 필자는 기사가 편집되어 세상으로 나오면 반드시 잘못된 내용이나 오타가 없었는지 확인해본다. 이번 경우와 같이 문제가 발견되면 그 내용을 블로그에 올린다. 필자의 블로그에서 "마소"로 검색하면 관련 글을 쉽게 찾을 수 있다. 올바른 "가비지 콜렉터의 상태 변화" 그림은 마소 7월호 원고의 오타에서 볼 수 있다.

    System.DateTime

    이미 6월("XML 직렬화")과 7월("성능분석 – CLR Profiler")에서 사용했던 예제라 식상한 감이 있지만, 이번에도 Customer 객체의 XML 직렬화 코드(리스트 2)를 기준으로 이야기를 전개해보겠다. 기초를 다지고 난 후엔 좀더 복잡한 응용프로그램을 분석해 볼 예정이니 지적 도전욕구가 불타오르는 독자들은 미안하지만 한번만 더 참아주길.

    지난번 기사를 읽었던 독자에겐 사족이 될 수 있겠지만 오랜만에 마소를 읽는 독자를 위해 부연설명을 하자면, 클래스 Customer은 고객의 이름이나 주소를 표현하는 단순한 객체이다. Customer::BuildXml() 메써드를 실행시키면 Customer 인스턴스의 멤버 변수 값을 XML 문자열로 출력한다. 그리고 클래스 Program은 콘솔 응용프로그램 ConsoleApplication1.exe의 진입점을 제공한다. ConsoleApplication1.exe는 매개변수로 넘겨 받은 회수만큼 Customer::BuildXml() 메써드를 반복 호출할 뿐이다.

    <리스트 2> XML 직렬화 예제

    public class Customer
    {
        public string Name;
        // 지면상의 이유로 Address의 BuildXml 메써드 구현은 제시하지 않는다. 하지만 Customer의 BuildXml과 유사하다고 생각하면 된다.
        public Address[] Address;
    
        public string BuildXml()
        {
            string xmlStr = string.Empty;
    
            xmlStr += "<?xml version=\"1.0\" encoding=\"utf-16\"?>" + Environment.NewLine;
            xmlStr += "<Customer xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://example\">" + Environment.NewLine;
            xmlStr += "  <Name>" + Name + "</Name>" + Environment.NewLine;
    
            foreach(Address address in Address)
            {
                xmlStr += address.BuildXml() + Environment.NewLine;;
            }
    
            xmlStr += "</Customer>" + Environment.NewLine;;
            return xmlStr;
        }
    }
    
    ////////////////////////////////////////////////////////////////////////////////////
    class Program
    {
        [STAThread]
        static void Main(string[] args)
        {
            if(args == null || args.Length < 1)
            {
                Console.WriteLine("iteration needed!");
                return;
            }
            int iterations = int.Parse(args[0]);
    
            Customer customer1 = CreateCustomerInstance();
    
            for(int i=0; i<iterations; i++)
            {
                string xmlStr = customer1.BuildXml();
            }
        }
    }
        

    응용프로그램의 전체 성능을 측정하기 위한 가장 간단한 방법은 아무래도 System.DateTime을 사용하는 것이다. <리스트 3>와 같이 측정할 구간 앞뒤에 DateTime.Now를 호출하여 실행시간을 측정할 수 있다. 누구나 생각할 수 있는 방법이고, 일회성 측정이 목적이라면 유용한 수단이다. 그러나 쉬운 방법인 만큼 문제도 많다.

    우선 실행시간 측정을 위해 추가한 코드는 어디까지나 성능측정시에만 필요하다는 점이다. 제품을 출시할 때 "Console.WriteLine(DateTime.Now – startDateTime);"과 같은 코드를 그대로 놔둘 수는 없다. 별것 아닌 듯 보여도 DateTime.Now도 수만 번 호출하면 컴퓨터가 전혀 기뻐하지 않는다.

    <리스트 3>의 경우 Customer::BuildXml() 메써드를 반복 호출하는 구간의 실행시간은 알 수 있지만, Customer::BuildXml() 메써드 내부의 구간별 실행시간은 알 수 없다. Address::BuildXml() 메써드의 호출이 Customer::BuildXml() 메써드에서 차지하는 비중을 알기 위해서는 Customer::BuildXml() 메써드 내부에 또 다른 DateTime.Now를 추가해야 한다. 이런 식으로 필요한 구간마다 성능측정용 코드를 추가하려고 하면 제때 퇴근 못하고 야근을 해야 할지도 모른다.

    <리스트 3> System.DateTime

    [STAThread]
    static void Main(string[] args)
    {
        // 생략……
    
        System.DateTime startDateTime = DateTime.Now;
        for(int i=0; i<iterations; i++)
        {
            string xmlStr = customer1.BuildXml();
        }
        Console.WriteLine(DateTime.Now - startDateTime);
    }
        

    #if / #endif 전처리기 지시문

    C#은 C/C++ 프로그래머에게 익숙한 #define, #if와 같은 전처리기 지시문을 제공한다. 전처리기 지시문을 사용하면, 성능측정용 코드가 필요할 때만 컴파일 되도록 만들 수 있다. <리스트 4>는 디버그/추적 빌드용으로 ConsoleApplication1.exe를 컴파일 했을 때만 측정용 코드가 함께 컴파일 된다. 이렇게 전처리기 지시문을 사용하면 제품 출시 때마다 성능측정용 코드를 삭제하느라, Ctrl+F 키 빨리 누르기 대회를 열 필요가 없어진다. VDT 증후군에 걸릴 위험도 줄어드니 의료비를 아끼는 셈이다.

    하지만 여전히 몇 가지 문제가 해결되지 않고 있다. 우선 릴리즈 빌드를 했을 때CheckStartPoint()와 CheckEndPoint()의 내부는 컴파일되지 않지만, 이 두 함수를 호출하는 코드, 예를 들어 Main() 함수 내부에서의 CheckStartPoint() 호출 코드는 컴파일된다. 결과적으로 비어있는 함수를 계속해서 호출하게 되는데, 전처리기 지시문을 사용하기 전보다는 훨씬 나아졌지만 어느 정도의 성능 저하를 감수해야 한다.

    호출 코드 자체가 컴파일되지 않게 하려면, <리스트 5>와 같이 각 호출 코드 앞뒤에 "#if (DEBUG || TRACE)" / "#endif" 등을 써넣어야 한다. 하지만 생각해보자. 처음 며칠 간은 잊지 않고 지시문을 써넣을 것이다. 그러나 2주, 3주가 지나고 나면 누군가 깜박 잊고 지시문 없이 함수를 호출하는 사태가 벌어지고 만다. 실수하는 사람이 그날 초코파이를 사기로 내기하는 것도 좋다. 운이 좋다면 그렇지 않을 때보다 50%는 실수가 줄어들 것이다. 하지만 여러분이 초코파이의 희생자가 되고 싶지 않다면 보다 나은 방법을 찾아야 한다. 다행히도 그 방법은 존재하고 어렵지도 않다.

    <리스트 4> 전처리기 사용하기

    class Program
    {
        // 측정용 코드 시작
        private static DateTime startDateTime;
    
        private static void CheckStartPoint()
        {
    #if (DEBUG || TRACE)
            startDateTime = DateTime.Now;
    #endif
        }
    
        private static void CheckEndPoint()
        {
    #if (DEBUG || TRACE)
            Console.WriteLine(DateTime.Now - startDateTime);
    #endif
        }
        // 측정용 코드 끝
    
        [STAThread]
        static void Main(string[] args)
        {
            // 생략…….
    
            CheckStartPoint();
            for(int i=0; i<iterations; i++)
            {
                string xmlStr = customer1.BuildXml();
            }
            CheckEndPoint();
        }
    }
        

    <리스트 5> 빈 함수 호출 없애기

    [STAThread]
    static void Main(string[] args)
    {
        // 생략…….
    
    #if (DEBUG || TRACE)
        CheckStartPoint();
    #endif
    
        for(int i=0; i<iterations; i++)
        {
            string xmlStr = customer1.BuildXml();
        }
    
    #if (DEBUG || TRACE)
        CheckEndPoint();
    #endif
    }
        

    System.Diagnostics.ConditionalAttribute 특성

    앞서 살펴봤듯이 #if / #endif 와 같은 전처리기 지시자는 소스 코드를 복잡하게 만든다. 이해하기 쉽지 않을 뿐더러, 릴리즈 빌드시에 성능분석용 코드를 완전히 제거하기도 힘들다. .NET Framework는 전처리기 지시자를 대체 또는 보완할 수 있는 방법을 제공한다. <리스트 6>는 <리스트 4>의 전처리기 지시자를 Conditional 특성으로 대체했다. Conditional 특성은 #if / #endif 와 유사한 기능을 제공하지만, 그 작동 방식은 매우 다르다. Conditional 특성이 붙은 메써드는 조건과 상관없이 무조건 컴파일 된다. <리스트 6>에 경우에는 디버그(DEBUG) 또는 추적(TRACE) 조건이 없더라도 어셈블리에 CheckStartPoint(), CheckEndPoint() 가 포함된다. 다만 JIT(Just In Time) 단계에서 이 두 메써드를 호출하는 지점을 모두 무시하게 된다. 다시 말해 <리스트 5>와 같은 효과를 볼 수 있다. <화면 1>는 DEBUG/TRACE 상수를 제거한 후에 디버깅(F5)을 시작한 장면이다. 비록 동영상으로 보여주지는 못하지만 한단계씩 코드를 실행시켜보면 CheckStartPoint()와 CheckEndPoint()는 실행되지 않는다.

    <리스트 6> Conditional 특성 활용하기

    using System.Diagnostics;
    
    class Class1
    {
        // 측정용 코드 시작
        // #if (DEBUG || TRACE)
        private static DateTime startDateTime;
    
        [Conditional("TRACE"), Conditional("DEBUG")]
        private static void CheckStartPoint()
        {
            startDateTime = DateTime.Now;
        }
    
        [Conditional("TRACE"), Conditional("DEBUG")]
        private static void CheckEndPoint()
        {
            Console.WriteLine(DateTime.Now - startDateTime);
        }
        // #endif
        // 측정용 코드 끝
    
        [STAThread]
        static void Main(string[] args)
        {
            // 생략…….
    
            CheckStartPoint();
            for(int i=0; i<iterations; i++)
            {
                string xmlStr = customer1.BuildXml();
            }
            CheckEndPoint();
        }
    }
        

    빌드 구성

    빌드 구성

    <화면 1> 빌드 구성

    좋은 점이 있으면 그 반대도 있기 마련이다. Conditional 특성에는 몇 가지 제한사항이 있다. 우선 메써드에만 적용할 수 있다. <리스트 6>에는 CheckStartPoint(), CheckEndPoint() 메써드 외에도 정적 변수 startDateTime가 선언되어 있다. 이 변수 역시 측정용 코드이긴 하지만 메써드가 아니기 때문에 Conditional 특성을 적용할 수 없었다. 만약 이러한 변수도 릴리즈 빌드시에 컴파일이 되지 않길 원한다면, Conditional 특성과 #if / #endif 전처리기 지시자를 함께 사용하면 된다. <리스트 6>에서는 단순히 전처리기 지시자의 주석을 해제하면 된다.

    Conditional 특성에 OR 조건을 부여하는 것은 쉽다. <리스트 6>와 같이 여러 개의 조건을 나열하기만 하면 된다. 그러나 AND 조건을 표현하려면 약간의 꽁수가 필요하다. <리스트 7>과 <리스트 7>은 각각의 방법을 보여준다. <리스트 7>은 MSDN의 예제인데 조건 B가 필요한 메써드 DoIfAandB()를 조건 A가 필요한 메써드 DoIfA()를 통해 호출한다. 만약 올바른 접근권한 설정을 통해 DoIfAandB() 메써드가 직접 호출되는 것만 방지할 수만 있다면, 조건 A와 조건 B 모두 충족될 때만 해당 코드가 실행될 것이다. 이 방법에는 단점이 있는데 조건 A만 충족되었을 때 DoIfA() 메써드가 호출된다는 점이다. 비어있는 함수로 처리되겠지만 그것조차도 부담스러울 수 있다.

    <리스트 8>은 전처리기 지시자를 활용한 방식이다. 조건 A와 조건 B가 모두 있을 때만 새로운 조건 BOTH를 생성한다. <리스트 7>보다 오히려 간단하면서도 비어있는 메써드를 호출하는 일도 없다.

    <리스트 7> AND 조건 부여하기 – 첫 번째 방법

    [Conditional("A")]
    static void DoIfA()
    {
        DoIfAandB();
    }
    
    [Conditional("B")]
    static void DoIfAandB()
    {
        // Code to execute when both A and B are defined...
    }
        

    <리스트 8> AND 조건 부여하기 – 두 번째 방법

    #if ( A && B )
    #define BOTH
    #endif
    
    [Conditional("BOTH")]
    static void DoIfAandB()
    {
        // Code to execute when both A and B are defined...
    }
        

    CPU 프로필러

    지금까지 사용자의 필요에 따라 직접 성능측정 코드를 작성해 넣는 다양한 방법에 대해 알아봤다. 응용프로그램의 성능저하 요인을 어느 정도 파악하고 하고 있는 상황이라면 전문적인 CPU 프로필러를 사용하는 것보다 직접 측정용 코드를 작성하는 것이 오히려 나을 수 있다. 일반적으로 프로필러는 응용프로그램의 성능을 매우 저하시킨다. 그러므로 상용서버에 적용되어 있는 웹 응용프로그램 등에 프로필러를 적용해 볼 수는 없는 노릇이다.

    하지만 사용자 정의 코드로는 처음에 지적했던 문제 중 한가지는 해결할 방법이 없다. 성능저하의 원인을 짐작조차 못하는 경우에는 소스코드 중간중간에 성능측정 코드를 집어넣어야 한다. 그러다 보면 100줄짜리 소스코드를 살펴보니 그 중 30줄은 CheckFirstPoint()와 CheckEndPoint() 이더라는 상황을 맞게 된다. 또한 <리스트 6> 등에서는 동기화 문제를 전혀 생각하지 않았지만, 실제 응용프로그램을 분석할 때는 멀티쓰레드 환경도 고려해야 한다. 여러분도 알다시피 멀티쓰레드 프로그래밍은 싱글 쓰레드 프로그래밍보다 10배는 어렵다.

    이쯤 되면 CPU 프로필러를 사용하는 것이 유일한 대안일 수 있다. 필자는 되도록이면 라이센스의 제약을 받지 않으면서도 별도의 비용 없이 사용할 수 있는 프로필러를 여러 차례 찾아봤다. 그리하여 NProf와 DevPartner Performance Analysis Community Edition을 찾았다. 두 개의 프로필러의 기능은 다음과 같다.

    • NProf

      • .Net Framework 1.1 및 2.0 지원
  • 콘솔 / 윈도우 폼 / ASP.NET 응용프로그램 프로파일링 지원

  • Visual Studio와의 통합된 환경은 아직 지원 안됨

  • 라이센스: GPL 또는 LGPL

  • DevPartner Performance Analysis Community Edition

  • <필자 노트> DevPartner Studio

    지난 기사에서 필자는 DevPartner .NET Profiler Community Edition를 다음과 같이 소개했다. .NET Framework 1.1 지원함, VS.NET 2000 및 2003 지원. 마소 7월 호가 발행된 후에 필자는 Compuware 社로부터 한 통의 메일을 받았다. 내용인 즉 DevPartner .NET Profiler Community Edition의 후속 버전인 DevPartner Performance Analysis Community Edition가 출시되었고, .NET Framework 1.1과 2.0, Visual Studio .NET 2003/2005를 모두 지원한다는 것이었다.

    성능 분석 과정에서 DevPartner 제품을 활용하는 것은 후에 기사를 통해 직접 소개할 예정이다. 여기서는 독자가 알아둬야 할 몇 가지 사실만 지적해두려 한다. 새 버전이 출시되면서 라이센스도 바뀌어서 사용기한이 추가됐다. 45일간 무료로 사용할 수 있다고 한다. 필자가 직접 확인해 본 결과 현시점(2007년 7월 12일)에서 Compuware 한국 사이트는 아직 기존 DevPartner .NET Profiler Community Edition를 제공하고 있다. 추후 예고 없이 삭제될 수 있다고 하니 필요한 사람은 미리 다운로드 받아야 할 것 같다. 최신 버전인 DevPartner Performance Analysis Community Edition를 사용해 보고 싶다면 Compuware 영문 사이트에 방문하면 된다.

    참고) DevPartner 제품의 설치폴더(기본값: C:\Program Files\Compuware\)에 관련문서가 있다. 라이센스, 설치, 사용법 등을 한번쯤 읽어두자.

    DevPartner Performance Analysis Community Edition 사용해보기

    제품을 설치하고 나면 Visual Studio 상단 메뉴에 DevPartner가 추가된다. [DevPartner / Start Without Debugging with Performance Analysis] 버튼을 클릭하면, 응용프로그램이 실행된다. 사용자의 입력이 필요한 윈도우 폼 또는 웹 응용프로그램이라면 실제 상황에서 사용하는 버튼을 클릭하거나 필요한 값을 입력하고 난 후 응용프로그램을 종료시켜야 한다. ConsoleApplication1.exe는 사용자의 입력이 필요 없는 콘솔 응용프로그램이기 때문에 스스로 종료된다. CLR Profiler를 사용할 때도 그랬지만, 프로파일링시에는 응용프로그램의 실행시간이 보통 때보다 수십 또는 수백 배 증가할 수 있으므로 인내심을 가져야 한다.

    응용프로그램이 종료되면 성능분석결과(화면 2)가 나온다. CLR Profiler와 달리 가비지 콜렉터의 작동방식 등을 몰라도 누구나 이해할 수 있는 형태로 결과가 제시된다. 기본적으로 분석결과는 메써드가 전체실행시간에서 차지하는 비중이나 호출회수 등을 보여준다. 필요하다면 최상위 20개만 살펴볼 수도 있다. 뿐만 아니라 소스코드를 직접 살펴보며 각 줄이 성능에 미치는 영향을 확인할 수도 있다. CLR Profiler에서와 같이 호출 트리를 볼 수도 있는데, 메써드나 소스코드 줄을 선택하고 마우스 오른쪽 버튼을 누르면 "Go to Call Graph" 메뉴를 볼 수 있다.

    DevPartner Performance Analysis의 성능분석결과 보기 #1

    DevPartner Performance Analysis의 성능분석결과 보기 #2

    DevPartner Performance Analysis의 성능분석결과 보기 #3

    <화면 2> DevPartner Performance Analysis의 성능분석결과 보기

    NProf 사용해보기

    NProf는 오픈소스 프로젝트의 산물이다. 최근에는 .NET Framework 2.0과 ASP.NET 웹 프로젝트의 성능분석 기능이 추가됐다. 시간이 흐를수록 더 나은 모습을 보여주고 있다.

    NProf로 성능분석을 하려면 우선 [File / New] 메뉴 또는 "Ctrl+N"을 눌러서 새로운 프로젝트를 구성해야 한다. 프로젝트 구성(화면 3)을 선택한다. ConsoleApplication1.exe는 Customer::BuildXml() 메써드의 호출회수를 입력 매개변수로 받아야 하니, Argument 란에 10000을 써 넣었다. 프로젝트 구성을 마쳤으면 [Project / Start project run] 메뉴 또는 "F5"를 누른다. DevPartner .NET Profiler Community Edition와 마찬가지로 응용프로그램이 종료되면 <화면 3>의 두 번째 사진과 같이 성능분석결과가 제시된다. 비록 Compuware사의 제품과 달리 메써드별 비중만 보여주지만, 많은 경우에는 부족함을 느끼지는 않을 것이다.

    NProf로 성능분석하기 #1

    NProf로 성능분석하기 #2

    <화면 2> NProf로 성능분석하기

    글을 마치며

    성능분석에 관한 첫 번째 컬럼을 쓴 이후에 필자는 고민을 거듭했다. 어떻게 해야 독자들이 성능분석을 보다 쉽게 잘 이해할 수 있을지 생각해봤다. 사실 CLR Profiler에 대한 7월 기사는 설치방법과 콘솔 응용프로그램에 대한 간단한 분석예제만 제시했기 때문에 본격적인 성능분석을 하기 위해선 보다 깊게 주제를 다룰 필요가 있었다. 하지만 그 전에 또 다른 방식의 성능분석 방식을 소개하기로 했다. 하나만 집중적으로 파고 들면 지루해지기도 하거니와 문제의 다양한 측면을 살피지 못하게 될 수 있기 때문이다.

    필자의 의도가 올바르게 실현되어서, 독자 여러분이 이 글을 흥미롭게 읽었기를 바란다. 이제 여러분은 선택할 수 있는 다양한 옵션부터 모두 쥐게 되었고, 실제의 복잡한 응용프로그램을 다루기 위한 기본적인 준비가 됐다. 다음 컬럼에서는 종전과는 달리 테스트용이 아닌 복잡한 형태의 응용프로그램을 실전과 같이 분석해보겠다. 이제서야 기승전결 중 "전"에 들어섰다. 필자가 좋아하는 영화 "아마데우스"로 따지자면 모차르트가 오페라 "돈 조반니"를 세상에 내보이는 순간이다. 비록 필자의 필력이 모차르트의 재능에 비할 바는 아니지만, 최선을 다해 준비할 테니 기대해주었으면 한다.

    참고 문헌

    최 재훈

    블로그, 페이스북, 트위터 고성능 서버 엔진, 데이터베이스, 지속적인 통합 등 다양한 주제에 관심이 많다.
    Close Menu