오랫만에 데브피아 무료 세미나를 엽니다. Hello .NET Framework 4라는 제목으로 열리며, 데브피아 C# 포럼 SYSOP 정은성님과 데브피아 C# 포럼 및 Chul's Blog Time 블로그 운영자 / 삼성소프트웨어멤버십 회원 이철님과 함께 진행하는 공동 세미나입니다. 많은 관심과 참여 부탁드립니다. :-)
닷넷 프레임워크 기반 프로그래밍에서 회자되는 내용 중에 "어려운 범주"에 속하는 주제들이 몇 가지 있는데, 그 중 하나가 AppDomain에 관련된 것입니다. 닷넷 프레임워크는 전통적인 프로그래밍 모델인 프로세스와 스레드의 개념 위에 응용프로그램 도메인이라는 개념을 새롭게 제공합니다.
닷넷 프레임워크 위에서 실행되는 응용프로그램은 JRE의 경우와 마찬가지로 논리적으로 구획이 나뉘어진 하나의 Virtual Machine 위에서 실행되고, 모든 메모리 관리가 이루어지게 됩니다. 생산성 향상을 위하여 이러한 부분을 내부적으로 모두 감추고, 마치 컴파일러나 IL Assembler가 직접 실행 가능한 EXE 파일을 생산해내는것처럼 묘사되어있지만, 우리가 일상적으로 만들어내는 닷넷 컴파일러 기반의 EXE 파일은 미리 프로그래밍된 스텁 코드입니다.
대부분의 경우, 응용프로그램 도메인이 여러 개일 필요 없이, 단순히 프로세스 내에 여러 개의 스레드만을 사용하여 프로그래밍하기 때문에, 스텁 코드의 내용에 대해 고민할 필요가 없습니다. 하지만, 닷넷 프레임워크 실행 환경 안에서 다른 어셈블리를 불러온다는 것은, 기존에 LoadLibrary 같은 Win32 API를 이용하여 DLL을 후기 바인딩하는 것과는 개념적으로 차이가 있습니다.
LoadLibrary의 경우는 Win32 API이며, 메모리를 비롯한 모든 자원 관리가 운영 체제 내의 커널에 의하여 처리됩니다. 하지만 닷넷 프레임워크 환경에서 다른 어셈블리를 로드하고 해제하는 일은 닷넷 프레임워크 환경 내에 위치한 개별 VM에서 처리되는 것이고, 로드하고 해제하는 위치 또한 어셈블리를 로드하도록 요구한 코드가 속해있는 응용프로그램 도메인에 고정시키게 됩니다. 그리고, 프로그램의 안정성을 위하여, 한번 로드한 어셈블리를 개별적으로 언로드하는 동작은 Win32 API 때와는 달리 지원되지 않습니다. 이러한 특성 때문에 응용프로그램 도메인을 별도로 분리하여 어셈블리를 필요한만큼 한꺼번에 로드하고, 일괄적으로 해지하는 방식이 나타나게 된 것입니다.
이러한 규칙 아래에서 프로그래밍을 해야 하기 때문에 만약 여러 응용프로그램 도메인을 생성하고 해지하는 일이 빈번한 프로그램을 작성한다면, 다음과 같은 사항들을 고려할 필요가 있습니다.
컴파일러가 제공하는 표준 Stub 프로그램 대신, 직접 Visual C++ 컴파일러로 작성하여 mscoree.dll과 fusion.dll에 링크하는 커스텀 Stub 프로그램을 작성하여 프레임워크 응용프로그램을 시작하기
스텁 프로그램을 직접 작성하지 않을 경우, 진입점 메서드에 [LoaderOptimization] 어트리뷰트를 사용하여 다중 응용프로그램 도메인을 관리함을 명시하는 방법
그리고, 응용프로그램 도메인을 이용하여 프로그래밍할 때에는, Global Assembly Cache에 등록할 어셈블리가 아니라 할지라도, 강력한 이름을 어셈블리 내에 부여하여, 어셈블리에 고유한 Identity를 설정하는 것이 좋습니다.
흔히 강력한 이름 (Strong Name)은 어셈블리의 이름, 버전, 언어 및 지역 코드 만으로는 완전하게 식별할 수 없다는 것을 보완하기 위하여, 그리고 다수의 DLL 간의 버전 충돌이 발생하였을 때 DLL을 식별할 여지가 없는 DLL 지옥 (DLL hell)에 빠지는 상황을 예방하기 위하여 도입된 개념입니다만, GAC에 등록을 하던 하지 않던 간에 강력한 이름으로 서명한 어셈블리는 버전 간 충돌에 대한 걱정 없이 "고유한 성격"을 유지할 수 있습니다. 그런데, 이 부분이 왜 중요할까요?
간혹 전사적 정책에 따라, Active Directory 등에 연결되어있는 모든 컴퓨터로부터 관리자 권한을 박탈하고, 최소한의 권한만으로 컴퓨터를 액세스할 수 있도록 관리하는 경우가 있는데, 이런 환경에서 GAC는 사용하기 불편한 상태가 됩니다. (GAC의 기본 디렉터리가 %WINDIR%\Assembly에 있으므로 GAC를 변경하는 작업은 "당연하게도" 관리자 권한이 요구됩니다.) 지금 설명할 응용프로그램 도메인이 어셈블리를 찾는 방법은, GAC를 사용할 수 없을 경우, GAC를 대신하여 이용할 수 있는 방법이므로, 강력한 이름을 기초로 식별하는 방법이 유일하기 때문입니다.
닷넷 프레임워크에서 어셈블리를 로드하는 기본 방식은, 응용프로그램 도메인이 시작될 때 설정된 BaseDirectory를 기준으로 아랫쪽에 있는 디렉터리나 파일들을 검색하는 방식입니다. 그러나 이 방법은 편리하지만 매우 제약이 심합니다. 그래서 부수적으로 제공하는 API가 하나 더 있는데, 바로 System.Reflection 네임스페이스의 Assembly 클래스가 노출하는 일부 정적 메서드들 중 From 으로 끝이나는 정적 메서드 시리즈들입니다. 그런데 여기서 고민이 하나 더 생깁니다.
어셈블리를 특정 도메인 위에서 로드하려면, AppDomain 클래스의 메서드를 이용해야 하지만, 앞서 이야기한 규칙에 따르면 자유롭게 로드하기 위해서는 Assembly 클래스의 메서드들을 이용해야 하는 충돌 상황이 발생합니다. 어떻게 이 상황을 풀면 좋을까요? 이 부분에 대해서 많은 Workaround를 찾아다닌 끝에 제가 찾은 방법은 아래와 같습니다.
AppDomain.CreateDomain 메서드를 사용하여 새 응용프로그램 도메인을 만듭니다. 다만, 기존 응용프로그램 도메인과 동일한 Evidence, 유사한 Setting을 상속받을 수 있게 하기 위하여 아래와 같이 매개 변수를 좀 더 구체적으로 써 넣습니다.
반환 형식이 없고, 매개 변수가 없는 일반 함수를 만들고, 본문 안에서 AppDomain.CurrentDomain 속성을 활용하여 새 도메인 기준으로 코드를 작성해 나갑니다. 이 때, AppDomain.CurrentDomain.AssemblyResolve 이벤트를 구독하도록 프로그래밍합니다.
AppDomain.CurrentDomain.AssemblyResolve += new ResolveEventHandler(CurrentDomain_AssemblyResolve);
AssemblyResolve 이벤트는 보통의 이벤트 핸들러와는 다르게 이벤트 핸들러가 직접 결과를 반환하는 형태로 구성되어있습니다. 이 지점에서, 응용프로그램 도메인에 전달한 기준 디렉터리 경로를 활용하여 닷넷 프레임워크가 기본 정책에 따라 찾지 못한 어셈블리를 Assembly.LoadFrom 메서드로 따로 로드한 뒤 객체를 되돌려줌으로서 경로 문제를 보완하게 됩니다. 이 때, 로드할 대상 어셈블리에 대해 강력한 이름 서명이 되어있어야 DLL 간 충돌 문제를 완벽히 예방할 수 있습니다.
private static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
{
string basePath = AppDomain.CurrentDomain.GetData("_ApplicationBasePath") as string;
if (basePath == null || !Directory.Exists(basePath))
return null;
AssemblyName parsedName = new AssemblyName(args.Name);
switch (parsedName.Name)
{
case "My.Common":
case "My.Core":
case "My.Productivity":
return Assembly.LoadFrom(Path.Combine(Path.Combine(basePath, "Library"), String.Concat(parsedName.Name, ".dll")));
default:
return null;
}
}
그리고 새로 만든 함수를 domain.DoCallBack 메서드에 콜백 객체로 전달합니다. 만약, 실행 결과를 되돌려 받기 원한다면, domain.GetData 메서드로 콜백 함수 내에서 설정한 데이터를 넘겨받을 수 있습니다. DoCallBack 메서드는 동기 방식 메서드이므로 콜백 메서드의 실행이 끝나기 전까지는 제어권이 되돌아오지 않습니다.
참고로, 콜백 실행 도중 처리되지 않은 예외는 새로 만든 domain의 UnhandledException 이벤트 핸들러를 호출하게 되며, 이 보다 더 직접적으로 예외 처리를 할 필요가 있다면 아래와 같이 TargetInvocationException 형식에 대한 예외를 잡아내어 InnerException을 조사하도록 하면 더 빠르게 예외 처리를 할 수 있습니다.
도메인의 사용이 모두 끝나면, domain.Unload 메서드를 사용하여 안전하게 도메인에 로드된 모든 어셈블리를 메모리에서 해지합니다.
긴 강좌를 읽어 주셔서 감사합니다. 적절한 상황이나 조건이 그 때 마다 다르겠지만, 프로그램의 규모가 커지고 복잡해 질 수록 .NET Framework의 숨겨진 이면을 충분히 분석하여 활용하는 것이 중요할 것입니다. 응용프로그램 도메인에 관하여 복잡하게 고민 중이신 분들께 도움이 되었으면 합니다.
닷넷 프레임워크 기술들 중 가장 적게 알려지고 그 비중이 많이 축소되어 소개되는 부문이 바로 Visual C++ CLR일 것입니다. 생각보다 C++ CLR이 유용할 수 있음에도 불구하고 여론에 떠밀려서 거의 사장되다시피하고 있지요. 이번 블로그 포스트에서는 C++ CLR을 통해서 실용적인 코딩 하나를 해볼까합니다.
C++과 더불어 관련 라이브러리들 (STL, ATL, WTL, Boost, MFC 등)의 경우 일반적인 응용프로그램을 작성하거나, 성능 튜닝이 필요한 응용프로그램들을 작성하거나, 수학 라이브러리 등의 힘을 빌어 불필요한 오버헤드가 없는 고속 연산을 처리하는 등의 목적에 알맞게 디자인되어있습니다. 그러나 엔터프라이즈 프로그래밍의 경우에서처럼 구조화된 처리를 수행해야 하는 경우 상당히 불편한 점이 많습니다. 간단한 예로 당장 XML 문서 하나 분석하는 것도 썩 편리하지는 않지요.
Visual Studio 패밀리에서 공식적으로 제공되는 기능은 아니지만 C++ CLR도 닷넷 프레임워크 위에서 기동되는 어셈블리를 작성할 수 있습니다. 그리고 더 중요한 것은 기존의 x86 코드나 x64 코드를 MSIL 어셈블리 사이에 끼워넣을 수 있다는 점입니다. 이러한 사실을 바탕으로 하여 하이브리드 코드를 만들 수 있습니다.
그럼 하나씩 살펴보도록 하겠습니다.
1. C++ CLR 기반의 클래스 라이브러리 프로젝트 하나를 만듭니다. 프로젝트 이름으로 여기서는 "CLRxASPX"를 사용하기로 하겠습니다.
2. 여느 닷넷 프로젝트처럼 예상 가능한 종류의 파일들이 만들어집니다. AssemblyInfo.cpp 파일은 어셈블리 어트리뷰트를 정의하는 부분으로 다음과 같이 코드가 구성되어있습니다.
AssemblyInfo.cpp 보기
#include "stdafx.h"
using namespace System;
using namespace System::Reflection;
using namespace System::Runtime::CompilerServices;
using namespace System::Runtime::InteropServices;
using namespace System::Security::Permissions;
//
// 어셈블리의 일반 정보는 다음 특성 집합을 통해 제어됩니다.
// 어셈블리와 관련된 정보를 수정하려면
// 이 특성 값을 변경하십시오.
//
[assembly:AssemblyTitleAttribute("CLRxASPX")];
[assembly:AssemblyDescriptionAttribute("")];
[assembly:AssemblyConfigurationAttribute("")];
[assembly:AssemblyCompanyAttribute("")];
[assembly:AssemblyProductAttribute("CLRxASPX")];
[assembly:AssemblyCopyrightAttribute("Copyright (c) 2009")];
[assembly:AssemblyTrademarkAttribute("")];
[assembly:AssemblyCultureAttribute("")];
//
// 어셈블리의 버전 정보는 다음 네 가지 값으로 구성됩니다.
//
// 주 버전
// 부 버전
// 빌드 번호
// 수정 버전
//
// 모든 값을 지정하거나 아래와 같이 '*'를 사용하여 빌드 번호 및 수정 버전이 자동으로
// 지정되도록 할 수 있습니다.
C#에서와 마찬가지인 어트리뷰트 사용법입니다. 다만 C++ 컴파일러의 특성상 空문장임을 뒷쪽에 명시해둔 것이 다른 부분이라고 할 수 있겠습니다. 그리고 네임스페이스의 참조 방법이 C#과 동일하게 되어있습니다. C#에서 흔히 사용하는 using은 C++의 using namespace와 같습니다. 그리고 C++에서 흔히 사용하는 using std::cout; 같은 참조는 C#에서는 using Console = System.Console; 과 같이 표현이 가능합니다.
특이한 점이 하나 더 있다면 stdafx.h의 존재입니다. MFC나 ATL 프로젝트에서 Precompiled Header라는 명목으로 많이 사용되던 것이 C++ CLR에도 그대로 적용된 걸 볼 수 있습니다. 거리낌없이 그 개념 그대로 stdafx.h에 Windows API 헤더를 추가하거나 프로젝트 전반에 걸쳐 사용하고 싶은 다른 라이브러리의 헤더를 추가하면 됩니다.
그럼 stdafx.h는 어떻게 구성되어있는지 한 번 살펴보도록 하겠습니다.
stdafx.h 보기
// stdafx.h : 자주 사용하지만 자주 변경되지는 않는
// 표준 시스템 포함 파일 및 프로젝트 관련 포함 파일이
// 들어 있는 포함 파일입니다.
#pragma once
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
C++ CLR의 유용함이 느껴지는 부분입니다. 굵게 표시한 부분이 예제를 위하여 추가된 코드로, Win32 API 헤더를 추가한 것입니다. WIN32_LEAN_AND_MEAN은 컴파일러나 링커의 처리를 최소화하기 위한 것으로 통계적으로 사용 확률이 그닥 높지 않은 코드, 라이브러리를 사전에 제거해주는 옵션입니다. 예제에서는 API 참조를 위해서만 stdafx.h를 이용한 것이므로 stdafx.cpp에는 다른 서술이 되어있지 않아서 생략합니다.
이제 실제 코드를 살펴보기로 합니다.
CLRxASPX.h 보기
// CLRxASPX.h
#pragma once
using System::Object;
using System::EventArgs;
using System::Web::UI::Page;
namespace CLRxASPX {
public ref class MyCLRPage : public Page { protected:
void Page_Load(Object ^sender, EventArgs ^e);
};
}
using namespace로 모든 네임스페이스의 요소들을 참조할 수도 있지만 필요한 항목들만 위와 같이 참조할 수도 있습니다. 그리고 아래의 클래스 선언을 보면 일반 C++ 클래스 선언과는 구별되는 내용들이 몇 가지 있습니다. 우선, class 키워드 앞에 ref 키워드가 사용된 걸 볼 수 있는데 이것이 이 클래스가 Managed Class 임을 나타내는 것입니다. 그 다음에는 상속 대상을 ASP.NET의 페이지 클래스로 정한 것이 보입니다.
Protected 멤버 메서드로 Page_Load 메서드가 선언된 것이 보입니다. 이 때 알아둘 것이 있는데 ASP.NET은 내부적으로 Reflection을 통해서 실제로 Page 클래스의 Load 이벤트에 추가한 이벤트 처리기가 아니라도 자동으로 이름을 통하여 호출 대상을 결정하기 때문에 위의 메서드는 항상 실행됩니다.
그리고 C++ CLR에서 닷넷 프레임워크의 형식들을 이용할 때에는 닷넷 프레임워크 상의 관리되는 참조 객체임을 표현하기 위하여 과거의 Managed Extensions for C++ 시절때 사용하였던 __gc * 형식 대신 ^ 기호를 사용합니다. 포인터의 * 와는 구분되는 것으로 주소값을 얻기 위한 형변환 시도는 허용되지 않습니다. 이렇게 사용되는 형변환은 주소값에 대한 형변환이 아니라 형식 자체에 대한 형변환 시도가 됩니다. 별도로 해당 형식이 암묵적/명시적 형변환 연산자를 가지고 있지 않을 경우 상속 관계와는 무관하므로 컴파일이 실패합니다. 참고로, Page_Load 메서드가 이벤트 핸들러로서의 조건을 충족하려면 반환 형식은 void, 즉 반환 대상이 없으며, 두 개의 매개 변수를 받아들여야 합니다. 하나는 이벤트를 발행한 출처의 참조, 또 하나는 이벤트 인자 개체의 참조입니다. 자세한 내용은 System::EventHandler 대리자를 참고하시면 됩니다.
CLRxASPX.cpp 보기
// 기본 DLL 파일입니다.
#include "stdafx.h"
#include "CLRxASPX.h"
using System::String;
using System::Object;
using System::IntPtr;
using System::EventArgs;
using System::Web::UI::WebControls::Label;
구현하기 나름이지만, 원래의 C++ 코드 스타일을 유지하기 위하여 선언부와 구현부를 나눈다면 위와 같은 형태로 cpp 파일에 메서드 본문을 작성할 수 있습니다. 이 때, 메서드의 시그니처에 전체 네임스페이스가 지정되어야 함을 유의합니다.
windows.h 헤더 파일 내에 포함되어있는 항목들 중 시스템 정보를 추출하는 API를 이용해보기로 합니다. 이 부분은 C#이나 기타 닷넷 환경에서는 원래 Platform Invoke를 통하여 우회적으로 마샬링해야 하는 부분이지만 C++ CLR의 특성에 따라 Native Code를 평소처럼 실행할 수 있다는 것을 보여줍니다. SYSTEM_INFO 구조체의 주소값을 전달하여 시스템 정보를 구조체에 채우도록 만들고 이를 ASP.NET 서비스에서 보여준다는 것이 예제 프로그램의 컨셉입니다.
Label 컨트롤을 생성하기 위하여 gcnew 연산자를 사용하였습니다. 역시 C++에서 원래 사용하던 new 연산자와 구분되는 것입니다. gcnew 연산자로 관리되는 객체를 만들고, Text 프로퍼티에 문자열을 지정하였습니다. 관리 객체에 지정되는 문자열은 자동으로 System::String의 인스턴스로 분류되어 처리되도록 만들어져 있기 때문에 유니코드 문장임을 나타내는 접두사 L 없이도 쓰일 수 있습니다. 또한, 아랫쪽 코드를 보면 System::String 형식에만 정의되어있는 문자열간 합치기 기능이 C++에서도 동작하는 것을 볼 수 있습니다.
위의 예제 코드를 작성한 다음, 결과를 확인하기 위하여 아래와 같이 IIS 7.0에서 사용할 수 있는 web.config 파일을 작성하여 웹 어플리케이션 디렉터리를 구축하고, 해당 웹 어플리케이션 디렉터리 바로 아래의 Bin 폴더에 이 프로젝트로 빌드한 DLL을 저장하였습니다.
위의 내용에서 확인해야 할 부분이 두 가지가 있습니다. IIS 7의 system.webServer 섹션은 닷넷 프레임워크가 처리하는 것이 아니라 IIS 7이 스스로 처리하는 부분입니다. 우선 핸들러에 type이라고 되어있는 부분은 닷넷 프레임워크에서 불러들일 IHttpHandler 인터페이스와 호환되는 형식의 약식 명칭을 지정하는 부분입니다. Page 클래스를 상속함으로서 이 부분이 자동으로 구현되었기 때문에 우리가 방금 작성한 C++ CLR 클래스의 약식 명칭을 여기에 기술합니다. 네임스페이스와 클래스 이름, 그리고 Bin 폴더 내에 들어있는 클래스 라이브러리의 어셈블리 이름을 여기에 기출합니다. 그리고 preCondition에서는 이 핸들러가 통합 모드에서 실행되어야 하고 닷넷 프레임워크 2.0 런타임을 사용하여 구동되어야 함을 표기하고 있습니다.
이제 실행 결과를 살펴보도록 하겠습니다.
C++ CLR이 Native Code와 잘 연동되어 ASP.NET 서비스를 수행하고 있는 것을 볼 수 있습니다. 여기서는 공식적으로 프로젝트 템플릿이 제공된 것이 아니라서 이와 같이 간소한 형태로만 프로그램을 작성하였지만 C++ CLR로도 이와 같이 프로그램 작성을 할 수 있다는 것을 확인할 수 있었습니다. Platform Invoke로 해결하기 어려운 문제는 이와 같이 프로그램의 형태에 제한을 갖지 않고 직접 C++의 힘을 빌릴 수도 있다는 것을 기억하면 쉽게 문제를 풀어나갈 수 있을 것입니다.
C#과 CLR C++에서는 params 키워드, VB.NET에서는 ParamArray를 사용하여 구현할 수 있는 가변 매개 변수 배열은 참 편리한 도구입니다. 하지만 간과하기 쉬운 몇 가지 사실을 같이 숨기게 됩니다.
1. 말장난같지만 결국 배열이다.
public static object[] ToArray(params object[] args) { return args; }
위의 코드는 "말장난"입니다. 하지만 실제 사용되는 예를 보면 다음과 같습니다.
object[] test = ToArray(1, 2, 3, 4, 5, 6);
new object[] { } 대신 ToArray를 이용하여 간단히 배열을 초기화할 수 있었습니다. 이와 같이 가변 매개 변수 배열에 지정하는 모든 파라미터들은 배열로 묶여서 데이터로 지정이 되게 됩니다. 이러한 특성을 잘 이용하면 상당히 편리한 도구를 만들 수도 있는 것입니다. 이 "말장난"같은 함수가 ASP.NET과 같은 환경에서는 상당히 유용해집니다.
<%# ToArray(1, 2, 3, 4, 5, 6) %>
위와 같이 Data Assignment 표현식에서 프로그래밍 언어에 관계없이 완곡하고도 보기 쉬운 표현을 만들어낼 수 있는 것입니다. 즉, 함수를 부를 수 있는 시점이면 어떤 식으로든 사용이 가능하다는 의미입니다.
2. 가변 매개 변수 배열로는 원소가 0개이거나 null인 배열이 "절대" 올 수 없다?
결론부터 말하면 "아닙니다". 어떻게 해서 가능한 일일까요?
object[] test = ToArray((object[])null);
컴파일러가 해석하기 나름인 구문이 될 수 있겠으나, Visual C# 컴파일러의 경우 이 구문을 해석할 때, params 키워드로 지정하게 될 가변 매개 변수 항목 전체에 대한 "대체 구문"으로 인지를 하게 됩니다. 그래서 ToArray 입장에서는 전달되는 매개변수 배열 전체가 null 참조로 지정됩니다. 그렇다면 new object[] { } 같은 구문이나 이와 동일한 상태로 설정된 개체가 오는 경우에도 전체 배열이 대치될까요? 이 경우에도 같습니다.
그렇다면 이들을 배열의 원소로 포함을 시키려면 어떻게 해야 할까요? 답은 가변 매개 변수 배열의 자식 원소 형식으로 캐스팅하여 지정하는 방법입니다. 즉, null의 경우 (object)null로, 배열의 경우 (object)new object[] { }와 같습니다.
3. 리플렉션을 이용하여 접근하는 경우는 어떻게 되나?
그냥 가져다 쓰기에도 이렇게 복잡한 사실들을 많이 내포하고 있는데 리플렉션에서는 어떨까 싶습니다. 다행스럽게도, 리플렉션을 이용하여 접근하는 입장에서는 params 키워드와는 관계없이 일반 배열 개체를 대신 지정함으로서 이런 혼란을 사전에 피할 수 있습니다. 2번 토픽에서 보았던 특징을 그대로 반영하는 것입니다. :-)
Bouns: 실험을 위하여 사용한 코드 조각
using System;
static class Program { static object[] ToArray(params object[] args) { return args; }