동시성 프로그램에서 오류 처리를 어떻게 생각해야 하는지, 그리고 그 질문이 왜 구조적 동시성으로 이어지는지를 살펴본다.
2026년 3월 23일
동시성 프로그램에서 오류 처리를 어떻게 생각해야 할까요?
단일 스레드 프로그램에서는, 다양한 구현과 구체적 패턴의 동물원이 있기는 하지만, 대체로 표준적인 패턴으로 수렴했습니다. 오류가 발생하면, 그것을 처리할 준비가 된 스택 프레임을 찾을 때까지 스택을 따라 위로 전파됩니다. 그러는 동안 스택 프레임은 순서대로 풀리며, 각 프레임은 적절하게 자원을 정리하거나 파괴할 기회를 갖습니다.
이 패턴은 많은 현대 언어(C++, Python, Java)의 명시적 예외 처리 메커니즘을 분명하게 설명합니다. 이들 모두는 프레임 언와인드 시 정리를 위한 메커니즘도 갖고 있습니다(RAII, finally 블록, Python 컨텍스트 매니저). 하지만 이것은 Rust의 표준 패턴(Result 반환, ? 연산자, 언와인드 시 drop 호출), Go의 표준 패턴(고전적인 if err != nil { return err } 패턴과 정리를 위한 defer), 심지어 대부분의 현대 C 코드에서의 goto error 패턴 같은 관용구를 통한 방식까지도 설명합니다(Linux 커널의 예시 참고).
오늘날의 관점에서는 이 설명이 너무 일반적이어서 내용이 없는 것처럼 보일 수도 있지만, 항상 그랬던 것은 아닙니다. 다른 몇 가지 오류 처리 접근법(대부분은 이제 버려졌습니다)으로는 Lisp의 “restarts” 메커니즘, C의 longjmp1, UNIX 시그널 같은 “trap” 메커니즘, 그리고 Visual Basic의 악명 높은 on error 절이 있습니다. “언와인딩” 자체도 구조화된 호출 스택이 존재한다는 것을 전제로 하는데, 이 개념 역시 발명되고 대중화되어야 했습니다.
오늘 저는 이렇게 묻고 싶습니다. 단 하나의 스택이 없는 동시성 프로그램에서는 이 패턴을 어떻게 갱신해야 할까요? 여러 개의 동시 작업2이 존재할 때, 우리는 오류 조건을 처리하기 위해 코드를 어떻게 조직해야 할까요?
아마 오류 처리의 가장 단순한 경우는 오류가 발생했는데, 그것을 명시적으로 처리하는 코드가 전혀 없는 경우일 것입니다. 단일 스레드 프로그램에서는 오류가 진입점까지 “거품처럼 올라가서” 프로그램을 종료시키고, 가능하면 유용한 오류 메시지나 스택 트레이스를 남기기를 기대합니다.
여러 작업이 있는 동시성 프로그램에서는 무엇이 일어나야 하는지가 덜 명확합니다. 위로 전파해서 오류를 발생시킨 작업을 종료할 수는 있지만, 그 다음은 어떻게 해야 할까요? 구체적인 예를 위해 다음과 같은 장난감 프로그램3을 생각해 봅시다:
import threading
import time
def background_thread():
# This was supposed to be running some background work, but it
# encountered an error!
raise ValueError("oops")
def main():
threading.Thread(target=background_thread).start()
# do the main work
time.sleep(5)
print("All done, exiting!")
main()
이 프로그램은 두 개의 동시 스레드를 실행하려고 합니다. 하나는 어떤 “주된” 작업 함수를 수행하고, 다른 하나는 어떤 종류의 작업을 “백그라운드에서” 수행합니다. 그런데 백그라운드 스레드가 즉시 예외를 발생시킵니다. 무엇이 일어나야 할까요?
여러 언어 구현에서 이 프로그램의 변형을 실행해 보면, 대체로 흔한 접근법이 두 가지 있다는 것을 알 수 있습니다. 제 생각에는 이것들이 사실상 자명한 두 가지 선택지입니다:
Python에서는 프로그램이 예외를 즉시 기록하지만, 그 뒤에도 계속 실행됩니다. time.sleep이 끝날 때까지 기다린 뒤 종료하며, 심지어 종료 코드도 성공입니다!
$ time python exception_thread.py
Exception in thread Thread-1 (background_thread):
Traceback (most recent call last):
File "/Users/nelhage/.pyenv/versions/3.11.0/lib/python3.11/threading.py", line 1038, in _bootstrap_inner
self.run()
File "/Users/nelhage/.pyenv/versions/3.11.0/lib/python3.11/threading.py", line 975, in run
self._target(*self._args, **self._kwargs)
File "/Users/nelhage/Sync/code/structured-concurrency/exception_thread.py", line 7, in background_thread
raise ValueError("oops")
ValueError: oops
All done, exiting!
python exception_thread.py 0.03s user 0.02s system 0% cpu 5.124 total
$ echo $?
0
어느 쪽 선택지도 특별히 만족스럽지는 않습니다.
전체 프로그램을 죽이는 것은 지나치게 무거운 망치입니다. 저는 실제로 백그라운드 모니터링 goroutine에서 처리되지 않은 panic이 발생해 중요한 데몬을 크래시시키는 바람에, 심각도가 높은 장애 대응을 도운 적이 있습니다. 그 특정 사례에서는, “진짜” 작업은 중단 없이 계속되게 두는 편이 훨씬 나았습니다.
반대로 프로그램을 계속 실행하게 두면, 우리는 거의 확실하게 한 번도 테스트되거나 예상되지 않았던 상태에 놓이게 됩니다. 다른 작업들이 죽은 작업이 진행하거나 특정 행동을 수행하리라 기대하고 있다면, 교착 상태나 그보다 더 나쁜 상황에 빠질 위험이 매우 큽니다.
프로그램을 계속 실행하게 두는 것은 개발 중에도 고통을 만듭니다. 개발 중 많은 오류는 “사소한” 개발자 실수입니다. 오타, 단순한 논리 오류, 기타 작고, 가장 국소적이고, 대체로 사소한 실수들 말입니다. 그런 것을 만나면 저는 보통 가능한 한 빨리 고치고 프로그램을 다시 시작하고 싶습니다. 프로그램이 여전히 실행 중이면 수동으로 다시 시작해야 하고, 다른 작업들이 출력을 생성하고 있다면 예외가 스크롤백 속에 묻혀 버릴 수도 있습니다.
어떤 의미에서는, 오류를 “전달”할 더 나은 장소가 있으면 좋겠습니다. 단일 스레드 프로그램에서는 그 장소가 “호출자”입니다. 동시성이 존재할 때 작업들은 결국 되돌아갈 호출자가 없으니, 대신 무엇을 해야 할까요?
여기서는 Python의 asyncio 프레임워크에서 약간의 영감을 얻겠습니다. 이것은 위의 두 방식과는 구별되는 “제3의 길”을 취합니다. asyncio에서 작업은 Task 객체로 표현되며, 이것은 이벤트나 락이나 소켓 같은 것과 비슷하게 기다릴 수 있는 객체입니다. Task를 기다리면 그것이 완료될 때까지 막히고, 작업이 예외를 발생시키면 그 예외는 Task를 기다리는 누구에게든 다시 전달됩니다.
이 접근법은 특정한 입장을 강요하지 않습니다. 작업에서 빠져나온 예외를 누가 처리해야 하는지에 대한 입장을 취하지 않지만, 프로그래머가 스스로 그 결정을 내릴 수 있는 도구를 제공합니다.
하지만 여기에는 큰 단점이 따릅니다. 아무도 Task를 기다리지 않았고, 그 작업이 예외를 발생시키면, 그 예외는 사실상 프로그램 종료 시점까지 삼켜집니다. 그때가 되어서야 경고와 함께 출력됩니다. 누군가가 그 작업이 어떤 큐를 통해 출력을 만들어 주기를 기다리고 있었다면, 프로그램은 그저 영원히 멈춰 버립니다. 조용하고, 이해하기 어렵고, 수수께끼 같은 상태로 말입니다.
실제로 상황이 꽤 나쁘기 때문에, 저는 가끔 이것을 “기본적으로 asyncio는 메인 작업이 아닌 곳의 예외를 완전히 삼켜 버린다”라고 요약하곤 합니다. 문자 그대로 사실은 아니지만, 제 경험상 시작점으로 삼기에는 꽤 괜찮은 멘털 모델이고, 많은 개발자가 asyncio에서 겪는 경험을 유용하게 설명해 줍니다.
asyncio 접근법에는 추천할 점이 많습니다. 모든 **Task**를 반드시 기다리기만 한다면 말입니다. 이 불변 조건을 규칙으로 어떻게 강제할 수 있을까요? 가장 단순한 규칙은 아마 이럴 것입니다. 작업을 생성했다면, 그것을 기다질 책임도 당신에게 있다. 이 패턴은 asyncio.create_task(coro)를 비동기 컨텍스트 매니저로 만들어, 종료 전에 해당 작업을 기다리게 함으로써 표현할 수 있을 것입니다.
# (n.b. this is not real API in any version of Python)
async with asyncio.create_task(background_task()) as task:
# …
# `task` will be waited for on exit from this block, and any exception raised
물론 우리는 아주 자주 많은 작업을 만들고 싶어 하며, 때로는 동적으로 결정된 수의 작업을 만들고 싶기도 합니다. 그래서 contextlib.ExitStack의 관용구를 빌려, 임의의 수의 작업을 생성할 수 있게 해 주는 하나의 컨텍스트 매니저 객체를 둘 수 있습니다. 예를 들면 다음과 같습니다:
async with TaskLauncher() as tasks:
tasks.create_task(background_task())
# do the main work via a second task
tasks.create_task(asyncio.sleep(5))
# All tasks will be waited for on exit from the region
새 작업을 TaskLauncher를 통해서만 생성한다면, 이제 모든 작업이 분명한 부모에 “속해” 있고, 부모가 자식에게서 빠져나온 예외를 기다질 책임을 지는, 명확한 부모-자식 관계가 생깁니다. 어떤 작업이든 처리되지 않은 예외를 발생시키면, 그것은 이 계층 구조를 따라 위로 거슬러 올라갈 것입니다. 아무도 그것을 잡지 않으면, 결국 루트 작업과 asyncio.run 호출에 도달해 바닥을 칠 것입니다. 동시성 예외 처리 문제를 익숙한 단일 스레드 버전으로 바꾸는 데 상당히 가까워졌습니다!
불행히도 아직 한참 남았습니다. 위의 구상에는 서로 연관된 두 가지 심각한 문제가 있으며, 어느 쪽도 쉽게 고칠 수는 없습니다.
첫 번째는 교착 상태입니다. 위에서 우리는 작업의 부모가 “결국은” 그 작업을 기다릴 것이라고 말했습니다. 하지만 그것은 부모가 실제로 컨텍스트 매니저를 빠져나오기만 할 때만 사실입니다. 그런데 어떤 자식 작업이 오류를 만나 중단되었고, 원래 그 자식이 처리했어야 할 작업을 부모가 기다리고 있다면 어떻게 될까요? 그 작업은 결코 완료되지 않을 것입니다.
문제의 한 단면을 보여 주는 짧은 예가 있습니다:
from task_launcher import TaskLauncher
import asyncio
async def do_work(job_id, done_event):
if job_id == 1:
raise ValueError("Oops, job 1 failed!")
done_event.set()
async def main():
async with TaskLauncher() as tasks:
events = []
for i in range(4):
done = asyncio.Event()
tasks.create_task(do_work(i, done))
events.append(done)
for ev in events:
await ev.wait()
print("All done!")
if __name__ == '__main__':
asyncio.run(main())
이 예시는 흔한 패턴을 양식화한 버전입니다. 많은 “fan-out” 또는 “fan-out / fan-in” 동시성 패턴은 기본적으로 “몇 개의 작업을 시작한다; 그 작업들이 어떤 일을 수행한다; 부모가 그 일을 기다린다”라는 성격을 가집니다.
이 특정 버그에 대한 단순한 해결책은 많습니다4. 하지만 우리는 그것을 “자동으로” 혹은 어떤 일반적인 방식으로 고쳐 주는 패턴, 혹은 적어도 그렇게 쉽게 물릴 함정이 그 자리에 그냥 놓여 있지 않도록 해 주는 패턴을 찾고 싶습니다. 제 경험상 이런 종류의 교착 상태는 특히 동시성 시스템을 처음 개발하는 동안 믿기 어려울 정도로 쉽게 마주치게 됩니다.
단일 스레드 프로그램에서 오류 복구의 과제는 단지 제어 흐름을 언와인드하는 것만이 아니라, 실패한 작업과 관련되어 진행 중이던 어떤 일도 우리가 “되돌리거나” “정리”하도록 보장하는 것입니다. 메모리를 해제해야 할 수도 있고, 파일 핸들을 닫아야 할 수도 있으며, 어떤 자료구조를 일관된 상태로 복원해야 할 수도 있습니다.
동시성 프로그램에서는 실패한 작업과 연관된 자원에 여러 개의 서로 다른 실행 작업 자체가 포함될 수 있습니다! TaskLauncher와 연관된 작업 하나가 실패하면, 생성된 모든 작업이 어떻게든 중지되거나 적어도 버려진 상태를 정리할 기회를 갖도록 보장해야 합니다.
어떤 의미에서는 가장 단순한 해결책은 TaskLauncher가 종료되기 전에 생성된 모든 작업을 기다리는 것입니다. 그러나 실제로는 그 변경만으로도 교착 상태 문제는 훨씬 더 심각해집니다.
우리가 다음 두 가지를 모두 원한다면:
저는 기본적으로 서로 다른 작업에서 일어나는 사건에 반응해, 임의의 작업이 조기에 그리고 신속히 종료하도록 요청할 방법이 필요하다고 생각합니다. 다시 말해, 취소 메커니즘이 필요합니다.
우리는 여기서 발전시키고 있는 특정한 패러다임을 비추어 이 결론에 도달했지만, 이것은 훨씬 더 넓은 문제라고 생각하며, 돌이켜 보면 꽤 직관적이기도 합니다. 어떤 동시성 패러다임이든, 거기에는 “서로 협력하는 여러 동시 작업”의 어떤 버전이 존재합니다. 그리고 이는 곧 “그중 하나가 예상치 못하게 죽으면 어떻게 되는가”라는 질문에 대한 답을 요구합니다. 그리고 다시, 저로서는 “다른 작업들에게 취소하고 조기 종료하라고 요청한다” 이외에 완전히 일반적인 답을 상상하기가 어렵습니다.
물론, 일반 목적의 취소 메커니즘 없이도 특정 동시성 프로그램이나 패턴을 구현하는 것은 충분히 가능합니다. 임시방편 메커니즘과 신중한 추론 및 구성의 조합을 통해서 말입니다. 하지만 저는 그런 메커니즘 없이 일반적이고 조합 가능한 동시성 패러다임을 상상하기가 어렵습니다.
저는 이 결론이 불쾌하게 느껴집니다. 취소를 구현하고 지원하는 일은 어렵기 때문입니다. 그것은 사실상 모든 코드 조각에 추가적인 오류 경로를 도입하며, 게다가 그 오류 경로는 본질적으로 비동기적이어서 추론하거나 테스트하기가 어렵습니다. C의 pthread_cancel, Java의 Thread.stop, Ruby의 Thread.terminate 같은 역사적 취소 메커니즘 시도들은, 잘해 봐야 믿기 어려울 정도로 미묘하고 오류를 부르기 쉽고, 못해도 근본적으로 사용할 수 없습니다.
적어도 동시성의 맥락에서는 몇 가지 이점이 있기는 합니다. 이미 동시성 코드를 작성하고 있다면, 취소 메커니즘은 또 하나의 “비동기적으로 일어날 수 있는 일”을 추가하지만, 어쨌든 우리는 이미 그 문제의 어떤 버전은 안고 있습니다. asyncio 같은 협력적 동시성 시스템에서는 취소가 await 지점에서만 일어나도록 제한할 수도 있어, 잠재적인 혼란의 범위를 줄일 수 있습니다. 한편 Go는 취소를 Context 객체 안에 인코딩하고, 코드가 취소 여부를 명시적으로 확인하도록 요구하는데, 이것은 또 다른 종류의 절충을 가집니다.
더 일반적으로 말하면, 지난 수십 년 동안 이 분야는 많은 것을 배웠고, 이런 더 새로운 시스템들 중 일부는 실제로는 어느 정도 작동 가능한 일반 목적의 취소 메커니즘을 가진 것처럼 보입니다. 하지만 이 글은 취소 메커니즘의 과제와 설계 공간을 깊이 파고드는 것이 목적은 아니므로, 지금은 어떤 종류의 취소 API5가 있다고 가정하고 넘어가겠습니다.
작업을 취소할 능력이 있다면, 그것을 우리의 “작업 트리” 아이디어와 함께 사용해 동시성 오류 처리에 대한 꽤 일반적인 해법을 만들 수 있습니다:
TaskLauncher가 시작한 어떤 작업이든 예외를 발생시키면(컨텍스트 매니저를 실행 중인 부모 작업 자체도 포함해서), 우리는 다른 모든 작업(자식 작업들과 부모 작업 자신 모두)을 취소합니다.CancelledError 예외를 발생시켜, 작업이 그것을 잡았다가 다시 던질 수 있게 하거나, finally 블록이나 컨텍스트 매니저를 사용할 수 있게 하는 식입니다.TaskLauncher 컨텍스트를 빠져나갈 때, 우리는 모든 자식 작업이 종료되기를 기다립니다. 성공적으로 끝났든, 처리되지 않은 예외로 끝났든, 취소에 반응해 끝났든 상관없습니다.이 메커니즘을 사용하면, 동시성 오류는 이제 단일 스레드 오류와 꽤 비슷하게 동작합니다. 처리되지 않으면 잡혀서 위로 전파됩니다. 자식 작업에서 비롯된 오류를 포함해, 우리가 평소에 쓰는 오류 처리 메커니즘으로 잡아서 처리할 수도 있습니다. 단일 스레드에서 하던 통상적인 방식으로 오류 후 정리 코드를 작성하기만 하면, 여러 작업이 있는 경우에도 적절한 정리를 대부분은 얻을 수 있어야 합니다.
그 대가로, 우리는 동시성 코드에 추가적인 구조를 요구합니다. 작업들은 부모/자식 계층으로 중첩되어야 하고, 그 생명주기도 적절히 중첩되도록 보장해야 합니다.
이제 이 모든 것이 새로운 아이디어가 아니며, 제 발명도 아니라는 점을 인정해야 할 부분입니다(다만 이 형식으로 아이디어에 접근하는 글은 다른 곳에서 보지 못했습니다). 중첩된 생명주기를 가진 작업 트리와, 트리의 위아래로 자동 취소가 전파되는 이 패러다임은 최근 몇 년 동안 “구조적 동시성”이라는 이름 아래 서서히 그러나 꾸준히 인기를 얻고 채택되어 왔습니다.
이 아이디어와 구성 요소들의 계보는 길지만, 제가 알기로는 2016년에 처음 이름이 붙었고, 아마도 trio 프레임워크와, Trio의 창시자이자 수석 유지관리자인 njs가 쓴 이 아이디어를 깊이 있게 탐구한 에세이를 통해 가장 널리 알려졌습니다.
Python 3.11부터는 Python 자신의 asyncio에도 TaskGroup 클래스가 포함되어 있는데, 이것은 본질적으로 위에서 제가 설명한 TaskLauncher 구상의 (실전 사용 가능한) 버전입니다. trio는 자신들의 버전을 “nursery”라고 부릅니다. Go에서는 errgroup 패키지가 본질적으로 같은 의미론을 제공하며, 취소 지원을 제공하는 Go의 context 패키지를 바탕으로 합니다.
구조적 동시성에는 많은 장점이 있고, 저를 포함한 많은 사람들은 이 스타일로 프로그램을 작성하면 정확하고 안전한 동시성 코드를 쓰기가 훨씬 쉬워진다는 것을 발견했습니다(물론 다른 어려움이 남아 있는 것은 분명합니다!). 이 패러다임과 그 장점을 더 철저히 탐구한 글로는, 앞서도 링크했던 njs의 고전적인 에세이를 강력히 추천합니다.
마지막으로, 오류 처리에 대한 하나의 성찰과, 제가 애초에 왜 이런 렌즈와 이 글을 떠올리게 되었는지에 대한 이야기를 남기고 싶습니다.
프로그래머들이 오류 처리를 생각할 때, 그것은 종종 “견고성”의 문제로 분류되거나, “프로덕션” 혹은 “진지한 소프트웨어”의 문제로 여겨집니다. 즉, “규모가 커질 때”, 혹은 무언가가 “신뢰할 수 있어야 할 때”, 또는 매우 다양한 환경에서 실행되고 네트워크로부터의 예기치 않은 입력을 처리해야 할 때 신경 써야 하는 주제처럼 말입니다.
그리고 그것은 전부 사실입니다. 그런 종류의 시스템에서는 무엇이, 어떻게 잘못될 수 있는지, 그리고 그것을 어떻게 신중히 처리할지를 진지하게 생각하는 것이 분명 중요합니다.
그렇지만 제가 이 생각의 흐름을 시작한 출발점은 “성숙하고 견고한 프로그램” 쪽이 아니라, 그 정반대 끝이었습니다. 즉, 바닥부터 새로운 코드를 작성하는 개발 경험에 대해 생각하는 것이었습니다. 앞서 잠깐 언급했듯이, 새 프로그램을 쓸 때는 — 동시성이 있든 없든 — 초반에 “멍청한 버그”가 잔뜩 있는 경우가 아주 많고, 그저 그것들을 가능한 한 빨리 처리해 나가야 하는 단계가 매우 자주 있습니다.
구조적 동시성 프레임워크 밖에서 동시성 프로그램을 작성해 본 제 경험으로는, 바로 그 기본적인 개발 루프, 즉 “프로그램 실행, 멍청한 버그 확인, 멍청한 버그 수정”이라는 과정을 단순히 돌리는 것 자체가 정말 짜증 날 정도로 어려워지는 경우가 매우 많습니다. 단일 스레드 프로그램이라면 보기 좋은 스택 트레이스를 출력하고 종료했을 멍청한 버그가, 교착 상태로 바뀌거나, 삼켜지거나, 혹은 그보다 더 뒤틀린 무언가가 되는 나쁜 습관이 있기 때문입니다. 그리고 오류 처리를 추가하려는 ad-hoc 시도가 때로는 상황을 더 악화시킨다고 느낍니다! 예를 들어, 가끔 저는 오류를 어떤 파이프라인을 통해 “전달”하는 것이 “자연스러운” 접근법이라고 생각하곤 했습니다. 그러면 큰 동시성 작업의 끝에서 모든 오류를 모아 한 곳에 기록할 수 있으니까요. 그 접근법은 작동할 수 있지만, 때로는 전체 프로그램이 끝날 때까지 어떤 오류도 알 수 없게 만든다는 뜻이기도 하며, 그것은 개발 중에는 정말 답답합니다!
그래서 저는 구조적 동시성 접근법을 채택하거나, 적어도 제 환경에 “진짜” 구조적 동시성 라이브러리가 없더라도, 그것을 기본적인 사고방식과 패러다임으로 받아들이는 것만으로도, 동시성 프로그램을 처음부터 작성하고 디버깅하는 일이 극적으로 더 쉬워진다는 것을 발견했습니다. 심지어 금방 버릴 프로토타입에서도 그렇습니다. 그 효과는 단지 “언젠가”나 “프로덕션에서”가 아니라, 거의 즉시 배당을 가져다줍니다.
longjmp는 예외와 언와인딩을 구현하는 데 도움이 되는 원시 도구로 사용할 수 있습니다. 하지만 그것 자체로는 훨씬 더 저수준의 원시 도구이며, 매우 다양한 다른 패턴을 허용합니다.↩︎
저는 하드웨어 병렬성보다는 논리적 동시성에 더 관심이 있으므로, “task”라는 단어를 다른 여러 실행 흐름과 시간상 뒤섞일 수 있는, 별도의 선형적 실행 순서를 가리키는 데 사용하겠습니다.↩︎
이 논의의 대부분은 많은 언어와 동시성 프레임워크에 폭넓게 적용되도록 의도되었지만, 구체성을 위해 Python 예시를 사용하겠습니다. 다른 장점들 외에도, Python은 스레딩과 협력적 비동기성을 모두 지원하기 때문에 여러 패러다임을 탐구할 수 있게 해 줍니다.↩︎
아마 가장 단순한 수정은 asyncio.Event를 완전히 제거하고, 대신 Task 객체들을 우리가 직접 asyncio.gather 하는 것일 것입니다. 여기서는 쉽지만, “작업 단위”와 “자식 작업” 사이에 1:1 대응이 없을 수 있는 경우에는 항상 그렇게 단순하지는 않습니다.↩︎
그리고 실제로 Python의 asyncio에는 광범위한 취소 메커니즘이 있다는 점도 덧붙여 두겠습니다.↩︎
내 뉴스레터를 구독하세요(블로그 업데이트와 가끔 올라오는 다른 콘텐츠):
Nelson Elhage의 Made of Bugs는 Creative Commons Attribution 4.0 International License에 따라 라이선스가 부여됩니다.