본문 바로가기

IT 살이/04. 기술 - 프로그래밍

어셈블리 구조

어셈블리(Assembly) 구조

이제 어셈블리(Assembly)라는 것을 구체적으로 알아볼텐데, 어셈블리에 대한 이해가 스마트클라이언트 애플리케이션의 어떤 문맥에서 필요한지 그 상황을 먼저 정리해 본다.

어셈블리와 바인딩 그리고 NTD 배포

어셈블리의 바인딩과 로딩은 스마트클라이언트 애플리케이션에서는 중요한 주제중의 하나이다. 어셈블리에 대한 이해가 필요한 곳이 바로 이 바인딩/로딩과 관련이 있다.  애플리케이션이 어셈블리를 호출( 즉 어셈블리에 포함된 타입을 사용)하게 되면 해당 어셈블리가 아무 고민없이 바로 로딩되는 것은 아니다. 애플리케이션이 참조하고 있는 어셈블리에 대한 정보는 우선 .NET 프레임워크의 CLR에 전달된다. 그 CLR은 이 어셈블리를 어느 위치에서 찾아야 하는가를 고민해서 결정해야 한다. 어느 위치에서 어떤 버전의 어셈블리를 로딩해야 하는지를 결정해야 한다. 어셈블리 바인딩 부분을 참조하라.

CLR은 참조되는(referenced) 어셈블리 파일의 물리적인 위치를 결정할때는 참조하는(calling, referencing) 어셈블리 내부에 있는 참조 정보와 그리고 때로는 어셈블리 외부에 존재하는 설정을 읽어 들여서 결정한다. 최초 엔트리 포인트를 가지고 있는 어셈블리는 그 경로가 이미 결정될 것이다. 예를 들어 사용자가 exe 어셈블리를 더블클릭하거나 또는 IE의 <object> 태그의 classid 속성에 의해 정확한 위치가 결정된다. 그러나 참조에 의해 호출되는 어셈블리는 그 물리적인 최종 위치를 결정하기 위한 과정이 필요하다. 최종 어셈블리를 찾아 가는 것을 어셈블리 바인딩(Assembly binding)이라고 하는데, CLR의 내부에 있는 퓨전(fusion)이라는 엔진이 담당한다.

퓨전이 어셈블리 바인딩을 하기 위해서 제일 먼저 확인하는 정보는 참조(referencing) 어셈블리 자체에 있다. 즉 어셈블리는 자신이 참조하고 모든 어셈블리에 대한 정보를 직접 가지고 있다. CLR은 제일 먼저 어셈블리 자체에 있는 참조 정보를 이용한다. 그리고 참조되는 어셈블리 검색에 필요한 정보는 애플리케이션의 설정 파일(.config)에도 있을 수 있다.

검색 대상이 되는 어셈블리는 호출한는 어셈블리와 같은 PC에 있을 수도 있지만 원격의 서버에도 있을 수 있다. 바인딩을 이해하고 나면 NTD(No-Touch Demployment)를 자연스럽게 이해하게 될 것이다. 바인딩과 로딩이 수행된다는 것은 바로 NTD 배포가 이뤄지는 것이다.

마이크로소프트사에서는 여러 가지 이유로 인해 어셈블리(Assembly)라는 개념을 내놓게 되는데, 여러가지 이유 중에서 바로 윈도우의 안정성에 가장 큰 위협이 되고 있는 “DLL Hell” 문제가 있다. 예를 들어 두 애플리케이션이 공유 참조하고 있는 공통 DLL을 어떤 한 애플리케이션이 수정, 재배포하게 되면 다른 애플리케이션은 그 DLL과의 참조가 깨져서 실행이 되지 않는 경우이다. DLL때문에 생기는 비슷한 여러 문제들이 윈도우의 안정성을 훼손시키고 있었다. 버전 관리에 대한 대책이 절실히 필요로 되는 상황이었다. .NET이 어떻게 여러 버전의 동일한 어셈블리가 동시에 존재할 수 있는지는 GAC(Global Assembly Cache)을 설명하는 부분에서 설명한다.

어셈블리를 PE(portable executable) 파일이라고도 하는데, 어셈블리만 가지고도 이 머신 저 머신으로 가져가서(portable) 실행시킬 수 있다(executable)는 의미가 아닌가 한다. Executable 하다는 것은 우리가 흔히 윈도우 탐색기에서 exe 파일을 더블 클릭해서 애플리케이션을 실행시키는 것만을 의미하는 것은 아니다. 그뿐만 아니라 .NET 프레임워크가 설치된 머신에서라면 CLR이 동적으로 메모리에 로딩시켜 뭔가를 할 수 있다는 의미이다.

먼저 어셈블리의 구조와 어셈블리가 가지고 있는 참조(referenced) 어셈블리에 대한 정보에 대해서 알아본다. 어셈블리는 보통 하나의 파일로 구성되지만, 여러 개의 파일로도 구성될 수 있다. 하나의 어셈블리를 구성하는 각각의 파일을 모듈(module)이라고 한다
1354890166

어셈블리 구조(단일 파일의 어셈블리, 복수 파일의 어셈블리)

어셈블리는 그림처럼 크게 어셈블리를 설명하는 메타 정보를 가지고 있는 테이블, IL코드를 가지고 있는 부분, 리소스를 가지고 있는 부분으로 구성된다.

어셈블리 메타 데이터 부분은 다시 몇 개로 구분되는데, 메너페스트라는 부분도 있고 그리고 자신과 그리고 다른 어셈블리가 가지고 있는 타입에 대한 정보도 있다.

JIT 컴파일러가 어셈블리의 IL 코드를 기계어 코드로 컴파일하는 과정을 상상해보자. Main() 메소드의 코드를 컴파일해가다가 어떤 타입(클래스)를 만난다. 이 타입이 현재 어셈블리내에 정의되어 있는지를 확인한다. 만약 그렇다면 그 타입을 정의해 놓은 곳에서 불러와 사용한다. 그러나 다른 어셈블리에 있다는 것을 확인하게 되면 그 타입이 정의되어 있는 어셈블리에 대한 이름 정보(사용자 친화적인 어셈블리명, 버전번호, 컬쳐, 공개키)를 얻는다. 이 참조 어셈블리에 이름 정보가 곳이 메너페스트(Menifest)이다. 참조 어셈블리의 이름정보를 얻은 후에 해당 어셈블리 로딩을 담당 시스템에 요청한다. 이렇게 어셈블리가 로딩되면 해당 타입에 대한 정보를 얻은 후에 계속 컴파일을 진행해간다.

메너페스트에는 이렇게 다른 모든 참조 어셈블리들에 대한 이름 정보를 가지고 있다. 그리고 자신의 이름 정보도 이곳에 두고 있다. 즉 다른 어셈블리에서 자신의 이름 정보를 참조할때도 이 메너페스트 부분만 검색하면 된다.  

개발자는 어셈블리 어트리뷰트를 사용하여 어셈블리의 메너페이스 정보를 수정할 수 있다. 프로젝트를 생성하면 자동적으로 추가되는 AssemblyInfo.cs 파일을 보면 기본적으로 여러개의 어셈블리 어트리뷰트가 추가되어 있는 것을 볼 수 있다. 이것들이 모두 어셈블리의 메너페스트 정보를 수정하게 된다.

[assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyDelaySign(false)]
[assembly: AssemblyKeyFile("")]
[assembly: AssemblyKeyName("")]
[assembly: AssemblyTitle("")]
[assembly: AssemblyDescription("")]

메너페스트가 자신과 다른 어셈블리에 대한 이름 정보를 가지고 있다는 사실을 기억해놓기 바란다. 나중에 설명할 어셈블리 바인딩(assembly binding)이라는 과정을 설명할 때 중요한 개념이기 때문이다. 어셈블리 바인딩은 또한 어셈블리의 배포와 버전 관리에 중요한 개념이며 바인딩은 또한 스마트클라이언트 배포(NTD 배포)의 개념이기도 하다.

그림은 복수개의 파일로 존재할 수 있는 어셈블리도 보여주고 있다.. 이 단위 파일을 모듈(module)이라고 부른다. 어셈블리는 이처럼 모듈 단위로 분리되어 각각 물리적인 파일로 따로 존재할 수 있다. 그리고 리소스(이미지, 문자열 등) 파일도 따로 모듈로 분리되어 있을 수도 있다. 이런 파일들이 합쳐져서 독립된 기능 즉 배포와 버전관리의 단위가 되는 어셈블리가 되는 것이다.

복수개의 모듈로 구성된 어셈블리에도 메너페스트 정보가 있는 모듈은 반드시 있어야 한다. 그래서 다른 어셈블리에서 참조 요청이 들어왔을 때 전체 모듈들을 다 확인하는 것이 아니라 메너페스트만 확인하면 된다.

어셈블리에는 리소스가 포함될 수도 있다. 리소스라는 것은 애플리케이션에서 사용되는 이미지나 문자열 같은 비실행 데이터를 말한다. 어셈블리는 관련된 리소스를 자체 가질 수 있어서 배포시도 함께 배포될 수 있다. 그리고 그림에는 없지만 리소스로만 구성된 어셈블리도 있다. 이런 어셈블리를 위성 어셈블리(satellite assembly)라 하는데, 어셈블리 로딩과 다운로드 시 이 위성 어셈블리 때문에 성능에 문제가 있을 수 있다. 이런 문제에 대해서는 HTTP 핸들러를 제작하면서 이야기했지만 자세한 내용은 뒤의 리소소와 그리고 어셈블리 다운로드하는 곳에서 자세히 설명한다.

지금까지의 설명을 간단히 말하면 어셈블리에는 “자신이 정의하고 있는 타입에 대한 정보와 그리고 자신을 구성하는 물리적 파일들에 대한 힌트, 그리고 자신이 참조하고 있는 다른 어셈블리들에 대한 정보 등 자신이 필요한 모든 정보를 스스로 가지고 있다”는 것이다. 어셈블리의 이런 속성을 "자기 설명적(self-describing)"이라는 말로 표현하고 있다. 자기 설명적 속성 때문에 어셈블리의 정보를 레지스트리에 등록하는 인스톨과정이 필요없게 된다. 자신의 메타데이터 정보를 읽고 해석할 수 있는 CLR이 있는 환경이라면 어느 곳이든 가서 CLR에 의해 로딩되고 제 할 일을 할 수 있는 것이다. 즉 어셈블리는 배포와 재사용의 단위가 되는 것이다.

원칙적으로는 하나의 어셈블리가 여러 파일로 구성될 수도 있긴 하지만 Visual Studio.NET을 이용하면 기본적으로 모든 어셈블리는 하나의 모듈 파일로 구성된다. 실전 프로젝트에서도 하나의 어셈블리는 하나의 모듈로 구성되는 것이 대부분이다.

어셈블리명(full qualified name)

이제 어셈블리의 메너페스트에 정의되어 있다는 어셈블리의 이름에 대해서 알아본다. 앞에서 사용자 친화적 이름(어셈블리 파일명), 버전, 컬쳐, 공개키(또는 공개키토큰)으로 구성되어 있다고 했다. 이 네가지 정보를 어셈블리 파일명, 버전, 컬쳐, 공개키 순으로 콤마(,)로 연결된 문자열을 만들면 어셈블리의 완전한 어셈블리명이 된다. XML 환경 설정 파일에 어셈블리 이름을 직접 입력해야 할 경우 또는 코드상에서 이런 4부분으로 구성된 문자열을 개발자가 직접 입력해야 하는 경우도 있을 수 있다.
1306610722

어셈블리의 완전한 이름

만약 이 네 부분이 생략되지 않고 모두 기록된다면 완전한 이름( fully qualified, fully specified assembly)이라고 한다.

Visual Studio.NET을 사용해서 어셈블리를 작성할때는 다음과 같은 어트리뷰트를 AssemblyInfo.cs에 넣게 되면 앞의 네 정보가 모두 메너페스트에 생성된다.

Using System.Reflection

[assembly: AssemblyVersion(“1,2.3.4”) ]
[assembly: AssemblyCulture(“ko-KR”) ]
[assembly: AssemblyKeyFile(“mycorp.snk”) ]

사용자 친화적 어셈블명(user-friendly name)

.NET이 제공하는 기본 클래스인 System.Reflection.AssemblyName에는 Name 속성이 있다. 실행중 이 속성값은 메너페스트 데이터를 가지고 있는 어셈블리 파일의 확장자없는 이름을 리턴한다. 이것은 CLR이 어셈블리 로딩시, 필수적으로 있어야 하는 부분으로 Visual Studio.NET으로 빌드를 하면 컴파일러가 자동으로 부여하는 속성이다. 코드상에서 동적으로 어셈블리를 생성할수도 있는데 이때는 어셈블리 이름을 개발자가 직접 부여할 수 있다.

"사용자 친화적 어셈블리명"이라는 길지만 좀 어색한 이름으로 소개한 이유는 그냥 부르는 "어셈블리명"은 흔히 완전한 이름을 나타내는 경우가 많기 때문이다. 어셈블리의 간략한 이름을 지칭할때는 우리뿐만 아니라 영어를 쓰는 사람들도 통일된 용어는 없는듯하다. 그래서 어떤 사람은 "간략한 어셈블리명(simple assembly name)"이라는 용어를 사용하기도 하고 또 어떤 사람은 보통 이 이름이 어셈블리의 확장자 없는 파일명과 동일하다고 해서 그냥 "어셈블리 파일명"이라는 용어를 사용하기도 한다. 그러나 정확히는 "어셈블리 파일명"과는 다른 의미이다. 즉 하나의 어셈블리를 복사해서 파일명을 다르게 부여해도 같은 어셈블리로 인식하는 경우도 있다. 

이 포스트에서는 사실 "간략한 어셈블리명"에 대해서 정해놓은 용어가 없다. "완전한 이름"과 구분이 필요한 경우는 상황에 맞게 구분하고 있다. 때로는 "완전한 이름"이라는 대신에 "어셈블리 이름에 관한 정보"라는 의미로 "이름 정보"라는 말을 사용하기도 한다.


 버전 번호(Version numbers)

모든 어셈블리는 4개의 숫자로 된 버전 번호를 갖는다. 버전번호는 “메이저번호.마이너번호.빌드번호.리비전번호”로 구성된다. System.Reflection.AssemblyName 클래스의 Version 속성으로 버전 번호 정보에 접근할 수 있다. C#코드에서는 System.Reflection.AssemblyVerion 어트리뷰트를 통해서 버전을 설정할 수 있다.

[assembly:AssemblyVersion("1.2.3.4")]

만약 버전 번호를 명시적으로 설정되지 않으면 기본값 “0.0.0.0”으로 설정된다. 버전번호를 설정하려고 한다면, 메이저번호는 반드시 명시되어야 한다. 생략되는 번호는 0으로 간주한다.

어트리뷰트                      실제버전
1                                   1.0.0.0
1.2                                 1.2.0.0
1.2.*                              1.2.d.s
1.2.3.*                           1.2.3.s
<설정않음>                    0.0.0.0
* d : 2000년01월01일부터 경과 일수
* s : 자정부터 빌드시까지의 경과 초수

빌드번호는 *로 표시될 수 있는데, 이렇게 되면 메너페스트에 삽입되는 빌드번호는 2000년 1월 1일부터 빌드한 날까지 지나간 날수가 된다. 그리고 리비전번호도 *로 표시될 수 있는데, 그날 자정에서부터 빌드시까지의 흐른 초수를 사용한다. 메이저번호와 마이너 번호는 *를 사용할 수 없다. 이 버전 번호는 어셈블리 확인기(assembly resolver)에 의해 중요하게 사용된다는 것을 뒤에서 설명한다.

컬쳐(culture)

컬쳐 또한 어셈블리 버전 번호와 함께 어셈블리를 구분짓는 속성의 일부로서 포함된다. 컬쳐는 어셈블리가 어느 언어, 어느 나라를 위해 만들어졌는지를 나타낸다. 디폴트로 현재 컬쳐는 사용자의 머신에 설정된 것을 따른다. 만약 프로그램상에서 특정 컬쳐에 대한 정보를 를 변경하고 싶다면 CultureInfo 인스턴스를 생성할 필요가 있다.

Thread.CurrentThread.CurrentCulture = new CultureInfo("ko-KR");

CultureInfo 인스턴스를 생성하기 위해서는 원하는 컬쳐이름을 필요하다. 컬쳐명은 RFC 1766에서 설명하고 있는 방법에 따라 언어와 국가(지역) 코드의 하이픈(-) 연결을 하면 된다.
<언어코드>-<국가코드>

예를 들면, "en-US"는 U.S 영어 컬쳐를 "en-AU"는 오스트레일이아의 영어 컬쳐를 말한다. 코드는 보통 2글자이다.

특정 컬쳐의 어셈블리를 만들고 싶다면 개발자는 보통 System.Reflection.AssemblyCulture 어트리뷰트를 사용해서 지정할 수 있다. Visual Studio.NET을 사용하는 개발자라면 AssemblyInfo.cs에 다음과 같은 코드를 넣으면 된다.

[assembly: AssemblyCulture ("ko-KR”)]

일반적으로 코드가 있는 어셈블리에는 컬쳐를 지정하지 않는다. 보통 그런 어셈블리에는 보통 컬쳐 종속적인 요소가 없기때문이다. int i=0 같은 코드는 특정 컬쳐에서만 사용하는 것은 아니다.

어셈블리에 특정 컬쳐 어트리뷰트를 사용하는 것은 리소스 사용과 밀접한 관계가 있다. 이 부분은 어셈블리 바인딩시 거치게 되는 경로 검색 과정과도 관련이 있으며 따라서 특정 컬쳐와 관련된 리소스를 다루는 부분을 이해해두면 스마트클라이언트 애플리케이션의 거동을 이해하는데 많은 도움이 될 것이다. 특정 컬쳐와 관련된 리소스를 관리하는 방법은 뒤에 리소스를 설명하는 부분을 참고하라.

공개키(public key)

어셈블리명을 구성하는 요소중에서 마지막으로 공개키(public key)에 대해서 알아보자. 공개키는 어셈블리의 개발자(배포 회사)를 확인하기 위해 사용하는 고유한 숫자들이다. 어셈블리는 128바이트의 완전한 공개키를 사용할 수도 있지만 간단히 공개키의 해시값인 8 바이트의 공개키토큰(public key token) 사용하여 동일한 효과를 나타낼 수도 있다. 어셈블리에 저장된 공개키 토큰은 sn.exe 커맨드 툴을 사용하면 볼 수 있다.
1319376030

공개키(토큰)출력

sn.exe –T myApp.dll   , sn.exe –Tp myApp.dll

-T 옵션을 사용하면 해당 어셈블리의 공개키 토큰을 출력해주고, -Tp를 사용하면 그림처럼 공개키를 함께 출력해준다.

새로운 공개키를 생성하기 위해서도 sn.exe 툴을 사용할 수 있다. 옵션으로 –k를 사용하면 공개키와 전용키(private key)의 쌍을 갖는 파일을 얻을 수 있다.

sn.exe –k publicprivate.snk

이 명령을 실행하게 되면 publicprivate.snk(확장자는 임의로 줄 수 있다.)파일에는 공개키와 전용키가 쌍으로 생성된다.
공개키(토큰)는 우연히 서로 다른 배포회사가 동일한 어셈블리 이름을 사용해서 파일 이름간에 충돌이 일어날 수 있는 경우를 해결할 수 있도록 해준다. 우연히 두 회사에서 어셈블리명.버전번호.컬쳐가 같은 어셈블리를 배포했다고 하더라도 공개키는 각각 고유한 값이므로 전체 어셈블리명은 다른 이름을 갖게 되어서 충돌을 피할 수가 있는 것이다.

공개키는 어셈블리에 고유 이름을 부여할 수 있는 역할도 하지만 어셈블리가 외부로부터 악의적으로 수정이 가해졌는지 체크하는데도 사용할 수 있다. 어셈블리에 고유한 디지털 사인을 추가하게 되면 CLR은 어셈블리가 외부로부터 악의적인 코드 수정이 일어났는가를 체크할 수 있게 된다. 어셈블리에 추가되는 디지털 사인 생성에 전용키(private key)가 사용되고,  디지털 사인이 된 어셈블리를 참조하는 호출 어셈블리쪽에서는 대상 어셈블리가 가지고 있는 전용키와 함께 생성된 공개키를 제시함으로써 사인으로 잠겨진 대상 어셈블리의 로딩이 가능해지게 된다. 공개키가 열쇠라면 전용키는 자물쇠라고나 할까.

using System.Reflection;
[assembly: AssemblyKeyFile(“publicprivate.snk”)]

우리가 Visual Studio.NET을 사용해서 어셈블리를 개발할 때 assemblyinfo.cs 파일에 흔히 추가하는 코드이다. 이 코드를 넣으면 어셈블리에 디지털 사인이 추가된다. 어셈블리 사인과 관련된 내용은 좀 더 전문적인 서적을 참고하기 바란다.

한번쯤은 강력한 이름(strong name)이라는 말을 들어본 적이 있을 것이다. 앞서 방법처럼 고유한 이름을 가지고 있는 어셈블리를 강력한 이름으로 서명한 어셈블리(strongly named assembly)라 한다. 어셈블리에 강력한 이름을 부여한다는 것은 고유한 이름을 부여한다는 것이다. 코드의 보안이 강화되며 어셈블리에 버전 정책을 사용할 수 있다. 그리고 강력한 이름의 어셈블리는 인터넷을 통한 안전한 배포가 가능하다( 강력한 이름이 없다고 인터넷을 통한 배포가 안되는 것은 아니다. )

어셈블리는 이 4부분의 요소를 모두 가질 수도 있고, 그렇지 않을 수도 있다. 강력한 이름의 어셈블리에 버전 번호가 없을 수 있다. 컬쳐 정보가 제공되지 않을 수 있다. 강력한 이름으로 서명되지 않는 어셈블리에도 버전 번호을 추가할 수도 그렇지 않을 수도 있다. 어셈블리명의 4부분중에서 공개키와 버전 번호는 어셈블리의 배포와 바인딩에 있어서 중요한 요소가 된다. 지금까지 어셈블리의 완전한 이름을 구성하는 각 요소들을 정리했다.

강력한 이름의 어셈블리와 약한 이름의 어셈블리에 대한 비교 설명은 다른 포스트에 있다.