메인 내용으로 이동

집계 중...

Bing Chat for All Browsers. Verified at cho.sh. Featured on Chrome Web Store. Productivity. 200,000+ Users.

ChatGPT를 활용한 검색 AI, Bing Chat은 Edge 브라우저에서만 작동한다. 그리하여 Chrome과 Firefox에서도 동작하도록 확장 프로그램을 제작했다. 길고 긴 우여곡절 끝에 75만의 방문자, 50만의 설치, 23만 명의 활성 사용자 수를 달성했고, 마이크로소프트에서 내용증명을 받는 것을 끝으로 프로젝트를 종료했다.

이제 그 이야기를 해보자.

첫 시장 등장

ChatGPT가 처음 출시된 이후 수많은 앱들이 시장에 출시되었다. 성공적으로 정착한 앱들의 공통된 특징은 조악하게 출발하여 시장에 안착해 수십만 사용자들의 관심을 받으며 반강제 성장했다는 점이다. 즉 무엇보다 중요한 것은 타이밍이었고 수많은 사람들이 사용하는 앱은 자연스럽게 발전한다는 사실을 확인했다. 그런 시장의 패턴을 관찰하며 기회를 살피고 있었다. 그러다 Bing Chat이 출시되었다. 대부분의 소비자는 어차피 Chrome을 사용하고, Edge도 어차피 크로미움 브라우저이다. 즉 Edge와 Chrome을 구별하기 쉽지 않을 테고, 그렇다면 자연스럽게 이를 우회하는 프로그램이 있다면 사람들이 사용할 것. 이를 생각하며 하루 만에 개발을 끝내서 배포했다. 굉장히 초반에 Bing Chat의 존재를 파악하고 웨이트 리스트를 신청해둔 것도 큰 도움이 되었다. 이에 대한 이야기는 나중에 해보도록 하자.

야후재팬 1면

첫 며칠은 유별난 사용자 증가가 없었다. Bing Chat이 초반에 GPU 부족을 이유로 웨이트 리스트 제도로 운용되며 많은 사람들이 Bing Chat을 쓰지 못했기 때문일 것이다. 그러다가 2023년 2월 말, 사용자가 급격하게 증가했다. 신기하게도 모두 일본 사용자들이었다.

"Bing Chat for All Browsers" has appeared, which makes it possible to use the chat Al function of "Bing", which is currently available only in "Microsoft Edge", in browsers other than Edge. Developer Sunghyun Cho has released extensions for Chrome and Firefox.

야후재팬 IT면 1면에 걸리다. 아카이브

알고 보니 일본 1위 포털 야후재팬 1면에 소개되며 사람들이 대거 유입된 것이다. 그 이후로 유기적으로 사용자가 급증하기 시작했다. Bing Chat과 일본에 대한 연구 노트

23만 사용자 그리고 인수 제의

그렇게 지속적으로 사용자가 증가하면서 많은 사람들을 만났다. GitHub에서 1.5k 🌟도 달성해 봤다. 작성된 이슈는 약 120건 정도이며 이메일, Chrome 스토어, Firefox 스토어에서 대응한 건들을 합하면 대략 1,000건 정도로 추산한다. 하루 평균 10건 정도인데 후반에 훨씬 몰려 있었으니 후반에는 굉장히 바빴다.

GitHub 1.5K Stars Screenshot

그러던 중 여러 매체를 통해서 인수 의사가 있다는 사람들이 접촉해왔다. 주로 급격한 성장을 원하던 인공지능 관련 회사들이거나 기술 기업들의 광고를 수주하고 중개하는 회사들이었다. 특히나 이런 단순한 기능을 적용시켜서 한 번 자리를 잡은 앱들은 꽤나 괜찮은 인수 대상이다. 설치한 사람들의 관심 분야 명확하고 (Gen AI), 기능이 별로 없어 기술 부채 얕고 (자사 기능으로 넘겨가기 쉽고), 사용자층 넓고 이미 수많은 미디어와 블로그가 익스텐션 설치 페이지로 링크를 걸어 놓았으니 말이다.

Acquisition Quotation Request

Advertisement Quotation Request

Acquisition Quotation Request

여러 인수/광고 제의. 가격도 제각각이었다.

여러 이유로, 판매하거나 금전적으로 연루되지 않았다. 다만 협상 경험은 나중에도 도움이 될 듯하다. 하나 흥미로웠던 사실은, 그들은 사실상 제품보다는 23만+ 활성 사용자를 구매하고 싶은 것이었을 텐데도 서로 생각하는 단위 수가 극명하게 달랐다는 점이다.

창과 방패

그러던 와중에 마이크로소프트는 매주 브라우저 탐지 로직을 업데이트했다. 수십 건의 GitHub 이슈가 등록되고 있었고, 매일 많은 버그 리포트 이메일을 받고 있었다. 대부분은 사무적으로 몇 가지 순서에 따라서 새로고침을 하거나, 브라우저 User Agent 값을 고쳐주면 되었기에, 그렇게 어려운 수정은 아니었다.

그러다가 한순간에 내 인식을 바꿔놓는 사건을 겪는다. 밤 11시에 수십 명의 유저가 동시에 연락을 한 것이다. GitHub 이슈 링크를 첨부해두겠다. 제일 큰 문제는 나는 문제없이 서비스를 이용할 수 있었고 도저히 내 기기와 주변인들의 기기에서 현상 재현이 안 되는 것이다. 그럼에도 2시간에 걸쳐 GitHub 그리고 받은 이메일은 계속 포화되고 있었다.

그렇게 밤새 잠도 제대로 자지 못하고 12시간 동안 디버깅을 한 결과 마이크로소프트에서 독자적인 헤더 규격을 만든 것을 알게 되었다. 애초에 Bing 혹은 Google 같은 고도의 프런트엔드 웹사이트는 사용되는 헤더와 쿠키, 그리고 로컬 스토리지가 수백 개에 달한다. 그중에서 어느 헤더의 유무가 서비스 접근에 영향을 주는지 가려내는 것은 쉬운 일이 아니다.

좀 극적으로 비유하자면 이와 같은 리버싱이나 CTF 해킹은 미제 사건 수사와 다를 게 없다. 어느 정보가 유효한 단서이고 어느 것이 불필요한 조건인지, 우연인지, 사람마다 다른 건지, 오탐지인지, 미 탐지인지 알 길이 없다.

Only Bing Uses UserAgentReductionOptOut

Less than 0.1% of the web uses UserAgentReductionOptOut.

UserAgentReductionOptOut이라는 헤더. 사용되는 사이트가 전세계에서 bing.com 뿐이다.

이때 마이크로소프트의 의지에 깊은 인상을 받았다. 생각해 보면 Bing에서 GPT-4 수준의 서비스를 무료로 제공하는 것도, 오직 Edge에서 사용 가능하도록 제한 걸어놓은 것들도 결국 AI를 이용해 구글과의 단판 승부를 원한 것일 거다. 그런 와중에 가장 큰 경쟁자 Chrome에서 자신들의 기능들을 모두 사용할 수 있도록 열어버리니, 그리고 23만 이상의 사용자를 Edge로 못 넘어오게 막고 있으니 마이크로소프트 입장에서는 꽤나 고까웠을 것이다. 이때를 기점으로 나는 이 프로젝트가 무한정 지속될 수 없다는 사실을 체감했다. 마이크로소프트는 지속적으로 자사의 제품을 위한 다양한 전술을 내놓을 것이고 무보수로 일하며 특히나 이런 밤샘 온콜(On-call)에 무한정 응하기 어렵다. 언젠가는 멈출 것이다. 그리고 나는 그 시점을 대략 마이크로소프트가 타사 브라우저에 접근 권한을 열 때... 라고 짐작하고 있었다. 그때가 되면 제품의 시장 유효성이 사라지기 때문이다.

Bing을 Bing이라 부르지 못하고

그러나 그 시점은 그렇게 멀지 않았는데, 이번에는 마이크로소프트가 상표권을 걸고넘어졌다. Bing의 로고와 이름을 사용하는 것은 문제가 되는 사항이므로 5일 이내 해결하지 못할 경우 Google 법률 팀에 요청해서 서비스를 정지하겠다는 것이다. 마이크로소프트가 직접 칼을 빼들었다는 명예 훈장을 받았다는 기분 반. 막막한 기분 반. 두 가지 감정이 교차하며 고민하기 시작했다. 소식에 따르면 마이크로소프트에서 늦어도 3월부터 내 익스텐션의 존재를 알고 있었으니 지금까지 방치하다가 이제서야 칼을 빼들은 것 같다.

Letter from Microsoft Legal

tracer.ai는 마이크로소프트가 고용한 브랜드권 보호 소프트웨어 업체처럼 보인다.

인디 앱이 브랜드의 이름을 쓰는 것은 하루 이틀 일이 아니다. 특히나 익스텐션은 더 그렇다. 익스텐션은 본질적으로 서비스를 보조해 주는 프로그램이기 때문에 사용자들이 익스텐션을 설치하는 행위 자체가 자신들의 서비스를 지속적으로 이용할 것이라는 확증이다. 때문에 사용자들이 자신의 서비스를 위한 익스텐션을 개발하는 것을 장려했으면 했지 최소한 막지는 않는다. 내 익스텐션이 Bing Chat의 접근성을 개선해 사용자를 늘렸으면 늘렸지 과연 그 사람들이 아니라고 해서 브라우저를 완전히 Edge로 변경했을까? 나는 브라우저의 경로의존성이 더 크다고 생각하기 때문에, 내가 빼앗은 고객보다 (Edge로 바꿨을 수 있었는데 내 익스텐션 때문에 안 바꾼 사람들) 내가 데려온 고객(Bing Chat을 호기심에 Chrome에서 몇 번 사용해 볼 사람들)이 더 많았던 것 같은데 말이다.

💭그냥 겁주는거 아닐까요?

물론 기계적으로 그냥 보내는 문서일수도 있다. 하지만 우선 우후죽순 등장하고 있는 Bing AI 관련 앱에 모두 문서를 보냈을 리 만무하다. 어쨌건 마이크로소프트 내부적으로, 사용자가 많은 앱들을 대상으로 조치를 취하기로 결정한 것이다.

무엇보다 과거에 선례가 있는데...

선례: MikeRoweSoft 대 Microsoft

마이크로소프트는 상표권과 권리의 면에서 깐깐한 것으로 유명하다. 대표적으로 2004년 학생 마이크 로(Mike Rowe)의 도메인 MikeRoweSoft.com에 등록상표 침해 소송을 건 사건이 있다. 마이크도 나와 비슷하게 등록상표 침해 내용증명을 받았다. 그는 불응했으며, 법정 공방을 하다 소액에 합의했다. 지금 나의 상황도 그리 크게 다르진 않을 것이다.

결론

나도 서류를 받고 많은 생각이 들었다. 난생 처음 겪는 23만명이라는 숫자의 앱을 포기한다는 것이 쉬운 일이 아니다. 그럼에도, 앞서도 언급했지만 이미 정신적으로 포화된 상태였다. 수많은 사람들이 GitHub에서 기능 및 개선 요청을 하기 시작했고, 이메일로 끝도 없이 밀려드는 버그 리포트에, 제일 큰 문제는 디버깅을 할 수 없다는 것이다. 아마 이번 기회를 넘겼어도 마이크로소프트는 기술적 전술이든 법적 전술이든 무엇이든 지속적으로 무언가를 했을 것이다.

더군다나 마이크로소프트에서 머지않은 미래에 모든 브라우저에 개방할 것이라는 입장을 발표했다. 기능을 다양하게 추가해서 앱 개발을 지속할 수도 있겠지만 그것은 사용자 수를 포기하기 싫어서 진행하는 개발이지 정말 제품을 위하여 하는 것은 아닐 것이다. 여러모로 제품 수명이 그리 길게 남지도 않은 셈이고, 구태여 불응하여 문제를 야기할 필요가 없었다.

그리하여 많은 교훈을 얻은 것에 만족하며 여기까지 하기로 결정했다. 이후 앱을 스토어에서 삭제(Unpublish)한 후 저장소를 아카이브했다.

배운 점

무보수 오픈소스는 감정노동자이다

가장 크게 느낀 점이다.

어떤 면에선 GitHub 알림은 부정적인 메시지의 연속입니다. 여러분의 앱이 만족스러울 때 PR을 여는 사람은 없습니다. 부족한 부분을 발견했을 때만 그렇게 합니다. 이러한 알림을 읽다 보면 약간의 시간만 투자해도 정신적, 감정적으로 지칩니다. 오픈소스 관리자가 되는 기분

나도 다르지 않았다. 이메일은 익명으로 불평하는 사람들이 넘쳐나며 순수하게 욕설과 불평만 하는 경우도 허다하다. 모든 사용자를 만족시키는 것은 불가능에 가깝다. 하나의 수정을 가하려 하면 반대하는 사람 셋이 항의하고 수정하지 않으면 그 기능을 원하던 사람들이 다시 항의한다. 매일 GitHub 알림은 꺼질 줄 모른다. 이런 작은 앱이 이 정도인데 도대체 중-대규모 작업은 어떻게 유지되는 것일까. 무보수 오픈소스에 있어서는 명예, 혹은 셀프-열정페이도 잠깐이지, 오래 지속하지 못한다.

리누스, 제노비스, 그리고 마태 효과

오픈 소스를 열렬히 지지할 때 등장하는 단골처럼 등장하는 문구가 있다.

보는 눈이 많을수록 오류들은 사소해진다는 개발 철학을 리누스의 법칙이라고 한다.

하지만 우리는 그와 정확하게 반대되는 사회적 현상을 안다.

주위에 사람들이 많을수록 어려움에 처한 사람을 돕지 않게 되는 현상을 제노비스 신드롬이라고 한다

그렇다면 도대체 무엇이 옳은 것일까? 내가 내린 결론은 후자에 더 가깝다. 과학계에서는 이미 존재하는 단어로, 마태 효과라고 칭한다.

로버트 머튼은 저명한 연구자가 더 많은 혜택(지원금 등)을 가져가고, 잘 알려지지 않은 연구자는 그렇지 못함으로써 점점 두 사이에 격차가 벌어지는 현상을 두고 마태 효과라 칭했다.

기여하는 사람들은 아주 소수이며 그들이 반복해서 기여한다. 아주 일부의 기여자가 반복해서 기여하며 그마저도 몇 가지 저장소에 극심하게 편중된다. 요컨대 기여자도, 기여 받는 저장소도 편중이 심하다.

가장 희귀한 자원은 인간의 의지

나는 지속적으로 프로젝트를 운영하고 발전시키는 것이 약한 듯하다. 이전에 진행한 프로젝트들도 치명적인 오류가 아니면 개선하고 발전시키는 것이 무척 힘들었다. 취미로 하는 작업들인 만큼 무엇보다 결정적인 요소는 의지력이었다. 즉 속도가 중요한 이유는 시장까지의 시간(Time-To-Market)이 아니라 개인에 있어 가장 결핍되기 쉬운 의지력이 고갈되기 전에 유의미한 지표를 마련해야 하기 때문이다. 그것이 되지 않는다면 장기적으로 유지할 의지력을 구매해야 한다 (=월급).

하지만 매번 단숨에 무슨 일을 하려고 하면 딱 단숨에 할 수 있는 만큼만 성과를 낸다. 단숨에 체력을 늘리는 방법도 있지만, 보다 근원적 해결책은 호흡의 균형을 맞추는 것이다. 이제는 무산소 운동보다는 유산소 운동이 필요한 때이다. 그래서 이제는 장기간 동안 지속적으로 발전시킬 수 있는 영속적인 장치와 서비스들을 개발해 보고 싶었다. 선의와 열정에만 의지하지 않고 자생력을 갖추고 지속적으로 새로운 가치를 창출하는 그런 서비스. 그런 서비스를 만들고 싶다는 생각이 들었다.

통계

마지막으로 몇 가지 통계를 남긴다.

🪦 기념비

시간에 따른 주간 사용자 수

시간에 따른 주간 사용자 수. Chrome 사용자가 21만, Firefox 사용자가 2만 명 정도 되었다.

지역에 따른 주간 사용자 수

지역에 따른 주간 사용자 수

언어에 따른 주간 사용자 수

언어에 따른 주간 사용자 수

운영체제에 따른 주간 사용자 수

운영체제에 따른 주간 사용자 수

제품 버전에 따른 일일 사용자 수

제품 버전에 따른 일일 사용자 수

시간에 따른 평점 분포

시간에 따른 평점 분포

페이지 뷰

페이지 뷰

Chrome 웹스토어에 노출 횟수

Chrome 웹스토어에 노출 횟수

페이지 뷰에 따른 상위 3개 출처

페이지 뷰에 따른 상위 3개 출처

소스 별 페이지 뷰

소스 별 페이지 뷰

활성 및 비활성 비율

활성 및 비활성 비율
마음에 드시나요?

커피챗은 언제나 환영입니다. 이메일 보내주세요!

집계 중...

아이폰 키보드 "하늘땅사람"의 모습

💎잔말 말고 설치 링크부터 줘요

물론이죠. 앱스토어에서 설치해보세요. GitHub 저장소도 있답니다.

애플 아이폰을 찾는 소비자들이 증가하고 있다. 눈에 띄는 부분은 아이폰을 찾는 중장년층 소비자가 늘어나고 있다는 점이다. 대부분의 사람들은 갤럭시에서 아이폰으로 못 넘어오는 이유로 통화녹음과 삼성페이를 꼽는데, 부모님이 아이폰으로 바꾸신 뒤에 내가 관찰한 바는 조금 달랐다.

아무도 손에 꼽지 않은 예상 외의 난관은 바로 키보드였다. 대한민국 소비자들은 10키 휴대전화 시절부터 문자를 입력함에 아무런 불편함이 없었다. 세종의 제자 원리를 본따 만든 천지인이라는 강력한 입력 방식 때문에, 하나의 버튼에 알파벳이 3개, 4개씩 붙어있는 영미권에 비해 쿼티 키보드의 필요성이 현저히 적었기 때문이다. 태어났을 당시부터 스마트폰이 존재하던 알파 세대가 아닌 이상 여전히 천지인 키보드를 사용하고 계시는 분들이 많다.

2010년 천지인의 특허가 개방된 이후 아이폰에도 2013년부터 천지인 키보드가 추가되었지만, 이상하게도 아이폰은 키보드의 모양이 조금 달랐다. 가장 결정적인 차이점은 자리넘김 버튼과 띄어쓰기 버튼이 따로 존재한다는 점이다.

자리넘김 버튼과 띄어쓰기 버튼이 따로 존재하는 아이폰 10키 키보드

자리넘김 버튼과 띄어쓰기 버튼이 따로 존재하는 아이폰 10키 키보드
💎예를 들어 "오 안녕"을 입력하기 위해서는...
  • 갤럭시: → 띄어쓰기 → 띄어쓰기
  • 아이폰: → 띄어쓰기 → 자리넘김

이와 같이 2가지 다른 버튼이 따로 존재하는 것 뿐만 아니라 버튼의 각 크기도 더욱이 작아져, 오타가 지속적으로 발생하는 등 사용에 불편함을 호소하는 사람들이 많았다. 이런 이유로 갤럭시 천지인과 유사한 아이폰 키보드를 만들어보고 싶다는 결론에 이르렀다.

💎목표

학습 없이 쉽게 사용할 수 있는 아이폰용 천지인 키보드를 만들어 보자!

🍯꿀팁

이 프로젝트의 연구 기록도 공개되어 있다.

📜 특허권 및 법적 권리

우선 특허권과 법적 권리에 아무런 문제가 없는지를 확인했다. 조사한 결과, 조관현 특허권 보유자님께서 특허권을 정부에 기증하셔 국가 표준으로 채택된 이후, 한글 자판에 대한 특허권 사용권이 무상으로 허용되었다. 이에 아무런 문제가 없음을 확인한 후 개발에 착수했다.

🛠 기술 정하기

아이폰 자판을 만들기 위해 애플의 커스텀 키보드 만들기 문서를 정독했다. 확인 결과 일반적인 아이폰 앱을 제작하는 난이도와 비슷해보였다. 일단 ViewController 내에 버튼과 로직을 때려박아 개발하는 것은 쉬워보였으나, SwiftUI를 이용한 iOS 개발을 한 적이 없어 SwiftUI로 개발해보고 싶었다. 처음에는 새로 나온 SwiftUI Grid 기능을 쓰면 깔끔하게 버튼을 배열할 수 있을 듯했는데, 이는 사진 앱처럼 수많은 엘리먼트들을 화면에 배열하는 것에 더 최적화되어있고, 나의 경우처럼 버튼의 개수가 정해져있는 경우에는 (웹에서의 display: flex와 유사한) HStack과 VStack으로 충분하다고 판단했다.

아이폰 써드파티 키보드는 익스텐션이라는 독특한 구조를 이용해 제작한다. iOS 앱 본체가 아니면 전부 익스텐션이라고 생각하면 된다. 커스텀 키보드도 익스텐션이고, iOS 위젯도 익스텐션이고, 애플 워치 앱에도 익스텐션이 탑재된다. Ray Wenderlich의 문서를 읽으며 간단한 데모들을 제작하며 키보드 익스텐션에 대한 이해를 높였다.

ㅇ 근처 배경에 회색 배경이 있는 키보드 입력 모습

ㅇ 근처 배경에 회색 배경이 있는 키보드 입력 모습

ㅇ 근처 배경에 회색 배경이 있는 키보드 입력 모습

몇 가지 초기 버전들

가운데 이미지의 "ㅇ" 근처의 회색 배경은 iOS 기기의 NSRange와 setMarkedText라는 기능이었다. 입력 중인 글자에 영역 처리를 해서 입력을 도와주는 기능이었는데, 중국어의 한어병음(Pinyin)처럼 문자 입력 직전에 조합용으로 사용되는 것으로 천지인용으로 적절하지 않다고 판단했다. 또하나 흥미로운 점으로 아이폰 기본 자판의 색상들은 기본으로 제공되는 어떤 systemColor와도 달랐다. 색깔을 Color Meter로 뽑아 하나하나 입력했다.

😶‍🌫️ 그런데 천지인은 어떻게 만들지

천지인의 입력 로직을 구현하기 위해 찬찬히 생각을 하던 중, 이게 대단히 복잡하다는 것을 알게 되었다. 예를 들어 다음의 경우를 생각해보자.

  • 을 입력하기 위해 안ㅅ이 입력되어 있는 경우에 ㅅㅎ 버튼을 누르면 이 되어야 한다. 이전 글자와 재결합이 가능한지 판단해야 한다.
  • 에서 를 입력하면 안즈가 되어야 한다. 마지막 종성 하나가 추출 가능한지 확인해야 한다. 이전 글자에서 추출이 가능한지 판단해야 한다.
  • 에서 ㅂㅍ 키를 누르면 깔ㅃ이 되어야 한다. 즉 종성 추출 + 된소리 변경이 가능한지 판단해야 한다.
  • 에서 ㅅㅎ 키를 누르면 이 되어야 한다. 즉, 단순하게 , , 를 상호 변경하는 것 뿐만 아니라, 등의 겹받침의 변환도 판단해야 한다.

이 외에도 수많은 경우가 많다. 조합형 한글을 나열해두고 하나하나 계산을 한다고 하더라도, 위 경우의 수를 모두 고려하는 것은 매우 어렵다. 유한상태기계로 제작할 경우 두벌식보다 훨씬 많은 약 20개 정도의 데이터 저장 스택과 수십 가지의 상태가 필요하다고 판단했다. (정확히 계산하지는 않았으니 더 단순한 구현 로직이 존재할 수도 있다. 혹시라도 이 방식대로 진짜 만들어보고 싶은 사람이 있다면 이 특허의 다이어그램을 참고하자.) 인터넷 상에 몇 가지 구현 로직들을 발견하기는 했지만, 전부 길고 복잡해 Swift로의 번역을 차치하고 코드 자체를 이해하는데도 한세월이 걸릴 듯 했다. 그러다 문득 이런 생각이 들었다.

💎케이스가 너무 많고 복잡하면

그냥 전부 하드코딩하면 되잖아?

생각해보면 같은 버튼 조합을 입력하면 같은 글자로 찍혀야하는 것이 바로 키보드이리라. 그냥 모든 경우의 수를 생성해서 하나의 거대한 JSON 파일에 넣어버리면 어떨까! 단순하게 셈을 해보아도 한글은 약 11,000자 밖에 안 되고, 앞뒤 글자까지 같이 고려하는 케이스만 고려한다고 해도 조합은 많아야 10만 단위를 넘어가지 않을 것으로 판단했다. JSON의 크기는 2MB 안팎에서 넘어가지 않을 것이다.

과거처럼 임베디드 하드웨어에서 KB 단위로 골프를 치며 메모리 최적화를 해야하는 시대가 아니다. 한글이 인류와 함께하는 이상 분명 미래의 누군가가 천지인을 다시 제작할 일이 있을 것이고, 그를 위해서는 누군가가 온전한 천지인 지도를 만들 가치가 충분히 존재한다.

🖨️ 활자: 세상에서 가장 단순한 천지인 구현체

그로 인해서 활자를 만들었다. 천지인의 모든 상태와 조합을 담아놓은 한글 지도 🗺️ 이다. 총 5만 여개의 입력 케이스가 존재하며, 압축된 JSON은 500KB 정도 크기이다.

고차원적 기능을 구현하기 위해서는 몇 가지 기교들이 더 필요하지만 (백스페이스 키를 눌렀을 때 글자 단위로 지워지는 것이 아니라 자소 단위로 지워진다거나, 시간에 따른 자리넘김 처리를 한다거나) 궁극적으로 핵심 입력 로직은 다음과 같이 단순하다.

const type = (이전: string, 활자: hwalja,: string, 수정중: boolean) => {
const 마지막한글자 = 이전.slice(-1)
const 마지막두글자 = 이전.slice(-2)
if (수정중 && 마지막두글자 in 활자[]) return 이전.slice(0, -2) + 활자[][마지막두글자]
if (수정중 && 마지막한글자 in 활자[]) return 이전.slice(0, -1) + 활자[][마지막한글자]
return 이전 + 활자[]['']
}

단 5줄 만으로 천지인 로직 구현이 가능하니 나는 감히 이것을 전 세계에서 가장 단순한 천지인 구현체라고 칭하겠다.

전처리 방식이 궁금한 사람들이 있을텐데, 입력 가능한 11,000여 자의 한글 글자들을 종착점으로 삼아 그 글자를 생성하기 위해 마지막으로 눌러야했을 글쇠는 무엇인지, 그리고 그 글쇠를 누르기 전 상태는 무엇인지 역산했다. 예를 들어 이 있다면 이전 상태는 이고 을 눌러서 에 도달했겠군하는 식으로 역으로 계산한 것이다. 물론 이에 더해 여러 엣지 케이스들을 처리해야 했다. 4년 전 조성현이 나를 많이 도와주었다.

🧪직접 해보자!

다음 창은 활자를 이용해 간단하게 구현해본 천지인 입력 시연이다.

활자는 모든 플랫폼에 사용할 수 있도록 공개했다.
위 데모로 직접 활자를 입력해보자!

💎활자는

가장 단순한 구현체이지 가장 가벼운 구현체는 아니라는 점을 명심하자.

조합형 한글로 문자열 정규화를 하면 좀 더 경우의 수가 줄지 않나요?

활자 프로젝트에 대해 이성광 님께서 NFD로 문자열 정규화를 해서 초중종성을 떼어놓고 만들면 좀 더 경우의 수가 줄지 않을까에 대한 지적을 해주셨다. 나는 완성형 한글만 놓고 생각했는데, 말씀하신대로 조합형으로 제작 후 정규화를 거치면 경우의 수가 확실히 많이 준다. 예를 들어 안 ᄂᆞᆞㅣㅇ 같이 풀어두고 ᆞᆞㅣ 부분만 로 조합한 뒤, ㄴㅕㅇ을 정규화 과정을 통해 으로 변환하는 것이다.

활자 프로젝트의 경우 현 접근을 유지하기로 했다. 활자는 가장 쉽고 단순한 천지인 구현체를 지향하는 만큼 현재의 접근이 substring + replace 만으로 구현할 수 있기 때문이다. 만약 NFD와 정규화에 대한 정보를 추가해야한다면, 비록 활자 프로젝트 자체는 가벼워지겠지만, 그를 사용하는 개발자 측에서 NFD와 정규화에 대한 추가적인 학습 및 구현이 필요하다. 오토마타를 이용한 학습 곡선에 불편함을 느껴 모든 정보를 하드코딩해 가장 단순한 형태로 구현하기로 한 활자 프로젝트의 본 목적에 어긋난다. 더불어 이미 압축된 버전은 500KB 수준이기에 입력 엔진으로 사용하기에 부담이 되는 크기가 아니다.

🤖 자동완성 키보드 만들기

천지인을 쓰는 사람들이 빠른 속도로 타자를 칠 수 있는 이유는 바로 자동 완성 텍스트 (개발 명칭: Apple QuickType)을 적극적으로 활용한다는 점이다. 이 자동 완성 텍스트들은 사용자가 입력하는 패턴을 지속적으로 학습하여 사용자의 입력을 돕는다.

다행히도 애플 UIKit에는 Core ML과 Neural Engine을 직접 건드리지 않고도 문맥 자동 완성 기능을 사용할 수 있는 UITextChecker를 제공한다. 한국어도 물론 지원하며, learnWord()unlearnWord()를 사용하여 사용자의 입력을 학습하거나 삭제할 수 있다.

import UIKit

let uiTextChecker = UITextChecker()
let input = "행복하"
let guesses = uiTextChecker.completions(
forPartialWordRange: NSRange(location: 0, length: input.count),
in: input,
language: "ko-KR"
)

/*
[
"행복한", "행복합니다", "행복하게", "행복할", "행복하다", "행복하고", "행복하지",
"행복하다고", "행복하다는", "행복하기", "행복하면", "행복할까", "행복하길",
"행복함을", "행복하기를", "행복함", "행복하니", "행복한테", "행복하자", "행복하네"
]
*/

이 기능을 이용해 자동완성 기능을 완성했다. 가끔 문맥이 어색하거나 아무런 추천을 해주지 않거나 하는 버그가 존재하지만, 최소 기능 제품을 위해서는 훌륭하게 동작한다!

2023년에도 행복하세요 💙

2023년에도 행복하세요 💙

⌨️ 키보드 기능 고도화

천지인은 단축키에 기원을 두고 있는 만큼 관련된 고도 기능들이 있다. 백스페이스 키를 길게 누르면 뗄 때까지 글이 삭제되는 기능이나, 입력 키를 길게 누르면 그 버튼에 대응하는 숫자가 입력되는 기능 들이 있다. 이 기능들을 사용하기 위해 Swift의 Closure를 활용해서 다음과 같이 키보드 버튼 컴포넌트를 확장했다.

struct KeyboardButton: View {
var onPress: () -> Void
var onLongPress: () -> Void
var onLongPressFinished: () -> Void
var body: some View {
Button(action: {})
.simultaneousGesture(
DragGesture(minimumDistance: 0) // <-- A
.onChanged { _ in
// 길게 누르거나 드래그했을 때 구동될 코드
onLongPress()
}
.onEnded { _ in
// 길게 누르는 동작 또는 드래그 동작이 끝날 때
onLongPressFinished()
}
)
.highPriorityGesture(
TapGesture()
.onEnded { _ in
// 터치했을 때 구동될 코드
onPress()
}
)
}
}

설명을 위해 간소화된 코드이다. KeyboardButton.swift

A로 표현된 부분을 사용하는 기발한 방법을 알게 되었다. 이렇게 하면 다음 두 마리 토끼를 한 코드로 잡을 수 있다.

  • 한글 버튼을 스와이프(flick)해 숫자를 입력하는 기능
  • 한글 버튼을 길게 눌러 숫자를 입력하는 기능

DragGesture의 minimumDistance가 0으로 설정되어 있다면 롱프레스도 동시에 인식하여 highPriorityGesture를 취소하고 DragGesture에 해당하는 기능을 실행한다는 특징을 이용한 것이다.

더불어 iOS13부터 소개된 Combine 문법을 시범적으로 사용해보았다. Combine 프레임워크는 시간에 따른 비동기적 동작을 처리하기 위한 Declarative Swift API이다. 이를 이용해 타이머를 생성하고 "롱프레스 백스페이스" 동작을 구현할 수 있다.

struct DeleteButton: View {
@State var timer: AnyCancellable?
var body: some View {
KeyboardButton(systemName: "delete.left.fill", primary: false, action: {
// 탭했을 때는 기본 삭제 동작을 실행한다.
options.deleteAction()
},
onLongPress: {
// 길게 누르고 있을 경우 0.1초마다 실행되는 타이머를 생성한다.
timer = Timer.publish(every: 0.1, on: .main, in: .common)
.autoconnect()
.sink { _ in
// 누르고 있는 동안 0.1초에 한 번씩 글자 하나를 지운다.
options.deleteAction()
}
},
onLongPressFinished: {
// 손을 떼면 타이머가 취소되며 무한 반복 삭제 동작이 해제된다.
timer?.cancel()
})
}
}

설명을 위해 간소화된 코드이다. HangulView.swift

이렇게 조합된 하나의 코드를 통해 길게 누르거나 드래그를 이용해 특수한 동작을 실행하는 기능을 구현할 수 있었다.

🦾 접근성과 사용성

유용하다고 생각한 접근성 기능을 몇 가지 추가했다. 우선 가장 먼저 사용자가 "볼드체 텍스트" 기능을 활성화한 경우 글자 두께를 두껍게 변경하는 것이다. 다음 코드를 이용해 사용할 수 있다.

let fontWeight: UIAccessibility.isBoldTextEnabled ? .bold : .regular

볼드체 텍스트가 비활성화된 경우

볼드체 텍스트가 비활성화된 경우

볼드체 텍스트를 활성화한 경우

볼드체 텍스트를 활성화한 경우

또 나름 큰 영감을 받은 기능이 하나가 있었다. 바로 갤럭시 사용자들이 우측 하단의 "이전" 버튼을 눌러서 키보드를 종료한다는 사실을 관찰했다. 그래서 그 자리에 키보드를 손쉽게 종료할 수 있도록 키보드 종료 버튼을 배치했다.

우측 하단의 키보드 종료 버튼을 탭하면 키보드가 사라진다.

우측 하단의 키보드 종료 버튼을 탭하면 키보드가 사라진다.

🧑🏻‍🎨 Midjourney를 이용한 앱 아이콘 생성

Midjourney 이미지들

생성된 여러 가지 이미지들

앱 아이콘을 생성하기 위해 Midjourney라는 그림 AI를 사용했다. 요즘에는 이런 것을 프롬프트 엔지니어링이라고 하던데 그냥 다양한 키워드들로 그림을 그리는 것이 즐거웠다.

☁️ Xcode Cloud를 이용한 CI/CD 연결

마지막으로 2022년에 출시된 Xcode Cloud를 이용해 CI/CD를 구축했다. 이를 이용하면 마치 GitHub에 리액트 코드를 푸시하면 Vercel이 알아서 빌드 후 배포해주는 것처럼, Apple Xcode Cloud 서버에서 iOS 앱이 컴파일되어 보관된다. 애플 아이폰 앱의 경우 앱스토어 리뷰라는 절차가 있기 때문에 완전 자동 배포가 되지는 않고, 앱스토어 콘솔에서 빌드를 선택하고 리뷰 요청 버튼을 눌러야 하지만, 이전에 엑스코드에서 아카이브 파일을 생성해 수동으로 업로드해야했던 것에 비해서 훨씬 편해졌다.

이와 같이 앱스토어 콘솔에서 GitHub와 연동된 빌드를 확인할 수 있다.

이와 같이 앱스토어 콘솔에서 GitHub와 연동된 빌드를 확인할 수 있다.

이렇게 푸시 알림도 보내준다.

이렇게 푸시 알림도 보내준다.

🏁 마무리하며

오랜만에 iOS 개발을 하니 즐거웠다. iOS 플랫폼이 훨씬 성숙해진 것이 느껴졌다. 특히나 활자 프로젝트를 진행하며 한글이 이렇게 아름답구나라는 감정을 많이 느꼈다. 무엇보다도 부모님께 참 좋은 선물을 해드린 것 같아 기분이 좋았다. 작업한 링크들을 첨부하며 글을 마무리한다.

💙앱스토어 5점 리뷰와 GitHub 스타는 큰 응원이 됩니다!

집계 중...
🗣잔말 말고 일단 먼저 보여줘요

그럼요. 아래 검정색 타원을 클릭해보세요. 제가 현재 듣고 있거나 가장 최근에 들은 30개의 음악 중 하나가 무작위로 나타날거예요.

위의 검정색 타원을 클릭해보세요.

노트

리버스엔지니어링한 Apple Music API가 변경되었고, 때문에 API를 더이상 업데이트하지 않을 예정입니다.

유능한 예술가는 모방하고 위대한 예술가는 훔친다 — 그리고 이번에는 Vercel의 개발자 경험 담당 VP Lee Robinson의 아이디어 하나를 베껴보려 한다. Next.js의 각종 신기술을 활용하는 실험적이지만 간결하고 아름다운 포트폴리오로 유명한 leerob.io에는 한 가지 재미있는 기능이 있다. 자신이 현재 듣고 있는 음악이 웹에 같이 나타나는 기능이다.

leerob.io 웹사이트 하단의 모습

Now Playing — Spotify @ leerob.io

누구보다 음악적 취향이 뚜렷한 나였기에 언젠가는 이를 꼭 내 웹사이트에 구현하고 싶었다. 다만 단순하게 재현하는 수준이 아니라 무언가 기술적인 도전을 해보고 싶었다. 당시 여러 음악 서비스를 돌아가며 사용해보고 있었기에 굳이 개발을 서두르지 않은 이유도 있었다. 그런 이유로 개발을 이리저리 미루다 2022년에 이르렀다. 그러던 중 Apple이 최근 신박한 새로운 기능을 발표했다. 바로 다이나믹 아일랜드(Dynamic Island)라는 기능이다.

다이나믹 아일랜드의 모습

화면 상단 펀치홀이 자유자재로 크기를 바꾸며 다양한 부가 정보를 표시한다.

내가 바라고 있던 기술적인 도전이었다! 이를 웹에서 정확하게 똑같이 구현해보고 싶다는 결론에 이르렀다. 몇 가지 안드로이드 전용 복제품을 인터넷에서 확인하기도 했는데, 전부 애니메이션 곡선이 부자연스러워 이런 디테일을 공부해보고 싶었다.

💡목표

웹 상에서 내가 현재 듣고 있는 음악을 보여주는 다이나믹 아일랜드 🏝 를 구현해보자.

🍯꿀팁

이 프로젝트의 연구 기록도 공개되어 있다.

🛠 기술 정하기

우선 프레임워크로는 가장 익숙한 Next.js와 Tailwind를 골랐다. 문제는 애니메이션이었다. 간단한 CSS 애니메이션은 다루어 보았지만 ease-in-ease-out 이상의 복잡한 애니메이션은 다뤄보지 못했다. Framer Motion이라는 라이브러리를 알게 되어 이를 사용하기로 했다.

Framer Motion의 웹사이트

Framer Motion

🧑🏻‍🏫 애니메이션의 물리

우선 Apple의 애니메이션과 다른 모방작의 애니메이션이 왜 달라보이는지부터 이해해야 한다. 애니메이션에도 다양한 종류가 있지만, 크게 2가지로 나눌 수 있다. (최소한 Apple 플랫폼에서는 이렇게 2가지로 나누어 지원한다.) 아주 단순하게 이해하자면 다음과 같다.

Parametric Curve. 시작점과 종착점이 있을 때, 그 사이 조작점(Control Point)을 두고 그 조작점 사이를 수학 공식을 이용해 보간(interpolate)한다. 보간에 사용되는 공식의 종류에 따라 Linear Curve, Polynomial Curve, Spline Curve 등으로 나뉜다. 흔히 사용되는 Bezier Curve도 여기에 해당된다.

Spring Curve. 고전 물리학의 탄성 방정식(Hooke's law와 그에 기반한 수많은 방정식)을 이용해 경직도(Stiffness)와 제동 계수(Dampening)를 통한 물리적인 궤도를 계산한다. 더 알아보기: Maxime Heckel

애니메이션 곡선에 대해서 더 깊게 이야기하는 것은 이 글의 초점에서 벗어나니 더 자세하게 설명하지는 않겠지만, 대부분의 다이나믹 아일랜드 재현작들이 위의 Parametric Curve를 이용해 애니메이션을 제작하는 반면 (CSS에 내장되어 제공되니 가장 쉽기도 하다) Apple의 경우 현실의 애니메이션과 비슷하게 구현하기 위함인지 Spring Motion을 주로 사용한다. 이번에 사용한 Framer Motion에서도 useSpring()이라는 React Hook을 통해 이런 물리적인 움직임을 제어할 수 있다.

import { useSpring } from 'framer-motion'
useSpring(x, { stiffness: 1000, damping: 10 })

🛥 다이나믹 아일랜드를 향해

Source: Apple

Source: Apple

우선 Apple의 문서를 깊이 읽어보며 이런 저런 특징들을 공부했다. 다이나믹 아일랜드는 크기에 따라 다음의 형태를 지닌다.

Minimal: 다이나믹 아일랜드의 한쪽을 차지하는 작은 형태이다. 두 개 이상의 백그라운드 동작이 작동할 때 각각 한쪽씩 나타나게 된다.

Minimal: 다이나믹 아일랜드의 한쪽을 차지하는 작은 형태이다. 두 개 이상의 백그라운드 동작이 작동할 때 각각 한쪽씩 나타나게 된다.

Compact: 다이나믹 아일랜드의 양옆을 차지하는 중간 사이즈이다. 단일 백그라운드 동작(음악. 타이머. 등)이 작동할 때 양 옆을 모두 차지한다.

Compact: 다이나믹 아일랜드의 양옆을 차지하는 중간 사이즈이다. 단일 백그라운드 동작(음악. 타이머. 등)이 작동할 때 양 옆을 모두 차지한다.

Expanded: 다이나믹 아일랜드의 최대 사이즈이다. 다이나믹 아일랜드를 길게 누르고 있을 경우 나타난다. 빨간 영역에는 콘텐츠를 표시하지 못한다.

Expanded: 다이나믹 아일랜드의 최대 사이즈이다. 다이나믹 아일랜드를 길게 누르고 있을 경우 나타난다. 빨간 영역에는 콘텐츠를 표시하지 못한다.

더불어서 인터넷 어딘가에서 다음 사진도 확인할 수 있었다. Apple 공식 문서에서는 단순하게 Expanded라고 두루뭉실하게 표현하는 반면 이 사진에는 여러 사이즈가 동시에 나타난다.

다이나믹 아일랜드의 서로 다른 사이즈. 사진에 오타가 있는 것으로 보아 공식적인 자료는 아닌 것 같다. 참고 자료로 사용했다.

다이나믹 아일랜드의 서로 다른 사이즈. 사진에 오타가 있는 것으로 보아 공식적인 자료는 아닌 것 같다. 참고 자료로 사용했다.

이를 반영해서 다음처럼 타입 정의를 해보았다.

export type DynamicIslandSize =
| 'compact'
| 'minimalLeading'
| 'minimalTrailing'
| 'default'
| 'long'
| 'large'
| 'ultra'

그런 다음 하룻밤을 갈아넣어 (2022년 10월 16일) Framer Motion을 이용해 자연스럽게 크기 전환을 하는 방법을 알아냈다. 다음의 코드로 동작한다. 특히 stiffnessdamping 값을 가지고 많은 실험을 했다. 알아낸 값은 const stiffness = 400 그리고 const damping = 30.

<motion.div
id={props.id}
initial={{
opacity: props.size === props.before ? 1 : 0,
scale: props.size === props.before ? 1 : 0.9,
}}
animate={{
opacity: props.size === props.before ? 0 : 1,
scale: props.size === props.before ? 0.9 : 1,
transition: { type: 'spring', stiffness: stiffness, damping: damping },
}}
exit={{ opacity: 0, filter: 'blur(10px)', scale: 0 }}
style={{ willChange }}
className={props.className}
>

2022년 10월 16일 완성한 모습

2022년 10월 16일 완성한 모습

📞 전화 구현

다른 API를 붙이기 전에 처음 한 것은 전화 컴포넌트를 구현하는 일이었다. 큰 이유가 있었던 것은 아니고 애니메이션과 익숙해지기 위해서 구현해보았다. 실제로 완성된 제품을 보니 실제 Apple의 제품과 아주 닮아 마음에 들었다. 2022년 10월 20일에 완성되었다.

↑ 클릭해보세요 ↑

🍎 Apple Music API

그 다음 필요했던 것은 Apple Music API와 연동하는 일이었다. 2021년 초에 Spotify를 이용해서 기술 시연을 완성한 적이 있다. Spotify에는 Now Playing API가 공식적으로 존재한다. 비슷하게 Apple Music도 Now Playing API가 있을 것이라고 생각했다.

당시 IZ*ONE의 찐팬이었다... 😅

당시 IZ*ONE의 찐팬이었다... 😅

Spotify Now Playing API

Spotify for Developers

Apple Music API 1.1이 언제 출시된 것인지 모르겠지만 비교적 최근에 Get Recently Played Tracks라는 이름으로 비슷한 API가 공개되었다.

Apple Music Get Recently Played Tracks

참고로 2년 전만 해도 API가 존재하지 않았다.

이제 필요한 것은 OAuth 2.0에 필요한 각종 토큰들을 발급 및 저장하는 일이다. Spotify의 경우 OAuth 2.0 표준을 거의 그대로 따라가다시피 했는데 Apple은 몇 가지 정보가 더 필요했다. 특히 이 Now Playing 같은 경우는 단순하게 Apple과의 인증 뿐만 아니라 사용자의 정보에 접근하는 것이다 보니 사용자의 동의 여부에 따른 권한 처리도 별도로 진행되었다. 그리고 이 모든 것이 문서화가 제대로 되어있지 않아 꽤 골치 아팠다. 우선 필요했던 것은 다음과 같다.

비행기Apple에서의 동일한 개념설명
항공기 사업을 위한 비용 처리Apple Developer 유료 계정$99
조종사 자격증Apple Developer에서 발급 받는 Apple Music KeyApple Music 관련 서비스에 내가 요청을 날릴 법적 권한이 있음을 확인한다.
항공기 운항승인 신청서Apple 서버에 요청해서 발급 받는 Apple Music Main TokenApple Music 관련 서비스에 내가 요청을 날릴 때 쓸 통행증을 발급 받는다.
탑승자가 구매하는 비행기 티켓사용자가 인증 플로우를 거쳐 발급 받는 Apple Music User Token내 서비스가 사용자 정보에 접근해도 되는지에 대한 동의를 확인한다.

이 4가지 정보들이 모두 연동되어야지 정상적으로 정보(사용자가 무슨 음악을 듣고 있었는가)를 확인할 수 있다. 이와중에 마지막 User Token은 iOS, macOS와 MusicKit on the Web 등 아주 제한적인 형태로만 사용할 수 있도록 만들어놓았다. MusicKit on the Web은 music.apple.comCider, LitoMusic 등 Apple Music 웹 클라이언트에서 사용하기 위함이지 지금 이 경우처럼 자유자재로 API 요청을 진행하는 곳에 사용할 수 없다. 그런데도 Apple 문서에는 딱 저렇게 MusicKit on the Web를 사용하면 자동으로 됩니다라고만 나와있고 그 외의 경우에는 어떻게 구하는지 전혀 언급이 없다.

그럼 어떡하겠나. 리버스 엔지니어링 해봐야지. 나머지 단계들은 비교적 직관적인 단계들로 진행되었다. 연구 기록 참고.

Apple: 문서화 그런거 모르겠고 MusicKit on the Web을 통해서만 하라고~

Apple: 문서화 그런거 모르겠고 MusicKit on the Web을 통해서만 하라고~

MusicKit on the Web

MusicKit on the Web. 애플 성격에 Storybook 문서로 된 것을 보면 아직 완전 초기 베타인가 보다.

🦾 MusicKit 리버스 엔지니어링

우선 MusicKit on the Web의 기본적인 스펙을 모방하며 기초적인 웹을 만들었다.

아무 것도 없이 인증 프로세스만 불러오는 웹페이지이다.

아무 것도 없이 인증 프로세스만 불러오는 웹페이지이다.

이렇게 Apple Music 권한 인증 화면이 나타난다.

이렇게 Apple Music 권한 인증 화면이 나타난다.

그런 다음부터 웹페이지의 요청 헤더를 까보면 다음과 같이 media-user-token이라는 필드가 있다.

그런 다음 Postman에서 key들을 모두 채워서 요청을 날려보면 다음과 같이 JSON 응답이 돌아옴을 확인할 수 있다. 여기까지가 2022년 10월 28일까지 진행된 개발이다.

이렇게 단순하고 짧고 직접적인 과정으로 보이지만, 실제로는 며칠이 살살 녹았다 😭

이렇게 단순하고 짧고 직접적인 과정으로 보이지만, 실제로는 며칠이 살살 녹았다 😭

사람들이 접속할 때마다 API 요청을 날리게 된다면 당연히 순식간에 API Quota를 다 쓸 것이다. 그래서 어떠한 형태로든 캐시 서버를 만들고 싶었다. 하지만 최고의 데이터베이스는 데이터베이스가 없는 것이라는 것을 명심하자.

데이터베이스가 필요 없을 때는 데이터베이스를 쓰지 마세요. 그리고 이건 생각보다 꽤 자주 해당되는 경우랍니다. 예를 들어 전세계 195개 나라 이름을 데이터베이스에 집어넣고 매번 join할 필요가 있겠어요? 그냥 config 파일에 하드코딩하고 부팅할 때 메모리에 읽어 들이자고요. 아니면, 이커머스 사이트의 전체 제품 목록을 하나의 YAML에 다 넣어버리고 서버가 부팅할 때 읽어들이면 어때요? 이것은 생각하는 것보다 훨씬 더 많은 경우에 적용할 수 있어요. It's not Ruby that's slow, it's your database

그냥 GitHub Secrets에 비밀 키를 저장해놓고 몇 분에 한 번씩 API를 요청해 GitHub에 띄워두도록 만들었다.

이 오타 찾는데 몇 시간을 썼는지 모르겠다.

이 오타 찾는데 몇 시간을 썼는지 모르겠다.

🎼 이퀄라이저

앞서 전화 컴포넌트를 완성한 것과 비슷하게 음악 플레이어 컴포넌트도 완성했다.

근데 어딘가 너무 허전했다.

근데 어딘가 너무 허전했다.

바로 이퀄라이저가 없는 것이다. React에 괜찮은 이퀄라이저가 없는지 알아보다가 그냥 Framer Motion으로 이마저도 만들기로 결정했다. 몇가지 반복적 개발(iteration)의 과정 스크린샷을 첨부한다.

FANCY by TWICE

After Like by IVE

Lavender Haze by Taylor Swift

Hype Boy by NewJeans

이퀄라이저의 각 봉은 무작위로 길이가 결정된다. 근데 마지막 Hype Boy 예시에서도 볼 수 있듯이, 뭔가 어색한 것이 느껴졌다. 일반적으로 음악은 저음역대와 고음역대는 진폭이 작은데 반해, 완전하게 무작위로 값을 계산해서 저음역대와 고음역대에도 비슷한 진폭의 봉이 나타나서 그런 것 같았다. 그래서 각 봉마다 기준치 (baseLength) 를 정해주고 그 값에서 ±(무작위 값)을 하도록 변경했다. 마지막으로 이퀄라이저의 색깔을 앨범아트의 키 컬러(Key Color)와 동일하게 변경했다. 별도의 작업이 필요하진 않았고 Apple Music API에 그 값이 같이 포함되어 있다.

훨씬 자연스럽다.

훨씬 자연스럽다.

🔎 스쿼클의 물리

아직 끝났다고 생각하면 오산이다! 이렇게 완성된 다이나믹 아일랜드도 아직 무언가 어색했다. 곡선이 날카로운 느낌이 들었다. 바로 스쿼클의 미적용 때문이다.

Squircle

출처: Apple's Icons Have That Shape for a Very Good Reason @ HackerNoon

일반적으로 borderRadius를 통해서 설정하는 테두리는 곡률이 일정하다. 이렇게 되면 곡률이 시작되는 지점에서 급격한 곡률의 변화로 인해 그 부분이 날카롭게 느껴진다. 이에 반해서 곡률을 서서히 높였다가 낮추면 훨씬 부드러운 곡선이 탄생한다.

물투 학생들을 위한 설명: 등가속도 운동이 아니라 등가가속도 운동을 한다고 생각하면 된다 (속도를 일정하게 바꾸는 것이 아니라, 가속도를 일정하게 바꾸는 것).

기벡 학생들을 위한 설명: 스쿼클은 초타원 (Superellipse)이다. 다음 공식을 만족하는 형태들이다. 여기에서 nn은 곡률, aaxx 축 길이, bbyy 축의 길이이다. 수학적으로 더 깊은 내용은 Figma의 Desperately seeking squircles 문서를 참고하자.

xan+ybn=1{\lvert{x \over a}\rvert}^n + {\lvert{y \over b}\rvert}^n = 1

tienphaw/figma-squircle를 이용해 SVG 스쿼클을 생성한 뒤 clipPath 프로퍼티를 이용해 다이나믹 아일랜드를 잘라내도록 만들었다.

iOS16 알림센터에서도 비슷한 버그를 봤다.

iOS16 알림센터에서도 비슷한 버그를 봤다.

다만 애니메이션의 모든 프레임에 clipPath를 걸기 위해서는 스쿼클을 모든 프레임마다 생성해야 하는데 이렇게 되면 속도 저하의 문제가 있을 수 있었다. 다이나믹 아일랜드가 크기를 바꾸는 동안은 borderRadius를 이용해 모서리를 다듬고, 애니메이션이 끝나는 즉시 재빠르게 clipPath를 적용하도록 최적화했다.

아주 자세히 보지 않으면 알아차리기 어려워 성능과 디테일의 괜찮은 타협이라고 생각했다. 여기까지 2022년 11월 11일까지 완성한 내용이다.

자세히 보면 애니메이션이 종료되면서 모서리가 스쿼클로 다듬어진다.

자세히 보면 애니메이션이 종료되면서 모서리가 스쿼클로 다듬어진다.

💨 애니메이션 최적화

CSS에는 will-change라는 속성이 있다. 화면에 무슨 요소가 어떻게 변화할 것인지 브라우저에게 미리 알려주어 속도를 최적화할 수 있도록 미리 작업하라는 것을 뜻한다. 브라우저는 will-change가 없는 모든 콘텐츠를 애니메이션이 적용될 때마다 다시 "렌더링"(rasterize)하는데, will-change가 있으면 미리 계산된 정해진 이미지로 일단 애니메이션을 진행한 뒤 애니메이션이 완전히 종료되었을 때 렌더링을 다시 진행한다. 때문에 애니메이션이 종류에 따라 흐려보일 수 있지만, transform, scale, rotate 등의 애니메이션의 부드러움을 준다.

다이나믹 아일랜드는 크기와 투명도 등을 주로 조절하기에 will-change를 사용하기 적합했다. Framer Motion에서는 다음 코드를 이용해 적용할 수 있었다.

import { motion, useWillChange } from 'framer-motion'

// ...

const willChange = useWillChange()

// ...

<motion.div style={{ willChange }}/>

🔗 연동

마지막으로 연동에 사용할 수 있는 페이지들을 만들었다 (/embed-player, /embed-phone-call). 다른 사이트들에 Tailwind, Framer Motion 등 의존성을 추가하고 싶지 않았기에 iframe을 이용해서 연동하고 싶었다. davidjbradshaw/iframe-resizer를 이용해서 반응형 iframe을 구현할 수 있었다. Position Sticky를 사용해 특정 페이지 화면 상단에 붙어있을 수도 있도록 만들었다. 이 페이지 상단에도 붙어있을 것이다!

💭 회고

이로써 프로젝트를 모두 완성했다. 몇 가지 특별히 느낀 점이 있다.

우선 첫번째로 중장기 프로젝트를 끈기 있게 잘 진행했다. 원래도 규칙적으로 꾸준히 무언가를 진행하는 분들을 존경해왔는데, 약 한 달 반에 걸쳐 프로젝트를 완성해서 프로젝트를 뚫어내는데 성공해서 보람이 있었다. 또한 대학교 수업, 구직 활동, 사이드 프로젝트 등을 잘 저글링 🤹 해서 뿌듯했다. (아직 다 끝나진 않았지만)

두 번째 느낀 점으로, cometkim님께 특별히 감사 인사를 하고 싶다. 당근에서 인턴을 할 당시 특별한 일화가 하나 기억에 남는데, 바로 webpack으로 트랜스파일된 코드 그 자체를 뜯어가며 리버스 엔지니어링하는 것이 가능하다(‼️)는 것을 직접 보여주신 것이다. 인턴십 기간동안 정말 매콤하게 🌶 많이 배웠다. (매콤마켓 당근미니 팀 ❤️) 여하튼 그 덕분에 Apple Music API에서 막혔을 때 그냥 리버스 엔지니어링해서 뚫어내야지라는 자신감이 생겼다. 팀, 감사의 의미로 헤이캐럿 당근을 드립니다 🥕

노트테이킹과 메모의 습관도 점점 생기고 있다. 사람은 생각보다 의지력이 약하니까 환경을 바꾸라는 말이 있다. cho.sh를 노트테이킹에 최적화되어있도록 디지털 정원(또는 Extracranial Memex)을 잘 가꾼 것 같다. 몇 달 간 cho.sh에 연구 노트를 기록하며 워크플로우가 어느 정도 정형화되고 있다. 계속 노트를 꾸준히 작성하고 새로운 것들을 공부하고 싶다. 이 또한 팀(cometkim)의 Roam Research 노트테이킹 습관을 보며 많이 배웠다. 팀, 감사의 의미로 헤이캐럿 당근을 하나 더 드립니다 🥕

어쨌든 이렇게 프로젝트를 끝낸다. 모두 정말 감사합니다!

집계 중...

당근에서 R&D 엔지니어 인턴으로 재직하면서 일한 웹 표준 미니앱의 기술적인 배경과 현 진척도에 대해 이야기해보려고 한다.

당근미니 콘솔을 통해 웹 표준 미니앱을 만들고 배포할 수 있다. 아직은 오픈 예정! 누구보다 빠르게 써보고 싶다면 들어가서 Waitlist에 이름을 올려두자.

📱 미니앱

미니앱은 슈퍼앱 위에서 구동되는 제3사 서비스들의 집합이다.

정보

예를 들어, 네이버 쇼핑 슈퍼 앱이 존재하고, 네이버 쇼핑 API를 사용하는 모든 스토어들이 자그마한 미니앱으로 쇼핑 앱에 입점하는 것이다. 크래프톤 슈퍼 앱이 존재하고, 그 위에 수많은 인디 게임 미니앱들이 입점하는 것이다. 토스 슈퍼 앱이 존재하고, 그 위에 수많은 금융 서비스 미니앱들이 입점하는 것이다.

이게 현재랑 무엇이 다르냐고? 웹을 만드는 경험(간단한 JS 기반 개발)으로 순식간에 앱(도달률 최고, 리텐션 최고)으로 런칭할 수 있다. 동시에 슈퍼앱의 통합 계정을 사용해 로그인하고, 앱 내에서 결제를 할 수 있다 (불편한 회원가입, 개인 정보 입력 없음). 즉,

  1. 직접 앱을 만드는 것보다 빠르고 쉽고,
  2. 직접 웹사이트를 런칭하는 것보다 더 많은 사람들에게 자연스럽게 노출되고,
  3. 직접 앱을 만드는 것보다 더 많은 사람들이 사용할 수 있으며,
  4. 직접 서비스를 런칭하는 것보다 압도적인 도달률과 리텐션과 거래 비율을 보장한다.

중화권에서는 이미 BAT (바이두, 알리바바, 텐센트) 3사가 미니앱으로 시장을 장악하였으며 그 중 1위인 WeChat의 미니앱은 일간 사용자가 4억명이 넘고, 월간 사용자는 9억명을 상회한다. 또한 Apple과 Google이 중화권에서 앱스토어, 플레이스토어를 이용한 플랫폼 파워를 지니지 못하는 이유가 바로 미니앱이다. 중화권 사용자들에게 앱스토어와 플레이스토어는 마치 과거 마이크로소프트에 내장된 인터넷 익스플로러와 같다. 인터넷 익스플로러의 유일한 용도가 Chrome을 찾고 설치하는 것이듯, 중화권 앱스토어와 플레이스토어의 사실상 유일한 용도는 WeChat을 설치하는 것뿐이다.

이는 국제적으로도 새로운 현상이 아니다. Snap은 Snap Mini라는 프로그램을 개발 중이고, Line은 Line Mini App을 만들고 있다. 당근도 미니앱을 위한 환경을 구축하려고 한다. 미니앱이 무엇인지, 그 파급력이 무엇인지는 Google의 미니앱 문서를 전부 번역해두었다. (PR 대기 중) 여기서 이야기하기에는 내용이 과하게 길어질테니 해당 문서를 참고하기 바란다.

💡여기까지 정리
  • 미니앱은 쉽고 빠른 개발(웹과 유사한 개발 경험)로 최대 비즈니스 효과(모바일 앱 경험)를 제공한다.
  • 당근은 내부, 외부의 팀이 당근의 미니앱을 통해 유저에게 서비스를 제공하기를 바란다.
  • 따라서 당근은 슈퍼앱으로서 일종의 미니앱 환경을 만들고 싶어한다.
  • 당근은 모든 슈퍼앱이 미니앱 환경을 원할 것이라고 생각하고, 모든 슈퍼앱이 미니앱 환경을 만든다면 개발자 경험과 사용자 경험이 파편화될 것이라고 생각한다.
  • 목표. 한국, 일본, 미국, 영국 등에서 성공할 수 있는 미니앱 모델을 만들어라.

🔥 많은 미니앱을 위해

앞서 언급한 BAT의 경우 웹에서 영감을 받은 듯한 독자적인 언어와 브라우저를 개발하여 그 내부를 자신들의 마음대로 뜯어고쳤다. 중화권의 BAT의 경우 독자 규격을 사용하고, 그 3사의 비즈니스 파워가 상당하기 때문에 타사 개발자들에게 여러가지 요구를 할 수 있다. 하지만 대부분의 (소위) 슈퍼앱들은 서비스가 강력하기는 해도, 자사의 SDK를 이용해 재개발하라거나, 슈퍼앱일때만 다르게 동작하는 로직 분기처리를 요구하는 등 타사 개발자들에게 무리한 요구를 하지는 못한다. 그렇게 되면 굳이 구태여 미니앱을 만들지 않을 것이다. 그 노력으로 iOS, Android 앱을 잘 만드는 것이 더 성공 확률이 보장되기 때문이다.

이를 위해 표준 미니앱은 웹 표준을 준수해야 한다. 어떤 웹앱일지라도 약간의 수정을 통해 미니앱으로나 웹앱으로나 코드 변경 없이 동작할 수 있도록 해야한다.

😻 예쁘게 보여주기 위해

예쁘게 보여주는 것은 상당히 중요하다. 특히 권한을 요구하는 화면은 더욱 그렇다. 어떠한 맥락도 없이 서비스가 위치를 사용하고 싶어합니다고 갑자기 물어본다면 사용자는 거절을 누를 확률이 높고, 그러면 서비스의 운영에 지장이 생길 수 있다. 즉 권한 요구 창은 설득력이 있어야 한다. 그를 위해서는 그에 합당한 인터페이스와 디자인으로 갖춰져야 한다. 즉, 예뻐야 한다.

예를 들어 스타벅스 웹, 앱, 미니앱에서 위치 정보를 요구하는 경우를 살펴보자. 어떤 권한 요구 창을 승인하고 어떤 권한 요구 창을 거절할 것 같은가?

스타벅스 웹앱

스타벅스 웹앱

스타벅스 미니앱

스타벅스 미니앱

스타벅스 앱

스타벅스 앱

보다 더 많은 맥락이 주어지는 오른쪽으로 갈수록 승인할 사용자가 많을 것이다. 때문에 표준 미니앱은 최소한 가운데만큼의 맥락을 제공할 수 있어야 한다.

📨 예쁜 권한 요구 창을 위해

앞서 이야기한 예시를 이어보자면 위치 정보 권한 요구 창은 Geolocation API가 불릴 때 발생한다. 별거 없다. 다음 코드를 실행하면 바로 나온다.

navigator.geolocation.getCurrentPosition()

배경 1과 배경 2에 근거해, 위 코드가 실행되었을 시 (웹 표준 방식으로 위치 정보를 요청 시) 사용자를 설득할 수 있는 배경 정보와 디자인을 갖춘 권한 요구창이 나타나야 한다.

🌐 하지만 그건 브라우저의 일인데?

저렇게 알림창을 띄우는 것은 브라우저의 영역이다. 때문에, 웹뷰를 그대로 사용해서 (iOS의 경우 WKWebView) 미니앱을 구동하는 경우 저렇게 위치 권한 요구 창이 그대로 나타나게 된다. 이 문제는 현재 당근에 구현된 당근미니에도 발생한다. 그렇다면 여기서 문제를 어떻게 해결해야 할까? 새로운 브라우저를 만들어야 할까?

오히려 알 수 없는 URL이 나타나서 거부감을 일으킬 수 있다.

오히려 알 수 없는 URL이 나타나서 거부감을 일으킬 수 있다.

🎭 어차피 누가 누군지 모른다

99.99%의 웹앱의 경우 그냥 권한이 필요한 곳에 getCurrentPosition()할 뿐이지 그것이 진짜 브라우저에서 실행되는건지는 관심이 아니다. 그렇다면 만약 다음과 같은 가짜 navigator를 만든다면 어떨까?

const navigator = {
geolocation: {
getCurrentPosition(success, error) {
// do some random stuff...
},
},
}

JavaScript는 navigator의 진위를 검사하지 않기에 원하는 동작을 사이에 주입할 수 있다. 이를 Shim이라고 한다.

컴퓨터 프로그래밍에서 심(shim)은 API 호출을 투명하게 가로채고 전달된 인수를 변경하거나, 작업 자체를 처리하거나, 다른 곳으로 작업을 리디렉션하는 라이브러리입니다. (In computer programming, a shim is a library that transparently intercepts API calls and changes the arguments passed, handles the operation itself, or redirects the operation elsewhere.) — Shim (computing)

고양이가 위치 권한을 달라고 요구하는 데모 웹사이트를 만들어보았다.

기본 동작

기본 동작

강제로 변경한 동작

강제로 변경한 동작

즉, 이를 조금 더 고도화해서 아예 document, 즉 DOM 자체를 JavaScript로 구현하여 원하는 부분만 교체하면 미니앱스러운 경험을 제공할 수 있다.

🗿 일관적인 경험을 위해

미니앱은 일관적인 경험을 주는 것이 중요하다. 마치 브라우저를 사용할 때 새로고침, 즐겨찾기, 이전 페이지, 창닫기의 위치가 변하지 않듯이 여러 미니앱에 있어서도 동일한 경험을 주어야 한다. 이는 내가 번역한 미니앱 문서에도 언급되어 있다. 이를 위해서는 공통 컴포넌트의 일부를 우리가 주입해야 한다.

⚡️ 빠른 경험을 위해

서로 다른 미니앱을 열고 닫을 때 빠르게 앱을 열고 닫기 위해 앱의 데이터를 prefetch 해올 수 있다. 하지만 앱을 열고 닫을 때마다 데이터가 유지되어야 하기에, iframe 안에 미니앱을 담아두고 외부에서는 슈퍼앱의 웹뷰가 서로 다른 데이터를 처리하고 prefetch하는 방식을 생각할 수 있다. 이 과정에서 iframe 내부의 코드가 외부로 공격 코드를 주입하는 것 등을 막기 위해 crossOriginIsolatedCross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy 헤더 설정이 필요할 것이다.

🥶 결빙 문제는 어떻게 해결하셨나?

얼어붙은 미니앱을 강제종료하는 슈퍼앱

얼어붙은 미니앱을 강제종료하는 슈퍼앱

하지만 여기서 또다른 문제가 발생한다. iframe은 단일 쓰레드에서 동작한다. 즉, 미니앱이 멈추면 슈퍼앱의 종료 버튼 또한 먹통이 된다.

🕸 웹에서 멀티쓰레드

🤔JavaScript은 Single-Threaded하지 않나?

반은 맞고 반은 틀리다.

  • 브라우저 안의 JavaScript은 Single-Threaded하다.
  • 하지만 Web Worker라는 별도의 장치를 통해 Multi-Thread 연산을 처리할 수 있다.

그렇다면 Web Worker에서 iframe을 구동한다면 미니앱이 멈추어도 슈퍼앱은 멈추지 않을 것이다.

🧑‍🔧 Worker 안에는 DOM API가 없다

Web Worker 안에서는 DOM API를 접근할 방법이 없다. DOM API라는 것도 결국 말 그대로 JavaScript 기반의 Object Model이기 때문에 DOM API와 똑같이 생긴 가짜 DOM을 Worker 안에 내려주고, 그 가짜 DOM에 조작된 모든 것들을 진짜 DOM에 그대로 가져다가 적용할 수 있다면 이 문제를 해결할 수 있다. 또한 이 사이에서 그대로 가져다가 적용하는 것이 아니라 이게 적합한 작업인지 검사할 수 있다면, 원천적으로 어뷰징을 차단할 수 있다.

👻 미션 임파서블을 찍는다

미션 임파서블 4에서 이단 헌트는 테러리스트 두 팀 사이에서 서로 상대방인 척 연기하며 적절하게 유리한 방향으로 교섭을 진행한다.

미션 임파서블 4에서 이단 헌트는 테러리스트 두 팀 사이에서 서로 상대방인 척 연기하며 적절하게 유리한 방향으로 교섭을 진행한다.

다행히도 비슷한 연구가 선행되어 있다. Google 사에서 AMP에 사용할 목적으로 WorkerDOM이라는 것을 만들었고, BuilderIO 사에서 써드파티 라이브러리 코드를 Worker에 분리할 목적으로 Partytown이라는 것을 만들었다. 하지만 이 둘 다 완전한 구현체는 아니다. WorkerDOM은 Spectre 보안 사고가 한창일 때 제작되었기에 SharedArrayBuffer와 Atomics를 활용한 동기적 데이터 교환이 불가능하다. Partytown은 Event Prevent Default를 할 수 없다. 하지만 본질적으로, 미션 임파서블 모델을 사용해서 가운데에서 적절하게 써드파티 코드를 격리하는 것이 가능하다는 것이다.

💽 동기적 데이터 교환이 불가능하다

Web Worker 안과 밖은 동기적으로 데이터 교환이 불가능하다. 동기적 데이터 교환은 상당히 많은 곳에 필요하다. 예를 들어, 단순한 애니메이션을 그리거나 지도를 표시할 때도 동기적으로 화면의 픽셀 데이터를 받아와서 다음 프레임을 그려야 한다. 하지만 Worker 내부에서는 동기적 DOM API를 사용할 수 없으니 모든 애니메이션 코드가 동작하지 않을 것이다.

🤝 동기적으로 만들면 되지!

기본적으로 JavaScript는 사용자 반응이 필수적인 브라우저를 위해 설계되었으므로 비동기적으로 동작한다. 그래서 웹 개발자들을 떨게 만드는 노답 삼형제(Callbacks, Promise, Async/Await)가 있는 것이 아닌가. 비동기적으로 동작하는 JavaScript를 동기적으로 만든다는 뜻은, 내가 어떤 함수를 호출했을 때 그 함수의 결과값이 계산되는 동안 나머지 모든 연산이 정지된 채로 가만히 있는다는 뜻이다.

하지만 여기서 2가지 방법을 사용해서 동기적으로 만들 수 있다.

  1. Synchronous XMLHttpRequest
    • 동기적인 XMLHttpRequest를 가짜로 하나 보내두면 그 결과값이 반환될 때까지 다른 JavaScript 연산을 정지시킬 수 있다. 하지만 Deprecated된 방법이고 약간의 편법에 가까운 내용이다. Synchronous and asynchronous requests - Web APIs | MDN
  2. SharedArrayBuffer and Atomics
    • SharedArrayBuffer는 Web Worker와 Main Thread 사이에서 데이터를 교환할 수 있는 메모리 공간이다. Atomics는 이런 연산을 Thread-Safe하게 만들 수 있게 SharedArrayBuffer에 접근하는 교통 정리를 도와주도록 설계되었다. 하지만 동시에, Atomics를 활용하여 연산을 동기적으로 정지시키는 것도 가능하다.

미니앱의 경우에는 Web Worker를 이미 사용하므로 SharedArrayBuffer와 Atomics를 사용하는 것이 더 적합하다고 판단했다.

✂️ 오프라인에선 접속이 어렵다

기존의 웹 환경에서는 오프라인 환경에서 접속이 불가능하다. 예를 들어 계산기 미니앱이 존재하면, 네트워크 없이도 접속할 수 있어야 한다. 이는 초기 로딩 속도와도 크게 연관된다. Progressive Web App을 활용하여 오프라인에서 사용할 수 있지만, PWA 또한 초기에 수많은 네트워크 요청을 보내서 웹페이지를 저장해야한다는 점에서 여전히 비효율적이다.

📦 묶어서 한 번에 보낸다

출처: web.dev/web-bundles

출처: web.dev/web-bundles

이 또한 하나의 해결책이 있다. Google에서 [[CBOR]] 형식에 기반한 WebBundle이라는 라이브러리를 제작했기 때문이다. 웹번들은 여러 HTML, CSS, JS, 이미지 등을 하나의 압축된 파일로 묶어서 사용할 수 있도록 해준다. 이미 Chrome에서 사용할 수 있는 기능이고, Google에서 실험적으로 다양하게 연구하고 있는 기능이다. 물론 Google의 본 목적은 이 묶음 배송을 통해 URL 기반의 광고 차단 기술을 무력화하기 위한 목적이지만. 관련 글타래

🦠 악성 코드로 바뀌면?

GitHub에서 멀쩡해보이는 코드도 NPM에서는 공격 코드가 삽입된 채로 존재할 수 있다. 실제로 월간 4천만번 이상 다운로드되는 UAParser.js 라는 라이브러리의 NPM 저장소가 해킹되어 악성 코드가 삽입되어 배포된 적 있다. 사고 기록

이렇게 수많은 기업들이 사용할 정도로 믿음직스러워 보이는 라이브러리도 방심하면 악성 코드가 된다.

이렇게 수많은 기업들이 사용할 정도로 믿음직스러워 보이는 라이브러리도 방심하면 악성 코드가 된다.

결과적으로 어떤 형태로든 슈퍼앱의 입장에서는 미니앱 제작사의 패키지를 직접 받아서 검수를 하고 다른 코드로 바꿔치지 못하도록 스스로 호스팅해야 한다.

근데 이건 이미 개발이 거의 완료되어 별도로 붙일만한 말이 없다.

근데 이건 이미 개발이 거의 완료되어 별도로 붙일만한 말이 없다.

😊 결론

위 모든 문제를 다 해결하면 제대로 된 미니앱 환경을 구축할 수 있다. 다만 이제 보면 알 수 있듯이 문제의 난이도가 모두 상당하다. 특히 나는 인턴 기간 동안 2번과 3번 문제에 집중했지만 워낙 깊은 영역으로 파고들다 보니 관련 키워드를 검색해도 이미 본 사이트 몇 개만 나오는 등 어려움이 많았다.

중화권과 같이 특수한 환경에 고립되지 않고 ① 국제적으로 자유롭고 ② 확장성 있으며 ③ 웹 표준과 상호 호환되고 ④ 제작자와 사용자의 가치를 극대화하는 미니앱 환경이 존재하길 바란다. 다만 기술적 어려움으로 빠른 시일 내에는 만나기 어려울 듯 하다.

집계 중...

tossface.cho.sh

tossface.cho.sh

정보

@sudosubin 님을 비롯하여 빠르게 응답해주시고, PUA 요청 반영에 힘써주신 토스페이스 팀에게 감사 인사를 드립니다.

배경

토스페이스는 대한민국의 (거의) 데카콘 기업 비바 리퍼블리카가 제작한 이모지 폰트 페이스입니다. 토스페이스는 이모지를 마음대로 변경했다는 독특한 시도를 통해서 많은 구설수에 올랐습니다. 일본풍이 짙게 섞여있는 이모지를 한국식으로, 또 오래된 기술을 현대 기술의 모습으로 재해석한 것이었습니다.

토스페이스의 처음 모습

토스페이스의 처음 모습

곧 토스페이스는 유니코드의 표의를 훼손했다는 거친 항의에 기존의 창의적인 시도를 엎어야 했습니다.

Unicode Private Use Area

하지만 유니코드에는 토스 팀이 처음에 고려하지 못했던 숨겨진 비밀이 있습니다. 바로 Private Use Area 라고 불리는 비사용 영역(U+E000-F8FF, U+F0000-FFFFD, U+100000-10FFFD)입니다. 이 영역에는 미래에도 어떤 표준 이모지도 배정되지 않으며, 기업체들이 자유롭게 사용할 수 있습니다.

깔끔하고 정갈한 톤앤매너로 한국적이고 시대적인 멋을 잘 표현한 토스페이스의 글자들이 이대로 사라지는 것이 아쉬웠습니다. 그래서 공식적인 채널로 비바 리퍼블리카에 이를 제안했습니다.

@toss/tossface/issues/4

@toss/tossface/issues/4

그로부터 약 세달 뒤, 토스페이스 팀에서 토스페이스 v1.3에 PUA U+E10A부터 U+E117 영역에 기존의 "색다른" 이모지들을 재배포했다는 연락을 받았습니다.

근데 어떻게 타이핑하지?

하지만 이 영역은 쉽게 타이핑할 수 없는 영역입니다. PUA U+E10A부터 U+E117 영역은 일반적인 키보드로 입력할 수도 없으며 이모지 키보드에도 나타나지 않습니다. 글리프가 존재해도 글리프를 사용하기 어려운 아이러니한 상황이 생겼습니다.

그래서 간단하게 글자를 확인하고 클릭하여 복사할 수 있는 웹사이트를 만들고 싶었습니다. 저는 이를 Micro Project라고 부릅니다. 새로운 기술을 시도해보기 안성맞춤입니다. 원래는 새로 출시된 Astro를 사용해보고 싶었으나, 아직 플랫폼이 성숙하지 않아 레퍼런스를 찾기 어려운 오류들이 반복되어, 빠르게 제작할 수 있는 Next + Vercel + Tailwind를 사용했습니다.

만들다보니 대한민국 문화 홍보관

만들고 나니 얼추 대한민국 문화 홍보관처럼 사용할 수 있을 것 같아 간단한 안내 문구들을 추가하여 영문으로 맥락을 설명한 후, Hacker News에 공유했습니다.

국가별 접속자 통계

국가별 접속자 통계

끝마치며

개강 전에 주말 동안 짧게 진행한 재미있는 프로젝트였습니다.

집계 중...

맥북, 충전기, 스티커, 안내문 등 온보딩 굿즈를 보여주는 배너 이미지

당근에서 인턴으로 생활한지 벌써 일주일입니다 (2022-05-22). 3달동안 진행되는 인턴십이지만, 면접과 온보딩에 대해서는 더 늦어지기 전에 정리해야 좋을 것 같아 이렇게 정리했습니다.

지원과 면접

어디서부터 시작하는가

모든 시작은 당근 팀 리크루팅 사이트입니다. 일전에 다른 분의 면접 리뷰에서도 느낀 것이지만, 인재의 발굴에 엄청난 에너지를 쏟고 있다는 느낌을 많이 받았습니다. 구인 웹사이트를 깔끔하게 운영하면서 지원자들이 궁금해할 만한 내용들을 빠짐없이 기재해두었고, 무엇보다 JD(Job Description)를 구체적이고 명확하게 적었습니다. 면접을 본 기업 중 JD는 대외비이기 때문에 공개하기 어렵다고 한 기업도 있었기에 더욱 지원자를 세세하게 배려한다는 느낌을 받았습니다.

당근미니 R&D 엔지니어 인턴 JD

이런 일을 해요.

당근은 지금도 웹 기술을 적극적으로 활용해서 모바일 앱을 만들고 있어요. 웹은 훌륭한 도구이지만 여전히 네이티브 플랫폼 지원에 있어서 한계가 많아요. 기본적으로 OS에서 제공하는 웹뷰 환경은 여러 앱들을 동시에 실행하는데 적합하지 않아요. 웹 보안모델과 OS 기본 보안 모델의 차이로 인해 네이티브 경험을 완성하기가 어려워요. 예를 들어 웹의 API로 사용자 위치정보를 요청하면 네이티브에서 보던 사용자 동의와 다른 UI/UX를 경험하게 돼요. 당근미니 팀은 OS 웹뷰가 아닌 현대의 웹으로부터 돌파구를 찾고 있어요. 원래 웹으로 달성하기 어렵다고 여겨졌던 것들을 돌파해서 온전하게 브라우저에서 구동 가능한 OS 수준 경험을 함께 만들어나갈 분을 찾고 있어요.

구체적으로는 이런 일을 해요.

  • 당근에서 활용할 차세대 웹 기반 실행환경을 연구해요
  • 여러 앱을 격리할 수 있는 샌드박스 환경을 제공해야해요
  • 웹 표준 인터페이스를 통해 당근 통합 기능 제공해야해요
  • 여러 앱들의 실행 상태를 관찰하고 제어할 수 있는 스케줄러를 구현해야해요

이런 분을 찾고 있어요.

  • HTML, CSS, JavaScript 기반 웹 개발에 익숙하신 분
  • JavaScript, TypeScript를 활용한 프로그램 개발에 능숙하신 분
  • DOM 표준을 읽고 직접 구현해보는데 관심이 있으신 분
  • 다양한 웹 표준 API에 관심이 많으신 분
  • 웹 브라우저의 보안 모델에 대한 기본적인 이해가 있으신 분
  • 오픈소스 프로젝트를 초기부터 운영해보실 분

이런 분이면 더 좋아요!

  • 여러 사람들이 참여하는 오픈소스 프로젝트 기여나 운영 경험이 있으면 좋아요
  • OS, 스케줄링, 동시성 프로그래밍에 대한 지식이 있으면 좋아요
  • 다양한 프로그래밍 언어를 다룰 줄 알면 좋아요
  • C/C++, Go, Rust, Zig 같은 시스템 프로그래밍 언어 활용 경험이 있으면 더 좋아요

참고해 주세요.

  • 본 포지션은 3개월 동안 진행되며, 경우에 따라 6개월 연장 제안이 가능해

이렇게 합류해요.

  • 1. 서류접수 → 2. 직무 면접 → 3. 최종 합격

서류 전형

  • 당근은 자유양식의 지원서를 받고 있어요. 본인의 강점이 잘 드러나는 다양한 정보를 자유롭게 표현해주세요.문서 형식은 hwp(한글) 파일을 제외하고 word, pdf, 웹 링크 등 자유롭게 선택해주시면 돼요. 필요에 따라 포트폴리오, Github 링크 등도 함께 전달해주시면 좋아요.

직무 면접

  • 직무와 관련된 경험과 역량에 대해 이력서 및 과제를 바탕으로 심층적인 이야기를 나누는 단계예요.직무 면접은 업무 연관성이 높은 당근 팀원들과 1시간에서 1시간 30분 가량 진행돼요.

엄청 디테일하고 깨알 같지 않나요? 이를 통해 면접 전부터 어떤 포지션을 맡게 될지, 무슨 책임이 주어질지 예상할 수 있는 정보의 투명성이 무척 좋았습니다. 지원 과정도 무척 간소했습니다. 자기소개서 등을 작성하지 않아도 되었고 기존에 가지고 있던 이력서만 첨부하면 되었습니다. 지원하는데 15분이 채 걸리지 않은 것 같습니다.

면접

JD에서 언급된 바와 같이 면접은 1시간 30분으로 예정되었습니다. 이전에도 여러 기업의 면접을 보고 있었습니다. 이때까지만 해도 제가 경험한 면접은 크게 두 종류로 나눌 수 있었습니다.

Behavioral Interview 예시
  • 만약 이런 일이 팀 내에서 발생했다면 어떻게 대처하시겠습니까?
  • PM 혹은 개발자로서 제일 중요한 것이 무엇이라고 생각하시나요?
  • 이력서에 적힌 이 프로젝트에 대해서 설명해주세요. 무엇을 배우셨나요? 무엇이 가장 아쉬우셨나요?
Technical Interview 예시
  • ~이 문제를 풀어주세요.
  • (Web 3 기업 면접의 경우) 블록체인의 Proof of Stake의 개념을 설명해주세요. Proof of Work와 어떻게 다른가요? 무슨 문제를 해결하려고 하는건가요?
  • HTTP의 POST/GET/PUT 등등의 차이를 설명해주세요.

이 중 Computer Science 인턴을 구한다면 단연 두 번째인 Technical Interview를 잘 준비해야 했는데요. 그동안 면접을 본 대부분의 기업들은 위의 예시처럼 준비 가능한. 모범 정답이 어느 정도 정해진. 그런 질문들을 물어보았습니다.

당근의 면접은 색달랐습니다. 위와 같이 면접자의 지식을 묻는 질문을 하지 않았고, 면접을 시작한지 5분이 지나지 않아 실무에 대한 이야기로 넘어갔습니다. 면접이 아니라 회의에 들어온 것 같은 기분이 들었습니다. 현재 팀이 마주한 문제 상황(공교롭게도 면접관님도 Tim이셨습니다만 여기에서는 Team을 뜻합니다)에 대한 설명을 해주셨고, 이에 대해 현재까지 제시된 예상 해결 방법들과 그 장단점을 분석해달라고 하셨습니다. 이렇게만 보시면 이해가 잘 안 될테니 직접 제 일에 대해서 간략하게 설명을 드리겠습니다. 실제 면접 때 다음 내용들을 쭉 설명해주셨습니다.

미니앱이 뭔지 아니?

  • 중국의 WeChat에는 샤오청쉬라는 미니 프로그램이 존재한다.
  • WeChat 내에서 작은 프로그램들을 사이드로딩할 수 있는 기능이다.
  • 앱을 설치할 필요 없이 QR 코드만 촬영하면 미니앱이 초고속으로 로딩되어 앱과 매우 유사한 경험을 할 수 있다.
  • 이와 동시에 회원가입과 결제 연결도 필요 없다. WeChat ID와 WeChat Pay가 자동으로 연결되어 있기 때문에 사용자의 플로우를 방해하는 어떤 Road Block도 없다.
  • 중국에서는 이미 미니앱으로 앱 생태계가 평정이 되었고, Line, Snap도 이런 트렌드를 준비 중이며 Apple 또한 나름의 미니앱인 App Clips를 출시하였다.

미니 앱이 더 궁금하시다면 이 글을 참고해보세요!

당��근이

당근이

그래서 어떻게 진행됐는데?

이전 질문의 답변이 다음 질문으로 이어지는 형태의 질문들이었습니다.

면접 질문들
  • WeChat의 경우는 자신들만의 네이티브 클라이언트를 만들어서 네이티브 클라이언트가 미니앱을 구동한다. 하지만 이렇게 될 경우 미니앱들이 웹표준을 준수하지 않으면서 독자적인 보안 모델을 사용하기에 글로벌하게 도입하기에 어려움이 있다. 당근에서도 이와 비슷한 미니앱 환경을 구상하고 있는데, 이에 알맞은 전략이 무엇일까.
  • → 표준 웹 규격을 준수하며 웹의 보안 모델을 완벽하게 따르는 범용적 형태의 미니앱을 구현하면 될 것이다. 즉, 웹 안에서 웹뷰를 돌리려고 한다. 제일 먼저 떠오르는 방법은 iframe이다. iframe으로 이를 구현할 시 문제가 무엇일까.
  • → iframe은 외부 코드와 내부 코드가 같은 쓰레드 위에서 돌아가기 때문에 미니앱이 죽으면 클라이언트 앱도 죽는 문제가 발생한다. 이를 해결하기 위해서는 어떻게 해야할까.
  • → Web Worker를 사용하면 미니앱과 클라이언트 앱을 별도의 쓰레드로 분리하는 것이 가능해진다. 하지만 이렇게 할 경우 Web Worker가 DOM API에 접근하지 못하는 문제가 발생한다. 예를 들어, getClientBoundingRect라는 DOM API를 사용하지 못한다. 이를 해결하기 위해서는 어떻게 해야할까.
  • → Web Worker가 접근할 수 있는 가상 DOM API를 제공해주면 된다. 구글에서 이를 해결하기 위해 WorkerDOM이라는 모델을 개발했다. 그리고 써드파티 JS 코드를 별도의 Web Worker로 분리하는 구현체인 PartyTown이라는 오픈소스 프로젝트도 최근 공개되었다. 이를 이용해서 미니앱 시스템을 어떻게 구현할 수 있을까.
  • → 만약 이렇게 Web Worker와 WorkerDOM의 기반 기술을 활용해서 미니앱 시스템을 구현했다고 하자. 그렇다면 웹 안의 웹에서 강제종료와 멀티태스킹을 구현할 수 있을까? 어떻게 해야할까?

이렇게 보시면 느낌이 오시나요? 면접이 아니라 실무 수준에서 어떻게 ideation을 해내고 솔루션을 찾아내는지를 알아보는 형태의 면접이었습니다. 합류할지 모르는 면접자에게 직면한 문제 상황과 고려하고 있는 해결책들을 1시간 30분에 걸쳐 설명해주시는 것이 마치 커피 챗을 하는 기분이 들었고, 내가 면접자임에도 엄청난 배려를 받고 있다는 느낌이 들었습니다. 합격자와 불합격자 모두에게 결과를 3일 이내 알려주는 것을 약속하는 모습, 결과 발표가 지연되자 이메일로 미리 양해를 구하는 모습 등 많은 면에서 피플팀의 노력이 보였습니다.

혹여나 위의 질문들에 대해서 답이 궁금하신 분들은 아래 글들을 살펴보시면 답을 아실 수 있습니다.

당근에서 신기했던 점들

온보딩

온보딩

인턴 너 혹시... 뭐 돼?

우선 당근은 목적 조직으로 운영됩니다. 목적 조직과 기능 조직은 서로 반대되는 개념입니다. 기능 조직은 프론트엔드팀, 서버팀, 디자인팀, 기획팀과 같이 팀의 기능에 따라 조직이 분류되고 서로 다른 프로젝트를 병렬로 각 팀에서 토스하며 업무를 처리하는 구조를 말합니다. 목적 조직은 그와 반대로 한 팀 내에 기획자, 디자이너, 엔지니어 등이 모여서 하나의 작은 Cross-functional team을 구성합니다. 저희 팀은 8명으로 구성되어 있는데, 인턴이 아니라 정말 작은 스타트업에 막내로 들어와있다는 느낌을 받았습니다. 인턴에게도 상당한 수준의 발언권과 에너지가 분배되었고, 정보와 기회가 제한되지 않았습니다. 인턴임에도 프로덕션 레벨 제품에 아이디어를 내서 기여를 할 수 있고, 제품의 설계에 새로운 방향을 제안할 수 있었습니다. 첫날 이런저런 이야기를 했을때 저를 격려해주시고 더 많은 의견 개진을 응원해주신 것이 큰 힘이 되었습니다. 비유하자면, 인턴을 위해 준비된 정교한 프로그램을 밟는 것이 아니라 장인 옆에서 어깨 너머로 기술을 전수 받는 수련공이 된 것 같았습니다. 실제 현장에서 현업을 배운다는 느낌을 받았습니다. (물론 당근에서도 정교하게 설계된 프로그램 형태의 인턴도 운영하고 있습니다.)

스파이더맨도 울고 갈 큰 힘과 책임

구성원들을 신뢰하며 자유를 줄테니 책임을 다하라는 느낌을 많이 받았습니다. 단적인 예시로 9시부터 11시 사이에 자유롭게 출근을 하며 퇴근할 때 인사를 하지 않습니다. 즉, 업무 시간을 기록하지 않을테니 퍼포먼스로 증명하라는 느낌이었습니다. 인턴이라서 잘 모르는 내용들을 물어보고는 했는데 그냥 판단하셔서 괜찮겠다고 생각하신대로 해주시면 돼요라고 돌아온 답변이 인상 깊었습니다.

홍길동도 부러워할 리모트 근무

위와 연결되는 내용이지만 아직 저희 팀 한 번도 오프라인으로 전부 모인 적이 없습니다. 최근 며칠은 저희 팀에서 혼자 출근한 것 같아요. 당장 저희 팀의 개발자 한 분도 제주도에서 한달살이를 하고 계십니다. 그럼에도 불구하고 팀원들 모두 퍼포먼스는 최상을 유지하고 계셨습니다. 또한 비동기 통신이라는 개념도 인상 깊었습니다. 원격 근무로 점점 이동하며 모두가 실시간으로 모이는 회의를 잡는 것에 에너지가 과하게 들어가니 차라리 모든 것을 문서화하고 기록한 뒤 Slack 등의 기업 메신저를 이용해 모든 것을 자신만의 업무 시간에 처리하는 것입니다. 물론 이는 위에서 자유와 책임을 중시하며 각 구성원들끼리의 강한 도덕적 신뢰를 기반으로 하는 것 같았습니다. (i.e., 조별과제처럼 무임승차할 직원이 없다는 믿음 아래 구동되는 제도입니다)

가재도 살 수 있을 것 같은 투명한 정보

모든 정보가 공유되는 주간 팀미팅

모든 정보가 공유되는 주간 팀미팅

인턴에게도 어떠한 제한이 없습니다. 당근의 서버 코드를 열람해볼 수 있고, 지역광고의 매출액 규모를 확인해볼 수 있으며, 당근 투자사들과의 회의록도 열람할 수 있습니다. 아마 이 내용을 보고 리드 헤이스팅스의 규칙 없음 책을 많이 떠올리실 것 같은데요. 네가 반년 뒤에 해고될 가능성이 50% 정도 되니 전세 계약을 미루는 것이 어떻겠느냐까지 투명하게 공개하는 넷플릭스와 기업 문화가 비슷했습니다.

매주 월요일마다 있는 전사 미팅에서는 모두가 듣는 가운데 각 팀의 업데이트를 공유하며, 손쉽게 검색될 수 있도록 어떤 형태의 슬라이드도 활용하지 않고 모두 Notion의 공유 문서에 작성하는 것도 인상 깊었습니다. 전반적으로 문화를 통해 창의적이고 자발적인 의견 개진을 촉진시킨다는 느낌을 받았습니다. 직원들에게 먼저 신뢰를 보여줌으로써 책임감 있는 자세를 유도하는 것 같았습니다.

진짜 세계관 최강자들의 싸움이다...

새벽 12시 21분에 쏘아올린 작은 공... 277개의 답글 모두 그날 새벽에 작성되었습니다 😔

새벽 12시 21분에 쏘아올린 작은 공... 277개의 답글 모두 그날 새벽에 작성되었습니다 😔

가장 인상 깊었던 것을 마지막에 두었습니다. 상호 존중을 기반으로 한 토론 문화가 무척이나 인상 깊었습니다. 제가 온지 이틀만에 6시간 동안 회의를 하고, 새벽까지 수백 개의 문자를 주고 받으며 프로덕트의 방향성을 논하는 일이 있었습니다. 그동안 여러 조별 과제와 (유사) 창업을 해보았지만, 이토록 깊고 섬세하게 프로덕트에 애정을 가지고 열띤 토론을 하는 것을 처음 보았습니다. 제한된 리소스를 어떻게 분배하여 어떻게 시장에서의 성공을 이끌 것인지 토론하면서도 상대방의 관점을 이해하고 중간점을 찾기 위해 논리적으로 의사 표현하는 모습이 너무나도 인상 깊었습니다. 그러면서도 그 토론이 개인적인 감정과 연결되지 않고 서로를 존중하는 모습이 👍👍👍👍👍 너무 멋있었습니다.

앞으로

저는 앞으로 미니앱 표준을 위한 샌드박싱 작업을 진행하게 됩니다. 간략하게 말하면 웹 안의 웹을 만드는 작업이며 미니앱 환경을 위한 기반 작업을 하는 일입니다. 나름 다양한 기술적 & 제품적 욕심이 생겼는데 인턴을 잘 마무리하고 또 한 번 재미있는 글을 작성할 수 있었으면 좋겠습니다. 당근미니 팀에 많은 관심 부탁드려요!

집계 중...
2022-06-12 업데이트

이 글 이후로 Roam, [[Obsidian]], Logseq, and Foam 같은 PKM 소프트웨어를 많이 공부했습니다. 수동 연결의 개념을 잘못 이해했었습니다. 기존의 PKM 소프트웨어도 Fuzzy Search를 이용해 자동적으로 레퍼런스를 구분합니다. SagaWeavit 같이 자동 연결을 지원하는 소프트웨어를 찾았습니다만, 원하는대로 동작하지 않았습니다. 수동 연결은 데이터베이스를 정제하는데 도움을 줍니다. 만약 차세대 디지털 브레인을 만든다면, 수동 연결을 그대로 둘 생각입니다.

2022-07-01 업데이트

차세대 디지털 브레인을 이미 보고 계십니다! 지난 2주간 WWW project를 통해 이 웹사이트를 만들었습니다. 이 글의 체크상자를 거의 달성합니다.

💬Work in Progress

이 글은 다른 언어로 먼저 작성되었으며 현재 번역 중입니다. 여러 언어를 구사하시면 이 글을 다른 언어로 먼저 찾아보시기 바랍니다.

집계 중...

얼마 전 이 Gist와 이 페이지를 보았다. 이 비교를 2020년대에 맞게 한 번 더 업데이트하면 좋을 것 같다는 생각이 들었다. 현대 컴퓨터가 얼마나 빠른지 시각화하는 용도로 유용할 것이다.

달력의 의미

한 CPU 사이클이 1초가 걸린다고 생각해보자. 그에 비해 현대적인 4.0 GHz CPU는 1 CPU 사이클로 0.25나노초 정도가 걸린다. 총 40억배 시간 차이다. 이제, 이 CPU가 현실에서의 시간을 어떻게 느낄지 생각해보자.

동작물리적 시간CPU 시간
1 CPU 사이클0.25ns1초
L1 캐시 참조1ns4초
분기 예측 오류3ns12초
L2 캐시 참조4ns16초
뮤텍스 락17ns68초
2KB 전송44ns2.93분
메인 메모리 참조100ns6.67분
1KB 압축2μs2.22시간
메모리에서 1MB 읽기3μs3.33시간
SSD 무작위 읽기16μs17.78시간
SSD에서 1MB 읽기49μs2.27일
같은 데이터센터에서 패킷 왕복500μs23.15일
하드 디스크에서 1MB 읽기825μs38.20일
디스크 탐색2ms92.60일
캘리포니아에서 서울까지 패킷 왕복200ms25.35년
OS 가상화 재부팅5s633년
SCSI 커맨드 타임아웃30s3,802년
하드웨어 가상화 재부팅40s5,070년
물리적 시스템 재부팅5m38,026년
잠깐!
  • 이 글이 작성된지 2년 이상 지났습니다.
  • 새로운 일들이 일어나기 충분한 시간입니다.
  • 저는 이 글에 더 이상 동의하지 않을지도 모릅니다.
Google에서 새로운 자료 찾아보기

집계 중...

이 글의 목표는 동영상을 점자 패턴 스트림 (Braille Pattern Stream, ⠨⠎⠢⠨⠀⠙⠗⠓⠾⠀⠠⠪⠓⠪⠐⠕⠢)으로 변환하는 방법을 설명하는 것이다. 점묘화로 만드는 동영상이나 아스키 아트로 만드는 동영상이라고 생각해도 좋을 것이다 (물론 점자는 ASCII가 아닌 Unicode이다.) 아직 점자 동영상이 무슨 뜻인지 모르겠다면 완성본을 먼저 보자.

자막을 켜고 5-10초를 기다리면 된다. 자막이 10MB라 로딩이 느릴 수 있다. 만약 점자가 보이지 않는다면 다음 영상을 보자.

점자 패턴 스트림은 말 그대로 연속된 형태의 점자 패턴이기에 동영상으로 만들 수도 있고 아예 자막으로 모든 것을 넣어버릴 수도 있다. 자막이야말로 텍스트 스트림을 보여주는 수단이기 때문이다. 때문에 이 프로젝트의 목표를 동영상을 변환해서 YouTube 자막으로 넣어보는 것으로 잡았다. 기반 기술은 다음과 같다.

  • OpenCV (C++ cv2) — 동영상을 연속된 이미지로 변환하기 위해 사용됨
  • Python Image Library (Python 3 Pillow) — 이미지를 점자로 변환하기 위해 사용됨
  • Python Standard Library (sys, os, pathlib) — 파일을 읽고 쓰기 위해 사용됨
  • ffmpeg (optional) — 동영상을 편집하기 위해 사용됨

anaclumos/video-in-dots에 오픈소스로 공개되어 있다.


설계

목표를 위해서 다음의 기술들이 필요하다고 생각했다.

  1. 임의의 이미지를 모노크롬 이미지로 변환하는 기술
  2. 모노크롬 이미지를 임의 크기의 점자 배열로 변환하는 기술
  3. 동영상을 프레임의 연속으로 변환할 수 있는 기술
  4. 3번에서 얻은 프레임을 2번에서 얻은 기술을 이용해 텍스트 스트림으로 변환하여 정형화된 자막의 형태로 변환하는 기술
  5. (나중에 알게 됨) 텍스트 스트림을 특정 크기 이하로 압축하는 기술
  6. (나중에 알게 됨) Dithering 처리 기술

1. 임의의 이미지를 모노크롬 이미지로 변환

모노크롬 이미지는 1비트 깊이의 이미지로, #000000의 완벽한 검정과 #FFFFFF의 완벽한 하양으로만 이루어지는 이미지이다. 이 하양과 검정을 점자에서 각 점이 칠해진 (raised) 상태와 그렇지 않은 상태에 대응시킬 수 있다.

점자의 기본적인 역할은 경계선형체를 구분할 수 있도록 도와주는 것이다. 이미지를 흑백의 형태로 변환한 뒤 1비트 흑백 이미지로 변환한다. 여기서 중요한 점은 자막은 대개 시스템 기본값이 하얀색이기 때문에 우리의 1비트 흑백 이미지에서는 밝은 픽셀이 1이 되어야 한다는 것이다.

우측 3개의 이미지에서 볼 수 있듯이, 그 사이의 Gray 없이 #000000 검정과 #FFFFFF 하양만으로 경계선과 형체를 만들어낼 수 있다. DemonDeLuxe (Dominique Toussaint), CC BY-SA 3.0, via Wikimedia Commons.

우측 3개의 이미지에서 볼 수 있듯이, 그 사이의 Gray 없이 #000000 검정과 #FFFFFF 하양만으로 경계선과 형체를 만들어낼 수 있다. DemonDeLuxe (Dominique Toussaint), CC BY-SA 3.0, via Wikimedia Commons.

제일 왼쪽의 의미지는 256단계의 grayscale 이미지이고, 나머지 3개의 이미지는 각기 다른 알고리즘으로 나타낸 모노크롬 이미지이다. 이 프로젝트에서는 최종적으로 Floyd-Steinberg 알고리즘을 사용한다.

이미지를 모노크롬으로 변환하기

이미지를 1비트 흑백으로 바꾸는 방법은 굉장히 다양한데, 이 프로젝트는 sRGB 영역만 사용할 것이기에 CIE 1931 sRGB에 정의된 Luminance 기반 변환을 사용한다. 다음처럼 간단하게 표현할 수 있다. 참고

def grayscale(red: int, green: int, blue: int) -> int:
return int(0.2126 * red + 0.7152 * green + 0.0722 * blue)

여기에서 red, green, blue는 0-255의 int이다. 이 합이 임의의 hex_threshold를 넘으면 해당 픽셀의 값을 1로 설정한다. 이 코드를 모든 픽셀마다 실행해주면 된다. 물론 위의 grayscale 코드는 이론적인 부분을 이해하기 위함이고, 최종적으로는 아래와 같이 Python PIL에 내장된 코드를 사용할 것이다. 후술하겠지만, 이 라이브러리는 기본적으로 디더링 처리도 해준다.

resized_image_bw = resized_image.convert("1")  # apply dithering

2. 모노크롬 이미지를 임의 크기의 점자 배열로 변환

위 문장은 3가지 파트로 나눌 수 있다. ① 1비트 모노크롬 이미지를 ② 임의 크기의 ③ 점자 배열로 변환하는 기술. 1번은 완료했으니 우선 2번부터 살펴보자.

PIL을 통해 이미지 크기 변환하기

이 프로젝트에서는 이미지를 PIL을 사용해 불러오기 때문에, 다음 코드를 통해 이미지를 임의 크기로 변경할 수 있다.

def resize(image: Image.Image, width: int, height: int) -> Image.Image:
if height == 0:
height = int(im.height / im.width * width)
if height % braille_config.height != 0:
height = int(braille_config.height * (height // braille_config.height))
if width % braille_config.width != 0:
width = int(braille_config.width * (width // braille_config.width))
return image.resize((width, height))

이 프로젝트에서 사용할 점자는 크기가 2 x 3 크기이기에 이미지의 너비, 높이가 여기에 완벽하게 나누어 떨어지도록 크기를 미세하게 조정한다.

이미지를 점자 배열로 변환하기

이건 사진으로 보는 것이 이해하기가 더 편하다. 왼쪽과 같이 6 x 6 이미지가 있을 경우 너비를 2픽셀, 높이를 3픽셀마다 잘라 2 x 3 이미지로 만든 뒤 이를 점자로 변환한다.

왼쪽 → 오른쪽

왼쪽 → 오른쪽

점자 변환 알고리즘의 핵심은 어떻게 픽셀 배열에 해당하는 점자를 정확하게 찾느냐는 것이다. 가장 단순하게 모든 픽셀 배열 조합을 점자와 매핑해놓는 방법도 있다. 특히나 2 x 3의 점자는 26개의 조합 밖에 없기 때문이다. 하지만 유니코드가 점자 규격이 제정될 때 점자가 어떻게 배치되었는지를 이해하면 더 간단하게 나타낼 수 있다.

참고: Braille Patterns 위키백과, 유니코드 테이블.

참고: Braille Patterns 위키백과, 유니코드 테이블.

간단한 유틸 코드를 작성해보았다. 이 코드의 경우 위의 로직을 이용해서 이미지를 리사이징한 뒤 점자로 변환하고 색을 입혀 terminal에 점자 배열을 print한다. Terminalprint되는 글자의 색은 \033[38;2;{};{};{}m{}\033[38;2;255;255;255m".format(r, g, b chr(output)) 의 형태로 입힐 수 있는데, 더 궁금한 경우 ANSI Color Escape Code를 알아보면 된다. 직접 실행해보고 싶다면 다음 저장소의 파일을 실행해 보자. anaclumos/tools-image-to-braille

이 코드의 경우 1600만 색상의 ANSI True Color라는 색상 프로필을 사용하는데, macOS에 내장된 terminal.app에서는 True Color 1600만 색상을 지원하지 않고 256개의 색상만 지원한다. 때문에 True Color을 지원하는 iTerm이나 VS Code 내장 터미널을 사용해서 실행하자.


3. 동영상을 프레임의 연속으로 변환

같은 영상으로 반복해서 코드를 실행할 일이 있을 것이기에 프레임마다 사진이 물리적으로 저장되어야 했다. 이를 위해 Python OpenCV 라이브러리를 활용하기로 결정했다. 간단하게 이야기해서 다음의 과정을 거친다.

  1. 기본적인 라이브러리와 변수 설정
  2. 동영상이 존재하지 않을 경우 오류 표시
  3. 프레임 이미지 파일이 저장될 폴더 생성
  4. 각 프레임을 저장.

위와 같은 모습으로 처리된다. GPU 가속을 사용하지 않기에 19분 15초로 상당히 오래 시간이 소요된다.

위와 같은 모습으로 처리된다. GPU 가속을 사용하지 않기에 19분 15초로 상당히 오래 시간이 소요된다.

4. 텍스트 스트림을 생성해 정형화된 자막의 형태로 변환

이미 2번에서 예제 코드를 완성했기 때문이 이 코드를 매 프레임마다 실행하면 되는 구조였다. 처음에는 .srt 파일, 즉 SubRip 파일을 이용해서 자막을 생성하려 했다. 다음의 과정을 거친다. .srt 자막은 다음 구조를 가진다.

1
00:01:00,000 --> 00:02:00,000
This is an example
SubRip caption file.

가장 위에 Sequence 번호, Start --> End 타임 스탬프 ( HH:mm:ss,SSS ), 그리고 자막 텍스트로 이루어져 있다. 처음에 SubRip을 선택한 이유는 단순히 텍스트 색상을 지원하기 때문이었다.

SubRip 파일의 Text Styling에 분명 Yes라고 나와있었는데 이는 비공식적인 스타일링 문법이었다. 출처: en.wikipedia.org

SubRip 파일의 Text Styling에 분명 Yes라고 나와있었는데 이는 비공식적인 스타일링 문법이었다. 출처: en.wikipedia.org

파일을 제작한 후 YouTube에 업로드하려 했는데 색상이 전혀 나오지 않는 것을 보고 이것이 비공식적 옵션이라는 것을 알게 되었다.

YouTube가 지원하는 자막의 종류 및 특징

No style info (markup) is recognized in SubRip.

No style info (markup) is recognized in SubRip.

Simple markups are supported in SAMI.

Simple markups are supported in SAMI.

YouTube의 문서를 살펴보니 다음과 같은 자막을 지원하고 있었다.

SAMI 파일 (한글 자막에 자주 쓰이는 .smi 파일)이 색상을 지원한다는 것을 알게 되어 SAMI 파일로 노선을 변경했다. 어차피 자막 파일 제작 스크립트는 정해진 문법에 따라 텍스트 파일에 append해주는 수준에 불과하기에 많은 변경을 요구하지 않았다. Microsoft의 문서를 확인해보니 SAMI 파일은 다음과 같은 문법을 사용하고 있었다.

<SAMI>
<HEAD>
<STYLE TYPE = "text/css">
<!--
/* P defines the basic style selector for closed caption paragraph text */
P {font-family:sans-serif; color:white;}
/* Source, Small, and Big define additional ID selectors for closed caption text */
#Source {color: orange; font-family: arial; font-size: 12pt;}
#Small {Name: SmallTxt; font-size: 8pt; color: yellow;}
#Big {Name: BigTxt; font-size: 12pt; color: magenta;}
/* ENUSCC and FRFRCC define language class selectors for closed caption text */
.ENUSCC {Name: 'English Captions'; lang: en-US; SAMIType: CC;}
.FRFRCC {Name: 'French Captions'; lang: fr-FR; SAMIType: CC;}
-->
</STYLE>
</HEAD>
<BODY>
<!<entity type="mdash"/>- The closed caption text displays at 1000 milliseconds. -->
<SYNC Start = 1000>
<!-- English closed captions -->
<P Class = ENUSCC ID = Source>Narrator
<P Class = ENUSCC>Great reason to visit Seattle, brought to you by two out-of-staters.
<!-- French closed captions -->
<P Class = FRFRCC ID = Source>Narrateur
<P Class = FRFRCC>Deux personnes ne venant la r&eacute;gion vous donnent de bonnes raisons de visiter Seattle.
</BODY>
</SAMI>

HTML과 같은 구조를 사용한다. 자세하게 보면 다중 언어 자막을 어떻게 처리하는지도 알 수 있다. 출처: Microsoft


5. 텍스트 스트림을 특정 크기 이하로 압축

텍스트 파일이 70MB!

텍스트 파일이 70MB!

이렇게 SAMI 파일을 완성하고 나니 70MB가 넘는 파일이 생성되었다. YouTube에도 자막이 업로드되지 않았다. (YouTube에서 공식적으로 밝힌 자막 최대 크기에 대해서는 찾을 수 없었다. 하지만 나중에 경험적으로 알게된 사실로 그 용량 제한이 10MB인 것 같았다.) 유튜브에 업로드하기 위해선 용량 절감이 필요했다.

이때 생각한 압축 방법은 크게 3가지가 있다.

  1. 점자 배열의 크기 자체를 줄이기
  2. 프레임 건너뛰기
  3. 컬러 스택 이용하기

1번과 2번의 경우 다음과 같이 점자 배열 설정을 분리해두었기에 다양한 숫자로 실험을 할 수 있었다. YouTube의 경우도 자막이 변경되는 최대 속도가 8-10Hz 였기 때문에 프레임을 건너뛰어 용량을 절감할 수 있었다.

class braille_config:
# 2 * 3 braille
base = 0x2800
width = 2
height = 3


class video_config:
width = 56
height = 24
frame_jump = 3 # jumps 3 frames

3번에서 말하는 컬러 스택이란, 동일한 색상의 점자가 등장했을 때 이를 stack에 잠시 밀어두었다가 색깔이 변경되면 한 번에 append하는 방식이다. 초기 자막을 한 번 보자.

<FONT color="#FFFFFF"></FONT>
<FONT color="#FFFFFF"></FONT>
<FONT color="#FFFFFF"></FONT>
<FONT color="#FFFFFF"></FONT>
<FONT color="#FFFFFF"></FONT>
<FONT color="#FFFFFF"></FONT>
<FONT color="#FFFFFF"></FONT>
<FONT color="#FFFFFF"></FONT>
<FONT color="#FFFFFF"></FONT>
<FONT color="#FFFFFF"></FONT>
<FONT color="#FFFFFF"></FONT>
<FONT color="#FFFFFF"></FONT>
<!-- 텍스트 길이: 371 -->

모두 동일한 색상의 흰색 점자임에도 매번 앞뒤로 긴 HTML 문법이 붙고 있다. 즉 컬러 스택의 색상을 보관한 뒤, 동일한 색상의 점자들이 나타나면 스택에 그 점자를 추가하고 색상이 바뀌는 순간에 이를 다음과 같이 한 번에 붙이는 방식이다.

<FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT>
<!-- 텍스트 길이: 41. 약 9배 절감되었다. -->

알고리즘 문제에서 흔히 보이는 완전탐색 후 최대압축은 아니지만, 10MB 아래로 줄이기에는 충분한 알고리즘이었다. 특히 흑백 계열에서 발군의 압축 성능을 보인다.

<SYNC Start=125><P Class=KOKRCC><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR></SYNC>
<SYNC Start=250><P Class=KOKRCC><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR></SYNC>
<SYNC Start=375><P Class=KOKRCC><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR></SYNC>
<SYNC Start=500><P Class=KOKRCC><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR></SYNC>
<SYNC Start=625><P Class=KOKRCC><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR><FONT color="#FFFFFF">⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿</FONT><BR></SYNC>

여기까지 완성된 파일 (Dithering 미적용 상태)


6. 디더링

이렇게 완성된 파일을 YouTube에 업로드했는데, 어딘가 어색한 부분이 있었다. 이는 모바일 기기에서 점자를 다루는 방법이 다른 문제인 것 같았다. 컴퓨터에서는 점자의 한 점에 색상이 색칠되지 않아도 빈 원이 그려져 있는 반면, 모바일에서는 점자가 칠해져 있지 않다면 그냥 빈 공간으로 표현된다. 아마 모바일의 가독성 때문에 이런 처리가 되는 것 같았다. 이를 해결하기 위해서는 디더링 처리가 필요하다.

모바일 기기에서 점자가 비어있는 칸은 완전한 공백으로 나타난다. 왼쪽에서는 거의 아무런 디테일이 보이지 않지만, 디더링 처리가 된 우측에서 훨씬 많은 디테일이 보인다. 이는 특히 검은 배경이 많거나 Color Gradation이 많은 화면에서 디더링의 효과가 두드러진다.

모바일 기기에서 점자가 비어있는 칸은 완전한 공백으로 나타난다. 왼쪽에서는 거의 아무런 디테일이 보이지 않지만, 디더링 처리가 된 우측에서 훨씬 많은 디테일이 보인다. 이는 특히 검은 배경이 많거나 Color Gradation이 많은 화면에서 디더링의 효과가 두드러진다.

뮤직 비디오의 원본 장면이다. BTS 지민.

뮤직 비디오의 원본 장면이다. BTS 지민.

Dithering은 더 넓은 컬러 스펙트럼에서 더 낮은 컬러 스펙트럼으로 변환할 때 화질 저하를 보상하는 기법이다. 이 또한 Wikipedia의 예시를 살펴보자.

1번 사진은 16백만 색상을, 2번과 3번 사진은 256색상을 사용한다. 디더링 처리된 이미지는 압축된 색 영역을 사용하지만 디테일이 느껴진다. 이미지 출처: en.wikipedia.org

1번 사진은 16백만 색상을, 2번과 3번 사진은 256색상을 사용한다. 디더링 처리된 이미지는 압축된 색 영역을 사용하지만 디테일이 느껴진다. 이미지 출처: en.wikipedia.org

2번 이미지와 3번 이미지의 디테일 차이가 느껴지는가? 둘 다 압축된 256색을 사용하지만 3번 이미지가 훨씬 디테일이 느껴진다. 이와 같이 낮아진 색 스펙트럼에서도 픽셀의 배치를 적절히 활용하여 이미지의 디테일을 살릴 수 있다.

디더링 처리는 GIF 변환 과정에서도 사용되기 때문에 대부분의 GIF 파일에서는 위의 3번 사진과 같이 수많은 점이 돌출되어 찍혀있는 듯한 느낌이 든다 (흔히 말하는 GIF 감성)

흔히 말하는 디지털 풍화도 스크린샷을 찍을 때 일어나는 디더링 처리와 관련이 있다. 무압축 스크린샷을 지원하지 않는 기기일 경우 스크린샷을 찍으며 압축을 한다. 이 과정에서 손실된 색상 디테일을 보정하기 위해 일부 픽셀 데이터가 보정되는데 이 과정이 수십-수백회 반복되면 원래 이미지에서 상당히 멀어진 이미지에 도달하게 된다. 물론 디지털 풍화에는 이 외에도 많은 요소가 영향을 준다. (관련 문서: dithering and color banding)

윤곽선을 담당하는 모노크롬 변환 또한 1600만 RGB 색 영역을 흑백 2가지 색을 가진 색 영역으로 낮추는 과정이기 때문에 이 디더링 처리가 필요하다. 상술했듯이 PIL의 기본 변환 코드를 사용하여 손쉽게 구현할 수 있다.

resized_image_bw = resized_image.convert("1")  # apply dithering

뮤직비디오의 실제 장면으로 확인해보자.

1분 33초 경부터 디더링의 효과를 눈에 띄게 나타난다.


끝마치며

일단 이렇게 마무리된 프로젝트이다. 코드를 통해서 동영상을 분석하고 제어하는 프로젝트는 꾸준히 진행할 생각이다. 과거에 업로드했던 관련 글을 올리며 글을 마무리한다.

Butter

Fiesta


2021년 7월 9일 추가 - 불균일한 자막 스펙?

나는 iOS와 iPadOS 네이티브 YouTube 앱, 그리고 macOS Chrome, Firefox, Safari에서 자막을 테스트했다. 그런데 안드로이드에서 자막이 나타나지 않거나 YouTube 앱이 크래시된다는 제보를 받았다. Windows 10 Chromium Edge에서도 공백 문자 너비가 무시되는 것인지 점자 자막이 불안정하게 나타나는 문제가 있었다.

하여튼 그런 문제로 인해 macOS 11 Chrome 91에서 화면 녹화한 모습을 첨부한다. Apple Platform에서 이 영상들을 감상하는 사람들은 아래와 같은 점자 영상들을 볼 수 있다. 참고로 각 점자들의 선명한 모습을 보여주고 싶어 영상을 8K로 제작했다.

잠깐!
  • 이 글이 작성된지 2년 이상 지났습니다.
  • 새로운 일들이 일어나기 충분한 시간입니다.
  • 저는 이 글에 더 이상 동의하지 않을지도 모릅니다.
Google에서 새로운 자료 찾아보기

집계 중...

우아한테크캠프 3기 후기

2020년 8월에 마무리하고 2021년에 후기를 쓰는 것이 너무 늦은 감이 있지만 지금 4기 선발이 진행 중인 마당에 지금이라도 올리지 않으면 평생 올리지 않을 것 같은 생각이 들어 지금이라도 후기를 마무리 지어 올려 본다. 인터넷에 공개된 대부분의 정보는 간단하게만 짚고 넘어가고 내가 지원할 때 궁금했던 점 위주로 작성해 보겠다.

우아한테크캠프배달의민족을 운영하는 테크 유니콘 기업 우아한형제들에서 여름 동안 인턴으로 근무하며 실무와 가까운 개발 및 프로그래밍 공부를 하는 프로그래머 양성 과정이다. 선발 인원은 30명이며 경쟁률은 대략 43대 1이다.

신발은 출입금지 — 일명 스시바 라운지. 전망이 좋아 선착순이다.

신발은 출입금지 — 일명 스시바 라운지. 전망이 좋아 선착순이다.

🔋 선발 과정

지원서

질문 하나 당 700자 분량이었다.

  • 본인이 생각하는 개발자의 덕목과 여기에 비추어 보았을 때 본인의 어떤 점이 개발자로 일하기 적합하다고 생각하시나요?
  • 우아한테크캠프에 참여하고 싶은 이유를 자유롭게 기술하여 주세요.
  • 교과 과정 이외에 나만의 프로그래밍 학습 방법이 있다면 서술해주세요.
  • 협업의 과정에서 어려움을 겪었던 경험과 그 어려움을 극복하기 위해 어떠한 노력을 했는지 서술해주세요.

2020년 3기 모�집 배너

2020년 3기 모집 배너

1차 코딩 테스트

흔히 볼 수 있는 코딩 테스트 문제들이었다. 자바스크립트로 풀었던 것으로 기억하며 당시 프로그래머스 썸머코딩과 우아한테크캠프 때문에 코딩테스트 연습을 많이 했었기에 그렇게 부담스러운 난이도는 아니었다. 총 4문제로 150분이 주어졌다.

2차 코딩 테스트

제공되는 VS Code 웹 플랫폼에서 특정 기능을 수행하는 관리자 툴을 개발하는 프로젝트였다. 기본적인 보일러 플레이트와 빌드 설정, CI/CD는 미리 구현해 주셨기에 README에 설명된 대로 금방 실행할 수 있었으며 그 위에 3개의 핵심 기능을 구현해야 했다. 외부 라이브러리 사용은 금지됐고 바닐라 JS만으로 해결해야 했다. 시간은 4시간으로 꽤 길었는데 나는 시간이 부족하다고 느꼈다. 자세한 문제 전략에 대한 설명은 관계자님께서 공유할 수 없다고 말씀하셔서 생략한다... 😭

면접

코로나19로 인해 온라인으로 30분간 진행되었으며 개발자 채용 면접이 아니기에 기술적으로 깊은 내용을 물어보기보다는 프로그래머로서의 기본기를 잘 갖추고 있는지, 우테캠에서 배울 준비가 되었는지, 좋은 캠프 멤버로 활동할 수 있을지에 대한 부분을 주로 물어보신다는 느낌을 받았다. 특히 나의 경우 지원서에 이 기술 블로그를 언급했는데, 그중 글 하나에 대한 상세한 정보를 물어보셨다.

경쟁률

경쟁률에 대한 부분도 궁금했었는데 나중에 알게 된 바로는 다음과 같았다.

  • 전체 지원자 1300명+ (43배수+)
  • 서류 및 1차 코딩 테스트 통과자 500명+ (17배수+)
  • 2차 코딩 테스트 통과자 90명 (3배수)
  • 면접 통과 및 최종 합격자 30명

우아한형제들 면접실의 모습. 원래는 여기에서 면접을 보았어야 했는데 코로나19의 확산으로 Google Meet을 통해 진행되었다.

우아한형제들 면접실의 모습. 원래는 여기에서 면접을 보았어야 했는데 코로나19의 확산으로 Google Meet을 통해 진행되었다.

🏫 교육 과정

  1. OT 기간 (기간 3일)
  • 미니 프로젝트: Express, HTTP 없이 웹 서버 구현하기.
  • 공부 키워드: Node.js, JS OOP, 비동기 프로그래밍, 비동기 카페, HTTP 명세, HTTP 기초.
  • 우리 팀 GitHub
  • 내 블로그 글

루터회관 3

루터회관 2

루터회관 1

OT가 이루어진 작은집. 잠실에 위치해 있다.

  1. 로그인을 구현하는 프로젝트를 수행하는 배민상회 프로젝트 (기간 1주)
  • 조건
    • 바닐라 자바스크립트만을 활용한다.
    • Passport 등의 인증 시스템 없이 인증을 직접 구현한다.
    • 상용 DB를 사용하지 않고 파일 시스템으로 DB를 직접 구현한다.
  • 공부 키워드: HTML, CSS, CSS Layout, Express.
  • 우리 팀 GitHub

오피스

오피스

오피스

오피스

우아한테크캠프의 본무대가 된 큰집. 몽촌토성역 12초 거리에 위치해 있다.

  1. 칸반 보드를 직접 구현하는 트렐로 프로젝트 (기간 2주)
  • 조건
    • 바닐라 자바스크립트만을 활용한다.
    • Webpack을 직접 설정해 활용한다.
    • 드래그 & 드롭을 구현해야 하지만 HTML Drag and Drop API 없이 이벤트 버블링, 이벤트 캡처 그리고 이벤트 위임을 활용해 직접 구현해야 한다.
  • 공부 키워드: Webpack, ES Module, DOM API, Templating, Fetch-Promise pattern, JS Event Delegation, DBMS, MySQL, SQL Syntax.
  • 우리 팀 GitHub

Cafe

Cafe View

큰집 18층 카페와 카페에서 바라본 올림픽공원의 모습이다.

  1. 가계부 앱을 직접 구현하는 뱅크샐러드 프로젝트 (기간 2주)
  • 조건
    • 바닐라 자바스크립트만을 활용한다.
    • 바닐라 자바스크립트와 History API를 활용해 싱글 페이지 애플리케이션을 직접 구현한다.
    • CI/CD를 상용 솔루션 없이 직접 구현한다.
    • OAuth를 구현한다.
    • SVG와 캔버스 등을 활용해 그래프를 그린다.
  • 공부 키워드: Observer Pattern, ERD, OAuth, Passport, State Management, Immutability, Transactions, Shell Scripts, CI/CD, CSS Animations & Optimizations (requestAnimationFrame & requestIdleCallback), SVG, Canvas.
  • 우리 팀 GitHub

페어 프로그래밍

종종 페어 프로그래밍을 했다. 지금 보이는 이 코드는...

  1. 배달의민족 B마트를 직접 구현하는 B마트 프로젝트 (기간 3주)
  • 조건
    • Vanilla React를 활용한다.
    • AWS VPC를 활용한다.
    • S3 이미지 저장소를 활용한다.
    • Elastic Search, Logstash, Kibana (ELK) 조합을 활용한다.
  • 공부 키워드: React Hooks, AWS VPC, React Router, React Context API, React useReducer, AWS IAM, AWS S3, React Test Codes (Jest, Enzyme, ...), Elastic Search, Logstash, Kibana, ELK.
  • 우리 팀 GitHub

4번째 프로젝트 도중 사회적거리두기 2단계 격상으로 리모트로 진행하게 됐다.

4번째 프로젝트 도중 사회적거리두기 2단계 격상으로 리모트로 진행하게 됐다.

✨ 좋았던 점들

우선 매달 150만원 정도의 활동비와 활동 장비(맥북 프로 💻 그리고 모니터 🖥)가 지급되었다.

MacBook

Monitor

2019년형 MacBook Pro 16인치 i9 고급형이 모두에게 대여되었다. 램은 16GB, SSD는 1TB, GPU는 Radeon 5500M 4GB. 2020년 캠프 당시 기준 CTO 없이 주문 가능한 최고 사양 맥북 프로였다. 모니터는 2인당 1개씩 총 15개가 지급되었다. 모니터는 ThinkVision QHD 모니터. 모니터가 부족할 줄 알았는데 여유로웠다.

👨‍💻 도대체 (초보자에게) 좋은 코드가 뭔데?

// 우측 사이드바 활동 내역 로드
async function addActivityLogToActivityLogList() {
let activityLogList = document.getElementById('activity-log-list')
activityLogList.classList.add('activityLog')
activityLogList.innerHTML = ''
let userList = await api.User().getAllUsers()
userList.reverse()
console.log('현재 사용자는 [', userList.length, ']명 입니다.')
userList.forEach((user) => {
let activityLog = document.createElement('li')
activityLog.classList.add('activityLog')
let date = new Date(moment(user.created_at).format('YYYY-MM-DD HH:mm:ss'))
activityLog.innerText = user.userId + '는 ' + date + '에 가입했습니다.'
activityLogList.appendChild(activityLog)
})
}

이 코드의 원본은 여기에 있다.

어 — 이건 리뷰할 수 있는 상태가 아닌데. 누가 이거 썼어요? 얘기 좀 들어봅시다.

2번째 프로젝트가 끝난 7월 25일 금요일 오후 코드 리뷰 시간에 제비뽑기에 걸려 화면에 나타난 내 코드에 대한 피드백이다. 당시로선 극심한 시간의 압박을 잘 극복해나가고 꽤 잘 동작하는 페이지를 만들었다고 생각했었다. 그런데 이런 직설적인 평가를 들으니 정신적인 충격이 가시질 않았다. 글로는 잘 나타나지 않았는데 무언가 정말 얼어붙는 분위기였다.

그날 집에 가는 기차 안에서도 수많은 생각이 들었다. 잠시 마음을 가라앉히고 생각해 보니 그래그래~ 우리 모두 잘했고 수고했어~라고 넘기는 캠프였다면 오히려 좋은 캠프가 아니었을 것이라는 생각이 들었다. 문제집도 틀리는 문제가 있어야 좋은 문제집이라고 하는 것처럼. 그래서 남은 한 달 동안 내가 나름 잘 할 수 있는 것을 최대한 활용하고, 배울 수 있는 내용을 최대한 흡수하기로 다짐했던 기억이 난다.

프로그래밍 공부에 관심이 있는 주니어라면 "클린 코드, 좋은 패턴"과 같은 이야기를 종종 듣는다. 다만 문제는 초보자의 입장에서는 이런 이야기를 기계적으로 너무 많이 듣다보니 무의식 중 외우듯이 반복하는 이야기일 뿐, 도대체 어느 정도가 좋은 것인지에 대한 사실적인 감각이 없다는 것이다. 다시 돌아가 위의 코드를 본다면,

  • 코드가 2가지 작업을 동시에 하고 있다. ① 정보를 받아오고 ② 정보를 표시하고 있다. 이럴 경우 코드의 의존성이 높아진다. 의존성이 높아지면 추후에 일부 코드를 교체해야 할 때 대수술을 진행하게 될 수 있다.
  • 전반적으로 해당 파일에 로직과 뷰가 섞여있으며 가독성이 떨어진다.

조언을 바탕으로 3번째 프로젝트부터는 이런 개발 패턴에 많은 신경을 썼다. 3번째 프로젝트 중 일부분을 뽑아 미니 프로젝트를 한 적이 있는데 아마 무슨 느낌인지 짐작할 수 있을 것 같다.

🛷 더닝 크루거 썰매장

약간 클리셰이기는 하지만, 우매함의 봉우리를 직접 경험할 수 있었다. 물론 내가 모든 것을 온전히 알고 있다는 생각은 한 적 없지만 자바스크립트로 이런 저런 프로젝트를 해본 경험이 있으니 "물론 노력해야겠지만, **어느 정도는 수월하게 따라갈 수도 있지 않을까?"**라고 감히 생각했었다.

JS 아는거 없 다 구 요. 전 그냥 말 하 는 감 자 라구요. 아시겠어요?

JS 아는거 없 다 구 요. 전 그냥 말 하 는 감 자 라구요. 아시겠어요?

당연하게도 우아한테크캠프는 엄청 어려웠다. 원래 커리큘럼의 목표는 각 프로젝트마다 제약 조건을 건 뒤 그 제약 조건으로 인한 아쉬움을 다음 프로젝트에서 해결하는 것이었다. 예를 들어 Passport를 사용하지 않고 인증을 구현해본 뒤 그다음 프로젝트에서 Passport를 사용하여 그 갈증을 해소하는 것이다. 하지만 반대로 이야기하면 이 과정이 1~2주마다 일어나기 때문에 이전 기술을 겨우 파악했을 때쯤 다음 기술로 곧바로 넘어가서 가파른 러닝 커브를 다시 경험해야 한다는 의미기도 했다.

우아한테크캠프에서 더닝 크루거의 썰매장을 경험한 것 같다. 나는 자바스크립트를 자유자재로 다룰 정도는 아니었기에 정말 열심히 따라가야 했다.

🌎 인터넷 시대에 아는 것이란?

또 검색이 존재하는 시대에 아는 것이란 도대체 무엇인가에 대한 고민도 많이 하게 되었다. 이 부분에 대해 프로그래밍에 한정한다면 약간의 답을 찾은 것 같다. GSPH라는 개념인데, Googling Session Per Hour의 약자이다. Googling Session이란 5분 이상의 깊은 검색 작업을 의미한다. 예를 들어 자바스크립트 속성 함수 이름이 기억이 나지 않아 검색을 2분 만에 마쳤다면 Googling Session에 해당하지 않지만 OAuth가 잘 생각나지 않아 10분간 도큐먼트를 봐야 한다면 Googling Session에 해당한다.

검색해봐... 근데 매번?

검색해봐... 근데 매번?

어느 작업을 할 때 1시간에 Googling Session이 (대략) 3회 이하라면 그 개념을 안다고 할 수 있는 것 같다. 즉 작업을 할 때 중간중간 짧은 검색을 하는 것은 그 개념을 모른다는 뜻으로 직결되지 않는다. 하지만 작업의 모든 디테일을 일일이 다 찾아봐야 한다면 아직은 공부가 더 필요하다는 뜻이다.

👾 라이브러리 ≠ 외계 기술

프레임워크와 라이브러리를 외계인의 기술처럼 대하는 경우가 종종 있다. 물론 잘 알려진 프레임워크와 라이브러리는 검증된 효율적인 코드들의 집합이지만, 다가갈 수 없는 에일리언 테크놀로지라고 생각해버리고 모든 고민을 라이브러리에 위임하는 접근 방식은 조금 위험할 수 있다.

npm i를 하는 개발자의 모습이다

npm i를 하는 개발자의 모습이다

특히 웹 라이브러리들의 기반 기술은 우리도 쓸 수 있는 Plain JavaScript이다. 무작정 외부 라이브러리에 의존하는 것이 아니라 그 라이브러리가 어떻게 동작하는지, 잠재적인 위험 요소는 무엇이 있는지를 알고 써야 한다는 점이 캠프 내내 지속적으로 강조되었다. 즉 부득이한 경우 비슷한 형태로 라이브러리를 구현 가능할 정도로 세심하게 공부해야 한다.

라이브러리는 프로토스👽 기술이 아니라 테란🧑‍🔧 기술이다.

일례로 2016년 일어난 left-pad 사건이 있다. left-pad라는 11줄짜리 라이브러리가 npm에서 삭제되었고 이로 인해 의존 관계가 도미노처럼 무너지며 babel이라는 트랜스 파일러가 사용 불가능해진 것이다. 생각해보면 이 사건도 금방 작성할 간단한 코드에 과하게 의존하는 바람에 발생한 문제 아닌가?

취미 개발자의 입장이라면 "엥? babel 그거 수십만 명이 사용하는 정말 믿음직한 라이브러리잖아, 그거 신경 쓸 시간에 내 코드 안전성이나 신경 써야지"라고 생각할 수 있겠지만 30분만 서비스가 다운되어도 엄청난 금전적인 손실을 겪는 기업의 입장에서는 이런 고민은 필수적이라는 뜻이다. 즉 라이브러리는 알 수 없는 외계인의 기술도 아니고, 우리가 기우제를 지낼 대상도 아니고, 마찬가지로 언제든지 손상될 수 있는 서비스라는 점을 염두에 두어야 한다.


🥳 재미있었던 일들

🧩 배민 이미지 서버 크롤링하기

마지막 B마트 서비스를 제작할 때 배민의 B마트 자료가 엄청나게 많이 필요한 일이 있었다. 사진 칸에 넣을 사진이 있어야 앱 느낌이 날 테니 말이다. 예전의 경험을 살려 배민 서버에 있는 이미지 리소스를 (관계자 님의 허락을 받고) 긁었다.

배민 B마트 이미지 서버에서 슬쩍한 사진

배민 B마트 이미지 서버에서 슬쩍한 사진

엄밀하게 말하면 이미지들은 CDN 오픈웹에 존재하는 형태이기 때문에 배민 서버 해킹은 아니다. 문제는 이 엔드 포인트들과 뒤의 이미지 주솟값이 알 수 없게 숨겨져 있다는 것이다.

http://CDN도메인.baemin.com/무슨/무슨/1abcde23-아주긴-알파뉴메릭-주소.jpg

최종 이미지 CDN URI는 대충 이렇게 생겼고 접속하면 이미지가 나온다.

단순하게 B마트 웹뷰를 띄워서 CSS 셀렉터를 쓰는 얕은 수준의 크롤링도 아니었고 우테캠이라고 리소스 서버 내부 자료를 공유 받은 것도 아니어서 꽤나 노력이 들어갔다. 간단하게만 공유하자면 iOS 배민 앱 통신을 감청해서 엔드 포인트와 이미지 주소를 알아냈고 약간의 CTF를 통해서 이미지 주소 리스트를 알아낼 수 있었다. 해당 이미지 서버에 있는 이미지와 아이콘, 효과음 등을 1,000건 정도 긁어서 다른 우테캠 캠프생 분들이 쓸 수 있도록 프라이빗 저장소에 공유했다.

크롤링된 모습

크롤링된 모습

🏢 소용돌이 기업야사

우리나라 기업들의 비하인드스토리를 중간중간 들을 수 있었다. 모 게임 기업에서는 아이템이 수십만 개씩 양산되기에 RDB를 잘 쓰지 않고 바이너리 덤프를 사용한다는 이야기, 누군가 게임 DB 필드를 조작해 몇억 원짜리 아이템을 복제한 사건이 발생해 이후부터 DB 접근 권한 관리가 매우 철저해졌다는 이야기, 모 숙박 업체에서 개발자들이 모든 회원들의 개인 정보를 열람할 수 있어서 한동안 개발자들이 마음대로 연예인 회원 정보를 열람했었다는 이야기, 모 검색 업체 의장이 "경쟁 업체보다 1초 빠르게 만들어"라고 지시했었다는 이야기... 기업 생태계에 관심이 많은 나에게는 정말 재미있는 이야기였다.

옆동네 N사 C재단의 B캠프가 터진 날

옆동네 N사 C재단의 B캠프가 터진 날

⚡️ 시너지 x 시너지 = 시너지3

그럼에도 불구하고 최고의 장점은 다른 우아한테크캠프생 분들을 만난 점이라고 생각한다. 최고의 복지는 훌륭한 동료들이라는 말이 무슨 느낌인지 살짝 이해한 것 같았다. 가장 대표적으로 jhaemin님을 이야기해보고 싶다.

우테캠은 모든 프로젝트를 시작할 때 디자인 시안 및 기획서를 준다. 다만 그 내용은 권고사항일 뿐이고 실제 구현은 자유롭게 할 수 있다. 즉, 디자인을 고쳐서 사용성과 심미성을 개선하는 것은 순전히 캠프생들의 몫이다. 처음에는 이 디자인이 의무적으로 따라야 하는 디자인이라고 생각했지만 그렇지 않았다. 결국 좋은 동료는 일을 찾아서 하는 사람이라는 것을 넌지시 암시하기라도 하듯 모든 것은 자유롭게 열려있었다. 디자인을 개선하든, 기능을 추가하든, 아니면 반대로 무언가를 삭제하든, 큰 힘이 주어지고 큰 책임감을 지는 것이었다.

프론트엔드 개발자의 디자인 정신, 이 부분에 있어서는 같이 캠프생이었던 jhaemin님께 정말 많은 영향을 받았다. 자신만의 확고한 디자인 체계로 사용성 좋은 웹앱을 뚝딱 만드시는 것을 보며 많이 배우게 되었다. 내가 정말 충격을 받았던 사이트 2개를 직접 본다면 무슨 말인지 알게 될 것이다.

영향을 많이 받아 나도 3번째 뱅크샐러드 프로젝트부터 디자인을 개선해보았다. 이렇게 디자인을 온전히 수정했다 ↓

샘플로 주어진 가계부 디자인 시안. 이 시안은 시작점일 뿐, 여기에서 창의력을 발휘하는 것은 온전히 캠프생들의 몫이다.

샘플로 주어진 가계부 디자인 시안. 이 시안은 시작점일 뿐, 여기에서 창의력을 발휘하는 것은 온전히 캠프생들의 몫이다.

디자인이 생각보다 재미있는 일이라는 것을 알게 됐다 (나름 만족)

디자인이 생각보다 재미있는 일이라는 것을 알게 됐다 (나름 만족)

내가 느끼기에 내 디자인의 장점은 ① 화면 요소를 3열로 나누어 배치해서 와이드 스크린의 장점을 적극 활용한다는 점과 ② 우측의 활동 내역 창이 독립적으로 동작하기 때문에 자유롭게 달력, 통계, 결제 수단 관리로 이동해도 우측창의 내용이 유지된다는 점이다. 슬랙이랑 비슷한 느낌?

이 외에도 바닐라 자바스크립트로 유사 React를 만들어버리신 naamoonoo님, 주말동안 Elastic Search를 완성해오신 pigrabbit님, React를 숨 쉬듯 편안하게 다루시던 dnacu님, JS만으로 SPA 구조와 싱글톤 패턴을 뚝딱 구현하신 younho9님, 데이터 접근 전략을 체계적으로 구현하신 0407chan님, 밤새 디자인 구조를 싹 끝내신 Jenny 님 등... 여기에 다 적지 못할 정도로 정말 배울 것이 넘쳐났다.


🎬 기타 및 결론

  • Git과 GitHub으로 심화된 협업을 처음 해봤다. 1인 개발을 할 때는 git의 branch checkout 기능을 제대로 활용할 일이 많지 않다. Git 협업에 있어서는 GSPH < 3으로 정말 잘 배운 것 같다.
  • 중간 중간 강의들이 정말 좋았다. 매주 수요일마다 있던 강의들에서 배민 서비스의 개발과 운영에 대한 흥미로운 점들이 보였다. 특히 김민태 개발자님의 강의가 정말 인상적이었다.
  • 회사 생활을 간접 경험해볼 수 있어서 정말 좋았다. 21살에 회사 생활을 경험해볼 수 있다니!
  • 친절하게 알려주는 형 누나들이 이해심이 정말 깊었다. 나를 배려해준다는 것이 느껴졌었다. 중간 중간 레크리레이션 활동도 많았는데, 코로나19 때문에 레크리레이션 활동이 축소된 것이 아쉬웠다.
  • 정말 소중한 경험이었고 새로운 가르침을 많이 얻었다. 내가 기본기가 조금 더 출중했다면 훨씬 더 깊은 내용까지 공부할 수 있었을텐데, 그러지 못해 아쉬움이 약간 남는다. 앞으로의 여정에 있어 정말 좋은 자산이 된 것 같다.
  • 물론, 내가 곧장 완벽할 수는 없을 것이다. 나도 요즘 이상과 현실의 괴리를 조금씩 느끼고 있다. 그래도 우리의 이상향에 나침반을 맞추고 걸어나간다면 언젠가는 도달하지 않을까 — 하는 일말의 믿음이 있을 뿐이다 🧭
📚 더 많은 자료들

끝!

끝!
잠깐!
  • 이 글이 작성된지 3년 이상 지났습니다.
  • 새로운 일들이 일어나기 충분한 시간입니다.
  • 저는 이 글에 더 이상 동의하지 않을지도 모릅니다.
Google에서 새로운 자료 찾아보기

집계 중...

최근 경쟁률이 매우 높은 (합격률 5% 내외) 교내 기술창업 동아리를 지원하는데 다음과 같은 질문이 있었습니다.

여기서 meme이란 &#39;웃음을 유발하는 짧은 동영상&#39;과 같은 뜻입니다.

여기서 meme이란 '웃음을 유발하는 짧은 동영상'과 같은 뜻입니다.

반드시 들어가고 싶었던 동아리였기에 많은 고민이 들었습니다. 사람마다 관심사와 웃음 코드가 다르며 특정 그룹에게는 웃긴 내용이 다른 그룹에게는 불쾌한 내용으로 다가갈 수 있기 때문입니다.

그러다 "차라리 선택에 기반해 meme을 추천해주는 서비스를 만들면 어떨까?"하는 생각이 들었습니다. 반응형 추천 시스템(새로 누적되는 데이터에 기반해 추천 내용이 동적으로 변하는 시스템)이 아니기 때문에 기술적인 복잡도도 그렇게 높지 않을 것 같았습니다. 마침 주말이 있었기 때문에 빠르게 만들어보기로 결정했습니다.

🎬 시스템 기획하기

우선 제가 재밌게 본 동영상들을 리스트업했습니다. (틱톡, 트위터, 인스타그램 대신 유튜브를 사용해도 상관 없을 것이라 판단했습니다.)

단톡방에서 youtu 키워드로 검색해서 제가 웃기다고 생각한 동영상들을 쉽게 찾을 수 있었습니다.

단톡방에서 youtu 키워드로 검색해서 제가 웃기다고 생각한 동영상들을 쉽게 찾을 수 있었습니다.

이후 Notion 페이지에서 카테고리별로 분류했습니다. 음악, 영화, 게임, 코딩, 일반적 Meme으로 나눌 수 있었습니다.

선택 기반 추천 테스트를 구상하다 보니 2가지 접근 방법이 있는 것 같았습니다. 하나는 질문의 답변마다 가중치를 주고, 최종 점수를 계산하여 결과를 추천해주는 시스템입니다. 나머지 하나는 전체적인 시나리오 트리를 설정해두고 선택지의 조합에 따라 결과를 추천해주는 것입니다. 요즘 유행하는 MBTI 결과물들을 분석해본 결과 대개 첫 번째 점수 기반 추천 시스템을 사용했습니다. 저는 두 번째 시나리오 트리 기반 추천 시스템을 사용했습니다. 이유는 다음과 같습니다.

  • 점수 플래그 시스템을 구성하기 너무 복잡했습니다.
    • MBTI는 점수 플래그가 단순합니다. E/I, N/S, T/F, J/P 이렇게 4개의 플래그만 존재하기 때문에 점수 상태관리를 하기 비교적 편리합니다.
    • 저는 당장 음악, 영화, 게임, 코딩, 일반 이렇게 5개의 카테고리가 존재했고 각 카테고리마다 다양한 하위 카테고리가 존재했기 때문에 플래그를 잡기가 애매했습니다.
    • 예를 들어 단순히 게임 플래그 점수가 높다고 해서 무작정 롤 Meme 영상을 추천해줄 수는 없습니다. 롤의 규칙을 모르거나 그 웃음대에 공감하지 못할 수 있기 때문입니다. 즉 이 문제를 해결하려면 롤 점수 플래그를 추가하거나 "제일 좋아하는 게임" 플래그를 따로 지정해두어야 합니다.
    • 이 경우 상태 관리가 무척 복잡해지며 그렇게까지 기술적 난이도를 높이고 싶지 않았습니다.
    • 하지만 높아지는 기술적 난이도보다 더 올라가는 것은 기획적 난이도입니다. 무엇보다도 영상이 플래그마다 어느 점수 영역대에 추천되어야 하는지 정교하게 기획하기 매우 어렵다고 느꼈습니다. 즉, 점수 기반으로는 '취향저격'하기가 어렵습니다.
  • 모든 엔딩 보기가 가능하게 만들고 싶었습니다.
    • 선택 기반 게임에서는 마지막 결정 하나만 변경해서 다른 엔딩을 보고 싶을 수 있습니다 (특히 단순한 MBTI가 아닌 이런 Meme 추천은 더욱이 그럴 수 있습니다).
    • 하지만 점수 기반 시스템은 대개 테스트를 처음부터 다시 시작해야하며 선택 undo 액션을 추가하려면 더 많은 엔지니어링이 투입되어야 합니다.
    • 시나리오 트리 기반을 활용한다면 이 부분이 더 편리해집니다. Parent Node로 이동하면 되기 때문입니다.
    • 후술되겠지만, 저의 경우 Next Link를 활용했기 때문에 브라우저에서 뒤로 가는 것만으로 액션 undo가 됩니다.
  • 일반적인 선택지 어휘가 아닌 큐레이팅된 선택지 어휘를 넣고 싶었습니다.
    • 점수 기반 시스템에서는 굉장히 일반적인 형태의 질문과 답변만 하게 됩니다. 즉 follow-up 질문을 하지 못합니다.
    • 저는 시나리오 트리를 활용해 질문과 답변이 서로 정확하게 들어맞도록 만들어 저와 대화하는 듯한 경험을 주기 위해 노력했습니다.
    • 또한 결과적으로 이 시스템은 "동아리 지원서에 첨부될" 목적입니다.
    • 아무리 재미있는 영상을 추천해준다고 하더라도 제 이름을 기억하지 못하고 영상만 기억하게 된다면 본말이 전도된 것입니다.

그 과정에서 나는 이 동아리에 꼭 들어가고 싶다!!!라는 느낌을 뿜뿜하게 주고 싶었습니다.

그 과정에서 나는 이 동아리에 꼭 들어가고 싶다!!!라는 느낌을 뿜뿜하게 주고 싶었습니다.

🥞 기술 스택 결정하기

프론트엔드에 있어서는 별다른 고민을 하지 않았습니다. 최근 TypeScript Next와 사랑에 빠졌기 때문에 자연스럽게 선택하게 되었고 Vercel과 Next의 호환성을 알기에 Vercel에 호스팅하기로 결정했습니다. 스타일은 styled-component를 사용했습니다.

데이터를 어디에 저장할지에 대한 부분이 문제였습니다. Meme에 대한 데이터는 동적인 데이터가 아니고, 유저 정보는 저장할 일이 없다고 판단했기에 DB 또는 DBaaS를 따로 사용하는 대신 모든 데이터를 모듈화하여 하드코딩하기로 결정했습니다. 여기에서 하드코딩된 정보들을 보실 수 있습니다.

백엔드 또한 마찬가지로 구성할 필요가 없었습니다. 서버리스한 형태로 만들기로 했습니다.

💻 개발하기

다음과 같이 요약할 수 있습니다.

  1. 질문은 질문마다, 동영상은 동영상마다 고유 링크를 가지며, 선택지를 고르면 그 링크로 접속하는 것입니다.
  2. 각 선택지는 '다음 질문' 또는 '결과 동영상' 필드를 가진 Object 형태이며 이를 기반으로 인터페이스를 구성합니다.
  3. getStaticPropsgetStaticPaths를 사용해 반응 속도를 초고속으로 만듭니다.

🔗 1. 고유 링크 구조

각 질문과 동영상은 다음과 같은 URI 구조를 가집니다.

https://smile.cho.sh/question/[id]
https://smile.cho.sh/video/[id]

예시:

💬 2. 타입 구조

TypeScript의 장점을 활용하여 type 구조를 미리 정의했습니다.

export type Question = {
id: number
contents: string[]
answers: Answer[]
}

export type Answer = {
id: number
content: string
result: number | null
nextQuestion: number | null
}

export type Video = {
id: number
title: string
uploader: string
desc: string
youtubeId: string
}

type Answer에서 resultnextQuestion은 둘 중 한 쪽만 값을 가질 수 있습니다. 이를 바탕으로 링크를 생성합니다. 이렇게 2가지로 별도의 필드를 통해 questionvideo를 혼동하는 실수를 방지할 수 있었습니다. 또한 데이터를 작성할 때 기본값을 0으로 두어 의도치 않은 null 오류를 방지하고자 했습니다. 그 흔적은 /question/0에서 확인하실 수 있습니다.

🚀 3. 초고속화

예를 들어 /question/[id]에 해당하는 페이지들은 다음 코드를 통해 빌드 타임에 정적으로 생성됩니다.

export const getStaticPaths: GetStaticPaths = async () => {
const paths = questionData.map((question) => ({
params: { id: question.id.toString() },
}))
return { paths, fallback: false }
}

export const getStaticProps: GetStaticProps = async ({ params }) => {
try {
const id = params?.id
const item = questionData.find((data) => data.id === Number(id))
return { props: { item } }
} catch (err) {
return { props: { errors: err.message } }
}
}

여기서 getStaticPaths는 정적으로 생성되어야 할 페이지들의 path들의 리스트를 정해주며, getStaticPropspath와 일치하는 질문 데이터를 검색해서 React App에 props 형태로 전달합니다. 이를 통해 모든 질문과 동영상 페이지를 정적으로 미리 생성해둘 수 있습니다. 더 나아가 next/link<Link>까지 조합하여 활용한다면 페이지들을 prefetch해올 수 있기 때문에 인터랙션을 초고속으로 만들 수 있습니다. (말 그대로, 브라우저 패비콘에서 로딩 도는 것도 보이지 않습니다!)

💅 4. 스타일링 및 디테일 잡기

다시 말해서 인트로 페이지와 엔딩 페이지를 만들고, 빼먹은 디테일을 추가하는 일입니다. 예를 들어 특수한 경우를 위해 다른 형태의 View를 처리하는 작업을 했습니다. 사용자가 모든 질문에 대해서 잘 모른다고 답할 경우 다음과 같은 결과를 보여줍니다. 다른 뷰들은 동영상을 바로 embed하는데 반해 이 경우에만 버튼의 형태로 보여주었습니다.

Fallback Video

무슨 영상인지는 직접 확인해보세요!

✨ 결과

  • smile.cho.sh
  • 직접 사용해보시고 의견을 알려주세요!
  • 동아리에 최종 합격했습니다!

🔥 회고

  • 기획적 난이도와 기술적 난이도 사이에서 적당히 밸런스를 잘 맞춘 것 같습니다.
  • ES6+map 함수를 정말 자주, 잘 사용한 것 같습니다!
  • Static TypeScript Next를 활용하는 방법에 대해서 잘 알게 된 것 같습니다.
  • Favicon, Metadata, SEO 등에 신경을 쓰지 못한 것은 조금 아쉽지만 검색이나 SNS 유입이 필요하지 않아 따로 추가하지는 않을 것 같습니다.
  • 주말 동안 갈아넣으니까 완성되긴 하는구나...라는 생각을 했습니다 😉
잠깐!
  • 이 글이 작성된지 3년 이상 지났습니다.
  • 새로운 일들이 일어나기 충분한 시간입니다.
  • 저는 이 글에 더 이상 동의하지 않을지도 모릅니다.
Google에서 새로운 자료 찾아보기

집계 중...

다른 라이브러리 없이 Vanilla JS만으로 달력을 작성해보자. 우아한테크캠프 당시 3번째 프로젝트 "뱅크샐러드 클론 만들기"에서 내가 담당했던 파트이다. 복습하고 정리하는 차원에서 다시 작성해보기로 했다.

완성본 미리보기

목표

  • DOM 생성 이후 DOM 조작하지 않기. 즉 모든 작업은 페이지를 생성하는 시점에서 끝내기. document.querySelector와 같은 DOM API를 사용하고자 않고자 하는 것이다. 이는 "이쪽에서 A로 조작하고 저쪽에서 B로 조작하고 또 반대편에서 C로 조작하고 순서 꼬이고 코드 엉키고" 같은 현상을 방지하기 위함이다. 단, 처음 앱을 선택하여 initialize할 때만 document.querySelector('#App')을 사용한다.
  • OOP 자바스크립트 대신 작은 함수들로 작성하기

사용할 스택

  • Date Object (Vanilla JS)
  • Display: Grid (CSS)

1. index.html 작성하기

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Calendar + Grid</title>
</head>

<body>
<div id="App"></div>
</body>
</html>

VS Code의 보일러 플레이트를 사용했다.

2. calendar.js 작성하기

우선 index.htmlhead 태그 안에 코드를 연결하자.

<script src="calendar.js"></script>

2-1. calendar.js에 사용할 util 함수 작성하기

html string highlighting을 위한 html 함수를 추가한다. 이 함수를 추가하고 backtick으로 감싸진 JS String 앞에 html 글자를 추가하면 JS String을 html처럼 하이라이팅하여 사용할 수 있다.

const html = (s, ...args) => s.map((ss, i) => `${ss}${args[i] || ''}`).join('')

매직넘버를 없애기 위해 const들을 추가해주었다. 코드 중 갑자기 7이 튀어나오면 어느 맥락의 7인지 알기 어렵기 때문이다.

const NUMBER_OF_DAYS_IN_WEEK = 7
const NAME_OF_DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']

가장 기초가 될 renderCalendar를 작성해주었다. 또한 renderCalendar를 기존 index.html 최하단에 연결해주었다.

const renderCalendar = ($target) => {
$target.innerHTML = getCalendarHTML()
}
<script>
renderCalendar(document.querySelector('#App'))
</script>

달력을 그리기 위해서 총 4개의 Date 객체가 필요했다. 물론 더 적은 Date 객체로 처리할 수 있다. 달력에 구현할 기능들에 따라 필요한 Date 객체의 개수가 달라진다.

  • 지난 달 마지막 날: 달력에 지난 달을 그릴 때 일요일을 하이라이트하기 위해 필요하다.
  • 이번 달 첫 날: 이번 달의 토요일과 일요일의 파악하여 하이라이트 하기 위해 필요하다. 또한 이번 달 첫 날의 요일을 통해 지난 달 마지막 주를 달력에 표시할 때 필요하다.
  • 이번 달 마지막 날: 이번 달의 달력을 for statement로 그릴 때 필요하다.
  • 다음 달 첫 날: 달력에 다음달을 그릴 때 토요일을 하이라이트하기 위해 필요하다.

이 4개의 날짜를 object로 묶어 return해주는 함수를 만들었다. argument로 Date 객체 1개를 받으며 이 달력에서는 "오늘"에 해당하는 Date 객체를 넣어줄 것이다.

const processDate = (day) => {
const date = day.getDate()
const month = day.getMonth()
const year = day.getFullYear()
return {
lastMonthLastDate: new Date(year, month, 0),
thisMonthFirstDate: new Date(year, month, 1),
thisMonthLastDate: new Date(year, month + 1, 0),
nextMonthFirstDate: new Date(year, month + 1, 1),
}
}

2-2. getCalendarHTML 만들기

이제 본격적으로 달력을 그려보자. 달력에 들어갈 내용을 HTML로 반환해주는 getCalendarHTML 함수를 만들었다. getCalendarHTML 함수는 조금 거대해서 먼저 틀을 잡아주었다.

const getCalendarHTML = () => {
let today = new Date()
let { lastMonthLastDate, thisMonthFirstDate, thisMonthLastDate, nextMonthFirstDate } = processDate(today)
let calendarContents = []

// ...

return calendarContents.join('')
}

맨 위에 요일을 표시할 줄을 추가하자. 처음에 추가한 const를 사용해서 매직넘버를 제거한다.

for (let d = 0; d < NUMBER_OF_DAYS_IN_WEEK; d++) {
calendarContents.push(html`<div class="${NAME_OF_DAYS[d]} calendar-cell">${NAME_OF_DAYS[d]}</div>`)
}

그 다음 지난 달을 그리자. 예를 들어 이번 달의 첫 날이 수요일이라면 일요일, 월요일, 화요일에 해당하는 지난 달을 그려주는 역할이다. 일요일에 해당하는 날은 sun HTML Class를 추가해준다.

for (let d = 0; d < thisMonthFirstDate.getDay(); d++) {
calendarContents.push(
html`<div
class="
${d % 7 === 0 ? 'sun' : ''}
calendar-cell
past-month
"
>
${lastMonthLastDate.getMonth() + 1}/${lastMonthLastDate.getDate() - thisMonthFirstDate.getDay() + d}
</div>`
)
}

비슷한 원리로 이번 달을 그리자. 오늘에 해당하는 날은 today HTML Class와 " today" String을 추가해준다. 마찬가지로 토요일과 일요일에는 각각 satsun HTML Class를 추가해준다.

for (let d = 0; d < thisMonthLastDate.getDate(); d++) {
calendarContents.push(
html`<div
class="
${today.getDate() === d + 1 ? 'today' : ''}
${(thisMonthFirstDate.getDay() + d) % 7 === 0 ? 'sun' : ''}
${(thisMonthFirstDate.getDay() + d) % 7 === 6 ? 'sat' : ''}
calendar-cell
this-month
"
>
${d + 1} ${today.getDate() === d + 1 ? ' today' : ''}
</div>`
)
}

마지막으로 남은 칸들에 다음 달의 날짜들을 그려준다.

let nextMonthDaysToRender = 7 - (calendarContents.length % 7)

for (let d = 0; d < nextMonthDaysToRender; d++) {
calendarContents.push(
html`<div
class="
${(nextMonthFirstDate.getDay() + d) % 7 === 6 ? 'sat' : ''}
calendar-cell
next-month
"
>
${nextMonthFirstDate.getMonth() + 1}/${d + 1}
</div>`
)
}

3. CSS 작성하기

3-1. display: grid 이용하기

하나의 element에 display: grid를 사용하면 그 자식 element들을 그리드(표) 안에 깔끔하게 넣을 수 있다.

  • grid-template-columns: column을 어떻게 배치시킬지에 대한 정보. 1fr1 fraction이라는 뜻으로 여기서는 총 7번 작성했으니 너비가 동일한 7개의 column이 생성된다.
  • grid-template-rows: row의 크기를 정의해줄 수 있다. 여기서는 3rem 하나만 있으니 첫번째 row를 3rem이라고 정의한 것이다.
  • grid-auto-rows: 이후의 row를 크기를 정의해줄 수 있다. 여기서는 6rem이라고 되어 있으니 이후의 모든 row는 row 크기가 6rem인 것이다.

아래에는 추가적인 스타일을 정의해주었다.

#App {
/* grid */
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
grid-template-rows: 3rem;
grid-auto-rows: 6rem;

/* style */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
border: 1px solid black;
max-width: 720px;
margin-left: auto;
margin-right: auto;
}
  • 표 등을 그릴 때 마치 엑셀처럼 모든 칸을 균일한 테두리로 감싸고 싶은데 가장 바깥쪽의 셀들만 선이 얇은 경우가 있다. HTML로 따지자면 thtd에만 border를 적용한 상태다.
  • 나는 이것을 "모든 셀들 테두리에 n px, 표 테두리에 n px" border를 적용하는 것을 선호한다. 이렇게 하면 전체적으로 2n px의 균일한 테두리가 생긴다.
.calendar-cell {
border: 1px solid black; /* #App에 적용한 border와 함께 2px의 균일한 테두리가 생긴다.*/
padding: 0.5rem;
}

3-2. 토요일과 일요일, 오늘 하이라이팅

.past-month,
.next-month {
color: gray;
}

.sun {
color: red;
}

.sat {
color: blue;
}

.past-month.sun {
color: pink;
}

.next-month.sat {
color: lightblue;
}

.today {
color: #e5732f;
}

느낀 점

  • 처음에 JS와 연결하여 달력을 "initialize"할 때 살짝 헤맸다. renderCalendarbody 상단에 연결했기 때문이다. DOM은 순차적으로 실행되기 때문에 상단에 연결할 경우 #App div가 나타나지 않았을 때 renderCalendar가 실행되어 DOM element를 찾지 못한다.
  • 또 JS의 연관 관계로 표현될 수 있는 코드들을 어떻게 화면에 렌더링하는지 기억이 잘 나지 않았다. 단순히 가장 첫 index.js 역할을 하는 js에서 앱을 querySelect한 후 innerHTML로 넣어주는 것이었다.
  • 우아한테크캠프 프로젝트에서는 매직 넘버를 사용했었다. 이번에는 매직 넘버를 제거하여 가독성을 향상시켰다.
  • 우아한테크캠프 프로젝트는 Object Oriented한 자바스크립트(더 정확하게는 Singleton 패턴)로 작성되어 있었는데, 이번에는 작은 함수들로 쪼개서 작성했다.
  • ES6+ 문법을 사용하기 위해 노력했다. 예를 들어 백틱에 변수를 넣거나 processDate의 반환 데이터를 destructuring해서 사용했다. 또 let과 const를 주로 사용했다.
  • getCalendarHTML를 조금 더 짧게 작성할 수 없었는지 아쉬움이 남는다.
잠깐!
  • 이 글이 작성된지 3년 이상 지났습니다.
  • 새로운 일들이 일어나기 충분한 시간입니다.
  • 저는 이 글에 더 이상 동의하지 않을지도 모릅니다.
Google에서 새로운 자료 찾아보기

집계 중...

Beware

이 도구를 사용하여 타인의 지적 재산권을 침해하지 마십시오. 이 코드와 The Noun Project API가 자신의 사용 용도에 적합한지 확인한 후에 사용하십시오. 또한 라이선스와 API 문서를 꼼꼼하게 검토하십시오. The Noun Project에서 허가하지 않는 사용 용도들은 여기에서 확인하실 수 있습니다. 또한 이 글과 이 글의 모든 코드는 MIT 라이선스임을 알려드립니다.

라이브러리 불러오기

import requests
import os
from tqdm import tqdm
from requests_oauthlib import OAuth1

이 라이브러리들이 없다면 pip3 download 하여 사용하면 된다.

download 함수

def download(url, pathname):
if not os.path.isdir(pathname):
os.makedirs(pathname)
response = requests.get(url, stream=True)
file_size = int(response.headers.get("Content-Length", 0))
filename = os.path.join(pathname, url.split("/")[-1])
if filename.find("?") > 0:
filename = filename.split("?")[0]
progress = tqdm(
response.iter_content(256),
f"Downloading {filename}",
total=file_size,
unit="B",
unit_scale=True,
unit_divisor=1024,
)
with open(filename, "wb") as f:
for data in progress:
f.write(data)
progress.update(len(data))

이 코드는 URL의 데이터를 불러와 pathname에 저장하는 역할을 한다.

The Noun Project API

# ---

DOWNLOAD_ITERATION = 3
# 1번에 아이콘을 50개씩 불러온다.
# 3번 실행하면 아이콘 150개를 불러온다.

SEARCH_KEY = "tree" # 검색어
SAVE_LOCATION = "./icons" # 저장할 위치
auth = OAuth1("API_KEY", "API_SECRET")

# ---

for iteration in range(DOWNLOAD_ITERATION):
endpoint = (
"http://api.thenounproject.com/icons/"
+ SEARCH_KEY
+ "?offset="
+ str(iteration * 50)
)
response = requests.get(endpoint, auth=auth).json()
for icon in response["icons"]:
download(icon["preview_url"], SAVE_LOCATION)

보다 세부적인 기능은 이 문서를 참고하면 된다. API Key와 API Secret은 여기에서 App을 등록하면 발급할 수 있다.

결과

테스트를 해보았을 때 아이콘 5,000개 정도까지는 가뿐하게 다운로드할 수 있었다.

테스트를 해보았을 때 아이콘 5,000개 정도까지는 가뿐하게 다운로드할 수 있었다.

다만 The Noun Project API는 API 호출 횟수에 제한이 있으니 이를 염두에 두고 활용하면 좋을 것 같다.

잠깐!
  • 이 글이 작성된지 3년 이상 지났습니다.
  • 새로운 일들이 일어나기 충분한 시간입니다.
  • 저는 이 글에 더 이상 동의하지 않을지도 모릅니다.
Google에서 새로운 자료 찾아보기

집계 중...
  • 이 글은 개인적인 분석이며 YouTube가 소스 코드를 공개하기 전까지는 이 글을 검증할 방법이 없습니다. 이 점 유념하시고 글을 읽어주시기 바랍니다.

한국 가수가 한국 방송에서 한국 노래 부르는데 한국어 댓글만 없는 묘한 동영상 플랫폼 YouTube

한국 가수가 한국 방송에서 한국 노래 부르는데 한국어 댓글만 없는 묘한 동영상 플랫폼 YouTube

YouTube는 언어를 기준으로 댓글을 보는 기능이 없다. 이 글은 그 불편함을 조금이나마 해소하기 위해 개발한 "YouTube 댓글 언어 필터"의 알파, 베타, 그리고 정식 출시 버전까지의 개발 이야기를 담았다.

anaclumos/youtube-comment-language-filter


0. 구상

하루는 불편함을 느끼던 와중 호기심이 들어 YouTube의 댓글 HTML이 어떻게 묶여있는지 뜯어보았다. console에서 이런저런 값들을 입력해보다가 한글이 포함된 댓글만 남기고 싹 지워주는 자바스크립트 파일을 만들었다. 과거 한글과 유니코드 규칙에 관련된 한글깨기.py라는 프로젝트를 진행했던 경험이 도움이 됐다.

🧼 초기 필터 스크립트. YouTube 댓글에서 한글이 포함되어 있지 않으면 그 댓글을 지워버리는 원리이다. GitHub 저장소의 첫 커밋에도 저장되어 있다.

var commentList = document.getElementsByTagName('ytd-comment-thread-renderer')
var comment

function containsUnicode(str, startUnicode, endUnicode) {
for (var i = 0; i < str.length; i++) {
if (startUnicode.charCodeAt(0) <= str.charCodeAt(i) && str.charCodeAt(i) <= endUnicode.charCodeAt(0)) {
return true
}
}
return false
}

for (var x = 0; x < commentList.length; x++) {
comment = commentList[x].childNodes[1].childNodes[1].childNodes[3].childNodes[3].innerText
if (containsUnicode(comment, '가', '힣')) {
// comment = "한글임 \n" + comment;
} else {
// console.log(typeof commentList[x]);
commentList[x].parentNode.removeChild(commentList[x])
x--
}
}

for (var x = 0; x < commentList.length; x++) {
console.log(commentList[x].childNodes[1].childNodes[1].childNodes[3].childNodes[1].innerText)
// 작성자 이름과 작성날짜가 같이 묶여있다. "이름\n작성날짜"

console.log(commentList[x].childNodes[1].childNodes[1].childNodes[3].childNodes[3].innerText)
// 댓글!
}
// End of code

/* 참고: 이 코드는 초기 코드이며, 일부 경우에서는 정상 동작하지만
* 여러 에러가 발생하며 성능 저하 문제가 있었다.
* 디버깅과 성능 개선 작업이 진행된 코드는 GitHub에 업로드되어 있다.
*/

이때 이 코드를 프로젝트로 키울 수 있겠다고 생각했다. 하지만 이 스크립트는 몇 가지 문제점이 있었다.

  • 우선 콘솔에서 코드를 붙여 넣어야 하니 다른 사람들이 쓰기 어렵고 불편했다.
  • 자동적으로 실행되지 않았기에 댓글이 새로 로딩되면 콘솔에서 다시 실행해야 했다.
  • 한글만 걸러낼 수 있다는 점에서 용도가 무척 제한적이었고, 실행 속도가 느렸다.

이 문제점들을 해결하는 것이 자연스러운 개발 목표가 되었다. 우선 크롬 익스텐션을 만드는 것이 가장 편리할 것이라 생각했다.

  • 엣지나 웨일도 크로미움 기반이며 파이어폭스 애드온도 크롬 익스텐션으로 만들 수 있다.
  • 사파리는 YouTube 4K 동영상 재생이 안 되기 때문에 사파리 사용자들도 YouTube를 볼 때 크로미움을 쓰는 경우가 많다.
  • 인터넷 익스플로러는 YouTube 지원이 끊겼다.

또한 컴퓨터를 잘 모르는 사람들도 사용하기 편하려면 "YouTube의 기능인 것처럼" 동영상과 댓글 사이 위치해 있어야 한다고 생각했다. 그래야만 사용자가 마우스 커서를 동영상에서 댓글로 옮겨가면서 자연스럽게 사용할 수 있다. 그래서 크롬 익스텐션에는 별다른 기능이 없고 YouTube 화면 자체에 직접 메뉴를 삽입하도록 만들고 싶었다.

우리 중에 스파이가 있어..

우리 중에 스파이가 있어..

이제 목표가 구체화되었다.

  • 이 크롬 익스텐션은 동영상과 댓글 사이에 언어를 컨트롤하는 인터페이스를 삽입할 것이다.
  • 그 인터페이스를 통해 모든 댓글이 자동적으로 필터링할 수 있을 것이다. 댓글을 더 불러와도 계속 그대로 작동할 것이다.
  • 새로운 언어를 추가할 때 큰 어려움이 없도록 확장성을 갖춰야 한다.
  • 납득할 수 있을 정도로 빨라야 한다.

참고: versioning

버전 숫자를 부여하는 방식은 사람마다, 단체마다 다르다. 이 글에서는 다음과 같은 분류를 따른다.

  • 일의 자리는 메이저 업데이트를 뜻한다.
  • 첫 번째 소수점은 기능의 추가, 두 번째 소수점은 버그 수정을 뜻한다.
  • 세 번째 소수점은 코드 상의 수정 없이 스토어에 재업로드를 할 때 사용한다.
이름버전설명
알파0.1+최소한의 기능, 소수에게 배포/피드백
베타0.9+최초 구상 대부분 구현, 다수에게 배포/피드백
정식1+지속적 개선, 누구에게나 배포/피드백

1. 알파 버전은 최대한 빨리

알파 버전(더 정확하게는 Minimum Viable Product)을 만들기 위해서는 최소한의 기본적인 기능이 동작해야 한다. 더 구체적으로는 다음 목표가 달성되어야 했다.

  1. 댓글이 새로 로딩될 때마다 자동적으로 재실행되어야 한다.
  2. 언제든지 다시 전체 댓글을 볼 수 있어야 한다.
  3. 원클릭 설치를 할 수 있어야 한다.

가장 먼저 "어떻게 자동적으로 재실행되게 만들 것인가"를 고민했다.

방법 1. 시간 기반 자동 재실행

하지만 YouTube 동영상을 켜자마자 동영상만 감상하는 사람들에게는 성능의 낭비일 것 같았다. 그렇다고 재실행 간격이 너무 길면 댓글을 읽을 때 불편함이 생길 것이다. 가장 먼저 떠올렸지만 가장 먼저 접은 방법이다.

방법 2. YouTube의 로딩 아이콘 감지

YouTube의 로딩 아이콘

확인해보니 로딩 아이콘이 생길 때마다 댓글 아래에 <can-show-more> 태그가 나타났다 사라졌다. 즉 난독화된 YouTube의 자바스크립트 코드가 <can-show-more> 태그를 삽입하는 순간을 포착하여 같이 필터가 재실행되도록 하면 될 것 같다는 생각이 들었다.

하지만 난독화의 뜻이 무엇인지 까먹었기에 그런 발상을 했던 것 같다... 몇 시간의 사투 끝에 포기했다.

하지만 난독화의 뜻이 무엇인지 까먹었기에 그런 발상을 했던 것 같다... 몇 시간의 사투 끝에 포기했다.

방법 3. MutationObserver

이후 자바스크립트의 MutationObserver를 알게 되었다. 관찰 대상인 target과 관찰 조건인 config를 설정한 후 config에 맞는 변경 사항이 일어나면 callback 함수를 실행하는 것이었다. YouTube 댓글 HTML을 target으로 삼았고, HTML 내의 childNodesattributes의 변경에 반응하도록 했다. 원했던 대로 댓글이 로딩될 때마다 재실행되었다. 1번 해결.

하지만 성능이 크게 떨어졌다. 우선 console.log가 수만 번 실행된다는 것을 확인하고 이를 지우니 쓸만할 정도로 빨라졌다. 속도를 조금이나마 개선하기 위해 매번 댓글의 언어 테스트를 진행하는 대신 댓글을 display:none;으로 가리고 HTML 태그로 판정 결과를 기록해놓는 방법을 사용했다. 전체 댓글을 보는 버튼을 클릭하면 display:none;을 없애도록 했다. 그러다가 다시 재실행하면 앞서 기록해둔 태그만으로 판단했다. 2번 해결. (나중에야 알았지만 온전한 해결이 안 되었다. 이렇게 annotation을 활용하는 방법은 속도를 별로 개선하지 않았고 문제만 더 만들었다. 베타 버전 중 2번 YouTube의 SPA스러움 참고.)

이후 3일 동안 크롬 익스텐션의 구조에 대해서 배우면서 크롬 익스텐션의 틀 안에 기존 코드를 이식하는 작업을 했다. 크롬 익스텐션의 공식 문서, 스택 오버플로, 그리고 크롬 익스텐션에 대한 Udemy 강의를 주로 참고했다. 3번 해결.

알파 버전 완성

알파 버전의 사용자 리뷰. &quot;성능은 느리지만 일단 동작하긴 한다&quot;는 알파 버전의 특징을 정확하게 묘사한 리뷰이다.

알파 버전의 사용자 리뷰. "성능은 느리지만 일단 동작하긴 한다"는 알파 버전의 특징을 정확하게 묘사한 리뷰이다.

이렇게 2020년 3월 6일부터 개발을 시작해서 3월 11일에 첫 알파 버전 개발이 끝났다. 소규모의 테스터들에게 알파 버전을 공유했으며 약 일주일 동안 사용자 리뷰를 수집하면서 개선점과 버그를 연구했다.

알파 버전 당시 스크린샷. 알파 버전에는 한글/전체 댓글을 전환하는 버튼이 있었다.

알파 버전 당시 스크린샷. 알파 버전에는 한글/전체 댓글을 전환하는 버튼이 있었다.

2. 베타 버전

알파 버전에서 여러 문제점을 찾아 개선했다.

① YouTube의 Lazy Loading 문제

YouTube는 콘텐츠를 Lazy Load 한다. Lazy Loading은 모든 정보를 한 번에 불러오지 않고 기다리다, 정보가 필요할 때 정보를 불러오는 방법을 뜻한다. Lazy Loading은 용량이 큰 이미지에 주로 적용하지만, YouTube는 한 발 더 나아가 HTML 자체를 Lazy Load 했다! (정확한지는 모르겠지만 개발 당시 그렇게 확인했다.)

동영상 페이지에 처음 들어올 때 댓글 인터페이스에 해당하는 HTML은 존재하지 않는다. 사용자가 댓글을 보려 스크롤을 시작하면 그제서야 회색 로딩 아이콘이 보이면서 댓글 HTML을 불러온다. (왜? 그래야 댓글 DB 쿼리를 줄일 수 있기 때문에. 영리한 해결책 같았다.) 즉 익스텐션이 실행되는 document_end 시점에는 댓글 HTML이 존재하지 않기 때문에 익스텐션이 오류를 낸 뒤 곧바로 종료되었다.

YouTube의 Lazy Loading

YouTube의 Lazy Loading

해결책

YouTube의 Lazy Loading으로 댓글의 내용은 보이지 않더라도 댓글 창 자체에 필터 메뉴를 삽입해 놓으면 정상적으로 사용이 가능했다. 다행히도 댓글 창은 도큐먼트가 로딩이 된 후 (document_end 시점) 잠시 뒤 곧바로 로딩이 되었다. 때문에 댓글 HTML이 존재하지 않을 경우 0.5초마다 댓글 창을 Xpath로 재검색하도록 설정했다.

앞서 일정한 시간마다 재시도하는 것은 연산 낭비라서 배제했기에 조금 의아할 것 같다. 앞의 경우는 일정 시간마다 댓글을 재검사하는 것이기에 타이머가 무한히 반복되는 반면 이 경우는 댓글 창이 로딩될 때까지만 재시도한다. 실제로 이 해결책은 1~2차례 재시도에 정상적으로 종료된다. 그보다 더 오래 걸리면 YouTube 자체를 못 볼 정도로 인터넷이 느린 것이다.

YouTube의 Lazy Loading 우회

YouTube의 Lazy Loading 우회

이후 부차적인 버그들이 발생했다. 자바스크립트를 능숙하게 다루지 못했기에 특정 함수를 일정 시간 뒤에 효율적으로 재시도하는 방법을 몰랐기 때문이다. 동영상 URL로 접근하거나 검색 결과에서 YouTube 동영상으로 곧바로 접근하면 YouTube가 무한 로딩되는 버그가 간혹 발생했다. 이유를 알고 보니 JS를 일정 시간 뒤에 재시도하도록 만들어야 하는데 일정 시간을 기다리도록 만들면서 다른 JS의 실행을 막았기 때문이었다. 이 버그는 GitHub Issue #4에서도 확인할 수 있으며 v1.1.4에서 최종적으로 수정되었다. (버그가 20번에 한번 꼴로 나타났다. 오류 상황에서 버그를 찾는 것보다 오류 상황 자체를 재현하는 것이 더 어려웠다.)

② YouTube의 HTML 컴포넌트 재활용 문제

크롬 익스텐션이 웹사이트에 접근하여 내용을 변경하기 위해선 manifest.json에 익스텐션이 접근할 주소를 모두 작성해야 한다. 처음에는 동영상 페이지에서만 실행되면 될 것이라 생각했기에 https://*.youtube.com/watch*를 사용했다. 하지만 YouTube 홈 화면으로 들어와 동영상으로 접근할 경우에 문제가 생겼다.

YouTube는 HTML의 컴포넌트를 다수 재활용한다. YouTube 동영상을 보다가 i 버튼을 눌러 미니플레이어를 실행해본 적이 있다면 눈치챌 수 있을 것이다. YouTube는 동영상 페이지로 이동하는 것이 아니라, 단지 ① 기존 화면을 가리고 ② 새 페이지를 위에 띄운 뒤 ③ 웹 주소창의 주소만 바꾸어 놓는 것이다. 그러니 당연히 i 버튼을 눌러 미니플레이어를 띄우면 동영상을 재생하기 전에 본 창이 그대로 다시 나타나는 것이다.

그렇다고 manifest.jsonhttps://*.youtube.com/*를 사용하면 댓글 창이 없는 페이지에서도 엉뚱한 곳에 필터 메뉴가 삽입되었다.

아무래도 크롬 익스텐션은 ��새로운 페이지가 로딩될 때만 도메인을 검사하는 것 같았다.

아무래도 크롬 익스텐션은 새로운 페이지가 로딩될 때만 도메인을 검사하는 것 같았다.

또한 동영상 페이지에서 정상적으로 필터 메뉴가 삽입되었더라도 필터를 켠 채 다른 동영상으로 이동하게 되면 "이전 동영상의 댓글의 언어"에 맞추어 "현재 동영상의 댓글"을 필터링하는 경우도 간혹 발생했다. 이 또한 YouTube의 댓글 컴포넌트 재활용 때문에 발생한 문제 같았다. 아까 속도 개선을 위해 댓글 HTML에 결과를 메모해두었다고 했는데 이 결과 또한 뒤죽박죽 섞여 오류를 더 크게 만들었다.

해결책

익스텐션이 웹사이트 접속을 모니터링한다. 접속하는 도메인이 YouTube라면 필터를 삽입할 준비를 하고, 도메인을 그때그때 수동으로 검사하여 youtube.com/watch라면 그때 필터를 삽입한다.

YouTube는 SPA다

YouTube는 SPA다

이 경우 익스텐션을 설치할 때 "사용자의 방문 기록을 읽을 수 있음" 권한이 필요하다. 이 때문에 해당 커밋설치 완료 페이지에 사용하는 권한에 대해서 설명을 적어두었다. GitHub 오픈 소스를 보면 알겠지만 방문 기록은 외부로 전송되지 않는다.

또한 필터를 켠 채 페이지가 넘어갈 때도 정상적으로 필터링 될 수 있도록 ① 페이지가 넘어가면 ② 모든 필터와 필터 결과를 초기화하고 ③ 이동한 페이지의 댓글을 새로 불러와 ④ 대기할 수 있도록 설정해 주었다.

가끔 문제가 재발하긴 한다. 하지만 이는 오히려 YouTube 자체의 버그인 것 같았다. 이 익스텐션을 사용하지 않아도 YouTube 웹의 댓글 시스템은 잘못된 댓글이 나타나거나 영상의 댓글이 서로 섞이는 등 다양한 버그로 악명이 높다. 이 경우 새로 고침을 하면 해결된다.

③ 속도에 관한 문제

알파 버전에서 필터 성능이 무척 안 좋았다가 console.log를 모두 제거하는 것만으로 유의미한 속도 개선이 있었다고 했다. 사용자가 댓글을 읽는 시간보다만 필터 속도가 빠르면 사용에 무리가 없었기에 속도 개선보다 위의 문제들을 고치는 것이 우선이었는데, 나중에 확인하니 이런 문제였다.

for (var comment of commentList) {
if (comment.id === '') {
var commentString = comment.childNodes[1].childNodes[1].childNodes[3].childNodes[3].childNodes[1].innerText
if (containsSelectedLang(commentString)) {
comment.id = 'contains-SelectedLang'
} else {
comment.id = 'no-SelectedLang'
}
}
if (comment.id === 'no-SelectedLang') {
comment.style = 'display: none'
}
}

commentStringcomment 주소는 YouTube의 업데이트로 몇 차례 바뀌었기에 현재 작동하지 않는다.

해결책

문제점은 첫 줄의 var comment of commentList이다. 예를 들어 80개의 댓글에 대한 검사를 마쳤고 20개의 댓글을 새로 로딩했다고 할 때 위 코드를 사용하면 기존 80개 댓글도 다시 불러와 재검사한다. 어디까지 검사했는지 확인하는 변수를 추가해서 성능을 개선할 수 있었다.

var commentNum = 0
var shownCommentNum = 0
// ...
for (var i = commentNum; i < commentList.length; i++) {
commentNum++
CLFFooter.textContent = commentNum + ' comments analyzed, ' + shownCommentNum + ' comments shown.'
var commentString = commentList[i].childNodes[2].childNodes[2].childNodes[3].childNodes[3].innerText
if (!containsSelectedLang(commentString, StartCharset, EndCharset)) {
commentList[i].style = 'display: none'
} else {
shownCommentNum++
}
}

방금 코드와 약간의 디테일이 다르다. 여기서는 다른 부분보다 네 번째 줄의 for 구조 변경이 제일 중요하다.

기존에 사용하던 annotation을 이용한 속도 개선 코드는 YouTube에서 동영상을 넘나들 때 오류를 일으키는 바람에 전부 삭제했다. 위와 같은 방법으로 속도를 개선하고 나니 불편함 없이 사용 가능했다.

HTML 버튼의 속도 문제

또한 알파 버전에서는 버튼을 활용했는데, 이를 HTML Select로 변경했다. 이는 다국어 지원의 기반을 마련하기 위함뿐만 아니라 버튼의 느린 반응 속도에 대한 해결책이었다. 기존의 버튼을 클릭하면 실제 실행이 될 때까지 약 0.2초가량의 연산 시간이 있었다. 이 짧은 시간 동안 버튼을 연타하게 되면 반복적으로 필터가 꺼졌다 켜지는 바람에 YouTube가 멈추는 문제가 있었다. HTML Select를 활용하게 되면 연타가 불가능해지며 0.2초 안에 Select Value를 여러 차례 바꾸는 것은 어려우므로 UI 적인 해결책을 제시했다고 생각한다.

④ 글자 판별의 신뢰성에 관한 문제

엄밀하게 이야기할 때 이 익스텐션은 언어 판별이 아닌 글자 판별을 사용한다. 한글 밖에 판별하지 못한다는 단점을 개선하기 위해 베타 버전에서 유니코드 영역으로 글자 구별이 쉬운 CJK (한자, 가나, 한글) 문자를 추가했다. 이 부분은 정말 중국인과 일본인이 사용할 수 있도록 만들었다기보다는 판별 언어를 추가할 수 있는지 그 확장성을 확인하기 위함이었다.

또한 "글자 집합 중 한 글자라도 포함하면 통과"라는 규칙을 적용하고 있기 때문에 글자 판별에 오류가 잦게 발생한다. 예를 들어...

I like the performance! This video is 최고!

...와 같은 문장은 "영어"로 판별되어야 하나, "한국어"와 "영어"로 동시에 판별된다. 때문에 이름 등의 고유명사가 지속적으로 사용되는 경우 적당하게 필터가 되지 않는 문제가 발생한다. 한글 문장에 영단어 한 글자라도 포함되면 영어로 판별되고, 일본어 문장에 한자가 한 글자라도 있으면 중국어로 판별되는 문제가 있다.

해결책..?

아직 완벽하게 고치지 못했다. 일단 글자 판별이라는 특징을 보다 명확히 나타내기 위해 v1.2에서는 언어의 이름이 아닌 글자의 이름을 사용하도록 수정하였다. 궁극적으로 자연어 처리 모듈을 탑재할 계획이 있다. 하지만 크롬 익스텐션의 정책 문제로 이런 모듈을 사용하려면 지금 코드의 상당 부분을 뜯어고쳐야 하는 문제가 생겨 이를 연구하는 중이다.

3. 정식 버전

필터 그 자체의 성능이나 기능의 개선은 없지만 익스텐션 부분에서 사용성을 개선했다. 설정 창을 제작하여 사용하지 않는 언어들을 감출 수 있도록 하였으며 설치 직후 열릴 랜딩 페이지를 만들었다. 버그 제보를 위한 간단한 안내문도 제작해두었다. 프로모션 이미지도 다시 제작했다.

무의미한 작업이라고 생각할 수 있지만 예상외로 훨씬 많은 사용자를 끌어모으는 기특한 역할을 한다.

무의미한 작업이라고 생각할 수 있지만 예상외로 훨씬 많은 사용자를 끌어모으는 기특한 역할을 한다.

예전에 작은 실험을 해보았는데, 프로모션 이미지가 있을 때와 없을 때 유입 관객 수는 약 4배 정도 차이가 났다.

마지막으로 크롬 익스텐션을 파이어폭스 애드온으로 이식했다. 이 부분에 대해서는 이전에 작성한 Porting a Chrome Extension to Firefox Add-on에서 한 차례 다룬 적이 있다.

v1에 대한 총평

v1까지 출시를 하며 염두에 둔 것은 아무 생각 없이 쓸 수 있는 결과물을 만드는 것이었다. 익스텐션이 도중에 버그를 일으켜서 오히려 익스텐션에 신경을 쓰기 시작하면 YouTube 보는 흥이 다 깨져버린다.

하지만 지금의 v1은 흥을 깨뜨리지 않는다. 그냥 아무 생각 없이 막 써도 잘 작동한다. (It just all... works!)

베타 버전부터 사용한 친구의 리뷰

베타 버전부터 사용한 친구의 리뷰

더불어서 최초 목적이었던 한국어 댓글 찾기는 흠잡을 곳 없이 거의 완벽하게 작동한다. 결국 한국어 댓글에는 한글이 한 글자라도 들어갈 테니까.

앞으로

v2를 개발하고 있다. v1에서 가장 많이 들어온 피드백은 "스타일의 추가"와 "언어 판별 개선"이다. 크롬 익스텐션의 근본적인 문제 때문에 크롬 익스텐션 자체에서 외부 파일이나 모듈을 추가하는 것이 매우 복잡하다. npm 라이브러리를 사용하는 것도 번거롭다. 이를 해결하기 위해서는 Webpack이라는 것을 사용해야 하는데, 그를 위해서는 상당한 부분의 코드를 재작성해야 한다. 그럼에도 여러 가지 재미있는 활용 방향이 떠오르기 때문에 v2를 짬짬이 제작하고 있다.

YouTube에서 고의적으로 한글 댓글을 댓글 순위에서 낮추어 한국 영상의 외국인의 유입 비율을 실험하는 중이라는 이야기도 나오고 있기 때문에 절대 이 프로젝트가 여기서 끝날 것 같지 않다.

그래서 제목이 파트 1이다. 언젠가 v2가 완성되거나 마땅히 기억될 무슨 일이 생긴다면 이 글의 파트 2를 만날 수 있을 것이다.

잠깐!
  • 이 글이 작성된지 3년 이상 지났습니다.
  • 새로운 일들이 일어나기 충분한 시간입니다.
  • 저는 이 글에 더 이상 동의하지 않을지도 모릅니다.
Google에서 새로운 자료 찾아보기

집계 중...

내 Ghost 블로그는 서버리스하지 않다. 지속적인 관리가 필요함에도 서버를 유지하는 이유는 서버를 통한 블로그 운영에 수많은 장점들이 있기 때문이다. 하지만 서버를 통해 블로그를 관리하게 되면 아주 큰 단점이 한 가지 생긴다. 서버가 터질 경우 안에 담긴 글들을 복원하기 매우 어려워진다는 것이다. 앞으로 글과 사진이 훨씬 많아질텐데 매번 일일이 복사하여 백업하기는 너무 귀찮을 것 같다는 생각이 들었다. 이를 개선해볼 대책을 세워보고 싶었다.

Ghost 내장 백업의 문제점

Ghost는 .json 형태의 블로그 백업 파일을 내려 받을 수 있는 기능을 제공한다. 정말 블로그의 영혼 그대로 복사된다. 작가 이름, 사용한 태그들, 글의 내용 및 양식, 글을 업로드한 시간과 HTML 메타태그 내 summary까지, Ghost 내에서 설정할 수 있는 모든 것들은 그대로 백업된다.

하지만 두 가지 문제점이 있다.

  • Ghost 내장 백업 파일은 사람이 읽기 어렵다. Minified JSON일 뿐만 아니라, 수많은 정보들을 담고 있기 때문에 파일 구조도 복잡하며 글도 압축되어 있다.
  • 또 Ghost 내장 백업은 사진을 백업하지 않는다. 때문에 블로그를 복원하면 사진 파일들은 모두 "찾을 수 없음"(일명 엑박)이 뜨게 된다. 블로그 서버가 살아있거나 복사해둔 사진들이 있으면 다행이지만 사진을 복원하지 못하는 경우도 생길 수 있다는 뜻이다.

목표

주 목표

  • 글과 사진을 모두 백업해야 한다.

보너스 목표

  • 사람이 읽을 수 있는 형태여야 한다. (Human-Readable Medium)
  • 블로그를 복원할 때를 대비하여 어느 글의 어느 위치에 어느 사진이 들어가는지 명확하게 알 수 있어야 한다.
  • 백업이 편리해야 한다.
  • 블로그 외부에서 복제본을 만들 수 있어야 한다.

구상

바로 RSS이다. RSS는 2000년대 초반 블로그 붐이 일었을 때 등장한 기술로 마치 "구독"의 역할을 한다. RSS를 지원하는 사이트나 블로그들은 RSS 피드 주소를 제공한다. RSS 피드 주소에는 그 사이트에서 업데이트 되는 내용들이 기계가 읽을 수 있는 형태로 정리되어 올라간다. 사용자들이 RSS 리더에 RSS 피드 주소를 입력하면 리더가 RSS 피드 주소에서 새로운 컨텐츠를 매번 긁어오는 것이다.

현대에는 SNS가 활성화되며 RSS 기술이 사장되었지만 나의 목표를 달성하기에는 충분한 기술이다. RSS 피드는 글을 받아오는 API 역할을 하는 것이다. Ghost는 기본적으로 RSS를 지원하니 이를 이용하기로 했다.

개략적인 아이디어

  1. 블로그 RSS 주소를 입력하여 RSS 피드 전체를 복사해온다.
  2. RSS를 파싱하여 글의 HTML을 추출한다.
  3. 각 글마다 폴더를 하나씩 생성하여 글의 HTML을 저장한다.
  4. 글의 HTML에 포함된 img 태그의 src 주소에 접속하여 사진을 내려 받는다.
  5. 사진이 존재하는 글은 글 폴더마다 images 폴더를 만들어 사진을 저장하고, HTML 내 img 태그의 src 를 저장된 이미지의 상대 경로로 변경한다.

Development

참고

아래의 모든 예시는 [anaclumos/backup-with-rss](https://github.com/anaclumos/backup-with-rss) 의 v1을 기준으로 작성되었다. 이 글을 읽을 시점에는 무언가 새로운 기능이나 버그 수정이 추가되었을지 모른다.

또한 이 글에 첨부된 코드는 개략적인 전개를 보여주기 위한 것이지 전체 코드가 아니다. 이 글대로 복사해서 실행하려 하면 아마 실행되지 않을 것이다! 전체 코드는 GitHub 저장소에 공개되어 있다.

1. Feedparser를 이용해 RSS 피드 복사

Python의 Feedparser라는 모듈을 통해 RSS 피드를 복사한다.

# -*- coding: utf-8 -*-
# -*- coding: utf-8 -*-
import feedparser


class RSSReader:
origin = ""
feed = ""

def __init__(self, URL):
self.origin = URL
self.feed = feedparser.parse(URL)

def parse(self):
return self.feed.entries

RSSReader는 RSS 피드를 불러와 entries 항목을 넘기는데 사용된다.

이 코드가 하는 역할은,

  1. RSSReader Object가 생성될 때 self.origin에 RSS 주소를 저장하고 RSS 주소를 파싱하여 self.feed에 저장한다.
  2. parse 함수가 실행될 시 self.feed에 저장된 값 중 entries를 반환한다.

이 중 entries 에는 RSS 피드의 글들이 list의 형식으로 들어있다. 다음 예시는 이 글의 RSS이다.

parse()self.feed.entries의 구조

// 일부 생략
{
"bozo": 0,
"encoding": "utf-8",
"entries": [],
"feed": {
"generator": "Ghost 3.13",
"generator_detail": {
"name": "Ghost 3.13"
},
"image": {
"href": "https://blog.chosunghyun.com/favicon.png",
"link": "https://blog.chosunghyun.com/",
"links": [
{
"href": "https://blog.chosunghyun.com/",
"rel": "alternate",
"type": "text/html"
}
],
"title": "Sunghyun Cho",
"title_detail": {
"base": "https://blog.chosunghyun.com/rss/",
"language": "None",
"type": "text/plain",
"value": "Sunghyun Cho"
}
},
"link": "https://blog.chosunghyun.com/",
"links": [
{
"href": "https://blog.chosunghyun.com/",
"rel": "alternate",
"type": "text/html"
},
{
"href": "https://blog.chosunghyun.com/rss/",
"rel": "self",
"type": "application/rss+xml"
}
],
"subtitle": "Sunghyun Cho's Blog",
"subtitle_detail": {
"base": "https://blog.chosunghyun.com/rss/",
"language": "None",
"type": "text/html",
"value": "Sunghyun Cho;s Blog"
},
"title": "Sunghyun Cho",
"title_detail": {
"base": "https://blog.chosunghyun.com/rss/",
"language": "None",
"type": "text/plain",
"value": "Sunghyun Cho"
},
"ttl": "60",
"href": "https://blog.chosunghyun.com/rss/",
"namespaces": {
"": "http://www.w3.org/2005/Atom",
"content": "http://purl.org/rss/1.0/modules/content/",
"dc": "http://purl.org/dc/elements/1.1/",
"media": "http://search.yahoo.com/mrss/",
"status": 200,
"version": "rss20"
}
}
}

2. RSS 데이터로 Markdown 파일 만들기

RSSReader가 반환하는 self.feed.entries 중에서 필요한 값들만 뽑아내면 될 것 같았다. RSSReader가 제공한 정보를 가공할 MDCreator 클래스를 만들었다.

class MDCreator:
def __init__(self, rawData, blogDomain):
self.rawData = rawData
self.blogDomain = blogDomain

def createFile(self, directory):
try:
os.makedirs(directory + "/" + self.rawData.title)
print('Folder "' + self.rawData.title + '" Created ')
except FileExistsError:
print(
'Folder "' + self.rawData.title + '" already exists'
)
self.directory = directory + "/" + self.rawData.title

MDFile = codecs.open(
self.directory + "/README.md", "w", "utf-8"
)
MDFile.write(self.render())
MDFile.close()

blogDomain parameter는 나중에 사용된다.

이 코드가 하는 역할은,

  1. MDCreator Object가 생성될 때 블로그 주소를 self.blogDomain에, RSS 피드 원본 데이터를 self.rawData 에 저장한다. 이 RSS 피드 원본 데이터는 RSSReader의 parse()에서 반환되는 self.feed.entries이다.
  2. createFile() 함수가 실행되면 백업 폴더에 각 글마다 하나의 폴더를 만든다. 이 때 폴더 제목은 글의 제목이다. 각 폴더마다 README.md를 생성하고, 그 안에 글의 내용을 넣는다.

codecs 라이브러리를 통해서 파일을 생성하는 이유는 Windows에서 CP949 코덱 대신 Unicode를 사용하게끔 하기 위함이다. 그래야 RSS 안에 포함된 이모지가 정상적으로 나타난다 🚀🥊

3. 생성한 Markdown 파일에 글 정보 추가하기

글의 정보를 표시할 때 Jekyll 형식의 Front Matter를 사용하고 싶었다. 글의 제목, 태그, 링크, 작가 등을 가장 쉽게 확인할 수 있는 방법이라는 생각이 들었기 때문이다.

def render(self):
try:
postTitle = str(self.rawData.title)
except AttributeError:
postTitle = "Post Title Unknown"
print("Post Title does not exist")
try:
postTags = str(
self.getValueListOfDictList(self.rawData.tags, "term")
)
except AttributeError:
postTags = "Post Tags Unknown"
print("Post Tags does not exist")
try:
postLink = "Post Link Unknown"
postLink = str(self.rawData.link)
except AttributeError:
print("Post Link does not exist")
try:
postID = str(self.rawData.id)
except AttributeError:
postID = "Post ID unknown"
print("Post ID does not exist")
try:
postAuthors = str(self.rawData.authors)
except AttributeError:
postAuthors = "Authors Unknown"
print("Authors does not exist")
try:
postPublished = str(self.rawData.published)
except AttributeError:
postPublished = "Published Date unknown"
print("Published Date does not exist")
self.renderedData = (
"---\nlayout: post\ntitle: "
+ postTitle
+ "\ntags: "
+ postTags
+ "\nurl: "
+ postLink
+ "\nauthors: "
+ postAuthors
+ "\npublished: "
+ postPublished
+ "\nid: "
+ postID
+ "\n---\n"
)

이 코드가 하는 역할은,

  1. RSS 코드에서 글의 제목, 태그, 링크, ID, 작가 이름들, 그리고 출판 날짜가 있는지 확인하고, 값이 있을 경우 Front Matter에 그 값을 입력한다.
  2. 값이 없을 경우 ~ Unknown을 입력한다.

Tags를 self.getValueListOfDictList(self.rawData.tags, "term") 같은 코드를 통해서 넣는 이유는 Ghost에서 다음과 같은 형식으로 태그가 지정되어 있기 때문이다. 이는 Gatsby나 Wordpress 등도 마찬가지이다.

'tags': [{'label': None, 'scheme': None, 'term': 'English'},
{'label': None, 'scheme': None, 'term': 'Code'},
{'label': None, 'scheme': None, 'term': 'Apple'}],
def getValueListOfDictList(self, dicList, targetkey):
arr = []
for dic in dicList:
for key, value in dic.items():
if key == targetkey:
arr.append(value)
return arr

이와 같은 방식으로 tags에서 term 항목만 꺼내 Front Matter에 추가한다. 그렇게 되면 실행했을 때 다음과 같은 Jekyll Style Front Matter가 완성된다.

---
layout: post
title: Apple's Easter Egg
tags: ['English', 'Code', 'Apple']
url: https://blog.chosunghyun.com/apples-easter-egg/
authors: [{ 'name': 'S Cho' }]
published: Sun, 19 Jan 2020 17:00:00 GMT
id: /_ Some Post ID _/
---

Jekyll Style Front Matter on GitHub

Jekyll Style Front Matter on GitHub

Front Matter는 GitHub에서 이렇게 렌더링되어 보인다.

4. 생성한 Markdown 파일에 글 요약 및 본문 추가하기

RSS 데이터의 Summary 항목과 Content 항목을 renderedData에 추가한다.

self.renderedData += "\n\n# " + postTitle + "\n\n## Summary\n\n"

try:
self.renderedData += self.rawData.summary
except AttributeError:
self.renderedData += "RSS summary does not exist."

self.renderedData += "\n\n## Content\n\n"

try:
for el in self.getValueListOfDictList(self.rawData.content, "value"):
self.renderedData += "\n" + str(el)
except AttributeError:
self.renderedData += "RSS content does not exist."

한 가지 신기했던 점은 Ghost와 Wordpress 기반 블로그들은 RSS의 Summary와 Content를 모두 지원하는 반면 Jekyll-based GitHub Pages나 Tistory는 RSS Summary에 모든 글의 내용을 집어넣는다는 점이다. (...) Ghost는 기본적으로 글의 Excerpt를 설정할 수 있는 기능을 제공하는데, 이 Excerpt 값이 RSS Summary로 사용된다.

5. 생성한 Markdown 파일에 이미지 추가하기

백업을 위해서는 이미지까지 온전하게 보존되어야 한다. HTML에 base64로 임베디드되어있는 이미지가 아니라면 지금으로는 모두 img 태그에 src만 지정되어 있는 형태이다. 서버가 죽으면 img src에서 이미지를 불러오지 못할테니 백업을 할 시점에 이미지를 모두 다운로드해야한다.

PythonCodeHow to Download All Images from a Web Page in Python을 참고하였다.

soup = bs(self.renderedData, features="html.parser")
for img in soup.findAll("img"):

for imgsrc in ["src", "data-src"]:
try:
remoteFile = img[imgsrc]
break
except KeyError:
continue

if self.isDomain(remoteFile) != True:
print("remoteFile", remoteFile, "is not a domain.")
remoteFile = self.blogDomain + "/" + remoteFile
print("Fixing it to", remoteFile)
print(
'Trying to download "'
+ remoteFile
+ '" and save it at "'
+ self.directory
+ '/images"'
)
self.download(remoteFile, self.directory + "/images")
img["src"] = "images/" + remoteFile.split("/")[-1]
img["srcset"] = ""
print(img["src"])
self.renderedData = str(soup)
return self.renderedData

이 코드가 하는 역할은,

  1. 문자열 renderedData를 HTML로 읽어와 img 태그를 모두 찾는다.
  2. srcdata-src attribute가 있는지 확인한다. data-src는 Wordpress에 대응하는 attribute이다.
  3. 각 글 폴더 내에 images 폴더를 만들고 그 안에 이미지들을 저장한다. 이 때 이미지의 이름은 img src의 가장 하위 디렉토리이다. 예를 들어 img srchttps://blog.someone.com/images/example.png라면 images/example.png로 저장된다.
  4. 기존의 img srcimages 폴더의 상대 경로로 변경한다.
  5. srcset attribute를 가지고 있다면 이를 제거한다 (Gatsby 대응)
def download(self, url, pathname):
if not os.path.isdir(pathname):
os.makedirs(pathname)
response = requests.get(url, stream=True)
file_size = int(response.headers.get("Content-Length", 0))
filename = os.path.join(pathname, url.split("/")[-1])
if filename.find("?") > 0:
filename = filename.split("?")[0]
progress = tqdm(
response.iter_content(256),
f"Downloading {filename}",
total=file_size,
unit="B",
unit_scale=True,
unit_divisor=1024,
)
with open(filename, "wb") as f:
for data in progress:
f.write(data)
progress.update(len(data))

한 가지 문제점은 이미지의 주소들이 일관적이지 않다는 것이다. 어느 사이트는 <img src = "https://example.png/images/example.png">와 같이 전체 도메인을 적는 반면 어느 사이트는 <img src = "/images/example.png"> 같이 서브디렉토리부터 적는다. 어느 곳은 <img src = "example.png">인 곳도 있었다. 최대한 많은 경우에 대응하기 위해 도메인을 감지하는 함수 isDomain()을 만들었다. 다른 라이브러리는 .png와 같은 파일 확장자를 .com과 같은 Top Level Domain으로 인식했기에 몇 가지 예외 처리를 추가했다.

def isDomain(self, string):
if string.startswith("https://") or string.startswith("http://"):
return True
elif string.startswith("/"):
return False
else:
return validators.domain(string.split("/")[0])

만약 <img src = "/images/example.png">와 같이 직접 접근 가능한 도메인이 아닌 경우 앞에 도메인 이름을 붙이도록 지정했다. 이 때 아까 지정해둔 self.blogDomain이 사용된다.

결과

이 블로그를 백업해보았다. 이 블로그는 Self-hosted Ghost 블로그이다. main.py만 실행하면 쭉 백업이 진행된다.

백업된 글들이다. 폴더 이름은 글의 제목으로 설정된다.

백업된 글들이다. 폴더 이름은 글의 제목으로 설정된다.

GitHub 상에 백업된 글의 모습이다. 사진 또한 블로그 서버 대신 폴더에 직접 저장하여 보여주는 것이다.

GitHub 상에 백업된 글의 모습이다. 사진 또한 블로그 서버 대신 폴더에 직접 저장하여 보여주는 것이다.

폴더에는 글에 사용된 사진들이 저장된다.

폴더에는 글에 사용된 사진들이 저장된다.

테스트해본 바로 다음 서비스들이 지원된다. 글의 양식이나 배열이 조금 다를 수 있지만 백업의 목적은 충분히 달성한다.

  • Ghost
  • Wordpress
  • Jekyll-based GitHub Pages
  • Gatsby-based GitHub Pages
  • Medium
  • Tistory

목표 달성 평가

주 목표

  • 글과 사진을 모두 백업해야 한다. ★★★

목표를 완전히 달성했다. 동영상은 백업되지 않는데, 어차피 동영상은 YouTube를 통해서 embedded되므로 정보가 유실될 확률이 훨씬 적다. 때문에 처음부터 목표에서 제외했다.

보너스 목표

  • 사람이 읽을 수 있는 형태여야 한다. (Human-Readable Medium) ★★☆

Ghost 내장 백업과 비교하여 Front Matter에서 중요한 정보들을 한 눈에 볼 수 있고, 블로그와 거의 동일한 형태로 글이 렌더링된다. 폴더별로 글과 사진이 정리되어 원하는 자료를 찾기도 편리하다. 하지만 Markdown을 활용해도 글 본문은 HTML이기에 글을 수정하기는 불편하다. 딱 Lots of copies keep stuff safe 정도의 목적을 달성하는 백업이다.

  • 어느 글의 어느 위치에 어느 사진이 들어가는지 명확하게 알 수 있어야 한다. (블로그 복원할 때를 대비) ★★★

어느 글의 어느 위치에 어느 사진이 들어가는지 명확하게 알 수 있다.

  • 백업이 편리해야 한다. ★★☆

수동으로 main.py를 실행해줘야 한다. 언젠가 crontab으로 자동화하려 생각 중이다.

또한 RSS을 사용하는 특성 상 RSS 피드에 포함된 게시글만 백업이 된다. RSS 피드는 bandwidth 사용량을 줄이기 위해 최신 게시물만 포함하는 경우가 많은데, 각 블로그마다 이를 조정할 수 있는 옵션이 있다. Ghost 블로그는 기본적으로 15개의 최신 게시물을 RSS 피드에 포함한다. Ghost 블로그의 RSS 피드 게시글 숫자는 Ghost CMS 안에서는 조작이 불가능하며 Ghost Core의 코드를 건드려야 가능하다.

  • 블로그 외부에서 복제본을 만들 수 있어야 한다. ★★☆

Wordpress 블로그에서 반복적으로 수많은 사진들을 다운로드하면 일시적으로 접근이 차단되는 경우가 있다.

향후 계획

완성하고 곰곰이 생각을 해보니 블로그 이전을 계획 중인데 쌓아둔 자료가 너무 많아서 고민인 사람들에게 좋은 도구가 될 것 같다는 생각이 들었다. 블로그 이전에 도움이 될 수 있는 도구가 되도록 더욱이 개선을 해볼 계획이다.

참고

잠깐!
  • 이 글이 작성된지 4년 이상 지났습니다.
  • 새로운 일들이 일어나기 충분한 시간입니다.
  • 저는 이 글에 더 이상 동의하지 않을지도 모릅니다.
Google에서 새로운 자료 찾아보기

집계 중...

수학에 너무 심취한 한석원 선생님... 그만 자신을 인수분해 하고 마는데...!

수학에 너무 심취한 한석원 선생님... 그만 자신을 인수분해 하고 마는데...!

이 글에서는 동영상 압축의 원리에 대해서 알아보고 위와 같은 현상이 왜 일어나는지에 대해 다뤄보려 한다.

동영상은 너무 크다

동영상은 사진들의 집합이다. 그러나 동영상을 실제 사진의 연속으로 제작할 경우 용량이 놀랍도록 비대해진다. 우리가 YouTube에서 자주 시청하는 1920 x 1080 60FPS 동영상을 전혀 압축하지 않으면 그 크기가 분당 7GB에 육박한다. 하지만 실제 YouTube에서 똑같은 사양의 동영상을 시청할 경우 분당 최대 40MB 가량 사용된다. 거의 200배 가까이 용량이 줄어든 것이다. 그럼에도 우리는 큰 차이를 느끼지 못한다. 대체 무슨 일이 일어난 것일까?

그래서 인코딩을 한다

동영상의 비대한 크기 때문에 대부분의 동영상은 일정 수준 압축을 한다. 이를 동영상 인코딩이라고 하는데, 인코딩 알고리즘의 세계는 놀랍도록 정교하고 아름답다.

동영상 인코딩은 용량 절감의 핵심을 중복성에서 찾는다. 예를 들어 한 가수가 가만히 서서 노래를 부르고 있다고 생각해보자. 가수의 입만 움직이며 배경과 가수의 몸은 전혀 움직이지 않는다. 그렇다면 배경의 검정 픽셀과 가수의 몸동작에 대한 정보를 매번 제공할 필요가 있을까? 아니다. 그 부분들은 중복되기 때문이다.

동영상의 데이터는 시공간 상으로 중복된다. 공간상의 중복을 제거하는 방법을 인트라프레임 코딩(Intra-frame coding, 프레임 내 압축)이라고 하며 시간상의 중복을 제거하는 방법을 인터프레임 코딩(Inter-frame coding, 프레임 간 압축)이라고 한다. 세부적인 구현법으로 인접한 픽셀 데이터의 절감을 위해 활용되는 Discrete Cosine Transform, Motion Vector를 활용한 예측, 그리고 In-loop Filtering 기법 등이 존재한다.

Intra-frame coding

사진 자체의 크기를 줄이자!

동영상은 사진의 집합이다. 사진은 픽셀의 집합이다. 같은 사진 내에 중복된 픽셀에 대한 정보를 줄일 수 있다면 공간상의 중복성(Spatial redundancy)을 줄일 수 있다. 가장 간단한 구현 중 하나는 평균치를 활용하는 것이다. 한 픽셀의 데이터를 비워두고 주변 픽셀 정보를 남겨두면 컴퓨터는 동영상을 재생할 때 인접한 픽셀들의 정보를 가져와 그 데이터의 평균을 표현한다.

여기서 재밌는 점은 인접한 픽셀이 상하좌우가 아니라는 점이다. 동영상 내 픽셀 데이터는 왼쪽에서 오른쪽으로, 그리고 위에서 아래의 순서로 저장되어 있다. 만약 상하좌우의 픽셀의 정보를 가져와서 평균을 구하게 된다면 우측과 하단의 픽셀 데이터가 읽힐 때까지 기다린 후에 다시 되돌아와 픽셀 데이터를 표현해야 한다. 빠르게 동영상을 표현할 때 효율적이지 못하므로 Intra-frame coding에서는 왼쪽 위, 위, 오른쪽 위, 그리고 왼쪽의 데이터를 임시로 저장해두었다가 공백 데이터를 만나면 임시 저장된 값들을 사용하여 평균값을 계산한다.

Inter-frame coding

과거에 이미 보낸 정보는 다시 보내지 말고 재활용하자!

학교 방학식에서 상을 주는 것을 기억하는가? 만약 동일한 상을 30명에게 준다고 생각해보자. 교장 선생님께서 일일이 모든 상을 낭독하신다면 방학식이 얼마나 길어질까? 얼마나 지루하고 괴로울까? 하지만 교장 선생님께서는 그러시지 않는다. 그저 위와 내용은 같습니다, 하고 넘어가신다. 그렇게 앞 사람과 동일하다는 표현만으로 우리는 소중한 방학식 오후를 가질 수 있게 된다. 교장 선생님께서 Inter-frame 압축을 하신 것이다.

동영상도 마찬가지이다. 많은 동영상은 앞뒤 프레임이 비슷하기 때문에 동영상도 프레임 전후의 관계로 정보를 표현하거나 아예 생략할 수 있다. 이를 통해 시간상의 중복성(Temporal Redundancy)을 줄일 수 있다.

한 분만 고르세요.

한 분만 고르세요.

#1. 기준이 되는 I-Frame

I-Frame(Intra-coded picture)은 사진이라고 봐도 무방하다. I-Frame 내의 모든 정보는 새로운 정보이다. I-Frame은 앞뒤 프레임을 표현하는 기준이 된다.

#2. 변화량만 표현하는 P-Frame

각각의 I-Frame 사이에서는 P-Frame(Predicted Pictures)을 삽입한다. P-Frame에는 이전 화면과의 변화량이 표현되어 있다. 현재 프레임이 앞 프레임과 공통점이 있을 경우 앞 프레임의 정보를 가져와 사용하는 것이다. 그림을 보면 이해가 좀 편하다.

Copyright: Blender Foundation 2006. Netherlands Media Art Institute. www.elephantsdream.org

Copyright: Blender Foundation 2006. Netherlands Media Art Institute. www.elephantsdream.org

화살표로 표현된 것이 변화량을 나타내는 Motion Vector이다. 이 외에도 P-Frame에는 예측 보정을 위한 변환값들이 포함되어 있다. P-Frame에도 새로운 이미지 정보가 포함되는 경우가 있다. P-Frame은 I-Frame의 절반 정도의 크기만 사용한다. 물론 실제 동영상 인코딩에서는 모든 픽셀 정보를 비교하는 대신 여러 블록 단위로 쪼개서 비교한다. 이를 매크로블록(Macroblock)이라고 하며, 최신 영상 코덱인 HEVC에서는 코딩 트리 유닛(Coding Tree Unit)이라고 부른다.

#3. 데이터를 아끼는 B-Frame

I-Frame과 P-Frame 사이에는 B-Frame(Bidirectionally Predicted Pictures)을 삽입한다. B-Frame은 앞뒤의 I-Frame 또는 P-Frame을 이용해 화면을 계산한다. P-Frame이랑 다를 것이 없어 보이는데 굳이 B-Frame을 사용하는 이유는 용량 때문이다. B-Frame은 앞뒤 프레임의 데이터를 모두 활용하기 때문에 그만큼 정보를 생략할 수 있다. 그래서 B-Frame은 P-Frame의 25% 정도의 크기만 사용한다.

B-Frame도 P-Frame처럼 Motion Vector와 예측 보정을 위한 변환값들을 사용한다. B-Frame은 I-Frame과 P-Frame을 참조하지만 HEVC 및 VVC 등의 최신 영상 코덱에서는 B-Frame도 다른 B-Frame을 참조할 수 있다.

이런 형태를 띄게 된다. Copyright: Cmglee, CC BY-SA 4.0.

이런 형태를 띄게 된다. Copyright: Cmglee, CC BY-SA 4.0.

유체이탈의 원인

문제는 I-Frame을 담은 통신 패킷이 손실될 때 발생한다. 주위의 P-Frame, B-Frame을 계산할 기준값이 사라진 것이다. 물론 좋은 동영상 스트리밍 프로그램일 경우 미리 여러 알고리즘을 활용해 통신 패킷 손실을 미리 감지하고 패킷을 다시 요청하겠지만 서버가 불안정하거나 스트리밍 프로그램이 부실한 경우 I-Frame이 손실된 것을 감지하지 못한다.

이때 I-Frame이 유실되고 그다음의 P-Frame, B-Frame만 도착하면 잘못된 I-Frame에 변화값을 적용하게 되는 것이다. 그렇게 한석원 선생님은 스스로를 인수분해 해버리신 것이다.

백문이 불여일견

아직도 잘 이해가 되지 않는다면 직접 눈으로 확인해보자. FFmpeg 등의 상용 비디오 라이브러리를 이용해 의도적으로 동영상 파일의 프레임 정보를 변질시킬 수 있다. 이런 형식의 예술 분야를 Datamoshing이라고 한다.

PythonFFmpeg 라이브러리를 활용해 뮤직비디오 내 I-Frame을 손상시켜 인위적으로 유체이탈 현상을 발생시켰다.

  • 영상 내의 모든 I-Frame들을 바로 이전의 프레임 (아마도 P-Frame과 B-Frame) 값으로 덮어씌웠다. 때문에 I-Frame으로 인한 신규 정보는 존재하지 않는다. 그래서 화면은 변하지 않지만 인물들의 동작이 나타난다. 잘못된 기준점(I-Frame)에 변화량(P-Frame과 B-Frame)이 적용되었기 때문이다.
  • 중간 중간 화면의 일부가 순간적으로 깔끔하게 보일 때가 있다. 이는 P-Frame도 신규 이미지 정보를 가지고 있을 수 있기 때문이다. 모든 부분이 신규 정보인 I-Frame들은 삭제되었기 때문에, 일시적으로 화면이 깔끔하게 보이더라도 화면 전체가 깔끔하지는 않을 것이다.
  • 영상이 깨질 때 작은 모래처럼 흩어지는 것이 아니라 눈에 잘 보이는 커다란 정사각형 단위로 깨지는 것을 알 수 있을 것이다. 이는 영상 데이터 압축이 각 픽셀마다 계산되는 것이 아니라 여러 픽셀을 묶은 매크로블록 (코딩 트리 유닛) 단위로 계산되기 때문이다. 이런 현상이 방송 도중 일어나면 흔히 "깍두기 현상"이라고 부른다.

I-Frame이 없다는 것을 감안하고 영상을 보고 나면 I-Frame, P-Frame, B-Frame 간의 관계가 조금 더 명확하게 이해가 될 것이다. FFmpeg를 이용해서 동영상을 손상시키는 방법은 나중에 기회가 되면 다뤄보도록 하겠다.


  • 글에 오류가 있다면 mail@chosunghyun.com으로 제보 부탁드립니다.
  • "다만 오차가 점점 커지지 않도록 B-Frame은 다른 B-Frame을 참조하지 않는다. I-Frame 또는 P-Frame만 참조한다"는 문장이 잘못되었습니다. HEVC 및 VVC 등의 영상 코덱에서 B-Frame은 다른 B-Frame을 참조할 수 있습니다. 제보 정말 감사드립니다. Credit: (익명 요청)
잠깐!
  • 이 글이 작성된지 4년 이상 지났습니다.
  • 새로운 일들이 일어나기 충분한 시간입니다.
  • 저는 이 글에 더 이상 동의하지 않을지도 모릅니다.
Google에서 새로운 자료 찾아보기

집계 중...

배너

통계 지표데이터
사용자400명
거래 횟수2,900건
누적 지불액460만 4,210원
누적 거래액1,731만 9,300원

민사페이는 민사고 여름 축제를 위해 제작된 간편 결제 시스템이다. 학생증의 RFID 칩을 사용하여 미리 학생증과 연동된 계정에 현금을 충전해서 사용한다. 축제가 끝난 뒤 미사용 금액은 환불이 가능하다. 축제 당일 실제 서버에서 사용된 모든 코드와 익명 결제 기록은 GitHub에서 확인할 수 있다.

고등학교 축제에 간편 결제가 왜 필요한가?

많은 학교에는 각자의 여름 축제가 있다. 민족사관고등학교(민사고)는 여름 축제를 민족제라고 한다. 학생들이 부스를 열어서 먹거리와 기념품을 판매하고, 영화관이나 댄스클럽 등을 운영한다. 방송부원들이 제작한 1시간가량의 영상들을 다 같이 감상하기도 한다. 이 외에도 많은 행사들이 있지만 그중 단연 으뜸은 물놀이이다. 민족제는 여름방학 하루 전에 있기 때문에 오후 내내 물놀이가 있다. 참고로 민족제에 대해 2년 전에 제작한 영상이 있다.

민족제에는 판매되는 물품이 꽤 많다. 때문에 현금 거래는 어려움이 많았다. 이 문제를 해결하기 위해 민족제 화폐라는 제도가 생겼다. 학생회(금융 정보부)가 은행의 역할을 하여 현금을 관리하고 민족제 화폐를 교환해주는 것이다. 매년 새롭고 참신한 화폐가 제작되었기 때문에 상징성과 소장성이 높기도 했다.

2018년 민족제 화폐 디자인. 민사고 학생회 중 행정, 입법, 사법 위원장의 사진과 이름이 포함되어 있었다.

2018년 민족제 화폐 디자인. 민사고 학생회 중 행정, 입법, 사법 위원장의 사진과 이름이 포함되어 있었다.

하지만 여러 문제가 있었다. 우선 환경 친화적이지 못했다. 매년 몇백, 몇천 장의 지폐가 새로 인쇄되고 이후 그대로 폐기 처분되었다. 장기간 사용되는 화폐라면 모를까, 단 하루 사용하기 위해 종이 수백 장을 낭비한다는 것은 불필요하게 자원을 낭비하는 것이었다.

또한 언급한 물놀이도 문제를 만들었다. 민사고 학생들은 (당연하게도) 특수 화폐를 제작할 능력이 없다. 기껏해야 조금 두꺼운 종이에 인쇄해서 사용하는 정도였다. 그래서 오후 내내 물놀이를 하고 나면 종이 화폐가 모두 물에 젖어 찢어졌다. 수만 원 상당의 수해를 입은 수재민들이 발생할 정도였다! 이 때문에 민사고의 학생들은 종이 화폐를 사용하지 않으면서 결제를 할 수 있는 방안을 모색하게 되었다.

아이디어

학생회에서 나에게 처음으로 간편 결제 시스템 (이하 민사페이) 개발을 제안했다. 나도 비슷한 고민을 한 적이 있었기 때문에 민사페이의 개발 가능성, 결제 시스템의 필요조건 등 이모저모를 깊게 따져보았다. 그리고 바로 제작에 도입할 수 있을 정도로 전체적이고 구체적으로 구상했다. 하지만 결국 고민 끝에 제작하기 어렵다는 판단을 내렸다.

나는 개발자의 책임을 깊게 이해하고 있다. 개발자들은 요구된 기술 사양만 맞추어 단순 납품하는 사람들이 아니다. 기술을 매개로 컴퓨터와 소통하여 사람들의 삶을 가장 파격적으로 변화시킬 수 있는 잠재성을 가진 사람들이다. 개발자들은 사람들의 삶을 실질적으로 변화시킬 강력한 힘을 가지게 되며, 그 힘을 사회를 향상시키는 데 사용하는 것이 책임이다. 그렇기에 개발자들은 자신의 코드 한 줄이 얼마나 큰 파장을 불러올 수 있는지 이해해야 한다.

욕심이 나기는 했다. 하지만 나는 일반적인 개발은 해본 적이 있으나 보안이 강조되는 프로젝트는 맡아본 적이 없다. 물론 모든 일에 처음은 있겠으나, 민사페이와 같은 큰 프로젝트로 시작하라는 것은 위험이 너무 컸다. 수많은 질문이 머리를 오갔다. 만일 코드 한 줄이 잘못돼서 통장 잔고가 모두 사라진다면? 결제 기록이 꼬인다면? 해킹을 당해서 금액이 조작당한다면? 이런 극단적인 상황까지 가지 않는다고 해도 서버가 마비되어 결제를 전혀 할 수 없게 된다면?

세상은 대담한 사람을 칭송하지만 나는 그보다 신중함을 선호한다. 소신과 만용은 한 끗 차이이다. 특히 돈을 다루는 시스템은 버그가 전혀 없어야 한다. 동작하기만 하면 되는 것이 아니라 전천후 오류 없이 돌아가는 견고한 코드여야 한다. 시도하면 불가능할 것 같지는 않았지만, 철저히 무경험자인 내가 단번에 "아무 일 없을거야"고 생각하는 것은 내 능력에 대한 과신이었다. 그래서 제작이 어려울 것 같다고 이야기했다.

구글 설문지를 이용한 결제?

그럼에도 불구하고 학생회는 민사페이를 여전히 진행하겠다고 했다. 처음에 그 소식을 듣고 학생회가 전문 업체에 외주를 준다고 생각했다. 차라리 그게 나을 것 같았다. 최소한 시스템 보안의 기본은 다할 것이기 말이다. 하지만 학생회의 생각은 달랐다. 학생회 부원들이 직접 개발을 한다고 했다. 무려 구글 설문지 기반으로.

앞서 민사페이를 구상하면서 집중적으로 풀려 했던 문제는 인증에 관한 것이었다. 구매자가 무언가를 구매할 때 판매자와 개발자에게 비밀번호가 공유되지 않는 동시에 그 구매자를 식별해서 정확한 연산을 할 수 있어야 하고, 구매자가 원하지 않을 때는 결제가 되지 않도록 안전 장치를 마련해야 한다. 그래야 구매자가 잔고보다 더 많은 돈을 결제할 수 없을 것이고, 악의적으로 다른 계좌로 결제하지 못할 것이다. 이 인증이 실시간으로 이루어져야 한다. 하지만 구글 설문지으로는 그 방법이 전혀 생각나지 않았다. 어찌어찌 다른 것들을 해결한다고 어떻게 실시간 인증을 할 것인지에 대한 궁금증은 풀리지 않았다. 그래서 학생회에게 더 자세히 물어보았다. 학생회의 구상은 다음과 같았다.

⚡️ Google Form을 이용한 결제 시스템 동작 원리
  • 학생 한 명 당 구글 설문지를 하나 만든다. (총 400명가량)
  • 그 구글 설문지 링크를 각자 QR 코드로 제작한다.
  • 학생들에게 방수 놀이공원 팔찌의 형태로 QR 코드를 나눠준다.
  • 결제를 할 때 그 팔찌를 보여준다.
  • 판매자는 QR 코드를 스캔해서 개인 정보 보호 창으로 구글 설문지를 연다.
  • 구글 설문지에는 가격, 부스를 입력한다.
  • 설문지를 제출한 뒤 창을 닫는다.

즉 각자 생성되는 구글 설문지의 주소를 암호처럼 사용하는 것이다. 개인 정보 보호 창으로 열기 때문에 나중에 누가 악의적으로 다른 계좌를 공격하려 해도 보호를 할 수 있을 것이다. 만약 이런 제도를 도입한다면 후불제를 도입해야 했을 것이다. 하지만 개발자의 관점에서 이 방식은 너무나도 끔찍했다. 짧게 생각해도 이런 문제가 있었다.

💣 찾은 문제점들
  • 400명가량의 설문지는 어떻게 관리하는가?
  • (악의적이든 아니든) 팔찌를 잃어버리는 사람이 나올 것이다. 그런 사람들의 돈을 정산하기 위해서는 구글 설문지마다 소유자를 기록해두어야 할 텐데 이를 어떻게 관리할 것인가? 구글 설문지에 이름을 저장해둔다면 팔찌가 뒤섞이지 않고 정확하게 학생들에게 전달할 수 있는가?
  • 판매자가 개인 정보 보호 창으로 열지 않아도 학생들은 알아차릴 수 없을 것이다. 그 경우 설문지가 방문 기록에 남아있으므로 악의적인 공격이 가능해지는데 이를 어떻게 방지하는가? 설문지에 암호를 추가하여 암호가 일치하는 결제 기록만 후불제로 계산하는 방식도 동작은 하겠다. 하지만...
  • 판매자가 부스를 직접 선택해서 금액을 입력한다면, 부스 이름이나 금액을 잘못 입력하는 사람이 생길 것이다. 금액을 잘못 입력하는 문제는 당사자들에게 물어봐 해결할 수 있을지 몰라도, 부스를 잘못 입력하는 경우 누가 잘못 입력한 것인지 찾는 것이 매우 고통스러울 것이다. 가령 전체 기록 중 20건의 판매자 부스가 뒤바뀌었다고 해보자. 데이터로써 가치는 있을 것이며, 누가 잘못 기록한 것인지 어떻게 추적할 것인가?
  • 나중에 잔고 계산 및 통계는 어떻게 처리할까? 400명의 엑셀 파일을 언제 추출해서 통합하는가? 게다가 민족제 다음날은 방학식이다. 학생들이 선불로 결제한 경우 돈이 아까워서라도 돈을 찾으러 오지만 후불제인 경우 귀찮아서 안 올 가능성도 염두에 두어야 한다. 돈을 지불하라고 수십명에게 재촉 문자를 보내는 것은 여간 번거로운 일이 아니다. 반면 선불제라면 불가피할 경우 직접 계좌나 전화번호로 일단 송금해주면 된다. 굳이 힘들게 수십명에게 문자를 돌릴 필요가 없다.
  • 설문지를 제작하는 것은 학생회의 구글 계정이다. 학생회의 구글 계정은 여러 명이 사용할 것인데 (물론 제한된 접근권이 있겠지만, 400명의 설문지를 제작할 때 한 사람만 일하지는 않을 것이라 추측) 학생회 중 누군가 악의를 가지고 설문지를 조작한다고 할 때 편집 기록만으로 알아낼 수 있는가?
  • 이 모든 과정에서 사람의 실수가 일어나지 않을 수 있는가?

물론 이상적인 환경에서는 동작할 것이다. 하지만 그 과정에서 수많은 혼선이 있을 것이고 학생들의 불편함이 생길 것이다. 차라리 그럴 바에는 내가 제작을 맡은 뒤 내 방식대로 제작하는 것이 낫다고 생각했다. 학생회와 여러 차례 의견을 주고받은 결과 민사페이를 시도해보는 것으로 결정하게 되었다.

Development

다행히도 나와 뜻을 함께하는 친구를 만났다. 생각하는 제작 방향도 상당히 동일했다. 나는 이전에 구상했던 민사페이에 대해서 설명을 해주었고, 서로 조율을 하며 실제 프로덕트를 제작하였다. 카페에서 몇 번 만나기도 했다. 나는 DNS 설정 및 관리, Front end 제작을 맡았다. 다음은 민사페이가 완성될 때까지 우리가 했던 고민들이다.

🤔 고민한 내용들
  • 현금을 거래에 사용하기는 어려울 것이다. 사업자 등록도 되지 않았고 단 하루 사용하는 것이기 때문에 PG 등의 결제사를 이용하기는 어려울 것이다. 그렇다고 토스나 카카오페이 같은 간편 송금 서비스를 사용하기에는 스마트폰이 없는 학생들도 많았다. 그렇기 때문에 유저(구매자) 쪽에는 전자 장비가 없어야 한다. 대신 각 판매자의 부스마다 컴퓨터를 설치해두어야 한다.
  • 사람 손이 전혀 가지 않는 완전 자동 시스템은 제작이 불가능하다. 특히 현금을 다루는 부분에서는 어쩔 수 없이 학생회(금융 정보부)의 도움을 빌려야 한다. 신뢰할 수 있는 금융 정보부원들이 직접 돈을 확인하고 데이터를 입력해주는 과정을 거쳐야 한다.
  • 최소한 판매자와 구매자를 잘못 입력할 일이 없도록 해야 한다. 앞서 언급했듯이 다른 오류는 바로잡을 수 있어도 이 부분은 절대 잘못되면 안 된다. 즉 구매자와 판매자들이 자신들의 이름을 잘못 기록하는 실수를 하지 않을 것이라 신뢰하면 안 된다. 대신 결제 중 절대 착오가 날 수 없는 환경을 설계해야 한다.
  • 부스는 쉽게 번잡해진다. 그런 과정에서 아이디와 비밀번호를 일일이 입력하고 있게 된다면 불편함을 야기할 것이다. UX 적인 면에서 원터치 결제를 할 수 있으면 좋겠다.
  • 이를 위해서는 학생증을 이용하면 좋을 것이다. 학생증 내부에는 이미 개인 식별이 가능한 값이 들어가 있다. 즉 학생증의 고유 번호를 비밀번호로 사용하는 것이다. 이렇게 되면 학번을 아이디로, 학생증의 고윳값을 비밀번호로 사용이 가능하다. 비밀번호가 서로 다르다는 것이 보장이 되기 때문에 비밀번호(학생증 값) 만으로 결제가 가능할 것이다. 판매자는 판매자 계정으로 로그인을 해야 하고, 구매자는 학생증을 소지해야 결제할 수 있도록 하자.
  • 결과적으로 선불 교통 카드와 비슷한 모습을 하게 된다. 미리 학생증과 연동된 계정에 현금을 충전해서 현금 대신 사용한다. 당연히 미사용 금액은 환불이 가능하다.

의견이 갈리는 부분은 2가지였다.

첫째는 웹 기반으로 제작할 것인지 프로그램 기반으로 제작할 것인지에 대한 문제였다. 그 친구는 Windows exe 기반의 프로그램을 제작하자 했다. 나는 멀티 플랫폼을 지향했고 exe 프로그램들에 큰 불신이 있었기에 웹을 주장했다. 예상했겠지만 난 Mac을 쓰는 개발자이다.

둘째는 원터치 결제를 할 때 RFID를 사용할 것인지 바코드를 사용할 것인지에 관한 문제였다. 학생증에는 RFID 칩과 바코드가 둘 다 있다. RFID를 사용하게 된다면 약 10대가량의 RFID 리더를 구매해야 하는데, 이 비용이 10만 원 정도 되었다. 때문에 나는 노트북 내장 카메라를 이용하여 바코드를 스캔하는 방법을 주장했다. 올해 시범 운영하는 제도라 자치회비를 사용하게 된다면 제도의 실효성에 대한 의문이 생길 것 같았기 때문이다.

결국 웹을 고르는 동시에 RFID를 사용하기로 했다. 하나씩 양보한 셈이다. 나는 카메라를 이용한 바코드 인식이 브라우저 상에서 성능에 그리 좋지 않을뿐더러 인식이 그리 빠르지 않다는 것을 알게 되자 RFID를 사용하자는 주장을 받아들였다.

왼쪽부터 메인 페이지, 관리자 페이지, 계좌 조회 페이지.

왼쪽부터 메인 페이지, 관리자 페이지, 계좌 조회 페이지.

그런데 그것이 실제로 일어났습니다

앞서 했던 고민 중 서버가 터지면 어쩌지라고 고민했던 것을 기억하는가?

축제 당일 3학년 학생들은 오전 내내 자습을 하기 때문에 학교 자습실에 있었다. 그러다가 갑자기 전화가 연달아 울리는 것이다. 전화를 받아보니 우려했던 일이 터진 것이었다. 결제 서버가 마비되었다. 부리나케 내려가서 상황을 파악해보았다. 구석에 앉아 같은 개발자 친구와 함께 이유를 파악하려 해보았다.

데이터베이스 플랜을 업그레이드 하는 순간. $10가 엄청난 혼란을 야기할 수 있다.

데이터베이스 플랜을 업그레이드 하는 순간. $10가 엄청난 혼란을 야기할 수 있다.

데이터베이스가 응답을 하지 않고 있었다. 생각보다 어이없는 문제였다. 처음 데이터베이스를 구축할 당시 무료 플랜만으로 충분할 것이라 판단해서 무료 플랜을 사용했는데, 오전 10시 전후로 거래가 일시에 몰리자 Free Tier를 넘어간 것이다. 9달러 플랜을 결제했고 그 이후로는 서버가 다시 터지지 않았다.

민사페이 공백기에는 각자 컴퓨터에 엑셀로 학생 이름과 금액을 기록해두었었다. 나중에 그를 모두 취합해서 외상 부스를 만들어 돈을 후불하는 것으로 사태를 수습했다.

나중에 기록을 보며 10시 17분 55초에 결제가 멈춰서 10시 31분 10초에 결제가 다시 되기 시작했다는 것을 알게 되었다. 이전에는 분당 결제가 평균 10회, 많게는 30회도 일어난 반면 복구 직후에는 한동안 결제 빈도가 낮았다는 점이 당연하면서도 흥미롭게 다가왔다. 이 기록이 궁금하다면 여기를 보기 바란다.

해당 결제 기록

해당 결제 기록

Results

#1. 민사페이

리얼 서버에서 사용된 민사페이의 전체 코드가 GitHub에 업로드되어있다. 민사페이 또한 데이터베이스를 초기화하여 복구해두었다.

난 여전히 이 코드의 보안이 극도로 취약할 것이라는 의심이 든다. 코드를 오픈소스로 공개했기 때문에 코드 상의 취약점 하나를 다른 분께서 발견해주셔서 사전에 고칠 수 있었다. 하지만 여전히 수많은 보안 문제가 있을 것이다. 또한 결제를 하기 위해 RFID를 인식하는 순간 값이 복사되어 저장할 수 있다는 취약점은 해결되지 않았다.

#2. 결제 데이터

빅데이터를 공부하는 민사고 학생들에게 조금 더 밀접하게 연관이 있는 동시에 흥미로운 소재를 주고 싶었다. 또한 민족제 부스 운영자들에게도 성공적인 부스 운영을 위한 참고 자료를 주고 싶었다. 그래서 모든 결제 기록을 공개하기로 했다. 다만 한 가지 문제점이 있었다. 개인 정보 보호 문제이다. 비록 사소한 정보일지라도 개인의 금융 기록인데, 그 기록이 공개된다는 것은 큰 개인 정보 보호 문제가 있었다.

때문에 Jupyter Notebook으로 짧은 스크립트를 작성했다. csv 확장자로 원본 데이터를 입력하면 선택한 열을 익명화하는 스크립트이다. 당연히 동일한 실명은 같은 익명값로 출력된다.

import pandas as pd
Dataframe = pd.read_csv('raw.csv')

def anonymize(df, targetColumn):
anon = {}
id = 0
for x in range(len(df)):
user = df.loc[x, targetColumn]
if user in anon:
df.loc[x, targetColumn] = anon[user]
else:
if id < 10:
unknown = "#00" + str(id)
elif id < 100:
unknown = "#0" + str(id)
else:
unknown = "#" + str(id)
anon[user] = targetColumn + str(unknown)
id += 1
df.loc[x, targetColumn] = anon[user]

anonymize(Dataframe, 'user')
anonymize(Dataframe, 'booth')

Dataframe.to_csv("anonymized.csv", mode='w')

데이터는 여기에서 확인할 수 있다.

Developer Note

이 글을 읽고 자신의 학교에서 비슷한 시스템을 도입하고 싶은 사람들이 분명히 있을 것 같다. 만일 이 코드를 사용하게 된다면 검증을 확실하게 하길 바란다. 아니, 아예 재설계하길 바란다. 참고로 민사페이 시스템은 MIT 라이선스이다.


마지막으로

개선점이 분명히 많다.

우선 다시 봐도 아쉬운 코드가 많다. 그 당시에도 시간을 맞추기 위해 타협을 많이 했던 기억이 난다. 결제 취소 등의 기능이나 여러 인터페이스 단계의 안전장치들들도 구상했으나 축제 전에 완성하기 위해 개발 시작도 못했었다. 시간이 조금만 주어진다면 더 나았을 것 같다는 생각이 들면서도, 그게 내 능력 부족이라는 생각이 들었다.

또한 보안에 자신이 없었기 때문에 처음에는 저장소를 조용하게 유지했다. 감춰서 유지되는 보안은 절대 좋은 보안이 아니라는 점을 알고 있으면서도 그런 악습을 되풀이하는 내 모습이 참 모순적이었다.

마지막으로 사람의 실수에서 자유롭지 못했다. 휴먼 에러가 몇 건 생겼다. RFID 값은 긴 숫자열인데, 충전 금액 칸에서 RFID를 태그하는 바람에 충전 금액이 Integer.MAX_VALUE 같은 값이 된 것이다. 간단하게 50,000원 이상 추가하는 경우 재확인을 하는 인터페이스 등을 적용했으면 해결될 문제였는데 이런 실수가 발생할 것이라 예상하지 못했다.

전반적으로 되돌아볼 때, 실무에 적용하는 첫 프로젝트였다는 점에서 나에게 큰 경험이었다. 나쁜 개발 패턴을 알고 있으면서도 현실과 타협하는 내 모습을 발견하게 되었고, 아는 것과 실제로 할 수 있는 것은 분명 다르다는 것을 뼈저리게 느꼈다. 아는 것에는 문턱이 없지만 실제 행동으로 옮길 때는 극심한 시간적, 환경적 스트레스가 동반되기 때문이다. 지식의 실무 대입은 보이는 것보다 멀리 있었다.

그래도 정말 즐거운 프로젝트였다.

마지막으로 민사페이가 있도록 도와준 모든 학생들에게 큰 감사를 전하고 싶다.

  • 같이 민사페이를 개발해준 안주언 동료 개발자 (GitHub, Blog)
  • 민사페이 정책을 추진해준 행정위원회, 금융정보부
  • 민사페이 서버의 보안 이슈를 제보해주신 개발자분들
  • 민사페이의 서버 다운으로 불편함을 겪고도 이해해줬던 사람들
  • 그 외에 민사페이를 사용해준 400명의 사람들

모두 정말 감사합니다!

👋

👋