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





































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