본문으로 건너뛰기 C++ span 심화 | '배열 뷰' C++20 가이드

C++ span 심화 | '배열 뷰' C++20 가이드

C++ span 심화 | '배열 뷰' C++20 가이드

이 글의 핵심

C++ span 심화의 C++, span, "배열, 1.

들어가며

std::span은 C++20에서 도입된 배열 뷰입니다. 배열이나 vector의 연속 메모리를 복사 없이 참조하며, string_view와 유사한 개념입니다. [C++ span 기초](/blog/cpp-span/)에서 span의 기본 개념과 소유권 모델을 다뤘다면, 이 글에서는 한 단계 더 들어가 동적/정적 크기의 차이, subspan을 이용한 슬라이싱, 행렬·이미지 데이터처럼 실제 실무에서 마주치는 다차원 데이터를 span으로 다루는 방법을 살펴봅니다.


실전 경험에서 배운 교훈

span을 실제 코드에 도입하다 보면 문서만 봐서는 잘 드러나지 않는 함정들을 마주치게 됩니다. 특히 흔한 실수는 임시로 만든 vector나 함수 지역 변수를 가리키는 span을 그대로 반환하거나 저장해버리는 경우인데, 컴파일러가 경고를 주지 않는 경우가 많아 런타임에서야 문제가 드러납니다. 또한 span이 원본 컨테이너의 크기 변경을 자동으로 반영해주지 않는다는 점도 처음 접할 때 헷갈리기 쉬운 부분입니다. 이 글에서는 이런 실수들을 실제 코드로 짚어보고, 행렬 연산·슬라이딩 윈도우·이미지 픽셀 처리 같은 실전 예제를 통해 span을 안전하게 활용하는 방법을 정리합니다.

1. span 기본

기본 사용

span을 함수 매개변수로 받으면, 배열이든 vector든 호출하는 쪽에서 별도 변환 코드 없이 자연스럽게 넘길 수 있습니다. 이는 span이 배열이나 vector로부터 암시적으로 생성될 수 있도록 설계되어 있기 때문이며, 이때 실제 데이터는 전혀 복사되지 않고 포인터와 크기 정보만 전달됩니다. 배열을 함수 인자로 넘기면 포인터로 decay되어 크기 정보가 사라지는 C 스타일의 고질적인 문제도, span을 매개변수 타입으로 쓰면 자동으로 해결됩니다.

#include <span>
#include <vector>
#include <iostream>

// std::span<int>: int 배열의 뷰 (복사 없이 참조)
// 배열, vector, 다른 연속 메모리 컨테이너 모두 받을 수 있음
void print(std::span<int> s) {
    // span은 범위 기반 for문 지원
    // 내부적으로 포인터와 크기를 가짐
    for (int x : s) {
        std::cout << x << " ";
    }
    std::cout << std::endl;
}

int main() {
    // 배열: 자동으로 span으로 변환
    // 크기 정보가 자동으로 전달됨 (배열 decay 방지)
    int arr[] = {1, 2, 3, 4, 5};
    print(arr);
    
    // vector: 자동으로 span으로 변환
    // vector의 연속 메모리를 참조 (복사 없음)
    std::vector<int> v = {6, 7, 8, 9, 10};
    print(v);
    
    // 부분 배열: 명시적으로 span 생성
    // std::span(포인터, 크기): 배열의 일부만 뷰로 전달
    print(std::span(arr, 3));  // 처음 3개만: 1 2 3
    
    return 0;
}

출력:

1 2 3 4 5
6 7 8 9 10
1 2 3

동적 vs 정적 크기

span은 두 번째 템플릿 인자로 크기를 받을 수 있는데, 생략하면 std::dynamic_extent가 적용되어 런타임에 크기를 저장하는 동적 span이 되고, 정수를 명시하면 정적 span이 되어 컴파일 타임에 크기가 고정됩니다. 동적 span은 크기가 서로 다른 여러 배열을 하나의 타입으로 다룰 수 있어 유연하지만, 정적 span은 배열 크기가 이미 알려진 상황에서 잘못된 크기의 배열을 넘기는 실수를 컴파일 타임에 즉시 잡아낼 수 있다는 안전성 이점이 있습니다.

#include <span>
#include <iostream>

int main() {
    int arr[] = {1, 2, 3, 4, 5};
    
    // 동적 크기 (런타임): std::span<int>
    // 크기가 템플릿 인자에 없음 → 런타임에 크기 저장
    // 장점: 유연함 (다양한 크기 배열 받음)
    // 단점: 크기 정보가 런타임에 저장됨 (약간의 오버헤드)
    std::span<int> dynamicSpan(arr);
    std::cout << "동적 크기: " << dynamicSpan.size() << std::endl;  // 5
    
    // 정적 크기 (컴파일 타임): std::span<int, 5>
    // 크기가 템플릿 인자로 명시 → 컴파일 타임에 크기 확정
    // 장점: 크기 검증 (컴파일 타임), 최적화 가능
    // 단점: 정확한 크기만 받을 수 있음
    std::span<int, 5> staticSpan(arr);
    std::cout << "정적 크기: " << staticSpan.size() << std::endl;  // 5
    
    // 크기 불일치 시 컴파일 에러
    // std::span<int, 10> wrongSpan(arr);  // ❌ 에러: 배열은 5개인데 10개 요구
    
    return 0;
}

2. subspan

부분 범위

subspan은 원본 데이터를 전혀 복사하지 않고, 그중 일부 구간만 가리키는 새로운 span을 만들어내는 연산입니다. 첫 번째 인자로 시작 오프셋을, 두 번째 인자로 원소 개수를 지정하며, 두 번째 인자를 생략하면 그 오프셋부터 끝까지를 의미합니다. 아래 예제에서 볼 수 있듯, first3·from3·middle은 모두 같은 원본 배열 arr을 공유하는 서로 다른 뷰일 뿐이므로, 세 개의 subspan을 만드는 데 드는 비용은 사실상 0에 가깝습니다.

#include <span>
#include <iostream>

int main() {
    int arr[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    std::span<int> s(arr);  // 전체 배열의 뷰
    
    // subspan(offset, count): 부분 범위 추출
    // offset: 시작 인덱스 (0부터)
    // count: 요소 개수
    
    // 처음 3개: 인덱스 0부터 3개
    auto first3 = s.subspan(0, 3);  // [1, 2, 3]
    std::cout << "처음 3개: ";
    for (int x : first3) std::cout << x << " ";
    std::cout << std::endl;
    
    // 3번째부터 끝까지: count 생략 시 끝까지
    auto from3 = s.subspan(3);  // [4, 5, 6, 7, 8, 9, 10]
    std::cout << "3번째부터: ";
    for (int x : from3) std::cout << x << " ";
    std::cout << std::endl;
    
    // 중간 범위: 인덱스 3부터 4개
    auto middle = s.subspan(3, 4);  // [4, 5, 6, 7]
    std::cout << "중간 4개: ";
    for (int x : middle) std::cout << x << " ";
    std::cout << std::endl;
    
    // 모든 subspan은 원본 배열을 참조 (복사 없음)
    
    return 0;
}

출력:

처음 3개: 1 2 3
3번째부터: 4 5 6 7 8 9 10
중간 4개: 4 5 6 7

first, last

first(n)last(n)subspan의 특수한 경우를 더 읽기 쉽게 표현한 편의 함수입니다. “앞에서 n개”나 “뒤에서 n개”처럼 흔히 쓰이는 패턴을 매번 subspan(0, n)이나 subspan(size() - n)으로 계산하는 대신, 의도를 그대로 드러내는 이름의 함수로 바로 표현할 수 있어 코드의 가독성이 좋아집니다.

#include <span>
#include <iostream>

int main() {
    int arr[] = {1, 2, 3, 4, 5};
    std::span<int> s(arr);
    
    // 처음 N개
    auto first2 = s.first(2);
    std::cout << "처음 2개: ";
    for (int x : first2) std::cout << x << " ";
    std::cout << std::endl;
    
    // 마지막 N개
    auto last2 = s.last(2);
    std::cout << "마지막 2개: ";
    for (int x : last2) std::cout << x << " ";
    std::cout << std::endl;
    
    return 0;
}

출력:

처음 2개: 1 2
마지막 2개: 4 5

3. const span

읽기 전용

함수가 데이터를 읽기만 하고 수정하지 않는다는 것을 타입 시스템으로 강제하고 싶다면, 매개변수를 std::span<const int>처럼 요소 타입에 const를 붙여 선언합니다. 이렇게 하면 함수 본문 안에서 실수로 원소를 수정하는 코드를 작성해도 컴파일 에러로 즉시 잡히며, 호출하는 쪽에서는 일반 vectorconst vector든 상관없이 안전하게 넘길 수 있습니다. 아래 예제의 readconst int span을 받아 수정이 원천 차단되어 있고, write는 일반 int span을 받아 원본 데이터를 실제로 두 배로 만듭니다.

#include <span>
#include <vector>
#include <iostream>

void read(std::span<const int> s) {
    for (int x : s) {
        std::cout << x << " ";
    }
    std::cout << std::endl;
    // s[0] = 10;  // 컴파일 에러
}

void write(std::span<int> s) {
    for (int& x : s) {
        x *= 2;
    }
}

int main() {
    std::vector<int> v = {1, 2, 3};
    
    read(v);   // OK
    write(v);  // OK
    
    std::cout << "수정 후: ";
    read(v);   // 2 4 6
    
    const std::vector<int> cv = {4, 5, 6};
    read(cv);   // OK
    // write(cv);  // 컴파일 에러
    
    return 0;
}

4. 실전 예제

아래 두 예제는 다차원 데이터를 다루는 Matrix 클래스와, 시계열 데이터를 처리하는 슬라이딩 윈도우 알고리즘에서 span이 실제로 어떤 역할을 하는지 보여줍니다.

예제 1: 행렬 연산

Matrix는 내부적으로 2차원 데이터를 하나의 연속된 vector<double>에 저장하고, 행 인덱스로부터 오프셋(row * cols)을 계산해 접근합니다. getRow는 그 특정 행에 해당하는 구간만 가리키는 span을 반환하는데, 이렇게 하면 행 하나를 복사해서 새 벡터로 만들지 않고도 그 행의 원소들을 마치 독립된 배열처럼 순회하거나 수정할 수 있습니다. const 오버로드를 함께 제공한 덕분에, 상수 Matrix에서는 자동으로 읽기 전용 span<const double>이 반환되어 실수로 원본을 수정하는 것을 막아줍니다.

#include <span>
#include <vector>
#include <iostream>

class Matrix {
    std::vector<double> data;
    size_t rows, cols;
    
public:
    Matrix(size_t r, size_t c) : data(r * c), rows(r), cols(c) {}
    
    // 행 접근 (수정 가능)
    std::span<double> getRow(size_t row) {
        return std::span(data.data() + row * cols, cols);
    }
    
    // 행 접근 (읽기 전용)
    std::span<const double> getRow(size_t row) const {
        return std::span(data.data() + row * cols, cols);
    }
    
    // 요소 접근
    double& operator()(size_t i, size_t j) {
        return data[i * cols + j];
    }
    
    void print() const {
        for (size_t i = 0; i < rows; ++i) {
            auto row = getRow(i);
            for (double x : row) {
                std::cout << x << " ";
            }
            std::cout << std::endl;
        }
    }
};

int main() {
    Matrix m(3, 3);
    
    // 행 설정
    auto row0 = m.getRow(0);
    for (size_t i = 0; i < row0.size(); ++i) {
        row0[i] = i + 1;
    }
    
    auto row1 = m.getRow(1);
    for (size_t i = 0; i < row1.size(); ++i) {
        row1[i] = (i + 1) * 10;
    }
    
    auto row2 = m.getRow(2);
    for (size_t i = 0; i < row2.size(); ++i) {
        row2[i] = (i + 1) * 100;
    }
    
    // 출력
    m.print();
    
    return 0;
}

출력:

1 2 3
10 20 30
100 200 300

예제 2: 슬라이딩 윈도우

이동 평균처럼 데이터를 일정 크기의 겹치는 구간으로 나눠 처리해야 할 때, 매 반복마다 새로운 벡터를 만들어 값을 복사하는 대신 span으로 원본 데이터의 다른 부분을 가리키기만 하면 훨씬 효율적입니다. 이 예제는 반복문을 돌며 시작 위치를 한 칸씩 옮긴 span을 만들고, average 함수는 그 span이 실제로 원본 벡터의 어느 부분에서 왔는지 전혀 신경 쓰지 않고 그저 주어진 범위의 평균만 계산합니다. 이렇게 알고리즘(평균 계산)과 데이터 접근(어느 구간을 볼 것인가)을 분리할 수 있다는 것이 span을 함수 인터페이스에 사용하는 큰 장점입니다.

#include <span>
#include <vector>
#include <iostream>

double average(std::span<const int> window) {
    double sum = 0;
    for (int x : window) {
        sum += x;
    }
    return sum / window.size();
}

int main() {
    std::vector<int> data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    
    const int windowSize = 3;
    
    std::cout << "슬라이딩 윈도우 평균:" << std::endl;
    for (size_t i = 0; i <= data.size() - windowSize; ++i) {
        std::span<const int> window(data.data() + i, windowSize);
        std::cout << "윈도우 [" << i << "-" << (i + windowSize - 1) 
                  << "]: " << average(window) << std::endl;
    }
    
    return 0;
}

출력:

윈도우가 한 칸씩 이동할 때마다 평균값이 어떻게 부드럽게 변하는지 확인할 수 있습니다. 원본 데이터가 1부터 10까지 순차적으로 증가하므로, 각 윈도우의 평균도 정확히 그 구간의 중앙값과 일치합니다.

슬라이딩 윈도우 평균:
윈도우 [0-2]: 2
윈도우 [1-3]: 3
윈도우 [2-4]: 4
윈도우 [3-5]: 5
윈도우 [4-6]: 6
윈도우 [5-7]: 7
윈도우 [6-8]: 8
윈도우 [7-9]: 9

5. 자주 발생하는 문제

문제 1: 댕글링 span

span은 원본 데이터를 소유하지 않으므로, 원본이 소멸된 뒤에도 span이 살아남으면 그 span은 이미 해제된 메모리를 가리키는 댕글링 상태가 됩니다. dangling() 함수는 지역 변수 v를 만들고 그것을 가리키는 span을 반환하는데, 함수가 끝나는 순간 v가 소멸되므로 반환된 span은 즉시 무효화됩니다. 이런 함수를 작성하지 않는 가장 확실한 방법은 애초에 컨테이너 자체(vector)를 반환하거나, span은 오직 이미 존재하는 컨테이너를 매개변수로 받아 그 자리에서만 사용하는 용도로 제한하는 것입니다.

#include <span>
#include <vector>
#include <iostream>

// ❌ 위험: 지역 변수 반환
std::span<int> dangling() {
    std::vector<int> v = {1, 2, 3};
    return v;  // v 소멸, span은 댕글링!
}

// ✅ vector 반환
std::vector<int> safe() {
    return {1, 2, 3};
}

// ✅ span 매개변수로만 사용
void process(std::span<int> s) {
    for (int x : s) {
        std::cout << x << " ";
    }
}

int main() {
    // ❌ 위험
    // auto s = dangling();
    // for (int x : s) {}  // 정의되지 않은 동작
    
    // ✅ 안전
    std::vector<int> v = safe();
    process(v);
    
    return 0;
}

문제 2: 임시 객체

함수 호출 인자 자리에서 즉석으로 만든 임시 vectorspan으로 넘기면, 그 임시 객체는 함수 호출문이 끝나는 시점(세미콜론을 만나는 순간)에 곧바로 소멸합니다. 만약 함수가 받은 span을 함수 호출이 끝난 뒤에도 계속 사용한다면(반환하거나 다른 곳에 저장한다면) 이미 사라진 임시 객체를 가리키는 댕글링 문제가 발생합니다. 이런 위험을 피하려면 span으로 넘길 데이터를 반드시 이름이 있는 지역 변수에 먼저 담아, 그 변수의 수명이 함수 호출을 포함하는 범위 전체를 확실히 덮도록 만들어야 합니다.

#include <span>
#include <vector>
#include <iostream>

void func(std::span<int> s) {
    for (int x : s) {
        std::cout << x << " ";
    }
}

int main() {
    // ❌ 위험: 임시 객체
    // func(std::vector<int>{1, 2, 3});  // 임시 객체 소멸, 댕글링!
    
    // ✅ 변수 저장
    std::vector<int> v = {1, 2, 3};
    func(v);
    
    return 0;
}

문제 3: 크기 변경 불가

span은 원소 개수를 늘리거나 줄이는 연산(push_back, resize 등)을 전혀 제공하지 않습니다. 이는 span이 애초에 “이미 존재하는 연속 메모리 구간을 그대로 바라보기만 하는” 뷰이기 때문에 당연한 제약입니다. 게다가 원본 vectorpush_back으로 재할당되어 메모리 주소 자체가 바뀌면, 기존에 만들어둔 span은 여전히 옛 주소를 가리키고 있어 자동으로 갱신되지 않습니다. 원본이 변경된 뒤 최신 상태를 반영하려면 아래처럼 span을 그 자리에서 다시 만들어야 합니다.

#include <span>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {1, 2, 3};
    std::span<int> s(v);
    
    // ❌ 크기 변경 불가
    // s.push_back(4);  // 컴파일 에러
    // s.resize(10);    // 컴파일 에러
    
    // ✅ vector로 크기 변경
    v.push_back(4);
    
    // span은 자동 업데이트 안 됨 (재생성 필요)
    s = std::span<int>(v);
    
    for (int x : s) std::cout << x << " ";  // 1 2 3 4
    std::cout << std::endl;
    
    return 0;
}

문제 4: 포인터 vs span

포인터와 크기를 별도 인자로 받는 구식 방식은 두 값이 항상 정확한 쌍으로 전달된다는 보장이 전혀 없습니다. 호출자가 실수로 잘못된 크기를 넘기거나 인자 순서를 헷갈려도 컴파일러는 이를 감지하지 못합니다. span으로 매개변수를 바꾸면 포인터와 크기가 하나의 값으로 항상 함께 다니므로 이런 실수 자체가 줄어들고, 범위 기반 for처럼 현대적인 C++ 문법도 그대로 사용할 수 있어 코드가 더 간결해집니다.

#include <span>
#include <iostream>

// ❌ 구식: 포인터 + 크기
void oldStyle(int* data, size_t size) {
    for (size_t i = 0; i < size; ++i) {
        std::cout << data[i] << " ";
    }
}

// ✅ 현대적: span
void modernStyle(std::span<int> data) {
    for (int x : data) {
        std::cout << x << " ";
    }
}

int main() {
    int arr[] = {1, 2, 3, 4, 5};
    
    oldStyle(arr, 5);
    std::cout << std::endl;
    
    modernStyle(arr);
    std::cout << std::endl;
    
    return 0;
}

6. 실전 예제: 이미지 처리

이미지 데이터는 흔히 픽셀별로 RGB 세 바이트가 연속으로 저장된 하나의 거대한 바이트 배열로 표현됩니다. Image 구조체는 이 원시 바이트 배열에서 특정 픽셀(3바이트) 또는 특정 행(width×3바이트) 구간만 가리키는 span을 반환해, 좌표 계산과 실제 데이터 접근 로직을 깔끔하게 분리합니다. toGrayscale처럼 모든 픽셀을 순회하며 값을 읽고 쓰는 연산도, getPixel이 반환한 span<uint8_t>을 통해 마치 독립된 3바이트 배열을 다루듯 자연스럽게 구현할 수 있습니다. 이는 원본 바이트 배열을 직접 인덱스 계산하며 다루는 것보다 훨씬 안전하고 읽기 쉬운 코드입니다.

#include <span>
#include <vector>
#include <iostream>

struct Image {
    std::vector<uint8_t> pixels;
    int width, height;
    
    Image(int w, int h) : pixels(w * h * 3), width(w), height(h) {}
    
    // 픽셀 접근 (RGB)
    std::span<uint8_t> getPixel(int x, int y) {
        int offset = (y * width + x) * 3;
        return std::span(pixels.data() + offset, 3);
    }
    
    // 행 접근
    std::span<uint8_t> getRow(int y) {
        int offset = y * width * 3;
        return std::span(pixels.data() + offset, width * 3);
    }
    
    // 그레이스케일 변환
    void toGrayscale() {
        for (int y = 0; y < height; ++y) {
            for (int x = 0; x < width; ++x) {
                auto pixel = getPixel(x, y);
                uint8_t gray = static_cast<uint8_t>(
                    0.299 * pixel[0] + 0.587 * pixel[1] + 0.114 * pixel[2]
                );
                pixel[0] = pixel[1] = pixel[2] = gray;
            }
        }
    }
};

int main() {
    Image img(100, 100);
    
    // 픽셀 설정 (빨강)
    auto pixel = img.getPixel(50, 50);
    pixel[0] = 255;  // R
    pixel[1] = 0;    // G
    pixel[2] = 0;    // B
    
    std::cout << "픽셀 (50, 50): RGB(" 
              << static_cast<int>(pixel[0]) << ", "
              << static_cast<int>(pixel[1]) << ", "
              << static_cast<int>(pixel[2]) << ")" << std::endl;
    
    // 그레이스케일 변환
    img.toGrayscale();
    
    auto grayPixel = img.getPixel(50, 50);
    std::cout << "그레이스케일: RGB(" 
              << static_cast<int>(grayPixel[0]) << ", "
              << static_cast<int>(grayPixel[1]) << ", "
              << static_cast<int>(grayPixel[2]) << ")" << std::endl;
    
    return 0;
}

출력:

픽셀 (50, 50): RGB(255, 0, 0)
그레이스케일: RGB(76, 76, 76)

정리

핵심 요약

  1. span: C++20 배열 뷰
  2. 복사 없음: 참조만 (소유 안함)
  3. 동적/정적 크기: 런타임/컴파일 타임
  4. subspan: 부분 범위 추출
  5. const span: 읽기 전용
  6. 수명 주의: 원본 배열 유효 동안만

span vs 다른 타입

타입소유권크기 변경타입용도
span없음불가모든 타입배열 뷰
vector있음가능모든 타입동적 배열
string_view없음불가문자열문자열 뷰
포인터+크기없음불가모든 타입구식

실전 팁

사용 원칙:

  • 배열/vector 매개변수는 span
  • 부분 배열 전달은 subspan
  • 읽기 전용은 const span
  • 소유권 필요하면 vector

성능:

  • 복사 없음 (포인터+크기만)
  • 큰 배열일수록 효과 큼
  • 인라인 최적화 가능
  • 오버헤드 거의 없음

주의사항:

  • 원본 배열 수명 관리
  • 임시 객체 주의
  • 크기 변경 불가
  • 댕글링 span 방지

다음 단계


관련 글


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Everything about C++ span 심화 : from basic concepts to practical applications.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, span, C++20, 배열, view 등으로 검색하시면 이 글이 도움이 됩니다.