[프로그래밍 노트] String 이해하기 (1): MIME 파서 구현하기

  • Post author:
  • Post category:칼럼
  • Post comments:0 Comments
  • Post last modified:February 8, 2020

변경 내역

  1. 2006.07.29 작성.

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

MIME 파서 구현하기

닷넷은 System.String 객체 하나로 거의 모든 문자열 작업을 통일시켰다. 그렇다고 문자열 사용법이 단순해졌다고 별다른 생각없이 프로그래밍 작업을 하다가는 큰 낭패를 보기가 쉽다. 최선의 방법은 닷넷의 String 구현을 제대로 이해하고, 최적의 해법을 찾는 것이다.

최재훈 | 현재 모바일 메시지 전송업체에서 산업기능요원으로 재직 중이고, 복학을 앞두고 있다. 주로 VC++, C#, 그리고 MSSQL2000으로 서버를 개발한다. 최근에는 웹 서비스에 중점을 두고 있다. 회사 일 외에는 블로그를 통해 기술과 일상생활에 대해 이야기하기 좋아한다

C 스타일의 문자열은 다루기가 어렵고, 버그가 자주 발생하기로 유명하다. Null 종료 문자열은 단순한 연속적인 메모리 공간에 불과하다. 문자열의 길이와 같은 정보를 스스로 갖고 있지 않고, 오직 할당된 공간에 처음 등장하는 Null이 문자열의 종료지점이라는 것만 약속되어 있을 뿐이다. 덕분에 버퍼 오버런과 같은 문제를 겪기 쉽다. CString이나 BSTR 같은 래퍼 클래스가 어느 정도 고통을 경감해주기는 한다. 하지만 이런 클래스는 수없이 많은데다가 통일성도 거의 없기 때문에 각 클래스 간의 특징과 관계를 공부해야 하는 또 다른 고통을 안겨 준다. 덕분에 꿈에서 BSTR, OLECHAR, char*, LPSTR의 이름을 단 악령과 마주쳤다고 증언하는 개발자를 만나기는 그리 어렵지 않다.

닷넷은 System.String 객체 하나로 모든 문자열 작업을 통일시켰다. COM+ 서비스든 데이터베이스와 통신하는 데이터 계층이든 간에 상관없이 동일한 System.String 객체를 사용함으로써 일관성을 지킬 수 있게 됐다. 그러나 문자열의 사용법이 단순해졌다고 생각 없이 프로그래밍을 하다가는 낭패 보기 십상이다. 자칫 잘못하면 듀얼 코어 CPU도 감당하기 어려운 괴물이 탄생할지도 모른다. 이번 시간에는 간단한 MIME 파서를 구현해보고, 어떤 점을 고려해야 하는지 알아보겠다.

MIME로 멀티미디어 컨텐트를 전달해보자.

웹 서비스는 최근에 주목 받고 있는 분산객체시스템 중 하나다. 흥미로운 점은 웹 서비스로 메시지를 전달할 때, MIME를 활용하는 경우가 많다는 사실이다. 일반적으로 닷넷 웹 서비스는 SOAP이라는 XML 포맷으로 정보를 주고 받는다. 대부분의 경우에는 MIME를 사용하지 않아도 충분히 구현이 가능하다. 예를 들어 특정 도시의 현재 시각을 제공하는 웹 서비스에 요청할 때는 HTTP 본문에 도시 이름을 담고 있는 XML 메시지만 있으면 된다. 그러나 블로그에 사진을 업로드해 주는 웹 서비스라면 좀더 복잡해진다. 여러 가지 구현 방법이 있겠지만 <리스트 1>과 같이 MIME를 활용하는 방법이 많이 쓰인다.

첫번째 줄부터 SOAPAction까지가 HTTP 헤더다. Content-Type을 보면, 각 미디어 타입이 "MIME-boundary"라는 문자열로 둘러 쌓여 있음을 알 수 있다. 또한 가장 중요한 데이터인 SOAP 메시지의 Content-ID가 "<[email protected]>"이라는 사실도 알 수 있다. 앞으로의 예제에서는 HTTP 헤더는 이미 분석이 된 상황이라고 가정하고, 본문을 파싱하는 상황만 고려한다.

<리스트 1> 사진 업로드 요청 메시지

POST /ImageUpload/ImageUpload.asmx HTTP/1.1
Host: hostname
Content-Type: multipart/related; boundary="MIME_boundary"; text/xml; start="<[email protected]>";
Content-Length: length
SOAPAction: "upload"

--MIME_boundary
Content-Type: text/xml; charset=UTF-8
Content-Transfer-Encoding: 8bit
Content-ID: <[email protected]>

<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
	<soap:Body>
		<Upload userid="kaistizen" password="have a nice day!">
			<Content cid="0021186938931291464491" />
		</Upload>
	</soap:Body>
</soap:Envelope>

--MIME_boundary
Content-Transfer-Encoding: base64
Content-Type: image/jpeg
Content-ID: 0021186938931291464491
Content-Length: 22234
Content-Disposition: attachment; filename="example.jpg"

/9j/4AAQSkZJRgABAQIAHAAcAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a
HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIy

중략......

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

--MIME_boundary--
	

<리스트 2> MIME (Multipurpose Internet Mail Extensions)란 무엇인가?

MIME는 본래 텍스트 이메일을 넘어 사진첨부와 같은 기능을 제공하기 위해 만들어진 데이터 포맷이다. 하지만 MIME는 이메일 뿐만 아니라 미디어 타입을 다뤄야 하는 웹 기술에도 적용된다. 가장 대표적인 예가 ‘웹 페이지 보관 파일’이다. 인터넷 익스플로러로 아무 사이트나 들어가보자. 그리고 [파일?다른 이름으로 저장?웹 페이지 보관 파일(*.mht)]를 선택한다. 생성된 파일을 메모장으로 열어보면 보기에도 어지러운 문자가 가득할 것이다.

복잡한 겉 모습에 속지 말고 차분하게 내용을 들여다보면, 패턴이 보인다. 처음 몇 줄에는 전체 문서에 관한 설명이 나오는데, 그 중에서도 Content-Type 중 boundary 값에 주목하자. boundary="—-=_NextPart_000_0000_01C65B60.2E413050"일 때, 큰 따옴표 안에 감싸인 값들을 찾기(Ctrl+F) 기능으로 확인해보자. 웹 페이지를 구성하는 HTML, CSS, 그리고 그림 파일 등이 모두 한 파일에 포함되어 있음을 알 수 있다.

최초의 파서.

<리스트 3>는 실제로 MSDN Magazine 2002년 3월호 기사 내용 중 일부(참고문헌 1 참조)를 수정한 소스코드다. 이 기사는 필자가 처음으로 웹 서비스를 개발할 때 참조한 문서이기도 하다. <리스트 3>은 MIME 중 지정된 Content-ID를 가진 MIME Part의 바디를 찾아서 반환한다. HTTP 본문을 한 줄씩 읽어서 "Content-ID: 0021186938931291464491"이 들어간 첫번째 문장(소스코드 10번째 줄)을 찾는다. 첫번째 문장을 찾으면 MIME Part의 몸체까지 스트림의 위치를 이동시킨다. (소스코드 12번째 줄) 이제 MIME Part의 끝을 알리는 "–MIME_boundary" 가 나올 때까지 한 문장씩 더해서, 전체 SOAP 메시지를 재구성한다.

첫번째 파서는 원본 MIME를 위에서부터 아래로 한 차례 읽는다. 물론 매 줄마다 "–MIME_boundary" 문자열을 포함하고 있는지 확인하므로 메시지를 2회 읽는다고 볼 수 있지만, 선형적인 증가이므로 일단 무시해도 좋을 것이다. 그렇다면 이것으로 좋은 것일까? 아직 판단을 내리기에는 이르다.

<리스트 3> 문제 있는 파서 구현물

static string First(string text)
{
	string contentID = "Content-ID: 0021186938931291464491";
	string boundary = "--MIME_boundary";
	string mimeBody = null;

	StringReader sr = new StringReader(text);
	for (String line = sr.ReadLine(); line != null; line = sr.ReadLine())
	{
		if (line.IndexOf(contentID) > -1)
		{
			while (line.Length > 0) // MIME 헤더넘어가기
			{
				line = sr.ReadLine();
			}

			for (line = sr.ReadLine(); line != null && line.IndexOf(boundary) == -1; line = sr.ReadLine())
			{
				mimeBody = mimeBody + line;
			}
		}
	}
	return mimeBody;
}
	

성능 테스트를 해보자.

우리는 흔히 권위에 호소하는 오류를 잘 저지른다. 고등학교 때 교육 받은 것은 잊어버리고 말이다. 유명한 잡지의 기사에 실린 소스 코드라고 무조건 믿는 것은 큰 실수다. 검증하기 전까지는 잠재적인 문제가 있을 수 있음을 잊지 말아야 한다. 실제로 필자는 위의 코드를 기반으로 멀티미디어 업로드 웹 서비스를 개발했었다. 잘 모르는 분야에 접근할 때, 전문가의 작품을 활용한다면 시간과 노력이 적게 들 수 있기 때문이다. 더욱이 MSDN Magazine이라면 충분히 신뢰할 수 있다고 믿었다. 그러나 일정 중간에 성능 테스트를 해보니 문제가 발견됐다. 10개 정도의 요청이 한번에 들어왔을 때, 2~3개는 타임아웃이 되고 말았다. 만약 이 상태로 서비스를 개시한다면, 사무실이 콜 센터로 바뀌는 것은 시간 문제일 것이 분명했다.

필자는 무엇이 문제인지 알아보기 위해 즉시 CLR Profiler(리스트 4)를 꺼내 들었다. 그리고 얼마 지나지 않아서 <리스트 3>의 코드가 병목의 원인임을 알았다. 당시의 테스트 자료를 공개할 수 없어서, 그 대신 <리스트 3>을 100회 실행시켜봤다. 프로파일링한 결과가 <리스트 5>이다. 척 보기에도 뭐가 문제인지 분명해진다. String 객체에 할당된 메모리가 1.3기가나 되고, 이 객체들은 거의 대부분(99.47%) String::Concat 메써드의 호출 때문에 발생했다.

<리스트 3>을 다시 보면, Concat이라는 메써드를 호출하는 곳이 보이지 않는다. 이것은 C#에서 String 객체 간의 + 연산이 Concat 메써드 호출로 해석되기 때문이다. 이를테면 메써드 First 중 mimeBody = mimeBody + line;mimeBody = String.Concat(mimeBody,line);으로 해석된다.

<리스트 4> CLR Profiler

CLR Profiler는 마이크로소프트가 무료로 제공하는 성능분석도구이다. 응용 프로그램의 실행주기에 어떤 객체가 생성되는지, 각각 얼만큼의 메모리를 할당 받았는지, 그리고 가비지 콜렉터의 작동 정보 등을 수집 및 분석하여 제공한다. 또한 <리스트 5>와 같이 호출 트리에 따른 할당 정보도 제공한다. CLR Profiler는 콘솔 또는 윈도우폼, ASP.NET, 그리고 윈도우 서비스 응용 프로그램을 분석할 수 있다. 자세한 사용법은 <참고문헌 2>의 문서를 참고하면 알 수 있다. 닷넷 프레임워크 버전에 따라 알맞은 버전을 선택하여 사용하면 된다.

문제 있는 파서의 프로파일링 결과

<리스트 5> 문제 있는 파서의 프로파일링 결과

무엇이 문제인가?

병목 구간은 알아냈다. 이제 문제의 원인을 알아낼 차례다. 결론부터 말하자면 닷넷에서 한번 만들어진 문자열은 변경될 수 없기 때문이다. 다시 말해 System.String 객체는 읽기 전용(Immutable)이다. String 객체가 제공하는 수많은 메써드도 실상은 객체 내의 데이터를 조작하는 것이 아니라, 변경된 내용을 담고 있는 새로운 객체를 생성해서 반환할 뿐이다.

<리스트 6>는 두 개의 String 객체를 만든다. 각각의 객체는 "string is immutable."과 "string is immutable. Really?"라는 내용을 담고 있다. 얼핏 보기엔 첫번째 줄에서 객체 msg에 "string is immutable."이 할당되고, 두번째 줄에서 String 인스턴스 msg의 내용에 "Really?"을 덧붙여서 "string is immutable. Really?"라고 바꾸는 것 같아 보인다. 실상은 최초에 할당된 문자열은 변경되지 않고, "string is immutable. Really?"라는 내용을 가진 String 객체를 하나 더 생성한다.

<리스트 6> String은 읽기 전용이다

string msg = "string is immutable.";
msg = msg + " Really?"
	

문제를 재조명해보자.

다시 <리스트 3>을 살펴보자. 일단 소스 코드의 15번째 줄에 있는 루프문만 고려해 보자. 메써드 First의 마지막 부분인 이 루프문은 <리스트 1>의 HTTP 메시지 중에서 BASE64로 인코딩된 "example.jpg" 문자열을 파싱해낸다. 이때 <리스트 1>의 BASE64 문자열이 한 줄에 n 바이트씩, m행만큼 기록됐다고 해보자. 한 줄씩 읽어나가므로 문자열은 m개가 생성될 것이고, 이때 각 문자열은 "n * 현재 행"만큼의 메모리를 차지하게 된다. 이를 공식으로 정리해보면 다음과 같다. 최초의 예상과는 달리 필요한 메모리의 크기는 데이터 크기( n x m )가 아닌 행수의 제곱( m^2)에 비례한다.

n x 1 + n x 2 + n x 3 … + nm(m+1)/2

이제 이론을 검증해보자. <리스트 3>의 메써드 First로 <리스트 1>의 HTTP 본문을 파싱하면 몇 개의 System.String 인스턴스가 생성될까? 우선 매개변수 text에 HTTP 본문이 실린다. 10번째 줄에서 "Content-ID: 0021186938931291464491"이 들어간 첫번째 문장을 찾을 때까지 한 줄씩 읽는다. 즉, 하나의 String 객체를 생성한다. 12번째 줄의 루프에서는 "Content-ID: 0021186938931291464491"인 MIME의 몸체가 나올 때까지 한 줄씩 읽는다. 17번째 줄의 루프는 MIME 몸체를 한 줄씩 읽고, 19번째 줄에서 String::Concat 메써드를 호출해서 새로운 String 객체를 생성한다. 즉, MIME 몸체를 처리하는 과정에서 매 줄마다 2개의 객체가 만들어진다. <리스트 1>의 HTTP 본문은 총 470줄이고, 이 중에서 BASE64 문자열이 446줄이다. 그러므로 총 생성되는 String 인스턴스의 개수는 다음과 같을 것이다.

1 + (470 – 446) +446 x 2 = 917 (개)

그럼 String 객체 생성을 위해 어느 정도의 메모리가 필요할까? HTTP 본문의 크기는 약 33KB 이고 이중 BASE64 문자열이 32kb 정도를 차지한다. 매개변수 text의 HTTP 본문은 32KB, BASE64 문자열을 제외한 본문을 한 줄씩 읽으므로 약 1KB가 할당돼야 한다. BASE64 문자열은 전체가 446줄(m)이고, 72개(n)의 문자가 한 줄을 이루고 있다. 그러므로 72 x 446 x (446 + 1) / 2 =7177032 바이트가 필요하다. 전체적으로 약 7메가 바이트가 필요함을 알 수 있는데, 닷넷의 String은 문자를 유니코드로 처리하므로 문자 당 2바이트가 필요하므로 실제로는 약 14메가의 공간이 할당돼야 한다.

<리스트 7>은 실제 프로파일링 결과다. 대체로 종전의 예측과 일치하는 값이 나왔다. 붉은 상자로 표시된 String 객체는 1054개가 생성됐고, 약 14.7메가의 메모리를 할당 받았다. HTTP 요청 하나를 처리하는데 최소한 14메가의 메모리가 필요한 서비스라면 보통 문제가 아니다. 이제 문제 해결을 시도해보자.

인스턴스의 개수와 할당된 메모리

<리스트 7> 인스턴스의 개수와 할당된 메모리

새로운 시도를 해보자.

이 같은 상황에서 벗어나려면 무엇보다도 생성되는 String 객체의 개수를 줄일 필요가 있다. 그리고 객체의 수를 줄이려면 문자열 조작과 관련된 String::Concat, String::Replace와 같은 메써드의 호출 회수를 줄여야 한다. 여러 가지 방법이 있겠지만, <리스트 8>는 한가지 대안을 보여준다. 먼저 찾고자 하는 MIME Part의 위치를 탐색한 다음, String::Substring 메써드로 원하는 MIME Part를 한번에 구할 수 있다. 이때 탐색 시간은 약 3m = O(m)을 소비한다. 또한 "mimeBody = text.Substring(startIndex, endIndex – startIndex);"이 반환하는 바이트 이하의 String 객체 하나만 생성될 뿐이다.

<리스트 8> 나아진 파서

static string Second(string text)
{
	string contentID = "Content-ID: 0021186938931291464491";
	string boundary = "--MIME_boundary";
	string mimeBody = null;

	int startIndex = text.IndexOf(contentID);
	startIndex = text.IndexOf("\r\n\r\n", startIndex) + 4;
	int endIndex = text.IndexOf("\r\n\r\n" + boundary,startIndex);
	if (startIndex >= 0 && endIndex >= 0)
	{
		mimeBody = text.Substring(startIndex, endIndex - startIndex);
	}
	return mimeBody;
}
	

이제 이론이 아닌 실증을 보일 차례다. 동일한 데이터에 대해 새로운 파서를 100회 실행시켜본 결과가 <리스트 9>에 제시되어 있다. <리스트 5>와 비교했을 때 할당된 메모리가 약 1/200으로 줄어들었다. 이 정도면 고무적인 성과다. 하지만 성능 문제도 해결됐는지 확인해 보지 않고는 안심하기엔 이르다.

CLR Profiler는 객체의 생명 주기에 따른 메모리 할당을 분석해줄 뿐이다. 이 분석 자료은 부하가 많이 걸리는 구간을 보여주고, 성능개선의 실마리를 제공한다. 그러나 각 구간이 차지하는 실행시간의 비중은 보여주지 못하므로, 성능 개선 여부는 다른 방식으로 알아내야 한다. 여기서는 간단하게 두 파서를 100회 실행하는 코드 전후에 시각을 System.Datetime 객체로 기록하여 소요된 시간을 측정해보았다. <리스트 10>에서 알 수 있듯이 두번째 파서가 첫번째 파서에 비해 20배 가량 빨랐다. 이제 요청이 타임아웃되는 상황을 피할 수 있게 됐다. 사무실이 콜 센터가 되는 위기를 넘겼으니 샴페인이라도 터트려서 자축이라도 할 일이다.

나아진 파서의 프로파일링 결과

<리스트 9> 나아진 파서의 프로파일링 결과

실행시간 비교

<리스트 10> 실행시간 비교

결론

Unmanaged C++에 익숙한 개발자라면, 메모리를 요리조리 헤집고 다니며 최적의 문자열 변환을 시도하는데 익숙하다. 하지만 닷넷은 System.String 객체에 대한 직접적인 조작을 허용하지 않는다. 또한 String 객체 외에 다른 문자열 래퍼 클래스가 있는 것도 아니다. 그렇다고 새로운 String 객체의 생성을 피할 방법이 아주 없는 것은 아니다. 어디나 비공식적인 경로가 있기 마련이다. 하지만 알려진 몇몇 방법을 사용한다면 추후 프레임워크 새 버전이 출시됐을 때 문제가 발생할 수 있다. 더욱이 꽁수를 사용하면 코드가 복잡해지고, 이해하기 어려운 코드는 유지보수하기도 어렵다. 그러므로 최선의 방법은 닷넷의 String 구현을 이해하고, 현재의 틀 안에서 최적의 방법을 찾는 것이다.

이번 기사에서는 String 객체의 불변성(Immutability)이 어플리케이션의 성능에 어떤 영향을 미칠 수 있는지 알아보고, 한가지 대안도 살펴봤다. 다음 시간에도 계속해서 실전에서 마주치게 되는 문자열 처리와 관련된 성능 문제를 알아보겠다.

참고 문헌

Author Details
Kubernetes, DevSecOps, AWS, 클라우드 보안, 클라우드 비용관리, SaaS 의 활용과 내재화 등 소프트웨어 개발 전반에 도움이 필요하다면 도움을 요청하세요. 지인이라면 가볍게 도와드리겠습니다. 전문적인 도움이 필요하다면 저의 현업에 방해가 되지 않는 선에서 협의가능합니다.

Leave a Reply

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