CS 면접 관련 질문들을 공부하면서 스마트 포인터에 대해 찾아보게 되었다. 이와 관련해서 안에 쓰여져 있는 내용들 중 잘 모르는 것이나 궁금한 것들을 찾아 꼬리에 꼬리를 물다보니 꽤나 오랜 시간 동안 찾아보게 되었다. 그 과정에서 알게 된 내용이나 생각한 내용을 정리해보고자 한다.
출처 : 코딩의 시작, TCP School
메모리 관리에 대해서 가장 먼저 알아야 할 내용은 메모리 구조이다.
메모리 구조는 위에 보이는 것처럼 되어있는데 이 중 프로그래머가 신경써야 할 부분은 힙 영역이다.
나머지 영역은 프로그램이 알아서 관리를 하지만, 힙 영역은 프로그래머가 동적으로 메모리를 할당할 수 있는 부분이기에 실수가 발생할 수 있기 때문이다.
예를 들면 이런 것이다. 게임을 하면서 던전에 들어갈 때 동적인 메모리를 할당받아 10MB만큼 사용했다고 치자. 그런데 던전이 끝날 때 할당받은 메모리를 반납하지 않으면 어떻게 될까? 이 메모리 공간이 다시 사용될 일이 없다고 할지라도 프로그램을 이를 사용 중인 공간으로 인식한다. 이것이 메모리 누수이다. 램 누수라고도 한다.
새로운 메모리를 할당받고 반납하지 않는 일을 반복하게 되면, 사용하지도 않으면서 공간을 차지하는, 소위 쓰레기 메모리들이 늘어나게 된다. 그러면서 프로그램은 점차 느려지게 된다.
힙 영역에 쓰레기 메모리들이 쌓이면서 힙 영역이 부족해지면 프로그램은 힙 영역을 확장한다. 이를 반복하다보면 메모리에 한계가 오게 되고, 결국 더 이상 확장할 수 없게 되면 프로그램을 강제 종료한다. 우리가 램누수 게임이라고 부르는 게임들이 자주 튕기는 이유이다.
다행스럽게도, 유니티에서 사용하는 언어인 C#에서는 가비지 컬렉션이라는 기능을 제공한다. 접근할 수 없는 객체들을 주기적으로 정리하여 메모리 누수를 방지해준다. 단, 가비지 컬렉션은 코스트가 높은 작업이기 때문에 자주 실행되면 프로그램의 성능이 저하될 수 있으므로 쓰레기는 최대한 적게 만드는 것이 좋다.
C와 C++의 경우는 가비지 컬렉션 기능이 없다. 아마 포인터를 사용하기 때문인 것 같다. 가비지 컬렉션을 수행하면서 메모리의 주소를 자주 바꾸기 때문에, 포인터를 사용하는 C나 C++과는 상성이 좋지 않은 것 같다. 실제로 C#에서는 포인터를 사용할 수 있지만 사용하려면 안전하지 않다는 의미의 unsafe를 동반하는 코드를 작성해야 하며, 변수의 주소를 고정하는 fixed 기능을 제공하거나, 일반적인 포인터 사용을 대체하는 ref, out, in을 제공하는 것을 봐서는 어느정도 맞는 추측일 것 같다.
때문에 C의 경우, malloc() 혹은 calloc()으로 할당받은 메모리를 free()로 해제해주어야 하고, C++의 경우 new로 할당받은 메모리를 delete()로 해제해주어야 한다. 다만 C++은 다행히도 가비지 컬렉터처럼 메모리 누수를 방지해주는 스마트 포인터라는 개념이 도입된 모양이다. 가비지 컬렉터와 비교하면 사용은 조금 불편한 것 같지만 효율은 좋은 것 같다.
추가로, 언리얼은 C++ 기반인데, 그러면 가비지 컬렉터가 없나? 싶어 찾아보니 오브젝트의 베이직 클래스인 UObject에는 가비지 컬렉터를 사용하고, 코드 내에서는 스마트 포인터도 사용한다는 것 같다.
왜 이런 메모리 누수가 발생하는 것일까? 예전에는 개발 능력이 부족해서 그런걸까? 싶었지만, 지금은 생각이 바뀌었다. 아마 빠듯한 개발 일정 때문일 것이다. 이것저것 바쁘게 기능을 구현하다보면 코드를 되돌아볼 시간 따위는 없다. 구현하고 나서 "이렇게 하면 더 좋을 것 같은데" 싶은 생각이 들어도 남은 일을 쳐내기 바쁘다. 다 그런 것은 아니겠지만 나는 그랬다.
회사 입장에서도 하루라도 빨리 개발 일정을 앞당겨서 빠르게 자금을 회수하길 바랄 것이다. 특히 게임 업계는 망하면 손해가 이만저만이 아닌데, 고작 성능 개선을 위해 붙잡고 끙끙 싸매기보다는 빠르게 개발해주길 바라지 않을까? 그렇게 생각하면 램누수로 인한 팅김이 잦은 게임을 하며 짜증보다는 안타까운 마음이 앞선다. 그들은 아마 주어진 시간 내에서 최선을 다했을 것이다. 그래도 게임이 궤도에 오르면 보통 최적화 패치를 해주니 다행이다. 지금 생각해보면 어쩔 수 없는 수순인 것 같기도 하다.
s의 길이는 2000이라고 가정하여 닫히는 괄호( } )를 넣어 10만 회 반복하여 1회당 평균 소요 시간을 5번씩 구해본 결과는 다음과 같았습니다.
erase 활용(단위 : ms)
210984
211956
211002
219648
210093
for문 활용(단위 : ms)
164263
164159
163900
163354
163827
총 5회 시행 결과 최소 21.8%, 최대 25.6% 까지 소요 시간이 감소했습니다.
열리는 괄호( { )를 넣었을 때는 시간 소요가 500배가량 커서 1000번만 반복하도록 했습니다.
erase 활용(단위 : ms)
99133066
100115358
100007476
99407056
100030480
for문 활용(단위 : ms)
93395129
94328784
93144627
94805494
95105520
총 5회 시행 결과 최소 4.1%, 최대 6.7% 까지 소요 시간이 감소했습니다.
닫히는 괄호의 경우, 반복문을 도는 시간보다 한 시행 내에서 소요되는 시간의 비중이 커서 소요 시간 감소의 폭이 적은 것으로 보입니다. 반대로, 열리는 괄호의 경우는 반복문을 도는 시간의 비중이 상대적으로 커져서 방법의 변화가 큰 영향을 준 것 같네요.
열리는 괄호와 닫히는 괄호의 짝이 맞는다면, 단순히 계산했을 때 평균적으로 14.55%정도 소요 시간을 감소시킬 수 있을 것으로 보입니다.
둘 다 테스트에서 충분히 통과하는 코드이고, 얼핏 보기에는 큰 차이 없어보이지만 직접 얼마나 차이가 나는지 확인해보니 아주 큰 차이인 것 같습니다. 모든 부분에서 이만큼의 효율을 증가시킬 수는 없겠지만 만약 가능하다면 꼬박 하루의 시간이 걸리는 계산을 한다고 가정하면 3.6시간 가량을 단축시킬 수 있고, 게임의 경우 시스템 권장 사항 등이 15%정도 낮아지고 그만큼 로딩시간이 감소하거나 프레임이 향상되고 게임을 쾌적하게 즐길 수 있을테니까요.
참 별 것 아닌 것처럼도 보이지만, 이런 것들이 모여 큰 차이를 만들어낼 테니 늘 고민하는 자세가 필요할 것 같습니다.