'개발'에 해당되는 글 10건

  1. 2011.06.19 NDC 간략한 참관기 #3 (4)
  2. 2011.06.12 NDC 간략한 참관기 #2 (1)
  3. 2011.06.04 NDC 간략한 참관기 #1
  4. 2011.05.14 기본 타입 간 (무분별한) 변환은 자제합시다 (3)
  5. 2011.02.27 Visual Studio는 C/C++을 싫어해 (?) (3)
  6. 2011.02.19 Bitwise Switch (2)
  7. 2010.04.26 std::unique_ptr
  8. 2010.04.26 RVO와 NRVO
  9. 2009.11.11 volatile과 메모리 배리어 (2)
  10. 2009.11.07 C/C++의 몇 가지 키워드들 (1)

NDC 간략한 참관기 #3

개발 2011.06.19 02:52


 4일차 참관기. 갈수록 참관기를 빙자한 자기 주장이 되어가고 있다. (...) 카메라 시스템쪽 세션도 좋았는데, 발표 자료가 올라오지 않는데다 들은지 2주가 넘어가서 기억이 가물 가물하다. orz 젭알 올려주셉습셉...



 - 온라인 게임 처음부터 끝까지 동적언어로 만들기 (이승재 선임연구원 / Nexon)

 ( 참조 링크 :
리카넷:가짜블로그, runivityNDC 강연 노트 )

 요즘은 lua를 쓰는 일은 별로 없지만, lua 정도의 추상화 수준을 제공하는 언어를 가지고 게임 로직을 통째로 짤 수 있다면 참 편리할 것이라고 생각했는데, 그 일이 실제로 일어났습니다.


 게임에서 C++을 사용하는 여러 가지가 있겠지만 제일 큰 건 아무래도 퍼포먼스가 아닐까. 그런데 요즘의 클라이언트 기준으로 볼 때 게임 로직이 실행 시간에서 차지하는 비율은 잘해야 10% 안이고, 아주 빡세게 로직을 돌리는 게임이 아닌 이상에야 다들 엇비슷한 수준의 수행 시간을 차지한다. 그렇다면 로직의 수행 시간을 희생하더라도 좀 더 생산성을 고려하는 방향으로 가면 좋을 거라는 생각을 늘 해왔는데, 데스크탑 히어로즈는 그걸 온라인 게임에서 직접 적용한 사례로써 꽤 중요한 기점이 아닐까 생각한다.

 사실 지금도 대부분 게임에서 게임 로직을 스크립트나 데이터의 형태로 빼는 정도의 작업은 다들 하고 있지만, 어쨌든 핵심적인 구조와 프레임워크는 전부 C++로 구현하는 형태라 조금이라도 핵심적인 로직을 건드리려고 하면 컴파일이 뒤따라온다. 만약 그런 로직들까지 모조리 데이터/스크립트로 빼려면 구조가 복잡해지고, 또 설계하고 스크립트에 바인딩하는데 들어가는 개발 비용도 만만찮다. 특히나 lua를 C++에 쌩으로 바인딩하는건 거의 어셈을 직접 다루는 기분까지 들 정도라 -_-; luabind 같은걸 쓰면 훨씬 쉽긴 하지만 컴파일 시간이 안드로메다로 떠남.


 어쨌건 수행 시간 제약이 없다면 이런 비용을 전부 무시하고 동적 언어만 가지고도 게임을 만들 수 있다! 동적 언어로 짜면 좋은 점은 당연히 생산성이 급격하게 올라간다는 점. C++에서는 제공되지 않는 고레벨 언어 기능들을 사용하면 작성하는 코드나 구조부터가 크게 달라진다. 물론 이 쪽이 훨씬 간결하고 높은 표현력을 가지고 있을 것이다. 데스크탑 히어로즈의 로직은 약 22000 라인이었다고 하는데 C++로 짰으면 적어도 5~6만 라인은 나오지 않았을까 하는 생각이...
 
 이를테면 코루틴을 쓰면 기존 언어들에서는 State pattern이나 IoC 등을 동원해서 삽질하던 코드들이 yield 하나로 대체되어 훨씬 간결해지고 응집도도 높아진다. 그리고 클로저를 사용하면 코드 흐름의 응집성이 크게 개선되는 것과 더불어 제공되는 고차 함수의 개념으로 인해 언어 차원에서 제공되지 않는 임의의 제어 구조를 적극 작성, 활용하게 되어 불필요한 코드가 크게 줄어든다. lua에서는 테이블의 형태로 제공되는 덕타이핑[각주:1]을 쓰면 기존의 OOP 언어들에서는 별별 삽질을 해서 구현하던 컴퍼넌트 시스템의 유연성을 날로 먹을 수 있다. 동적 언어와는 연관이 덜 하지만 멀티메써드는 싱글 디스패치의 한계로 인한 한계를 넘어서서 프로그래머로 하여금 훨씬 간결한 설계를 할 수 있도록 도와준다.


 그렇다고 동적 언어에 강점만 있느냐 하면 또 그렇지만은 않다. 타입 안전성 체크가 컴파일 시점에서 런타임으로 넘어감으로 인해 실수를 찾아내는 것은 훨씬 어려워진다. 세션에서 언급된 문제점 중 가장 강조하시던 부분은 바로 오타. lua를 쓰던 사람들이라면 다들 치를 떨 것이다. - -; [각주:2] 동적 언어 커뮤니티에서는 단위 테스트를 통해 이런 실수를 잡을 수 있다고 주장하지만, 개인적으로 언어 차원에서 잡아줘야 할 무결성 문제를 사용자로 하여금 직접 잡도록 하는 것은 그 자체로 심각한 결함이라고 생각한다. 단위 테스트는 어디까지나 명세의 검증을 위해 사용되어야 하는거고, 코드의 무결성 검증은 컴파일러가 해야 할 일이 아닐까?

 개발 환경의 한계도 지적되었다. 언어 개발 커뮤니티들을 돌아보다 보면 언어나 플랫폼의 성공에는 언어 자체의 우수함 보다는 제공되는 개발 환경이 더 많은 영향을 끼친다는 의견도 많은데, 이는 어느 정도 현실을 반영하고 있다. 이클립스 없는 자바나 VS 없는 Win32 플랫폼이 이 정도로 성공할 수 있었을까? 이런 면에서 볼 때 풀 타임 작업자가 부족할 수 밖에 없는 오픈된 언어들에는 한계가 있다고들 한다. 그래도 lua 같이 커뮤니티가 큰 언어들은 기본적인 도구들은 마련되어 있으니 좀 나은 듯.


 추가로, 예전부터 해오던 개인적인 생각이 하나 있는데... 좀 더 규모가 크고 복잡한 프로젝트에서 고레벨 언어를 메인으로 쓰기 위해서는 이종 Heterogeneous 언어 간의 상이한 동시성 모델도 생각해봐야 하지 않나 싶다.

 예를 들어 lua 같은 경우는 ANSI C의 한계로 인해 멀티 쓰레드라는 개념이 거의 배제되어 있다. 고로 저레벨 코드의 지원 없이 lua를 쓰레드 안전하게 쓸 수 있는 방법은 공유되는 데이터 자체를 없애고 VM을 나누어 메시지 패싱으로 가는 것 뿐이다. [각주:3] 공유되는 데이터가 아주 적고 논리적으로 잘 분할이 된다면 VM을 다수 올리면 되겠지만, 그게 가능하면 멀티 쓰레딩보단 마영전 서버처럼 프로세스를 여러개 올리는게 합리적이다. 이렇게 되면 통신 수단을 구현하는 저레벨 코드의 한계로 인해 기존 동적 언어의 강점 중 상당수가 무력화될 수 밖에 없다.
 
 예를 들어, 내가 아는 한도에서는 C 코드에서 lua의 함수나 테이블, 코루틴 등의 객체 내부 내용들에 직접 접근할 수 있는 방법은 없다. 다시 말해 lua VM 간의 쓰레드 세이프한 메시지 패싱를 구현한다 쳐도 lua 단에서 이를 투명하게 사용하는데 제약이 있거나, 가능하더라도 그 비용이 (수행/개발 모두) 적지 않을 거라는 이야기다. 비프로그래머들도 접근 가능하다는 강점을 살리기 위해서는 이런 부분에 대한 투명성이 중요할텐데... 이브 온라인에서는 Stackless python을 메인 로직에 적극 쓰고 있다고 하는데, 이 부분에 대해서는 어떤 해법을 적용했는지 궁금하다. [각주:4] 의외로 현실적인 수준의 해법을 쓰고 있을지도 모르겠지만...


 고레벨 언어를 상용 프로젝트에서 이 정도로 적극적으로 사용하는 사례는 이브 온라인 정도 밖에 들어보지 못했는데, 이렇게 한국에서 적용한 사례를 직접 보게 되니 여러 모로 자극도 되고, 바쁘다는 핑계로 손 놓고 있던 사이드 프로젝트에도 다시 눈을 돌리게 되는 계기가 되어 좋았다. 언어를 만들어야지 -> 그럼 파서가 필요하잖아? -> 근데 파서 제네레이터가 만드는 코드들은 예쁘지가 않아 -> 그럼 내가 만들어야지 -> LALR는 쓰기가 짜증나니 GLR 파서를 만들자 -> 근데 논문이 이해가 안 간다... -> 낮잠이나 자자 -> ... 의 Yak shaving 루프를 또 한번 타기 시작 (...)



 - 슈퍼클래스 : 단순함을 유지하여 개발생산성 향상시키기 (김성익 대표 / 소프트네트)

 ( 참고 자료 : NDC 공식 블로그, NDC 강연 노트 )

 에 뭐랄까... 이 세션에 대해서는 상당히 비판적/공격적인 시각으로 접근할 것 같다. 발표자 분께 악감정이 있는 것은 아니고, 근본적으로 좁힐 수 없는 의견차 때문에 그런 것이니 양해해주시길 - -;


 일단 발표의 기저에는 점점 복잡해져 가는 소프트웨어 구조를 간단하게 만들어 설계 과정과 이를 구현하는 데에서 치르는 개발 비용을 최소화하자는 아이디어가 있다. 그 와중에 모든 기능을 슈퍼 클래스에 몰아 넣자는 역발상이 나온 것으로 보인다. 실제로 적용해본 결과 요구 사항을 구현하는데 들어가는 비용이 줄었으며, 훨씬 더 단순하고 직관적인 코드를 만들 수 있었다고 한다. 그 과정에서 단점으로는 "코드가 조잡해지는 느낌, 하드 코딩하는 느낌"이 있었지만, 이는 인식의 전환을 하면 된다는 주장이다.

 
 소프트웨어 공학이 발전하면서 경험적으로 얻은 통찰 중 하나로는 유지보수하기 쉬운, 다시 말해 이해하고 변경하기 쉬운 프로그램들은 대개 응집성 Cohesion이 높고 결합성 Coupling이 낮다는 사실이 있는다. 이는 어느 언어를 사용해서 소프트웨어를 개발하건 (심지어는 어셈블리라도!) 적용되는 원칙으로, 좋은 코드일수록 구조가 뚜렷하게 드러나는 것은 이런 원칙을 충실하게 지켰기 때문이다. 클래스/인터페이스/다형성/정보 은닉 등의 도구들의 본질은 이러한 원칙을 코드에 명시적으로 드러내고, 더 나아가 차후에 추가되는 코드에도 이 원칙들을 강제하는 것이라고 생각한다.

 어떤 문제에 대해 어떻게 설계하여 어떻게 구현하건 좋은 코드를 짜다 보면 그 나름의 구조가 있을 것이다. 이 세션에서 주장하는 바는 복잡하게 언어적인 도구를 사용하여 이런 구조를 강제하는 대신 코드 차원에서 알아서 잘 정리하면 된다는 이야기로 수렴된다는 느낌을 받았다. 다시 말해, 설계 과정을 통해 문제가 가지고 있는 구조적인 복잡도를 별도로 분리해내는 대신, 이를 전부 구현부로 떠넘기겠다는 이야기다.


 이렇게 하면 확실히 코딩하긴 편할 것이고, 구현도 빠를 것이다. 구조에 대한 고민이 없어도 동작하게는 만들 수 있기 때문에... 문제는 유지보수다. 코드를 원래 작성했던 사람이라면 코드 전반의 의도나 흐름, 구조를 이해하고 있기 때문에 이러한 기조를 일관되게 유지할 수 있을 것이다. 그런데 그 사람이 떠나면 어떻게 될까? 차후 유지보수되는 코드들에도 원래의 의도가 제대로 반영될 수 있을지 의문이다. 언어적/구조적으로 아무런 제약도 없는 상황에 놓인 코드가 어느 정도의 속도로 스파게티화 하는지는 대차게 말아 먹은 많은 수의 소프트웨어 프로젝트들이 보여주고 있다.

 사실 복잡한 구조는 가급적 지양하는게 맞지만, 그렇다고 장점이 없는 것은 아니다. 어떤 문제든 본질적으로 내재하고 있는 복잡도의 정도가 있을 것이고, 이는 구조/구현부 등에 나누어질 것이다. 불필요한 일반화를 하지 않았다고 가정할 때, 구조를 잘 설계할수록 개개 모듈들의 구현이 간단해지는 경향을 띈다. 다시 말해 이해하기 쉽다. 일반적으로 구체적인 내부 구현에 비해 전반적인 구조, 모듈 간의 관계, 명세 등을 기억하는게 훨씬 쉽다. 그렇다면 구조보다는 구현이 간단하고 이해하기 쉬운게 유지보수에 유리하다는 이야기.
 

 슈퍼 클래스에 로직을 몰아 넣는 방법으로 얻는 유연성이라고 하는 것 역시 내가 보기엔 크게 효용이 있을 것 같지는 않다. 높은 응집성과 낮은 결합성이라는 원칙을 충실하게 따르려고 하면 어떤 접근법을 사용하든 코드에는 그에 따른 구조가 생길 것이고, 이렇게 잡힌 구조가 바뀌어야 한다면 문제의 본질 자체가 변한 상황이라고 할 수 있다. 이런 상황에서 슈퍼 클래스를 이용한 접근법을 적용하니 변경책의 복잡도가 확연하게 줄어드는 상황이 얼마나 될까? 내 생각엔 응집성과 결합성의 원칙을 포기하는 이외의 경우는 없을 것 같다. goto 쓰면 편할 상황이 많지만 가급적 안 쓰는 데에는 다 이유가 있는 것과 비슷한 이치다.

 이를테면 pt 28페이지에서 좀 극단적인 시나리오를 제시하고 있는데, 내 생각엔 저 경우는 애초에 해결해야 하는 문제의 본질 자체가 게임 시스템 전반에 걸쳐 있는 케이스다. 어떤 방법론을 쓰건 간에 이 문제를 제대로 해결하려면 이동, 이벤트, 조건, 이펙트, 버프, 스탯 등의 게임 컨셉들을 엄밀하게 정의한 뒤, 이들 간의 상호 작용을 통해 해결하는게 이상적이다. 정도의 차이는 있겠지만, 이런 방식으로 해결하면 유사한 요구 사항은 앞으로 데이터 레벨에서 처리할 수 있을 것이다. 하지만 이를 위해서는 슈퍼 클래스에 몰아 넣건 뭘 하건 모델링/설계 자체의 비용을 피할 수는 없지 않을까? 만약 이 비용을 피할 수 없다면 슈퍼 클래스를 사용하는 의미가 어디에 있는지 의문이 든다.
 
 pt에서 들고 있는 사례들을 보면[각주:5] 이런 원칙들을 포기하는 방향을 제시하는 것으로 보이는데, 이런 예외가 하나 둘 쌓이기 시작하면 유지 보수가 불가능해지는 것은 시간 문제라고 본다. 특히나 해당 코드를 직접 작성한 사람이 아니라면 그 의도를 곡해할 가능성도 높을테고...


 물론 구조를 표현하기 위해서 추가적으로 들어가는 노이즈를 줄이는 것은 틀림 없이 의미 있는 일이고, 생산성에 도움이 된다는 점은 부인할 수 없다. 또한 다루는 문제의 본질에 비해 복잡한 구조는 최대한 지양해야 한다는 것 역시 동의한다. 하지만 이런 문제들을 해결하기 위해 슈퍼 클래스에 모든 로직을 몰아 넣는 구조가 가지는 근본적인 문제점 - 높은 결합성, 낮은 응집성의 코드로 수렴하게 될 위험성 - 을 생각해보자면 결과적으로 손해 보는 트레이드 오프가 아닐까 생각한다. 순간적인 생산성 향상을 댓가로 차후의 유지보수성을 지불하는 것이니 말이다.

 다만 프로토타이핑용 코드에 적용하는 경우라면 좋은 접근법이 될 수 있다. 이 경우는 한번 쓰고 버릴 코드이고, 빠른 구현 자체가 요구 사항이기 때문이다. 하지만 pt의 문맥을 볼 때 이건 발표자 분의 의도와는 거리가 있는 듯 하다. 대충 짠 프로토타이핑 코드와 좋은 구조의 코드가 공존하는 게 불가능한 것이 아니기도 하고 말이다.


 아마 발표자 분과 내가 가진 입장 차이는 아마 리팩토링에 대한 시각차에서 오지 않을까 하는 생각이 든다. 나 같은 경우는 꼭 필요하다면 리팩토링은 최대한 공격적으로 진행되어야 한다는 입장이다. 하지만 발표 문맥을 볼 때 발표자 분 같은 경우는 구조 변경에 대한 부담감을 가지고 있는 듯 하다. 이런 점에서 이런 발상을 하고 적용한게 아닌가 싶은데...
 
 하지만 이게 좋은 해법인가를 묻는다면 좀 회의적이다. 사실 많은 사람들이 구현 비용과 유지 보수성 사이에서 비슷한 고민을 했고, 그 결과 일단은 빠르게 구현한 뒤 이를 좋은 구조로 쉽게 리팩토링할 수 있도록 환경을 구축하는 것이 적당한 선이라는 결론을 내렸다. 이런 점을 생각해보면 단위 테스트의 커버리지를 높이고 적극적인 커뮤니케이션을 통해 systematic한 기획을 하도록 유도하여 리팩토링의 비용을 낮추는 정공법으로 가는 게 구조에 대한 고민/설계를 피하는 것보다는 더 근본적인 해법이 아닐까... 물론 현실적으로 쉽지 않은 선택이지만.


 번외지만 컴파일러 마법 부분도 문제가 있다. 자주 호출되는 루틴에 대해서는 컴파일러에 의하여 Constant propagation -> Dead code elimination 최적화를 사용하면 된다는 이야기인데, 문제는 예시로 든 코드에서 비교 대상이 되는 멤버 변수는 상수가 아니라는 점에 있다. JIT가 적용 된 것도 아니고 저런 최적화를 할 수 있을리가 없는데... - -; 이건 명백하게 잘못된 내용이 아닌가 싶은데 실제 게임 코드 레벨에서 확인은 하고 pt에 넣으신건지 모르겠다. (개인적으로는 이 부분에서 이 세션에 대한 신뢰를 잃었다.)

  1. 정적 타입 언어에서는 go의 interface나 Haskell의 Type class 등의 모습으로 구현된다. 이 쪽은 덜 유연한 편이지만. [본문으로]
  2. 발표자분께서는 이 문제의 근본적인 해결을 위해 아예 정적 타입 루아인 Dal이라는 언어를 만들고 계시다. [본문으로]
  3. "프로그래밍 루아" 책의 뒷 부분에서는 마치 IPC를 하듯 루아 VM 간의 통신을 도와주는 모듈을 C 코드 차원에서 구현하는 예제가 나온다. [본문으로]
  4. Lua와 마찬가지로 Stackless python의 설계에는 선점형 멀티태스킹에 대한 고려가 이루어지지 않은 것으로 알고 있다. [본문으로]
  5. 메시 모델의 손 위치를 사운드 출력부에서 참조한다거나, 카메라 객체에서 플레이어 머리 본의 TM을 참조하는 등 [본문으로]

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

NDC 간략한 참관기 #3  (4) 2011.06.19
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
tags : ndc, 개발, 참관기
Trackback 0 : Comments 4

NDC 간략한 참관기 #2

개발 2011.06.12 16:16

 2일, 3일차에는 의견을 따로 정리하기에는 애매했던 세션들이 많아서 글 하나로 압축... 다 써놓고 보니 전혀 간략하지 않아 보이는 건 착시입니다.



 성공하는 제품을 만들기 위한 팀 구축 (박종천 / Blizzard)

 이 세션에 대한 내용은 찾아 봐도 요약본이 없고, 또 슬라이드도 올라오지 않을 것 같아서 전반적인 내용도 따로 정리해보았다. 일주일 넘게 지난 상황이라 좀 틀린 내용이 있을지도...

 우선 블리자드에서 한 첫 작업인 스타크래프트의 한글 지원 패치를 언급함으로써 청중들에게 환호를 받으며 강연을 시작. 그리고 본 강연의 내용은 여러분들도 다 아는 내용을 우리는 이렇게 적용하고 있다는 것 밖에 없다면서 소속사의 위엄을 본격적으로 드러내었다. -_-;

 제일 처음은 팀을 구축하는 데에 있어 필요한 것은 프로젝트와 인력, 예산이며, 이 중에서는 좋은 프로젝트가 가장 중요하다고 강조. 그 이유는 성공적인 제품 개발에 있어 좋은 인력과 충분한 예산은 당연히 필요한 조건으로 이 둘이 없이는 프로젝트를 진행조차 할 수 없으며, 이러한 조건을 완벽하게 갖추더라도 안 좋은 프로젝트를 하게 될 개연성은 충분하기 때문이다. 따라서 이 부분에 지속적으로 신경을 기울일 필요가 있다.

 그 뒤 팀이 실제로 구축되고 발전하는 과정은 집단의 발달 단계 모형을 인용하여 설명하였으나 여기에 대해서는 거의 언급하지 않고 넘어갔던 것으로 기억한다. 해당 모형의 발전 단계에 따라 팀이 발전할 수록 팀원 간 신뢰와 지식 공유의 깊이가 깊어지고 최종 단계에 이르러야 최대한의 퍼포먼스로 가지고 일을 할 수 있다는 내용. 이를 위한 척도로는 커뮤니케이션, 효율, 생산성 등이 있다.  팀이 구축된 이후 업무를 수행할 때 프로젝트 오너, 프로듀서, 리더, 엔지니어 등 역할에 따라 할 일이 서로 다르니, 팀원들이 실제로 수행할 역할을 명확하게 정의하고 그에 따라 업무를 적절하게 위임한다.

 제품을 만들 때에는 세 가지 현실적인 제약이 있는데, 각각 Cost(Resource), Time(Schedule), Scope(Quality)다. 대개의 경우는 Cost과 Time을 많이 투자할수록 Scope가 좋아지지만 반드시 그렇게만 흘러간다는 보장은 없다는게 문제. 하지만 블리자드는 Cost와 Time에 대해서는 신경쓰지 않는다. -_-; 여기에는 Cost와 Time을 투자하면 높은 Quality를 얻을 수 있다는 확신이 필요하다. 그러한 확신의 뒤에는 블리자드의 전 직원들이 게임 플레이어로써 제품에 대해 주는 광범위한 피드백이 있다. 몇몇 매니저 급에 의해 품질이 평가/파악되는 시스템에서는 불가능한 일.

 문제를 맞닥뜨리면 우선 해결할 문제를 택한 뒤 이를 정의, 분석하고, 여기에서 다양한 해법을 찾는다. 이 때 다양한 해법을 찾는 것에 방점을 찍었는데, 그 이유는 즉흥적으로 떠올린 해법을 바로 적용한 경우 해당 문제가 확실하게 해결된 상태로 남기보다는 차후 또 다른 문제를 일으킬 가능성이 있기 때문이다. 그 뒤 선택한 해법을 토대로 의사 결정권자를 설득한다. 이 역시 중요한데, 이 과정을 스킵하면 차후에 의사 결정권자의 결정에 따라 문제 해결에 들였던 노력이 무용이 될 수도 있기 때문.

 문제를 분석할 때에는 크게 중요성과 해결 가능성 둘을 기준으로 한다. 이를테면 중요하지도 않고 쉽지도/긴급하지도 않은 문제는 무시하고, 중요하고 해결이 간단한/긴급한 문제는 곧장 수행한다. 이 때 긴급하진 않지만 중요한 일이 긴급하지만 중요하지 않은 일보다 우선이다. 그 이유는 중요하지만 긴급하지 않은 일은 곧 중요하고 긴급한 일이 되어 돌아오기 때문이다. 하지만 중요하지 않은 일이 중요한 일이 되는 경우는 별로 없다. 또한 중요하지도, 긴급하지도 않은 일은 무시할 수 있는 내공이 필요. 이러한 우선 순위 관리가 제대로 되야 자기 시간을 관리할 수 있다.

 또한 프로젝트를 진행할 때에는 빠른 진행 속도로 인한 함정에 빠지기 쉽다. 아무리 빠르고 효율적으로 일을 해도 방향이 잘못되면 프로젝트는 실패하는데, 이게 잘 되는 것으로 착각하면 곤란. 잘못하는 일은 대체로 빠르게 진행되는 경향이 있으므로 너무 잘 되면 의심해봐야 한다. 멈춰서 프로젝트의 방향을 검토하는 일은 최대한 자주 이루어져야 한다. 최근 몇 년 간 이야기가 나오는 애자일 개발론이 그 좋은 예. 다만 방향을 검토하는 비용이 너무 비싸면 자주 멈추기 힘드므로 최대한 간단하게 점검할 것.


 여기까지는 팀을 구축하고 프로젝트를 진행하는 일반론적인 이야기다. 대부분 알고는 있음에도 여건 상 실천은 못하는, 비교적 흔하고 추상적인 내용들이다. 다들 이상론이라고 생각했던 것을 실제로 해내고 있다는 점이 블리자드의 저력 중 하나. 이후는 인사 관리에 대한 내용으로 회사 내의 조금 더 구체적인 사례를 들어 설명하였다.

 우선 블리자드의 인사 팀에는 Attract -> Develop -> Engage라는 표어가 붙어 있다. 말 그대로 인재가 오고 싶어하는 매력적인 회사가 된 뒤 이 인재들을 개발해서 제대로 써먹자는 의미. 대부분의 기업들에 제대로 써먹자만 남아 있다는 점을 생각해보면 확실히 차이나는 점이고, 이게 이후 발표의 주제 의식을 꿰뚫는 이야기이기도 하다. 발표자 분은 무척 감동 받은 문구였다고.

 우선 인사 고과(Performance Review)는 블리자드의 소프트웨어 엔지니어에 적용되는 사례를 들었는데, 블리자드에서는 소프트웨어 엔지니어를 평가하기 위해 Productivity, Professionalism(Reliability), Teamwork(Communication)과 같은 Soft skill과 Knowledge, Functionality(코드의 올바른 동작), Implementation(코드의 퀄리티), Design & Architecture같은 개발 능력을 척도로 삼아 각각에 대해 5점의 평가를 내린다.

 여기에서 평가를 내리는 주체는 팀장들과 평가 받는 엔지니어 자기 자신으로, 연 말에 스스로에 대한  한 해의 평가를 내린 뒤 이에 대해 팀장들과 토론을 하면서 점수를 확정 짓는 방식. 점수의 기준은 현재 대상이 가지고 있는 직급에 따라 다르게 적용되며, 당연히 높은 직급을 가지고 있을수록 더 높고 엄격한 평가 기준이 적용된다. 여기에서 좋은 평가를 받은 사람 중 다음 직급을 얻기 위해 충분한 실력을 가지고 있다고 판단되는 사람들은 직급 상승. 이러한 평가를 위한 테스트도 있다고 한다.

 인사 고과에서 인상 깊은 점 중 하나는 이렇게 평가한 내용들을 그냥 인사 고과에 반영하고 마는 것이 아니라는 점에 있다. 실제 평가를 내린 팀장급들이 각각의 평가 대상들에 대하여 각 척도마다 A4 용지 한장 분량의 평가(라지만 실제로는 앞으로 커리어에 대한 조언)를 작성하여  전달하는 것이다. [각주:1] 산업에 갓 뛰어든 사람들 대부분은 초기에 자신의 방향을 제대로 잡지 못하고 방황하는 경우가 많은데, 이렇게 선배 개발자들이 확실하게 멘토가 되어 주는 체계는 무척 인상적이다.

 발표자 본인이 속한 팀에서는 여기에서 더 나아가 6개월 단위로 평가를 하고 있으며, 매 평가마다 척도 1~2개에서 1~2점 정도의 향상을 목표로 하고 있다고 한다. 회사에서는 인재들에 대해 이렇게 계속해서 발전해 나가는 것을 요구하며, 또 그것을 적극 지원하고 있다. 아래에서도 다루겠지만, 블리자드에서는 단순히 경제적 지원 정도로 끝나는게 아니라 개개인의 발전을 위한 방향 설정, 목표 제시까지 해주고 있다.

 지나가는 이야기 식으로 블리자드 내부의 직급 Title 에 대한 이야기도 언급되었다. Software Engineer(이하 S/E)의 경우 신입은 Assistant S/E로 시작하며, 이들에 대한 기대는 대략 "숨만 잘 쉬어 줘도 고마운" 정도라고 한다. (...) 그 뒤 Associate S/E가 되면 어느 정도 일을 믿고 맡길 수 있는 수준으로 보며, 실제 Software engineer가 되면 스스로가 알아서 일할 수 있는 수준이 된다고 한다. 이렇게 Senior S/E까지가 일반적인 엔지니어 직급이며, 이후 팀을 이끄는 Lead S/E로 나가서 프로듀서/테크니컬 디렉터 등의 관리직으로 가는 커리어 패스가 일반적. 하지만 개발에만 전념하고자 하는 경우는 Principal S/E로 남아 계속해서 개발을 할 수도 있으며, 코딩만 20~30년을 하여 이 루트의 궁극에 달한 Distinguished S/E[각주:2]는 말 그대로 코딩의 신이라고. 평생 코딩만 할 수 있는 회사다!

 교육 역시 열성적으로 지원하고 있다. 아예 Blizzard learning & education이라는 이름으로 이를 지원하는 프로그램이 따로 있다. 학습을 위한 도서 구비는 기본이며, 내부에서 이루어지는 세미나나 Engineering discussion group[각주:3], 컨퍼런스 등을 지원한다. 특히나 Principal S/E 이상 직급은 매해 세개 이상의 컨퍼런스에 참여하여 세션을 진행해야 한다고. 그 외에도 대학 진학과 학비를 지원하는 프로그램도 있고, 개인 프로젝트 역시도 적극 지원한다고 한다. 이를테면 아이폰 게임 개발을 한다고 하면 사내의 팀을 구성해주는 등등의 일을 지원해준다고.

 가장 인상 깊었던 내용은 Individual Development Plan이라는 것으로, 간단히 말해 개개인의 발전을 위한 계획을 함께 세우고 이를 실제로 실천하도록 도와주는 시스템이라고 할 수 있다. 앞서 언급된 인사 고과와 비슷한 성격이지만, 보다 더 구체적으로 목표를 제시하고 계획을 잡아준다. 이를테면 언제까지 목표로 제시한 어떤 책을 마스터할 것 등등. 그리고 실제로 목표 달성을 독촉(...)하기도 하는 듯. 자신이 S/E라고 단순히 공학적인 부분만을 목표로 잡는 것이 아니라 개개인의 발전을 위해 필요한 부분들이라면 모두 목표가 될 수 있다. 발표자분도 관련 프로그램에 의해 타 대학에서 재무 관련 프로그램을 들었다고 하였다.

 후반부 내용들은 다 적어놓고 보니 어떻게 보면 마치 블리자드 모집 홍보 같은 기분도 들 정도인데, 이건 그 내용들을 단순히 열거하는 것만으로도 이 회사가 인재에 들이는 노력이나 비용이 바로 와닿을 정도로 공을 들이고 있다는 이야기다. 발표자 분은 이를 회사와 팀, 사원의 비전과 목표를 일치시켜 가는 과정이라고 하였는데, 사원들이 회사의 목표에 따라 올 것을 요구하긴 하지만, 그 목표가 궁극적으로 사원에게도 발전의 기회가 된다는 이야기.


 내용을 다 적고 나니 의외로 할 말이 없어졌다. 발표 전에 말하신대로 모두가 알지만 실천은 못하는 정론을 다루고 있고, 그걸 블리자드에서는 제대로 하고 있고, 또 할 수 있다는 이야기. 마치 밥 로스 아저씨를 보는 기분이랄까 - -; 발표 중에 나온 말 대로 블리자드는 똑똑한 사람들이 열심히 일하는 회사고, 또 그렇게 되도록 시스템을 구축한 회사인 듯. 굳이 의견을 하나 더 붙이자면 그냥 ... 블리자드에서 일하고 싶다? (...)



 절차적 지형의 생성 관련 세션 (김주복, 이원 / Nexon)

 (참조 : NDC 공식 블로그, devCAT publication)

 3일차 베스트 세션. 내 친구에게서 들은 바로는 발표자이신 두 분은 PT의 신(...)이고, 게다가 주제도 무척 끌렸음. 그래서 잔뜩 부푼 기대감을 가지고 갔는데, 그 기대를 배신하지 않은 아주 좋았던 세션이었다. 총 3 시간에 걸친 세션이었는데 원래는 마지막 시간이 실제 구현에 대한 내용이었으나 보안 문제로 인해 툴인 월드 머신을 다루는 시간이 되었다고 한다. 하지만 월드 머신의 구조 자체를 다루는 것만으로도 구현에 대한 힌트를 상당 부분 얻을 수 있었기에 충분히 만족했다.

 우선 첫 시간은 절차적 지형 생성의 동기와 그 방법론들에 대한 대략적인 소개. 요즘 들어... 라기에는 꽤 예전부터 절차적인 컨텐츠 생성에 대한 연구가 이루어져 왔다. 그 이유는 컨텐츠의 용량 제약, 다양성, 의외성 등 여러 가지가 있겠지만, 어디에도 빠지지 않는 이유는 생산성이다. 게임 하나에서도 엄청난 규모의 데이터를 다루는 지금 이걸 다 수동으로 만드는 건 현실적으로 무리다. 당연히 자동화에 눈길이 갈 수 밖에 없고, 이에 대해 게임 플레이에 핵심적이지 않은 부분에 대해서는 절차적 데이터 생성은 아주 좋은 도구가 될 수 있다는 것.
 
 애니메이션 간 전이나 임의의 컨텐츠 생성 등은 조합의 폭발로 인해 아티스트/디자이너가 일일히 데이터를 만드는 것이 별 의미도 없고 무척 비효율적인데, 생산성의 향상을 위해 절차적인 데이터 생성이 효율적으로 적용되는 사례다. 따지고 보면 3D 렌더링도 메쉬와 텍스쳐, 애니메이션, 셰이더 등의 데이터로부터 2D 장면을 실시간으로 생성하는 일종의 절차적인 생성이라고 볼 수 있겠다. [각주:4] 우리는 그야말로 절차적 생성의 시대에서 살고 있는 것이다. 메타 프로그래밍도 있다! 코드 제네레이터도 있다! 이제 코드 제네레이터를 만드는 메타 프로그램을 생성하는 코드 제네레이터를 만드는 ...

 마찬가지의 관점에서 볼 때 하이트 맵은 절차적 생성을 적용하기에 아주 매력적인 대상이라고 볼 수 있다. 만들어야 하는 데이터의 크기가 매우 크고 디테일 작업에 손이 많이 가지만, 게임 플레이에 있어서는 디테일보다는 전반적인 지형 형태가 좀 더 중요하다. 하지만 디테일이 없으면 외관이 매우 어색해지기 때문에 아예 무시할 수도 없다. M2 팀에서 절차적 지형 생성을 도입하면서 목표로 삼은 것은 고품질의 지형을 최소한의 수작업으로 만드는 것으로 이 경우는 생산성에 방점을 찍은 케이스가 되겠다.

 이후 실제 적용한 방법들을 설명하였는데, 간단하게는 "나이브한 방법은 컨텐츠의 퀄리티와 제어 가능성 Controllability이 떨어지고, 이 둘을 높이려면 수작업이 필요하고" 정도로 요약할 수 있다. 순수하게 알고리즘적인 방법들을 사용하면 기획자가 의도한 형태라던지 모양 같은 컨텍스트가 누락되는 경향이 있고, 이런 변수들을 알고리즘/데이터에 세세히 반영하기 시작하면 수작업의 비율이 늘어난다. 다시 말해 은탄환은 없고 트레이드오프만 존재하는 상황. 특히나 강/길이 잘 만들어지지 않았다고 하는데, 이 둘은 컨텍스트가 중요한 요소들이라 요구되는 수준이 그만큼 더 높았기 때문이 아닐까 하는 생각이 든다.

 여기에서 M2팀이 권하는 방법은 어느 정도의 절충안과 더불어 남이 만든 걸 최대한 가져다 쓰자- 인 듯. 반지의 제왕 DEM 프로젝트는 우수한 퀄리티를 보여주고 있지만 그만큼 수작업이 많이 들어가는 케이스로 보이고, 실제 지형에서 가져오는 방법은 제어 가능성이 매우 떨어진다. 이미지 기반으로 능선을 파악하고, 해당 능선에 맞게 적절한 DEM들을 조합하여 지형을 생성하는 방법도 있지만 이건 능선을 만드는 수준에 머물렀다고... 전반적으로 잘 작성된 리뷰 페이퍼를 읽는 듯한 느낌이었다.
 

 두 번째 세션은 과정에 조금 더 집중한 세션. 디렉터가 그린 작은 개념도 하나에서 전 대륙에 걸친 지형을 생성하는데 사용한 방법론, 겪었던 시행 착오들과 더불어 그 결과까지 일목요연하게 요약하는게 무척 인상적이었다.

 기본적으로는 지형/산/강 등의 기본적인 지형 단위를 설정하고, 이런 지형 단위들에 대해 특성을 줄 수 있는 요소들을 적절하게 정의한 뒤 각각의 지역 별로 특성을 설정하여 전반적인 지형을 생성한다.[각주:5] 일단 언급된건 지형/산/강 정도지만 이건 전반적인 지형 구조에 해당되는 내용이고, 다른 샘플을 볼 때 디테일하게 내려가면 좀 더 다양한 지형들을 갖추고 있는 듯 하였음.

 여기에서 짚고 넘어간 점 중 하나가 이렇게 절차적으로 생성되는 컨텐츠의 경우는 생성하는 과정 자체에 집중하다보니 정작 컨텐츠가 게임 플레이에 어떻게 적용되는지를 간과하게 될 소지가 있다는 점. 개인적으로도 패턴이 절차적으로 생성되는 음악 게임을 만들려고 시도할 때 직접 플레이는 안 해보고 패턴이 예쁘게 나오느냐만 봤던 기억이 있어서 슬쩍 뜨끔.[각주:6] 특히나 이렇게 레벨을 제작할 때는 항공 사진만 보게 되는 맹점이 있기 때문에 특히나 유의해야 할 듯 하다. 게임 플레이에 대한 검증이 최우선이라는 것.

 결과물을 개선해나가는 과정은 절차적인 컨텐츠 생성에서 겪게 되는 전형적인 튜닝 과정이었을 듯하다. 이렇게 알고리즘적인 컨텐츠 생성은 결과가 좋건 나쁘건 왜 그렇게 나오는지 이해하기 어려운 경우가 많다. 게다가 눈으로 직접 보지 않으면 퀄리티를 평가하기도 어렵다. 그런 이유로 대개 튜닝을 위해선 Hill climbing을 손으로 직접 -_-; 하게 되고, 그 결과 Global maxima는 아니라도 대충 Local maxima라도 찾아내면 나름 성공적인 경우라고 볼 수 있을 듯 하다.

 M2팀에서는 기초적인 단위를 바탕으로 지형 생성에 필요한 구조를 갖추고 이를 조합하여 최종적인 생성하는 방법론을 쓰고 있는데, 여기에서 실험 끝에 찾아낸 Local maxima들을 다른 지역에서도 재활용할 수 있도록 한 듯 하다. 물론 이건 전부 추측이지만. ;


 궁금했던 점이 있었는데... 일단 자동적으로 생성한 데이터를 다룰 때 지켜야 하는 철칙 중 하나가 바로 자동으로 생성된 데이터는 손으로 수정하지 말 것이다. 입력 값이 바뀔 때마다 변경점을 손으로 재반영해야 하기 때문. 하지만 지형 같은 경우는 게임 플레이를 위해서는 일정 부분 수작업이 들어 갈 수 밖에 없다. 마을이라거나 길 같이 자동 생성에는 부적합한 부분이 분명 있기 때문. 그런데 퀘스트 동선 등의 기획적인 의도가 변경된다던지 하는 이유로 수작업이 적용된 지역의 세부적인 지형이 바뀐다면?
 
 일단 M2팀은 이에 대한 나름의 복안이 있다고 했으며, 강연에서 나온 내용만으로 봐도 자동 생성의 영역을 특정 지역으로 국한시킬 수 있기 때문에 큰 문제가 되지는 않는다고 하셨던 듯. 즉, 기존의 작업은 버리되 새로 만드는 비용을 최소한으로 줄이는 전략이다. 어차피 기존과 같이 손수 작업을 하는 경우에도 지형을 바꾸는 작업은 비슷한 비용이 들어가니 절차적 지형 생성의 본래 의도를 해치진 않는 방법인 셈이다.

 또 하나는 어떻게 해야 게임의 컨텍스트를 최대한 잘 살릴 수 있는가. 이를테면 강 같은 경우도 그렇지만 길이라거나 랜드 마크의 위치, 동선 등에 대한 제어 가능성은 게임 디자인에 있어서 꽤 중요한 부분이라고 생각한다. 지형에 맞춰서 이들을 배치하는게 보통이겠지만, 지형을 여기에 맞춰야 하는 경우도 있을 수 있다고 생각. 사실 이 부분은 기본적으로 하이트맵을 변조하는 툴인 월드 머신만 가지고는 아무래도 무리가 있는 부분이라고 생각하고, 게임 내부의 컨텍스트를 구조적으로 지형 생성 과정에 완전히 통합, 반영할 수 있는 툴 없이는 어렵지 않을까 싶은 생각이 들었다. 혹시 원래 예정되었던 3번째 세션에서 이런 내용을 다루는게 아니었을까 하는 생각도 ;
  1. 발표자분 말로는 "이대로만 했어도 훌륭한 사람이 되었을텐데"라고. (...) [본문으로]
  2. 직급명이 정확히는 기억이 안 나지만, 미국 소프트웨어 회사의 일반적인 직급 체계를 보면 아마 맞을 것이다. [본문으로]
  3. S/E의 특정한 주제를 다루는 그룹으로, 해당 주제에 관심 있는 사람들이 모여 보다 깊게 학습하고 만나서 토의한다고 한다. [본문으로]
  4. 사실 게임계에서 3D가 대세가 된 가장 큰 이유 중 하나가 바로 생산성이기도 하다. [본문으로]
  5. 물론 이 특성이나 요소들은 순수하게 개념적인 것이다. 실제 사용되는 데이터는 입력되는 비트맵과 변환 로직이고, 나오는 데이터는 하이트필드 하나다. [본문으로]
  6. 그 게임의 목표는 음악을 받아 악보를 적절하게 인지하고 패턴을 생성하는 것이었는데 패턴 생성은 둘째치고 음원 인식에서부터 털렸다. orz [본문으로]

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

NDC 간략한 참관기 #3  (4) 2011.06.19
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
tags : ndc, 개발, 참관기
Trackback 1 : Comment 1

NDC 간략한 참관기 #1

개발 2011.06.04 17:00

 저번 주 내내 NDC를 참관했는데, 기억이 흐릿해지기 전에 몇몇 세션에 대해 글로 정리해본다 강연의 정리는 다른 분들이 잘 해놓으셨으니 개인적인 수준의 감상만 남김. 강연 요약본이 아니니 강연 내용 자체를 원하셨던 분들께는 죄송. 들었던 세션 전부를 정리하는 건 아니고, 정리해 볼 만한 의견이 있는 경우만 정리해본다.



 구세대 개발자의 신세대 플레이어를 위한 게임 만들기 (김동건 본부장 / 넥슨)

 ( 관련 링크 : NDC 공식 블로그, NDC 2011의 강연 노트를 모아보아요! (05월 30일) )

 우선은 Entry age의 확보에 대한 강조로 시작했는데, 새로 게임을 시작하는 유저에 대한 강조는 이미 닌텐도부터 시작하여 수 많은 게임 회사들이 강조하지만 그걸 실제 게임 디자인까지 반영시키는 경우는 그리 많지 않은 것 같다. 그 이유는 보통 게임 개발자들이 자신에게 재미있는 게임을 만들려고 하기 때문인데, 이를 정확히 짚어냄. 닌텐도가 Wii로 성공한 까닭이 바로 그 지점이고. 개인적으로는 Wii Sports가 개발자와 유저의 괴리를 잘 보여주는 지점이라고 생각한다. 이 게임은 메타 크리틱 점수가 평균 76점으로 닌텐도 게임 치고는 최악의 평가를 받았음에도, 판매고는 거의 8000만장에 육박.

 다만 충성 고객에 대한 언급이 부족한 것은 조금 의아했다. 아마 PT의 주제를 흐리지 않을까 걱정한 게 아닐까 싶은데, 그래도 언급할 가치는 있지 않았을까? Wii는 신규 유저 창출에 극단적으로 올인한 케이스인데, 분명 잘 나가긴 했지만 파급 효과에 비해 지속력이 떨어져 한 순간의 바람이 되어 버렸다. PT에서 언급하고 있는 20%의 이탈률은 아주 충성률이 높은 경우라고 생각하는데[각주:1], 여기에 대해서도 어느 정도 이야기는 하는게 좋지 않았을까.

 이후 이어진 게임 디자인적인 관점은 대공감. 새로운 게임 메커닉에 대한 천착으로 인해 망한 온라인 게임의 수는 셀 수도 없고, 반대로 새로운 게임 경험의 도입/구시대적 게임 경험의 척출을 거부하다가 뒤쳐진 게임들도 많다. 이제 중요한건 어떻게 플레이하느냐가 아니라 그게 재미있느냐라는 이야기. 게이머 커뮤니티인 루리웹에서 나온 루까성[각주:2] 같은 단어는 이러한 내용들이 하드코어한 게이머들에게도 느껴질 정도로 대세가 되었다는 의미도 될 수 있지 않을까?

 와우 패치의 역사를 보면 이런 방향성이 느껴지는데, 간단한 어그로 미터의 도입이라거나 퀘스트 라인의 일직선화, 퀘스트 목표를 지도에 표시, 던젼 도감의 도입 등 가장 핵심적인 게임 플레이를 제외한 다른 잡다한 요소는 전부 쳐내고 게이머로 하여금 중요한 게임 경험에만 집중하도록 발전하는 것을 볼 수 있다. 게임 디자인에 있어서 한국과 해외의 가장 큰 차이라면 이런 부분이 아닐까 싶었는데, 역시 탑 레벨 스튜디오는 트렌드를 아주 빠르게 따라가는구나 싶었음.

 근데 문제는 이 쪽만 따라가면 또 안 된다는거다. (...) 일반론적으로는 새로운 게임 유저들이 지속적으로 유입되는 것도 중요하지만, 이게 온라인 게임으로 가면 그 유저들이 계속해서 남는 것 역시 중요하다. 여기에는 하드코어 게이머들이 만들어내는 이야기와 커뮤니티의 역할이 무척 큼. 그리고 그 하드코어 게이머를 잡아내려면 탄탄한 게임 플레이와 하이엔드 컨텐츠가 있어야 한다. 와갤과 용개가 없었다면 한국에서 와우 커뮤니티는 반토막 나지 않았을까? 상충되는 두 목표를 함께 잡아내기는 참 어려울텐데[각주:3] 여기에는 어떤 답을 냈을지 궁금...



 영웅전 액션 시스템:기민하면서도 다채로운 액션 프로토타이핑 (박영준 / 넥슨)

 ( 관련 링크 : NDC 2011의 강연 노트를 모아보아요! (05월 30일) )

 궁극적으로 게임 플레이 프로그래머를 지향하는 입장에서 무척 재미있게 봤던 세션. 기술이 발전하고 게임 플레이에 대한 요구 레벨이 점차 올라가면서 게임 플레이와 프레젠테이션 레이어 사이의 경계가 모호해지는 추세다. 조금 더 정확히 말하자면 전통적으로 프레젠테이션 레이어에서 담당하던 영역이 은근슬쩍 게임 플레이로 넘어오고 있다. 그 이유야 조금 더 직관적인/사실적인 게임 플레이를 위해서고...

 특히나 애니메이션이 그런 경향이 있는 것 같은데, 이렇게 될수록 게임 플레이 프로그래밍은 점점 고달퍼질 것으로 예상된다. 물리니 IK니 하는 절차적 애니메이션 기법들이 끼어들어가는 와중에 또 이걸 어색하지 않은 애니메이션으로 잘 맞춰주는게 꽤 어려운 일이라고들 하는 것 같다. 근데 나는 여기에 대해 아는게 거의 없다는 문제가... (...)

 어쨌든 나름 성공적으로 서비스하고 있는 마영전에서는 여기에 대해 어떤 접근을 취했는지에 대해 다루는데, 기본적으로는 액션 자체를 데이터 드리븐하게 다루고 여기에서 발생하는 이벤트를 적당한 애니메이션을 연동시키는 방향... 으로 이해했다. 아직까지 PT가 올라오지 않은 상황이라 정확히는 기억은 못하겠지만. 이렇게 기반을 만들고 실제 컨텐츠를 만들어 내는 사람들이 실제 액션을 작업하면서 필요한 기능을 프로그래머들에게 요청하는 형태인 듯.

 놀랐던 건 이런 기반 시스템을 베타 3개월 전에 뒤엎어서 만들었다는 점. 라이브하는 게임을 뒤엎은 것과 다를 바가 없는 일인데, 이걸 해냈다. 물론 그 이유는 생산성이다. 사실 논리적으로 생각해보면 이게 올바른 길이 맞는게, 앞으로 엄청난 수의 컨텐츠를 만들어 내야 하는데 개발 프로세스가 병목이 되어 이터레이션 자체가 안 되는 상황이라면 이걸 뜯어 고치는게 당연한거다. 보통 이런 문제는 나중에 갈수록 고치기 점점 힘들어지고, 가장 싼 값에 고칠 수 있는 시점은 당장인 경우가 많다.
 
 허나 현실적으로 "무슨 일이 일어날지 모르니까" "여태까지 잘 되던건데 꼭 고쳐야 하나" "문제되면 그 때 고치지" 같은 방어적/수동적인 생각들이 없을 수 없다. 하지만 이건 비용에 비해 이득이 작아 안 해도 될 정도로 사소한 일인 경우나 시간이 지나도 문제가 심각해지지 않을 경우에나 적용될 말이고, 근본적인 부분의 문제라면 가능한 이른 시일 내로 작업에 들어가는게 맞다고 본다. 그런 관점에서 이 사례는 확신만 있다면 문제점을 안고 가는 것보다는 이를 근본적으로 해결하는게 맞는 일이라는 증명이 되어줄 듯 하다. 2일차 박종천씨의 세션에서도 한 번 언급된 내용이지만, 사소하지만 긴급한 일보다는 중요하지만 긴급하지 않은 일이 우선.


 
 메이플 스토리 : 잘되는 게임을 위한 라이브 코어 개발 (황의권 / 넥슨)

 ( 관련 링크 : rein's worldNDC 2011의 강연 노트를 모아보아요! (05월 30일) )

 또 하나의 좋았던 세션. 사실 NDC 세션 중에서 별로였던 세션은 몇 개 없었지만. 여기에서는 크게 라이브 서비스에서의 혁신과 개선, 이 두 가지 내용에 대해 다루었는데, 이제 막 업계에서 일하기 시작한 뉴비로써 배울 게 굉장히 많았다.

 게임 업계에서는 라이브 서비스를 좋아하는 사람도 있겠고 싫어하는 사람도 있겠지만, 내 경우는 프로젝트에 대한 컨트롤이 안 된다는 점 때문에 라이브 유지보수를 별로 안 좋아한다. 일단은 자기가 짠 코드가 아니라 함부로 코드를 고친다거나 하기가 어렵고, 그 비용을 예상하기가 어렵다. 엄청난 분량의 레거시가 잔뜩 쌓여 있는 상황에서는 작은 수정에 들어가는 비용도 신작에 비해 훨씬 크다. 게다가 팀의 분위기도 가능하면 사고나 치지 말자~ 이런 경우도 많고. 능력자 입장에서 하고 싶은 건 많은데 현실이 안 받쳐주는 경우 그냥 모럴이 붕괴해버리는 결과도 많을 것이다.

 세션과는 무관하게 여기에 대해서는 할 말이 참 많은데 (...) 코드의 의도를 설명하는 주석이나 가능한 경우 기초적인 단위 테스트 작성 정도는 후에 해당 코드를 유지보수할 사람에 대한 최소한의 예의다. 조금 더 욕심을 부리자면 더 좋은 코드를 - 사이드 이펙트가 적고 구조화, 모듈화가 아주 잘 되어 있는 등 - 작성하는게 좋겠지만, 이건 개개인의 능력에 달린 일이니 강요할 수는 없다. 하지만 앞에서 말한건 최소한의 의지만 있어도 할 수 있는 일인데 이조차 안 한다는 것은 그냥 배려가 없다는 이야기.

 이 세션을 들으면서 느낀 점 중 하나가 발표자 분이 이런 모럴적인 부분에 대해 경험적으로든 직관적으로든 잘 이해하고 있다는 점이었다. 특히나 지금 현재 상황이 더 나아지고 있다는 느낌을 주는게 중요하다는 점을 강조하는 부분이 인상 깊었다. 내츄럴 본 개발자들이 싫어하는 것은 환경을 개선조차 할 수 없도록 만드는 상황이지, 개선 자체의 어려움이 아니다.

 하지만 라이브이기 때문에 이런 개선/변경도 영향력 검토가 수반되어야 한다. 어떻게 보면 약간 과하다고 생각될 정도로[각주:4] 방어적인 자세가 아닐까, 하는 생각도 들긴 했지만 이 정도는 의견 차이일 뿐 맞다/틀리다를 논할 부분은 아닐 것 같다. 어찌되건 수정이 사이드 이펙트를 가져오지 않도록 보장하는 것은 상품을 돈 받고 파는 입장, 조금 더 세련되게 말하자면 프로로써 지극히 당연한 일이기 때문.

 그보다 중요한 것은 이런 자세를 취하면서도 현 상황을 개선하려는 의지를 놓지 않는다는 점인 듯하다. 그 사이의 타협점을 맞추는게 리더로써 중요한 자질이 아닐까 싶고. 실제로 레거시에 이런 개선 사항을 적용하려면 타협이 없을 수 없는데, 우리는 이런 적용을 위해 어떤 타협을 했으며 결과적으로 어떤 개선을 했다, ndc에는 전반적으로 이런 세션이 많아서 좋았음. 실제로 당장은 아니라도 근 시일 내로 레거시 프로젝트에 적용해 볼 만한 내용이 많았던 듯.



 마비노기 영웅전 자이언트 서버의 비밀 (양승명 / 넥슨)

 ( 관련 링크 : rein's world, NDC 2011의 강연 노트를 모아보아요! (05월 30일) )

 단일 서버의 구현에 대한 내용으로 기술적/설계적으로 가장 흥미로웠던 세션 중 하나였다. 일단은 성공적으로 서비스하고 있는 게임의 서버 구조를 다룬다는 자체만으로도 흥미로운데 게다가 그 서버는 C#으로 만들어진 확장성 있는 단일 서버인데다 크고 아름다운 구조를 가지고 있다. (...) 내가 발표의 문맥을 제대로 이해했다면 이러한 기술적인 결정은 전적으로 엔지니어들 선에서 이루어진건데, 이게 사실이면 데브캣은 엔지니어들이 재미있게 일할 수 있는 환경이 아닐까 싶다는 생각.

 전반적으로 다양한 컨셉들의 추상화가 꽤 잘 되어 있다는 느낌을 받았다. 기본적으로 Service로 수행 주체를 추상화하여 물리적인 서버 대신 추상적인 서비스를 이용하도록 하는 것이 핵심 아이디어. 여기에 Entity로 대상 객체를[각주:5], Operation으로 수행 로직을 정의하고 이를 적절하게 분산 처리하면 scalable한 throughput을 얻을 수 있지 않겠느냐...는 접근인 듯.

 다만 퍼포먼스 측정을 위한 프로토타이핑이 선행되지 않아서인지 예상보다 좋지 않은 성능을 보여줬다고 하는데, 현재로써는 부하가 가장 큰 메시지 직렬화 부분을 극한으로 최적화하는 정공법으로 문제를 완화했다고 한다. 따지고 보자면 근본적인 문제를 해결한 것은 아닌 듯. 개인적으로는 데이터를 처리하는 로직의 시간 복잡도에 추가로 서버 간 커뮤니케이션으로 인해 데이터의 크기까지도 시간 복잡도에 영향을 주게 되는게 핵심적인 문제라고 보이는데[각주:6], 이런 면을 볼 때 게임 서버에 있어서 로직 처리와 이를 분산시키는 기반 엔진의 칼 같은 분리는 아직까지 쉬운 일이 아닐지도 모르겠다. 

 또 하나 궁금했던게 레이턴시였는데... 실시간 게임이라면 레이턴시가 무척 중요한 로직이 반드시 있다. 그런데 지금의 구조에서는 한 엔티티는 계속해서 한 서비스가 처리하는데, 특정 서비스에 부하가 걸리면 해당 서비스에 걸려있는 엔티티 전부가 느려지고, 엮여 있는 서비스들까지 연달아서 반응이 느려지는 문제가 있었다고 한다. 이게 단일 프로세스 내부의 쓰레드 간 로드 밸런싱이라면 그냥 랜덤하게 Work stealing하는 구조로 만들어서 간단하게 해결할 수 있지만 분산 처리에서는 발생 빈도에 비해 그 자체의 커뮤니케이션 오버헤드가 너무 크기 때문에 쉽게 택할 수 있는 방법은 아닐 듯 하다.

 사실 이 부분에 대해서는 그다지 경험이 없어서 잘 모르겠고 또 결과론적인 이야기지만 서비스를 기준으로 서버 인스턴스를 나누기보다는 엔티티를 기준으로 서버 인스턴스를 나누는게 좀 더 나은 성능을 보이지 않았을까? 하나의 오퍼레이션이 여러 서비스를 돌면서 생기는 커뮤니케이션 오버헤드와 한 서버 인스턴스가 하나의 서비스만을 다룸으로 생기는 성능적인 이득을 비교해보면 아무래도 앞이 더 크지 않을까 하는 생각이 들었다. 구조는 약간 덜 우아하겠지만 ;

 또 하나 눈 여겨 볼 부분 중 하나가 바로 DB로 인한 병목. DB의 이론 중 하나로는 CAP 이론이 있는데, 간단히 말해서 일관성, 가용성, 분할 가능성 셋 모두를 만족시키는 DB는 없다는 이야기다. 이 중 분할 가능성은 다르게 말하면 확장성이라고 볼 수 있는데, 대부분의 게임 서버에서는 게임 디자인 상 일관성, 가용성 모두가 필요하기 때문에 확장성을 포기하는 RDBMS를 쓰게 된다. 그런데 기술적인 문제를 해결하는 대신 게임 디자인을 약간 바꾸는 선택을 취한다면 어떨까 하는 생각이 들었다. 이를테면 일관성의 레벨을 eventual consistency 수준으로 낮추는 대신 비일관된 경우를 최대한 덜 보도록 디자인 차원에서 플레이어들을 적절하게 클러스터링을 해준다면? 등등. 물론 망상 수준이다 (...)

 그런데 이런 것 다 제쳐두더라도 이런 구조의 게임이 실제 수만명 단위의 동접자를 받아 성공적으로 서비스된다는 자체가 무척 인상적이었다. 한 10년 지나면 반응성이 중요한 심리스 MMO 같은 게임에서도 적용할 수 있는 분산 서버 모델이 나올 수 있을 것 같다는 생각도 들었다. 물론 그런 시대도 이러한 기술적인 도전이 계속되어야 나올 것이라는 점에서 무척 가치 있는 도전이었다고 생각한다. 




 첫 날은 인상적인 세션이 많았다. 덕분에 글도 길어짐.
  1. 와우의 경우 리치왕의 분노 시절까지 신규 유저의 70%가 10레벨 이전에 떨어져 나갔다. 친구 초대 등도 있지만 기본적으로 결제가 필요한 유료 게임이라는 점에서 상당히 충격적인 지표다. [본문으로]
  2. "루리웹에서 까이면 성공한다"의 약자. - -; [본문으로]
  3. 와우의 레이드에는 하드 모드라는게 있다. 하드 모드 도입 이전에 캐쥬얼 게이머들은 하이엔드 컨텐츠를 해보지 못한다고 징징거렸는데, 도입 이후에는 할 게 너무 많다 (...), 코어 게이머들과 격차가 너무 많이 난다 등으로 징징거린다. [본문으로]
  4. 개인적으로 64비트 플랫폼으로의 포팅은 msdn의 가이드만 꼼꼼히, 충실히 따라도 큰 문제 없다고 생각한다. 물론 이 경우는 포팅 자체보단 테스트의 문제였지만. 자체적인 스트레스 테스트가 가능했다면 좀 다른 선택을 했을지도 모르겠다는 생각이 들었다. [본문으로]
  5. 이건 추후 성능 문제가 발생한 이후 추가된 개념 [본문으로]
  6. 물론 분산 처리 자체로 인해 constant factor가 낮아지는 것도 있겠지만... [본문으로]

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

NDC 간략한 참관기 #3  (4) 2011.06.19
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
tags : ndc, 개발, 참관기
Trackback 0 : Comment 0

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

개발 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

Visual Studio는 C/C++을 싫어해 (?)

개발 2011.02.27 12:58
 이번에 새로운 프로젝트를 세팅하면서 해보려고 했던 것 중 하나가 비쥬얼 스튜디오의 텍스트 파일의 기본 인코딩 방식을 CP949에서 UTF-8로 바꾸는 것이었다. (여기에서 뭔가 이상함을 느끼는 사람들도 있을텐데, 일단 계속 읽어주시길 바란다.)

 내가 개인적으로 작성하는 코드 대부분은 가급적 영어로만 작성하려고 하기 때문에 유니코드를 고집해야 할 절체절명의 이유는 없다. 하지만 앞으로도 한글을 전혀 적지 않는다는 보장은 없는 법이며, 가끔 가다 ASCII 영역 이외의 문자 및 인코딩 방식을 사용해서 컴파일에 문제가 발생하는 오픈 소스 프로젝트 덕분에 귀찮음을 겪은 적이 있었다. 그래서 발생하지 않을만한 경우까지 고려하는 성급한 일반화 Premature generalization를 하는 안 좋은 버릇이 발동한 것이다.

 사실 비쥬얼 스튜디오에서 저장하려는 파일의 인코딩을 바꾸는 것은 그리 어려운 일이 아니다. 버젼마다 방법이 약간 다르긴 한데, 2010에서는 File - Advanced File Options...을 택하면 현재 편집 중인 텍스트 파일의 인코딩 및 개행 방식을 바꿀 수 있다. 다만 이게 파일 한 개 단위로 적용이 되기 때문에 프로젝트 전체에 대해 일괄적인 변경을 가한다거나 하는 작업은 불가능하다. 내가 바라는 것은 솔루션/프로젝트 단위로 적용할 수 있는 파일 정책인 것이다.

 그리하여 열심히 웹을 뒤지다 보니 놀라운 문구를 하나 발견할 수 있었다.


Visual Studio saves files in UTF-8 encoding by default, which is a Unicode-based format and should be handled appropriately by most computers.

VS2010: Unicode file saving | Microsoft Connect
 
???!!!


 ... 그렇다면 그간 CP949로 저장되어 온 수 많은 나의 .cpp, .h 파일들은 대체 어떻게 된 것인가? 하지만 내 실수가 있을 수도 있기에 아래의 단계를 밟아 다시 한 번 검증해 보기로 하였다.

  1. 새로 프로젝트를 만든다.
  2. 한글을 포함하는 소스 파일을 만든다.
  3. 저장한다.
  4. 인코딩 방식을 확인한다.
 아니나 다를까 역시나 CP949로 저장이 되어 있다. 하지만 이 것만 가지고 무려 Microsoft 공식 답변의 권위를 무시하기는 어려웠기에 조금 더 정확한 정보를 얻기 위해 다른 정보를 찾아보았고, 똑같은 사이트에서 무언가 수상한 문구를 하나 찾을 수 있었다.


We did switch to using UTF-8 w/BOM from VS 2008 onwards,
with a few exceptions for Native Tools.

Source FIle Encoding. | Microsoft Connect


 즉, 2008에서 만든 네이티브 프로젝트에선 사용자 컴퓨터의 로케일에 맞는 지역 인코딩을 기본으로 택한다는 이야기이다.[각주:1] 그렇다면 애초에 유니코드가 기본인 CLI 계열의 프로젝트는 이미 UTF-8로 잘만 저장을 해왔다는 것이다... 이 충격적인 진실을 확인하기 위해 기존에 만든 C# 소스 파일들을 뒤져보니 역시나 UTF-8 with BOM으로 저장되어 있었다. orz

 요약하자면,
  • VS 2008까지는 일부 네이티브 프로젝트의 텍스트 파일은 UTF-8이 아닌 시스템 로케일에 따른 인코딩 방식으로 저장된다.
  • VS 2010에서는 모든 텍스트 파일의 기본 인코딩 방식은 특별히 명시하지 않는 한 UTF-8 with BOM이다.
  • 그런데 네이티브 프로젝트들은 여전히 시스템 로케일에 따라 인코딩 방식이 선택된다. orz
  • 따라서 우리는 MS가 이런 간단한 문제조차 신경쓰지 않을 정도로 네이티브 개발자들을 홀대한다는 결론을 내려볼 수 있다. (??)

 

  1. 물론 위의 답변에서 VS 2010은 자동으로 UTF-8을 택한다고 했으니 이론적으로 VS2010에서는 네이티브 프로젝트라고 해도 UTF-8로 저장되는게 맞다. [본문으로]

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

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
RVO와 NRVO  (0) 2010.04.26
Trackback 0 : Comments 3

Bitwise Switch

개발 2011.02.19 01:56

 코딩하다 비트셋을 쓰면 아래 같이 각 플래그들을 검사해서 해당되는 경우 관련 작업을 수행하는 식의 코드를 줄줄이 작성하는 경우가 가끔 있다.


if (bitset & FLAG_A) {
	/* A 플래그와 관련된 작업들 */
}

 ...

if (bitset & FLAG_Z) {
	/* Z 플래그와 관련된 작업들 */
}


 오늘 위와 같은 코드를 보다가 아래처럼 비트셋에 적용되는 switch 문이 있으면 좋겠다는 생각이 들어서 검색해보았는데 못 찾아서 (그 이유는 아래에서 밝혀진다 -_-) 직접 만들어 보기로 하였다.


bitwise_switch(bitset) {
	case FLAG_A: /* A 플래그와 관련된 작업들 */

	...

	case FLAG_Z: /* Z 플래그와 관련된 작업들 */
}


 사실 구현 자체는 그리 어려운게 아니지만 비트 연산을 이용한 몇 가지 트릭이 필요했다. 일단 코드는 아래와 같다.

 
// Calculate LSB index in O(1) using de bruijn sequences.
// For more information, read "Using de Bruijn sequences to index a 1 in a computer word"
inline const int GetLSBIndex(const uint32_t LSB)
{
	static const uint32_t DeBruijnConstant32 = 0x077CB531U;
	static const int32_t HashTable[32] =
	{
		 0,  1, 28,  2, 29, 14, 24,  3,
		30, 22, 20, 15, 25, 17,  4,  8,
		31, 27, 13, 23, 21, 19, 16,  7,
		26, 12, 18,  6, 11,  5, 10,  9
	};

	return HashTable[static_cast(LSB * DeBruijnConstant32) >> 27];
}

inline const uint32_t ExtractLSB(uint32_t& bitset)
{
	uint32_t LSB = bitset & (-bitset);
	bitset = bitset ^ LSB;
	return LSB;
}

#define bitwise_switch(bitset) \
	for (uint32_t __bitset__ = bitset; __bitset__ != 0;) \
		switch (GetLSBIndex(ExtractLSB(__bitset__))) \


 동작은 아주 간단하다. 주어진 비트셋에서 Least Significant Bit를 추출한 뒤 추출한 LSB의 위치를 계산, 이 값을 switch-case 문으로 넘기는 것을 반복하는 것이다.
 
 위 코드에서 함수 ExtractLSB는 주어진 비트셋에서 LSB를 뽑아내는 비트 연산 기법인 bitset & (-bitset)을 구현한 코드로 쉽게 이해할 수 있다. 함수 GetLSBIndex는 주어진 LSB 값을 받아 오프셋을 계산하는 함수인데, 이를 위해 De Bruijn 수열을 이용, Perfect hash function을 만들어 사용한다. 알고리즘의 원리가 궁금하신 분들은 이 논문을 참조하시면 되겠다. 비트 값을 굳이 이렇게 오프셋 값으로 바꿔서 사용하는 이유는 switch-case 분기문에 주어지는 인자가 연속된 정수 값인 경우 컴파일러가 이를 점프 테이블을 이용하는 간접 분기문으로 바꾸는 최적화를 해주기 때문이다.


 최적화가 목표였던 건 아니지만 그래도 각각의 비트에 대한 분기 처리가 O(1)에 처리되도록 신경을 썼으니 처리 속도가 궁금해진다. 그렇다면 이 기법을 이용하는 경우 속도가 얼마나 빨라질까? 불필요한 비트 검사가 생략되니 빨라질 수도 있겠지만, 비트 하나를 검사하는데 들어가는 비용이 확연히 커졌으니 느려질 수도 있다. 과연 결과는?


열기



 오늘의 교훈: 최적화 같은거로 고민하지 말자. 최적화로 얻는 시간보다 최적화에 들이는 시간이 더 많을거다. -_-;;

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

기본 타입 간 (무분별한) 변환은 자제합시다  (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
RVO와 NRVO  (0) 2010.04.26
volatile과 메모리 배리어  (2) 2009.11.11
Trackback 0 : Comments 2

std::unique_ptr

개발 2010.04.26 22:32

 C++ 차기 표준인 C++0x에서는 다양한 언어적 차원의 기능과 라이브러리들이 추가되었는데, 라이브러리 중 unique_ptr라는 상당히 흥미로운 물건이 추가되었다. 이 클래스의 역할은 기존의 auto_ptr 클래스를 대체하는 것인데, 이를 이해하려면 auto_ptr에 대해 어느 정도 이해가 필요하다.

 
 auto_ptr는 C++ 코딩의 원칙 중 하나인 RAII[각주:1]를 구현하기 위해 표준 라이브러리에 존재하는 클래스로, auto_ptr 객체가 정의된 스코프를 벗어나면 해당 auto_ptr에 저장된 객체의 해제를 보장하는 역할을 한다. 간단히 말해 스마트 포인터인 셈이다. 안 그래도 복잡한 언어인 C++에서 이러한 스마트 포인터의 존재는 코드의 복잡도를 낮추는데 무척 중요한 역할을 한다. 자원 할당 및 해제까지는 조심스럽게 코딩함으로써 어찌 할 수 있겠으나, 예외 안전성의 영역까지 가면 자원을 사람이 일일히 관리하기가 거의 불가능한 수준이 되기 때문이다. [각주:2]

 헌데 auto_ptr 클래스의 동작을 잘 보면 상당히 특이하다. 일반적으로 복사 생성자와 대입 연산자는 동일한 객체를 두 개 만든다는 의미에 맞게 동작하는데, 이 경우는 포인터의 소유권을 이전하는 방식으로 동작한다. 이를테면 auto_ptr 객체 a, b가 있을 때 a에 b를 대입하면 a가 가리키고 있던 객체는 해제되고 b에 있는 객체를 가리킨다. 그리고 b에는 널 포인터가 들어간다. 결과적으로 한 포인터를 가리키는 auto_ptr 객체는 동시에 하나만 존재하게 되어 이중 해제가 되지 않음을 보장하는 것이다. (물론 잘못 쓰면 충분히 가능한 일이지만)


 문제는 STL 컨테이너를 사용하는 경우 컨테이너에 들어가는 객체들에 요구되는 조건 중 하나가 복사 생성자와 대입 연산자가 동일한 객체를 두 개 만든다는 의미를 가져야 하는 것이다. 헌데 auto_ptr는 이러한 조건을 지키지 않기 때문에 STL 컨테이너에 사용하는 경우 내부에서 이루어지는 대입, 복사 과정에서 가리키는 포인터가 마구마구 해제되어 버리는 불상사가 발생한다. 그렇기 때문에 표준에서 auto_ptr를 STL 컨테이너를 구체화시키기 위한 타입으로 사용하는 것을 아예 금지하고 있으며[각주:3]여기를 참조하자.'>, boost에서는 아예 복사라는 개념 자체를 제거한 scoped_ptr을 제공한다.

 C++에서 STL 컨테이너에 담을 수 없는 클래스라면 아무리 좋은 물건이라도 반쪽 클래스일 수 밖에 없다. 이러한 것을 극복하기 위해 소유권을 이전하는 형식이 아닌, 소유권을 나누는 방식의 shared_ptr이 제안된다. 이는 레퍼런스 카운팅 방식으로 동작하는 포인터로 사실상 모든 경우에 대해 포인터를 대체할 수 있으며 대부분의 경우에 대해 멀티 쓰레드 환경과 예외에 대해 안전하고 가리키는 포인터가 없어지는 경우는 해당 자원이 자동으로 해제되기 때문에 무척 유용하다. 순환 참조에 대해서만 조심하면 자바나 C#의 가비지 콜렉터가 부럽지 않을 정도다.

 그러나 모든 편리함은 그 댓가를 필요로 하는 법이다. shared_ptr은 자체적으로 레퍼런스 카운팅을 제공하지 않는 클래스에 대해서도 동작해야 하기 때문에 각 개체마다 레퍼런스 카운트 값을 저장할 공간을 추가로 가져야 한다. 이 값은 shared_ptr마다 하나가 아니라 가리키는 객체마다 하나씩 필요하므로 새로운 포인터를 가리킬 때마다 추가로 힙 공간을 할당해야 한다는 의미이다. 게다가 shared_ptr의 복사 및 소멸마다 레퍼런스 카운트 값을 조절해야 하는데 이는 쓰레드 안전해야 하므로 비교적 수행 속도가 느린 atomic operation을 이용해야 하며, 이는 전부 힙 공간을 조작하는 행위이므로 캐쉬 지역성에 악영향을 끼칠 우려가 있다. 여기에 추가로 객체 접근을 위해 포인터를 역참조할 때 이 값이 shared_ptr에 저장된다면 shared_ptr의 크기가 2배로 불어나며 레퍼런스 카운트를 저장해두는 곳에 저장한다면 간접 참조를 해야 하므로 메모리 연산이 두 번 일어나는 셈이다.

 추가 - 다만, shared_ptr과 대상 객체를 한 번에 만드는 make_shared 함수를 사용하면 메모리 할당을 한 번으로 줄이고, 레퍼런스 카운트 값을 객체에 인접시켜 저장하는 구현이 가능하다. 하지만 현재 vc 같은 경우는 아주 멍청하게 구현이 되어 있으므로 이건 그냥 shared_ptr을 쓰는 것과 차이가 없다. 제대로 구현을 하기 위해서는 type erasure를 이용한 다소 까다로운 테크닉이 들어가야 하기 때문.

 물론 대부분의 경우 shared_ptr의 유용성은 이러한 오버헤드를 충분히 감수하고도 남을만 하지만, 해당 객체의 수명이 아주 명확한 경우까지 shared_ptr을 쓰는 것은 낭비라고 볼 수 있다. 다시 말해 auto_ptr와 shared_ptr 사이의 절충안이 있으면 좋겠다는 의미다. 이를 위해 C++ 표준 위원회에서는 unique_ptr이라는 클래스를 새롭게 도입한다.


 unique_ptr은 기본적으로 auto_ptr와 유사하게 소유권의 이전에 기반한 동작을 한다. 그러나 일반 복사 생성, 대입 연산이 아닌 C++0x에서 새롭게 추가된 R-value reference를 이용한다는 것이 틀리다. 자세히 들어가자면 무척 긴 내용이라 시시콜콜 설명하진 않겠으나 R-value reference를 이용한 복사 생성와 대입 연산은 의미적으로 값의 복사가 아닌 값의 이동을 뜻한다. [각주:4] 때 마침 STL 컨테이너에서도 효율성을 위해 내부적인 복사 및 대입 동작은 전부 R-value reference를 이용하도록 바뀌었는데, 일반 복사 및 대입 연산자를 막아 버리는 대신 R-value reference를 이용한 복사 및 대입 연산자만 정의한다면 STL에서도 사용할 수 있는 auto_ptr가 생긴다는 것이 unique_ptr의 의미이다.


 unique_ptr와 auto_ptr의 차이점은 대충 아래와 같이 정리할 수 있다.

  • 일반 복사, 대입 연산을 막은 대신 R-value reference를 이용함으로써 그 의미가 뚜렷해진다.
  • 또한 STL 컨테이너에서도 사용할 수 있다.
  • 해제자를 따로 지정할 수 있다. (배열의 경우는 템플릿 특수화를 통해 전용 해제자를 이미 정의해놓은 상태이다)

 간단히 말해 auto_ptr가 할 수 있는 것은 unique_ptr도 전부 할 수 있으며, 거기에 다양한 기능이 추가된데다 raw pointer에 비해 추가적인 오버헤드도 없는 스마트 포인터라 할 수 있다. 앞에서 말한 auto_ptr를 대체하기 위한 클래스라는 것은 바로 이런 의미이다. 다만 의미적인 뚜렷함과 안전한 사용을 위해 대부분의 암시적인 변환이 막혀 있고, 일반적인 대입 및 복사가 막혀 있으므로 unique_ptr 끼리의 대입에는 std::move 함수를 명시적으로 사용해야 하는 등 코딩량이 다소 늘어난다는 불편함이 있으나, 안전한 코딩을 위해서라면 이 정도는 충분히 감수할 만 하다.


 unique_ptr의 경우 개인 프로젝트인 C--에서도 상당히 유용하게 쓰고 있는데, 이를 잘만 쓰면 아무런 오버헤드 없이 유효 범위가 확실한 객체에 대해 delete를 전부 제거할 수 있으며, 나머지도 shared_ptr을 이용하여 제거할 수 있다. 유일하게 예외가 있다면 union을 쓰는 경우인데 (union의 멤버는 생성자를 가질 수 없다) 이 경우는 사용자가 따로 래퍼 클래스를 만들어야 한다.

 개인적으로는 boost의 intrusive_ptr[각주:5]의 인터페이스를 좀 더 다듬어서 오버헤드가 적은 shared_ptr의 모양새로 표준에 도입해보는 것도 좋지 않을까 생각하는데, 표준화 위원회는 이 쪽에 관심이 없거나 이 정도면 충분하다고 생각하는 것 같다. 내가 만들어 볼까 생각도 해봤으나 모든 코너 케이스를 고려하면서 안전한 스마트 포인터 클래스를 만드는 것은 그리 만만한 일이 아닐 것 같다.

  1. Resource aquisition is initlaization, 자원의 획득은 객체의 생성과, 해제는 객체의 소멸과 일치시키라는 의미로, 컴파일러가 자동으로 관리해주는 객체의 생명 주기와 자원의 생명 주기를 일치시켜 컴파일러로 하여금 자원을 자동적으로 관리하도록 하는 정책을 의미한다. [본문으로]
  2. 여담이지만 C++에서 delete란 만악의 근원으로, 가능하면 컴파일러가 알아서 삽입하도록 코드를 짜는게 좋다. C++에서는 그 방법으로 제안되는 것이 스마트 포인터이다. [본문으로]
  3. '관련 [본문으로]
  4. R-value reference가 도입된 목적이 어차피 곧 무효화될 임시 객체에 대해서는 값을 복사하지 말고 이동시켜서 복사에 따른 오버헤드를 줄이자는 것이기 때문이다. [본문으로]
  5. shared_ptr과 비슷하게 레퍼런스 카운트를 하지만 이 녀석은 개체 자신이 레퍼런스 카운트 값을 유지해야 한다는 제약이 있다. 물론 퍼포먼스 측면에서는 그만큼 이득이 있다. [본문으로]

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

Visual Studio는 C/C++을 싫어해 (?)  (3) 2011.02.27
Bitwise Switch  (2) 2011.02.19
std::unique_ptr  (0) 2010.04.26
RVO와 NRVO  (0) 2010.04.26
volatile과 메모리 배리어  (2) 2009.11.11
C/C++의 몇 가지 키워드들  (1) 2009.11.07
Trackback 1 : Comment 0

RVO와 NRVO

개발 2010.04.26 21:08

 컴파일러가 컴파일 시간에 수행하는 최적화는 참으로 다양하다. 의미 없는 코드를 삭제하는 Dead code elimination 같은 기본적인 테크닉부터 함수 내부 코드만이 아니라 함수 사이의 관계까지 최적화를 하는 Interprocedural optimization까지 많은 종류의 최적화가 수행된다. 특히나 템플릿을 적극 활용하는 스타일의 C++ 코딩에서는 많은 변인들이 컴파일 시간에 정적으로 결정되기 때문에 이러한 최적화의 혜택을 많이 받는다. [각주:1] 이로 인해 C++의 템플릿을 강력한 유연성과 높은 효율 두 마리 토끼를 함께 잡을 수 있는 것이다.

 이러한 최적화로 인해 얻어지는 수행 효율의 증대는 크누스 교수님으로 하여금 어설픈 최적화는 모든 악의 근원이다라는 말까지 남기도록 하였다. 그럼에도 불구하고 쪼잔한 우리 프로그래머들은 컴파일러를 믿지 못하고 문장 단위의 최적화를 시도하는 경우가 많은데, 이 중 가장 대표적인 사례를 들라면 함수의 반환값을 복사 대신 참조로 넘기기 위한 다양한 노력이 있다. 스캇 마이어의 Effective C++에서도 함수의 반환값을 참조로 넘기지 말라고 강조를 하지만 복사 생성으로 인해 성능에서 피를 본 적이 있는 사람이라면 이러한 유혹에 빠질 수 밖에 없는 것이다.

 이를테면 스코프에 대한 개념이 잘 잡혀 있지 않은 초보 프로그래머들이 '참조로 넘기는게 효율이 좋데'라는 말을 듣고 최적화를 하려고 한다. 그런데 그렇게 큰 객체가 아니라 힙에다가 매 번 할당하는 것은 비효율적이고 또 수동 할당 해제 등의 번거로움도 싫다. 그래서 힙에 할당을 하지 않고 그냥 스택에 있는 객체를 이용하려 한다 치면 아래와 같은 실수를 저지를 수 있다.

SomeClass& someFunction()
{
    SomeClass ret;
    // Do something...
    return ret;
}

 당연히 안 될 말이다. SomeClass의 인스턴스 ret는 해당 함수가 끝나는 순간 그 유효 기간이 다 한다. 자동으로 소멸자가 호출되어 관련 정보를 깨끗하게 정리해버리는 것이다. 이 상황에서 객체 자체가 아닌, 객체의 참조를 반환한다면 대재앙이 일어날 수 밖에 없다. 그렇다면 이건 어떨까?

class SomeClass
{
    // ...

    SomeObject buffer_;
}
SomeObject& SomeClass::getSomeObject()
{
    // do Something on buffer_
    return buffer_;
}

 이는 큰 문제 없이 돌아가겠지만, 객체 안의 객체를 반환할 경우에나 사용할 수 있다는, 다시 말해 원본 객체 상태의 일부에 한정되는 객체만 반환 가능하다는 한계점을 가지고 있다. SomeClass에 종속적이지 않은 객체를 만들고 싶은 경우라면 사용할 수 없는 코드인 것이다.

 그렇게나 효율을 강조하는 언어인 C++에서 이런 문제 하나 제대로 해결을 못하고 결국 무작정 복사를 해야 한다니 무언가 이상하다. 컴파일러 개발자나 C++ 표준 위원회 등이 이러한 내용에 대해 고민을 안 해봤을리가 없다. 그리고 이 문제에 대해 Return value optimization(RVO)이라는 해법을 내놓는다. RVO란 함수가 특정 값을 반환할 때 객체를 생성하고 복사, 소멸시키는 삼중 오버헤드를 단순히 객체 생성 한 번으로 끝내도록 만드는 최적화이다. 우선 아래의 코드를 보자.

SomeClass someFunction()
{
    // Do something...

    return SomeClass(someArgument);
}

 이렇게 반환문에 객체 생성문을 명시했다는 것은 실제 반환되는 위치에 곧바로 해당 객체를 생성하여도 의미적으로 볼 때 차이가 없을 것이라는 의미이다. 그렇다면 위의 코드는 아래와 같은 코드로 바뀔 수 있다. (정확하게 아래와 같지는 않다. 단지 이렇게 될 것이라는 것을 말할 뿐이다.)

void someFunction(SomeClass& returnTo)
{
    // Do something...

    returnTo.SomeClass::SomeClass(someArgument); // Construct SomeClass on returnTo
}

 이렇게 반환값 최적화가 일어나면 반환값을 굳이 참조로 넘기지 않아도 효율적인 코드가 생성된다. 헌데 위와 같은 최적화만 가지고는 아무래도 반환하는 객체에 대한 표현력이 생성자에 의해 곧바로 만들어 질 수 있는 익명 객체 수준으로만 한정되기 때문에 아쉬운 점이 있다. 이를 보완하는 최적화 테크닉도 있는데 이는 Named return value optimization(NRVO)으로, 이름을 가진 반환값까지 이러한 최적화의 대상으로 만든다.

Object generate()
{
    Object someObject;

    someObject.doSomething();

    return someObject;
}

 이런 코드는 앞서 언급한 RVO만 가지고는 반환값 최적화의 혜택을 받을 수 없는데, NRVO는 이마저 최적화시켜버린다. someObject가 있다고 치면 굳이 someObject를 생성할 필요 없이 반환값을 저장할 변수를 가져와서 그 곳에다가 someObject 관련 작업을 해버리는 것이 포인트이다.

void generate(Object& returnTo)
{
    returnTo.Object::Object();
    returnTo.doSomething();
}

 물론 실제 코드가 이렇게 간단하게 최적화될 수 있다면 고대 컴파일러인 VC 6.0에서부터 구현이 되었을 것이다. 하지만 실제로 사용하는 코드들은 이보다 복잡한 경로를 거쳐 값들을 반환하기 때문에 훨씬 복잡한 알고리즘을 동원하여 최적화를 하는 것으로 보인다. MSDN에 따르면 NRVO는 2005부터 지원을 하고 있다. [각주:2]

 다만 이 최적화의 문제라면 생성자, 소멸자의 호출 횟수가 달라지면서 같은 코드라도 내는 결과가 최적화 여부에 따라 달라질 수 있다는 점인데, 웹에서 본 바에 따르면 이러한 최적화는 C++ 표준에서 이미 허용 가능 한 범위로 정의된 것으로 보인다. 성능이 좋은 컴파일러를 이용하는 경우라면 적어도 값 반환에 따른 복사의 오버헤드를 걱정할 필요는 없다는 이야기이다. 즉, 대부분의 경우 값을 반환하는 루틴을 최적화하는 것은 어설픈 최적화의 범주에 속한다는 의미.

 기계의 관점에서 쪼잔할 정도로 코드를 최적화하는 것[각주:3]이 과거에는 의미가 있었을지 모르겠으나, 요즘 컴파일러들의 최적화 실력을 보면 어설픈 수준의 최적화는 오히려 성능을 낮추고 코드의 유지 보수를 힘들게 만들 가능성이 높다. 이러한 수준의 최적화는 컴파일러에게 맡기고, 사람은 프로그램의 흐름이나 논리를 면밀하게 검토하여 불필요한 연산을 제거하거나 보다 더 효율적인 알고리즘으로 교체하는 등 기계가 할 수 없는 보다 더 높은 수준에서의 최적화를 해야 할 것이다.

  1. 예를 들어, VS의 STL vector 구현을 보면 값 하나 참조하는 데에만 함수가 몇 번씩 호출되어 콜스택이 몇 중으로 쌓이는데 최적화된 목적 코드에서는 이 코드들이 전부 인라인되고 상수 브랜칭들도 다 삭제되어 해당 객체의 포인터를 곧바로 가리키는 코드로 바뀐다. [본문으로]
  2. 해당 사이트에 NRVO가 적용 안 되는 경우 등이 자세히 설명되고 있으므로 한번 읽어 볼 만 하다. [본문으로]
  3. 이를테면 배열 대신 포인터를 쓴다거나 [본문으로]

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

Visual Studio는 C/C++을 싫어해 (?)  (3) 2011.02.27
Bitwise Switch  (2) 2011.02.19
std::unique_ptr  (0) 2010.04.26
RVO와 NRVO  (0) 2010.04.26
volatile과 메모리 배리어  (2) 2009.11.11
C/C++의 몇 가지 키워드들  (1) 2009.11.07
tags : c++, NRVO, RVO, 개발, 최적화
Trackback 0 : Comment 0

volatile과 메모리 배리어

개발 2009.11.11 19:36

 이전 글에서 volatile 키워드에 대해 간단하게 언급했는데, 핵심은 간단하다. volatile 속성을 가진 변수는 프로그램 밖의 다른 문맥들에 의해서도 비동기적으로 접근될 수 있다. 따라서 특정 쓰레드가 해당 변수에 하는 작업들은 다른 모든 문맥들 역시 볼 수 있어야 한다는 것이다. 하드웨어를 직접 제어하기 위해 Memory-mapped I/O를 하는 경우가 가장 대표적인 예이다. 고로, 프로그램 문맥 상에서는 레지스터만을 이용해서 똑같은 일을 할 수 있는 경우라 해도 가시성의 확보를 위해 컴파일러는 해당 작업을 메모리에도 저장하도록 코드를 만든다.

  

 volatile 속성을 가진 변수는 그 정의대로 동작하기 위해 컴파일러 최적화 기법 중 하나인 명령어 재배치(instruction reordering)의 대상에서 제외된다. 명령어 재배치란 빠른 연산을 위해 일부 연산의 순서를 바꾸어 파이프라인을 최대한 활용하는 최적화 기법인데, 프로그램 밖의 다른 문맥들이 접근할 때 연산의 순서가 뒤바뀐 상태라면 큰 문제가 될 수 있으므로 이러한 조치를 취하는 것이다. 명령어 재배치로 인해 프로그램이 오동작할 수 있는 유명한 예로는 double-checked locking pattern이 있다.

Singleton* getInstance()
{
    if (instance == NULL) {
        lock.lock();
        if (instance == NULL) {
            instance = new Singleton
        }
        lock.unlock();
    }
    return instance;
}

 DCLP는 프로그램 전체에서 한 번만 이루어지는 생성자 호출을 위해 객체가 생성이 된 이후에도 매 번 불필요하게 락을 얻는 오버헤드를 줄이려는 의도에서 나온 패턴이다. 이는 우선 instance가 비어 있는가부터 체크한 뒤 락을 얻어 객체가 생성되는 순간에만 락을 얻는다. 이를 제시된 코드의 흐름대로만 보면 아무런 문제가 없다. 그러나 여기에서 명령어가 재배치되기 시작하는 순간 문제가 꼬여버리게 된다. 6번째 줄을 더 잘게 쪼개어 본다면 

  1. 메모리를 할당한 뒤
  2. 생성자의 논리에 따라 할당된 메모리를 초기화하고
  3. 해당 메모리 주소를 instance에 대입한다.

 이런 순서가 될 것이다. 그런데 2번과 3번 사이에는 의존성이 없으므로 이 둘을 서로 뒤집어도 단일 프로그램 문맥 상으로는 아무런 문제가 없다. 컴파일러에 따라서는 이 둘의 순서를 뒤집는게 성능 상 더 낫다고 판단, 명령어 재배치를 하자는 결론을 내릴 수도 있다. 이렇게 되면 멀티 쓰레드 환경에서는 아래와 같은 비극이 발생할 가능성도 있다. 

  1. 쓰레드 A가 진입하여 메모리를 할당 받고 이를 instance에 대입한다.
  2. 그 뒤 생성자를 통해 메모리를 초기화하기 시작한다.
  3. 그런데 쓰레드 B가 들어와 2번째 줄을 검사한다. 이 때 instance는 NULL이 아니다.
  4. 초기화가 완료되지 않은 객체가 쓰레드 B에 의해 사용된다.

 이를 막으려면 명령어가 재배치되지 않도록 해야 한다. 이를 위해 instance에 volatile 속성을 넣으면 컴파일러에 의한 재배치는 막을 수 있을 것 같다. 그러면 이걸로 모든게 완벽하게 해결된 것일까? 안타깝게도 그런 것 같지는 않다. 명령어를 재배치하는 것은 컴파일러만이 아니라 CPU 레벨에서도 이루어지기 때문이다. 현대 CPU 중 상당수는 파이프라인 및 명령어 단위 병렬성 등을 최대한으로 활용하기 위해 명령어 간 의존성을 동적으로 분석, 수행 순서를 임의로 바꾸는 비순차 실행(Out of order execution) 기법을 적극 활용한다. 이는 컴파일과는 무관하게 런타임에 이루어지는 것으로, 단순히 생성되는 코드의 순서와 메모리 접근 여부에만 영향을 줄 수 있는 volatile 키워드로는 해결할 수 없는 문제이다.

  

 사실 따지고 보면 컴파일러에 의한 것이건 CPU에 의한 것이건 비순차적 실행이 문제가 될 수 있는 경우는 어렵지 않게 상상해 볼 수 있다. 이를테면 아래와 같은 코드를 생각해보자. 

lock.lock();
a++;
lock.unlock();

 위는 동기화 객체를 사용하는 전형적인 예이다. 그런데 만에 하나라도 비순차 실행에 의해 1번째 줄과 2번째 줄의 코드 수행 순서가 뒤바뀐다고 가정해보자. 우리가 이 코드를 믿고 쓸 수 있을까? 메모리 접근 순서가 제대로 보장되지 않는다면 이런 간단한 코드조차 사용할 수 없게 된다.

 크리티컬 섹션과 같은 동기화 객체에서 중요한 것은 동기화 객체에 의해 보호되는 코드 혹은 객체는 무슨 일이 있어도 동시에 한 쓰레드만이 사용할 수 있어야 한다는 것이다. 이러한 목적을 달성하려면 동기화 객체 사용 이전과 이후를 기준으로 메모리 읽기/쓰기가 구분되어야 한다. 이를 위해 프로세서 내부의 메모리 읽기/쓰기의 순서를 코드에 명시된 순서대로 하도록 제약하는 메모리 배리어(Memory barrier)라는 개념이 도입된다. 메모리 배리어의 종류에도 몇 가지가 있으나, 위와 같은 목적으로는 특정 시점을 기준으로 이전의 모든 읽기/쓰기를 완료한 뒤 이후의 읽기/쓰기를 재개하는 풀 메모리 배리어가 사용된다.

 MSDN에 나온 바에 따르면 Win32 API에서는 각종 동기화 객체와 연관된 함수, 원자적인 연산인 Interlocked 계열 함수, 쓰레드를 블럭시키는 함수에서 메모리 배리어가 사용되며, POSIX쪽의 메모리 배리어에 대해서는 알아보진 않았지만 아마 상식적으로 볼 때 비슷할 것이다. 거기에 C++0x에서는 메모리 배리어가 강제되는 원자적인 연산 관련 함수들이 추가된다. VS2005 이후의 VC++에서는 volatile 키워드에 메모리 배리어를 추가했다지만, 표준 구현이 아니니 volatile을 동기화 목적으로는 사용하지 않는게 좋을 것 같다.

 

 멀티 쓰레드 프로그래밍이 어려운 까닭은 다른게 아니라 이런 로우 레벨의 개념들이 제대로 추상화가 되지 않은 상황이라 이들을 모르고 사용하면 쉽게 잡아내기 어려운 버그가 속출할 수도 있다는 것이다. 게다가 이를 부정확하게 알고서 동기화에 volatile을 함부로 쓴다거나 하는 경우 퍼포먼스가 낮아지는 것은 둘째치고 잡아낼 수 없는 버그가 속출할 가능성이 무척 높다. 자기가 잘 모르는 내용은 아예 쓰지 말도록 하자. 지금 이 말 쓰면서 스스로가 찔리긴 하지만 ;

  

 - 결론 

  • volatile considered harmful - 동기화에는 명시적으로 동기화 객체나 atomic operation만 쓰자.
  • 컴파일러와 프로세서에 의한 명령어 재배치는 엄연히 다른 개념이니 구분하자.

 

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

Visual Studio는 C/C++을 싫어해 (?)  (3) 2011.02.27
Bitwise Switch  (2) 2011.02.19
std::unique_ptr  (0) 2010.04.26
RVO와 NRVO  (0) 2010.04.26
volatile과 메모리 배리어  (2) 2009.11.11
C/C++의 몇 가지 키워드들  (1) 2009.11.07
Trackback 0 : Comments 2

C/C++의 몇 가지 키워드들

개발 2009.11.07 23:47

C++에서는 의외로 사람들이 잘 모르는 키워드가 많다. 이를테면 autoregister 같이 존재 의미부터가 희미한 키워드부터[각주:1] mutable 같이 잘만 쓰면 유용할 수도 있는 키워드, volatile 같이 알려지긴 했지만 사람들이 잘못 이해하고 있는 경우가 많은 키워드, export 같이 컴파일러들에게 외면 당한 키워드 등등 알아보면 C++의 세계는 크고 아름답다무궁무진하다. 그래서 세상에서 제일 익히기 어려운 프로그래밍 언어 타이틀을 땄다.

 

 C/C++에서는 변수를 선언할 때 보통 자료형 이름 앞에 해당 변수의 유효 기간과 가시 영역에 영향을 주는 storage class specifier와 변수의 상수성, 일시성을 지정하는 cv-qualifier, 이 두 분류의 속성들이 붙을 수 있다. 그 외에도 class, struct, enum 등의 키워드를 이용, 즉석에서 자료형을 정의하고 사용하는 것도 가능하지만 자료형 선언과 변수 선언은 서로 분리시키는 것이 보통이므로 변수가 가질 수 있는 속성은 실질적으로 위 두 가지가 전부라고 볼 수 있다.

  

 현재 C/C++ storage class specifier에는 auto, register, static, extern 네 종류가 있고, 멤버 변수에 한해서 mutable이 있다. 대부분의 프로그래머들은 static과 extern 키워드가 무슨 역할을 하는지 잘 알고 있으나 auto와 register는 사실상 사장된 키워드들이라 모르는 경우가 많다.

 우선 auto 키워드는 해당 변수의 가시 영역을 변수가 초기화되는 지점의 scope로 한정시키는 역할을 한다. 즉, 지역 변수를 선언하는데 사용되는 키워드이다. 그러나 C++ 컴파일러는 storage class specifier가 지정되지 않은 모든 변수에는 암시적으로 auto 키워드를 붙여 지역 변수로 분류하기 때문에 이를 명시적으로 사용할 이유는 전혀 없다. 그렇기 때문에 C++0x에서는 가능한 경우에 한해 컴파일러가 타입을 자동으로 유추하는데에 사용하는 키워드로 그 목적이 바뀌었다.

 register 키워드는 해당 변수가 굳이 메모리에 기록될 필요가 없을 때 속도 향상을 위해 가급적 레지스터에만 쓰도록 권유하는 키워드이다. 그러나 대부분의 컴파일러들은 충분히 똑똑하기 때문에 이러한 키워드를 쓰지 않아도 레지스터를 최대한 활용하도록 알아서 최적화를 해준다. 그런 이유로 register는 사실상 거의 쓰이지 않는 키워드이고, 상당수의 컴파일러에서는 이 키워드 자체를 그냥 무시한다.

 static 키워드는 익히 알려진 대로 정적 변수를 선언하는데 쓰이나 예외적인 용법이 있다. 전역 변수에 static을 붙일 경우 해당 변수는 속한 번역 단위[각주:2] 밖으로 변수가 노출되지 않도록 보장한다. 허나 C++의 익명 네임스페이스 역시 똑같은 기능을 제공하므로 C++을 사용한다면 이를 굳이 사용할 필요는 없을 것이다.

 mutable 키워드는 상수 객체에서도 변경할 수 있는 멤버 변수를 지정하는데 사용되는 키워드이다. 이 속성이 지정된 변수는 해당 객체가 상수 객체이거나 상수 멤버 함수에서도 수정이 가능해진다. 이 키워드는 대개 객체의 실제 상태와는 직접적인 연관이 없는 변수에 사용한다. (그다지 좋은 예는 아니라 생각하지만) 이를테면 그래프 객체를 만든다 할 때 현재 객체가 가리키고 있는 노드를 mutable 속성을 지닌 내부 변수로 지정한면 iterator가 따로 없더라도 상수 멤버 함수들을 통해 상수 그래프 객체의 순회를 쉽게 구현할 수 있게 된다.

  

 cv-qualifier에는 constvolatile이 있는데, const는 익히 알려진대로 변수에 상수성을 추가하는 키워드이다. 이는 아주 널리 쓰이고 있으므로 별다른 추가적인 설명은 필요 없을 것이다. 그러나 volatile은 많은 사람들이 그 기능에 대해 오해를 하고 있다.

 큰 오해 중 하나가 "volatile은 해당 객체의 최적화를 막는 키워드"라는 것이다. 결과적으로 본다면 맞는 말이지만, 이는 키워드의 본래 목적을 왜곡할 수 있다. 기본적으로 volatile은 프로그램 문맥 외의 요인으로 인해 해당 객체가 비동기적으로 변경될 가능성이 있음을 컴파일러에게 알려주는 키워드이다. 따라서 컴파일러는 이를 참조하여 해당 객체에 대한 접근을 할 때 매 번 레지스터가 아니라 메모리에서 읽고 쓰도록 바이너리를 작성하며, 또한 병렬성 극대화를 위해 명령들의 수행 순서를 바꾼다거나 하는 공격적인 최적화를 하지 않는다.[각주:3] 그러나 프로그램의 흐름을 바꾸지 않는 최적화까지 막는 것은 아니다. 이를테면

volatile int a = 1;
a = a * 4; // Equivalent to a = a << 2;

 이러한 코드가 있을 때 곱하기보다는 쉬프트 연산이 훨씬 저렴하므로 가능한 경우 곱하기를 쉬프트 연산으로 최적화하는데, 이렇게 프로그램의 흐름을 바꾸지 않는 정도에 한해서는 최적화가 이루어질 수도 있다. 물론 이는 컴파일러 의존적이므로 반드시 이렇다고 단언할 수는 없다.

 또 한 가지의 오해 중 하나는 "멀티 쓰레드 프로그래밍에서 동기화 용도로 사용될 수 있다는 것"이다. 물론 바쁜 대기(busy-waiting) 등에서 CPU 레지스터와 실제 메모리 사이에서 생긴 괴리로 인한 문제 정도라면 volatile을 사용하여 해결할 수도 있으나 이 역시 CPU의 명령어 비순차 실행으로 인한 오류 가능성을 고려해보면 좋은 선택은 아니다. 게다가 data race 등의 문제를 volatile로 해결할 수 있는 방법은 없으며, 이는 atomic operation이나 동기화 객체를 사용하여 해결하는 수 밖에 없다. 예외는 있으나[각주:4] 대부분의 경우 멀티 쓰레드 프로그래밍과 volatile은 아무 상관 없다고 생각하는 것이 속 편하다.

 그 외에 캐시를 사용하지 않게 만든다거나 하는 등의 오해도 있지만, 이는 컴퓨터 구조에 대한 기본적인 지식만 있어도 풀릴 오해이다. 캐시를 사용하고 말고는 프로그램 레벨에서 결정되는 것이 아니라 하드웨어 레벨에서 결정되는 문제이며, 응용 프로그램 수준에서 이를 바꾸려면 특별한 인스트럭션을 써야 하지만 이는 volatile 키워드의 목적을 달성하는데 있어서는 아무런 의미도 없는 일이기 때문이다.

  

 export 키워드는 템플릿을 사용했을 때 클래스 선언과 구현을 분리할 수 있도록 도와주는 키워드이다. 이 키워드를 쓴 템플릿 함수는 다른 번역 단위들에 노출이 되어 다른 번역 단위에서도 사용할 수 있게 된다... 는 것이 당시 표준에 export 키워드를 넣은 목적이었다.

 그러나 안타깝게도 이는 현재 C++의 컴파일 방식에 정면으로 배치되기 때문에 컴파일러 입장에서는 구현하기가 무척 어렵다. C++은 각각의 번역 단위를 따로 목적 코드로 컴파일한 뒤 목적 코드끼리 서로 링크하여 최종적인 실행 파일을 생성해낸다. 그런데 템플릿 함수나 클래스는 구체화를 하기 전까지는 목적 코드를 생성할 수 없고, 구체화는 링크 단계가 아니라 컴파일 초기 단계에서 이루어진다. 다시 말해 링크 단계가 아니라 컴파일 단계에서 모든 번역 단위에게 템플릿의 정의를 알려야 하는데 이는 무척 비효율적일 뿐만 아니라 기존 컴파일러의 구조와도 정면으로 배치되기 때문에 메이저 컴파일러들은 전부 export의 구현을 포기했다. 대부분의 컴파일러가 지원하지 않는 기능이기에 사실상 용도 폐기된 셈이다.

  

 이외에도 C++에는 다양한 키워드가 있는데, 이 정도가 사람들이 잘 모르거나 잘못 알고 있는 키워드가 아닌가 싶다. 나 역시 얼마 전까지는 이 중 상당수를 잘못 알고 있었다. 이러한 키워드가 많다는 것은 C++이 무척 어려운 언어라는 것을 반증하는 것이 아닌가 싶은데, 이도 모자라 내년에는 C++ 확장팩 C++0x가 발매될 예정이라고 한다. 과연 따라갈 수 있을까?

 

  1. 이 중 auto는 C++의 다음 표준인 C++0x에서 다른 용도로 사용되는 것으로 결정되었다. 참고로 x는 16진수 A임이 유력하다. [본문으로]
  2. 번역 단위란 #include, #ifdef 등 전처리 과정이 끝난 cpp 파일 하나를 의미한다. [본문으로]
  3. 물론 이는 하드웨어 레벨에서 이루어지는 비순차 실행까지 막지는 못한다. [본문으로]
  4. Java나 C#, 혹은 VC++과 같이 volatile 키워드를 사용하면 메모리 배리어가 보장되는 메모리 모델에서는 부분적으로 사용 가능하다. 그런데 VC++은 메모리 배리어가 보장되는게 맞는지 좀 모호하다. [본문으로]

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

Visual Studio는 C/C++을 싫어해 (?)  (3) 2011.02.27
Bitwise Switch  (2) 2011.02.19
std::unique_ptr  (0) 2010.04.26
RVO와 NRVO  (0) 2010.04.26
volatile과 메모리 배리어  (2) 2009.11.11
C/C++의 몇 가지 키워드들  (1) 2009.11.07
tags : c++, 코딩, 키워드
Trackback 0 : Comment 1

티스토리 툴바