2. 데이터 모델 골격 — 규격서 I/O 표를 구조체로

한 문장 요약 규격서의 표 한 줄 = 구조체 멤버 하나. 고민할 건 딱 네 가지다 — 어떤 타입? P 가 M/O/C 중 무엇? 길이는 고정인가 가변인가? 단, C 가 여러 개이면 먼저 “동시에 올 수 있나”를 묻는다 — 아니라면 tagged union (2-9). 그리고 다 정한 뒤에 마지막으로 멤버 순서 — 정렬 큰 것부터 (4장).


0. 코드 치기 전에 만드는 표 세 개

빈 파일 앞에서 막막한 이유는 뭘 만들지가 안 정해졌기 때문이다. 표부터 만들면 구조체는 기계적으로 나온다.

표 ①: 입력

필드타입P조건 (P = C 일 때)범위·길이설명
nf_instance_idstringM—36 (UUID)NF 식별자
nf_typeenumM—AMF/SMF/UPF…NF 종류
heart_beat_timerintegerO—1~3600초 단위
ipv4_addressesstring[]O—최대 8개, 각 15자IPv4 목록
upf_infoobjectCnf_type == UPF—UPF 전용 정보

표 ②: 출력

필드타입M/O설명
result_codeintegerM201/400/500
locationstringO생성된 리소스 URI

표 ③: 에러

상황리턴코드응답
필수 필드 누락RET_INVALID_ARG400 Bad Request
UUID 형식 위반RET_BAD_FORMAT400
이미 등록됨RET_FAIL409 Conflict
DB 오류RET_IO_ERROR500

P(Presence) 열이 제일 중요하다 — M / O / C

M(Mandatory) = 없으면 에러. O(Optional) = 없어도 정상. C(Conditional) = 조건이 참이면 M, 거짓이면 없음. 조건 문장은 조건 열에 따로 옮겨 적는다. 이 구분을 안 하고 코드를 짜면, “보낸 적 없는 필드가 0으로 들어와서” 생기는 원인을 알 수 없는 버그가 나온다. (아래 2장에서 해결 — O 는 2-2~2-5, C 는 2-6~2-10)


1. 타입 매핑 — 규격서 타입을 C 타입으로

flowchart LR
    subgraph SPEC["규격서"]
        S1["integer"]
        S2["string"]
        S3["enumeration"]
        S4["boolean"]
        S5["array of X"]
        S6["object"]
        S7["bit string"]
    end
    subgraph C["C 타입"]
        C1["int32_t / uint16_t<br/>★ 범위 보고 결정"]
        C2["char[N+1]<br/>★ +1 은 널 종단"]
        C3["typedef enum"]
        C4["uint8_t (0/1)"]
        C5["X arr[MAX];<br/>uint16_t cnt;"]
        C6["중첩 struct"]
        C7["uint32_t flags"]
    end
    S1-->C1
    S2-->C2
    S3-->C3
    S4-->C4
    S5-->C5
    S6-->C6
    S7-->C7

1-1. 정수 — 크기를 범위 보고 고른다

규격서 범위C 타입크기
0 ~ 255uint8_t1
0 ~ 65535uint16_t2
-32768 ~ 32767int16_t2
0 ~ 약 42억uint32_t4
그 이상 / 시각int64_t, uint64_t8

int 를 기본값으로 쓰지 말 것

int 의 크기는 플랫폼마다 다를 수 있다. 네트워크로 나가거나 파일에 저장되는 구조체에는 반드시 <stdint.h> 의 고정 크기 타입을 쓴다. 로컬 계산용 변수에만 int 를 쓴다.

time_t 를 int 에 넣지 말 것

64비트 time_t 를 32비트 int 에 담으면 2038년에 음수가 된다. 정적 분석기(Coverity)도 이걸 잡는다. → [C] 64비트 time_t와 32비트 int 변환

1-2. 문자열 — 항상 +1

#define NF_ID_LEN       36              /* 규격서가 말하는 최대 길이 */
#define NF_ID_SIZE      (NF_ID_LEN + 1) /* ★ 널 종단 자리 */
 
char nf_instance_id[NF_ID_SIZE];        /* 37바이트 */

LEN 과 SIZE 를 이름으로 구분하라

  • _LEN = 글자 수 (규격서 숫자 그대로)
  • _SIZE = 배열 크기 (_LEN + 1)

이렇게 나눠두면 strlen(x) > NF_ID_LEN 과 sizeof(buf) == NF_ID_SIZE 를 헷갈리지 않고 쓸 수 있다. 헷갈리면 off-by-one 버그가 난다.

1-3. enum — 반드시 UNKNOWN 과 END 를 넣는다

typedef enum {
    NF_TYPE_UNKNOWN = 0,        /* ★ 0 = "설정 안 됨". memset(0) 과 맞아떨어진다 */
    NF_TYPE_AMF,
    NF_TYPE_SMF,
    NF_TYPE_UPF,
    NF_TYPE_PCF,
    NF_TYPE_UDM,
    NF_TYPE_END                 /* ★ 개수 세기 / 범위 검사용 */
} nf_type_t;
 
/* 문자열 ↔ enum 변환은 항상 짝으로 만든다 */
static const char *g_nf_type_str[NF_TYPE_END] = {
    "UNKNOWN", "AMF", "SMF", "UPF", "PCF", "UDM"
};
 
const char *nf_type_to_str(nf_type_t t)
{
    if (t < 0 || t >= NF_TYPE_END) return "INVALID";
    return g_nf_type_str[t];
}
 
nf_type_t nf_type_from_str(const char *s)
{
    int i;
    if (s == NULL) return NF_TYPE_UNKNOWN;
    for (i = 1; i < NF_TYPE_END; i++) {
        if (strcasecmp(s, g_nf_type_str[i]) == 0) return (nf_type_t)i;
    }
    return NF_TYPE_UNKNOWN;
}

0 을 UNKNOWN 에 주는 이유

구조체를 memset(&x, 0, sizeof(x)) 로 초기화하면 enum 필드도 0이 된다. 0이 유효한 값(NF_TYPE_AMF 같은)이면 초기화 안 한 필드가 AMF로 보인다. 0은 항상 “설정되지 않음” 으로 비워둔다.

_END 를 배열 크기로 쓰면 자동으로 따라온다

g_nf_type_str[NF_TYPE_END] 로 선언하면, enum 에 값을 추가했는데 문자열을 안 넣으면 컴파일러가 나머지를 NULL 로 채운다. -Wmissing-field-initializers 를 켜면 경고까지 준다.


2. Optional(O) 과 Conditional(C) — 제일 중요한 부분

2-1. 문제: “0이 들어온 건가, 안 온 건가?”

/* ✗ 이 구조체로는 구분할 수 없다 */
typedef struct {
    char     nf_id[NF_ID_SIZE];
    uint16_t heart_beat_timer;      /* Optional 인데... 0이면? */
} nf_profile_t;

heart_beat_timer == 0 일 때 두 가지 해석이 가능하다.

  1. 상대가 “0초” 라고 보냈다 (말이 안 되지만 규격상 가능할 수도)
  2. 상대가 안 보냈고, 내가 memset(0) 한 값이다

이 둘을 구분 못 하면 기본값을 적용해야 할지 말지를 결정할 수 없다.

2-2. 해결 ①: has_ 플래그 (권장, 제일 단순)

typedef struct {
    /* ── Mandatory: 플래그 없음 ── */
    char      nf_id[NF_ID_SIZE];
    nf_type_t nf_type;
 
    /* ── Optional: 값 + 있음 표시 ── */
    uint8_t   has_heart_beat_timer;     /* 0 = 없음, 1 = 있음 */
    uint16_t  heart_beat_timer;
 
    uint8_t   has_priority;
    uint16_t  priority;
} nf_profile_t;
/* 쓰는 쪽 */
if (in->has_heart_beat_timer) {
    timer = in->heart_beat_timer;
} else {
    timer = DEFAULT_HB_TIMER;           /* 기본값 적용 */
}
 
/* 채우는 쪽 */
out->has_heart_beat_timer = 1;
out->heart_beat_timer     = 30;

채우는 매크로를 만들어두면 실수가 없다

#define SET_OPT(_s, _f, _v)                                    \
    do { (_s)->has_##_f = 1; (_s)->_f = (_v); } while (0)
 
#define GET_OPT(_s, _f, _def)                                  \
    ((_s)->has_##_f ? (_s)->_f : (_def))
SET_OPT(prof, heart_beat_timer, 30);
timer = GET_OPT(prof, heart_beat_timer, DEFAULT_HB_TIMER);

## 는 토큰 붙이기(token pasting). has_##_f → has_heart_beat_timer 가 된다. 값 설정과 플래그 설정이 항상 같이 일어나므로 한쪽만 하는 실수가 불가능하다.

2-3. 해결 ②: 비트마스크 (필드가 많을 때)

Optional 필드가 20개쯤 되면 has_ 플래그 20개가 구조체를 어지럽힌다. 이때는 비트마스크.

/* 있음 표시를 비트 하나씩 */
#define F_HEART_BEAT_TIMER  (1u << 0)
#define F_PRIORITY          (1u << 1)
#define F_CAPACITY          (1u << 2)
#define F_LOAD              (1u << 3)
#define F_LOCALITY          (1u << 4)
 
typedef struct {
    uint32_t  present;              /* ★ 어떤 Optional 이 왔는지 비트로 */
 
    char      nf_id[NF_ID_SIZE];
    nf_type_t nf_type;
 
    uint16_t  heart_beat_timer;
    uint16_t  priority;
    uint16_t  capacity;
    uint8_t   load;
    char      locality[LOCALITY_SIZE];
} nf_profile_t;
 
/* 조작 매크로 */
#define P_SET(_s, _f)    ((_s)->present |=  (_f))
#define P_CLR(_s, _f)    ((_s)->present &= ~(_f))
#define P_HAS(_s, _f)    (((_s)->present & (_f)) != 0)
P_SET(prof, F_HEART_BEAT_TIMER);
prof->heart_beat_timer = 30;
 
if (P_HAS(prof, F_PRIORITY)) { ... }

비트 연산이 낯설면

→ [C] 비트플래그(Bit Flag) 연산 → 자세한 복붙 매크로는 3번 문서

2-4. 해결 ③: “불가능한 값”을 센티널로 (제한적으로만)

타입센티널쓸 수 있는 조건
문자열buf[0] == '\0'빈 문자열이 유효값이 아닐 때 ✓
포인터NULL항상 ✓
enum_UNKNOWN (0)0을 유효값으로 안 쓸 때 ✓
양수 전용 정수-1 또는 0위험 — 규격을 잘 확인할 것
배열cnt == 0✓

정수에 센티널을 쓸 때는 규격서를 세 번 확인하라

“이 값은 1부터 시작하니까 0은 없음으로 쓰자” 라고 했는데 나중에 규격 개정으로 0이 유효값이 되면 전부 뒤집어야 한다. 애매하면 has_ 플래그를 쓰는 게 안전하다.

2-5. 세 방식 비교 (Optional 표현)

has_ 플래그비트마스크센티널
가독성★★★★★★
메모리필드당 1바이트전체 4바이트0
필드 추가쉬움비트 상수 추가—
실수 위험낮음 (매크로로 묶으면)중간높음
권장기본으로 이것Optional 15개 이상문자열/포인터만

2-6. C (Conditional) — 규격서 표에는 M/O 말고 C 가 있다

3GPP 같은 규격서의 필드 표에는 P 열(Presence)이 있고, 값이 세 가지다.

P뜻코드 표현validate 가 하는 일
M항상 있어야 함플래그 없음없으면 에러
O있어도, 없어도 됨has_있을 때만 범위 검사
C조건이 참이면 M, 거짓이면 없음has_ + 조건식조건 참인데 없으면 에러 / 조건 거짓인데 있으면 정책대로

C 는 표만 봐서는 안 되고 설명 열의 조건 문장을 읽어야 한다. (“nfType 이 SMF 이면 present”, “notifEnable 이 true 이면 M”) 그리고 이 조건 문장이 어떤 모양이냐가 코드 구조를 정한다 — 아래 2-8.

C 를 O 처럼 처리하면 검사가 통째로 빠진다

O 는 “없어도 정상”이라 validate 가 아무것도 안 한다. C 는 “어떤 경우엔 없으면 안 된다.” 표에서 C 를 O 로 옮겨 적으면, 필수여야 할 필드가 빠진 요청이 정상으로 통과한다. 0장의 표 ①에 조건 열을 하나 더 만들어 두는 이유다.

2-7. C 가 하나뿐일 때 — has_ + 조건 검사

구조체는 O 와 똑같다. 달라지는 건 validate 뿐이다.

① 규격서의 조건 문장을 표로 먼저 옮긴다 (코드 치기 전)

필드조건조건 참일 때조건 거짓일 때
notif_urinotif_enable == 1필수없어야 정상 (있으면 → 정책)

② 그 표가 그대로 코드가 된다

/* 조건식은 함수로 뽑는다 — validate 본문이 조건 문장으로 어지러워지지 않게 */
static int cond_notif_uri_required(const sub_req_t *r)
{
    return r->notif_enable != 0;
}
 
/* validate 안에서 */
if (cond_notif_uri_required(r)) {
    if (!r->has_notif_uri) {                     /* ★ 조건 참인데 없다 → 에러 */
        LOG_ERR("notif_uri required when notif_enable=1 (%s/%d)", __func__, __LINE__);
        return RET_INVALID_ARG;
    }
} else if (r->has_notif_uri) {                   /* 조건 거짓인데 왔다 → 정책 결정 */
    LOG_WRN("notif_uri ignored: notif_enable=0 (%s/%d)", __func__, __LINE__);
}

"조건 거짓인데 왔다" 는 규격서를 확인하고 한 번만 정한다

엄격하게 RET_INVALID_ARG 로 거절할지, 관대하게 로그만 남기고 무시할지는 규격과 상대 구현에 따라 다르다. 정해 두지 않으면 함수마다 다르게 처리하게 된다. validate 한 곳에서만 결정한다.

2-8. C 가 여러 개일 때 — 조건 문장의 모양을 먼저 분류한다

C 가 여러 개라고 무조건 union 이 아니다. 함께 존재할 수 있는가로 갈린다.

flowchart TD
    Q1{"C 필드들이 <b>동시에</b><br/>존재할 수 있나?"}
    Q1 -->|"예 — 서로 무관"| P1["① 독립 조건<br/>각자 has_ + 각자 조건식<br/><b>union ✗</b>"]
    Q1 -->|"예 — 같이 오거나 같이 없음"| P3["③ 묶음<br/>하위 struct 로 감싸고 has_ 하나"]
    Q1 -->|"예 — 하나 이상이면 됨"| P4["④ 최소 하나<br/>각자 has_ +<br/>validate 에서 합계 ≥ 1"]
    Q1 -->|"아니오 — 정확히 하나만"| Q2{"어느 것인지 알려 주는<br/>필드(판별자)가 있나?"}
    Q2 -->|"있다 — 예: nf_type"| P2["② tagged union<br/>기존 enum 을 태그로 재사용"]
    Q2 -->|"없다"| P2b["② tagged union<br/>kind enum 을 새로 추가"]
패턴규격서의 문장 예C 표현
① 독립”a 는 X 일 때, b 는 Y 일 때 present” (X·Y 는 서로 무관)각자 has_ + 조건 함수
② 배타”nfType 이 AMF 면 amfInfo, SMF 면 smfInfo, UPF 면 upfInfo”tagged union
③ 묶음”fqdn 과 port 는 함께 present”하위 struct + has_ 하나
④ 최소 하나”ipv4Addr 또는 ipv6Addr 중 최소 하나”각자 has_ + has_v4 + has_v6 >= 1 검사

동시에 올 수 있는 필드를 union 에 넣지 말 것

union 은 멤버들이 같은 메모리를 겹쳐 쓴다. 하나를 쓰면 다른 하나가 지워진다. ④ 의 ipv4 / ipv6 처럼 둘 다 올 수 있는데 union 으로 만들면, 둘째를 파싱하는 순간 첫째가 조용히 망가진다. 컴파일도 되고 에러도 안 난다. union 은 “정확히 하나만 존재한다”고 규격이 보장할 때만.

2-9. 배타 C → tagged union (판별자 + union)

규칙은 하나다. “지금 어느 멤버가 유효한가”를 알려주는 태그(판별자)를 union 과 함께 둔다. 태그 없는 union 은 읽는 쪽이 어느 멤버를 봐야 하는지 알 방법이 없다.

규격서 표 (단순화한 예)

필드타입P조건
nf_typeenumMAMF / SMF / UPF …
amf_infoobjectCnf_type == AMF 일 때
smf_infoobjectCnf_type == SMF 일 때
upf_infoobjectCnf_type == UPF 일 때

세 C 는 nf_type 이 하나로 정하므로 동시에 존재할 수 없다 → 배타 → union. 그리고 판별자가 이미 M 필드(nf_type)로 있다 → 새 태그를 만들 필요 없이 그걸 쓴다.

/* ── 멤버마다 자기 구조체로 감싼다 ─────────────────────── */
typedef struct {                        /* AMF: 값이 한 개씩 */
    uint16_t  set_id;
    uint8_t   region_id;
} amf_info_t;
 
typedef struct {                        /* SMF: 값이 한 개씩 */
    uint16_t  tac;
    char      dnn[DNN_SIZE];
} smf_info_t;
 
typedef struct {                        /* UPF: ★ 여러 개 — 배열과 개수를 이 안에 가둔다 */
    uint8_t   has_fqdn;
    uint8_t   sst_cnt;
    uint8_t   sst[MAX_UPF_SST];
    char      fqdn[FQDN_SIZE];
} upf_info_t;
 
/* ── 본 구조체 ──────────────────────────────────────────── */
typedef struct {
    nf_type_t  nf_type;                 /* ★ 태그: 아래 union 중 무엇이 유효한지 */
 
    union {                             /* ★ nf_type 이 가리키는 것 하나만 유효 */
        amf_info_t  amf;
        smf_info_t  smf;
        upf_info_t  upf;
    } info;
 
    char       nf_id[NF_ID_SIZE];       /* 정렬 1 → 맨 끝 (4장) */
} nf_profile_t;

"C 가 한 개씩 / 여러 개" 가 섞여 있으면 구조체로 감싸면 된다

upf_info_t 는 배열(여러 개)을 갖고, amf_info_t 는 단일 값만 갖는다. 이 차이를 각자의 구조체 안에 가두면 union 입장에서는 전부 “구조체 하나”라서 똑같이 다룰 수 있다. 배열의 cnt 도 그 구조체 안에 넣는다. union 밖에 두면 태그가 바뀔 때 엉뚱한 개수를 보게 된다. 멤버가 단일 값 하나뿐이어도 (예: uint16_t 하나) 구조체로 감싸 두면 나중에 필드가 늘어날 때 union 을 안 건드린다.

읽기와 쓰기는 항상 태그를 거친다

/* 읽기: 태그가 맞을 때만 포인터를 내준다. 틀리면 NULL — 쓰레기를 읽을 기회를 아예 안 준다 */
const upf_info_t *nf_profile_get_upf(const nf_profile_t *p)
{
    if (p == NULL || p->nf_type != NF_TYPE_UPF) return NULL;
    return &p->info.upf;
}
 
/* 쓰기: ① 태그 ② union 전체를 0 으로 ③ 그 멤버만 채운다 */
int nf_profile_set_upf(nf_profile_t *p, const upf_info_t *src)
{
    CHK_PTR(p);
    CHK_PTR(src);
 
    p->nf_type = NF_TYPE_UPF;
    memset(&p->info, 0x00, sizeof(p->info));    /* ★ 이전 멤버의 잔재 제거 */
    p->info.upf = *src;
    return RET_OK;
}

validate 는 유효한 멤버 하나만 본다

switch (p->nf_type) {
case NF_TYPE_AMF:
    /* p->info.amf 만 검사 */
    break;
case NF_TYPE_SMF:
    if (p->info.smf.dnn[0] == '\0') {
        LOG_ERR("smf dnn missing (%s/%d)", __func__, __LINE__);
        return RET_INVALID_ARG;
    }
    break;
case NF_TYPE_UPF:
    if (p->info.upf.sst_cnt == 0 || p->info.upf.sst_cnt > MAX_UPF_SST) {
        LOG_ERR("upf sst_cnt(%u) invalid (%s/%d)", p->info.upf.sst_cnt, __func__, __LINE__);
        return RET_INVALID_ARG;
    }
    break;
default:                                /* PCF 등 info 가 없는 NF — 조건에 해당 없음 */
    break;
}

"조건은 참인데 xxx_info 가 안 왔다" 는 파싱 단계에서 잡는다

tagged union 구조체 안에는 “안 왔다”를 나타낼 자리가 없다. 태그가 UPF 이면 info.upf 는 늘 “있는” 것이다. 그러므로 JSON이나 바이트를 읽는 함수가 nf_type == UPF 인데 upfInfo 가 없으면 그 자리에서 RET_INVALID_ARG 를 내고 구조체를 채우지 않는다. 조건이 참일 때도 그 멤버가 있을 수도 없을 수도 있다면 uint8_t has_info; 를 태그 옆에 하나 더 둔다.

판별자가 따로 없는 배타 C — kind enum 을 새로 만든다

/* 규격: "주소는 ipv4 또는 ipv6 중 정확히 하나" — 이걸 알려 주는 필드가 표에 없다 */
typedef enum {
    ADDR_KIND_NONE = 0,                 /* ★ 0 = 아직 안 채움 (memset 과 맞아떨어진다) */
    ADDR_KIND_V4,
    ADDR_KIND_V6,
    ADDR_KIND_END
} addr_kind_t;
 
typedef struct {
    addr_kind_t kind;                   /* ★ 태그 */
    union {
        char v4[IPV4_STR_SIZE];
        char v6[IPV6_STR_SIZE];
    } u;
} addr_t;

2-10. tagged union 규칙 6개

#규칙어기면
1태그가 항상 짝으로 있다. 태그 없는 union 은 만들지 않는다어느 멤버가 유효한지 알 수 없다
2읽기 전에 태그부터 확인 (switch (tag) 또는 getter)다른 멤버의 바이트를 엉뚱한 타입으로 해석한 쓰레기 값
3쓸 때는 태그 설정 → memset(&u, 0) → 멤버 채우기 를 setter 함수 하나로이전 멤버의 잔재가 남아 새 멤버 일부가 오염된다
4태그의 0 은 NONE / UNKNOWN — memset(0) 초기화가 “아무것도 없음” 이 된다초기화 안 한 구조체가 첫 멤버를 가진 것처럼 보인다
5switch 에 default: 를 둔다 (범위 밖 태그, info 가 없는 타입)알 수 없는 태그가 조용히 통과한다
6멤버 안에 동적 할당(포인터) 이 있으면 free 도 태그로 갈라서 한다잘못된 멤버의 포인터를 free → 크래시 또는 누수
/* 6번 예: 해제도 태그로 가른다 */
void nf_profile_free(nf_profile_t *p)
{
    if (p == NULL) return;
    switch (p->nf_type) {
    case NF_TYPE_UPF:  SAFE_FREE(p->info.upf.big_list);  break;   /* 이 멤버가 포인터를 가질 때만 */
    default: break;
    }
    memset(p, 0x00, sizeof(*p));
}

union 의 크기는 가장 큰 멤버를 따른다

upf_info_t 가 수 KB 이고 amf_info_t 가 4바이트여도 nf_profile_t 는 항상 수 KB 를 차지한다.

  • 멤버 크기가 비슷하면 → union 이 이득 (합이 아니라 최댓값이니까)
  • 한 멤버만 유독 크면 → 그 멤버는 union 에서 빼고 포인터(upf_info_t *upf)로 두고, 태그가 UPF 일 때만 할당·해제한다

union 의 정렬은 정렬이 가장 큰 멤버를 따른다

4장의 배치 규칙에서 union 은 중첩 struct 와 같은 취급이다. 8바이트 멤버가 하나라도 있으면 union 전체가 8바이트 그룹이다.

union 을 통째로 memcpy 로 전송하지 말 것

전송 바이트열에는 태그가 가리키는 멤버만 써야 한다. 안 쓰는 멤버 자리의 바이트는 쓰레기이고, 상대와 union 크기도 다를 수 있다. pack 함수에서 switch (tag) 로 갈라 필드를 하나씩 쓴다. → 3번 문서


3. 배열 — 고정이냐 동적이냐

3-1. 판단 기준

flowchart TD
    Q1{"규격서에 최대 개수가<br/>명시되어 있나?"}
    Q1 -->|있다| Q2{"최대 개수 × 크기가<br/>수 KB 이하인가?"}
    Q1 -->|없다/매우 큼| D["동적 할당<br/>ptr + cnt"]
    Q2 -->|예| F["고정 배열<br/>★ 기본 선택"]
    Q2 -->|아니오| D

웬만하면 고정 배열

고정 배열은 malloc/free 가 없으니 누수가 원천적으로 불가능하다. 규격서에 “최대 8개” 라고 써 있으면 그냥 arr[8] 로 잡는다. 메모리 몇 KB 아끼려다 누수 버그 하나 만드는 게 훨씬 손해다.

3-2. 고정 배열 (권장)

#define MAX_IPV4_ADDR   8
#define IPV4_STR_SIZE   16          /* "255.255.255.255" + '\0' */
 
typedef struct {
    uint16_t  ipv4_cnt;                                  /* ★ 실제 개수 */
    char      ipv4_addr[MAX_IPV4_ADDR][IPV4_STR_SIZE];   /* ★ 최대 크기 */
} nf_profile_t;
/* 추가하는 함수 — 개수 검사를 반드시 넣는다 */
int profile_add_ipv4(nf_profile_t *p, const char *addr)
{
    CHK_PTR(p);
    CHK_STR(addr);
 
    if (p->ipv4_cnt >= MAX_IPV4_ADDR) {          /* ★ 이게 없으면 오버플로 */
        LOG_ERR("ipv4 list full (%u) (%s/%d)", p->ipv4_cnt, __func__, __LINE__);
        return RET_TOO_SMALL;
    }
    if (strlen(addr) >= IPV4_STR_SIZE) {         /* ★ 길이도 검사 */
        LOG_ERR("addr too long (%zu) (%s/%d)", strlen(addr), __func__, __LINE__);
        return RET_TOO_SMALL;
    }
 
    snprintf(p->ipv4_addr[p->ipv4_cnt], IPV4_STR_SIZE, "%s", addr);
    p->ipv4_cnt++;
    return RET_OK;
}

cnt 검사를 빼먹으면 스택이 통째로 깨진다

구조체가 스택에 있으면 배열 뒤에는 리턴 주소가 있다. cnt 검사 없는 쓰기 한 번이 임의 코드 실행 취약점이 된다. 배열에 쓰는 코드는 무조건 cnt >= MAX 검사부터.

3-3. 동적 배열 (개수를 모를 때만)

typedef struct {
    uint16_t   item_cnt;
    item_t    *items;               /* ★ 짝: free */
} big_list_t;
 
int list_init(big_list_t *l, uint16_t cnt)
{
    CHK_PTR(l);
    memset(l, 0x00, sizeof(*l));
 
    if (cnt == 0) return RET_OK;    /* 0개는 정상. 할당 안 함 */
 
    l->items = calloc(cnt, sizeof(item_t));    /* ★ 곱셈 오버플로 검사 포함 */
    CHK_PTR(l->items);
 
    l->item_cnt = cnt;
    return RET_OK;
}
 
void list_free(big_list_t *l)
{
    if (l == NULL) return;
    SAFE_FREE(l->items);
    l->item_cnt = 0;                /* ★ 개수도 같이 0으로 */
}

free 하고 cnt 를 안 지우면 더 위험하다

포인터는 NULL 인데 cnt 가 5로 남아 있으면, for (i = 0; i < l->item_cnt; i++) l->items[i]... 가 NULL 역참조로 죽는다. 포인터와 개수는 항상 같이 움직인다.

3-4. 문자열 배열 (가장 실수 잦음)

typedef struct {
    uint16_t   cnt;
    char     **strs;                /* 포인터의 배열 — 이중 해제 필요 */
} str_list_t;
 
void str_list_free(str_list_t *l)
{
    uint16_t i;
    if (l == NULL) return;
 
    if (l->strs != NULL) {
        for (i = 0; i < l->cnt; i++) {
            SAFE_FREE(l->strs[i]);  /* ① 안쪽 문자열들 먼저 */
        }
        SAFE_FREE(l->strs);         /* ② 포인터 배열 나중 */
    }
    l->cnt = 0;
}

바깥만 free 하면 문자열 전부가 샌다

free(l->strs) 만 하면 포인터 배열만 없어지고, 각 문자열은 주소를 잃은 채 남는다. 되찾을 방법이 없으니 프로세스가 죽을 때까지 누수다. 안쪽부터 바깥으로 — 해제 순서의 기본 원칙. → [C] 개수 필드 + 포인터의 포인터


4. 정렬(Align)과 패딩 — 멤버 순서가 구조체 크기를 정한다

한 문장 요약 컴파일러는 각 멤버를 “자기 크기의 배수 주소”에 놓는다. 안 맞으면 빈 바이트(패딩)를 끼운다. 그래서 정렬 요구가 큰 멤버(8 → 4 → 2 → 1)부터 선언하면 패딩이 거의 사라진다.

4-1. 왜 맞춰야 하나 — CPU 가 읽는 단위

CPU 는 메모리를 바이트 단위가 아니라 워드 / 캐시라인 덩어리로 가져온다. 4바이트 값이 덩어리 경계를 걸쳐 있으면 한 번에 못 읽는다.

주소:   0   1   2   3 | 4   5   6   7 |
        ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
정렬 ○  [ uint32_t  ]                      ← 0번지: 한 번에 읽힘
정렬 ✗      [ uint32_t  ]                  ← 1번지: 두 덩어리에 걸침
CPU정렬 안 된 접근 결과
x86 / x86-64동작은 하지만 느리다 (덩어리 2번 읽기). 원자성도 보장 안 됨
ARM / MIPS / SPARC 등기종·설정에 따라 SIGBUS 로 프로세스가 죽는다

"CPU 단위"의 정확한 뜻

64비트 CPU 라고 모든 멤버를 8바이트로 맞추는 게 아니다. 규칙은 멤버 각자의 타입 크기(자연 정렬, natural alignment) 다.

주소 % alignof(타입) == 0

타입크기 = 정렬 (LP64: Linux x86-64 / ARM64)
char, uint8_t, char[N]1 (배열도 원소 기준이라 1)
uint16_t2
uint32_t, int, float, enum (보통)4
uint64_t, double, 포인터, long (Linux)8
중첩 struct그 안의 멤버 중 가장 큰 정렬

4-2. 컴파일러가 하는 일 — 패딩 두 종류

  1. 멤버 앞 패딩 (중간 패딩): 다음 멤버의 정렬 주소까지 빈 바이트로 채운다.
  2. 꼬리 패딩 (tail padding): 구조체 전체 크기를 가장 큰 멤버 정렬의 배수로 올린다. → 구조체 배열에서 두 번째 원소도 정렬되게 하려는 것이다.

4-3. 실측 예 — 같은 필드, 순서만 바꿨는데 64 → 56 바이트

✗ 규격서 표 순서 그대로 (작은 것 ↔ 큰 것 뒤섞임)

typedef struct {
    uint8_t   has_heart_beat_timer;   /* off  0, 1B                        */
                                      /*      ▒ 패딩 1B (다음이 2B 정렬)      */
    uint16_t  heart_beat_timer;       /* off  2, 2B                        */
    char      nf_id[NF_ID_SIZE];      /* off  4, 37B  (→ 41)               */
                                      /*      ▒ 패딩 3B (다음이 4B 정렬)      */
    nf_type_t nf_type;                /* off 44, 4B                        */
    uint64_t  registered_at;          /* off 48, 8B                        */
    uint8_t   load;                   /* off 56, 1B                        */
                                      /*      ▒ 꼬리 패딩 7B (8의 배수로)     */
} nf_profile_bad_t;                   /* sizeof = 64  (실데이터 53 + 패딩 11) */

○ 정렬 큰 것부터

typedef struct {
    uint64_t  registered_at;          /* off  0, 8B  ┐ 8B 그룹              */
    nf_type_t nf_type;                /* off  8, 4B  ┐ 4B 그룹              */
    uint16_t  heart_beat_timer;       /* off 12, 2B  ┐ 2B 그룹              */
    uint8_t   has_heart_beat_timer;   /* off 14, 1B  ┐ 1B 그룹              */
    uint8_t   load;                   /* off 15, 1B  ┘                     */
    char      nf_id[NF_ID_SIZE];      /* off 16, 37B (→ 53) ★ 정렬 1 이라 맨 끝 */
                                      /*      ▒ 꼬리 패딩 3B (8의 배수로)     */
} nf_profile_t;                       /* sizeof = 56  (실데이터 53 + 패딩 3)  */

위 오프셋은 손으로 계산한 값이다 (LP64, enum 4바이트 가정)

내 환경에서 확인하려면 아래 4-5 의 offsetof / _Static_assert 로 직접 찍어본다. long·포인터 크기가 다른 32비트 환경에서는 결과가 달라진다.

패딩 11B → 3B. 필드 하나도 안 건드렸는데 12% 줄었다. 이 구조체를 세션 버퍼로 수만 개 잡으면 그 차이가 그대로 메모리 비용이 된다.

4-4. 정렬 순서 규칙 (외울 것 4줄)

flowchart LR
    A["① 8바이트<br/>uint64_t / double / 포인터"] --> B["② 4바이트<br/>uint32_t / int / enum"]
    B --> C["③ 2바이트<br/>uint16_t"]
    C --> D["④ 1바이트<br/>uint8_t / char / <b>char[N]</b>"]
  1. 정렬 큰 것 → 작은 것 순으로 선언한다.
  2. char[N] 문자열 배열은 정렬 1 이므로 크기가 커도 맨 끝에 둔다. (char[37] 을 앞에 두면 뒤 멤버가 줄줄이 밀려 패딩이 생긴다)
  3. 중첩 struct 는 “그 안의 최대 정렬”로 취급해서 해당 그룹 자리에 넣는다.
  4. 같은 크기끼리 모은다. (uint8_t 플래그들은 한곳에 몰아둬도 공짜다)

"큰 것부터" 는 크기를 줄이는 규칙이지 정확성 규칙이 아니다

순서를 어겨도 컴파일러가 알아서 패딩을 넣으므로 프로그램은 정상 동작한다. 다만 메모리가 낭비되고 캐시에 덜 들어갈 뿐이다. 진짜로 위험한 경우는 패딩이 아니라 아래 4-6 (구조체를 바이트로 다루는 경우) 이다.

4-5. 눈으로 확인하는 법 — 추측하지 말고 찍어본다

#include <stddef.h>     /* offsetof */
 
/* 1) 컴파일 타임 고정: 누가 멤버를 끼워 넣어 크기가 바뀌면 빌드가 깨진다 */
_Static_assert(sizeof(nf_profile_t) == 56, "nf_profile_t size changed");
_Static_assert(offsetof(nf_profile_t, nf_id) == 16, "nf_id offset changed");
 
/* 2) 런타임으로 한 번 찍어보기 */
printf("size=%zu\n", sizeof(nf_profile_t));
printf("registered_at=%zu nf_type=%zu nf_id=%zu\n",
       offsetof(nf_profile_t, registered_at),
       offsetof(nf_profile_t, nf_type),
       offsetof(nf_profile_t, nf_id));
도구쓰는 법
gcc -Wpadded패딩이 들어간 위치마다 경고 (노이즈가 많으니 확인용으로만)
pahole ./a.out -C nf_profile_t구조체 레이아웃 + 구멍(hole) 크기를 표로 출력
sizeof / offsetof위 코드 그대로. 가장 확실하다

_Static_assert 는 C11

컴파일러가 C99 이하 모드이면 -std=gnu11 을 주거나 typedef char size_chk[(sizeof(x) == 56) ? 1 : -1]; 같은 구식 트릭을 쓴다.

4-6. 이 규칙이 통하지 않는 곳 — 순서를 바꾸면 안 되는 구조체

상황이유대응
네트워크로 나가는 구조체순서·크기가 규격/상대방과 맞아야 함구조체는 내부용으로 정렬 최적화, 전송은 pack 함수로 필드 하나씩 → 3번 문서
파일에 통째로 저장 / mmap저장 당시와 읽을 때 레이아웃이 같아야 함버전 필드 + 고정 크기 타입 + _Static_assert 로 크기 고정
공유 메모리 / IPC 로 통째 전달상대 프로세스와 같은 헤더 컴파일이어야 함헤더를 한 곳에서만 관리, 순서 변경 시 양쪽 동시 재빌드
이미 배포된 라이브러리 ABI멤버 위치가 바뀌면 기존 바이너리가 깨짐끝에만 추가, 기존 순서 유지
하드웨어 레지스터 / 프로토콜 헤더 매핑순서가 곧 규격#pragma pack(아래 경고 참고) 또는 pack 함수

#pragma pack(1) 로 패딩을 없애면 정렬 문제가 다시 살아난다

패딩을 없앤 구조체의 uint32_t 멤버는 홀수 번지에 놓일 수 있다. x86 에서는 느려지는 정도지만 ARM 등에서는 SIGBUS 다. 게다가 &p->field 로 포인터를 만들어 넘기면 컴파일러가 정렬돼 있다고 가정한 코드를 만든다. 그래서 네트워크 바이트열은 pack 함수로 한 필드씩 복사하는 쪽이 안전하다.

4-7. 앞 장의 습관과 충돌하는 곳 — 어떻게 풀까

이 문서 앞부분은 “Optional 값 바로 옆에 has_ 플래그” 와 “배열 cnt 를 바로 위에” 를 권했다. 이걸 그대로 지키면 uint8_t 와 uint16_t 가 번갈아 나와 패딩이 생긴다.

방법내용평가
(A) has_ 를 한 그룹으로 몰기값들은 크기순으로, uint8_t has_* 는 1B 그룹에 모아서 배치. 그룹 주석으로 관계를 남김★★★ 플래그 수가 적을 때
(B) 비트마스크 present 를 맨 위에uint32_t present 하나가 4B 그룹에 들어가므로 패딩 0★★★ Optional 이 많을 때 (위 2-3)
(C) 옆에 두고 패딩 감수가독성 우선구조체가 몇 개 안 될 때만
/* (A) 예 — 값은 큰 순서, 플래그는 1B 그룹으로 모은다 */
typedef struct {
    uint16_t  heart_beat_timer;   /* has_heart_beat_timer 와 짝 */
    uint16_t  priority;           /* has_priority 와 짝 */
    uint16_t  ipv4_cnt;           /* ipv4_addr 의 개수 */
 
    /* ── 1B 그룹: Optional 존재 표시 ── */
    uint8_t   has_heart_beat_timer;
    uint8_t   has_priority;
 
    char      ipv4_addr[MAX_IPV4_ADDR][IPV4_STR_SIZE];   /* 정렬 1 → 맨 끝 */
} nf_profile_t;

cnt 는 "바로 위"가 원칙이지만, char 배열이라면 정렬 문제가 없다

uint16_t cnt 바로 뒤에 char arr[N][M] 이 와도 char 는 정렬 1 이라 패딩이 안 생긴다. 정렬 문제는 cnt 뒤에 uint32_t/uint64_t 원소 배열이 올 때만이다. 그 경우에만 cnt 를 다른 2B 멤버 옆으로 옮긴다.


5. 완성된 복붙 골격

5-1. 헤더 ({{모듈}}_type.h)

/* ══════════════ {{모듈}}_type.h ══════════════ */
#ifndef {{모듈대문자}}_TYPE_H
#define {{모듈대문자}}_TYPE_H
 
#include <stdint.h>
#include "common.h"
 
/* ── ① 길이 상수: 규격서 숫자를 그대로 ─────────────── */
#define {{필드}}_LEN        {{규격서길이}}
#define {{필드}}_SIZE       ({{필드}}_LEN + 1)
#define MAX_{{배열}}_CNT    {{규격서최대개수}}
 
/* ── ② enum: UNKNOWN=0, 끝에 _END ──────────────────── */
typedef enum {
    {{ENUM}}_UNKNOWN = 0,
    {{ENUM}}_{{값1}},
    {{ENUM}}_{{값2}},
    {{ENUM}}_END
} {{enum타입}}_t;
 
const char      *{{enum타입}}_to_str({{enum타입}}_t v);
{{enum타입}}_t   {{enum타입}}_from_str(const char *s);
 
/* ── ③ 입력 구조체 ─────────────────────────────────── */
/* ★ 아래 순서는 '읽기 쉬운 순서'다. 다 짠 뒤 4장 규칙(정렬 큰 것 → 작은 것, char[] 는 맨 끝)으로 재배치하고
 *   sizeof / offsetof 를 찍어 확인한다. (전송용이면 순서를 바꿔도 pack 함수가 책임진다) */
typedef struct {
    /* Mandatory */
    char            {{필수문자열}}[{{필드}}_SIZE];
    {{enum타입}}_t  {{필수enum}};
    uint32_t        {{필수정수}};
 
    /* Optional — has_ 플래그와 값을 나란히 */
    uint8_t         has_{{선택필드}};
    uint16_t        {{선택필드}};
 
    /* 배열 — cnt 를 바로 위에 */
    uint16_t        {{배열}}_cnt;
    {{항목타입}}    {{배열}}[MAX_{{배열}}_CNT];
 
    /* Conditional ① 조건이 하나뿐 — 구조는 Optional 과 같고 validate 에서 조건을 본다 */
    uint8_t         has_{{조건필드}};
    {{조건타입}}    {{조건필드}};
 
    /* Conditional ② 배타 (정확히 하나만) — 태그 + union. 동시에 올 수 있으면 쓰지 말 것 */
    union {
        {{타입A}}_info_t  {{a}};        /* 태그 == {{TAG_A}} 일 때만 유효 */
        {{타입B}}_info_t  {{b}};        /* 여러 값을 갖는 쪽은 배열·cnt 를 자기 struct 안에 */
    } info;                             /* ★ 태그는 위쪽 {{필수enum}} 을 재사용하거나 kind 를 새로 둔다 */
} {{모듈}}_req_t;
 
/* ── ④ 출력 구조체 ─────────────────────────────────── */
typedef struct {
    uint16_t  result_code;
    char      {{출력필드}}[{{출력}}_SIZE];
} {{모듈}}_rsp_t;
 
/* ── ⑤ 생명주기 함수 (자원을 쓰면 반드시 짝으로) ───── */
void {{모듈}}_req_init({{모듈}}_req_t *r);
int  {{모듈}}_req_validate(const {{모듈}}_req_t *r);
void {{모듈}}_req_free({{모듈}}_req_t *r);      /* 동적 배열이 있을 때만 */
 
/* ── union 이 있으면 반드시 짝으로 (직접 접근 금지) ─── */
const {{타입A}}_info_t *{{모듈}}_get_{{a}}(const {{모듈}}_req_t *r);   /* 태그 다르면 NULL */
int   {{모듈}}_set_{{a}}({{모듈}}_req_t *r, const {{타입A}}_info_t *src);
 
/* ── ⑥ Optional 조작 매크로 ────────────────────────── */
#define SET_OPT(_s, _f, _v)   do { (_s)->has_##_f = 1; (_s)->_f = (_v); } while (0)
#define GET_OPT(_s, _f, _def) ((_s)->has_##_f ? (_s)->_f : (_def))
#define CLR_OPT(_s, _f)       do { (_s)->has_##_f = 0; (_s)->_f = 0; } while (0)
 
#endif /* {{모듈대문자}}_TYPE_H */

5-2. 초기화 · 검증 ({{모듈}}_type.c)

/* ══════════════ {{모듈}}_type.c ══════════════ */
#include "{{모듈}}_type.h"
#include "guard.h"
 
void {{모듈}}_req_init({{모듈}}_req_t *r)
{
    if (r == NULL) return;
    memset(r, 0x00, sizeof(*r));
    /* ★ memset(0) 만으로 끝나게 타입을 설계했다면 여기 추가 코드가 없다.
     *   이게 좋은 설계다. enum 0 = UNKNOWN, 문자열 = "", cnt = 0, has_ = 0 */
}
 
/* ★ 규격서의 "M/O" 와 "범위" 열이 그대로 코드가 된다 */
int {{모듈}}_req_validate(const {{모듈}}_req_t *r)
{
    uint16_t i;
 
    CHK_PTR(r);
 
    /* ── Mandatory 검사 ── */
    if (r->{{필수문자열}}[0] == '\0') {
        LOG_ERR("{{필수문자열}} is missing (%s/%d)", __func__, __LINE__);
        return RET_INVALID_ARG;
    }
    if (r->{{필수enum}} == {{ENUM}}_UNKNOWN || r->{{필수enum}} >= {{ENUM}}_END) {
        LOG_ERR("{{필수enum}}(%d) invalid (%s/%d)", r->{{필수enum}}, __func__, __LINE__);
        return RET_INVALID_ARG;
    }
 
    /* ── 길이 검사 ── */
    if (strlen(r->{{필수문자열}}) > {{필드}}_LEN) {
        LOG_ERR("{{필수문자열}} too long(%zu>%d) (%s/%d)",
                strlen(r->{{필수문자열}}), {{필드}}_LEN, __func__, __LINE__);
        return RET_BAD_FORMAT;
    }
 
    /* ── Optional 은 "있을 때만" 범위 검사 ── */
    if (r->has_{{선택필드}}) {
        CHK_RANGE(r->{{선택필드}}, {{최소}}, {{최대}});
    }
 
    /* ── Conditional ①: 조건이 참일 때만 필수 ── */
    if ({{조건식}}) {
        if (!r->has_{{조건필드}}) {
            LOG_ERR("{{조건필드}} required when {{조건설명}} (%s/%d)", __func__, __LINE__);
            return RET_INVALID_ARG;
        }
        /* 여기서 범위 검사 */
    } else if (r->has_{{조건필드}}) {
        LOG_WRN("{{조건필드}} ignored (%s/%d)", __func__, __LINE__);   /* 또는 거절 — 정책 */
    }
 
    /* ── Conditional ②: union 은 태그가 가리키는 멤버만 ── */
    switch (r->{{필수enum}}) {
    case {{TAG_A}}:  /* r->info.{{a}} 만 검사 */  break;
    case {{TAG_B}}:  /* r->info.{{b}} 만 검사 */  break;
    default:         break;                      /* ★ default 를 빼지 말 것 */
    }
 
    /* ── 배열: 개수 + 각 항목 ── */
    if (r->{{배열}}_cnt > MAX_{{배열}}_CNT) {
        LOG_ERR("{{배열}}_cnt(%u) exceeds max(%d) (%s/%d)",
                r->{{배열}}_cnt, MAX_{{배열}}_CNT, __func__, __LINE__);
        return RET_INVALID_ARG;
    }
    for (i = 0; i < r->{{배열}}_cnt; i++) {
        /* 각 항목 검사 */
    }
 
    return RET_OK;
}
 
void {{모듈}}_req_free({{모듈}}_req_t *r)
{
    if (r == NULL) return;
    /* 동적 할당한 멤버가 있으면 여기서 — 안쪽부터 바깥으로 */
    memset(r, 0x00, sizeof(*r));
}

validate 를 입구에서 딱 한 번 부른다

모든 함수가 모든 필드를 검사하면 코드가 검사문 천지가 된다. 외부 입력이 들어오는 지점에서 validate 한 번, 그 뒤 내부 함수들은 믿는다. 이게 1번 문서의 “경계에서만 빡세게” 원칙의 실천판이다.


6. 자주 하는 실수 모음

실수증상예방
문자열 배열에 +1 안 함마지막 글자가 잘리거나 널 종단 없음_LEN / _SIZE 이름 구분
enum 0을 유효값으로 씀초기화 안 한 필드가 유효해 보임0은 항상 _UNKNOWN
Optional 을 0으로 구분”안 보냄” 과 “0을 보냄” 혼동has_ 플래그
배열 cnt 검사 누락스택 오버플로, 보안 취약점추가 함수를 만들어 그 안에서만 쓰기
free 후 cnt 안 지움NULL 역참조list_free 함수에서 같이
문자열 배열 바깥만 free전량 누수안쪽 루프 → 바깥 순서
int 를 네트워크 구조체에플랫폼마다 크기 다름<stdint.h> 고정 크기
작은 멤버와 큰 멤버를 번갈아 선언패딩으로 구조체가 부풀어 오름 (64B → 56B 사례)정렬 큰 것 → 작은 것, char[] 는 끝 → 4장
#pragma pack(1) 로 패딩 제거ARM 등에서 SIGBUS, 포인터 취득 시 UB구조체는 정렬 유지, 전송은 pack 함수
멤버 순서를 바꿨는데 sizeof/offsetof 를 안 봄파일·공유메모리·ABI 호환이 조용히 깨짐_Static_assert 로 크기·오프셋 고정
규격서의 C 를 O 로 옮겨 적음필수여야 할 필드가 빠져도 통과한다표에 조건 열을 따로 둔다 → 2-6
동시에 올 수 있는 필드를 union 에둘째를 넣는 순간 첫째가 조용히 지워진다”정확히 하나” 일 때만 union → 2-8
태그 없는 union읽는 쪽이 어느 멤버인지 모른다태그를 항상 짝으로 → 2-10
union 멤버를 바꿀 때 memset 안 함이전 멤버 잔재가 남아 일부 오염setter 하나로 태그·memset·대입 묶기
union 을 그대로 memcpy 해서 전송안 쓰는 멤버 자리는 쓰레기pack 에서 switch (tag) 로 갈라 쓰기
구조체를 그대로 memcpy 해서 전송패딩·엔디안 때문에 상대가 못 읽음반드시 pack/unpack → 3번

구조체를 통째로 네트워크에 보내지 말 것

send(fd, &profile, sizeof(profile), 0);   /* ✗ 절대 금지 */
  1. 패딩: 컴파일러가 정렬을 위해 넣는 빈 바이트가 플랫폼마다 다르다 (→ 4장)
  2. 엔디안: x86 은 little, 네트워크는 big
  3. 타입 크기: long 이 4바이트일 수도 8바이트일 수도

세 가지 중 하나만 달라도 상대는 쓰레기를 읽는다. 반드시 필드를 하나씩 바이트 버퍼에 옮기는 pack 함수를 거친다.


7. 체크리스트

규격서를 보고 구조체를 짤 때

  • 입력 / 출력 / 에러 표 세 개를 먼저 만들었는가
  • 각 필드의 P(M/O/C) 를 표에 적었는가
  • C 인 필드는 조건 문장을 표의 조건 열에 옮겨 적었는가
  • C 가 여러 개면 동시에 올 수 있는지 따져 union / 독립 / 묶음 / 최소하나를 골랐는가
  • 정수 타입을 범위 보고 골랐는가 (int 남용 안 했는가)
  • 문자열 배열 크기가 규격 길이 + 1 인가
  • enum 의 0이 UNKNOWN 인가, 끝에 _END 가 있는가
  • enum ↔ 문자열 변환 함수를 짝으로 만들었는가
  • Optional 필드에 has_ 플래그(또는 비트마스크)가 있는가
  • union 마다 태그가 짝으로 있고, 태그 0 이 NONE 인가
  • union 접근이 getter/setter 를 거치는가 (직접 접근하는 곳이 없는가)
  • 배열마다 cnt 가 바로 위에 선언되어 있는가
  • init / validate / free 세 함수를 만들었는가
  • memset(0) 만으로 유효한 초기 상태가 되는가
  • 멤버를 정렬 큰 것(8→4→2→1) 순으로 배치했는가 (char[] 는 맨 끝)
  • sizeof / offsetof 로 패딩이 얼마나 생겼는지 확인했는가

구조체를 다 쓴 뒤

  • validate 가 규격서의 M/O/C·범위·길이를 전부 반영하는가
  • C 의 조건이 거짓인데 값이 온 경우의 정책을 정했는가 (거절 / 무시+로그)
  • union 을 보는 switch 마다 default: 가 있는가
  • 동적 할당한 멤버마다 free 가 있는가
  • free 함수가 NULL 을 받아도 안전한가
  • 네트워크로 나가는 구조체를 memcpy 로 보내지 않는가
  • 파일·공유메모리·ABI 로 노출되는 구조체는 _Static_assert 로 크기를 고정했는가

관련 노트