Tree Borrows는 Stacked Borrows의 교훈을 바탕으로, 두 단계 대여의 본격적 지원과 가변 참조 유일성의 지연, raw 포인터의 범위·가변성 상속, UnsafeCell 처리의 단순화 등을 통해 더 적은 UB와 현실적 최적화의 균형을 추구하는 Rust의 새로운 앨리어싱 모델 제안이다. 이 글은 SB와의 차이점, 설계상의 트레이드오프, 최적화에 미치는 영향, 그리고 Unique 의미 부여의 가능성을 설명한다.
지난 가을부터 Neven이 Rust를 위한 새로운 앨리어싱(aliasing) 모델인 Tree Borrows를 개발하는 인턴십을 해왔다. 잠깐만, 라는 목소리가 들린다 — Rust에 이미 앨리어싱 모델이 있지 않나? Ralf가 계속 얘기하던 그 “Stacked Borrows” 말이야? 맞다, 있지만 Stacked Borrows는 가능한 앨리어싱 모델에 대한 제안들 중 하나일 뿐이며, 그리고 그것은 상당수의문제를안고있다. Tree Borrows의 목적은 Stacked Borrows에서 배운 교훈을 토대로 더 문제점이 적은 모델을 만들고, 공식 Rust 모델을 결정하기 전에 이러한 모델들에서 어떤 트레이드오프와 미세 조정이 가능한지 감을 잡기 위해 몇 가지 다른 설계 결정을 취하는 데 있다.
Neven은 자신의 블로그에 Tree Borrows에 대한 자세한 소개 글을 작성했으니 먼저 읽어보길 권한다. 그는 최근 RFMIG 미팅에서 이 내용을 발표했으며, 발표 영상도 여기에서 볼 수 있다. 이 글에서는 Stacked Borrows와의 차이점에 집중하겠다. 독자가 이미 Stacked Borrows를 알고 있으며 Tree Borrows에서 무엇이, 왜 달라지는지를 이해하고자 한다고 가정한다.
간단히 쓰기 위해, 때때로 Stacked Borrows는 SB로, Tree Borrows는 TB로 표기하겠다.
Tree Borrows의 가장 큰 새로움은 두 단계 대여(two-phase borrows)를 제대로 지원한다는 점이다. 두 단계 대여는 NLL과 함께 도입된 메커니즘으로, 아래와 같은 코드를 허용한다:
fn two_phase(mut x: Vec<usize>) {
x.push(x.len());
}
이 코드가 까다로운 이유는 다음과 같이 디설거(탈당)되기 때문이다:
fn two_phase(mut x: Vec<usize>) {
let arg0 = &mut x;
let arg1 = Vec::len(&x);
Vec::push(arg0, arg1);
}
이 코드는 명백히 일반적인 대여 검사 규칙을 위반한다. arg0에 &mut x를 빌려둔 상태에서 x.len()을 호출하고 있기 때문이다! 그럼에도 컴파일러는 이 코드를 허용한다. 작동 방식은 arg0에 저장된 &mut x를 두 단계로 나누는 데 있다. 예약(reservation) 단계에서는 다른 참조를 통해 x를 여전히 읽을 수 있다. 실제로 arg0에 쓰기(또는 그에 쓸 수 있는 함수를 호출)해야 할 때에만 그 참조가 “활성화(activated)”되고, 그 시점부터(그리고 그 대여의 수명 끝까지)는 다른 참조를 통한 접근이 허용되지 않는다. 자세한 내용은 RFC와 rustc-dev-guide의 두 단계 대여 챕터를 참고하라. 이 글에서 중요한 점은, 메서드 호출에 의해 묵시적으로 대여가 발생할 때(예: x.push(...)), Rust가 이를 두 단계 대여로 취급한다는 것이다. 코드에 &mut를 직접 쓰면, 이는 “예약” 단계가 없는 일반 가변 참조로 취급된다.
앨리어싱 모델의 관점에서 두 단계 대여는 큰 문제다. x.len()이 실행될 때쯤이면 arg0는 이미 존재하고, 가변 참조는 다른 포인터를 통한 읽기를 허용하지 않는 게 맞기 때문이다. 그래서 Stacked Borrows는 여기서 아예 포기하고 두 단계 대여를 거의 raw 포인터처럼 취급한다. 이는 만족스럽지 않으므로, Tree Borrows에서는 두 단계 대여를 제대로 지원한다. 더 나아가, 우리는 모든 가변 참조를 두 단계 대여로 취급한다. 이는 대여 검사기가 허용하는 것보다 더 관대하지만, 가변 참조를 완전히 일관된 방식으로 다룰 수 있게 해준다. (이 부분은 조정 가능하지만, 곧 보게 되겠지만 이 결정은 예상치 못한 큰 이점을 가져다준다.)
바로 이것이 트리가 필요한 이유다. arg0와 Vec::len에 전달된 참조는 둘 다 x의 자식이다. 여기서는 부모-자식 관계를 표현하기에 스택만으로는 충분하지 않다. 일단 트리 사용이 확립되면, 두 단계 대여의 모델링은 꽤 직관적이다. 이들은 Reserved 상태로 시작하며, 서로 관계없는 포인터로부터의 읽기를 허용한다. 해당 참조(또는 그 자식 중 하나)에 대해 첫 번째 쓰기가 실제로 발생할 때에만 상태가 Active로 전이되며, 그때부터는 관계없는 다른 포인터로부터의 읽기가 더 이상 허용되지 않는다. (자세한 내용은 Neven의 글을 보라. 특히 여기에는 하나의 불쾌한 놀라움이 숨어 있음을 유의하라. UnsafeCell이 개입되어 있다면, 예약된 가변 참조는 관계없는 포인터를 통한 “변이”도 허용해야 한다! 다시 말해, &mut T의 앨리어싱 규칙은 이제 UnsafeCell의 존재에 의해 영향을 받는다. 두 단계 대여가 도입될 때 이 점을 사람들이 인지했는지는 모르겠지만, 피하기도 어려워 보이므로, 뒤늦게 생각해도 대안이 무엇이었을지는 분명치 않다.)
Stacked Borrows에서 가장 흔한 문제 원천 중 하나는 가변 참조의 유일성을 너무 성급하게 강제한다는 점이다. 예를 들어, 다음 코드는 Stacked Borrows에서는 불법이다:
let mut a = [0, 1];
let from = a.as_ptr();
let to = a.as_mut_ptr().add(1); // 이 시점에 `from`은 무효화됨
std::ptr::copy_nonoverlapping(from, to, 1);
불법인 이유는 as_mut_ptr이 &mut self를 받기 때문인데, 이는 전체 배열에 대한 유일(배타) 접근을 주장하므로 앞서 만든 from 포인터를 무효화한다. 그러나 Tree Borrows에서는 저 &mut self가 두 단계 대여다! as_mut_ptr은 실제로 어떤 쓰기도 수행하지 않으므로, 참조는 예약 상태로 남아 활성화되지 않는다. 즉, from 포인터는 여전히 유효하고 전체 프로그램은 정의된 동작이다. as_mut_ptr 호출은 *self 읽기처럼 취급되지만, from(그리고 그것이 파생된 공유 참조)은 관계없는 포인터를 통한 읽기를 완전히 허용한다.
우연히도 from과 to 줄을 바꾸면 이 코드는 Stacked Borrows에서도 동작한다. 하지만 이는 좋은 이유 때문이 아니다. SB에는 읽기 시 사용한 태그 위에 있는 모든 Unique를 단지 “비활성화”할 뿐, 그 Unique에서 파생된 raw 포인터는 계속 활성 상태로 둔다는, 그다지 스택답지 않은 규칙이 있기 때문이다. 기본적으로 raw 포인터는 그것들이 파생된 가변 참조보다 더 오래 살 수 있는데, 이는 매우 비직관적이며 프로그램 분석에 잠재적으로 문제를 야기한다. TB에서는 순서를 바꾼 프로그램이 여전히 괜찮지만 이유는 다르다. to가 먼저 만들어지면, 그것은 예약된 두 단계 대여로 남는다. 이 상태에서는 공유 참조를 만들어 그로부터 from을 파생시키는 것(self에 대한 읽기처럼 동작)이 허용된다. 예약된 두 단계 대여는 관계없는 포인터로부터의 읽기를 용인하기 때문이다. to에 실제 쓰기가 이뤄질 때 비로소 그것(정확히는 그것을 만든 &mut self)이 유일성이 요구되는 활성 가변 참조가 되는데, 그 시점은 이미 as_ptr이 반환된 이후이므로 충돌하는 &self 참조가 존재하지 않는다.
일관되게 두 단계 대여를 사용하면 이 해키한 SB 규칙을 완전히 제거할 수 있고, SB에서 가장 흔한 UB 원천 중 하나도 고칠 수 있다. 전혀 예상치 못했기에, 정말 즐거운 반전이다. :)
다만, 다음 프로그램은 SB에서는 괜찮지만 TB에서는 잘못된 코드임에 주의하라:
let mut a = [0, 1];
let to = a.as_mut_ptr().add(1);
to.write(0);
let from = a.as_ptr();
std::ptr::copy_nonoverlapping(from, to, 1);
여기서는 to에 대한 쓰기가 두 단계 대여를 활성화하므로 유일성이 강제된다. 즉, as_ptr을 위해 만들어지는 &self(이는 self 전체를 읽는 것으로 간주된다)는 to와 양립할 수 없고, 따라서 from이 만들어질 때 to는 무효화된다(정확히는 읽기 전용이 된다). 지금까지 이 패턴이 실제 코드에서 흔하다는 증거는 없다. 위와 같은 문제를 피하는 방법은 “무엇이든 하기 전에 raw 포인터들을 모두 준비해 두는 것”이다. TB에서는 as_ptr과 as_mut_ptr 같은 참조를 받는 메서드 호출과 그 반환 raw 포인터를 서로 겹치더라도 서로 분리된 위치에 사용한다면 괜찮지만, 반드시 첫 번째 raw 포인터 쓰기 이전에 그런 메서드들을 모두 호출해야 한다. 일단 첫 쓰기가 발생하면, 그 이후의 참조 생성은 앨리어싱 위반을 유발할 수 있다.
Stacked Borrows의 또 다른 큰 골칫거리는 raw 포인터를 처음 만들어진 타입과 가변성에 묶어두는 것이다. SB에서는, 참조를 *mut T로 캐스팅할 때 결과 raw 포인터가 T가 덮는 메모리에만 접근하도록 제한된다. 이는 배열의 한 요소(또는 구조체의 한 필드)에 대한 raw 포인터를 취한 뒤 포인터 연산으로 이웃 요소에 접근할 때 사람들을 자주 곤란하게 만든다. 더구나 참조를 *const T로 캐스팅하면, 그 참조가 가변이었더라도 실제로는 읽기 전용이 된다! 많은 이들이 앨리어싱 관점에서 *const와 *mut의 차이가 중요하지 않다고 예상하므로, 이는 혼란의 단골 원인이다.
TB에서는 참조→raw 포인터 캐스트 시 더 이상 리태깅(retagging)을 하지 않음으로써 이를 해결한다. raw 포인터는 그 부모 참조와 동일한 태그를 사용하여, 부모로부터 가변성과 접근 가능한 주소 범위를 그대로 상속한다. 게다가, 참조는 타입이 기술한 메모리 범위에 엄격히 갇히지 않는다. 어떤 부모 포인터로부터 &mut T(또는 &T)가 생성될 때, 우리는 처음에는 그 참조가 T로 설명되는 메모리 범위에 접근할 수 있도록 기록하고(그리고 그 범위에 대한 읽기 접근이 있었던 것으로 간주한다), 동시에 “지연 초기화(lazy initialization)”도 수행한다. 초기 범위 밖의 메모리 위치에 접근하려 할 때, 부모 포인터가 그 위치에 접근할 수 있었는지를 확인하고, 가능하다면 자식에게도 동일한 접근 권한을 부여한다. 이는 충분한 접근 권한을 가진 부모를 찾거나 트리의 루트에 도달할 때까지 재귀적으로 반복된다.
이는 TB가 container_of 스타일 포인터 연산과 extern 타입과 호환되도록 하여 SB의 또 다른 한계를 극복함을 의미한다.
또한 다음 코드는 TB에서 합법이 된다:
let mut x = 0;
let ptr = std::ptr::addr_of_mut!(x);
x = 1;
ptr.read();
SB에서는 ptr과 지역 변수 x에 대한 직접 접근이 서로 다른 태그를 사용했기 때문에, 지역 변수에 쓰기를 수행하면 그에 대한 모든 포인터가 무효화되었다. TB에서는 더 이상 그렇지 않다. 지역 변수에 직접 만들어진 raw 포인터는 그 지역 변수에 대한 직접 접근과 임의로 앨리어싱할 수 있다.
TB의 동작이 더 직관적이라고도 할 수 있지만, 이는 더 이상 지역 변수에 대한 쓰기를 “가능한 모든 별칭이 무효화되었다”는 신호로 사용할 수 없음을 의미한다. 다만 TB가 이를 허용하는 것은 함수 본문에 addr_of_mut(또는 addr_of)가 “직접 존재”할 때뿐임을 주의하라! 만약 &mut x 참조가 만들어지고, 그 다음에 어떤 다른 함수가 그로부터 raw 포인터를 파생한다면, 그 raw 포인터들은 다음에 x에 쓰기가 발생할 때 실제로 무효화된다. 그래서 나에게 이는 훌륭한 절충안처럼 보인다. raw 포인터를 사용하는 코드는 UB 위험이 더 낮아지고, raw 포인터를 사용하지 않는 코드(이는 문법적으로 보기 쉬움)는 SB만큼이나 마음껏 최적화될 수 있다.
TB의 이러한 접근 전체는, 앞 절에서 언급한 스택을 위배하는 SB의 해키한 규칙을 TB가 필요로 하지 않는다는 사실에 의존한다는 점을 주의하라. 만약 SB에서 raw 포인터가 부모 태그를 그대로 상속했다면, 그것들은 파생된 유일 포인터와 함께 무효화되어, 바로 그 해키한 규칙이 지원하려고 추가된 코드를 모두 금지했을 것이다. 즉, 이러한 개선을 SB에 역이식(backport)하는 것은 가능성이 낮다.
UnsafeCellTB에서는 UnsafeCell의 처리도 상당히 바뀌었다. 우선, SB의 또 다른 큰 문제가 해결되었다. &i32를 &Cell<i32>로 변환하고 그 뒤 쓰기를 전혀 하지 않는 것이 마침내 허용된다. 이는 TB가 UnsafeCell에서 허용되는 앨리어싱을 다루는 방식에서 자연스럽게 나온다. 이들은 raw 포인터로의 캐스트처럼 취급되므로, &Cell<i32>를 재대여(reborrow)하면 부모 포인터의 태그(따라서 권한)를 그대로 상속한다.
좀 더 논쟁적인 부분으로, TB는 &T가 T 내부 어딘가에 UnsafeCell을 포함할 때, 어느 부분이 읽기 전용이 되는지를 더 덜 정밀하게 바꾼다. 특히 &(i32, Cell<i32>)에 대해 TB는 첫 번째 필드가 일반 i32임에도 _두 필드 모두_를 변이하는 것을 허용한다. 전체 참조를 “이것은 앨리어싱을 허용한다”로 취급하기 때문이다.1 반면 SB는 실제로 처음 4바이트는 읽기 전용이고 마지막 4바이트만 별칭 포인터를 통한 변이를 허용함을 알아냈다.
이런 설계 결정을 내린 이유는 TB의 전반적 철학이 “UB를 줄이고 더 많은 코드를 허용하는 쪽으로” 기울었기 때문이다(SB에서는 그 반대 방향을 택했었다). 이는 두 모델을 통해 설계 공간을 최대한 넓게 탐색하려는 의도적인 선택이다. 물론 TB가 여전히 원하는 최적화를 허용하고, rustc가 생성하는 LLVM IR을 정당화할 만큼 충분한 UB를 유지하는지 확인하고 싶었다. 이것들이 우리가 필요한 UB의 “하한”이었다. 그리고 이러한 제약에서, 우리는 꽤 단순한 방식으로 UnsafeCell을 지원할 수 있음이 드러났다. 즉, &T의 앨리어싱 규칙에 대해 경우를 두 가지만 둔다. 어디에도 UnsafeCell이 없다면 이 참조는 읽기 전용이고, 그렇지 않다면 이 참조는 앨리어싱을 허용한다. 전체 Rust 의미론(앨리어싱 모델 포함)에 대한 정리 증명을 많이 생각하는 사람으로서, 이 접근은 기분 좋게 단순해 보였다. :)
이 결정이 다소 논쟁적일 것이라 예상했지만, 받은 반발은 생각보다 컸다. 좋은 소식은 이 부분이 아직 확정과는 거리가 멀다는 것이다. 우리는 TB를 SB와 비슷하게 UnsafeCell을 다루도록 바꿀 수 있다. 앞서 설명한 차이들과 달리, 이 부분은 다른 설계 선택과 완전히 독립적이다. 개인적으로는 TB의 접근을 선호하지만, 현재 상황으로는 결국 SB와 유사한 UnsafeCell 처리를 채택할 것으로 예상한다.
지금까지 TB가 SB와 어떻게 다른지, 어떤 코딩 패턴이 UB인지에 대해 많이 썼다. 그렇다면 동전의 반대면, 즉 최적화는 어떨까? 분명히 SB가 더 많은 UB를 가지므로, TB는 더 적은 최적화를 허용할 것으로 예상해야 한다. 실제로 TB가 잃는 최적화의 큰 부류가 하나 있다. 바로 “추측적 쓰기(speculative write)”, 즉 이전에는 해당 위치에 쓰지 않던 경로에 쓰기를 삽입하는 것이다. 이는 강력한 최적화이고 SB가 이를 구현할 수 있어 기뻤지만, 대가가 크다. 가변 참조가 “즉시(unique) 유일”해야 하기 때문이다. “과도한 유일성” 문제가 얼마나 흔한지를 고려하면, 현재로서는 추측적 쓰기를 허용하느니 그런 코드를 모두 합법으로 만드는 편이 더 나아 보인다. 우리는 여전히 읽기를 중심으로 매우 강력한 최적화 원리를 갖고 있고, 코드가 실제로 쓰기를 수행할 때는 더 많은 최적화가 가능해지므로, 추측적 쓰기를 고집하는 것은 다소 지나치다고 느낀다.
다른 한편으로, TB는 SB가 실수로 금지했던 중요한 최적화 집합 — 읽기의 재배치 — 을 실제로 허용한다! SB의 문제는 “가변 참조 읽기, 그 다음 공유 참조 읽기”에서 “공유 참조 읽기, 그 다음 가변 참조 읽기”로 재배치할 경우, 새로운 프로그램에서는 공유 참조 읽기가 가변 참조를 무효화할 수도 있다는 점이다. 즉, 재배치가 UB를 도입할 수 있다! 이 최적화는 어떤 특별한 앨리어싱 모델 없이도 가능한 것이라, SB가 이를 허용하지 않는 것은 꽤 난처한 문제다. 이미 위에서 여러 번 언급한 스택 위배 해키 규칙만 아니었다면, SB에서도 이 문제를 고치는 비교적 쉬운 방법이 있었겠지만, 안타깝게도 그 규칙은 하중을 지탱하고 있으며, 이를 제거하면 너무 많은 기존 코드가 UB가 된다. 반면 TB는 그러한 해키 규칙이 필요 없으므로, 올바른 일(Right Thing, TM)을 할 수 있다. 즉, 읽기를 수행할 때 관계없는 가변 참조들을 완전히 비활성화하지 않고, 단지 읽기 전용으로 만든다. 이는 “공유 참조 읽기, 그 다음 가변 참조 읽기”가 “가변 참조 읽기, 그 다음 공유 참조 읽기”와 동등하다는 뜻이며, 최적화가 구원된다. (이의 결과로, 리태그들도 서로 재배치될 수 있는데, 그것들 역시 읽기처럼 동작하기 때문이다. 따라서 첫 번째 쓰기가 이들 중 하나로 수행될 때까지는 다양한 포인터를 설정하는 순서가 영향이 없어야 한다.)
UniqueTree Borrows는 아직 구현하지 않았지만 탐구하기를 무척 기대하는 확장을 위한 길을 연다. 바로 Unique에 의미를 부여하는 것이다. Unique는 원래 noalias 요구 사항을 표현하려는 의도로 표준 라이브러리에 있는 비공개 타입이지만, 실제로 LLVM 수준에서 그 속성을 내보내도록 연결된 적은 없다. Unique는 표준 라이브러리에서 주로 두 곳, Box와 Vec에서 사용된다. SB(와 TB)는 Box는 특별 취급(러스트 컴파일러와 동일)을 하지만 Unique는 그렇지 않으므로, Vec에는 어떤 앨리어싱 요구 사항도 없다. 그리고 사실 SB의 접근은 Vec에는 전혀 맞지 않는다. 여기서는 얼마나 큰 메모리를 유일하게 만들지 알 수 없기 때문이다. 하지만 TB에서는 지연 초기화가 있으므로, 처음부터 메모리 범위를 확정할 필요가 없다 — “접근 시” 유일하게 만들면 된다. 이는 Vec의 Unique에 의미를 부여하는 실험을 해볼 수 있음을 뜻한다.
물론 실제로는 잘 안 될 수도 있다. 사람들은 Vec으로 노골적으로 앨리어싱되는 일들을 하기도 하는데, 예컨대 arena를 구현하기 위해서다. 한편으로 Vec의 유일성은 그것이 이동되거나 값으로 전달(by value)될 때에만, 그리고 실제로 접근되는 메모리 범위에 대해서만 적용된다. 그러니 arena와도 양립 가능할 수 있다. 내가 생각하기에 이를 알아내는 가장 좋은 방법은 플래그 뒤에 Unique 의미론을 구현하고 실험해 보는 것이다. 잘 된다면, Box에 대한 모든 특별 취급을 제거하고, Box가 Unique에 대한 뉴타입으로 정의되어 있다는 사실에 의존하는 방향도 가능할 것이다. 이렇게 하면 최적화 잠재력이 약간 줄어들 것이다(Box<T>는 적어도 T 크기만큼의 메모리 범위를 가리키는 것이 알려져 있지만, Unique에는 그런 정보가 없다). 하지만 Box의 마법스러움을 줄이는 것은 오랜 염원이었으니, 이는 수용 가능한 트레이드오프일지도 모른다.
Box나 Vec 어느 쪽도 앨리어싱 요구 사항을 가져서는 안 된다고 생각하는 이들도 많다는 점은 언급해둔다. 나는 먼저 일반적인 코딩 패턴과 양립할 만큼 충분히 경량의 앨리어싱 요구 사항을 가질 수 있는지 탐색해볼 가치가 있다고 본다. 설령 결국 Box와 Vec이 raw 포인터처럼 동작한다고 결론 내리더라도, 우리 도구 상자에 Unique가 있고, unsafe 코드 작성자들이 마지막 성능을 짜내는 데 이를 노출하는 것은 여전히 유익할 수 있다.
이상으로 Stacked Borrows와 Tree Borrows의 주요 차이점들을 살펴보았다. 보다시피 거의 모든 경우에서 TB는 SB보다 더 많은 코드를 허용하고, 실제로 내가 SB의 두 가지 가장 큰 문제라고 생각하는 것 — 가변 참조의 과도한 유일성 강제, 그리고 참조와 raw 포인터를 그것이 만들어진 타입의 크기 범위에 가두는 것 — 을 고친다. 이는 unsafe 코드 작성자들에게 아주 좋은 소식이다!
TB가 바꾸지 않는 것은, 어떤 참조가 함수 호출 전체 동안(다시 사용되든 아니든) 유효하게 남아 있도록 강제하는 “프로텍터(protector)”의 존재다. 프로텍터는 우리가 내보내고자 하는 LLVM noalias 주석을 정당화하는 데 절대적으로 필요하며, 그렇지 않으면 불가능한 더 강한 최적화들도 가능하게 한다. 나는 프로텍터가 Tree Borrows에서 남아 있는 예상치 못한 UB의 주된 원천이 될 것이라 예상하며, 여기에는 우리가 가질 수 있는 여지가 많지 않다고 본다. 아마도 이는 프로그래머들에게 코드를 조정하도록 안내하고, 이 미묘한 이슈를 가능한 널리 알리기 위한 문서화에 투자해야 하는 경우일 것이다.
Neven은 Miri에 Tree Borrows를 구현했으니, MIRIFLAGS=-Zmiri-tree-borrows를 설정해 직접 가지고 놀아보고 자신의 코드를 검사해볼 수 있다. 놀라움이나 우려에 부딪히면 꼭 알려달라! t-opsem Zulip과 UCG 이슈 트래커가 그런 질문을 하기 좋은 장소다.
이상이다. 읽어줘서 고맙고 — 여기까지의 모든 실제 작업(그리고 이 글에 대한 피드백)을 해준 Neven에게 감사의 말을 전한다. 이 프로젝트를 감독하는 일은 무척 즐거웠다! 그의 글을 꼭 읽어보고, 발표 영상도 시청하길 바란다.