워라밸 브레이커, 메모리릭을 찾아라(4/4)

| 2021-11-29

안녕하세요, 넷마블 TPM실 기술분석팀 김범진입니다.

앞서서 수동으로 덤프를 수집해 메모리릭이 발생하는 위치를 찾는 과정을 살펴봤습니다.

이 과정으로 소멸하지 않고 메모리를 점유하고 있는지 객체가 어느 것인지 힌트는 얻었지만, 본질을 해소하려면 어떤 것을 조심해야 할까요?

모던 C++의 메모리릭 유형

메모리릭을 유발하는 객체에 대한 힌트를 얻었다고 하더라도, 메모리릭을 찾기 위해서는 기술적인 패턴을 알아야 합니다. smart pointer의 개발 및 smart pointer 사용 유저 증가로 인해 과거 많이 발생했던 delete 누락과 overwrite 등의 패턴은 보기 힘들어졌습니다. 하지만 smart pointer에도 고질적인 메모리릭을 유발하는 패턴이 있습니다. 바로 “순환 참조(Circular Reference)“라고 명명된 녀석입니다. 

shared_ptr

먼저 smart pointer의 한 종류인 shared_ptr가 개발된 배경은, 작업자가 신경 쓰지 않아도 때가 되면 자동으로 delete 해서 leak을 방지하기 위함이었습니다.

내부에는 크게 실제 객체의 주소 값과 참조 카운트란 녀석이 있습니다. 참조 카운트는 대입 또는 생성 시 증가하고, 초기화(혹은 overwrite) 또는 소멸 시 감소합니다. shared_ptr는 참조 카운트 값이 0이 되면, 실제 객체를 삭제하는 도우미입니다. 이 기능 덕분에 use after free도 방지할 수 있습니다.

순환 참조가 왜 릭인가요?

A, B 두 객체가 있다고 가정해 봅시다. 그리고 각 객체는 서로에 대해 shared_ptr을 소유하고 있습니다. 이것을 삭제하려면 어떻게 해야 할까요? 각각 소유한 shared_ptr을 명시적으로 초기화해 참조 카운트가 감소하면 자연스럽게 삭제될 겁니다. 하지만 보통 자동화 유틸이라는 측면에 가려져 명시적인 초기화를 누락할 수도 있습니다. 

한쪽이 초기화되기 전까지는 영원히 ref count는 ‘1’입니다

명시적인 초기화를 소멸자에 작성하는 케이스도 있을 겁니다. 이때는 동작 순서가 맞지 않아 영원히 삭제되지 않습니다. 소멸자는 delete 이후에 호출되는 함수이기 때문이며, delete는 smart pointer의 참조 값이 0이 돼야 합니다.

즉 개발자의 의도대로라면, A를 delete하면 A 소멸자를 호출해서 B의 ref count가 감소합니다. 그리고 B를 delete하며 B 소멸자가 호출돼 A의 ref count가 감소합니다. 그로 인해 다시 B를 delete해야 하는(…) 마치 뫼비우스의 띠 같은 논리 상태가 돼버립니다.

이런 케이스 등으로 영원히 서로 걸린 참조를 해제하지 못해 참조 카운트를 감소시키지 못하면, 결국 메모리릭으로 발전합니다.

MMO 서버에서 순환 참조가 왜 나오나요?

현업에서 왜 이런 상황이 나오는지 예시를 들어 본다면 더 명확해질 것입니다.

MMO 월드에서 상호작용하는 플레이어 객체를 설계해 봅시다. 공격하는 타깃이나 소환수 등의 데이터에 바로 접근하기 위해 포인터를 사용할 것이고, use after free로 인한 데이터 오염이나 프로세스 크래시가 우려되므로 shared_ptr로 구성한 객체를 만듭니다. 그리고 타깃이나 펫을 명시적으로 초기화하기 위해 소멸자에 초기화 코드를 작성해 둡니다. 이것으로 객체 설계가 끝났습니다.

월드에서 상호작용하는 객체 class 예시

이제 전투 콘텐츠를 만들어 봅시다. A와 B가 서로 싸우고 있군요. 이때 대부분 RPG에서는 데미지 계산을 빠르게 처리하기 위해 타겟팅이라는 개념을 도입합니다. A와 B는 각각 서로를 타깃으로 지정하고 있습니다. 전투 막바지에  B가 A에게 막타를 치고 A를 사망 처리한다고 가정해 봅시다.

전투 콘텐츠 예시

타깃을 공격하는 함수는 다음과 같이 작성될 겁니다.

타깃을 공격하는 함수 예시

B가 A에게 막타를 가했고, A가 사망함으로 B는 더이상 타깃 A가 필요 없습니다. 명시적인 초기화가 필요한 부분입니다. 그럼 A의 참조 카운트는 감소할 것이고, 결국 0이 돼 객체는 제대로 삭제될 것입니다. 삭제되면서 A 역시 소멸자에 넣어둔 타깃 B를 명시적으로 초기화할 것입니다. 여기까지는 문제가 없어 보입니다.

하지만 A와 B가 전투 중, 길을 가던 못된 마법사가 A와 B에게 범위 스킬을 사용해 동시에 타격하고, 동시에 사망 처리를 한다고 가정해 봅시다. 범위 공격에 대한 함수는 다음과 같이 작성될 겁니다.

지나가는 마법사의 횡포(?) 예시

마법사의 범위공격은 논타깃(None-Target) 공격입니다. 타깃이 필요 없으므로 마법사의 타깃은 여전히 null입니다. 마법사 입장에서는 A와 B가 각각 지정한 타깃 데이터는 필요가 없으며, 관심도 없습니다. A와 B의 타깃을 명시적으로 초기화하는 작업이 누락될 확률이 높습니다. 만약 누락한다면, 사망한 A와 B 객체는 여전히 서로를 타깃으로 지정한 채 남게 됩니다. 이것이 앞서 설명했던 순환 참조 관계입니다. 예시로 든 논리적인 허점 외에도 여러 케이스가 있을 수 있습니다. 다양한 가능성을 생각해 봐야 할 것입니다.

shared_ptr은 주의해서 사용해야 합니다

shared_ptr을 사용 시, 고려해야 할 사항이 있어 보입니다. 삭제할 때, 소멸자 의존성을 좀 더 줄여 명시적인 초기화 함수로 호출해 보는 것도 한 방법일 겁니다. 추가로 이런 상황을 방지하기 위해 weak_ptr이란 것도 있습니다. shared_ptr과 사용법은 동일하지만, 참조 카운트가 증가하지 않습니다. weak_ptr을 도입하는 것도 한 방법일 것입니다.

순환 참조로 보이는 현상은 예제 외에도 다양한 패턴으로 나타날 수 있습니다. 이런 성질을 정확히 알고, 주의해서 사용해야 합니다.

주의해서 익숙해질 때까지

여기까지, 메모리릭을 찾기 위한 한 방법을 소개했습니다. 이것 외에도 여러 방법이 있을 것이니, 각자 스타일에 응용할 수 있는 힌트가 됐으면 합니다. 이 방법은 시간이 오래 걸리고 무식해 보이지만, 익숙해진다면 여러 해 묵혀둔 버그를 해소하실 수도 있습니다. 한번 테스트해 보시는 것도 나쁘지 않으리라 생각합니다.

메모리릭을 찾을 때, 메모리 프로파일러만 사용하기엔 제약사항이 많습니다. 또한 프로파일러는 개발단계에만 활용하는 것이 좋습니다. 메모리릭을 방지하기 위한 smart pointer가 복잡한 이슈를 만드는 케이스도 있습니다. 주의해서 사용해야 합니다.

긴 글로 공유한 지식이 여러분께 도움이 됐으면 합니다. 

감사합니다.