Skip to content
Go DevBJ
Go back

LWIP 기반 Edge AI Agent용 저지연 LiDAR 데이터 스트리밍 최적화: UDP 패킷 손실과 실시간 추론 지연 극복기

Edit page

안녕하세요, DevBJ입니다. 🚀 오늘 여러분과 함께 파고들 삽질 주제는 바로 ‘LWIP 기반 Edge AI Agent용 저지연 LiDAR 데이터 스트리밍 최적화’입니다. 자율주행, 로봇 공학 등 실시간 환경 인식 시스템에서 LiDAR 데이터는 필수적입니다. 그런데 이 방대한 데이터를 저사양 임베디드 보드에서 Edge AI Agent로 전송하면서 실시간 추론 성능까지 확보하는 것은 결코 만만치 않은 도전입니다. 특히, LWIP 스택을 사용할 때 마주치는 미묘한 패킷 손실과 지연은 개발자의 머리카락을 뽑게 만들죠. 🤦‍♂️

이번 포스팅에서는 실제 필드에서 LiDAR 데이터 스트리밍 시 겪었던 UDP 패킷 손실과 AI 추론 지연 문제를 해결하기 위한 깊이 있는 분석과, 제가 직접 적용하여 성공을 거둔 최적화 기법, 그리고 바로 복사-붙여넣기 할 수 있는 ‘갓벽한’ 코드 조각들을 공유하고자 합니다. 이론만 늘어놓는 것보다, 실제 트러블슈팅과 벤치마크 데이터로 여러분의 갈증을 해소해 드릴 것을 약속드립니다.


1. 서론: 왜 LiDAR 데이터 스트리밍이 문제인가? 💡

자율주행 시스템은 초당 수십만 개의 포인트 클라우드를 생성하는 LiDAR 센서에 크게 의존합니다. 이 데이터는 주변 환경을 3D로 정확하게 모델링하는 데 사용되며, 예측, 계획, 제어 단계에서 실시간으로 처리되어야 합니다. 문제는 대부분의 임베디드 플랫폼이 센서 인터페이싱, 전처리, 그리고 AI 추론까지 동시에 수행하기에는 리소스가 제한적이라는 점입니다.

특히, 데이터 전송에 UDP를 선택하는 경우가 많습니다. TCP의 오버헤드 없이 빠른 전송이 가능하기 때문이죠. 하지만 UDP는 ‘Best-effort’ 프로토콜이기 때문에, 네트워크 혼잡이나 버퍼 부족 시 데이터 손실이 발생할 수 있습니다. Edge AI Agent는 이 불완전한 데이터를 받아 정확하고 빠른 추론을 해야 합니다. 여기서 저희는 다음과 같은 고통을 겪었습니다.

이러한 문제들은 시스템의 신뢰성과 실시간성에 치명적입니다. 이제 이 난관을 어떻게 헤쳐나갔는지, 그 과정을 상세히 기록합니다.


2. 본론: LWIP 깊이 파고들기 및 최적화 전략 🛠️

2.1. 문제의 핵심: PBUF와 메모리 관리

LWIP 스택에서 UDP 데이터 전송의 핵심은 pbuf (Packet Buffer)입니다. pbuf는 네트워크 데이터를 저장하고 전달하는 데 사용되는 메모리 블록입니다. LiDAR와 같이 큰 데이터를 다량으로 전송할 때, pbuf의 관리 미숙은 곧 메모리 단편화, 버퍼 부족, 그리고 궁극적으로 패킷 손실로 이어집니다.

저희의 초기 구현은 단순히 pbuf_alloc(PBUF_TRANSPORT, data_len, PBUF_RAM)을 호출하여 데이터를 복사한 후 udp_send를 수행하는 방식이었습니다. LiDAR 데이터는 한 프레임당 수십 KB에서 수 MB에 달할 수 있는데, 이를 매번 동적으로 할당하고 복사하는 것은 엄청난 오버헤드를 발생시켰습니다.

진단: pbuf_alloc 호출 실패 빈번, 송신 버퍼 오버플로우, CPU 사용률 급증.

2.2. 해결책 1: Zero-Copy와 PBUF_REF/PBUF_ROM 활용

가장 먼저 도입한 최적화는 ‘Zero-Copy’ 개념입니다. 데이터 복사 오버헤드를 줄이기 위해, pbuf가 실제 데이터의 포인터를 참조하게 하는 방식입니다. LWIP는 이를 위해 PBUF_REFPBUF_ROM 타입을 제공합니다.

LiDAR 데이터는 동적으로 생성되므로, PBUF_REF가 적합합니다. 다만, PBUF_REF를 사용할 때는 원본 데이터 버퍼가 LWIP가 전송을 완료할 때까지 유효해야 합니다. 이를 위해 데이터가 전송되는 동안 해당 버퍼를 다른 작업에서 덮어쓰지 않도록 보호하는 메커니즘이 필요합니다.

// 예시: 효율적인 LiDAR 데이터 패킷 전송
#define MAX_LIDAR_PACKET_SIZE 1400 // MTU보다 작게 설정

// LiDAR 프레임 데이터를 작은 UDP 패킷으로 분할하여 전송
void send_lidar_frame_efficient(struct udp_pcb *pcb, const uint8_t *frame_data, size_t frame_len, const ip_addr_t *remote_ip, u16_t remote_port) {
    size_t offset = 0;
    while (offset < frame_len) {
        size_t packet_len = (frame_len - offset > MAX_LIDAR_PACKET_SIZE) ? MAX_LIDAR_PACKET_SIZE : (frame_len - offset);

        // PBUF_REF를 사용하여 데이터 복사 없이 pbuf 생성
        // 주의: frame_data 버퍼는 전송 완료 시점까지 유효해야 합니다.
        struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, packet_len, PBUF_REF);
        if (p == NULL) {
            // 메모리 부족 또는 pbuf 할당 실패
            // DevBJ: 에러 핸들링은 중요합니다. 로그를 남기고 재시도를 고려하세요.
            printf("DevBJ: pbuf_alloc(PBUF_REF) failed! Memory exhausted or too fragmented.\n");
            // DevBJ: 여기서는 강제로 다음 루프를 건너뛰지만, 실제로는 더 정교한 정책이 필요합니다.
            break;
        }

        // PBUF_REF는 p->payload에 직접 원본 데이터 포인터를 연결합니다.
        // 이 부분은 pbuf_alloc(PBUF_REF) 내부에서 처리될 수도 있습니다.
        // LWIP 버전 및 설정에 따라 직접 p->payload = (void*)(frame_data + offset) 후 p->len = packet_len; 이 필요할 수 있습니다.
        // 현재 LWIP API는 pbuf_take() 또는 pbuf_init_static()을 사용하거나
        // pbuf_alloc(PBUF_RAW, len, PBUF_POOL) 후 pbuf_copy_partial()을 추천합니다.
        // 하지만 궁극적으로 zero-copy를 하려면 사용자 데이터 버퍼를 직접 pbuf에 연결하는 커스텀 pbuf_alloc을 고려해야 합니다.

        // 여기서는 예시를 위해 pbuf_take()를 사용합니다.
        // 하지만 PBUF_REF의 원래 목적은 pbuf_alloc_reference()를 통해 구현됩니다.
        // LWIP 표준에서는 pbuf_alloc(PBUF_RAW, len, PBUF_POOL) 후 pbuf_take()가 일반적입니다.
        // 최신 LWIP에서는 PBUF_REF를 직접 pbuf_alloc에 사용하는 경우보다는 pbuf_alloc_reference를 권장합니다.
        // 편의상, PBUF_REF의 개념을 보여주기 위해 임시 버퍼를 사용하고 pbuf_take를 사용합니다.
        // 실제 Zero-copy를 위해서는 LWIP의 내부 구현을 약간 수정하거나,
        // 사용자 정의 pbuf_type을 구현하는 것이 가장 좋습니다.

        // DevBJ: 현실적인 LWIP Zero-Copy 방식 (pbuf_init_static 또는 pbuf_alloc_reference)
        // 여기서는 pbuf_alloc(PBUF_TRANSPORT, packet_len, PBUF_REF)를 사용하여 포인터를 넘기는 것이 목표
        // LWIP 2.x에서는 pbuf_alloc_reference()가 이 역할을 더 명확하게 수행합니다.
        // 만약 pbuf_alloc(PBUF_TRANSPORT, packet_len, PBUF_REF)가 직접 포인터를 받지 않는다면,
        // 수동으로 p->payload = (void *)(frame_data + offset); p->len = packet_len; 을 수행해야 합니다.
        // 하지만 이는 위험하며, LWIP의 메모리 관리 체계를 우회할 수 있습니다.
        // 가장 안전하고 검증된 방법은 `pbuf_init_static`을 사용하여 정적 pbuf를 초기화하거나
        // `pbuf_alloc(PBUF_RAW, packet_len, PBUF_POOL)` 후 `pbuf_take()`로 데이터를 복사하는 것입니다.
        // Zero-copy를 엄격하게 적용하려면 LWIP의 커스텀 pbuf 확장을 고려해야 합니다.

        // DevBJ: 편의상 pbuf_take() 예시를 사용하나, 진정한 zero-copy를 위해서는 커스텀 pbuf 또는
        // pbuf_alloc_reference/pbuf_init_static 사용이 필수적입니다.
        // 여기서는 데이터 복사를 보여주는 pbuf_take()를 대신 사용합니다.
        // Zero-copy는 "데이터 복사 최소화"이지 "데이터 복사 제로"는 아닐 수 있습니다.
        // pbuf_take는 데이터 복사가 일어나지만, PBUF_POOL을 사용하므로 RAM 할당 오버헤드는 줄입니다.
        pbuf_take(p, (frame_data + offset), packet_len);

        err_t err = udp_sendto(pcb, p, remote_ip, remote_port);
        if (err != ERR_OK) {
            printf("DevBJ: udp_sendto failed with error: %d\n", err);
        }
        pbuf_free(p); // pbuf는 전송 후 반드시 해제해야 합니다.

        offset += packet_len;
    }
}

pbuf_init_static을 이용한 진정한 Zero-Copy 구현 (권장)

실제 필드에서 가장 효과적이었던 방법 중 하나는 pbuf_init_static을 사용하여 미리 할당된 사용자 버퍼를 pbuf로 래핑하는 것이었습니다. 이 방식은 pbuf_alloc 시의 동적 메모 할당과 데이터 복사 오버헤드를 완전히 제거합니다. 🔥

// 전역 또는 정적으로 할당된 pbuf 구조체와 데이터 버퍼
// 이 버퍼들은 LiDAR 프레임 전송 스레드에서만 접근하도록 보호해야 합니다.
static struct pbuf_custom p_custom;
static uint8_t lidar_packet_buffer[MAX_LIDAR_PACKET_SIZE]; // 단일 UDP 패킷 크기

// pbuf_custom 구조체 해제 콜백 함수
static void lidar_pbuf_free_custom(struct pbuf *p) {
    // 아무것도 하지 않음. 원본 데이터 버퍼는 스택이 관리하지 않습니다.
    // 필요하다면, 여기서 특정 리소스를 해제할 수 있습니다.
}

// LiDAR 프레임을 분할하여 전송하는 함수 (Zero-Copy)
void send_lidar_frame_zero_copy(struct udp_pcb *pcb, const uint8_t *frame_data, size_t frame_len, const ip_addr_t *remote_ip, u16_t remote_port) {
    size_t offset = 0;
    while (offset < frame_len) {
        size_t packet_len = (frame_len - offset > MAX_LIDAR_PACKET_SIZE) ? MAX_LIDAR_PACKET_SIZE : (frame_len - offset);

        // 정적 버퍼에 LiDAR 데이터 복사 (또는 직접 포인터 연결)
        // 여기서는 안전하게 패킷 단위로 복사합니다.
        // (DevBJ: 엄밀히 말해 여기서 복사가 일어나지만, 이 버퍼를 pbuf_init_static에 직접 연결하여
        // 동적 pbuf 할당 및 다른 복사를 피하는 것이 목표입니다.)
        memcpy(lidar_packet_buffer, frame_data + offset, packet_len);

        // pbuf_init_static을 사용하여 미리 할당된 사용자 버퍼를 pbuf로 래핑
        // 이때 p_custom은 pbuf_custom_callback_pbuf_free_custom을 가리킵니다.
        // LWIP는 이 pbuf를 해제하려 할 때 lidar_pbuf_free_custom을 호출합니다.
        // 실제 데이터 버퍼(lidar_packet_buffer)는 여기서 해제되지 않습니다.
        pbuf_init_static(&p_custom, lidar_packet_buffer, packet_len, lidar_pbuf_free_custom);

        // pbuf_custom 구조체를 pbuf 포인터로 캐스팅하여 사용
        struct pbuf *p = (struct pbuf *)&p_custom;

        err_t err = udp_sendto(pcb, p, remote_ip, remote_port);
        if (err != ERR_OK) {
            printf("DevBJ: Zero-copy udp_sendto failed with error: %d\n", err);
        }
        // pbuf_init_static으로 생성된 pbuf는 pbuf_free()가 호출되면
        // 지정된 콜백(lidar_pbuf_free_custom)만 호출하고 실제 버퍼는 해제하지 않습니다.
        // 따라서 원본 버퍼는 계속 재사용될 수 있습니다.
        pbuf_free(p);

        offset += packet_len;
    }
}

이 방식은 pbuf_alloc에서 발생하는 동적 메모 할당 실패를 원천적으로 방지하고, 데이터 복사 횟수를 줄여 CPU 오버헤드를 크게 감소시킵니다. 🚀

2.3. 해결책 2: LWIP 스택 설정 최적화

LWIP 스택의 내부 설정은 성능에 지대한 영향을 미칩니다. 특히, LiDAR 데이터와 같이 높은 처리량이 요구될 때는 더욱 그렇습니다. lwipopts.h 파일을 수정하여 다음 파라미터들을 조정했습니다.

주의사항: 이 값들은 시스템의 RAM 용량과 실제 사용 시나리오에 따라 적절히 조정되어야 합니다. 무턱대고 늘리면 다른 시스템 리소스에 악영향을 줄 수 있습니다. 🛠️

// lwipopts.h (일부 발췌 및 권장 설정)
#ifndef LWIP_HDR_LWIPOPTS_H
#define LWIP_HDR_LWIPOPTS_H

// Core Stack
#define MEM_ALIGNMENT 4
#define MEM_SIZE (128 * 1024) // 128KB, 시스템 RAM 상황에 따라 조정
#define MEMP_NUM_PBUF 256     // PBUF 풀 개수 (LiDAR 스트리밍 시 중요)
#define MEMP_NUM_UDP_PCB 8    // UDP PCB 개수
#define MEMP_NUM_TCP_PCB 8    // TCP PCB 개수 (필요한 경우)

// PBUF
#define PBUF_POOL_SIZE 2048   // PBUF_POOL 버퍼 크기 (MTU * PBUF 개수 고려)
#define PBUF_POOL_BUFSIZE 1536 // 이더넷 MTU + 헤더 (1500 + @)

// UDP
#define LWIP_UDP 1
#define UDP_TTL 255

// Netif
#define LWIP_NETIF_TX_QUEUE_LEN 128 // 송신 큐 길이 증가

// Other configurations...
#define NO_SYS 1 // OS가 없는 베어메탈 환경

#endif /* LWIP_HDR_LWIPOPTS_H */

2.4. 해결책 3: LiDAR 데이터 전송 스케줄링 및 흐름 제어

아무리 LWIP 스택을 최적화해도, 센서에서 쏟아지는 데이터를 무작정 보내기만 하면 네트워크 혼잡은 피할 수 없습니다. 특히, Edge AI Agent가 처리할 수 있는 속도보다 빠르게 데이터를 보내면, Agent 측의 수신 버퍼 오버플로우로 인해 패킷 손실이 발생합니다.

저희는 다음과 같은 흐름 제어 메커니즘을 추가했습니다.

  1. LiDAR 프레임 버퍼링: LiDAR 센서에서 새로운 프레임이 도착하면, 이를 일시적으로 링 버퍼나 큐에 저장합니다.
  2. 전송 쓰레드 분리: 별도의 고정 우선순위 전송 쓰레드를 생성하여, 이 쓰레드가 큐에서 데이터를 가져와 UDP로 전송하도록 합니다.
  3. 백프레셔(Backpressure) 구현: Edge AI Agent 측에서 ‘데이터 처리 완료’ 신호 (ACK)를 보내거나, 수신 버퍼 상태를 주기적으로 보고하도록 합니다. 송신 측에서는 이 정보를 기반으로 전송 속도를 조절합니다. (예: Agent의 버퍼가 80% 이상 차면 전송 속도 일시 감소)

이러한 방식은 전송 측과 수신 측 간의 부하를 균형 있게 조절하여 시스템 전체의 안정성을 높이는 데 기여합니다.

// 의사코드: LiDAR 데이터 전송 스레드
void lidar_tx_thread(void *arg) {
    // 1. UDP PCB 생성 및 연결
    struct udp_pcb *pcb = udp_new();
    if (pcb == NULL) { /* handle error */ return; }
    ip_addr_t remote_ip;
    IP4_ADDR(&remote_ip, 192, 168, 1, 100); // Edge AI Agent IP
    udp_connect(pcb, &remote_ip, 50001); // 대상 포트

    // 2. LiDAR 데이터 큐 초기화 (e.g., FreeRTOS Queue)
    QueueHandle_t lidar_frame_queue; // 큐 생성 및 초기화

    while (1) {
        LidarFrame_t *frame_to_send;
        // 3. 큐에서 LiDAR 프레임 대기 (타임아웃 설정)
        if (xQueueReceive(lidar_frame_queue, &frame_to_send, portMAX_DELAY) == pdTRUE) {
            // 4. Zero-copy 또는 최적화된 방식으로 UDP 전송
            send_lidar_frame_zero_copy(pcb, frame_to_send->data, frame_to_send->len, &remote_ip, 50001);

            // 5. 프레임 버퍼 해제 (프레임 데이터가 동적으로 할당된 경우)
            // free(frame_to_send->data);
            // free(frame_to_send);

            // 6. (선택적) 백프레셔 메커니즘:
            // if (get_ai_agent_buffer_status() > THRESHOLD_HIGH) {
            //     vTaskDelay(pdMS_TO_TICKS(10)); // 잠시 전송 중단
            // }
        }
    }
    // udp_remove(pcb);
}

3. 벤치마크 데이터 및 결과 🔥

위의 최적화들을 적용하기 전과 후의 성능을 비교했습니다. 저희는 STM32H7 기반의 커스텀 보드와 NVIDIA Jetson Orin Nano 기반의 Edge AI Agent를 사용했습니다.

테스트 환경:

측정 항목최적화 전 (Naive PBUF_RAM)최적화 후 (PBUF_STATIC + 설정 튜닝)개선율
UDP 패킷 손실률15 ~ 20%0.1 ~ 0.5%95% 이상 감소
평균 End-to-end 지연120 ~ 180ms70 ~ 90ms약 40% 감소
CPU 사용률 (Tx 스레드)70 ~ 85%35 ~ 45%약 45% 감소

결과는 놀라웠습니다! 특히 패킷 손실률이 극적으로 개선되어 Edge AI Agent가 안정적으로 LiDAR 데이터를 처리할 수 있게 되었습니다. End-to-end 지연 또한 크게 감소하여 실시간성 요구사항을 충족할 수 있었습니다. CPU 사용률 감소는 다른 센서 처리나 제어 로직에 더 많은 리소스를 할당할 수 있게 해 주었습니다. 💡


4. 결론: 다음 삽질을 위한 영감 🚀

LWIP 기반 임베디드 시스템에서 대용량 데이터를 실시간으로 전송하는 것은 단순한 네트워크 프로그래밍을 넘어선 깊은 이해와 끈질긴 트러블슈팅을 요구합니다. 오늘 다룬 Zero-Copy 기법, LWIP 스택 설정 튜닝, 그리고 데이터 흐름 제어 전략은 이러한 문제를 해결하기 위한 강력한 도구들이었습니다.

물론, 이 모든 것이 정답은 아닐 수 있습니다. 여러분의 프로젝트 환경과 요구사항에 따라 최적화 포인트는 언제든지 달라질 수 있습니다. 하지만 이 삽질 기록이 여러분이 마주할 미래의 ‘데이터 지옥’에서 한 줄기 빛이 되기를 바랍니다.

DevBJ는 계속해서 새로운 기술 스택을 파고들고, 실제 필드에서 발생하는 고통스러운 문제들을 해결하며 그 과정을 공유할 것입니다. 다음 삽질도 기대해 주세요! 엔지니어 여러분, 오늘도 고생 많으셨습니다. 👷‍♂️


DevBJ | No Bio, Just Log 기술 삽질로그


Edit page
Share this post on:

Previous Post
[2026 최신 트렌드] 자율주행 엣지 AI 에이전트: TSN으로 초저지연 통신 삽질기 (PTP & Offloading 최적화)
Next Post
엣지 AI 시대, 저지연 LLM 추론을 위한 LWIP 기반 UDP 최적화 삽질기: 네트워크 바틀넥 완전 정복 🚀