메모리 제약이 없는 시스템에서 굳이 비트 단위의 '구조체 조각내기'에 몰두하는 행위. 어떤 이는 이를 '성급한 최적화'라 비판하지만, 저는 이것이야말로 프로그래머의 지적 유희가 빚어낸 가장 순수한 형태의 예술이라 봅니다.

비록 수신 비트의 위치를 바꾸고 'not_received'로 로직을 반전시키는 과정이 실용적 가치보다 자기만족에 가깝다는 점은 부정할 수 없겠군요. 하지만 시스템 아키텍처의 빈틈을 파고들어 12KiB를 4KiB로 압축하는 과정에서 얻는 그 짜릿한 희열은, 그 자체로 고도로 추상화된 두뇌의 운동입니다.

"왜 이렇게까지 하지?"라는 의문이 들 때가 바로 우리가 엔지니어링의 본질과 마주하는 순간이죠. 비록 벤치마크조차 하지 않은 실험일지라도, 최적화의 미로를 탐험하는 과정에서 발견하는 그 사소한 효율성의 조각들이야말로 우리가 코드를 사랑하는 이유 아닐까요? 때로는 실용성보다 '재미'라는 목적지 자체가 훨씬 더 중요할 때가 있는 법입니다.

Original News: 때로는 성급한 최적화도 재미있다 (2025) [원본 링크]
연결성 모니터링 시스템의 ICMP Echo Request 기록 구조체를 줄이는 과정에서 링 버퍼 메모리 사용량이 12KiB에서 4KiB로 감소함
sent_ns와 received_ns를 모두 저장하지 않고 수신 후에는 지연 시간만 남기도록 공용체를 쓰자 배열 크기가 8KiB로 줄어듦
나노초 정밀도 대신 100마이크로초 단위를 쓰고 received를 비트필드로 바꿨지만, 구조체 패딩 때문에 추가 절감은 발생하지 않음
소스 주소 대신 ICMP identifier의 일부 의미를 4비트 카운터로 대체하면서 구조체가 8바이트로 줄고 512개 배열이 4KiB가 됨
애플리케이션은 메모리 제약이 없었기 때문에 실용적 필요는 없었지만, 필드 배치와 비트 접근 비용까지 따지는 최적화 실험이 됨


문제 설정: ping 기록을 저장하는 방식

연결성 모니터링 시스템은 여러 서버에 ICMP Echo Request를 보내고, 1분·5분·15분 구간의 지연 시간과 패킷 손실 평균을 관찰함
처음 떠올린 저장 방식은 512개 엔트리의 링 버퍼이며, 각 엔트리는 송신 시각, 수신 시각, 소스 주소, 시퀀스 번호, 수신 여부를 담음
초기 구조체 배열 pings_rb[512]의 크기는 12KiB로 측정됨

struct ping_timestamp {
uint64_t sent_ns;
uint64_t received_ns;
in_addr_t source_addr;
uint16_t seq_no;
bool received;
};

첫 번째 절감: 송신 시각과 경과 시간을 공용체로 통합

실제로 남기고 싶은 값은 수신 이후의 received - sent 지연 시간이므로, 송신 시각과 경과 시간을 동시에 보관할 필요가 없음
sent_ts와 elapsed_ts를 공용체로 묶은 구조체는 같은 슬롯을 송신 전에는 송신 시각으로, 수신 후에는 경과 시간으로 사용함
이 변경 뒤 512개 배열 크기는 12KiB에서 8KiB로 줄어듦

struct ping_timestamp_2 {
union {
uint64_t sent_ts;
uint64_t elapsed_ts;
};
in_addr_t source_addr;
uint16_t seq_no;
bool received;
};

두 번째 시도: 정밀도 축소와 비트필드

ping 시간은 수십·수백·수천 밀리초 단위로 측정되므로 나노초 정밀도를 모두 저장할 필요가 없음
시간 단위를 100마이크로초, 즉 0.1ms 단위로 바꾸면 43비트로 최대 20년 동안의 ping 추적이 가능함
received의 참·거짓 값에 8비트를 쓰는 것은 과하므로 비트필드를 적용함
하지만 ping_timestamp_3의 배열 크기도 8KiB로 유지되어 추가 절감이 발생하지 않음

struct ping_timestamp_3 {
uint64_t sent_or_elapsed_ts: 43;
uint64_t received: 1;
uint64_t seq_no: 16;
in_addr_t source_addr;
};

구조체 패딩 때문에 줄어들지 않은 크기

ping_timestamp_2는 마지막에 정렬 요구사항을 맞추기 위한 패딩 바이트가 붙음
ping_timestamp_3는 첫 8바이트에 시간, 수신 여부, 시퀀스 번호를 넣지만, 뒤에 소스 주소와 패딩이 남음
비트필드를 적용했어도 36비트의 패딩이 남아 구조체 전체 크기가 줄지 않음
단순히 bool을 비트로 줄이는 것만으로는 메모리 배치와 정렬 문제를 해결하지 못함

소스 주소 제거와 4비트 카운터

제품이 모바일 데이터 네트워크에서 동작하는 동안 소스 주소가 자주 바뀌기 때문에 기존 구조체는 소스 주소를 보관함
주소가 바뀔 때 시퀀스 번호도 재설정되며, 과거에는 서로 다른 소스 주소와 같은 시퀀스 번호를 가진 패킷이 동시에 처리된 적이 있음
ICMP Echo Request에는 애플리케이션이 자신이 보낸 패킷을 식별할 수 있는 16비트 identifier 필드가 있음
전체 16비트를 모두 쓸 필요가 없으므로, 남는 4비트를 소스 주소 변경 시 증가하는 롤링 카운터로 사용함
이 카운터는 애플리케이션의 다른 위치에서 감시되는 소스 주소 변경에 맞춰 증가함

struct ping_timestamp {
uint64_t elapsed_or_sent_ts : 43;
uint64_t received : 1;
uint64_t counter: 4;
uint64_t seq_no: 16;
};

최종 결과와 필드 배치

최종 구조체는 소스 주소 필드를 제거하고 64비트 안에 시간, 수신 여부, 카운터, 시퀀스 번호를 담음
512개 링 버퍼 배열 크기는 4KiB가 되어 한 페이지 데이터로 줄어듦
초기 12KiB 대비 총 8KiB를 절감함
필드 순서는 seq_no가 16비트 경계에 맞도록 조정되어, 로드 시 시프트 없이 단일 ldrh 명령으로 읽을 수 있음
elapsed_or_sent_ts를 읽을 때는 마스크만 필요함

추가 최적화: 수신 비트 접근 비용 줄이기

2025-06-21 추가 내용에서는 received와 counter의 순서를 바꾸면 received 비트 접근이 시프트와 마스크 대신 시프트만 필요함
이 변경은 received 접근을 더 싸게 만들지만, counter를 읽을 때 received 비트를 마스크로 제거해야 하는 비용을 만듦
2025-06-22 추가 내용에서는 counter를 received가 참일 때만 읽는 조건을 이용함
received의 의미를 뒤집어 not_received로 두면, not_received가 0인지 확인한 조건 내부에서 counter 마스크가 컴파일러에 의해 완전히 제거됨

struct ping_timestamp {
uint64_t elapsed_or_sent_ts : 43;
uint64_t counter: 4;
uint64_t not_received : 1;
uint64_t seq_no: 16;
};

결론

최적화 결과는 메모리 사용량을 12KiB에서 4KiB로 줄였지만, 애플리케이션 자체는 메모리 제약을 받지 않음
실제 필요성과 별개로 구조체 레이아웃, 패딩, 비트필드, 명령어 수준 접근 비용을 따져보는 실험이 됨
마지막 주석에서는 “문제”라는 표현도 느슨하게 쓴 것이며, 벤치마크조차 하지 않았다고 밝힘
연결성 모니터링 시스템의 ICMP Echo Request 기록 구조체를 줄이는 과정에서 링 버퍼 메모리 사용량이 12KiB에서 4KiB로 감소함
sent_ns와 received_ns를 모두 저장하지 않고 수신 후에는 지연 시간만 남기도록 공용체를 쓰자 배열 크기가 8KiB로 줄어듦
나노초 정밀도 대신 100마이크로초 단위를 쓰고 received를 비트필드로 바꿨지만, 구조체 패딩 때문에 추가 절감은 발생하지 않음
소스 주소 대신 ICMP identifier의 일부 의미를 4비트 카운터로 대체하면서 구조체가 8바이트로 줄고 512개 배열이 4KiB가 됨
애플리케이션은 메모리 제약이 없었기 때문에 실용적 필요는 없었지만, 필드 배치와 비트 접근 비용까지 따지는 최적화 실험이 됨


문제 설정: ping 기록을 저장하는 방식

연결성 모니터링 시스템은 여러 서버에 ICMP Echo Request를 보내고, 1분·5분·15분 구간의 지연 시간과 패킷 손실 평균을 관찰함
처음 떠올린 저장 방식은 512개 엔트리의 링 버퍼이며, 각 엔트리는 송신 시각, 수신 시각, 소스 주소, 시퀀스 번호, 수신 여부를 담음
초기 구조체 배열 pings_rb[512]의 크기는 12KiB로 측정됨

struct ping_timestamp {
uint64_t sent_ns;
uint64_t received_ns;
in_addr_t source_addr;
uint16_t seq_no;
bool received;
};

첫 번째 절감: 송신 시각과 경과 시간을 공용체로 통합

실제로 남기고 싶은 값은 수신 이후의 received - sent 지연 시간이므로, 송신 시각과 경과 시간을 동시에 보관할 필요가 없음
sent_ts와 elapsed_ts를 공용체로 묶은 구조체는 같은 슬롯을 송신 전에는 송신 시각으로, 수신 후에는 경과 시간으로 사용함
이 변경 뒤 512개 배열 크기는 12KiB에서 8KiB로 줄어듦

struct ping_timestamp_2 {
union {
uint64_t sent_ts;
uint64_t elapsed_ts;
};
in_addr_t source_addr;
uint16_t seq_no;
bool received;
};

두 번째 시도: 정밀도 축소와 비트필드

ping 시간은 수십·수백·수천 밀리초 단위로 측정되므로 나노초 정밀도를 모두 저장할 필요가 없음
시간 단위를 100마이크로초, 즉 0.1ms 단위로 바꾸면 43비트로 최대 20년 동안의 ping 추적이 가능함
received의 참·거짓 값에 8비트를 쓰는 것은 과하므로 비트필드를 적용함
하지만 ping_timestamp_3의 배열 크기도 8KiB로 유지되어 추가 절감이 발생하지 않음

struct ping_timestamp_3 {
uint64_t sent_or_elapsed_ts: 43;
uint64_t received: 1;
uint64_t seq_no: 16;
in_addr_t source_addr;
};

구조체 패딩 때문에 줄어들지 않은 크기

ping_timestamp_2는 마지막에 정렬 요구사항을 맞추기 위한 패딩 바이트가 붙음
ping_timestamp_3는 첫 8바이트에 시간, 수신 여부, 시퀀스 번호를 넣지만, 뒤에 소스 주소와 패딩이 남음
비트필드를 적용했어도 36비트의 패딩이 남아 구조체 전체 크기가 줄지 않음
단순히 bool을 비트로 줄이는 것만으로는 메모리 배치와 정렬 문제를 해결하지 못함

소스 주소 제거와 4비트 카운터

제품이 모바일 데이터 네트워크에서 동작하는 동안 소스 주소가 자주 바뀌기 때문에 기존 구조체는 소스 주소를 보관함
주소가 바뀔 때 시퀀스 번호도 재설정되며, 과거에는 서로 다른 소스 주소와 같은 시퀀스 번호를 가진 패킷이 동시에 처리된 적이 있음
ICMP Echo Request에는 애플리케이션이 자신이 보낸 패킷을 식별할 수 있는 16비트 identifier 필드가 있음
전체 16비트를 모두 쓸 필요가 없으므로, 남는 4비트를 소스 주소 변경 시 증가하는 롤링 카운터로 사용함
이 카운터는 애플리케이션의 다른 위치에서 감시되는 소스 주소 변경에 맞춰 증가함

struct ping_timestamp {
uint64_t elapsed_or_sent_ts : 43;
uint64_t received : 1;
uint64_t counter: 4;
uint64_t seq_no: 16;
};

최종 결과와 필드 배치

최종 구조체는 소스 주소 필드를 제거하고 64비트 안에 시간, 수신 여부, 카운터, 시퀀스 번호를 담음
512개 링 버퍼 배열 크기는 4KiB가 되어 한 페이지 데이터로 줄어듦
초기 12KiB 대비 총 8KiB를 절감함
필드 순서는 seq_no가 16비트 경계에 맞도록 조정되어, 로드 시 시프트 없이 단일 ldrh 명령으로 읽을 수 있음
elapsed_or_sent_ts를 읽을 때는 마스크만 필요함

추가 최적화: 수신 비트 접근 비용 줄이기

2025-06-21 추가 내용에서는 received와 counter의 순서를 바꾸면 received 비트 접근이 시프트와 마스크 대신 시프트만 필요함
이 변경은 received 접근을 더 싸게 만들지만, counter를 읽을 때 received 비트를 마스크로 제거해야 하는 비용을 만듦
2025-06-22 추가 내용에서는 counter를 received가 참일 때만 읽는 조건을 이용함
received의 의미를 뒤집어 not_received로 두면, not_received가 0인지 확인한 조건 내부에서 counter 마스크가 컴파일러에 의해 완전히 제거됨

struct ping_timestamp {
uint64_t elapsed_or_sent_ts : 43;
uint64_t counter: 4;
uint64_t not_received : 1;
uint64_t seq_no: 16;
};

결론

최적화 결과는 메모리 사용량을 12KiB에서 4KiB로 줄였지만, 애플리케이션 자체는 메모리 제약을 받지 않음
실제 필요성과 별개로 구조체 레이아웃, 패딩, 비트필드, 명령어 수준 접근 비용을 따져보는 실험이 됨
마지막 주석에서는 “문제”라는 표현도 느슨하게 쓴 것이며, 벤치마크조차 하지 않았다고 밝힘