이전 글에서 C 언어의 메모리 관리 문제와 GLib의 자동 정리 매크로를 통한 해법을 다루었습니다. 그때의 결론은 “우리는 답을 찾을 것이다. 늘 그랬듯이.”였죠.

그로부터 2년이 지난 지금, 코드를 작성하는 방식 자체가 달라지고 있습니다. AI 코딩 어시스턴트가 일상의 도구가 된 시대에, 한 가지 질문이 떠올랐습니다:

C가 그렇게 위험할까? Claude Code와 함께 코딩한다면, 믿고 맡겨도 되지 않을까?

이 글은 그 질문에 대한 실험입니다. 실제로 Claude Code에게 실무 수준의 C 메모리 관리 코드를 작성하게 하고, 그 결과물이 memory-safe한지 분석합니다.

실험 설계

단순한 malloc/free 쌍이 아닌, 실무에서 마주치는 세 가지 시나리오를 선정했습니다:

  1. GLib 기반 리소스 풀 관리 — 여러 리소스의 생명주기를 추적하는 패턴
  2. 소유권 이전이 필요한 팩토리 함수g_steal_pointer 패턴의 올바른 사용
  3. 비동기 콜백에서의 메모리 정리 — 에러 경로를 포함한 정리 로직

각 시나리오에 대해 Claude Code에게 요청한 프롬프트, 생성된 코드, 그리고 분석을 순서대로 기록합니다. 이 글 자체가 Claude Code와의 협업으로 작성되었으므로, 일종의 메타 실험이기도 합니다.

실험 1: GLib 기반 연결 풀 관리

프롬프트

GLib를 사용하여 데이터베이스 연결 풀을 구현해주세요. g_autoptr과 자동 정리 매크로를 활용하고, 연결의 생성/대여/반납/파괴를 관리하는 구조로 작성해주세요.

생성된 코드

#include <glib.h>
#include <stdio.h>

/* --- 타입 정의 --- */
typedef struct {
    gint    id;
    gboolean in_use;
    gchar   *connection_string;
} DbConnection;

typedef struct {
    GPtrArray *connections;   /* DbConnection 포인터 배열 */
    gint       max_size;
    GMutex     lock;
} ConnectionPool;

/* --- DbConnection 생명주기 --- */
static DbConnection *
db_connection_new (gint id, const gchar *conn_str)
{
    DbConnection *conn = g_new0 (DbConnection, 1);
    conn->id = id;
    conn->in_use = FALSE;
    conn->connection_string = g_strdup (conn_str);
    return conn;
}

static void
db_connection_free (DbConnection *conn)
{
    if (conn == NULL)
        return;
    g_free (conn->connection_string);
    g_free (conn);
}

/* GLib 자동 정리 매크로 등록 */
G_DEFINE_AUTOPTR_CLEANUP_FUNC (DbConnection, db_connection_free)

/* --- ConnectionPool 생명주기 --- */
ConnectionPool *
connection_pool_new (gint max_size, const gchar *conn_str)
{
    ConnectionPool *pool = g_new0 (ConnectionPool, 1);
    g_mutex_init (&pool->lock);
    pool->max_size = max_size;
    pool->connections = g_ptr_array_new_with_free_func (
        (GDestroyNotify) db_connection_free);

    for (gint i = 0; i < max_size; i++) {
        DbConnection *conn = db_connection_new (i, conn_str);
        g_ptr_array_add (pool->connections, conn);
    }

    return pool;
}

static void
connection_pool_free (ConnectionPool *pool)
{
    if (pool == NULL)
        return;
    g_ptr_array_unref (pool->connections);  /* 내부 요소도 함께 해제 */
    g_mutex_clear (&pool->lock);
    g_free (pool);
}

G_DEFINE_AUTOPTR_CLEANUP_FUNC (ConnectionPool, connection_pool_free)

/* --- 연결 대여 / 반납 --- */
DbConnection *
connection_pool_acquire (ConnectionPool *pool)
{
    g_autoptr (GMutexLocker) locker = g_mutex_locker_new (&pool->lock);

    for (guint i = 0; i < pool->connections->len; i++) {
        DbConnection *conn = g_ptr_array_index (pool->connections, i);
        if (!conn->in_use) {
            conn->in_use = TRUE;
            return conn;
        }
    }

    g_warning ("Connection pool exhausted (max: %d)", pool->max_size);
    return NULL;
}

void
connection_pool_release (ConnectionPool *pool, DbConnection *conn)
{
    g_return_if_fail (pool != NULL);
    g_return_if_fail (conn != NULL);

    g_autoptr (GMutexLocker) locker = g_mutex_locker_new (&pool->lock);
    conn->in_use = FALSE;
}

/* --- 사용 예 --- */
int
main (void)
{
    g_autoptr (ConnectionPool) pool =
        connection_pool_new (3, "host=localhost dbname=test");

    DbConnection *conn1 = connection_pool_acquire (pool);
    DbConnection *conn2 = connection_pool_acquire (pool);

    if (conn1)
        g_print ("Acquired connection %d\n", conn1->id);
    if (conn2)
        g_print ("Acquired connection %d\n", conn2->id);

    /* 사용 후 반납 */
    connection_pool_release (pool, conn1);
    connection_pool_release (pool, conn2);

    /* pool이 스코프를 벗어나면 자동으로 모든 연결이 해제됨 */
    return 0;
}

분석

눈여겨볼 점들입니다:

잘한 점

  • G_DEFINE_AUTOPTR_CLEANUP_FUNC를 사용하여 DbConnectionConnectionPool 모두에 자동 정리를 등록했습니다. 이전 글에서 다루었던 GLib의 자동 정리 패턴을 정확히 따르고 있습니다.
  • g_ptr_array_new_with_free_func로 배열 해제 시 내부 요소도 함께 정리되도록 했습니다. 이는 소유권을 배열에 위임하는 관용적 GLib 패턴입니다.
  • GMutexLockerg_autoptr로 선언하여, 함수가 어느 경로로 반환되든 뮤텍스가 해제됩니다. 이는 C++의 std::lock_guard에 해당하는 패턴으로, 데드락 방지에 효과적입니다.
  • g_new0으로 할당하여 모든 필드가 0으로 초기화됩니다.

주의할 점

  • connection_pool_acquire가 반환하는 DbConnection *은 풀 내부의 포인터입니다. 풀이 해제된 후에 이 포인터를 사용하면 use-after-free가 발생합니다. 이는 의도된 설계이지만, 사용자가 풀의 생명주기보다 연결을 오래 보관하지 않아야 한다는 계약(contract)이 코드에 명시적으로 문서화되어 있지 않습니다.

실험 2: 소유권 이전이 필요한 팩토리 함수

프롬프트

GLib 기반으로 설정 파일을 파싱하여 구조체를 생성하는 팩토리 함수를 작성해주세요. 내부에서 여러 단계의 할당이 일어나고, 실패 시 모든 중간 할당이 정리되어야 합니다. 성공 시에는 g_steal_pointer로 소유권을 호출자에게 이전해주세요.

생성된 코드

#include <glib.h>
#include <stdio.h>

typedef struct {
    gchar *host;
    gint   port;
    gchar *database;
    gchar *username;
    GArray *allowed_ips;
} ServerConfig;

static void
server_config_free (ServerConfig *config)
{
    if (config == NULL)
        return;
    g_free (config->host);
    g_free (config->database);
    g_free (config->username);
    if (config->allowed_ips)
        g_array_unref (config->allowed_ips);
    g_free (config);
}

G_DEFINE_AUTOPTR_CLEANUP_FUNC (ServerConfig, server_config_free)

/**
 * server_config_new_from_key_file:
 * @path: 설정 파일 경로
 * @error: (out): 에러 반환
 *
 * Returns: (transfer full): 새로 할당된 ServerConfig, 실패 시 NULL
 */
ServerConfig *
server_config_new_from_key_file (const gchar *path, GError **error)
{
    g_autoptr (ServerConfig) config = NULL;
    g_autoptr (GKeyFile) key_file = g_key_file_new ();
    g_autoptr (GError) local_error = NULL;

    /* 1단계: 파일 로드 */
    if (!g_key_file_load_from_file (key_file, path, G_KEY_FILE_NONE,
            &local_error)) {
        g_propagate_error (error, g_steal_pointer (&local_error));
        return NULL;
    }

    config = g_new0 (ServerConfig, 1);

    /* 2단계: 필수 필드 파싱 */
    config->host = g_key_file_get_string (key_file, "server", "host",
        &local_error);
    if (local_error) {
        g_propagate_error (error, g_steal_pointer (&local_error));
        return NULL;  /* config는 g_autoptr에 의해 자동 해제 */
    }

    config->port = g_key_file_get_integer (key_file, "server", "port",
        &local_error);
    if (local_error) {
        g_propagate_error (error, g_steal_pointer (&local_error));
        return NULL;  /* config->host도 config와 함께 해제 */
    }

    config->database = g_key_file_get_string (key_file, "server", "database",
        &local_error);
    if (local_error) {
        g_propagate_error (error, g_steal_pointer (&local_error));
        return NULL;
    }

    /* 3단계: 선택적 필드 */
    config->username = g_key_file_get_string (key_file, "server", "username",
        NULL);  /* 실패해도 무시 */

    /* 4단계: IP 목록 파싱 */
    {
        g_auto (GStrv) ip_list = g_key_file_get_string_list (key_file,
            "security", "allowed_ips", NULL, NULL);

        if (ip_list) {
            config->allowed_ips = g_array_new (FALSE, FALSE, sizeof (gchar *));
            g_array_set_clear_func (config->allowed_ips,
                (GDestroyNotify) g_free);

            for (gint i = 0; ip_list[i] != NULL; i++) {
                gchar *ip = g_strdup (ip_list[i]);
                g_array_append_val (config->allowed_ips, ip);
            }
        }
    }

    /* 모든 단계 성공: 소유권 이전 */
    return g_steal_pointer (&config);
}

int
main (void)
{
    g_autoptr (GError) error = NULL;
    g_autoptr (ServerConfig) config =
        server_config_new_from_key_file ("/etc/myapp/server.conf", &error);

    if (config == NULL) {
        g_printerr ("Failed to load config: %s\n", error->message);
        return 1;
    }

    g_print ("Server: %s:%d/%s\n", config->host, config->port,
        config->database);
    return 0;
}

분석

이전 글에서 다룬 황새울 프로젝트의 gaeguli_pipeline_new()와 동일한 패턴입니다.

잘한 점

  • 팩토리 함수 내부에서 g_autoptr(ServerConfig)로 선언하여, 어느 에러 경로에서 return NULL을 하든 그 시점까지 할당된 모든 메모리가 자동으로 해제됩니다. 2단계에서 실패하면 1단계에서 할당한 config->hostserver_config_free를 통해 함께 정리됩니다.
  • 성공 시 g_steal_pointer(&config)로 소유권을 호출자에게 명확히 이전합니다.
  • 호출자 측에서도 g_autoptr(ServerConfig)로 받아서 자동 해제를 보장합니다.
  • GError 패턴을 올바르게 사용하여, g_propagate_error로 에러를 전파하고 g_steal_pointer로 로컬 에러의 소유권을 이전합니다.

발견된 버그

  • allowed_ipsGArraygchar *를 저장하면서 g_array_set_clear_funcg_free를 직접 등록한 부분은 실제 버그입니다. GArray의 clear func은 요소 슬롯의 주소를 전달합니다. 요소가 gchar *이므로 clear func은 gchar **(배열 내부 버퍼의 한 지점)를 받게 되고, g_free는 이 배열 내부 주소를 해제하려 시도하여 정의되지 않은 동작(undefined behavior)을 유발합니다. 올바른 해법은 GPtrArray를 사용하는 것입니다:
    config->allowed_ips = g_ptr_array_new_with_free_func (g_free);
    for (gint i = 0; ip_list[i] != NULL; i++)
        g_ptr_array_add (config->allowed_ips, g_strdup (ip_list[i]));
    

    이 버그는 컴파일 시 경고 없이 통과하며, 특정 조건에서만 크래시가 발생하기 때문에 발견이 어렵습니다. AddressSanitizer를 통해서야 비로소 잡히는 종류의 문제입니다. AI가 생성한 코드도 이런 미묘한 실수를 포함할 수 있다는 점에서, 동적 분석 도구의 역할이 더욱 중요해집니다.

실험 3: 비동기 콜백에서의 메모리 정리

프롬프트

GLib의 비동기 패턴(GTask)을 사용하여 파일을 비동기로 읽고 파싱하는 함수를 작성해주세요. 콜백에서 에러가 발생했을 때 모든 중간 할당이 정리되어야 합니다.

생성된 코드

#include <gio/gio.h>
#include <glib.h>

typedef struct {
    gchar  *filename;
    gchar  *content;
    gsize   length;
    GStrv   lines;
} ParsedFile;

static void
parsed_file_free (ParsedFile *pf)
{
    if (pf == NULL)
        return;
    g_free (pf->filename);
    g_free (pf->content);
    g_strfreev (pf->lines);
    g_free (pf);
}

G_DEFINE_AUTOPTR_CLEANUP_FUNC (ParsedFile, parsed_file_free)

/* 백그라운드 스레드에서 실행되는 작업 */
static void
parse_file_thread_func (GTask        *task,
                        gpointer      source_object,
                        gpointer      task_data,
                        GCancellable *cancellable)
{
    const gchar *path = (const gchar *) task_data;
    g_autoptr (ParsedFile) result = NULL;
    g_autoptr (GError) error = NULL;
    g_autofree gchar *raw_content = NULL;
    gsize raw_length = 0;

    /* 1단계: 파일 읽기 */
    if (!g_file_get_contents (path, &raw_content, &raw_length, &error)) {
        g_task_return_error (task, g_steal_pointer (&error));
        return;  /* result는 NULL이므로 해제할 것 없음 */
    }

    /* 2단계: UTF-8 유효성 검사 */
    if (!g_utf8_validate (raw_content, raw_length, NULL)) {
        g_task_return_new_error (task, G_IO_ERROR,
            G_IO_ERROR_INVALID_DATA,
            "File '%s' is not valid UTF-8", path);
        return;  /* raw_content는 g_autofree에 의해 자동 해제 */
    }

    /* 3단계: 결과 구성 */
    result = g_new0 (ParsedFile, 1);
    result->filename = g_path_get_basename (path);
    result->content = g_steal_pointer (&raw_content);
    result->length = raw_length;
    result->lines = g_strsplit (result->content, "\n", -1);

    /* 성공: GTask에 소유권 이전 */
    g_task_return_pointer (task, g_steal_pointer (&result),
        (GDestroyNotify) parsed_file_free);
}

void
parse_file_async (const gchar        *path,
                  GCancellable       *cancellable,
                  GAsyncReadyCallback callback,
                  gpointer            user_data)
{
    GTask *task = g_task_new (NULL, cancellable, callback, user_data);
    g_task_set_task_data (task, g_strdup (path), g_free);
    g_task_run_in_thread (task, parse_file_thread_func);
    g_object_unref (task);
}

ParsedFile *
parse_file_finish (GAsyncResult *result, GError **error)
{
    return g_task_propagate_pointer (G_TASK (result), error);
}

/* --- 콜백과 메인 루프 --- */
static void
on_file_parsed (GObject      *source,
                GAsyncResult *result,
                gpointer      user_data)
{
    GMainLoop *loop = user_data;
    g_autoptr (GError) error = NULL;
    g_autoptr (ParsedFile) pf = parse_file_finish (result, &error);

    if (pf == NULL) {
        g_printerr ("Parse failed: %s\n", error->message);
    } else {
        g_print ("File: %s (%zu bytes, %u lines)\n",
                 pf->filename, pf->length,
                 g_strv_length (pf->lines));
    }

    g_main_loop_quit (loop);
}

int
main (int argc, char *argv[])
{
    if (argc < 2) {
        g_printerr ("Usage: %s <file>\n", argv[0]);
        return 1;
    }

    GMainLoop *loop = g_main_loop_new (NULL, FALSE);
    parse_file_async (argv[1], NULL, on_file_parsed, loop);
    g_main_loop_run (loop);
    g_main_loop_unref (loop);

    return 0;
}

분석

비동기 패턴은 메모리 관리가 가장 까다로운 영역입니다. 콜백이 다른 스레드에서 실행되고, 에러 경로가 여러 갈래로 나뉘기 때문입니다.

잘한 점

  • GTask의 소유권 모델을 정확히 따르고 있습니다. g_task_set_task_datag_free를 소멸자로 등록하여 경로 문자열의 해제를 보장합니다.
  • g_task_return_pointerparsed_file_free를 소멸자로 전달하여, 호출자가 결과를 가져가지 않더라도 GTask 소멸 시 자동으로 정리됩니다.
  • 스레드 함수 내부에서 g_autoptrg_autofree를 조합하여, 1단계 실패 시에는 할당된 것이 없고, 2단계 실패 시에는 raw_content가 자동 해제되며, 3단계에서는 g_steal_pointer로 소유권을 result에 이전합니다.
  • 콜백에서도 g_autoptr(ParsedFile)로 받아 자동 해제를 보장합니다.

주의할 점

  • parse_file_async에서 g_object_unref(task)를 즉시 호출하는 것은 올바른 GLib 패턴이지만, GLib에 익숙하지 않은 개발자에게는 “아직 실행 중인 task를 해제한다”고 오해할 수 있습니다. 이는 GTask가 내부적으로 자신에 대한 참조를 보유하기 때문에 안전합니다.

검증

코드 리뷰만으로는 부족합니다. 실제로 컴파일하고 동적 분석 도구를 돌려봐야 합니다.

# AddressSanitizer로 컴파일
gcc -fsanitize=address -g -o pool_test pool_test.c $(pkg-config --cflags --libs glib-2.0)
./pool_test

# Valgrind로 메모리 누수 검사
valgrind --leak-check=full --show-leak-kinds=all ./pool_test

AddressSanitizer는 heap-buffer-overflow, use-after-free, double-free, memory leak을 런타임에 잡아줍니다. Valgrind는 더 느리지만 초기화되지 않은 메모리 읽기까지 감지합니다. 두 도구 모두 Claude Code가 생성한 코드에서 문제를 보고하지 않았다면, 그 코드는 상당히 신뢰할 수 있는 수준입니다.

관찰과 생각

세 가지 실험을 통해 관찰한 점들입니다:

  1. GLib 관용구에 대한 이해: g_autoptr, g_steal_pointer, G_DEFINE_AUTOPTR_CLEANUP_FUNC 등 GLib의 자동 메모리 관리 매크로를 적절한 맥락에서 사용합니다. 이전 글에서 다룬 황새울 프로젝트의 패턴과 동일한 구조를 자연스럽게 생성합니다.

  2. 에러 경로의 정리: 가장 인상적인 부분입니다. 팩토리 함수에서 어느 단계에서 실패하든, 그 시점까지의 모든 할당이 정리되는 구조를 만들어냅니다. 이는 수동으로 goto cleanup 패턴을 작성하는 것보다 훨씬 안전하고 읽기 쉽습니다.

  3. 소유권 의식: “이 포인터의 소유자가 누구인가?”를 일관되게 추적합니다. g_steal_pointer로 소유권을 명시적으로 이전하고, g_task_return_pointer에 소멸자를 등록하는 등, 소유권 모델을 체계적으로 따릅니다.

  4. 한계: AI가 생성한 코드라고 해서 검증을 건너뛸 수는 없습니다. 실험 2에서 발견한 GArray의 clear func 버그처럼, 컴파일러도 잡지 못하고 특정 조건에서만 발현되는 미묘한 문제가 포함될 수 있습니다. 또한 연결 풀의 use-after-free 가능성처럼 API 계약(contract)으로만 방지되는 문제는 코드 자체만으로 해결되지 않습니다.

맺음말

C가 위험한 것이 아니라, C로 작성하는 사람이 실수할 수 있는 것입니다. 그리고 실수의 상당 부분은 패턴의 문제입니다 — 올바른 패턴을 알고, 일관되게 적용하는 것이 핵심입니다.

AI 코딩 어시스턴트는 이 패턴의 적용에서 놀라울 정도로 일관적입니다. g_autoptr을 빠뜨리거나, 에러 경로에서 free를 잊거나, 소유권 이전을 누락하는 — 사람이 피곤하거나 급할 때 저지르는 실수를 하지 않습니다.

물론 AI가 생성한 코드를 맹신해서는 안 됩니다. 하지만 AddressSanitizer와 Valgrind 같은 검증 도구와 결합한다면, “C는 위험하니 쓰지 말라”는 권고에 대해 “도구를 갖춘 C 개발자는 안전하다”고 답할 수 있지 않을까요.

이전 글의 맺음말을 빌려, 조금 수정해봅니다:

우리는 답을 찾을 것이다. 늘 그랬듯이. — 다만 이제는 조금 더 빨리.