Rust의 Stream 조합자 flatten과 switch의 차이, 고차 스트림과 일차 스트림의 개념, 그리고 switch의 구현과 활용 사례를 살펴봅니다.
_Stream_은 _Future_에 대해 _Iterator_가 _value_에 대해 그러한 것과 같습니다:
Rust 관점에서 Iterator는 다음과 같이 정의됩니다:
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
enum Option<T> {
/// 값이 없습니다.
None,
/// 값이 있습니다!
Some(T),
}
그리고 Stream은 다음과 같이 정의됩니다:
trait Stream {
type Item;
fn poll_next(
self: Pin<&mut Self>,
context: &mut Context<'_>,
) -> Poll<Option<Self::Item>>;
}
enum Poll<T> {
/// 값이 아직 준비되지 않았습니다.
Pending,
/// 값이 즉시 준비되었습니다!
Ready(T),
}
(이 글에서는 Pin과 Context는 무시합시다1).
Poll::Pending은 아직 준비된 값이 없다는 뜻입니다. Stream은 나중에 다시 폴링되어야 합니다. 언제 Stream을 다시 폴링하는 것이 가장 좋은지는 이 글의 주제가 아니므로 다루지 않겠습니다. Poll::Ready(Some(T))는 값이 준비되어 반환되었다는 뜻입니다. 마지막으로 Poll::Ready(None)는 Stream이 닫혔음 을 의미하며, 다시 폴링해서는 안 됩니다!
Le Comte

간결하고 직관적이군요. 저는 이런 유사성이 좋습니다: next 대 poll_next, Option<T> 대 Poll<Option<T>>. 제가 일관성을 얼마나 좋아하는지 말씀드렸던가요?
추가로, Iterator의 강점 중 하나는 조합자(combinators) 입니다. 즉, 하나의 Iterator를 다른 Iterator로 변환하는 방법입니다. 이런 조합자는 다음처럼 연결해서 사용할 수 있습니다:
iterator
.skip_while(…)
.filter(…)
.enumerate(…)
.map(…)
그 비밀은 단순합니다. 새 Iterator를 반환하는 메서드일 뿐이기 때문에 연결할 수 있는 것입니다.
그리고 Iterator와 비슷하게, Stream도 많은 조합자 를 가질 수 있습니다. 즉, 하나의 Stream을 다른 Stream으로 변환하는 방법이죠! 다시 말하지만, 일관성입니다…
정확합니다! 정말 흥미로운 점은, 이것이 아주 멋진 기능들을 가져다준다는 것입니다.
예제로 map 을 봅시다:
Rust에서는 다음처럼 보일 것입니다:
trait Stream {
type Item;
// …
fn map<U, F>(self, f: F) -> impl Stream<Item = U>
where
F: FnMut(Self::Item) -> U,
{
// …
}
}
map 메서드는 Stream의 항목들(Stream::Item 타입)을 새로운 타입 U로 매핑하는 함수 F를 받아서, 결과적으로 또 다른 Stream<U>를 만듭니다.
그럼 filter 도 하나 더 보시겠습니까?
Rust에서는 다음처럼 보일 것입니다:
trait Stream {
type Item;
// …
fn filter<F>(self, f: F) -> impl Stream<Item = Self::Item>
where
F: FnMut(&Self::Item) -> bool,
{
// …
}
}
filter 메서드는 Stream의 항목을 유지할지 말지를 알려주는 함수 F를 받아서, 같은 항목 타입을 가진 또 다른 Stream을 만듭니다.
좋습니다. 하나 더 볼까요? Iterator::flatten처럼 Stream::flatten도 존재할 수 있습니다. 아이디어는 스트림의 스트림, 즉 내부 스트림들을 생성하는 외부 스트림을 평탄화하는 것입니다. 이제부터 재미있어지니 조금 파고들어 봅시다.
Rust에서는 다음처럼 보일 것입니다:
trait Stream {
type Item;
// …
fn flatten(
self,
) -> impl Stream<Item = <Self::Item as Stream>::Item>
where
Self::Item: Stream,
{
// …
}
}
문법에 겁먹지 마세요. 한 조각씩 읽어보면 됩니다.
Le Comte

음, where 절을 분해해 봅시다:
Self는 Stream을 구현하는 타입을 나타냅니다.Self::Item은 그 Stream이 생성하는 항목의 타입을 나타냅니다.Self::Item: Stream은 항목들이… 스트림이라는 뜻입니다! 조금 다르게 말하면, 이 스트림은 스트림들을 생성합니다.그리고 반환 타입은:
impl Stream은 Stream을 구현하는 임의의 타입 을 의미합니다.Self::Item: Stream 덕분에 이 스트림이 스트림들을 생성한다는 것을 알고 있으니…)<Self::Item as Stream>::Item은 생성된 스트림들이 생성하는 항목들의 타입을 나타냅니다.이번 것은 수학 표기가 더 쉬웠군요.
아마 더 쉽긴 하겠지만, flatten이 정확히 무엇을 하는지는 설명해 주지 않죠. 어떻게 동작하는지 이해하기 위해 Rust 코드를 조금 봅시다:
/// 스트림을 평탄화하는 타입.
// `#[pin]`은 무시하세요. `Pin`과 projection은 제쳐두기로 했습니다.
pub struct Flatten<Outer, Inner> {
/// 내부 스트림들을 생성하는 외부 스트림.
#[pin]
outer_stream: Outer,
/// 가장 최근에 생성된 내부 스트림.
#[pin]
inner_stream: Option<Inner>,
}
좋아요, 좋아요. 천천히 갑시다. 이제 Flatten에 Stream을 구현해야 하지 않겠습니까?
impl<Outer> Stream for Flatten<Outer, Outer::Item>
// ^^^^^^^^^^^
// |
// 우리의 내부 스트림 타입입니다!
where
// 내 이름은 스트림, `Outer` 스트림 🕵️.
Outer: Stream,
// `Outer`는 스트림들을 생성하는 스트림입니다!
Outer::Item: Stream,
{
type Item = <Outer::Item as Stream>::Item;
fn poll_next(
self: Pin<&mut Self>,
context: &mut Context<'_>
) -> Poll<Option<Self::Item>>
{
// 이것은 무시하세요. `Pin`과 projection은 제쳐두기로 했습니다.
let mut this = self.pin_projection();
loop {
if let Some(inner_stream) = this.inner_stream.as_mut().as_pin_mut() {
// 내부 스트림이 있습니다. 폴링해 봅시다!
match inner_stream.poll_next(context) {
// 내부 스트림이 `item`을 생성했습니다!
Poll::Ready(Some(item)) => return Poll::Ready(Some(item)),
// 내부 스트림이 닫혔습니다. 잊어버립시다.
Poll::Ready(None) => {
this.inner_stream.set(None);
// 루프를 다시 돌립니다.
}
// 내부 스트림이 pending입니다. 할 일이 없습니다.
Poll::Pending => return Poll::Pending,
}
} else {
// 내부 스트림이 없나요? 괜찮습니다. 외부 스트림을 폴링합시다!
match this.outer_stream.as_mut().poll_next(context) {
// 새로운 내부 스트림!
// 군중이 “어서와아 내부 스트리임”이라고 외칩니다.
Poll::Ready(Some(inner_stream)) => {
this.inner_stream.set(Some(inner_stream));
// 루프를 다시 돌립니다.
}
// 외부 스트림이 닫혔습니다. 모두 닫아버립시다.
Poll::Ready(None) => return Poll::Ready(None),
// 외부 스트림이 pending입니다. 할 일이 없습니다.
Poll::Pending => return Poll::Pending,
}
}
}
}
}
식은 죽 먹기입니다. Poll이 enum이고, match가 match이기 때문에 이 코드는 읽기도 쉽고 이해하기도 쉽습니다. 놀랄 것도 없죠. 거의 지루할 정도입니다.
우리는 새로 폴링된 각 내부 스트림 —즉 외부 스트림이 생성한 것— 이 닫힐 때까지 완전히 소비된다는 점을 알 수 있습니다. 그런 다음 새로운 내부 스트림을 폴링하고, 이것도 외부 스트림이 닫힐 때까지 계속됩니다.
Flatten의 동작: 외부 스트림이 내부 스트림들을 생성합니다. 각 내부 스트림은 완전히 소비된 뒤에야 외부 스트림이 생성한 다음 내부 스트림으로 넘어갑니다. 외부 스트림이 새로운 내부 스트림을 준비하고 있더라도, 현재 내부 스트림이 닫히기 전까지는 폴링되지 않습니다.
외부 스트림은 1, 2, 3을 생성하는 내부 스트림 A 를 생성합니다. 그다음 A 가 닫히면 외부 스트림이 다시 폴링되어 새로운 내부 스트림 B 를 생성하고, B 는 4와 5를 생성합니다. 이어서 외부 스트림이 내부 스트림 C 를 생성하고, C 는 6과 7을 생성합니다.
이제 기본 예제로 실행해 봅시다. 이번에는 the futures crate의 도움을 받겠습니다.
// `StreamExt`는 `Stream` trait을 “확장”하는 trait입니다.
// 모든 조합자가 여기에 들어 있습니다.
use futures::{executor, stream::{self, StreamExt}};
fn main() {
executor::block_on(async {
let stream = stream::iter(vec![
// 첫 번째 내부 스트림들.
stream::iter(vec![1, 2, 3]),
// 두 번째 내부 스트림.
stream::iter(vec![4, 5]),
// 세 번째 내부 스트림.
stream::iter(vec![6, 7, 8, 9]),
])
.flatten();
// ^^^^^^^
// |
// 중요한 부분
dbg!(&stream.collect::<Vec<_>>().await);
});
}
여기에는 많은 일이 일어나고 있습니다.
futures::executor는 비동기 런타임이며, executor라고도 불립니다. block_on 함수는 현재 스레드에서 future가 완료될 때까지 실행하는 특수한 executor입니다. 우리는 단일 비동기 블록, 즉 future 하나만 실행하고 싶기 때문에 여기서는 안성맞춤입니다.stream::iter는 Iter라는 특별한 스트림을 만듭니다. 이것은 Iterator를 Stream으로 변환합니다. 모든 항목이 이미 알려져 있으니 조금 쓸모없다고 말할 수도 있습니다. 그런 분께는 부드럽게 상기시켜 드리겠습니다… 예제입니다! flatten의 동작을 설명하기 위해 있는 것이죠.stream::iter입니다. 우리는 세 개의 내부 스트림을 생성하는 외부 스트림을 만들고 있습니다. 이 내부 스트림들도 모두 stream::iter 스트림입니다. 참으로 사랑스럽게도 기분 좋군요.stream을 반환하기 직전에 flatten을 호출합니다.스트림이 무엇을 반환할지 시험하기 위해 조합자 collect를 사용합니다. 이것은 모든 항목을, 여기서는 Vec로 수집합니다. collect는 Stream이 아니라 Future를 반환합니다. 즉, 많은 값을 생성하는 것이 아니라 단일 값을 생성합니다. 그래서 결과를 그냥 await하면 됩니다. 그리고 그 결과는 다음과 같이 표시됩니다…
[src/main.rs:17:9] &stream.collect::<Vec<_>>().await = [
1,
2,
3,
4,
5,
6,
7,
8,
9,
]
색종이 투척.
Flatten은 고차 스트림의 전형적인 예입니다!
Le Procureur

처음에는 일차(first-order) 와 고차(higher-order) 라는 용어가 위압적으로 느껴질 수 있지만, 사실 이해하기는 꽤 쉽습니다.
함수 fn(x) -> y는 일차 함수입니다. 하지만 fn(fn(x) -> y) -> z는 고차 함수입니다. 함수를 인자로 받는 함수이기 때문입니다. fn(x) -> fn(y) -> z도 고차 함수인데, 함수를 반환하기 때문입니다.
다르게 말하면, 함수가 다음 중 하나라도 만족하면 고차 함수입니다:
이것은 스트림에도 유사하게 적용됩니다. 스트림은 인자 를 갖지도 않고 무언가를 반환 하지도 않습니다. 대신 항목을 생성 합니다. 이를 알고 나면, 스트림이 비스트림 항목을 생성하면 일차 스트림이고, 다음과 같으면 고차 스트림입니다:
flatten의 경우, T의 스트림들의 스트림을 T의 스트림으로 변환합니다. 우리는 고차 스트림에서 일차 스트림으로 가는 것입니다.
좋은 소식입니다. 서론은 여기서 끝입니다. 또 다른 좋은 소식은, 이제 switch 조합자에 대해 이야기할 수 있다는 점입니다!
Le Comte

이 모든 게 그냥 서론이었다고요? 정말요? 아이들이 단순한 왜라는 질문을 했을 때 얼마나 큰 정신적 충격을 받았을지 상상도 안 됩니다…
그건 그렇고, 라틴어 문구로 뽐내는 김에… 마침 떠오르는 말이 하나 있군요: fabricando fit faber!
정확합니다. switch 조합자가 무엇을 하는지 이해하려면 이 모든 설명이 필요했습니다. 이것은 고차 스트림을 일차 스트림으로 전환 하지만, flatten과는 다릅니다.
좋습니다, 조합자의 시그니처는 flatten과 정확히 같지만 동작은 다릅니다! flatten은 외부 스트림을 사용해 내부 스트림을 생성하고, 그 내부 스트림이 완전히 소비될 때까지 외부 스트림을 다시 폴링하지 않는다는 점을 기억하시나요? switch는 다릅니다. 문서를 열어봅시다:
This combinator flattens a stream of streams, i.e. an outer stream yielding inner streams. This combinator always keeps the most recently yielded inner stream, and yields items from it, until the outer stream produces a new inner stream, at which point the inner stream to yield items from is switched to the new one.
생성된 내부 스트림은… 외부 스트림이 새 내부 스트림을 생성할 때까지 유지됩니다.
flatten은 내부 스트림이 없거나 내부 스트림이 닫혔을 때 외부 스트림을 폴링합니다.switch는 매번 외부 스트림을 폴링하며, 외부 스트림이 pending이거나 닫혔을 때 내부 스트림도 폴링합니다.Switch의 동작: 외부 스트림이 내부 스트림들을 생성합니다. 각 내부 스트림은 외부 스트림이 pending인 동안 소비됩니다. 외부 스트림이 새 내부 스트림을 준비하는 즉시, 현재 내부 스트림은 새 것으로 교체됩니다.
외부 스트림이 내부 스트림 A 를 생성합니다. A 는 1을 생성하기 시작하지만, 갑자기 외부 스트림이 새로운 내부 스트림 B 를 준비합니다. 그러면 내부 스트림 A 는 B 로 교체되고, B 는 4를 생성합니다. 이어서 외부 스트림이 또 다른 내부 스트림 C 를 준비하면 B 는 C 로 교체됩니다. C 는 6과 7을 생성합니다.
이제 이것을 Rust에서 어떻게 구현할 수 있는지 봅시다:
/// 스트림을 전환하는 타입.
// `#[pin]`은 무시하세요. `Pin`과 projection은 제쳐두기로 했습니다.
pub struct Switch<Outer>
where
Outer: Stream
{
/// 내부 스트림들을 생성하는 외부 스트림.
#[pin]
outer_stream: Outer,
/// 내부 스트림의 상태.
#[pin]
inner_stream_state: InnerStreamState<Outer::Item>,
}
/// 더 많은 초능력을 가진 `Option`.
// `#[pin]`은 무시하세요. 블라블라블라, 무슨 말인지 아시죠.
enum InnerStreamState<Inner> {
/// 아직 내부 스트림이 생성되지 않았습니다.
None,
/// 가장 최근에 생성된 내부 스트림.
Some {
#[pin]
inner_stream: Inner,
}
}
그리고 이제, 모두가 기다리던 순간, Stream 구현입니다:
impl<Outer> Stream for Switch<Outer>
where
// 내 이름은 스트림, `Outer` 스트림 🕵️.
Outer: Stream,
// `Outer`는 스트림들을 생성하는 스트림입니다!
Outer::Item: Stream,
{
type Item = <Outer::Item as Stream>::Item;
fn poll_next(
self: Pin<&mut Self>,
context: &mut Context<'_>
) -> Poll<Option<Self::Item>>
{
// 이것은 무시하세요. `Pin`과 projection은 제쳐두기로 했습니다.
let mut this = self.pin_projection();
let mut outer_stream_is_closed = false;
// 가장 최근 내부 스트림을 eager하게 폴링합니다.
while let Poll::Ready(ready) = this.outer_stream.as_mut().poll_next(context) {
match ready {
// 새로운 `inner_stream`이 있습니다!
Some(inner_stream) => {
this.inner_stream_state.set(InnerStreamState::Some { inner_stream });
// 루프를 계속 돌립니다.
}
None => {
outer_stream_is_closed = true;
break;
}
}
}
match this.inner_stream_state.pin_projection() {
// 아직 내부 스트림이 생성되지 않았습니다.
InnerStreamState::None => {
// 스트림의 상태는 외부 스트림의 상태와 같습니다.
if outer_stream_is_closed {
Poll::Ready(None)
} else {
Poll::Pending
}
}
// 내부 스트림이 존재합니다. 폴링합시다!
InnerStreamState::Some { inner_stream } => match inner_stream.poll_next(context) {
// 내부 스트림이 항목을 생성했습니다.
Poll::Ready(Some(item)) => Poll::Ready(Some(item)),
// 내부 스트림과 외부 스트림이 모두 닫혔습니다.
Poll::Ready(None) if outer_stream_is_closed => Poll::Ready(None),
// 내부 스트림만 닫혔거나 pending입니다.
Poll::Ready(None) | Poll::Pending => Poll::Pending,
},
}
}
}
로켓 과학은 아니지만, 그만큼 재미있습니다! 이제… 실행해 봅시다!
let stream =
stream::iter([
// 첫 번째 내부 스트림.
stream::iter(vec![1, 2, 3]),
// 두 번째 내부 스트림.
stream::iter(vec![4, 5]),
/// 세 번째 내부 스트림.
stream::iter(vec![6, 7, 8, 9]),
])
.switch();
// ^^^^^^
// |
// 이런!
assert_eq!(
stream.collect::<Vec<_>>().await,
vec![6, 7, 8, 9],
);
Le Comte

허. 첫 번째와 두 번째 내부 스트림은 무시되는군요. 마치 외부 스트림이 pending이 될 때까지 반복해서 폴링된 것처럼요.
참고로, 이는 다음과 같이 말하는 코드와 문서 둘 다 와 일치합니다:
This combinator always keeps the most recently yielded inner stream, and yields items from it
맞습니다! 문서의 나머지 부분과 switch 조합자가 왜 꽤 강력한지 이해하려면, 재미있는 예제를 상상해 봐야 합니다. 눈을 감고, 적절한 예제를 떠올려 봅시다…
Le Factotum

제가 해봐도 될까요? 이런 흐름을 상상할 수 있겠습니다:
외부 스트림이 7을 생성:
외부 스트림이 42를 생성:
이 예제는 switch의 강력함을 꽤 잘 보여줍니다. 내부 스트림이 외부 스트림에 따라 동적으로 계산되기 때문입니다. 이것은 아주 많은 구체적인 사용 사례로 이어집니다. 예를 들면:
리스트를 위한 UI 컴포넌트가 있고
리스트는 diff 연산으로 갱신될 수 있습니다. 예를 들면:
Insert { index, value }: 새 항목 value를 index에 삽입,Remove { index }: index의 항목 제거,Set { index, value }: index의 항목을 value로 갱신,Reset { values }: 모든 항목을 지우고 새 항목들을 삽입,리스트는 어떤 용어로 동적으로 필터링될 수 있으며, 필터가 갱신되면 리스트는 (Reset으로) 재설정되어야 하고, 그 뒤에 새로운 갱신들이 생성될 수 있습니다…
이것은 7과 42의 첫 번째 예제와 비슷합니다.
훌륭한 아이디어입니다! 적어봅시다.
우선, 다른 채널에서 오는 값들을 생성하는 스트림이 필요합니다. 머리가 뜨거워지네요 어, 채널 이라! futures crate에는 channel::mpsc 모듈이 있습니다! mpsc는 multi-producer, single-consumer 의 약자입니다. 이는 비동기 태스크 사이에서 값을 보내기 위한 일종의 큐입니다. 기본적으로 하나 이상의 sender (즉 producer)와 하나의 receiver (즉 consumer)가 있습니다. 문서를 스크롤합니다. The Receiver type implements Stream! 좋습니다, 공식적으로 여기서 receiver 부분이 우리의 외부 스트림입니다:
use futures::channel::mpsc;
// 이 큐는 “무한”합니다. 즉, “unbounded”입니다.
let (sender, receiver) = mpsc::unbounded::<usize>();
let stream = receiver;
// 외부 스트림 `receiver`가 무언가를 생성하기 전까지
// `stream`은 pending입니다.
sender.unbounded_send(7).unwrap();
sender.unbounded_send(42).unwrap();
assert_eq!(
stream.collect::<Vec<_>>().await,
vec![7, 42],
);
좋은 시작 아닌가요?
지금까지 stream은 일차 스트림입니다. 이제 내부 스트림들을 계산해 봅시다! 음, 잠깐만요. 새로운 구조체에 Stream을 직접 구현하지 않고도 Stream을 빠르게 만드는 방법은 없을까요?
Le Factotum

Aut disce aut discede (보시죠, 저도 라틴어를 압니다!). 헛기침한다. 흠흠. 다시 한 번, futures crate가 해결해 줍니다! 훌륭한 stream::poll_fn 함수가 있거든요. 이것은 폴링될 때 주어진 함수를 실행하는 Stream을 만듭니다.
맹세컨대, 저는 이 사람들과 아무 관계가 없습니다. 그럼에도 좋은 추천이군요. 쉬운 것부터 시작합시다:
use std::task::Poll;
let mut next_value: usize = 7;
let stream = stream::poll_fn(move |_| {
let current_value = next_value;
next_value = next_value.saturating_add(1);
Poll::Ready(Some(current_value))
});
assert_eq!(
stream.take(6).collect::<Vec<_>>().await,
vec![7, 8, 9, 10, 11, 12],
);
이렇게 만들어진 스트림은 스스로 닫히지 않기 때문에, 여기서는 6개의 값만 가져오기 위해 take 조합자를 사용해야 합니다. 하지만 동작합니다! 정말 동작하네요! 스트림이 폴링될 때마다 다음 정수를 생성합니다. 이제 이 두 스트림을 결합하고 —자, 어서!— switch 조합자를 써봐야 하지 않겠습니까?!
let (sender, receiver) = mpsc::unbounded::<usize>();
let mut stream = pin!(receiver
// `sender`에서 새 값을 받을 때마다…
.map(|init_value| {
// …새 내부 스트림을 만듭시다:
let mut next_value = init_value;
stream::poll_fn(move |_| {
let current_value = next_value;
next_value = new_value.saturating_add(1);
Poll::Ready(Some(current_value))
})
})
.switch());
// ^^^^^^^^
// |
// 드디어!
sender.unbounded_send(7).unwrap();
// `stream`이 내부 스트림으로 전환되었습니다.
// 5개 항목을 가져와 봅시다.
assert_eq!(
stream.by_ref().take(5).collect::<Vec<_>>().await,
vec![7, 8, 9, 10, 11],
);
// 5개를 더 가져옵시다.
assert_eq!(
stream.by_ref().take(5).collect::<Vec<_>>().await,
vec![12, 13, 14, 15, 16],
);
// 모두 좋습니다.
// 새 내부 스트림을 트리거합시다.
sender.unbounded_send(42).unwrap();
// `stream`은 “재설정”되었고 새로운 내부 스트림을 생성합니다.
assert_eq!(
stream.take(5).collect::<Vec<_>>().await,
vec![42, 43, 44, 45, 46],
);
짜잔! 같은 stream입니다. 우리는 계속 그것을 폴링합니다. 하지만 내부 스트림은 동적으로 재설정됩니다. 얼마나 멋진가요?
이 여정에서 우리는 스트림을 가지고 놀았습니다. 이제는 일차 스트림과 고차 스트림이 무엇을 의미하는지도 안다고 자랑할 수 있겠군요. 우리는 새로운 종류의 스트림 Switch를 소개했습니다. 또 futures crate에 매우 유용한 도구들이 많이 들어 있다는 것도 보았습니다. 그래서 저는 pull request#2997, feat: Add StreamExt::switch on rust-lang/futures-rs를 열었습니다. 이것은 Jonas Platte가 작성하고 Jonas와 제가 함께 유지보수하는 async_rx::Switch 구현을 포팅한 것입니다. 처음에는 Matrix Rust SDK를 위한 목적이었습니다. 이것이 다른 사람들에게도 유용할 수 있다고 생각했고, 그래서 지금 여기까지 오게 되었습니다. 이것이 바로 이 글의 동기였습니다. switch가 flatten과 어떻게 다른지, 그리고 얼마나 유용할 수 있는지를 설명하는 것이죠.
재미있으셨기를 바랍니다!