기본 타입 간 (무분별한) 변환은 자제합시다

개발 2011.05.14 01:59

 요새 하는 일 중 상당 부분이 대개 남이 짜놓은 코드에서 발생하는 버그들을 잡는 것인데, 이 버그들을 보다보면 여러 가지 생각을 하게 된다. 전생에 내가 뭘 잘못했길래 ... 같은 부류의. 내가 폭력적인 싸이코패스였다면 좀 달라졌을까? (...)


 이 중 상당수는 아주 기초적인 부분들을 신경쓰지 않아서 발생하는 버그다. 코드를 작성하기 시작할 때 최소한의 원칙을 세우고 거기에 대해 약간만 신경을 써주면 애초에 발생하지 않았을 버그들이지만, 그 시점에 치르는 약간의 비용이 아까워서/혹은 아예 그런 생각 자체를 못해서 차후에 1주~1달 가까이를 무의미하게 소모하게 된 것이다. 특히나 해당 코드를 작성한 사람이 아니라 대개 다른 사람이 삽질을 해야 한다는 점에서 질이 아주 안 좋다고 볼 수 있다. -_-


 아래에서 드는 몇 가지 사례는 프로그래밍에 있어서 기본 중 기본이라고 할 수 있는 기본 형 변환조차 제대로 다루지 못하여 발생한 버그 혹은 문제들이다.



  • signed/unsigned 정수형 간의 경계 없는 사용

 C/C++은 signed/unsigned 형을 구분하여 사용하며, 표준 라이브러리에서도 size_t, ptrdiff_t 등의 형태로 이 둘 모두를 사용하기 때문에 언어를 사용하는 입장에서 "signed 형만 골라 쓴다" 식의 고유한 정책을 취하기는 쉽지 않다. 게다가 이에 관련된 형 변환 정책은 직관적이지 못하고[각주:1], 자칫 잘못하면 off-by-one 에러와 함께 엮여 무한 루프를 만들어 내는 지옥도를 보기 십상이라 이는 언어 설계의 미스로 자주 지적된다.


 signed/unsigned 정수형 간의 비교 연산으로 인한 버그는 좀 식상한 이야기이니 다른 케이스를 이야기해보자. unsigned 정수형은 추상대수학에서 말하는 기본적인 환 ring 의 일종으로 덧셈, 곱셈이 수학적으로 잘 정의 well-define 된다. 이러한 수학적인 우아함 때문에 현존하는 거의 모든 아키텍쳐에서는 연산 과정에 오버플로가 일어나도 해당 결과에 UINT_MAX로 나머지 연산을 한 값이 나오도록 정의하며, C 표준도 그러하다.


 다만 signed 정수형에서 발생하는 overflow의 경우 C 언어 표준에 따르면 그 처리 방법은 미정의이다. 허나 2의 보수 체계를 택하는 대개의 아키텍쳐에서 signed 정수형과 unsigned 정수형의 차이란 비트값을 정수로 변환할 때 사용하는 다항식의 최상단 계수가 음수냐 양수냐 밖에 없으므로 회로를 간단하게 만들기 위해 이 둘 사이의 덧셈과 곱셈은 호환되게 만드는 경우가 많다. 그런 관계로 컴퓨터 구조를 어설프게 알고 있는 경우 이 둘 사이를 마음대로 오가도 문제 없다고 생각하는 경우가 있다.


 헌데 나눗셈과 나머지 연산이 나오면 이야기가 좀 다르다. signed와 unsigned에 적용되는 연산이 완전히 다른 것이다. 여기에 동일한 크기의 정수형 사이의 signedness 캐스팅에는 signed->unsigned 캐스팅이 이루어진다는 기본적인 캐스팅 원칙이 함께 적용되면 프로그래머 입장에서 어떤 연산이 적용될지 한 눈에 파악하기가 어려워진다.



 일례를 들어보겠다. 어떤 코드에서는 시점을 나타내기 위한 타입으로 아래와 같이 unsigned int를 사용한다고 치자.


typedef unsigned int TimeType;

 일반적으로 시점은 음의 값을 가질 이유가 없으며, timeGetTime 등을 사용하여 시간 값을 가져오는 경우 표현 가능한 기간에 현실적인 수준의 제약이 있는 점 등을 감안하면 이는 어느 정도 합리적인 선택이다. 그런데 여기에서 모종의 이유로 인해 시차를 표현하기 위한 용도로도 저 타입을 함께 사용하기로 했다. 게임으로 치자면 쿨다운이라거나 마법의 시전 시간 등도 포함될 것이다. 일반적인 경우라면 이 역시 대개 양의 값을 가질테니 큰 문제는 없을터다.


const TimeType Cooldown::getAppliedCooldownTime()
{
	return baseCooldown_ - cooldownBonus_/divisionFactor_;
}

 원래는 쿨다운 보너스는 양수 값만 가지고, 나누기 계수는 그 때 그 때 다른 값을 가진다고 치자. 그런데 어느 날 이걸 페널티로도 써보겠다는 멋진 생각 아래 기획에서 보너스 값에 음수 값을 넣기로 하였다고 치자. 코드에 대해 잘 모르는 경우라면 충분히 있을 법 한 일이다. 여기에 나누기 계수로 2를 넣는다면 저 함수의 반환 값은 대략 21억 가량이 나올 것이다. 대충 짠 코드 하나 덕분에 스킬 하나의 쿨다운 시간이 몇 개월이 되어 버린 멋진 결과다.


 unsigned와 signed를 섞어 쓰는 코드의 악랄함은 unsigned 정수형의 수학적인 우아함과 더불어 이에 완전히 호환되는 2의 보수 모델 덕분에 어지간하면 큰 문제 없이 잘 돌아 간다는 것에 있다. 그래서 C4018 같은 경고가 나와도 그냥 무시하는 코드도 많고, 경고조차 안 나오는 연산 코드에 대해서는 그 위험성조차 인지하지 못하는 사람들이 대부분이다. 이런 걸 당연하게 여기기 시작하면 이런 문제가 발생해도 잡아내기가 무척 힘들다.



  • 부정확한 부동 소수점을 정수형으로 캐스팅함으로 생기는 오차

 정수와 부동 소수점 사이를 오가는 코드 역시 주의 대상인데, 이런 코드에는 세 가지 문제점이 있다.



 하나는 정밀도 문제다. 부동 소수점은 정확한 수가 아닌 근사값이다. 특히나 십진법을 사용하는 입장에서 자주 사용하게 되는 수 대부분은 부동 소수점으로 정확한 값을 표현할 수 없다. 이런 근본적인 문제와 더불어 부동소수점을 정수형으로 캐스팅하는 연산이 단순한 절삭 연산이라는 점 때문에 0.00001 가량의 작은 오차가 1로 확 벌어지는 경우가 발생할 수 있다. 이를테면,


int value = 1000 * 1.7 * 1.14;

 정상적인 계산이라면 위의 값은 당연히 1938이 나와야 한다. 그런데 내 컴퓨터에서 돌리면 1937이 나온다. 사실 이건 양반이다. 심지어는 비트 레벨에서 완전히 동일한 double 두 값을 뺐는데도 0이 안 나오는 경우도 있을 수 있다. 대부분의 컴파일러에는 이런 비일관성을 줄이는 옵션도 있지만, 이는 프로그램의 속도에 악영향을 준다.



 더 큰 문제는 다른 컴퓨터/플랫폼에서도 항상 똑같은 결과가 나온다는 보장이 없다는 것이다. 이는 부동소수점 연산에는 여러 가지 방식이 있기 때문인데, 이로 인해 부동소수점 연산은 비결정적인 non-deterministic 특성을 띄게 된다. lockstep 류의 네트워크 동기화를 사용하거나 로직이 재연 가능해야 하는 경우에는 이렇게 작은 차이도 치명적이라 floating point determinism을 확보하기 위해 벼라별 삽질을 다 하는 마당에, 이런 작은 오차가 정수형 연산까지 번져 버리면 곤란하다.



  • 타입 간 표현 가능한 수치 범위의 차이로 인한 오버플로 문제

 또 하나의 문제는 타입 간 캐스팅으로 인해 생기는 오버플로다. 정수형끼리의 캐스팅을 다루는 경우는 대개 해당 타입들의 범위를 잘 알고 있다. 그렇기에 오버플로가 일어날 가능성이 아무래도 더 명백하게 드러나서 대부분 크게 문제가 되지는 않는다. 보통 이 경우는 연산으로 인한 오버플로가 더 문제다.


 그런데 부동소수점 실수형의 범위는 대부분 아주 큰 값, 심지어는 무한대로도 생각하는 경향이 있어서 오버플로가 발생할 가능성에 대해서는 아무래도 둔감하다. 실제로 발생하는 경우도 극히 드믈다. 그런데 유의해야 하는 경우가 있으니 바로 실수가 정수형 변수로 캐스팅되는 경우다. 예를 들어 소수점 이하 2자리를 정수형으로 반환하려는 의도의 아래 코드를 보자. (양수만 입력된다고 가정하자.)


const int32_t getFractional2(const float positiveInput)
{
    return (positiveInput - static_cast<int32_t>(positiveInput)) * 100.0f;
}

 아주 간단한 코드지만 버그를 가지고 있다. positiveInput이 만약 int32_t의 범위를 넘어선다면 이 코드의 결과는 미정의다. 해당 캐스팅이 어떤 결과를 가져올지는 플랫폼마다 다르겠지만, 내가 사용하는 플랫폼에서는 0x80000000가 나온다. 이 문제를 해결하려면 내림을 위해 int 캐스팅을 사용할 게 아니라 아래와 같이 floor 함수를 사용해야 한다. 이 경우 괄호 안의 표현식은 0.0 ~ 1.0 사이의 값을 가지게 되므로 오버플로가 일어나지 않는다.


const int32_t getFractional2(const float positiveInput)
{
    return (positiveInput - std::floor(positiveInput)) * 100.0f;
}

 사실 이 코드는 정밀도 한계 때문에 입력이 천만만 넘어가도 0만 반환하므로 별 의미 없는 문제일지도 모른다. 하지만 함수의 스펙대로 0~100 사이의 값이 반환되는 것과 예측 불가의 미정의된 값이 반환되는 것은 완전히 다른 이야기다. 입력의 범위를 확신할 수 있는 상황이 아니라면 가급적 오버플로가 일어나지 않게 방어적으로 코드를 짜는게 맞다. 산술 연산으로 인해 생기는 오버플로는 잡아내기 힘들지만, 캐스팅으로 인해 생기는 오버플로는 비교적 잡아내기 쉬우니 그리 어려운 일도 아니다. 단지 귀찮을 뿐.



  • 정수 - 실수 간 캐스팅 오버헤드

 이건 버그는 아니지만 한번 짚고 넘어갈 만한 부분이다. 정수형/실수형을 택할 수 있는 상황에서 속도 문제 때문에 정수형을 고집하는 사람들이 의외로 많다. 정수형 계산이 실수형 계산에 비해 빠른 것은 사실이다. 하지만 여러 분야에 있어 부동 소수점 연산 속도는 매우 중요하기 때문에 구조적으로 많은 최적화가 이루어졌고, 대부분의 프로세서는 부동 소수점 연산만을 위해 물리적/논리적으로 많은 자원을 할당해둔다. 이런 배려 덕분에 나눗셈 연산 정도를 제외하면 정수 연산과 부동소수점 연산의 속도 차이는 생각만큼 크지 않다.


 이를 위해 희생한 부분 중 하나가 있으니 바로 정수/실수 사이의 캐스팅에서 발생하는 오버헤드다. x86을 포함한 상당수의 아키텍쳐에서는 간결한 구조를 위해 FPU를 독립적으로 다루며, 레지스터 역시 정수형과 부동소수점형이 따로 분리되어 관리된다. 즉, 정수형과 실수형 사이의 캐스팅이 이루어진다는 것은 레지스터에 있는 정수형 레지스터에 저장된 값을 메모리로 빼놨다가 다시 실수형 레지스터로 옮겨가는 과정을 수반한다는 이야기다. 캐스팅 연산 자체의 비용은 그리 크지 않더라도 이런 메모리 연산 과정이 수반되기 때문에 가급적 피하는게 좋다.


 이런 구조를 모른다면 최적화라는 명분 하에 멀쩡한 float형 변수들을 전부 int로 바꾸고 여기에다 실수 상수들을 곱하고 나누는 삽질을 하는 것도 충분히 가능한 이야기이고 또 자주 일어나는 일이다. 어설픈 프로그래머보단 컴파일러가 미세 최적화는 훨씬 잘하니 아예 손을 안 대는게 낫다.



 이론은 이러한데, 아래의 코드를 가지고 실제로도 그런가를 확인해보았다.


const int ArraySize = 10000000;
const int IterationCount = 20;

int    cast[ArraySize];
float pureFloat[ArraySize];
int    pureInt[ArraySize];

#pragma optimize("", off)
void integerCast()
{
    for (int j = 0; j < IterationCount; j++) {
        for (int i = 0; i < ArraySize; i++) {
            cast[i] *= 1.1f;
        }
    }
}

void pureInteger()
{
    for (int j = 0; j < IterationCount; j++) {
        for (int i = 0; i < ArraySize; i++) {
            pureInt[i] *= 11;
        }
    }
}

void pureFloatingPoint()
{
    for (int j = 0; j < IterationCount; j++) {
        for (int i = 0; i < ArraySize; i++) {
            pureFloat[i] *= 1.1f;
        }
    }
}
#pragma optimize("", on)

 컴퓨터마다 그 결과가 다르겠지만, 내 경우는 순수 실수 사이의 연산이 정수와 실수를 섞은 경우보다 1.7 ~ 1.8배 가량 빨랐고, 순수 실수와 순수 정수 연산 사이에서도 큰 수준의 차이는 없었다. 루프에 들어가는 오버헤드나 pragma 때문에 놓친 공격적인 최적화의 기회까지 감안하면 실제론 이보다 더 크게 차이난다고 봐도 될 것이다. 물론 저렇게 무식하게 곱연산만 하는 경우는 없겠지만, 적어도 성능 때문에 정수형을 쓸 필요는 없다는 근거 정도는 되리라 생각한다. 부동소수점을 써선 안 되는 경우는 정밀도 문제가 엮여 있거나 연산의 일관성이 필요한 경우지, 적어도 높은 성능이 필요한 경우는 아니다.



 마지막으로 이런 문제점들이 모이고 모여 총체적 난국을 만들어내는 코드 하나를 살펴보도록 하자. 위에 나온 예시와 같이 시간을 표현하기 위해 unsigned 정수형을 사용하는 코드다.


...

    TimeType baseCastingTime_;
    TimeType castingTimePerLevel_;
    float castingTimeFactor_;

...

const TimeType Casting::getCastingTime(const int32_t skillLevel)
{
    return baseCastingTime_ * castingTimeFactor_ +
           castingTimePerLevel_ * skillLevel;
}

...

TimeType castingTime = casting.getCastingTime(skillLevel);

...

 baseCastingTime_이 300, castingTimePerLevel_이 -50, castingTimeFactor_가 1.0f, skillLevel이 3이라고 치자. 그럼 어떤 값이 나올까? signedness를 떠나 상식적으로 생각해보면 당연히 150이 나와야 한다. 그리고 실제로 돌려보니 150이 나왔다. 문제 해결! ... 이면 여기에 이렇게 글을 썼을리가 없다. -_-


 x86, 윈도우즈 플랫폼에서 위의 코드를 수행하면 확실히 150이 나온다. 그런데 x64로 컴파일해서 돌리면 0이 나온다! 어셈도 안 쓰고 플랫폼 의존적인 함수도 없이 순수 C++ 표현식만 썼음에도 플랫폼마다 다른 결과가 나오는 대단한 코드를 만들어 버린 것이다. -_-



 이런 결과가 나오는 까닭은 이렇다. (1) castingTimeFactor와 baseCastingTime을 곱하면 암묵적으로 float 캐스팅이 이루어진다. (2) 그리고 castingTimePerLevel과 skillLevel을 곱하면 역시 암묵적으로 TimeType으로 캐스팅된다. (3) 최종적으로 이 둘을 더한 결과는 float이고, 함수는 이를 다시 TimeType으로 캐스팅하여 반환한다.


 문제는 2번과 3번이다. 2번에서는 약 42억 가량의 unsigned 값이 들어가고, 3번에서 둘을 더한 float 값 역시도 42억 가량의 값이 들어가게 된다. 이 때 부동소수점 정밀도 문제로 인해 아래 9비트 만큼의 정보가 잘려 나가면서 정확히 2의 32승을 가리키는 값이 되어버리고, 이 값이 unsigned 정수로 캐스팅되면 0이 된다. 음수가 unsigned 정수형에 들어갈 때 내부적인 표현이 부동소수점 정밀도 문제와 겹치면서 문제가 발생한 것이다.



 그렇다면 의문점이 남는다. 왜 32비트에서는 제대로 된 값이 나왔나? 생성된 어셈 코드를 보면 32비트에서는 계산된 FPU 레지스터의 결과값(3)을 바로 정수형으로 치환해서 메모리에 저장하는 코드가 생성된다. 여기에서 한 가지 알아둬야 할 점은 x86 아키텍쳐의 FPU 내부에서 이루어지는 연산은 기본적으로 80비트 부동소수점을 다룬다는 점이고, 이 레지스터에 저장되는 값은 32비트 정수를 손실 없이 저장할 수 있다. 여기에 80비트->32비트 부동소수점 변환 과정이 생략되고 바로 정수형으로 캐스팅되므로 저렇게 올바른 값이 나오는 것이다. 실제로 float 형으로 한 번 지역 변수에 저장했다가 반환하면 64비트처럼 0이 반환된다.


 그런데 64비트는 MMX 명령어를 이용하여 부동소수점 연산을 수행하는 코드를 만들어 냈다. 이게 내부적으로 어떤 과정을 거쳐 수행되는지는 모르겠으나, 해당 명령어들이 수행되는 과정들을 관찰해보면 32비트와는 다르게 별다른 변환 과정 없이 32비트 부동소수점만을 이용하여 계산이 이루어지는 것을 볼 수 있다. 어떻게 보자면 이 쪽이 좀 더 일관성 있는 동작을 한다고 볼 수도 있다.



 결론은 간단하다.


  • 기본형 간의 불필요한 캐스팅이 일어나는 상황은 최대한 줄이도록 한다.
  • 연산은 가급적 같은 타입의 변수끼리 하도록 한다. 즉, 암묵적인 캐스팅을 자제한다.
  • 타입 간의 경계에서 일어나는 캐스팅은 정밀도나 범위등을 고려하여 주의 깊게 수행하도록 한다.

 위의 사례를 보면 알 수 있겠지만 unsigned형과 더불어 정수와 실수형을 마구 섞어쓰면 아주 사소해보이는 표현식 수행을 이해하는 일도 아주 피곤한 일이 되어버린다. 생각해보면 해결해야 할 문제의 본질과 전혀 상관 없는 저런 지식들을 프로그래머가 일일히 알아야 할 이유는 전혀 없다. 하지만 C/C++가 애초부터 잘못 설계된 언어인 관계로 어쩔 수 없다. 가급적이면 저런 상황 자체를 안 만드는 게 차선책이다.

  1. 심플하긴 하다. 크기가 같으면 signed를 unsigned로 캐스팅하며 다르면 더 큰 signed 형으로 캐스팅한다. 다만 언제는 signed로, 언제는 unsigned로 캐스팅하는 비일관성이 문제다. [본문으로]

'개발' 카테고리의 다른 글

NDC 간략한 참관기 #2  (1) 2011.06.12
NDC 간략한 참관기 #1  (0) 2011.06.04
기본 타입 간 (무분별한) 변환은 자제합시다  (3) 2011.05.14
Visual Studio는 C/C++을 싫어해 (?)  (3) 2011.02.27
Bitwise Switch  (2) 2011.02.19
std::unique_ptr  (0) 2010.04.26
Trackback 0 : Comments 3

마법소녀 마도카 마기카 - 10화까지의 감상

감상 2011.03.20 16:55
 이번 시즌의 화제작 마마마가 덕후 커뮤니티를 넘어서 일반인 커뮤니티까지 범람하는 모습을 보고 호기심에 한번 볼까 싶어 봤다. 근데 몇 년간 묻어 두었던 내 마음 속 덕후 본능을 자극해버림. (...) 덕분에 웹 서핑에 위키질하느라 그간의 개인적인 모든 작업들이 일시 정지... 여기에다 11화의 방영이 미뤄지는 바람에 마음 속 공허함을 달래기 위해 블로그에 글까지 투척하기에 이르렀다. orz

 사실 이 작품이 화제가 된 건 마법소녀물이라는 장르의 클리셰를 뒤트는 정도가 아니라 전부 갈아 엎어버리면서 -_-; 파격을 가져온 것에 기인하지만 이거야 화제를 불러 일으켰을 뿐, 이 애니의 진가는 그런 파격이 아니라 시나리오나 연출 같이 영상물의 가장 기본적인 미덕에서 온다고 생각한다. 그렇지 않고서야 반짝 인기를 끄는 정도에서 머물렀을테고.

 우선 몇 년간 대세였던 모에와는 달리 캐릭터에 크게 의존하지 않는 점도 좋고, (그렇다고 캐릭터가 약하냐 하면 또 그것도 아니고.) 또 탄탄한 설정에 기반해서 복잡한 플롯, 짜임새 있는 구조를 능숙하게 풀어나가는 시나리오부터 화면에 쏟아져 내리는 각종 메타포나 기호 등의 연출 등은 제작진이 설정 덕후들로 하여금 뛰놀라고 깔아준 멍석이 틀림없다. 멍석까지 깔아줬는데 그 위에서 뛰놀지 않는다는 것은 분명 예의가 아닐 것이다. -_-;
 

 이하 개인적인 잡상들. 1~10화에 대한 치명적인 수준의 스포일러가 잔뜩 있으니 혹여나 추후 시청하실 분들은 스킵하시길 바란다. 혹은 덕후 스타일의 장황한 감상문을 거슬려 하시는 분들 역시 비추천이다. (..)



더보기

 

 보면서 하고 싶은 얘기들을 써놓으니 좀 속이 후련하다. 이제야 다른 작업을 시작해 볼 수 있을 것 같다. 근데 24일날 11화가 방영되면 다시 삽질을 시작할 게 뻔하잖아? 아마 안 될거야 (...)
 

'감상' 카테고리의 다른 글

슈퍼 마리오 3D 랜드  (0) 2011.12.15
마법소녀 마도카 마기카 - 10화까지의 감상  (0) 2011.03.20
슈퍼 마리오 갤럭시 2  (0) 2011.02.12
소셜 네트워크  (0) 2010.12.07
서태지 8집을 돌이켜보며  (2) 2010.12.02
기계식 키보드 이용기  (0) 2010.11.27
Trackback 0 : Comment 0

티스토리 툴바