순환 참조는 메모리 누수를 발생시킬 수 있습니다

러스트의 메모리 안정성 보장은 (메모리 누수 (memory leak) 라고도 알려져 있는) 뜻하지 않게 해제되지 않는 메모리를 생성하기 어렵게 만들지만, 불가능하게 만드는 것은 아닙니다. 메모리 누수를 완전히 방지하는 것은 러스트가 보장하는 것 중 하나가 아닌데, 이는 메모리 누수도 러스트에서는 메모리 안정성에 포함됨을 의미합니다. Rc<T>RefCell<T>를 사용하면 러스트에서 메모리 누수가 허용되는 것을 알 수 있습니다: 즉 아이템들이 서로를 순환 참조하는 참조자를 만드는 것이 가능합니다. 이는 메모리 누수를 발생시키는데, 그 이유는 순환 고리 안의 각 아이템의 참조 카운트는 결코 0이 되지 않을 것이고, 그러므로 값들은 버려지지 않을 것이기 때문입니다.

순환 참조 만들기

예제 15-25의 List 열거형과 tail 메서드 정의를 시작으로 어떻게 순환 참조가 생길 수 있고, 이를 어떻게 방지하는지 알아봅시다:

파일명: src/main.rs

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {}

예제 15-25: RefCell<T>를 가지고 있어서 Cons 배리언트가 참조하는 것을 변경할 수 있는 cons 리스트 정의

예제 15-5의 List 정의의 또 다른 변형이 이용되고 있습니다. 이제 Cons 배리언트 내의 두 번째 요소는 RefCell<Rc<List>>인데, 이는 예제 15-24에서 했던 것처럼 i32 값을 변경하는 능력을 가진 대신, Cons 배리언트가 가리키고 있는 List 값을 변경하길 원한다는 의미입니다. 또한 tail 메서드를 추가하여 Cons 배리언트를 갖고 있다면 두 번째 아이템에 접근하기 편하게 만들었습니다.

예제 15-26에서는 예제 15-25에서 사용한 main 함수를 추가하고 있습니다. 이 코드는 a에 리스트를 만들고 b에는 a의 리스트를 가리키고 있는 리스트를 만들어 넣었습니다. 그다음 a의 리스트가 b를 가리키도록 수정하는데, 이것이 순환 참조를 생성합니다. 이 과정에서 참조 카운트가 얼마인지 여러 곳에서 확인하기 위해 곳곳에 println! 구문들을 넣었습니다.

파일명: src/main.rs

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {
    let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

    println!("a initial rc count = {}", Rc::strong_count(&a));
    println!("a next item = {:?}", a.tail());

    let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

    println!("a rc count after b creation = {}", Rc::strong_count(&a));
    println!("b initial rc count = {}", Rc::strong_count(&b));
    println!("b next item = {:?}", b.tail());

    if let Some(link) = a.tail() {
        *link.borrow_mut() = Rc::clone(&b);
    }

    println!("b rc count after changing a = {}", Rc::strong_count(&b));
    println!("a rc count after changing a = {}", Rc::strong_count(&a));

    // Uncomment the next line to see that we have a cycle;
    // it will overflow the stack
    // println!("a next item = {:?}", a.tail());
}

예제 15-26: 두 개의 List 값이 서로를 가리키는 순환 참조 생성하기

초깃값 리스트 5, Nil을 가진 List 값을 갖는 Rc<List> 인스턴스를 만들어 a 변수에 넣었습니다. 그다음 Rc<List> 인스턴스에 만들어서 b에 넣었는데, 여기에는 10과 a의 리스트를 가리키고 있는 또 다른 List 값이 있습니다.

a를 수정하여 이것이 Nil 대신 b를 가리키도록 하였는데, 이렇게 순환이 만들어집니다. 이는 tail 메서드를 사용하여 a에 있는 RefCell<Rc<List>>로부터 참조자를 얻어오는 식으로 이루어졌는데, 이것을 link라는 변수에 넣었습니다. 그다음 RefCell<Rc<List>>borrow_mut 메서드를 사용하여 Nil 값을 가지고 있는 Rc<List> 내부의 값을 bRc<List>로 바꾸었습니다.

잠시 마지막 println! 문이 실행되지 않도록 주석 처리하고서 이 코드를 실행시키면 아래와 같은 출력을 얻게 됩니다:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished dev [unoptimized + debuginfo] target(s) in 0.53s
     Running `target/debug/cons-list`
a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2

a의 리스트가 b를 가리키도록 변경한 이후 abRc<List> 인스턴스의 참조 카운트는 둘 다 2입니다. main의 끝에서 러스트는 b를 버리는데, 이는 bRc<List> 참조 카운트를 2에서 1로 줄입니다. Rc<List>가 힙에 보유한 메모리는 이 시점에서 해제되지 않을 것인데, 그 이유는 참조 카운트가 0이 아닌 1이기 때문입니다. 그런 다음 러스트는 a를 버리고, 이는 마찬가지로 aRc<List> 인스턴스가 가진 참조 카운트를 2에서 1로 줄입니다. 이 인스턴스의 메모리 또한 버려질 수 없는데, 왜냐하면 이쪽의 Rc<List> 인스턴스도 여전히 무언가를 참조하기 때문입니다. 리스트에 할당된 메모리는 정리되지 않은 채 영원히 남을 것입니다. 이러한 순환 참조를 시각화하기 위해 그림 15-4의 다이어그램을 만들었습니다.

Reference cycle of lists

그림 15-4: 리스트 ab가 서로를 가리키고 있는 순환 참조

만일 여러분이 마지막 println!의 주석을 해제하고 프로그램을 실행해 보면, 러스트는 a를 가리키고 있는 b를 가리키고 있는 a를 가리키고 있는 등등 스택 오버플로우가 날 때까지 이 순환을 출력하려 할 것입니다.

실제 프로그램과 비교했을 때 이 예제에서의 순환 참조 생성 결과는 그렇게까지 심각하진 않습니다: 순환 참조가 생성된 직후 프로그램이 종료되니까요. 하지만, 더 복잡한 프로그램이 많은 양의 메모리를 순환 참조하여 할당하고 오랜 시간 동안 이걸 가지고 있게 되면, 프로그램은 필요한 양보다 더 많은 메모리를 사용하게 되고 사용 가능한 메모리를 다 써버리게 되어 시스템을 멈추게 할지도 모릅니다.

순환 참조를 만드는 것은 쉽게 이루어지지는 않지만, 불가능한 것도 아닙니다. 만일 여러분이 Rc<T> 값을 가지고 있는 RefCell<T> 혹은 그와 유사하게 내부 가변성 및 참조 카운팅 기능이 있는 타입들의 중첩된 조합을 사용한다면, 여러분이 직접 순환을 만들지 않음을 보장해야 합니다; 이 순환을 찾아내는 것을 러스트에 의지할 수는 없습니다. 순환 참조를 만드는 것은 프로그램의 논리적 버그로서, 자동화된 테스트, 코드 리뷰, 그 외 소프트웨어 개발 연습 등을 통해 최소화해야 할 것입니다.

순환 참조를 피하는 또 다른 해결책은 데이터 구조를 재구성하여 어떤 참조자는 소유권을 갖고 어떤 참조자는 그렇지 않도록 하는 것입니다. 결과적으로 몇 개의 소유권 관계와 몇 개의 소유권 없는 관계로 이루어진 순환을 만들 수 있으며, 소유권 관계들만이 값을 버릴지 말지에 관해 영향을 주게 됩니다. 예제 15-25에서는 Cons 배리언트가 언제나 리스트를 소유하기를 원하므로, 데이터 구조를 재구성하는 것은 불가능합니다. 부모 노드와 자식 노드로 구성된 그래프를 이용한 예제를 살펴보면서 소유권 없는 관계가 순환 참조를 방지하는 적절한 방법이 되는 때가 언제인지 알아봅시다.

순환 참조 방지하기: Rc<T>Weak<T>로 바꾸기

지금까지 Rc::clone을 호출하는 것은 Rc<T> 인스턴스의 strong_count를 증가시키고, Rc<T> 인스턴스는 자신의 strong_count가 0이 된 경우에만 제거되는 것을 보았습니다. Rc::downgradeRc<T>의 참조자를 넣어서 호출하면 Rc<T> 인스턴스 내의 값을 가리키는 약한 참조 (weak reference) 를 만드는 것도 가능합니다. 강한 참조는 Rc<T> 인스턴스의 소유권을 공유할 수 있는 방법입니다. 약한 참조는 소유권 관계를 표현하지 않고, 약한 참조의 개수는 Rc<T> 인스턴스가 제거되는 경우에 영향을 주지 않습니다. 약한 참조가 포함된 순환 참조는 그 값의 강한 참조 개수를 0으로 만드는 순간 깨지게 되기 때문에, 순환 참조를 일으키지 않게 될 것입니다.

Rc::downgrade를 호출하면 Weak<T> 타입의 스마트 포인터를 얻게 됩니다. Rc::downgradeRc<T> 인스턴스의 strong_count를 1 증가시키는 대신 weak_count를 1 증가시킵니다. Rc<T> 타입은 strong_count와 유사한 방식으로 weak_count를 사용하여 Weak<T> 참조가 몇 개 있는지 추적합니다. 차이점은 Rc<T> 인스턴스가 제거되기 위해 weak_count가 0일 필요는 없다는 것입니다.

Weak<T>가 참조하고 있는 값이 이미 버려졌을지도 모르기 때문에, Weak<T>가 가리키고 있는 값으로 어떤 일을 하기 위해서는 그 값이 여전히 존재하는지를 반드시 확인해야 합니다. 이를 위해 Weak<T>upgrade 메서드를 호출하는데, 이 메서드는 Option<Rc<T>>를 반환할 것입니다. 만일 Rc<T> 값이 아직 버려지지 않았다면 Some 결과를 얻게 될 것이고 Rc<T> 값이 버려졌다면 None 결괏값을 얻게 될 것입니다. upgradeOption<T>를 반환하기 때문에, 러스트는 Some의 경우와 None의 경우가 반드시 처리되도록 할 것이고, 따라서 유효하지 않은 포인터는 없을 것입니다.

예제로 리스트처럼 어떤 아이템이 오직 다음 아이템에 대해서만 알고 있는 데이터 구조 말고, 자식 아이템 그리고 부모 아이템에 대해 모두 알고 있는 아이템을 갖는 트리를 만들어 보겠습니다.

트리 데이터 구조 만들기: 자식 노드를 가진 Node

자기 자식 노드에 대해 알고 있는 노드로 이루어진 트리를 만드는 것으로 시작해 보겠습니다. i32값과 함께 자식 Node 값들의 참조자들도 가지고 있는 Node라는 이름의 구조체를 만들겠습니다:

파일명: src/main.rs

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

Node가 자기 자식들을 소유하도록 하고, 이 소유권을 공유하여 트리의 각 Node에 직접 접근할 수 있도록 하고 싶습니다. 이렇게 하기 위해 Vec<T> 아이템이 Rc<Node> 타입의 값이 되도록 정의하였습니다. 또한 어떤 노드가 다른 노드의 자식이 되도록 수정하려고 Vec<Rc<Node>>RefCell<T>로 감싼 children을 갖도록 하였습니다.

그다음 예제 15-27처럼 이 구조체 정의를 이용하여 3 값을 갖고 자식 노드가 없는 leaf라는 이름의 Node 인스턴스, 그리고 5 값과 leaf를 자식으로 갖는 branch라는 이름의 인스턴스를 만들도록 하겠습니다:

파일명: src/main.rs

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

예제 15-27: 자식이 없는 leaf 노드와 이 leaf를 자식으로 갖는 branch 노드 만들기

leafRc<Node>를 복제하여 이를 branch 내에 저장했는데, 이는 leaf에 있는 Node의 소유자가 이제 둘이 되었다는 뜻입니다. branch로부터 branch.children를 통하여 leaf까지 접근할 수 있게 되었지만, leaf에서부터 branch로 접근할 방법은 없습니다. 그 원인은 leafbranch에 대한 참조자를 가지고 있지 않고 이들 간의 연관성을 알지 못하기 때문입니다. leaf에게 branch가 자신의 부모임을 알려주고 싶습니다. 이걸 다음에 해보겠습니다.

자식에서 부모로 가는 참조자 추가하기

자식 노드가 그의 부모를 알도록 하기 위해서는 parent 필드를 Node 구조체 정의에 추가할 필요가 있겠습니다. 문제는 parent의 타입을 결정하는 데에 있습니다. 여기에 Rc<T>를 넣게 되면 branch를 가리키고 있는 leaf.parentleaf를 가리키고 있는 branch.children으로 이루어진 순환 참조를 만들게 되며, 이들의 strong_count값을 결코 0이 되지 않게 하는 원인을 제공할 것이기 때문에, 여기에 Rc<T>를 사용할 수 없음을 알고 있습니다.

이 관계들을 다른 방식으로 생각해 보면, 부모 노드는 그의 자식들을 소유해야 합니다: 즉 만일 부모 노드가 버려지게 되면, 그의 자식 노드들도 또한 버려져야 합니다. 하지만, 자식은 그의 부모를 소유해서는 안 됩니다: 즉 자식 노드가 버려지더라도 그 부모는 여전히 존재해야 합니다. 이것이 바로 약한 참조를 위한 경우입니다!

따라서 Rc<T> 대신 Weak<T>를 이용하여, 특별히 RefCell<Weak<Node>>를 이용하여 parent의 타입을 만들겠습니다. 이제 Node 구조체 정의는 아래와 같이 생겼습니다:

파일명: src/main.rs

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

노드는 자신의 부모 노드를 참조할 수 있게 되겠지만 그 부모를 소유하지는 않습니다. 예제 15-28에서는 main을 업데이트하여 이 새로운 정의를 사용하도록 해서 leaf 노드가 자기 부모인 branch를 참조할 수 있는 방법을 갖도록 합니다:

파일명: src/main.rs

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

예제 15-28: 부모 노드 branch의 약한 참조를 갖는 leaf 노드

leaf 노드를 만드는 것이 parent 필드를 제외하고는 예제 15-27과 비슷해 보입니다: leaf는 부모 없이 시작돼서, 새 비어있는 Weak<Node> 참조자 인스턴스를 생성하였습니다.

이 시점에서 upgrade 메서드를 사용하여 leaf의 부모에 대한 참조자를 얻는 시도를 하면 None 값을 얻습니다. 첫 번째 println! 구문에서는 아래와 같은 출력을 보게 됩니다:

leaf parent = None

branch 노드를 생성할 때도 parent 필드에 새로운 Weak<Node> 참조자를 넣었는데, 이는 branch에게는 부모 노드가 없기 때문입니다. leaf는 여전히 branch의 자식 중 하나입니다. 일단 branchNode 인스턴스를 갖게 되면, leaf를 수정하여 자기 부모에 대한 Weak<Node> 참조자를 갖도록 할 수 있습니다. leafparent 필드의 RefCell<Weak<Node>>에 있는 borrow_mut 메서드를 사용하고, 그런 다음 Rc::downgrade 함수를 사용하여 branchRc<Node>로부터 branch에 대한 Weak<Node> 참조자를 생성하였습니다.

leaf의 부모를 다시 한번 출력할 때는 branch를 가지고 있는 Some 배리언트를 얻게 될 것입니다: 이제 leaf는 자기 부모에 접근할 수 있습니다! leaf를 출력할 때 예제 15-26에서와 같이 궁극적으로 스택 오버플로우로 끝나버리는 그 순환 문제도 피하게 되었습니다; Weak<Node> 참조자는 (Weak)로 출력됩니다:

leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })

무한 출력이 없다는 것은 이 코드가 순환 참조를 생성하지 않았음을 나타냅니다. 또한 Rc::strong_countRc::weak_count를 호출하여 얻은 값을 살펴보는 것으로도 알 수 있습니다.

strong_countweak_count의 변화를 시각화하기

새로운 내부 스코프를 만들고 branch의 생성 과정을 이 스코프로 옮겨서 Rc<Node> 인스턴스의 strong_countweak_count 값이 어떻게 변하는지 살펴봅시다. 그렇게 하면 branch가 만들어질 때와, 그 후 스코프 밖으로 벗어났을 때 어떤 일이 생기는지 알 수 있습니다. 수정본은 예제 15-29와 같습니다:

파일명: src/main.rs

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );

    {
        let branch = Rc::new(Node {
            value: 5,
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(vec![Rc::clone(&leaf)]),
        });

        *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

        println!(
            "branch strong = {}, weak = {}",
            Rc::strong_count(&branch),
            Rc::weak_count(&branch),
        );

        println!(
            "leaf strong = {}, weak = {}",
            Rc::strong_count(&leaf),
            Rc::weak_count(&leaf),
        );
    }

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );
}

예제 15-29: 내부 스코프에서 branch를 만들고 강한 참조 카운트와 약한 참조 카운트 시험하기

leaf가 생성된 다음, 이것의 Rc<Node>는 강한 참조 카운트 1개와 약한 참조 카운트 0개를 갖습니다. 내부 스코프에서 branch를 만들고 leaf와 연관짓게 되는데, 이때 카운트를 출력하면 branchRc<Node>는 강한 참조 카운트 1개와 (Weak<Node>branch를 가리키는 leaf.parent에 대한) 약한 참조 카운트 1개를 갖고 있을 것입니다. leaf 내의 카운트를 출력하면 강한 참조 카운트 2개를 갖고 있음을 보게 될 것인데, 이는 branch가 이제 branch.children에 저장된 leafRc<Node>에 대한 클론을 가지게 되었지만, 약한 참조는 여전히 0개이기 때문입니다.

내부 스코프가 끝나게 되면, branch는 스코프 밖으로 벗어나게 되고 Rc<Node>의 강한 참조 카운트는 0으로 줄어들게 되므로, 이것의 Node는 버려집니다. leaf.parent의 약한 참조 카운트 1개는 Node가 버려질지 말지와는 아무런 관계가 없으므로, 아무런 메모리 누수도 발생하지 않습니다!

만일 이 스코프의 끝부분 뒤에 leaf의 부모에 접근 시도를 한다면 다시 None을 얻게 될 것입니다. 프로그램의 끝부분에서, leafRc<Node>는 강한 참조 카운트 1개와 약한 참조 카운트 0개를 갖고 있는데, 이제 leaf 변수가 다시 Rc<Node>에 대한 유일한 참조자이기 때문입니다.

참조 카운트와 값 버리기를 관리하는 모든 로직은 Rc<T>Weak<T>, 그리고 이들의 Drop 트레이트에 대한 구현부에 만들어져 있습니다. 자식과 부모의 관계가 Weak<T> 참조자로 있어야 함을 Node의 정의에 특정함으로써, 여러분은 순환 참조와 메모리 누수를 만들지 않으면서 자식 노드를 가리키는 부모 노드 혹은 그 반대의 것을 만들 수 있습니다.

정리

이번 장에서는 러스트가 일반적인 참조자를 가지고 기본적으로 보장하는 것들과는 다른 보장과 절충안을 만들어 내기 위해 스마트 포인터를 사용하는 방법을 다루었습니다. Box<T> 타입은 알려진 크기를 갖고 있고 힙에 할당된 데이터를 가리킵니다. Rc<T> 타입은 힙에 있는 데이터에 대한 참조자의 개수를 추적하여 그 데이터가 여러 개의 소유자를 가질 수 있도록 합니다. 내부 가변성을 갖춘 RefCell<T> 타입은 불변 타입이 필요하지만 그 타입의 내부 값을 변경할 필요가 있을 때 사용할 수 있습니다; 이것은 또한 컴파일 타임 대신 런타임에 대여 규칙을 따르도록 강제합니다.

또한 DerefDrop 트레이트를 다루었는데, 이는 스마트 포인터의 수많은 기능을 활성화해 줍니다. 메모리 누수를 발생시킬 수 있는 순환 참조와, Weak<T>을 이용하여 이를 방지하는 방법도 탐구하였습니다.

이번 장이 여러분의 흥미를 자극하여 직접 여러분만의 스마트 포인터를 구현하고 싶어졌다면, ‘러스토노미콘’에서 더 유용한 정보를 확인하세요.

다음에는 러스트의 동시성에 대해 이야기해 보겠습니다. 심지어 몇 가지 새로운 스마트 포인터에 대해서도 배우게 될 것입니다.