오늘의 학습 내용
에이타니 + 코드카타 + 학습
에이타니: 클래스(Class)와 객체(Object)의 정의 및 메모리 배치 메커니즘
객체지향 프로그래밍을 지탱하는 가장 거대한 두 축은 클래스(Class)와 객체(Object, 인스턴스)이다. 복습 노트를 통해 정리한 설계도와 실체의 개념을 바탕으로, C++ 컴파일러가 이를 바라보는 시각과 실제 시스템 메모리의 스택/힙 영역에 데이터가 어떻게 배치되는지 정리했다.
클래스와 객체의 본질적 차이
기본 개념
클래스 (Class): 청사진과 설계도
- 클래스는 객체를 만들어내기 위해 변수와 함수를 정의해 둔 사용자 정의 자료형(User-defined Data Type)이다.
- 코드 단에서 class Player { ... };라고 선언하는 행위는 메모리 공간을 전혀 차지하지 않는다. 단지 컴파일러에게 "앞으로 Player라는 이름의 객체를 만들면 이런 모양으로 인식해라"고 가이드라인만 제공하는 구조적 틀이다.
객체 (Object / Instance): 실제 물리적 실체
- 클래스라는 설계도를 바탕으로 프로그램 실행 시점(Runtime)에 변수 선언을 통해 메모리에 구체적으로 할당된 실체를 의미한다.
- Player player1;을 실행하는 순간, 메모리의 빈 공간에 player1만을 위한 고유한 물리적 방이 개설되며 이때 비로소 사용 가능한 데이터 공간이 확보된다.
멤버 변수(Member Variable)와 메모리 할당 원리
기본 개념
객체의 상태(State)와 속성을 저장하기 위해 클래스 내부에 선언된 변수이다. 필드(Field)라고도 부른다.
추가로 알아야 할 핵심 포인트 : 구조체 배열과 인스턴스별 독립성
- 하나의 클래스로부터 player1, player2 등 여러 개의 객체를 생성하면, 이들은 메모리상에서 완전히 분리된 독립된 공간을 차지한다.
- player1.health = 100;을 수정한다고 해서 player2.health 값이 변하지 않는 이유는, 메모리 주소 자체가 완전히 다른 별개의 물리 블록에 멤버 변수들이 배치되기 때문이다.
- 메모리 정렬(Structure Alignment): C++ 컴파일러는 CPU의 메모리 접근 효율을 높이기 위해 멤버 변수들을 단순히 크기대로만 붙이지 않고, 특정 바이트(예: 4바이트 또는 8바이트) 단위로 패딩(Padding) 바이트를 끼워 넣으며 최적화된 주소지에 배치한다. 따라서 변수의 선언 순서에 따라 객체 하나의 실제 물리 크기가 달라질 수 있다.
멤버 함수(Member Function)의 공유 메커니즘
기본 개념
객체가 수행할 수 있는 행동(Behavior)과 기능을 구현한 클래스 내부의 함수이다. 메서드(Method)라고도 부른다.
추가로 알아야 할 핵심 포인트 : 암시적 this 포인터
- 메모리 절약 법칙: 멤버 변수는 객체마다 독립적으로 메모리를 할당받지만, 멤버 함수는 객체가 100만 개가 만들어지더라도 메모리의 코드 영역(Code Segment)에 단 하나만 존재한다. 객체를 만들 때마다 함수 코드까지 복사하는 것은 엄청난 메모리 낭비이기 때문이다.
- 그렇다면 단 하나뿐인 TakeDamage() 함수는 어떻게 자기를 호출한 객체가 player1인지 player2인지 구별해서 체력을 깎을 수 있을까?
- C++ 컴파일러는 멤버 함수를 호출할 때, 호출한 객체의 메모리 주소값을 함수의 첫 번째 인자로 은밀하게 넘겨준다. 이를 this 포인터라고 한다. 내부적으로는 아래와 같이 변환되어 작동하는 셈이다.
헷갈리는 핵심 개념 바로잡기
멤버 변수와 지역 변수(Local Variable)의 스코프 차이
- 잘못된 생각: 클래스 내에 정의된 변수와 함수 내에 정의된 변수는 수명 주기가 비슷하다.
- 올바른 개념: 지역 변수는 함수가 실행되는 동안 스택(Stack) 프레임에 잠깐 머물다 함수가 return하는 즉시 소멸한다. 반면 멤버 변수는 그 변수가 속한 '객체'가 메모리에 살아있는 한(게임이 끝나거나 해당 액터가 파괴되기 전까지) 계속해서 메모리에 유지되는 완벽히 다른 생명 주기를 가진다.
멤버 변수의 크기가 곧 클래스의 크기인가?
- 잘못된 생각: 클래스의 크기는 내부의 멤버 변수 크기와 멤버 함수의 코드 크기를 모두 더한 값이다.
- 올바른 개념: 앞서 설명했듯 함수는 객체 내부에 저장되지 않는다. sizeof(Player)를 측정해 보면 오직 내부의 멤버 변수들의 크기 합산(및 패딩 바이트)만 계산되어 나온다. 심지어 멤버 변수가 아예 없는 빈 클래스(class Empty {};)의 크기를 측정하면 0바이트가 아니라 1바이트가 도출된다. 메모리상에서 고유한 객체로서의 주소(정체성)를 가질 수 있도록 컴파일러가 최소한의 더미 공간을 부여하기 때문이다.
코드카타: 카카오 기출 - 신고 결과 받기
이번 문제는 각 유저가 신고한 내역을 취합하고, 누적 신고 횟수가 특정 기준(k번)을 넘은 불량 유저를 가려내어 해당 유저를 신고한 사람들에게 처리 결과 메일을 발송하는 해시 기반 데이터 모델링 유형의 문제이다.
작성한 풀이는 문제의 핵심 예외 조건인 "동일한 유저가 한 유저를 여러 번 신고한 경우 신고 횟수는 1회로 처리한다"를 중복을 허용하지 않는 자료구조인 std::unordered_set을 활용해 아주 깔끔하고 직관적으로 해결했다. report_map의 Key를 '신고당한 사람(to)'으로 잡고, Value에 '신고한 사람들의 집합(set)'을 매핑했다.
알고리즘 분석 및 접근 방식
#include <string>
#include <vector>
#include <unordered_map>
#include <unordered_set>
#include <sstream>
using namespace std;
// K: 게시판 이용 정지 조건
// 같은 유저가 여러 번 신고하면 1회로 처리
vector<int> solution(vector<string> id_list, vector<string> report, int k) {
vector<int> answer(id_list.size(), 0);
unordered_map<string, unordered_set<string>> report_map;
unordered_map<string, int> userId;
for(int i = 0; i < id_list.size(); ++i)
{
userId[id_list[i]] = i;
}
for(const string& r : report)
{
stringstream ss(r);
string from, to;
ss >> from >> to;
report_map[to].insert(from);
}
for(const auto& pair : report_map)
{
if(pair.second.size() >= k)
{
for (const string& reporter : pair.second)
{
int index = userId[reporter];
++answer[index];
}
}
}
return answer;
}
코드 개선 및 최적화 전략
#include <string>
#include <vector>
#include <unordered_map>
#include <unordered_set>
using namespace std;
vector<int> solution(vector<string> id_list, vector<string> report, int k) {
vector<int> answer(id_list.size(), 0);
// 해시 맵 버퍼 미리 확보 (재할당 오버헤드 최소화)
unordered_map<string, int> userId;
userId.reserve(id_list.size());
for(int i = 0; i < id_list.size(); ++i) {
userId[id_list[i]] = i;
}
// Key: 신고당한 유저, Value: 그 유저를 신고한 사람들의 고유 집합
unordered_map<string, unordered_set<string>> report_map;
report_map.reserve(id_list.size());
// 1. 신고 내역 파싱 및 역방향 셋 매핑 (O(M))
for(const string& r : report) {
// stringstream 대신 고정 포맷의 특성을 활용해 공백 위치 탐색 (성능 최적화)
size_t space_idx = r.find(' ');
string from = r.substr(0, space_idx);
string to = r.substr(space_idx + 1);
report_map[to].insert(from); // 중복 신고는 set 내부에서 자동 제거됨
}
// 2. 정지 대상 유저 판별 및 메일 발송 카운팅 (O(N))
for(const auto& pair : report_map) {
// 신고자 수가 k명 이상인 경우에만 처리
if(pair.second.size() >= k) {
for (const string& reporter : pair.second) {
int index = userId[reporter];
++answer[index];
}
}
}
return answer;
}
개선 포인트
- stringstream 오버헤드 걷어내기
- stringstream은 다루기 편리하지만 내부적으로 객체를 생성하고 버퍼를 할당하는 무거운 도구이다. 이 문제의 report 원소는 항상 "공백" 한 개로만 구분된 고정 포맷("from to")이다. 따라서 std::string::find와 substr을 사용하면 메모리 할당을 줄이고 문자열 파싱 속도를 훨씬 끌어올릴 수 있다.
- unordered_map 내부의 unordered_set 동적 할당 오버헤드
- 해시 맵 안에 또 다른 해시 셋을 넣는 구조는 내부 버퍼가 커질 때마다 재할당(Rehash)이 빈번하게 일어나 런타임 성능에 영향을 줄 수 있다. 데이터를 삽입하기 전 userId 맵의 크기를 예약(reserve)해 주는 습관이 좋다.
공부를 마치며 느낀 점
언리얼 엔진에서 무수히 많은 몬스터 액터(Actor)나 발사체(Projectile)를 스폰할 때, 이들이 클래스라는 하나의 설계도를 공유하면서도 각자 고유한 트랜스폼 데이터와 체력을 유지하는 원리가 스택/힙 메모리의 배치 구조와 this 포인터 메커니즘 덕분이라는 것을 깊이 있게 깨달았다.
메모리를 낭비하지 않는 단단한 게임 서버를 설계하기 위해서는 매 프레임 일시적으로 쓰고 버릴 레이캐스팅 결과나 임시 정수 값들은 철저히 지역 변수(Local)로 제한하고, 전역적으로 유지해야 할 캐릭터의 정체성만 멤버 변수로 승격시켜 가볍고 기민한 클래스 레이아웃을 짜야겠다는 설계적 기준이 명확해졌다.
역방향 셋 매핑(map<string, set<string>>) 구조는 다 대 다(N:M) 관계를 가지는 소셜 데이터나 로그 시스템을 구축할 때 가장 기본이 되는 설계 형태이다.
게임 서비스에 비유하자면, 특정 어뷰징 행동을 한 유저를 정지할 때 "어떤 유저가 누구누구를 신고했는지" 일일이 전수조사하는 것이 아니라, "이 타깃 유저에게 들어온 고유 신고 리포트가 몇 개인지"를 인덱스 테이블로 상시 요약해 두는 원리와 같다는 점을 알게 되었다.