Devin.KR

복잡한 선언 읽기 - 함수 포인터와 콜백 테이블

개발자KR 조회 1

이 장에서 배우는 것

기본서에서 기본 타입과 포인터, 배열의 기초적인 사용법을 익혔습니다. 변수 이름 앞에 별표를 붙여 포인터를 만들고, 대괄호를 붙여 배열을 만드는 규칙은 직관적입니다. 하지만 C 언어에서 포인터와 배열, 함수가 결합하기 시작하면 선언문은 암호처럼 복잡해집니다. 포인터 배열, 배열을 가리키는 포인터, 함수를 가리키는 포인터 등은 실무 시스템 프로그래밍과 운영체제 커널 코드에서 일상적으로 등장합니다.

이 장에서는 복잡하게 얽힌 C 언어의 선언문을 읽는 체계적인 규칙을 배웁니다. 이 규칙을 바탕으로 함수 포인터를 이해하고, 함수 포인터를 활용하여 프로그램의 유연성을 높이는 콜백 패턴과 디스패치 테이블 기법을 도서관 대출 관리 프로그램에 적용해 보겠습니다.

  • 오른쪽-왼쪽 규칙을 사용하여 복잡한 포인터와 배열, 함수 선언을 정확하게 해석합니다.
  • typedef 키워드로 복잡한 함수 포인터 타입을 단순하고 가독성 높게 정의합니다.
  • 표준 라이브러리의 qsort 함수에 콜백 함수를 전달하여 구조체 배열을 원하는 기준으로 정렬합니다.
  • 명령어 문자열과 함수 포인터를 묶은 디스패치 테이블을 만들어 복잡한 분기문을 제거합니다.

문제 상황

작은 도서관 대출 관리 프로그램을 개발하고 있다고 가정해 봅시다. 프로그램은 콘솔 창에서 사용자의 명령어를 텍스트로 입력받아 동작합니다. 현재는 도서 목록 출력, 도서 추가, 도서 정렬 등의 기능을 구현하기 위해 긴 분기문을 사용하고 있습니다. 사용자가 입력한 문자열을 확인하기 위해 strcmp 함수와 if-else 문이 길게 나열된 상태입니다. 명령어가 하나 추가될 때마다 분기문은 계속 길어지고, 코드를 읽고 수정하기 어려워집니다.

또한 도서 목록을 정렬하는 기능도 문제입니다. 책 제목을 기준으로 정렬하는 코드와 출판 연도를 기준으로 정렬하는 코드가 각각 존재합니다. 배열의 요소를 교환하는 반복문 논리는 완전히 동일하지만, 두 책을 비교하는 단 한 줄의 조건문만 다르기 때문에 비슷한 정렬 함수를 여러 개 작성해야 하는 중복이 발생하고 있습니다.

코드의 중복을 줄이고, 새로운 명령어가 추가되더라도 기존 분기문을 수정하지 않고 확장할 수 있는 유연한 구조가 필요합니다. 이를 해결하는 핵심 도구가 바로 함수 포인터입니다. 함수 포인터를 자유자재로 다루기 위해서는 먼저 복잡한 선언을 읽는 법을 터득해야 합니다.

오른쪽-왼쪽 규칙으로 선언 해석하기

C 언어의 선언 문법은 '선언은 사용을 반영한다(Declaration mimics use)'는 철학을 바탕으로 만들어졌습니다. 즉, 변수를 선언할 때의 형태가 그 변수를 코드에서 사용할 때의 형태와 일치하도록 설계되었습니다. 이 때문에 포인터, 배열, 함수가 섞이면 선언을 읽는 방향이 시계추처럼 좌우로 왔다 갔다 하게 됩니다.

이러한 복잡한 선언을 기계적으로 해석할 수 있는 방법이 바로 '오른쪽-왼쪽 규칙'입니다. 이 규칙은 식별자(변수 이름)에서 출발하여 다음 순서대로 선언을 읽어나갑니다.

  1. 시작점 찾기: 선언문에서 변수의 이름(식별자)을 찾습니다. "이 변수는..."으로 해석을 시작합니다.
  2. 오른쪽 보기: 식별자의 바로 오른쪽을 봅니다. 배열을 나타내는 대괄호 [ ] 나 함수를 나타내는 소괄호 ( ) 가 있다면 먼저 해석합니다. 배열은 "...의 배열", 함수는 "...을 매개변수로 받고 ...를 반환하는 함수"로 읽습니다.
  3. 왼쪽 보기: 오른쪽을 다 읽었거나 가로막혔다면(닫는 괄호를 만났거나 끝에 도달했다면), 이제 왼쪽을 봅니다. 별표 * 가 있다면 "...를 가리키는 포인터"로 해석합니다.
  4. 괄호 빠져나가기: 현재 속해 있는 괄호 밖으로 나갑니다. 괄호를 벗어날 때마다 다시 2번(오른쪽 보기)과 3번(왼쪽 보기)을 반복합니다.
  5. 기본 타입 읽기: 마지막으로 가장 왼쪽에 있는 기본 타입(int, char, void 등)을 읽습니다.

이 규칙을 연습하기 위해 배열과 포인터가 섞인 예시를 보겠습니다. int *ptr[3]; 이라는 선언이 있습니다. 변수 이름은 ptr입니다. 오른쪽을 보면 [3]이 있으므로 "ptr은 크기가 3인 배열"입니다. 왼쪽을 보면 *가 있으므로 "배열의 각 요소는 포인터"입니다. 가장 왼쪽을 보면 int가 있으므로 "포인터가 가리키는 것은 int"입니다. 종합하면 "ptr은 int 포인터 3개를 담는 배열"이 됩니다. 이것이 포인터 배열입니다.

반대로 괄호가 들어간 int (*ptr)[3]; 을 보겠습니다. 이름은 ptr입니다. 괄호 안에 묶여 있으므로 오른쪽에는 아무것도 없고 왼쪽을 봅니다. *가 있으므로 "ptr은 포인터"입니다. 괄호를 빠져나와 오른쪽을 보면 [3]이 있으므로 "포인터가 가리키는 것은 크기가 3인 배열"입니다. 마지막으로 가장 왼쪽의 int를 읽으면 "배열의 요소는 int"입니다. 즉 "ptr은 크기가 3인 int 배열을 가리키는 포인터"가 됩니다. 이를 배열 포인터라고 부릅니다.

오른쪽-왼쪽 규칙을 적용하여 함수 포인터 선언을 단계별로 해석하는 과정을 보여주는 그림

함수 포인터 선언도 이 규칙을 완벽히 따릅니다. 위 그림에 나오는 void (*func_ptr)(int); 를 해석해 보겠습니다.

  • 변수 이름 func_ptr에서 시작합니다. (func_ptr은...)
  • 소괄호 안에 있으므로 바로 왼쪽의 *를 읽습니다. (...포인터이다.)
  • 괄호를 빠져나와 오른쪽의 (int)를 읽습니다. (...이 포인터는 int를 매개변수로 받는 함수를 가리킨다.)
  • 가장 왼쪽의 void를 읽습니다. (...이 함수는 void를 반환한다.)

결과적으로 "func_ptr은 int를 인자로 받고 반환값이 없는 함수를 가리키는 포인터"로 해석됩니다.

함수 포인터와 typedef

함수 포인터는 변수입니다. 일반 포인터가 데이터가 저장된 메모리 주소를 가리킨다면, 함수 포인터는 실행할 명령어가 저장된 메모리(일반적으로 코드 또는 텍스트 세그먼트라 부르는 영역)의 시작 주소를 가리킵니다. 함수 포인터를 사용하면 런타임에 어떤 함수를 호출할지 동적으로 결정할 수 있습니다.

하지만 함수 포인터는 문법이 복잡하여 변수를 선언하거나 함수의 매개변수로 넘길 때 코드를 매우 읽기 어렵게 만듭니다. 예를 들어, 다른 함수 포인터를 인자로 받는 함수의 선언은 void execute_command(void (*func)(int)); 처럼 괄호가 중첩되어 의미를 한눈에 파악하기 힘듭니다.

이럴 때 typedef 키워드를 사용하면 복잡한 함수 포인터 선언에 간결한 별칭을 부여할 수 있습니다. typedef로 함수 포인터 타입을 정의할 때는, 변수를 선언하는 문법과 동일하게 작성하고 맨 앞에 typedef를 붙이기만 하면 됩니다.

/* 반환값이 void이고 int 매개변수 하나를 받는 함수 포인터 타입의 이름을 ActionFunc로 정의 */
typedef void (*ActionFunc)(int);

/* 이제 ActionFunc를 기본 타입처럼 사용할 수 있습니다. */
ActionFunc my_func;
void execute_command(ActionFunc func);

이렇게 타입을 정의하면 코드의 가독성이 크게 향상될 뿐만 아니라, 함수 서명이 변경되어야 할 때 typedef 선언부 한 곳만 수정하면 되므로 유지보수도 매우 편리해집니다. 실무에서는 함수 포인터를 날것 그대로 사용하는 경우가 드물며, 거의 항상 typedef로 타입을 정의하여 사용합니다.

콜백 함수와 qsort

함수 포인터의 가장 강력한 용도 중 하나는 콜백(Callback) 패턴의 구현입니다. 콜백이란 다른 함수에게 인자로 전달되어, 특정 시점이나 조건이 만족되었을 때 '되부름'을 당하는 함수를 말합니다. 콜백을 사용하면 라이브러리 함수가 수행할 작업의 일부를 호출자가 직접 정의하여 주입할 수 있습니다.

C 표준 라이브러리의 정렬 함수인 qsort가 대표적인 예입니다. qsort 함수는 정수 배열, 문자열 배열, 구조체 배열 등 어떤 타입의 배열이든 정렬할 수 있습니다. 라이브러리를 만든 사람은 우리가 미래에 어떤 구조체를 만들지 알 수 없습니다. 요소들을 서로 바꾸는 로직은 라이브러리 내부에 구현되어 있지만, 두 요소 중 어느 것이 더 큰지 비교하는 로직은 타입마다 다르기 때문에 우리가 직접 작성해서 qsort에게 알려주어야 합니다.

qsort의 원형은 다음과 같습니다.

void qsort(void *base, size_t nmemb, size_t size,
           int (*compar)(const void *, const void *));

네 번째 매개변수가 바로 비교를 수행하는 콜백 함수의 포인터입니다. base는 배열의 시작 주소인데 void 포인터로 받기 때문에 어떤 타입이든 전달할 수 있습니다. 대신 qsort 내부에서는 포인터 산술 연산을 할 수 없으므로, 요소의 개수(nmemb)와 각 요소의 바이트 크기(size)를 함께 전달해야 합니다.

비교 함수는 const void 포인터 두 개를 매개변수로 받고 int를 반환해야 합니다. void 포인터를 매개변수로 받기 때문에, 비교 함수 내부에서는 전달받은 포인터를 실제 우리가 정렬하려는 데이터의 포인터 타입으로 강제 형변환(캐스팅)하여 사용해야 합니다. 비교 함수의 반환값은 다음과 같은 엄격한 규칙을 따릅니다.

qsort 비교 함수의 반환값 규칙
반환값 의미 (첫 번째 인자 a, 두 번째 인자 b) 정렬 결과 (오름차순 기준)
음수 (보통 -1) a가 b보다 작다 a가 b보다 배열의 앞쪽에 위치한다
0 a와 b가 같다 a와 b의 순서가 유지되거나 구현에 따라 바뀔 수 있다
양수 (보통 1) a가 b보다 크다 a가 b보다 배열의 뒤쪽에 위치한다

디스패치 테이블로 분기문 없애기

프로그램이 커지면 사용자의 입력을 처리하는 if-else 문이나 switch 문이 수십, 수백 줄로 길어지게 됩니다. 이는 코드를 읽기 어렵게 만들고, 새로운 명령을 추가할 때마다 거대한 함수를 수정해야 하므로 실수를 유발하기 쉽습니다.

이 문제를 해결하기 위해 데이터(명령어 문자열)와 동작(함수 포인터)을 구조체로 묶어 배열로 만드는 기법을 디스패치 테이블(Dispatch Table)이라고 부릅니다. 각 명령어에 해당하는 함수들의 원형(서명)을 통일시키고, 이를 가리키는 함수 포인터 타입을 정의합니다. 그리고 명령어 문자열과 함수 포인터를 쌍으로 갖는 구조체 배열을 선언합니다.

명령어 문자열과 함수 포인터를 담고 있는 구조체 배열이 메모리 상에서 어떻게 코드 영역의 함수를 가리키는지 보여주는 다이어그램

이렇게 테이블을 구축해두면 사용자가 명령어를 입력했을 때 배열을 순회하며 일치하는 문자열을 찾고, 문자열이 일치하면 구조체 안에 저장된 함수 포인터를 통해 즉시 함수를 호출할 수 있습니다. 반복문을 통해 명령어를 검색하므로 코드의 길이가 명령어 개수에 비례하여 길어지지 않으며, 새로운 명령어를 추가할 때는 분기문을 수정할 필요 없이 테이블에 배열 요소 하나만 추가하면 됩니다. 이는 시스템 프로그래밍에서 매우 자주 쓰이는 우아한 패턴입니다.

완성 코드

앞서 배운 콜백 함수와 디스패치 테이블 기법을 적용하여 작성한 작은 도서관 대출 관리 프로그램의 전체 코드입니다.

library_system.c

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

/* 도서 정보를 담는 구조체 */
typedef struct {
    int id;
    char title[64];
    int year;
} Book;

/* qsort에 전달할 비교 함수 타입 선언 */
typedef int (*CompareFunc)(const void *, const void *);

/* 제목 기준 오름차순 비교 함수 (콜백) */
int compare_by_title(const void *a, const void *b) {
    const Book *book_a = (const Book *)a;
    const Book *book_b = (const Book *)b;
    /* strcmp는 문자열을 비교하여 음수, 0, 양수를 정확히 반환합니다. */
    return strcmp(book_a->title, book_b->title);
}

/* 출판 연도 기준 오름차순 비교 함수 (콜백) */
int compare_by_year(const void *a, const void *b) {
    const Book *book_a = (const Book *)a;
    const Book *book_b = (const Book *)b;
    /* 오버플로를 방지하기 위해 직접 크기를 비교하여 반환합니다. */
    if (book_a->year < book_b->year) return -1;
    if (book_a->year > book_b->year) return 1;
    return 0;
}

/* 명령어 처리 함수 타입 선언 */
typedef void (*CommandFunc)(Book *books, int *count);

/* 전체 도서 목록 출력 함수 */
void cmd_list(Book *books, int *count) {
    printf("총 %d권의 책이 있습니다.\n", *count);
    for (int i = 0; i < *count; i++) {
        printf("[%d] %s (%d년)\n", books[i].id, books[i].title, books[i].year);
    }
}

/* 제목순 정렬 명령어 처리 함수 */
void cmd_sort_title(Book *books, int *count) {
    qsort(books, *count, sizeof(Book), compare_by_title);
    printf("제목순으로 정렬했습니다.\n");
}

/* 연도순 정렬 명령어 처리 함수 */
void cmd_sort_year(Book *books, int *count) {
    qsort(books, *count, sizeof(Book), compare_by_year);
    printf("출판연도순으로 정렬했습니다.\n");
}

/* 디스패치 테이블에 사용할 구조체 */
typedef struct {
    const char *name;
    CommandFunc func;
} Command;

int main(void) {
    /* 초기 도서 데이터 세팅 */
    Book library[10] = {
        {1, "C Programming Language", 1988},
        {2, "Advanced Programming in the UNIX Environment", 1992},
        {3, "Design Patterns", 1994},
        {4, "The Mythical Man-Month", 1975}
    };
    int book_count = 4;

    /* 디스패치 테이블 초기화 */
    Command commands[] = {
        {"list", cmd_list},
        {"sort_title", cmd_sort_title},
        {"sort_year", cmd_sort_year}
    };
    int num_commands = sizeof(commands) / sizeof(commands[0]);

    char input[32];
    printf("도서관 관리 프로그램에 오신 것을 환영합니다.\n");

    /* 메인 이벤트 루프 */
    while (1) {
        printf("명령어 입력 (list, sort_title, sort_year, quit) > ");
        if (scanf("%31s", input) != 1) break;

        if (strcmp(input, "quit") == 0) {
            printf("프로그램을 종료합니다.\n");
            break;
        }

        int found = 0;
        /* 디스패치 테이블을 순회하며 명령어 검색 */
        for (int i = 0; i < num_commands; i++) {
            if (strcmp(input, commands[i].name) == 0) {
                /* 문자열이 일치하면 연결된 함수 포인터를 통해 함수 호출 */
                commands[i].func(library, &book_count);
                found = 1;
                break;
            }
        }

        if (!found) {
            printf("알 수 없는 명령어입니다: %s\n", input);
        }
        printf("\n");
    }
    return 0;
}

줄별 해설

  • typedef int (*CompareFunc)(const void *, const void *); : qsort 함수에 인자로 넘길 비교 함수의 타입을 정의합니다. 반환형은 int이고, 두 개의 읽기 전용 void 포인터를 인자로 받습니다. 이 줄 덕분에 코드를 읽을 때 복잡한 서명 대신 CompareFunc라는 명확한 이름을 사용할 수 있습니다.
  • const Book *book_a = (const Book *)a; : 비교 함수 내부입니다. qsort는 데이터의 원본 타입을 모르기 때문에 void 포인터를 넘겨줍니다. 이를 우리가 정렬하려는 Book 구조체의 포인터로 형변환합니다. const 키워드를 유지하여 데이터를 실수로 변경하지 않도록 보장합니다.
  • typedef void (*CommandFunc)(Book *books, int *count); : 명령어 처리를 담당하는 함수의 서명을 정의합니다. 모든 명령어 함수는 도서 배열과 도서 개수가 담긴 변수의 포인터를 인자로 받아야 합니다.
  • Command commands[] = { ... }; : 이것이 바로 디스패치 테이블입니다. 명령어 문자열과 실행해야 할 함수의 이름을 쌍으로 묶어 배열로 초기화합니다. 함수의 이름 자체가 함수 포인터로 평가되므로 주소를 쉽게 저장할 수 있습니다.
  • commands[i].func(library, &book_count); : 입력받은 문자열과 일치하는 명령어를 테이블에서 찾았을 때 실행되는 핵심 코드입니다. if-else 문 없이 구조체에 저장된 함수 포인터를 통해 즉각적으로 해당 기능을 실행합니다.

실행 결과

프로그램을 컴파일하고 실행한 뒤, 목록을 확인하고 연도별 및 제목별로 정렬하는 명령을 내린 결과입니다.

$ cc -std=c17 -Wall -Wextra library_system.c
$ ./a.out
도서관 관리 프로그램에 오신 것을 환영합니다.
명령어 입력 (list, sort_title, sort_year, quit) > list
총 4권의 책이 있습니다.
[1] C Programming Language (1988년)
[2] Advanced Programming in the UNIX Environment (1992년)
[3] Design Patterns (1994년)
[4] The Mythical Man-Month (1975년)

명령어 입력 (list, sort_title, sort_year, quit) > sort_year
출판연도순으로 정렬했습니다.

명령어 입력 (list, sort_title, sort_year, quit) > list
총 4권의 책이 있습니다.
[4] The Mythical Man-Month (1975년)
[1] C Programming Language (1988년)
[2] Advanced Programming in the UNIX Environment (1992년)
[3] Design Patterns (1994년)

명령어 입력 (list, sort_title, sort_year, quit) > sort_title
제목순으로 정렬했습니다.

명령어 입력 (list, sort_title, sort_year, quit) > quit
프로그램을 종료합니다.

실무에서 자주 틀리는 것

함수 포인터 호출 문법 혼동

함수 포인터를 통해 함수를 호출할 때 문법을 혼동하기 쉽습니다. 함수 포인터를 역참조하여 호출하는 것이 원칙이지만, C 언어는 편의를 위해 일반 함수 호출과 동일한 문법도 허용합니다. 하지만 선언과 호출을 헷갈려 잘못된 코드를 작성하는 경우가 많습니다.

/* 틀린 코드 */
CommandFunc func = cmd_list;
*func(library, &count); // 오류: 함수 호출 연산자 ()가 역참조 연산자 *보다 우선순위가 높습니다.

/* 고친 코드 1 - 명시적 역참조 (괄호 필수) */
(*func)(library, &count);

/* 고친 코드 2 - 암시적 호출 (실무에서 가장 많이 씀) */
func(library, &count);

qsort 비교 함수에서 뺄셈 연산 오버플로

qsort의 비교 함수를 작성할 때 정수형 데이터를 비교하기 위해 단순히 두 값을 빼서 반환하는 코드를 작성하는 경우가 흔히 있습니다. 하지만 이 방식은 치명적인 정수 오버플로(Overflow) 버그를 유발할 수 있습니다.

/* 위험한 코드 */
int compare_bad(const void *a, const void *b) {
    int val_a = *(const int *)a;
    int val_b = *(const int *)b;
    return val_a - val_b; /* val_a가 매우 큰 양수이고 val_b가 매우 작은 음수이면 오버플로 발생 */
}

/* 고친 코드 */
int compare_good(const void *a, const void *b) {
    int val_a = *(const int *)a;
    int val_b = *(const int *)b;
    if (val_a < val_b) return -1;
    if (val_a > val_b) return 1;
    return 0;
}

void 포인터 형변환에서 포인터 연산 시도

void 포인터는 가리키는 대상의 크기를 알 수 없기 때문에 포인터 덧셈이나 뺄셈 같은 산술 연산을 할 수 없습니다. 배열의 특정 요소에 접근하려면 반드시 원래 타입의 포인터로 변환한 후에 연산해야 합니다.

/* 틀린 코드 */
void process_data(void *data, int index) {
    /* 오류: void* 는 포인터 산술 연산이 불가능합니다. */
    void *target = data + (index * sizeof(Book));
}

/* 고친 코드 */
void process_data(void *data, int index) {
    /* 원래 타입인 Book 포인터로 캐스팅한 후 연산하면 포인터 산술 규칙에 따라 자동으로 크기가 계산됩니다. */
    Book *target = (Book *)data + index;
}

한눈에 보기

선언 해석 규칙과 함수 포인터 개념 요약
개념 코드 예시 의미 해석
포인터 배열 int *arr[5]; 크기가 5인 배열. 각 요소는 int를 가리키는 포인터.
배열 포인터 int (*ptr)[5]; 포인터. 가리키는 대상은 크기가 5인 int 배열.
함수 포인터 void (*func)(int); 포인터. 가리키는 대상은 int를 인자로 받고 void를 반환하는 함수.
함수 포인터 타입 정의 typedef void (*Cmd)(void); 매개변수와 반환값이 없는 함수 포인터의 타입 이름을 Cmd로 정의.

연습 문제

  1. 오른쪽-왼쪽 규칙을 사용하여 다음 선언문의 의미를 한국어로 정확히 해석해 보세요. char *(*arr[10])(int);
  2. 문자열(char 포인터) 두 개를 매개변수로 받고, float을 반환하는 함수 포인터 타입을 CalcFunc라는 이름으로 정의하는 typedef 구문을 작성해 보세요.
  3. qsort를 사용하여 double 타입의 배열을 내림차순(큰 값이 먼저 오도록)으로 정렬하려고 합니다. 여기에 필요한 비교 함수를 작성해 보세요.

정답과 해설

  1. 정답: arr은 크기가 10인 배열이다. 배열의 각 요소는 포인터이다. 이 포인터가 가리키는 것은 int를 매개변수로 받고 char 포인터를 반환하는 함수이다. (간단히: int를 받고 char*를 반환하는 함수 포인터 10개를 담는 배열)
    해설: 이름 arr에서 시작하여 오른쪽의 [10]을 읽어 배열임을 파악합니다. 왼쪽의 *를 읽어 요소가 포인터임을 확인하고 괄호를 벗어납니다. 오른쪽의 (int)를 읽어 함수 포인터임을 파악하고, 마지막으로 왼쪽의 char *를 읽어 함수의 반환형을 확인합니다.
  2. 정답: typedef float (*CalcFunc)(char *, char *);
    해설: 함수 포인터 변수를 선언하는 것과 동일한 구조로 작성하되, 맨 앞에 typedef를 붙이고 변수 이름이 들어갈 자리에 새로 정의할 타입 이름(CalcFunc)을 적으면 됩니다.
  3. 정답:
    int compare_double_desc(const void *a, const void *b) {
        double val_a = *(const double *)a;
        double val_b = *(const double *)b;
        if (val_a > val_b) return -1; /* 앞의 값이 더 크면 배열의 앞쪽으로 보냄 (내림차순) */
        if (val_a < val_b) return 1;
        return 0;
    }
    해설: 내림차순 정렬이므로 첫 번째 인자가 더 클 때 음수를 반환하여 배열의 앞부분에 위치하도록 해야 합니다. 실수는 뺄셈 후 int로 변환할 때 소수점 이하가 버려지면서 0이 되어 비교가 잘못될 수 있으므로, 반드시 조건문으로 크기를 직접 비교해야 합니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.