보통 어플리케이션 개발에 러스트를 스택으로 포함시킨다 함은 성능이 중요한 부분을 컴파일 언어로 작성하겠다는 의도이다. 이 아지트도 백엔드가 러스트로 작성되어 있지만 성능은 그다지 중요하지 않다. 중요한 것은 타입..! 오직 타입 안전성이다.
SQL 쿼리의 타입 검사
일단은 SQL을 직접 작성하고 싶었다. 근데 타입스크립트에서 SQL이란 그저 문자열이기 때문에 타입 체크가 안 된다. 아마 가짜 데이터베이스를 띄워서 실행해보는 식으로 유닛 테스트를 잔뜩 작성해야 했을 것이다. 러스트에는 sqlx라는 라이브러리가 있다. 이 라이브러리를 사용하면 컴파일 시간에 이미 있는 데이터베이스에 PREPARE 쿼리를 날려 쿼리문의 입출력 타입이 러스트 변수 타입과 일치하는지 확인해준다. 그럼 적어도 현재의 스키마와 쿼리문, 러스트 코드가 서로 타입 안전하다는 것을 확인할 수 있다.
리액트와 서버 렌더링
하지만 결국은 리액트를 완전히 버릴 수는 없다. 일단 일지 목록 무한 스크롤을 구현하려면 캐싱과 DOM 업데이트를 직접 해줘야 하는데 리액트 안에서 놀다가 그런 짓을 하려고 하면 상당히 머리가 아플 것이다. 한번 시험삼아 러스트 안에서 소개 페이지, 일지 목록, 일지 내용과 댓글 목록까지 렌더링을 해보았지만 일지 작성이나 댓글 작성 UI를 만들고 페이지 전환까지 생각하니 리액트가 해주는 일이 많았다는 것을 새삼 느낀다. 정교한 서버 렌더링을 하려면 결국 Next.js를 써야 하는데 그렇게 하면 클라이언트와 디비 사이에 최소 서버 두 개, nginx까지 낀다고 하면 세 개를 거쳐서 왔다갔다 해야한다. 그건 너무 복잡하고 하여튼 예쁘지가 않다. 이게 다 예쁜 코드 짜보겠다고 하는 짓이니까....
또 일지 목록이나 열람 같은 간단한 읽기 기능들을 러스트에서 직접 렌더링하도록 구현해보니 Ajax 통신에서 생기는 오류나 지연을 처리할 필요가 없어 굉장히 편했다. 러스트-타입스크립트 간에 타입 맞추려고 OpenAPI 문서 만드는 것도 신경 안 써도 된다. Next.js도 보통은 디비가 달린 백엔드에 요청을 날리도록 짜기 때문에 OpenAPI 문서는 계속 관리를 해야 한다. 렌더링 서버와 백엔드 서버 사이의 통신을 어떻게든 간소화할 수 없을까!
N-API: Node.js에서 직접 Rust 함수 부르기
그러던 차에 어제 잠이 안 오는 김에 찾은 게 N-API라는 녀석이다. 얘는 node.js에서 C 함수들을 쉽게 부를 수 있게 해주는 인터페이스다. C 함수를 부를 수 있다는 것은 다시 말해 동적 라이브러리(*.so 또는 *.dll)의 코드를 바로 실행할 수 있다는 뜻이다. 러스트도 컴파일 언어니까 당연히 C에서 부를 수 있는 함수로 컴파일이 가능하다. 이를 활용해서 예전에 지뢰찾기 매크로에서도 파이썬에서 C 함수를 부르는 짓을 해본 적이 있다. 러스트에 그런 세팅이 있나 찾아보니 Vercel에서 만든 napi-rs라는 게 있더랬다. 이걸 쓰면 러스트 라이브러리를 node.js 모듈로 컴파일하면서 타입스크립트 타입까지 같이 만들어준단다. 좋았어!
번들링 문제 해결
그렇게 해서 Next.js + napi-rs + Rust sqlx라는 엄청난 스택을 세팅하는 데 성공했다. 한가지 어려웠던 점은 Next.js가 지맘대로 번들링하는 과정에서 바이너리로 컴파일된 모듈을 어떻게든 번들에 욱여넣으려 하다가 못하겠슈~ 하고 죽어버리는 문제였다. 이 부분은 Next.js 설정에서 'serverComponentsExternalPackages'라고 선택한 패키지를 번들링에서 제외하는 기능으로 해결을 했다. 한가지 마음에 안 드는 부분은 러스트 라이브러리를 컴파일할 때마다 매번 'yarn upgrade'로 패키지 파일을 업데이트해줘야 한다는 점이다. node_modules 폴더 내에 심볼릭 링크를 만드는 link 프로토콜을 쓰면 깔끔하게 처리가 됐을텐데 Next.js에 버그가 있어서 강제로 file 프로토콜을 써야했기 때문이다.
남은 문제들
이제 남은 것들은 설계에 대한 문제다. 특히 러스트와 node.js에서 각각 어디까지 담당할 것인지는 생각해보아야 한다. 일단 데이터베이스의 트랜잭션이 러스트 안에서만 사용되기 때문에 하나의 요청을 수행하는 것이 러스트 함수의 기본 단위가 된다. 한가지 고려해야 할 점은 입력의 검사를 어디서 할 것인지다. 일반적인 JSON 페이로드의 경우에는 어느쪽에서 하든 크게 상관은 없다. 굳이 따지자면 코드로 쓰기 편한 자바스크립트 쪽에서 하면 좋을 것이다. 그런데 어떤 입력들은 디비와 밀접하게 연관이 되기도 한다. 특히 사용자 인증과 관련된 검사들이 그러하다. 토큰 관리 등등은 redis와 SQL 데이터베이스를 같이 써야하니 러스트에서 해야 한다. 사실은 그냥 그렇게 짜도 되지만 그렇게 되면 관심사가 여기저기 분산되어버린달까... 그냥 인증 부분만 잘 감싸서 러스트에서 하는 걸로 하면 될 것 같다. 또 한가지 복잡한 건 파일 업로드일텐데 이건 일단 관련 부분 구현할 때 생각하는 걸로 미뤄두는 게 좋겠다. 인증 토큰을 어떻게 관리할지도 렌더링 서버와 백엔드 서버를 분리할 때는 복잡한 문제이지만 이제는 그냥 Next.js에서 쿠키도 잘 써서 관리하면 될 것 같다. 이것도 천천히 생각해보기로....
정리
아지트 개발은 나의 웹개발 도전의 장이다. 이런저런 최신 기술들을 끌어모아 만족스러운 개발자 경험과 결과물을 만드는 것이 목표다. 오늘 해결하고자 했던 문제는 이렇게 정리할 수 있다:
- SQL 스키마와 쿼리를 직접 작성하기
- SQL/서비스 간에 타입 안전성 확보
- 서비스-렌더링 간에 타입 안전성 확보
- 리액트 SSR 도입
이 문제들은 아래 기술 스택으로 해결책을 찾았다:
- Rust sqlx를 통한 SQL 쿼리 타입 검사
- napi-rs를 통한 node.js/Rust 상호운용
- Next.js를 통한 안정적인 리액트 SSR
이제 도커로 감싸고 인증 구현하면 기존 기능은 거의 구현이 될 것 같다. CSS 가독성도 어떻게 잘 높여볼 생각이다. 앞으로 추가해야 할 기능도 많다. 할 일이 많구나, 신난다!
[참고]
- sqlx: https://github.com/launchbadge/sqlx
- Next.js: https://nextjs.org/
- OpenAPI: https://www.openapis.org/
- NAPI-RS: https://napi.rs/
- Next.js + napi-rs: https://github.com/vercel/next.js/issues/59648
- monorepo + external packages: https://github.com/vercel/next.js/issues/43433
- yarn link 프로토콜: https://yarnpkg.com/protocol/link