일요일, 10월 28, 2018

Streaming 1: 스트리밍의 정의와 관련 용어 및 개념 정리

일요일, 10월 28, 2018
1. 개요
빅 데이터 시대에 스트리밍은 매우 중요한 화제로 대두되고 있다. 수많은 IoT 기기의 센서들로부터 쏟아지는 데이터, Youtube와 같이 인터넷에서 실시간으로 제공되는 동영상 스트리밍 서비스, 혹은 Melon, Genie와 같은 음악 스트리밍 서비스 등 스트리밍을 활용하는 사례는 점점 더 많아지고 있다. 이 글은 스트리밍에 대한 깊은 이해와 구현을 목표로 하며, 시리즈로 진행할 예정이다. Streaming 시리즈에 포함된 모든 글은 The world beyond batch: Streaming 101과 The world beyond batch: Streaming 102의 내용을 토대로 번역 및 요약정리를 한 결과이다.

2. 스트리밍(Streaming)이란?
스트리밍이라는 용어는 다양한 의미로 사용된다. 이 글에서는 무한히 흘러들어오는 데이터를 지속적으로 처리해주는 데이터 처리 엔진의 일종으로 정의한다. 엄밀히 말하면, 일반적인 스트리밍뿐만 아니라, 마이크로 배치(Micro-Batch)를 사용하는 것까지 포함할 수 있다. 이에 대한 자세한 내용은 곧 데이터의 종류에 대한 설명과 함께 이어가도록 하겠다.
스트리밍은 무한한 규모의 데이터를 처리할 때 주로 사용되며, 데이터가 도착하는 대로 처리한다는 특징 때문에 다양한 장점이 있다. 대표적으로 짧은 응답시간이 있다. 그리고 지속적으로 흘러들어오는 데이터의 양이 일정하다고 가정한다면, 데이터를 처리하기 위한 하드웨어 자원의 소모가 예측 가능하고, 따라서 워크로드를 적절히 분배하기 쉽다는 이점이 있다.

2.1 Unbounded data
계속 무한히 증가하는 데이터, 흔히 스트리밍 데이터라고 여겨진다. 그런데, 스트리밍, 혹은 배치(Batch)라는 용어는 특정 데이터를 처리할 때 사용되는 엔진과 관련이 있기 때문에, 조심스럽게 사용해야 한다. 따라서, 이 글에서는 데이터의 종류를 유한성으로 구분하도록 한다. 즉, 무한한 것은 Unbounded data, 유한한 것은 Bounded data라고 정의한다. 우선은 무한한 스트리밍 데이터를 Unbounded data, 유한한 배치 데이터를 Bounded data라고 쉽게 대입해서 생각해도 좋다.

2.2 Unbounded data processing
무한히 흘러드는 Unbounded data를 실시간으로, 그리고 지속적으로 처리하는 방식이다. 일반적인 의미의 스트리밍 엔진뿐만 아니라, 배치 엔진을 반복적으로 사용하는 것도 Unbounded data processing이 될 수 있다. 따라서, 이 글에서 이하 "스트리밍"이라는 용어의 사용은 단순히 Unbounded data를 처리하기 위한 엔진을 의미한다고 가정한다.

3. 스트리밍에 대한 오해 및 새로운 가능성
스트리밍 시스템이 할 수 있는 것과 할 수 없는 것에 대해서 살펴보도록 하자. 이 글의 목적은 잘 디자인된 스트리밍 시스템이 어떤 긍정적인 모습으로 나타날 수 있는지를 강조하는 것이기 때문에, 스트리밍 시스템이 잘 할 수 있는 것들에 중점을 두고 설명을 진행하도록 하겠다.

3.1 Lambda architecture
스트리밍 시스템은 오랜 기간에 걸쳐 부정확하고, 추측에 근거한 결과들을 내놓는다는 부정적인 시선에 시달려왔다. 이러한 선입견은 Lambda Architecture와 같이 상대적으로 정확한 결과들을 도출해내는 배치 시스템들에 의해 더욱 부각되었다. Lambda Architecture의 기본 아이디어는 같은 연산을 수행하는 스트리밍 시스템과 배치 시스템을 함께 사용하는 것이다. 일반적으로, 스트리밍 시스템에서 자주 사용되는 Approximation 알고리즘을 적용하거나, 혹은 스트리밍 시스템 자체가 결과의 정확성에 대한 보장을 해주지 않는 경우 때문에 부정확한 결과가 도출될 수도 있다. 따라서, 이후에 배치 시스템이 따라 실행되면서 정확한 결과를 얻어낼 수 있도록 일조하는 것이다.
결과적으로, 이 아이디어는 굉장히 성공적이었다. 그도 그럴 것이, 스트리밍 엔진은 정확성 측면에서는 부족한 점이 많기 때문이다. 하지만, 람다 시스템은 큰 약점을 가지고 있다. 람다 시스템의 관리자는 두 개의 독립적인 버전의 시스템을 따로 빌드하고 배포해야 하며, 결과적으로 두 개의 파이프라인의 결과를 병합해야 하는 등, 유지보수에 두 배의 노력이 필요하다.
Jay Krep은 이 람다 시스템에 의문을 제기하며, Questioning the Lambda Architecture와 같은 게시글에서 반복성과 관련된 가능성을 제시한다. Kafka처럼 재사용성을 제공하는 스트리밍 상호연결체를 바탕으로 한 시스템을 사용하는, 다시 말해서, 특정 작업을 수행하기 위한 목적으로 잘 디자인된 한 가지 파이프라인을 사용하는 Kappa Architecture를 제안한다.
이 아이디어에 더해 첨언하자면, 사실상 잘 디자인된 스트리밍 시스템은 이론적으로 배치 시스템의 기능적인 상위호환이 될 수 있다고 생각한다. 스트리밍 시스템으로 배치 시스템을 이기려면, 정확성과 시간에 대한 추론이라는 두 마리 토끼를 잡아야 한다.

3.2 Correctness
스트리밍 시스템이 정확성을 갖게 된다면, 배치 시스템과 동등한 지위를 얻게 된다. 정확성 보장의 핵심은 일관된 스토리지 저장소에 있다. 스트리밍 시스템은 전체 시간의 흐름 속에서 끊임 없는 상태(State)의 변화를 기록(Checkpointing)해둘 수 있는 방법이 필요하다. (참고: Jay Krep's Why local state is a fundamental primitive in stream processing) 그리고 시스템 장애와 관련된 측면에서도 시스템의 일관성을 충분히 유지할 수 있도록 잘 디자인되어야 한다. 적어도 정확히 한 번의 처리에 있어서, 강력한 일관성을 보장하는 것이 배치 시스템과의 경쟁에서 승리할 수 있는 토대이다. 처음 Spark 스트리밍이 처음 빅 데이터 필드에 출현했을 때, 이러한 일관성을 보장하도록 스트리밍 시스템들을 인도하는 등대같은 존재가 되었다. 다행스럽게도 이후에 비약적인 발전이 이루어졌다.

3.3 Tools for reasoning about time
스트리밍 시스템에 시간에 대한 추론을 할 수 있는 도구까지 제공된다면, 배치 시스템을 앞서게 된다. 시간상 순차적으로 발생하는 Unbounded data가 네트워크 지연으로 인해 시스템에 전송되면서 순서가 뒤죽박죽이 되기 때문에, 실제 데이터의 발생 시간을 추론해주는 도구는 필수적이다. 이에 대한 심층적인 이해에 앞서, 시간 도메인(Time domains)과 관련된 핵심적인 개념들을 짚고 넘어가도록 하겠다.

4. Event time vs Processing time
이 글에서 사용할 시간 관련 용어로는 Event time과 Processing time이 있다. 이 둘의 정의는 다음과 같다.
Event time: 데이터가 실제로 발생한 시간
Processing time: 발생된 데이터가 시스템에 의해 관찰되는 시간
이상적인 경우는 Event time과 Processing time이 정확히 일치하는 것이다. 즉, 데이터가 발생하자마자 바로 시스템에서 관찰되고, 처리되는 것이다. 하지만, 현실에서는 이러한 경우가 드물다. 데이터, 처리 엔진, 하드웨어의 특성 등에 따라 Event time과 Processing time 사이에는 격차가 발생한다. 이 격차를 Skew라고 하는데, Skew를 유발하는 요인은 대표적으로 세 가지 정도를 들 수 있다.
공유된 자원의 제한: 네트워크의 혼잡 및 파티션, 혹은 다용도의 CPU를 사용하는 환경에서의 공유된 CPU 자원
소프트웨어적인 원인: 분산 시스템에 사용되는 로직과 이로 인해 발생하는 경쟁 상황
데이터 자체의 특성: 보안용 키의 분배, Throughput의 비일관성, 또는 승객들이 비행기 모드로 오프라인에서 휴대폰을 사용하다가 온라인 환경으로 돌아왔을 경우에 발생하는 데이터의 무질서 등
결과적으로, Event time과 Processsing time을 그래프로 표현하면 다음과 같이 나타난다.
검은색 점선으로 표시된 대각선이 이상적인 경우이고, 빨간색 곡선이 현실을 반영한 것이다. 그리고 Event time과 Processing time 사이에 발생하는 수평 방향의 격차가 Skew이다. 이 Skew는 데이터를 처리하는 파이프라인으로 인해 발생하는 지연이라고 할 수 있다.
Event time과 Processing time을 매핑하는 것은 정적이지 않기 때문에, 데이터가 실제 발생한 시간이나 순서를 고려해야 하는 경우, 데이터를 분석하는 것은 매우 어려운 작업이 된다. 이러한 문제를 극복하고 무한히 흘러들어오는 Unbounded data를 잘 처리하기 위해서 Windowing과 같은 기법들을 사용하곤 한다. Windowing에 대한 자세한 내용은 다음 글에서 다루도록 하겠다. Windowing의 기본 아이디어는 데이터를 시간상에서 유한한 작은 조각들로 나누어 처리한다는 것이다.
만약 정확성이 요구되는 상황이거나 시계열 데이터를 처리하는 경우, Processing time을 기준으로 Windowing을 하면 문제가 발생한다. Event time과 Processing time의 관계를 정밀하게 분석하여 Event time을 기준으로 Windowing을 해야 한다. 이 과정에서 같은 Window에서 처리되어야 할 Event time 기반의 데이터가 다른 Window에 배정되는 경우 등 여러가지 예외상황들을 함께 고려해주면 된다.
하지만 불행하게도, Event time을 기준으로 Windowing을 하는 것도 마냥 장밋빛 미래를 선사하지는 않는다. Unbounded data를 처리한다는 맥락에서는, 데이터의 무질서와 일관되지 않은 Skew로 인해, 즉, Event time과 Processing time의 완벽한 매핑의 부재로 인해, 특정 Event time X에서 발생한 모든 데이터가 특정 Processing time에 관찰되었다는 식으로 단정짓기가 힘들다. 이를 Completeness의 문제라고 정의한다.
단순히 Unbounded data를 유한한 배치들로 나누어서 결과적으로 Completeness를 보장하는 식의 접근보다는, 복잡한 데이터로 인해 발생하는 불확실성에 대응할 수 있는 도구들을 디자인하는 접근 방식이 요구된다. Completeness의 관점에서 볼 때, 바람직한 시스템은 새로운 데이터가 들어올 경우, 오래된 데이터를 철회하거나 갱신하는 과정을 스스로 수행할 수 있어야 한다. 이러한 시스템에 대해 상세히 논의하기 전 이론적인 초석을 다지기 위하여, 다음 글에서는 데이터를 처리하는 여러가지 기법들에 대해서 자세히 살펴보도록 하겠다.

5. References
https://www.oreilly.com/ideas/the-world-beyond-batch-streaming-101
https://www.oreilly.com/ideas/the-world-beyond-batch-streaming-102

토요일, 8월 25, 2018

Parametric Classification: 연속 확률 분포의 나이브 베이즈 분류 (Naive Bayes Classification with Continuous Probability Distribution) - NBC

토요일, 8월 25, 2018
1. 개요
자연계의 많은 현상들은 정규분포(Normal Distribution)을 따른다고 알려져 있다. 이렇게 데이터가 연속 확률 분포를 따를 경우에는 어떻게 학습을 하고, 또 이를 바탕으로 어떻게 분류를 해야 할까?
N(x: μ, σ2)에서의 Parameter는 μ와 σ이다. 기하학적으로 확률 밀도 함수의 위치는 μ가 결정하고, 폭은 σ가 결정하기 때문이다. 이를 바탕으로 NBC의 방법론을 활용하여 파라미터 학습(Parametric Learning)과 분류(Parametric Classification)를 진행하는 과정을 살펴보도록 하자.

2. Maximum Likelihood Estimation(MLE) Review
학습은 MLE를 통해 진행하는데, 구체적인 예시를 다루기 전에, 정규분포를 따르는 데이터에 대해 MLE로 어떻게 Parametric Learning을 하는지 간단히 살펴보도록 하자. MLE에 대한 자세한 설명은 관련 포스트를 참조하길 바란다.
(참고: https://arkainoh.blogspot.com/2017/10/parametric.learning.maximum.likelihood.estimation.html)
정규분포를 따르는 어떤 N개의 데이터 x의 집합 D가 주어졌다고 가정하자.
편의를 위해, 학습하고자 하는 Parameter를 θ라고 하면, MLE는 다음 식의 답을 구하는 과정이라고 할 수 있다.
수식을 쉽게 정리하기 위해, 시그마 부분을 L로 치환하자.
이 L을 미분으로 풀면, 평균과 분산을 쉽게 구할 수 있다. 이렇게 구한 평균을 Sample Mean이라고 하고, 분산을 Sample Variance라고 하자.
Sample Mean과 Sample Variance는 특정 데이터셋(Dataset), 즉, 특정 샘플(Sample)에 대한 학습 결과를 의미한다. 당연하게도 샘플의 데이터 수가 매우 크거나, 샘플 데이터의 분포가 전체 데이터의 분포와 유사할 경우, 학습 정확도가 크게 나타날 것이다.

3. Learning: NBC를 통한 학습
그렇다면, 앞서 살펴본 MLE를 바탕으로, 연속 확률 분포를 따르면서 여러 속성(e.g. 키, 몸무게, etc.)들을 가진 데이터, 즉, Multivariate Data에 대한 학습과 분류를 진행해보도록 하자.
NBC에 대한 배경지식이 필요하다면, 다음 링크를 먼저 학습하고 오는 것을 추천한다. 이 글에서 똑같은 예제를 사용한다.
(참고: https://arkainoh.blogspot.com/2018/07/nbc.html)
데이터 x는 다차원의 Vector로 주어지고, x가 어떤 클래스에 속하는지 나타내는 r이 One-hot Encoded Vector로 주어진다. r의 각 요소는 몇 번째 클래스인지를 의미한다.
예를 들어, 특정 사람의 키와 몸무게가 주어지고 남자인지 여자인지 구분하는 문제라고 생각해보자. 첫 번째 클래스가 남자, 두 번째 클래스가 여자라고 가정할 경우, 키가 180, 몸무게가 70인 남자의 데이터는 x = [180 70], r = [1 0] 이런 식으로 주어진다.
데이터가 가지는 속성의 인덱스 i는 Vector x의 i 번째 차원을 의미한다. 그리고 클래스 인덱스 k는 Vector r의 k 번째 차원을 의미한다. 위의 예시에서, x1 = 180, x2 = 70, r1 = 1, r2 = 0이다. 그리고, i <= 2, k <= 2가 성립한다.
이러한 정보들을 바탕으로 NBC를 통해 m(Sample Mean)과 s(Sample Variance)를 학습하는 과정을 의사 코드(Pseudo Code)로 나타내면 다음과 같다.
우선 위 코드는 구하고자 하는 값들을 초기화히고, 준비하는 단계이다. 실제 값을 구하여 반환하는 로직은 다음과 같다.
예시와 함께 살펴보도록 하자. 위의 예시처럼, 데이터 x는 키, 몸무게로 이루어진 벡터로 주어지고, 클래스는 남자 혹은 여자로 주어진다고 가정하자.
설명의 편의를 위해 One-hot Encoded Vector인 r대신 남, 여, ... 이런 식으로 클래스의 정보가 주어진다고 가정하겠다. 그렇다면, D를 구성하는 데이터들은 다음과 같이 주어질 것이다.
데이터들을 카운트하여 표에 정리했더니 다음과 같이 결과가 나타났다고 생각해보자.
전체 데이터 수 N = 10이기 때문에, Priority Probability는 P(C1) = 4 / 10 = 0.4이고, P(C2) = 6 / 10 = 0.6이다. 이전 글에서 데이터가 이산 확률 분포를 따른다는 가정 하에서는 데이터를 단순히 카운트하여 Class Likelihood를 P(xi = vj | Ck)라고 놓고 구할 수 있었지만, 이번에는 데이터가 연속 확률 분포를 따르기 때문에 불가능하므로, MLE를 통해 Parameter를 학습한 뒤에 Class Likelihood를 구하도록 한다.
예를 들어, m11과 s11은 다음과 같이 구할 수 있다.
m11 = (150 + 160 + 160 + 170) / 4 = 160
s11 = ((150 - 160)2 + 0 + 0 + (170 - 160)2) / 4 = 50
이와 같은 방식으로 m12, s12, m21, s21, m22, s22를 모두 구하면 학습은 완료된다.

4. Classification: NBC를 통한 분류
학습된 정보를 통해 분류를 할 때는, 각 클래스 중에 Posterior Probability가 가장 큰 클래스로 배정하면 된다. 앞서 Priority Probability와 Class Likelihood를 미리 구해두었기 때문에, Posterior Probability는 이 둘의 곱으로 간단히 나타낼 수 있다.
이 둘 중 값이 큰 쪽으로 Classification을 한다. 계산이 복잡하기 때문에 log를 취하여 구하도록 한다. 그리고 Vector의 차원(Dimension)을 고려하지 않고, 간단화해서 생각해보도록 하자.
위의 식을 x에 대한 방정식이라고 생각하고, σ, P(C1), P(C2)는 그 값이 미리 주어지는 상수라고 가정하자. 그렇다면 이 세 가지 상수의 값이 어떻게 주어지느냐에 따라 방정식의 그래프가 다양하게 그려지게 된다. 크게 세 가지 정도의 경우를 살펴보자.

(1) σ1 = σ2 & P(C1) = P(C2)
이 경우는 두 식을 간단히 정리하면 다음과 같다.
① (x - μ1)2
② (x - μ2)2
좀 더 직관적인 분석을 위해 그래프로 그려보면 다음과 같다. 회색으로 칠해진 부분은 Error 영역을 의미한다.
동일한 크기의 확률 밀도 함수가 나란히 나타나는 것을 관찰할 수 있다. 두 함수의 높낮이는 같기 때문에, 분류가 되는 기준은 정확히 x가 μ1과 μ2의 중간지점이 되는 경우이다. 즉, μ1과 μ2의 산술평균이 분류의 기준이 된다.

(2) σ1 = σ2 & P(C1) != P(C2)
앞선 경우와 달라진 점은 확률 밀도 함수의 높낮이에 변화가 있다는 점이다. 각각 곱해지는 상수의 값이 다르기 때문에 나타나는 현상이다. 이에 따라 분류의 기준은 μ1과 μ2의 산술평균과는 다른 지점으로 이동하게 된다. 결과적으로, 곱해지는 Prior Probability가 클수록 해당 클래스로 분류되는 면적이 증가한다. 이는 당연한 결과이다. Prior Probability는 전체 데이터에서 해당 클래스가 어느 정도의 비율을 차지하는지를 의미하므로, 이 값이 높을수록 해당 클래스로 분류될 확률도 증가하는 것이다.

(3) σ1 != σ2 & P(C1) != P(C2)
가장 일반적인 경우이다. 이 경우 σ 값이 상이하므로, 확률 밀도 함수들의 폭이 서로 달라지게 된다. 확률 밀도 함수의 폭과 높낮이가 모두 상이하기 때문에 수많은 경우가 있겠지만, 예시로 다음과 같은 그래프를 그릴 수 있다.
이 경우 단순히 특정 지점을 하나를 기준으로 C1 혹은 C2로 분류되는 것이 아니라, 두 확률 밀도 함수의 교차점들, 즉, 두 개의 지점을 고려해야 한다. 따라서 Decision Boundary가 두 확률 밀도 함수의 교점을 기준으로 C2, C1, C2로 나뉘는 형태로 나타난다.

5. 결론
데이터가 이산 확률 분포를 따를 때, 그리고 연속 확률 분포를 따를 때 각각의 경우는 NBC라는 큰 틀에서 큰 차이가 없다. 단, Class Likelihood를 구할 때 연속 확률 분포를 따르는 데이터셋의 경우에는 Parametric Learning을 통해 각 클래스별 확률 밀도 함수의 μ와 σ2를 구해야 한다는 점을 주의해야 한다. 결국 이 Parameter들에 의해 각 클래스의 확률 밀도 함수의 모양과 위치가 결정되고, 이를 바탕으로 Decision Boundary가 형성된다.

6. References
Alpaydın, Ethem. Introduction to machine learning. Cambridge, MA: MIT Press, 2014. Print.

수요일, 8월 15, 2018

[Python] Ctypes를 활용한 C언어 연동 - 유니코드 한글 다루기

수요일, 8월 15, 2018

1. 개요
파이썬(Python)은 C언어를 기반으로 하기 때문에, C언어와의 연동이 비교적 자유롭다. 이 글에서는 윈도우(Windows) 환경에서 Python의 기본 모듈 중 하나인 Ctypes를 활용하여 C언어의 함수들을 사용하는 방법을 살펴볼 것이다. 특히 Python3의 기본 인코딩 방식인 유니코드 문자를 다루는 예제를 중점적으로 설명하도록 하겠다.

2. C언어 동적 연결 라이브러리(DLL) 만들기
윈도우 환경에서 Python으로 C언어의 함수들을 사용하려면, C언어를 기반으로 만들어진 DLL 파일(*.dll)이 필요하다. (cf. Linux/Unix 환경에서는 *.so 파일을 사용한다.) DLL 파일은 Visual Studio를 통해 간단하게 만들 수 있다. Visual C++ 항목의 새로운 프로젝트를 생성한다. 이때, 응용 프로그램 종류를 동적 연결 라이브러리(.dll)로 선택한다. 프로젝트 이름은 test_dll이라고 하자. Visual Studio 2017을 기준으로는 Windows 데스크톱 마법사를 선택할 시, 다음과 같은 팝업이 나온다.
추가 옵션에서는 모두 체크를 풀고, 빈 프로젝트를 선택한다. 프로젝트가 생성되었다면 소스 파일에 C++ 파일을 하나 만들고, 다음과 같이 코드를 입력한다.
extern "C" __declspec(dllexport)
int sum(int a, int b) {
  return a + b;
}
이때, 첫 줄이 중요하다. 이 코드는 외부에서 DLL 파일을 통해 해당 함수를 접근할 수 있도록 알리는 역할을 한다. 만약, 함수 구현부 앞에 이 코드가 없다면 외부에서 인식할 수 없는 숨겨진 함수가 된다. Python 측에서 함수를 사용하고 싶다면 반드시 포함해야 하는 코드다.
빌드를 마치면, 솔루션이 위치한 경로에 DLL 파일이 생성되었다는 메시지가 출력된다.
test_dll.vcxproj -> <솔루션 경로>\Debug\test_dll.dll
이 위치에 가서 Python을 실행하거나, Python이 실행될 위치에 DLL 파일을 복사한 뒤 Python을 실행하고, 콘솔에 다음과 같은 명령어를 입력해보자.
>>> import ctypes as c
>>> mydll = c.WinDLL('test_dll')
아무런 메시지도 뜨지 않는다면, 정상적으로 DLL 파일을 불러왔다는 의미이다. 그런데, 다음과 같은 오류 메시지가 출력될 수도 있다.
>>> mydll = c.WinDLL('test_dll')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "C:\anaconda3\lib\ctypes\__init__.py", line 348, in __init__
    self._handle = _dlopen(self._name, mode)
OSError: [WinError 193] %1은(는) 올바른 Win32 응용 프로그램이 아닙니다
이는 64 bit 버전의 Python을 사용할 경우 발생할 수 있는 문제이다. DLL 파일이 32 bit 운영체제를 기준으로 만들어져 있기 때문이다. 따라서, 이 문제를 해결하기 위해서는 DLL 파일을 64 bit 버전으로 만들어주면 된다. Visual Studio의 상단 메뉴에서 x86 부분을 x64로 바꿔준 뒤 다시 빌드를 한다.
Debug를 선택했다면, DLL 파일이 <솔루션 경로>\x64\Debug에 생성될 것이고, Release를 선택했다면, <솔루션 경로>\x64\Release에 생성될 것이다. Python을 실행하고 새로 생성된 DLL 파일로 다시 불러오기를 시도할 경우, 정상적으로 동작이 될 것이다.

3. Python에서 C언어 함수 사용하기
DLL 파일을 성공적으로 불러왔다면, mydll이라는 변수에 DLL 파일의 정보가 담겨있을 것이다. 앞선 예시에서 C언어 측 코드에 extern "C" __declspec(dllexport)이 사용된 함수라면, 해당 함수의 이름이 Dictionary 형태로 mydll 변수에 저장되어 있다. 예를 들어, 앞서 정의한 sum 함수를 불러오기 위해서는 Python 콘솔에 다음과 같이 입력하면 된다.
>>> c_sum = mydll['sum']
c_sum은 이제 C언어 측에서 정의된 sum함수가 되며, 정의된 그대로 사용할 수 있다. 예를 들어, 다음과 같이 Python 함수처럼 사용할 수 있다.
>>> a = c_sum(3, 5)
>>> a
8
>>> type(a)
<class 'int'>
그런데, 이는 Python에서 사용하는 int와 C언어에서 사용하는 int의 형식이 서로 호환되기 때문에 가능한 것이다. 만약 함수의 인자나 반환 값에 다른 자료형(Data Type)을 사용하고자 한다면, Ctypes 모듈에 있는 C언어의 자료형들로 변환하여 사용해야 한다. 자세한 사항은 https://docs.python.org/3.6/library/ctypes.html에서 확인할 수 있으며, C언어의 자료형과 Python의 기본 자료형이 다음과 같이 1:1로 맵핑되어 있다.
엄밀하게 말하자면, 이러한 Ctypes의 자료형들을 사용하여 C언어 함수를 사용하기 전, 함수의 인자 타입과 리턴 타입을 명시해주어야 한다. c_sum 함수의 경우, 다음과 같이 설정하면 된다.
>>> c_sum.argtypes = (c.c_int, c.c_int)
>>> c_sum.restype = c.c_int

4. 유니코드 문자열 처리
int 이외에 char, float, double 등의 기본 자료형은 위 예시와 크게 다르지 않기 때문에 쉽게 사용할 수 있을 것이다. 그러나, 배열이나 문자열 등을 처리하는 것은 상대적으로 까다롭다. 이번에는 Python의 문자열을 C언어 측에서 처리하고 반환하는 과정을 예제로 살펴보도록 하겠다.
Python에서는 문자를 처리할 때, 기본적으로 2 Bytes의 유니코드를 사용한다. 이는 C++에서 wchar_t 자료형에 해당한다. 3의 표를 보면, Python의 문자열 형식이 C type 중 wchar_t의 포인터 형식과 일치하며, NULL 문자로 종료(NUL terminated)되는 문자열 배열이라는 사실을 확인할 수 있다.
따라서, 테스트용으로 두 개의 C 함수를 만들도록 한다. 첫 번째는, Python 측에서 문자열을 받아서 앞 두 글자를 바꾸는 함수이고, 두 번째는 Python 측에서 정수 n을 인자로 받아서 n 길이의 문자열을 반환해주는 함수이다.
extern "C" __declspec(dllexport)
void str_arg_test(wchar_t* c) {
  c[0] = L'안';
  c[1] = L'녕';
}
extern "C" __declspec(dllexport)
wchar_t* str_ret_test(int n) {
  wchar_t* ret = new wchar_t[n];
  wchar_t ch = L'가';
  int i;
  for (i = 0; i < n; i++) {
    ret[i] = ch + i;
  }
  ret[i] = 0;
  return ret;
}
이때, 주의할 점은 문자 리터럴을 사용할 때, 앞에 L을 붙여줘야 한다는 것이다. 영어 알파벳의 경우, 1 Byte의 char 자료형으로도 표현할 수 있어서 상관없지만, 한글처럼 2 Bytes의 유니코드를 사용해야 하는 경우, 해당 문자가 2 Bytes를 사용한다는 것을 C 컴파일러에게 알려줄 필요가 있다. 이러한 의도로 사용하는 것이 L이다. 만약, L을 사용하지 않는다면 Python 측에서 문자열을 반환받았을 때 한글이 깨지는 현상이 발생한다.
그리고, 두 번째 함수에서 맨 마지막 문자에 0을 대입하는데, 이는 문자열의 맨 끝이 NULL로 종료된다는 점을 반영한 것이다. 만약 이 부분을 생략하면, Python 측에서 문자열을 반환받았을 때, 문자열의 길이가 제멋대로가 되며 뒷부분에 의도치 않은 문자들이 포함된다.
이제 Python 측에서 두 함수를 불러오도록 하자. 콘솔에 다음과 같이 입력한다.
>>> f1 = mydll['str_arg_test']
>>> f2 = mydll['str_ret_test']
>>> f1.argtypes = (c.c_wchar_p, )
>>> f2.restype = c.c_wchar_p
이때 주의할 점이 몇 가지 있다. 우선 argtypes는 Python의 튜플(tuple) 형식으로 설정해야 하기 때문에, 인자가 하나만 필요할 경우 (인자 타입, ) 형식으로 설정한다. 그리고 f1의 경우 인자로 받은 문자열의 일부를 변경하는데, 이것이 Call by Reference 형식이기 때문에 함수가 종료되더라도 호출한 쪽에서 인자로 제공한 문자열에 변경 사항이 반영되어야 한다. 만약 Python의 문자열을 그대로 f1에 인자로 제공한다면, 함수가 동작은 하지만 함수가 끝났을 때 Python 측의 문자열에 변경 사항이 제대로 반영되지 않는다. 따라서, f1의 의도를 반영하려면, Python 측에서 문자열을 우선 Ctypes의 c_wchar_p 형식으로 변환한 뒤 인자로 제공해야 한다. 변환은 다음과 같이 간편하게 할 수 있다.
>>> test_str = '인사하세요'
>>> c_test_str = c.c_wchar_p(test_str)
>>> c_test_str.value
'인사하세요'
이제 준비된 것들을 바탕으로 테스트를 하면, 다음과 같은 결과를 얻을 수 있다.
>>> test_str
'인사하세요'
>>> c_test_str.value
'인사하세요'
>>> f1(test_str)
2
>>> test_str
'인사하세요'
>>> f1(c_test_str)
2
>>> c_test_str.value
'안녕하세요'
>>> f2(10)
'가각갂갃간갅갆갇갈갉'
반환받은 문자열을 배열처럼 사용하고 싶다면, list 함수를 활용하면 된다.
>>> list(f2(10))
['가', '각', '갂', '갃', '간', '갅', '갆', '갇', '갈', '갉']

5. Source Code
(1) test_dll.cpp
extern "C" __declspec(dllexport)
int sum(int a, int b) {
  return a + b;
}
extern "C" __declspec(dllexport)
void str_arg_test(wchar_t* c) {
  c[0] = L'안';
  c[1] = L'녕';
}
extern "C" __declspec(dllexport)
wchar_t* str_ret_test(int n) {
  wchar_t* ret = new wchar_t[n];
  wchar_t ch = L'가';
  int i;
  for (i = 0; i < n; i++) {
    ret[i] = ch + i;
  }
  ret[i] = 0;
  return ret;
}

(2) test_dll.py
import ctypes as c
# load dll
mydll = c.WinDLL('test_dll')
# load C functions
c_sum = mydll['sum']
f1 = mydll['str_arg_test']
f2 = mydll['str_ret_test']
# set argtypes and restype
c_sum.argtypes = (c.c_int, c.c_int)
c_sum.restype = c.c_int
f1.argtypes = (c.c_wchar_p, )
f2.restype = c.c_wchar_p
# test
c_sum(3, 5)
test_str = '인사하세요'
c_test_str = c.c_wchar_p(test_str)
c_test_str.value
f1(test_str)
f1(c_test_str)
print(test_str)
print(c_test_str.value)
print(list(f2(10)))

6. References
https://docs.python.org/3.6/library/ctypes.html

FOLLOW @ INSTAGRAM