Rust의 async는 JS의 그것과는 다르더라


요약

Javascript의 async 문법은 복잡할 수 있는 비동기 로직을 쉽게 작성할 수 있어서, 기존에 callback 구현을 빠르게 갈아치우고 Javascript 코드 구현의 표준으로 자리잡았고, 다른 언어들도 빠르게 비슷한 문법을 도입하게 되었다.

Rust 또한 async 문법을 지원하는데, async 내에서 참조하는 객체가 이미 없어지는 상황이 발생하면 곤란하기 때문에 거기에 맞춰서 객체의 lifetime을 잘 설계해줄 필요가 있다. 자칫 잘못하면 불필요하게 객체들을 모조리 Arc로 감싸야 한다거나, 'static 으로 선언해야 하는 문제가 생길 수 있고, 이는 가능한 한 효율적인 구현을 목표로 하는 Rust 프로젝트에서 좋지 않은 선택이 될 수도 있다.

서론

LSP 서버가 구동하는 동안에 주기적으로 돌아가는 백그라운드 작업을 만들어본다고 치자. 해당 작업은 외부 요청 없이도 작동해야 하니까 보통은 tokio의 JoinHandle로 구현하는게 적절해 보였다. 그래서 JoinHandle을 추가하고 해당 작업이 읽고 쓸 데이터를 연결해주는데… 어째 코드가 갈수록 복잡해진다?!!

대망의 문제: task가 document_map에 대해 알아야 하네?

struct Backend {
  document_map: DashMap<String, Document>,
  task_loop: JoinHandle<()>,
}

원래 document_map을 통해 문서 정보를 관리하고 있던 우리의 Backend 어린이, 어느날 개발자가 뜬금없이 task_loop이란 걸 추가해서 주기적으로 문서를 수정하고 싶다고 하네요.

안타깝게도 Backend는 개나소나 들고있는 전역변수 같은게 아니라서, task를 생성하는 시점에 따로 지정해 주지 않으면 task 자체적으로는 document_map에 접근할 방법이 없어요. 자연스럽게 task에게 document_map의 reference를 넘겨주면 좋겠다 생각을 했는데, 아뿔싸 async로 생성하는 task의 lifetime은 이론상 'static이 되어야 하네요? 따라서 task가 참조하는 document_map의 lifetime 또한 'static으로 변경되어야 한다는 소리가 되네요.

바꿔말하면, document_map은 원래 Backend 것이라서 Backend가 없어질 때 같이 없어져야 하는데, task_loop이라는 새로운 녀석이 생겨서 이제는 document_map이 죽어도 task_loop이 살아있는 상태가 되어야 하는 것이네요. 실질적으로는 task_loop 또한 Backend의 것이니까 같이 죽여도 되는데, 컴파일러가 그 사실까지 알지는 못하니까, 불필요하게 lifetime을 늘어뜨리게 되었어요.

생각없이 document_map을 Arc로 감싸서 여러 쓰레드에서 공유할 수 있게 만들어봤어요.

struct Backend {
  document_map: Arc<DashMap<String, Document>>,
  task_loop: JoinHandle<()>,
}

그나마 다행이라고 할 수 있는건, DashMap이 이미 concurrency를 염두에 두고 설계된 물건이라 동시 변경에 대해서까지 걱정할 필요는 없다는 점?

Javascript였다면?

Javascript에서 동일한 기능을 구현한다고 가정해 보면, task_loop 또한 그냥 전역 event loop 에서 동작하는 또하나의 timer라고 볼 수 있다. 결국 해당 명령어를 실행하는 것이 main thread이기 때문에 동시성 문제같은것도 없고, 변수의 lifetime같은것도 신경쓰지 않아도 되니 구현이 간단해질 수 있을 것 같다.

대충 생각해보는 javascript pseudo-code:

class Backend {
  constructor() {
    this.document_map = new Map();
  }
}

async function main() {
  const backend = new Backend();
  const task_loop = setInterval(() => {
    // backend.document_map을 마음껏 물고뜯고즐기고...
  }, 1000);
  // ... main loop ...
  clearInterval(task_loop);
  // other cleanup on backend if necessary
}

설계문제인가?

돌이켜서 생각해보면, Document에 대해 접근할 수 있는 경로가 여러 개 생기는 것이 문제인 것 같다. 원래 document_map이 Backend에서만 접근할 수 있을때는 문제가 없었는데, task를 위한 접근경로를 추가하는 작업이 안맞는 옷을 억지로 끼워맞추려다 탈이 난 사례이지 않나 싶다.

결론?

Rust에서 제공하는 메모리 모델은 괜찮은 성능을 보장해주지만, 거기에 맞춰서 로직을 구현하는 일이 항상 편하고 즐겁진 않을 수 있다. 특히 Rust의 async는 잘못 쓰면 lifetime을 쉽게 망치기 때문에 주의해서 쓸 필요가 있다.

비즈니스 로직은 그냥 javascript, 혹은 Golang으로 짜는게 낫고, Rust에서 뭔가를 할 땐 가능한 lifetime을 강하게 제한할 수 있는 방법으로 구현할 방법을 고민해봐야 할 것 같다. 아니면 모조리 'static 라이프타임을 주고 모조리 메모리 해제 없이 진행해 보시던가…