C++ make_unique & make_shared | '스마트 포인터 생성' 가이드
이 글의 핵심
std::make_unique·make_shared로 스마트 포인터를 만드는 방법, new와의 차이, make_shared의 단일 할당·캐시 지역성, 예외 안전성, make_를 피해야 하는 경우(커스텀 삭제자 등), make_unique<T[]>, 팩토리 패턴까지 정리합니다.
make_unique & make_shared란?
std::make_unique (C++14)와 std::make_shared (C++11)는 스마트 포인터를 안전하고 효율적으로 생성하는 함수입니다. new를 직접 사용하는 것보다 예외 안전하고, make_shared는 성능도 더 좋습니다.
C/C++ 예제 코드입니다.
// ❌ new 사용
auto ptr1 = std::unique_ptr<int>(new int(10));
auto ptr2 = std::shared_ptr<int>(new int(10));
// ✅ make 함수
auto ptr1 = std::make_unique<int>(10);
auto ptr2 = std::make_shared<int>(10);
왜 필요한가?:
- 예외 안전: 메모리 누수 방지
- 성능:
make_shared는 할당 1번 (new는 2번) - 간결함: 타입을 한 번만 작성
- 명확함: 의도가 명확
make_unique와 new의 차이
std::make_unique<T>(args...)는 구현상 new T(...)와 동등한 생성을 한 번에 감싼 것이지만, 호출부에서는 raw 포인터가 드러나지 않습니다.
| 관점 | new + 스마트 포인터 생성자 | make_unique / make_shared |
|---|---|---|
| 표현 | T를 여러 번 쓰거나 new 결과가 중간에 남음 | 타입은 한 번, 곧바로 스마트 포인터 |
| 예외 안전 (구 규칙) | 여러 전체 표현식이 섞이면 순서 이슈 | 단일 함수 호출로 생성 경로가 단순 |
| 커스텀 삭제자 | 생성자에 직접 넘기기 쉬움 | 표준 make_unique / make_shared는 삭제자 인자 없음 |
private 생성자 | 같은 클래스의 new는 가능 | make_shared는 비프렌드 외부에서 호출 불가인 경우가 있음 |
즉, “기본 delete로 충분한 동적 객체”에는 make_가 기본값이고, delete가 아닌 정리(파일 닫기, delete[], free 등)나 접근 제어가 막힌 생성에서는 new(또는 allocate_shared 등) 경로가 남습니다.
make_shared의 메모리 최적화 (심화)
std::shared_ptr은 관리되는 객체와 제어 블록(강한 참조·약한 참조 카운트, 커스텀 삭제자/할당자 정보 등)을 둘 다 추적해야 합니다. shared_ptr<T>(new T) 형태는 흔히 객체용 메모리와 제어 블록용 메모리를 각각 할당합니다. 반면 make_shared<T>(args...)는 구현에 따라 한 번의 연속된 할당에 객체와 제어 블록을 함께 둘 수 있어, 다음이 기대됩니다.
- 할당 횟수 감소: 힙 트래킹·락 경합이 있는 환경에서 체감될 수 있음.
- 지역성: 객체와 제어 블록이 인접하면 캐시 친화적일 수 있음.
- 오버헤드: 두 번의
operator new호출을 한 번으로 줄이는 효과.
다만 객체가 매우 크고 weak_ptr이 오래 살아 남는 경우, make_shared로 묶인 덩어리 때문에 객체 본문이 쓸모없어진 뒤에도 메모리가 통째로 유지되는 현상이 생길 수 있습니다(아래 “메모리 해제 타이밍”과 FAQ 참고). 그런 프로파일이면 shared_ptr<T>(new T)가 유리할 수 있습니다.
일상 비유로 이해하기: 메모리를 아파트 건물로 생각해보세요. 스택은 엘리베이터 같아서 빠르지만 공간이 제한적입니다. 힙은 창고처럼 넓지만 물건을 찾는 데 시간이 걸립니다. 포인터는 “3층 302호”처럼 주소를 가리키는 메모지라고 보면 됩니다.
예외 안전성 심화
위의 func(std::unique_ptr<int>(new int(10)), compute()) 예는 C++17 이전에는 인자 평가 순서가 제한적으로만 보장되어, new까지 실행된 뒤 compute()에서 예외가 나면 unique_ptr이 만들어지지 않아 누수가 날 수 있었습니다. make_unique 한 번의 호출로 객체 생성과 스마트 포인터 포장이 한 경로로 묶이면 이런 “중간에 raw 소유권이 남는 창”이 줄어듭니다.
C++17부터는 함수 호출의 인자 표현식에 대해 더 엄격한 순서 규칙이 생겼지만, 여전히 make_가 의도를 분명히 하고 실수 여지를 줄인다는 점에서 권장 패턴으로 남습니다. 강한 예외 안전을 요구하는 코드에서는 “raw new 결과를 지역 변수에 담은 뒤 unique_ptr로 이전”처럼 단계를 나누는 것도 한 방법입니다.
언제 make_unique / make_shared를 쓰지 말아야 하나
다음은 대표적으로 new(또는 allocate_shared) + 생성자 쪽이 맞는 경우입니다.
- 커스텀 삭제자가 필요할 때 (
delete가 아닌 정리).make_unique는 삭제자를 받지 않습니다. - std::allocate_shared나 커스텀 할당자로 제어 블록·객체 배치를 정밀하게 잡아야 할 때.
shared_ptr의 생성이 클래스 내부 전용이어야 할 때(enable_shared_from_this와 함께 쓰는 패턴 등) — 설계에 따라make_shared를 밖에서 부르지 않습니다.make_shared로 인한 메모리 지연 해제가 문제일 때(큰 객체 + 오래 사는weak_ptr).- 배열:
make_unique<T[]>(n)는 가능하지만,shared_ptr의 배열은 C++20std::make_shared<T[]>(n)이전에는 관례적으로 커스텀 삭제자나shared_ptr특수화를 썼습니다.
자세한 커스텀 삭제자 내용은 C++ Custom Deleters 가이드를 참고하세요.
배열: make_unique<T[]>(n) 심화
C++14부터 std::make_unique<T[]>(n)는 길이 n의 동적 배열을 만들고, 삭제는 delete[]에 맞춰집니다. 주의할 점은 다음과 같습니다.
- 요소별 생성자 호출이 필요하면
std::vector<T>가 더 단순한 경우가 많습니다. make_unique<int[]>(10)은 기본 초기화 규칙을 따릅니다(타입에 따라 초기화되지 않은 값이 있을 수 있음). 값을 0으로 두려면std::vector나 루프 초기화를 고려하세요.- C++20에서는
std::make_shared<T[]>(n)로shared_ptr배열 생성이 표준화되었습니다.
C/C++ 예제 코드입니다.
// 동적 배열 — delete[]와 짝을 맞춤
auto p = std::make_unique<std::string[]>(4);
p[0] = "a";
// C++20: shared 배열
auto s = std::make_shared<double[]>(100);
실전 팩토리 패턴 (보강)
팩토리는 구체 타입을 숨기고 인터페이스만 노출할 때 unique_ptr/shared_ptr와 잘 맞습니다.
- 추상 베이스 +
unique_ptr<Base>반환: 소유권을 호출자에게 넘기며, 구현 파일에서만 파생 클래스를new하거나make_unique합니다. - 실패 가능한 생성:
std::optional<std::unique_ptr<T>>또는expected스타일로 “생성 실패”를 명시적으로 표현합니다. - 공유 캐시: 동일 키에 대해
shared_ptr을 재사용하려면make_shared로 한 번 만들고 맵에 넣는 패턴이 흔합니다(아래 “공유 캐시” 예시 참고).
struct Shape { virtual ~Shape() = default; };
struct Circle : Shape {};
inline std::unique_ptr<Shape> make_shape(const std::string& kind) {
if (kind == "circle") {
return std::make_unique<Circle>();
}
return nullptr; // 또는 optional
}
Database::connect 예시처럼 private 생성자 + 정적 멤버에서만 make_unique<Impl>을 호출하면, 호출자는 구체 DB 타입을 몰라도 됩니다.
장점 상세
1. 예외 안전성
void func(std::unique_ptr<int> ptr, int value) {
// ...
}
int compute() {
throw std::runtime_error("에러");
}
// ❌ new: 예외 시 누수 가능
func(std::unique_ptr<int>(new int(10)), compute());
// 실행 순서가 보장되지 않음:
// 1. new int(10)
// 2. compute() - 예외 발생!
// 3. unique_ptr 생성 (실행 안됨)
// → 메모리 누수
// ✅ make_unique: 안전
func(std::make_unique<int>(10), compute());
// make_unique는 원자적으로 실행
// → 메모리 누수 없음
이유: C++17 이전에는 함수 인자의 평가 순서가 정의되지 않았습니다. new와 unique_ptr 생성 사이에 예외가 발생하면 메모리 누수가 발생할 수 있습니다.
2. 성능 (shared_ptr)
C/C++ 예제 코드입니다.
// ❌ new: 할당 2번
auto ptr1 = std::shared_ptr<int>(new int(10));
// 1. int 할당 (4바이트)
// 2. 제어 블록 할당 (참조 카운트 등)
// ✅ make_shared: 할당 1번
auto ptr2 = std::make_shared<int>(10);
// int + 제어 블록 함께 할당
메모리 레이아웃:
C/C++ 예제 코드입니다.
// new 사용:
// [int] (힙 영역 1)
// [제어 블록] (힙 영역 2)
// make_shared:
// [int | 제어 블록] (힙 영역 1)
성능 비교:
- 할당 횟수:
make_shared1번 vsnew2번 - 캐시 지역성:
make_shared가 더 좋음 (연속된 메모리) - 할당 오버헤드:
make_shared가 더 적음
3. 간결함
// ❌ new: 타입 2번
auto ptr = std::unique_ptr<VeryLongTypeName>(new VeryLongTypeName(args));
// ✅ make_unique: 타입 1번
auto ptr = std::make_unique<VeryLongTypeName>(args);
실전 예시
예시 1: 기본 사용
C/C++ 예제 코드입니다.
// unique_ptr
auto ptr1 = std::make_unique<int>(42);
auto ptr2 = std::make_unique<std::string>("Hello");
auto ptr3 = std::make_unique<std::vector<int>>(10, 0);
// shared_ptr
auto ptr4 = std::make_shared<int>(42);
auto ptr5 = std::make_shared<std::string>("Hello");
예시 2: 배열
// C++14: make_unique 배열
auto arr1 = std::make_unique<int[]>(10);
// C++20: make_shared 배열
auto arr2 = std::make_shared<int[]>(10);
// 초기화
for (int i = 0; i < 10; i++) {
arr1[i] = i;
}
예시 3: 예외 안전성
void process(std::unique_ptr<Widget> w, int value) {
// ...
}
int compute() {
throw std::runtime_error("에러");
}
int main() {
// ❌ 예외 시 누수 가능
// process(std::unique_ptr<Widget>(new Widget()), compute());
// ✅ 안전
process(std::make_unique<Widget>(), compute());
}
예시 4: 팩토리
class Widget {
public:
static std::unique_ptr<Widget> create(int id) {
return std::make_unique<Widget>(id);
}
private:
Widget(int id) {} // private 생성자
};
make_shared 성능
C/C++ 예제 코드입니다.
// ❌ 할당 2번
auto ptr = std::shared_ptr<int>(new int(10));
// 1. int 할당
// 2. 제어 블록 할당
// ✅ 할당 1번
auto ptr = std::make_shared<int>(10);
// int + 제어 블록 함께 할당
자주 발생하는 문제
문제 1: 커스텀 삭제자
C/C++ 예제 코드입니다.
// ❌ make_unique는 커스텀 삭제자 불가
auto deleter = [](int* p) { delete p; };
// auto ptr = std::make_unique<int, decltype(deleter)>(10, deleter);
// ✅ 생성자 사용
auto ptr = std::unique_ptr<int, decltype(deleter)>(
new int(10), deleter
);
문제 2: 초기화 리스트
C/C++ 예제 코드입니다.
// ❌ 중괄호 초기화
// auto ptr = std::make_unique<std::vector<int>>({1, 2, 3});
// ✅ 소괄호
auto ptr = std::make_unique<std::vector<int>>(
std::initializer_list<int>{1, 2, 3}
);
문제 3: private 생성자
class Widget {
Widget() {} // private
public:
static std::shared_ptr<Widget> create() {
// ❌ make_shared 불가
// return std::make_shared<Widget>();
// ✅ new 사용
return std::shared_ptr<Widget>(new Widget());
}
};
문제 4: 메모리 해제 타이밍
C/C++ 예제 코드입니다.
// make_shared: 제어 블록과 객체 함께 할당
auto ptr = std::make_shared<LargeObject>();
std::weak_ptr<LargeObject> weak = ptr;
ptr.reset(); // 객체 소멸하지만 메모리는 weak_ptr 때문에 유지
// ✅ new 사용 시 객체 메모리만 해제
auto ptr = std::shared_ptr<LargeObject>(new LargeObject());
권장사항
C/C++ 예제 코드입니다.
// ✅ 기본적으로 make 함수
auto ptr1 = std::make_unique<Widget>();
auto ptr2 = std::make_shared<Widget>();
// ❌ new 사용 (특별한 경우만)
// - 커스텀 삭제자
// - private 생성자
// - 메모리 해제 타이밍 제어
실무 패턴
패턴 1: 팩토리 메서드
class Database {
public:
static std::unique_ptr<Database> connect(const std::string& url) {
auto db = std::make_unique<Database>();
db->connect_impl(url);
return db;
}
private:
Database() = default;
void connect_impl(const std::string& url) {
// 연결 로직
}
};
// 사용
auto db = Database::connect("postgres://localhost");
패턴 2: 리소스 관리
class FileHandle {
FILE* file_;
public:
static std::unique_ptr<FileHandle> open(const std::string& path) {
auto handle = std::make_unique<FileHandle>();
handle->file_ = fopen(path.c_str(), "r");
if (!handle->file_) {
throw std::runtime_error("파일 열기 실패");
}
return handle;
}
~FileHandle() {
if (file_) {
fclose(file_);
}
}
private:
FileHandle() : file_(nullptr) {}
};
// 사용
auto file = FileHandle::open("data.txt");
// 자동으로 파일 닫힘
패턴 3: 공유 캐시
class Cache {
std::map<std::string, std::shared_ptr<Data>> cache_;
public:
std::shared_ptr<Data> get(const std::string& key) {
auto it = cache_.find(key);
if (it != cache_.end()) {
return it->second; // 공유
}
// 새로 생성
auto data = std::make_shared<Data>(key);
cache_[key] = data;
return data;
}
};
// 사용
Cache cache;
auto data1 = cache.get("user:123");
auto data2 = cache.get("user:123"); // 같은 객체 공유
FAQ
Q1: make 함수의 장점은?
A:
- 예외 안전성: 메모리 누수 방지
- 성능:
make_shared는 할당 1번 - 간결함: 타입을 한 번만 작성
- 명확함: 의도가 명확
// make_unique: 예외 안전
func(std::make_unique<int>(10), compute());
// make_shared: 성능
auto ptr = std::make_shared<int>(10); // 할당 1번
Q2: 언제 new를 사용하나요?
A:
- 커스텀 삭제자:
make_unique는 커스텀 삭제자 불가 - private 생성자:
make_shared는 private 생성자 접근 불가 - 메모리 해제 타이밍:
make_shared는weak_ptr때문에 메모리 해제 지연
// 커스텀 삭제자
auto deleter = [](FILE* f) { if (f) std::fclose(f); };
auto ptr = std::unique_ptr<FILE, decltype(deleter)>(
std::fopen("file.txt", "r"), deleter
);
Q3: 배열은 어떻게 생성하나요?
A:
- make_unique: C++14부터 배열 지원
- make_shared: C++20부터 배열 지원
C/C++ 예제 코드입니다.
// make_unique: C++14
auto arr1 = std::make_unique<int[]>(10);
arr1[0] = 42;
// make_shared: C++20
auto arr2 = std::make_shared<int[]>(10);
arr2[0] = 42;
Q4: make_shared의 성능 차이는?
A: 할당 1번 vs 2번입니다. make_shared는 객체와 제어 블록을 함께 할당합니다.
C/C++ 예제 코드입니다.
// new: 할당 2번
auto ptr1 = std::shared_ptr<int>(new int(10));
// 1. int 할당
// 2. 제어 블록 할당
// make_shared: 할당 1번
auto ptr2 = std::make_shared<int>(10);
// int + 제어 블록 함께 할당
벤치마크:
make_shared: ~50nsnew+shared_ptr: ~100ns
Q5: 초기화 리스트는 어떻게 사용하나요?
A: 소괄호 사용이 필요합니다. 중괄호는 직접 사용할 수 없습니다.
C/C++ 예제 코드입니다.
// ❌ 중괄호: 직접 사용 불가
// auto ptr = std::make_unique<std::vector<int>>({1, 2, 3});
// ✅ 소괄호 + initializer_list
auto ptr = std::make_unique<std::vector<int>>(
std::initializer_list<int>{1, 2, 3}
);
// ✅ 또는 임시 벡터
auto ptr2 = std::make_unique<std::vector<int>>(
std::vector<int>{1, 2, 3}
);
Q6: make_shared의 단점은?
A: 메모리 해제 지연입니다. weak_ptr이 남아있으면 객체는 소멸되지만 메모리는 해제되지 않습니다.
C/C++ 예제 코드입니다.
auto ptr = std::make_shared<LargeObject>(1000000);
std::weak_ptr<LargeObject> weak = ptr;
ptr.reset(); // 객체 소멸, 하지만 메모리는 유지 (weak_ptr 때문)
// new 사용 시:
auto ptr2 = std::shared_ptr<LargeObject>(new LargeObject(1000000));
std::weak_ptr<LargeObject> weak2 = ptr2;
ptr2.reset(); // 객체 메모리 즉시 해제, 제어 블록만 유지
Q7: make_unique는 C++11에 없나요?
A: 없습니다. C++14에 추가되었습니다. C++11에서는 직접 구현할 수 있습니다.
C/C++ 예제 코드입니다.
// C++11: 직접 구현
template<typename T, typename....Args>
std::unique_ptr<T> make_unique(Args&&....args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
Q8: make 함수 학습 리소스는?
A:
- “Effective Modern C++” by Scott Meyers (Item 21)
- “C++ Primer” by Stanley Lippman
- cppreference.com - make_unique
- cppreference.com - make_shared
관련 글: unique_ptr, shared_ptr, weak_ptr.
한 줄 요약: make_unique와 make_shared는 스마트 포인터를 안전하고 효율적으로 생성하는 함수입니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ Custom Deleters | “커스텀 삭제자” 가이드
- C++ weak_ptr | “약한 포인터” 가이드
- C++ 스마트 포인터 기초 완벽 가이드 | unique_ptr·shared_ptr
관련 글
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ 스마트 포인터 기초 완벽 가이드 | unique_ptr·shared_ptr
- 모던 C++ (C++11~C++20) 핵심 문법 치트시트 | 현업에서 자주 쓰는 한눈에 보기
- C++ Chrono Literals |
- C++ malloc vs new vs make_unique | 메모리 할당 완벽 비교
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「C++ make_unique & make_shared | ‘스마트 포인터 생성’ 가이드」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「C++ make_unique & make_shared | ‘스마트 포인터 생성’ 가이드」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.
이 글에서 다루는 키워드 (관련 검색어)
C++, make_unique, make_shared, smart-pointer, C++14 등으로 검색하시면 이 글이 도움이 됩니다.