본문으로 건너뛰기 C++ Stack Unwinding | '스택 되감기' 가이드

C++ Stack Unwinding | '스택 되감기' 가이드

C++ Stack Unwinding | '스택 되감기' 가이드

이 글의 핵심

C++ Stack Unwinding: "스택 되감기" 가이드. 스택 되감기 기본·소멸 순서.

들어가며

스택 되감기(Stack Unwinding)는 C++ 예외 처리의 핵심 메커니즘입니다. 예외가 발생하면 스택을 거슬러 올라가며 지역 객체의 소멸자를 자동으로 호출하여 자원을 정리합니다.


1. 스택 되감기 기본

C++에서 예외를 throw하면 프로그램은 그 즉시 죽지 않습니다. 대신 런타임은 예외를 처리할 수 있는 catch 블록을 찾을 때까지 호출 스택을 거슬러 올라가면서, 지나쳐 가는 각 스택 프레임에 있던 지역 객체들의 소멸자를 순서대로 호출합니다. 이 과정을 스택 되감기라고 부르며, 이것이 바로 C++에서 try/catch 없이도 자원이 안전하게 정리될 수 있는 근본적인 이유입니다.

작동 원리

아래 예제는 func1 → func2 → func3 순서로 호출이 이어지다가 func3에서 예외가 던져지는 상황을 보여줍니다. 예외가 발생하는 순간 func3의 지역 객체 w3가 가장 먼저 소멸되고, 이어서 func2w2가 소멸된 뒤에야 func1catch 블록에 도달합니다. func1이 예외를 처리하고 나면 func1의 지역 객체 w1도 정상적으로 소멸되므로, 생성의 역순으로 소멸이 일어난다는 규칙이 예외 상황에서도 그대로 유지되는 것을 확인할 수 있습니다.

#include <iostream>
#include <stdexcept>

class Widget {
    std::string name;
    
public:
    Widget(const std::string& n) : name(n) {
        std::cout << name << " 생성" << std::endl;
    }
    
    ~Widget() {
        std::cout << name << " 소멸" << std::endl;
    }
};

void func3() {
    Widget w3("Widget3");
    throw std::runtime_error("에러 발생!");
    // w3 소멸자 호출
}

void func2() {
    Widget w2("Widget2");
    func3();
    // w2 소멸자 호출
}

void func1() {
    Widget w1("Widget1");
    try {
        func2();
    } catch (const std::exception& e) {
        std::cout << "예외 처리: " << e.what() << std::endl;
    }
    // w1 소멸자 호출
}

int main() {
    func1();
    return 0;
}

위 코드를 실행하면 아래와 같은 순서로 로그가 출력되며, 생성 순서(Widget1 → Widget2 → Widget3)와 소멸 순서(Widget3 → Widget2)가 정확히 역방향임을 눈으로 확인할 수 있습니다.

출력:

Widget1 생성
Widget2 생성
Widget3 생성
Widget3 소멸
Widget2 소멸
예외 처리: 에러 발생!
Widget1 소멸

핵심 개념:

  • 예외 발생 시 catch 블록을 찾기 위해 스택을 거슬러 올라감
  • 각 스택 프레임의 지역 객체 소멸자를 역순으로 호출
  • 자원이 자동으로 정리됨 (RAII)

2. 소멸 순서

여러 함수를 거치는 복잡한 호출 스택뿐 아니라, 하나의 스코프 안에 선언된 여러 지역 변수 사이에서도 소멸 순서 규칙은 동일하게 적용됩니다. C++ 표준은 지역 객체가 선언의 역순으로 소멸된다고 명시하고 있으며, 이는 예외가 발생해서 스택이 되감기는 상황에서도 예외 없이 지켜집니다.

같은 스코프 내 순서

아래 예제에서 r1, r2, r3는 이 순서대로 선언되었지만, throw 문에 도달하는 순간 소멸은 r3 → r2 → r1 순으로 정확히 반대로 일어납니다. 이 규칙을 알고 있으면, 뒤에 선언한 객체가 앞서 선언한 객체에 의존하는 경우(예: 로그 파일 핸들이 먼저 닫히기 전에 로거 객체가 먼저 소멸되면 안 되는 상황)를 선언 순서만으로 안전하게 설계할 수 있습니다.

#include <iostream>

class Resource {
    int id;
    
public:
    Resource(int i) : id(i) {
        std::cout << "Resource " << id << " 생성" << std::endl;
    }
    
    ~Resource() {
        std::cout << "Resource " << id << " 소멸" << std::endl;
    }
};

int main() {
    try {
        Resource r1(1);
        Resource r2(2);
        Resource r3(3);
        
        throw std::runtime_error("에러");
        
        // 소멸 순서: r3 -> r2 -> r1 (역순)
    } catch (const std::exception& e) {
        std::cout << "예외: " << e.what() << std::endl;
    }
    
    return 0;
}

실행 결과에서도 생성 순서(1 → 2 → 3)와 정반대인 소멸 순서(3 → 2 → 1)가 그대로 드러납니다.

출력:

Resource 1 생성
Resource 2 생성
Resource 3 생성
Resource 3 소멸
Resource 2 소멸
Resource 1 소멸
예외: 에러

3. RAII와 스택 되감기

스택 되감기가 실질적으로 유용해지는 것은 RAII(Resource Acquisition Is Initialization) 패턴과 결합될 때입니다. 자원의 획득과 해제를 객체의 생성자·소멸자에 묶어두면, 예외가 어디서 발생하든 스택 되감기가 알아서 소멸자를 호출해 자원을 정리해 주므로 개발자가 모든 예외 발생 지점마다 수동으로 정리 코드를 작성할 필요가 없어집니다.

RAII 패턴

FileHandler 클래스는 생성자에서 파일을 열고 소멸자에서 파일을 닫는 전형적인 RAII 패턴을 보여줍니다. processFile() 함수 안에서 파일을 연 뒤 예외가 던져지더라도, fh 객체가 스코프를 벗어나는 시점(즉 스택이 되감기는 시점)에 소멸자가 반드시 호출되므로 파일 디스크립터가 열린 채로 누수되는 일이 없습니다. try/finally 구문이 없는 C++에서 이 패턴이 사실상 자원 정리의 표준 방법으로 자리 잡은 이유가 여기에 있습니다.

#include <iostream>
#include <fstream>
#include <memory>

class FileHandler {
    std::ofstream file;
    
public:
    FileHandler(const std::string& path) : file(path) {
        if (!file.is_open()) {
            throw std::runtime_error("파일 열기 실패");
        }
        std::cout << "파일 열림: " << path << std::endl;
    }
    
    ~FileHandler() {
        if (file.is_open()) {
            file.close();
            std::cout << "파일 닫힘" << std::endl;
        }
    }
    
    void write(const std::string& data) {
        file << data;
    }
};

void processFile() {
    FileHandler fh("output.txt");
    fh.write("데이터");
    
    throw std::runtime_error("처리 중 에러");
    
    // fh 소멸자 자동 호출 -> 파일 자동 닫힘
}

int main() {
    try {
        processFile();
    } catch (const std::exception& e) {
        std::cout << "예외: " << e.what() << std::endl;
    }
    
    return 0;
}

출력:

파일 열림: output.txt
파일 닫힘
예외: 처리 중 에러

여러 자원 관리

실무에서는 하나의 함수 안에서 데이터베이스 연결, 네트워크 소켓, 파일 핸들처럼 여러 자원을 동시에 다루는 경우가 흔합니다. 아래 예제에서 dbconn을 각각 std::unique_ptr로 감싸두면, 둘 중 어느 시점에 예외가 발생하더라도 이미 생성된 자원들이 선언의 역순(conn → db)으로 안전하게 소멸됩니다. 스마트 포인터 없이 원시 포인터로 이런 자원을 관리했다면, 예외 발생 지점에 따라 어떤 자원은 정리되고 어떤 자원은 누수되는 상황을 일일이 손으로 처리해야 했을 것입니다.

#include <iostream>
#include <memory>

class Database {
public:
    Database() { std::cout << "DB 연결" << std::endl; }
    ~Database() { std::cout << "DB 종료" << std::endl; }
};

class Connection {
public:
    Connection() { std::cout << "Connection 열림" << std::endl; }
    ~Connection() { std::cout << "Connection 닫힘" << std::endl; }
};

void process() {
    auto db = std::make_unique<Database>();
    auto conn = std::make_unique<Connection>();
    
    throw std::runtime_error("에러");
    
    // 소멸 순서: conn -> db (역순)
}

int main() {
    try {
        process();
    } catch (const std::exception& e) {
        std::cout << "예외: " << e.what() << std::endl;
    }
    
    return 0;
}

4. 자주 발생하는 문제

스택 되감기의 규칙 자체는 단순하지만, 이를 제대로 이해하지 못하고 코드를 작성하면 자원 누수, 프로그램 강제 종료, 예상치 못한 부분 초기화 상태 등 다양한 문제로 이어질 수 있습니다. 아래 네 가지는 실무에서 특히 자주 나타나는 함정입니다.

문제 1: 자원 누수

원시 포인터를 new로 할당하고 함수 끝에서 delete하는 방식은, 그 사이에서 예외가 던져지는 순간 무너집니다. 아래 badFunction()처럼 delete ptr 이전에 process()가 예외를 던지면 스택 되감기가 일어나면서 delete 문 자체를 건너뛰어 버리기 때문에 메모리가 영영 해제되지 않습니다. std::unique_ptr로 바꾸면 포인터의 소멸자가 스택 되감기 도중에도 반드시 호출되므로 이런 누수가 원천적으로 발생하지 않습니다.

#include <iostream>

// ❌ 수동 메모리 관리 (위험)
void badFunction() {
    int* ptr = new int(10);
    
    process();  // 예외 발생 시 누수!
    
    delete ptr;  // 실행 안 됨
}

// ✅ RAII (안전)
void goodFunction() {
    auto ptr = std::make_unique<int>(10);
    
    process();  // 예외 발생해도 자동 정리
}

해결책: 스마트 포인터나 RAII 클래스를 사용하세요.

문제 2: 소멸자에서 예외

이미 하나의 예외로 인해 스택 되감기가 진행되는 도중에 또 다른 예외가 소멸자에서 던져지면, C++ 런타임은 두 예외 중 어느 것을 우선해야 할지 판단할 수 없어 std::terminate()를 호출하고 프로그램을 즉시 종료시킵니다. 이 때문에 표준은 소멸자를 사실상 noexcept로 취급하도록 강제하며, 소멸자 내부에서 예외를 던질 가능성이 있는 코드는 반드시 try/catch로 감싸서 소멸자 밖으로 예외가 새어나가지 않게 해야 합니다.

#include <iostream>
#include <stdexcept>

// ❌ 소멸자에서 예외 (위험)
class BadResource {
public:
    ~BadResource() {
        throw std::runtime_error("소멸자 에러");  // std::terminate!
    }
};

// ✅ 소멸자는 noexcept
class GoodResource {
public:
    ~GoodResource() noexcept {
        try {
            cleanup();  // 예외 발생 가능
        } catch (const std::exception& e) {
            // 예외 삼킴 또는 로깅
            std::cerr << "정리 중 에러: " << e.what() << std::endl;
        }
    }
    
private:
    void cleanup() {
        // 정리 작업
    }
};

해결책: 소멸자는 항상 noexcept이어야 하며, 예외를 내부에서 처리하세요.

문제 3: 부분 생성 객체

생성자가 완전히 끝나기 전에 예외가 던져지면, 그 객체는 “완성되지 않은 상태”로 간주되어 해당 객체의 소멸자는 아예 호출되지 않습니다. 대신 그 시점까지 이미 생성이 완료된 멤버 변수들만 선언의 역순으로 소멸됩니다. 아래 Widgetr1, r2, r3 세 멤버를 초기화하다가 r2의 생성자에서 예외가 발생하는데, 이때 이미 완성된 r1은 정상적으로 소멸되지만 r3는 애초에 생성이 시작조차 되지 않았으므로 소멸자 호출 대상에 들어가지 않고, Widget 자신의 소멸자 역시 호출되지 않습니다.

#include <iostream>
#include <stdexcept>

class Resource {
public:
    Resource(int id) : id_(id) {
        std::cout << "Resource " << id_ << " 생성" << std::endl;
        if (id_ == 2) {
            throw std::runtime_error("생성 실패");
        }
    }
    
    ~Resource() {
        std::cout << "Resource " << id_ << " 소멸" << std::endl;
    }
    
private:
    int id_;
};

class Widget {
    Resource r1;
    Resource r2;
    Resource r3;
    
public:
    Widget() : r1(1), r2(2), r3(3) {
        // r2 생성 중 예외 발생
        // r1은 자동 소멸, r3는 생성 안 됨
    }
};

int main() {
    try {
        Widget w;
    } catch (const std::exception& e) {
        std::cout << "예외: " << e.what() << std::endl;
    }
    
    return 0;
}

출력:

Resource 1 생성
Resource 2 생성
Resource 1 소멸
예외: 생성 실패

핵심: 생성된 멤버만 소멸자가 호출됩니다.

문제 4: catch 순서

catch 절은 코드에 나열된 순서대로 위에서부터 검사되며, 처음으로 타입이 일치하는 절 하나만 실행됩니다. 만약 상위 타입인 std::exception&을 먼저 배치하면, std::runtime_error처럼 그것을 상속하는 더 구체적인 예외 타입도 전부 상위 catch 절에서 잡혀버려 그 아래에 있는 구체적인 catch 절은 영원히 도달할 수 없는 코드가 됩니다. 컴파일러가 이런 경우 경고를 내주기도 하지만, 항상 구체적인 예외 타입을 먼저 쓰고 일반적인 타입(std::exception, ...)을 가장 마지막에 배치하는 습관을 들이는 것이 안전합니다.

#include <iostream>
#include <stdexcept>

int main() {
    try {
        throw std::runtime_error("런타임 에러");
        
    // ❌ 잘못된 순서
    // } catch (const std::exception& e) {
    //     // 모든 예외를 여기서 잡음
    // } catch (const std::runtime_error& e) {
    //     // 도달 불가!
    // }
    
    // ✅ 올바른 순서 (구체적 -> 일반)
    } catch (const std::runtime_error& e) {
        std::cout << "런타임 에러: " << e.what() << std::endl;
    } catch (const std::exception& e) {
        std::cout << "일반 예외: " << e.what() << std::endl;
    } catch (...) {
        std::cout << "알 수 없는 예외" << std::endl;
    }
    
    return 0;
}

해결책: 구체적인 예외를 먼저, 일반 예외를 나중에 배치하세요.


5. 성능 영향

“예외는 성능에 나쁘다”는 말을 들어봤을 텐데, 정확히는 절반만 맞는 이야기입니다. 대부분의 현대 C++ 컴파일러는 테이블 기반 예외 처리(zero-cost exception model)를 사용하므로, 예외가 실제로 던져지지 않는 정상 경로에는 추가 분기나 검사 코드가 거의 삽입되지 않습니다. 하지만 예외가 실제로 던져지는 순간에는 스택을 거슬러 올라가며 각 프레임의 해제 정보를 조회하고 소멸자를 호출하는 비교적 무거운 작업이 발생합니다.

Zero-Cost Exception (예외 없을 때)

아래 벤치마크는 이 비대칭성을 직접 보여줍니다. normalPath()를 백만 번 반복 호출하는 것과 exceptionPath()를 단 천 번만 호출하는 것을 비교했는데도, 예외를 던지는 쪽이 훨씬 적은 반복 횟수임에도 상당한 시간이 걸리는 것을 확인할 수 있습니다. 이 결과가 시사하는 실전 원칙은 명확합니다. 예외는 “정말 예외적인 상황”에만 사용하고, 반복문 안에서 흔히 발생할 수 있는 조건 분기용으로는 사용하지 않아야 합니다.

#include <iostream>
#include <chrono>

class Widget {
public:
    Widget() {}
    ~Widget() {}
};

void normalPath() {
    Widget w;
    // 정상 실행 (예외 없음)
}

void exceptionPath() {
    Widget w;
    throw std::runtime_error("에러");
}

int main() {
    // 정상 경로: 거의 비용 없음
    auto start1 = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 1000000; ++i) {
        normalPath();
    }
    auto end1 = std::chrono::high_resolution_clock::now();
    
    // 예외 경로: 비용 있음
    auto start2 = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 1000; ++i) {  // 횟수 줄임
        try {
            exceptionPath();
        } catch (...) {}
    }
    auto end2 = std::chrono::high_resolution_clock::now();
    
    auto duration1 = std::chrono::duration_cast<std::chrono::microseconds>(end1 - start1).count();
    auto duration2 = std::chrono::duration_cast<std::chrono::microseconds>(end2 - start2).count();
    
    std::cout << "정상 경로: " << duration1 << " μs" << std::endl;
    std::cout << "예외 경로: " << duration2 << " μs" << std::endl;
    
    return 0;
}

핵심:

  • 예외 없을 때: 거의 비용 없음 (Zero-Cost Abstraction)
  • 예외 발생 시: 스택 되감기 비용 발생 (느림)

6. 실전 예제: 트랜잭션 관리

스택 되감기와 RAII를 결합하면 데이터베이스 트랜잭션처럼 “성공하면 커밋, 실패하면 롤백”이라는 규칙을 명시적인 if/else 없이도 자연스럽게 구현할 수 있습니다. 핵심 아이디어는 트랜잭션 객체가 소멸될 때 커밋 여부를 확인해서, 커밋되지 않았다면 자동으로 롤백을 수행하도록 소멸자를 설계하는 것입니다.

#include <iostream>
#include <stdexcept>
#include <string>

class Transaction {
    std::string name;
    bool committed = false;
    
public:
    Transaction(const std::string& n) : name(n) {
        std::cout << "[" << name << "] 트랜잭션 시작" << std::endl;
    }
    
    ~Transaction() {
        if (!committed) {
            std::cout << "[" << name << "] 롤백" << std::endl;
        } else {
            std::cout << "[" << name << "] 완료" << std::endl;
        }
    }
    
    void commit() {
        committed = true;
    }
};

class Database {
public:
    void insert(const std::string& data) {
        std::cout << "INSERT: " << data << std::endl;
    }
    
    void update(const std::string& data) {
        std::cout << "UPDATE: " << data << std::endl;
    }
};

void processData(Database& db) {
    Transaction tx("DataProcess");
    
    db.insert("record1");
    db.update("record2");
    
    // 여기서 예외 발생하면 자동 롤백
    // throw std::runtime_error("처리 실패");
    
    tx.commit();  // 성공 시 커밋
}

int main() {
    Database db;
    
    try {
        processData(db);
    } catch (const std::exception& e) {
        std::cout << "에러: " << e.what() << std::endl;
    }
    
    return 0;
}

출력 (성공 시):

[DataProcess] 트랜잭션 시작
INSERT: record1
UPDATE: record2
[DataProcess] 완료

반대로 processData() 안에서 예외가 발생하면 tx.commit()이 호출되기 전에 스택 되감기가 시작되므로, Transaction의 소멸자는 committed가 여전히 false인 상태로 실행되어 자동으로 롤백 로그를 남깁니다. 별도의 catch 블록에서 롤백을 처리하는 코드를 작성할 필요 없이, “커밋되지 않은 트랜잭션은 소멸 시 롤백된다”는 규칙 하나로 예외 안전한 트랜잭션 관리를 구현한 것입니다.

출력 (실패 시, 예외 발생):

[DataProcess] 트랜잭션 시작
INSERT: record1
UPDATE: record2
[DataProcess] 롤백
에러: 처리 실패

7. 중첩 예외 처리

여러 계층의 함수가 서로를 호출하는 구조에서 예외가 발생하면, 각 계층은 예외를 그 자리에서 완전히 처리할 수도 있고 throw;로 다시 던져 상위 계층에 위임할 수도 있습니다. 이 패턴은 로깅이나 컨텍스트 추가처럼 “일단 여기서 기록은 남기되, 실제 처리는 더 상위에서 하도록 넘긴다”는 계층형 예외 처리 전략에서 자주 쓰입니다.

아래 예제는 level1 → level2 → level3 호출 체인에서 level3이 예외를 던지면, level2가 이를 잡아 로그를 남긴 뒤 throw;로 재던지고, 최종적으로 level1이 예외를 완전히 처리하는 흐름을 보여줍니다. 각 계층에 있는 Logger 객체는 스택 되감기가 진행되는 동안에도 정확한 순서로 소멸자를 호출하므로, 로그만 살펴봐도 예외가 어느 경로를 거쳐 처리되었는지 추적할 수 있습니다.

#include <iostream>
#include <stdexcept>

class Logger {
public:
    Logger(const std::string& msg) : message(msg) {
        std::cout << "[LOG] " << message << " 시작" << std::endl;
    }
    
    ~Logger() {
        std::cout << "[LOG] " << message << " 종료" << std::endl;
    }
    
private:
    std::string message;
};

void level3() {
    Logger log("level3");
    throw std::runtime_error("level3 에러");
}

void level2() {
    Logger log("level2");
    try {
        level3();
    } catch (const std::exception& e) {
        std::cout << "[level2] 예외 처리: " << e.what() << std::endl;
        throw;  // 재던지기
    }
}

void level1() {
    Logger log("level1");
    try {
        level2();
    } catch (const std::exception& e) {
        std::cout << "[level1] 최종 처리: " << e.what() << std::endl;
    }
}

int main() {
    level1();
    return 0;
}

출력:

[LOG] level1 시작
[LOG] level2 시작
[LOG] level3 시작
[LOG] level3 종료
[level2] 예외 처리: level3 에러
[LOG] level2 종료
[level1] 최종 처리: level3 에러
[LOG] level1 종료

8. 스택 되감기 vs 정상 종료

지금까지 살펴본 것처럼, 스택 되감기는 사실 “함수가 정상적으로 반환될 때 지역 객체가 소멸되는 과정”의 예외 버전에 가깝습니다. 소멸자가 호출되고 역순으로 정리된다는 점은 동일하지만, 예외 상황에서는 처리 경로가 더 복잡해지고 그만큼 비용도 더 든다는 차이가 있습니다. 아래 표로 두 경로의 공통점과 차이점을 정리합니다.

특징정상 종료스택 되감기
소멸자 호출
소멸 순서역순역순
성능빠름느림
자원 정리보장보장
finally 블록없음없음 (소멸자 사용)

정리

스택 되감기는 C++ 예외 처리 모델의 근간을 이루는 메커니즘으로, RAII와 결합될 때 비로소 진가를 발휘합니다. 소멸자가 항상 호출된다는 보장을 믿고 자원 관리 코드를 설계하면, 예외가 어디서 발생하든 자원 누수 걱정 없이 안전한 프로그램을 작성할 수 있습니다.

핵심 요약

  1. 스택 되감기: 예외 발생 시 스택 프레임 정리
  2. 소멸자 호출: 지역 객체 소멸자 역순 호출
  3. RAII: 소멸자에서 자원 정리 (자동)
  4. 소멸자 예외: 절대 안 됨 (std::terminate)
  5. 성능: 예외 없으면 비용 거의 없음
  6. 자원 안전성: 스마트 포인터, RAII 클래스 사용

스택 되감기 흐름

예외 발생

현재 스코프 지역 객체 소멸 (역순)

catch 블록 있나?
  ├─ 있음 → 예외 처리
  └─ 없음 → 상위 스택 프레임으로

      상위 스코프 지역 객체 소멸 (역순)

      반복...

실전 팁

안전성:

  • 모든 자원은 RAII 클래스로 관리
  • 소멸자는 절대 예외를 던지지 않음 (noexcept)
  • 스마트 포인터 적극 활용

성능:

  • 예외는 예외적 상황에만 사용
  • 정상 흐름에서는 예외 사용 자제
  • 예외 발생 시 스택 되감기 비용 고려

디버깅:

  • 소멸자에 로깅 추가하여 호출 순서 확인
  • std::terminate_handler 설정으로 소멸자 예외 추적
  • GDB로 스택 추적 (backtrace)

다음 단계


관련 글


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Everything about C++ Stack Unwinding : from basic concepts to practical applications.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, stack-unwinding, exception, destructor, RAII 등으로 검색하시면 이 글이 도움이 됩니다.