C++ 반복자: 종류와 연산, iterator_traits, 범위 기반 for의 디슈가링

이 글의 핵심

반복자는 알고리즘과 컨테이너를 잇는 인터페이스라서, 카테고리를 잘못 이해하면 커스텀 반복자가 std::sort나 뷰와 맞물리지 않습니다. 필터·변환 반복자를 직접 만들어 보고, back_insert_iterator·move_iterator·counted_iterator 같은 어댑터와 센티널 범위를 언제 쓰는지 정리합니다.

반복자 기본

반복자(iterator) 는 컨테이너 요소를 순회하는 객체입니다. 범위 기반 for와 vector에서 begin/end로 사용되며, size_t·ptrdiff_t로 거리·인덱스를 다룰 때와 Composite 패턴으로 트리 순회를 통일할 때도 자주 씁니다.

반복자 개념도

graph LR
    A[Container] --> B[begin]
    A --> C[end]

    B --> D[Iter]
    D -->|++| E[Iter]
    E -->|++| F[Iter]
    F -->|++| C

    D -->|*| G[Elem 1]
    E -->|*| H[Elem 2]
    F -->|*| I[Elem 3]
#include <vector>
using namespace std;

int main() {
    vector<int> v = {1, 2, 3, 4, 5};

    // begin/end
    for (auto it = v.begin(); it != v.end(); ++it) {
        cout << *it << " ";
    }
    cout << endl;

    // 역방향
    for (auto it = v.rbegin(); it != v.rend(); ++it) {
        cout << *it << " ";  // 5 4 3 2 1
    }
}

반복자 종류

반복자 계층 구조

graph TD
    A[Input Iterator] --> C[Forward Iterator]
    B[Output Iterator] --> C
    C --> D[Bidirectional Iterator]
    D --> E[Random Access Iterator]
    E --> F[Contiguous Iterator C++20]

    A -.->|읽기 전용| A1[istream_iterator]
    B -.->|쓰기 전용| B1[ostream_iterator]
    C -.->|단방향| C1[forward_list]
    D -.->|양방향| D1[list, set, map]
    E -.->|임의 접근| E1[vector, deque, array]
    F -.->|연속 메모리| F1[vector, array, string]

반복자 기능 비교표

반복자 타입읽기쓰기전진후진임의 접근예시 컨테이너
Input✅❌✅❌❌istream_iterator
Output❌✅✅❌❌ostream_iterator
Forward✅✅✅❌❌forward_list
Bidirectional✅✅✅✅❌list, set, map
Random Access✅✅✅✅✅vector, deque

Input Iterator

// 읽기 전용, 한 번만 순회
istream_iterator<int> in(cin);
istream_iterator<int> eof;

vector<int> v(in, eof);  // 입력 읽기

Output Iterator

// 쓰기 전용
ostream_iterator<int> out(cout, " ");

vector<int> v = {1, 2, 3};
copy(v.begin(), v.end(), out);  // 1 2 3

Forward Iterator

// 읽기/쓰기, 여러 번 순회
forward_list<int> fl = {1, 2, 3};

for (auto it = fl.begin(); it != fl.end(); ++it) {
    *it *= 2;
}

Bidirectional Iterator

// 양방향 이동
list<int> l = {1, 2, 3, 4, 5};

auto it = l.end();
--it;  // 마지막 원소
cout << *it << endl;  // 5

Random Access Iterator

// 임의 접근
vector<int> v = {1, 2, 3, 4, 5};

auto it = v.begin();
it += 3;  // 3칸 이동
cout << *it << endl;  // 4

cout << v[2] << endl;  // 3

카테고리별 연산·복잡도 요구사항(표준 개념과의 대응)

표준은 반복자를 연산 집합으로 정의합니다. 알고리즘이 RandomAccessIterator를 요구하면 +=, -, <, [] 등이 상수 시간에 동작해야 하며, BidirectionalIterator까지만 되면 advance가 선형 시간이 될 수 있습니다. Contiguous Iterator(C++20)는 to_address로 원시 포인터로 환원 가능한 임의 접근 반복자로, vector·array·string의 일반 반복자가 여기에 해당합니다.

카테고리핵심 추가 연산알고리즘에서의 의미
Input==, !=, *(읽기), ++(한 번 소비)단일 패스, 동일 구간 재순회 보장 없음
Output*(쓰기), ++쓰기 전용, 읽기와 섞으면 제약 많음
ForwardInput+Output 성격의 다회 순회동일한 순서로 재현 가능한 읽기
Bidirectional--역방향 한 칸, prev가 정말 O(1)
Random Access+=, -, [], < 등distance·advance가 상수 시간
Contiguous(C++20)메모리 연속SIMD·저수준 API와 연동

이 구분이 실무에서 중요한 이유는 알고리즘이 카테고리에 따라 다른 구현을 고르기 때문입니다. std::distance(l.begin(), l.end())는 list에서 원소 수만큼 ++를 반복하는 O(N)이고, vector에서는 뺄셈 한 번인 O(1)입니다. 그래서 list에서 루프 안에 std::distance나 std::next(it, i)를 넣으면 겉보기에는 O(N) 코드가 O(N²)이 됩니다. 반대로 std::sort는 임의 접근을 요구하므로 list에 넣으면 no match for 'operator-' 같은 긴 템플릿 오류가 나고, 이때는 멤버 함수 l.sort()를 써야 합니다.

C++20 Ranges에서는 위 분류가 std::input_iterator / forward_iterator / bidirectional_iterator / random_access_iterator / contiguous_iterator 등 concept으로 재정리되었으며, 커스텀 반복자는 연산자뿐 아니라 의미론(복사 횟수, 무효화)까지 맞춰야 합니다. 자세한 문법 요구사항은 cppreference의 LegacyIterator 계열 문서를 기준으로 삼는 것이 안전합니다.

실전 예시

커스텀 반복자

class Range {
private:
    int current;
    int last;   // 멤버 함수 end()와 같은 이름을 쓰면 컴파일 오류
    
public:
    class Iterator {
    private:
        int value;

    public:
        using iterator_category = forward_iterator_tag;
        using value_type = int;
        using difference_type = ptrdiff_t;
        using pointer = void;
        using reference = int;   // operator*가 값을 반환하므로 참조 타입이 아님
        
        Iterator(int v) : value(v) {}

        int operator*() const { return value; }

        Iterator& operator++() {
            ++value;
            return *this;
        }

        bool operator!=(const Iterator& other) const {
            return value != other.value;
        }
    };

    Range(int start, int end) : current(start), last(end) {}

    Iterator begin() { return Iterator(current); }
    Iterator end() { return Iterator(last); }
};

int main() {
    for (int x : Range(0, 10)) {
        cout << x << " ";  // 0 1 2 ... 9
    }
}

이 Iterator는 범위 기반 for에 필요한 세 가지(*, 전위 ++, !=)만 구현한 최소 반복자입니다. range-for에서는 잘 동작하지만, forward_iterator_tag를 선언했다고 해서 표준 알고리즘과 맞물리는 것은 아닙니다. std::find(r.begin(), r.end(), 5)처럼 알고리즘에 넣으면 ==와 후위 ++, 기본 생성자가 없어 컴파일 오류가 나고, C++20 std::forward_iterator 개념을 검사하면 std::sentinel_for/std::equality_comparable 제약 실패로 떨어집니다. 원래 예제에서는 reference를 int&로 적어 두었는데, operator*가 값을 반환하므로 선언이 거짓이었습니다. 이런 불일치는 컴파일은 되다가 iterator_traits를 보는 알고리즘에서만 이상하게 터지므로, 타입 별칭은 실제 연산자 시그니처와 반드시 맞춰야 합니다.

제가 커스텀 반복자를 리뷰할 때 가장 먼저 붙여 보는 것은 static_assert(std::forward_iterator<Range::Iterator>); 한 줄입니다. 이 한 줄이 실패하면 컴파일러가 어떤 요구사항이 빠졌는지 알려 주므로, 알고리즘에 넣었을 때 수백 줄짜리 오류를 해독하는 것보다 훨씬 빠르게 빈 곳을 찾을 수 있습니다.

필터 반복자

template<typename Iter, typename Pred>
class FilterIterator {
private:
    Iter current;
    Iter end;
    Pred predicate;

    void advance() {
        while (current != end && !predicate(*current)) {
            ++current;
        }
    }

public:
    FilterIterator(Iter begin, Iter end, Pred pred)
        : current(begin), end(end), predicate(pred) {
        advance();
    }

    auto operator*() const { return *current; }

    FilterIterator& operator++() {
        ++current;
        advance();
        return *this;
    }

    bool operator!=(const FilterIterator& other) const {
        return current != other.current;
    }
};

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

    auto isEven = [](int x) { return x % 2 == 0; };

    FilterIterator begin(v.begin(), v.end(), isEven);
    FilterIterator end(v.end(), v.end(), isEven);

    for (auto it = begin; it != end; ++it) {
        cout << *it << " ";  // 2 4 6 8 10
    }
}

필터 반복자에는 설계상 함정이 두 가지 있습니다. 첫째, end를 반복자 안에 들고 있어야 하므로 반복자 크기가 기반 반복자의 두 배 이상이 되고, 조건자(predicate)까지 복사됩니다. 둘째, 생성자와 ++에서 조건을 만족하는 원소를 찾을 때까지 앞으로 가기 때문에 begin()을 만드는 것 자체가 O(N)일 수 있습니다. 표준 views::filter가 첫 begin() 결과를 캐시하는 것도 이 비용 때문이며, 그 캐시 때문에 filter 뷰는 const로 순회할 수 없다는 제약이 생겼습니다. 이 예제는 --를 제공하지 않으므로 기반 반복자가 양방향이어도 전진 전용으로 동작합니다.

변환 반복자

template<typename Iter, typename Func>
class TransformIterator {
private:
    Iter current;
    Func transform;

public:
    TransformIterator(Iter it, Func f) : current(it), transform(f) {}

    auto operator*() const {
        return transform(*current);
    }

    TransformIterator& operator++() {
        ++current;
        return *this;
    }

    bool operator!=(const TransformIterator& other) const {
        return current != other.current;
    }
};

int main() {
    vector<int> v = {1, 2, 3, 4, 5};

    auto square = [](int x) { return x * x; };

    TransformIterator begin(v.begin(), square);
    TransformIterator end(v.end(), square);

    for (auto it = begin; it != end; ++it) {
        cout << *it << " ";  // 1 4 9 16 25
    }
}

변환 반복자의 operator*는 값을 새로 계산해 prvalue로 반환합니다. 그래서 *it = 10; 같은 쓰기는 의미가 없고, reference가 진짜 참조가 아니라서 C++17 기준으로는 입력 반복자(Input)로만 분류됩니다. C++20의 views::transform이 iterator_category와 iterator_concept를 따로 두는 이유가 바로 이것입니다. 개념상으로는 기반 반복자처럼 임의 접근이지만 레거시 카테고리는 더 약하게 보고합니다.

반복자 연산

반복자 연산 함수 비교

함수시간 복잡도지원 반복자설명
advance(it, n)O(1) ~ O(N)모든 반복자n칸 이동 (제자리 수정)
distance(first, last)O(1) ~ O(N)Input 이상두 반복자 간 거리
next(it, n=1)O(1) ~ O(N)Input 이상n칸 앞 반복자 반환
prev(it, n=1)O(1) ~ O(N)Bidirectional 이상n칸 뒤 반복자 반환
iter_swap(it1, it2)O(1)Forward 이상두 반복자 값 교환
vector<int> v = {1, 2, 3, 4, 5};

auto it = v.begin();

// 전진
advance(it, 3);  // 3칸 이동
cout << *it << endl;  // 4

// 거리
auto dist = distance(v.begin(), v.end());
cout << dist << endl;  // 5

// 다음/이전
auto next_it = next(it);
auto prev_it = prev(it);

advance vs next 차이:

graph LR
    A[it = v.begin] --> B{Which?}

    B -->|advance it, 3| C[Modify it]
    C --> D[it = begin+3]

    B -->|next it, 3| E[Return new]
    E --> F[it = begin]
    E --> G[return = begin+3]

이터레이터 특성(iterator_traits)과 타입 정보

템플릿 알고리즘은 구체 반복자 타입을 몰라도 iterator_traits<Iter>로 값 타입·차이 타입·카테고리를 얻습니다. C++98 시절에는 iterator 클래스에 typedef 다섯 종(iterator_category, value_type, difference_type, pointer, reference)을 붙이는 패턴이 표준이었으며, 포인터는 iterator_traits<T*> 부분 특수화로 동일 정보를 제공합니다.

iterator_category와 C++20 iterator_concept

C++17까지는 random_access_iterator_tag 등 태그 타입이 카테고리의 전부였습니다. C++20부터 반복자 타입은 멤버 iterator_concept로 contiguous_iterator_tag 같은 더 정밀한 개념 태그를 노출할 수 있습니다(포인터는 iterator_traits<T*>::iterator_concept로 제공). C++20 반복자 개념(std::random_access_iterator 등)과 std::ranges 알고리즘은 우선 iterator_concept, 없으면 iterator_category를 보고 판단합니다. 반면 std::sort 같은 레거시 알고리즘은 여전히 iterator_category만 봅니다. 그래서 한 반복자가 두 세계에서 서로 다른 카테고리로 취급될 수 있습니다. 태그 디스패치와 연결해 이해하면 컴파일 타임 분기의 실체가 보입니다.

#include <iterator>
#include <vector>
#include <iostream>
#include <typeinfo>

template <class Iter>
void describe() {
    using Tr = std::iterator_traits<Iter>;
    // C++20: 일부 구현에서 Tr::iterator_concept로 더 세분된 개념을 추가로 노출
    std::cout << "iterator_category: "
              << typeid(typename Tr::iterator_category).name() << '\n';
}

int main() {
    describe<std::vector<int>::iterator>();
}

pointer·reference와 프록시 반복자

일부 컨테이너(vector<bool> 등)는 프록시 참조를 쓰므로 reference가 정말 T&가 아닐 수 있습니다. 이 경우 auto로 역참조 결과를 받는 방식과, 연산자 -> 체인이 기대와 다를 수 있으니 범위 기반 for의 auto& 선택과 함께 읽는 것이 좋습니다. 연속 반복자가 아니면 std::to_address 사용도 제한적입니다.

std::iter_difference_t, std::iter_value_t (C++20)

C++20에서는 iterator_traits를 직접 꺼내기보다 std::iter_value_t<It>, std::iter_reference_t<It>, std::iter_difference_t<It> 같은 별칭 템플릿으로 가독성을 높입니다. Ranges 알고리즘 시그니처는 이 타입들을 기본으로 가정합니다.

#include <iterator>
#include <vector>

template <class It>
void use(It first, It last) {
    std::iter_difference_t<It> n = std::distance(first, last);
    (void)n;
    std::iter_value_t<It> copy = *first;
    (void)copy;
}

이터레이터 어댑터 패턴

어댑터는 기존 반복자·출력 대상을 감싸 다른 연산 모델을 제공합니다. 직접 FilterIterator를 작성하기 전에 표준 조합을 검토하는 것이 실무에서 안전합니다.

삽입 반복자(back_insert_iterator 등)

std::back_inserter, std::front_inserter, std::inserter는 컨테이너에 push_back 등을 호출하는 출력 반복자입니다. copy(src.begin(), src.end(), back_inserter(dest))는 미리 공간을 잡지 않고 확장 삽입할 때 유용합니다. 단, vector 재할당으로 인한 무효화를 염두에 두어야 합니다.

#include <vector>
#include <iterator>
#include <algorithm>

int main() {
    std::vector<int> a{1, 2, 3}, b;
    std::copy(a.begin(), a.end(), std::back_inserter(b));
}

reverse_iterator·move_iterator

reverse_iterator는 내부에 기반 반복자를 들고 있으면서 *rit가 *std::prev(rit.base())를 돌려주도록 구현됩니다. 즉 rbegin()의 base()는 end()이고, 역참조할 때 한 칸 앞 원소를 봅니다. 이 한 칸 차이 때문에 역방향으로 찾은 원소를 erase할 때 v.erase(rit.base())라고 쓰면 엉뚱한 원소(찾은 것의 다음)가 지워집니다. 올바른 형태는 v.erase(std::next(rit).base())입니다.

move_iterator는 역참조 결과가 T&&(rvalue 참조)인 반복자 어댑터입니다. std::vector<std::string> dst(std::make_move_iterator(src.begin()), std::make_move_iterator(src.end()));처럼 쓰면 원소를 복사하지 않고 이동하므로, 큰 문자열이나 unique_ptr처럼 복사가 비싸거나 불가능한 원소를 옮길 때 유용합니다. 이동된 뒤 src의 원소는 유효하지만 값이 지정되지 않은(valid but unspecified) 상태가 됩니다.

counted_iterator + default_sentinel (C++20)

반복 횟수와 끝을 나타내는 센티널을 분리해, 길이만 알고 끝 반복자는 다른 타입인 패턴을 안전하게 표현합니다. Ranges와 함께 쓰면 끝 타입이 다른 범위(sentinel range)를 자연스럽게 다룹니다.

#include <iterator>
#include <algorithm>
#include <cstddef>

int main() {
    int a[] = {1, 2, 3, 4, 5};
    auto first = std::counted_iterator<int*>(a, 5);
    std::for_each(first, std::default_sentinel, [](int x) { (void)x; });
}

뷰·파이프라인과의 역할 분담

C++20 std::ranges::views::filter, transform 등은 커스텀 반복자를 직접 만들지 않고도 지연 평가 파이프라인을 제공합니다. C++20 Ranges 기초와 함께 보면, “어댑터를 수작업으로 구현할지, 뷰로 조합할지” 판단이 쉬워집니다.

범위 기반 for의 디슈가링(desugaring)

range-for는 문법적 설탕(syntactic sugar)에 해당하지만, C++17에서 규칙이 정교해졌습니다. 핵심은 범위 표현식을 한 번 평가해 임시를 잡으며, begin/end를 ADL로 찾는다는 점입니다.

C++17 이후의 개략적 형태

대략 다음과 같은 구조입니다(실제 표준 문구와 begin/end 후보 집합은 더 길고, 배열·브레이스 초기화 등 특례가 있습니다).

// for (for-range-declaration : for-range-initializer) 문
// 내부적으로는 개념적으로:
// auto&& __range = for-range-initializer;
// auto __begin = begin(__range);   // 멤버 begin이 있으면 그것, 없으면 ADL로 찾은 begin
// auto __end   = end(__range);
// for (; __begin != __end; ++__begin) {
//     for-range-declaration = *__begin;
//     ...
// }

중요한 결과: 클래스에 begin이나 end라는 이름의 멤버가 하나라도 있으면 멤버 버전이 쓰이고, 없을 때만 ADL로 자유 함수 begin/end를 찾습니다. 이때 일반 이름 검색은 하지 않으므로, 전역에 begin을 선언해도 타입이 속한 네임스페이스가 아니면 발견되지 않습니다. C++17에서 바뀐 점은 __begin과 __end를 따로 선언해 서로 다른 타입(센티널)을 허용한 것입니다. 임시 벡터를 돌릴 때 __range(auto&&)가 임시에 묶여 수명이 루프 전체로 연장되는 이유도 여기에서 나옵니다.

다만 수명 연장은 for-range-initializer의 최종 결과에만 적용됩니다. for (auto x : make_vector_of_vectors()[0])처럼 임시의 일부를 순회하면, 바깥 임시는 초기화식이 끝나는 순간 소멸하고 __range는 이미 해제된 내부 벡터를 가리킵니다. for (char c : getName().c_str())류도 같은 문제입니다. 이 함정은 C++23(P2718)에서 초기화식 안의 모든 임시가 루프 끝까지 살도록 고쳐졌지만, C++20 이하로 컴파일한다면 중간 결과를 먼저 지역 변수에 담아야 합니다. 세부는 범위 기반 for 전용 글의 예시와 함께 보는 것을 권합니다.

begin ≠ 멤버만

일부 서드파티 버퍼는 멤버가 아닌 비멤버 begin만 제공합니다. C++17 규칙은 이런 타입도 range-for에 끼워 넣기 쉽게 설계되었습니다. 반대로, 잘못된 end 타입(센티널과 반복자 비교 불가 등)은 템플릿 에러로 드러나므로, 커스텀 범위는 같은 연산으로 비교 가능한 begin/end 쌍을 맞추는 것이 첫 번째 과제입니다.

프로덕션 이터레이터 패턴

end() 캐싱과 성능

매 루프마다 end()를 호출하는 것은 인라인·최적화되면 문제 없는 경우가 많지만, 디버그 빌드나 복잡한 컨테이너에서는 auto e = c.end()로 한 번만 잡아 두는 패턴이 읽기도 좋습니다. 범위 for는 이를 표준적으로 처리합니다.

센티널 범위와 알고리즘 선택

C++20 이후에는 begin과 end가 다른 타입인 범위가 일반적입니다(널 종료 문자열, istream 뷰 등). 이때 레거시 std::sort(first, last)처럼 동일 타입을 가정한 API와 충돌하지 않도록, std::ranges:: 알고리즘을 쓰는 편이 안전합니다.

예외·무효화·noexcept

저수준 반복자 루프는 예외 안전성과 반복자 무효화 규칙이 합쳐져 버그가 나기 쉽습니다. vector에서 erase 후 erase 반환 반복자를 사용하거나, 삽입으로 재할당이 일어날 수 있으면 인덱스 기반으로 바꾸는 등, 무효화를 먼저 정리한 뒤 반복자 설계를 하는 것이 좋습니다.

“직접 반복자 클래스” 대신

내부 라이브러리가 아니라면 Boost.Iterator의 iterator_facade류, 또는 인덱스 + 스팬, std::ranges 뷰로 요구사항을 줄이는 편이 유지보수 비용이 낮습니다. 꼭 필요할 때만 최소 연산자 집합으로 forward_iterator_tag 수준을 목표로 삼으며, 동치 관계(==)·복사 후 독립성을 문서화하세요.

자주 발생하는 문제

무효화된 반복자

// ❌ 반복자 무효화
vector<int> v = {1, 2, 3, 4, 5};

for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it == 3) {
        v.erase(it);  // it 무효화!
        // ++it;  // 위험!
    }
}

// ✅ erase 반환값 사용
for (auto it = v.begin(); it != v.end();) {
    if (*it == 3) {
        it = v.erase(it);  // 다음 반복자 반환
    } else {
        ++it;
    }
}

end() 역참조

// ❌ end() 역참조
vector<int> v = {1, 2, 3};
auto it = v.end();
// cout << *it << endl;  // UB

// ✅ end() 체크
if (it != v.end()) {
    cout << *it << endl;
}

반복자 타입 불일치

// ❌ 타입 불일치
vector<int> v;
list<int> l;

// auto it = v.begin();
// it = l.begin();  // 에러

// ✅ 올바른 타입
auto vit = v.begin();
auto lit = l.begin();

FAQ

Q1: 컨테이너별 무효화 규칙을 짧게 정리하면?

A:

  • vector/string: 삽입으로 재할당이 일어나면 모든 반복자·참조가 무효화되고, 재할당이 없으면 삽입 지점 이후만 무효화됩니다. erase는 지운 지점과 그 이후를 무효화합니다.
  • deque: 양 끝 삽입은 모든 반복자를 무효화하지만 원소 참조는 유지됩니다. 중간 삽입·삭제는 전부 무효화됩니다.
  • list/map/set: 삭제된 원소를 가리키던 것만 무효화됩니다.
  • unordered_map/unordered_set: 삽입으로 재해시(rehash)가 일어나면 모든 반복자가 무효화되지만, 원소 참조와 포인터는 유지됩니다.

Q2: 디버그 모드에서 무효화 버그를 잡을 수 있나요?

A: 네. libstdc++는 -D_GLIBCXX_DEBUG, MSVC는 디버그 빌드의 기본 반복자 검사(_ITERATOR_DEBUG_LEVEL=2)로 무효화된 반복자 사용이나 다른 컨테이너 반복자 비교를 런타임에 잡아 줍니다. libstdc++ 디버그 모드는 attempt to increment a singular iterator 같은 메시지를 내고 중단합니다. 다만 _GLIBCXX_DEBUG는 컨테이너 ABI를 바꾸므로 이 매크로 없이 빌드한 라이브러리와 섞어 링크하면 안 됩니다.

Q3: 반복자 성능은 포인터보다 느린가요?

A: 최적화 빌드에서는 vector 반복자가 포인터 래퍼라 인라인화되어 차이가 거의 없습니다. 디버그 빌드에서는 위의 검사 기능 때문에 눈에 띄게 느릴 수 있으므로, 성능 측정은 항상 릴리스 빌드로 해야 합니다.

관련 글: 범위 기반 for, C++ std::vector 기초, size_t·ptrdiff_t, Composite 패턴.


같이 보면 좋은 글