C 언어에서 동적 메모리 할당과 성능 최적화의 중요성

목차

도입 문구


C 언어에서 동적 메모리 할당은 성능 최적화와 관련된 핵심 요소입니다. 이 기사는 동적 메모리 할당의 기본 개념과 이를 최적화하는 방법을 다룹니다.

동적 메모리 할당의 개념


동적 메모리 할당은 프로그램 실행 중에 메모리를 할당받는 방식입니다. 이 방식은 프로그램이 실행될 때 필요한 만큼 메모리를 유동적으로 할당받고, 사용 후 반환할 수 있어 고정된 크기의 메모리 배열에 의존하지 않고 효율적인 메모리 사용을 가능하게 합니다.

동적 메모리의 필요성


정적 메모리 할당은 프로그램 실행 전에 메모리 크기를 고정시켜야 하므로, 크기가 가변적인 데이터를 처리하는 데 한계가 있습니다. 동적 메모리 할당은 이 문제를 해결하며, 프로그램 실행 중에 데이터를 유연하게 처리할 수 있도록 도와줍니다.

malloc과 free 함수의 사용


C 언어에서 동적 메모리를 할당하고 해제하는 기본 함수는 mallocfree입니다. 이 두 함수는 메모리 관리의 핵심적인 역할을 하며, 동적 메모리 할당의 기본적인 방식입니다.

malloc 함수


malloc 함수는 프로그램 실행 중에 동적으로 메모리를 할당할 때 사용됩니다. 이 함수는 요청한 크기만큼의 메모리 블록을 할당하고, 그 메모리의 첫 번째 주소를 반환합니다. 할당된 메모리 영역은 초기화되지 않으므로, 사용하기 전에 해당 메모리를 명시적으로 초기화해야 합니다.

int *arr = (int *)malloc(5 * sizeof(int));  // 5개의 int형 배열 메모리 할당
if (arr == NULL) {
    // 메모리 할당 실패 시 처리
}

free 함수


free 함수는 malloc이나 calloc 등의 함수로 할당한 메모리를 해제할 때 사용됩니다. 메모리를 반환하여 다시 사용할 수 있도록 만듭니다. 메모리 누수를 방지하기 위해 동적 메모리를 다 사용한 후에는 반드시 free 함수를 호출하여 메모리를 해제해야 합니다.

free(arr);  // 할당한 메모리 해제

동적 메모리 할당 후 해제를 적절히 관리하는 것은 프로그램의 안정성과 성능을 보장하는 중요한 요소입니다.

메모리 누수 문제 해결법


동적 메모리 할당을 사용할 때 가장 흔하게 발생하는 문제 중 하나는 메모리 누수입니다. 메모리 누수는 할당된 메모리를 사용한 후에 해제하지 않아, 프로그램 종료 후에도 메모리가 반환되지 않고 남아 있는 상황을 말합니다. 이는 장기적으로 시스템의 성능을 저하시킬 수 있습니다.

메모리 누수의 원인


메모리 누수는 주로 다음과 같은 원인으로 발생합니다:

  • 메모리 해제 누락: 동적 메모리를 할당한 후 적절하게 해제하지 않는 경우.
  • 메모리 할당 실패 처리 미비: malloc 함수가 메모리 할당에 실패했을 때 반환되는 NULL 포인터를 제대로 처리하지 않는 경우.
  • 다중 할당과 해제 문제: 동일한 메모리 영역에 여러 번 free를 호출하거나, 이미 해제된 메모리를 다시 접근하려 할 때 발생할 수 있습니다.

메모리 누수 해결법


메모리 누수를 방지하기 위해서는 동적 메모리 할당 후 항상 해제를 명확하게 해주는 것이 중요합니다. 또한, 메모리 할당 실패 시 이를 적절히 처리하고, 한번 해제한 메모리를 다시 해제하는 실수를 방지해야 합니다.

1. 메모리 해제 후 포인터를 NULL로 설정하기


메모리를 해제한 후 해당 포인터를 NULL로 설정하면, 이후 그 포인터를 사용하려 할 때 오류를 미리 방지할 수 있습니다.

free(arr);
arr = NULL;  // 포인터를 NULL로 설정

2. 메모리 할당 실패 체크하기


malloc 함수 호출 후 반환된 포인터가 NULL인지를 반드시 확인하여, 메모리 할당이 제대로 이루어졌는지 확인해야 합니다.

int *arr = (int *)malloc(5 * sizeof(int));
if (arr == NULL) {
    // 메모리 할당 실패 시 처리
}

메모리 누수 디버깅 도구


메모리 누수 문제를 추적하고 해결하기 위한 도구들이 존재합니다. 대표적인 도구로는 Valgrind가 있으며, 이는 동적 메모리 할당과 해제 상황을 추적하여 메모리 누수를 찾아줍니다. 이를 통해 코드를 최적화하고, 메모리 누수를 방지할 수 있습니다.

성능 최적화를 위한 메모리 관리 기법


효율적인 메모리 관리는 C 언어 프로그램의 성능을 최적화하는 데 중요한 역할을 합니다. 불필요한 메모리 사용을 줄이고, 프로그램 실행 속도를 향상시킬 수 있는 몇 가지 메모리 관리 기법을 소개합니다.

1. 메모리 재사용


동적 메모리 할당과 해제를 자주 반복하는 경우 성능 저하가 발생할 수 있습니다. 대신, 메모리 풀(Memory Pool) 기법을 사용하여 메모리를 미리 할당하고 필요할 때마다 재사용하는 방법을 사용할 수 있습니다. 이를 통해 메모리 할당과 해제에 소요되는 시간을 줄일 수 있습니다.

메모리 풀 예시

#define POOL_SIZE 100
int memory_pool[POOL_SIZE];  // 미리 할당된 메모리 풀
int *get_memory() {
    static int index = 0;
    if (index < POOL_SIZE) {
        return &memory_pool[index++];
    }
    return NULL;  // 풀을 다 사용했을 때
}

2. 최소한의 메모리 할당


불필요한 메모리 할당을 피하고, 최소한으로 필요한 메모리만 할당하는 것이 중요합니다. 예를 들어, 배열이나 구조체를 사용해야 할 때 그 크기를 정확하게 예측하고, 필요한 만큼만 할당하도록 합니다. 이는 메모리의 낭비를 줄이고, 더 많은 데이터를 처리할 수 있도록 도와줍니다.

3. 메모리 풀과 캐시 최적화 결합


메모리 풀 기법과 캐시 최적화 기법을 결합하여 성능을 더욱 향상시킬 수 있습니다. 캐시 지역성(locality)을 고려하여 데이터를 메모리 풀 내에서 가까운 위치에 배치하면, 캐시 히트율을 높이고 프로그램 속도를 개선할 수 있습니다.

4. 동적 배열 크기 조정


동적 배열을 사용할 때 배열의 크기를 필요에 따라 조정하는 방식도 성능 최적화에 유효합니다. 예를 들어, 배열의 크기가 커지면 realloc을 사용하여 배열 크기를 늘릴 수 있습니다. 하지만 realloc은 할당된 메모리 영역이 이동할 수 있기 때문에, 메모리 재할당을 최소화하는 것이 중요합니다.

int *arr = (int *)malloc(5 * sizeof(int));
arr = (int *)realloc(arr, 10 * sizeof(int));  // 배열 크기 확장

5. 메모리 접근 패턴 최적화


메모리 접근 패턴을 최적화하는 것도 성능에 큰 영향을 미칩니다. 예를 들어, 배열을 순차적으로 접근하는 것이 임의 접근보다 더 빠르기 때문에, 데이터를 순차적으로 처리하도록 코드를 작성하는 것이 좋습니다. 이를 통해 캐시 효율성을 높이고 성능을 개선할 수 있습니다.

메모리 풀을 활용한 최적화


메모리 풀(Memory Pool)은 미리 할당된 일정 크기의 메모리 블록을 여러 번 재사용하는 기법으로, 동적 메모리 할당과 해제를 반복하는 것보다 더 효율적인 메모리 관리 방법을 제공합니다. 이를 통해 메모리 할당과 해제에 따른 성능 저하를 줄이고, 시스템 자원을 보다 효율적으로 사용할 수 있습니다.

메모리 풀의 원리


메모리 풀은 고정 크기의 메모리 블록들을 미리 할당해 두고, 필요할 때마다 이 블록들을 할당하고 반환합니다. 메모리 할당과 해제를 자주 수행하는 프로그램에서 성능을 크게 개선할 수 있습니다. 풀에 있는 메모리 블록은 관리자가 직접 할당하고 해제하므로, 운영 체제의 메모리 관리자와의 상호작용을 줄여 성능을 최적화할 수 있습니다.

메모리 풀 사용 예시


간단한 메모리 풀의 예시를 통해 메모리 풀 관리 방법을 살펴보겠습니다. 이 예시에서는 10개의 정수형 배열을 미리 할당한 후, 이를 재사용합니다.

#include <stdio.h>
#include <stdlib.h>

#define POOL_SIZE 10  // 풀 크기

int memory_pool[POOL_SIZE];  // 메모리 풀

// 메모리 풀에서 하나의 메모리 블록을 반환하는 함수
int* get_memory() {
    static int index = 0;
    if (index < POOL_SIZE) {
        return &memory_pool[index++];  // 다음 사용을 위한 메모리 반환
    }
    return NULL;  // 더 이상 할당할 메모리가 없을 때
}

// 메모리 풀을 초기화하는 함수
void initialize_pool() {
    for (int i = 0; i < POOL_SIZE; i++) {
        memory_pool[i] = 0;  // 초기화
    }
}

int main() {
    initialize_pool();  // 메모리 풀 초기화

    int *mem1 = get_memory();
    *mem1 = 10;  // 메모리 할당 후 값 설정
    printf("Memory1: %d\n", *mem1);

    int *mem2 = get_memory();
    *mem2 = 20;  // 또 다른 메모리 블록 할당
    printf("Memory2: %d\n", *mem2);

    return 0;
}

메모리 풀 사용의 장점


메모리 풀을 사용하면 다음과 같은 장점이 있습니다:

  • 성능 향상: 메모리 할당과 해제에 드는 시간이 감소합니다. 시스템 콜을 줄여 메모리 관리가 효율적으로 이루어집니다.
  • 메모리 사용 최적화: 풀에서 미리 할당된 메모리를 재사용하므로, 메모리 낭비를 줄일 수 있습니다.
  • 예측 가능성: 메모리 풀을 사용하면 동적 메모리 할당이 더 예측 가능하고 안정적이어서, 메모리 누수나 할당 실패 문제를 더 쉽게 관리할 수 있습니다.

메모리 풀은 특히 성능이 중요한 시스템이나 실시간 시스템에서 많이 사용됩니다.

캐시 최적화와 메모리 접근 패턴


캐시 최적화와 메모리 접근 패턴을 효율적으로 관리하는 것은 성능 최적화에서 중요한 부분을 차지합니다. 적절한 메모리 접근 방식은 CPU 캐시 히트율을 높여 프로그램의 실행 속도를 크게 향상시킬 수 있습니다.

캐시의 개념


CPU는 데이터를 빠르게 처리하기 위해 캐시 메모리를 사용합니다. 캐시는 주 메모리(RAM)보다 훨씬 빠르게 데이터를 읽고 쓸 수 있기 때문에, 자주 사용하는 데이터나 명령어를 캐시에 저장하여 빠르게 접근할 수 있습니다. 하지만 캐시 크기는 제한적이므로, 데이터가 캐시에 자주 들어가지 않으면 성능이 저하될 수 있습니다.

메모리 접근 패턴 최적화


메모리 접근 패턴을 최적화하면 캐시 효율성을 극대화할 수 있습니다. 가장 중요한 전략은 순차적 접근입니다. 메모리를 연속적으로 접근하면 캐시 히트율이 높아져 성능이 향상됩니다.

순차적 접근


배열이나 리스트와 같은 연속된 데이터를 처리할 때, 데이터에 순차적으로 접근하는 방식은 캐시 최적화에 매우 유리합니다. 데이터가 메모리 상에서 인접한 위치에 있을수록 캐시가 효율적으로 활용됩니다.

// 배열 순차 접근
for (int i = 0; i < N; i++) {
    arr[i] = arr[i] * 2;  // 메모리 상에서 연속적으로 접근
}

비효율적인 접근


반면, 배열을 임의로 접근하는 경우 캐시 미스가 자주 발생하여 성능이 저하됩니다. 예를 들어, 2차원 배열에서 각 열을 순차적으로 처리하는 것보다 행을 순차적으로 처리하는 것이 캐시 효율성이 더 높습니다.

// 비효율적인 2차원 배열 접근 (행렬 접근 예시)
for (int i = 0; i < N; i++) {
    for (int j = 0; j < M; j++) {
        arr[i][j] = arr[i][j] * 2;
    }
}

// 캐시 최적화된 접근 (행 순차 처리)
for (int j = 0; j < M; j++) {
    for (int i = 0; i < N; i++) {
        arr[i][j] = arr[i][j] * 2;
    }
}

데이터 지역성(Locality) 최적화


데이터 지역성은 프로그램이 사용하는 데이터가 물리적으로 가까운 위치에 있을 때 발생하는 성질입니다. 시간적 지역성(Temporal Locality)과 공간적 지역성(Spatial Locality)을 고려하여 메모리를 최적화할 수 있습니다.

시간적 지역성


시간적 지역성은 동일한 데이터에 반복적으로 접근하는 것을 의미합니다. 이를 최적화하려면 자주 사용하는 데이터를 메모리에서 가까운 위치에 배치하여 접근 속도를 높일 수 있습니다.

공간적 지역성


공간적 지역성은 연속된 데이터나 인접한 데이터에 접근하는 것입니다. 이를 최적화하기 위해 데이터를 인접한 메모리 블록에 배치하면 캐시 효율성을 높일 수 있습니다.

캐시 최적화의 중요성


캐시 최적화는 프로그램 성능을 크게 개선할 수 있습니다. 메모리 접근 패턴을 잘 조정하고 캐시 지역성을 고려하면, 데이터가 자주 캐시에 저장되어 빠르게 처리될 수 있습니다. 이 과정은 특히 대규모 데이터 처리나 반복적인 계산을 포함하는 프로그램에서 성능을 획기적으로 향상시킬 수 있습니다.

다중 스레드 환경에서의 메모리 관리


다중 스레드 환경에서 메모리 관리의 중요성은 더욱 강조됩니다. 여러 스레드가 동시에 메모리에 접근하고 수정할 수 있기 때문에, 메모리 충돌이나 데이터 불일치가 발생할 수 있습니다. 이를 방지하기 위한 적절한 메모리 관리 기법이 필요합니다.

스레드 간 메모리 공유


다중 스레드가 동일한 메모리 영역에 접근할 때, 메모리 공유가 발생합니다. 스레드 간에 데이터를 안전하게 공유하려면 동기화를 통해 서로의 작업을 조정해야 합니다. 이를 통해 데이터 충돌을 방지하고 프로그램의 일관성을 유지할 수 있습니다.

동기화 기법

  • 뮤텍스(Mutex): 뮤텍스는 한 스레드가 공유 자원을 사용할 때 다른 스레드의 접근을 차단하는 동기화 도구입니다. 이를 사용하면 두 개 이상의 스레드가 동시에 자원에 접근하는 것을 막을 수 있습니다.
  • 세마포어(Semaphore): 세마포어는 특정 자원에 접근할 수 있는 스레드의 수를 제한하는 동기화 기법입니다. 이를 통해 동시에 여러 스레드가 자원을 사용하되, 정해진 수만큼만 접근할 수 있도록 제어할 수 있습니다.

뮤텍스를 사용한 예시

#include <pthread.h>

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void* thread_func(void* arg) {
    pthread_mutex_lock(&lock);  // 공유 자원 접근 전 잠금
    // 공유 자원 작업
    pthread_mutex_unlock(&lock);  // 작업 후 잠금 해제
    return NULL;
}

메모리 할당의 스레드 안전성


malloc과 같은 동적 메모리 할당 함수는 기본적으로 스레드 안전하지 않습니다. 즉, 여러 스레드가 동시에 메모리 할당을 요청할 경우 문제가 발생할 수 있습니다. 이를 해결하기 위해 스레드 안전한 메모리 할당 방법을 사용하거나, 각 스레드마다 독립적인 메모리 풀을 사용하는 방법을 고려할 수 있습니다.

스레드 안전한 메모리 관리 방법

  • TLS(Thread Local Storage): TLS는 각 스레드가 독립적인 메모리 영역을 사용할 수 있도록 합니다. 이를 통해 다중 스레드가 동일한 메모리 공간을 공유하지 않고, 각자의 로컬 메모리만을 사용하게 되어 데이터 충돌을 방지할 수 있습니다.
  • 메모리 풀의 사용: 각 스레드에 독립적인 메모리 풀을 할당하면, 메모리 할당과 해제 시 충돌을 방지할 수 있습니다.

메모리 경합과 해결책


다중 스레드 환경에서는 메모리 경합(Race Condition) 문제가 발생할 수 있습니다. 이는 두 개 이상의 스레드가 동시에 동일한 메모리 영역에 접근하고 수정하려 할 때 발생하는 문제입니다. 이를 해결하기 위해서는 스레드 간 동기화가 필수적입니다.

경합 문제 해결 기법

  • 뮤텍스세마포어를 사용하여 경합을 방지하고, 스레드가 공유 자원에 접근할 때 순서를 제어합니다.
  • 원자적 연산(Atomic Operation): 여러 스레드가 동시에 접근하는 변수를 원자적으로 처리하는 방법입니다. 이를 통해 스레드 간에 경합을 피할 수 있습니다.

다중 스레드 환경에서의 메모리 풀 사용


다중 스레드 환경에서는 각 스레드가 독립적으로 메모리 풀을 사용할 수 있도록 구성하는 것이 좋습니다. 이를 통해 각 스레드가 다른 스레드와의 경합 없이 메모리를 할당하고 해제할 수 있습니다.

#define POOL_SIZE 10
int memory_pool[POOL_SIZE];

int* get_memory(int thread_id) {
    // 각 스레드에 고유한 메모리 풀 할당
    // thread_id를 이용해 독립적인 메모리 풀을 반환
}

결론


다중 스레드 환경에서 메모리 관리의 핵심은 동기화스레드 안전성입니다. 이를 통해 데이터 불일치와 충돌을 방지하고, 성능을 최적화할 수 있습니다. 메모리 경합을 해결하기 위한 기술적 접근과, 각 스레드가 독립적으로 메모리를 사용할 수 있도록 하는 기법들이 중요한 역할을 합니다.

동적 메모리 할당의 실용적 예시


동적 메모리 할당은 실제 소프트웨어 개발에서 중요한 역할을 하며, 다양한 상황에서 유용하게 사용될 수 있습니다. 이 섹션에서는 동적 메모리 할당을 활용한 몇 가지 실용적인 예시를 소개하고, 이를 통해 어떻게 성능을 최적화할 수 있는지 설명합니다.

1. 동적 배열 사용


동적 배열은 사용자가 실행 중에 크기를 변경할 수 있어, 필요한 만큼 메모리를 할당하여 유연하게 데이터를 처리할 수 있습니다. 예를 들어, 사용자가 입력한 데이터의 크기가 동적으로 변화할 때 동적 배열을 사용할 수 있습니다.

#include <stdio.h>
#include <stdlib.h>

int main() {
    int n;
    printf("배열의 크기를 입력하세요: ");
    scanf("%d", &n);

    // 동적 배열 할당
    int *arr = (int *)malloc(n * sizeof(int));
    if (arr == NULL) {
        printf("메모리 할당 실패\n");
        return 1;
    }

    // 배열에 데이터 입력
    for (int i = 0; i < n; i++) {
        arr[i] = i + 1;
    }

    // 배열 출력
    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);
    }

    // 메모리 해제
    free(arr);
    return 0;
}

2. 링크드 리스트 구현


동적 메모리 할당은 링크드 리스트와 같은 동적 자료 구조에서 중요한 역할을 합니다. 링크드 리스트는 데이터를 동적으로 추가하고 삭제할 수 있어 메모리 효율적으로 데이터를 처리할 수 있습니다.

#include <stdio.h>
#include <stdlib.h>

// 노드 구조체 정의
struct Node {
    int data;
    struct Node* next;
};

int main() {
    // 노드 생성
    struct Node* head = (struct Node*)malloc(sizeof(struct Node));
    if (head == NULL) {
        printf("메모리 할당 실패\n");
        return 1;
    }

    head->data = 10;
    head->next = NULL;

    // 새로운 노드 추가
    struct Node* second = (struct Node*)malloc(sizeof(struct Node));
    second->data = 20;
    second->next = NULL;
    head->next = second;

    // 리스트 출력
    struct Node* temp = head;
    while (temp != NULL) {
        printf("%d ", temp->data);
        temp = temp->next;
    }

    // 메모리 해제
    free(second);
    free(head);
    return 0;
}

3. 동적 메모리 할당을 이용한 행렬 연산


행렬 연산은 수학적 계산에서 매우 자주 사용됩니다. 동적 메모리 할당을 이용하면 행렬 크기를 실행 중에 동적으로 설정할 수 있어 유연한 연산이 가능합니다.

#include <stdio.h>
#include <stdlib.h>

int main() {
    int rows, cols;
    printf("행렬의 행과 열을 입력하세요 (행 열): ");
    scanf("%d %d", &rows, &cols);

    // 행렬을 위한 메모리 할당
    int **matrix = (int **)malloc(rows * sizeof(int *));
    for (int i = 0; i < rows; i++) {
        matrix[i] = (int *)malloc(cols * sizeof(int));
    }

    // 행렬 초기화
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            matrix[i][j] = i * j;
        }
    }

    // 행렬 출력
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            printf("%d ", matrix[i][j]);
        }
        printf("\n");
    }

    // 메모리 해제
    for (int i = 0; i < rows; i++) {
        free(matrix[i]);
    }
    free(matrix);

    return 0;
}

4. 동적 메모리 할당을 통한 게임 오브젝트 관리


게임 개발에서 동적 메모리 할당은 게임 오브젝트를 관리하는 데 유용합니다. 예를 들어, 게임의 각 레벨에서 등장하는 캐릭터나 오브젝트를 동적으로 할당하여 메모리 효율성을 높일 수 있습니다.

#include <stdio.h>
#include <stdlib.h>

typedef struct {
    char name[50];
    int health;
    int damage;
} GameObject;

int main() {
    int num_objects;
    printf("게임 오브젝트의 수를 입력하세요: ");
    scanf("%d", &num_objects);

    // 동적 메모리 할당
    GameObject *objects = (GameObject *)malloc(num_objects * sizeof(GameObject));
    if (objects == NULL) {
        printf("메모리 할당 실패\n");
        return 1;
    }

    // 게임 오브젝트 초기화
    for (int i = 0; i < num_objects; i++) {
        printf("오브젝트 %d 이름과 체력, 공격력 입력: ", i + 1);
        scanf("%s %d %d", objects[i].name, &objects[i].health, &objects[i].damage);
    }

    // 게임 오브젝트 출력
    for (int i = 0; i < num_objects; i++) {
        printf("오브젝트 %d: 이름: %s, 체력: %d, 공격력: %d\n", i + 1, objects[i].name, objects[i].health, objects[i].damage);
    }

    // 메모리 해제
    free(objects);
    return 0;
}

실용적 예시에서의 성능 최적화


위의 예시들에서 동적 메모리 할당은 다양한 방식으로 활용되었습니다. 각 예시는 실제 애플리케이션에서 메모리 사용을 효율적으로 최적화하고, 필요한 만큼만 메모리를 할당하여 프로그램의 성능을 향상시킬 수 있습니다. 게임 개발, 수학적 계산, 데이터 처리 등에서 동적 메모리 할당은 중요한 역할을 합니다.

요약


본 기사에서는 C 언어에서 동적 메모리 할당과 성능 최적화 방법에 대해 다루었습니다. 동적 메모리 할당의 기본 개념, mallocfree 함수 사용법, 메모리 누수 방지 방법부터 성능을 최적화하는 다양한 기법까지 상세히 설명했습니다. 또한, 메모리 풀 활용, 캐시 최적화 및 다중 스레드 환경에서의 메모리 관리 방법을 제시했습니다.

동적 메모리 할당은 유연한 메모리 사용과 성능 향상을 가능하게 하지만, 적절한 관리가 필요합니다. 각 스레드나 프로그램이 효율적으로 메모리를 활용할 수 있도록, 메모리 풀과 같은 기법을 사용하여 최적화할 수 있습니다. 동적 메모리 할당을 제대로 이해하고 적용함으로써, 더 나은 성능과 안정성을 제공할 수 있습니다.

목차