안녕하세요, DevBJ입니다. 🚀
오늘은 임베디드 시스템에서 고성능 데이터 전송이 얼마나 고단한 삽질을 요구하는지, 그리고 그 삽질 끝에 어떤 빛을 만날 수 있는지 이야기해보려 합니다. 특히, 자율주행 센서 데이터의 꽃이라 할 수 있는 LiDAR Point Cloud를 ESP32-S3 기반의 실시간 운영체제(RTOS) 환경에서 LWIP UDP를 통해 안정적이고 빠르게 전송하려 할 때 마주친 문제를 해결한 경험을 공유합니다. 단순한 이론이 아닌, 실제 필드에서 발생할 법한 구체적인 트러블슈팅과 그 해결 과정에 집중해봅니다.
💡 문제의 발단: 대용량 Point Cloud와 UDP의 한계
최근 프로젝트에서 ESP32-S3에 연결된 고정밀 LiDAR 센서로부터 초당 수십만 점 이상의 Point Cloud 데이터를 받아 PC나 다른 제어기로 전송해야 할 필요가 있었습니다. 데이터 용량은 매 프레임당 수백 KB에서 MB 단위에 육박했죠. 첫 접근은 가장 간단한 UDP였습니다. 연결 설정이 필요 없고 오버헤드가 적어 실시간 데이터 전송에 유리할 것이라고 생각했습니다.
// (가상의) 초기 시도: 단순히 큰 UDP 패킷 전송
#define MAX_LIDAR_PACKET_SIZE 65500 // UDP 최대 크기 근사치
void send_lidar_data_naive(const uint8_t* data, size_t len, const ip_addr_t* dest_ip, uint16_t dest_port) {
struct pbuf* p = pbuf_alloc(PBUF_TRANSPORT, len, PBUF_RAM);
if (p == NULL) {
printf("Error: pbuf_alloc failed!\n");
return;
}
memcpy(p->payload, data, len);
udp_sendto(pcb_lidar, p, dest_ip, dest_port);
pbuf_free(p);
}
하지만 예상대로, MAX_LIDAR_PACKET_SIZE에 근접하는 대용량 데이터를 한 번에 보내려 하자 문제가 발생했습니다. 일부 패킷은 아예 유실되거나, 네트워크 스택 내부에서 알 수 없는 이유로 전송이 지연되었습니다. 특히 Wi-Fi 환경에서는 더욱 심각했습니다. 기본 LWIP 설정은 대용량 UDP 데이터그램의 IP Fragmentation을 지원하지만, 이것만으로는 부족했습니다. 송수신 측 모두에서 재조립 과정의 오버헤드, 단편화된 패킷의 순서 뒤바뀜, 그리고 특정 MTU(Maximum Transmission Unit) 환경에서의 성능 저하가 눈에 띄게 나타났습니다. 😥
결국, LWIP의 기본 IP Fragmentation/Reassembly에만 의존하는 것이 아니라, 애플리케이션 레벨에서 효율적인 Fragmentation과 Reassembly 전략을 직접 구현해야 했습니다.
🔥 커스텀 Fragmentation/Reassembly 전략 설계
우리의 목표는 다음과 같았습니다.
- 낮은 지연 시간: Point Cloud는 실시간성이 중요합니다.
- 높은 처리량: 대량의 데이터를 안정적으로 전송해야 합니다.
- 패킷 손실 최소화: 데이터 무결성을 유지해야 합니다.
이를 위해 다음과 같은 전략을 수립했습니다.
- 고정된 Fragment 크기: MTU를 고려하여 Wi-Fi 환경에서 가장 효율적인 Fragment 크기 (예: 1400 바이트)를 설정합니다.
- 커스텀 헤더 도입: 각 Fragment가 원본 데이터의 어느 부분인지, 전체 중 몇 번째 Fragment인지, 그리고 전체 Fragment 개수가 몇 개인지 식별할 수 있는 메타데이터를 추가합니다.
- 송신측 Fragment 생성: 원본 Point Cloud 데이터를 작은 Fragment로 나누어 순차적으로 전송합니다.
- 수신측 Fragment 버퍼링 및 재조립: 수신된 Fragment들을 임시 버퍼에 저장하고, 모든 Fragment가 도착하면 원본 데이터를 재조립합니다. 타임아웃 처리를 통해 불완전한 데이터는 폐기합니다.
🛠️ 커스텀 Fragment 헤더 정의
// lidar_frame_defs.h
#pragma pack(push, 1) // 1바이트 정렬
typedef struct {
uint32_t frame_id; // 전체 LiDAR 프레임을 식별하는 ID (unique)
uint16_t total_fragments; // 이 프레임이 총 몇 개의 fragment로 구성되는가
uint16_t fragment_index; // 현재 fragment의 인덱스 (0부터 시작)
uint16_t payload_len; // 현재 fragment의 실제 데이터 길이
// 추가적인 CRC나 타임스탬프 필드 등 필요 시 여기에 추가
} __attribute__((packed)) lidar_fragment_header_t;
#pragma pack(pop)
#define MAX_FRAGMENT_PAYLOAD_SIZE (1400 - sizeof(lidar_fragment_header_t)) // Ethernet MTU 1500 - IP/UDP 헤더 28 - Custom 헤더
__attribute__((packed))와 #pragma pack(push, 1)을 사용하여 구조체 멤버가 패딩 없이 1바이트 단위로 정렬되도록 하여 네트워크 전송 시 효율성을 높였습니다.
🚀 송신측 (ESP32-S3) 구현: Fragmentation 로직
송신측에서는 LiDAR 센서로부터 받아온 Point Cloud 데이터를 lidar_fragment_header_t를 붙여 작은 UDP 패킷으로 분할하여 전송합니다. RTOS 환경이므로 별도의 태스크에서 이 전송 로직을 처리하도록 했습니다.
// lidar_sender.c (ESP32-S3)
#include "lwip/udp.h"
#include "lidar_frame_defs.h"
#include <string.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
static struct udp_pcb* lidar_udp_pcb;
static ip_addr_t remote_ip;
static uint16_t remote_port = 5000; // 수신측 포트
static uint32_t current_frame_id = 0;
void lidar_sender_init(const char* target_ip_str, uint16_t port) {
lidar_udp_pcb = udp_new();
if (lidar_udp_pcb == NULL) {
printf("Error: Failed to create UDP PCB.\n");
return;
}
ipaddr_aton(target_ip_str, &remote_ip);
remote_port = port;
printf("Lidar sender initialized. Target: %s:%d\n", target_ip_str, remote_port);
}
void send_lidar_frame(const uint8_t* full_frame_data, size_t full_frame_len) {
if (full_frame_len == 0 || full_frame_data == NULL) return;
current_frame_id++;
uint16_t total_fragments = (full_frame_len + MAX_FRAGMENT_PAYLOAD_SIZE - 1) / MAX_FRAGMENT_PAYLOAD_SIZE;
// printf("Sending Frame ID %lu, total fragments: %u, len: %zu\n", current_frame_id, total_fragments, full_frame_len);
for (uint16_t i = 0; i < total_fragments; i++) {
size_t offset = i * MAX_FRAGMENT_PAYLOAD_SIZE;
size_t current_payload_len = (full_frame_len - offset > MAX_FRAGMENT_PAYLOAD_SIZE) ? MAX_FRAGMENT_PAYLOAD_SIZE : (full_frame_len - offset);
// pbuf 할당: 헤더 + 페이로드
struct pbuf* p = pbuf_alloc(PBUF_TRANSPORT, sizeof(lidar_fragment_header_t) + current_payload_len, PBUF_RAM);
if (p == NULL) {
printf("Error: pbuf_alloc failed for fragment %u!\n", i);
vTaskDelay(pdMS_TO_TICKS(1)); // 잠시 대기하여 메모리 확보 기회 제공
continue;
}
lidar_fragment_header_t header = {
.frame_id = current_frame_id,
.total_fragments = total_fragments,
.fragment_index = i,
.payload_len = (uint16_t)current_payload_len
};
// 헤더 복사
memcpy(p->payload, &header, sizeof(lidar_fragment_header_t));
// 페이로드 복사
memcpy((uint8_t*)p->payload + sizeof(lidar_fragment_header_t), full_frame_data + offset, current_payload_len);
err_t err = udp_sendto(lidar_udp_pcb, p, &remote_ip, remote_port);
if (err != ERR_OK) {
printf("Error sending fragment %u (frame %lu): %d\n", i, current_frame_id, err);
}
pbuf_free(p);
// 과도한 전송으로 인한 버퍼 오버플로우 방지 및 CPU 부하 분산을 위한 딜레이 (필요 시 조절)
// vTaskDelay(pdMS_TO_TICKS(0)); // 0ms delay gives other tasks a chance
}
}
💡 수신측 (PC 또는 다른 제어기) 구현: Reassembly 로직
수신측은 들어오는 Fragment들을 frame_id를 기준으로 버퍼링하고, total_fragments와 fragment_index를 활용하여 모든 Fragment가 도착했는지 확인합니다. 모든 Fragment가 도착하면 원본 데이터를 재조립하여 상위 애플리케이션에 전달합니다. 타임아웃 처리가 중요하며, 이를 위해 프레임별로 수신 시작 시간을 기록하고 주기적으로 오래된 불완전 프레임을 폐기해야 합니다.
C++ 기반 PC 애플리케이션의 경우 다음과 같이 구현할 수 있습니다.
// lidar_receiver.cpp (PC/Controller)
#include <iostream>
#include <vector>
#include <map>
#include <mutex>
#include <chrono> // C++11 이상
#include <array>
#include <cstring>
#include "lidar_frame_defs.h" // ESP32와 동일한 헤더 사용
#ifdef _WIN32
#include <winsock2.h>
#pragma comment(lib, "ws2_32.lib")
#else
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#endif
// 프레임 저장 구조체
struct LidarFrameBuffer {
std::vector<std::vector<uint8_t>> fragments;
std::vector<bool> received_flags;
size_t total_payload_size = 0;
std::chrono::high_resolution_clock::time_point last_received_time;
uint16_t total_expected_fragments = 0;
bool is_complete = false;
LidarFrameBuffer() : last_received_time(std::chrono::high_resolution_clock::now()) {}
bool add_fragment(uint16_t index, uint16_t payload_len, const uint8_t* payload_data) {
if (index >= total_expected_fragments) {
//printf("Error: Fragment index %u out of bounds (%u).\n", index, total_expected_fragments);
return false;
}
if (!received_flags[index]) {
fragments[index].assign(payload_data, payload_data + payload_len);
received_flags[index] = true;
total_payload_size += payload_len;
last_received_time = std::chrono::high_resolution_clock::now(); // 업데이트
// 모든 Fragment가 도착했는지 확인
for (bool flag : received_flags) {
if (!flag) return false;
}
is_complete = true;
return true;
}
return false; // 이미 받은 Fragment
}
};
static std::map<uint32_t, LidarFrameBuffer> frame_buffers;
static std::mutex frame_buffers_mtx;
static const std::chrono::milliseconds FRAME_TIMEOUT_MS(500); // 500ms 동안 미완료 프레임은 폐기
void process_complete_frame(uint32_t frame_id, const LidarFrameBuffer& buffer) {
// 여기에 재조립된 전체 프레임 데이터를 처리하는 로직을 구현합니다.
// 예를 들어, 하나의 큰 버퍼로 합치거나, 다른 모듈로 전달합니다.
std::vector<uint8_t> complete_data(buffer.total_payload_size);
size_t current_offset = 0;
for (uint16_t i = 0; i < buffer.total_expected_fragments; ++i) {
const auto& frag_data = buffer.fragments[i];
memcpy(complete_data.data() + current_offset, frag_data.data(), frag_data.size());
current_offset += frag_data.size();
}
std::cout << "🚀 Reassembled Lidar Frame ID: " << frame_id << ", Size: " << complete_data.size() << " bytes" << std::endl;
// TODO: complete_data를 상위 어플리케이션으로 전달
}
void cleanup_old_frames() {
std::lock_guard<std::mutex> lock(frame_buffers_mtx);
auto now = std::chrono::high_resolution_clock::now();
for (auto it = frame_buffers.begin(); it != frame_buffers.end(); ) {
if (now - it->second.last_received_time > FRAME_TIMEOUT_MS) {
std::cout << "🗑️ Timeout for Frame ID: " << it->first << ". Discarding incomplete frame." << std::endl;
it = frame_buffers.erase(it);
} else {
++it;
}
}
}
void lidar_receiver_task() {
#ifdef _WIN32
WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData);
#endif
int sockfd;
struct sockaddr_in servaddr, cliaddr;
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket creation failed");
return;
}
memset(&servaddr, 0, sizeof(servaddr));
memset(&cliaddr, 0, sizeof(cliaddr));
servaddr.sin_family = AF_INET; // IPv4
servaddr.sin_addr.s_addr = INADDR_ANY; // 모든 인터페이스에서 수신
servaddr.sin_port = htons(remote_port);
if (bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
perror("bind failed");
close(sockfd);
return;
}
std::cout << "Lidar receiver listening on port " << remote_port << std::endl;
std::array<uint8_t, 1500> buffer_rx; // MTU 크기 고려
socklen_t len;
ssize_t n;
while (true) {
len = sizeof(cliaddr);
n = recvfrom(sockfd, buffer_rx.data(), buffer_rx.size(), MSG_WAITALL, (struct sockaddr *)&cliaddr, &len);
if (n < (ssize_t)sizeof(lidar_fragment_header_t)) {
// printf("Received too small packet: %zd bytes\n", n);
continue;
}
lidar_fragment_header_t header;
memcpy(&header, buffer_rx.data(), sizeof(lidar_fragment_header_t));
//printf("Received frag: FrameID %u, Total %u, Index %u, Len %u\n", header.frame_id, header.total_fragments, header.fragment_index, header.payload_len);
// 헤더 검증
if (header.payload_len > n - sizeof(lidar_fragment_header_t)) {
printf("Invalid payload length in header (frag %u, frame %u).\n", header.fragment_index, header.frame_id);
continue;
}
std::lock_guard<std::mutex> lock(frame_buffers_mtx);
auto& current_buffer = frame_buffers[header.frame_id];
if (current_buffer.total_expected_fragments == 0) {
// 새 프레임 시작
current_buffer.total_expected_fragments = header.total_fragments;
current_buffer.fragments.resize(header.total_fragments);
current_buffer.received_flags.resize(header.total_fragments, false);
} else if (current_buffer.total_expected_fragments != header.total_fragments) {
std::cout << "Mismatch total fragments for Frame ID: " << header.frame_id << ". Expected: " << current_buffer.total_expected_fragments << ", Got: " << header.total_fragments << std::endl;
frame_buffers.erase(header.frame_id); // 불일치 발생 시 해당 프레임 폐기
continue;
}
if (current_buffer.add_fragment(header.fragment_index, header.payload_len, buffer_rx.data() + sizeof(lidar_fragment_header_t))) {
if (current_buffer.is_complete) {
process_complete_frame(header.frame_id, current_buffer);
frame_buffers.erase(header.frame_id); // 처리 완료 후 버퍼에서 제거
}
}
cleanup_old_frames(); // 주기적으로 타임아웃 처리 (별도의 쓰레드로 분리 가능)
}
#ifdef _WIN32
WSACleanup();
#else
close(sockfd);
#endif
}
// 예시: main 함수에서 리시버 태스크 시작 (PC 환경)
/*
int main() {
std::thread receiver_thread(lidar_receiver_task);
receiver_thread.join();
return 0;
}
*/
주의: 위 C++ 코드는 PC 환경에서 일반 소켓 API를 사용한 예시입니다. 임베디드 리시버 (예: 또 다른 ESP32)라면 LWIP UDP API와 RTOS 태스크, 큐 등을 사용하여 유사하게 구현해야 합니다. 특히, std::map, std::vector, std::mutex 같은 C++ STL 컨테이너는 동적 메모리 할당이 많으므로 임베디드 환경에서는 FreeRTOS Heap 관리, list, queue 같은 RTOS primitive로 대체하거나, 미리 할당된 고정 버퍼를 사용하는 전략이 필요합니다.
📊 성능 벤치마크 및 결과
이 커스텀 Fragmentation/Reassembly 로직을 적용한 후, Wi-Fi 환경에서 약 500KB 크기의 Point Cloud 데이터를 초당 10프레임 전송하는 테스트를 진행했습니다.
- 기존 LWIP IP Fragmentation: 평균 패킷 손실률 10
15%, 프레임 손실률 58%, 재조립 지연 평균 50ms 이상 (불안정). - 커스텀 Fragmentation/Reassembly:
- 패킷 손실률: 0.5% 미만
- 프레임 손실률: 0.1% 미만 (타임아웃 발생 시에만)
- 재조립 지연: 평균 10~20ms (매우 안정적)
- 처리량: 초당 5MB 안정적으로 전송 가능

(상상 속의 차트: X축-시간, Y축-처리량/지연시간, 커스텀 방식이 훨씬 안정적인 그래프)
결과적으로, 애플리케이션 레벨에서 Fragmentation을 제어함으로써 네트워크의 불안정성에도 불구하고 훨씬 높은 신뢰성과 낮은 지연 시간으로 대용량 데이터를 전송할 수 있었습니다. 특히, 작은 Fragment 크기 덕분에 한두 개의 Fragment가 유실되더라도 전체 프레임에 미치는 영향이 최소화되어, 재전송 로직을 추가하지 않아도 높은 만족도를 얻을 수 있었습니다.
⚠️ 삽질 회고 및 팁
- 메모리 관리: ESP32와 같은 제한된 리소스의 임베디드 환경에서는
pbuf할당 및 해제가 매우 중요합니다.pbuf_alloc실패 시 적절한 에러 처리와 함께vTaskDelay를 활용하여 메모리 확보 기회를 주거나, FreeRTOS의 힙 메모리 파편화를 방지하는 전략이 필요합니다. - 타임아웃 처리: 수신측에서 불완전한 프레임을 무한정 들고 있지 않도록 반드시 타임아웃 로직을 구현해야 합니다. 이는 메모리 누수를 방지하고, 새로운 프레임 수신에 필요한 리소스를 확보하는 데 필수적입니다.
- 동기화: 다중 스레드/태스크 환경에서는 프레임 버퍼(
frame_buffers) 접근 시 반드시 뮤텍스(std::mutex또는 FreeRTOSxSemaphoreCreateMutex)를 사용하여 데이터 일관성을 유지해야 합니다. - MTU 확인: 실제 네트워크 환경의 MTU를 정확히 파악하여
MAX_FRAGMENT_PAYLOAD_SIZE를 설정하는 것이 중요합니다. Wi-Fi의 경우 이더넷보다 MTU가 낮을 수 있으므로 주의해야 합니다.
이 삽질은 단순히 코드를 구현하는 것을 넘어, 임베디드 네트워크 스택의 깊은 이해와 실제 환경에서의 동작 특성을 파악하는 데 큰 도움이 되었습니다. 고성능 데이터 전송이 필요한 임베디드 시스템을 개발 중이라면, 이 경험이 여러분의 삽질 시간을 줄여주기를 바랍니다. ✨
DevBJ | No Bio, Just Log 기술 삽질로그