C/C++ volatile 키워드

Posted 2008. 12. 1. 23:17

2006년 9월 마이크로소프트웨어 기고글입니다.


약 60여개의 C++ 키워드 중에 가장 사용 빈도가 낮은 키워드는 무엇일까? 정답은 volatile이다. 대부분의 C/C++ 참고 서적은 1-2줄 정도로 volatile이 컴파일러의 최적화(optimization) 막아준다고만 설명하고 있다. 또한 Java5 이후로 명확한 메모리 모델이 확립된 자바와는 달리 C/C++의 경우 volatile에 대한 명확한 표준이 없고 컴파일러마다 구현에 차이가 있다는 점도 volatile 키워드의 사용을 어렵게 하고 있다. 하지만 임베디드 시스템이나 멀티쓰레드 프로그래밍이 보편화된 만큼, 이 글에서는 volatile 키워드의 기초부터 다시 살펴보고자 한다.


volatile 소개

volatile로 선언된 변수는 외부적인 요인으로 그 값이 언제든지 바뀔 수 있음을 뜻한다. 따라서 컴파일러는 volatile 선언된 변수에 대해서는 최적화를 수행하지 않는다. volatile 변수를 참조할 경우 레지스터에 로드된 값을 사용하지 않고 매번 메모리를 참조한다. 왜 volatile이라는 키워드가 필요한지 이해하려면 먼저 일반적인 C/C++ 컴파일러가 어떤 종류의 최적화를 수행하는지 알아야 한다. 가상의 하드웨어를 제어하기 위한 다음 코드를 살펴보자.


*(unsigned int *)0x8C0F = 0x8001

*(unsigned int *)0x8C0F = 0x8002;

*(unsigned int *)0x8C0F = 0x8003;

*(unsigned int *)0x8C0F = 0x8004;

*(unsigned int *)0x8C0F = 0x8005;

잘못된 하드웨어 제어 코드


이 코드를 보면 5번의 메모리 쓰기가 모두 같은 주소인 0x8C0F에 이루어짐을 알 수 있다. 따라서 이 코드를 수행하고 나면 마지막으로 쓴 값인 0x8005만 해당 주소에 남아있을 것이다. 영리한 컴파일러라면 속도를 향상시키기 위해서 최종적으로 불필요한 메모리 쓰기를 제거하고 마지막 쓰기만 수행할 것이다. 일반적인 코드라면 이런 최적화를 통해 수행 속도 면에서 이득을 보게 된다.

하지만 이 코드가 MMIO(Memmory-mapped I/O)처럼 메모리 주소에 연결된 하드웨어 레지스터에 값을 쓰는 프로그램이라면 이야기가 달라진다. 각각의 쓰기가 하드웨어에 특정 명령을 전달하는 것이므로, 주소가 같다는 이유만으로 중복되는 쓰기 명령을 없애버리면 하드웨어가 오동작하게 될 것이다. 이런 경우 유용하게 사용할 수 있는 키워드가 volatile이다. 변수를 volatile 타입으로 지정하면 앞서 설명한 최적화를 수행하지 않고 모든 메모리 쓰기를 지정한 대로 수행한다.


*(volatile unsigned int *)0x8C0F = 0x8001

*(volatile unsigned int *)0x8C0F = 0x8002;

*(volatile unsigned int *)0x8C0F = 0x8003;

*(volatile unsigned int *)0x8C0F = 0x8004;

*(volatile unsigned int *)0x8C0F = 0x8005;

올바른 하드웨어 제어 코드


특정 메모리 주소에서 하드웨어 레지스터 값을 읽어오는 프로그램의 경우도 마찬가지다. 아래 코드의 경우 같은 주소에서 반복적으로 메모리를 읽으므로, 최적화 컴파일러라면 buf[i] = *p;에서 *p를 한 번만 읽어온 후에 그 값을 반복해 사용할 것이다. 하지만 volatile 키워드가 있다면 *p를 참조할 때마다 직접 메모리에서 새 값을 가져와야 한다. 이 경우는 하드웨어가 메모리 0x8C0F 번지 값을 새롭게 변경해 주는 경우이다.


void foo(char *buf, int size)

{

     int i;

     volatile char *p = (volatile char *)0x8C0F;

 

     for (i = 0 ; i < size; i++)

     {

         buf[i] = *p;

         ...

     }

}

하드웨어 레지스터 읽기


가시성


volatile 키워드는 앞서 살펴본 하드웨어 제어를 포함하여 크게 3가지 경우에 흔히 사용된다.

 

(1) MMIO(Memory-mapped I/O)

(2) 인터럽트 서비스 루틴(Interrupt Service Routine)의 사용

(3) 멀티 쓰레드 환경

 

세 가지 모두 공통점은 현재 프로그램의 수행 흐름과 상관없이 외부 요인이 변수 값을 변경할 수 있다는 점이다. 인터럽트 서비스 루틴이나 멀티 쓰레드 프로그램의 경우 일반적으로 스택에 할당하는 지역 변수는 공유하지 않으므로, 서로 공유되는 전역 변수의 경우에만 필요에 따라 volatile을 사용하면 된다.


int done = FALSE;

 

void main()

{

     ...

     while (!done)

     {

         // Wait

     }

     ...

}

 

interrupt void serial_isr(void)

{

     ...

     if (ETX == rxChar)

     {

         done = TRUE;

     }

     ...

}

serial.c


위 시리얼 통신 예제는 전역 변수로 done을 선언해서 시리얼 통신 종료를 알리는 ETX 문자를 받으면 main 프로그램을 종료시킨다. 문제는 done이 volatile이 아니므로 main 프로그램은 while(!done)을 수행할 때 매번 메모리에서 done을 새로 읽어오지 않는다는 점이다. 따라서 serial_isr() 루틴이 done 플래그를 수정하더라도 main은 이를 모른 채 계속 루프를 돌고 있을 수 있다. done을 volatile로 선언해주면 매번 메모리에서 변수 값을 새로 읽어오므로 이 문제가 해결된다.

인터럽트의 경우와 마찬가지로 멀티 쓰레드 프로그램도 수행 도중에 다른 쓰레드가 전역 변수 값을 임의로 변경할 수 있다. 하지만 컴파일러가 코드를 생성할 때는 다른 쓰레드의 존재 여부를 모르므로 변수 값이 변경되지 않았다면 매번 새롭게 메모리에서 값을 읽어오지 않는다. 따라서 여러 쓰레드가 공유하는 전역 변수라면 volatile로 선언해주거나 명시적으로 락(lock)을 잡아야 한다.

이처럼 레지스터를 재사용하지 않고 반드시 메모리를 참조할 경우 가시성(visibility) 이 보장된다고 말한다. 멀티쓰레드 프로그램이라면 한 쓰레드가 메모리에 쓴 내용이 다른 쓰레드에 보인다는 것을 의미한다.


문법과 타입


C/C++의 volatile은 상수(constant)를 선언하는 const와 마찬가지로 타입에 특정 속성을 더해주는 타입 한정자(type qualifier)이다. const int a = 5; 라고 선언했을 경우 a라는 변수는 정수 타입의 변수이면서 동시에 상수의 속성을 가짐을 의미한다. 같은 방식으로 volatile int a; 라고 선언해주면 정수 변수 a는 volatile 속성을 가지게 된다.

조심해야 할 것은 포인터 타입에 volatile을 선언하는 경우이다. int *를 volatile로 선언하는 몇 가지 방법을 비교해보자.


volatile int* foo;

int volatile *foo;

int * volatile foo;

int volatile * volatile foo;

volatile의 선언


복잡하게 보이지만 원리는 const 타입 한정자와 동일하다. *를 기준으로 왼쪽에 volatile 키워드가 올 경우 포인터가 가리키는 대상이 volatile함을 의미하고, * 오른쪽에 volatile 키워드가 올 경우에는 포인터 값 자체가 volatile임을 의미한다. volatile이 *의 양쪽에 다 올 경우는 포인터와 포인터가 지시하는 대상이 모두 volatile함을 의미한다. 하드웨어 제어의 예처럼 일반적으로 포인터가 가리키는 대상이 volatile 해야 할 경우가 많으므로 volatile int * 형태가 가장 많이 사용된다. volatile int*와 int volatile*은 동일한 의미이다.


01: int foo(int& a)

02: {

03: }

04:

05: int bar(volatile int& b)

06: {

07: }

08:

09: int main()

10: {

11:     volatile int a = 0;

12:     int b = 0;

13:

14:     foo(a);

15:     bar(b);

16: }

 

$ g++ a.cc

a.cc: In function `int main()':

a.cc:14: error: invalid initialization of reference of type 'int&' from expression of type 'volatile int'

a.cc:2: error: in passing argument 1 of `int foo(int&)'


또한 int는 volatile int의 서브타입에 해당한다. 서브 타입을 쉽게 설명하면 A를 요구하는 곳에 언제든지 B를 사용할 수 있다면 B는 A의 서브 타입이다. 쉬운 예로 Class의 경우 Derived 클래스는 Base 클래스의 서브 타입이다. volatile int를 요구하는 곳에 언제든지 int를 넘길 수 있으므로 int는 volatile int의 서브 타입이라고 말할 수 있는 것이다. 그 예로 위 C++ 프로그램은 컴파일 에러가 난다. volatile int를 받는 bar() 함수에 int 인자로 호출하는 것은 아무 문제없지만, int를 요구하는 foo() 함수에 volatile int 타입인 a를 넘기면 컴파일 에러가 나는 것이다.


01: int foo(int& a)

02: {

03: }

04:

05: int bar(const int& b)

06: {

07: }

08:

09: int main()

10: {

11:     const int a = 0;

12:      int b = 0;

13:

14:     foo(a);

15:      bar(b);

16: }

foobar2.cc


이 관계가 쉽게 이해되지 않는다면 위의 예처럼 volatile 키워드를 같은 타입 한정자인 const로 대체해보자. 변수 a는 const int 타입이므로 이미 상수로 선언되었다. const int인 a를 int를 요구하는 foO() 함수에 넘기면 const라는 가정이 깨어지므로 컴파일 에러가 된다. 반대로 b의 경우 원래 const가 아니었지만 bar로 넘기면서 const 속성을 새롭게 부여받게 된다. const 속성을 부여받는다고 말하면 무엇인가 기능이 추가되는 것 같지만, 이는 바꿔 말해서 원래 int의 2가지 기능인 읽기, 쓰기에서 쓰기 기능이 사라지는 것으로 볼 수도 있다. 이를 클래스로 표현해 보면 다음과 같을 것이다.


class ConstInteger {

public:

     ConstInteger(int v) : value(v) {}

     int get() { return value; }

 

protected:

     int value;

};

 

class Integer : public ConstInteger {

public:

     Integer(int v) : ConstInteger(v) {}

     void set(int v) { value = v; }

};

ConstInteger.cc


위 클래스를 두고 보면 volatile/const int와 int의 관계가 명확해진다. Integer는 ConstInteger을 상속한 클래스이므로 ConstInteger를 요구하는 곳 어디에나 쓸 수 있는 것이다. 반대로 Integer가 필요한 곳에 ConstIntger를 넘기면 set() 메쏘드가 없으므로 문제가 된다. 따라서 컴파일러는 이를 금지하는 것이다.

volatile의 const와 같은 맥락에서 생각할 수 있다. volatile 속성을 부여받는 다는 것은 바꿔 말하면 컴파일러가 최적화를 할 자유를 잃는다고 말할 수 있다. ConstInteger의 경우만큼 명확하지는 않지만 이 관계를 클래스로 생각해 본다면 아마 다음과 같을 것이다.


class VolatileInteger {

public:

     VolatileInteger(int v) : value(v) {}

     int get() { return value; }

     void set(int v) { value = v; }

 

protected:

     int value;

};

 

class Integer : public VolatileInteger {

public:

     Integer(int v) : VolatileInteger(v) {}

     void optimize();

};

VolatileInteger.cc


재배치(reordering)


지금까지 volatile 키워드의 일반적인 기능과 문법에 대해서 살펴보았다. C 표준은 volatile 키워드와 메모리 모델에 대한 명확한 정의를 내리지 않고 있기 때문에 컴파일러마다 그 구현에 다소 차이가 있다. C++ 표준은 volatile 대해 별도의 정의하지 않고 가능한 한 C 표준을 따르라고만 하고 있다.

마이크로소프트의 Visual C++를 예로 들어보면, volatile 키워드에 앞서 살펴본 가시성(visibility) 뿐만 아니라 재배치(reordering) 문제에 대한 해결책도 추가하였다. Visual C++의 volatile 변수는 다음과 같은 기능을 추가로 한다.

 

(1) volatile write: volatile 변수에 쓰기를 수행할 경우, 프로그램 바이너리 상 해당 쓰기보다 앞선 메모리 접근은 모두 먼저 처리되어야 한다.

(2) volatile read: volatile 변수에 읽기를 수행할 경우, 프로그램 바이너리 상 해당 읽기보다 나중에 오는 메모리 접근은 모두 이후에 처리되어야 한다.

 

재배치(reordering)는 컴파일러가 메모리 접근 속도 향상, 파이프라인(pipeline) 활용 등 최적화를 목적으로 제한된 범위 내에서 프로그램 명령의 위치를 바꾸는 것을 말한다. 우리가 프로그램에 a = 1; b = 1; c = 1; 이라고 지정했다고 해서 컴파일된 바이너리가 반드시 a, b, c 순서로 메모리를 쓰지 않을 수 있다는 뜻이다. 만약 a, c가 같은 캐시(cache)에 있거나 인접해 있어서 같이 쓸 경우 속도 향상을 볼 수 있다면 a = 1; c = 1; b = 1; 로 순서가 바뀔 수 있는 것이다.

Visual C++의 경우 volatile을 사용하면 컴파일러가 수행하는 이러한 재배치에 제약을 주게 된다. a = 1; b = 1; c = 1;에서 c가 volatile로 선언된 변수였다면 a = 1;과 b=1;은 반드시 c에 1을 대입하기 전에 일어나야 한다. 물론 a와 b 사이에는 순서가 없으므로 b = 1; a = 1; c = 1; 과 같은 형태로 재배치가 일어날 수는 있다. 재배치가 일어나지 않도록 보장하는 문제가 왜 중요한지는 MSDN에서 발췌한 다음 예를 통해 살펴보자.


#include <iostream>

#include <windows.h>

using namespace std;

 

volatile bool Sentinel = true;

int CriticalData = 0;

 

unsigned ThreadFunc1( void* pArguments ) {

while (Sentinel)

Sleep(0); // volatile spin lock

 

// CriticalData load guaranteed after every load of Sentinel

cout << "Critical Data = " << CriticalData << endl;

return 0;

}

 

unsigned ThreadFunc2( void* pArguments ) {

Sleep(2000);

CriticalData++; // guaranteed to occur before write to Sentinel

Sentinel = false; // exit critical section

return 0;

}

 

int main() {

HANDLE hThread1, hThread2;

DWORD retCode;

 

hThread1 = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)&ThreadFunc1,

NULL, 0, NULL);

hThread2 = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)&ThreadFunc2,

NULL, 0, NULL);

 

retCode = WaitForSingleObject(hThread1,3000);

 

CloseHandle(hThread1);

CloseHandle(hThread2);

 

if (retCode == WAIT_OBJECT_0 && CriticalData == 1 )

cout << "Success" << endl;

else

cout << "Failure" << endl;

}

volatile.cpp


프로그램 수행은 간단하다. 이 프로그램은 쓰레드를 2개 생성하는데 ThreadFunc1은 Sentinel 플래그가 true인 동안 루프를 돌고, ThreadFunc2는 잠시 기다렸다 Sentinel 플래그를 false로 만들어준다. ThreadFunc2는 Sentinel을 false로 만들기 전에 전역 변수인 CriticalData을 1만큼 증가시킨다..

이 프로그램에서 만약 Sentinel이 volatile로 선언되지 않았다면 ThreadFunc1은 가시성을 보장받지 못하므로, ThreadFunc2가 Sentinel의 값을 바꾸더라도, 레지스터에 든 값을 사용해 영원히 루프를 돌 수 있음은 이미 살펴보았다. 그럼 이번에는 volatile로 선언해서 Sentinel의 가시성이 보장된다면 이 프로그램의 수행 결과는 어떻게 될까?

간단히 생각하면 CriticalData를 1증가 시킨 이후에 Sentinel을 false로 바꾸므로 ThreadFunc1은 1이라는 값을 찍을 것이라고 생각할 것이다. 하지만 CriticalData는 volatile이 아니므로 여전히 메모리가 아닌 ThreadFunc2의 레지스터에만 남아있을 확률이 있다. 이 경우 ThreadFunc1은 변경된 CriticalData가 아닌 0이라는 값이 나올 수 있다. 수행 타이밍에 따라서 0이 되기도 1이 되기도 하는 것이다.

가정을 바꿔서 CriticalData 또한 volatile이라고 해보자. 모든 문제가 해결된 것 같지만, 결과는 여전히 0 혹은 1이 나온다. CriticalData가 volatile이면 레지스터가 아닌 메모리에 직접 쓰므로 가시성은 확보되지만, 재배치의 문제가 남아있다. 컴파일러가 보기에 ThreadFunc2의 CriticalData++과 Sentinel = false는 전혀 관계없는 변수이다. 따라서 최적화를 이유로 이 순서를 뒤집어 Sentinel = false를 먼저 수행하고 CiriticalData=+을 수행할 수 있다. 이 경우 ThreadFunc2에서 Sentinel = false만 수행하고 컨텍스트 스위치(context switch)가 일어난 경우 ThreadFunc1은 아직 CriticalData++이 수행되기 전에 CriticalData 값인 0을 읽을 수 있다.

여기서 Visual C++가 추가한 시멘틱(semantic)을 적용해보자. ThreadFunc2에서 Sentinel = false는 volatile write이므로 프로그램 바이너리에서 그 이전에 수행되어야 할 명령은 모두 volatile write 이전에 수행되게 된다. 따라서 CriticalData++;은 반드시 Sentinel = false; 이전에 수행된다. ThreadFunc1은 Sentinel을 volatile read하므로 그 이후에 실행되는 CriticalData 읽기는 반드시 Sentinel을 읽은 후에 수행된다. 따라서 위 프로그램은 정확히 1을 출력하는 올바른 프로그램이 된다.

또한 재배치가 일어나지 않음을 보장할 경우 Sentinel이 volatile이기만 하면 CriticalData는 volatile이 아니더라도 가시성(visibility)이 보장되는 효과도 있다. 이렇게 다른 volatile 변수로 인해 공짜로 가시성을 얻는 경우 피기백킹(piggybacking, 돼지 등을 타고 공짜로 달린다는 의미)이라고 부른다.

 

좋은 코딩 습관으로 생각되었다가 재배치 문제로 안전하지 않음이 밝혀진 예로 더블 체크 이디엄(double check idiom)이 있다. 아래 코드처럼 initialized == false로 초기화 여부를 확인하고 객체를 생성해 얻어올 경우 반드시 락을 잡아줘야 한다. read-test-write는 원자적(atomic)이지 않기 때문에 여러 쓰레드가 동시에 초기화를 시작할 수 있기 때문이다. 문제는 한 번 초기화가 된 이후에도 매번 객체를 얻어갈 때마다 락을 잡고 풀어야 한다는 점이다.


Foo* Foo::getInstance()

{

mutex.lock();

 

if (instance == 0) {

     instance = new Foo();

}

 

mutex.unlock();

 

return instance;

}

check.cc


이러한 오버헤드를 피하기 위해 다음과 같은 일단 초기화 여부를 확인한 이후에 실제로 락을 잡아서 다시 한 번 정말 초기화되지 않았는지 확인하는 패턴이 널리 사용되었다. 이 경우는 앞선 예와는 달리 한 번 초기화가 이루어지고 나면 더 이상 락을 잡지 않고 객체를 얻어올 수 있다.

이 코드는 언뜻 보기에 무척 효율적으로 보이지만 재배치와 관련해 큰 문제가 있다. 특히 instance = new Foo(); 의 수행 순서가 문제가 된다. 메모리를 할당 받아 생성자를 호출한 후에 메모리 주소를 instance를 대입한다고 하면 별 문제가 없겠지만, 일부 필드의 초기화 과정과 instance의 포인터 대입의 순서가 컴파일러 재배치로 인해 바뀔 수 있다. 이 경우 아직 일부 필드가 초기화되지 않은 상태에서 instance가 0이 아니게 되므로, 다른 쓰레드가 객체를 얻을 수 있다.


Foo* Foo::getInstance()

{

if (instance == 0) {

     mutex.lock();

 

     if (instance == 0) {

         instance = new Foo();

}

 

mutex.unlock();

}

 

return instance;

}

double_check.cc


정리


지금까지 C/C++의 volatile 키워드의 기본적인 기능과 관련된 문제들을 살펴보았다. volatile은 미묘한 키워드라 잘 알고 쓰면 큰 도움이 될 수 있지만, 또한 여러 가지 문제를 일으키는 근원이 되기도 한다. 특히 명확한 표준이 있는 게 아니므로, 사용하는 자신이 사용하는 C/C++ 컴파일러의 매뉴얼을 꼼꼼히 읽고 volatile을 어떻게 지원하는지 파악하는 게 중요하다.

volatile은 단일 CPU 환경에서 컴파일러 재배치 문제는 해결해주지만, MMU나 멀티CPU에 의한 재배치에 대해서는 완전한 대안을 제공하지 못한다. 또한 변수를 읽은 후에 값을 수정하고 다시 쓰는 read-modify-write를 원자적으로 수행할 수 있게 해주지도 않는다. a += 5; 같은 단순한 명령도 실제로는 a를 메모리에서 읽고 5를 더한 후에 다시 메모리 쓰는 복잡한 연산이므로 a를 volatile로 선언하는 것만으로는 이 코드를 멀티쓰레드에서 안전하게 수행할 수는 없다는 뜻이다. 유용성과 한계를 충분히 인지하고 필요에 따라 적절히 volatile을 사용하자.

 

참고문헌


[1] The "Double-Checked Locking is Broken" Declaration http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html









마이크로소프트웨어 2006년 4월 기고글입니다.


소프트웨어 개발 관련 기술은 빠른 속도로 발전하고 있지만, 여전히 많은 개발자들은 코드 속의 버그와 전쟁을 벌이고 있다. 소프트웨어는 다른 산업과 달리 이례적으로 개발 비용보다 유지 보수비용이 훨씬 크다. 개발 과정에서 유지보수가 차지하는 비중이 1980년대에 50%에서 2000년 이후에는 90%라는 조사가 있을 정도이다. 유지보수는 소프트웨어의 요구사항이 지속적으로 변화한다는 문제에서 기인하지만, 설계, 구현 과정에서 발생한 버그 수정도 상당 부분을 차지한다. 수많은 소프트웨어 전문가는 이런 버그, 달리 말해서 소프트웨어 결함을 줄이기 위해 상당한 노력을 기울여 왔으며, 최선의 소프트웨어 프로세스와 코드 감사 및 리뷰에 의존하던 수동적인 버그 발견 및 수정 절차를 어느 정도 자동화하기에 이르렀다. 그 정점에 서있는 기술이 정적 프로그램 분석(static program analysis) 기술이다. 이 글에서는 자바 언어의 프로그램 분석 도구인 FindBugs 프로젝트를 중심으로, 버그 없는 소프트웨어 작성 프로세스에 대해 이야기해 보겠다.


소프트웨어의 버그는 정말 무섭다. 잘못된 한 줄의 코드가 수천 수백억의 재산을 한순간에 날려버릴 수 있다. 실제로 1998년 화성 주변을 돌던 인공위성이 파괴되었다. 이 사건은 미국식 단위(feet)를 사용하던 한 소프트웨어모듈과 미터법을 사용하는 다른 모듈이 결합되어 생긴 결과였다. 나사(NASA)의 과학자들은 인공위성이 파괴되기 전까지 이런 버그가 존재한다는 사실조차 모르고 있었다.

상업용 소프트웨어도 사정이 다르지 않다. 2005년 7월 안티 바이러스 소프트웨어 벤더인 트렌드 마이크로(Trend Micro) 사는 자사 제품의 버그로 인해, 8백만 달러(약 80억 원)를 배상해야만 했다. 이 버그는 백신 업데이트를 다운받은 사용자의 CPU를 점유하여 성능을 떨어뜨렸다. 콜센터는 폭주했고, 기업은 명성을 잃고 엄청난 비용을 치러야만 했다.

소프트웨어는 버그와 전쟁을 벌이고 있다고 해도 과언이 아니다. 따라서 소프트웨어 결함을 조기에 발견하고 효과적으로 줄이기 위한 연구가 소프트웨어 공학을 중심으로 계속되고 있다. 또한 이와 별개로 소프트웨어의 버그를 자동으로 찾아주는 프로그램에 대한 연구도 무척 활발히 진행되고 있다. 이 글에서는 후자에 속하는 프로그램 분석 기술을 자세히 살펴보자.


프로그램 분석


소프트웨어 개발은 대부분의 일을 사람이 해왔지만, 근래의 소프트웨어의 눈부신 발전은 반복적이고 귀찮은 개발 과정의 상당수를 자동화하기에 이르렀다. 이제 우리는 요구사항 분석, 설계, 구현, 테스트, 유지 보수에 이르는 소프트웨어 개발의 전 과정을 CASE(Computer Aided Software Engineering) 도구의 도움을 받아서 수행하고 있다. 구현과 테스트 측면에서만 본다면, 작게는 디버깅, 테스팅, 프로파일링을 비롯한 개별 기능을 수행하는 개발도구가 있고, 크게는 이런 도구들을 통합하는 통합개발도구(IDE)가 있다.

소프트웨어 개발 방법은 역사를 살펴보면, 개발 도구의 발전과 그 맥을 같이한다. 과거 컴파일러조차 없던 시절의 개발자들은 어셈블리 코드, 혹은 기계어를 직접 컴퓨터에 입력해야 했으며, 작은 실수조차도 원인을 알 수 없는 심각한 버그가 되는 경우가 많았다. C를 비롯한 다음 세대의 고급 언어의 출현은 컴파일러라는 자동화 도구가 있었기 때문에 가능했다. 컴파일러는 C에서 어셈블리 언어로의 번역 과정을 자동화 했을 뿐만 아니라, 개발자가 흔히 저지르는 실수와 오류를 즉시 교정해 주는 역할을 하였다. 예컨대, C언어에서 구문 끝에 세미콜론(;) 하나를 빠뜨려서 몇 시간씩 코드를 검토해야 하는 일은 없어진 것이다.

다시 말해서 우리가 알고 있는 단순히 언어 번역기로만 알고 있는 컴파일러는 사실 두 가지 기능을 수행한다. 첫째는, 컴파일러 고유의 기능인 한 언어를 또 다른 언어(보통 어셈블리 언어)로 번역하는 것이다. 둘째는, 번역하는 프로그램 언어 속에 존재하는 구문 오류를 찾아내서 즉시 개발자에게 알려준다. 즉 컴파일 과정은 런타임에 버그가 될 수 있었던 잠재적인 소프트웨어 결함을 조기에 발견하는 효과가 있다. 이때 컴파일러가 사용하는 기법에는 구문 검증 기술(parsing), 타입 시스템(type system) 등이 있다.

하지만 구문이 정확하고, 타입 오류가 없는 프로그램이 버그가 없는 프로그램은 아니다. 실제로 구문 검증과 타입 시스템은 수많은 소프트웨어 버그 중에서 극히 일부만을 찾아낼 뿐이다. 컴퓨터 과학자들은 좀 더 많은 버그를 찾아내고자 연구를 거듭했고, 그 결과로 나온 기술이 자동 증명(theorem proving)과 정적 프로그램 분석(static program analysis)(줄여서 정적 분석이라고도 함)이다.

프로그램 분석은 간단히 말해서 프로그램을 실제로 수행시켜 보기 전에 프로그램의 여러 성질을 파악해서, 버그가 있는지 없는지를 판별해 내는 기술이다. 우리가 버그를 찾아내기 위해 흔히 사용하는 기술인 테스팅은 런타임에만 버그를 찾을 수 있다. 하지만 테스팅이 모든 프로그램 수행 경로를 실행시켜 보는 것이 불가능하기 때문에 버그를 찾아내는데 한계가 있는 반면에, 프로그램 분석은 프로그램을 실제로 실행시키지 않으므로 탐지 가능한 모든 버그를 찾아낼 수 있다.


프로그램 분석(static program analysis)은 프로그램을 수행하기 전에 버그를 발견한다.


특히 타입 시스템이 풀지 못했던, 배열 경계를 벗어나는 오류, 널 포인터 오류, 레이스 컨디션 오류 등이 이런 프로그램 분석이 해결하고자 하는 주요 결함이다. 얼마 전에 마소에도 소개된 적이 있는 서울대 이광근 교수진에서 개발한 아이락(Airac)도 정적으로 C언어의 배열 경계 검사를 해주는 프로그램 분석 도구이다.

프로그램 분석 기술은 하나의 단어로 이야기하지만, 실제 분석 기술은 매우 다양하다. 단순히 프로그램의 특정 버그 패턴을 찾아내는 기술도 있고, 아이락처럼 프로그램을 요약해서 가상으로 수행시켜보는 요약 해석(abstract interpretation), 각종 데이터/제어 흐름 분석, 진보된 타입 시스템 등 다양한 기술이 있다.


Airac

Airac(Static Analyzer for Automatic Verification of Array Index Ranges in C Programs)은 서울대에서 개발한 C 프로그램 분석 도구이다. 특히 Airac는 C 프로그램에서 발생 가능한 배열 인덱스 오류를 찾아준다. 배열 인덱스 오류는 잠재적인 버퍼 오버플로우 등의 주요 원인이므로, 이 문제를 미리 해결할 수 있으면 프로그램에 상당한 안전성을 확보할 수 있다. Airac은 nML이라는 함수형 프로그래밍 언어로 작성되었는데, 불행히 프로그램과 소스 코드는 라이선스 계약을 통해서만 얻을 수 있다.



버그를 찾아라. FindBugs!


프로그램 분석 도구의 개념은 참 근사해 보인다. 개발자들이 엄청난 시간을 지겨운 디버깅과 버그 수정에 보낸다는 사실을 생각하면, 자동으로 버그를 찾아주는 도구는 자동으로 코딩을 해주는 도구만큼이나 매력적이다. 하지만 개발자는 반대로 그런 도구가 존재할까라는 의구심을 가지며, 현재 기술 수준으로는 생산적인 소프트웨어 개발에 도움이 될 만큼 훌륭한 분석 도구가 없다고 생각한다. 프로그램 분석 도구가 개발 과정에 가져다 줄 이점을 느껴보려면 실제로 분석 도구를 사용해보자.

프로그램 분석 도구는 널리 사용되고 있지 않아서 그렇지, 실제로 꽤 많은 프로젝트가 활발히 개발 중이다. 그중 가장 많은 사용자를 가진 자바 프로그램 분석 도구로 메릴랜드 대학에서 개발한 FindBugs(http://findbugs.sourceforge.net/)가 있다.

FindBugs 프로젝트는 다음과 같은 흥미로운 관찰 결과를 바탕으로 시작하였다.

 

◆ 훌륭한 개발자도 종종 바보 같은 실수를 한다.

◆ 여러 버그는 비슷한 성질을 가지고 있다.

◆ 버그 패턴: 빈번히 오류가 되는 코드 사용법(idiom)이 있다

◆ 이런 버그 패턴을 자동으로 찾을 수 있을까? Yes!


일단 훌륭한 개발자도 종종 바보 같은 실수를 한다는 점을 보자. 다음은 Eclipse 2.1.0에 실제로 있었던 코드이다.


if (entry == null) {

     IClasspathContainer container=

     JavaCore.getClasspathContainer(entry.getPath(),

     root.getJavaProject());

...

Eclipse 2.1.0 org.eclipse.jdt.internal.ui.javaeditor.ClassFileEditor 코드 일부


if (!searchQueryData.isBookFiltering()

&& (lastWS != null || lastWS.length() > 0)) {

Eclipse 2.1.0 org.eclipse.help.ui.internal.search.HelpSearchPage 코드 일부



이 코드의 버그는 매우 명백하다. entry가 null인지 확인한 후에 entry.getPath()를 부르므로, 이 코드에 들어가면 무조건 NullPointerException이 발생한다. lastWS의 경우도 마찬가지 문제가 있다. 이 코드를 작성한 사람은 절대 초보 개발자가 아니다. 많은 경험을 가진 훌륭한 개발자라도 가끔은 이런 실수를 하기 마련이다. 하지만 이런 실수를 했다는 사실보다 더 놀라운 것은, 이 코드가 Eclipse 2.1.0까지 발견되지 않고 그대로 코드 속에 남아있었다는 점이다.

하지만 FindBugs를 이용하면 이러한 NullPointer 버그는 프로그램을 수행해 보기도 전에 곧바로 잡아낸다. 데이트 흐름 분석(data flow analysis), 제어 흐름 분석(control flow analysis) 등의 기법을 사용하면 이런 문제를 정적으로 발견하는 것이 가능하기 때문이다.

이미 소프트웨어 공학은 소프트웨어 결함(버그) 발견의 시기와 비용간의 상관관계에 대한 많은 분석을 수행하였다. 여기서 얻은 결론은 버그를 빨리 발견할수록 수정하는데 비용이 적게 든다는 것이다. 마이크로소프트가 보안 결함이 있는 소프트웨어를 배포해놓고, 이를 패치하기 위에 들이는 노력을 생각한다면 같은 버그를 테스트 단계, 혹은 구현 단계에서 발견할 수 있다는 그 경제적 이익은 엄청나다.

다른 예제를 살펴보자. 자바는 언어 차원에서 멀티쓰레드 프로그래밍을 지원하며, 네트워크를 비롯한 여러 라이브러리에서 쓰레드 사용을 장려하고 있다. 하지만, 멀티쓰레드 프로그램의 버그는 프로그램 수행을 예측할 수 없게 만든다. 항상 잘되다가 가끔씩만 문제가 생기는 버그는 거의 대부분 이 멀티 쓰레드 버그의 속성 때문이다. 하지만 정작 버그 자체만 놓고 보면 정말 사소한 개발자의 실수로 밝혀지는 경우가 대부분이다.

 

while (!someCondition) {

     synchronized (lock) {

         lock.wait();

     }

}

위 예제는 개발자들이 자주 저지르는 멀티 쓰레드 프로그래밍 관련 오류이다. 원래는synchronized 블록에 들어가서 조건 검사를 해야 하는데, 반대로 조건부터 검사하고 락(lock)을 잡은 경우이다. while 조건 검사와 synchronized 블록 사이에 다른 쓰레드가 조건을 변경할 수 있으므로, 이 프로그램은 레이스 컨디션에 취약하다. 사소한 실수이지만, 이런 종류의 버그를 테스트로 찾아내는 것은 거의 불가능하다. 멀티쓰레드 버그는 타이밍에 민감하기 때문에 보통 소프트웨어가 릴리즈된 후에야 발견된다. 물론 그만큼 버그 파악과 해결에 걸리는 비용도 커진다.

FindBugs를 이용하면 일부 멀티 쓰레드 관련 버그를 매우 쉽게 발견할 수 있다. FindBugs는 자바 바이트 코드를 분석해서 버그를 찾는다. 위 버그의 경우 자바 바이트 코드에서 monitorenter와 Object.wait() 호출이 연달아 오는 패턴만 찾아내면 된다. 정적 분석 도구는 코드를 실제로 수행시켜 보는 것이 아니므로 멀티쓰레드 프로그램의 버그를 찾아내는데 가장 효율적인 방법이라 할 수 있다.

뿐만 아니라 FindBugs는 사용자들이 자바 언어/라이브러리를 잘못 이해하고 사용하는 경우도 탐지한다. 예를 들어 자바는 객체의 비교를 위해 equals라는 메쏘드를 이용하는데, 실수로 연산자(==)를 사용할 수도 있다. 라이브러리를 잘못 사용한 경우는 자바의 경우 해시 테이블(hash table)에 객체를 삽입하기 위해서는 hashCode와 equals를 모두 오버라이드해야 하는데 어느 한쪽만 오버라이드하는 경우도 있다. FindBugs는 이런 문제도 자동으로 찾아서 알려준다. 이런 패턴은 발견하는 데로 계속 추가가능하기 때문에 FindBugs의 탐지 기능은 지속적으로 발전하고 있다. 지면 관계상 여기에 모든 기능을 소개할 순 없지만, 보통 개발자들이 생각하는 것보다 훨씬 많은 버그를 잡아낸다.


PMD

PMD(http://pmd.sourceforge.net/)는 자바 프로그램에서 버그를 찾아주는 프로그램 분석 도구이다. FindBugs와는 달리 버그뿐만 아니라, 최적화되지 못한 코드, 데드 코드(dead code), 중복 코드 등도 찾아준다. 룰셋(ruleset)이라고 불리는 메커니즘으로 잠재적으로 버그나 문제가 될 수 있는 프로그램의 패턴을 지정하는 방식을 사용한다. PMD 역시 Eclipse를 비롯한 다양한 통합개발도구에 대한 플러그인을 제공하므로, 개발 과정에서 지속적인 피드백을 얻을 수 있다.


프로그램 분석 사용 사례


아직까지도 프로그램 분석이 일상 개발과는 거리가 먼 이야기로 들린다면, 실제로 많은 소프트웨어 개발 프로젝트가 이미 프로그램 분석 도구 사용을 개발 과정의 일환으로 생각하기 시작했다는 사실을 말해주고 싶다. DOM, JDOM의 문제점을 지적하며 새로운 오브젝트 모델을 지향하고 나선 XOM도 FindBugs를 개발에 차용하고 있다. XOM는 프로젝트 홈페이지에 디자인 원리를 잘 정리해놓았는데, 개발 스타일에 JUnit을 이용한 단위 테스트와 함께 정적 검사(FindBugs와 PMD)를 명시하고 있다. XOM은 초기부터 FindBugs, 이와 유사한 도구인 PMD를 이용해 버그를 줄이는 방법을 쓰고 있다.

FindBugs 외에도 나사와 하니웰 테크놀로지(Honeywell Technology Center)에서 개발한 반데라(Bandera)라는 정적 분석 도구도 있다. 이 도구는 멀티 쓰레드 프로그램의 오류를 찾아주는데, 항공 운항을 위한 실시간 운영체제인 DEOS(Digital Engine Operating System)의 오류를 찾아낼 수 있었다.

프로그램 분석 도구는 어떻게 활용하는 것이 가장 좋을까? 방법은 실로 다양하다. 어느 정도 기간을 두고 한두 번씩 프로그램 분석 도구를 사용하여 버그를 잡아내는 방법도 있고, 개개인의 개발자가 Eclipse 같은 통합 개발 환경에 FindBugs를 설치하여 컴파일할 때마다 어떤 버그를 만든 건 아닌지 확인하는 방법도 있다. 혹은 소스 코드 관리 도구(CVS 혹은 SubVersion 같은 도구)에 저장된 소스 코드를 자동으로 꺼내와 프로그램 분석 도구를 수행한 후에 결과를 관련 개발자들에게 이메일로 보내는 방법도 있다. 단위 테스트나, 매일 빌드(daily build)가 비슷한 과정을 거쳐서 도입되었다는 점에 유의할 필요가 있다.

결론은 프로그램 분석 도구는 비용대비 무척 효율적인 버그 발견 방법이라는 점이다. 버그를 조기에 자동으로 발견할 수 있다는 장점을 애써 무시할 필요는 없는 법이다. 실제로 일부 개발자들은 오픈 소스를 꺼내와 FindBugs를 돌린 결과에서 버그를 발견해 오픈 소스에 기여하고 있는 경우도 있다.


앞으로


지금까지 프로그램 분석 도구의 장점과 이를 도입했을 때 얻을 수 있는 이점에 대해 간략히 소개하였다. 하지만 소프트웨어 개발에 만병통치약이란 없다. 프로그램 분석 도구의 고질적인 문제로는 아직까지 완벽하게 버그만을 발견해내지 못한다는 점이다. 가끔은 버그가 아닌 코드를 버그라고 하는 허위 경보(false positive)를 내기도 하고, 아예 버그를 발견하지 못하는 탐지 실패(false negative) 문제도 있다.

하지만 소프트웨어 결함 제거는 일종의 전선이다. 제 1 전선이 무너지면, 코드 리뷰, 검토, 단위 테스트, 기능 테스트, QA라는 2, 3, 4 전선이 계속 남아있다. 프로그램 분석 도구는 코딩 단계에서 버그를 최대한 빨리 발견하기 위한 제 1 전선의 역할을 훌륭하게 해낸다. 프로그램 분석 도구가 한 명의 적군(버그)라도 쓰러뜨린다면 우리 개발자가 개발 전쟁에서 승리할 확률이 더 높아짐을 명심하자.




















마이크로소프트웨어 2008년 5월 기고글입니다.

스칼라(Scala)는 객체지향 언어이면서 동시에 함수 언어이다. 스칼라는 자바와 마찬가지로 자바가상머신(JVM) 위에서 실행되며 기존 자바 라이브러리를 그대로 사용할 수 있다. 반대로 자바로 작성된 프로그램에서 스칼라로 작성된 라이브러리를 사용할 수도 있다. 다른 JVML(JVM 언어)인 JRuby, Jython과 마찬가지로 스칼라는 자바의 한계를 극복하기 위해 출현했다. 특히, 이미 설계된 컴포넌트 단위의 자바 라이브러리들을 효율적으로 재사용하기 위해서는 함수 언어의 특징인 고차 함수, 패턴 매칭 등이 필요하다는 것이다. 이 글에서는 자바와의 차이점을 부각시켜 스칼라의 특징과 장단점을 살펴보려 한다.


왜 새로운 언어가 필요했을까?


스칼라 언어를 만든 사람은 GJ, Pizza 프로젝트 등으로 유명한 마틴 오더스키(Martin Odersky)다. 그가 주도한 GJ와 Pizza는 자바 언어에 인자 다형성(parametric polymorphism)을 추가한 리서치 프로젝트였고 이런 결과물은 이후 자바 5의 제네릭스가 되었다. 하지만 JVM을 변경하지 않고 컴파일러 기술만으로 구현한 제네릭스는 성공이라고 보기 힘들었다. 오더스키는 이런 접근법에 한계를 느끼고, 함수 언어의 장점을 적극 반영한 새로운 JVML을 만들었다. 이렇게 나온 결과물이 스칼라이다.

 

스칼라의 목표는 컴포넌트 소프트웨어를 잘 지원하기 위한 언어이다. 자바는 대규모 개발 프로젝트에 주로 사용되는 언어이고, 각종 프레임워크를 가져와서 조합해 사용하는 경우가 다른 언어보다 압도적으로 많다. 하지만 자바 언어 자체는 소형 디바이스를 지원하기 위한 오크(Oak)라는 프로그래밍 언어에서 출발했고, 웹에서도 소형 애플릿 작성을 주로 사용되었다. 이런 자바를 각종 프레임워크로 포장해 대형 프로젝트에 사용하기 시작하다보니 언어의 한계로 인해 여러 문제점을 겪게 되었다.

 

컴포넌트 지원 언어는 2가지 특징을 가져야 한다. 첫째, 규모 가변적(scalable)해야 한다. 바꿔 말해, 컴포넌트의 크기와 관계없이 항상 같은 방법으로 사용할 수 있어야 한다는 것이다. 또한, 언어에 복잡한 요소를 더하기 보다는 추상화(abstraction), 조합(composition), 분해(decomposition)에 초점을 맞춰야 함을 의미한다. 둘째, 이런 특징을 만족시키기 위해서 프로그래밍 언어는 객체지향적일 뿐만 아니라 함수형이어야 한다.


HelloWorld


스칼라를 본격적으로 공부하기에 앞서 스칼라의 기본적인 문법과 취향을 느낄 수 있도록 먼저 HelloWorld 프로그램을 작성해보자. 비교를 위해 자바 HelloWorld를 나란히 배치했다.


object HelloWorld {

     def main(args: Array[String]) {

         println("Hello, world!")

     }

}

Scala HelloWorld


class HelloWorld {

     public static void main(String[] args) {

         System.out.println("Hello World!");

     }

}

Java HelloWorld



일단 가장 큰 차이점으로 HelloWorld 클래스를 선언할 때 스칼라 프로그램은 object라는 키워드를 쓰고 있다. 스칼라는 class와 object를 구분하는데, object는 싱글톤(singleton)으로 클래스의 객체를 하나만 생성함을 의미한다. 스칼라 HelloWorld는 싱글톤이기 때문에 main 메서드를 선언할 때도 static이라는 키워드를 사용하지 않는다.

 

자바에서는 화면에 문자열을 출력하기 위해 System.out.println을 사용했는데, 스칼라는 간결하게 println이라고 사용할 수 있다. 자바는 문장의 끝에 세미콜론을 항상 붙여줘야 하는데, 스칼라는 세미콜론이 없다. 자바는 타입을 먼저 쓰고 변수를 선언하는데, 스칼라는 변수 : 타입 형태로 변수를 선언한다. 자바에서 String[]인 문자열 배열은 스칼라에서는 Array[String]으로 쓴다.

 


스칼라 인터프리터와 컴파일러


스칼라를 설치하고 스칼라 바이너리(scala)를 실행시키면 다음과 같이 인터프리터 모드로 동작한다. 인터프리터 모드에서는 입력되는 식을 계산해서 결과를 보여준다. 이후 예제에서 scala>가 나오면 인터프리터에 입력한 것으로 생각하면 된다.

 

C:\Users\Administrator>scala

Welcome to Scala version 2.7.0-final (Java HotSpot(TM) Client VM, Java 1.6.0_10-

beta).

Type in expressions to have them evaluated.

Type :help for more information.

 

scala> 3+5

res1: Int = 8

스칼라 인터프리터


스칼라 컴파일러(scalac)는 스칼라 코드를 자바 클래스파일(바이트코드)로 컴파일해준다. 사용법은 자바 컴파일러(javac)와 유사하다. 위 HelloWorld.scala라는 컴파일하려면 다음과 같이 명령을 내려주면 된다.


C:\Users\Administrator>scalac HelloWorld.scala

스칼라 컴파일러


컴파일된 스칼라 프로그램을 실행시키려면 scala를 이용하면 된다.

 

C:\Users\Administrator>scala HelloWorld

Hello, world!

스칼라 프로그램의 실행



순수 객체지향 언어


자바의 타입은 기본 타입(primitive type)과 레퍼런스 타입(reference type)으로 나뉘고, 이진(boolean), 정수(int), 부동소수점(float, double) 등 기본 타입은 객체가 아니다. 이런 방식은 성능 향상에는 큰 도움이 되지만, 기본 타입과 레퍼런스 타입 사이를 변환하는 박싱(boxing), 언박싱(unboxing) 등의 문제로 프로그래밍 언어가 복잡해지는 문제가 있다.

 

자바 5에는 기본 타입과 레퍼런스 타입을 필요에 따라 자동변환해주는 오토박싱, 오토언박싱 기능이 들어갔지만, 기본 타입과 레퍼런스 타입으로 이원화된 타입 시스템 자체는 달라지지 않았다.

 

반대로 스칼라는 스몰토크, 루비와 마찬가지로 순수 객체지향 언어이다. 스칼라는 정수 5가 scala.Int 클래스의 객체이며, +, -, , *, / 등의 연산자는 하나의 인자를 받는 메서드다. 예를 들어 1 + 2 * 3 / x 같은 수식은 1.+(2.*3./(x)))와 같이 전부 메서드 호출로 변경된다. 스칼라에서는 자바에서 특수 문자로 취급하던 +, -, *, / 등의 문자도 메서드 이름으로 사용할 수 있음을 의미한다.

 

아래 그림에서 볼 수 있듯이 스칼라의 클래스 계층도는 scala.Any를 최상위로 해서 값(scala.AnyVal)과 레퍼런스(scala.AnyRef)를 하나의 계층으로 아우르고 있다.


스칼라의 클래스 계층도



함수 언어


스칼라는 모든 함수를 객체로 취급한다. 바꿔 말해, 스칼라에서 함수를 함수의 인자로 넘길 수도 있고, 함수의 리턴 값으로 함수가 리턴될 수도 있음을 의미한다. 스칼라 입문서에 있는 간단한 예제를 하나 살펴보자.

 

object Timer {

     def oncePerSecond(callback: () => unit) {

         while (true) { callback(); Thread sleep 1000 }

     }

     def timeFlies() {

         println("time flies like an arrow...")

     }

     def main(args: Array[String]) {

         oncePerSecond(timeFlies)

     }

}

함수를 함수의 인자로 넘기는 예제



위 예제는 Timer라는 클래스를 만들어, main에서 oncePerSecond라는 메서드를 호출할 때 또 다른 메서드인 timeFlies를 인자로 넘겨준 예제이다. oncePerSecond 함수의 인자를 보면 callback이 () => unit이라는 타입을 가짐을 알 수 있는데, ()는 인자를 하나도 받지 않음을 뜻하고, unit은 자바의 void와 유사하게 리턴 값이 없음을 뜻한다. 위 프로그램은 1초마다 한번씩 callback으로 넘어온 timeFlies 메서드를 호출하게 된다.

 

이 예제에서 하나 재미있는 사실은 Thread의 sleep 메서드를 호출할 때 Thread sleep 1000라고 적은 부분이다. 스칼라에서는 인자가 하나인 메서드를 호출할 때, 메서드 호출자(.)를 생략하고 위와 같이 적을 수 있다. 앞서 +, - 등이 메서드라고 언급했는데 1.+(2)가 아닌 1 + 2로 적을 수 있는 이유도 마찬가지다. 1 + 2에서 1은 리시버 오브젝트(receiver object), +는 메서드, 2는 + 메서드의 인자인데, 스칼라에서는 간결함을 위해 1.+(2)라고 적는 대신에 1 + 2라고 적을 수 있게 허용한 것이다.



정적 타입 시스템


스칼라는 자바에 비해 간결한 문법을 제공하지만, 강력한 정적 타이핑을 제공한다. 스칼라는 동적 JVML 언어인 그루비, JRuby, Jython 등과 달리 컴파일 타임에 모든 타입 오류를 잡아낼 수 있다. 스칼라가 여러 컴포넌트를 통합하기 위해 만들어진 언어라는 점을 감안하면 통합 오류를 조기에 잡아내는 정적 타입 시스템은 자연스러운 선택이다.

 

대신 스칼라는 같은 정적 타이핑을 사용하는 자바와 달리 경우에 따라 타입을 생략할 수 있다. 사용자가 모든 타입을 적어주지 않더라도 스칼라 컴파일러가 타입 추론(type inference)을 통해 부족한 부분을 채워주기 때문이다. 타입 추론은 자바 스타일의 문법을 가진 스칼라를 동적 타이핑하는 그루비나 루비처럼 간결하게 만들어주는 핵심 요소이다. (스칼라 코드는 같은 일을 하는 자바 코드의 1/3 정도 밖에 안 된다.)


일례로, 정수 2개를 더해서 돌려주는 add 함수를 생각해보자. add 함수를 정의할 때 x와 y는 정수 타입으로 정의를 해줬지만 add 함수의 리턴 타입은 생략했다. 하지만 스칼라 컴파일러가 봤을 때 정수 x와 정수 y를 더한 값을 돌려주므로 리턴 타입은 자동으로 Int가 된다는 사실을 추론해낼 수 있다. 이 함수를 인터프리터에 입력해보면, 인터프리터가 add의 타입을 (Int,Int)Int라고 정확히 추론해냄을 볼 수 있다.

 

scala> def add(x: Int, y: Int) = x + y

add: (Int,Int)Int

스칼라 타입 추론 (리턴 타입을 추론한 경우)


물론 다음과 같이 타입을 모두 적어줘도 무방하다.


scala> def foo(x: Int, y: Int): Int = x + y

foo: (Int,Int)Int

스칼라 타입 추론 (모든 타입을 써준 경우)


하지만 동적 타이핑하는 언어와 달리 모든 타입을 생략하면 컴파일러가 타입을 추론할 없기 때문에 오류가 발생한다. 스칼라 코딩을 처음 시작한 사람들은 타입을 어느 정도 생략해도 되는지 알기 어려운데, 시행착오를 통해 컴파일러가 어디까지 타입 추론을 해주는지 감을 잡는 일이 필요하다. 단, 컴파일러가 추론할 수 있더라도 필요한 경우에는 적절히 타입을 써주면 코드의 가독성을 높일 수 있음을 명심하자.


scala> def foo(x, y) = x + y

<console>:1: error: ':' expected but ',' found.

     def foo(x, y) = x + y

                    ^

<console>:1: error: identifier expected but eof found.

     def foo(x, y) = x + y

                                 ^

스칼라 타입 추론 (실패)



선언(val/var/def)

 

스칼라는 변수를 선언할 때 자바처럼 모든 타입을 다 적어줄 필요가 없다. 변수 x를 선언하고 1을 넣어주려면 다음과 같이 var 키워드를 사용하면 된다. var은 자바 변수와 동일하다.


scala> var x = 1

x: Int = 1

scala> x = x + 1

x: Int = 2

var의 사용



스칼라는 var 외에도 val을 통해 값을 선언할 수 있는데, valvar로 선언된 변수와 달리 값이 변하지 않는다. val로 만든 x에 x + 1이라는 새로운 값을 집어넣으면 다음처럼 x는 변경 불가능한 값(immutable value)이라는 오류가 발생한다.

 

scala> val x = 1

x: Int = 1

 

scala> x = x + 1

<console>:7: error: assignment to immutable value

     x = x + 1

        ^

val의 사용


스칼라에는 var/val 외에도 함수 객체를 선언하는 데 사용하는 def 키워드가 있다. def는 다음처럼 val과 마찬가지로 변하지 않는 값을 선언하는 데 사용할 수 있다.


scala> def x = 1

x: Int

 

scala> x = x + 1

<console>:7: error: value x_= is not a member of object $iw

     x = x + 1

     ^

def의 사용


valdef의 차이는 연산을 하는 시점에 있다. valval을 선언하는 시점에 우변을 계산해서 값을 할당하는 반면에 def는 실제로 사용되는 시점마다 우변을 새로 계산한다. 다음 예제는 이 차이를 분명하게 보여준다.


scala> var x = 1

x: Int = 1

scala> val y = x + 1

y: Int = 2

scala> def z = x + 1

z: Int

scala> z

res13: Int = 2

scala> x = 2

x: Int = 2

scala> y

res15: Int = 2

scala> z

res16: Int = 3

var과 def의 차이


위 예제에서 var로 선언한 변수 x에 1을 넣은 다음에 y와 z 모두 x + 1로 정의해주었다. 다만 y는 val을 z는 def를 사용해서 선언하였다. 일단 val y는 선언하는 순간에 값이 2임을 표시해준 반면에 def z를 선언했을 때는 타입이 Int라는 사실만 알려주고 값을 계산하지 않았음을 알 수 있다. z를 실제로 사용했을 때 2로 계산해준다. 이후 변수 x의 값을 2로 변경했을 때, y는 이미 선언한 시점에서 계산이 끝났으므로 값이 변경되지 않고 여전히 2인 반면에 z는 다시 x + 1을 이 시점에서 새로 계산하기 때문에 x 값을 2로 계산해서 3을 돌려줌을 볼 수 있다.

 

클래스


스칼라의 클래스는 기본적으로 자바와 유사하다. 다만 별도의 생성자 없이 클래스 이름 옆에 객체 생성 시 어떤 인자를 받을 것인지 써준다는 차이가 있다. 아래 Person 클래스는 성과 이름을 입력받는 간단한 클래스이다.

 

class Person(fname: String, lname: String) {

     def firstname() = fname

     def lastname() = lname

 

     override def toString() = firstname() + " " + lastname()

}

스칼라 클래스의 예


toString 메서드는 java.lang.Object의 toString 메서드를 오버라이드(override)한 것이다. 스칼라는 상위 클래스의 메서드를 오버라이드할 때 명시적으로 override라는 키워드를 써줘야 한다. 실수로 상위 클래스의 메서드를 의도치 않게 오버라이드하는 것을 막기 위한 조치이다.


케이스 클래스(case class)와 패턴 매칭(pattern matching)

 

패턴 매칭은 함수 언어의 고유한 특징 중에 하나로 함수 언어를 강력하게 만들어주는 핵심이다. 스칼라는 케이스 클래스를 통해 객체지향 언어 속에 패턴 매칭을 녹여 넣었다.


abstract class Shape

case class Rectangle(width: Double, height: Double) extends Shape

case class Circle(radius: Double) extends Shape

case class Square(side: Double) extends Shape

케이스 클래스의 예


케이스 클래스는 일반 클래스와 달리 다음과 같은 몇 가지 특징을 갖는다.

 

(1) 새로운 객체를 생성하기 위해 new 키워드를 사용할 필요가 없다. new Circle(5.0) 대신에 Circle(5.0)이라고 사용할 수 있다.

(2) 생성자의 파라미터에 사용된 값을 얻을 수 있는 getter 메서드가 자동으로 생성된다. 예를 들어 var c = Circle(5.0)를 선언했다면 c.radius는 5.0을 리턴한다.

(3) equals와 hashCode 메서드가 자동으로 만들어진다.

(4) toString 메서드가 자동으로 정의된다. Rectangle(5.0, 3.0)을 출력해보면 "Rectangle(5.0, 3.0)"이 나온다.

(5) 패턴 매칭을 통해 분해(decompose)될 수 있다.

 

 

케이스 클래스의 최대 장점은 (5)에서 언급한 패턴 매칭의 사용이다. 다음은 패턴 매칭을 사용한 면적(area) 계산 함수이다.


def area(s: Shape): Double = s match {

     case Rectangle(width, height) => width * height

     case Circle(radius) => radius * radius * 3.14

     case Square(side) => side * side

     case _ => 0

}

 

def perimeter(s: Shape): Double = s match {

     case Rectangle(width, height) => 2 * (width + height)

     case Circle(radius) => 2 * 3.14 * radius

     case Square(side) => side * 4

     case _ => 0

}

패턴 매칭을 통한 Shape의 면적/둘레 계산


area(면적)와 perimeter(둘레) 함수는 Shape의 객체 s를 넘겨받아 패턴 매칭을 한다. s의 객체가 Rectangle, Circle, Square일 때 각각의 케이스에 대해 어떤 일을 수행할 것인지 적어주면 된다. 이때 각 객체를 만들 때 사용되었던 인자가 원하는 변수에 자동으로 매칭된다. 예를 들어, Rectangle(5.0, 3.0)을 s로 넘겼다면 첫 번째 케이스에서 width와 height는 각각 5.0, 3.0이 된다. _는 앞선 패턴 매칭이 모두 실패했을 때 디폴트로 호출되는 케이스를 의미한다.

 

객체지향 프로그래밍에 익숙한 개발자라면 area를 Shape의 메서드로 선언하고, Rectangle, Circle, Square 등 각각의 서브클래스가 area를 구현하는 방식으로 코드를 작성할 수도 있을 것이다. area나 perimeter 같은 메서드는 몇 개로 고정되어 있고, Triangle이나 Ellipse 등 새로운 Shape이 계속 추가되는 상황이라는 이 방법이 더 좋다. 새로 추가되는 Shape에서 area와 perimeter 등 몇 개의 메서드만 구현해주면 되고 기존 파일을 고칠 필요가 없기 때문이다.

 

반대로 Shape의 종류는 고정된 상황에서 area, perimeter 등의 메서드를 계속 추가해 나가야 되면 상황이 달라진다. 메서드가 하나 추가될 때마다 모든 Shape의 서브클래스를 찾아서 메서드를 추가해줘야 하기 때문이다. 자바를 비롯한 전통적인 객체지향 언어에서는 이 문제를 비지터 패턴(visitor pattern)을 이용한 더블 디스패치(double dispatch)로 풀었다. 하지만 비지터 패턴은 이해하기도 어렵고, 싱글 디스패치하는 일반적인 객체지향 언어에서 자연스러운 접근 방법도 아니다.

 

패턴 매칭은 이런 상황에서 완벽한 솔루션을 제공한다. 새로운 함수/메서드를 만들고 패턴 매칭을 통해 각각의 케이스를 다루면 되기 때문이다. 케이스 클래스라고 이름 붙인 이유는 이처럼 패턴 매칭을 통해 각 케이스를 다룬다는 면을 강조한 것이다.

 


제네리시티(Genericity)


마지막으로 살펴볼 내용은 자바 제네릭스에 대응하는 제네리시티이다. 스칼라의 제네리시티는 자바보다 문법적으로 훨씬 간결하고 직관적이다. 이해를 위해 스칼라 튜토리얼에 있는 예제 코드를 하나 살펴보자.


class Reference[a] {

     private var contents: a = _

     def set(value: a) { contents = value }

     def get: a = contents

}

제네리시티


아무 타입이나 저장할 수 있는 Reference 클래스를 만들기 위해서 [a]라는 타입 파라미터를 받았다. Reference의 contents 필드를 타입 a로 선언했고, get의 리턴 타입과 set의 value 타입을 a로 선언했다. 실제로 사용할 때는 val cell = new Reference[Int] 형태로 타입을 넘겨주면 된다. 참고로, 스칼라는 기본 타입과 레퍼런스 타입의 구분이 없기 때문에 Int를 넣기 위해 Integer 객체를 만들어 박싱할 필요가 없다.


정리

 

스칼라는 다른 함수 언어와 달리 JVM 위에서 동작하는 언어라는 이점 때문에 상당히 실용적이다. 특히 이미 기존 자바 개발자들에게 스칼라는 기존 프로젝트 플랫폼을 변경하지 않고 필요한 부분에서 생산성을 높일 수 있는 중요한 도구가 될 것이다.

 

스칼라 컴파일러와 런타임의 버전은 2.7.0으로 상당히 안정되어 있고, 지금은 스칼라를 기반으로 한 라이브러리와 프레임워크도 출연하기 시작했다. Lift는 스칼라를 이용한 웹 프레임워크이고, Scalax, Scalaz 프로젝트 등은 스칼라 라이브러리이다. 또한 멀티 코어 시대를 대비한 병렬 프로그래밍(얼랭(Erlang)과 유사한 프로세스 모델을 제공) 또한 스칼라가 자랑하는 기능 중 하나이다.

 

짧은 지면에 새로운 프로그래밍 언어를 처음부터 소개하려다보니 응용 부분에서 많은 내용이 빠졌다. 보다 자세한 내용은 스칼라 홈페이지(http://www.scala-lang.org/)를 통해 얻길 바란다.

 

 

Scalax, Scalaz

 

스칼라는 JDK에 있는 클래스들을 쉽게 사용할 수 있지만, 한편으로는 스칼라 프로그래밍 언어 자체의 특징을 살리기 위한 라이브러리의 개발도 한참이다. 대표적으로 Scalax와 Scalaz 프로젝트가 있다.

 

일례로 Scalaz는 ScalaCheck이라는 테스팅 프레임워크를 제공한다. 물론 스칼라도 자바 테스팅 프레임워크인 JUnit을 사용할 수 있지만, ScalaCheck은 JUnit과 달리 함수 언어의 특징을 살려 명세 기반의 자동화된 테스팅을 제공한다. 이는 헤스켈의 QuickCheck과 유사하다. 또한 Scalaz는 함수 언어의 특징을 살리고자 모나드(monad) 관련 패키지도 제공한다.

 

스칼라의 장점을 잘 살린 함수형 프로그래밍을 하고 싶다면 이런 라이브러리 프로젝트가 중요한 학습 소스가 될 것이다.



참고문헌


[1] A Scala Tutorial for Java Programmers

http://www.scala-lang.org/docu/files/ScalaTutorial.pdf

 

[2] Scala by Example

http://www.scala-lang.org/docu/files/ScalaByExample.pdf

 

[3] An Overview of the Scala Programming Language

http://www.scala-lang.org/docu/files/ScalaOverview.pdf

 

[4] The Scala Language Specification Version 2.7

http://www.scala-lang.org/docu/files/ScalaReference.pdf





































자바 컴파일러 들여다보기

Posted 2008. 11. 30. 02:12

마이크로소프트웨어 2006년 7월 기고글입니다.


자바 개발자라면 누구나 자바 컴파일러(javac)를 사용한다. 하지만 상당수 개발자는 컴파일러가 어떻게 동작하는지 관심이 없다. 이들은 작성한 소스 코드가 오류 없이 컴파일되고 컴파일의 결과로 나온 클래스 파일이 원하는 기능을 수행하면 그만이라고 말한다. 하지만 컴파일러 작성자가 아니더라도 컴파일러가 소스 코드를 어떤 형태의 바이너리(자바의 경우 바이트코드)로 변환시키는지 알아두면 유용한 경우가 많다. 이 글에서는 몇 가지 예제를 중심으로 자바 컴파일러의 내부 동작을 엿보는 기회를 가지려고 한다.


T 업계의 종사하는 사람이라면 18개월마다 컴퓨팅 파워가 2배가 된다는 무어(Moore)의 법칙을 잘 알고 있을 것이다. 컴파일러에도 이와 비슷한 법칙이 있다.


Proebsting's Law

컴파일러 기술은 18년마다 컴퓨팅 파워(computing power)를 2배로 증가시킨다.


이 법칙이 정확한 예측치는 아니더라도 비약적으로 발전하는 하드웨어에 비해 컴파일러의 발전 속도가 상당히 더디다는 것만은 유추할 수 있다. 하지만 그렇다고 마냥 정체되어 있기만 한 것은 아니었다. 50-60년대에 최초의 컴파일러가 나오고 벌써 수십 년이 지났으므로, 지금 우리가 사용하는 컴파일러는 초창기에 비해 상당히 발전한 것이다.

따라서 개발자도 컴파일러를 블랙박스로만 생각할 것이 아니라, 어떤 일을 해주는지 조금은 알아둘 필요가 있다. 컴파일러가 변환하는 소스 코드와 바이너리의 관계를 코드 모양(code shape)이라고 하는데, 최적화 컴파일러의 복잡한 변환(transformation) 과정은 아니더라도, 이런 코드 모양을 몇 가지 숙지하고 있으면 좋다. 이 글에서는 자바의 표준 컴파일러인 javac을 통해 자바 언어와 바이트코드에 대한 이해의 폭을 넓혀 보려고 한다.


데드 코드(dead code)와 조건부 컴파일


C/C++ 개발자들이 자바 개발을 시작하면 가장 먼저 느끼는 불편함 중에 하나가 전처리기(preprocessor)의 부재일 것이다. C/C++에서는 #ifdef #endif를 이용해서 특정 코드를 선택적으로 컴파일할 수 있는데, 자바는 그런 전처리기가 없기 때문이다. 하지만 자바 컴파일러를 잘 이용하면 자바에서도 조건부 컴파일이 가능하다. 우선 자바 컴파일러의 배경 지식부터 살펴보자.

컴파일러는 바이너리를 생성하기에 앞서서 실행 흐름 분석(control flow analysis)을 통해 소스 프로그램을 분석하는데, 이 분석을 통해 하는 일 중에 하나가 데드 코드 제거(dead code elimination)이다. 자바 컴파일러도 프로그램 흐름상 절대로 수행될 수 없는 코드를 발견하면 이 부분은 바이트코드에 포함시키지 않는다. 다음 예제를 보자.


public class Foo {

     public static void main(String args[]) {

         if (false) {

             System.out.println("Never");

         }

     }

}

데드 코드 예제


자바 디컴파일러 javap

자바 개발 환경을 설치하면 javac, java와 함께 몇 가지 프로그램이 함께 설치되는데, 그 중 하나가 자바 디컴파일러인 javap이다. javap는 클래스 파일을 읽어서 클래스와 메쏘드, 그리고 각 메쏘드의 바이트코드를 보여주는 프로그램이다. 특히 자바 네이티브 인터페이스(Java Native Interface, JNI) 작성 시에 메쏘드 시그너처(signature)를 뽑아내는데 유용한 도구이다. -c 옵션을 주면 각 메쏘드의 바이트코드도 볼 수 있는데, 이 글의 디컴파일 결과는 모두 javap -c를 사용한 것이다.


위 코드에서 if (false) System.out.println("Never")은 수행될 수 없는 코드이다. 따라서 바이트코드에 포함되지 않는데, 자바 디스어셈블러인 javap를 이용해 Foo.class를 살펴보면 main 메쏘드에 return 문만 있는 것을 볼 수 있다.


public static void main(java.lang.String[]);

Code:

0: return

데드 코드 Foo의 디컴파일


물론 if (false)의 경우는 너무 당연해서 별로 쓸모가 있어 보이지 않는다. 하지만 자바 컴파일러가 데드 코드를 바이트코드에 포함시키지 않는다는 사실을 이용하면 C/C++ 전처리기리처럼 조건부 컴파일을 흉내 낼 수 있다. 다음 예제를 살펴보자.


class Configure {

     public static final boolean debug = false;

}

 

public class Foo {

     public static void main(String args[]) {

         if (Configure.debug) {

             System.out.println("Debug.");

         }

     }

}

조건부 컴파일 예제


[debug가 static final인 경우]

public static void main(java.lang.String[]);

Code:

0: getstatic #2; //Field java/lang/System.out:Ljava/io/PrintStream;

3: ldc #3; //String Debug.

5: invokevirtual #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V

8: return

조건부 컴파일 디컴파일


자바에서 static final로 선언된 필드는 상수(constant)이다. 따라서 위 조건부 컴파일 예제의 경우 Configure.debug라는 플래그가 false이면 main 메쏘드의 if (Configure.debug) 부분이 데드 코드가 되므로 바이트코드에 포함되지 않는다. 반대로 debug를 true로 바꿔주면, if (Configure.debug) 부분 코드가 컴파일되는데, debug는 static final로 선언되어 있으므로 항상 참이다. 따라서 javac은 if 문을 없애고 바로 System.out.println을 실행하도록 코드를 생성한다. 즉 flag를 true/false로 바꿔준 후에 새로 컴파일하면 조건부 컴파일이 되는 것이다.


[debug가 final이 아닌 경우]

public static void main(java.lang.String[]);

Code:

0: getstatic #2; //Field Configure.debug:Z

3: ifeq 14

6: getstatic #3; //Field java/lang/System.out:Ljava/io/PrintStream;

9: ldc #4; //String Debug.

11: invokevirtual #5; //Method java/io/PrintStream.println:(Ljava/lang/String;)V

14: return

final이 아닌 경우 디컴파일


만약에 debug가 final이 아니었다면 런타임에 값이 바뀔 수 있으므로, [debug가 false가 아닌 경우]의 3행에서처럼 ifeq로 debug 값을 테스트하는 부분이 들어갔을 것이다.


배열 초기화(array initialization)


C/C++ 프로그램을 개발할 때 바이너리 데이터를 메모리에 읽어오는 방법으로 배열 초기화를 사용하는 경우가 많다. 특히 바이너리 외에 별도의 파일 시스템이 없는 임베디드 시스템의 경우 어플리케이션의 그림(image)나 소리(sound) 데이터를 char[]에 저장한다. 로더(loader)가 프로그램을 로드할 때 배열의 값을 메모리의 데이터 영역으로 바로 복사해주기 때문에 효율적이다.

다음은 썬(Sun)에서 만든 J2ME의 Personal Basic Profile 소스 코드에서 발췌한 코드이다.


class IxcClassLoader extends ClassLoader {

/* fields */

private static byte[] utilsClassBody = { // The .class file

(byte) 0xca, (byte) 0xfe, (byte) 0xba, (byte) 0xbe,

(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x2e,

(byte) 0x00, (byte) 0x3d, (byte) 0x0a, (byte) 0x00,

(byte) 0x11, (byte) 0x00, (byte) 0x1f, (byte) 0x07,

(byte) 0x00, (byte) 0x20, (byte) 0x07, (byte) 0x00,

(byte) 0x21, (byte) 0x0a, (byte) 0x00, (byte) 0x03,

(byte) 0x00, (byte) 0x22, (byte) 0x0a, (byte) 0x00,

(byte) 0x02, (byte) 0x00, (byte) 0x23, (byte) 0x0a,

(byte) 0x00, (byte) 0x02, (byte) 0x00, (byte) 0x24,

(byte) 0x07, (byte) 0x00, (byte) 0x25, (byte) 0x07,

(byte) 0x00, (byte) 0x26, (byte) 0x07, (byte) 0x00,

(byte) 0x27, (byte) 0x0a, (byte) 0x00, (byte) 0x08,

...

PBP의 IxcClassLoader 클래스


이 코드를 보면 utilsClassBody라는 byte[] 필드에 클래스 생성에 필요한 유틸리티 클래스의 바이트코드를 바이너리 형태로 넣어 놨음을 알 수 있다. static 필드이므로 클래스 정적 초기화(static initialization)시에 해당 필드가 초기화될 것이다. C/C++ 코드를 많이 작성해온 개발자라면 이런 형식의 배열 사용법에 익숙할 것이다. 자바에서 이런 배열 초기화의 문제점은 무엇일까? 대답에 앞서서 이 클래스를 한 번 디컴파일해보자.


static {};

Code:

0: sipush 1165

3: newarray byte

5: dup

6: iconst_0

7: bipush -54

9: bastore

10: dup

11: iconst_1

12: bipush -2

14: bastore

15: dup

16: iconst_2

17: bipush -70

19: bastore

20: dup

21: iconst_3

22: bipush -66

24: bastore

25: dup

26: iconst_4

27: iconst_0

28: bastore

29: dup

...

PBP의 IxcClassLoader 클래스 디컴파일


무엇이 문제인지 감이 잡히는가? 지면 관계상 일부만 표시했지만, byte[] utilsClassBody는 길이가 1165이다. C/C++이였으면 이 데이터를 곧바로 메모리에 로드하였겠지만, 자바의 클래스 로더는 그렇지 않다. 자바의 경우 클래스를 초기화할 때 1165 길이의 byte[]를 힙에 생성하고 각 원소를 하나하나 초기화해주어야 한다. 이 초기화를 위해서 무려 7738개의 바이트코드가 필요하다. 코드 길이도 문제지만, 클래스 로딩 시간 또한 무척 길어진다.

자바의 원산지인 썬에서 배포하는 코드에서 이런 문제점이 있다는 점을 생각해보면 언뜻 비슷해 보이는 두 언어의 차이점을 정확히 알고 쓰는 일이 쉽지 않음을 알 수 있다.


문자열 처리


자바는 문자열의 편리한 처리를 위해서 문자열 병합 연산자(+)를 제공한다. 따라서 우리는 "Hello" + "World"와 같이 문자열을 병합할 수 있고, "I am " + name + "."과 같이 중간에 변수를 삽입해서 문자열을 생성할 수도 있다.

하지만 자바의 String 클래스는 변경 불가능(immutable)한 클래스이다. 따라서 한 번 문자열을 생성하면 문자열의 값을 바꾸는 것은 불가능하다. 따라서 replace, replaceAll, toLowerCase 등의 메쏘드는 모두 기존의 문자열은 그대로 두고 새로운 문자열을 리턴한다. java.lang.String의 API를 유심히 읽어본 개발자라면 정답을 알고 있을 것이다.


◆ java.lang.String API에서 발췌

문자열 병합은 StringBuilder(혹은 StringBuffer) 클래스의 append 메쏘드를 통해 이루어진다. 추가적인 정보를 위해서는 자바 언어 명세서(Java Language Specification)을 참조하기 바란다.


즉 우리가 String a = "Hello"; a += " World"; 라고 프로그램을 작성하더라도 실제 병합은 StringBuilder 클래스를 통해 이루어짐을 의미한다. 역시 실제 프로그램을 컴파일해서 확인해보도록 하자.


public class Foo {

     public static void main(String args[]) {

         String h = "Hello";

         h += "World";

     }

}

 

public static void main(java.lang.String[]);

Code:

0: ldc #2; //String Hello

2: astore_1

3: new #3; //class java/lang/StringBuilder

6: dup

7: invokespecial #4; //Method java/lang/StringBuilder."<init>":()V

10: aload_1

11: invokevirtual #5; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;

14: ldc #6; //String World

16: invokevirtual #5; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;

19: invokevirtual #7; //Method java/lang/StringBuilder.toString:()Ljava/lang/String;

22: astore_1

23: return

String 병합


바이트코드를 잘 모르더라도 String 대신에 StringBuilder 클래스를 통해 문자열을 병합하고 이후에 toString 메쏘드를 이용해 문자열을 돌려줌을 쉽게 유추해 볼 수 있다. 이처럼 javac은 자바 개발자가 알게 모르게 여러 가지 일을 해주고 있는데, 바꿔 말하면 자바 개발자는 의도하지 않게 비효율적인 명령을 실행할 수도 있다는 뜻이다. 실제로 초창기 자바는 멀티 쓰레드 세이프한 StringBuffer의 append 메쏘드를 이용했는데, append는 매번 락을 잡았다 풀었다 하였기 때문에 심각한 속도 저하의 원인이 되기도 했었다.


Inner Class


이너 클래스가 있는 클래스를 컴파일하면, $ 표시가 붙은 클래스 파일이 생성되는 것을 보았을 것이다. 그리고 JVM 명세서를 잘 읽어보면 이너 클래스라는 게 존재하지 않는다는 사실도 알 수 있을 것이다. 그렇다면 다음과 같이 이너 클래스가 있는 클래스를 컴파일하면 어떤 일이 생기는 걸까?


public class Foo {

     static class Bar {

         private boolean flag = true;

     }

     public static void main(String args[]) {

         Bar bar = new Bar();

         System.out.println(bar.flag);

     }

}

Inner Class


위 예제의 경우 Foo 내부에 Bar라는 정적 이너 클래스를 선언하였다. Bar는 Foo의 이너 클래스이므로 private 멤버라도 직접 접근이 가능하다. 이 클래스를 컴파일하면 Foo.class와 Foo$Bar.class라는 두 개의 클래스 파일이 나타난다. JVM에는 이너 클래스의 개념이 없으므로, 자바 소스 코드 상에서는 Bar는 Foo의 이너 클래스이지만 바이트코드 상에서는 별도의 클래스가 되는 것이다.


public class Foo extends java.lang.Object{

     public Foo();

     public static void main(java.lang.String[]);

}

 

class Foo$Bar extends java.lang.Object{

     Foo$Bar();

     static boolean access$000(Foo$Bar);

}

Inner Class 디컴파일


또 하나 특이한 점으로는 access$000라는 메쏘드가 있다. 원래 Bar 클래스에는 없는 메쏘드인데 어째서 바이트코드에는 나타난 것일까? 그 이유는 자바 소스 코드에서는 Bar가 이너 클래스이므로 Foo의 main 메쏘드에서 Bar의 private field가 접근 가능해야 하는데, 실제 바이트코드 상에서는 별도의 클래스이므로, private 멤버의 값을 읽을 수 없게 된다. 이 문제를 해결하기 위해 자바 컴파일러는 access$000이라는 메쏘드를 생성해 Foo가 Bar의 필드 참조 시 access$000으로 대체하게 된다.



Enumeration


Java 5에는 타입 세이프 enum 강화된 for 루프, 제네릭스(generics), 오토박싱(autoboxing), 언박싱(unboxing), 가변인자(varargs), 정적 임포트(static import), 메타데이터(metadata) 등 여러 가지 언어 기능이 추가 되었다. 하지만 언어만 바뀌었을 뿐 기존의 JVM이 사용하던 바이트코드에는 차이가 없다. 이 말은 결국 이런 기능들은 전부 컴파일러가 해준다는 뜻이다. 타입 세이프 enum을 통해서 컴파일러가 어떤 바이트코드를 생성해 내는지 살펴보자.


public enum Week {

     MON, TUE, WED, THU, FRI, SAT, SUN

}

Week Enum


위 Week enum은 월요일부터 일요일까지 각각의 요일을 나타낸다. enum이 없던 시절에는 어떻게 구현했을까? 한 가지 방법은 int 상수를 이용하는 것이다.


public class Week {

     public static final int MON = 0;

     public static final int TUE = 1;

     public static final int WED = 2;

     public static final int THU = 3;

     public static final int FRI = 4;

     public static final int SAT = 5;

     public static final int SUN = 6;

}

int 상수를 이용한 Week Class


이 방법도 동작에는 전혀 문제가 없지만, MON, TUE, WED 같은 값들이 int이기 때문에 MON * 2 같이 의미 없는 연산을 하더라도 컴파일 타임에 타입 에러를 발견할 수 없었다. 이런 문제를 해결하기 위해 타입 세이프 enum 패턴이 널리 쓰였다.


public class Week {

     public static final Week MON = new Week();

     public static final Week TUE = new Week();

     public static final Week WED = new Week();

     public static final Week THU = new Week();

     public static final Week FRI = new Week();

     public static final Week SAT = new Week();

     public static final Week SUN = new Week();

}

타입 세이프 enum 패턴


이 방식은 타입 세이프하지만 각 필드가 오브젝트이므로 int를 사용한 방식과는 달리 switch 문에 사용이 불가능하다는 불편함이 있었다. 반면에 Java5에 도입된 타입 세이프 enum은 타입 세이프하면서 switch 문에서도 사용이 가능하다. 어떻게 구현한 것일까? Week enum을 디컴파일해보자.


public final class Week extends java.lang.Enum{

public static final Week MON;

 

public static final Week TUE;

 

public static final Week WED;

 

public static final Week THU;

 

public static final Week FRI;

 

public static final Week SAT;

 

public static final Week SUN;

 

...

 

static {};

Code:

0: new #4; //class Week

3: dup

4: ldc #7; //String MON

6: iconst_0

7: invokespecial #8; //Method "<init>":(Ljava/lang/String;I)V

10: putstatic #9; //Field MON:LWeek;

...

타입 세이프 enum 패턴


우리가 간단히 enum Week { MON, TUE, ... }로 써줬지만 실제로 컴파일하면 타입 세이프 enum 패턴처럼 각각이 public final static Week 필드로 들어가 있음을 알 수 있다. 즉 우리가 타입 세이프 enum 패턴에서 수동으로 구현해주던 것을 컴파일러가 대신해 주는 것이다. 클래스 초기화인 static{} 에서는 Week의 객체를 생성해서 각 필드를 초기화해주고 있다. 객체를 생성할 때 MON은 0, TUE는 1, WED는 2 하는 식으로 값을 주고, switch 문에서는 이 값을 얻어오는 ordinal() 메쏘드를 호출하여 switch 문에서도 사용할 수 있게 해준다.



정리


지금까지 자바 컴파일러(javac)가 해주는 몇 가지 일을 살펴보았다. 특히 자바 소스 코드의 특정 구문이 어떤 바이트코드로 변환되는지를 위주로 보았다. 우리는 javap를 이용해 결과를 살펴보았지만, JVM 지식이 있고 자바 언어 명세서를 꼼꼼히 읽은 개발자라면 이미 알고 있었을 내용일지도 모르겠다.

자바 개발자를 만나서 이야기해보면 깜짝 놀라는 일 중에 하나는, 자바의 가장 근간이 되는 자바 언어 명세서(Java Language Specification)와 자바 가상 머신(Java Virtual Machine) 명세서를 숙독한 개발자가 거의 없다는 점이다. 자바 튜토리얼(tutorial)이나 여러 자바 입문서로 시작하여 단 한 번도 명세서를 보지 않고 지금까지 프로그램을 작성해 온 개발자가 부지기수다.

모든 자바 컴파일러, 자바 가상 머신은 모두 이 명세서를 기준으로 만들어졌다. 썬에서 배포하는 레퍼런스 구현(reference implementation)도 결국 이 명세서를 바탕으로 만든 구현 중 하나에 지나지 않는다. 즉 자바 언어 명세서는 자바 세상의 성경인 셈이다. 명세서를 바탕으로 자바의 문법과 의미를 분명히 명확히 이해하고, 자바 코드가 어떤 바이트코드로 변환되는지를 숙지하는 것은 고급 개발자가 되기 위해 반드시 필요한 일이다.

이 과정에서 디컴파일러를 이용해 의문을 해소하는 일은 무척 유용하다. 앞서 언급하지는 않았지만 try, finally 구문은 바이트코드로 어떻게 표현되는지, synchronized 블록은 바이트코드로 어떻게 변환되는지 등을 살펴보면 자바 언어와 JVM에 대한 이해를 넓힐 수 있다. 자바의 경우 컴파일러에 대한 지식은 결국 언어 지식을 넓히는 길인 셈이다.


참고문헌


[1] Java Language Specification, Third Edition Gosling, Joy, Steele, Bracha Addison Wesley 2005

[2] Java Virtual Machine Specification, Second Eidtion Lindholm, Yellin, Addison-Wesley 1999

 



















코드 난독화(Code Obfuscation)

Posted 2008. 11. 30. 01:04

마이크로소프트웨어 2007년 12월에 기고한 글입니다.

요즘 보안 취약점 분석자들은 각종 보안 문제 분석에 역공학(reverse engineering) 기술을 적극 활용하고 있다. 역공학은 소스 코드 없이 윈도우즈 실행 파일(PE, Portable Executable)이나, 자바 바이트코드 등을 직접 분석해서 프로그램이 어떤 기능을 수행하는지 파악하여 취약점을 찾아내는 기술이다, 필요하면 직접 프로그램 바이너리를 수정해 불법적인 일을 수행하게 만들기도 한다. 이에 대한 대응으로 코드를 복잡하게 만들어 알아보기 힘들게 하는 코드 난독화(code obfuscation) 기술이 발전하였다. 이 글에서는 코드 난독화의 기본 원리를 알아보자.


역공학(reverse engineering)이란?


역공학의 개념


대부분의 개발자들은 컴파일된 바이너리를 완전한 블랙박스로 취급한다. 윈도의 PE 포맷이나 리눅스의 ELF 포맷을 이해하더라도 코드섹션(code section)에 들어있는 기계어(어셈블리어)를 이해하기란 무척 어렵다. 하지만 보안 취약점 분석가들의 주요 업무는 이런 바이너리 코드를 읽고 보안 취약점을 찾아내는 것이다. 이는 보안 전문가들이라 할지라도 쉽지 않은 일인데, 보통 컴파일된 바이너리를 다시 원래의 소스코드로 복구하는 프로그램인 디컴파일러(decompiler)의 도움을 받는다. 디컴파일러는 원래 소스 코드를 완벽히 복구하지는 못하지만, 어셈블리에 비해서는 훨씬 이해하기 쉬운 프로그래밍 언어(주로 C)의 소스 코드를 생성해준다. 또한 코드 섹션에 직접 브레이크를 걸고 실행시켜볼 수 있는 디버거(debugger)의 도움을 받는 경우도 많다.

 

이처럼 바이너리 코드를 분석해 유용한 정보를 뽑아내는 작업을 역공학(reverse engineering)이라 부른다. 최근 보안 취약점 분석의 상당 부분은 역공학과 관련되어 있다. 실제로 역공학은 윈도우처럼 소스 코드가 공개되지 않은 운영체제나, 어플리케이션의 보안 버그를 찾아내는데 적극적으로 활용되고 있다.

 

역공학의 위험성을 보여주는 예로, 각종 보안 프로그램들이 뚫린 사례를 들 수 있다. 예를 들어 도서관 같은 공용 컴퓨터에 불법적인 프로그램을 설치하는 것을 막기 위해 리부팅하면 하드가 리셋(reset)되는 시스템이 있다. 이 시스템은 추가적은 프로그램 설치를 위해 관리자 모드를 제공하는데, 4자리의 패스워드를 입력하도록 되어있다. 역공학을 이용해 패스워드를 검사하는 루틴을 찾아내면, 이 부분을 건너뛰게 만들 수도 있다.

 

수십 가지의 안전장치를 마련한 인터넷 뱅킹도 예외가 아니다. 안전한 공인 인증서를 이용한다고 해도, 입력받은 비밀번호와 공인인증서 비밀번호를 비교하는 코드를 찾아내서 해당 루틴을 건너뛰게 만든다면 공인인증서의 비밀번호(passphrase)는 의미가 없어진다. 아무리 많은 안전장치를 걸어놔도 윈도 머신에서 실행되는 바이너리라면, 그 내용을 분석해 해당 코드를 제거해 버리면 되기 때문이다.

 

현재 MS 윈도의 보안 모델에서는 이런 역공학에 의한 공격에 효과적인 대응책이 없다. 그나마 우리가 할 수 있는 차선책은 바이너리를 분석하기 어렵도록 복잡하게 만들어 주는 것이다. 코드 난독화의 필요성은 여기서 출발한다.


코드 난독화(code obfuscation)


코드 난독화는 프로그램을 변화하는 방법의 일종으로, 코드를 읽기 어렵게 만들어 역공학을 통한 공격을 막는 기술을 의미한다. 난독화는 난독화의 대상에 따라 크게 1) 소스 코드 난독화와 2) 바이너리 난독화로 나눌 수 있다. 소스 코드 난독화는 C/C++/자바 등의 프로그램의 소스 코드를 알아보기 힘든 형태로 바꾸는 기술이고, 바이너리 난독화는 컴파일 후에 생성된 바이너리를 역공학을 통해 분석하기 힘들게 변조하는 기술이다.

 

일단 소스 코드 난독화의 필요성을 먼저 이야기해보자. 첫 번째 경우는 부득이하게 소스 코드를 릴리즈(release)해야 하는 경우이다. 예를 들어 플래시 파일 시스템을 파는 A라는 회사가 있다고 하자. B 회사는 A 회사의 라이브러리를 구매해서 제품을 만들려고 한다. B 회사는 매우 많은 플랫폼을 가지고 있고, 여러 설정에 따라서 플래시 파일 시스템을 조금씩 다르게 컴파일해야할 필요가 있다고 하자. 이 경우 B 사가 일일이 해당 라이브러리를 빌드해서 릴리즈해주는 방법도 있지만, 난독화 도구를 이용해 코드를 적당히 알아보기 힘들게 만든 후에 A 사에 넘겨서 A사가 직접 빌드하도록 하는 것이 편리할 것이다.

 

또 다른 예로 최근 부각 받는 AJAX의 경우, 자바스크립트(JavaScript)로 작성된 코드가 브라우저에 그대로 노출되는 문제가 있다. 소스 코드를 공개하고 싶지 않은 개발자라면 AJAX의 이런 특성이 큰 부담이 될 것이다. 이 경우 자바스크립트 코드를 쉽게 알아보지 못하도록 난독화 도구를 사용할 수 있다. 다음은 JavaScript Obfuscator가 실제로 자바 스크립트를 난독화한 예제이다. 원래 코드와 난독화된 코드를 비교해보면, 난독화 과정에 대한 감을 잡을 수 있을 것이다.


<원래 코드>

//detect which browser is used

var detect = navigator.userAgent.toLowerCase();

var OS,browser,version,total,thestring;

if (checkIt('konqueror'))

{

browser = "Konqueror";

OS = "Linux";

}

else if (checkIt('opera')) browser = "Opera"

else if (checkIt('msie')) browser = "Internet Explorer"

else if (!checkIt('compatible'))

{

browser = "Netscape Navigator"

version = detect.charAt(8);

}

else browser = "An unknown browser";

//version of browser

if (!version) version = detect.charAt(place + thestring.length);

//client OS

if (!OS)

{

if (checkIt('linux')) OS = "Linux";

else if (checkIt('x11')) OS = "Unix";

else if (checkIt('mac')) OS = "Mac"

else if (checkIt('win')) OS = "Windows"

else OS = "an unknown operating system";

}

 

//check the string

function checkIt(string)

{

place = detect.indexOf(string) + 1;

thestring = string;

return place;

}


<난독화된 코드>

var e=navigator.userAgent.toLowerCase();var f,b,c,total,d;if(a('konqueror')){b="Konqueror";f="Linux";};else if(a('opera'))b="Opera";else if(a('msie'))b="Internet Explorer";else if(!a('compatible')){b="Netscape Navigator";c=e.charAt(8);};else b="An unknown browser";if(!c)c=e.charAt(g+d.length);if(!f){if(a('linux'))f="Linux";else if(a('x11'))f="Unix";else if(a('mac'))f="Mac";else if(a('win'))f="Windows";else f="an unknown operating system";};function a(string){g=e.indexOf(string)+1;d=string;return g;};


C/C++ 개발자에게는 코드 난독화가 생소한 개념일 수 있지만, 자바 개발자들은 수년전부터 코드 난독화 도구를 실용적인 목적으로 이용해 오고 있다. 자바의 바이트코드는 윈도 실행 파일보다 훨씬 많은 심볼(symbol)을 컨스턴트 풀(constant pool) 영역에 가지고 있기 때문이다. 난독화를 거치지 않은 자바 바이트코드를 디컴파일해보면, 원래 소스 코드의 대부분이 복구되는 것을 알 수 있다.


윈도 PE 분석에 가장 많이 사용되는 어셈블리 디버거인 OllyDbg



코드 난독화에 대한 재미난 사실은 이 기술이 바이러스나 웜 제작자들에 의해 처음 연구되었다는 사실이다. 바이러스나 웜은 백신의 스캔에 걸리지 않기 위해 자신의 바이너리를 교묘한 형태로 숨겨왔는데, 이렇게 바이너리를 변형해 정보를 숨기는 기술이 바이너리 코드 난독화의 목표이다. 반면에 백신 개발자들은 이렇게 변형된 웜을 탐지하기 위해, 소스 코드를 확인할 수 없는 바이너리 코드의 안정성과 취약점을 분석하는 기술을 발전시켜왔다, 코드 난독화와 바이너리 분석 기술은 서로 경쟁하는 기술인 셈이다.


기본적인 바이너리 코드 난독화 방법


가장 기본적인 코드 난독화는 바이너리에서 심볼 정보를 제거하거나 변경하는 것이다. C/C++로 컴파일된 바이너리의 경우 디버깅 옵션을 주면 바이너리에 심볼 정보가 포함되는데 난독화 도구는 이런 정보를 지워서, 바이너리에서 사람이 이해할 수 있는 정보를 최대한 제거하는 것이다. 자바의 경우는 심볼 정보가 프로그램 수행에 필요하므로, 심볼 정보를 제거할 수는 없다. 대신에 자바용 난독화 도구는 심볼 이름을 바꾸는 방법을 많이 사용한다. MyString이라는 클래스 이름보다는 M라는 클래스 이름이 알아보기 어렵고, find라는 메쏘드 이름보다는 f라는 이름이 공격자가 이해하기 어렵다는 점에 착안한 것이다.

 

그 외에도 다음과 같은 난독화 방법이 있다.

 

1. 필요 이상으로 복잡한 코드를 만들거나, 아무 것도 하지 않는 코드를 삽입한다.

 

이 난독화 기술은 실행되지 않는 함수를 추가하거나, 아무 것도 하지 않는 함수들을 중간 중간에 삽입하여 바이너리 코드 분석을 힘들게 만드는 것이다. 이런 일은 데드 코드(dead code)를 제거하고, 코드를 짧고 간단하게 만드는 최적화 컴파일러(optimizing compiler)가 하는 일에 역행하지만, 역공학을 어렵게 만드는 데는 효과적이다.

 

물론 이런 기술을 사용해도 공격자가 함수 호출 그래프(call graph)와 흐름 그래프(control flow graph)를 그리고, 세심하게 코드를 분석하면 취약점을 찾아내는 것은 시간문제이다. 하지만 그렇다고 이런 난독화 기술이 의미가 없지는 않다. 난독화의 목표는 역공학을 불가능하게 만드는 게 아니라, 충분히 어렵게 만들어서 공격자가 포기하고 다른 공격 대상을 찾게 만드는 데 있기 때문이다.

 

2. 코드를 여기저기로 복사하고, 옮긴다.

 

관련된 코드를 최대한 멀리 떨어진 곳에 배치하거나, 함수를 인라이닝(inlining)하고, 반복되는 몇 개의 구문(statement)을 합쳐서 익명의 함수를 만드는 등의 일을 할 수 있다. 똑같은 함수를 복사하여, 각기 다른 지점에서 다른 함수 이름을 사용하는 방법도 있다. 연달아 불리는 서로 관련 없는 함수를 하나의 함수로 묶어주는 방법도 있다. 성능을 심각하게 해치지 않는 범위에서 코드를 뒤섞는 모든 방법이 여기에 포함된다.

 

 

3. 데이터를 알아보기 힘들게 인코딩(encoding)한다.

 

취약점 분석가들이 바이너리 분석에서 가장 먼저 하는 일은 바이너리에 포함된 텍스트(text) 문자열을 찾아내는 것이다. 일례로, C 언어로 char passwd[] = "mypass"; 같은 코드가 컴파일되어 바이너리에 포함되어 있다면 strings 명령으로 바이너리를 스캔해 보는 것만으로도 암호를 발견해 낼 수 있다. 믿지 않겠지만, 실제로 이런 코드가 발견되는 경우가 매우 빈번하다.

 

해결책은 텍스트 문자열을 알아보기 힘든 방식으로 인코딩하고 필요할 경우만 디코딩(decoding)해서 사용하는 방법이다. 특히 외부로 노출되었을 경우 곤란한 정보인 경우, 이런 과정을 거치는 것이 좋다. 인코딩/디코딩하는 방법으로 암호 알고리즘을 사용하는 것도 한 방법이다. 하지만 암호 알고리즘도 역공학 앞에서는 완벽한 보호책이 되지 못한다. 복호화에 사용되는 키가 메모리 어딘가에 반드시 존재해야 하고, 복호화 직전에 이 키를 읽어와야만 하기 때문이다. 공격자는 언제든지 암호키를 읽을 수 있다.

 

앞서 살펴본 3가지 방법은 역공학을 어렵게 만들기는 하지만, 프로그램의 성능을 심각하게 떨어뜨릴 수 있다. 난독화 도구는 각종 난독화 방법을 적용할지 여부를 옵션으로 남겨놓는데, 시스템과 성능과 역공학에 대한 저항성 등을 상충되는 요구 사항을 잘 고려한 후에 결정해야할 문제다.



정리

 

앞서 이야기한 것처럼 역공학에 대한 100% 완벽한 대안은 없다. 바이너리를 분석하고 수정할 수 있다면 어떠한 안전장치도 통과할 수 있기 때문이다. 다만 전자 상거래나 보안 제품 등 높은 수준의 보안성을 요구하는 프로그램이라면, 약간의 성능 저하를 감수하고서라도 각종 코드 난독화 기술을 적용해볼 여지를 가지고 있다.

이 글에서는 역공학과 코드 난독화의 기본적인 개념과 간단한 메커니즘만 소개하였고, 세부적인 기술에 대한 논의는 지면 관계상 보류하였다. 관심 있는 독자는 "obfuscate"라는 단어로 좀 더 많은 자료를 찾아보길 바란다.

 

 

참고 문헌

[1] Building Secure Software, Viega, McGraw, Addison-Wesley







웹과 사용자 인터페이스

Posted 2008. 11. 29. 23:12

마이크로소프트웨어 2008년 9월호 박스 기사입니다.



GWT의 위젯이나 고급 Ajax UI 라이브러리는 HTML과 CSS 밖에 없는 브라우저 위에 버튼, 레이블, 메뉴 등을 쉽게 만들 수 있게 해준다. 대표적인 Ajax UI 툴킷은 Ext이다. 위 그림은 Ext로 MS 윈도 데스크톱 환경을 비슷하게 구현한 것이다.

 

웹 브라우저에 그림을 그리려면 HTML와 CSS를 사용해야 한다. 따라서 웹 UI 라이브러리는 자바 SWT와 달리 UI가 변할 때마다 HTML을 동적으로 생성하는 방식을 쓴다. 브라우저에서 데스크톱 이상의 UI 툴킷을 구현하는 것은 웹 표준이 의도한 바를 넘어선 해킹인 셈이다. 속도 향상을 위해 많은 최적화 방법이 사용되지만 여전히 답답할 만큼 속도가 느리다.

 

HTML5에서는 성능 문제가 보다 쉽게 해결될 수 있다. HTML5에 추가된 <canvas> 자바스크립트로 호출할 수 있는 2D 벡터 그래픽 API를 제공하기 때문이다. DOM에서 canvas 오브젝트를 얻어, GDI+나 Cairo와 유사한 2D 벡터 그래픽 API로 그릴 수 있다. 이 방식을 사용하면 UI 툴킷을 좀 더 효율적으로 구현할 수 있고 기존 UI 툴킷도 쉽게 포팅할 수 있으리라 기대된다.



스크립트 언어와 개발 도구(IDE)

Posted 2008. 11. 29. 23:09

마이크로소프트웨어 2008년 9월 박스 기사입니다.

흔히 스크립트 언어는 개발 도구 개발이 어렵다고 생각하는 사람이 많다. 동적 타입 시스템을 사용하는 스크립트 언어는 실행 전에 정확한 타입 정보를 알기 어렵기 때문이다. 예를 들어, MS 비주얼 스튜디오 인텔리센스(IntelliSense)는 자동으로 변수 혹은 함수 이름을 완성해주는 기능이다. 하지만 불행히도 스크립트 언어는 인텔리센스 같은 자동 완성 기능을 완벽히 지원하기 어렵다. 변수의 정확한 타입을 모르기 때문이다. 또 다른 예로, 리팩토링이 있다. 메서드 이름 변경 리팩토링을 했을 때 스크립트 언어는 메서드가 사용된 위치를 100% 정확히 파악할 수 없다.

 

하지만 개발 도구 지원이 약한 것이 반드시 언어 특징 때문은 아니다. 정적 타입 시스템을 사용하는 언어라고 크게 상황이 다르지 않기 때문이다. 예를 들어 자바는 정적 타입 시스템을 사용하지만 컴파일 타임에 모든 정보를 알 수 있지는 않다. 대표적인 예가 리플렉션(reflection)이다. 자바도 실행 시에 문자열로 메서드 이름이나 필드 이름을 참조할 있기 때문에 스크립트 언어와 마찬가지로 인텔리센스나 리팩토링 기능이 정확할 수 없다. 스크립트 언어 개발 도구가 미흡한 것은 아직 다른 언어처럼 개발 도구 개발에 많은 노력을 하지 않았기 때문으로 보는 것이 옳다.



웹 클라이언트 프로그래밍 언어

Posted 2008. 11. 29. 23:06

요즘은 자바 플랫폼과 .NET을 보면 다중 언어 지원이 대세다. 개발자가 하나의 프로그래밍 언어에 얽매여 있을 필요가 없는 시대가 왔다. 그런데 웹 클라이언트 프로그래밍만은 예외다. 어떤 개발자도 예외 없이 자바스크립트를 쓰도록 강요받고 있기 때문이다. 자바스크립트 나름의 매력에도 불구하고 웹 클라이언트 프로그래밍에 다른 언어를 쓰고자 하는 욕망이 있다 구글의 GWT, 280Slides의 Objective-J 등은 웹 클라이언트 프로그래밍에 각각 자바와 Objective-C라는 기존 언어를 쓰고자 하는 시도다. 이 글에서는 자바스크립트의 대안으로 사용되는 웹 클라이언트 프로그래밍 언어들을 살펴보려 한다.


마이크로소프트웨어 2008년 9월 기고글입니다.


새로운 웹 클라이언트 프로그래밍 언어


과거에는 프로그래밍 언어 하나로 전체 시스템을 만드는 방식이 일반적이었다. 하지만 스크립트 언어, 함수 언어 등의 필요성과 장점이 부각되면서 하나의 시스템을 만드는 데 여러 언어를 동시에 쓰는 방법이 인기를 얻고 있다. 예를 들어, 주요 컴포넌트는 자바로 작성하고 컴포넌트 통합 및 사용자 인터페이스 작성은 그루비로 하는 방식은 각 언어의 특징을 잘 살린 실용적인 접근법이다.

 

처음부터 다중 언어 지원을 중요한 기능으로 내세웠던 .NET은 현재 C#, VB.NET 외에도 IronPython, IronRuby, F#을 비롯해 수십 개의 프로그래밍 언어를 지원하고 있다. .NET에 대응하여 다빈치 머신(Davinci Machine) 프로젝트를 통해 다중 언어 지원을 강화하고 있는 자바 플랫폼도 그루비, Jython, JRuby, 스칼라 등의 자바가상머신 언어 개발을 적극적으로 후원하고 있다. 프로그래밍 언어의 중요 개발자들은 마이크로소프트와 썬 썬마이크로시스템즈가 영입해 개발 속도를 높이고 있다.

 

이런 변화의 바람 속에도 꿋꿋이 단일 언어만 고집하는 분야가 웹 클라이언트 프로그래밍이다. Ajax 바람 속에 웹 프로그래밍이 서버에서 클라이언트로 추가 넘어가고 있지만 자바, 파이썬, 루비를 포함한 서버의 다양한 개발 언어와 달리 클라이언트 프로그래밍은 오직 자바스크립트만이 유일한 대안이다. 대신 jQuery, Dojo, Prototype 등의 Ajax 라이브러리들은 각자의 취향에 따라 여러 속임수로 다른 언어를 흉내 내고 있다.

 

하지만 시간이 흐르면서 웹 클라이언트 프로그래밍에 새로운 언어들이 등장하기 시작했다. 대표 선수는 자바를 웹 프로그래밍에 사용한 구글의 GWT(Google Web Toolkit)다. 웹 프레젠테이션 도구인 280Slides 작성에 쓰인 Objective-J도 유명하다. 그 외에도 아직 프로토타입 수준이지만 루비, 파이썬 등 현존하는 거의 모든 언어가 모두 웹에서 돌아가는 시대가 열렸다.

 

이들 언어는 기존 웹브라우저에서 그대로 동작한다. 비법은 컴파일러에 있다. GWT의 경우 GWT 컴파일러가 자바로 작성된 소스 코드를 컴파일해서 자바스크립트를 생성한다. 일반적인 컴파일러는 고급 언어를 컴파일해서 중간 언어 혹은 기계 코드를 생성하는데 비해 웹 프로그래밍 언어 컴파일러는 최종 코드가 자바스크립트라는 차이만 있을 뿐 기본적인 컴파일러 동작 방식은 동일하다. 컴파일러 기술에 웹 클라이언트 프로그래밍 언어의 새로운 지평을 열었다.


GWT(Google Web Toolkit)


Ajax 프로그래밍은 구글의 대표 기술이다. 지메일(Gmail), 구글맵(Google Maps) 등 구글 대표 서비스는 Ajax 기술을 활용한 풍부한 사용자 인터페이스를 제공하였고 경쟁자들을 물리칠 수 있었다. 누구보다 자바스크립트를 많이 사용하는 구글이 자바스크립트가 아닌 자바로 웹 프로그래밍 툴킷을 제작했다. 구글이 밝힌 GWT의 장점은 컴파일러 최적화를 이용한 고성능 자바스크립트, 개발 도구 지원, 구글 API 및 재사용 UI 컴포넌트다.

 

GWT의 기본 가정은 손으로 작성한 자바스크립트 코드보다 자바로 작성한 후 컴파일러가 최적화하는 편이 성능이 더 좋다는 것이다. 물론 자바스크립트에 정통한 개발자는 최고의 성능을 발휘하는 아름다운 코드를 작성할 수도 있겠다. 하지만 C 언어가 아닌 어셈블리를 써야만 최고의 성능을 발휘할 수 있으니 무조건 어셈블리만 사용하자는 말과 동일하다. GWT는 자바 코드 컴파일해 얻은 자바스크립트 코드가 손으로 작성한 자바스크립트 코드보다 더 빨리 로딩되면서 코드 크기는 오히려 작다고 이야기한다.

 

이식성도 중요한 이유다. 자바스크립트는 이식성이 나쁜 언어다. 웹브라우저마다 자바스크립트 인터프리터, DOM, 이벤트 등이 미묘하게 다르고 브라우저 간 호환성을 위해서는 상당한 노력이 필요하다. 물론 고급 Ajax 라이브러리가 이 문제를 상당히 해결한 것은 사실이지만 GWT는 이 문제를 더 높은 수준에서 해결했다. GWT 개발자는 GWT 개발 환경 하나만 익히면 모든 브라우저에서 호환되는 코드를 자동으로 만들 수 있게 된다.

 

GWT가 자바를 선택한 또 다른 이유는 개발 도구 지원이다. 자바는 이클립스(Eclipse)라는 세상에서 가장 강력한 개발 도구를 보유한 언어이다. 물론 개발자 개인 취향에 따라 IntelliJ 혹은 NetBeans를 선택할 수도 있다. 반대로 자바스크립트는 개발 도구 지원이 열악하다. 동적 타입 시스템을 쓰는 스크립트 언어 특징상 개발 도구 개발이 어렵기 때문이다. 크고 복잡한 응용 프로그램 개발의 경우 개발 도구 지원 여부는 프로그래밍 언어 선택의 중요 기준이 된다.

 

GWT은 재밌는 기능 중 하나는 JSNI(JavaScript Native Interface)이다. 원래 자바는 JNI(Java Native Interface)라고 해서 자바로 작성된 프로그램이 C 함수를 호출할 수 있는 인터페이스를 열었다. GWT는 웹 브라우저에서 돌아가는 코드를 생성하므로 C 코드를 호출할 수는 없는 대신 자바스크립트 코드를 호출할 수 있게 했는데 그 인터페이스가 JSNI다. 특정 브라우저에만 있는 기능을 이용하거나 GWT가 생성한 코드보다 더 효율적인 코드를 자바스크립트로 작성할 수 있다고 판단했을 때 쓰면 된다.

 

GWT를 사용한다고 모든 자바 프로그램이 웹에서 실행되지는 않는다. GWT는 자바 언어를 사용하지만 JRE(Java Runtime Environment)를 모두 제공하지는 않기 때문이다. GWT는 JRE의 일부를 웹으로 포팅했고 웹 프로그래밍을 위한 별도의 API를 제공한다. 또한 UI를 쉽게 만들 수 있도록 기존 스윙(Swing) 혹은 SWT와 유사한 UI API도 제공한다. UI 렌더링을 할 때는 HTML을 동적으로 생성하여 붙이는 방식을 쓴다.


Objective-J



Objective-J를 이용해 만든 프레젠테이션 도구 280slides


GWT가 데스크톱 프로그래밍의 일부를 웹 프로그래밍에 도입한 프로젝트라면 Objective-J와 Cappuccino는 데스크톱 프로그래밍 환경을 웹에 그대로 옮겨놓은 야심찬 프로젝트다. Objective-J는 그 이름에서 알 수 있듯이 Mac OS X 데스크톱 응용 프로그램 개발에 사용되는 Objective-C를 웹으로 옮겨놓은 버전이다. Objective-J를 만든 North280은 웹 프레젠테이션 도구(MS의 파워포인트, 애플의 키노트)인 280Slides 통해 Objective-J의 가능성을 보여줬다. Cappuccino는 Mac OS X UI 라이브러리인 Cocoa를 웹으로 포팅한 것이고 역시 North280 팀이 만들었다.

 

웹 프로그래밍 하면 떠오르는 모습은 Rails, Django 등의 웹 프레임워크로 서버 측 프로그램을 작성하고 HTML, CSS, Ajax 라이브러리로 클라이언트 프로그램을 만드는 방식이다. 하지만 서버 쪽 코드를 줄이고 데스크톱 응용 프로그램과 유사한 웹 응용 프로그램을 만들려는 시도도 계속되고 있다. 대표적인 예가 SproutCore이다. 플래시나 실버라이트가 표방하는 리치 클라이언트(rich client)의 자바스크립트 버전인 셈이다. SproutCore 프로그램은 대부분의 시간을 웹 서버와 독립적으로 동작하다가 데이터를 저장하거나 불러올 때만 Ajax 라이브러리로 서버와 통신하는 방식을 사용한다. SproutCore로 작성된 프로그램은 Rails 웹 응용 프로그램보다는 데스크톱 Cocoa 프로그램을 더 닮았다.

 

이런 변화의 바람 속에서 웹 프로그램이 Rails 보다 Cocoa 프로그램을 더 닮았다면 자바스크립트가 아닌 Objective-C로 코딩하자는 생각에서 출발한 프로젝트가 Objective-J다. Objective-J의 예는 프로그래밍 언어는 단순한 문법이 아니라 그 언어를 사용하는 사람, 문화, 기술을 통칭함을 보여준다. Objective-J 도입은 자바스크립트, CSS, HTML, DOM을 이용한 전통적 웹 응용 프로그램 개발 방식을 대체하는 데스크톱 스타일의 개발 환경을 웹으로 가져온다.

 

Objective-J는 North280 팀에 의해 조만간 오픈소스화될 예정이다. Objective-J (http://objective-j.org/) 홈페이지를 확인해 보기 바란다.


Flapjax


앞서 언급한 GWT나 Objective-J는 기존 프로그래밍 언어를 웹으로 포팅하고 각 언어의 장점을 살려 데스크톱 응용 프로그램을 쉽게 구축할 수 있는 방법을 제공했다. 반면에 Flapjax는 새로운 웹 프로그래밍 모델을 제시하는 프로그래밍 언어다.

 

자바스크립트 프로그래밍의 문제점 중 하나는 콜백 함수 등록이 너무 많다는 점이다. 마우스 위치를 따라 네모 박스가 약간 시간 간격을 두고 따라오는 간단한 자바스크립트 프로그램을 생각해보자. 다음과 같은 HTML, 자바스크립트 코드가 필요할 것이다.


<div id="box" style="position:absolute; background:yellow;">

     Seconds to deadline: <span id="time">...time...</span>

</div>

<script>

 

document.addEventListener(

     'mousemove',

     function(e) {

         var left = e.pageX

         var top = e.pageY

         setTimeout(function() {

             document.getElementById("box").style.top = top;

             document.getElementById("box").style.left = left;

         }, 500);

     }, false);

</script>

자바스크립트 예제


자바스크립트 프로그래밍에 익숙한 개발자는 위 코드가 직관적이라고 느낄 수도 있겠다. 하지만 마우스 위치에 따라 네모 상자를 옮기는 간단한 일을 하기 위해 콜백을 2번 등록하고 "box" ID를 찾아서 값을 변경해야 한다. 가독성도 떨어진다. 이 프로그램은 마우스의 움직임에 따라 다음 그림과 같은 명확한 데이터 흐름이 있지만 위 자바스크립트 소스 코드만 보고 이 사실을 한 번에 알아내기는 쉽지 않다.


데이터 흐름도(data flow)


Flapjax는 이처럼 자바스크립트(Ajax) 프로그램에서 빈번히 발생하는 데이터 흐름 중심으로 프로그램을 기술하는 언어다. 위 프로그램은 Flapjax로 작성하면 다음과 같다. Flapjax는 자바스크립트와 유사한 문법을 가지며 자바스크립트로 컴파일된다.

 

<div style={!

     {

         position:"absolute", background: "yellow",

         top: delay(mouseTop(), 500),

         left: delay(mouseLeft(), 500)

     }

!}

> Seconds to deadline: {! timeStream() !}. </div>

Flapjax 예제 (주의: 설명을 위해 간단하게 만들었기 때문에 실제 동작하지 않음)


이 프로그램을 해석하는 방법은 간단하다. mouseTop()과 mouseLeft()는 지속적으로 현재 마우스의 위치를 주는 데이터 소스로 생각하면 된다. top 값은 mouseTop 값이 바뀜에 따라 지속적으로 변경되는데, 변경 시에는 500ms의 지연이 있다. 마찬가지로 left 값은 mouseLeft() 값이 바뀜에 따라 지속적으로 바뀌고 500ms의 지연이 있다. timeStream()은 흐르는 시간을 데이터 소스로 주기 때문에 화면에 표시되는 시간이 계속 바뀐다.

 

Flapjax는 지속적으로 값이 변경되는 데이터 소스가 존재하고 이 값이 변하면 여기에 의존하는 값들이 자동으로 변경되는 방식을 채택했다. 따라서 일반적인 자바스크립트 프로그래밍처럼 변경이 예상되는 이벤트에 일일이 콜백을 걸어줄 필요 없이도 자동으로 새로운 값이 계산된다. Flapjax 프로그래밍 모델에서는 데이터 흐름이 명시적으로 보이는 것이 장점이고 document.getElementByID를 이용해 변경할 노드를 찾을 필요 없을 값을 삽입할 위치에 직접 코드를 적어주면 된다.



핫루비(HotRuby)


컴파일러 외에 웹에서 다중 언어를 구현하는 또 다른 방식은 자바스크립트로 가상 머신을 구현하는 것이다. 예를 들어 자바가상머신(JVM)을 자바스크립트로 작성하면 자바 프로그램을 바이트코드로 컴파일한 후에 자바스크립트로 돌릴 수 있다. 물론 가상머신은 보통 성능을 높이기 위해 효율적인 C/C++ 코드로 작성하는 것이 일반적이지만 이론적으로는 자바스크립트로 작성해도 아무런 문제가 없다.

 

핫루비 프로젝트는 루비 인터프리터를 자바스크립트로 구현하는 프로젝트다. Ruby 1.9 YARV(Yet Another Ruby VM)의 바이트코드 인터프리터를 만들었다. <script type="text/ruby"></script> 태그 안에 루비 코드를 작성해 넣으면 텍스트를 뽑아서 XMLHttpRequest로 서버에 보낸다. 서버 CGI는 루비 코드를 받아서 바이트코드로 바꾸고 직렬화해 JSON으로 보낸다. 브라우저는 루비 인터프리터를 돌려서 루비 코드를 실행한다. 핫루비는 기존 코드를 재활용하기 위해 루비 컴파일러를 서버에 두는 방식을 택했지만 이론적으로는 루비 컴파일러 또한 자바스크립트로 만들어 넣을 수 있다.

 

존 레시그(John Resig)는 다음 코드로 핫루비의 성능을 측정했다[3]. 벤치마크 결과 Ruby 1.8.2에서는 12.25초가 걸렸다. 자바스크립트로 구현한 핫루비의 성능은 어느 정도 일까? 놀랍게도 파이어폭스 2에서 6.71초, 파이어폭스 3.0b5에서 2.47초 만에 수행이 끝났다. C로 구현한 Ruby 1.8.2 보다 자바스크립트로 구현한 핫루비가 몇 배나 더 빠른 셈이다. 게다가 파이어폭스 버전이 올라갈수록 더 빨라지고 있다.


startTime = Time.new.to_f

sum = ""

50000.times{|e| sum += e.to_s}

endTime = Time.new.to_f

puts (endTime - startTime).to_s + ' sec'

루비 벤치마크


["YARVInstructionSequence\/SimpleDataFormat",1,1,1,{"arg_size":0,"local_size":4,"stack_max":3},"","src","top",["startTime","sum","endTime"],0,[["break",null,"label_21","label_29","label_29",0]],[2,["putnil"],["getconstant","Time"],["send","new",0,null,0,null],["send","to_f",0,null,0,null],["setlocal",4],4,["putstring",""],["setlocal",3],"label_21",5,["putobject",50000],["send","times",0,["YARVInstructionSequence\/SimpleDataFormat",1,1,1,{"arg_size":1,"local_size":1,"stack_max":2},"block in ","src","block",["e"],[1,[],0,0,-1,-1,3],[["redo",null,"label_0","label_22","label_0",0],["next",null,"label_0","label_22","label_22",0]],["label_0",5,["getdynamic",3,1],["getdynamic",1,0],["send","to_s",0,null,0,null],["send","+",1,null,0,null],["dup"],["setdynamic",3,1],"label_22",["leave"]]],0,null],"label_29",["pop"],7,["putnil"],["getconstant","Time"],["send","new",0,null,0,null],["send","to_f",0,null,0,null],["setlocal",2],9,["putnil"],8,["getlocal",2],["getlocal",4],["send","-",1,null,0,null],["send","to_s",0,null,0,null],["putstring"," sec"],9,["send","+",1,null,0,null],["send","puts",1,null,8,null],8,["leave"]]]

서버가 생성해 JSON으로 준 핫루비 바이트코드


상식적으로 납득하기 어려운 벤치마크 결과를 설명하기 위해서는 Ruby 1.8.2와 Ruby 1.9의 차이를 알아야 한다. Ruby는 인터프리터가 비효율적인 것으로 유명한 스크립트 언어다. 동일한 일을 수행하는 코드를 작성하면 C 보다 100배 이상 느린 경우가 다반사다. 파이썬이 소스코드를 일단 중간 코드(바이트코드)로 변환한 후에 인터프리트하는 것과 달리 루비는 1.8.2까지 파싱된 소스 코드(AST)를 매번 새로 방문하면서 코드를 실행하는 방식을 택했기 때문이다. YARV는 이런 문제를 해결하기 위해 루비에 바이트코드 인터프리터를 추가하는 프로젝트였고 루비 1.9는 YARV를 기본으로 채택했다. 핫루비는 바이트코드 인터프리터이기 때문에 루비 1.8.2의 비효율적인 실행 방식에 비해 빠르다.

 

핫루비의 성능이 괜찮은 또 다른 요인은 자바스크립트 인터프리터가 점점 빨라지고 있다는 점이다. 파이어폭스 2.0과 3.0b5의 비교를 해보면 3.0b에서 2.71배 속도 향상이 있음을 볼 수 있다. 앞서 언급한 GWT, Objective-J 등 대형 Ajax 프레임워크가 나오면서 자바스크립트 속도 향상이 브라우저 벤더들 사이에서 중요한 요구사항이 되었기 때문이다.

 

모질라는 파이어폭스의 성능을 더 높이기 위해 현재 아도브가 기증한 타마린(Tamarin) 스크립트 엔진을 파이어폭스 스크립트 엔진인 스파이더몽키(SpiderMonkey)에 붙이는 액션몽키(ActionMonkey) 프로젝트를 진행하고 있다. 그것만으로 모자랐는지 아도브는 타마린에 바이트코드 실행 기록을 남겨서 머신 코드를 생성하는 트레이스 JIT를 추가한 타마린 트레이싱(Tamarin Tracing)까지 내놓고 자바스크립트 속도를 높이기 위해 안간힘을 쓰고 있다.

 

이런 움직임은 애플 사파리에서도 마찬가지다. 애플은 최근 레지스터 바이트코드를 쓰는 다이렉트 쓰레드(direct-threaded) 코드 기반의 효율적인 자바스크립트 엔진 인터프리터인 스쿼럴피시(SquirrelFish)를 내놨고, 그 속도는 JIT 컴파일러를 내장한 타마린을 능가하고 있다. 스쿼럴피시는 앞으로 사파리에 탑재되어 데스크톱 사파리와 iPhone 사파리 모바일 등에 사용될 예정이다.


정리


웹 프로그래밍은 서버에서 클라이언트로 이동하고 있다. 플래시, 실버라이트, JavaFX 등 비표준 RIA 플랫폼 이런 변화를 앞당긴 기술이다. 웹 표준 기반 응용 프로그램도 서서히 서버에서 클라이언트로 방향 전환을 하고 있다. 따라서 Ajax로 대표되는 웹 클라이언트 프로그래밍의 중요성이 날로 커지고 있다.

 

앞서 살펴본 것처럼 웹 클라이언트 프로그램이 발전하고 데스크톱 응용 프로그램과 유사한 기능을 원하게 되면 기존 데스크톱 응용 프로그램에 작성에 사용되는 프로그래밍 언어와 라이브러리들이 웹으로 진입할 가능성이 크다. 컴파일러 혹은 VM 기술에 힘입어 기존 브라우저를 수정하지 않고도 다양한 언어를 지원할 수 있기 때문이다.

 

따라서 앞으로 웹 개발자는 빠르고 효율적인 개발을 위해 서버 기반 웹 프레임워크에서 벗어나 클라이언트 솔루션을 찾아볼 필요가 있다. GWT, Objective-J, Flapjax 등의 새로운 웹 프로그래밍 언어 및 프레임워크는 웹 응용 프로그램 작성에 있어 든든한 지원군이 되어줄 것이다.


참고문헌


[1] GWT(Google Web Toolkit) Documentation

http://code.google.com/webtoolkit/overview.html

 

[2] Flapjax Tutorial

http://www.flapjax-lang.org/tutorial/

 

[3] Ruby VM in JavaScript

http://ejohn.org/blog/ruby-vm-in-javascript/














하스켈의 IO

Posted 2008. 11. 29. 04:34

마소 4월호 박스 기사입니다.


스켈은 순수 함수형 언어이며 원칙적으로 사이드 이펙트가 없다. 여기서 사이드 이펙트는 프로그램 수행의 결과로 상태가 변화하는 경우를 일컫는다. 대표적인 사이드 이펙트는 IO로, 화면에 결과를 출력하거나 키보드로부터 입력을 받는 일을 말한다. 간단한 예로 putStr 함수는 문자열을 받아서 화면에 출력해주며 타입은 다음과 같다.

 

putStr :: String -> IO ()

putStr의 타입


putStr은 String을 인자로 받고 IO를 수행한 후에 ()를 리턴한다는 뜻이다. 또 다른 예로 getLine을 사용자에게 한 줄 입력을 받는 함수며 타입이 다음과 같다.


getLine :: IO String

getLine의 타입


getLine은 인자가 없고 IO를 수행 후에 String 타입을 리턴한다. do {}를 사용하면 이렇게 IO를 수행하는 함수를 통해 조금 더 복잡한 일을 순차적으로 수행할 수 있다. 앞서 소개한 두 함수를 조합해 사용자가 한 줄 입력을 받은 후에 그대로 보여주는 프로그램은 다음과 같다.


echo :: IO ()

echo = do { s <- getLine

                ; putStr s }

echo 함수



공짜 점심은 끝났다. CPU 클럭 속도 경쟁이 끝나고 본격적인 멀티코어 시대가 시작되었다. CPU 하나가 낼 수 있는 성능은 이미 한계에 도달했기 때문에 프로세서 벤더들은 성능을 향상시키기 위해 여러 개의 코어를 사용하기 시작했다. 멀티 코어 시대는 소프트웨어 개발자에게 큰 변화를 요구한다. 기존의 순차적인 프로그램 작성 방식으로는 코어가 늘어난다고 성능 개선 효과를 기대하기 힘들기 때문이다. 앞으로는 병렬 프로그래밍이 고급 소프트웨어 개발자들의 필수지식이 될 것이다. 이 글은 락/컨디션 변수를 사용한 고전적인 병렬 프로그래밍의 문제점을 짚어보고, 그 대안으로 함수형 언어인 하스켈(Haskell)을 사례로 소프트웨어 트랜잭션 메모리(STM)의 개념을 소개하려 한다.


 2008년 4월 마이크로소프트웨어 기고글입니다.


병렬 프로그래밍

 

병렬 프로그래밍(parallel programming)은 하나의 프로그램이 동시에 여러 일을 수행하게 만드는 방법을 통칭한다. 병렬 프로그래밍이라는 말은 고성능 컴퓨팅(HPC, High Performance Computing)의 한 분야를 지칭하는 좁은 뜻으로도 사용되기 때문에 혼돈을 피하기 위해 동시 프로그래밍(concurrent programming)이라는 말을 쓰기도 한다.

 

병렬 프로그래밍의 가장 일반적인 사례는 자원(보통은 메모리)을 공유하고 락(lock)과 컨디션 변수(conditional variable) 통해 공유 자원의 접근을 통제하는 멀티쓰레드 프로그래밍이다. OS 프로세스를 여러 개 만들고 프로세스 간 통신(IPC, Inter-Process Communication)을 통해 데이터를 처리하는 것도 병렬 프로그래밍의 한 예이다. 함수형 언어인 얼랑(Erlang)처럼 여러 프로세스(process, 여기서 프로세스는 OS 프로세스가 아닌 언어 런타임의 경량 프로세스임)가 메시지 큐(message queue)를 통해 데이터를 주고받는 모델도 병렬 프로그래밍의 한 예이다.

 

병렬 프로그램을 작성하는 일은 순차적인 프로그램 작성에 비해 무척 어렵다. 일례로 데이터 구조의 일종인 데크(double ended queue)를 작성하는 일은 학부생 프로그래밍 숙제 수준에서 해낼 수 있는 일이지만, 규모가변성(scalability) 보장되면서 쓰레드-세이프(thread-safe)한 데크 작성은 논문으로 낼 수 있는 수준이 된다.

 

여기서 규모가변성이란 코어의 수를 늘렸을 때 프로그램의 성능 향상이 일어나는 정도를 말한다. 코어가 2배가 되었을 때 프로그램 성능이 2배가 된다면 규모가변성이 매우 뛰어난 병렬 프로그래밍이고, 코어가 2배가 되었음에도 성능 향상이 미미하다면 규모가변성이 떨어지는 프로그램이다.


락/컨디션 변수의 문제점

 

병렬 프로그래밍의 고전이자 가장 일반적인 모델은 락과 컨디션 변수를 이용한 멀티쓰레드 프로그래밍이다. 멀티쓰레드 프로그래밍은 공유 자원(혹은 공유 메모리)을 가정하고 여러 개의 쓰레드가 동시에 프로그램을 수행하며 같은 자원에 데이터를 읽고 쓰며 통신하는 방법이다. 락과 컨디션 변수는 운영체제를 비롯해 일부 시스템 소프트웨어를 작성하는데 주로 사용되었으나, 자바가 모니터(락과 컨디션 변수)를 프로그래밍 언어에 포함시키면서 일반 개발자들도 널리 사용하게 되었다.

 

하지만 결론부터 이야기하면 락을 이용한 멀티쓰레드 프로그래밍 기술이 널리 퍼질수록 전 세계의 개발자들은 더욱 더 많은 버그를 만들어내고 인터넷으로 연결된 우리가 사는 이 세상은 더 나빠진다. 멀티쓰레드 프로그램을 오류 없이 설계하기는 어렵기 때문이다. 또한 비결정적인(nondeterministic)인 프로그램 특성 때문에 테스트가 어렵고, 버그가 발생해도 재현이 힘들기 때문에 멀티쓰레드 프로그램의 디버깅은 불가능에 가깝다.

 

락을 이용한 멀티쓰레드 프로그래밍의 결정적 문제는 안정성과 규모가변성의 충돌이다. 멀티쓰레드 프로그램을 정확하고 안전하게 짜는 방법은 락을 최대한 적게 쓰는 데 있다. 동기화가 필요한 부분에 큰 단위로 락을 잡으면 복잡도가 낮아서 비교적 정확한 프로그램을 짤 수 있다. 하지만 동기화 단위가 큰 만큼 많은 쓰레드가 유용한 일을 수행하기 보다는 다른 쓰레드가 작업 수행을 끝내기를 기다리면서 대부분의 시간을 보내게 된다. 즉, 코어가 2배 4배가 되더라도 성능 향상은 미미할 가능성이 크다. 반대로 락을 잘게 쪼개서 잡으면 규모 가변성이 좋아지지만 데드락(deadlock)과 레이스 컨디션(race condition)이라는 고질적인 병폐에 부딪힐 확률이 그만큼 커진다. 빠르기만 하고 계산 값이 틀린 엉터리 프로그램이 되는 셈이다.

 

락의 문제점을 정리하면 다음과 같다.

 

1) 레이스: 락 잡는 것 잊어먹었을 때 발생한다.

2) 데드락: 락을 정해진 순서와 다르게 획득했을 때 발생한다.

3) 깨어나지 않음: 컨디션 변수를 깨우지(wakeup) 않았을 때 발생한다.

4) 에러 복구: 에러가 발생했을 때 락을 모두 해제하지 못했을 때 발생한다.

 

 

 

 

STM이란?

 

멀티쓰레드 프로그래밍의 병폐는 이미 널리 알려져 있고 이미 수많은 글들이 이런 문제를 극복하는 방법을 다루고 있다. 일례로 데드락을 방지하기 위해서는 시스템의 존재하는 모든 락에 순서를 매기고, 여러 개의 락을 획득할 때는 반드시 정해진 순서를 따르는 것이 한 방법이다. 불필요한 공유 자원 사용을 줄이고 블로킹 큐(blocking queue), 카운트다운 래치(countdown latch) 등 고수준의 유틸리티 클래스를 사용하는 것도 대책이 될 수 있다.

 

이 글에서는 락 기반 멀티쓰레드 프로그래밍의 팁 대신 멀티코어 시대에 새로운 병렬 프로그래밍 모델로 주목받고 있는 소프트웨어 트랜잭션 메모리(이하 STM)를 소개하려 한다.

 

STM은 데이터베이스에서 말하는 트랜잭션(transaction)의 개념을 프로그래밍 언어로 빌려온 것이다. DB 트랜잭션과 마찬가지로 프로그램 내에서 하나의 단위로 수행되어야 하는 일을 묶어주고 그 일을 모두 수행하거나 전혀 수행하지 않는다. 쉽게 말해서 모 아니면 도(all or nothing) 방식이다. STM에서는 중간 상태(inconsistent state)가 노출되는 문제가 존재하지 않는다.

 

이해를 돕기 위해 병렬 프로그래밍의 고전적인 예인 은행 계좌 이체를 생각해보자 계좌 A에서 돈을 빼내서 계좌 B로 이체하기 위해서는 2가지 일을 해야 한다. 1) 일단 A 계좌에서 돈을 인출하고 2) B 계좌에 돈을 넣는다. (물론 2번, 1번순으로 처리할 수도 있다.) 데이터베이스에서는 이런 상황을 ACID 모델로 해결한다. ACID의 A는 원자성(atomicity)로 1, 2는 하나의 단위로 수행되어야 함을 뜻한다. I는 독립성(isolation)으로 도중에 다른 태스크에 의해 방해받지 않음을 뜻한다. C(consistency)와 D(durability)는 저장 매체와 관련이 있다.

 

STM에 대한 이해를 돕기 위해 하스켈로 작성된 프로그램을 하나 살펴보자. 아래 하스켈 코드는 계좌 from에서 계좌 to로 amount만큼의 돈을 이체하는 간단한 함수이다. ***참고로 대부분의 개발자에게는 낯선 하스켈을 선택한 이유는 하스켈이 STM을 가장 먼저 시작한 프로그래밍 언어이고 하스켈의 강력한 타입 시스템이 STM의 장점을 잘 살려주기 때문이다.


transfer :: Account -> Account -> Int -> IO ()

-- 은행 계좌 'from'에서 'to‘로 amount만큼 이체한다.

transfer from to amount

= atomically ( do { deposit to amount

                           ; withdraw from amount } )

하스켈로 작성된 은행계좌 이체


일단 transfer :: Account -> Account -> Int -> IO ()로 표기된 부분은 transfer 함수의 타입을 선언한다. 각각의 인자는 -> 로 구분되어 있는데, transfer 함수는 첫 번째 인자와 두 번째 인자로 Account 타입을 받고, 세 번째 인자로는 Int(정수)를 받는다. 마지막 IO ()는 transfer 함수의 리턴 타입을 나타내는데 IO를 수행하고 ()를 리턴함을 의미한다. 참고로 ()는 C/C++, 자바의 void와 동일하게 리턴 값이 없을 때 사용한다.


다음은 실제로 함수를 정의하는 부분이다. transfer 함수는 from to amount라는 세 개의 인자를 받으며 amount 만큼을 to 계좌에 입금하고, amount 만큼을 from 계좌에서 인출한다. deposit to amount는 deposit이라는 함수를 to와 amount 2개의 인자로 호출한 함수 호출이다. do 명령은 하스켈에서 IO를 수행하기 위한 블록(반드시 IO만은 아니고 모나드에 적용된다.)으로 do {} 안에 명령은 순차적으로 실행된다. 따라서 일단 to 계좌에서 돈을 인출한 다음에 from 계좌에 돈을 넣게 되는 것이다.

 

여기서 주목할 부분은 atomically이다. do 블록을 감싸고 있는 atomically는 앞서 언급한 원자성(atomicity)과 독립성(isolation)을 보장해준다. atomically 안에 들어있는 STM 액션은 기본적으로 모 아니면 도 스타일로 중간 상태를 허용하지 않는다.

 

이 방식의 장점은 다음과 같다.

 

1) 락을 획득하는 순서 때문에 발생하는 데드락 문제가 사라진다.

2) 또한 에러 발생 시 복구도 자동으로 이루어진다.

3) 규모가변성 측면에서 뛰어나다. 아래 그래프는 사이먼 페이튼 존스(Simon Peyton Jones)가 2007년 OSCON에서 발표한 규모가변성 자료이다. 컴파일러와 통합한 STM의 경우 성능이 인상적이다.



하지만 다음 2가지 문제는 여전히 남아 있는 것처럼 보인다.

 

1) 명시적으로 락을 잡는 것에 비해 성능 향상이 없다.

사실이 아니다. 이 문제는 atomically를 어떻게 구현하느냐에 달려있다. 일반적으로 STM이 취하는 방식은 DB 트랜잭션이 주로 사용하는 낙관적 동시성(optimistic concurrency) 방식과 동일하다. 하스켈에서 atomically 지정된 STM 액션은 아무런 락을 잡지 않고 일단 코드를 수행한다. 코드 수행 도중에 발생하는 메모리 읽기/쓰기는 트랜잭션 로그에 남긴다. 메모리 쓰기는 메모리에 직접 수행하지 않고 로그에만 남긴다. atomically가 끝나면 트랜잭션 로그를 한 번에 커밋한다. 만약 그 사이에 다른 쓰레드가 들어와서 트랜잭션이 읽고 쓴 메모리를 변경했다면, 트랜잭션을 다시 수행한다.


2) atomically를 선언하지 않고 같은 메모리에 접근하는 경우가 생기면 레이스 컨디션의 문제가 있다.

이 문제는 락 기반 멀티쓰레드 프로그램의 고질적인 문제점이다. 락은 기본적으로 모든 참여자가 룰을 지킬 것이라는 믿음 하에 출발한다. 특정 메모리 영역에 접근하기 위해서는 특정 락을 잡겠다는 규칙을 모두가 지켜야만 레이스 컨디션이 없는 프로그램을 작성할 수 있다. 단 하나라도 이 규칙을 깨뜨리는 경우가 생기면 그대로 버그가 되기 때문이다.

 

얼핏 보면 atomically도 같은 문제점을 안고 있는 것처럼 보인다. 하나라도 atomically를 사용하지 않고 메모리에 접근하면 레이스 컨디션이 생기기 때문이다. 하스켈은 이 문제를 타입 시스템을 통해 풀었다. atomically는 얼핏 보기에 프로그래밍 언어의 고유 기능으로 보이지만 실제로는 다음 타입을 갖는 함수이다.


atomically: STM a -> IO a

atomically의 타입

 

STM 액션은 IO 액션과 마찬가지로 사이드 이펙트를 가지지만 그 범위가 훨씬 좁다. STM 액션에서 할 수 있는 일은 (TVar a) 타입의 트랜잭션 변수를 읽고 쓰는 것만 가능하다. 반대로 IO 액션에서는 IO 변수만 읽고 쓸 수 있으며 직접 트랜잭션 변수에 접근하지 못한다. IO 액션에서 트랜잭션 변수에 접근하는 유일한 방법이 atomically이다. atomically를 사용하지 않고는 트랜잭션 변수에 접근할 수 없게 타입 시스템에서 강제한 것이다.

 

 

 

STM 액션

 

앞서 atomically로 지정된 STM 액션들은 자동으로 원자성과 독립성을 보장받는다고 말했다. 이제는 실제로 STM 변수를 어떻게 사용하는지 살펴보자.


type Account = TVar Int

 

withdraw :: Account -> Int -> STM()

withdraw acc amount

     = do { bal <- readTVar acc

             ;writeTVar acc (bal - amount) }

withdraw 함수

 

위 예제는 withdraw 함수를 STM을 통해 구현한 것이다. STM 변수는 TVar(TArray, TChan, TMVar 등도 있다) 타입으로 위 예제에서는 Account를 TVar Int로 정의했다. STM 변수는 2가지 기능이 있다. readTVar는 변수의 값을 읽는 것이고, writeTVar는 변수의 값을 쓴다. 앞서 말한 것처럼 이때 STM 액션은 공유 메모리에 쓰기 전에 임시로 다른 장소에 저장을 했다가 다른 태스크의 방해 없이 수행에 성공하면 한 번에 커밋한다.


deposit :: Account -> Int -> STM()

deposit acc amount = withdraw acc ( - amount)

deposit 함수


이 두 함수를 보고 다시 앞서 transfer 함수를 떠 올려보면 STM 또 다른 장점을 볼 수 있다. 이 예제에서 withdraw와 deposit 함수는 각각 STM 액션으로 트랜잭션으로 처리할 수 있는 구조를 가지고 있다. 개발자가 transfer 함수처럼 이 두 함수를 하나의 트랜잭션으로 호출하고 싶으면 단순히 이 둘을 do {} 블록에 묶고 atomically만 붙여주면 된다. 이처럼 STM으로 작성된 루틴은 필요에 따라 얼마든지 합성이 가능하다는 장점이 있다.

 

반대로 이 두 함수를 락으로 구현했다고 생각해보자. withdraw도 락을 잡고 deposit도 락을 잡았다고 하더라도 이 둘을 합성한 transfer는 여전히 문제가 있다. 락을 잡는 순서에 따라서 데드락이 발생할 수도 있고, 별도의 락을 잡아주지 않는 이상 withdraw와 deposit 사이에는 레이스도 존재하기 때문이다. 락 기반 멀티쓰레드 프로그래밍은 작성하기도 어려울 뿐만 아니라 프로그램을 재활용하는 모듈화가 힘들다는 또 다른 숨은 문제점이 있다.

 

STM 블로킹

 

지금까지는 주로 락을 대체할 수 있는 STM 액션과 atomically에 대해서만 이야기했다. 여기서 또 하나 특정 조건이 만족될 때까지 블로킹할 수 있는 컨디션 변수의 역할을 해줄 수 있는 무엇인가가 필요하다. 이 역할은 하스켈 STM에서 retry가 맡는다.

limitedWithdraw :: Account -> Int -> STM ()

limitedWithdraw acc amount

= do { bal <- readTVar acc

                  ; if amount > 0 && amount > bal

                  then retry

                  else writeTVar acc (bal - amount) }

limitedWithdraw 함수

 

위 예제는 인출 금액이 잔고보다 많을 경우 잔고가 생길 때까지 대기하는 limitedWithdraw 함수이다. 여기서 주의 깊게 봐야할 부분은 retry이다. retry는 현재 트랜잭션을 취소하고 새로 트랜잭션을 시작하라는 의미이다. 이때 트랜잭션을 즉시 다시 시작할 수도 있겠지만 은행 잔고가 바뀔 가능성이 낮기 때문에 이렇게 폴링(polling)할 경우 굉장히 비효율적인 코드가 된다. 하스켈 STM은 if 문에서 체크한 변수(이 경우 bal)를 검사해 해당 변수가 변경되었을 때 새로 트랜잭션을 시작해준다. retry는 조건이 바뀌면 자동으로 retry가 되는 것을 보장해 주기 때문에 컨디션 변수처럼 영원히 깨어나지 않거나 잘못된 상황에서 깨어나는 버그를 걱정할 필요가 없다.

 

예제: Buffer

 

지금까지 알아본 내용을 토대로 간단한 버퍼를 만들어보자.

> module STM where

>

> import Random

> import Control.Monad

> import Control.Concurrent

> import Control.Concurrent.STM

>

> type Buffer a = TVar [a]

>

> newBuffer :: IO (Buffer a)

> newBuffer = newTVarIO []

>

> put :: Buffer a -> a -> STM ()

> put buffer item = do ls <- readTVar buffer

>                                     writeTVar buffer (ls ++ [item])

>

> get :: Buffer a -> STM a

> get buffer = do ls <- readTVar buffer

>                      case ls of

>                          [] -> retry

>                          (item:rest) -> do writeTVar buffer rest

>                                                  return item

  buffer.lhs STM을 이용한 버퍼 구현


위 코드는 여러 태스크가 동시에 사용할 수 있는 동시 버퍼(concurrent buffer)를 STM을 이용해 구현한 것이다. 여기서 newBuffer를 newTVarIO []로 선언했는데 newTVarIO는 atomically . newTVar과 동일하다. put은 STM 액션으로 버퍼의 끝에 원소를 하나 더 한다. ++는 리스트 두 개를 더해서 새로운 리스트를 만드는 함수이다. 여기서 buffer는 크기 제한이 없는 큐이므로 put은 블로킹하지 않는다.


get의 경우 리스트에서 첫 번째 원소를 빼고 나머지를 다시 저장한다. 이 때 buffer가 비어있을 경우가 있으므로 buffer가 비어있으면 retry한다. retry는 ls <- readTVar buffer의 ls에 의존적이므로 다른 태스크가 buffer에 새로운 원소를 put하는 순간에 깨어나서 다시 트랜잭션을 시도하게 된다.

 

 

맺음말

 

정리하자면 STM의 가장 큰 장점은 기존 락/컨디션 변수 기반의 멀티쓰레드 프로그래밍보다 훨씬 프로그래밍 하기가 용이하다는 데 있다. 또한 STM은 근본적으로 데드락이나 컨디션 변수의 잘못된 깨어남 등의 가능성을 차단해 주고, 코어가 늘어났을 때 규모가변성을 어느 정도 보장해준다.


STM은 아직 많은 개발자들이 사용하는 일반적인 방법은 아니다. 하지만 오랜 시간 여러 연구자들이 검증해온 아이디어이고 현재 기존 프로그래밍 언어에 접목하려는 시도가 활발히 이루어지고 있다. 특히 하스켈 커뮤니티에서 가장 먼저 그 시도가 이루어졌고, 이 글 역시 하스켈과 STM에 깊은 기여를 한 마이크로소프트 리서치의 사이먼 페이튼 존스(Simon Peyton Jones) 글과 강연에서 많은 도움을 받았다.


앞으로 다가올 멀티코어 시대를 맞을 준비는 STM으로 시작해보자.


참고문헌

[1] Beautify concurrency, Simon Peyton Jones

http://research.microsoft.com/~simonpj/papers/stm/beautiful.pdf

[2] Lock -Free Data Structures using STMs in Haskell, FLOPS'06

http://research.microsoft.com/Users/simonpj/papers/stm/lock-free-flops06.pdf

[3] Composable memory transactions, PPOPP'05

http://research.microsoft.com/Users/simonpj/papers/stm/stm.pdf

[4] stm-2.1.1.0: Software Transactional Memory

http://www.haskell.org/ghc/docs/latest/html/libraries/stm/Control-Concurrent-STM.html

CUDA

Posted 2008. 7. 10. 05:38
마소 7월 호에는 NVIDIA의 GPU 프로그래밍 모델인 CUDA에 대해 원고를 작성했습니다. GPU는 병렬 프로그래밍 있어서는 CPU 보다 앞서간 면이 있는 만큼 CUDA 프로그래밍 모델은 기본적으로 data parallel한 프로그래밍 모델입니다. OpenGL이나 DirectX의 그래픽스 API를 변용해 사용하는 고전적인 GPGPU 방식을 탈피해 programmability를 대폭 늘렸습니다. 물론, GPU는 여전히 CPU가 아니기 때문에 자연스럽지 못한 추상화(abstraction)도 여기저기 널려 있긴 합니다. CUDA에 대한 자세한 소개는 마소 7월호를 보세요 :)


[dW] Dead like COBOL

Posted 2008. 6. 30. 19:01
자바는 이제 낡고 오래된 기술이라는 이야기가 나온 게 이미 꽤 시간이 흘렀습니다. 책 이름에 "beyond Java"가 붙은 책이 베스트셀러가 되는가 하면, 자바의 대안으로 여러 스크립트 언어들이 활발히 개발되고 있습니다. IBM dW의 Dead like COBOL은 이렇게 위기에 처한 자바의 앞날에 대해 이야기하고 있습니다.

이  글에 별로 새로운 내용은 없습니다. 자바 5에서 이미 한계를 드러낸 프로그래밍 언어 자바만 보지 말고, 자바 플랫폼, 자바 가상 머신, 새로운 자바 플랫폼 언어(그루비, 스칼라 등)에 주목하라는 것입니다. 결론은 자바는 쉽게 없어지지 않을 거라는 것이죠.

저는 개인적으로 자바 가상 머신을 타겟으로 새로 개발되는 여러 언어에 관심이 많습니다. 예전에 스칼라에 대한 글을 쓴 적도 있었는데, 특히 자바와는 패러다임이 다른 함수 언어가 스크립트 언어를 JVM 위에 돌리려는 노력이 지속적으로 이루어지고 있습니다.

올해 쿼드 코어(코어 4개)를 시작으로 2년마다 코어 수가 2배씩 늘어가는 멀티코어 시대를 맞이했습니다. 과거 슈퍼 컴퓨터를 만들던 HPC(High Performance Computing) 분야 외에도 멀티코어 시대의 병렬 프로그래밍(Parallel Programming) 대한 아무런 준비가 없기 때문에 소프트웨어 개발자들은 당분간 혼돈의 시기를 살아갈 가능성이 큽니다.

하지만 대중적으로 사용되는 프로세서 중에도 이미 멀티코어 프로세서가 있는데 그 대표주자가 Cell Broadband Engine (CBE) Processor(줄여서 Cell 프로세서)입니다. PC에서는 아직 쿼드 코어도 일반화되지 않았지만 Cell 프로세서는 무려 9개의 코어를 가지고 있습니다. Cell 프로세서 프로그래밍이 어렵다고 소문이 나 있는 것도 이 때문입니다.

인텔의 코어 2 쿼드 프로세서는 4개의 동일한 코어를 가진 반면 Cell은 1개의 PPE(PowerPC Processing Element)와 8개의 SPE(Synergistic Processing Element)로 이루어져 있습니다. PPE는 PowerPC 아키텍처를 가진 메인 프로세서이고 8개의 SPE는 보조 프로세서 역할을 합니다. SPE는 각각 코드와 데이터를 저장할 수 있는 256kB의 로컬 스토어와 128비트의 레지스터를 128개 가지고 있습니다. SPE는 SIMD를 수행하는 데 최적화된 프로세서라고 보시면 됩니다.

Cell 프로그래밍은 크게 2가지 이유로 어렵습니다.

1) 메모리 모델

SPE 프로그래밍을 어렵게 하는 가장 큰 요인 중 하나는 캐쉬가 전혀 없다는 사실입니다. 로컬 스토어에 데이터를 load/store하기 위해서는 명시적으로 명령을 내려야만 합니다.

2) 병렬성

Cell 프로세서의 병렬성은 작게는 PPE와 SPE 모두 SIMD 명령을 지원한다는 점에서 나옵니다. 또한 슈퍼스칼라(superscalar)를 지원하기 때문에 한 클럭의 2개의 명령을 실행할 수 있습니다. 크게는 PPE와 8개의 SPE가 병렬로 동작합니다. PPE는 2개의 하드웨어 쓰레드를 가지고 있기 때문에 총 10개의 태스크가 동시에 수행되는 셈입니다.


멀티코어 프로그래밍에서 제일 중요한 것은 "프로그래밍 모델"입니다. 여러 코어를 효율적으로 활용할 수 있는 프로그래밍 모델을 찾는 것이 가장 큰 과제인 셈이죠. IBM dW의 Unleashing the power of the Cell Broadband Engine은 Cell 프로세서 특징과 프로그래밍 모델을 요약하고 있습니다. 어떻게 SPE를 프로그래밍해야 하는지 dW 기사가 어느 정도 이야기를 하고 있지만 쉬워 보이지는 않습니다. PPE, SPE의 특성, 메모리 모델 등을 다 고려해야만 성능이 난다고 하면 제대로 프로그래밍할 수 있는 사람은 별로 없을 테니깐요.

블로그 순위?

Posted 2008. 5. 25. 14:25
고감자 님이 HanRSS 구독수를 기반으로 블로그 순위를 뽑으셨네요. 믿기지 않게도 제 블로그가 국내에서 58위에 랭크되어 있습니다. 일일 방문자 수도 많지 않고 글마다 리플도 거의 없는 (아 무플의 슬픔ㅠ.ㅠ) 제 블로그가 이렇게 높은 순위를 기록하고 있다는 사실이 조금 의아합니다.

물론 HanRSS 구독수만 따지면 지금 이 순간 구독하시는 분이 무려 743분이시니 절대 적은 수는 아니지만 고정 독자 수에 비해서 너무 의견이 없어서 항상 의아하게 생각했습니다.

물론 글 내용이 프로그래밍 언어와 컴파일러, 아키텍처 등 특정 분야에 상당히 치우친 것은 사실입니다. 그래도 나름 관심을 가진 분야라 고정 구독하시는 분들일텐데, 대부분의 글에 별 반응이 없어서 사실 아무도 안 읽는 듯한 느낌이 들 정도거든요.

제 스스로가 다른 블로그에 글을 남기거나 의견을 교류하는 데 적극적인 편은 아니라 사실 할 말은 없습니다. 스스로를 블로거라고 생각해 본적도 없고, 내가 글을 쓰는 행위가 블로깅이라고도 생각해 본적도 없긴 하지만, 그래도 활발한 피드백을 받는 블로거 분들을 보면 가끔 부럽긴 합니다.

그리고 보니 간만에 쓴 비기술 글이네요.


업데이트: 많은 분들이 댓글을 남겨주셨네요. 앞으로 더욱 열심히 글을 쓰도록 하겠습니다. 감사감사.

Java Virtual Machine (JVM)과의 인연

Posted 2008. 5. 22. 04:42
저는 JVM에 관련된 글을 종종 쓰는 편입니다. 마소에서 자주 우려먹은 주제 역시 JVM 혹은 자바 내부 구현 쪽이었습니다. 물론 더 이상 JVM 관련 실무를 하지 않으면서 지식의 한계를 느껴서 지금은 프로그래밍 언어로 관심사가 많이 넘어갔습니다.

사실 특례하기 전에는 프로그래밍 언어, 컴퓨터 아키텍처, VM 등에 별로 관심이 없었습니다. 하지만 우연한 기회에 특례로 입사한 회사에서 JVM 관련 일을 하게 되었습니다. 국내에서는 드물게 CDC 급(핸드폰에 들어가는 CLDC급 VM과 달리 J2SE 스펙과 동일한 임베디드용 JVM) 소스 코드를 열심히 볼 기회를 가졌습니다.

JVM 하면서 논문 읽는 법을 배웠고, 복잡한 소프트웨어를 작성하는 방법도 배웠고, 시스템 프로그래밍의 기초를 다졌고, 프로그래밍 언어에 대한 관심을 가지게 되었으니 JVM 명세와 코드는 지금 제가 개발자로 살아가는 데 막대한 영향을 끼친 것 같습니다.

JVM한 덕분에 작년 초에는 삼성소프트웨어멤버쉽(SSM)에서 자바가상머신을 주제로 이틀 간 강의도 했습니다. 준비 기간에 비해서 강의 시간이 너무 많아서(무려 16시간) 무척 힘들었는데 (제 목소리에 졸았을 학생들에게 죄송) 그래도 매우 가치 있는 경험이었습니다.

지금은 예전에 비해서 무척 다양한 일을 하고 있어서 예전만큼 깊이 있게 논문이나 코드를 들여다보고 있지 못해서 조금 답답한 마음도 있습니다. VM이나 코어에 가까운 일을 업으로 삼는 행복을 느낄 수 있는 곳이 많지 않다는 사실도 알게 되었고요. (적어도 국내에서는요)

JVM 관련해서 깊이 있게 보시는 분이 계시면 같이 이야기할 수 있는 자리를 만들어 보는 것도 좋을 것 같다는 생각이 듭니다. 제 블로그는 의견 남겨주시는 분이 거의 없는데, 관심 있으신 분은 적극적으로 의견 좀 남겨주시길.


Method inlining and deoptimization

Posted 2008. 5. 22. 04:18
axis님이 HotSpot Server JVM의 성능 이야기를 하면서 언급하신 공격적인 method inlining과 deoptimization에 대한 이야기를 하려 합니다. 관련 내용은 The Java HotSpot Performance Engine: Method Inlining Example에 이미 잘 정리되어 있습니다.

간단히 정리하면, HotSpot JVM은 final로 선언되어서 서브클래싱될 수 없는 메서드 뿐만 아니라 일반 메서드도 공격적으로 inlining 했다가 서브클래스가 런타임에 로드되면 이를 확인해서 inlining 된 메서드를 도로 푸는(deoptimize)하는 방법을 사용합니다.

inlining의 장점

1) 메서드 콜 오베헤드가 없다
2) dynamic dispatch 오버헤드가 없다
3) constantn folding이 추가로 가능하다. 예를 들어, a.foo() + 2가 있을 때 a.foo()가 3으로 inling되면 3 + 2 = 5로 constant folding이 가능하기 때문이다.


런타임에 컴파일하기 때문에 최적화가 가능한 상황을 표시해두고 최적화한 다음에, 그 가정이 깨어지면 최적화를 푸는 방법이 인상적입니다. 컴파일 타임에 모든 결정을 해야 하는 정적인 컴파일러는 할 수 없는 최적화 방법이기도 합니다.

Functional Programming Creeps into Job Specs는 미국에서 개발자 채용할 때 함수 언어 구사자를 찾는 경우가 조금씩 늘고 있다는 소식입니다. 함수 언어인 Haskell, Ocaml, F#, Erlang, Scheme, Lisp 등이 구인 광고에 이름을 올리기 시작했습니다.

Is Functional Programming the new Python? 은 이런 현상에 대한 논평인데, 기업이 함수 언어 사용자를 찾기 시작한 이유는 2000년 경우 파이썬 개발자를 채용한 것과 같은 심리라는 지적입니다. 즉, 돈 안 되는 이상한 언어를 구사자를 찾을수록 실력 있는 개발자를 뽑을 확률이 높다는 이야깁니다. 함수 언어를 먹고 살기 위해 배우는 언어가 아니라 열의를 가지고 공부해야만 알 수 있는 언어이기 때문에, 함수 언어 구사자를 찾으면 자연적으로 다른 언어 구사자에 비해 실력 좋은 개발자를 뽑을 가능성이 커집니다.

수 십년을 동굴 속에 갖혀 지낸 함수 언어가 멀티 코어, 분산 처리, 결함 허용 등의 키워드와 맞물려 세상으로 나오려고 힘쓰고 있습니다.



까멜레오 소식

Posted 2008. 5. 14. 13:40
까멜레오는 얼마 전에 살짝 윈도 바이너리를 공개해놨는데 아직 베타 테스트 단계(이제 첫 릴리즈를 했음)라 널리 홍보는 하지 않고 있었습니다. 현재 성능 개선 및 안정화 작업으로 한참 바쁘게 보내고 있었습니다.

그런데 오늘 까멜레오 소식이 동시에 여러 곳에서 들어왔습니다. 일단 downloadsquad에서 Chameleo: Open source video player from Korea라는 제목으로 까멜레오 소개 기사가 올라왔습니다. 한국에서 하는 오픈소스 비디오 플레이어라는 제목이 마음에 드네요. 기사 내용은 간략합니다. 까멜레오가 있고, 여러 코덱을 지원하고 Joost나 Babelgum처럼 웹 비디오를 지원하지는 않지만 비트토런트 클라이언트를 내장하고 있다.

그리고 얼마 전에 GigaOM의 LIz Gannes와 까멜레오에 관해 전화 인터뷰를 했었는데, 관련 기사가 오늘 newteevee에 올라왔습니다. 제목은  Chameleo:New Open-Source Video Player입니다.  까멜레오 플레이어는 플러그인을 비롯한 확장성에 초점을 맞추고 있다고 비교적 정확하게 기사를 작성해 주었습니다. (전화 인터뷰에서 강조했습니다^^)  기사 말미에 제가 인터뷰하면서 했던 말도 나옵니다.


NomadConnection CTO Kwang Yul Seo told NewTeeVee that his company plans to release a Linux version of Chameleo this week and a Mac version in June.


일단 기사 내용에도 언급되었지만 까멜레오는 멀티플랫폼을 지원합니다. 윈도 바이너리는 이미 공개되어 있고, 지난주와 이번주에 리눅스 포팅 작업을 어느 정도 마무리 지었습니다. 이번주 금요일에 릴리즈할 0.1.3은 윈도 뿐만 아니라 리눅스 포트도 함께 릴리즈할 예정입니다. 맥 포팅은 계획되어 있는데, 늦어도 6월 말 전에는 릴리즈를 하려고 생각하고 있습니다.

더불어 웹초보님께서 저희보다 더 빠르게 downloadsquad의 글을 발견하고 까멜레오: 한국의 오픈소스 비디오 플레이어라는 제목으로 사용 후기까지 남겨주셨네요. 까멜레오의 장점과 단점을 예리하게 지적해 주셨습니다.  일단 툴팁이 없어서 메뉴 사용이 불편하다는 점은 인지하고 있었고 다음 버전에는 툴팁이 들어갈 예정입니다.



-- 여기부터는 개발자만 읽으세요  --

newteevee 기사 중에 보면 리소스를 좀 많이 먹는다는 지적이 있는데, 지난번에 릴리즈하고 나서 이 문제를 해결하는 데 많은 시간을 보내고 있습니다. valgrind 도구 중 하나인 callgrind도 프로파일링 해본 결과 YUV 비디오를 OpenGL 텍스터로 올리기 위해 RGB로 변환하는 gst-ffmpegcolorspace에서 CPU를 30% 이상 차지하고 있다는 사실을 발견했습니다.

현재 OpenGL 셰이더를 이용해  YUV to RGB 변환하는 부분과 SIMD 명령셋인 MMX, SSE, SSE2 등을 이용해 칼라 스페이스 변환하는 부분 코드를 작성하고 있는 중입니다. 이 문제가 해결되면 성능 문제는 어느 정도 해결될 것으로 보입니다.

이번주 금요일에 까멜레오 2번째 릴리즈인 0.1.3 릴리즈를 예정하고 있습니다. 아직 베타 릴리즈인 만큼 따뜻한 사랑과 애정으로 지켜봐주세요. 개발자 입장에서 프로그램은 자식 같아서 어디가서 안 좋은 소리 듣고 오면 가슴이 참 아프답니다. 비굴비굴. (__)


Virtualization의 종류

Posted 2008. 5. 13. 04:17
요즘 자주 듣는 단어 중에 하나가 가상화(virtualization) 기술입니다. 보통 운영체제 위에 또 다른 운영체제를 설치할 수 있게 해주는 VMware나 Parallels Desktop 등 통해 익숙해졌으리라 생각합니다.

가상화는 생각보다 넓은 범주의 기술을 의미하는데, 크게 다음 3가지로 나눠 볼 수 있습니다.

1) 머신-수준 가상화(Machine-Level Virtualization) : 이 범주에 속하는 대표적인 프로그램이 VMware, Parallels Desktop, Virtual Box 등입니다.

2) 시스템-수준 가상화(System-Level Virtualization) : Paravirtualilzation이라고도 불리며 Xen이 대표적입니다.

3) 언어-수준 가상화(Language-Level Virtualization) : 프로그래밍 언어 수준의 가상 머신을 의미하며 JVM과 .NET이 대표적인 언어 수준 가상화입니다.


저는 언어 수준 가상화만 관심 가지고 보고 있었는데, 요즘은 조금 더 밑으로 내려가도 재미있는 이슈가 많은 것 같다는 생각이 듭니다.

Dynamic Profiling

Posted 2008. 5. 13. 04:14
JIT를 제공하는 Java Server VM이 정적으로 컴파일된 C/C++ 수준 혹은 그 이상의 성능을 보일 수 있는 중요한 이유 중에 하나는 런타임에 수집한 데이터를 가지고 컴파일을 최적화하기 때문입니다.

다음 코드를 예로 봅시다.

for (i = 0 ; i < 10; i++) {
    if (cond) {
        g();
    } else {
        h():
    }
}

루프 안의 g 혹은 h 함수를 inline 할지 말지 결정하려면 cond이 true일 확률이 높은지 false인 확률이 높은지 알 수 있어야 합니다. 만약 cond가 거의 항상 true라면 g를 inline하고 h는 그냥 두어야 할 것입니다. 하지만 런타임 정보가 없는 C/C++ 컴파일러은 이런 최적화를 수행하기는 힘듭니다.

이런 한계 때문에 요즘은 일반적인 컴파일러들도 런타임 정보를 활용해 추가적인 최적화를 하기도 합니다. 일례로 인텔 컴파일러는 바이너리가 동적 프로파일링(dynamic profiling)을 수행하도록 한 번 컴파일한 후에, 이를 실행해 얻은 프로파일링 데이터를 바탕으로 다시 한 번 컴파일해 최적화된 코드를 생성할 수도 있습니다.


« PREV : 1 : 2 : 3 : 4 : ··· : 13 : NEXT »