이 블로그를 보시던 분들이 계시는지는 잘 모르겠습니다만 ; 텍스트 큐브가 망하고 한 동안 블로깅 안 하다가 티스토리에 새로 열었습니다.
이런저런 이야기
웹 서핑을 하다 아케이드 파이어가 올해 여름 즈음 신보를 내려고 준비하고 있다는 이야기를 보았다. 좀 더 찾아보니 올해 5월 18일 경에 수 주 내로 새로운 물건(new material)을 보여준다는 이야기와 전작들에 비해 조금 더 업비트이지만 오케스트라적인 스트링 사운드에 의존적인 편곡은 여전하다는 이야기. Neon bible보다는 Funeral을 좋아하기에 환영이다.
어쨌든, 오랜만에 앨범을 들고 나온다니[footnote]여타 활동은 여전히 활발했다고 하지만[/footnote] 내가 제일 좋아하는 곡 중 하나인 wake up을 포스팅해보련다. 몇 번을 들어도 가슴을 파고 드는 가사는 여전하다. 사신의 손길이 내 손에 닿는 순간에야[footnote]when the reaper he reachs and touches my hand[/footnote] 곡조가 돌연 밝아지며 자신이 가려는 곳이 어디인지를 깨닫는[footnote]I can see where I'm going to be[/footnote] 아이러니 역시 아직도 좋아한다.
멀티 프로세서 시대가 개막하면서 "공짜 점심은 끝났다"는 표현을 많이들 한다. 똑같은 프로그램을 사용해도 프로세서가 빨라지면 수행 속도 역시 알아서 빨라지던 시기는 지나고 이제 멀티 프로세서를 효율적으로 이용하는 프로그램을 짜야 빠른 수행이 가능한 시기가 왔기 때문이다. 이러한 병렬적 확장성은 대개 프로그램이 얼마나 병렬성을 잘 확보했느냐에 달려 있는데, 이를 객관적으로 평가하기 위한 수치 역시 존재한다.
멀티 프로세서를 활용하여 병렬적으로 돌아가는 알고리즘을 평가할 때, 프로세서 숫자가 증가함에 따라 이루어지는 속도 향상 비율을 일반적으로 속도 향상율(Speedup)이라 한다. 이를테면 단일 CPU에서 5초 걸리는 특정 알고리즘을 병렬화하여 10개의 프로세서에서 돌리니 1초가 걸렸다, 이러면 속도 향상치는 5인 셈이다. 또한 이를 프로세서 갯수로 나눈 값은 효율(Efficiency)라 한다. 위의 예시에서 효율은 0.5이다.
N개의 프로세서를 이용하면 최대 N의 속도 향상율과 1의 효율을 가지는 것이 가장 이상적인 경우일 것이다. 이를 Linear speedup이라 하며, 이는 병렬 프로그램의 궁극점이라 할 수 있다. 하지만 동기화에 따른 오버헤드가 명백하게 존재하는 상황에 이렇게 이상적인 속도 향상율을 얻는 것은 불가능에 가깝다.
여기서 문제. 그렇다면 가능한 최고의 속도 향상 효율은 몇일까?
답안
요즘 케스파와 블리자드의 힘싸움이 대단히 흥미롭게 전개되고 있다. 3년간 지지부진했던 협상 끝에 블리자드의 마이크 모하임이 협상 결렬을 선언하자 네티즌들은 케스파에 대해 비난을 퍼붓더니만 최근 나온 케스파의 보도자료에 여론이 역전되어 가는 판세이다. 누가 옳고 그른지에 대해서는 다른 사람들에 의해 계속해서 토론되고 있으니 여기에서는 굳이 더 언급하지 않겠다. 다만 개인적으로 이번 보도 자료는 언플이라고 밖에 보기 힘든 구석이 상당히 많다고 생각한다.
사실 어느 쪽이 이길지는 명약관화하다. 게임에 대한 원 저작권을 가지고 있는 블리자드로써는 절대로 질 수가 없는 판세다. 협회와 계약을 하지 않더라도 블리자드와 계약할만한 곳이 없을 것이라 단언하기 어렵다. 설령 초기 1~2년 간 계약이 맺어지지 않더라도 스타크래프트 II (이하 스타2)의 저력을 볼 때 전 세계 시장에서 이스포츠로써 성공할 확률은 대단히 높다. 이스포츠 게임으로 디자인되지 않았던 워크래프트 3 (이하 워3)가 중국에서 성공한 사례를 보면 애초에 이스포츠를 염두에 두고 디자인된 스타2는 워3보다 몇 배는 큰 시장을 형성할 것이 확실하다. 이 정도의 파이라면 조금 불리한 조건에서라도 시장 선점 효과를 노리고 계약할 회사가 없을 것이라 보긴 어렵다. 아니, 애초에 스타2는 굉장히 재미있는 게임이기 때문에 성공할 수 밖에 없고, 협회가 없더라도 관련 리그들이 알아서 생겨 날 것이다.
그런데 이런 논의들이 진행되는 것을 보면 무언가 핵심이 빠진 느낌이 든다. 기존의 이스포츠에 관련된 논의라면 항상 선수, 기업 그리고 협회 정도가 주체였기 때문인지 다들 게임 개발사에게 있어 이스포츠가 어떤 의미를 가지는지를 생각해 보지 않는 것 같다. 그래서 게임 개발사가 주체인 이번 논의에 대해서는 다들 핵심을 엇짚고 있을 가능성에 대해서도 생각해봐야 하지 않을까? 사실 게임을 가지고 대회를 치르는 이스포츠 판에서 그 게임을 개발한 개발사를 논의의 대상에서 배제한다는 것 자체가 말이 안 되는 발상임에도, 그간 이루어져 온 논의는 언제나 그래왔다.
내가 말하고 싶은 것은, 이스포츠가 핵심이 되는 게임을 개발하는 것이 게임 개발사에게 있어 수익이 되느냐는 것이다. 여기에서 조금 더 구체적으로 들어가자면, 이스포츠로써 게임이 성공하는 것이 개발사에게도 성공이 되느냐는 이야기가 될 수도 있다. 물론 이스포츠로써 게임이 성공하면야 게임 개발사에게 굳이 나쁠 것은 없다. 나름대로 광고 효과도 될 것이고, 게이머 커뮤니티가 활발해지는 것도 좋다. 그런데 이 것이 직접적인 수익으로 연결되느냐는 또 다른 이야기다. 넥슨 역시 카트 라이더 경기를 상당 기간 지원해왔으나, 불경기가 닥치자 곧바로 지원을 끊는 것을 봐선 현재의 이스포츠 모델은 개발사들에게 그다지 돈이 되는 구조가 아니다.
여기에서 스타1이 이스포츠로써 성공한 케이스를 돌이켜보자. 98년 스타1이 발매되고, 그럭저럭 괜찮은 판매고를 올렸다. 여기에 PC방 열풍이 불었는데, 스타1이 PC방에서 즐기기에 괜찮은 게임이라는 것이 밝혀지면서 다들 스타1을 게임방에 들이고, 이 것이 시너지 효과를 일으키면서 스타1과 PC방 모두가 상승세를 탄다. 게임이 이렇게 성공하자 많은 수의 대회가 치루어지기 시작했고, 경기 하나 하나가 방송의 포맷에도 적합했기 때문에 게임 전문 방송사가 개국하는 계기로 작용한다. 이렇게 판이 커지니 하나의 스포츠라고 봐도 좋을 정도가 되어 이스포츠라는 단어도 나오고, 커뮤니티도 탄탄해지면서 광안리에서 펼쳐지는 결승전에 10만 관중이 모일 정도가 되었다.
이런 일련의 과정을 보면 이스포츠의 성공은 게임의 성공에 힘 입은 바가 크다. 물론 이스포츠를 일으키기 위한 많은 사람들의 노력을 폄하하려는 것은 아니다. 다만, 게임이 성공하지 않았더라면 이러한 판 자체가 존재하지 않았을 것이라는 이야기를 하는 것이다. 이는 이후 이스포츠의 차기 종목으로써 많은 게임들을 발굴하려는 노력에도 불구하고 결과적으로 이스포츠 판에서 의미 있는 수준으로 살아 남은 것은 스타1, 워3, 와우 같은 블리자드의 특급 히트작 밖에 없다는 점이 반증한다. 적어도 아직까지는 이스포츠로 인해 게임이 성공한다는 것보다는 그 역이 더 설득력이 있다는 이야기다.
헌데 이번 스타2는 이스포츠로 인해 게임이 성공하는 사례를 만들기 위한 실험의 일환으로 보인다. 게임을 플레이해보면 이를 위해 상당히 신경 쓴 점들이 엿보인다. 물론 블리자드답게 게임 플레이가 최우선이긴 하나, 차기 배틀넷이나 보기에 좋은 게임 플레이, 인상적인 장면을 만들어내는 유닛 디자인 등은 워3보단 확실히 이스포츠에 적합하며, 이스포츠로써 생명이라 할 수 있는 밸런스에 들인 공을 보자면 여타 게임 개발사들로써는 감히 따라하기 어려울 수준이다. 이 정도 공을 들였으면 본전이 생각날 만도 하다.
사실 전작인 스타1에서 이스포츠는 의도되었다기보다는 커뮤니티에 의해 발견되었다는 표현이 적합하다. 그래서인지 블리자드도 스타1에 대해서는 지적 재산권을 적극 행사하려 들지 않았다. 내가 아는 한도 내로는 유료 관중, 중계권 판매 같이 컨텐츠를 이용하여 직접적으로 돈을 벌려는 상황이 아닌 이상 블리자드가 이를 행사한 적은 단 한번도 없다. 아마 앞으로도 비슷한 정책을 계속 취해나갈 가능성이 높을 것이다. 허나 이러한 정책이 이스포츠로써 만들어진 게임인 스타크래프트2에도 적용되어야 할 이유는 어디에도 없다. 자신들이 투자한게 없는 상황에서 발견된 이스포츠에도 그 권리를 요구한다면 도의적인 비판을 받을 수도 있겠으나, 자신들이 만들어 낸 이스포츠에 대한 권리를 요구하고 이를 통해 적극적으로 수익을 취하려는 것은 충분히 타당한 일이다.
여기에서 협회가 문제시 삼은 부분 중 일부분을 한번 살펴보도록 하자.
간단히 말해 블리자드 측은 게임 리그를 치름으로써 발생하는 부가적인 수익들에 대해 로열티를 받겠다는 의미이며, 협회는 고정된 게임 사용료만을 지불하고 나머지 수익은 독점하겠다는 이야기이다. 만약 협회의 주장대로 계약이 체결되면 스타2가 이스포츠로써 대성공을 한다 하여도 블리자드에 떨어지는 수익은 게임 사용료에 국한되게 된다. 그 규모가 블리자드에게 만족스러운 수준이면 모르되, 현재 블리자드의 규모와 협회가 말하는 '합리적인 수준의 게임 사용료'라는 문구를 볼 때, 그럴 가능성은 높지 않다. 이렇게 되면 스타2를 굳이 이스포츠 게임으로써 개발한 이유가 무색하게 될 뿐이다.
많은 사람들이 뜨악해하는 내용인데, 이는 이미 대부분의 게임 약관들에 들어 있는 내용이다. 유저의 리플레이부터 유저가 제작한 맵, 관련 창작물 등은 전부 블리자드에게 귀속되며, 블리자드가 아닌 여타 게임들이라 하여도 이는 크게 다르지 않다. 계약서에 이러한 내용을 넣은 것은 게임 약관의 연장일 뿐이라는 이야기이다. 만약 이러한 내용이 없다면 협회가 경기 내용을 중계, 판매함으로써 얻는 수익에 대해 블리자드가 로열티를 주장할 수 없게 된다. 반지의 제왕을 영화화한다 할 때, 영화사가 dvd 및 극장 수익을 원작자인 톨킨 제단과 나누지 않겠다고 주장한다 생각해보자. 과연 사람들은 어떤 반응을 보일까?
나는 이 부분에 대해서는 잘 모른다. 허나 다른 분의 글에서 본 바에 따르면 블리자드가 직접 협회에 대한 회계 감사를 수행하는 것이 아닌, 회계법인을 고용하여 스타2로 인해 발생한 수익을 투명하게 가려내어 계약대로 로열티를 받아내기 위한 것이으로, 돈이 오가는 계약을 맺을 떄 일반적으로 포함되는 내용이라 한다. 이게 사실이라면 이는 비즈니스를 잘 모르는 나 같은 친구를 현혹시키기 위해 협회가 고의적으로 보도 자료에 첨부했다고 보이며, 로열티에 대한 자기 권리를 주장하기 위해서는 충분히 넣을 수 있는 내용이라 생각한다.
계약 대부분의 내용은 스타2로 인해 이스포츠가 크게 성공한다면 블리자드 역시 크게 수익을 얻을 수 있도록 하기 위한 장치들이다. [footnote]협회가 문제시 삼은 나머지 두 조항은 1년 단위로 재계약하는 부분과 리그 운영에 대해서는 사전 승인을 받아야 한다는 점이다. 전자를 문제시 삼는 이유를 나는 도저히 모르겠고, 후자 같은 경우는 협회의 협상력에 따라 충분히 사전 협약 정도로 바꿀 수 있는 내용이었다고 본다.[/footnote] 이스포츠를 노리고 게임을 만든 개발사로써는 충분히 주장할 수 있을 법한 내용들이 아닌가? 그 세세한 내용이나 분배 비율 등이 문제 삼을 수는 있겠으나, 개발사가 그 수익의 일부를 나누어 가겠다는 생각 자체를 문제시 삼는 협회의 태도는 쉽게 납득하기가 어렵다.
이 쯤에서 내가 블리자드를 지지하는 이유를 밝혀야 할 것 같다. 이는 아이러니하게도 이스포츠의 미래를 위해서이다. 재미있지 않은가? 한국 이스포츠 계가 점령군인 블리자드에게 속이고 쓸개고 다 빼주는 것이 이스포츠의 미래를 위한다는 주장이.
현재 한국 이스포츠를 보면 10년간 스타1만이 지속되고 있으며, 스타판이 무너지면 한국 이스포츠도 무너진다. 헌데 지금 스타판을 보면 타이밍 좋게/나쁘게 터진 승부조작건 덕분에 그 생명줄이 끊어질락 말락 아슬아슬한 상황이다. 이러한 문제의 원인은 표면적으로 볼 때 스타1 말고는 현재 한국 이스포츠계에서 살아남은 게임이 없기 때문이다. 그렇다면 스타1만이 이스포츠계에서 살아 남은 까닭을 고민해보는 것이 옳다.
스타1만이 살아 남은 이유에는 여럿이 있을 것이다. 대체 게임을 발굴하지 못한 방송사 및 협회에도 그 책임이 있을 것이고, 스타1만큼 성공한 게임이 다시 나오지 않았기 때문일 수도, 스타1처럼 방송에 적합한 게임이 아니었기 때문일 수도 있다. 어떤 이유이건간에 현재 이스포츠로써 성공할 수 있는 게임의 수는 극히 제한되어 있으며, 이러한 조건을 만족시키는 게임을 만드는 것은 일반적인 게임을 만드는 것보다 힘든 일이다. 게다가 게임 개발 비용이 점차 높아지는 것이 현 추세인데, 여기에 그 수익이 불확실한 이스포츠까지 신경쓰며 게임을 만든다는 것은 현재로써는 모험일 수 밖에 없다. 이스포츠에 적합한 게임이 몇 개 안 나오는 마당에, 그런 게임들이 히트한다는 보장도 없으니 대체 게임을 발굴하는 것은 요원한 일이다.
이 쯤 되면 대안은 쉽게 떠올릴 수 있다. 이스포츠에 적합한 게임을 발굴한다는 개념을 벗어나 만들면 된다. 그런 게임들이 다수 나오고, 그 중 잘 만든 게임 일부가 히트한다면 이스포츠의 대안으로써 밀어볼만 할 것이다. 헌데 이건 작은 규모의 게임 개발사로는 어림도 없고, 한국에서는 넥슨, NC 정도 되는 레벨이라야 시도해볼만하다. 게다가 일반적으로 "재미있는 게임"을 만들면 충분히 성공할 수 있음에도 그 수준을 넘어 게임 플레이를 높은 수준으로 다듬어야 이스포츠에 적합할 것이라는 점을 생각해보면 저런 메이져 개발사에서도 평소에 비해 훨씬 많은 기한과 자금을 투자해야 그런 게임을 만들 수 있을 것이다. [footnote]모르긴 해도 스타2 역시 개발에 들어간 비용이 최소한 와우 수준, 500억 이상은 될 것이라 추정한다. 참고로 그 NC조차 우주 먹튀의 타뷸라 라사에 들어간 자금 덕분에 아이온 출시 이전까지 휘청거렸다.[/footnote] 다시 말해, 이스포츠에 적합한 게임을 의도적으로 만드는 것은 높은 리스크를 지는 것이다.
바로 여기에서 문제가 무엇인지 알 수 있다. 현재 이스포츠 시장의 구조로써는 저런 게임을 만드는 것은 비용은 큰 데, 효과는 작다. 협회가 제시하는 계약에 따른다면 기껏해야 매 해 수십억을 받는 정도일텐데, 개발사 입장에서 큰 비용을 들여가며, 또 큰 리스크를 지면서까지 이런 게임을 만들 이유는 별로 없지 않을까? 게다가 이런 류의 게임 개발에 있어서 노하우가 있는 회사라고 해봐야 블리자드 말고는 전무하다고 봐도 무관한데, 맨 땅에 헤딩하는 것은 결코 쉬운 일이 아니다.
결국 높은 리스크를 진다면 큰 보상을 받아야 한다. 블리자드가 택한 방법이 바로 이 것이다. 높은 리스크에 작은 보상이라면 아무도 하지 않겠지만, 높은 리스크에 큰 보상이 보장된다면 블리자드 이후로 뛰어들 업체들이 있을 수도 있다. 이렇게 된다면 새로운 게임을 발굴하는 가능성, 즉 운빨에만 의존하던 기존 이스포츠에 비해 게임의 스펙트럼이 넓어질 것이다. 이는 이스포츠판 자체의 활성화로 이어질 것이며, 또한 게임 하나에 생사를 의존하던 비정상적인 구조에서 산업으로써 자리 잡을 가능성이 커진다. 그다지 어려운 논리도 아니다. 중요하고도 어려운 역할을 맡았다면, 그만큼의 보상을 보장해줘야 그 판이 돌아갈테니까.
내가 블리자드를 적극 지지하는 이유는 바로 이런 것이다. 이스포츠에 있어 가장 중요한 게임을 제공하는 게임 개발사가 지금 같이 게임 개발 셔틀로 남아서는 꿈도 희망도 없다. 게임 개발사 역시 이스포츠의 대주주가 되어야 이스포츠도 살아 남을 수 있다는 이야기다. 그리고 이는 게임 개발사에게 합당한 보상을 하는 것에서 시작될 것이다.
보통 멀티 쓰레드 프로그래밍 하면 손사레부터 치는 사람들이 대단히 많다. 이 쪽에 대해 나름 공부를 하고 있다 생각하지만 아직까지 버그가 없으면서 높은 병렬성을 가진, 어느 정도 이상 규모를 가진 프로그램을 일반적인 순차적 프로그래밍을 짜듯 쉽게 만들 자신은 없다. 팀 스위니 같은 천재조차도 멀티 쓰레드 프로그래밍은 쉽지 않다고 고백하는 것을 보면 현재 주된 개발 방식 어딘가에 동시성과 맞지 않는 근본적인 한계가 존재한다는 추측을 하게 된다.
멀티 쓰레드 프로그래밍이 어려운 까닭을 파고 들어가면 현재 가장 주류를 이루고 있고 또 성공적으로 적용 중인 구조적 프로그래밍이라는 개념 자체가 동시성 프로그래밍에 적합하지 않다는 점에 그 근본적인 원인이 있음을 알게 된다.[footnote]물론 이게 goto를 쓴다고 해결된다는 의미는 절대 아니다.[/footnote] 이러한 원인을 알기 위해서는 우선 구조적 프로그래밍에 대한 이해가 필요하다.
구조적 프로그래밍이란 프로그램의 논리를 작은 단위로 나누어 생각할 수 있도록 하위 구조(sub-structure)라는 논리적인 단위를 제공한다. 이러한 하위 구조의 가장 작은 단위는 일반적으로 문장(statement)인데, 구조적 프로그래밍에서는 이들을 순차적으로, 혹은 필요에 따라 비순차적으로 수행하도록 적합한 제어 구조를 제공함으로써 작은 하위 구조로 부터 더 큰 프로그램을 조립해나간다.
여기에서 중요한 것은 프로그램의 문맥이 특정한 상태에서 어떤 하위 구조로 진입했을 때, 프로그램 작성자가 그 결과를 결정적으로(deterministic) 예측할 수 있다는 점에 있다. 하위 구조의 동작 결과를 알고 있기 때문에 이러한 하위 구조를 조립한 상위 구조들의 동작 결과도 미리 예측 가능하며, 이러한 전제 하에 상방식(bottom-up), 혹은 하방식(top-down) 설계가 가능해진다. 이 때 각각의 하위 구조가 다른 구조에 영향을 적게 줄 수록 유지 보수가 쉬워지는 경향이 있는데, 이를 위해 그 범위를 가능한 하나의 객체로 좁히기 위한 노력들이 훗날 객체 지향 패러다임에 상당한 영향을 주었다고 한다.
이 구조적 프로그래밍에서 가장 강력한 도구를 들라면 역시 서브루틴, 혹은 메쏘드이다. 잘 짜여진 서브루틴이라면 전제 조건(pre-condition)과 사후 조건(post-condition), 부가 효과(side-effect)가 명확하게 정의될 수 있다. 전제 조건이란 서브루틴에 진입하기 이전 프로세스[footnote]여기에서 프로세스란 시스템 프로그래밍적 관점에서의 그 용어가 아닌, 프로그램의 인스턴스로써의 프로세스를 의미한다.[/footnote]의 상태가 가져야 하는 전제 조건들을 의미하며, 사후 조건이란 서브루틴을 수행한 이후 반환되는 결과 값 및 변화한 프로세스의 상태이다. 부가 효과란 해당 서브루틴의 수행으로 인해 발생한 프로세스의 변화 자체를 의미한다. 현재의 컴퓨터 모델에서는 서브루틴을 수행하는 사이에 발생한 메모리 영역의 변화 일체가 서브루틴 수행의 부가 효과라고 볼 수 있다.[footnote]물론 이에 한정되지는 않는다. I/O도 부가 효과라고 볼 수 있으니까.[/footnote]
헌데 기존의 프로그램에 동시성이라는 개념이 등장하는 순간 기존의 이런 도구들이 전부 의미가 없어진다. 두 개 이상의 실행 문맥이 동시에 한 메모리 영역을 읽고 쓰는데에 아무런 제약이 가해지지 않기 때문이다. 헌데 서브루틴이건 하위 구조이건 수행 자체에는 시간이 필요하고, 그 사이에 한 메모리 영역을 두 쓰레드가 동시에 조작하려 시도하면 비결정적(non-deterministic)인 결과, 이른바 데이터 레이스가 나오게 된다.
문제는 이 뿐만이 아니다. 설령 데이터 레이스가 존재하지 않는다고 하여도 싱글 쓰레드와 멀티 쓰레드 사이에는 프로그래밍 방식에 있어 현격한 차이가 있다. 만약 단일 쓰레드가 특정 객체를 수정하는 서브루틴를 수행한다 하면 일반적으로 진입 이전과 이후의 객체 상태는 모두 의미 있는 상태일 것이다. 이러한 가정 하에 구조적 프로그래밍이 가능해지는 것이다. 그러나 두 개 이상의 쓰레드가 그러한 서브루틴을 수행한다면 수정이 완전히 이루어지지 않는 불완전한 상태의 객체에 접근하게 될 가능성이 있다. 동시에 접근될 가능성이 있는 모든 객체에 대해 가능한 모든 불완전한 상태에 대한 대비를 해야 한다는 것이다. 이러면 당연히 프로그램의 복잡도가 현격하게 상승하게 되며, 이는 구조적 프로그래밍의 강점 하나가 그대로 사라지는 것을 의미한다.
여기에서 구조적 프로그래밍의 가장 큰 전제가 무너지는 것이다. 구조적 프로그래밍에 있어 그 결과를 신뢰할 수 없는 하위 구조는 존재 가치가 없으며, 그러한 하위 구조를 단 하나 가지고 있는 것 만으로도 해당 프로그램은 전혀 신뢰할 수 없는 프로그램이 되고 만다. 그렇지 않은 하위 구조를 짜는 것은 싱글 쓰레드에 비해 몇 배의 노력이 들어가며, 뒤에서도 설명하겠지만 특정 조건을 만족하지 않는 이상 이러한 하위 구조들은 서로 조합이 불가능하다는 치명적인 문제가 존재한다.
그간 연구자들이 이에 대해 손을 놓고만 있었던 것은 아니다. 컴퓨터 과학계에서는 수십년 전부터 동시성에 대한 연구가 시작되었고, 특히나 CPU 자원의 공평한 분배가 중요한 운영체제론에서는 동시성에 대한 연구가 광범위하게 진행되어 왔다. 쓰레드나 락, 데이터 레이스 등의 개념을 프로그래밍 자체보다는 운영체제나 시스템 프로그래밍 등의 테마를 공부하며 배우는 사람이 많은 것은 바로 이런 까닭이다.
가장 먼저 나온 방안은 (그리고 현재까지 가장 널리 통용되는 방안은) 필요한 경우 락을 이용하여 프로그램 수행을 직렬화하는 것이다. 간단히 말해 의미 없는, 불완전한 상태의 객체에 관한 서브루틴이 수행될 가능성이 있는 경우 진입 지점에 적당히 락을 걸어 두 개 이상의 쓰레드가 동시에 객체를 수정하지 못하도록 막는 것이다. 이 방법은 구현이 쉽고, 가장 기계 친화적인 방식이기 때문에 아직까지 널리 쓰인다.
그러나 구조적 프로그래밍과 이 방식이 잘 맞느냐를 묻는다면 회의적이다. 구조적 프로그래밍은 말 그대로 하위 구조의 조합을 통해 프로그램을 만들어가지만, 일반적으로 락 기반 프로그래밍은 객체 단위로 락을 할당한다는 점을 생각해보면 이 방식은 런타임에 존재하는 실제 객체의 상태에도 다분히 의존적이다. 기존에는 주로 하위 구조들이 이루는 프로그램의 구조에 대해서만 신경쓰면 됐다면 [footnote]꼭 그렇지만은 않지만, 대부분은 좋은 설계에 의해 해결된다.[/footnote] 이제는 여기에 런타임의 프로세스 상태라는 새로운 차원까지 치밀하게 신경써야 한다. 이로 인해 락을 이용한 하위 구조들끼리는 별 다른 조치 없이 그대로 조합하는 것은 불가능하며, 악명 높은 동시성 버그인 데드락이 발생하는 까닭의 99%는 여기에 있다. [footnote]이 뿐만 아니라 lock contention, lock convoying등 락으로 인한 문제점은 셀 수도 없이 많다.[/footnote]
이런 방식 대신, 프로세서가 제공하는 원자적 명령어[footnote]Compare and swap등.[/footnote]를 통해 서브루틴을 수행하는 구간에 대해 해당 객체가 항시 유효한 상태임을 보장하는 방식인 이른 바 Lock-free, Wait-free로 대변되는 non-blocking 동기화 기법도 존재한다. 여기에서는 보통 프로그램의 복잡도를 제어 가능한 수준으로 낮추기 위해 선형화(Linearization)라는 개념을 도입한다. 선형화의 아이디어는 간단히 말해 특정 쓰레드가 특정 객체에 대한 작업를 수행한다 치면 다른 쓰레드에 있어서는 이 작업이 한 순간에 일어난 것처럼 보이도록 하자는 것이다. 데이터베이스의 트랜잭션과 어느 정도 유사한데, 이런 방식으로 접근을 하면 동시적으로 수행되는 각 작업들 사이의 선후 관계를 판별하는게 가능해지고, 또한 락을 이용한 방법과는 달리 하위 구조끼리의 조합도 가능해진다.
허나 non-blocking 동기화 기법은 기존의 알고리즘을 선형화시켜야 한다는 제한이 있기 때문에 코딩하기가 대단히 까다롭다. 이는 현대 프로세서들이 제공하는 원자적 연산 명령들의 한계 때문인데, 대부분의 경우 근접해있는 64비트 변수 두 개를 원자적으로 바꾸는 명령 정도가 한계이다. 이러한 명령만을 이용하여 프로그래밍하는 것은 락을 이용한 프로그래밍에 비해 절대 쉽다고 할 수 없다. 그렇기 때문에 대개의 경우는 아주 기초적이고 자주 사용되는 자료구조에 한해 이러한 프로그래밍 기법을 사용하는 것이 대부분이다.
그러나 선형화라는 아이디어 자체는 상당히 유용하기 때문에 동시성 모델을 만들고 사고하는데 있어 (락을 이용하는 경우에도) 쓸만하며, 또한 하위 구조간 조합이 아무 제약 없이 가능하다는 강점 덕분에 구조적 프로그래밍과 상당히 궁합이 잘 맞는다. 여기에서 더 나아가 특정 코드 구간에서 일어나는 모든 연산이 원자적으로 일어난다는 것을 보장할 수 있으면 어떨까? 이런 아이디어에서 Transactional memory라는 개념이 등장한다. Transactional memory란 간단히 말해 특정 수행 구간에서 일어난 메모리 변화를 원자적으로 반영시키는 개념으로, 메모리 버젼의 트랜잭션이라고 생각하면 된다. Transactional memory가 구현되면 모든 코드를 아주 쉽게 선형화 할 수 있게 되므로 동기화에 대한 시름거리를 하나 덜어버리는 셈이 되나, 안타깝게도 아직까지는 이를 실용적인 수준까지 구현한 사례는 존재하지 않는다.
현재까지는 어느 쪽이든 그 난이도가 낮지 않다. 이 외에도 수많은 방법들이 제안되었고 또 제안되고 있지만, 아직까지는 확실하게 은탄환이라 불릴 만한 해법은 나오지 않은 상태이다. 병렬, 동시성 프로그래밍은 로직 자체를 고민하는 것도 만만치 않은데 여기에 이런 다양한 문제들이 엮이면서 그 난이도가 살인적인 수준까지 올라간다. 마땅한 방법이 없는 현재로써는 이를 하나 하나 공부하며 그때 그때 맞는 방법론을 찾아 적용하는 수 밖에 없어 보인다.
C++ 차기 표준인 C++0x에서는 다양한 언어적 차원의 기능과 라이브러리들이 추가되었는데, 라이브러리 중 unique_ptr라는 상당히 흥미로운 물건이 추가되었다. 이 클래스의 역할은 기존의 auto_ptr 클래스를 대체하는 것인데, 이를 이해하려면 auto_ptr에 대해 어느 정도 이해가 필요하다.
auto_ptr는 C++ 코딩의 원칙 중 하나인 RAII[footnote]Resource aquisition is initlaization, 자원의 획득은 객체의 생성과, 해제는 객체의 소멸과 일치시키라는 의미로, 컴파일러가 자동으로 관리해주는 객체의 생명 주기와 자원의 생명 주기를 일치시켜 컴파일러로 하여금 자원을 자동적으로 관리하도록 하는 정책을 의미한다.[/footnote]를 구현하기 위해 표준 라이브러리에 존재하는 클래스로, auto_ptr 객체가 정의된 스코프를 벗어나면 해당 auto_ptr에 저장된 객체의 해제를 보장하는 역할을 한다. 간단히 말해 스마트 포인터인 셈이다. 안 그래도 복잡한 언어인 C++에서 이러한 스마트 포인터의 존재는 코드의 복잡도를 낮추는데 무척 중요한 역할을 한다. 자원 할당 및 해제까지는 조심스럽게 코딩함으로써 어찌 할 수 있겠으나, 예외 안전성의 영역까지 가면 자원을 사람이 일일히 관리하기가 거의 불가능한 수준이 되기 때문이다. [footnote]여담이지만 C++에서 delete란 만악의 근원으로, 가능하면 컴파일러가 알아서 삽입하도록 코드를 짜는게 좋다. C++에서는 그 방법으로 제안되는 것이 스마트 포인터이다.[/footnote]
헌데 auto_ptr 클래스의 동작을 잘 보면 상당히 특이하다. 일반적으로 복사 생성자와 대입 연산자는 동일한 객체를 두 개 만든다는 의미에 맞게 동작하는데, 이 경우는 포인터의 소유권을 이전하는 방식으로 동작한다. 이를테면 auto_ptr 객체 a, b가 있을 때 a에 b를 대입하면 a가 가리키고 있던 객체는 해제되고 b에 있는 객체를 가리킨다. 그리고 b에는 널 포인터가 들어간다. 결과적으로 한 포인터를 가리키는 auto_ptr 객체는 동시에 하나만 존재하게 되어 이중 해제가 되지 않음을 보장하는 것이다. (물론 잘못 쓰면 충분히 가능한 일이지만)
문제는 STL 컨테이너를 사용하는 경우 컨테이너에 들어가는 객체들에 요구되는 조건 중 하나가 복사 생성자와 대입 연산자가 동일한 객체를 두 개 만든다는 의미를 가져야 하는 것이다. 헌데 auto_ptr는 이러한 조건을 지키지 않기 때문에 STL 컨테이너에 사용하는 경우 내부에서 이루어지는 대입, 복사 과정에서 가리키는 포인터가 마구마구 해제되어 버리는 불상사가 발생한다. 그렇기 때문에 표준에서 auto_ptr를 STL 컨테이너를 구체화시키기 위한 타입으로 사용하는 것을 아예 금지하고 있으며[footnote]관련 내용으로는 여기를 참조하자.[/footnote], boost에서는 아예 복사라는 개념 자체를 제거한 scoped_ptr을 제공한다.
C++에서 STL 컨테이너에 담을 수 없는 클래스라면 아무리 좋은 물건이라도 반쪽 클래스일 수 밖에 없다. 이러한 것을 극복하기 위해 소유권을 이전하는 형식이 아닌, 소유권을 나누는 방식의 shared_ptr이 제안된다. 이는 레퍼런스 카운팅 방식으로 동작하는 포인터로 사실상 모든 경우에 대해 포인터를 대체할 수 있으며 대부분의 경우에 대해 멀티 쓰레드 환경과 예외에 대해 안전하고 가리키는 포인터가 없어지는 경우는 해당 자원이 자동으로 해제되기 때문에 무척 유용하다. 순환 참조에 대해서만 조심하면 자바나 C#의 가비지 콜렉터가 부럽지 않을 정도다.
그러나 모든 편리함은 그 댓가를 필요로 하는 법이다. shared_ptr은 자체적으로 레퍼런스 카운팅을 제공하지 않는 클래스에 대해서도 동작해야 하기 때문에 각 개체마다 레퍼런스 카운트 값을 저장할 공간을 추가로 가져야 한다. 이 값은 shared_ptr마다 하나가 아니라 가리키는 객체마다 하나씩 필요하므로 새로운 포인터를 가리킬 때마다 추가로 힙 공간을 할당해야 한다는 의미이다. 게다가 shared_ptr의 복사 및 소멸마다 레퍼런스 카운트 값을 조절해야 하는데 이는 쓰레드 안전해야 하므로 비교적 수행 속도가 느린 atomic operation을 이용해야 하며, 이는 전부 힙 공간을 조작하는 행위이므로 캐쉬 지역성에 악영향을 끼칠 우려가 있다. 여기에 추가로 객체 접근을 위해 포인터를 역참조할 때 이 값이 shared_ptr에 저장된다면 shared_ptr의 크기가 2배로 불어나며 레퍼런스 카운트를 저장해두는 곳에 저장한다면 간접 참조를 해야 하므로 메모리 연산이 두 번 일어나는 셈이다.
물론 대부분의 경우 shared_ptr의 유용성은 이러한 오버헤드를 충분히 감수하고도 남을만 하지만, 해당 객체의 수명이 아주 명확한 경우까지 shared_ptr을 쓰는 것은 낭비라고 볼 수 있다. 다시 말해 auto_ptr와 shared_ptr 사이의 절충안이 있으면 좋겠다는 의미다. 이를 위해 C++ 표준 위원회에서는 unique_ptr이라는 클래스를 새롭게 도입한다.
unique_ptr은 기본적으로 auto_ptr와 유사하게 소유권의 이전에 기반한 동작을 한다. 그러나 일반 복사 생성, 대입 연산이 아닌 C++0x에서 새롭게 추가된 R-value reference를 이용한다는 것이 틀리다. 자세히 들어가자면 무척 긴 내용이라 시시콜콜 설명하진 않겠으나 R-value reference를 이용한 복사 생성와 대입 연산은 의미적으로 값의 복사가 아닌 값의 이동을 뜻한다. [footnote]R-value reference가 도입된 목적이 어차피 곧 무효화될 임시 객체에 대해서는 값을 복사하지 말고 이동시켜서 복사에 따른 오버헤드를 줄이자는 것이기 때문이다.[/footnote] 때 마침 STL 컨테이너에서도 효율성을 위해 내부적인 복사 및 대입 동작은 전부 R-value reference를 이용하도록 바뀌었는데, 일반 복사 및 대입 연산자를 막아 버리는 대신 R-value reference를 이용한 복사 및 대입 연산자만 정의한다면 STL에서도 사용할 수 있는 auto_ptr가 생긴다는 것이 unique_ptr의 의미이다.
unique_ptr와 auto_ptr의 차이점은 대충 아래와 같이 정리할 수 있다.
간단히 말해 auto_ptr가 할 수 있는 것은 unique_ptr도 전부 할 수 있으며, 거기에 다양한 기능이 추가된데다 raw pointer에 비해 추가적인 오버헤드도 없는 스마트 포인터라 할 수 있다. 앞에서 말한 auto_ptr를 대체하기 위한 클래스라는 것은 바로 이런 의미이다. 다만 의미적인 뚜렷함과 안전한 사용을 위해 대부분의 암시적인 변환이 막혀 있고, 일반적인 대입 및 복사가 막혀 있으므로 unique_ptr 끼리의 대입에는 std::move 함수를 명시적으로 사용해야 하는 등 코딩량이 다소 늘어난다는 불편함이 있으나, 안전한 코딩을 위해서라면 이 정도는 충분히 감수할 만 하다.
unique_ptr의 경우 개인 프로젝트인 C--에서도 상당히 유용하게 쓰고 있는데, 이를 잘만 쓰면 아무런 오버헤드 없이 유효 범위가 확실한 객체에 대해 delete를 전부 제거할 수 있으며, 나머지도 shared_ptr을 이용하여 제거할 수 있다. 유일하게 예외가 있다면 union을 쓰는 경우인데 (union의 멤버는 생성자를 가질 수 없다) 이 경우는 사용자가 따로 래퍼 클래스를 만들어야 한다.
개인적으로는 boost의 intrusive_ptr[footnote]shared_ptr과 비슷하게 레퍼런스 카운트를 하지만 이 녀석은 개체 자신이 레퍼런스 카운트 값을 유지해야 한다는 제약이 있다. 물론 퍼포먼스 측면에서는 그만큼 이득이 있다.[/footnote]의 인터페이스를 좀 더 다듬어서 오버헤드가 적은 shared_ptr의 모양새로 표준에 도입해보는 것도 좋지 않을까 생각하는데, 표준화 위원회는 이 쪽에 관심이 없거나 이 정도면 충분하다고 생각하는 것 같다. 내가 만들어 볼까 생각도 해봤으나 모든 코너 케이스를 고려하면서 안전한 스마트 포인터 클래스를 만드는 것은 그리 만만한 일이 아닐 것 같다.
컴파일러가 컴파일 시간에 수행하는 최적화는 참으로 다양하다. 의미 없는 코드를 삭제하는 Dead code elimination 같은 기본적인 테크닉부터 함수 내부 코드만이 아니라 함수 사이의 관계까지 최적화를 하는 Interprocedural optimization까지 많은 종류의 최적화가 수행된다. 특히나 템플릿을 적극 활용하는 스타일의 C++ 코딩에서는 많은 변인들이 컴파일 시간에 정적으로 결정되기 때문에 이러한 최적화의 혜택을 많이 받는다. [footnote]예를 들어, VS의 STL vector 구현을 보면 값 하나 참조하는 데에만 함수가 몇 번씩 호출되어 콜스택이 몇 중으로 쌓이는데 최적화된 목적 코드에서는 이 코드들이 전부 인라인되고 상수 브랜칭들도 다 삭제되어 해당 객체의 포인터를 곧바로 가리키는 코드로 바뀐다.[/footnote] 이로 인해 C++의 템플릿을 강력한 유연성과 높은 효율 두 마리 토끼를 함께 잡을 수 있는 것이다.
이러한 최적화로 인해 얻어지는 수행 효율의 증대는 크누스 교수님으로 하여금 어설픈 최적화는 모든 악의 근원이다라는 말까지 남기도록 하였다. 그럼에도 불구하고 쪼잔한 우리 프로그래머들은 컴파일러를 믿지 못하고 문장 단위의 최적화를 시도하는 경우가 많은데, 이 중 가장 대표적인 사례를 들라면 함수의 반환값을 복사 대신 참조로 넘기기 위한 다양한 노력이 있다. 스캇 마이어의 Effective C++에서도 함수의 반환값을 참조로 넘기지 말라고 강조를 하지만 복사 생성으로 인해 성능에서 피를 본 적이 있는 사람이라면 이러한 유혹에 빠질 수 밖에 없는 것이다.
이를테면 스코프에 대한 개념이 잘 잡혀 있지 않은 초보 프로그래머들이 '참조로 넘기는게 효율이 좋데'라는 말을 듣고 최적화를 하려고 한다. 그런데 그렇게 큰 객체가 아니라 힙에다가 매 번 할당하는 것은 비효율적이고 또 수동 할당 해제 등의 번거로움도 싫다. 그래서 힙에 할당을 하지 않고 그냥 스택에 있는 객체를 이용하려 한다 치면 아래와 같은 실수를 저지를 수 있다.
[code cpp]SomeClass& someFunction()<br>{<br> SomeClass ret;<br> // Do something...<br><br> return ret;<br>} [/code]
당연히 안 될 말이다. SomeClass의 인스턴스 ret는 해당 함수가 끝나는 순간 그 유효 기간이 다 한다. 자동으로 소멸자가 호출되어 관련 정보를 깨끗하게 정리해버리는 것이다. 이 상황에서 객체 자체가 아닌, 객체의 참조를 반환한다면 대재앙이 일어날 수 밖에 없다. 그렇다면 이건 어떨까?
[code cpp]class SomeClass<br>{<br> // ...<br><br> SomeObject buffer_;<br>};<br>[/code]
[code cpp]SomeObject& SomeClass::getSomeObject()<br>{<br> // do Something on buffer_<br> return buffer_;<br>}<br>[/code]
이는 큰 문제 없이 돌아가겠지만, 객체 안의 객체를 반환할 경우에나 사용할 수 있다는, 다시 말해 원본 객체 상태의 일부에 한정되는 객체만 반환 가능하다는 한계점을 가지고 있다. SomeClass에 종속적이지 않은 객체를 만들고 싶은 경우라면 사용할 수 없는 코드인 것이다.
그렇게나 효율을 강조하는 언어인 C++에서 이런 문제 하나 제대로 해결을 못하고 결국 무작정 복사를 해야 한다니 무언가 이상하다. 컴파일러 개발자나 C++ 표준 위원회 등이 이러한 내용에 대해 고민을 안 해봤을리가 없다. 그리고 이 문제에 대해 Return value optimization(RVO)이라는 해법을 내놓는다. RVO란 함수가 특정 값을 반환할 때 객체를 생성하고 복사, 소멸시키는 삼중 오버헤드를 단순히 객체 생성 한 번으로 끝내도록 만드는 최적화이다. 우선 아래의 코드를 보자.
[code cpp]SomeClass someFunction()<br>{<br> // Do something...<br><br> return SomeClass(someArgument);<br>} [/code]
이렇게 반환문에 객체 생성문을 명시했다는 것은 실제 반환되는 위치에 곧바로 해당 객체를 생성하여도 의미적으로 볼 때 차이가 없을 것이라는 의미이다. 그렇다면 위의 코드는 아래와 같은 코드로 바뀔 수 있다. (정확하게 아래와 같지는 않다. 단지 이렇게 될 것이라는 것을 말할 뿐이다.)
[code cpp]void someFunction(SomeClass& returnTo)<br>{<br> // Do something...<br><br> returnTo.SomeClass::SomeClass(someArgument); // Construct SomeClass on returnTo<br>} [/code]
이렇게 반환값 최적화가 일어나면 반환값을 굳이 참조로 넘기지 않아도 효율적인 코드가 생성된다. 헌데 위와 같은 최적화만 가지고는 아무래도 반환하는 객체에 대한 표현력이 생성자에 의해 곧바로 만들어 질 수 있는 익명 객체 수준으로만 한정되기 때문에 아쉬운 점이 있다. 이를 보완하는 최적화 테크닉도 있는데 이는 Named return value optimization(NRVO)으로, 이름을 가진 반환값까지 이러한 최적화의 대상으로 만든다.
[code cpp]Object generate()<br>{<br> Object someObject;<br> someObject.doSomething();<br> return someObject;<br>}<br>[/code]
이런 코드는 앞서 언급한 RVO만 가지고는 반환값 최적화의 혜택을 받을 수 없는데, NRVO는 이마저 최적화시켜버린다. someObject가 있다고 치면 굳이 someObject를 생성할 필요 없이 반환값을 저장할 변수를 가져와서 그 곳에다가 someObject 관련 작업을 해버리는 것이 포인트이다.
[code cpp]void generate(Object& returnTo)<br>{<br> returnTo.Object::Object();<br> returnTo.doSomething();<br>}<br>[/code]
물론 실제 코드가 이렇게 간단하게 최적화될 수 있다면 고대 컴파일러인 VC 6.0에서부터 구현이 되었을 것이다. 하지만 실제로 사용하는 코드들은 이보다 복잡한 경로를 거쳐 값들을 반환하기 때문에 훨씬 복잡한 알고리즘을 동원하여 최적화를 하는 것으로 보인다. MSDN에 따르면 NRVO는 2005부터 지원을 하고 있다. [footnote]해당 사이트에 NRVO가 적용 안 되는 경우 등이 자세히 설명되고 있으므로 한번 읽어 볼 만 하다.[/footnote]
다만 이 최적화의 문제라면 생성자, 소멸자의 호출 횟수가 달라지면서 같은 코드라도 내는 결과가 최적화 여부에 따라 달라질 수 있다는 점인데, 웹에서 본 바에 따르면 이러한 최적화는 C++ 표준에서 이미 허용 가능 한 범위로 정의된 것으로 보인다. 성능이 좋은 컴파일러를 이용하는 경우라면 적어도 값 반환에 따른 복사의 오버헤드를 걱정할 필요는 없다는 이야기이다. 즉, 대부분의 경우 값을 반환하는 루틴을 최적화하는 것은 어설픈 최적화의 범주에 속한다는 의미.
기계의 관점에서 쪼잔할 정도로 코드를 최적화하는 것[footnote]이를테면 배열 대신 포인터를 쓴다거나[/footnote]이 과거에는 의미가 있었을지 모르겠으나, 요즘 컴파일러들의 최적화 실력을 보면 어설픈 수준의 최적화는 오히려 성능을 낮추고 코드의 유지 보수를 힘들게 만들 가능성이 높다. 이러한 수준의 최적화는 컴파일러에게 맡기고, 사람은 프로그램의 흐름이나 논리를 면밀하게 검토하여 불필요한 연산을 제거하거나 보다 더 효율적인 알고리즘으로 교체하는 등 기계가 할 수 없는 보다 더 높은 수준에서의 최적화를 해야 할 것이다.