IE 는 그 자체가 Local Server COM 개체이면서, COM 개체 특유의 다양한 확장을 제공하고 있다. 비단, IE 자체의 확장에서만 COM 이 쓰이는 것이 아니고, IE 가 보여주는 HTML 에서조차 <OBJECT/> 라는 태그를 통해서 COM 개체를 인스턴스화 시킬 수 있고, HTML 의 태그 하나하나까지도 실제로는 COM 개체로 제공되기 때문에, 그야말로 완벽한 COM Application 이면서 그를 위해 무수히 많은 COM 개체를 만들어 내게 한 장본인이다. 최근 닷넷 프레임워크가 나오면서 "Smart Client" 라는 반가운 소식이 ActiveX 개발자들에게 전해졌다. 분명, Smart Client 가 제작하기 쉽고 유지/보수가 간편하긴 하지만, 기존 ActiveX 컨트롤들은 그 제작 방법에 있어서 다양한 IE 와의 연동 기법을 사용하고 있었다. Smart Client 역시 그와 동일한 기능을 기본적으로 구현할 수 있어야만 실제 현업에서 사용할 수 있을 텐데, 비록 닷넷 프레임워크가 COM 환경과의 연동을 고려해 제작되긴 했지만, COM 자체로 동작되는 것은 아니기 때문에 기존 ActiveX 컨트롤의 기능을 Smart Client 로 포팅하는 것이 그다지 만만하진 않다. 이번호에서는, 바로 Smart Client 가 어떻게 "IE 와의 연동" 을 할 수 있는지를 소개하고자 한다.

정성태 kevin@dotnetxpert.com

현재 (주) 닷넷 엑스퍼트 에서 기술컨설턴트로 일하고 있으며, 주로 COM-ActiveX, COM+, .NET Framework 관련한 작업을 하고 있다.


--------------------------------------------------------------------------------

연재 순서
1회 : Smart Client 의 개요
2회 : Internet Explorer 와의 연동
3회 : 배포


연재 가이드
운영체제 : IIS 가 설치된 Windows 운영체제 - 필자의 경우 Windows 2003
개발도구 : Visual Studio .NET 2003, .NET Framework v1.1.4322
기초지식 : COM, C#, ASP.NET 기초
응용분야 : 현재로서는 기업 내부의 인트라넷 환경에서의 ActiveX 컨트롤 대체.
이후, 닷넷 프레임워크가 일반화되면 외부 웹사이트 에서의 ActiveX 컨트롤 대체

[강좌의 소스를 다운로드하고 싶다면 클릭하세요!!]


--------------------------------------------------------------------------------


COM 으로 ActiveX 를 개발해 보신 분이라면 대개의 경우 일정한 순서로 공부를 했을 것이다. 우선, 기본적인 메서드/속성을 구현했을 것이고 그 다음 약간 어려운 이벤트 연결을 공부했을 것이다. 그렇게 COM 에 익숙해지고 나면, 웹에서의 ActiveX 컨트롤 제작을 해보게 되고 결국 Internet Explorer 와의 연동을 익히게 된다. 마찬가지로 우리는 연재 1회에서 기본적인 기능을 할 수 있는 Smart Client 를 개발해 보았다. 스크립트 상에서 Smart Client 의 속성, 메서드, 이벤트를 다룰 수 있는 방법을 소개했으며, 그에 따른 보안 관련된 설정을 알아보았다. 순서대로, 이제 우리는 Smart Client 로 Internet Explroer 와 연동하는 방법을 알아내야 한다. .NET Framework 은 일종에 "프로그래밍 운영환경" 에 지나지 않기 때문에 IE 와의 연동이 그다지 매끄럽게 이루어지지는 않는다. 아마도 그 부분은 닷넷에서의 변화만으로는 부족하고 IE 자체도 변화되어야 가능할텐데, 그 부분에 대해서는 차기 Internet Explorer 버전에서나 기대해 볼 수 밖에 없다. 어쨌든, 우리는 현재의 IE 환경에서 구현을 해야만 한다. 그러다 보니, 이번 연재에서도 역시 "COM" 에 대한 지식을 기본전제로 하게 될텐데, 그렇다고 해서 코드가 VC++ 로 된 것은 아니니, 닷넷 언어만을 아시는 분들도 봐두시면 unmanaged 환경과의 interop 을 어떻게 할 수 있는 지에 대한 많은 지식을 얻게 될 것이다.

아직, 공식적으로 MS 에 의해서 제공되는 IE 와의 연동에 대한 예제 코드는 나와 있지 않은 상태이기 때문에, 여기서 보여준 코드가 "표준" 적인 접근방식이라고 말할 수는 없다. 단지, 필자가 시행착오로 알아낸 것들이기 때문에 이외에도 얼마든지 다양한 방법으로 구현가능할 수도 있다는 것이다. 각자의 개발 경험만큼 여전히 다른 식으로 "IE 와의 연동" 을 이끌어 낼 수 있으며 오히려 더욱 더 좋은 방법들이 여러분 머리속에 있을 수도 있으니, 이 기사를 보면서 그 가능성을 제한하지 말고 자신이 생각하는 IE 와의 연동 기법도 구현해 보기 바라며 또한 그 방법을 널리 알려주기 바란다. 이번호에서는 다음의 내용을 주제로 이야기를 진행할 것이다.

1. 스크립트와의 연동
2. IWebBrowser2 인터페이스 얻기
3. DWebBrowserEvents2 이벤트 연결
4. Smart Client 와 IE 의 세션 공유

참고로, 1회 기사를 읽지 않은 독자는 반드시 읽어보고 이번호 기사를 따라해 볼 것을 권한다. 왜냐하면, 이번 호 기사에서의 실습은 1회에서 했던 설정사항을 그대로 이어받아서 할 것이기 때문에, 그러한 고려를 하지 않은 사용자라면 이번호에서 개발하는 Smart Client 의 동작이 안될 수 도 있음을 감안하기 바란다.


1. 스크립트와의 연동

ActiveX 를 제작하면서, HTML 에 포함된 스크립트 함수를 호출하는 기능을 만들어 본 적이 있을 것이다. 이것을 Smart Client 로 구현해 보자. COM 을 잘 아시는 분이라면 그다지 어렵지 않은 작업이겠지만, 그렇지 않은 분들은 감이 잘 잡히지 않을 것이다. 예제를 위한 Smart Client 는 지난 호에 작업했던 TreeControl 을 확장해 나가는 방식으로 하겠다. Smart Client 에서 HTML 내의 Script 함수를 호출하는 것은 어떤 의미에서 "이벤트" 를 발생시키는 것과 동일한 기능을 한다. 사실, 이벤트를 구현하기 위해서는 이벤트 소스를 구현해야 하는 등의 다소 복잡한 부가 코드가 필요하지만 스크립트 함수를 직접 호출하는 것은 그보다 좀더 간단한 코드로 구현을 할 수 있다. 실제로, 필자의 경우에는 이벤트를 구현하는 것보다 Script 함수를 직접 호출하는 것을 더욱 선호하기도 한다. 우선, 코딩에 앞서 이론적인 사항부터 살펴보자.

IE 는 HTML 내부의 태그뿐만 아니라, JScript 함수까지도 IDispatch 인터페이스로 다룰 수 있게 하고 있다. 이해를 돕기 위해 [코드 1] 의 JScript 함수를 보자. 여기서 test_func 함수 자체가 IDispatch 인터페이스인 것이다.

[코드 1]

function test_func()
{
alert( ‘일반 함수입니다.’ );
}


좀더 정확히 살펴보면, test_func 함수는 IDispatchEx 인터페이스를 구현하고 있다. IDispatchEx 에 대해서 다소 낯설어 하실 분이 계실 텐데, 설령 그럴지라도 대개의 경우 모르는 상태로 너무나 일반적으로 사용한 것이 바로 IDispatchEx 인터페이스이다. 이것은 기존 IDispatch 구현을 포함하면서 멤버에 대한 동적 추가/삭제를 지원하고 있다. IDispatch 의 동작원리 자체가 IDispatchEx 로의 기능확장을 위한 배려가 되어 있다고도 볼 수 있다. 예를 들어보자면, IDispatch 를 구현한 COM 개체의 "TestFunc" 이라는 함수를 호출한다고 가정하자. 그럼, Script 에서 TestFunc 를 obj.TestFunc() 이라고 호출하게 되고, 이는 Active Scripting Engine 내부에서 obj 가 구현한 IDispatch 개체의 GetIDsOfNames 메서드를 호출해서 "TestFunc" 이름에 해당하는 DISPID 를 반환받게 되고, 그 DISPID 를 인자로 해서 IDispatch::Invoke 메서드를 호출해주는 것이다. 즉, IDispatch::GetIDsOfNames 에 의한 이름과 DISPID 쌍으로 메서드 호출이 이루어지므로, 멤버를 추가하기 위해서 단지 그 매핑 테이블 구조에 또 다른 이름과 DISPID 를 추가해 주고, 그 DISPID 와 호출될 메서드 함수의 포인터를 관리하기만 하면 되는 것이다. 여러분이 이러한 IDispatchEx 를 언제 사용했는 지를 잠깐 살펴보자.

[코드 2]

<script language=Jscript>
function window.()
{
_btn_Test.newField = "새로운 멤버변수 추가";
_btn_Test.newFunc = newAddedFunc;

alert( _btn_Text.newField );
_btn_Text.newFunc( "새로운 멤버함수 추가" );
}

function newAddedFunc( outputText )
{
alert( outputText );
}
</script>
<body>
<input type=button id="_btn_Test" value="버튼을 누르세요">
</body>


[코드 2] 를 보시면, "어! 이거 많이 보던 거네" 라고 하는 독자가 있을 것 같다. 아시는 것처럼, 원래의 <INPUT /> 요소에는 newField 나 newFunc 이라는 멤버는 존재하지 않는다. 하지만, INPUT 요소가 IDispatchEx 를 구현하고 있기 때문에 위와 같이 멤버 변수와 멤버 메서드를 실행시에 동적으로 추가를 하게 된 것이다. [코드 2] 는 COM 개체에 대해 전혀 모르는 웹 프로그래머들도 자주 사용해 보았을 것이다. 그렇게 우린 이미 암암리에 IDispatchEx와 충분히 친해져 있었던 것이다. HTML 요소에서는 위와 같은 코드를 많이 사용해 봤을 텐데, 정작 Script 함수까지도 IDispatchEx 라는 것을 아는 개발자들은 많지 않다. 위의 원리를 알았으니, Script 함수도 IDispatchEx 를 구현했다는 것을 직접 예를 들어 증명해 보자.

[코드 3]

function window.()
{
testFunc.newField = "새로운 멤버 변수 추가";
testFunc.newFunc = newAddedFunc;
testFunc();
}

function testFunc()
{
alert( testFunc.newField );
testFunc.newFunc( "새로운 멤버 함수 추가" );
}

function newAddedFunc( outputText )
{
alert( outputText );
}


[코드 2] 에서 설명한 IDispatchEx 를 이해하고 스크립트 함수가 그 IDispatchEx 로 구현되었다는 것을 안다면 [코드 3] 예제가 그리 낯설지 않을 것이다. 보는 바와 같이, 스크립트 함수인 testFunc 역시 HTML 요소와 똑같이 동적인 멤버 추가가 가능하다는 것을 알 수 있다.

이야기의 주제를 다시 Smart Client 로 돌려 보자. 결국, Smart Client 에서 스크립트 함수를 호출하는 것은 IDispatch 인터페이스 포인터의 Invoke 를 호출하는 것과 다를 것이 없다. 예를 위해서 이전에 제작해 두었던 TreeControl 에 스크립트 함수를 호출할 수 있는 코드를 추가해 보자. 우선, 호출할 스크립트 함수의 IDispatch 인터페이스를 보관할 속성이 필요하다. TreeEvent.cs 파일의 ITreeControlCOMIncoming 인터페이스에 다음과 같은 속성을 추가한다.

object _NodeClicked { set; get; }

TreeControls.cs 파일에서는 위의 인터페이스에 구현된 프로퍼티를 구현해 주어야 한다.

[코드 4]

object _NodeClicked = null;
public object _NodeClicked // 공용 프로퍼티로 정의
{
get { return _nodeClicked; }
set { _nodeClicked = value;
}

// 기존에 정의된 TreeView::AfterSelect 이벤트 핸들러를 다음과 같이 수정.
private void _treeView_AfterSelect( ... )
{
if ( _nodeClicked != null )
{
// IDispatch::Invoke 메서드를 호출하는 것과 동일한 호출 역할
Type type = _nodeClicked.GetType();
type.InvokeMember( "", BindingFlags.InvokeMethod, null, _nodeClicked, null );
}
}


IDispatch::Invoke 메서드를 직접 호출해 주던 것과 달리 Type 클래스의 InvokeMember 메서드를 호출한 것만 제외한다면 VC++ 로 된 코드와 비교해서 패턴 자체는 크게 변한 것이 없다. Smart Client 측에서는 내부적으로 [코드 4] 와 같이 구현해주면 된다. 이어서 [코드 5] 에서 보는 것처럼 HTML 스크립트에서 호출될 스크립트 함수를 대입해 주고, 해당 스크립트 함수안에 코드를 구현해 주면 된다.

[코드 5]

Form1._Control1._NodeClicked = treeControl_nodeClicked;
function treeControl_nodeClicked()
{
// 트리컨트롤에 선택된 노드를 가져와 처리
}


이제, 컴파일 하고 웹브라우저에서 테스트를 해보자. 트리 컨트롤에서 노드를 클릭하면, [코드 5] 에서 구현한 treeControl_nodeClicked 스크립트 함수가 실행되는 것을 확인할 수 있다. 결국, 이전에 말했던 것처럼 결과적으로는 이벤트 함수와 역할이 동일할 뿐만 아니라, 지난 호의 이벤트 구현을 위해 소요되었던 코드와 비교해 볼 때 구현도 간단해졌다. ( 참고로, 여전히 보안 설정은 "Unmanaged 코드를 호출가능" 하도록 설정해 두어야 한다. )

참고로, 필자가 겪은 재미있는 오류를 하나 설명해 보고자 한다. HTML 에 구현된 스크립트 함수를 CLR 에서는 InvokeMember 로 호출하게 되는 데, 만약 그 스크립트 함수 내부에서 오류가 발생하면, 흔히 보아온 Script 오류 메시지 창이 뜨지 않고, .NET Framework 의 예외 메시지 창이 뜨게 된다. 예외 내용은 System.Reflection.TargetInvocationException 이니, 혹시나 이 예외가 발생했다고 해서, CLR 코드를 의심하지 말고 스크립트 함수를 살펴보기 바란다.


2. IWebBrowser2 인터페이스 얻기

이번 연재의 하이라이트가 아닐까 생각된다. 필자가 처음 Smart Client 를 제작하고서 가장 먼저 해결하고자 했던 것이 바로 자신을 호스팅하고 있는 IWebBrowser2 인터페이스를 얻어내는 것이다. 아마도 이미 개발된 기존 ActiveX 중에서도 많은 활용을 하고 있는 부분이 아닐까 싶다. ActiveX 컨트롤로 구현할 때의 얘기를 해보면, WebBrowser 개체는 자신이 호스팅하고 있는 모든 컨트롤에 대해서, CreateInstance 로 개체를 생성한 후, IUnknown::QueryInterface 를 통해 IObjectWithSite 인터페이스 포인터를 얻어낸다. 만약 그 인터페이스가 구현되어져 있다면 개체의 Site IUnknown 인터페이스 포인터를 인자로 해서 IObjectWithSite::SetSite 메서드를 호출해 주고, 결과적으로 ActiveX 컨트롤은 넘겨받은 Site 인터페이스 포인터를 통해서 자신을 호스팅하고 있는 IWebBrowser2 인터페이스 포인터를 얻어낼 수 있게 되는 것이다.

ActiveX 에서의 구현처럼 처음에 Smart Client 역시 IObjectWithSite 인터페이스를 구현해서 단순히 클래스에서 상속을 받아주면 되지 않을까 싶었는 데, 성공하지 못했다. 아직 필자가 RCW / CCW 의 운영에 대한 지식이 미흡해서 밝히지 못했음을 미리 말해둔다. 이 강좌의 [소스 다운로드]에서 제공하는 소스에서는 IObjectWithSite 인터페이스 코드를 실어두었으니, 독자들도 도전해 보기 바란다.

가장 표준적일 수 있는 IObjectWitheSite::SetSite 인터페이스를 통한 방법이 미흡한 실력으로 인해 구현을 할 수 없지만, 프로그래밍 세계에서는 어떤 문제 해결을 위한 방법이 오직 하나만 있는 것이 아님을 독자들도 잘 알고 있을 것이다. 그렇다면, 과연 뭐가 남아 있는가? 할 수 없다. 이렇게 되면, 역시 예전의 unmanaged 환경에서 제공되었던 길을 찾아야 한다. 나름대로 여러 가지 방법이 있을 수 있겠지만, 필자가 발견한 방법은 Microsoft Active Accessibility SDK ( 이하, MSAA 로 표기 ) 에서 제공해주는 ObjectFromLresult 함수를 이용한 방법이다. 이것은 해당 응용 프로그램이 고유하게 정의한 윈도우 메시지를 통해서 프로세스간에도 사용할 수 있도록 완전히 마샬링된 인터페이스 포인터를 얻을 수 있는 방법을 제공해 준다. 물론, 그렇다고 해서 모든 ActiveX 컨트롤이 기본적으로 제공하는 것은 아니고, 응용 프로그램 개발자가 알아서 내부에 구현을 해주어야 한다. 다행히도, 웹 브라우저 컨트롤의 경우 이 방법을 제공하고 있는 데, 아마도 이 기능을 넣어둔 MS 의 개발자 조차도 닷넷 환경의 Smart Client 에서 유용하게 쓰일 것이라고는 예측하지 못했을 것 같다.

접근 방법은 정해졌으니 하나씩 구현을 해보자. 우선 해야 할 것은 IWebBrowser2 개체의 윈도우 핸들을 알아내야 한다. 이는 Smart Client 가 윈도우이고 그의 부모 윈도우가 WebBrowser 라는 것으로 쉽게 구할 수 있다. <화면 1> 은 Spy ++ 유틸리티를 이용해서 웹브라우저 안에 활성화된 Smart Client 의 위치를 확인해 본 것이다.



[화면 1: IE 와 TreeControl 의 부모/자식 관계]

지난 회에 만들어 두었던 Smart Client 인 TreeControl 의 경우, 윈도우 폼 위에 TreeView 컨트롤을 얹어 놓은 형태이다. [화면 1] 을 보게 되면, 강조된 "WindowsForms10.Window8.app9" 가 바로 윈도우 폼이고, 하위의 "WindowsForms10.SysTreeView32.app9" 가 TreeView 컨트롤이다. 우리가 구해야 할 IWebBrowser2 인터페이스 포인터를 담고 있는 윈도우는 Smart Client 의 부모 윈도우인 "Internet Explorer_Server" 이기 때문에, Win32 API 에서 제공하고 있는 GetParent API 로 구할 수 있다. 일단 HWND 를 구하고 나면 그 이후의 구현은 Microsoft 에서 제공해 주는 문서대로 구현을 하면 된다. 해당 문서에 대해서는 연재 마지막의 "참고 URL" 에서 확인할 수 있으니 참조하시고, Smart Client 에서 사용하기 위해 C# 언어로 포팅된 코드는 [코드 6] 에 실어 놓았다. 한가지 더 언급하자면, 정상적인 컴파일을 위해서 PIA ( Primary Interop Assembly ) 모듈로 제공이 되는 "Microsoft.mshtml" 을 참조 추가해야 한다. 주의해야 할 것은, 이 모듈은 ".NET Framework 재배포모듈" 이 설치된 컴퓨터에는 포함되어 있지 않고, VS.NET 을 설치한 경우에만 GAC 에 등록된다는 것이다. 이것으로 인해 문제가 발생하게 되는데, VS.NET 2003 IDE 에서 참조를 추가하는 경우, 기본적으로 GAC 에 등록된 모듈에 대해서 로컬복사가 "False" 여서 이렇게 되면 클라이언트에서 활성화되는 Smart Client 의 경우 해당 DLL 을 찾을 수 없으므로 동작 자체가 되지 않는다. 따라서, 반드시 참조로 추가한 다음 해당 모듈의 속성창에서 로컬 복사 속성을 "True" 로 바꿔야 한다.

[코드 6]

// 자신의 윈도우 핸들을 구하고 부모인 WebBrowser 윈도우 핸들을 구한다.
IntPtr pHandle = this.Handle;
pHandle = GetParent( pHandle );

// WebBrowser 컨트롤에서 정의된 특별한 메시지를 얻어낸다.
uint nMsg = RegisterWindowMessage( "WM_HTML_GETOBJECT" );

// ObjectFromLresult 의 인자로 전달되어져야 할 lRes 값을 구하고.
uint lRes = 0;
SendMessageTimeout( pHandle, nMsg, 0, 0, 2, 1000, ref lRes ); // SMTO_ABORTIFHUNG : 2

// MsHTML.h / ComDef.h 에서 IHTMLDocument2 의 GUID 를 얻을 수 있다.
Guid htmlDocumentGuid = new Guid( "332C4425-26CB-11D0-B483-00C04FD90119" );
// MSAA SDK 에서 제공하는 함수를 통해서
// 마샬링된 IHTMLDocument2 인터페이스 포인터를 구한다.
ObjectFromLresult( lRes, ref htmlDocumentGuid, 0, out _htmlDocument);

Type t = _htmlDocument.GetType();
string title = (string)t.InvokeMember( "title", BindingFlags.GetProperty, null, _htmlDocument, null );

// mshtml.HTMLDocument docObj = (mshtml.HTMLDocument)obj;
// string title2 = docObj.title;


아시겠지만, 위의 코드에서 GetParent, RegisterWindowMessage, SendMessageTimeout, ObjectFromLresult 메서드는 Win32 API 로써 PInvoke 로 호출된다. ( 해당 DllImport 선언과 관계된 소스는 [소스 다운로드] 에 실려 있다. ) 일단은 위의 과정을 통해서, IHTMLDocument2 인터페이스까지 얻을 수 있다.

[코드 6] 을 자세히 보면, 맨 마지막 라인에 주석 처리가 된 것에 대해서 의문을 제기할 것이다. 주석 처리된 라인의 위에 보면 IHTMLDocument2::title 을 구하기 위해서 복잡하게 Type.InvokeMember 를 호출한 것을 볼 수 있는데, 굳이 그렇게 한 것에는 "보안" 과 관계된 설정사항 때문이다. 이쯤에서 고백하는데, 필자 나름대로 Smart Client 를 개발하면서 이해할 수 없는 현상을 많이 접해 보았다. 위의 코드 역시 필자에게는 아직 그런 "이해할 수 없는 현상" 중의 하나로 남아 있다. 주석처리된 코드에서 보는 것처럼, 만약 mshtml.HTMLDocument 로 형변환을 하게 되면, 지난 호에 설정했던 "IneternetSmartClient_Zone" 에 "Full Trust" 를 주어야 한다. 가능한 최소한의 권한만을 Smart Client 에게 주는 것이 바람직하기 때문에 필자로서는 "Full Trust" 를 사용하지 않는 다른 방법을 찾았고 그것이 Type.InvokeMember 였다. 물론, 원칙적으로 이해하려고 든다면, mshtml.HTMLDocument 를 사용하는 것에는 지난 호에 살펴본 "Unmanaged Code 호출가능" 권한만으로도 충분하다고 볼 수 있다. 그런데, 왜 "Full Trust" 를 필요로 하게 되었는지에 관해서는 필자 자신도 어떻게 설명할 길이 없다. Full Trust 를 Smart Client 에게 허용해서 mshtml.HTMLDocument 를 사용할 것인지, 아니면 제한된 "SmartClientSet" 을 허용하고 프로그램을 다소 복잡하게 할 것인지는 독자의 선택에 달려 있다. 물론, 필자는 이 연재 전체에 걸쳐서 SmartClientSet 을 선택한 것을 기본전제로 코드를 구현했음을 알아주기 바란다.

위의 과정을 통해서 일단 object 형식의 IHTMLDocument2 인터페이스를 구하게 되었고, Untyped 상태에서 해당 객체의 메서드/속성을 호출하는 방법을 알아 보았다. 경우에 따라서 Smart Client 제작에 웹 브라우저와의 연동을 고려해 볼때, 이 정도 수준에서도 끝낼 수 있다. 사실 ActiveX 컨트롤을 제작하던 때를 생각해 보면, IWebBrowser2 인터페이스를 구하려는 목적에는 크게 2가지를 떠 올릴 수 있을 것이다. 첫번째는 IWebBrowser2::Document 를 통해서 컨트롤을 호스팅하고 있는 HTML 문서를 접근하고자 하는 경우이고, 두번째는 웹 브라우저의 이벤트를 받고 싶은 경우이다. 대개의 경우, 첫번째 목적이라면 ObjectFromLresult 함수의 특이한 능력 덕분에 굳이 IWebBrowser2 인터페이스까지 구할 필요없이 [코드 6] 까지만 구현하면 된다. 하지만, 웹 브라우저의 이벤트를 받고자 한다면 결국 IWebBrowser2 인터페이스까지 얻어야 가능하다.

웹 브라우저로부터 이벤트를 받는 것을 목표로, 힘들겠지만 한단계 더 나아가서 IWebBrowser2 인터페이스까지 구해보자. IE 에서의 ActiveX 컨트롤을 만들어 보신 분들은 IWebBrowser2::Document 를 통해서 IHTMLDocument2 인터페이스를 쉽게 얻었던 기억이 있을 것이다. 하지만, 그 역으로 변환하는 것에 대해서 해보신 분들은 많지 않을 것 같다. 일단, 그 방법에 관한 C++ 로 된 소스는 기사의 마지막에 제시한 "참고 URL" 부분을 참조하시고, 여기서는 C# 버전으로 포팅된 코드로 살펴보자. mshtml.HTMLDocument 를 다루기 위해서 Microsoft.mshtml 모듈을 참조한 것과는 달리, 아래에서 설명할 테지만, IWebBrowser2 인터페이스를 다루기 위한 참조 ( ShDocVw.dll, "Microsoft Internet Controls" ) 를 추가할 필요는 없다.

[코드 7]

// IHTMLDocument2 로부터 IWebBrowser2 인터페이스 얻기

// managed 개체로부터 unmanaged IUnknown 포인터를 반환
IntPtr docPtr = Marshal.GetIUnknownForObject( _htmlDocument );

// IOleCommandTarget GUID는 DocObj.h / ComDef.h 에서 구함
Guid ocmTargetGuid = new Guid( "B722BCCB-4E68-101B-A2BC-00AA00404770" );
IntPtr ocmPtr;
// IUnknown::QueryInterface 를 Marshal 클래스에 정의된 QueryInterface 를 이용
// IHTMLDocument2 로부터 IOleCommandTarget 인터페이스를 가져오기
Marshal.QueryInterface( docPtr, ref ocmTargetGuid, out ocmPtr );
Marshal.Release( docPtr );

// IServiceProvider 의 GUID 는 ServProv.h 에서 구함
Guid svpGuid = new Guid( "6d5140c1-7436-11ce-8034-00aa006009fa" );
IntPtr svpPtr;
// IOleCommandTarget 으로부터 IServiceProvider 인터페이스를 가져오기
Marshal.QueryInterface( ocmPtr, ref svpGuid, out svpPtr );
Marshal.Release( ocmPtr );

// unmanaged IServiceProvider 개체를 managed 개체로 변환
SmartClient.IServiceProvider svpObject = (SmartClient.IServiceProvider)Marshal.GetObjectForIUnknown( svpPtr );

// SID_SWebBrowserApp 와 IID_IWebBrowser2 GUID 는 ExDisp.h 에서 구함
Guid sidWBA = new Guid( "0002DF05-0000-0000-C000-000000000046" );
Guid webBrowser2 = new Guid( "D30C1661-CDAF-11d0-8A3E-00C04FC9E26E" );
// IServiceProvider::QueryService 를 통해서 IWebBrowser2 인터페이스 포인터를 반환
object WBObject = svpObject.QueryService( ref sidWBA, ref webBrowser2 );
Marshal.Release( svpPtr );

// SHDocVw.WebBrowser 로 형변환
// 이후 IWebBrowser2 개체와 동일하게 처리.
_webBr = (IWebBrowser2)WBObject;


[코드 7] 을 보고 있으면, 아무래도 unmanaged COM 개체들을 C++ 로 다루던 것을 managed 환경의 C# 에서 다루는 것이 쉽지만은 않아 보인다. [코드 7] 의 내용에서 핵심 작업은 System.Runtime.InteropServices.Marshal 클래스로 어떻게 unmanaged 환경에서의 COM 작업을 대체하느냐 하는 것인데, 보시는 것처럼, C/C++ 보다 약간 부가적인 코드를 필요로 하긴 해도, C# 으로도 동일하게 포팅을 할 수 있다. COM 개체와의 상호운용을 가능케 하는 Marshal 클래스에는 [코드 7] 에서 보여준 메서드 이외에도 unmanaged 와 managed 사이의 다리 역할을 해주는 static 함수들이 상당수 포함되어 있으니, 시간이 날때 틈틈이 살펴 보는 것도 좋겠다.

왠만큼 COM 을 하신 분들의 경우에는 [코드 7] 을 하나씩 뜯어보면 모를 만한 부분은 없을 텐데, 그래도 IServiceProvider 에 대해서는 짚고 넘어가야 겠다. [코드 6] 에서 본 것처럼, IDispatch 를 상속받은 인터페이스의 메서드를 호출하려면 Type.InvokeMember 를 통해서 가능하다는 것을 알았다. 하지만, IUnknown 으로부터 상속받은 IServiceProvider 같은 경우에는 사정이 틀려진다. 즉, DISPID 가 없는 경우에는 직접 vtable 에 기반한 메서드 호출을 해야 하기 때문이다. 다행히도 C# 의 interface 구문으로 [코드 8] 과 같이 C/C++ 에서와 같은 interface 를 만들 수 있게 해주고 있다. 더군다나, IServiceProvider 같은 경우에는 단순하게 IUnknown 인터페이스에서 단 하나의 QueryService 메서드만을 추가한 인터페이스이기 때문에 그다지 어렵지 않게 구현할 수 있다.

[코드 8]

[Guid("6d5140c1-7436-11ce-8034-00aa006009fa")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] // IUnknown 기본구현
[ComImport()]
public interface IServiceProvider
{
[return: MarshalAs(UnmanagedType.IUnknown)] object QueryService( ref Guid guidService, ref Guid riid );
}


물론, [코드 8] 에 구현된 Interface 를 직접 상속받아서 사용할 일은 없다. [코드 7] 에서 보는 것처럼 unmanaged 환경에서 구한 인터페이스 포인터를 managed 개체로 변환한 후에 Stub 만 구현해 둔 인터페이스로 "형변환" 을 해주어 사용하는 목적으로 쓰일 것이기 때문이다. 사실, IServiceProvider 까지는 구현이 간단해서 해볼만 할텐데, 그와 동일하게 여러분은 IWebBrowser2 에 대해서도 [코드 8] 과 같은 동일한 형태의 인터페이스를 직접 코딩 해주어야 한다. 만약, 이벤트 까지 생각한다면, DWebBrowserEvents2 인터페이스까지도 만들어야 한다. 그에 대해서 어떤 독자들은, 왜 SHDocVw.dll 을 참조한 RCW (Runtime Callable Wrapper) 를 사용하지 않느냐고 말씀하시는 분이 계실지도 모르겠다. 맞는 말이다. 이론상으로는 분명 SHDocVw.dll 을 참조추가하는 것으로 끝나야 정상이다. 그러나, 현실은 그렇지 않다. 위에서 IWebBrowser2 를 IServiceProvider 처럼 인터페이스 정의로 하지 않고 직접 SHDocVw.dll 을 참조해서 형변환하여 쓰게 되면, Smart Client 가 실행시에 오류를 발생하고 만다. 필자는 그런 이해할 수 없는 현상앞에서 많은 시간을 소비하였고, 결국 찾아낸 것이 C# Interface 를 별도로 정의하는 것이었다. 참고로, IWebBrowser2 인터페이스와 DWebBrowserEvents 인터페이스 정의는 소스가 길어지므로 지면상 [소스 다운로드] 으로 넣었으니 참조하기 바란다.


3. DWebBrowserEvents 이벤트 연결

결국, 우여곡절 끝에 IWebBrowser2 인터페이스까지 얻게 되었다. [코드 7] 에서 필자가 C# 으로 새롭게 정의한 IWebBrowser2 인터페이스로 형변환된 개체는 SHDocVw.IWebBrowser2 로 형변환 한 것과 동일하게 사용할 수 있다. SHDocVw.dll 을 사용하지 않은 관계로 우리가 직접 구현한 C# IWebBrowser2 인터페이스에는 한가지 문제를 지니고 있는데, 웹 브라우저에 이벤트를 연결할 수 있는 방법이 모호하다는 것이다. SHDocVw.dll 참조에서는 적절한 RCW 개체가 tlbimp.exe 에 의해서 "Interop.SHDocVw.dll" 로 제공이 되어 지므로 그걸 사용하면 문제가 없었지만, 말씀드렸던 것처럼 Smart Client 에서만큼은 SHDocVw.dll 을 참조하여 구현하게 되면 오류가 발생하므로 그런 서비스는 받을 수가 없는 상태이다. 여기서 예상할 수 있겠지만, 우리 역시 tlbimp.exe 가 산출해 준 "Interop.SHDocVw.dll" 대로 구현을 한다면 자연스럽게 이벤트를 delegate 로 연결할 수 있을 것이다. 하지만, 아쉽게도 필자 나름대로 잠시 시간을 내어 "Interop.SHDocVw.dll" 과 맞추려는 시도를 했지만 성공하진 못했다. 독자들 중에서 이에 대해 시도를 하실 분이 계시다면, 기사의 마지막에 그와 관련된 "고급 COM Interop" 기사 자료를 "참고 URL" 에 포함시켰으니 참고하기 바란다. 필자 역시, 언젠가는 재도전을 염두에 두고, 중간 단계의 시도를 TreeEvent.cs 에 실어 놓았는데, 지면상 다루지는 못하고 [소스 다운로드] 에서는 넣어 두었으니 그 코드로부터 시작한다면 노력이 좀 덜 들 것이다.

그건 그렇고, 닷넷에서 제공해주는 RCW 를 사용할 수 없다고 해서 구현이 불가능한 것은 아니다. 역시 unmanaged 시절에 익힌 것처럼, COM 이벤트 방식으로 얼마든지 구현이 가능하다. 잠시, COM 에 낯선 분들을 위해서 COM 이벤트 모델을 살펴보고 지나가겠다. [그림 1] 을 보면서 아래의 설명을 이해하기 바란다.

보는 바와 같이, COM 개체는 이벤트를 발생하는 인터페이스를 구현한 다음, 그 이벤트 인터페이스를 나열할 수 있는 IConnectionPointContainer 를 구현해 두어야 한다. 클라이언트 측에서는 바로 그 IConnectionPointContainer 인터페이스를 QueryInterface 로 조회를 하게 되고, FindConnectionPoint 메서드를 통해서 연결하고자 하는 이벤트 인터페이스에 해당하는 GUID 를 넘겨주어 그 이벤트 인터페이스에 대한 연결지점을 담당하는 IConnectionPoint 인터페이스를 또 다시 구해올 수 있다. 이후, 클라이언트 측에서는 이벤트 인터페이스를 상속받은 클래스를 정의하고 그 클래스의 인스턴스를 만든 다음, IConnectionPoint::Advise 에 인스턴스 포인터를 넘겨주면, 이른바 "이벤트를 거는 (sink)" 작업이 끝나게 된다. 이후로는 COM 서버에서 이벤트가 발생하면 Advise 에 전달되었던 클라이언트 측에 구현된 클래스의 포인터를 통해서 해당 메서드를 호출해 주게 되는 것이 바로 COM 이벤트 구조이다. 한마디로, COM 이벤트는 "양방향 통신" 을 하게 된다.



[그림 1: COM 에서의 이벤트 구현]

위의 예를 우리가 구현한 Smart Client 예제와 비교를 해보자. [그림 1] 에서 "Client" 는 우리가 구현한 TreeControl 이 될 것이고, "Connectable Object" 는 웹 브라우저 가 될 것이다. 따라서, 우리는 웹 브라우저로부터 IConnectionPointContainer 인터페이스를 조회한 후, 그 인터페이스에서 제공해 주는 FindConnectionPoint 메서드를 사용해서 웹 브라우저가 구현한 "DWebBrowserEvents2" 인터페이스에 대한 IConnectionPoint 인터페이스를 구할 수 있다. 이후, 이벤트를 걸고 싶을 때는 IConnectionPoint::Advise 메서드를 호출하면 되고, 이벤트 해제를 하고자 할때는 IConnectionPoint::Unadvise 메서드를 호출해 주면 되는 것이다. 이를 C# 코드로 표현해 보면 [코드 9] 와 같다. 보시는 것처럼, 간단하게 이벤트를 연결할 수 가 있다. 참고로, IConnectionPointContainer 인터페이스는 IServiceProvider 와는 달리 닷넷 Base Class Library 차원에서 UCOMIConnectionPointContainer 라는 인터페이스로 제공되어 지고 있으며, IConnectionPoint 는 UCOMIConnectionPoint 라는 이름으로 동일한 구조의 메서드까지 포함해서 제공을 해주기 때문에 IServiceProvider 처럼 개발자가 구현해줄 필요가 없다.

[코드 9]

// DWebBrowserEvents 에 대한 GUID 값
Guid DEventsGuid = new Guid( "34A715A0-6587-11D0-924A-0020AFC7AC4D" );

// WebBrowser 개체를 IConnecitonPointContainer 로 형변환
UCOMIConnectionPointContainer ICpc = (UCOMIConnectionPointContainer)WBObject;

// DWebBrowserEvents2 인터페이스에 대한 IConnectionPoint 인터페이스 포인터를 반환
ICpc.FindConnectionPoint( ref DEventsGuid, out _ICp );

// TreeControl 에서 DWebBrowserEvents2 인터페이스를 구현하고 있기 때문에,
// 자신의 this 포인터를 전달하고, 이후 이벤트 연결 해제를 위해 Cookie 값을 반환
_ICp.Advise( this, out _dwCookie );


[코드 9] 의 소스에서는 지면상 생략했지만, TreeControl 개체는 이미 DWebBrowser2 인터페이스를 상속받아서 그에 대한 멤버 함수를 모두 구현해 놓았기 때문에, 마지막 부분의 _ICp.Advise 메서드에서 자신의 this 포인터를 전달하는 것이 가능하다. 이렇게 이벤트를 걸어주고 나면, 이후로 웹브라우저로부터 이벤트가 발생할 때마다 DWebBrowserEvents2 인터페이스를 상속받아 구현해 두었던 TreeControl 클래스의 멤버 메서드가들이 실행되어진다. 우리는 그저 필요한 이벤트에 한해서 그 해당 메서드에 코드를 추가해 주기만 하면 된다.

이제 왠만큼 구현이 끝난 것 같다. 여기서 잠시, COM 을 하지 않은 독자라면 간과하고 지나갈 수 있을 문제를 짚고 넘어가겠다. 위에서 QueryInterface 로 구한 인터페이스 포인터 값은 알려진 데로 내부적으로 IUnknown::AddRef 가 호출되어지므로, 증가된 참조수를 줄여주기 위해서 반드시 Marshal.Release 를 호출해 주어야만 하고, 걸어놓은 이벤트에 대해서도 Unadvise 로 해제를 해주어야 한다. Smart Client 에서 자원해제를 위한 가장 적절한 위치는 IDisposable.Dispose 메서드 내부이다. 그곳에서 _ICp, _htmlDocument, _webBr 개체에 대한 해제 코드를 잊지 말고 넣어주고, 해제와 관련된 코드도 [소스 다운로드] 에 넣어 두었으니 확인하기 바란다.

이번 절을 통해서, Smart Client 를 호스트하고 있는 HTML Document 에 대한 인터페이스 포인터, WebBrowser 에 대한 인터페이스 포인터, 그리고 그 WebBrowser 에 이벤트를 연결하는 방법을 구현해 보았다. Microsoft.mshtml 개체로의 형변환을 Full Trust 보안하에서만 제공해 준다는 것이 다소 원칙에 맞지 않는 듯 싶은데 애석하게도 이 문제는 .NET Framework 1.1 에서만 발생하는 것은 아니다. 필자가 테스트하고 있는 SQL Server 차기 버전인 Yucon 과 함께 설치되는 ".NET Framework 2.0" 버전에서도 위의 문제는 동일하게 발생하고 있는 것을 확인할 수 있었다. 물론, .NET Framework 2.0 도 아직 정식 릴리즈가 된 것은 아니기 때문에 확정지을 수는 없겠지만, 현재로서는 기대를 할 수 없는 상황이다.

4. Smart Client 와 IE 의 세션 공유

Smart Client 를 실무에 적용시키다 보니 IE 와의 연동을 성공하게 된 것이 IE 와의 세션 공유에도 도움이 될 수 있었다.. 아마 현업에서 직접 Smart Client 를 개발해 보신 분들은 꼭 한번쯤 걸려 넘어졌을 돌뿌리가 바로 IE 와의 세션 공유가 아닐까 싶은데, 연재 1회의 기사만 읽고도 여러분들은 이미 IE 와의 세션 공유를 할 수 있는 방법을 안 것이나 다름이 없다. 왜냐면, 웹브라우저에 할당된 세션을 구분해 주는 고유한 "세션 ID" 는 HTML 스크립트 상에서도 구할 수 있고, 그렇게 구한 세션 ID 를 연재 1회에서 배운 "속성" 값에 대입해서 내부적으로 보관하고 있다가 필요한 경우 그 값을 쓰면 되기 때문이다. 실제로, 서버와 맺어진 세션을 구분해 주는 세션 ID 는 Cookie 를 통해서 서버에서 전달 받은 상태이고, HTML Document 는 cookie 라는 속성을 통해서 접근을 허용하고 있기에, 연재 1회 정도에서 배운 실력을 발휘해 본다면, 아래와 같은 코드로 아주 쉽게 구현할 수가 있게 되는 것이다.

Form1._Control1.CookieValue = document.cookie;

하지만, 세션과 연동해야 하는 모든 Smart Client 를 사용하는 웹페이지에서 위의 구문을 유지해 주는 것도 하나의 일이다. 게다가, 이번 연재 2회를 통해 여러분들은 그러한 번거로움을 해결할 수 있는 열쇠를 갖게 되었다. 즉, 앞의 단계에서 구했던 _htmlDocument 개체에서 cookie 속성을 통해 현재 Smart Client 를 호스팅하고 있는 IE 의 쿠키값을 구해서 사용하면 되기 때문이다. 이미 앞에서 배운 것처럼, 쿠키값은 아래와 같은 코드로 간단히 구해질 수 있다.

Type t = _htmlDocument.GetType();
string cookieValue = (string)t.InvokeMember( "cookie", BindingFlags.GetProperty, null, _htmlDocument, null );

이렇게 간단히 cookieValue 값은 구했고, 그 쿠키를 설정해서 서버와 통신을 하면 된다. unmanaged 환경에서는 서버와 통신하기 위해 보통 IXMLHTTP 개체를 사용했었겠지만, .NET Framework 에서는 Base Class Library 차원에서 제공되는 WebRequest 클래스를 사용하는 것이 더욱 자연스럽다. 사용방법에 있어서도 IXMLHTTP 개체 못지 않게 쉬운 구조를 유지해 주고 있으니 필자는 WebRequest 를 선택하여 [코드 10] 과 같이 구현해 보았다.

[코드 10]

// URL 에서 호스트 도메인 부분을 구한다.
string currentURL = _webBr.LocationURL;
Uri curURI = new Uri( currentURL );

// Query 를 날릴 aspx 경로를 설정.
string reqURL = "http://" + curURI.Host + "/WebApp/GetSessionValue.aspx";

// Factory 메서드 패턴으로 WebRequest 개체를 생성.
WebRequest webReq = WebRequest.Create( reqURL );

// 웹브라우저와의 세션공유를 위해 Cookie 값을 WebRequest 의 HTTP 요청 헤더에 설정
webReq.Headers.Add( "Cookie", cookieValue );

// 요청을 보내고, 받은 byte 스트림을 String 으로 변환해서 출력.
WebResponse response = webReq.GetResponse();
Stream stream = response.GetResponseStream();
byte [] byteText = new byte[ response.ContentLength ];
stream.Read( byteText, 0, (int)response.ContentLength );

string text = Encoding.Default.GetString( byteText );
MessageBox.Show( text );


[코드 10] 에서는, 서버에 이미 GetSessionValue.aspx 웹폼 페이지를 만들어 두었다고 가정하고 있으며, 그 페이지에서는 다른 페이지에서 설정된 Session 변수값을 출력해주는 역할을 하고 있다. 기억해 두어야 할 것은, 서버와 새롭게 연결을 맺는 클라이언트는 HTTP 요청 헤더에 세션을 공유하고자 하는 연결에 해당하는 세션 ID 를 Cookie 에 실어서 보내야 한다는 것이다. WebRequest 의 경우, 헤더를 추가하기 위해 Headers 라는 NameVauleCollection 형식의 공용 속성을 제공해 주고 있어서, 기존 웹브라우저에서 구한 Cookie 값에서 (ASP.NET 의 경우) ASP.NET_SessionId 키값을 동일하게 설정해 주어서 보내면 된다. [코드 10] 과 같이 HTTP 요청을 하게 되면, [표 1] 과 같은 구조로 HTTP 요청 헤더가 구성되게 된다.

GET /WebApp/GetSessionValue.aspx HTTP/1.1
Connection: Keep-Alive
Accept: */*
Accept-Encoding: gzip, deflate
Accept-Language: ko,en-us;q=0.5
Cookie: ASP.NET_SessionId=ucrave3mxye3ltqojrbnr455
Host: localhost
User-Agent: Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.2; i-NavFourF; .NET CLR 1.1.4322; .NET CLR 1.0.3705)


[표 1: 세션을 공유하기 위해 Cookie 에 ASP.NET_SessionId 를 설정]

보시는 것처럼, 굵은 서체로 된 ASP.NET_SessionId 가 기존 연결에서 구한 값이기 때문에, 서버는 새로운 세션을 생성하지 않고, 이미 맺어진 세션중에서 주어진 세션 ID 에 해당하는 세션 개체를 그대로 사용하게 되어, Smart Client 내부에서 생성한 WebRequest 개체와 Smart Client 를 호스팅 하고 있는 웹브라우저간에 동일한 세션을 사용하게 된다.


연재 2회를 마치며

연재 1회에서는 약간의 COM 지식이면 충분했었는데, 이번 2회의 연재에서는 COM 으로 도배를 한 것 같다. 얼핏 보면, 이것이 Smart Client 에 대한 기사인지 COM 에 대한 기사인지 구분이 안 갈 정도이다. 그렇다고 해서 COM 지식을 모르고는 Smart Client 를 접근할 수 없다는 인식은 하지 말기 바란다. 본래의 .NET Framework 에서 제공하는 Base Class Library 로도 Smart Client 의 위력은 충분히 발휘할 수 있지만, 단지 필자의 연재에서는 기존 ActiveX 에서 구현했던 것들을 Smart Client 에서도 구현하려다 보니 COM 에 대한 배경을 제외시키기가 불가능했을 뿐이다. 그나마 위안을 삼는다면, 여기서 다루는 코드들의 대부분이 모든 Smart Client 를 구현하는 데에 있어 변경없이 쓸 수 있기 때문에 별도의 클래스로 구현해 두어, Smart Client 를 구현할 때 마다 상속을 통해 처리를 하면 되기 때문에 그렇게 부담갖지 않아도 될 것이다.

이로써, Smart Client 를 제작함에 있어서 기존 ActiveX 를 대체할 수 있을 정도의 기반 지식은 다룬 것 같다. 하지만, 역시 Smart Client 의 목표는 사용자들의 컴퓨터에서 쉽게 운영이 될 수 있을 때 더욱 가치가 있을 것이다. 다음 회에서는 이를 위한 Smart Client 배포 문제를 다루고 그와 관련된 보안 사항을 다뤄보도록 하겠다.


참고 URL

Microsoft 기술 자료 : HOWTO - Get IHTMLDocument2 from a HWND
http://support.microsoft.com/default.aspx?scid=http://support.microsoft.com:80/support/kb/articles/Q249/2/32.asp
&NoWebContent=1&NoWebContent=1

IWebBrowser2 인터페이스와 IHTMLDocument2 인터페이스 상호접근
http://www.sysnet.pe.kr/Default.aspx?mode=2&sub=0&pageno=7&detail=1&wid=2&wtype=0

VS.NET 도움말 : Visual Studio.NET / .NET Framework / .NET Framework 를 사용한 프로그래밍 / 관리되지 않는 코드와의 상호 운용 / 고급 COM Interop
ms-help://MS.VSCC.2003/MS.MSDNQTR.2003FEB.1042/cpguide/html/cpconadvancedcominterop.htm

Smart Client 정의 : 뒤늦은 소개지만, 혹시나 필자의 글을 읽고 Smart Client 는 곧 ActiveX 와 동일한 격이라고 생각할 분들이 계실 것 같아서, Smart Client 에 대한 보다 일반적인 정의를 담고 있는 자료를 소개한다.

http://211.169.248.133/netxpert/board/board_view.aspx?bbs=zColumns&pageno=1&searchKey=&searchtype=&basewid=46
Posted by 퓨전마법사
,