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에는 가비지 컬렉터를 사용하고, 코드 내에서는 스마트 포인터도 사용한다는 것 같다.

 

왜 이런 메모리 누수가 발생하는 것일까? 예전에는 개발 능력이 부족해서 그런걸까? 싶었지만, 지금은 생각이 바뀌었다. 아마 빠듯한 개발 일정 때문일 것이다. 이것저것 바쁘게 기능을 구현하다보면 코드를 되돌아볼 시간 따위는 없다. 구현하고 나서 "이렇게 하면 더 좋을 것 같은데" 싶은 생각이 들어도 남은 일을 쳐내기 바쁘다. 다 그런 것은 아니겠지만 나는 그랬다.

회사 입장에서도 하루라도 빨리 개발 일정을 앞당겨서 빠르게 자금을 회수하길 바랄 것이다. 특히 게임 업계는 망하면 손해가 이만저만이 아닌데, 고작 성능 개선을 위해 붙잡고 끙끙 싸매기보다는 빠르게 개발해주길 바라지 않을까? 그렇게 생각하면 램누수로 인한 팅김이 잦은 게임을 하며 짜증보다는 안타까운 마음이 앞선다. 그들은 아마 주어진 시간 내에서 최선을 다했을 것이다. 그래도 게임이 궤도에 오르면 보통 최적화 패치를 해주니 다행이다. 지금 생각해보면 어쩔 수 없는 수순인 것 같기도 하다.

많은 개발자분들이 블로그를 시작하면 좋다고 하시는 이유를 알 것 같습니다.

어제 막 시작한 참이지만 개발탭을 만들어놓고 글이 하나도 없으니 뭐라도 써야될 것 같은 기분이 드네요.

게임을 할 때도 이런 일이 있지요. 소위 길드나 서클과 같은 곳에 가입하게 되면, 누가 시킨 것도 아닌데 다른 사람들의 시선이 신경쓰여서라도 더 열심히 해야만 할 것 같은 기분이 들잖아요? 친구랑 같은 게임을 하게 되었을 때도 그렇고요.

덕분에 아침부터 공부를 할 수 있게 되어 다행입니다. 공부는 시작해서 집중하다보면 재밌지만, 시작하기가 어려운 일이니까요. 앞으로 열심히 블로그를 써야겠습니다.

 

개발탭에는, 아직 부족한 부분이 많아 다른 분들처럼 수준 높은 정보글같은 것은 쓰기 어려울테니 코드를 구현하면서 스스로 고민한 부분이나, 이에 관한 생각 등을 적게될 것 같습니다.

 

코딩테스트 연습 - 괄호 회전하기 | 프로그래머스 스쿨 (programmers.co.kr)

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

이번에 적을 글은, 프로그래머스 Lv.2 괄호 회전하기를 풀면서 들었던 생각에 관한 탐구입니다. 문제는 다음과 같습니다.

 

괄호를 왼쪽으로 회전하면서 올바른 괄호 문자열이 되게 하는 x의 개수를 구하는 문제입니다.

올바른 괄호인지 확인하는 방법은 vector를 stack처럼 사용해서 구현했는데, 왼쪽으로 괄호를 회전하는 방법이 고민되는 부분이었습니다.

 

s += s[0];
s.erase(s.begin());

처음에는 단순히 첫 번째 글자를 마지막에 추가하고, 첫 번째 원소를 삭제하는 방식을 활용했습니다.

 

그런데, 첫 번째 원소를 삭제하면 뒤에 있는 모든 원소를 앞으로 한 칸씩 땡겨와야 하기 때문에, s의 길이가 만약 1000이라면 999개의 원소를 이동하는 작업을 999번이나 반복해야할테니, 약간 비효율적이라고 느꼈습니다.

그래서 어떻게하면 더 효율적으로 구현할 수 있을까? 생각을 해봤는데, 어차피 원형으로 순회하는 것이기 때문에 배열을 변환하는 것이 아니라 for문을 활용하여 특정 지점부터 끝까지 순회를 하고, 처음부터 특정 지점까지 순회를 하는 방식으로 구현을 해보았습니다.

그림판을 활용해서 그려봤습니다.

그림으로 표현하면 이렇게 되겠네요.

실제로 이렇게 바꿔서 제출을 해보니, 소요시간이 미미하지만 단축된 것을 확인할 수 있었습니다.

하지만 누를 때마다 결과가 들쭉날쭉이어서 실제로 어느정도의 차이가 있는지 궁금하여 Dev-C++을 활용하여 확인해봤습니다.

 

 

1번(erase를 활용한 방법)

#include <string>
#include <vector>
#include <iostream>
#include <chrono>

using namespace std;
using namespace chrono;

// 괄호가 옳게 되어있는지 체크 
bool checkBracket(string s) {
    vector<char> bracket; // STACK으로 사용 
    
    for(int i=0; i<s.size(); i++) {
        if(s[i] == '(' || s[i] == '{' || s[i] == '[') bracket.push_back(s[i]); // 괄호가 열리면 push
        else if(bracket.size() == 0) return false; // 괄호가 닫히는 경우 pop을 해야하므로, pop할 원소가 없으면 return 
        else if(s[i] == ')') { // 괄호가 닫히면 pop
            if(bracket.back() == '(') bracket.pop_back();
            else return false;
        }
        else if(s[i] == '}') {
            if(bracket.back() == '{') bracket.pop_back();
            else return false;
        }
        else if(s[i] == ']') {
            if(bracket.back() == '[') bracket.pop_back();
            else return false;
        }
    }
//     for(int i=0; i<rotate; i++) {
//         if(s[i] == '(' || s[i] == '{' || s[i] == '[') bracket.push_back(s[i]); // 괄호가 열리면 push
//         else if(bracket.size() == 0) return false; // 괄호가 닫히는 경우 pop을 해야하므로, pop할 원소가 없으면 return 
//         else if(s[i] == ')') {  // 괄호가 닫히면 pop
//             if(bracket.back() == '(') bracket.pop_back();
//             else return false;
//         }
//         else if(s[i] == '}') {
//             if(bracket.back() == '{') bracket.pop_back();
//             else return false;
//         }
//         else if(s[i] == ']') {
//             if(bracket.back() == '[') bracket.pop_back();
//             else return false;
//         }
//     }
    if(bracket.size() == 0) return true;
    else return false;
}

int solution(string s) {
    int answer = 0;
    
    system_clock::time_point startTime = system_clock::now(); // 시작 시간 
    
    int repeat = 100000;
    
    for(int i=0; i<repeat; i++) {
		for(int i=0; i<s.size(); i++) {
        if(checkBracket(s)) answer++;
        
        s += s[0];
        s.erase(s.begin());

    	}
	}
	
	system_clock::time_point endTime = system_clock::now(); // 종료 시간 
    
	nanoseconds spend = endTime - startTime; // 소요 시간 
	nanoseconds averSpend = spend / repeat; // 평균 소요 시간 
	    
	cout << averSpend.count() << endl;
    
    return answer;
}

int main() {
	string s ="";
	for(int i=0; i<2000; i++) s+='}';
	solution(s);
	return 0;
}

2번(for문을 활용한 방법)

#include <string>
#include <vector>
#include <iostream>
#include <chrono>

using namespace std;
using namespace chrono;

// 괄호가 옳게 되어있는지 체크 
bool checkBracket(string s, int rotate) {
    vector<char> bracket; // STACK으로 사용 
    
    for(int i=0; i<s.size(); i++) {
        if(s[i] == '(' || s[i] == '{' || s[i] == '[') bracket.push_back(s[i]); // 괄호가 열리면 push
        else if(bracket.size() == 0) return false; // 괄호가 닫히는 경우 pop을 해야하므로, pop할 원소가 없으면 return 
        else if(s[i] == ')') { // 괄호가 닫히면 pop
            if(bracket.back() == '(') bracket.pop_back();
            else return false;
        }
        else if(s[i] == '}') {
            if(bracket.back() == '{') bracket.pop_back();
            else return false;
        }
        else if(s[i] == ']') {
            if(bracket.back() == '[') bracket.pop_back();
            else return false;
        }
    }
     for(int i=0; i<rotate; i++) {
         if(s[i] == '(' || s[i] == '{' || s[i] == '[') bracket.push_back(s[i]); // 괄호가 열리면 push
         else if(bracket.size() == 0) return false; // 괄호가 닫히는 경우 pop을 해야하므로, pop할 원소가 없으면 return 
         else if(s[i] == ')') {  // 괄호가 닫히면 pop
             if(bracket.back() == '(') bracket.pop_back();
             else return false;
         }
         else if(s[i] == '}') {
             if(bracket.back() == '{') bracket.pop_back();
             else return false;
         }
         else if(s[i] == ']') {
             if(bracket.back() == '[') bracket.pop_back();
             else return false;
         }
     }
    if(bracket.size() == 0) return true;
    else return false;
}

int solution(string s) {
    int answer = 0;
    
    system_clock::time_point startTime = system_clock::now(); // 시작 시간 
    
    int repeat = 100000;
    
    for(int i=0; i<repeat; i++) {
        for(int i=0; i<s.size(); i++) {
        if(checkBracket(s, i)) answer++;

//        s += s[0];		
//        s.erase(s.begin());

        }
	}
	
	system_clock::time_point endTime = system_clock::now(); // 종료 시간 
    
	nanoseconds spend = endTime - startTime; // 소요 시간 
	nanoseconds averSpend = spend / repeat; // 평균 소요 시간 
	    
	cout << averSpend.count() << endl;
    
    return answer;
}

int main() {
	string s ="";
	for(int i=0; i<2000; i++) s+='}';
	solution(s);
	return 0;
}

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%정도 낮아지고 그만큼 로딩시간이 감소하거나 프레임이 향상되고 게임을 쾌적하게 즐길 수 있을테니까요.

 

참 별 것 아닌 것처럼도 보이지만, 이런 것들이 모여 큰 차이를 만들어낼 테니 늘 고민하는 자세가 필요할 것 같습니다.

+ Recent posts