C++20 range adaptor: views::filter·transform 파이프라인의 지연 평가와 댕글링 함정

이 글의 핵심

C++20 Range Adaptor(std::views)가 왜 컨테이너 대신 지연 평가되는 뷰를 반환하는지, 그리고 이 설계가 만들어내는 댕글링 뷰·조건자 다중 호출 같은 실전 함정을 예제로 설명합니다.

Range Adaptor란?

C++20 이전까지 범위를 다루는 표준적인 방법은 std::copy_if, std::transform 같은 알고리즘에 반복자 쌍(begin/end)을 넘기고, 결과를 담을 새 컨테이너를 미리 준비하는 것이었습니다. 필터링 후 변환하는 두 단계 작업만 해도 임시 벡터가 하나 생기고, 세 단계면 임시 벡터가 두 개 생깁니다. Range Adaptor(범위 어댑터)는 이 문제를 근본적으로 다르게 풉니다 — 어댑터는 데이터를 즉시 계산해서 새 컨테이너에 담아 반환하지 않고, 원본 범위를 감싸는 뷰(view) 를 반환합니다. 뷰는 “이 범위를 이렇게 가공해서 보여주겠다”는 계획만 들고 있을 뿐, 실제 연산은 뷰를 순회(iterate)하는 순간까지 미뤄집니다. 이것이 지연 평가(lazy evaluation) 입니다.

지연 평가가 왜 중요한지는 두 가지 관점에서 이해하면 명확해집니다. 첫째, 어댑터를 아무리 길게 체이닝해도 중간 단계마다 컨테이너를 할당하지 않습니다. filter | transform | take 세 단계를 이어도 힙 할당은 0번입니다 — 각 어댑터는 원본 범위에 대한 얇은 래퍼(wrapper)일 뿐이고, 실제 원소 하나하나는 최종 순회 루프가 operator++와 operator*를 호출할 때 비로소 필터 조건을 검사하고 변환 함수를 적용합니다. 둘째, 이 덕분에 무한 범위를 다룰 수 있습니다. std::views::iota(0)는 끝이 없는 정수 시퀀스를 나타내는 뷰인데, 즉시 평가하는 방식이었다면 이 코드는 결코 끝나지 않았을 것입니다. 하지만 지연 평가 덕분에 iota(0) | filter(...) | take(5)처럼 뒤에 take를 붙이면, 실제로는 조건을 만족하는 원소 5개를 찾을 때까지만 무한 시퀀스를 조금씩 당겨오고 멈춥니다.

#include <ranges>

std::vector<int> v = {1, 2, 3, 4, 5};

// 어댑터: 범위 -> 뷰
auto filtered = v | std::views::filter([](int x) { return x > 2; });

이 코드에서 filtered가 생성되는 시점에는 어떤 필터링도 일어나지 않습니다. filtered는 단지 “v를 필터링해서 보여주는 뷰”라는 정보를 담은 객체이고, 실제로 for (int x : filtered) 같은 순회가 시작되어야 x > 2 검사가 실행됩니다. 이 지연성은 편리하지만, 뒤에서 다룰 두 가지 함정(댕글링 범위, 조건자 다중 호출)의 근본 원인이기도 합니다.

지연 평가가 실제로 만드는 함정

지연 평가는 “계산을 미룬다”는 개념 자체가 두 가지 실무 문제를 낳습니다. 하나는 수명 문제이고, 다른 하나는 부작용(side effect) 문제입니다.

임시 vector에 ranges::max_element를 쓴 결과를 역참조할 때의 컴파일 에러

그림: 실제 실행 결과 — 임시 컨테이너에 ranges 알고리즘을 쓰면 반복자 대신 std::ranges::dangling이 돌아와, 댕글링을 런타임이 아니라 컴파일 단계에서 막아 준다. (환경: Ubuntu 24.04 · g++ 13.3)

flowchart LR
    A["뷰 생성\nfilter/transform 체이닝"] -->|"즉시 계산 안 함"| B["뷰 객체\n(파이프라인 계획만 보유)"]
    B -->|"for 순회 시작"| C["원소 1개 요청"]
    C --> D{"filter 조건 검사"}
    D -->|"통과"| E["transform 적용"]
    D -->|"탈락"| C
    E --> F["소비자에게 전달"]
    F -->|"다음 원소 요청"| C

위 다이어그램처럼 실제 계산은 “당겨쓰기(pull-based)” 방식으로, 소비자 쪽 루프가 원소를 요청할 때마다 파이프라인을 거슬러 올라가며 한 원소씩 계산합니다. 컨테이너를 먼저 다 만들고 나중에 순회하는 즉시 평가 모델과는 완전히 다른 실행 순서라는 점을 이해해야 아래 함정들이 왜 발생하는지 납득이 갑니다.

댕글링 범위: 원본이 뷰보다 먼저 죽는 문제

뷰는 기본적으로 원본 범위를 소유하지 않습니다. filter, transform 같은 어댑터는 원본에 대한 참조(또는 반복자)만 들고 있다가, 나중에 순회 시점에 그 참조를 통해 원본에 접근합니다. 문제는 그 원본이 뷰보다 먼저 스코프를 벗어나 소멸해버리는 경우입니다.

// ❌ 지역 변수를 참조하는 뷰 반환
auto getView() {
    std::vector<int> v = {1, 2, 3};
    return v | std::views::filter([](int x) { return x > 1; });
    // v 소멸 — 반환된 뷰는 이미 죽은 벡터를 가리키는 댕글링 참조가 된다
}

// △ 참조로 받기: 원본 수명은 호출자가 책임진다
auto getView(const std::vector<int>& v) {
    return v | std::views::filter([](int x) { return x > 1; });
}

첫 번째 getView()에서 v는 lvalue라서 v | views::filter(...)는 v를 가리키는 ref_view를 감싼 뷰를 만듭니다. 함수가 반환되면 v는 소멸하고 힙 버퍼도 해제되지만, 반환된 뷰는 여전히 그 지역 변수의 주소를 들고 있습니다. 이 코드는 경고 없이 컴파일되고, 해제된 메모리가 아직 덮어써지지 않았다면 디버그 빌드에서는 그럴듯한 값이 나오기도 합니다. 그러다 최적화 수준이 바뀌거나 호출 경로가 달라지면 쓰레기 값을 순회하거나 크래시가 나는, 전형적인 UB(정의되지 않은 동작)입니다. 디버깅할 때 필터 조건부터 의심하기 쉽지만, 실제 원인은 순회할 원본이 이미 존재하지 않는다는 데 있습니다. AddressSanitizer(-fsanitize=address)를 켜면 stack-use-after-return이나 heap-use-after-free로 바로 드러납니다.

두 번째 버전도 완전히 안전한 것은 아닙니다. 호출자가 getView(std::vector<int>{1, 2, 3})처럼 임시 객체를 넘기면 const&가 임시에 묶이고, 그 임시는 호출 식이 끝나면 사라지므로 같은 댕글링이 생깁니다. 함수 안에서 만든 컨테이너를 뷰와 함께 돌려주고 싶다면 return std::move(v) | std::views::filter(...);처럼 rvalue를 넘기면 됩니다. 이때 어댑터는 ref_view 대신 원본을 소유하는 owning_view를 만듭니다(P2415, C++20에 소급 적용된 결함 보고라 최근 GCC·Clang·MSVC 표준 라이브러리에서 지원). 그래서 함수가 뷰를 반환하는 시그니처를 볼 때는 “이 뷰가 참조하는 원본의 수명이 호출자 쪽에서 뷰보다 길게 보장되는가”를 먼저 확인해야 합니다.

조건자는 여러 번 호출될 수 있다 — 부작용은 금물

views::filter에 넘긴 predicate(조건 함수)는 뷰를 순회할 때마다 다시 호출됩니다. filter_view는 첫 번째 begin() 결과(첫 번째로 조건을 통과한 위치)만 캐시하고 나머지 원소의 판정 결과는 저장하지 않으므로, 같은 뷰를 두 번 순회하면 대부분의 원소에 대해 조건자가 두 번 실행됩니다. 조건자 안에 부작용이 있으면 결과가 순회 횟수와 사용 패턴에 따라 달라집니다.

흔한 예가 “필터링하면서 동시에 개수도 세면 한 번의 순회로 끝난다”는 발상으로, 조건자 안에서 캡처한 카운터를 증가시키는 코드입니다.

// ❌ 필터 조건자 안에서 부작용을 일으키는 코드
int count = 0;
auto view = logs | std::views::filter([&count](const Log& l) {
    bool match = l.level == LogLevel::Error;
    if (match) ++count;  // 부작용: 몇 번 호출되는지에 따라 값이 달라진다
    return match;
});

for (const auto& l : view) { /* 처리 */ }
std::cout << count;  // 뷰를 한 번만 순회했을 때만 기대한 값이 된다

이 코드는 루프 하나로만 순회하면 맞는 값을 내지만, 같은 뷰를 화면 출력과 파일 기록에 재사용하는 식으로 두 번 순회하면 개수가 두 배 가까이 됩니다. std::ranges::distance(view)처럼 개수를 구하는 호출도 한 번의 순회로 계산되므로 같은 문제를 일으킵니다. 개수를 세는 작업은 조건자에서 분리하는 편이 안전합니다.

// ✅ 조건자는 순수하게, 개수는 따로
auto isError = [](const Log& l) { return l.level == LogLevel::Error; };
auto errorView = logs | std::views::filter(isError);
auto count = std::ranges::count_if(logs, isError);

비슷한 함정이 transform을 filter 앞에 둘 때도 생깁니다. v | views::transform(f) | views::filter(pred)에서는 조건을 검사하기 위해 원소를 역참조할 때 f가 한 번 호출되고, 통과한 원소를 소비자가 다시 역참조할 때 f가 또 호출됩니다. 즉 조건을 통과한 원소마다 변환 함수가 두 번 실행됩니다. 그래서 필터 조건자와 변환 함수 모두 몇 번 호출돼도 같은 결과를 내는 순수 함수로 유지하는 것이 지연 평가 모델의 전제 조건입니다.

파이프라인은 몇 번 순회하는가 — 단일 패스로 융합되는 이유

filter | transform | take처럼 어댑터를 체이닝해도, 원본 컨테이너를 여러 번 순회하는 것이 아닙니다. 각 어댑터는 독립된 루프를 도는 것이 아니라, 최종 소비자(예: for 루프)가 operator++를 한 번 호출할 때마다 그 호출이 파이프라인을 따라 연쇄적으로 전파되는 구조입니다. 즉 filter가 조건을 검사해 통과한 원소 하나에 대해서만 transform이 실행되고, 그 결과 하나가 소비자에게 전달됩니다 — 원소당 한 번의 “패스”로 전체 파이프라인이 융합(fuse)되는 것입니다. 이는 STL 알고리즘을 파이프라인으로 직접 연결해서 쓸 때(std::transform 결과를 벡터에 담고 다시 std::copy_if를 돌리는 식)와 비교하면 확실한 성능 이점입니다 — 중간 결과를 담는 컨테이너가 없으니 캐시 지역성도 좋고 메모리 할당도 없습니다.

다만 이 이점은 “한 번만 순회할 때”에 한정됩니다. 아래 코드처럼 같은 뷰 객체를 두 번 순회하면, 필터와 변환 연산은 매 순회마다 처음부터 다시 실행됩니다. 뷰는 계산 결과를 캐시하지 않기 때문입니다.

// 지연 평가: 매번 재계산
auto view = v | std::views::filter(pred) | std::views::transform(func);

for (int x : view) { /* ... */ }  // 계산
for (int x : view) { /* ... */ }  // 다시 계산

// ✅ 한 번만 계산 필요 시
std::vector<int> cached(view.begin(), view.end());
for (int x : cached) { /* ... */ }  // 캐시 사용
for (int x : cached) { /* ... */ }  // 캐시 사용

filter나 transform 함수가 가벼운 산술 연산이라면 두 번 재계산해도 체감 성능 차이가 거의 없습니다. 하지만 조건자나 변환 함수가 정규식 매칭, 파일 I/O, 네트워크 조회처럼 무거운 연산을 포함한다면, 뷰를 여러 번 순회하기 전에 반드시 std::vector나 ranges::to<std::vector>()로 한 번 구체화(materialize)해서 캐시해야 합니다. 이 판단 기준(연산이 가벼운가 무거운가, 몇 번 순회할 것인가)을 코드 리뷰 때 명시적으로 따져보는 습관을 들이면 불필요한 재계산으로 인한 성능 저하를 미리 막을 수 있습니다.

기본 사용

Range Adaptor는 두 가지 문법으로 호출할 수 있습니다 — 함수처럼 범위를 첫 인자로 넘기는 방식과, 파이프 연산자(|)로 범위 뒤에 붙이는 방식입니다. 둘은 동일한 결과를 만들지만, 파이프 문법은 여러 어댑터를 왼쪽에서 오른쪽으로 순서대로 읽을 수 있어 체이닝할 때 가독성이 훨씬 좋습니다. 이 때문에 실무 코드에서는 파이프 문법이 사실상 표준처럼 쓰입니다.

namespace vws = std::views;

std::vector<int> v = {1, 2, 3, 4, 5};

// 어댑터 적용
auto view1 = vws::filter(v, [](int x) { return x > 2; });

// 파이프라인
auto view2 = v | vws::filter([](int x) { return x > 2; });

view1과 view2는 완전히 동일한 타입, 동일한 결과를 만듭니다. vws::filter(v, pred) 형태는 인자가 하나뿐일 때는 괜찮지만, 세 단계 이상 이어붙이면 vws::take(vws::transform(vws::filter(v, pred), func), 3)처럼 괄호가 안쪽에서 바깥쪽으로 겹겹이 쌓여 읽는 순서와 실행 순서가 반대로 뒤집힙니다. 반면 파이프 문법은 v | filter(pred) | transform(func) | take(3)처럼 데이터가 흘러가는 순서 그대로 코드를 작성할 수 있어, 유닉스 셸 파이프라인에 익숙한 개발자라면 특히 자연스럽게 느껴질 것입니다.

실전 예시

예시 1: 파이프라인 조합

#include <ranges>
#include <vector>

std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

// 여러 어댑터 조합
auto pipeline = numbers
    | std::views::filter([](int x) { return x % 2 == 0; })  // 짝수
    | std::views::transform([](int x) { return x * x; })    // 제곱
    | std::views::take(3);                                   // 처음 3개

for (int x : pipeline) {
    std::cout << x << " ";  // 4 16 36
}

이 예시는 “짝수만 골라서, 제곱한 다음, 앞의 3개만” 이라는 요구사항을 세 개의 독립된 알고리즘 호출과 두 개의 임시 벡터 없이 한 줄로 표현합니다. 앞서 설명한 지연 평가 덕분에, 실제로는 for 루프가 pipeline의 첫 원소를 요청하는 순간부터 numbers를 앞에서부터 훑으면서 짝수를 찾고, 찾을 때마다 제곱해서 반환하는 과정을 3번 반복하고 멈춥니다. numbers에 원소가 100만 개 있어도 take(3)이 있는 한 처음 몇 개의 짝수만 확인하고 나머지는 건드리지 않습니다 — 즉시 평가 방식(전체를 필터링한 벡터를 만들고, 전체를 변환한 벡터를 만들고, 그중 3개만 취하는 방식)과 비교하면 이 차이가 왜 성능상 의미 있는지 명확해집니다.

예시 2: 재사용 가능한 어댑터

#include <ranges>

// 어댑터 저장
auto evenFilter = std::views::filter([](int x) { return x % 2 == 0; });
auto square = std::views::transform([](int x) { return x * x; });

std::vector<int> v1 = {1, 2, 3, 4, 5};
std::vector<int> v2 = {6, 7, 8, 9, 10};

// 재사용
auto result1 = v1 | evenFilter | square;
auto result2 = v2 | evenFilter | square;

views::filter(pred)처럼 범위 없이 조건자만 넘기면, 그 결과는 아직 어떤 범위와도 결합되지 않은 “부분 적용된 어댑터 객체”가 됩니다. 이 객체는 함수 객체처럼 값으로 복사하거나 변수에 저장해서 여러 범위에 재사용할 수 있습니다. 로그 필터링, 유효성 검증 규칙처럼 “동일한 변환 로직을 여러 데이터셋에 반복 적용”하는 상황에서 이 패턴이 유용합니다. 다만 조건자나 변환 함수가 상태를 캡처하고 있다면(예: 참조로 캡처한 외부 변수), 여러 범위에 재사용할 때 그 상태가 호출 사이에 공유된다는 점을 주의해야 합니다 — 이것이 앞서 설명한 “조건자는 순수해야 한다”는 원칙과 같은 맥락입니다.

예시 3: 커스텀 어댑터

#include <ranges>

// 커스텀 어댑터: 홀수만
auto odds = std::views::filter([](int x) { return x % 2 != 0; });

// 커스텀 어댑터: 3의 배수만
auto multiplesOf3 = std::views::filter([](int x) { return x % 3 == 0; });

std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

auto result = v | odds;  // 1 3 5 7 9

여기서 “커스텀 어댑터”라는 표현은 views::filter 자체를 새로 구현한다는 뜻이 아니라, 이미 있는 어댑터에 우리 도메인에 특화된 조건자를 결합해 이름 붙인 조합을 만든다는 뜻입니다. odds, multiplesOf3처럼 의미가 드러나는 이름으로 어댑터 조합을 저장해두면, 호출부 코드가 filter([](int x){ return x % 2 != 0; })라는 람다 표현식 대신 odds라는 의도가 분명한 이름으로 읽혀서 가독성이 좋아집니다. 진짜 의미의 커스텀 어댑터(예: 자체 뷰 클래스를 view_interface로부터 상속해 구현하는 것)는 훨씬 복잡한 주제이므로, 대부분의 실무에서는 이 예시처럼 표준 어댑터의 조합에 이름을 붙이는 수준으로 충분합니다.

예시 4: 조건부 어댑터

#include <ranges>
#include <iostream>
#include <vector>

using namespace std;

template<typename Range>
auto conditionalFilter(Range&& r, bool applyFilter) {
    // if/else로 서로 다른 뷰를 반환하면 반환 타입 추론이 실패하므로
    // "필터를 적용하지 않는다"를 "항상 참인 조건"으로 바꿔 타입을 하나로 맞춘다
    return std::forward<Range>(r)
        | std::views::filter([applyFilter](int x) { return !applyFilter || x > 5; });
}

int main() {
    std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    
    auto result = conditionalFilter(v, true);
    
    for (int x : result) {
        std::cout << x << " ";  // 6 7 8 9 10
    }
}

처음에는 if (applyFilter) return r | views::filter(...); else return r | views::all;처럼 분기마다 다른 뷰를 반환하고 싶어지지만, 두 분기의 타입이 각각 filter_view<ref_view<...>, 람다>와 ref_view<...>로 달라서 auto 반환 타입 추론이 실패하고 컴파일 에러가 납니다. 위 코드처럼 조건 자체를 “적용 여부”를 포함한 하나의 조건자로 합치면 반환 타입이 하나로 통일됩니다. 분기마다 파이프라인 구조가 완전히 달라서 이렇게 합칠 수 없다면, 결과를 std::vector로 구체화해 반환하거나 두 뷰 타입을 std::variant로 감싸는 방법이 남습니다.

주요 어댑터

namespace vws = std::views;

// 필터링
vws::filter(pred)
vws::take(n)
vws::drop(n)
vws::take_while(pred)
vws::drop_while(pred)

// 변환
vws::transform(func)
vws::reverse

// 분할/결합
vws::split(delimiter)
vws::join

// 생성
vws::iota(start)
vws::iota(start, end)

// 기타
vws::all  // 전체 뷰
vws::counted(it, n)
vws::common

이 목록은 크게 네 가지 역할로 나뉩니다. filter 계열(filter, take, drop, take_while, drop_while)은 원소를 골라내거나 잘라내는 역할이고, transform과 reverse는 원소 자체나 순회 순서를 바꾸는 역할입니다. split과 join은 중첩된 범위를 나누거나 합치는 역할로, 예를 들어 구분자로 문자열을 토큰화할 때(split) 또는 범위의 범위(vector<vector<int>>)를 평평하게(join) 만들 때 씁니다. 마지막으로 iota, all, counted, common은 기존 데이터가 아니라 새로운 뷰를 “생성”하거나 기존 반복자를 뷰로 감싸는 유틸리티입니다. 실무에서 압도적으로 많이 쓰이는 조합은 filter + transform + take이며, 나머지는 특정 상황(텍스트 파싱에서의 split, 무한 시퀀스 생성의 iota)에서 등장합니다.

자주 발생하는 문제

문제 1: 타입 추론

// ❌ 복잡한 타입
std::vector<int> v = {1, 2, 3};
std::ranges::filter_view<std::ranges::ref_view<std::vector<int>>, /* ... */> filtered = 
    v | std::views::filter([](int x) { return x > 1; });

// ✅ auto
auto filtered = v | std::views::filter([](int x) { return x > 1; });

뷰의 실제 타입은 람다의 익명 타입까지 포함하기 때문에 사람이 직접 손으로 쓸 수 없는 수준으로 복잡합니다. auto를 쓰지 않고 타입을 명시하려는 시도는 대부분 실패하거나, 컴파일러가 내부적으로 사용하는 구현 세부 타입에 의존하는 코드가 되어 컴파일러 버전이 바뀌면 깨질 위험이 있습니다. 뷰를 다루는 코드에서는 항상 auto로 받는 것이 규칙에 가깝고, 함수의 반환 타입은 auto(C++14 이상의 반환 타입 추론)로 두면 됩니다. 클래스 멤버로 저장해야 한다면 decltype으로 타입을 얻어 두거나, 표준에는 타입 소거 뷰가 없으므로 결과를 컨테이너로 구체화하거나 range-v3의 any_view 같은 라이브러리 타입을 쓰는 방법이 있습니다.

문제 2: 어댑터 순서

std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

// 순서에 따라 결과 다름
auto r1 = v | std::views::reverse | std::views::take(3);
// 10 9 8

auto r2 = v | std::views::take(3) | std::views::reverse;
// 3 2 1

두 코드는 의미가 다릅니다. r1은 “뒤에서부터 3개”, r2는 “앞에서 3개를 뒤집은 것”입니다. reverse는 원본이 양방향 범위이면 원소를 복사하지 않고 순회 방향만 바꾸므로 어느 쪽이든 비용은 거의 없습니다.

순서가 비용에 영향을 주는 것은 filter와 transform의 상대 위치입니다. filter를 먼저 두면 조건을 통과한 원소에만 transform이 적용되지만, transform을 먼저 두면 버려질 원소까지 모두 변환하고, 앞에서 설명했듯 통과한 원소는 변환 함수가 두 번 호출됩니다. 반면 take(n)은 위치에 따라 의미 자체가 바뀝니다. filter | take(3)은 “조건을 만족하는 처음 3개”이고 지연 평가 덕분에 3개를 찾는 즉시 멈추지만, take(3) | filter는 “앞의 3개 중 조건을 만족하는 것”이라 결과가 다릅니다. 비용보다 먼저 원하는 의미가 어느 쪽인지를 기준으로 순서를 정해야 합니다.

어댑터 조합

namespace vws = std::views;

// 파이프라인
auto pipeline = vws::filter(pred) 
              | vws::transform(func) 
              | vws::take(n);

// 적용
std::vector<int> v = {1, 2, 3, 4, 5};
auto result = v | pipeline;

부분 적용된 어댑터끼리도 파이프 연산자로 미리 조합할 수 있습니다. vws::filter(pred) | vws::transform(func) | vws::take(n)처럼 범위 없이 어댑터만 먼저 이어붙이면, 그 자체가 하나의 재사용 가능한 “파이프라인 객체”가 되어 나중에 원하는 범위에 한 번에 적용할 수 있습니다. 여러 함수에서 동일한 가공 절차(예: “유효하지 않은 레코드 걸러내고, 정규화하고, 상위 N개만”)를 반복해서 쓴다면 이렇게 조합해둔 파이프라인을 상수나 헬퍼 함수로 노출하는 편이 코드 중복을 줄여줍니다.

참고 자료

FAQ

어댑터 체인이 길어지면 컴파일 시간에 영향이 있나요?

있습니다. 각 어댑터가 템플릿으로 중첩되면서 타입이 깊게 쌓이고, 람다 하나하나가 고유 타입을 만들기 때문에 체인이 길어질수록 컴파일러가 처리해야 할 템플릿 인스턴스화 양도 늘어납니다. 실무에서는 자주 반복되는 파이프라인 조합을 헬퍼 함수로 추출해 재사용하면 컴파일 시간 부담을 줄일 수 있습니다.