[C언어 50강] 33강. 포인터로 함수 설계: out-parameter, 배열/구조체 반환 전략

[C언어 50강] 33강. 포인터로 함수 설계: out-parameter, 배열/구조체 반환 전략

C언어에서 함수 설계가 어려워지는 지점은 문법이 아니라 데이터의 소유권과 생명주기가 함수 경계를 넘나들기 시작할 때입니다. 특히 배열이나 구조체처럼 크기가 있거나, 계산 결과를 여러 개 돌려줘야 하는 경우에 "반환값 하나로는 부족한데 어떻게 인터페이스를 잡아야 하지?"라는 고민이 생깁니다. 오늘은 포인터를 활용한 함수 설계에서 가장 실무적인 주제인 out-parameter 패턴, 배열/구조체 반환 전략을 개념 중심으로 정리해보겠습니다.


핵심 개념

  • 반환값은 "함수의 성공/실패 상태"로 쓰고, 실제 결과 데이터는 out-parameter로 전달하면 계약이 명확해진다.
  • 배열은 값으로 직접 반환할 수 없으므로(언어 차원 제약) 호출자 버퍼 전달 또는 동적 할당 반환 중 하나를 의도적으로 선택해야 한다.
  • 구조체는 값 반환이 가능하지만, 크기/복사비용/소유권을 고려해 값 반환과 포인터 반환을 상황별로 구분해야 한다.

개념 먼저 이해하기

많은 초급자가 함수 설계를 할 때 먼저 고민하는 건 "어떻게든 동작하게 만들기"입니다. 하지만 실무에서 더 중요한 건 "오해 없이 사용 가능한 API인가"입니다. 같은 기능이라도 인터페이스를 어떻게 정의하느냐에 따라 버그율과 유지보수 난도가 크게 달라집니다. 포인터를 사용하는 함수 설계가 어려운 이유도 여기에 있습니다. 포인터는 단순히 주소를 전달하는 문법이 아니라, 누가 메모리를 만들고, 누가 해제하고, 실패했을 때 어떤 상태를 보장하는지라는 약속 전체를 담기 때문입니다.

먼저 out-parameter 개념을 잡아봅시다. out-parameter는 함수가 계산한 결과를 호출자 쪽 변수에 써주는 방식입니다. 예를 들어 int parse_int(const char *s, int *out_value) 같은 형태입니다. 여기서 반환값 int는 성공/실패 코드로 쓰고, 실제 파싱 결과는 out_value에 기록합니다. 이 방식의 장점은 명확합니다. 첫째, 실패를 분기하기 쉽습니다. 둘째, 결과를 여러 개 반환할 수 있습니다(out_a, out_b처럼). 셋째, 반환값을 상태 코드로 통일하면 팀 전체 에러 처리 규칙을 맞추기 쉽습니다.

배열은 C 함수 설계에서 특별 취급 대상입니다. C에서 배열은 함수 반환 타입으로 직접 쓸 수 없습니다. 그래서 현실적인 선택지는 두 가지입니다. (1) 호출자가 버퍼와 길이를 넘기고 함수가 채워 넣는다. (2) 함수가 동적 할당해서 포인터를 반환하고 호출자가 free한다. 둘 중 무엇이 맞는지는 "결과 크기를 호출자가 미리 아는가", "할당 책임을 호출자에게 넘겨도 되는가", "실패 처리를 얼마나 단순화할 것인가"로 결정합니다. 라이브러리성 API에서는 보통 호출자 버퍼 방식이 예측 가능하고 안전하며, 가변 길이 결과(예: 파일 전체 읽기)에서는 동적 할당 반환이 더 자연스럽습니다.

구조체는 배열보다 선택지가 넓습니다. 구조체는 값으로 반환이 가능하므로 작은 POD 구조체(좌표, 통계 요약, 설정 묶음 등)는 값 반환이 깔끔합니다. 최신 컴파일러는 반환 최적화(RVO 유사 최적화, ABI 기반 레지스터 반환 등)를 수행해 생각보다 비용이 크지 않은 경우가 많습니다. 그러나 구조체가 매우 크거나 내부에 동적 메모리 포인터를 가진다면 단순 값 복사 모델이 오히려 혼란을 만듭니다. 이때는 out-parameter나 명시적 생성/파괴 함수(init/destroy) 패턴이 더 안정적입니다.

핵심은 포인터 자체가 아니라 계약의 명시성입니다. 좋은 함수는 최소한 다음 질문에 답할 수 있어야 합니다. "실패하면 out 값은 보존되는가?", "부분 성공 상태가 가능한가?", "반환된 포인터를 누가 free하는가?", "입력 포인터 NULL은 허용되는가?". 이 질문에 코드와 주석이 같은 답을 줄 때, 팀원은 구현을 몰라도 API를 안전하게 사용할 수 있습니다.

또 하나 중요한 포인트는 "실패 원자성"입니다. 함수가 여러 out-parameter를 채우는 중간에 실패하면 호출자는 애매한 상태를 받기 쉽습니다. 그래서 실무에서는 임시 변수에 계산을 끝낸 뒤 마지막에 한 번에 out에 커밋하는 방식을 많이 씁니다. 즉, 성공 전까지는 호출자 상태를 건드리지 않는 겁니다. 이 원칙 하나만 지켜도 디버깅 체감 난도가 크게 내려갑니다.

정리하면, 포인터로 함수 설계한다는 말은 포인터 연산을 잘한다는 뜻이 아닙니다. 데이터 흐름, 소유권, 실패 시나리오를 인터페이스 수준에서 설계하는 능력을 뜻합니다. 이 관점으로 코드를 보면, "왜 이 함수는 int를 반환하고 결과는 포인터로 받는지", "왜 길이 인자를 반드시 같이 받는지"가 전부 납득되기 시작합니다.

기본 사용

예제 1) 상태 코드는 반환값, 실제 결과는 out-parameter

#include <stdio.h>
#include <ctype.h>

/* 성공: 0, 실패: 음수 */
int parse_two_digits(const char *s, int *out_value) {
    int v;

    if (!s || !out_value) return -1;
    if (!isdigit((unsigned char)s[0]) || !isdigit((unsigned char)s[1])) return -2;

    v = (s[0] - '0') * 10 + (s[1] - '0');
    *out_value = v;
    return 0;
}

int main(void) {
    int value = -1;
    int rc = parse_two_digits("42", &value);

    if (rc != 0) {
        printf("parse failed: %d\n", rc);
        return 1;
    }

    printf("value=%d\n", value);
    return 0;
}

설명:

  • 반환값을 결과 데이터로 쓰지 않고 상태 코드로 고정하면 호출부의 분기 규칙이 일관됩니다.
  • out_value는 성공 경로에서만 갱신되므로 실패 시 호출자 상태가 예측 가능합니다.
  • 포인터 인자는 항상 NULL 가능성을 먼저 차단하는 습관이 중요합니다.

예제 2) 배열 결과: 호출자 버퍼를 받는 패턴

#include <stdio.h>
#include <stddef.h>

/* src의 제곱을 dst에 기록, 성공 시 기록 개수 반환, 실패 시 0 */
size_t square_copy(const int *src, size_t n, int *dst, size_t dst_n) {
    size_t i;

    if (!src || !dst) return 0;
    if (dst_n < n) return 0; /* 경계 검사 */

    for (i = 0; i < n; i++) {
        dst[i] = src[i] * src[i];
    }
    return n;
}

int main(void) {
    int in[5] = {1, 2, 3, 4, 5};
    int out[5] = {0};
    size_t written = square_copy(in, 5, out, 5);

    if (written == 0) {
        printf("copy failed\n");
        return 1;
    }

    for (size_t i = 0; i < written; i++) {
        printf("%d ", out[i]);
    }
    printf("\n");
    return 0;
}

설명:

  • 배열은 길이 없이 전달되면 안전하게 다룰 수 없으므로 길이 인자는 계약의 일부입니다.
  • 함수 내부에서 경계 검사를 수행하면 오버런을 구조적으로 방지할 수 있습니다.
  • 라이브러리성 코드에서 가장 많이 쓰이는 형태이며 테스트도 쉽습니다.

예제 3) 구조체 반환 전략: 작은 구조체는 값 반환

#include <stdio.h>

typedef struct {
    int sum;
    int min;
    int max;
} Stats;

Stats calc_stats3(int a, int b, int c) {
    Stats s;
    s.sum = a + b + c;
    s.min = a;
    s.max = a;

    if (b < s.min) s.min = b;
    if (c < s.min) s.min = c;
    if (b > s.max) s.max = b;
    if (c > s.max) s.max = c;
    return s;
}

int main(void) {
    Stats r = calc_stats3(7, 2, 9);
    printf("sum=%d min=%d max=%d\n", r.sum, r.min, r.max);
    return 0;
}

설명:

  • 결과가 작고 의미적으로 하나의 묶음이면 구조체 값 반환이 읽기 쉽고 사용성이 좋습니다.
  • out-parameter 여러 개보다 호출부 가독성이 높아지는 경우가 많습니다.
  • 단, 구조체 내부가 동적 메모리를 소유한다면 별도 소유권 규칙을 설계해야 합니다.

예제 4) 동적 할당 반환: 소유권을 명시적으로 넘기기

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

/* 성공 시 0, *out에 새 버퍼 전달(호출자가 free) */
int make_sequence(size_t n, int **out) {
    int *buf;

    if (!out || n == 0) return -1;

    buf = (int *)malloc(n * sizeof(int));
    if (!buf) return -2;

    for (size_t i = 0; i < n; i++) buf[i] = (int)(i + 1);

    *out = buf; /* ownership transfer */
    return 0;
}

설명:

  • int **out은 "포인터 자체를 함수가 채운다"는 뜻입니다.
  • 이 패턴은 결과 크기를 함수가 결정할 때 유용하지만, free 책임을 반드시 문서화해야 합니다.
  • 실패 경로에서 *out을 건드리지 않는 규칙을 지키면 호출부가 단순해집니다.

자주 하는 실수

실수 1) out-parameter를 채우기 전에 NULL 검사 생략

  • 원인: 정상 경로만 상정하고 포인터 유효성 검사를 뒤로 미룸.
  • 해결: 함수 첫 줄에서 입력 포인터 검증 후 즉시 실패 반환(guard clause) 적용.

실수 2) 배열 길이 인자를 받지 않거나 신뢰만 하고 검증하지 않음

  • 원인: "호출자가 알아서 맞게 넣겠지"라는 가정.
  • 해결: 길이는 항상 함께 받고, 함수 내부에서 최소/최대 경계 검사를 수행.

실수 3) 동적 할당 반환 후 소유권을 문서화하지 않음

  • 원인: 코드만 보면 알 거라고 생각함.
  • 해결: 주석/함수명/문서에 "caller must free"를 명시하고 예제 코드에 해제 포함.

실수 4) 실패 중간 상태를 호출자에게 노출

  • 원인: out 변수에 단계별로 바로 기록.
  • 해결: 임시 변수에 계산 후 성공 시 마지막에 커밋하는 실패 원자성 패턴 사용.

실무 패턴

  • 반환 규약 통일: 팀 단위로 0 성공 / 음수 실패 혹은 bool + errno 스타일 중 하나로 고정.
  • 이름으로 의도 드러내기: out_, inout_ 접두어로 매개변수 역할을 명확히 표시.
  • 계약 주석 최소 세 줄: 입력 조건(NULL 허용 여부), 성공 시 보장, 소유권 책임.
  • 테스트 우선 설계: 정상/경계/실패(메모리 부족, 잘못된 길이) 케이스를 API 설계 단계부터 준비.
  • 정적 분석/경고 활용: -Wall -Wextra -Wpedantic -Wconversion으로 포인터 관련 위험을 조기 탐지.

오늘의 결론

한 줄 요약: 포인터 함수 설계의 본질은 문법이 아니라 계약이며, out-parameter·배열 길이·소유권 규칙을 명확히 하면 C API는 충분히 안전하고 읽기 쉬워진다.

연습문제

  1. int divide_checked(int a, int b, int *out_q, int *out_r)를 작성해 0으로 나누기 실패를 상태 코드로 반환하고, 성공 시에만 몫/나머지를 기록해보세요.
  2. 문자열 배열(char *items[])에서 가장 긴 문자열 길이를 계산하는 함수를 만들되, 길이와 인덱스를 out-parameter 두 개로 반환해보세요.
  3. 작은 구조체를 값 반환하는 버전과 out-parameter 버전 두 가지로 구현해 호출부 가독성과 테스트 난이도를 비교해보세요.

이전 강의 정답

  • 32강 연습문제 1(전역/static/자동 변수 비교):
    • 자동 지역 변수는 호출마다 초기화되어 값이 누적되지 않고, static 지역 변수와 전역 변수는 프로그램 종료까지 생존하므로 호출 간 값이 유지됩니다. 즉 차이는 스코프가 아니라 저장 기간에서 발생합니다.
  • 32강 연습문제 2(지역 배열 주소 반환 리팩터링):
    • 지역 배열 주소 반환은 lifetime 위반이므로, malloc으로 버퍼를 확보해 문자열을 복사한 뒤 반환해야 합니다. 반환 포인터의 해제 책임은 호출자에게 있고, 문서/주석에 명시해야 안전합니다.
  • 32강 연습문제 3(리터럴 수정 시도 교정):
    • 문자열 리터럴 쓰기는 UB 가능성이 있으므로 const char *로 받는 것이 맞습니다. 수정이 필요하면 char[] 또는 동적 버퍼에 복사본을 만들어 수정해야 합니다.

실습 환경/재현 정보

  • 컴파일러: Apple clang 17+ 또는 gcc 13+
  • 컴파일 옵션: -std=c11 -Wall -Wextra -Wpedantic -O0 -g
  • 권장 추가 옵션: -Wconversion -fsanitize=address,undefined -fno-omit-frame-pointer
  • 실행 환경: macOS(arm64) 터미널 또는 Linux 셸
  • 재현 체크:
    1. 모든 예제가 경고 없이 빌드되는지 확인
    2. NULL/길이 부족 입력에서 함수가 안전하게 실패하는지 확인
    3. 동적 할당 반환 예제에서 free 누락 시 sanitizer로 누수 탐지
    4. out-parameter가 실패 시 오염되지 않는지 단위 테스트로 검증