Published on

C++23 std - -mdspan로 버퍼 수명·소유권 오류 잡기

Authors

서로 다른 팀/모듈이 같은 버퍼를 주고받는 C++ 코드에서 가장 자주 터지는 문제는 “이 포인터 누가 소유하지?”와 “언제까지 유효하지?”입니다. 특히 이미지/텐서/행렬처럼 2D 이상 데이터를 다룰 때는 T* + 폭/높이 + stride + offset 조합이 늘어나면서, 버퍼 경계(out-of-bounds)와 유스애프터프리(use-after-free) 가능성이 급격히 커집니다.

C++23의 std::mdspan은 이런 문제를 완전히 해결해 주지는 않지만(소유권은 여전히 별도), 다음을 통해 오류를 더 빨리, 더 명확하게 잡게 도와줍니다.

  • 다차원 인덱싱을 타입/레이아웃/extent로 모델링해 “어떤 형태의 버퍼인지”를 API에 드러냄
  • stride/레이아웃을 표준화된 방식으로 표현해, 잘못된 인덱싱을 줄임
  • “뷰(view)만 전달한다”는 의도를 강제해, 소유권을 별도 타입(예: std::vector, std::unique_ptr, std::shared_ptr, 풀 할당자)로 분리하도록 유도

아래에서는 std::mdspan으로 버퍼 소유권·수명 오류를 잡는 설계 패턴을 중심으로, 실전 코드 예제를 정리합니다.

std::mdspan 한 줄 정의: 소유하지 않는 다차원 뷰

std::mdspan은 기본적으로 다음 두 가지를 결합합니다.

  • 데이터 포인터(또는 접근자)
  • 다차원 인덱싱 규칙(크기 extents, 레이아웃 layout_*)

중요한 점은 mdspan 자체는 메모리를 소유하지 않는다는 것입니다. 즉, mdspanstd::span의 다차원 버전 같은 성격이며, 수명 관리는 별도의 소유자(owner)가 맡아야 합니다.

이 특성 덕분에 “이 함수는 버퍼를 소유하지 않는다”가 API 레벨에서 드러나고, 반대로 소유권을 숨긴 채 포인터를 돌려쓰는 코드가 줄어듭니다.

왜 raw pointer + stride API가 수명 버그를 키우나

전형적인 C 스타일 API를 떠올려 봅시다.

void blur_u8(
    std::uint8_t* data,
    int width,
    int height,
    int stride_bytes
);

이 API는 호출자에게 너무 많은 “암묵 지식”을 요구합니다.

  • data가 최소 height * stride_bytes 만큼 유효해야 함
  • stride_byteswidth와 픽셀 크기 관계를 만족해야 함
  • 함수가 data를 보관하지 않는지(비동기 작업이 없는지)
  • data가 aliasing 되는지(같은 버퍼를 다른 뷰로 동시에 쓰는지)

이 중 수명 문제는 특히 심각합니다. 예를 들어 비동기 작업으로 넘겼는데, 호출자가 std::vector를 지역 변수로 만들고 바로 반환하면 data는 곧바로 댕글링이 됩니다.

mdspan은 비동기 자체를 막지는 못하지만, “뷰”라는 사실을 더 명확히 드러내고, 소유자 타입을 분리하도록 설계를 유도합니다.

기본 예제: 2D 이미지 뷰를 mdspan으로 받기

2D 그레이스케일 이미지를 예로 들면, 다음처럼 받을 수 있습니다.

#include <mdspan>
#include <cstdint>

using image_view = std::mdspan<
    std::uint8_t,
    std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>,
    std::layout_right
>;

void invert(image_view img) {
    for (std::size_t y = 0; y < img.extent(0); ++y) {
        for (std::size_t x = 0; x < img.extent(1); ++x) {
            img(y, x) = static_cast<std::uint8_t>(255 - img(y, x));
        }
    }
}

여기서 얻는 이점은 다음과 같습니다.

  • 함수 시그니처만 봐도 “2차원 버퍼를 (y, x)로 접근한다”가 보임
  • extent(0), extent(1)로 경계를 명시적으로 사용
  • layout_right를 통해 메모리 레이아웃(마지막 인덱스가 연속)을 표현

단, 이 예제는 “연속(contiguous) 2D”를 전제합니다. 실제 영상 처리에서는 stride가 존재하므로, 다음 섹션의 패턴이 더 중요합니다.

stride 있는 버퍼: layout_stride로 안전하게 모델링

카메라 프레임, BMP/PNG 디코딩 결과, GPU에서 내려온 버퍼 등은 흔히 행마다 padding이 들어가 stride_byteswidth보다 큽니다.

이때 mdspanstd::layout_stride를 쓰면, “이 뷰는 이런 stride로 해석해야 한다”를 타입과 생성 시점에 고정할 수 있습니다.

#include <mdspan>
#include <cstdint>
#include <vector>

using ext2d = std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>;

using stride_layout = std::layout_stride;
using stride_mapping = stride_layout::mapping<ext2d>;

using image_view_stride = std::mdspan<std::uint8_t, ext2d, stride_layout>;

image_view_stride make_image_view(
    std::uint8_t* data,
    std::size_t height,
    std::size_t width,
    std::size_t stride_bytes
) {
    // 2D에서 (y, x) 오프셋 = y * stride + x
    stride_mapping map(ext2d(height, width), std::array<std::size_t, 2>{stride_bytes, 1});
    return image_view_stride(data, map);
}

void zero_border(image_view_stride img) {
    const auto h = img.extent(0);
    const auto w = img.extent(1);

    for (std::size_t x = 0; x < w; ++x) {
        img(0, x) = 0;
        img(h - 1, x) = 0;
    }
    for (std::size_t y = 0; y < h; ++y) {
        img(y, 0) = 0;
        img(y, w - 1) = 0;
    }
}

이 패턴의 핵심은 “stride를 따로 int로 들고 다니며 매번 계산”하는 대신, 뷰 생성 시점에 stride를 매핑으로 고정한다는 점입니다. 결과적으로 다음 종류의 버그를 줄입니다.

  • stride 단위(바이트/원소) 혼동
  • widthstride를 뒤집는 실수
  • 포인터 산술 중 오버플로/부호 문제(int로 받는 관행)

소유권과 수명: owner + mdspan view로 분리 설계

mdspan은 소유하지 않으므로, “누가 버퍼를 잡고 있나”를 명확히 해야 합니다. 실무에서 가장 안전한 패턴은 다음입니다.

  • 소유자: std::vector<T> 또는 std::unique_ptr<T[]> 같은 RAII 타입
  • 접근: mdspan 뷰를 즉시 생성해서 함수에 전달
  • 뷰를 장기 보관해야 한다면: 뷰가 아니라 소유자와 함께 묶은 타입을 사용

패턴 A: 호출자 소유, 함수는 뷰만 사용

#include <mdspan>
#include <vector>

using mat = std::mdspan<float, std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>>;

void scale(mat m, float s) {
    for (std::size_t i = 0; i < m.extent(0); ++i)
        for (std::size_t j = 0; j < m.extent(1); ++j)
            m(i, j) *= s;
}

int main() {
    std::size_t r = 3, c = 4;
    std::vector<float> buf(r * c);

    mat m(buf.data(), r, c);
    scale(m, 0.5f);
}

이 구조에서는 수명 버그가 생기려면, 호출자가 buf를 먼저 파괴한 뒤 m을 쓰는 식의 명백한 실수를 해야 합니다. 즉, 뷰가 “가볍고 즉시 사용되는 값 타입”이라는 관례가 자리 잡으면 위험이 줄어듭니다.

패턴 B: 뷰를 저장해야 한다면 owner를 함께 저장

비동기 파이프라인, 캐시, 작업 큐 등에선 뷰를 저장하고 싶어집니다. 이때 mdspan만 저장하면 수명 버그가 매우 쉽게 발생합니다.

struct image_buffer {
    std::vector<std::uint8_t> storage;
    std::size_t h{};
    std::size_t w{};

    using view_t = std::mdspan<
        std::uint8_t,
        std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>
    >;

    view_t view() {
        return view_t(storage.data(), h, w);
    }

    view_t view() const {
        return view_t(const_cast<std::uint8_t*>(storage.data()), h, w);
    }
};

핵심은 “장기 보관은 소유자 타입으로 하고, 뷰는 매번 생성”입니다. 이렇게 하면 작업 큐에 image_buffer를 넣는 순간 수명이 묶이므로, 유스애프터프리 가능성이 크게 줄어듭니다.

API 설계 팁: mdspan은 입력/출력 계약을 선명하게 만든다

mdspan을 쓰면 함수 계약을 더 구체적으로 표현할 수 있습니다.

  • 읽기 전용: std::mdspan<const T, ...>
  • 쓰기 가능: std::mdspan<T, ...>
  • 레이아웃 고정: layout_right 또는 layout_left
  • stride 허용: layout_stride

예를 들어 “연속 메모리만 받는다”를 강제하고 싶으면 layout_right를 선택하고, “stride를 허용한다”면 layout_stride를 선택합니다. 이렇게 하면 호출자가 잘못된 버퍼 형태를 넘기는 문제가 컴파일/리뷰 단계에서 더 잘 드러납니다.

경계 오류(out-of-bounds)를 더 빨리 찾는 방법

mdspan 자체는 기본적으로 범위 체크를 하지 않습니다. 하지만 다음 방법으로 경계 오류를 더 빨리 잡을 수 있습니다.

  • extent() 기반 루프를 표준화해서, 하드코딩된 width/height 사용을 줄임
  • AddressSanitizer(ASan), UndefinedBehaviorSanitizer(UBSan)와 함께 테스트
  • 디버그 빌드에서 인덱스 체크 래퍼를 씌우기

간단한 디버그 체크 래퍼 예시

#include <mdspan>
#include <cassert>

template <class M>
struct checked2d {
    M m;

    decltype(auto) operator()(std::size_t i, std::size_t j) const {
        assert(i < m.extent(0));
        assert(j < m.extent(1));
        return m(i, j);
    }
};

// 사용
// checked2d<img_view> c{img};
// c(y, x) = ...;

이런 래퍼는 성능이 중요한 릴리스 빌드에서는 제거하고, 테스트/디버그에서만 활성화하는 식으로 운용할 수 있습니다.

std::span과의 역할 분담

  • 1D 연속 버퍼는 std::span<T>가 가장 단순하고 강력합니다.
  • 2D 이상 또는 stride/레이아웃이 중요한 경우 std::mdspan이 적합합니다.

실제로는 “소유자 std::vector + 1D는 span + 2D는 mdspan” 조합이 가장 흔한 형태가 됩니다.

mdspan과 오류 전파를 결합하기

버퍼를 받아 처리하는 함수는 대개 다음 실패를 다룹니다.

  • 크기 불일치(예: 입력/출력 shape mismatch)
  • stride/레이아웃 제약 위반
  • 외부 리소스 로딩 실패

이때 예외를 쓰지 않는 코드베이스라면 std::expected와 결합해 계약을 더 명확히 만들 수 있습니다. 관련 패턴은 다음 글의 접근을 함께 참고하면 좋습니다.

예를 들어 뷰 생성 시점에 stride 제약을 검사하고 실패를 반환할 수 있습니다.

#include <expected>
#include <mdspan>
#include <array>

enum class img_err {
    bad_stride,
    empty
};

using ext2d = std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>;
using view2d = std::mdspan<std::uint8_t, ext2d, std::layout_stride>;

std::expected<view2d, img_err> try_make_view(
    std::uint8_t* data,
    std::size_t h,
    std::size_t w,
    std::size_t stride_bytes
) {
    if (!data || h == 0 || w == 0) return std::unexpected(img_err::empty);
    if (stride_bytes < w) return std::unexpected(img_err::bad_stride);

    std::layout_stride::mapping<ext2d> map(ext2d(h, w), std::array<std::size_t, 2>{stride_bytes, 1});
    return view2d(data, map);
}

이렇게 하면 “뷰를 만들 수 있는 버퍼인지”가 처리 초기에 드러나고, 이상한 인덱싱으로 넘어가기 전에 실패를 반환할 수 있습니다.

흔한 함정 3가지

1) mdspan을 멤버로 저장하고 owner를 저장하지 않기

mdspan만 멤버로 들고 있으면, 원본 버퍼가 파괴되는 순간 뷰는 댕글링이 됩니다. 저장이 필요하면 앞서 본 것처럼 owner를 함께 보관하고, 뷰는 필요할 때 생성하세요.

2) stride 단위를 바이트로 할지 원소로 할지 혼동

layout_stride 매핑에 넣는 stride는 “원소 단위”로 이해하는 코드가 많지만, 예제처럼 std::uint8_t면 바이트와 원소가 같아 착시가 생깁니다. float/std::uint16_t 등으로 바뀌면 즉시 문제가 됩니다.

안전한 방법은 다음 중 하나를 택하는 것입니다.

  • stride를 “원소 단위”로 표준화하고, 바이트 stride는 입력 단계에서 변환
  • 타입 별로 명확한 변수명 사용: stride_elems, stride_bytes

3) 레이아웃을 문서로만 합의하고 타입에 박지 않기

서로 다른 모듈이 row-major라고 말하면서 실제로는 col-major로 해석하는 사고가 자주 납니다. mdspanlayout_right/layout_left를 타입으로 들고 가게 해 이런 실수를 줄입니다.

마이그레이션 전략: 포인터 API를 한 번에 갈아엎지 말자

레거시가 큰 프로젝트에서는 다음 순서가 현실적입니다.

  1. 내부 구현에서 포인터 산술을 mdspan으로 감싼다(외부 API는 유지)
  2. 경계/stride 검증을 뷰 생성 시점으로 이동한다
  3. 외부 API도 점진적으로 mdspan 또는 span 기반으로 교체한다
  4. 뷰 저장이 필요한 구간은 owner를 포함하는 타입으로 리팩터링한다

이 방식은 기능 변경을 최소화하면서도, 가장 위험한 “인덱싱/stride” 영역부터 안전성을 끌어올립니다.

정리

  • std::mdspan은 다차원 버퍼를 소유하지 않는 뷰로 표현해, 소유권·수명 문제를 설계 단계에서 더 잘 드러내게 합니다.
  • stride/레이아웃을 layout_stride 등으로 모델링하면, 포인터 산술 기반 코드에서 흔한 실수를 줄일 수 있습니다.
  • 장기 보관이 필요하면 mdspan을 저장하지 말고 owner를 저장하고 뷰는 필요할 때 생성하세요.
  • 입력 검증과 오류 전파는 std::expected 같은 도구와 결합하면 더 견고해집니다. 관련 패턴은 C++23 std - -expected로 예외 없이 오류전파+자원관리도 함께 참고하면 좋습니다.

mdspan은 성능을 해치지 않으면서도(대개 제로 오버헤드) 버퍼 계약을 코드에 새기는 도구입니다. 특히 이미지/텐서/행렬처럼 “형태(shape)와 레이아웃이 곧 의미”인 데이터를 다룬다면, T* 기반 API에서 발생하던 수명·경계·stride 문제를 눈에 띄게 줄일 수 있습니다.