본문 바로가기
내배캠/TIL

[TIL: 2026.07.03] 생성자·소멸자의 생명주기와 접근 제어자 및 프로그래머스 - 최댓값과 최솟값 문제 풀이

by 4.41p._.m 2026. 7. 3.
오늘의 학습 내용
에이타니 + 코드카타 +  학습
에이타니: 생성자·소멸자의 생명주기와 접근 제어자
객체의 탄생부터 소멸에 이르는 생명주기를 관리하는 생성자·소멸자, 그리고 객체의 외벽을 세우는 접근 제어자는 안정적인 소프트웨어 아키텍처를 구축하기 위한 필수 지식이다. 복습 노트를 바탕으로 정리하고자 한다.

생성자(Constructor)와 소멸자(Destructor)의 작동 원리

기본 개념

  • 생성자: 객체가 메모리에 할당되는 순간 자동으로 호출되어 멤버 변수의 초기화나 리소스 할당을 담당한다.
  • 소멸자: 객체가 메모리에서 해제되는 순간 자동으로 호출되어 시스템 자원 반환, 동적 할당 메모리 해제 등의 정리 작업을 수행한다.

 

추가로 알아야 할 핵심 포인트: RAII 패턴과 가상 소멸자

  • RAII (Resource Acquisition Is Initialization): C++에서 가장 중요한 자원 관리 디자인 패턴이다. "자원 획득은 초기화와 동시에 이루어져야 한다"는 뜻으로, 생성자에서 메모리 할당이나 파일 열기를 수행하고, 소멸자에서 이를 자동으로 해제하도록 설계하는 방식이다. 이를 통해 개발자가 실수로 delete나 fclose를 누락하더라도 메모리 누수가 발생하는 것을 원천 차단한다.
  • 가상 소멸자(Virtual Destructor)의 절대적 중요성: 상속 관계에서 부모 클래스의 포인터로 자식 객체를 가리키고 있을 때(Base* ptr = new Derived();), 객체를 delete ptr;로 해제하면 자식 클래스의 소멸자가 호출되지 않고 부모 클래스의 소멸자만 호출되는 심각한 문제가 발생한다. 이를 방지하기 위해 상속 구조를 가진 최상위 부모 클래스의 소멸자에는 반드시 virtual 키워드를 붙여 가상 소멸자로 선언해야 한다.

 

객체의 생성 및 소멸 순서 계층 구조

복습 노트에 정리된 대로 스택(Stack) 영역에 생성된 지역 객체들은 "생성은 선입선출(FIFO), 소멸은 후입선출(LIFO)"의 명확한 역순 구조를 가진다. 여기에 '상속 관계'가 더해지면 계층 구조에 따른 물리적 제약이 발생한다.

1.부모 클래스 생성자 호출:상속 계층의 최상위부터.

자식 객체를 생성하더라도, 메모리 배치상 부모의 멤버들이 먼저 자리를 잡아야 하므로 부모 클래스의 생성자가 가장 먼저 실행됩니다.

2.자식 클래스 생성자 호출:하위 레이어로 이동.

부모의 초기화가 완료된 후, 자식 클래스 고유의 멤버 변수와 생성자 본문 코드가 실행되며 객체 탄생을 마칩니다.

3.자식 클래스 소멸자 호출:사라질 때는 역순으로.

객체의 수명이 다해 소멸할 때는 자식 클래스의 소멸자가 먼저 호출되어 자식 레이어가 독자적으로 쓰던 자원을 먼저 정리합니다.

4.부모 클래스 소멸자 호출:최종 자원 반환.

마지막으로 부모 클래스의 소멸자가 실행되며 부모 레이어의 자원까지 완전히 걷어내고 메모리 공간을 반환합니다.

 

객체의 생성 및 소멸 순서 계층 구조

기본 개념

외부나 자식 클래스에서 해당 멤버(변수/함수)에 접근할 수 있는 권한을 통제하는 문법이다.

접근 제어자 클래스 내부 자식 클래스 클래스 외부 (인스턴스 참조)
public Access 가능 Access 가능 Access 가능
protected Access 가능 Access 가능 Access 불가능
private Access 가능 Access 불가능 Access 불가능

 

추가로 알아야 할 핵심 포인트 : 컴파일 타임의 성벽

  • 접근 제어자는 런타임(프로그램 실행 중)에 메모리를 통제하는 기술이 아니라, 컴파일 타임(빌드 시점)에 컴파일러가 소스 코드를 검사하는 보안 규칙이다.
  • 즉, 외부 코드에서 private 멤버에 접근하려고 하면 컴파일러가 바이너리 파일을 뽑아내기 전에 빌드 에러를 발생시켜 프로그래머의 설계 실수를 강제로 차단한다. 구조적으로 안정적인 모듈을 만들기 위해 변경 가능성이 있는 모든 멤버 변수는 private 구역에 가두고 인터페이스 함수만 public으로 열어두는 습관이 권장된다.

 

헷갈리는 핵심 개념 바로잡기

기본 생성자(Default Constructor)의 자동 생성 조건

  • 잘못된 생각: 클래스를 만들면 컴파일러가 무조건 아무 일도 안 하는 기본 생성자를 항상 자동으로 만들어 준다.
  • 올바른 개념: 개발자가 클래스 내부에 매개변수가 있는 생성자를 단 하나라도 직접 정의하는 순간, 컴파일러는 기본 생성자(Player())를 자동으로 만들어 주지 않는다. 따라서 생성자를 커스텀하게 구현했다면 매개변수가 없는 기본 객체 생성 기능도 열어두기 위해 Player() = default; 등을 명시적으로 선언해 주어야 예기치 못한 컴파일 에러를 막을 수 있다.

구조체(struct)와 클래스(class)의 유일한 차이점

  • 잘못된 생각: 구조체는 데이터만 담는 바구니이고, 클래스는 함수까지 포함하는 복잡한 객체지향용 문법이다.
  • 올바른 개념: C++에서 struct와 class는 기술적으로 완전히 동일한 기능을 가진다. 구조체도 생성자, 소멸자, 상속, 다형성을 모두 구현할 수 있다. 두 문법의 유일한 차이점은 키워드를 생략했을 때 적용되는 기본 접근 제어자(Default Access Specifier)이다.
    • struct: 기본 접근 제어자가 public으로 지정된다.
    • class: 기본 접근 제어자가 private으로 지정된다.

코드카타: 프로그래머스 - 최댓값과 최솟값 

이번 문제는 공백으로 구분된 문자열 속 정수들을 분석하여 최솟값과 최댓값을 찾아 하나의 문자열로 결합하는 데이터 파싱(Parsing) 및 정렬 유형의 문제이다.

작성한 코드는 공백 문자를 기준으로 문자열을 한 땀 한 땀 잘라 정수로 변환한 뒤, 이를 벡터에 모아 정렬(std::sort)하여 양 끝값을 도출해 냈으며 예외 처리(마지막 문자열 처리)까지 진행했다.

알고리즘 분석 및 접근 방식

#include <string>
#include <vector>
#include <algorithm>
#include <iostream>

using namespace std;

string solution(string s) {
    vector<int> nums;
    string num = "";
    
    // 공백 기준으로 문자열을 구분하여 정수 벡터에 넣기
    for(int i = 0; i < s.length(); ++i)
    {
        if(s[i] == ' ')
        {
            nums.push_back(stoi(num));
            num = "";
            continue;
        }
        
        num += s[i];
    }
    
    // 마지막 정수 벡터에 추가
    nums.push_back(stoi(num));
    
    // 오름차순 정렬
    sort(nums.begin(), nums.end());
    
    return to_string(nums[0]) + " " + to_string(nums[nums.size() - 1]);
}

핵심 로직 작동 원리

  • 문자열 순차 스캔: 루프를 돌며 공백(' ')을 만나기 전까지의 문자들을 임시 변수 num에 누적(+= s[i])하다가, 공백을 만나는 순간 정수로 변환(stoi)해 벡터에 담는다.
  • 마지막 원소 예외 처리: 대다수의 개발자가 문자열이 끝났을 때 마지막 숫자를 벡터에 넣지 못하는 상황을 방지하기 위해, 루프가 끝난 뒤 nums.push_back(stoi(num));를 명시하여 이 예외를 완벽히 방어했다.
  • 정렬을 통한 최댓값/최솟값 획득: 정렬을 수행하면 nums[0]은 자동으로 최솟값, nums[nums.size() - 1]은 최댓값이 되므로 안전하게 정답을 도출할 수 있다.

 

연산 효율성 및 최적화 포인트 분석

시간 복잡도: 문자열의 길이를 $L$, 문자열에 포함된 정수의 개수를 N이라 하자.

  1. 문자열을 돌며 숫자를 추출하고 파싱하는 데 O(L)이 걸린다.
  2. 추출된 정수들을 오름차순 정렬하는 데 O(N log N)이 소요된다.
  • 전체 시간 복잡도는 O(L + N log N)이다. 문제의 제약 조건이 널널하여 통과에는 전혀 문제가 없지만, 하드웨어 성능을 극한으로 끌어올려야 하는 환경이라면 아래 두 가지 아쉬운 점이 남는다.

아쉬운 점 1 (정렬 오버헤드): 문제에서 요구한 것은 오직 최솟값과 최댓값 단 2개뿐이다. 모든 원소의 정렬 순서를 맞출 필요가 없으므로 정렬 연산(O(N log N))을 단 한 번의 순회(O(N))로 대체할 수 있다.

아쉬운 점 2 (메모리 파편화 및 할당 오버헤드): 모든 정수를 vector<int>에 담아두기 위해 동적 메모리 할당이 발생하며, 문자를 매번 누적하는 num += s[i] 연산 역시 빈번한 문자열 버퍼 재할당을 유발한다.

 

코드 개선 및 최적화 전략

#include <string>
#include <sstream>
#include <algorithm>

using namespace std;

string solution(string s) {
    // 1. 문자열을 파싱하기 위한 스트림 객체 생성
    stringstream ss(s);
    
    int current_num;
    // 2. 첫 번째 숫자를 뽑아 최솟값과 최댓값의 기준점으로 초기화
    ss >> current_num;
    int min_val = current_num;
    int max_val = current_num;
    
    // 3. 스트림이 빌 때까지 정수를 하나씩 추출하며 실시간 갱신 (O(N))
    while (ss >> current_num) {
        min_val = min(min_val, current_num);
        max_val = max(max_val, current_num);
    }
    
    // 4. 결과 포맷에 맞춰 최솟값과 최댓값 결합 후 반환
    return to_string(min_val) + " " + to_string(max_val);
}

개선 포인트

  1. stringstream을 이용한 자동 공백 분리
    • 스트림에 문자열을 밀어 넣고 ss >> 값 연산을 수행하면, 공백과 음수 기호(-)를 라이브러리가 알아서 인식하여 즉시 정수형 변수에 값을 파싱해 준다. substr이나 stoi를 직접 구현할 필요가 없어진다.
  2. 실시간 최소/최대 갱신 (Running Min/Max)
    • 첫 번째 숫자를 가져와 최솟값(min_val)과 최댓값(max_val)의 초기 상태로 지정한다.
    • 이후 스트림에서 숫자를 하나씩 뽑아올 때마다 std::min, std::max 함수로 값을 비교하며 실시간 갱신한다. 이 방식을 사용하면 메모리 공간 복잡도가 O(N)에서 O(1) 상수 공간으로 줄어들고 속도도 최적화된다.

 

핵심 메커니즘 비교 요약

비교 항목 기존 풀이 방식 최적화 스트림 방식
자료구조 메모리 std::vector<int> (개수 N만큼 동적 할당) 변수 몇 개만 사용 (O(1) 상수 공간)
시간 복잡도 O(L + N log N) (정렬 연산 포함) O(L) (문자열 단 1회 순회 및 파싱)
코드 안전성 루프 탈출 후 마지막 원소 예외 처리 필요 스트림 버퍼 조건식(while)으로 자동 예외 방어

 

공부를 마치며 느낀 점

언리얼 엔진에서 C++ 액터 클래스를 빌드할 때, 게임이 시작되거나 끝나는 시점에 자원을 정돈하는 핵심 트리거가 소멸자와 RAII 패턴에 기반한다는 사실을 심도 있게 이해할 수 있었다.

특히 상속 관계에서 가상 소멸자(virtual)를 누락했을 때 엔진 내부에서 잡히지 않는 미세한 메모리 누수가 발생할 수 있다는 점을 인지하게 되었으니, 앞으로 확장성을 고려한 상속 구조를 설계할 때는 최상위 부모의 소멸자 처리와 protected/private 배치를 더욱 면밀하게 체크하여 견고한 컴포넌트를 설계해야겠다.

 

공백 기반 파싱 알고리즘을 짤 때 인덱스를 계산하고 마지막 데이터를 밀어 넣는 수동 예외 처리가 가끔 실수를 유발하곤 했는데, C++의 stringstream을 도구로 활용하니 코드가 놀라울 정도로 직관적이고 견고해진다는 것을 배웠다. 더불어 "최댓값과 최솟값"이라는 목적성에 집중하여 데이터를 전부 수집한 뒤 가공(정렬)할 것인가, 들어오는 즉시 필터링(실시간 대소 비교)할 것인가에 대한 아키텍처적 시야를 넓힐 수 있었다. 앞으로 게임 데이터 파싱 모듈이나 패킷 역직렬화 코드를 빌드할 때, 무조건 메모리에 할당하기 전 이 스트림 기반 필터링 설계를 먼저 대입해 보는 습관을 지녀야겠다.