서론
현대의 통신은 대부분 비동기 통신 형태로 동작합니다. 이때 메시지를 임시로 담아둘 공간이 필요하고, 반복적으로 넣고(Enqueue) 빼는(Dequeue) 작업이 발생합니다. 이런 상황에서 순환 자료구조인 링버퍼를 사용하면 효과적으로 해결할 수 있습니다.
여기서는 배열 기반(캐시 친화적) 구조를 사용하고, 포인터로 Front, Rear를 가리키는 방식으로 다루겠습니다.
또한 TCP 통신의 경우 데이터를 임의로 잘라서 전달하기 때문에 수신 측에서는 데이터가 완성될 때까지 버퍼링이 필요합니다.
L4 계층의 송수신 버퍼가 가득 차면 패킷 송수신이 불가능해지므로 빠르게 비워줘야 하고, recv/send는 시스템 콜(syscall)을 호출하는 고비용 작업이기 때문에 메시지를 모아두었다가 한 번에 처리하는 방식도 장점이 될 수 있습니다.
링버퍼의 구조
링버퍼는 원형 큐와 유사하게 맨 앞과 맨 뒤가 연결될 수 있도록 만든 원형 버퍼입니다.
네트워크 메시지 처리에 사용하는 버퍼이므로, 바이트 단위로 넣고/빼는 인터페이스도 필수로 가져야 합니다.
Full / Empty 구분 문제
front 포인터와 rear 포인터가 같을 때는 일반적으로 “비어있다”고 판단합니다.
하지만 링버퍼 크기가 100일 때 100개 데이터가 꽉 차 있는 상태에서도 front == rear가 될 수 있어 Empty와 Full이 구분되지 않는 문제가 발생합니다.
이를 isFull 같은 변수로 해결할 수도 있지만, 멀티스레드 환경에서 해당 변수를 읽는 타이밍 문제가 생길 수 있습니다.
그래서 이 구현에서는 실제 할당을 Size + 1로 하고, 다음 규칙으로 판정합니다.
- rear + 1 == front → Full
- front == rear → Empty
즉, 항상 1칸을 비워두는 방식으로 Full/Empty를 안정적으로 구분합니다.
복사 횟수 개선
MoveFront, MoveRear, Peek 같은 동작에서 데이터 복사가 불필요하게 늘어날 수 있습니다.
이를 줄이기 위해 recv()/send()에서 직접 버퍼 포인터에 쓰고 읽을 수 있도록 GetFront(), GetRear() 같은 인터페이스를 제공하면 성능을 개선할 수 있습니다.
- GetRear()로 쓰기 위치를 직접 받아 recv()로 채우고
- MoveRear(n)로 “쓰기 완료” 처리
- GetFront()로 읽기 위치를 직접 받아 send()로 전송
- MoveFront(n)로 “소비 완료” 처리
멀티스레드 SPSC(Single Producer Single Consumer) 환경
이 링버퍼는 멀티스레드 SPSC 환경에서 문제 없이 동작할 수 있습니다.
생산자(Producer)는 데이터를 넣을 때 현재 넣을 수 있는 공간(Free) 을 확인하고 rear에만 쓰기 작업을 수행합니다.
소비자(Consumer)는 데이터를 꺼낼 때 현재 사용 중인 데이터(Used) 를 확인하고 front만 이동합니다.
즉, 설령 순간적으로 크기 판단이 어긋나더라도 동일 위치에 대한 “동시 쓰기”가 발생할 여지가 낮고, 게임 서버/클라 구조상 SPSC가 깨질 일이 없다면 락 없이 운영하는 선택을 할 수 있습니다.
RingBuffer.h
#pragma once
#include <cstddef>
static constexpr std::size_t RING_DEFAULT_SIZE = 5000;
// 고성능 송수신용 Ring Buffer 클래스, 부분 Enqueue/Dequeue 미지원(요청 크기 미만이면 실패).
// 내부 버퍼는 (mCapacity + 1)로 할당하며, Full/Empty 구분을 위해 항상 1칸을 비움.
class RingBuffer
{
public:
// 기본 생성자.
RingBuffer();
explicit RingBuffer(std::size_t bufferSize);
~RingBuffer() noexcept;
// 끊기지 않고 한 번에 Enqueue 가능한 연속 공간 크기를 반환
// @return 연속적으로 쓸 수 있는 공간 크기
std::size_t GetDirectEnqueueSize() const;
// 끊기지 않고 한 번에 Dequeue 가능한 연속 데이터 크기를 반환
// @return 연속적으로 읽을 수 있는 데이터 크기
std::size_t GetDirectDequeueSize() const;
void ClearBuffer() noexcept;
bool IsFull() const noexcept;
std::size_t GetBufferSize() const noexcept { return mCapacity; }
std::size_t GetUseSize() const noexcept;
std::size_t GetFreeSize() const noexcept { return mCapacity - GetUseSize(); }
bool IsEmpty() const noexcept { return mReadWindow == mWriteWindow; }
// Rear 위치에 데이터를 삽입
bool Enqueue(const char* data, std::size_t size) noexcept;
// Front 위치에서 데이터를 반환
bool Dequeue(char* outData, std::size_t size) noexcept;
// Front 위치에서 데이터를 읽기
bool Peek(char* outData, std::size_t size) noexcept;
// Front 포인터를 앞으로 이동시켜 데이터를 소비(제거)한다.
bool MoveFront(std::size_t moveSize) noexcept;
char* GetFront() const noexcept;
// Rear 포인터를 앞으로 이동시켜 "쓰기 완료" 영역을 확장한다.
bool MoveRear(std::size_t moveSize) noexcept;
char* GetRear() const noexcept;
private:
// 복사&이동 금지
RingBuffer(RingBuffer&&) = delete;
RingBuffer& operator=(RingBuffer&&) = delete;
RingBuffer(const RingBuffer&) = delete;
RingBuffer& operator=(const RingBuffer&) = delete;
// 포인터 멤버 변수 이동에 사용
void MovePointer(char*& pointer, std::size_t addValue);
// Dequeue, Peek 구현
bool ReadInternal(char* outData, std::size_t size, bool isPeekMode) noexcept;
private:
char* mBuffer; // 실제 버퍼 시작 주소
char* mEnd; // 마지막 유효 칸(= &mBuffer[mCapacity])
char* mReadWindow; // 현재 읽기 시작 위치
char* mWriteWindow; // 현재 쓰기 시작 위치
std::size_t mCapacity; // 전체 버퍼 크기(논리 capacity = mCapacity)
};
RingBuffer.cpp
#include "RingBuffer.h"
#include <cstring>
RingBuffer::RingBuffer() : RingBuffer(RING_DEFAULT_SIZE)
{
}
RingBuffer::RingBuffer(std::size_t bufferSize)
: mBuffer(nullptr)
, mEnd(nullptr)
, mReadWindow(nullptr)
, mWriteWindow(nullptr)
, mCapacity(bufferSize)
{
// 예외 방어: 0 크기 방지
if (mCapacity == 0)
{
mCapacity = RING_DEFAULT_SIZE;
}
// +1: FULL/EMPTY 구분을 위해 "항상 비워두는 칸"을 포함한 실제 할당 크기
mBuffer = new char[mCapacity + 1];
mEnd = mBuffer + mCapacity;
mReadWindow = mBuffer;
mWriteWindow = mBuffer;
}
RingBuffer::~RingBuffer() noexcept
{
delete[] mBuffer;
mBuffer = nullptr;
mEnd = nullptr;
mReadWindow = nullptr;
mWriteWindow = nullptr;
mCapacity = 0;
}
std::size_t RingBuffer::GetUseSize() const noexcept
{
// write/read window의 상대 위치로 사용량 계산 (mBufferEnd는 inclusive)
if (mWriteWindow >= mReadWindow)
{
return static_cast<std::size_t>(mWriteWindow - mReadWindow);
}
// wrap: [readWindow..end] + [start..writeWindow)
return static_cast<std::size_t>((mEnd - mReadWindow) + 1 + (mWriteWindow - mBuffer));
}
std::size_t RingBuffer::GetDirectEnqueueSize() const
{
// [주의] "한 칸 비워두기" 규칙 때문에,
// read window가 start에 있을 때는 end 칸을 쓰면 FULL 판정이 애매해질 수 있어
// 일부 케이스에서 end까지 못 쓰도록 제한한다.
// read window가 start라면 "맨 끝 칸"은 사용 불가로 보는 규칙
if (mReadWindow == mBuffer)
{
// write window ~ end 직전까지만
return static_cast<std::size_t>(mEnd - mWriteWindow);
}
else if (mReadWindow > mWriteWindow)
{
return static_cast<std::size_t>(mReadWindow - mWriteWindow - 1);
}
else
{
// write window부터 end까지는 연속으로 쓸 수 있음 (end 포함)
return static_cast<std::size_t>(mEnd - mWriteWindow + 1);
}
return 0;
}
std::size_t RingBuffer::GetDirectDequeueSize() const
{
if (mReadWindow > mWriteWindow)
{
// read window~end 까지는 연속 구간
return static_cast<std::size_t>((mEnd - mReadWindow) + 1);
}
return static_cast<std::size_t>(mWriteWindow - mReadWindow);
}
void RingBuffer::MovePointer(char*& pointer, std::size_t addValue)
{
const std::size_t len = mCapacity + 1; // 실제 할당 길이
std::size_t idx = static_cast<std::size_t>(pointer - mBuffer); // 0..mCapacity
idx += addValue;
idx %= len;
pointer = mBuffer + idx;
}
// ----------------------------------------------------------------------------
// 포인터만 이동해서 "논리적으로" 데이터 제거/추가하는 함수
// (실제로 memcpy는 안 함)
// ----------------------------------------------------------------------------
bool RingBuffer::MoveFront(std::size_t moveSize) noexcept
{
// 예외 처리
if (IsEmpty() || moveSize == 0 || moveSize > mCapacity)
return false;
// 실제 사용량보다 더 빼려 하면 실패
const std::size_t used = GetUseSize();
if (moveSize > used)
return false;
MovePointer(mReadWindow, moveSize);
return true;
}
char* RingBuffer::GetFront() const noexcept
{
return mReadWindow;
}
bool RingBuffer::MoveRear(std::size_t moveSize) noexcept
{
// 예외 처리
if (IsFull() || moveSize == 0 || moveSize > mCapacity)
return false;
// 남은 공간보다 더 넣으려 하면 실패
const std::size_t freeSize = GetFreeSize();
if (moveSize > freeSize)
return false;
MovePointer(mWriteWindow, moveSize);
return true;
}
char* RingBuffer::GetRear() const noexcept
{
return mWriteWindow;
}
void RingBuffer::ClearBuffer() noexcept
{
mWriteWindow = mBuffer;
mReadWindow = mBuffer;
}
// ----------------------------------------------------------------------------
// 상태 판정
// ----------------------------------------------------------------------------
bool RingBuffer::IsFull() const noexcept
{
// write window가 end이고 read window가 start이면 "다음 칸"이 start라서 full
if (mWriteWindow == mEnd && mReadWindow == mBuffer)
return true;
if (mWriteWindow + 1 == mReadWindow)
return true;
return false;
}
// ----------------------------------------------------------------------------
// Enqueue / Dequeue
// ----------------------------------------------------------------------------
bool RingBuffer::Enqueue(const char* data, std::size_t size) noexcept
{
// 예외 처리
if (data == nullptr || size == 0 || size > mCapacity)
return false;
// 공간이 부족하면 실패
if (size > GetFreeSize())
return false;
const std::size_t direct = GetDirectEnqueueSize();
// requestSize가 "연속으로 쓸 수 있는 크기"를 넘으면 split해서 2번 memcpy
if (size > direct)
{
const std::size_t firstSize = direct;
const std::size_t secondSize = size - firstSize;
if (firstSize > 0)
std::memcpy(mWriteWindow, data, firstSize);
std::memcpy(mBuffer, data + firstSize, secondSize);
}
else
{
std::memcpy(mWriteWindow, data, size);
}
MovePointer(mWriteWindow, size);
return true;
}
bool RingBuffer::Dequeue(char* outData, std::size_t size) noexcept
{
return ReadInternal(outData, size, false);
}
bool RingBuffer::Peek(char* outData, std::size_t size) noexcept
{
return ReadInternal(outData, size, true);
}
// 내부 공통 처리: 복사는 항상 수행, isPeekMode=false일 때만 front 이동
bool RingBuffer::ReadInternal(char* outData, std::size_t size, bool isPeekMode) noexcept
{
// 예외 처리
if (outData == nullptr || size == 0 || size > mCapacity)
return false;
if (GetUseSize() < size)
return false;
// read window부터 끊기지 않고 읽을 수 있는 연속 구간
const std::size_t direct = GetDirectDequeueSize();
// 요청 크기가 연속 구간을 넘으면 split copy
if (size > direct)
{
const std::size_t firstSize = direct;
const std::size_t secondSize = size - firstSize;
if (firstSize > 0)
std::memcpy(outData, mReadWindow, firstSize);
std::memcpy(outData + firstSize, mBuffer, secondSize);
}
else
{
std::memcpy(outData, mReadWindow, size);
}
// Peek가 아니면 실제로 소모(read window 이동)
if (!isPeekMode)
{
MovePointer(mReadWindow, size); // wrap 포함 이동
}
return true;
}'TIL(Today I Learn) > TIL' 카테고리의 다른 글
| 바이브 코딩 중인 내가 쓰는 Agent Skills - 260611 (0) | 2026.06.02 |
|---|---|
| 구조체 패킷의 한계와 직렬화 버퍼(Serialization Buffer)가 필요한 이유 (0) | 2026.02.19 |
| 메모리 풀과 프리리스트 – 고정 크기 객체의 Alloc/Free를 빠르게 만드는 방법 (0) | 2026.02.19 |
| 컴파일 시간을 크게 줄여주는 사전 컴파일된 헤더, PCH ( Visual C++ ) (0) | 2026.02.18 |
| NHN 코딩테스트 후기 (0) | 2023.08.12 |