{ "version": "https://jsonfeed.org/version/1", "title": "LiteHell의 블로그", "home_page_url": "https://blog.litehell.info", "feed_url": "https://blog.litehell.info/feed/json", "description": "LiteHell의 개인블로그입니다. 프로그래밍이나 제 개인적인 일상에 관련된 글들이 올라옵니다.", "icon": "https://gravatar.com/avatar/837266b567b50fd59e72428220bf69b1", "author": { "name": "Yeonjin Shin", "url": "https://litehell.info" }, "items": [ { "id": "rewriting_blog_2025", "content_html": "

개론

\n

기존 블로그는 next.js를 이용했다. next.js만의 기능을 활용하기 위해서라기보단 그냥 SSG(Static Site Generation) 편하게 하려고 next.js를 썼다.

\n

다 좋은데 한가지 불편한 점이 있다. 이미지를 첨부할 때 귀찮다는 것이다.

\n

왜 귀찮은가?

\n

필자는 블로그 글을 작성할 때 VS Code를 쓴다.

\n

next.js는 public/ 안에 이미지를 넣어야 한다. 그리고 블로그 글은 posts/에 쓴다.\n따라서 VS Code 내에서 마크다운 미리보기를 열면 다음과 같이 이미지가 표시되지 않는다.

\n

\"VS

\n

어떻게 해결할 수 있을까?

\n

재작성

\n

쿨타임도 돌았겠다. 그냥 블로그를 다시 만들면 된다. 고치는 것보다 새로 만드는 게 더 재밌을 것 같았다.

\n

이번에는 webpack 대신 esbuild를 쓰려 한다. esbuild가 더 빠르다고 들어서 한번 써보려 한다. esbuild는 configuration 파일을 쓰지 않고 명령행에 모든 옵션을 지정한다. 따라서 package.json에 그냥 명령행 옵션을 다 때려박았다. (스크립트 파일을 만들어도 되지만 옵션이 그리 많지 않아서 그럴 필요성을 못 느꼈다. 나중에 옵션이 좀 많아지면 스크립트 파일로 분리하려 한다.)

\n

구상

\n

내 블로그는 딱히 동적인 컨텐츠가 없다. 그래서 이번에는 순수 HTML로만 출력하도록 작성했다.

\n

순수 HTML로 출력되는 클래식한 방법은 템플릿 언어를 이용하는 방법이다. 하지만 템플릿은 타입(Type)이 견고하지 못하기 때문에 유지보수가 어렵다. 예시로 다음 코드는 Typescript 빌드 과정에서 오류가 발생하지 않는다.

\n
const exampleTemplateString = 'Hello, {{name}}!'\nvar template = Handlebars.compile(exampleTemplateString);\nconsole.log(template({ Name: \"John Doe\" }));\n
\n

또한 프론트엔드를 여러 컴포넌트로 나누어 개발하는 데에는 템플릿 언어보다 React나 Vue 같은 모던 프레임워크 라이브러리가 더 적합하다. 이 또한 템플릿 언어가 Type이 견고하지 않음에서 기인한다. React나 Vue 같은 건 컴포넌트 매개변수 잘못 적으면 바로 IDE에 빨간 줄이 쳐지는 데 템플릿 언어는 그렇지 않다. 물론 React나 Vue를 쓰면 styled-component나 Module CSS 같은 걸 쓸 수 있는 점도 있다.

\n

그렇다면 React를 이용하여 개발하되 결과물은 JS가 필요없는 순수 HTML로 출력할 수 있을까?

\n

renderToStaticMarkup

\n

당연히 가능하다. React에서 제공하는 renderToStaticMarkup 함수를 이용하면 된다. 이를 이용하면 React 컴포넌트들이 렌더링된 HTML 코드를 얻을 수 있다.

\n

Javascript가 없는 HTML 코드이니 상호작용은 불가능하며, 또한 hydration에도 이용될 수 없다. hydration을 염두에 둔다면 renderToString을 이용해야 한다.

\n

구조

\n

따라서 다음과 같은 구조로 작성했다.

\n
    \n
  1. 게시글 목록을 가져와서 정렬한다.
  2. \n
  3. 게시글 목록에서 이용가능한 route(e.g. /post/loremipsum, /category/Linux/1)들을 모두 계산한다.\n
    export default async function getRoutes(posts: BlogPost[]) {\nconst totalPages = Math.ceil(posts.length / postCountPerPage);\n\nreturn [\n    \"/\",\n    \"/tags\",\n    \"/categories\",\n    \"/license\",\n    ...range(1, totalPages).map((i) => `/page/${i}`),\n    ...posts.map((i) => i.name).map((i) => `/post/${encodeURIComponent(i)}`),\n    ...getTagRoutes(posts),\n    ...getCategoryRoutes(posts),\n];\n}\n
    \n
  4. \n
  5. 정적 파일들(robots.txt, 이미지 파일 등)을 복사한다.
  6. \n
  7. 각 route별로 html 파일을 생성한다.
  8. \n
  9. RSS/Atom/JSON 피드를 생성한다.
  10. \n
\n

결론

\n

\"VSCode에

\n

보다시피 마크다운 미리보기에 이미지가 잘 보인다. 굿~

\n

Javascript가 이전보다 많이 줄어든(사실상 없는) HTML이긴 하지만 댓글을 작성하려면 Javascript를 활성화해야 한다. 이는 GitHub Site를 이용하여 호스팅하는 이상 어쩔 수 없다.

\n

어쨌든 해피엔딩~

\n

TO-DO

\n

devServer를 똑바로 만들어야 한다. 지금은 만들다가 말아서... 나중에 시간 날때 완성해야 한다.

\n

지금 코드는 Blue-Green 전략으로 수정이 생기면 재빌드하는 그런 코드로 짜다가 말았는데 지금 생각해보니 헛짓거리 같다. 다시 만들어야지...

\n

dev-server도 완성했다. 잘 된다. --2025. 06. 21.

", "url": "https://blog.litehell.info/post/rewriting_blog_2025", "title": "블로그 재작성한 이야기", "summary": "튜닝의 끝은 순정", "image": "https://blog.litehell.info/asis.png", "date_modified": "2025-06-18T13:04:30.989Z" }, { "id": "reinventing_scroll", "content_html": "

서론

\n

이 글은 내가 주식회사 슈르에서 인턴으로 일하던 2023년 12월 ~ 2024년 2월 사이의 이야기이다.

\n

이때 나는 고인물테스트(https://goinmultest.pro, 현재는 운영종료)의 프론트엔드를 개발하고 있었다. (관련 글: 2024년의 회고) 여기서 스크롤 관련하여 삽질을 엄청 많이 하게 됐는데 이에 대하여 다루고자 한다.

\n

본문

\n

UI 컨셉

\n

영상을 보자.

\n\n

영상을 보면 메인 페이지가 다음과 같이 생겼다.

\n

\"메인

\n

맨 위 \"웹툰\", \"HOME\", \"K-POP\" 버튼은 카테고리 버튼이다. 어떤 카테고리 버튼이 선택되냐에 따라 아래 표시되는 게시글이 달라진다.

\n

\"카테고리

\n

컨텐츠는 좌우 방향으로 무한히 스크롤되야 한다. 예를 들자면 왼쪽으로 스크롤을 엄청 많이 해도 끊임없이 컨텐츠가 반복되야 한다.

\n

또한 하단 컨텐츠는 정중앙에 스내핑되야 한다. 영상을 잘 보면 컨텐츠가 스내핑되고 있는 것을 알 수 있다.

\n

이러한 UI를 CSS, JS(추후 화가 나서 TS로 재작성했다), HTML만으로 구현해야 했다.

\n

라이브러리

\n

남의 돈이 가장 좋듯 코드도 남이 만든 코드를 갖다 쓰는 게 가장 좋다.

\n

저런 UI랑 비슷한 UI는 Carousel이다. (아닐 수도 있다. 만약 필자가 틀리다면 알려주면 감사하겠다.) 그래서 라이브러리르 찾아볼까 했는데

\n
    \n
  1. 컨텐츠가 좌우가 아닌 상하로도 스크롤이 되야하는 특성상 (위 사진 참고) 마음에 드는 라이브러리가 딱히 없었고
  2. \n
  3. 라이브러리 괜찮은 건 React로 된 게 많은데 React를 쓰지않고 만들고 있었다.
  4. \n
\n

그래서 라이브러리를 딱히 쓰지 않게 됐다. 왜 React나 Vue를 쓰지 않는 지 궁금하다면 2024년의 회고 글을 보라.

\n

네이티브 스크롤

\n

라이브러리가 아무리 노력해도 웹 브라우저가 제공하는 스크롤 기능을 이길 수 없다. 그렇기 때문에 가능하다면 웹 브라우저의 스크롤 기능을 활용하는 게 이득이다.

\n

...이상적으로는 그렇다. 내가 Safari 혐오가 생긴 게 이때부터였다.

\n

처음에 CSS의 scroll-snap 기능을 써보려 했는데 얘는 Gecko랑 Blink에서의 동작이 서로 달랐다. 그러니 패스.

\n

지금 시점에서 정확히 기억나진 않지만 네이티브 스크롤만으로 해결하려 하니 사파리에서 버그가 나거나 크롬에서 버그가 나거나 둘 중 하나인 케이스가 너무 많았다. 아무리 해결하려 해도 답이 없더라....

\n

그래서 결국 네이티브 스크롤을 쓰지 않고 바퀴를 재발명하게 됐다.

\n

좌우 무제한 스크롤의 재발명

\n

이제 스크롤을 재발명하기로 했다. 어떻게?

\n

컨테이너와 아이템

\n

(이 글에서의 컨텐츠 = 이미지에서의 \"post\"이다.)

\n

일단 카테고리 버튼은 신경쓰지 말고 컨텐츠만 집중해보자. 컨테이너의 자식을 아이템이라 하자. 아래와 같이 컨테이너와 아이템이 있다. 핑크색이 컨테이너고 청록색이 아이템이다. 컨테이너 바깥에 위치한 아이템은 보이지 않으며, 컨텐츠 컨테이너의 너비와 컨텐츠 아이템의 너비는 항상 동일하다고 가정하자.

\n

\"컨테이너와

\n

이용자가 좌우로 스크롤을 하면 이용자의 스크롤에 따라 아이템들의 위치를 모두 동일하게 이동시킨다.

\n

\"이동된

\n

그러면 컨테이너 외부의 아이템은 보이지 않으므로, 이용자에게는 좌우 스크롤이 되는 것처럼 보여진다.

\n

CSS와 transform

\n

positionabsolute인 요소는 조상 요소중 positionrelative이거나 absolute인 가장 가까운 요소를 기준으로 위치가 결정된다는 것은 CSS 상식이다.

\n

컨테이너의 positionrelative로 하고, 아이템에 다음과 같은 CSS를 적용하자.

\n
position: absolute;\ntop: 0px;\nleft: 50%;\n
\n

그러면 사진과 같이 모든 아이템이 컨테이너의 정중앙으로 정렬된다.

\n

\"정중앙에

\n

우리 이제 여기서 생각을 잠깐 해보자. 저 정중앙에 위치된 아이템을 좌우로 각각 \"적절히\" 이동시키면 아래와 같은 이미지들을 구현할 수 있지 않을까?

\n

\"컨테이너와

\n

\"이동된

\n

여기서 transform이 등장한다. transformtranslateX 함수를 이용하면 특정 HTML 요소를 X축으로 이동시킬 수 있다.

\n

위 이미지를 프레임이라 할 때, 컨텐츠 아이템들의 X축 위치는 다음 두가지 정보로부터 유도될 수 있음은 자명하다.

\n
    \n
  1. 컨테이너의 정중앙에 가장 가까운 컨텐츠가 어떤 컨텐츠인지
  2. \n
  3. 1번의 컨텐츠가 컨테이너의 정중앙으로부터 얼마나 떨어져 있는지의 방향(왼쪽/오른쪽)과 거리를 가진 값
  4. \n
\n

이에 대하여 코드에 상세히 설명한 주석이 있다. 그 주석은 다음과 같다.

\n
/**\n         * 2개의 예시로 알아보는 translate값 계산 알고리즘\n         * 참고: 모든 예시에서 root의 너비=child를 가정함.\n         *\n         * 첫번째 예시\n         *           ________________\n         * 1. 위와 같이 너비 16px의 root가 있다고 가정한다.\n         *\n         *           __AAAAAAAAAAAAAA(AA) ※ 괄호안은 root 영역의 바깥에 있으므로 보이지 않는다.\n         * 2. basisChildOffset=2, basisChildIndex=(A의 index)라고 가정하고\n         *    basisChild의 translate값을 2로 설정한다.\n         *\n         * 3. root 영역을 보자. root 영역의 왼쪽에는 2px의 여백이 있으며 오른쪽에는 여백이 없다.\n         *\n         *           (BBBBBBBBBBBBBB)BBAAAAAAAAAAAAAA(AA) ※ 괄호안은 root 영역의 바깥에 있으므로 보이지 않는다.\n         * 4. 왼쪽 여백을 채우기 위해 A의 왼쪽에 B가 나타나도록 B의 translate값을 설정한다.\n         *\n         *           BBAAAAAAAAAAAAAA\n         * 5, 끝!\n         *\n         * 두번째 예시\n         *          ________\n         * `1. 위와 같이 너비 8px의 root가 있고, 3개의 child A, B, C가 있다고 가정한다.\n         *\n         *          ________                                (AAAAAAAA) ※ 괄호안은 root 영역의 바깥에 있으므로 보이지 않는다.\n         *  2. basisChildOffset=36, basisChildIndex=(A의 offset)라고 가정하고\n         *     basisChild의 translate값을 40으로 설정한다.\n         *\n         *  3. root 영역이 비어있다.\n         *\n         *          ________                        (CCCCCCCCAAAAAAAA) ※ 괄호안은 root 영역의 바깥에 있으므로 보이지 않는다.\n         *          ________                (BBBBBBBBCCCCCCCCAAAAAAAA)\n         *          ________        (AAAAAAAABBBBBBBBCCCCCCCC)\n         *          ________(CCCCCCCCAAAAAAAABBBBBBBB)\n         *          BBBBBBBB(CCCCCCCCAAAAAAAA)\n         *  4. 위와 같이 루프를 돌면서 root영역을 채운다.\n         *\n         *          BBBBBBBB\n         * 5. 끝!\n         */\n
\n

위 주석을 요약하면 다음과 같다.

\n
    \n
  1. 위에서 말한 두가지 정보로 정중앙에서 가장 가까운 아이템을 X축 이동시킨다.
  2. \n
  3. 컨테이너의 영역을 꽉 채울 때까지 아이템을 하나하나씩 X축으로 이동시킨다.
  4. \n
\n

이제 스크롤의 의미가 바꿨다. 스크롤은 이용자의 상호작용에 따라 \"컨테이너의 정중앙에서 가장 가까운 아이템과 컨테이너와의 거리\"를 적절히 변경하는 방식으로 구현될 수 있다.

\n

(\"컨테이너의 정중앙에서 가장 가까운 아이템의 종류\"는 내부적으로 자동 정규화(normalization)된다고 가정하자)

\n

관성 스크롤

\n

스크롤을 재발명한다는 것은 관성 스크롤(\"Kinetic scrolling\", \"Inertial scrolling\", 혹은 \"Momentum scrolling\"이라 불린다)을 재발명하는 것과 같다.

\n

이에 대해서는 Ariya Hidayat씨의 Javascript Kinetic Scrolling의 도움을 매우 많이 받았다. 이용자가 X축으로 이동한 만큼 스크롤하되 내부적으로는 속도를 계산한다. 속도를 계산할 때는 이동평균하여 값이 튀지 않도록 보정한다.

\n

터치가 끝났을 때 속도와 터치 방향을 확인하여 속도가 특정값 이상이고 방향이 알맞다면 다음 아이템으로 자동 스크롤하고 아닌 경우에는 원래 아이템으로 자동 스크롤하도록 구현한다. (어처피 스내핑해야 하므로 이렇게 구현해도 상관없다.)

\n

컨텐츠 내부에서의 상하 스크롤

\n

smooth-scrollbar 라이브러리를 갖다 붙였거나 브라우저의 네이티브 스크롤를 이용하거나 둘 중 하나였던 거 같은데 정확히는 기억나지 않는다.

\n

카테고리 버튼과의 연동

\n

카테고리 버튼도 좌우로 스크롤 가능하고, 이용자가 스크롤하는 만큼 컨텐츠도 좌우로 움직여졌으면 좋겠다는 요구사항이 있었다.

\n

그러면 컨텐츠와 카테코리 버튼의 상태가 양방향으로 연결되야 한다. 카테고리 버튼이 좌우로 스크롤되면 컨텐츠도 좌우로 스크롤되고, 컨텐츠가 좌우로 스크롤되면 카테고리 버튼도 스크롤되야 한다.

\n

근데 이렇게 하니 버그가 기가 막히더라. 그래서 그냥 컨텐츠가 좌우로 스크롤될 때만 카테고리 버튼이 같이 스크롤되도록 하고, 카테고리 버튼은 그냥 이용자가 직접 좌우 스크롤할 수 없도록 막았다. (위에 첨부한 사진을 자세히 보면 버튼을 스크롤하는 것이 아닌 \"클릭\"하고 있다는 걸 알 수 있다.)

\n

결론

\n

이때를 기점으로 사파리 혐오가 생겼다. 버그 잡느라 되게 힘들었는데 그래도 돌이켜보면 재미있는 경험이었다.

\n

잔버그 고치는 데 시간을 많이 썼다. 다만 커밋 로그 하나하나 보면서 블로그 글 쓰고 싶진 않아서 이 글에서는 생략했다.

\n

웹에서 네이티브 어플리케이션 수준의 UX/UI를 구현하는 건 매우 힘들다는 걸 느끼게 됐다. 세상 일이 참 쉽지가 않다.

\n

비록 퇴사했지만 언제나 번창했으면 좋겠다.

", "url": "https://blog.litehell.info/post/reinventing_scroll", "title": "무한 스크롤 구현하기", "summary": "신나는 바퀴의 재발명", "image": "https://blog.litehell.info/screenshot_1.jpg", "date_modified": "2025-03-13T14:57:39.688Z" }, { "id": "cups_monochrome_printing_bug", "content_html": "

개요

\n

당근마켓에서 복합기를 샀다.

\n

CUPS 서버를 구축해 프린터를 공유하고 테스트 페이지를 인쇄했는데 인쇄가 모노크롬(회색조)으로만 인쇄됐다. 왜일까...

\n

해결법

\n

열심히 검색하면서 찾아본 결과 CUPS에서 프린터를 추가할 때 회색조 인쇄를 기본값으로 하는 버그가 있어서 그렇다.

\n

아래 명령어에서 PRINTER 부분만 프린터 이름으로 바꿔서 실행하면 된다.

\n
sudo lpadmin -p PRINTER -o print-color-mode-default=color\n
\n

끝~

\n

참고 문서

\n", "url": "https://blog.litehell.info/post/cups_monochrome_printing_bug", "title": "리눅스에서 인쇄가 흑백으로만 되는 CUPS 버그", "summary": "아니 왜 색이 안나와", "image": "https://gravatar.com/avatar/837266b567b50fd59e72428220bf69b1", "date_modified": "2025-03-12T15:12:14.190Z" }, { "id": "how_to_run_rpg_maker_mv_on_linux", "content_html": "

서문

\n

RPG Maker MV로 제작된 게임을 리눅스에서 wine으로 실행했는 데 자꾸 로딩에서 걸렸다.

\n

해결법

\n

그냥 Windows를 가상 머신으로 깔까 고민하던 와중 nwjs.dll을 발견했다. 게임 www 폴더 내 package.json의 구조도 nwjs 어플리케이션의 package.json과 유사했다.

\n

그러면 그냥 리눅스용 nwjs 바이너리를 받아서 직접 실행하면 되지 않을까? 실제로 해본 결과 매우 잘 됐다. 그냥 nwjs 바이너리를 받아서 직접 실행하면 된다.

\n
    \n
  1. nwjs 홈페이지에서 리눅스용 nwjs 바이너리를 다운받는다. (SDK 버전을 다운받아야 할 필요는 없다.)
  2. \n
  3. 게임 폴더내에 www 폴더를 찾는다.
  4. \n
  5. www 폴더를 1번에서 받은 nwjs 바이너리로 실행시키면 된다. (e.g. ~/nwjs-binary/nwjs ~/game/www)
  6. \n
\n

이러면 실행이 매우 잘 된다. 게임에 따라서 안 될 수도 있긴 한데... 나는 잘 됐다.

\n

", "url": "https://blog.litehell.info/post/how_to_run_rpg_maker_mv_on_linux", "title": "Linux에서 RPG Maker MV로 제작된 게임 실행하기", "summary": "wine으로 실행이 잘 안 될 때", "image": "https://gravatar.com/avatar/837266b567b50fd59e72428220bf69b1", "date_modified": "2025-02-18T11:02:55.344Z" }, { "id": "retrospective_of_representative_2023", "content_html": "

알림

\n

본래 2023년 말에서 2024년 초 사이에 게시할 주제였으나 필자가 게으른 관계로 퇴고가 계속 늦어졌다. 그렇게 계속 미루다가 2025년 초가 되어서야 간략하게나마 초안을 마치게 됐다. 그러나 초안을 한 번 읽어보니 이 글이 공개되면 중앙대학교 학생사회를 뒤집고, 또한 얽히고설킨 사람들의 신의를 잃을 것 같다는 생각이 들었다. 민감한 내용이 너무 많은데 이 내용을 모두 검열하면 남는 내용이 없다. 따라서 결국 글을 공개하지 않기로 결정했다. 추후 시간이 좀 많이 흐르면 공개할 수도 있겠지만 적어도 지금은 아니다.

\n

혹여 이 글을 기대하신 분들이 계시다면 심심한 양해를 구한다.

", "url": "https://blog.litehell.info/post/retrospective_of_representative_2023", "title": "학생자치 후기", "summary": "동아리연합회 분과장 임기를 마치며", "image": "https://gravatar.com/avatar/837266b567b50fd59e72428220bf69b1", "date_modified": "2025-01-31T10:58:09.183Z" }, { "id": "my_first_patent", "content_html": "

들어가는 글

\n

많은 사람들은 특허 출원과 특허 등록의 차이를 모른다. 회사 입학 원서를 예시로 들자면, 삼성전자 공채에 원서를 내는 걸 특허 \"출원\"이라고 하고, 삼성전자 공채에 최종합격하는 것을 특허 \"등록\"이라고 한다.

\n

2024-1학기 캡스톤디자인 프로젝트를 하면서 프로포절 발표할 때 특허 출원을 넣으면 입 털기 좋을 것 같다는 생각이 들었다. 그래서 장구 컨트롤러 프로토타입을 기반으로 특허를 출원했다.

\n

명세서부터 특허출원까지

\n

특허고객등록

\n

특허를 출원하려면 특허고객번호가 필요하다. 특허고객등록을 하면 된다. 쉽게 할 수 있다.

\n

가출원

\n

특허 출원은 의외로 어렵지 않다. 특허는 가출원할 수 있다. 대충 휘갈겨 쓴 임시명세서로 가출원하면 특허출원번호가 바로 나온다. 청년이면 출원료 85% 감면도 해준다.

\n

일단 캡스톤디자인 프로포절과 프로포절 발표에서 입을 터는 게 목표였기에 출원번호를 얻는 게 중요했다. 그래서 마크다운으로 대충 쓰고 PDF로 바꿔서 제출했다. 내용은 다른 명세서의 목차를 참고해 나름 특허명세서의 구조를 갖추려 노력했다.

\n

\"임시명세서의

\n

위는 임시명세서에 넣었던 도식이다. draw.io로 그리다가 짜증나서 그림판(Krita나 GIMP 둘 중 하나인데 정확히는 기억나지 않는다.)으로 휙휙 그렸다.

\n

대충 쓴 임시명세서를 특허청에서 제공한 전자출원프로그램으로 출원했다. 전자출원은 무조건 윈도우에서만 가능하며(리눅스 wine 시도시 오류남), 공인인증서가 반드시 필요하다. 본인이 윈도우를 끔찍이 싫어하는 자유 소프트웨어 원리주의자라면 우편으로 출원하면 되긴 한데, 나는 그렇게까지 하고 싶진 않아서 그냥 VM에 윈도우 설치하고 전자출원했다.

\n

덧붙여서, 공개되는 특허서류에 주소가 공개되는 데 이게 싫은 사람은 특허청 홈페이지에서 주소가 구 단위까지 공개되도록 바꿀 수 있다. 참고하도록 하자.

\n

공익변리 지원하기

\n

특허는 명세서가 가장 중요하다. 명세서를 제대로 쓰려면 변리사를 고용해야 하는데 변리사를 고용하는 건 비싸다. 대학생이 이용할 수 있는 변리사 무료지원 사업은 크게 다음 세가지가 있다.

\n\n

공익변리사센터는 학과장 확인서를 받아야해서 귀찮고, 공익상담은 방문하려니 가기가 귀찮고 전화로 하려니 설명하다가 열불이 터질 것 같다는 생각이 들었다. 그래서 공익변리사센터의 서류작성지원 사업을 이용했다. 공익변리사센터에서 제시하는 양식을 다 채우고 필자가 작성했던 임시명세서를 추가로 제출했다.

\n

\"공익변리사센터로부터

\n

지원사업에 선정되면 위와 같이 문자가 온다. 변리사님께서 제출한 서류를 읽다가 이해가 안되면 전화를 해서 물어볼 수 있다. 친절히 답해주면 된다. 명세서는 선정 후 2~3개월 정도 기다리면 이메일로 받을 수 있다.

\n

\"정식명세서의

\n

변리사님이 써준 정식명세서는 위와 같이 변리사 사무소에서 그림을 깔끔하게 다시 그려준다. 분량도 31페이지 정도로 굉장히 상세하게 써주신다. (참고로 본인이 쓴 임시명세서는 6페이지였다.)

\n

정식출원

\n

임시출원된 특허를 정식출원된 특허로 바꾸는 방법은 다음 두가지 방법이 있다.

\n\n

두번째 방법의 존재를 몰라서 첫번째 방법으로 할 생각이었는데 공익변리사센터에서 배정받은 변리사님과 통화하던 중 여쭤보니 두번째 방법으로 하는 게 좋을 것 같다는 답변을 들었다. (모든 경우에 그런 건 아니고 특허 케이스마다 다를 것이다. 내 경우에는 그랬다.) 그래서 기존특허를 근거로 우선권주장하여 정식명세서로 출원했다.

\n

심사

\n

이제 기다리기만 하면 된다. 순번 기다리는데 최소 1년 반 정도 걸린다. 운 좋으면 한번에 통과되고 운 안 좋으면 의견제출통지서가 날라온다. 지금도 기다리고 있는데 한번에 통과될 지 안 될지는 잘 모르겠다.

\n

후기

\n

특허를 \"출원하는 것\" 자체는 쉽다. 근데 등록되는 건 어렵다. 출원은 그리 어렵지 않으니 여러분도 특허를 한번 출원해보면 좋을 것 같다.

\n

프로포절 발표에서 입 털려고 출원한 거였는데 청자들에게 큰 영향이 있었는 지는 잘 모르겠다.

\n

(번외) 리듬게임의 정의

\n

아래 문단은 내가 특허명세서 쓸 때 작성한 리듬게임의 정의의 초안이다.

\n
\n

\"리듬게임\"은 조작 지시가 음악의 흐름에 따라 제시되고, 이용자는 지시에 따라 입력장치(예: 키보드, 마우스, 악기를 모방한 입력장치, 발로 누르는 버튼, 버튼, 터치스크린 등)를 조작하며, 최대한 많은 조작 지시를 정해진 타이밍에 최대한 근접한 시간에 수행하는 것을 목표로 하는 게임을 의미한다. 그리고 \"아케이드 리듬게임\"은 리듬게임 중 일정한 장소에서 일정한 시설을 갖추고 제공되는 게임을 의미한다.

\n
\n

위 정의를 변리사님이 살짝 수정하고 교정한 최종 버전은 다음과 같다.

\n
\n

리듬 게임이란, 조작 지시가 음악의 흐름에 따라 제시되고, 사용자는 지시에 따라 입력 장치(예: 키보드, 마우스, 악기를 모방한 입력 장치, 발로 누르는 버튼, 버튼, 터치스크린 등)를 조작하며, 최대한 많은 조작 지시를 정해진 타이밍에 성공시키는 것을 목표로 하는 게임을 의미한다. 그리고 아케이드 리듬 게임이란 리듬 게임 중에서도 일정한 장소(예: 오락실)에서 일정한 시설을 갖추고 제공되는 게임을 의미한다.

\n
\n

혹여나 다른 사람에게 도움이 될까싶은 마음에 적어둔다. 누군가에게 도움이 됐으면 좋겠다.

", "url": "https://blog.litehell.info/post/my_first_patent", "title": "내 첫 특허 출원기", "summary": "출원과 등록은 엄연히 다르다구요!", "image": "https://blog.litehell.info/janggu_patent_figure_old.png", "date_modified": "2025-01-29T08:01:46.305Z" }, { "id": "caucalendar_in_serverless", "content_html": "

서론

\n

학사일정/RSS/시간표 미리보기 서비스의 공통점은 크롤링 서비스이다. 학사일정 서비스의 특정을 요약하면 다음과 같다.

\n\n

다른 서비스의 특징은 다음과 같다.

\n\n

이 글의 시점은 2024년 초이다.

\n

AWS Lambda를 이용한 서버리스 전환

\n

그렇다면 크롤링 부분만 AWS Lambda 함수로 분리하면 매우 단순한 구조의 서비스이지 않은가?\n따라서 다음과 같이 중앙대학교 학사일정 서비스를 재구성했다.

\n

\"calendar.puang.network의

\n

다른 서비스는 백엔드 자체가 필요없으므로 더 단순하다. 강의시간표 미리보기 서비스와 중앙대학교 RSS 서비스의 서버리스 구조도는 다음과 같다.

\n

\"pre-timetable.puang.network와

\n

과정

\n

원래 스크린샷 하나하나 찍어가며 상사하게 쓸 생각이었는데 귀찮아진 관계로 대충 글로 정리한다.

\n
    \n
  1. S3 버킷을 만든다.
  2. \n
  3. 정적 호스팅을 활성화한다.\n\n
  4. \n
  5. 람다 함수를 만든다.
  6. \n
  7. 람다 함수에 권한을 설정한다. (서비스계정 같은 거 만들 필요가 없다. 람다 함수에 권한 부여하면 AWS SDK가 알아서 인식한다.)
  8. \n
  9. 리다이렉션이 필요하다면 S3 버킷에 리다이렉션 규칙을 설정한다.\n\n
  10. \n
  11. Scheduler를 만든다.
  12. \n
\n

참 쉽죠?

\n

결론

\n

위와 같이 서버리스 서비스를 구축하여 월1000~1500원 정도의 비용으로 서비스를 운영하고 있다.

\n

원래 글을 작년에 쓸 계획이었는데 미루고 미루다보니 1년이 지났다... 이제 글도 썼으니 동아리나 다른 후배에게 서비스를 인수인계할 계획이다.

", "url": "https://blog.litehell.info/post/caucalendar_in_serverless", "title": "학사일정 ICS 서비스 개발기 (下)", "summary": "Go와 함께 서버리스로", "image": "https://blog.litehell.info/caucalendar_serverless.png", "date_modified": "2025-01-29T07:54:49.043Z" }, { "id": "retrospective_of_2024", "content_html": "

들어가는 글

\n

올해는 뭔가 많은 일이 있었다.... 되게 바빴지만 동시에 많은 것들을 이루어낸 해이기도 했다. 회고를 쓰면서 지난날들을 되돌아보려 한다.

\n

하반기

\n

하반기에는 인턴십을 하고 학업을 마무리했다.

\n

하계방학 인턴십

\n

중앙대학교 현장실습지원센터를 통해 여러 인턴십에 지원했다. LG CNS 채용 연계형 인턴십과 카카오 채용 연계형 겨울 인턴십, 그리고 주식회사 슈르의 산학연계 인턴십에 지원했다.

\n

LG CNS 채용 전형은 서류, 코딩테스트, 2:2 비대면 면접 순으로 진행됐고, 카카오 채용 전형은 서류, 코딩테스트, 3:1 대면 면접으로 진행됐다. 둘 다 면접까지는 갔으나 아쉽게도 불합격했다. LG CNS는 같이 면접 본 다른 지원자분께서 스펙이 너무나 뛰어났고, 카카오 인턴십은 면접이 서툴러서 잘못 본 것이 원인인 것 같다. 카카오는 대면면접을 볼 때 대기실이 따로 있고, 면접비로 50,000원 상당의 카카오페이포인트와 춘식이 핫팩을 주는 점이 좋았다.

\n

주식회사 슈르는 중앙대학교 현장실습 통합관리 시스템을 통해 지원했다. 전형은 서류, 1:1 비대면 면접 순으로 진행됐다. 면접은 웹과 관련된 기술면접으로만 이루어졌다. 회사가 가산디지털단지역에 있어서 학교 기숙사에서 출퇴근하기 매우 편했고, 사람들이 되게 좋았다. 출퇴근은 자유로운 편이었으며, 휴가 사용은 완전히 자유로웠다. 포괄임금제이지만, 초과근무시 그에 상응하는 보상휴가를 지급해준다.

\n

회사에서는 고인물테스트의 프론트엔드를 개발했다. 그때 회사에 나를 포함하여 인턴이 3명 있었는데, 내가 백엔드를 하면 다른 분께서 프론트 개발하는 데 우여곡절이 많을 것 같아서 그냥 내가 프론트엔드를 맡았다.

\n

처음 회사 직원분께서 사이트가 매우 간단할 것으로 예측하고 그냥 HTML + CSS + Javascript 조합으로 빠르게 만들자고 제안하셨다. 초반 기획서도 그렇게 거창하지 않았기에 알겠다고 하고 HTML + CSS + Javascript 조합으로 만들었다. 그러나 기획서가 가면 갈수록 수정되면서 복잡해졌고, 이에 나는 개발 편의성을 위해 Javascript 코드를 Typescript 코드로 재작성하고 Webpack을 이용한 빌드 시스템을 구축했다.

\n

가장 인상 깊었던 것은 좌우 무한 스크롤링을 구현하라는 요구사항이었다. 처음에는 웹브라우저에서 제공하는 스크롤바를 최대한 활용하려 했는데 사파리에서 자꾸만 버그가 나서 결국 그냥 바퀴를 재발명했다. 마우스랑 터치 이벤트를 받아 이동량을 계산하고 계산한 값에 따라 requestAnimationFrame으로 자식 요소의 위치를 이동시킴으로써 스크롤링을 직접 구현했다. 가장 하기 싫은 방법이었지만 결국 어쩔 수 없었다. 사소한 버그는 있었지만, 나름 그럴싸하게 동작했다. (이에 대해서는 추후 별도의 글로 쓸 예정)

\n

학교 프로젝트에서 디자이너나 기획자랑 협업할 일이 없었는데 회사에서 처음으로 기획자, 디자이너, 실무자와 같이 협업했다. 그 과정에서 슬랙이랑 노션도 적극적으로 써보고 스타트업이 어떻게 돌아가는지에 대해 많은 걸 배울 수 있어 좋은 경험이었다.

\n

2024-1학기

\n

학교 수업

\n

2024-1학기에는 정보보호이론, 네트워크응용설계, 신호및시스템, 데이타베이스시스템, 캡스톤디자인(2)를 수강했다. 임베디드 관련 과목을 듣고 싶었는데 담당 교수님이 안식년인지라 어쩔 수 없이 신호및시스템을 대신 수강하게 됐다.

\n

정보보호이론은 DES, AES, RSA, ElGamel와 같은 암호에 관한 내용을 배웠다, 한 번 제대로 배워보고 싶었던 내용이라서 재밌었다. 네트워크응용설계는 네트워크 레이어 3~7을 Top-down으로 배운다. 데이타베이스시스템은 DB 시스템의 내부구조에 대해 가르친다. 신호및시스템은 푸리에 변환에 대해 맛보기로 가르쳐준다. 모두 다 좋은 내용이어서 만족스러웠다.

\n

바이드럼(캡스톤디자인)

\n

중앙대학교는 캡스톤디자인을 2번 해야 한다. 2023-2학기에는 알고모여를 했었고 (관련 글) 이제 2024-1학기에도 프로젝트를 해야 했다.

\n

팀 인원은 다행스럽게도 2023-2학기의 구성 그대로 가기로 결정돼서 주제만 빠르게 결정되면 됐다. 이때 리듬 게임을 만들 것을 다시 한번 더 제안했다. 말로만 제안하면 또 반대를 받을 게 예상돼서 이번에는 프로토타입을 미리 만들어 팀원들을 설득했다. 프로토타입에 관한 이야기는 이 글을 참고하라.

\n

주제부터 결과물까지 모든 것이 어그로였다. (중앙대에서 졸업작품으로 아케이드 리듬 게임을 하는 용자는 매우 드물다) 그래서 이왕 어그로 끄는 김에 최종 발표도 어그로로 하기로 했다. 한솜미술센터에서 사물놀이복을 빌려서 최종 발표를 했다. 결과는 매우 성공적이었고 웃음을 참지 못하던 조교의 표정을 아직도 잊을 수가 없다.

\n

상반기

\n

상반기에는 본격적인 사회인이 됐다.

\n

첫 정규직 직장

\n

하계방학에 인턴십을 진행한 주식회사 슈르에 정규직으로 입사하게 됐다. 내가 학업에 열중하는 동안 회사는 여러 우여곡절을 겪으며 체계가 더 단단해졌다. 회사에서 개발하는 이커머스 서비스의 백오피스 프론트엔드 개발을 주로 맡았었다. 스타트업 기업에서 근무하면서 스타트업이 어떻게 돌아가는 지를 인턴으로 근무할 때보다 더 자세히 알 수 있었다. 다른 현직 개발자와 협업도 처음으로 해봤지만, 개인적으로 시간에 쫓겨 개발하느라 기술적인 성장을 많이 이루지 못한 것 같다. 그와는 별개로 Firebase를 본격적으로 처음 써봤는데 꽤 편리해서 좋았었다.

\n

되게 열심히 일했다. 학교로 졸업사진 찍으러 가다 회사에 큰 일 터져서 바로 헐레벌떡 회사로 달려가 고쳐도 보고... 여러 추억을 쌓았다.

\n

이직

\n

슈르를 다니다가 더 좋은 직장에서 더 높은 연봉의 오퍼를 받아 이직하게 됐다. 아직 모든 것이 낯설고 앞으로 내가 잘할 수 있는지 두렵지만 어찌 됐든 잘 적응해서 어서 빨리 성취를 이루고 싶다.

\n

자취

\n

직장을 옮기니 집과 직장 사이의 거리가 더 멀어져 결국 자취를 하게 됐다. 수도권 집값 너무 비싸서 볼 때마다 아깝다는 생각이 들지만, 그래도 어쩔 수 없다. 집값도 비싸면서 이상한 집은 얼마나 많은지... 그래도 자취를 하니 직장까지 가기가 너무 편해서 만족스럽다. 특히 자취를 한 번도 안 했다가 이제서야 진정한 프라이버시를 얻게 된 점이 너무 좋다.

\n

현재 근황

\n

개인서버

\n

지인으로부터 안 쓰는 데스크톱 본체를 얻게 됐다. 이 본체에 Proxmox를 설치해 개인 서버를 구축했다. 이제 NAS를 설치하려고 이것저것 알아보고 있는데 TrueNAS는 제대로 쓰려면 하드웨어 패스쓰루를 해야 한다고 해서 내키지 않고, openmediavault는 웹 파일 브라우저 UI가 뭔가 마음이 들지 않아서... 그냥 내가 직접 만들까도 생각하고 있다.

\n

Tor 릴레이

\n

개인적으로 고대역폭 Tor 릴레이를 구축해보고 싶다. 그래서 KINX에 관련 문의를 해봤는데 추후 견적이 어떻게 나오는가에 따라서 안 할 수도 있다.

\n

프로젝트 \"나무위키 이야기\"

\n

나무위키의 초반기 역사에 대한 책을 쓰고 싶어서 올해 중반쯤에 프로젝트를 결성했는데, 바쁘게 살다 보니 그 새 까먹어서 이제서야 본격적인 시동을 걸게 됐다. 사람들이 슬슬 기억이 안 나기 시작해서 원할하게 될지는 모르겠지만, 어쨌든 잘 이루어졌으면 좋겠다.

\n

쓰지 못한 글들

\n

아직 못 쓴 글들은 다음과 같다. 학생자치후기는 특성상 검열이 많이 될 수 있어서 재미가 없을 수도 있다.

\n\n

아랫글들은 잠깐 쓰다 말았는데 완성 안 하고 그냥 삭제할 수도 있다.

\n\n

아랫글은 쓰다 말았는데 타이밍을 놓쳐서 그냥 삭제할 계획이다.

\n\n

마무리

\n

이제 연말이 얼마 남지 않았다. 다음 해에는 다들 즐거운 일만이 있기를!

", "url": "https://blog.litehell.info/post/retrospective_of_2024", "title": "2024년의 회고", "summary": "졸업과 취업", "image": "https://gravatar.com/avatar/837266b567b50fd59e72428220bf69b1", "date_modified": "2024-12-27T10:41:06.311Z" }, { "id": "caucalendar_1", "content_html": "

들어가는 글

\n

나는 캘린더 앱을 적극적으로 활용한다. 시간이나 약속을 머릿속으로만 관리하면 잘 잊어버리기 때문에 캘린더 앱을 적극적으로 활용하고 있다.

\n

그렇게 캘린더 앱을 적극적으로 쓰다가 대학교에 입학했다. 시험기간이나 수강정정기간 같은 것도 캘린더 앱에 뜨면 좋겠는데 이걸 직접 추가하는 건 귀찮았다. 그래서 중앙대학교 학사일정 페이지를 크롤링하는 어플리케이션을 작성했다. 그리고 캘린더 앱과 내 어플리케이션을 연동하는 데에는 iCalendar 파일 포맷을 이용했다.

\n

iCalendar

\n

Google CalendarMS Outlook, 혹은 필자가 이용하는 FastMail에서는 캘린더 기능을 제공한다. 이 캘린더 서비스들은 기본적으로 특정한 iCalendar 주소를 구독하는 기능을 지원한다. 즉, 다시 말해 필자가 구글 캘린더나 아웃룩에 iCalendar 파일 주소를 추가하면, 구글 캘린더나 아웃룩 서버가 주기적으로 iCalendar 주소에 접속해 동기화한다.

\n

iCalendar 파일은 다음과 같은 형식으로 되어있다.

\n
BEGIN:VCALENDAR\nVERSION:2.0\nTIMEZONE-ID:Asia/Seoul\nX-WR-TIMEZONE:Asia/Seoul\nX-WR-CALNAME:중앙대학교 학사일정\nX-WR-CALDESC:calendar.puang.network에서 제공하는 중앙대학교 학사일정\nCALSCALE:GREGORIAN\nPRODID:adamgibbons/ics\nMETHOD:PUBLISH\nX-PUBLISHED-TTL:PT1H\nBEGIN:VTIMEZONE\nTZID:Asia/Seoul\nTZURL:http://tzurl.org/zoneinfo-outlook/Asia/Seoul\nX-LIC-LOCATION:Asia/Seoul\nBEGIN:STANDARD\nTZOFFSETFROM:+0900\nTZOFFSETTO:+0900\nTZNAME:KST\nDTSTART:19700101T000000\nEND:STANDARD\nEND:VTIMEZONE\nBEGIN:VEVENT\nUID:552361268d864ef42fff1bee5d295e073f7ab2b2@calendar.puang.network\nSUMMARY:신정(공휴일)\nDTSTAMP:20240825T081930Z\nDTSTART;TZID=Asia/Seoul;VALUE=DATE:20220101\nEND:VEVENT\nBEGIN:VEVENT\nUID:eb57cfcaf7345c4ad83d1e7537dd81016db2d8a7@calendar.puang.network\nSUMMARY:2022년 1학기 재입학 원서접수\nDTSTAMP:20240825T081930Z\nDTSTART;TZID=Asia/Seoul;VALUE=DATE:20220103\nDTEND;TZID=Asia/Seoul;VALUE=DATE:20220107\nEND:VEVENT\nEND:VCALENDAR\n
\n

위와 같은 식으로 iCalendar 아이템(VCALENDAR) 속에 여러 일정(VEVENT)들이 나열되어 있다. iCalendar 형식은 할일(VTODO)이나 일기(VJOURNAL)도 지원하지만 이 글에서는 다루지 않는다.

\n

Koa.js를 이용한 첫 버전

\n

첫 버전은 Koa 프레임워크를 이용하여 간단하게 작성했다. 원래 이전에는 express를 썼었는데, express는 async 함수 핸들러가 바로 지원되지 않아서 약간 귀찮다는 단점이 있었기에 Koa 프레임워크를 이용했다.

\n

이 프로그램에서 중요한 것은 iCalendar 파일을 제공하는 것이다. 따라서 그 외의 요소는 모두 부수적인 것이다. 그렇기에 프론트엔드는 다음과 같이 디자인이 극단적으로 되어있어도 상관없었다. (사진은 첫 커밋 버전의 메인 페이지이다.)

\n

\"첫

\n

다만 그래도 위처럼 만드는 건 좀 심하니 bulma CSS 프레임워크를 이용해 아래와 같이 간단히 꾸몄다.

\n

\"bulma

\n

이때가 2019년 5~6월쯤이였다. 이때의 구조도는 다음과 같다.

\n

\"서비스

\n

당시 가상서버에서는 여러 웹서비스가 구동되고 있었기에, Host를 확인하여 알맞은 웹서비스로 트래픽을 전달해야 했다. 따라서 Nginx로 리버스 프록시가 동작하고 있었다.

\n

위 사진에서 PM2는 프로세스가 꺼지면 다시 켜주는 역할을 한다. 리브레위키의 리버티엔진에서 쓰길래 써봤다.

\n

크롤링 스크립트 분리

\n

초기에는 크롤링을 분리하기 귀찮아서, 그냥 요청이 들어올 때마다 학교 홈페이지에 접속해 학사일정 iCalendar 파일(이하 \"ics 파일\")을 제공했다. 그랬더니 어느순간 학교에서 서버 ip를 차단했다. 이게 2019년 11월 쯤의 일이였다.

\n

그래서 크롤링하는 코드를 별도의 파일로 분리하고, crontab을 이용해 크롤링 스크립트가 주기적으로 실행되게 했다. 크롤링된 데이터는 Sequelize ORM을 이용해 저장했다.

\n

GitHub Action

\n

학사일정 서비스에 버그가 생겼다고 캘린더 앱에서 잘 보이던 일정이 갑자기 사라지진 않는다. 그래서 동작에 이상이 생겨도 기존에 쓰던 사람들은 티가 잘 안난다.

\n

그래서 동작이 정상적으로 이루어지는 지 주기적으로 확인하기 위해 다음과 같이 GitHub Action을 추가했다.\n푸시나 커밋시가 아닌 특정 주기에 따라 반복되는 GitHub CI로 테스트가 주기적으로 이루어지도록 했다.

\n

따라서 이를 통해 학교 홈페이지의 갑작스런 디자인/API 변경에도 대응할 수 있었다.

\n
name: Build and test\n\non:\n  schedule:\n    - cron: '0 19 * * *'\n  push:\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n\n      - name: Build\n        run: docker build --target test .\n
\n

푸시나 커밋시가 아닌 cron으로 GitHub CI를 추가하여 학교 홈페이지의 갑작스런 디자인/API 변경에도 대응할 수 있도록 했다.

\n

도커라이징

\n

개인서버에서 돌아가던 서비스들을 다 도커 컨테이너에 감싸는 작업을 했었다. 그때 학사일정 ics 서비스도 도커 컨테이너로 감쌌다. crontab을 이용해 따로 돌아가던 크롤링 스크립트는 어플리케이션에 다시 집어넣어서, 어플리케이션 실행시 크롤링이 자동으로 주기적으로 실행되도록 수정했다. Docker에서 crontab을 쓰려면 약간 귀찮기 때문이다.

\n

처음에는 node:14 도커 이미지를 기반으로 썼는데 값싼 가상서버에서 쓰기에는 디스크를 너무 많이 차지했다. 그래서 나중에 Alpine Linux 기반 도커를 기반으로 바꿨다. Alpine Linux 기반 이미지를 쓰니 디스크 소비량을 줄일 수 있었다. 이때가 2021년 2~3월인가 그랬을 것이다.

\n

Go 언어로의 재작성

\n

그렇게 Javascript로 작성해서 잘 쓰다가 문득 이런 생각이 들었다. 'Go를 쓰면 더 빠르지 않을까?' Javascript는 인터프리터 언어이고, Go는 컴파일 언어이니, 알고리즘의 효율성이 유사하다는 가정하에 Go가 더 빠를 수도 있지 않을까란 생각이 들었다. 물론 Go 언어를 한 번 써보고 싶은 생각도 없진 않았다.

\n

그래서 Go 언어로 학사일정 서비스를 재작성했다. 크롤러는 다음과 같이 고루틴을 이용하여 비동기적으로 동시에 구동되도록 했다.

\n
package main\n\nimport \"time\"\n\nfunc crawlWorker() {\n\tfor {\n\t\ttime.Sleep(time.Hour * 1)\n\t\tfetchAllYears()\n\t}\n}\n\nfunc setupCrawller() {\n\tgo crawlWorker()\n}\n
\n

Docker를 이용한 테스팅

\n

DigiCert CA 인증서와 관련된 문제가 있어 해당 CA 인증서를 추가하여 문제를 해결했었다. 물론 HTTP 요청시 인증서 오류를 모두 무시하도록 하는 방법도 있지만, 그 방법은 보안이 취약해지기에 채택하지 않았다.

\n

다만 이렇게 CA 인증서를 추가하는 식으로 해결할 시에는 go test -v 명령어만으로 테스트를 할 수 없다는 문제점이 있었다. 그래서 Docker를 테스트에도 활용할 수 있도록 다음과 같이 Dockerfile을 수정했다.

\n
FROM golang:alpine AS base\n\nWORKDIR /app\n\n# To avoid tls error from swedu.cau.ac.kr\nCOPY digicert-ca.pem /usr/local/share/ca-certificates/digicert-ca.crt\nRUN cat /usr/local/share/ca-certificates/digicert-ca.crt >> /etc/ssl/certs/ca-certificates.crt\n\nCOPY go.mod go.sum ./\nRUN go mod download && go mod verify\n\nCOPY static ./static\nCOPY *.go ./\n\nFROM base AS deployment\nRUN go build -v -o /app/app\nCMD [\"/app/app\"]\n\nFROM base As test\n\nRUN go test -v ./...\n
\n

서버리스

\n

위와 같이 만들어서 굴리다가 추후 AWS Lambda 함수를 이용한 서버리스로 재작성했다. 이에 대해선 다음 글에서 이어서 작성하도록 하겠다.

", "url": "https://blog.litehell.info/post/caucalendar_1", "title": "학사일정 ICS 서비스 개발기 (上)", "summary": "Javascript랑 함께 Docker로", "image": "https://blog.litehell.info/caucalendar_first_commit_html.png", "date_modified": "2024-09-15T13:06:50.914Z" }, { "id": "bidrum_on_rust", "content_html": "

들어가는 글

\n

※ 참고: 이 시리즈의 글은 시간순 작성을 최대한 목표하고 있으나, 글의 짜임새나 가독성을 위해 미리시점이나 과거시점의 이야기가 섞이거나 순서가 일부 달라질 수 있습니다.

\n

내가 만들고 싶은 게임은 아케이드 리듬게임이였다. 마침 내 방에 라즈베리 파이(4B Rev 1.2)가 있어서, 라즈베리 파이로 게임을 구동하고 싶었다. Unity나 Unreal Engine으로 만든 게임이 라즈베리 파이 위에서 돌아갈까? 아마도 돌아가지 않을 것이다. 실제로 돌려본 적은 없지만 라즈베리 파이가 성능이 좋은 편은 아니니까.

\n

그래서 필자는 마침 Rust 프로그래밍 언어를 한 번 써보고 싶은 생각도 있었기에, Rust로 직접 게임을 만들어보기로 결심했다. 게임 엔진을 쓰면 나중에 게임이 유명해졌을 때 라이선스비를 내야 한다는 것도 하나의 이유였다. (좀 과한 김칫국이긴 하지만...) 그래서 유니티니 언리얼이니 하는 게임 엔진을 쓰지 않고 Rust로 기초부터 쌓아올리기 시작했다.

\n

SDL2를 이용한 게임 프로그래밍

\n

Rust에는 rust-sdl2라는 라이브러리 바인딩이 있다. SDL2는 오디오, 키보드, 마우스, 그래픽, 조이스틱을 다를 수 있게 하는 크로스플랫폼 라이브러리이다. 즉, 그래픽이나 마우스 등에 대한 Direct3D/OpenGL 등의 운영체제/플랫폼 종속적인 API를 추상화하고 단일한 인터페이스로 통일하여 크로스 플랫폼으로 개발할 수 있게 하는 라이브러리이다.

\n

따라서 SDL2를 이용하면 Mac OS X/Windows/Linux에서 크로스플랫폼으로 실행할 수 있고, 라이브러리도 별로 무겁지 않다. 그래서 SDL2를 이용하게 됐다.

\n

SDL2

\n

SDL2는 rust-sdl 레포 내의 예제 코드를 보면서 따라하면 사용하기 쉽다. SDL2는 먼저 Window(창)을 만든 뒤, Window의 Canvas에 원하는 것을 그리고 렌더링하고 클리어하는 것을 반복한다. 이를 순서대로 나타내면 다음과 같다.

\n
\n

(A) Window 생성 → (B) Window의 Canvas를 Clear한다 → (C) Canvas에 뭔가를 그린다. → (D) Window의 Canvas를 Present한다. → (E) 화면이 표시한다. → (F) B로 되돌아간다.

\n
\n

학부 수준의 컴퓨터그래픽스 수업을 들었거나 OpenGL 프로그래밍을 조금이라도 맛보았다면 매우 이해하기 쉬울 것이다.

\n

SDL2는 키보드나 조이스틱 인식을 위한 EventPump 기능을 제공한다. 키보드 인풋이 들어오면 EventPump에 Event가 생성된다. 게임은 이 EventPump에 Event가 있는지 확인하여 만약 Event가 있다면 해당 Event의 데이터를 활용해 키보드 인풋을 처리할 수 있다. 게임 특성상 이 EventPump은 디버깅 용으로만 주로 이용됐다.

\n

GameCommonContext

\n

위에서 언급한 바에 같이 그래픽을 렌더링하기 위해서는 Canvas 객체에 대한 접근이 필요하다. 그래서 초기 게임 초기화시 Canvas와 Window, SDL Context 등을 담은 GameCommonContext 객체를 만들고 이를 함수간에 서로 주고받는 형태로 빠르게 게임을 구현했다.

\n
use kira::manager::AudioManager;\nuse sdl2::{render::Canvas, EventPump, video::Window};\n\npub(crate) struct GameCommonContext {\n    pub(crate) coins: u32,\n    pub(crate) price: u32,\n    pub(crate) sdl_context: sdl2::Sdl,\n    pub(crate) audio_manager: AudioManager,\n    pub(crate) canvas: Canvas<Window>,\n    pub(crate) event_pump: EventPump,\n}\n
\n

(속성은 나중에 게임이 개발되면 될 수록 더 늘어난다.)

\n

그리고 이는 추후 대규모 리팩토링으로 Rust의 특징을 온몸으로 깨닫는 계기가 됐다.... 나중에 소유권이랑 lifetime 문제가 미친 듯이 터져나와서 그거 씨름하느라 엄청 고생하게 됐다.

\n

Rust에서의 시리얼 통신

\n

게임을 장구 하드웨어와 연동하기 위해서는 시리얼 통신이 필요하다.

\n

Arduino Leonardo 등 HID 에뮬레이션을 지원하는 보드가 있으면 장구 하드웨어에서 키보드 인풋을 주도록 할 수도 있다. 그런데 당장 내 방에 있는 게 Arduino UNO 호환보드(흔히 \"짭두이노\"라고 불리는 보드)밖에 없었다. 그래서 시리얼 통신으로 구현했다. 나중의 미래에 레오나르도 보드를 사서 키보드 인풋으로도 구현해보긴 했는데.... 딱따구리마냥 장구 채를 갖다대기만 해도 장구 채를 미친듯이 연타한 것마냥 동작하는 버그가 있어서 그냥 시리얼 통신을 계속 쓰게 됐다.

\n

Rust에서는 시리얼 통신을 어떻게 할까? 고맙게도 serialport라는 라이브러리가 있다. 이를 이용해 장구 하드웨어와의 시리얼 통신 코드를 작성했다. 그리고 장구 하드웨어로부터 인풋을 읽는 코드를 멀티쓰레딩으로 분리하고 AtomicU8을 이용하여 게임 쓰레드와 인풋 쓰레드 간에 장구 인풋 상태를 서로 공유했다.

\n

왜 뜬끔없이 AtomicU8이냐? 장구 컨트롤러의 상태를 나타내는 데에는 4개의 비트만 있으면 충분하다. 그래서 컨트롤러는 1바이트의 데이터를 무한히 연속적으로 보낸다. 이 1바이트를 해석하는 코드는 다음과 같다.

\n
pub(crate) fn parse_janggu_bits(bits: u8) -> JangguState {\n    JangguState {\n        궁채: if bits & 1 != 0 {\n            Some(DrumPane::채편)\n        } else if bits & 2 != 0 {\n            Some(DrumPane::북편)\n        } else {\n            None\n        },\n        북채: if bits & 4 != 0 {\n            Some(DrumPane::채편)\n        } else if bits & 8 != 0 {\n            Some(DrumPane::북편)\n        } else {\n            None\n        },\n    }\n}\n
\n

장구 컨트롤러와 연동하는 쓰레드는 컨트롤러로부터 시리얼 통신으로 받은 데이터를 바로 GameCommonContext 개체의 janggu_bits_ptr 필드에 저장한다.

\n
// ... (생략) ....\n\npub(crate) struct GameCommonContext {\n   // ... (생략) ...\n   pub(crate) janggu_bits_ptr: Arc<AtomicU8>,\n}\n\nimpl GameCommonContext {\n    pub(crate) fn read_janggu_state(&self) -> JangguState {\n        return parse_janggu_bits(\n            self.janggu_bits_ptr\n                .load(std::sync::atomic::Ordering::Relaxed),\n        );\n    }\n}\n\n
\n

게임 쓰레드에서 장구의 상태를 확인할 때는 read_janggu_state 메소드를 이용한다.

\n

추후 미래에 AtomicU8을 없애고 Product-Consumer Lock으로 JangguState 객체를 직접 공유해보기도 했는데, 렉이 너무 심해서 그냥 AtomicU8을 계속 쓰게 됐다.

\n

결론

\n

Rust를 이용한 게임 개발은 초창기에는 할만했는데 뒤로 갈수록 어려웠다. 소유권, 대여, lifetime 문제를 해결하느라 골머리를 참 많이 썩었다. 그래서 결론적으로 짧은 기간 안에 게임을 개발해야 한다면 Rust는 별로 좋은 선택이 아니지 않을까라는 생각이 들었다.

", "url": "https://blog.litehell.info/post/bidrum_on_rust", "title": "Rust와 SDL2", "summary": "게임 개발에 Rust를 써보셨나요?", "image": "https://gravatar.com/avatar/837266b567b50fd59e72428220bf69b1", "date_modified": "2024-08-11T14:35:24.968Z" }, { "id": "bidrum_and_janggu_controller_prototyping", "content_html": "

서문

\n

문득 장구를 이용한 리듬 게임을 만들고 싶었다. 태고의 달인도 있는데 장구의 달인이 안 될 이유가 있을까? 그래서 군대 있을 때 계속 장구 게임을 상상만 하다가 복학하고 3학년 2학기에 캡스톤디자인 과목을 듣게 됐다.

\n

3학년 2학기 캡스톤디자인 과목에서 게임 아이디어를 제시했지만, 다른 팀원들은 리스크가 너무 크다고 반대했다. 지금 막 개강했는데 하드웨어 개발부터 시작하면 십중팔구 프로젝트가 망할 것이라는 지적이였다. 반박하기에는 너무나도 맞는 말이였다. 그래서 그냥 받아들이고 \"알고모여\"라는 웹 어플리케이션 프로젝트를 하게 됐다. 의외로 교수님의 평가가 계속 호평이여서 손쉽게 A+를 받았다.

\n

중앙대학교는 캡스톤디자인을 2번 해야 졸업이 가능하다. 그래서 4학년 1학기에도 캡스톤디자인을 해야 했는데, 이때 게임 아이디어를 다시 꺼내고 싶었다. 그러나 하드웨어도 없이 빈손으로 아이디어를 꺼낸다면 또다시 팀원들로부터 리스크 우려를 받을 것 같았다. 그래서 일단 프로토타이핑을 해서 설득해야 되겠단 판단이 들었다. 판단이 이루어졌으면 바로 실천해야 하지 않겠는가? 바로 당근마켓으로 45,000원짜리 어린이 장구를 샀고, 곧이어 아두이노랑 쿠킹호일, 그리고 몇가지 부품을 구매했다.

\n

프로토타이핑

\n

유선과 무선

\n

내가 만드는 게임은 단순히 타격 여부만을 인식하지 않는다. \"어떤 채로 어떤 면이 타격됐는가\"를 인식한다. 즉, 다시 말해 열편으로 궁편을 친다면 \"궁편이 타격됐다\"라는 정보가 아닌 \"열편으로 궁편이 타격됐다\"라는 정보가 인식된다.

\n

이를 구현하려면 채에도 센서가 있어야 된다. 그리고 이 센서는 게임 본체랑 연결되어야 한다. 어떻게 연결한 것인가? 무선과 유선 두가지 방법이 있다.

\n

무선과 유선 각 두 가지 방법의 장단점은 다음과 같다.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
무선유선
장점선이 걸리지 않음정확도가 매우 높고 딜레이가 낮음
단점너무 낮은 정확도 및 높은 딜레이선이 걸리적거림
\n

무선 통신은 정확도가 너무 낮다는 단점이 있다. 무선으로 채의 위치를 측정한다고 가정해보자. Inpixion의 자료에서의 무선 위치 측정 시스템의 정확도와 레어턴시는 다음과 같다.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
UWBChirp (CSS)BLEWi-Fi
정확도10~50 cm1~2m< 5 m10 m
레이턴시< 1 ms< 1 ms3~5s3~5s
\n

정확도가 너무 낮아서 무선 위치 측정 시스템은 쓸 수가 없다. 블루투스와 와이파이는 레이턴시때문에 리듬게임 컨트롤러로 쓸 수조차 없다.

\n

'그렇다면 무선이되 다른 방법을 쓰면 되는 것 아닌가?'라는 생각이 들 수도 있는데, 내 머리로는 그렇게 할 수 있는 방법이 딱히 떠오르지 않았다. 그래서 그냥 유선으로 연결하기로 하고, 걸리적거리는 문제는 나중에 해결하기로 했다.

\n

시분할

\n

자, 이제 유선으로 채의 종류를 인식하기로 했다. 장구는 두 개의 채와 두 개의 접촉면이 있다. 이 경우 가능한 경우의 수는 총 몇 가지인가? 답은 아래 표에서 볼 수 있듯 9가지이다. 두 개의 채로 하나의 접촉면을 동시에 치는 경우(아래 표에서 5번, 9번)도 고려해야 하므로 9가지이다.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
궁채북채
1XX
2궁면X
3북면X
4X궁면
5궁면궁면
6북면궁면
7X북면
8궁면북면
9북면북면
\n

전기는 색깔이 없다. 접촉면에서 흐르는 전기가 궁채에서 흐르는 전기인지 북채에서 흐르는 전기인지 알 수 없다. 그렇다면 접촉면에 닿은 채가 어떤 채인지 어떻게 구분해야 할까? 필자는 FDM(주파수 분할)과 TDM(시분할)을 생각했다. 처음엔 FDM을 생각했었는데, 아두이노로 구현하기에는 난이도가 높을 것 같아 비교적 구현이 손쉬운 TDM으로 결정했다.

\n

알고리즘

\n

시분할 알고리즘은 매우 단순하다.

\n
    \n
  1. 궁채에 전기를 흘리고 북채에 전기를 흘리지 않는다.
  2. \n
  3. 북면에 전기가 흐르는 지 확인한다. 북면에 전기가 흐르면 궁채는 북면에 접촉하고 있다.
  4. \n
  5. 궁면에 전기가 흐르는 지 확인한다. 궁면에 전기가 흐르면 궁채는 궁면에 접촉하고 있다.
  6. \n
  7. 북채에 전기를 흘리고 궁채에 전기를 흘리지 않는다.
  8. \n
  9. 북면에 전기가 흐르는 지 확인한다. 북면에 전기가 흐르면 북채는 북면에 접촉하고 있다.
  10. \n
  11. 궁면에 전기가 흐르는 지 확인한다. 궁면에 전기가 흐르면 북채는 궁면에 접촉하고 있다.
  12. \n
  13. 2, 3, 5, 6번에서의 정보를 종합하면 어떤 채가 어떤 접촉면에 접촉하고 있는 지 알 고 있다.
  14. \n
\n

이를 도식도로 나타내면 다음과 같다.

\n

\"알고리즘의

\n

위를 실제 회로로 구현하기 위해서는 특정 회로를 신호로 열거나 닫을 수 있어야 한다.

\n

릴레이

\n

특정 회로를 신호로 열거나 닫는 대표적인 부품은 릴레이이다. 릴레이는 일종의 스위치 역할을 하는 부품으로, 전자석을 이용하여 회로를 열거나 닫는다.

\n

\"아두이노

\n

매우 직관적이고 만들기 쉬워서 처음에 릴레이를 시도했었다. 그러나 문제가 있었다. 릴레이는 딜레이가 너무 크고(5ms) 결정적으로 딱따구리 같은 소음이 난다. 위 알고리즘을 빠르게 무한반복해야 하는 특성상 릴레이 열고닫기를 반복하니 전자석 딱딱거리는 소리가 무한히 들리는 것이었다.

\n

트랜지스터

\n

그래서 주변 분들의 조언을 받아 트랜지스터를 이용했다. 주변 분께 딜레이가 1ms이하인 릴레이가 있나고 여쭤보니, 그 분께서 그런 릴레이는 없으니 트랜지스터를 쓰라고 답변해주신 것이 큰 도움이 됐다. 트랜지스터는 라디오 만들 때나(증폭) 쓰는 건 줄 알았는데 검색해보니 트랜지스터도 스위치처럼 쓸 수 있음을 알게 됐다.

\n

NPN 트랜지스터와 저항으로 약간의 시행착오를 겪으니 잘 인식됐다. 트랜지스터를 이용한 회로는 아래와 같다.

\n

\"트랜지스터를

\n

위 회로에서 궁채, 열채, 북편, 채편 기호는 각각의 부위에 붙은 전도체를 의미한다.

\n

전도체

\n

전기가 흘려야 하니 채와 접촉면에는 전도체 물질을 부착해야 한다. 뭘 붙일까 고민하다가 펌프 발판을 수작업으로 제작할 때 은박지를 이용했다는 글이 생각났다. 그래서 다이소에서 쿠킹호일을 사다가 붙였다. 내구성은 썩 좋은 것 같진 않았지만 꽤 잘 인식됐다.

\n

\"장구

\n

사진은 위와 같다. 사진에는 보이지 않지만, 장구의 면에도 은박지가 부착되어 있다.

\n

결론

\n

쿠킹호일과 NPN 트랜지스터, 저항, 그리고 아두이노를 이용해 장구 컨트롤러를 만들었다. 내구성은 썩 좋지 않았지만 인식은 잘 됐다. 2023년 겨울방학의 일이었다.

", "url": "https://blog.litehell.info/post/bidrum_and_janggu_controller_prototyping", "title": "쿠킹호일과 트랜지스터로 리듬게임 컨트롤러 만들기", "summary": "학교 캡스톤디자인 작품으로 장구 리듬게임 만든 이야기", "image": "https://blog.litehell.info/controller-algorithm.png", "date_modified": "2024-07-19T12:53:12.021Z" }, { "id": "show_all_slides_of_pptx_and_convert_to_pdf_batch_operation", "content_html": "

서문

\n

이번 학기에 데이터베이스시스템 과목을 수강하게 됐다. 이 과목의 강의자료는 교재 홈페이지에서 제공하는 pptx 파일을 이용하는데, 숨김 처리된 슬라이드도 모두 활용한다. 따라서 숨겨진 슬라이드를 모두 숨김 해제해야 했다.

\n

파일이 한두개면 그냥 직접 숨겨진 슬라이드를 숨김 해제하면 된다. 하지만 강의가 시작되는 Chapter 12이후의 파일은 약 20개 정도였다. 물론 그 pptx들을 다 강의하진 않겠지만, 한두개의 파일이 아닐 것임은 확실했다.

\n

이걸 어떻게 하면 일괄처리할 수 있을까?

\n

해결

\n

pptx 파일의 구조

\n

pptx 파일은 zip 파일이다. pptx파일을 압축 프로그램을 열면 다음과 같은 구조를 볼 수 있다.

\n

\"pptx파일을

\n

ppt/slides 디렉토리 내의 xml 파일들이 슬라이드를 나타내는 xml 파일이다. 숨김 처리된 슬라이드의 xml 파일을 보면 다음과 같이 루트 요소의 show 속성이 0으로 설정되어 있음을 확인할 수 있다.

\n

\"숨겨진

\n

그렇다면 ppt/slides 디렉토리 내의 xml 파일의 루트 요소에서 show 속성만 제거하면 되지 않을까?

\n

Python 스크립팅

\n

python을 이용하면 기본으로 제공되는 라이브러리만으로 간단히 해결할 수 있다.

\n
#!/bin/python3\nimport re\nfrom os import listdir\nfrom os.path import isfile, join\nfrom zipfile import ZipFile\nimport xml.etree.ElementTree as ET\n\npptx_dir = \".\"\n# pptx_dir 디렉토리 내에서 파일명이 .pptx로 끝나는 파일의 목록을 가져온다.\npptx_files = [f for f in listdir(pptx_dir) if isfile(join(pptx_dir, f)) and f.endswith(\".pptx\")]\n# ppt/slides/*.xml 패턴을 검사하기 위한 정규표현식\nslide_xml_filename_pattern = re.compile(\"ppt/slides/[^/]+\\\\.xml\")\n\nfor pptx_file in pptx_files:\n    print(\"Processing pptx %s \" % pptx_file)\n    # pptx파일을 zip 파일로 연다.\n    with ZipFile(pptx_file, 'a') as zipfile:\n        # ppt/slides/*.xml 형태의 파일 목록을 가져온다.\n        slide_xml_filenames = [i for i in zipfile.namelist() if not slide_xml_filename_pattern.fullmatch(i) is None]\n        for slide_xml_filename in slide_xml_filenames:\n            print(\"Processing xml %s\" % slide_xml_filename)\n            xml = None\n            # xml을 파싱한다.\n            with zipfile.open(slide_xml_filename, mode = 'r') as file:\n                xml = ET.parse(file)\n            # 루트 요소에 show 속성이 있다면 제거한다.\n            if \"show\" in xml.getroot().attrib:\n                xml.getroot().attrib.pop(\"show\")\n            # 수정된 xml을 pptx 파일 내에 쓴다.\n            with zipfile.open(slide_xml_filename, mode = 'w') as file:\n                xml.write(file)\n
\n

위 스크립트는 디렉토리내의 pptx 파일을 열고, pptx 파일 내에서 파일명이 ppt/slides/*.xml 형태인 파일을 xml로 파상한 뒤, 루트 요소에서 show 속성을 삭제하고 다시 쓰는 것을 일괄 반복하는 스크립트이다.

\n

사용 시에는 pptx_dir 변수값만 필요에 따라 수정하여 쓰면 된다. 위 스크립트를 실행하면 pptx_dir 변수에 설정된 디렉토리 내에 있는 pptx 파일들에서 숨김 처리된 슬라이드를 모두 숨김 해제한다.

\n

pptx ➡️ pdf 일괄 변환

\n

필자는 OneNote를 쓰는데 OneNote는 인쇄물 삽입을 pdf나 docx로만 해야한다. 따라서 모든 pptx를 pdf로 변환할 필요가 있다.

\n

이건 쉽다. 그냥 LibreOffice 명령어 한 줄이면 끝난다.

\n
libreoffice --headless --convert-to pdf *.pptx\n
\n

결론

\n

이상으로 여러개의 pptx 파일에서 숨김 처리된 슬라이드를 모두 일괄 숨김 해제하고 pdf로 일괄 변환하는 방법에 대해 알아보았다.

\n

다른 사람들에게 도움이 됐으면 좋겠다.

", "url": "https://blog.litehell.info/post/show_all_slides_of_pptx_and_convert_to_pdf_batch_operation", "title": "PPTX 파일 모든 슬라이드 숨김 해제하고 PDF 변환 일괄 작업하기", "summary": "Python과 LibreOffice를 이용한 pptx 일괄 처리", "image": "https://blog.litehell.info/pptx_zip_structure.png", "date_modified": "2024-03-06T08:33:00.099Z" }, { "id": "retrospective_of_2023", "content_html": "

들어가는 글

\n

2023년은 포스트코로나(POST COVID-19)의 해였다. 전역한 자에게 사회의 공기는 상쾌했고, 그리웠던 사람들을 오랜만에 볼 수 있어 좋았다. 그리고 2023년은 유난히 바쁜 해였다. 중요한 전공 과목들을 본격적으로 배우고, 첫 캡스톤디자인을 시작했다. 지난 날들을 되짚으며, 회고를 2023년 12월 30일쯤에 쓰기 시작했는데... 현생이 바빠서 못 쓰다가 이제서야 퇴고를 하게됐다. 두서없는 글이지만, 넓은 아량으로 읽어주셨으면 좋겠다.

\n

2023-1학기

\n

편의상 여름방학때 한 일도 이 문단에 같이 적겠다.

\n

학생자치 당선

\n

필자는 2020년에 중앙동아리 부회장을 한 경력이 있다. 이 경력을 바탕으로 동아리연합회 분과장 보궐선거에 출마했다. 예상 외로 영화동아리에서 후보가 출마해 혹시나 하는 마음이 들었지만, 경선에서 가볍게 압승했다.

\n

학생자치의 경험은 꽤 재밌었지만, 아쉬운 점도 많았다. 이에 관한 것들은 이야기거리가 많으니 차후에 별도의 글로 쓰도록 하겠다.

\n

알고리즘 학회 홈페이지 디자인 개편

\n

\"ChAOS

\n

중앙대학교 소프트웨어학부 알고리즘 학회 ChAOS의 부회장이 되면서 디자인을 간단하게 개편했다. Bootstrap 같은 라이브러리를 쓰진 않았고 그냥 HTML과 CSS로 간단하게 작성했는데, 나름 깔끔하게 잘 뽑혔다고 생각한다. 반응형이라서 모바일에서도 잘 보인다.

\n

SketchDaily references 앱 출시

\n

필자는 SketchDaily references 사이트를 이용한다. 데스크톱에선 좋은데, 모바일에선 사소한 버그가 있었다. 마침 Flutter에 관심이 있어서 이 웹사이트를 앱으로 만들어보면 좋을 것 같다는 생각이 들었고, 사이트 운영자에게서 허락을 받았다.

\n
\n

Hi there

\n

I'm ok with you doing it as long as:

\n
    \n
  1. it's free
  2. \n
  3. no ads
  4. \n
\n

-arto

\n
\n

---- On Wed, 30 Nov 2022 07:46:30 -0700 LiteHell litehell@litehell.info wrote ---

\n
\n

Dear artomizer:

\n

I’m Yeonjin Shin, an user of SketchDaily reference site.

\n

I usually use your SketchDaily reference site on my android phone, and it’s a good website for drawing references to me. But, I think It would be better if there is a mobile app for mobile users.

\n

So, I want to make SketchDaily reference app for mobile users. I hope you to let me know if I can make a mobile app for SketchDaily reference?

\n

Sincerely,
\nYeonjin Shin

\n
\n

Yeonjin Shin
\nCSE Student; Rookie software engineer

\n

Homepage: https://litehell.info
\nGitHub: https://github.com/litehell
\nEmail: litehell@litehell.info

\n
\n
\n

2022년 12월 1일에 위와 같이 허락을 받고 개발을 시작했다. 그리고 2022년 6월 중에 개발을 마무리하고 그 다음 달에 첫 프로덕션 버전을 배포했다.

\n

본래 앱 디자인은 다른 분께서 해주시기로 하셨으나, 불가피한 사정이 생기신 관계로 내가 직접 간단히 만들게 됐다.

\n

Google Play에 앱을 출시하면서 느낀 점은 이것저것 정책적으로 신경써야 하는 게 꽤 많다는 점이였다. 개인정보, 청소년보호... 등등 응답해야 하는 것들이 꽤 있었고 출시 이후에도 세법이나 정책에 관한 메일이 자주 날라왔다. 애플은 내가 출시를 안해봐서 모르겠다.

\n

2023년 12월 28일을 기준으로 이 앱을 설치한 사용자가 한 410명쯤 되며, 미국인이 가장 많고, 그 뒤로 인도, 멕시코, 러시아 순으로 많았다. 인터넷 없이도 작동했으면 좋겠다는 의견도 있었는데 이건 웹사이트 운영자랑 협의해야 하는 문제인지라 실현 가능할 지에 대해서 부정적이다.

\n

학교수업

\n

1학기에는 알고리즘, 운영체제, 컴파일러, 소프트웨어공학, 멀티코어컴퓨팅, 선형대수학 수업을 들었다.

\n

필자는 수학에 능숙하지 않은 관계로 선형대수학 수업에서 잘 따라가지 못할 것 같아 많은 걱정을 했는데 생각외로 할만했다. 행렬과 근사를 위한 수학이라는 느낌을 매우 강하게 받았고, 컴퓨터그래픽스를 위한 수학이라는 인상을 강하게 받았다.

\n

운영체제 수업은 Operating Systems Internals and Design Principles 교재의 PPT를 이용한 수업으로 진행됐다. 시험은 누가 누가 더 PPT를 잘 외우는 지를 겨루는 형태였다. 내용은 컴퓨터공학도라면 꼭 알아야 하는 내용이라고 생각했지만, 시험과 수업 방식이 너무 아쉬웠다. 특히 과제에 대해 필자는 세마포어를 주제로 과제를 낸다면 세마포어를 구현하는 과제가 나와야 한다고 생각하는데, 교수님께서 세마포어를 이용하는 과제를 내서셔 아쉬웠다. 그래도 교수님께서 성격은 매우 좋으셨다.

\n

소프트웨어공학에서는 다른 사람들과 함께 협업할 때 소프트웨어를 어떻게 개발하는 지, 일정 내에 복잡한 소프트웨어를 어떻게 개발하는 지, 복잡한 소프트웨어를 어떻게 하면 최대한 적은 리스크로 개발할 수 있는 지에 대해 배웠다. 수업 내용이 너무 많아서 힘들었지만 추후 회사생활에 꼭 필요하다고 생각하는 내용이였다. 다만 기존에 예정됐었던 프로젝트 과제가 취소된 것은 개인적으로 아쉬웠다.

\n

멀티코어컴퓨팅은 POSIX pthread, C++ threads, Java thread, OpenMP 등 멀티코어 컴퓨팅을 구현하는 방법과, Amhdal's Law, 데이터를 각 쓰레드에 어떻게 나누어야 하는 지, 병렬 컴퓨팅에는 어떤 종류가 있는 지 등 병렬 컴퓨팅에 대한 이론을 개괄적으로 배웠다. 과제가 많이 나왔는데 필자는 과제하는 것을 좋아하는 성격인지라 재밌었다.

\n

특히 멀티코어컴퓨팅 과제 보고서를 작성하면서 LaTeX을 적극적으로 이용하고, 친구한테 LaTeX를 설파했는데 확실히 논문 스타일의 공학 보고서를 쓰는 데에는 매우 유용했다. 코드를 명령어 하나로 삽입할 수 있다는 점, 그래프와 표를 삽입할 때 위치가 왔다갔다하면서 문서 레이아웃이 깨지는 문제가 없다는 점, git으로 버전 관리를 하기가 매우 용이하다는 점 등이 좋았다.

\n

컴파일러 수업은 오토마타(DFA, NFA 같은 것들), 번역, 최적화 등을 개론적으로 다루었다. 컴파일러 최적화가 어떻게 이루어지는 지 간략하게 알 수 있어서 좋았다.

\n

알고리즘 수업은 교수님께서 단순히 어떤 알고리즘이 있다고 암기하는 것보다는 알고리즘에 대한 접근방법과 관점을 이해시키려 한다는 느낌을 많이 받았다. 강의력이 훌륭하신 교수님이셨다.

\n

2023-2학기

\n

알고모여

\n

중앙대학교는 졸업하려면 캡스톤디자인을 2번 이상 해야 한다.

\n
\n

컴퓨터공학전문 프로그램 공학교육인증에 관한 규정

\n

제48조(졸업요건)
\n다음 각 호의 요건을 모두 충족한 경우 컴퓨터공학전문 프로그램 졸업요건을 갖춘 것으로 한다. (개정 2012.03.01, 2013.03.01, 2015.01.01, 2016.03.01, 2018.03.01)

\n

⑦ 창의적설계, 캡스톤디자인(1), 캡스톤디자인(2) 교과목을 반드시 이수하여야 한다. 창의적설계 교과목 이수 전과 캡스톤디자인(1),(2) 교과목 이수 후에 수강한 설계학점(프로젝트학점)은 인정하지 않는다.

\n
\n

그래서 방학때 (필자포함) 3인으로 이루어진 팀을 만들고 아이디어만 간단하게 정했다. 필자는 리듬게임을 좋아하는 지라 리듬게임을 만들자고 했었는데 토론 결과 아무래도 무난한 아이디어가 좋을 것 같다는 쪽으로 합의가 되어 알고모여라는 서비스를 만들게 됐다.

\n

\"알고모여의

\n

알고모여는 알고리즘 스터디를 목표로 하는 웹사이트이다. 필자는 앞서 언급했듯이 알고리즘 학회의 부회장이였다. 그래서 1학기때 알고리즘 문제풀이 스터디를 운영했었는데 스터디원이 문제를 풀었는지 확인하는 일이 은근 귀찮아서 봇을 만들어 돌렸었다. 그래서 이걸 아예 서비스화해서 스터디원과 스터디장이 알고리즘 스터디를 편하게 운영할 수 있는 웹서비스를 만들면 좋지 않겠느냐는 의견이 나왔고, 그 아이디어가 채택되어 웹개발을 하게 됐다.

\n

나는 React와 Next.js로 프론트엔드를 개발하고, 다른 팀원은 Spring Boot로 백엔드를 개발하며, 나머지 팀원은 알고리즘 문제 채점 서버를 개발했다. Next.js를 이용한 이유는 React 개발환경 구축하는 데 들이는 시간을 단축하기 위해서이다. (다른 프레임워크도 있긴 하지만 써본 경험이 있는 프레임워크를 채택하는 것이 시간 단축에 더 유리할 것이라 판단한 것도 있었다.)

\n

내가 백엔드와 프론트엔드 중 프론트엔드를 채택한 것은 백엔드를 담당한 팀원이 웹개발 경험이 별로 없어서이기 때문이다. 프론트엔드는 어느 정도의 HTML/CSS 지식과 경험을 갖추고 있어야만 (비록 미적으로 아름답지는 않아도) 그럴싸하게 완성된 결과물이 나오지만, 백엔드는 데이터베이스와 HTTP, 그리고 웹 프레임워크(e.g. Spring)에 대한 대략적인 지식만 갖추고 있어도 그럴싸하게 완성된 결과물이 나온다. 따라서 웹개발이 부족한 팀원에게는 프론트엔드보다 백엔드를 맡기는 게 더 낫겠다는 판단이 들어서 내가 프론트엔드를 맡게 됐다.

\n

캡스톤 프로젝트를 하는 과정에서 JIRA 이슈 트래커를 이용하고, Pull Request에서 최소 1인 이상 Code Review해야 Merge하는 규칙을 세워 개발했는데 확실히 도움이 많이 됐다. 이슈 트래커를 이용하니 내가 지금 무슨 일을 해야하는 지 빠르게 알 수 있었고, 특히 JIRA의 칸반 보드 디자인이 직관적으로 보기가 편했다.

\n

\"알고모여

\n

\"알고모여

\n

그리고 Pull Request에서 최소 1인 이상의 코드 리뷰를 강제함으로써 코드 퀼리티를 높일 수 있었다. 필자는 백엔드 개발도 어느정도 알았기에 백엔드 코드를 읽어서 머리속으로 화이트박스 테스트를 했고, 프론트엔드 개발을 담당한 팀원께서는 프론트엔드 개발 지식이 많은 편이 아니었기에 직접 PR을 checkout하고 실행하여 버그를 찾는 블랙박스 테스트를 주로 해주셨다. 필자는 PR에서 백엔드 개발자가 놓친 부분(검증이라던가)이나 애매모호한 변수/함수명에 대한 의견을 주로 제시했고 프론트엔드 개발자는 필자가 미처 발견하지 못한 버그를 많이 찾아주셨다.

\n

위와 같은 개발 과정을 통해 꽤 완성도 있는 결과물을 산출할 수 있었고 그 결과 캡스톤디자인 과목에서 A+를 받을 수 있었다.

\n

수업

\n

컴퓨터그래픽스 수업에서는 행렬을 이용해 3D 객체가 어떻게 2D 화면에 투영되는지, 3D 공간에서의 변형(회전, 이동 등)이 행렬을 이용하여 어떻게 이루어지는 지부터 시작하여 OpenGL을 이용한 쉐이더와 기법들, 빛과 그림자, 이를 이용한 실제 OpenGL 프로그램 제작까지 했다. 컴퓨터그래픽스에 대해 무지했던더라 수업이 굉장히 재밌었고, Vertex shader와 Geometry shader를 활용한 Phong shading 등의 기법과 그림자 및 빛의 구현을 처음으로 배웠다.

\n

컴퓨터통신 수업에서는 물리 레이어부터 시작해 4계층 직전까지를 다룬다. Bottom-Up 방식으로 진행되어 컴퓨터통신 구조에 대한 전체적인 통찰을 얻기에 훌륭한 강의였다. 지금까지 기껏해봐야 추상화된 TCP/IP 소켓 API만 이용해보았기에 이더넷, Wi-Fi, IP 프로토콜의 작동 원리를 배울 수 있는 컴퓨터통신 수업에서 많은 걸 얻을 수 있었다. 비록 수업은 힘들었지만, 상위 레이어가 하위 레이어를 이용하고, Peer와 Peer 간에는 프로토콜(메세지 포맷)이 동일하다는 원칙 하에서 인터넷이 어떻게 동작하는 지 알 수 있었다.

\n

리눅스 시스템 응용설계에서는 리눅스 커널의 소스코드를 살펴보며 리눅스의 Mutex와 같은 동시성(Concurrency) 매카니즘과 프로세스 관리, 스케쥴링이 어떻게 이루어지는 지를 배웠다. 교수님께서 리눅스에 대해 진심이라는 것을 느꼈고, 리눅스 CFS의 동작 원리를 배울 수 있어 좋았다.

\n

머신러닝프로젝트와 패턴인식 과목에서는 머신러닝과 패턴인식에 대한 이론을 기초적인 수학에 기반하여 배웠다. 서로 다른 과목이긴 한데 교수님이 똑같으서셔 내용이 비슷했다. 머신러닝과 패턴인식이 무엇인지 몰랐던 필자에게 \"머신러닝과 패턴인식은 단순히 어떠한 데이터(점)이 어떠한 구역(분류)에 해당되는 지를 선/평면(퍼셉트론)이나 통계적 방법론(패턴인식)을 이용하여 찾아내는 것이다.\"라는 거대한 통찰을 제시하고 이러한 통찰 하에 머신러닝과 패턴인식에 필요한 기초적인 수학과 이에 기반한 이론(퍼셉트론, 다층 퍼셉트론, 딥러닝)을 간략하게 가르치셨다. 필자는 인공지능에 대해 아무것도 몰랐기에 수업이 매우 보람찼다.

\n

코딩부트캠프는 코딩테스트 보는 과목이다. 코딩테스트가 너무 쉬웠기에 그냥 졸입필수과목이라서 수강했다라는 말 외에는 딱히 쓸 말이 없다.

\n

2023년 소감

\n

매우 바쁜 해였다. 동아리 부회장도 하고 동아리연합회 임원도 하고 캡스톤디자인도 하고 면접도 보러다니고 하루하루가 바빴다. 하루하루를 바쁘게 산 만큼 언젠가 보상이 왔으면 좋겠다.

", "url": "https://blog.litehell.info/post/retrospective_of_2023", "title": "2023년의 회고", "summary": "학생자치와 캡스톤디자인", "image": "https://blog.litehell.info/cauchaos.png", "date_modified": "2024-02-05T15:55:48.396Z" }, { "id": "receiving_gpl_from_fsf", "content_html": "

자유/오픈소스 소프트웨어 라이선스는 MIT, BSD, Apache 등 여러가지 종류의 라이선스가 있다. 이 중 가장 유명한 카피레프트 라이선스로는 Linux, GNU, gcc 등으로 널리 알려진 GNU GPL 라이선스가 있으며, notepad++, Git, uBlock Origin, MariaDB 등의 많은 프로그램에서도 채택하고 있다. 주로 GPLv2 라이선스와 GPLv3 라이선스가 많이 쓰이는 데, GPLv2의 하단부를 읽으면 다음과 같은 문구를 볼 수 있다.

\n
\n

You should have received a copy of the GNU General Public License along
\nwith this program; if not, write to the Free Software Foundation, Inc.,
\n51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

\n
\n

만약 GNU GPLv2 전문을 받지 못했을 시 자유 소프트웨어 재단으로 편지를 보내주면 친절히 전문을 인쇄해서 보내준다고 써져있다. 여기서 호기심이 생겼다, 진짜로 보내줄까?

\n

선지자

\n

이런 궁금증은 나만 든게 아니었다. 외국의 개발자 mendhak이 이미 이를 시도했다. 글에 나온 바에 따르면, 진짜로 보내준다!

\n

진짜로 된다는 걸 확인해보니 나도 한 번 해보고 싶어졌다. 따라서 나도 국제우편을 한 번 보내보았다.

\n

우편과 국제반신우표권

\n

먼저 주변의 우체국에 가서 국제반신우표권을 구매했다. 국제반신우표권은 수신자가 나라와 상관없이 주변 우체국에서 우표로 교환할 수 있는 일종의 교환권이다. 집 주변 가장 가까운 우체국에서는 재고가 없었기에 조금 거리가 있는 큰 우체국에 가서 1,450원을 주고 구매했다. 이 글을 읽는 여러분도 국제반신우표권을 살 생각이 있다면 미리 우체국에 재고가 있는 지 확인해보는 것을 추천한다.

\n

\"국제반신우표권의

\n

(위 사진은 도장의 지역명을 삭제한 사진이다.)

\n

국제반신우표권과 함께 간단한 편지를 작성했다. 그 편지의 내용은 다음과 같다.

\n
\n

Dear Free Software Foundation

\n

I received a software, and its license notice said,

\n
You should have received a copy of the GNU General Public License
\n
\n

along with this program; if not, write to the Free Software
\nFoundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

\n
\n

However, the license was not provided with the software.
\nPlesae can you provide me with a copy of GNU General Public License?

\n

I have enclosed a international reply coupon, which is can be exchanged with a stamp in a nearby post office.

\n

Sincerely,\nYeonjin Shin

\n
\n

회신용 봉투를 안 넣으면 FSF 로고가 인쇄된 봉투에 담아서 줄 것 같다는 생각이 들어서 회신용 봉투는 일부러 안 넣었다. 이 편지와 국제반신우표권을 집에 있던 규격봉투(소봉투)에 집어넣고 우표를 붙였다.

\n

\"봉투와

\n

무게에 따라 다르겠지만, 미국으로 국제우편을 부치는 데에는 총 700원이 들었다. 사진에는 710원의 우표가 붙여져있지만, 부치는 데에는 700원이면 충분하다. (물론 무게에 따라 달라질 수 있다.)

\n

위와 같이 준비한 편지와 국제반신우표권을 봉투에 넣은 뒤, 2023년 7월 7일 주변 우체통에 넣어서 부쳤다. 오랜만에 우체통에 편지를 넣으니 감희가 새로웠다.

\n

\"봉함된

\n

진행

\n

지금(12월 7일)도 답장이 오지 않았다... 아무래도 중간에 유실된 것 같다. 그래도 혹시 만약에 답장이 오게 된다면 업데이트하겠다.

", "url": "https://blog.litehell.info/post/receiving_gpl_from_fsf", "title": "FSF에 편지 보내서 GPL 받아보기", "summary": "그러나 편지는 오지 않았다", "image": "https://blog.litehell.info/upu-irc-mosaic.jpg", "date_modified": "2023-12-07T14:19:37.603Z" }, { "id": "korean_style_award_with_latex", "content_html": "

들어가는 말

\n

최근 알고리즘 동아리에서 주최한 대회에 운영진으로 참가했다. 대회가 마무리된 후 1,2,3등을 위한 상장을 만들어야 했는데 이때 LaTeX를 써보면 재밌을 것 같다는 생각이 들었다.

\n

따라서 LaTeX을 이용해 아래와 같은 상장을 만들었다.

\n

TikZ

\n

TikZ는 LaTeX에서 그림을 그릴 때 쓰는 패키지이다. 주로 pgfplots와 함께 사용하거나 그래프를 그릴 때 이용되지만 간단한 그림 정도는 그릴 수 있다.

\n

current page

\n

TikZ에는 current page라는 특별한 노드가 있다. 이 노드는 current page.south westcurrent page.west와 같이 이용되며, 페이지의 모서리를 가리키고 있다.

\n

아래 예시를 보자. TikZ & PGF 메뉴얼 3.1.10버전의 260페이지에 있는 예시이다.

\n
\\begin{tikzpicture}[remember picture, overlay]\n    \\draw [line width=1mm, opacity=.25]\n        (current page.center) circle (3cm);\n\\end{tikzpicture}\n
\n

위 예시 코드를 빌드하면 페이지의 정중앙에 반지름이 3cm인 원이 그려진다. 위 코드에서 remember pictureoverlaycurrent page 노드를 사용하기 위해 필요하다.

\n

호 그리기

\n

TikZ는 \\draw arc (시작각도:종료각도:반지름) 형태의 명령어로 호(arc)도 그릴 수 있다.

\n

cycle

\n

cycle을 이용하면 마지막 노드에서 다시 처음 노드로 이어지는 닫힌 경로를 만들 수 있다.

\n

예시를 보면 이해가 쉽다.

\n
Without cycle\n\n\\begin{tikzpicture}\n\\draw (0, 0) -- (1, 1) -- (2, 0);\n\\end{tikzpicture}\n\nWith cycle\n\n\\begin{tikzpicture}\n\\draw (0, 0) -- (1, 1) -- (2, 0) -- cycle;\n\\end{tikzpicture}\n\n
\n

위 코드를 빌드하면 아래와 같은 결과가 나온다.

\n

\"tikz

\n

상장 템플릿 소스코드

\n

본 템플릿에서 그린 상장 테두리 장식은 호와 직선만을 이용해 그릴 수 있는 기하학적인 무늬(문방구 상장용지에서 흔히 볼 수 있는 무늬)이다. 따라서 TikZ를 잘 활용하면 상장 테두리 장식을 그릴 수 있다.

\n

GitHub Gist에서도 볼 수 있다.

\n
\\documentclass{minimal}\n\\usepackage{kotex}\n\\usepackage[svgnames]{xcolor}\n\\usepackage{tikz}\n\\usepackage[a4paper, top=100pt, left=80pt, right=80pt, bottom=120pt]{geometry}\n\\usetikzlibrary{calc}\n\n\\newcommand{\\thickframemargin}{40pt}\n\\newcommand{\\thickframewidth}{6pt}\n\\newcommand{\\thinframemargin}{46pt}\n\\newcommand{\\thinframewidth}{1pt}\n\\newcommand{\\framecornerradius}{30pt}\n\\newcommand{\\framecolor}{Goldenrod}\n\\begin{document}\n\\begin{tikzpicture}[remember picture, overlay]\n    % thick corner\n    \\draw[color=\\framecolor, line width=\\thickframewidth]\n     % north west rounded corner\n    ([xshift=\\thickframemargin, yshift=-\\thickframemargin-2*\\framecornerradius] current page.north west) arc (270:450:\\framecornerradius)\n    -- ([xshift=\\thickframemargin, yshift=-\\thickframemargin] current page.north west) arc (180:360:\\framecornerradius)\n     % north east rounded croner\n    -- ([xshift=-\\thickframemargin-2*\\framecornerradius, yshift=-\\thickframemargin] current page.north east) arc (180:360:\\framecornerradius)\n    -- ([xshift=-\\thickframemargin, yshift=-\\thickframemargin] current page.north east) arc (90:270:\\framecornerradius)\n     % south east rounded corner\n    -- ([xshift=-\\thickframemargin, yshift=\\thickframemargin+2*\\framecornerradius] current page.south east) arc (90:270:\\framecornerradius)\n    -- ([xshift=-\\thickframemargin, yshift=\\thickframemargin] current page.south east) arc (0:180:\\framecornerradius)\n    % south west rounded corner\n    -- ([xshift=\\thickframemargin+2*\\framecornerradius, yshift=\\thickframemargin] current page.south west) arc (0:180:\\framecornerradius)\n    -- ([xshift=\\thickframemargin, yshift=\\thickframemargin] current page.south west) arc (270:450:\\framecornerradius)\n    % cycle\n    -- cycle;\n\n\n    % thin corner\n    \\draw[color=\\framecolor, line width=\\thinframewidth]\n     % north west rounded corner\n    ([xshift=\\thinframemargin, yshift=-\\thinframemargin-2*\\framecornerradius] current page.north west) arc (270:360:\\framecornerradius)\n    -- ([xshift=\\thinframemargin+\\framecornerradius, yshift=-\\thinframemargin-\\framecornerradius] current page.north west) arc (270:360:\\framecornerradius)\n     % north east rounded croner\n    -- ([xshift=-\\thinframemargin-2*\\framecornerradius, yshift=-\\thinframemargin] current page.north east) arc (180:270:\\framecornerradius)\n    -- ([xshift=-\\thinframemargin-\\framecornerradius, yshift=-\\thinframemargin-\\framecornerradius] current page.north east) arc (180:270:\\framecornerradius)\n     % south east rounded corner\n    -- ([xshift=-\\thinframemargin, yshift=\\thinframemargin+2*\\framecornerradius] current page.south east) arc (90:180:\\framecornerradius)\n    -- ([xshift=-\\thinframemargin-\\framecornerradius, yshift=\\thinframemargin+\\framecornerradius] current page.south east) arc (90:180:\\framecornerradius)\n    % south west rounded corner\n    -- ([xshift=\\thinframemargin+2*\\framecornerradius, yshift=\\thinframemargin] current page.south west) arc (0:90:\\framecornerradius)\n    -- ([xshift=\\thinframemargin+\\framecornerradius, yshift=\\thinframemargin+\\framecornerradius] current page.south west) arc (0:90:\\framecornerradius)\n    % cycle\n    -- cycle;\n\\end{tikzpicture}\n\\fontsize{16pt}{16pt}\\selectfont 제 1 호\n\n\\vspace{32pt}\n\n\\begin{center}\n\\fontsize{48pt}{48pt}\\selectfont\n상\\hspace{1.25em}장\n\\end{center}\n\n\\vspace{50pt}\n\n\n\\fontsize{20pt}{20pt}\\selectfont\n최우수상\\hspace{\\stretch{1}}홍길동\n\n\n\\vspace{80pt}\n\n\\begin{center}\n \\fontsize{20pt}{30pt}\\selectfont\n 위 사람은 \\LaTeXe를 잘 활용하여 타의 모범이 되었으므로 이 상을 수여합니다.\n\\end{center}\n\n\n\\vspace{\\stretch{1}}\n\n\\begin{center}\n \\fontsize{20pt}{20pt}\\selectfont\n 1970년 1월 1일\n\\end{center}\n\n\n\\vspace{2em}\n\n\\begin{center}\n \\fontsize{30pt}{30pt}\\selectfont\n \\LaTeXe{} 애호가 김철수\n\\end{center}\n\n\\end{document}\n\n
\n

사진

\n

\"한국식

", "url": "https://blog.litehell.info/post/korean_style_award_with_latex", "title": "LaTeX을 이용한 한국식 상장 템플릿", "summary": "한국인이라면 한 번쯤 봤을 법한 그 양식", "image": "https://blog.litehell.info/latex_tikz_cycle.png", "date_modified": "2023-11-05T04:26:36.499Z" }, { "id": "docker_for_testing", "content_html": "

들어가는 글

\n

필자는 중앙대학교 공지사항을 RSS로 만들어서 구독한다. RSS로 만든 후 메신지 봇을 붙이면 알아서 알려주니 편하다.

\n

그러나 최근 해당 RSS 프로그램의 테스트가 실패하는 현상이 발견됐다. 확인한 결과, 중앙대학교 SW교육원 홈페이지의 TLS 인증서 이슈였던 것으로 확인됐다. 따라서 이를 해결하기 위해 일단 실행되고 있는 Docker 컨테이너에 직접 접근해서 해당 사이트의 CA 인증서를 설치했다.

\n

기존 테스트 방법의 한계점

\n

버그는 일단 임시방편으로 수정한 것이니 레포에는 반영되지 않았다. 따라서 테스트 실패 메일이 매일매일 내 메일함으로 전송됐다.

\n

어떻게 하면 이 버그를 수정하고 잘 테스트할 수 있을까? 먼저 이 버그를 수정하려면 Dockerfile을 수정해야 한다. Dockerfile에 다음 내용을 추가하여 Docker 이미지 빌드시 CA 인증서를 복사하도록 했다. LiteHell/cau-rss 레포의 커밋 21013a3에서 확인할 수 있다.

\n
COPY swedu-cert.pem /usr/local/share/ca-certificates/swedu-cert.crt\nRUN cat /usr/local/share/ca-certificates/swedu-cert.crt >> /etc/ssl/certs/ca-certificates.crt\n
\n

이제 위 버그 수정도 같이 테스트해야 한다. 아래에 있는 기존의 GitHub Action으로는 이 버그 수정을 테스트할 수 없다. go test -v ./... 명령어가 빌드된 Docker 이미지 내에서 실행되는 것이 아니기 때문이다.

\n
      - name: Build\n        run: go build -v ./...\n\n      - name: Test\n        run: go test -v ./...\n
\n

어떻게 하면 테스트할 수 있을까? 답은 간단하다. Docker로 테스트도 하면 된다.

\n

Docker를 이용한 테스트

\n

Multi-stage 빌드

\n

Docker는 빌드를 여러 단계로 나누어 진행할 수 있다. 아래 예시 Dockerfile을 보자.

\n
FROM node AS base\nWORKDIR /app\nADD src package.json package-lock.json tsconfig.json .\n\nRUN npm i\nRUN npm bulid\n\nCMD [\"npm\", \"run\", \"start\"]\n
\n

Typescript 프로젝트를 위한 간단한 Dockerfile이다. 이를 다음과 같이 여러개의 단계(stage)로 쪼갤 수 있다.

\n
FROM node AS base\nWORKDIR /app\nADD src package.json package-lock.json tsconfig.json .\n\nFROM base AS deps\nRUN npm i\n\nFROM deps AS build\nRUN npm bulid\n\nFROM build AS deployment\nCMD [\"npm\", \"run\", \"start\"]\n\n
\n

위와 같은 Dockerfile을 이용하면 Docker 빌드시 특정 스테이지까지만 빌드할 수 있다. 예를 들어 아래 명령어는 deps 스테이지까지만 빌드한다.

\n
docker build --target deps\n
\n

스테이지가 직선적이여야 할 필요는 없다. 다음과 같이 스테이지가 중간에 분기하도록 작성할 수도 있다.

\n
FROM node AS base\nWORKDIR /app\nADD src package.json package-lock.json tsconfig.json .\n\nFROM base AS deps\nRUN npm i\n\nFROM deps AS build\nRUN npm bulid\n\nFROM build AS english\nCOPY english .\n\nFROM english AS deployment-international\nCMD [\"npm\", \"run\", \"start\", \"--lang=english\"]\n\nFROM build AS korean\nCOPY korean .\n\nFROM korean AS deployment-domestic\nCMD [\"npm\", \"run\", \"start\", \"--lang=korean\"]\n
\n

위 Dockerfile의 경우 build 스테이지에서 english 스테이지와 korean 스테이지로 분기한다.

\n

BuildKit

\n
FROM node AS base\nWORKDIR /app\nADD src package.json package-lock.json tsconfig.json .\n\nFROM base AS deps\nRUN npm i\n\nFROM deps AS build\nRUN npm bulid\n\nFROM build AS english\nCOPY english .\n\nFROM english AS deployment-international\nCMD [\"npm\", \"run\", \"start\", \"--lang=english\"]\n\nFROM build AS korean\nCOPY korean .\n\nFROM korean AS deployment-domestic\nCMD [\"npm\", \"run\", \"start\", \"--lang=korean\"]\n
\n

위 Dockerfile을 가지고 아래 명령어를 실행한다고 가정해보자.

\n
docker build --target deployment-domestic\n
\n

위 경우 빌드에 필요한 스테이지는 base, deps, build, korean, deployment-domestic이다. 그러나 실제로 위 명령어를 실행해보면 불필요한 english, deployment-international 스테이지도 빌드하는 것을 확인할 수 있다.

\n

이는 도커 레거시 빌더를 이용하기 때문에 생기는 문제이다. Docker BuildKit은 사용되지 않는 스테이지를 자동으로 파악하여 불필요한 스테이지는 빌드를 생략한다. 따라서 Docker BuildKit을 설치한 후 다음 명령어로 빌드하면 필요한 스테이지만 빌드할 수 있다.

\n
DOCKER_BUILDKIT=1 docker build --target deployment-domestic\n
\n

Multi-stage 빌드를 이용한 테스트

\n

이제 Docker를 이용해 테스트를 하는 방법에 대해 알아보자. 다음은 cau-rss 레포의 Dockerfile 내용을 약간 수정한 예시이다.

\n
FROM golang:alpine AS base\nWORKDIR /app\n\nCOPY go.mod go.sum ./\nRUN go mod download && go mod verify\n\nCOPY cau_parser ./cau_parser\nCOPY server ./server\n\n# To avoid tls error from swedu.cau.ac.kr\nCOPY swedu-cert.pem /usr/local/share/ca-certificates/swedu-cert.crt\nRUN cat /usr/local/share/ca-certificates/swedu-cert.crt >> /etc/ssl/certs/ca-certificates.crt\n\nCOPY static ./static\nCOPY html ./html\n\nCOPY *.go ./\n\nFROM base AS build\nRUN go build -v -o ./app ./\nCMD [\"/app/app\"]\n\nFROM base AS test\nRUN [\"go\", \"test\" ,\"-v\", \"./...\"]\n\n
\n

base 스테이지에서 의존성을 설치한 뒤 각종 필요한 파일들을 복사하고 TLS 인증서 오류 해결을 위한 CA 인증서를 복사한다. test 스테이지는 base 스테이지에서 테스트 명령어를 실행하는 스테이지이며, build 스테이지는 base 스테이지를 바탕으로 도커 이미지를 빌드하는 스테이지이다.

\n

따라서 위 Dockerfile을 이용해 build 스테이지까지 빌드하면 도커 이미지를 만드는 것이며, test 스테이지까지 빌드하면 테스트를 실행하게 되는 것이다. 이를 명령어로 나타내면 다음과 같으며, 캐시로 인해 테스트가 진행되지 않는 것을 방지하기 위해 --no-cache 매개변수를 추가했다.

\n
# Test\nDOCKER_BUILDKIT=1 docker build --no-cache --target test .\n\n# Build\nDOCKER_BUILDKIT=1 docker build --target build\n
\n

테스트 실패시 Docker 빌드 오류가 발생한다. 이를 응용하면 다음과 같이 테스트 성공시 빌드를 진행하고, 실패시 오류 메세지를 출력하는 bash 스크립트를 작성할 수 있다.

\n
export DOCKER_BUILDKIT=1\n\ndocker build --no-cache --target test .\ntest_status=$?\nif [ $test_status -eq 0 ]; then\n  docker build --taget build . --tag example-application\nelse\n  echo \"ERROR while testing!\"\nfi\n
\n

Github Action을 이용한 활용

\n

GitHub Action을 이용하면 다음과 같이 push시 테스트가 이루어지도록 할 수 있다.

\n
name: Test\non: push\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Test\n        run: docker build --no-cache --target test .\n        env:\n          DOCKER_BUILDKIT: 1\n\n
\n

빌드도 잘 되는지 확인하고 싶다면 빌드하는 job을 하나 더 추가하면 된다.

\n
name: Build and test\non: push\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build\n        run: docker build --target build .\n        env:\n          DOCKER_BUILDKIT: 1\n\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Test\n        run: docker build --no-cache --target test .\n        env:\n          DOCKER_BUILDKIT: 1\n\n
\n

결론

\n

Docker 이미지로 배포를 진행하는 경우, Docker로 테스트도 같이 진행하면 실제 배포 환경과 유사한 환경에서 테스트를 진행할 수 있다는 큰 장점이 있다. 따라서 복잡한 어플리케이션이라면 이 글을 참고해 Docker로 테스트도 같이 하는 것이 좋은 선택이 될 수 있다.

", "url": "https://blog.litehell.info/post/docker_for_testing", "title": "Docker로 테스트하기", "summary": "Docker로 빌드만 하지 말고 테스트도 하자", "image": "https://gravatar.com/avatar/837266b567b50fd59e72428220bf69b1", "date_modified": "2023-10-25T14:49:49.041Z" }, { "id": "fcitx5_for_101_key_keyboard_kde_laptop", "content_html": "

서론

\n

필자는 초창기에 ibus를 썼었다. ibus는 웬만한 프로그램에서 아무 버그없이 잘 작동한다. 딱 한가지, 리브레오피스에서 공백 입력이 안 된다는 치명적인 버그만 빼면 말이다.

\n

그래서 ibus 다음으로 하모니카에서 유지보수하는 nimf를 썼었다. nimf는 리브레오피스에서의 치명적인 버그는 없었지만, 엔터키를 누르면 텍스트가 사라지는 버그가 있었다. 근데 이 버그, 처음에만 짜증나지 좀 지나면 적응된다. 그래서 적응해서 쓰다가 생각해보니 '이건 좀 아닌 것 같다'싶어서 다른 입력기를 설치했다.

\n

본 블로그 글은 Arch Linux를 기준으로 설명한다.

\n

KDE에서의 키보드 레이아웃

\n

입력기를 바꾸기 위해 삽질하는 과정에서 한글키가 오른쪽 Alt키로 인식되는 현상을 확인했다. 분명히 아치 리눅스 설치 초기에 매핑을 했었는데, 시스템 업데이트를 하는 과정에서 원상복구가 된 것 같다. 그래서 이번에는 KDE 설정 프로그램을 이용해 한글키와 한자키를 매핑했다.

\n

\"KDE

\n

위와 같이 시스템 설정 프로그램의 입력 장치 🠞 키보드 화면에서 오른쪽 Alt 키를 한/영 키로 만들기, 오른쪽 Ctrl 키를 한자 키로 만들기 항목을 체크하면 된다. (키보드 레이아웃에 따라 약간 다를 수 있다.) 노트북 등의 101/104키 호환 레이아웃이라면 위 과정을 반드시 거쳐야 한다.

\n

한/영, 한자키 인식여부 확인방법

\n

본인 키보드가 101/104키인지 106키인지 헷갈린다면 키보드 키 갯수 세지말고 먼저 xev 프로그램을 설치한다.

\n
sudo pacman -S xorg-xev\n
\n

그리고 콘솔 창에서 xev 프로그램을 실행한다.

\n
xev\n
\n

xev 프로그램 창을 활성화하고 한글키랑 한자키를 눌러본다. 다음과 같이 콘솔 창에 Hangul이나 Hangul_Hanja키가 인식된 메세지가 출력되면 한/영 키, 한자 키가 정상적으로 인식되는 것이다.

\n
KeyRelease event, serial 39, synthetic NO, window 0x9000001,\n    root 0x79b, subw 0x0, time 1234567, (-10, 10), root:(10, 10),\n    state 0x0, keycode 108 (keysym 0xff31, Hangul), same_screen YES,\n
\n
KeyPress event, serial 39, synthetic NO, window 0x9000001,\n    root 0x79b, subw 0x0, time 1234567, (10, 10), root:(10, 10),\n    state 0x0, keycode 105 (keysym 0xff34, Hangul_Hanja), same_screen YES,\n
\n

만약 위와 같은 메세지가 안 뜨고 Alt_R이나 Control_R이 인식된다면 위에 써진 내용에 따라 매핑하면 된다.

\n

fcitx5 설치 방법

\n

먼저, 다음 명령어를 실행해 fcitx5를 설치한다.

\n
sudo pacman -S fcitx5-im fcitx-hangul\n
\n

/etc/environment 파일에 다음 내용을 추가한다. 입력기로 fcitx를 쓰도록 지정하는 작업이다.

\n\n
GTK_IM_MODULE=fcitx\nQT_IM_MODULE=fcitx\nQT4_IM_MODULE=fcitx\nQT5_IM_MODULE=fcitx\nXMODIFIERS=@im=fcitx\n
\n

그 다음에 ~/.xprofile 파일에 다음 내용을 추가한다. 부팅시에 fcitx5가 실행되도록 한다.

\n
fcitx5 -d\n
\n

재부팅하고 env | grep fcitx 명령어를 실행해 환경변수가 제대로 변경됐는지 확인해보자. 제대로 변경됐다면 다음과 같이 뜰 것이다.

\n
GTK_IM_MODULE=fcitx\nQT4_IM_MODULE=fcitx\nXMODIFIERS=@im=fcitx\nQT5_IM_MODULE=fcitx\nQT_IM_MODULE=fcitx\n
\n

만약 환경변수가 제대로 변경되지 않았다면 ~/.xprofile 파일에서 fcitx5 -d 위에 다음 내용을 추가하고 재부팅한다. 그러면 환경변수가 정상적으로 변경될 것이다.

\n
export $(/usr/lib/systemd/user-environment-generators/30-systemd-environment-d-generator)\n
\n

fcitx5 설정

\n

fcitx5-configtool 명령어를 실행하면 다음 창이 뜬다.\n\"fcitx5

\n

위 화면에서 한국어가 안 보이면 입력기 추가버튼을 눌러서 추가한다. (입력기 추가 화면에서 한국어가 안 보이면 현재 언어만 표시 옵션을 해제하면 된다.)

\n

밑에서 전역 옵션 구성하기... 버튼을 누르면 다음 화면이 뜬다.

\n

\"fcitx5

\n

Trigger Input Method가 한/영을 전환하는 단축키 설정이다. 오른쪽의 + 버튼을 눌러 한글 키를 추가하면 된다.

\n

fcitx5는 기본적으로 한/영을 전환할때 작은 툴팁을 표시한다. 거슬리면 위 화면에서 Show Input Method Information when switch input method를 체크 해제하면 된다.

\n

이제 한글 입력을 버그없이 잘 할 수 있게 됐다. 끝!

", "url": "https://blog.litehell.info/post/fcitx5_for_101_key_keyboard_kde_laptop", "title": "한글 입력을 위한 fcitx5 설치", "summary": "KDE 노트북에서의 버그없는 한글 입력을 위한 삽질기", "image": "https://blog.litehell.info/kde_keyboard_settings.png", "date_modified": "2023-10-17T12:34:16.790Z" }, { "id": "webpack_and_react_ssg_3", "content_html": "

전 글까지 style-loader를 썼다. style-loader는 style 태그를 동적으로 생성하여 CSS를 DOM 안에 주입하는 로더이다. 즉, style-loader를 쓰면 js 스크립트가 실행되면서 style 태그가 동적으로 생성되고, 그 태그 내에 css가 동적으로 삽입되면서 스타일이 적용된다.

\n

하지만 정적 페이지로 빌드후 속도가 느린 서버로 게시하거나 스크립트 용량이 비대하면, 스크립트가 완전히 다 실행되기 전까지의 찰나동안 스타일이 적용되지 않은 깨진 페이지가 나타난다. 이런 버그를 막기 위해서는 MiniCssExtractPlugin을 이용하면 된다.

\n

MiniCssExtractPlugin은 CSS를 스크립트를 통해 DOM에 주입하지 않고 별도의 CSS 파일에 저장한 뒤, 빌드시에 해당 CSS 파일을 삽입하는 link 태그를 HTML에 삽입한다. 즉 동적으로 CSS를 주입하지 않는다. 따라서 이를 이용하면 스크립트가 완전히 다 실행되기 전까지 페이지 스타일이 적용되지 않는 현상을 해결할 수 있다.

\n

MiniCssExtractPlugin을 이용하기 위해서는 먼저 해당 패키지를 설치해야 한다. 다음 명령어로 해당 패키지를 설치한다.

\n
yarn add --dev mini-css-extract-plugin\n
\n

그 다음 webpack.config.js 파일에서 다음 부분을 다음과 같이 수정한다.

\n
// 수정 전\nmodule: {\n        ...commons.module,\n        rules: [\n            ...commons.module.rules,\n            // CSS를 빌드시 로드하도록 하면 오류가 발생한다. (Node.js 환경은 웹브라우저가 아니므로 스타일 주입 시도가 당연히 실패하기 때문이다.)\n            // 따라서 css파일은 웹브라우저단에서 로드되는 번들 스크립트에서만 주입되도록 \n            // src/index.tsx Webpack 설정에만 추가한다.\n            {\n                test: /\\.css$/,\n                use: ['style-loader', 'css-loader', 'postcss-loader']\n            },\n        ]\n    },\n    plugins: [\n        ...commons.plugins,\n        new HtmlWebpackPlugin({\n        filename: 'index.html',\n        // template 속성을 추가한다.\n        template: './src/index.html'\n    })],\n
\n
// 수정 후\nmodule: {\n        ...commons.module,\n        rules: [\n            ...commons.module.rules,\n            // CSS를 빌드시 로드하도록 하면 오류가 발생한다. (Node.js 환경은 웹브라우저가 아니므로 스타일 주입 시도가 당연히 실패하기 때문이다.)\n            // 따라서 css파일은 웹브라우저단에서 로드되는 번들 스크립트에서만 주입되도록 \n            // src/index.tsx Webpack 설정에만 추가한다.\n            // 그리고 프로덕션 빌드시에는 MiniCssExtractPlugin을 이용하여 js가 다 로드되기 전에는\n            // 스타일이 적용되지 않는 버그를 해결한다.\n            {\n                test: /\\.css$/,\n                use: [dev ? 'style-loader' : MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']\n            },\n        ]\n    },\n    plugins: [\n        ...commons.plugins,\n        new MiniCssExtractPlugin(),\n        new HtmlWebpackPlugin({\n        filename: 'index.html',\n        // template 속성을 추가한다.\n        template: './src/index.html'\n    })],\n
\n

위와 같이 수정한 후 정적 웹페이지를 빌드하면 이제 스크립트가 불러와지는동안 스타일이 적용되지 않는 문제가 해결된다.

", "url": "https://blog.litehell.info/post/webpack_and_react_ssg_3", "title": "Webpack과 React를 이용한 정적 웹사이트 만들기 (3)", "summary": "... + MiniCssExtractPlugin = TA-DA!", "image": "https://gravatar.com/avatar/837266b567b50fd59e72428220bf69b1", "date_modified": "2023-07-23T17:25:47.907Z" }, { "id": "creating_mastodon_instance", "content_html": "

트위터와 페디버스

\n

확실히 요즘 트위터는 달라졌다. 일론 머스크가 인수한 이후의 트위터는 확실히 뭔가 달라졌다. 뭔가 보여주겠다는 의지의 표출인가?

\n

이런 새로워진 트위터에 적응하지 못한 사람들은 마스토돈이나 미스키 등의 ActivityPub를 구현한 분산형 SNS 인스턴스로 이주하고 있다. 국내의 이런 분산형 SNS는 대표적으로 다음과 같다.

\n\n

위와 같이 서로 AcitivtyPub 등의 프로토콜을 이용해 통신하는 SNS 서비스들의 집합을 Fediverse(페디버스)라 일컬는다. 페디버스는 서버간에 서로 통신할 수 있기 때문에 다른 서버에 있는 사용자와도 소통할 수 있다. 즉, 서버 A에서 서버 B에 있는 사람을 팔로우할 수도 있다.

\n

Mastodon 설치

\n

분산형 SNS이니 당연히 본인이 직접 서버를 구축하는 것도 가능하다. 따라서 직접 VPS에 마스토돈을 설치했다. VPS 운영체제로는 데비안을 택했고, 사양은 Vultr $6 VPS로 했다.

\n

설치 자체는 공식 홈페이지의 문서를 따르는 식으로 진행했으나 중간중간 삽질을 약간 했다.

\n

root 계정 전환

\n

마스토돈을 설치하기 위해서는 먼저 root 계정으로 전환한다. su 명령어를 이용하면 된다.

\n

의존성 설치

\n

그 다음, 의존성을 설치해야 한다. 의존성 설치 자체는 공식 홈페이지에 있는 명령어를 복사-붙여넣기하면 끝난다.

\n

첫번째로 다음 명령어를 실행해 node.js v16을 설치한다.

\n
curl -sL https://deb.nodesource.com/setup_16.x | bash -\n
\n

그리고 다음 명령어를 실행해 PostgreSQL와 기타 다른 의존성들을 설치한다.

\n
apt install -y curl wget gnupg apt-transport-https lsb-release ca-certificates\nwget -O /usr/share/keyrings/postgresql.asc https://www.postgresql.org/media/keys/ACCC4CF8.asc\necho \"deb [signed-by=/usr/share/keyrings/postgresql.asc] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main\" > /etc/apt/sources.list.d/postgresql.list\napt update\napt install -y \\\n  imagemagick ffmpeg libpq-dev libxml2-dev libxslt1-dev file git-core \\\n  g++ libprotobuf-dev protobuf-compiler pkg-config nodejs gcc autoconf \\\n  bison build-essential libssl-dev libyaml-dev libreadline6-dev \\\n  zlib1g-dev libncurses5-dev libffi-dev libgdbm-dev \\\n  nginx redis-server redis-tools postgresql postgresql-contrib \\\n  certbot python3-certbot-nginx libidn11-dev libicu-dev libjemalloc-dev\n
\n

nodejs 버전 확인

\n

다만 여기서 주의해야 하는 것이 있다. 시스템에 따라서 nodejs 16버전이 아닌 그보다 더 최신 버전이 설치됐었을 수도 있다.\n상관없지 않냐고? 상관있다, nodejs v16 버전이 아니면 나중에 webpack precompile 과정에서 오류가 난다.

\n

설치된 nodejs의 버전은 node --version 명령어로 확인할 수 있다.

\n
$ node --version\nv18.13.0\n
\n

이는 데비안 apt 레포지토리에 있는 nodejs 패키지 버전이 16보다 더 최신이기 때문에 발생한 문제이다. 이를 해결하기 위해서는 먼저 sudo apt-cache policy nodejs를 실행해 어떤 버전이 설치 가능한지 확인해야 한다.

\n
$ sudo apt-cache policy nodejs\nnodejs:\n  Installed: 18.13.0+dfsg1-1\n  Candidate: 18.13.0+dfsg1-1\n  Version table:\n *** 18.13.0+dfsg1-1 500\n        500 https://deb.debian.org/debian bookworm/main amd64 Packages\n        500 https://debian.mirror.constant.com bookworm/main amd64 Packages\n     16.20.1-deb-1nodesource1 500\n        500 https://deb.nodesource.com/node_16.x bookworm/main amd64 Packages\n        100 /var/lib/dpkg/status\n
\n

위 예시에서는 18.13.0+dfsg1-116.20.1-deb-1nodesource1 버전이 설치 가능하다. 우리가 필요한 것은 nodejs v16대 버전이니 16.20.1-deb-1nodesource1을 설치할 것이다.

\n

apt에서 특정한 버전을 지정해 설치하기 위해서는 다음과 같이 명령어를 실행하면 된다.

\n
$ sudo apt install nodejs=16.20.1-deb-1nodesource1\n
\n

그러면 nodejs v16이 정상적으로 설치된 것을 확인할 수 있다.

\n

Yarn 설치

\n

위에서 nodejs, PostregreSQL 등의 의존성을 다 설치했으면 Yarn을 설치해야 한다. Yarn은 다음 명령어로 설치한다.

\n
corepack enable\nyarn set version classic\n
\n

혹시 위 명령어가 작동하지 않는다면 다음과 같이 npm을 이용해 설치할 수도 있다.

\n
sudo npm i -g yarn\n
\n

만약 위 npm을 이용한 명령어가 npm이 설치되어 있지 않아 실행되지 않는다면 아래 명령어로 npm을 실치할 수 있다.

\n
curl -qL https://www.npmjs.com/install.sh | sh\n
\n

Ruby 설치

\n

이제 Ruby를 설치해야 한다. 먼저 mastodon이라는 이름의 리눅스 계정을 생성한다.

\n
adduser --disabled-login mastodon\n
\n

그리고 쉘을 지정한다. (안 하면 sudo su - mastodon 명령어가 오류날 수 있다.) 아래 명령어에서는 쉘을 bash로 지정했는데, 쉘이 무조건 bash여야 할 필요는 없다. 선호하는 쉘이 있다면 그 쉘로 지정해도 된다.

\n
chsh -s /bin/bash mastodon\n
\n

이제 mastodon으로 계정을 전환하자.

\n
sudo su - mastodon\n
\n

다음 명령어를 모두 실행해 Ruby를 설치한다.

\n
git clone https://github.com/rbenv/rbenv.git ~/.rbenv\ncd ~/.rbenv && src/configure && make -C src\necho 'export PATH=\"$HOME/.rbenv/bin:$PATH\"' >> ~/.bashrc\necho 'eval \"$(rbenv init -)\"' >> ~/.bashrc\nexec bash\ngit clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build\nRUBY_CONFIGURE_OPTS=--with-jemalloc rbenv install 3.0.6\nrbenv global 3.0.6\n
\n

Ruby 설치가 완료됐다면 bundler도 설치한다.

\n
gem install bundler --no-document\n
\n

Ruby와 bundler 설치를 마쳤다면 root 유저로 되돌아간다.

\n
exit\n
\n

PostgreSQL 설정

\n

공식 문서에서 pgTune을 쓰고 싶으면 쓰라고 나와있는데 필자는 귀찮아서 건너뛰었다.

\n

PostgreSQL 설정을 위해 다음 명령어로 PostgreSQL 쉘을 띄운다.

\n
sudo -u postgres psql\n
\n

PostgreSQL 쉘이 띄워졌으면 다음 쿼리를 실행해서 SQL 계정을 생성한다.

\n
CREATE USER mastodon CREATEDB;\n
\n

계정이 생성됐으면 다음 명령을 쳐서 쉘을 빠져나온다.

\n
\\q\n
\n

마스토돈 다운로드

\n

이제 마스토돈을 다운로드하고 설정할 때가 왔다. 먼저 mastodon 계정으로 전환한다.

\n
sudo su - mastodon\n
\n

다음 명령어를 실행해 최신 stable 버전의 mastodon을 다운로드한다.

\n
git clone https://github.com/mastodon/mastodon.git live && cd live\ngit checkout $(git tag -l | grep -v 'rc[0-9]*$' | sort -V | tail -n 1)\n
\n

이제 Ruby 의존성과 JavaScript 의존성을 설치한다.

\n
bundle config deployment 'true'\nbundle config without 'development test'\nbundle install -j$(getconf _NPROCESSORS_ONLN)\nyarn install --pure-lockfile\n
\n

서버 swap 설정 및 nodejs heap 용량 설정

\n

마스토돈 설정(바로 다음 문단)을 하는 과정에서 Javascript heap out of memory 오류가 발생할 수 있다. 이는 서버에 RAM이 부족하기 때문이다. 이를 해결하기 위해서는 RAM을 더 꽂거나 swap 파일을 형성하고, 그 다음 node 설정을 수정해야 한다.

\n

먼저 swap파일을 생성하는 방법은 다음과 같다. 용량은 적절하게 바꾸면 된다.

\n
sudo fallocate -l 2G /tmp-swapfile\nsudo chmod 600 /tmp-swapfile\nsudo mkswap /tmp-swapfile\n
\n

생성된 swap파일은 다음과 같이 적용할 수 있다.

\n
sudo swapon /tmp-swapfile\n
\n

위 명령어로 적용된 swap파일은 재부팅이 될 시 다시 적용되지 않으므로 재부팅을 하면 위 명령어를 다시 쳐줘야 한다. 따라서 swap 파일을 영구적으로 적용하기 위해서는 /etc/fstab 파일을 수정해야 하나, 본 글에서는 마스토돈을 설정하는 동안에만 임시적으로 이용할 swap 파일을 생성하는 것이므로 이 파일을 수정하는 방법에 대해서 언급하지 않는다.

\n

이제 node 설정을 바꾸어야 한다. 먼저 현재 할당한 힙 용량을 다음 명령어로 확인한다.

\n
node -e 'console.log(v8.getHeapStatistics().heap_size_limit/(1024*1024))'\n
\n

실행하면 다음과 같이 뜰 것이다.

\n
$ node -e 'console.log(v8.getHeapStatistics().heap_size_limit/(1024*1024))'\n495.75\n
\n

위 용량을 참고해서 위 용량보다 적당히 더 큰 용량으로 힙 용량을 설정하면 된다. 힙 용량의 설정은 다음 명령어를 마스토돈 설정 명령어 실행 직전에 실행함으로써 할 수 있다.

\n
export NODE_OPTIONS=--max_old_space_size=800\n
\n

마스토돈 설정

\n

다음 명령어를 실행해 마스토돈 서버를 설정한다.

\n
RAILS_ENV=production bundle exec rake mastodon:setup\n
\n

만약 Javascript heap out of memory 오류가 떴다면 바로 윗 문단에 따라 힙 용량 및 swap 설정을 하고 다음 명령어를 실행하면 된다.

\n
RAILS_ENV=production bundle exec rails assets:precompile\n
\n

성공적으로 실행됐을 시 마스토돈 관리자 비밀번호가 표시될 것이다. 잊지 말고 메모하도록 하자.

\n

nginx 설정

\n

다음 명령어를 실행한다.

\n
cp /home/mastodon/live/dist/nginx.conf /etc/nginx/sites-available/mastodon\nln -s /etc/nginx/sites-available/mastodon /etc/nginx/sites-enabled/mastodon\n
\n

/etc/nginx/sites-available/mastodon 파일에서 example.com을 모두 자신의 마스토돈 도메인(내 경우에는 social.litehell.info)로 바꾼다. 그리고 다음 명령어를 실행한다.

\n
systemctl reload nginx\n
\n

CloudFlare Origin Certificate 설정

\n

필자는 CloudFlare Origin Certificate를 쓴다. CloudFlare에서 Origin Certificate를 생성한 뒤 서버에 저장하고, /etc/nginx/sites-available/mastodon 파일에서 ssl_certificate 속성과 ssl_certificate_key를 다운받은 서버/서버 개인키 경로로 수정하면 된다.

\n

systemd 설정

\n

아래 명령어를 실행한다.

\n
cp /home/mastodon/live/dist/mastodon-*.service /etc/systemd/system/\nsystemctl daemon-reload\nsystemctl enable --now mastodon-web mastodon-sidekiq mastodon-streaming\n\n
\n

그러면 마스토돈이 실행될 것이다. 이제 즐기면 된다.

\n

CloudFlare 최적화로 인한 사이트 깨짐 문제 해결

\n

\"CSS가

\n

CloudFlare를 쓰면 위와 같이 마스토돈이 깨지는 문제를 겪을 수 있다.\n이는, 무결성을 위해 HTML 내에 CSS 파일의 해시가 포함되어있는데, CloudFlare가 CSS를 자동 최적화하면서 CSS 파일이 변경되고, 이로 인해 해시가 불일치됨에 따라 웹브라우저가 CSS를 불러오지 않음으로써 발생하는 문제이다.

\n

이 문제는 CloudFlare에서 Auto Minify를 비활성화하고 모든 캐시를 삭제하여 해결할 수 있다.

\n

릴레이 연결

\n

마스토돈에 혼자 있으면 외롭다. 이를 극복하기 위해서는 릴레이를 연결해야 한다. 페디버스 내에서 인스턴스는 기본적으로 게시물을 팔로워가 있는 서버에만 전송한다. 따라서 타 서버의 팔로워가 없는 인스턴스는 외로울 수 밖에 없다. 이를 극복하기 위해 릴레이가 있다.

\n

릴레이는 구독하는 서버들간에 게시물을 나눈다. 릴레이에 구독된 인스턴스가 게시물을 릴레이로 보내면, 릴레이가 구독된 모든 서버들에게 게시물을 전송하는 방식이다. 따라서 릴레이내에 있는 서버간에는 팔로워가 있는지의 여부와 상관없이 게시물이 서로 공유된다.

\n

한국어권 릴레이는 다음 세가지 릴레이가 있다. 이 릴레이는 모두 화이트리스트이다.

\n\n

위 릴레이에 가입하기 위해서는 각 릴레이에서 요구하는 조건을 모두 만족시킨 뒤 릴레이측에 가입 신청을 하면 된다. 가입 신청 방법 및 조건은 릴레이마다 다르다. 가입 신청이 받아들여지면 릴레이 관리 페이지(/admin/relays)에서 해당 릴레이에서 안내하는 주소를 추가하면 된다. 참고로 내 경험상 개인 인스턴스라고 딱히 안 받아주진 않았다. 조건만 맞으면 받아주는 것 같으니 조건이 맞는다면 부담없이 신청해보자.

\n

모든 릴레이가 화이트리스트인 것은 아니다. RelayList에서 Registeration이 open으로 되어있는 릴레이는 가입신청을 하지 않아도 되는 릴레이들이다. 다만 대규모 릴레이는 구독이 처리되는 데 시간이 좀 오래 걸릴 수 있다.

\n

#FediBuzz Relay 서비스를 이용하면 특정 마스토돈 인스턴스의 타임라인을 릴레이를 통해 구독할 수도 있다. 해당 사이트의 안내를 따르면 특정 인스턴스의 타임라인을 구독할 수 있다.

\n

public/system 용량 문제

\n

public/system 디렉토리는 용량을 많이 잡아먹는다. 다음 두 가지 방법 중 하나를 택하여 해결하면 된다.

\n\n

RAILS_ENV=production ./bin/tootctl accounts prune\nRAILS_ENV=production ./bin/tootctl cache clear\nRAILS_ENV=production ./bin/tootctl media remove --days=0\nRAILS_ENV=production ./bin/tootctl media remove --prune-profiles --days=0\nRAILS_ENV=production ./bin/tootctl preview_cards remove --days=0

\n
- crontab과 위 명령어만으로 부족하면 그냥 대용량 하드디스크 하나 꽂고 `/etc/fstab` 파일 수정해서 `public/system` 디렉토리에 영구 마운트해버리기 (좀 무식하게 보일 수도 있지만 간단하고 직빵이다)\n\n마음에 드는 방법을 택하도록 하자.\n
", "url": "https://blog.litehell.info/post/creating_mastodon_instance", "title": "Mastodon 서버 구축하기", "summary": "6$짜리 VPS와 CloudFlare를 이용한 인스턴스 구축", "image": "https://blog.litehell.info/broken_css_mastodon.png", "date_modified": "2023-07-22T15:55:38.110Z" }, { "id": "min_max_heap", "content_html": "

백준 7662번 이중 우선순위 큐 문제

\n

백준 이중 우선순위 큐 문제는 본 글에서 소개하는 최소-최대 힙(Min-max heap)을 구현하면 풀리는 문제이다.

\n

필자는 최소-최대 힙을 시도하기에 앞서 다른 방법(최소 힙이랑 최대 힙 두개 만들기)을 시도했었으나 능력이 부족한 탓인지 실패했다. 따라서 이 방법의 정석적인 풀이방법인 최소-최대 힙을 영문 위키백과의 Min-max heap 문서을 보면서 풀었다. 이 글에서는 최소-최대 힙에 대해 설명하고 C++ 구현 코드를 제시하고자 한다.

\n

Heap이란?

\n

Heap은 최소값이나 최대값 등을 빠르게 구하기 위해 만들어진 완전 이진 트리(Complete binary tree) 형태의 자료구조이다. 일반적으로 Heap이라고 말할 때는 보통 최대 힙(Max-heap)이나 최소 힙(Min-heap)을 의미한다. 이 중에서 최대 힙은 다음과 같이 구현된다.

\n\n

최소 힙은 위에서 비교하는 방향만 돌려서 다음과 같이 구현하면 된다.

\n\n

위와 같이 구현된 최대 힙에서는 항상 가장 큰 값을 가진 원소가 루트가 되며, 최소 힙에서는 가장 작은 값을 가진 원소가 항상 루트가 된다. 즉, 최대 힙을 이용하면 주어진 값들 중에서 최댓값을 빠르게 구할 수 있으며, 최소 힙을 이용하면 최소값을 빠르게 구할 수 있다.

\n

그렇다면 여기서 궁금증이 하나 생긴다, 최댓값과 최소값을 둘 다 빠르게 구할 수 있는 힙 자료구조가 있을까? 이에 대한 정답은 본 글에서 소개하고자 하는 최소-최대 힙이다.

\n

최소-최대 힙

\n

Min-max heap(최소-최대 힙)은 홀수번째 레벨(이하 Min-level)의 원소는 그 밑에 있는 모든 원소들보다 작거나 같은 값을 가지며, 짝수번째 레벨(이하 Max-level)의 원소는 그 밑에 있는 모든 원소들보다 크거나 같은 값을 가진다. 루트가 있는 레벨은 Min-level이다.

\n

\"예시

\n

위 예시를 보자. Min-level에 있는 원소는 그 하위에 있는 원소들보다 작은 값을 가지며, Max-level에 있는 원소는 그 밑에 있는 원소들보다 큰 값을 가진다.

\n

따라서 우리는 최소-최대 힙에서 (루트는 Min-level이므로) 루트는 항상 힙의 최소값을 가지며, 2번째 레벨에 있는(루트 바로 밑 레벨에 있는) 두 원소 중 가장 큰 값이 힙의 최댓값을 나타냄을 알 수 있다.

\n

구현

\n

최소-최대 힙의 구현은 다음과 같이 이루어진다.

\n\n

위를 구현하기 위해서는 Push-up과 Push-down 알고리즘을 구현해야 한다.

\n

Push-Up의 구현

\n

예시를 들어 설명해보자. 다음 예시를 보라.

\n

\"예시

\n

위 그래프는 유효한 최소-최대힙이다. 위 그래프의 맨 끝에 3이라는 값을 추가했다고 가정해보자. 3을 추가할 시 아래와 같이 변한다.

\n

\"예시

\n

값을 추가하는 순간 유효한 최소-최대 힙이 아니게 된다. Min-level에 있는 6보다 더 작은 자식 3이 생기기 때문이다.

\n

따라서 이 그래프를 다시 최소-최대 힙으로 만들기 위해서는 원소 3을 위로 올려가면서 적절한 위치를 찾아야 한다.\n먼저, 추가한 값인 3이 Min-level에 있는 부모(6)보다 작다. 이는 최소-최대 힙의 조건과 모순되므로 부모와 추가된 값의 위치를 서로 바꿔준다.

\n

\"에시

\n

추가된 값이 부모와 바꿔지면서 Min-level로 옮겨졌음을 확인할 수 있다. 이제 3은 3을 루트로 하는 서브트리 내에서만큼은 무결하다. 왜나하면, 최소-최대 힙에서 루트에 있던 값은 그 밑의 모든 값들보다 작거나(혹은 루트의 레벨에 따라서, 크거나) 같아야 한다는 특징이 있는데, 이 값이 (6에서 3으로) 더 작아지는 것이 이 특징을 깨트리지 않음은 자명하기 때문이다.

\n

그러나, 위 그래프는 아직도 최소-최대 힙의 특징을 만족하지 못한다. 5번째 레벨의 원소(값: 4)와 7번째 레벨의 원소(값: 5)를 보라. 5번째 레벨과 7번째 레벨은 홀수번째 레벨이므로 Min-level이다. 따라서 원소 4의 하위 원소들은 모두 값이 4보다 크거나 같아야 하고, 원소 5의 하위 원소들도 모두 값이 5보다 크거나 같아야 한다. 그러나 원소 3은 5나 4보다 크거나 같지 않다. 원소 5(혹은 4)의 하위 원소인 원소 3이 5(혹은 4)보다 작은 값을 가지므로 최소-최대 힙의 조건과 모순된다.

\n

따라서 이 모순을 해결하기 위해, 원소 3을 상위의 Min-level에 있는 원소(위 사진에서 파란색으로 표시된 원소들)들과 비교하며 적절한 위치를 찾아야 한다. 원소 3을 파란색으로 표시된 원소들과 비교하여 위로 올려가며 적절한 위치를 찾은 결과는 다음과 같다.

\n

\"에시

\n

최소-최대 힙에 값 6이 성공적으로 추가됐음을 알 수 있다.

\n

따라서 우리는 이 예시가 다음과 같은 알고리즘에 따라 이루어졌음을 알 수 있다.

\n
    \n
  1. 추가된 값(이하 원소 A)을 힙의 맨 뒤에 추가한다. A가 Max-level에 추가됐다고 가정하자.
  2. \n
  3. A를 부모 원소와 비교한다.\n
      \n
    1. A가 부모 원소보다 작거나 같다면 A와 부모 원소를 서로 바꾼다.
    2. \n
    \n
  4. \n
  5. A를 A의 조부모 원소와 비교한다.\n\n
  6. \n
  7. 3번으로 되돌아간다.
  8. \n
\n

위 알고리즘을 A가 처음에 Min-level에 추가된 경우로까지 확장하면 다음과 같다.

\n
    \n
  1. 추가된 값(이하 원소 A)을 힙의 맨 뒤에 추가한다.
  2. \n
  3. A를 부모 원소와 비교한다.\n\n
  4. \n
  5. A를 A의 조부모 원소와 비교한다.\n\n
  6. \n
  7. 3번으로 되돌아간다.
  8. \n
\n

위 알고리즘이 최소-최대 힙의 Push-Up 알고리즘이다. 이 알고리즘을 이용하면 원소의 추가를 구현할 수 있다.

\n

Push-down의 구현

\n

Push-down은 다음과 같이 구현한다. 먼저, 아래와 같은 유효한 최소-최대 힙이이 있다고 가정하자.

\n

\"Min-max

\n

위 힙에서 최소값을 제거하고 최소값이 있던 자리(루트)에 8을 넣었다고 가정해보자. 그 결과는 다음과 같다.

\n

\"Min-max

\n

위 그래프는 유효한 최소-최대 힙이 아니다. 따라서 이 그래프를 최소-최대 힙으로 만들기 위해서는 원소들의 위치를 아래로 내려가며 조정해야 한다.

\n

먼저, 위 그래프에서 원소 8은 Min-level에 있다. 따라서 원소 8의 자식(Child)과 손자(Grandchild)들중 가장 작은 값을 가진 원소를 확인한다. 이 원소는 2이다.

\n

원소 2가 원소 8보다 더 작음은 Min-max heap의 조건에 모순된다. 따라서 원소 2와 원소 8의 위치를 서로 바꾼다.

\n

\"Min-max

\n

원소 2와 원소 8의 위치를 서로 바꾸었지만 Max-level에 있는 원소 7이 자식인 원소 8보다 더 작은 값을 가지고 있다. 이는 모순이다. 따라서 원소 7과 원소 8의 위치를 서로 바꾼다.

\n

\"Min-max

\n

이제 원소 2는 하위에 있는 모든 원소들보다 작은 값을 가지고, 원소 8은 하위에 있는 모든 원소들보다 큰 값을 가진다. 원소 7 위로는 Min-max heap의 조건과 모순되는 원소가 없다. 그러나 원소 7의 밑을 보라. 원소 7은 Min-level이므로 원소 7 하위의 모든 원소들보다 작거나 같은 값을 가져야 한다. 그러나 원소 6, 3, 5, 4로 인하여 이 조건이 만족되지 않는다.

\n

이를 해결하기 위해 원소 7의 자식과 손자들 중 가장 작은 값을 가진 원소를 찾는다. 이러한 원소는 3이다. 원소 3으로 인하여 원소 7이 Min-level의 조건을 만족하지 않으니 원소 3과 원소 7의 위치를 서로 바꾼다.

\n

\"Min-max

\n

원소 7의 부모는 Max-level이므로 원소 7의 부모는 원소 7보다 더 크거나 같은 값을 가져야 한다. 그러나 부모 원소의 값은 6이므로 7보다 크거나 같은 값이 아니다. 따라서 원소 7과 6의 위치를 서로 바꾼다.

\n

\"Min-max

\n

이제 원소 6 위의 모든 원소들 (2, 8, 3, 7)들은 Min-level과 Max-level의 조건을 만족한다. 그러나 원소 6은 Min-level의 조건을 만족하지 못한다. 따라서 원소 6이 Min-level의 조건을 만족하도록 하기 위해 원소 6의 자식과 손자들 중 가장 작은 값을 가진 원소를 찾는다. 이 원소는 4이다. 원소 4는 Min-level인 원소 6의 손자임이도 불구하고 6보다 작은 값을 가지고 있다. 이는 모순이므로 원소 6과 4의 위치를 바꾼다.

\n

\"Min-max

\n

원소 6의 부모를 보자. 원소 6의 부모의 값은 5인데, 이 부모 원소는 Max-level에 있다. 이는 모순이다. 이 모순을 해결하기 위해 원소 6과 원소 5의 위치를 서로 바꾼다.

\n

\"Min-max

\n

이제 유효한 Min-max heap이 만들어졌음을 확인할 수 있다. 이 예시로부터 알고리즘을 도출하면 다음과 같다.

\n\n

위 알고리즘을 A가 Max-level인 경우로까지 확장하면 다음과 같다.

\n\n

소스코드 (C++)

\n

이제 위에서 PushUp과 Pushdown의 구현 알고리즘을 살펴봤으므로 아래와 같이 C++로 구현할 수 있다.

\n
#include <iostream>\n#include <algorithm>\n#include <cmath>\n#define INT_SWAP(a,b) int tmp = a; a = b; b = tmp\n#define SWAP_HEAP(a,b) INT_SWAP(heap[(a)], heap[(b)])\n#define IS_MIN_LEVEL(index) ((int)(std::log2(index)) % 2) == 0\n\nint heapCount = 0; // How many elements in the heap?\nint heap[1000001]; // Heap, starting from index 1\n\nvoid insertHeap(int item); // inserts an element\nvoid popMin(); // removes minimum element\nvoid popMax(); // removes maximum element\nint seekMin(); // reads minimum element\nint seekMax(); // reads maximum element\n\n/**\n * Implementation of min/max pop using push-down\n */\n\n// Picks index of largest(or smallest) child(or grandchild) of given element\nint pickLargetOrSmallestDescendantIndex(int index, bool largest) {\n    int resultIndex = index * 2, resultValue = heap[index * 2];\n    int candids[] = { // Children and grandchildren\n        index * 2 + 1, // Right child\n        index * 4, // 1st grandchild (Left child of left child)\n        index * 4 + 1, // 2nd grandchild (Right child of left cihld)\n        index * 4 + 2, // 3rd grandchild (Left child of right child)\n        index * 4 + 3 // 4th grandchild (Right child of right child)\n    };\n\n    for (auto candidIndex: candids) {\n        if (candidIndex > heapCount)\n            continue; // If it's invalid index, continue\n\n        if (largest && resultValue < heap[candidIndex]) {\n            resultValue = heap[candidIndex];\n            resultIndex = candidIndex;\n        } else if (!largest && resultValue > heap[candidIndex]) {\n            resultValue = heap[candidIndex];\n            resultIndex = candidIndex;\n        }\n    }\n\n    return resultIndex;\n}\n\nvoid pushDownMin(int index);\nvoid pushDownMax(int index);\n\nvoid pushDown(int index) {\n    if (IS_MIN_LEVEL(index)) {\n        pushDownMin(index);\n    } else {\n        pushDownMax(index);\n    }\n}\n\nvoid pushDownMin(int index) {\n    if (index * 2 <= heapCount) { // if has children\n        int m = pickLargetOrSmallestDescendantIndex(index, false);\n\n        if (m >= index * 4) { // if m is a grandchild\n            if (heap[m] < heap[index]) {\n                SWAP_HEAP(m, index);\n                if (heap[m] > heap[m / 2]) {\n                    SWAP_HEAP(m, m / 2);\n                }\n                pushDown(m);\n            }\n        } else if (heap[m] < heap[index]) {\n            SWAP_HEAP(m, index); \n        }\n    }\n}\n\nvoid pushDownMax(int index) {\n    if (index * 2 <= heapCount) { // if has children\n        int m = pickLargetOrSmallestDescendantIndex(index, true);\n\n        if (m >= index * 4) { // if m is a grandchild\n            if (heap[m] > heap[index]) {\n                SWAP_HEAP(m, index);\n                if (heap[m] < heap[m / 2]) {\n                    SWAP_HEAP(m, m / 2);\n                }\n                pushDown(m);\n            }\n        } else if (heap[m] > heap[index]) {\n            SWAP_HEAP(m, index); \n        }\n    }\n}\n\nvoid popMin() {\n    if (heapCount <= 0)\n        return;\n    // Removes minimum element\n    heap[1] = heap[heapCount--];\n    // Push down root element to make the heap valid min-max heap\n    pushDown(1);\n}\n\nvoid popMax() {\n    if (heapCount <= 0)\n        return;\n    \n    int index;\n    if (heapCount == 1)\n        index = 1;\n    else if (heapCount == 2)\n        index = 2;\n    else\n        index = heap[2] > heap[3] ? 2 : 3;\n    \n    // Removes maximum element\n    heap[index] = heap[heapCount--];\n    // Push down to root element make the heap valid min-max heap\n    pushDown(index);\n}\n\n/**\n * Implementation of insertion using push-up\n */\n\nvoid pushUpMin(int index) {\n    if (index >= 4 && // if index >= 4, it must have a grandparent.\n        heap[index] < heap[index / 4]) {\n            SWAP_HEAP(index, index / 4);\n            pushUpMin(index / 4);\n        }\n}\n\nvoid pushUpMax(int index) {\n    if (index >= 4 && // if index >= 4, it must have a grandparent.\n        heap[index] > heap[index / 4]) {\n            SWAP_HEAP(index, index / 4);\n            pushUpMax(index / 4);\n        }\n}\n\nvoid pushUp(int index) {\n    if (index != 1) {\n        if (IS_MIN_LEVEL(index)) {\n            if (heap[index] > heap[index / 2]) {\n                SWAP_HEAP(index, index / 2);\n                pushUpMax(index / 2);\n            } else {\n                pushUpMin(index);\n            }\n        } else {\n            if (heap[index] < heap[index / 2]) {\n                SWAP_HEAP(index, index / 2);\n                pushUpMin(index / 2);\n            } else {\n                pushUpMax(index);\n            }\n        }\n    }\n}\n\nvoid insertHeap(int item) {\n    heap[++heapCount] = item;\n    pushUp(heapCount);\n}\n\n\nint seekMin() {\n    return heap[1];\n}\n\nint seekMax() {\n    if (heapCount == 1) {\n        return heap[1];\n    } else if (heapCount == 2) {\n        return heap[2];\n    } else {\n        return std::max(heap[2], heap[3]);\n    }\n\n}\n\nint main() {\n    std::cin.tie(NULL);\n    std::ios_base::sync_with_stdio(false);\n\n    // How many test cases?\n    int t;\n    std::cin >> t;\n\n    for (int i = 0; i < t; i++) {\n        // How many operations?\n        int q;\n        std::cin >> q;\n        \n        // Process operations\n        for (int j = 0; j < q; j++) {\n            char c;\n            int data;\n            std::cin >> c >> data;\n            \n            switch(c) {\n                case 'I':\n                    insertHeap(data);\n                    break;\n                case 'D':\n                    if (data == -1)\n                        popMin();\n                    else\n                        popMax();\n            }\n        }\n\n        // Print result\n        int max = seekMax(), min = seekMin();\n\n        if (heapCount == 0)\n            std::cout << \"EMPTY\\n\";\n        else\n            std::cout << max << ' ' << min << '\\n';\n        heapCount = 0;\n    }\n}\n
", "url": "https://blog.litehell.info/post/min_max_heap", "title": "최소-최대 힙(Min-max heap) 구현하기 (백준 7662번 이중 우선순위 큐 문제)", "summary": "최소값과 최대값을 동시에 구할 수 있는 힙 자료구조", "image": "https://blog.litehell.info/example.svg", "date_modified": "2023-05-03T15:24:12.104Z" }, { "id": "how_to_fix_no_sound_issue_in_samsung_laptop_ubuntu", "content_html": "

나는 삼성 노트북 9 Always(모델명: NT950XBE)를 이용한다. 꽤 괜찮은 노트북인데, 대학에서 운영체제 수업을 듣다보니 우분투를 깔 필요를 느껴서 듀얼부팅으로 설치했다.

\n

우분투를 설치하고 이용하는 데엔 큰 문제가 없었다, Wine으로 설치한 카카오톡도 잘 작동했다. 그러나 문제는 다른 데 있었다, 소리가 안 들렸다.

\n

어떻게 안 들리나요?

\n

이 소리가 안 들리는 증상을 자세히 서술하면 다음과 같았다.

\n\n

이어폰 잭은 그래도 sudo hda-verb /dev/snd/hwC0D0 0x1a SET_PIN_WIDGET_CONTROL 0x5명령어를 실행하면 정상적으로 작동은 하는 데 이조차도 영구적인 해결책이 아니었고, 잠시 소리가 idle이 되면 바로 원상복구된다는 한계가 있었다. 영구적으로 해결할 수 있는 방법이 없을까?

\n

생각보다 간단했던 해결방법

\n

찾아보니 이미 커널 버그로 보고된 문제였다. 이 버그 보고를 읽다가 문득 '/etc/modprobe.d/alsa-base.conf 파일 맨 밑에 다음 줄을 추가하면 되지 않을까?'라는 생각이 들었다. 그래서 추가했고, 재부팅했다.

\n
# audio fix\noptions snd-hda-intel model=alc298-samsung-amp\n
\n

결과는? 해결됐다. 이어폰도 스피커도 매우 잘 작동한다. 커널 버그라서 복잡하게 해결할 줄 알았는데 문제가 손쉽게 해결되서 다행이었다, 메데타시 메데타시.

", "url": "https://blog.litehell.info/post/how_to_fix_no_sound_issue_in_samsung_laptop_ubuntu", "title": "삼성 노트북(950XBE) 우분투에서 소리 안 들리는 버그 고치기", "summary": "한 줄로 고치는 버그", "image": "https://gravatar.com/avatar/837266b567b50fd59e72428220bf69b1", "date_modified": "2023-03-18T10:21:27.090Z" }, { "id": "bypassing_safe2go_datepicker_bug", "content_html": "

2023-02-04 수정사항

\n

현재는 해당 버그가 고쳐졌다.

\n\n

들어가는 글

\n

사람에 따라서는, 해외여행을 가기위해선 PCR 검사를 해야하는 경우가 있다. 이런 사람들은 민간병원이나 인천국제공항 코로나19 검사센터에서 검사를 받아야 한다. 인천국제공항 코로나19 검사센터에서 검사를 예약하려면 Safe2GO PASS라는 웹사이트를 이용해야 한다.

\n

Safe2GO Pass의 버그

\n

그런데 Safe2GO PASS에는 버그가 있다. (적어도 지금 글을 쓰는 2023-01-31 새벽에는 있었다.) 이 버그는 Chrome, Firefox, Edge, 데스크톱, 모바일에서 똑같이 발생했다.

\n

Safe2GO PASS에서는 코로나19 검사를 예약하려면 여행일정을 추가해야 한다. 그런데 무슨 이유에선지 이 웹사이트는 출발지 날짜를 선택하려고 클릭하면 아무것도 뜨지 않고 입력할 수도 없었다. (참고로 도착일은 선택이 잘 된다.)

\n

\"Safe2GO

\n

(이 상태에서 출발일 날짜를 선택할 수 없었다.)

\n

인천국제공항에서 출발하는 여행일정이라면 출발일 날짜를 지정해야 하는데 위와 같이 지정을 할 수가 없으니 코로나19 검사를 예약할 방법이 없다. 그렇다면 우리는 어떻게 해야할까?

\n

해결방법

\n

버그가 언제 수정될 지는 알 수 없으니, 일단 임시방편으로 버그를 우회해야 한다.

\n

먼저, 해당 화면에서 F12키를 눌러 개발자 도구를 킨다. 그리고 콘솔(혹은 Console) 탭을 연다.

\n

그 다음, 다음 스크립트에서 날짜 부분(2023-06-01)을 원하는 출발일 날짜로 바꿔 복사-붙여넣기한다.

\n
document.querySelector('.s-container').__vue__.start.date = \"2023-06-01\";\n
\n

이때, 위에서 날짜부분은 YYYY-MM-DD 형식을 정확하게 지켜야 한다. 즉, 예시를 들어 2020년 1월 1일이라면 2020-1-1이 아닌 2020-01-01로 바꿔야 한다. 혹시나하는 마음에 덧붙이자면, 2020-01-01이 아닌 2020.01.01은 당연히 안 된다.

\n

복사-붙여넣기를 하면 아래 사진과 같이 맨 하단에 스크립트가 입력되어 있을 것이다. 참고로 아래 사진은 Firefox 웹 브라우저를 이용한 모습이다. (어처피 Chrome도 개발자도구 콘솔은 비슷하게 생겼으므로 참고하는 데 큰 어려움은 없을 것이다.)

\n

\"Firefox

\n

이제 위 상태에서 엔터 키를 누르면 아래와 같이 출발일이 잘 입력된 것을 확인할 수 있다. 이제 나머지 정보를 입력해서 여행일정을 추가하면 된다.\n\"Safe2GO

\n

마무리

\n

웹사이트내에는 버그 제보를 받는 곳이 딱히 안 보여서, 아마도 담당자의 메일 주소인 것 같은 곳으로 버그 제보 메일을 보냈다. 어서 빨리 이 버그가 수정됐으면 좋겠다.\n

", "url": "https://blog.litehell.info/post/bypassing_safe2go_datepicker_bug", "title": "Safe2GO PASS 출발지 날짜 선택 안되는 버그 우회하기", "summary": "인천국제공항 코로나19 검사예약하려는 사람들을 위하여", "image": "https://blog.litehell.info/safe2go_pass_departure_date_click.png", "date_modified": "2023-01-30T17:03:29.886Z" }, { "id": "rebase_non_consecutive_commits", "content_html": "

들어가는 글

\n

글에 들어가기에 앞서, 이 글의 예시에서 rebase 전의 커밋 순서는 다음과 같다고 가정하자: ... → First commit → Secod commit → Third commit → Bugfix related to third commit → Bugfix related to second commit → Fourth commit → Second bugfix related to third commit

\n

연속된 커밋 합치기

\n

git rebase -i를 이용하면 간단하게 연속된 커밋을 합칠 수 있다.

\n
pick 89b7a0c First commit\nreword 7d62023 Secod commit\npick d3c0a39 Third commit\nfixup 4eb60d0 Bugfix related to third commit\npick 69a520d Bugfix related to second commit\npick 584974e Fourth commit\npick 3f9e8fa Second bugfix related to third commit\n
\n

위 to-do를 실행하면 reword로 커밋 7d62023의 메세지를 수정하게 된다. (이 예시에서는 오타를 정정했다고 가정하자.) 그리고 fixup으로 연속하는 커밋 d3c0a394eb60d0을 합치고, 커밋 4eb60d0의 메세지는 버려지게 된다. (메세지도 같이 합치고 싶다면 squash를 쓰면 된다.)

\n

이를 실행하면 git 히스토리는 다음과 같이 된다: First commit → Second commit → Third commit → Bugfix related to second commit → Fourth commit → Second bugfix related to third commit

\n

그렇다면 우리는 여기서 궁금증이 생긴다. 연속하지 않는 커밋을 합치고 싶을 땐 어떻게 하면 될까?

\n

연속하지 않는 커밋 합치기

\n

답은 간단하다. 그냥 to-do에서 커밋 순서를 바꾸면 된다.

\n
pick 89b7a0c First commit\nreword 7d62023 Secod commit\nfixup 69a520d Bugfix related to second commit\npick d3c0a39 Third commit\nfixup 4eb60d0 Bugfix related to third commit\nfixup 3f9e8fa Second bugfix related to third commit\npick 584974e Fourth commit\n
\n

interactive rebase에서 커밋 순서를 무조건 동일하게 해야할 필요는 없다. 연속하지 않는 커밋을 합쳐야 한다면 그냥 순서를 바꿔서 합치면 된다. (이 예시에서도 reword로 커밋 7d62023의 오타를 정정했다고 가정하자.)

\n

위와 같이 실행하면 git 히스토리는 다음과 같다: First commit → Second commit → Third commit → Fourth commit. Git History가 깔끔하게 된 것을 확인할 수 있다.

", "url": "https://blog.litehell.info/post/rebase_non_consecutive_commits", "title": "떨어져 있는 커밋 합치기", "summary": "의외로 간단한 git interactive rebase 활용법", "image": "https://gravatar.com/avatar/837266b567b50fd59e72428220bf69b1", "date_modified": "2023-01-10T05:32:23.209Z" }, { "id": "webpack_and_react_ssg_2", "content_html": "

들어가는 글

\n

1편에서와 같이 개발하면 매우 잘 된다, 구조가 단순하기 때문이다. 1편의 방식을 개발했을 때 빌드된 결과물을 보면 다음과 같이 생겼을 것이다.

\n
<!-- dist/index.html -->\n<!-- 참고로 이 예시는 예시일뿐이며, 세부사항은 개개인의 상황에 따라 다를 수 있다. -->\n<!doctype html>\n<html>\n    <head>\n        <meta charset=\"utf-8\">\n        <title>Lorem ipsum</title>\n        <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n        <script defer=\"defer\" src=\"166.js\"></script>\n        <script defer=\"defer\" src=\"main.js\"></script>\n    </head>\n    <body>\n\n    </body>\n</html>\n
\n

1편에서 번들링된 파일이 script 태그로 삽입되어 HTML 문서가 불려와진 후 모두 끝난 후 실행되는 형태이다. (defer속성은 스크립트가 문서 분석 이후에 실행되도록 한다.)

\n

즉, CSR(Client-Side Rendering) 방식이다. body 태그 내에 div 태그를 만들고 해당 태그에 React Component를 렌더링하는 코드가 클라이언트단에서 모두 실행되는 방식이다. 빌드 단계에서 렌더링이 이루어지지 않으며, 빌드 단계에서 이루어지는 것은 오직 코드 번들링뿐이다.

\n

리소스가 한 번만 로드되고 후속 페이지 로드시간이 빠르다는 장점이 있지만 몇가지 단점도 있다.

\n\n

이러한 단점들을 극복하려면 어떻게 해야할까?

\n

with React Hydration

\n

빌드시에 초기 HTML을 생성하고, 진입 스크립트을 hydration을 하는 방식으로 수정하면 된다. 여기서 hydration이란 ReactDOMServer에 의해 렌더링된 초기 마크업 코드에 이벤트 리스너를 연결(즉, 요약하자면 재활용)하는 것이다.

\n

이 방식을 이용하면 HTML 마크업 코드는 빌드 단계에서 생성되고, 클라이언트단에서는 React가 이미 생성된 HTML 코드에 부착(attach)하여 동작한다. 이 방식의 장점은 다음과 같다.

\n\n

이 방식을 채택하기 전에 먼지 어떤 사항을 확인해야 하는지 한 번 알아보자.

\n

유효한 HTML코드 작성

\n

모든 HTML 코드는 유효하게 작성되어야 한다. 예시를 들자면, p태그 안에는 p태그가 들어갈 수 없다.

\n

React hydration을 하기 위해서는, 빌드단에서 생성된 HTML 코드와 React Component가 생성하는 UI 코드가 일치해야 한다. 만약 초기 UI 불일치 오류가 나타난다면, 유효하지 않은 HTML 코드를 작성한 건 아닌지 한 번 확인해봐야 한다.

\n

클라이언트단에서만 작동하는 코드 수정

\n

Node.js 환경에서 실행되면 오류가 나는 코드가 있다. 이런 코드들은 브라우저 환경에서만 실행되도록 수정해야 한다.

\n
// 예시\n\nconst isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined'\n\nif (isBrowser())\n    import('../some_module_that_works_only_on_browser');\n
\n

또한, 브라우저단에서 결과가 결정되거나 항상 일정하지 않은 코드는 useEffect 등을 이용해 UI가 렌더링된 이후에 실행되도록 수정해야 한다.\nuseStateuseEffect 등은 브라우저단에서만 렌더링된다.

\n
import { useState, useEffect } from 'react';\n\n// 예시\nexport default function AreYouKorean() {\n    // const isKorean = navigator.language === 'ko';\n    const [isKorean, setIsKorean] = useState<boolean | null>(null);\n    useEffect(() => {\n        setIsKorean(navigator.language === 'ko');\n    });\n\n    let message: string = '...';\n    if (isKorean !== null)\n        message = isKorean ? '한국인이시군요.' : \"You're not Korean!\";\n    return <div>{ message }</div>\n}\n
\n

결과가 불명확한 코드 수정

\n

이런 코드는 당연히 빌드할 때 생긴 초기 UI와 클라이언트 렌더링시의 초기 UI가 서로 다르게 한다. 빌드시 시간의 초 단위가 짝수면 결과는 *Yes!*가 렌더링될텐데, 사용자가 로드할 때 시간이 홀수면 *No!*가 렌더링되니 서로 불일치하다. 이런 코드가 있다면 수정해야 한다.

\n
export default function isTimeEven() {\n    const time = (new Date()).getSeconds() % 2 === 0;\n    return time ? <p> Yes! </p> : <p> No! </p>;\n}\n
\n

hydration 적용

\n

위에서 언급한 문제를 모두 수정했다면 hydration시 초기 UI 불일치 오류는 없을 것이다. 이제 hydration을 적용해보자.

\n

진입 스크립트 수정

\n

먼저, hydrate하도록 src/index.tsx를 수정하자.

\n
// src/index.tsx\n\nimport React from 'react';\nimport ReactDOM from 'react-dom/client';\nimport '../styles/index.css';\nimport isBrowser from './isBrowser';\n\nexport default function Index() {\n    return <div className=\"bg-gray-400\">\n      Hello, World!\n    </div>\n}\n\n// 만약 브라우저라면\nif (isBrowser()) {\n    // body > #root 태그에\n    const rootDiv = document.querySelector('body > #root')!;\n    // hydration이 필요하다면 (빌드 결과물)\n    if (rootDiv.classList.contains('hydrate')) {\n        // Index 컴포넌트를 hydrate한다.\n        ReactDOM.hydrateRoot(rootDiv, <Index />);\n    } else /* 만약 hydration이 필요없다면 (webpack serve) */ {\n        // 그냥 렌더링 한다.\n        const root = ReactDOM.createRoot(rootDiv);\n        root.render(<Index />)\n    }\n}\n
\n

브라우저 환경 여부를 판단하는 src/isBrowser.ts도 간단하게 작성한다.

\n
// src/isBrowser.ts\n\nexport default function isBrowser () {\n    return typeof window !== 'undefined' && typeof document !== 'undefined';\n}\n
\n

src/index.tsx는 빌드 스크립트에서도 import된다. 따라서 브라우저단에서만 실행되야 하는 코드는 브라우저단에서만 실행되도록 수정해야 한다.

\n

HTML 템플릿 생성

\n

그 다음으로, 간단한 HTML 템플릿을 생성한다.

\n
<!-- src/index.html -->\n\n<!DOCTYPE html>\n<html lang=\"ko\">\n    <head>\n        <meta charset=\"utf8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n        <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n        <title>Lorem ipsum</title>\n    </head>\n    <body>\n        <div id=\"root\">\n\n        </div>\n    </body>\n</html>\n
\n

HTML 생성 스크립트 추가

\n

초기 HTML 코드를 생성하는 스크립트를 작성해야 한다. 먼저 스크립트 작성 전에 의존성을 몇가지 설치하자.

\n
yarn add cheerio tmp-promise\n
\n

그 다음으로, 다음과 같이 스크립트를 작성한다.

\n
// src/generate.tsx\n\nimport { load as loadHTML } from 'cheerio';\nimport { createWriteStream, promises as fs } from 'fs';\nimport React from 'react';\nimport server from 'react-dom/server';\nimport tmp from 'tmp-promise';\nimport Index from './index';\n\n(async () => {\n    // 임시 파일을 생성한다.\n    const tmpFile = await tmp.file();\n\n    // Webpack 번들러에 의해 생성된 HTML 파일의 위치이다.\n    const filename = './dist/index.html';\n\n    // 초기 UI 코드를 생성하는 ReactDOMServer의 스트림이다.\n    const reactStream = server.renderToPipeableStream(<Index />);\n    \n    // 스트림을 임시 파일에 쓴다.\n    const fileStream = createWriteStream(tmpFile.path);\n    \n    // 초기 UI 코드가 임시 파일에 모두 써졌으면\n    reactStream.pipe(fileStream).on('finish', async () => {\n        // 웹팩에 의해 번들링된 HTML 파일을 읽는다.\n        const htmlText = await fs.readFile(filename, { encoding: 'utf8' });\n\n        // HTML 파일의 id가 root인 태그에 초기 UI 코드를 넣는다.\n        const dom = loadHTML(htmlText);\n        dom('body > #root').html(await fs.readFile(tmpFile.path, { encoding: 'utf8' }));\n        dom('body > #root').addClass('hydrate');\n        dom('body > #root');\n\n        // HTML 코드를 저장한다.\n        await fs.writeFile(filename, dom.html());\n    });\n})();\n
\n

Webpack 설정 수정

\n

Webpack 설정 파일에 생성 스크립트로 번들링하는 설정을 추가한다.\nNode.js를 타켓팅할 시 일부 모듈은 C/C++ 에드온을 이용할 수 있다. 따라서 해당 경우에 대응할 수 있도록 node-loader 의존성을 같이 설치한다. (만약 그러한 모듈이 없다면 이 문단에서 .node 관련 내용은 무시해도 된다.) empty-loader 모듈은 빌드 스크립트에서 CSS 파일을 무시하는 데 이용된다.

\n
yarn add --dev node-loader empty-loader\n
\n

그리고 다음과 같이 webpack.config.js를 수정한다.

\n
// webpack.config.js\n\nconst path = require('path');\nconst HtmlWebpackPlugin = require('html-webpack-plugin')\nconst { CleanWebpackPlugin } = require('clean-webpack-plugin');\n\nconst dev = process.env.NODE_ENV === 'development';\n\n// src/index.tsx 설정과 src/generate.ts 설정에 공통적으로 들어가는 항목들이다.\nconst commons = {\n    mode: dev ? \"development\" : \"production\",\n    resolve: {\n        // .node가 추가되었다.\n        extensions: ['.tsx', '.ts', '.js', '.css', '.node']\n    },\n    module: {\n        rules: [\n            {\n                test: /\\.tsx?$/,\n                exclude: /node_modules/,\n                use: [\n                    {\n                        loader: 'babel-loader',\n                        options: {\n                            presets: ['@babel/preset-react', '@babel/preset-env']\n                        }\n                    },\n                    'ts-loader'\n                ]\n            },\n            {\n                // .node 파일을 로드한다.\n                test: /\\.node$/,\n                use: 'node-loader'\n            }\n        ]\n    },\n    plugins: [new CleanWebpackPlugin()]\n};\n\nmodule.exports = [/* src/index.tsx 설정 */{\n    ...commons,\n    optimization: {\n      splitChunks: {\n        chunks: 'all'\n      },\n      minimize: !dev\n    },\n    entry: './src/index.tsx',\n    output: {\n        path: path.resolve(__dirname, 'dist'),\n        filename: '[name].js'\n    },\n    module: {\n        ...commons.module,\n        rules: [\n            ...commons.module.rules,\n            // CSS를 빌드시 로드하도록 하면 오류가 발생한다. (Node.js 환경은 웹브라우저가 아니므로 스타일 주입 시도가 당연히 실패하기 때문이다.)\n            // 따라서 css파일은 웹브라우저단에서 로드되는 번들 스크립트에서만 주입되도록 \n            // src/index.tsx Webpack 설정에만 추가한다.\n            {\n                test: /\\.css$/,\n                use: ['style-loader', 'css-loader', 'postcss-loader']\n            },\n        ]\n    },\n    plugins: [\n        ...commons.plugins,\n        new HtmlWebpackPlugin({\n        filename: 'index.html',\n        // template 속성을 추가한다.\n        template: './src/index.html'\n    })],\n    devServer: {\n        static: {directory: path.join(__dirname, 'dist')},\n        open: true,\n        port: 'auto',\n    },\n    watchOptions: {\n        ignored: /node_modules/\n    }\n}, /* src/generate.tsx 설정 */ {\n    ...commons,\n    // node로 실행할 스크립트이기 때문에 target을 node로 한다.\n    target: 'node',\n    // entry는 src/generate.tsx로 한다.\n    entry: './src/generate.tsx',\n    module: {\n        ...commons.module,\n        rules: [\n            ...commons.module.rules,\n            {\n                // css파일은 무시한다.\n                test: /\\.css$/,\n                use: ['empty-loader']\n            },\n        ]\n    },\n    output: {\n        path: path.resolve(__dirname, 'dist-builder'),\n        filename: 'index.js'\n    }\n}]\n
\n

빌드 스크립트 실행

\n

이제 거의 다 됐다. 다음 명령어로 빌드해보자.

\n
npx webpack build\nnode ./dist-builder\n
\n

별 다른 오류없이 잘 될 것이다. 축하한다. 이제 앞으로 위 명령어로 빌드하면 초기 HTML 코드가 들어가있는 파일이 빌드될 것이다. 해당 빌드의 결과는 다음과 같다.

\n
<!-- dist/index.html -->\n<!-- 참고로 이 예시는 예시일뿐이며, 세부사항은 개개인의 상황에 따라 다를 수 있다. -->\n<!DOCTYPE html>\n<html lang=\"ko\">\n    <head>\n        <meta charset=\"utf8\">\n        <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n        <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n        <title>Lorem ipsum</title>\n        <script defer=\"defer\" src=\"166.js\"></script>\n        <script defer=\"defer\" src=\"main.js\"></script>\n    </head>\n    <body>\n        <div id=\"root\">\n            <div class=\"bg-gray-400\">Hello, World!</div>\n        </div>\n    </body>\n</html>\n
", "url": "https://blog.litehell.info/post/webpack_and_react_ssg_2", "title": "Webpack과 React를 이용한 정적 웹사이트 만들기 (2)", "summary": "... + Static HTML Generation = TA-DA!", "image": "https://gravatar.com/avatar/837266b567b50fd59e72428220bf69b1", "date_modified": "2023-01-10T05:00:54.358Z" }, { "id": "webpack_and_react_ssg_1", "content_html": "

들어가는 글

\n

본래 내 개인 웹사이트Next.js의 Static HTML Export 기능GitHub Pages를 이용해 배포됐다. 디자인에는 bulma CSS 프레임워크를 이용했다.

\n

\"홈페이지

\n

위와 같이 bulma에서 제공하는 component들을 이용해 간단하게 디자인했다.

\n

그러나 내 웹사이트를 몇 번 보다보니 디자인을 좀 설렁설렁하게 한 느낌이 들었다. 그래서 디자인을 한 번 바꿔보고 싶었고, 이번 기회에 Next.js를 쓰지 않고 직접 정적 파일로 배포하는 경험도 해보고 싶었다.

\n

이 글은 개인 홈페이지를 재작성한 경험에 기반하여 쓰는 글이지만, 독자들이 따라하며 참고할 수 있도록 일부 변형하여 작성했다. 설명을 최대한 자세하게 하려 노력했지만, 글 쓰는 실력이 좋은 편이 아니라 읽기 불편할 수 있으니 양해해주셨으면 좋겠다.

\n

Webpack과 Babel, 그리고 Typescript

\n

Next.js는 쓰지 않았다. 간단한 한 페이지 정적 페이지 만드는 데에는 Webpack이면 충분하다.

\n

이제 어떤 기술스택을 쓸 지 정했다, 이제 필요한 의존성을 먼저 설치하면 된다. 의존성을 한 번 설치해보자.

\n

의존성 설치하기

\n

먼저 React와 Typescript를 설치한다. 재디자인 전에 했듯이 이번에도 React로 개발할 것이다.

\n
yarn add react-dom react\nyarn add --dev typescript react @types/react @types/react-dom\n
\n

그 다음, 다음 명령어로 tsconfig.json을 생성한다.

\n
npx tsc --init\n
\n

생성된 tsconfig.json에서 jsx 속성을 react로(다르게 해도 되긴 한데 나는 이렇게 했다), 그리고 moduleResolution 속성은 node로 바꾸어야 한다. 이 외에 나머지 속성들은 개인 취향대로 하면 된다. 일단 개인 취향대로 하고 나중에 코딩하다 tsconfig.json때문에 오류나면 그때 수정하면 된다.

\n

참고로 내 tsconfig.json는 다음과 같다.

\n
{\n  \"compilerOptions\": {\n    \"target\": \"es2016\",\n    \"lib\": [\"DOM\"],\n    \"jsx\": \"react\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"node\",\n    \"typeRoots\": [\"node_modules/@types\", \"src/types\"],\n    \"allowJs\": false,\n    \"checkJs\": false,\n    \"allowSyntheticDefaultImports\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"strict\": true,\n    \"noImplicitAny\": true,\n    \"strictNullChecks\": true,\n    \"strictFunctionTypes\": true,\n    \"alwaysStrict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"skipLibCheck\": true\n  },\n  \"include\": [\"src/**/*.tsx\", \"src/**/*.ts\"],\n  \"exclude\": [\"node_modules\"]\n}\n\n
\n

그 다음으로는 Webpack이 필요하다. 다음 명령어로 Webpack을 추가하자.

\n
yarn add webpack webpack-cli\nyarn add --dev webpack-dev-server ts-loader style-loader clean-webpack-plugin css-loader html-webpack-plugin\n
\n

각 패키지들의 설명은 다음과 같다.

\n\n

나는 이번에 babel을 쓸 것이다. babel은 최신 버전의 자바스크립트 기능을 브라우저 지원에 신경쓰지 않고 쓸 수 있게 해준다. 아래 명령어로 babel도 설치하자.

\n
yarn add --dev @babel/core @babel/preset-env @babel/preset-react babel-loader\n
\n

TailwindCSS가 웹사이트 빠르게 만드는 데 좋다갈래 반신반의하는 느낌으로 한 번 써보려 한다. TailwindCSS를 쓰려면 다음 패키지들을 설치해야 한다.

\n
yarn add --dev autoprefixer postcss postcss-loader tailwindcss\n
\n

Webpack 설정하기

\n

이제 Webpack을 설정해야 한다. 루트 디렉토리에 webpack.config.js를 생성하고 내용을 다음과 같이 한다.

\n
// webpack.config.js\nconst path = require('path');\nconst HtmlWebpackPlugin = require('html-webpack-plugin')\nconst { CleanWebpackPlugin } = require('clean-webpack-plugin');\n\nconst dev = process.env.NODE_ENV === 'development';\nmodule.exports = {\n    // 개발시라면 개발 모드로 한다.\n    mode: dev ? \"development\" : \"production\",\n    optimization: {\n      splitChunks: {\n        // 청크를 가능한만큼 다 분리한다.\n        // 파일 각각의 용량을 줄여들어서 웹페이지 로드속도 감소에 도움이 된다.\n        chunks: 'all'\n      },\n      // 개발중이 아니라면 압축한다.\n      minimize: !dev\n    },\n    // 진입 파일이다.\n    // ./src/index.tsx 파일에 필요한 모듈들을 번들링한다고 이해하면 된다.\n    //\n    // 즉, 다시 말해 출력파일을 실행하면 ./src/index.tsx을 실행하는 것과 동일한 효과를 가진다,\n    // 다만 필요한 모듈들이 같이 번들링되어 있어 웹 환경 등에서도 실행이 용이할 뿐이다.\n    entry: './src/index.tsx',\n    output: {\n        path: path.resolve(__dirname, 'dist'),\n        // index.js로 하면 splitChunks 속성때문에 오류가 난다.\n        filename: '[name].js'\n    },\n    resolve: {\n        extensions: ['.tsx', '.ts', '.js', '.css']\n    },\n    module: {\n        rules: [\n            // 참고: use 속성은 마지막 아이템에서 첫 아이템으로의 순서, 즉 다시 말해 오른쪽에서 왼쪽으로의 순서로 해석된다.\n            {\n                test: /\\.tsx?$/,\n                exclude: /node_modules/,\n                use: [\n                    // Babel은 현대 자바스크립트를 브라우저 지원여부을 고려할 필요없이 쓸 수 있도록 해준다.\n                    {\n                        loader: 'babel-loader',\n                        options: {\n                            presets: ['@babel/preset-react', '@babel/preset-env']\n                        }\n                    },\n                    // Typescript를 Javascript로 컴파일해준다.\n                    'ts-loader'\n                ]\n            },\n            {\n                test: /\\.css$/,\n                // postcss-loader는 TailwindCSS 적용에 이용된다.\n                use: ['style-loader', 'css-loader', 'postcss-loader']\n            }\n        ]\n    },\n    plugins: [\n        // 빌드전에 dist 디렉토리내 파일들을 먼저 지우고 \n        new CleanWebpackPlugin(),\n        // 빌드후 출력파일들이 script태그로 포함된 html파일을 생성한다.\n        new HtmlWebpackPlugin({\n        title: 'Yeonjin Shin',\n        filename: 'index.html'\n    })],\n    // 디버깅할 때 쓰는 웹서버에 관한 설정이다.\n    // webpack serve로 열 수 있다.\n    devServer: {\n        static: {directory: path.join(__dirname, 'dist')},\n        open: true,\n        port: 'auto',\n    },\n    // 이거 안하면 node_modules내 수 많은 파일의 변경여부도 같이 확인하기 때문에\n    // 디버깅용 웹서버가 정상적으로 작동하지 않는다.\n    watchOptions: {\n        ignored: /node_modules/\n    }\n}\n
\n

TailwindCSS 설정하기

\n

TailwindCSS를 적용하려면 TailwindCSS 설정파일을 생성해야 한다. TailwindCSS 설정 파일은 다음 명령어로 생성할 수 있다. -p 매개변수를 주면 PostCSS 설정파일도 같이 생성해준다.

\n
npx tailwindcss init -p\n
\n

postcss.config.js는 생성된 그대로 쓰면 되고, tailwind.config.js만 수정하면 된다. tailwind.config.jscontent 속성에 TailwindCSS를 사용할 파일 패턴을 넣는다.

\n
// tailwind.config.js\n/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n  content: [\"./src/**/*.{js,css,ts,tsx}\"],\n  theme: {\n    extend: {},\n  },\n  plugins: [],\n}\n
\n

이제 TailwindCSS를 쓰기 위해 한 가지만 더 하면 된다. 다음과 같이 @tailwind 구문들이 들어간 css파일을 생성하고, 그 css 파일을 프론트엔드 코드에서 추가해주면 된다.

\n
/* styles/index.css */\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n/* ...more css here */\n
\n
// src/index.tsx\n\nimport '../styles/index.css';\n// ...more imports and code here\n
\n

TailwindCSS가 추가하는 CSS 코드는 PostCSS와 postcss-loader에 의해 CSS 파일에 자동으로 추가된다. 그리고 그 CSS 파일은 위와 같이 import만 해주면 style-loader에 의해 웹브라우저에서 html에 자동으로 추가된다.

\n

이제 TailwindCSS, Webpack, React를 쓰기 위한 준비가 끝났다. 코딩만 하면 된다.

\n

코딩

\n

아래와 같이 코드를 작성해보자.

\n
// src/index.tsx\n\nimport React from 'react';\nimport ReactDOM from 'react-dom/client';\nimport '../styles/index.css';\n\nfunction Index() {\n    return <div className=\"bg-gray-400\">\n      Hello, World!\n    </div>\n}\n\nconst rootDiv = document.createElement('div');\ndocument.body.appendChild(rootDiv);\nconst root = ReactDOM.createRoot(rootDiv);\nroot.render(<Index />)\n
\n

이렇게 하고 npx webpack build하면 잘 빌드될 것이다. dist/index.html 파일을 웹 브라우저로 열였을 때 \"Hello, World!\"가 뜨면 정상이다.

\n

위와 같은 식으로 코딩하면 된다. 개발할 때는 npx webpack serve로 웹 서버를 열면 편하게 디버깅할 수 있다.

", "url": "https://blog.litehell.info/post/webpack_and_react_ssg_1", "title": "Webpack과 React를 이용한 정적 웹사이트 만들기 (1)", "summary": "Webpack + TypeScript + React + TailwindCSS = TA-DA!", "image": "https://blog.litehell.info/homepage_before_redesign_2022.png", "date_modified": "2023-01-10T05:00:48.244Z" }, { "id": "retrospective_of_2022", "content_html": "

들어가는 말

\n

대단한 성취를 이루지도 못했는데 벌써 2022년이 다 지나갔다. 올해는 다르리라 다짐하는 의미에서 2022년에 무엇을 했는지 되짚어보고자 한다.

\n

부대안에서

\n

군대에서만큼은 잠시 코딩을 쉬고 싶었다. 그래서 부대에서 코딩을 열심히 하진 않았는데, 그럼에도 불구하고 휴식을 제대로 취하진 못했다. 아무래도 밖에 자유롭게 못 나가는 것에, 군대 특유의 통제도 있었고, 또 교대근무를 하다보니 몸이 피로했다. 육체적으로 힘든 일을 하진 않았지만, 모든 사람에게 군대가 그렇듯 다시 하고 싶은 경험은 아니었다. 그래서 그런지 쉬어가는 텀이 되진 못했다.

\n

내가 부대에서 목표로 한 것은 딱 3가지였다: 자격증, 그림, 운동. 운동은 게을려서 하지 못했지만 나머지 2개는 이뤘다. 그림 연습을 거의 매일 하는데 성공했고, 자격증도 취득했다.

\n

정보처리산업기사 취득(2021)

\n

2021년 12월 31일에 정보처리산업기사를 취득했다.

\n

군내시험이라 필기를 CBT가 아닌 OMR 마킹형식으로 했다. (실기는 사회와 똑같이 필답형이다.) 개정된 산업기사 시험이었는데, 난이도는 그리 어렵진 않았다. 알고리즘 문제가 순서도 그림이 아닌 코드 형태로 나와서 오히려 편했다.

\n

공군은 산업기사를 취득하면 휴가 1일(정확히는 휴가 하루치의 병영생활 가점)을 준다. 덕분에 휴가 하루 받아서 잘 다녀왔다.

\n

원래 2021년 회고에 들어갈 내용이지만, 어떤 이유에선지 까먹고 안 썼길래 이번 회고에 썼다.

\n

상공회의소 한자 3급 취득

\n

2022년 3월에 상공회의소 한자 3급을 취득했다. 중앙대학교는 졸업필수요건으로 모든 재학생에게 한자급수 3급 이상(예체능은 4급 이상)을 취득한 것을 요구한다. 졸업을 하려면 좋든 싫든 한자 자격증을 따거나 한자 관련 교양 수업 2개이상을 들어야 한다.

\n

내 주변 사람들 중에서는 군 e-러닝으로 한자 교양을 듣는 사람이 꽤 있었다. 졸업요건도 채우고 성적 잘 받으면 휴가도 받으니 1석2조다. 그러나 난 그러진 않았다. 첫번째 이유로는 학점을 이미 많이 들었고 두번째 이유로는 최대한 빠르게 끝내고 싶었다.

\n

그래서 에듀윌 상공회의소 한자 3급 2주끝장을 사서 일단 4급 한자 900자만 공부했다. (3급 900자는 몰라도 합격하는 데 지장없다.) 근데 완벽하게 외우지 않고 대충 외우고 갔더니 떨어졌다.

\n

비록 장병 자기개발비 지원사업으로 시험비용을 일부 환급받긴 했지만 돈이 아까웠다. 그래서 이번에는 Quizlet 앱과 책을 같이 병행하여 확실하게 공부했다.

\n

앱을 이용하니 확실히 외우는 데 큰 도움이 됐다. 퀴즐렛에 카드를 좌우로 넘기면서 학습하는 기능이 있는데, 이 기능은 가물가물하던 한자를 다시 외우는 데 탁월한 효과가 있다.

\n

위와 같이 한자를 반복적으로 외우고 인터넷에 올라온 기출문제들을 다 받아서 풀며 열심히 준비했다. 그렇게 열심히 공부하여 2022년 3월 17일날 시험을 봤고, 그 결과 당당하게 합격했다.

\n

개발

\n

위에서 코딩 안했다고 했는데, 아예 안하진 않았다. 부대사 사이버지식정보방에서 GitHub Codespaces를 이용해 어느정도 개발을 했다.

\n

군 복무중일때는 주로 만화동아리 사이트들의 버그를 고치는 데 주력했다. 그 외에도 나는 개인적으로 싼 서버를 개인서버로 쓰는데 Docker가 쓰는 용량을 줄이기 위해 3월에 Docker 이미지들을 전부 다 alpine 기반으로 바꾸기도 했다. 이 블로그의 RSS/Atom/Json 피드와 OpenGraph 코드도 3월에 추가된 것이다.

\n

fullcards

\n

fullcards만화동아리 홈페이지를 만드는 데 이용되는 웹 어플리케이션이다.

\n

4월에 해당 프로젝트를 정적 파일을 생성하는 형태로 바꾸려고 시도했다가 Nextjs 프레임워크 위에서 하려니 자꾸 꼬여서 포기하기도 했다. 이 건은 지금 다시 재시도하고 있다.

\n

5월에는 Firebase를 제거하고 TypeORM 라이브러리를 이용해 MariaDB 기반으로 전환했다. 기존에 Firebase를 쓸 때는 홈페이지 초반에 로딩이 잠깐 걸렸는 데 그걸 없애고 싶었다.

\n

Firebase에서 다른 데이터베이스로 마이그레이션하기는 귀찮았다. 그래서 처음에 서버사이드에서 Firebase 데이터를 받아서 주는 방식을 시도해봤는데, 끔찍하게 느렸다. 따라서 그냥 Firebase를 제거하고 서버에서 홈페이지를 렌더링할때 사이트 데이터를 함께 주는 방식으로 진행했다.

\n

지금은 로딩이 반짝 뜨긴 하지만, 전에 비하면 확실히 개선됐다. 이 반짝 뜨는 현상도 추후 수정 예정이다.

\n

말년휴가

\n

9월 말에 말년휴가를 나왔다. 확실히 좋았다. 중간에 부대 잠깐 찍고 다시 휴가나오기만 몇 번하면 되니, 사실상 민간인이었다.

\n

이때부터 연말까지 코딩을 꽤 많이 했다. fullcards의 에디터도 react-quill에서 TOAST UI Editor로 바꾸었고, 특히 theseed-skin-buma의 개발을 집중적으로 했다. 기존에 존재하던 버그들을 거의 다 수정하고, 이 과정에서 Tenpower님의 큰 도움을 받았다.

\n

11월에 Flutter에 관심이 생겨서 공부를 시작했다. React와 Vue를 둘 다 해본 사람이라 그런지 입문하고 이해하는 데 큰 어려움은 겪지 못했다. GDG Songdo Devfest도 이때 다녀왔다.

\n

전역

\n

12월 중에 전역했다. 말년휴가를 나가있던 사람이라 그런지 막 대단한 감흥을 느끼진 못했다.

\n

12월(정확히는 11월 30일)에는 Flutter 공부도 할 겸 SketchDaily reference 모바일 앱의 개발을 시작했다. 사이트 주인분께 허락도 받았고 아는 분께 앱 디자인도 부탁드렸다. 플레이스토어에 직접 배포해본 적이 없었는데, 이번 개발을 통해 한 번 해보고 싶다.

\n

다짐

\n

올해는 열심히 살 것이다. 코딩테스트를 준비하고, 전공공부에 집중할 것이다. 그리하여 마지막에 노력과 행운을 합쳐 좋은 기업에 취업하고 싶다.

", "url": "https://blog.litehell.info/post/retrospective_of_2022", "title": "2022년의 회고", "summary": "군대와 전역", "image": "https://gravatar.com/avatar/837266b567b50fd59e72428220bf69b1", "date_modified": "2023-01-08T15:01:25.655Z" }, { "id": "memory_of_namufix", "content_html": "

들어가며

\n

추억여행을 위해 2015년으로 올라가보자. 2015년에 나무위키가 생겼다. 나무위키는 리그베다위키엔하위키 미러를 무너트리고 급격히 부상했다. 토론과 편집에 있어 전에 비해 크게 향상된 편의성(접근성), 그리고 SEO가 큰 원인이였다.

\n

그때의 나무위키는 지금과 많이 달랐다. UI도 달랐고, 이미지 업로드 기능과 에디터도 없었다. 그래서 그때는 이미지를 올릴려면 imgur에 업로드한 뒤 그 주소를 직접 복사해야 했다. 그래서 '이걸 간단하게 할 수 있지 않을까?'라는 생각이 들었고, 바로 간단한 에디터와 imgur 이미지 업로드 기능을 추가하는 유저스크립트를 배포했다. 그게 바로 NamuFix의 시작이었다.

\n

\"NamuFix의

\n
(초창기 나무위키에 NamuFix 초기버전을 적용시킨 모습)
\n

이 글에서는 NamuFix 릴리즈 목록을 시간대별로 보면서 추억을 회상할 것이다. 글 쓰는데 익숙하지 않아 두서없을 수 있지만, 넓은 아량으로 읽어주기를 바란다.

\n

업데이트들

\n

2015년 초부터 2020년 초까지 총 5년동안 유지보수했다. 그래서 이 유저스크립트에는 추억이 참 깊다.

\n

첫 버전

\n

초반에 잠깐 GitHub Gist를 써서 배포하다가 그냥 GitHub레포를 하나 파서 배포했다. 처음에는 버전을 그냥 평범하게 3.14의 형식으로 매기다가 '15년 8월부터 YYMMDD.N(N은 해당 날짜의 몇 번째 업데이트인지를 나타내는 숫자)의 형식을 채택했다. 어렸을 때에는 버전을 자동으로 매기는 방법을 몰랐고, 버전을 직접 매길 때는 이 방법이 편했다.

\n

이렇게 YYMMDD.N 형태의 버전으로 배포한 첫 버전은 imgur 업로드 기능 외에도 몇가지 기능이 더 있었다. 임시저장 기능과 지도/TV팟/유튜브 삽입기능, 템플릿 불러오기 기능(귀찮게 복붙하지 말고 바로 불러올 수 있게 하는 기능)이다. 그때는 위키 갤러리가 엄청 활성화됐을 때라서, 그 곳에서 기능 건의를 받곤 했었다. GitHub 이슈 트래커는 비개발자들에게 접근성이 낮았기에 이렇게 직접 발품을 파는 방식이 더 좋았다.

\n

그 다음에 추가된 기능들은 문서 주시 기능과 리다이렉트를 간단하게 생성하는 기능들이였다. 문서 주시 기능은 문서에 변경 사항이 있으면 알림을 띄워주는 기능이고, 리다이렉트 생성 기능은 그냥 리다이렉트의 도착지 문서에서 클릭 한 번만으로 편하게 만들 수 있게 하는 기능이다. 문서 주시 기능은 초반에 딜레이를 낮게 했더니 서버측에서 차단하는 버그가 있었다. 그래서 급하게 강제로 비활성화해서 배포하고, 해당 버그를 고치고 다시 활성화했다가, 결국에 그냥 삭제해버렸다. 그 밖에도 토론에 아이덴티콘 추가하는 기능이랑, 사용자 기여/토론 통계 기능도 넣었다. 이 모든 것들이 8월까지의 일이었다.

\n

여시-워마드

\n

그러다가 8~9월쯤에 페미니즘 관련 사태가 터졌다. (시기가 정확하진 않다. 필자는 기억력이 매우 나쁘다.) 그때 나무위키에서는 archive.is로 아카이브를 떠서 넣는 유행이 있었는데, 기여해본 사람은 알겠지만 이게 약간 귀찮다. 주소를 일일이 복사-붙여넣기하기 위해 마우스를 움직이는 것도 귀찮은 것이 사람 마음 아니겠는가. 그래서 9월에 편집기에 아카이브 메뉴를 추가했다. 나무위키내 편집기에서 바로 아카이브를 만들어 링크를 첨부할 수 있는 기능이었다. 그때의 열렬한 반페미니즘 기여자들에게는 정말로 편리했을 것이다.

\n

그 밖에도 나무위키 이슈 트래커에 접속할 시(그때는 있었다. issue.namu.wiki라고...) https로 자동 전환해주는 기능이랑, 리버전 비교를 더 편하게 하는 기능, 편집 불가능한 문서에서 소스코드를 보여주는 기능도 추가했다. 그때는 편집 불가능한 문서에서 편집을 시도하면 소스코드를 보여주지 않고 그냥 오류 메세지만 표시했다. 이 기능이 '15년 9월 10일에 추가된 기능이었는데, 나중에 나무위키측에서도 편집 불가능 문서의 편집을 시도할 시 소스코드를 보여주도록 변경했다. 그래서 '15년 9월 17일에 해당 기능을 제거했다.

\n

비트코인 받아요

\n

9월 19일에는 나무위키 기부 버튼을 추가하도록 업데이트했다. 지금은 나무위키가 umanle s.r.l의 소유지만, 그때는 개발자 namu의 소유였다. 그때 namu가 돈 좀 기부해달라면서 비트코인 주소를 공개한 적이 있었다. 그 주소를 NamuFix를 통해 상단바 메뉴 형식으로 추가한 것이다, 누르면 비트코인 주소가 뜨도록. 이 기능은 추후 9월 28일에 하단 기부 문구형식으로 뜨도록 수정했고, 그 다음 날에 QR코드 이미지를 추가했다. 이 기능은 나중에 나무위키가 umanle s.r.l에서 인수되면서 '16년 9월 11일에 제거했다.

\n

자기 기여 보는 메뉴 추가하는 기능(이하 \"내 기여 메뉴 추가 기능\")도 '15년 9월에 추가했다. 지금은 편해졌지만, 그때는 자기 기여 보기가 좀 불편했다, 주소에서 IP/아이디 부분을 직접 바꿔서 쳐야했다...

\n

언제인진 기억안나지만 앵커 미리보기 기능도 추가했다. 나무위키 토론에서 토론 스레드 번호(예시: #3)를 누르면 해당 스레드로 가는 기능이 있는데, 이게 왔다갔다 하다보면 은근히 헷갈린다. 그래서 그냥 토론 스레드 번호위에 마우스만 올리면 해당 스레드 내용을 띄워주는 기능을 추가했다. 내가 매우 편하게 썼으니, 다른 사람들도 편하게 썼을 것이다.

\n

VPNGate

\n

그 당시 나무위키에는 VPNGate를 이용한 반달이 성행했다. 그래서 VPNGate IP인 게 확인되면 일단 차단하곤 했는데, 그거 편하게 하라는 의미에서 10월 4일에 토론시 VPNGate VPN 여부 확인 기능을 추가했다. 토론할때 어떤 IP이용자가 VPNGate IP면 옆에 뜨는 기능이다. 이 밖에도 편집화면에서 미리보기/비교를 바로 할 수 있도록 편집기를 위키백과와 비슷하게 탭방식으로 수정했고, 코드 강조 기능도 추가했다. 그때는 나무위키에 코드 구문 강조 문법이 없었는데, 나중에 나무위키측에서 자체적으로 지원을 시작했기에 해당 기능은 '16년 9월에 제거됐다.

\n

레이아웃 변경

\n

'15년 10월에 나무위키에서 프론트엔드를 변경했다. 초창기에는 Daum Dough를 이용한 검은색 프론트엔드를 썼었는데, 이를 완전히 버리고 Bootstrap에 기반한 프론트엔드로 완전히 탈바꿈한 것이다. 그래서 10월 10일과 11일에 이에 대응하는 대규모 업데이트를 실시했다. 이때 앞서 말한 내 기여 메뉴 추가 기능도 같이 삭제됐다, 프론트엔드가 바뀌면서 추가됐기 때문이다.

\n

10월 17일에는 imgur에서 나무위키를 차단함에 따라 imgur 업로더 기능을 삭제했다. 나무위키측에서는 급하게 캐시서버를 만들어 대응했다가 추후 자체적으로 이미지 업로드를 지원하는 방식으로 완전히 해결했다.

\n

이 자체 이미지 업로더를 쓰려면 파일 올리기 페이지를 들어가야 한다. 어? 따로 다른 페이지에 들어갈 필요없이 편집페이지에서 바로 이미지를 올릴 수 있으면 더 편하지 않을까? 그래서 NamuFix에서 '16년 3월 19일에 이걸 추가했다. 그리고 토론에서 IP주소 옆에 상세정보 조회 버튼도 만들었다.

\n

토론 주소의 변경

\n

'17년 7월에는 나무위키 토론 주소가 https://namu.wiki/topic/번호에서 https://namu.wiki/thread/번호 형식으로 변경됐었다. 그래서 이에 대응하는 긴급 업데이트를 진행했다. 그 다음날에는 IP주소 관련 기능의 속도도 개선했다.\n이 토론 주소 변경 이후로 많은 토론들이 접근불가가 됐다, 주소 형식의 변경으로 인해 단절됐기 때문이다.

\n

'17년 8월에는 나무위키에서 서버 부하를 줄인다는 이유로 토론에서 보이는 쓰레드만 불러들이도록 업데이트했다. 그래서 가만히 생각해보니 이거 무시하고 한 번에 불러들이는 기능을 추가하면 사람들이 좋아할 것 같았다. 그래서 Namufix에 보여지지 않는 쓰레드도 불러오는 기능을 추가했다.

\n

이미지 업로더

\n

10월에는 KISA WHOIS 조회 기능을 추가하고, A위키(지금은 알파위키지만 그때는 이름이 없었다.)를 지원하기 시작했으며, 드래그드롭 업로드 기능과 편집기 내 복사-붙여놓기를 통한 이미지 업로드 기능을 추가했다. 드래그드롭 업로드 기능이 확실히 반응이 좋았던 것으로 기억한다. 다른 사람이 NamuFix 이미지 업로드 기능으로 사진을 막 업로드하며 웹툰 문서를 만들다가 차단당하는 사례도 있었다.

\n

그리고 이미지 업로드시 확장자 jpeg이면 jpg로 변경하라고 경고 띄우는 기능도 추가했다. 지금은 고쳐졌을지 모르겠는데, 그때는 나무위키측에 확장자가 jpeg이면 이미지 업로드시 오류가 나는 버그가 있었다. 이유는 오직 namu만이 안다.

\n

차단기간 초단위 입력 기능

\n

10월에 차단기간을 초 단위로 입력하는 기능을 추가했다. 그때 나무위키의 차단 UI는 3일, 5일, 1주, 2주 이런 식으로만 선택할 수 있었다. (그래서 나무위키 규정을 보면 1개월을 4주로 계산한다.) 근데 UI가 그런거지 내부적으로는 초 단위까지 지정할 수 있었다. 그래서 이를 이용해 초 단위로 차단 기간을 설정할 수 있게 하는 기능을 추가했다. 처음에는 초 단위의 시간을 입력하게 했는데, 생각해보니 이건 아닌 거 같아서 다음날에 연/월/일/시/분/초를 입력하면 자동으로 초 단위의 시간이 입력되도록 개선했다.

\n

기록의 가독성 향상

\n

그때의 나무위키는 차단기록에서 차단 시간을 초 단위로만 표시했다. 초 단위의 시간만 보면 며칠인지 바로 감이 오지 않는다. 그래서 이 초 단위의 시간을 \"몇일 몇시간\" 이런식으로 좀 보기 편하게 바꾸는 기능을 10월에 추가했다.

\n

문서 역사에서 ACL 변경 기록도 (참고 : 그때의 ACL 시스템이랑 지금의 ACL 시스템은 다르다. 지금은 규칙기반의 ACL 시스템이지만, 그때는 열람/수정/삭제/토론/이동 권한을 member, admin, everyone 셋 중 하나로 설정하고, 추가적으로 국내 열람 가능 여부를 설정하는 방식이였다.) 보기 쉽게 바꾸는 기능도 추가했다. 문서 역사에서 ACL 변경 기록이 (everyone, everyone, member, everyone, everyone) 이런식으로 뜨니 뭐가 뭔지 한 번에 알아보기 어려웠다. 그래서 everyone모두 이런식으로 바꾸고 그 옆에 알맞은 아이콘을 추가하는 기능을 구현해 배포했다. 이렇게 하니 내가 보기 좀 편해서 좋았다.

\n

liberty 스킨

\n

'17년 11월에 나무위키에 liberty 스킨이 추가됐다. 그래서 이 스킨을 지원하도록 NamuFix를 수정했다. 그리고 이때쯔음 A위키가 알파위키로 바뀌며 주소도 https://awiki.theseed.io에서 https://www.alphawiki.org로 바뀌었다. 그래서 알파위키에서도 스크립트가 불려와지도록 업데이트했다.

\n

Greasmonkey 4, KST

\n

'17년에 Greasemonekey 4가 나오면서 API가 변경되었다. 따라서 이를 지원하기 위해 '17년 12월 초에 GM4를 지원하도록 스크립트를 수정했다. 또한 나무위키 게시판에서 시간대를 KST로 표시하는 기능을 추가했다. 그때 당시 나무위키 게시판은 시간대를 KST가 아닌 UTC로 표시했다. 그래서 시간을 보면 머릿속으로 9시간을 더하는 암산을 해야했는데... 귀찮지 않은가? 그래서 그냥 댓글/게시글 작성일의 시간대를 자동변환하는 기능을 추가했다.

\n

또한 '17 12월에 여러개의 계정이나 IP를 한 번에 차단하는 일괄 차단 기능을 추가했다. 내 기억상으로 나무위키/알파위키 관리자들이 이 기능을 매우 잘 썼던 것으로 기억한다.

\n

여기서 더 나아가, 토론에서의 긴급차단 기능을 문서 역사로 확대하고, 최근 변경 페이지사용자 기여내역, 나무위키 게시판에서도 긴급차단을 할 수 있도록 개선했다. 이 긴급차단 기능도 일괄차단 기능과 함께 관리자들이 애용했던 것으로 기억한다.

\n

INFRA, CUSTOMER, 차단사유, 상용구

\n

KISA WHOIS 조회시 네트워크 구분이 INFRA로 뜨면 공용 IP고 CUSTOMER로 뜨면 공용 IP가 아니라는 것을 아는가? 이는 위키 관리자에게 중요한 상식이다.

\n

근데 이걸 매번 KISA WHOIS 조회페이지에 입력해가면서 확인하면 귀찮다. 그래서 '18년 1월 초에 토론페이지에서 IP 옆에 해당 네트워크 구분을 표시하는 기능을 추가했다. 이때 서버에 요청을 너무 많이하면 안 되니까, 내부적으로 캐시를 만들어 중복되는 요청은 하지 않도록 했다.

\n

나무위키는 차단사유에 링크를 넣으면 사용자문서에서 그냥 텍스트로만 넣어준다. 즉, 눌러도 링크 안 열린다. 이건 지금도 그런데, 크리티컬하진 않지만 소소하게 번거롭다. 그래서 차단사유에 웹페이지 주소가 있으면 해당 주소로 가는 링크를 생성하는 기능을 1월 말에 추가했다. 몇몇 사람들에겐 약간 유용했을 것이다.

\n

'18년 2월에는 ID 기여자의 기여목록에서 차단내역을 조회하는 기능을 추가했다. 하긴, 직접 ID 복사해서 차단내역 검색하는 건 은근히 귀찮다. 나무위키 게시판을 자세히 보면 관리자들이 똑같은 댓글을 자주 다는 것(예시: \"기각합니다\")을 볼 수 있는데 이를 캐치하여 나무위키 게시판 댓글 사용구 기능도 추가했다.

\n

SSL은 이제 상식이죠

\n

나무위키는 초창기부터 no-ssl.namu.wiki로 SSL없이 접속할 수 있었다. 이 기능이 어느순간 삭제돼서 '18년 5월에 해당 주소에서의 NamuFix 지원을 삭제했다.

\n

'18년 10월에 알파위키가 잠시 폐쇄됐었다. 그래서 '18년 10월 말에 알파위키 지원을 삭제했다. 그리고 '19년 1월 초에 클릭 한 번으로 바로 되돌릴 수 있는 빠른 되돌리기 기능을 추가했다.

\n

'19년 1월 20일에는 IP Quality Score를 추가해달라는 요청도 있어서 KISA WHOIS 조회기능 사용시 IP Quality Score도 같이 표시되도록 개선했다. 근데 이게 그렇게 도움이 될 지는 잘 모르겠다.

\n

'19년 6월 말에는 일괄 블라인드 기능을 추가했고, 그리고 이것저것 자잘한 것들을 추가했다.

\n

지원중단

\n

2019년 9월쯤에 나무위키가 기존의 Swig 라이브러리를 던지고 Vue를 쓰기 시작했다. 이때를 기점으로 NamuFix에 매우 많은 버그가 생기기 시작했고, 버그를 고치기 어려워서 이때를 기점으로 지원을 중단했다. 그러나 지원을 중단했음에도 불구하고 Vue 업데이트를 일정 부분 되돌리는 유저스크립트를 만들어서 어떻게든 NamuFix를 쓰는 사용자분들이 있었다. 이걸 보면서 감회가 꽤 새로웠다.

\n

얼마나 썼을까?

\n

유저스크립트에 애널리틱스같은 걸 넣지 않아서, 얼마나 많은 사람들이 썼는지는 정확히 가늠할 수 없다. 하지만 적지 않은 사람들이 이 스크립트를 써주고 이슈와 PR을 올려줬다는 점 만큼은 기억하고 있다. 특히 관리자들이 이 스크립트를 써줬다는 것이 인상적이었다. 특히 일반 관리자가 아닌 사측 관리자도 쓰는 점은 더 놀라웠다.

\n

디테일의 UX, 그리고 추억

\n

이 유저스크립트는 \"디테일의 불편함\"을 개선하고 수정하는 데 중점을 뒀다. 사람들은 사소한 부분에서도 불편함을 느끼고, 사소한 부분에서도 편리함을 느낀다. 이 유저스크립트를 만들면서 한가지 교훈을 얻었다: UX는 디테일부터 시작한다는 것이다.

\n

이 스크립트는 곧 내 추억이다. 내가 만든 프로그램들 중 많은 사람들이 써준 첫 프로그램이었다. 이 스크립트를 만들면서 리그베다위키가 망하는 것을 보았고, 나무위키가 생기는 것을 보았고, 나무위키 관리자들이 선거로 뽑히는 것을 보았고, 나무위키에서 기존 리그베다식 표현이 삭제되는 것을 보았고, 나무위키가 umanle s.r.l로 인수되는 것을 보았다. 그러한 역사를 NamuFix를 유지보수하며 묵묵히 지켜봤다. 시험공부를 하다가도 NamuFix 개발이 너무 재밌어서 바로 버그를 수정하던 그 시절이 그립다.

\n

UX는 디테일에서부터 시작한다. 관찰과 소통으로 디테일의 불편함을 찾아내고 개선할 수 있었다.

", "url": "https://blog.litehell.info/post/memory_of_namufix", "title": "NamuFix의 추억", "summary": "내 첫 유저스크립트", "image": "https://blog.litehell.info/namufix_first_version.png", "date_modified": "2023-01-04T08:32:17.948Z" }, { "id": "review_of_gdg_songdo_devfest_songdo_2022", "content_html": "

처음으로 가보는 GDG Devfest

\n

2022년 11월 19일 인천 스파크업파크에서 열린 GDG Devfest 2022에 참가했다. 스파크업파크 건물은 규모가 꽤 크고 내부도 괜찮았다. 주변에 지하철역(인천대입구역)과 버스역이 있어서 오는 데 큰 불편함은 없었다.

\n

GDG Songdo Devfest에서는 Flutter, Server/ML, Android에 관한 여러 세션이 열렸다, 그리고 발표장소가 총 3군데가 있어 세 세션이 동시에 진행됐다. 그래서 시간이 겹쳐 몇몇 세션을 듣지 못한 것은 아쉬웠다.

\n

이번 Devfest에서는 Androiod/AR분야 세션이 열렸지만, 친구랑 같이 듣다보니 해당 분야의 세션은 듣지 않았다. 대신 Flutter나 GCP, AI 분야의 세션을 들었다. 너무 깊게 들어가는 수준으로 발표가 이루어지진 않아서, 듣는 데 큰 불편함은 없었다.

\n

GCP와 온프레미스 이야기

\n

첫번째부터 세번째까지 GCP와 온프레미스 운영에 관한 세션을 들었다. 발표장소가 모두 다 똑같아서 약간 편했다.

\n

첫번째 세션 — GCP의 새로운 기능 소개

\n

첫번째 세션에서는 GCP에서 새로 출시된 기능들을 소개해줬는데, 이중에서 Document AI와 Translation Hub기능이 인상적이었다. Document AI에서는 문서 인식 기능이, Translation Hub에서는 자동 번역 성능이 인상적이었다. Document AI 기능을 보면서 정형화된 문서(예시를 들자면 택배송장)에 OCR을 적극적으로 실시하는 기업이라면 좋을 것 같다는 생각이 들었다. 그리고 이 세션에서는 GCP 외에도 Google Workspaces의 새로운 변경사항도 소개해줬는데, 이 사항들은 굳이 개발자가 아니더라도 Google Workspaces를 적극적으로 활용하는 사람들이라면 꽤 흥미롭게 들을 수 있을 것 같았다.

\n

두번째 세션 — 누가 요즘 서비스 키 쓰나요?

\n

두 번째 세션은 \"GCP 조금 더 안전하게 사용하기 (feat. AWS)\" 가 주제였다. 나는 처음에 여기서 \"안전하게\"를 비용적 측면에서 \"안전하게\"라는 뜻으로 이해했었다. 그래서 GCP의 예산상한을 설정하는 방법과 같은 내용을 예상했었는데, 실제 내용을 들어보니 GCP를 보안적 측면에서 \"안전하게\" 사용하는 방법이었다. 그래서 발표를 듣는 초반에 잠깐 당황했었다.

\n

이 세션의 내용은 GCP Service account key의 단점을 이야기하고 이에 대한 해결책으로 GCP Workload Identity Federation을 제시하는 내용이다. 타 클라우드나 온프레미스 환경에서 GCP 서비스에 접근할 때 일반적으로 GCP Service account key를 이용한다. 하지만 이 account key는 유출되면 큰 보안적 risk가 발생하고, 관리자의 귀찮음으로 rotation이 주기적으로 이루어지지 않으며, 보관이나 관리가 부담스럽다는 단점이 있다.

\n

그래서 발표자는 이에 대한 대안으로 GCP Workload Identity Federation을 소개한다. GCP Workload Identity Federation은 AWS나 OIDC 혹은 SAML 2.0을 지원하는 외부 ID 제공자의 외부 ID에 GCP의 IAM 역할을 부여하는 방식이다. 즉, AWS의 람다나 ec2 인스턴스등에 GCP의 IAM 역할을 부여할 수 있다.

\n

그러므로 이 기능을 이용하면 AWS 등의 외부환경에서 서비스 키를 이용하지 않고 GCP로부터 토큰을 발급받아 GCP에 서비스 계정으로서 접근할 수 있다. 따라서 관련 config가 유출돼도 사전에 설정된 AWS의 람다나 ec2 인스턴스등이 아니라면 인증이 불가능하므로, config가 유출돼도 큰 타격을 받지 않는다. 또한 토큰의 유효기간도 짧으므로 기존의 Servie account key 방식보다 매우 안전하다.

\n

다만 이 기능이 아직 AWS의 EKS IRSA를 지원하지 않는다는 것이 단점이다. 발표자는 이에 대해 임시적인 해결책을 제시했지만, 이를 해결하는 PR이 관련 SDK에 제출됐다고 하니, 시간이 지나면 결국 해결될 것이다.

\n

Q&A 시간에 해당 기능의 예산 설정이 가능하나는 질문이 나왔다. 발표자는 이에 그건 안되고 추가적인 개발이 필요할 것이라고 답변했는데, 이 점은 약간 아쉬웠다.

\n

세번째 세션 — 온프로미스 고군분투, 해보았습니다.

\n

세번째 세션은 B2B 스타트업 기업에서 온프로미스 개발을 한 이야기이다. 처음부터 끝까지 문제해결을 위해 고군분투한 이야기라서 매우 재밌게 들었다.

\n

발표자분은 스타트업에서 개발 일정을 단축하기 위해 DevOps를 알아보았다, 이 분은 개발자였기에 DevOps 중에서 CI/CD 기술에 가장 관심이 많았다.

\n

그래서 개발하는 제품에 CI/CD 적용을 시도했다. 그러나 막상 해보려니 잘 안됐다. 그래서 이를 해결하기 위해 레거시 등 여러가지를 수정하게 되고, 그러다보니 안정성이 확보되어 납품이 늘어났다. 근데 납품이 늘어나니 또 고객사마다 환경이 달라지는 문제가 발생했다, 고객사에서 마음대로 수정을 하는 사태가 벌어진 것이다.

\n

이러한 사태를 방지하게 위해 아예 제품을 Docker로 컨테이너화하게 됐다. 근데 그러다보니 납품할때마다 APT니 Python이니 Yarn이니 Docker니 이런 것들을 직접 손으로 설치하는 게 귀찮아졌다.

\n

매번 납품할때마다 직접 손으로 쉘 명령어 두들기며 설치하는 건 너무 귀찮지 않은가? 그래서 이에 대한 해결책으로 처음에 스크립트화를 생각했다. 그런데 막상 스크립트화하려니 if문을 너무 많이 써야했다. 즉, 스크립트를 작성하는 게 좀 많이 귀찮았다. 그래서 발표자분은 이런 생각이 들었다: '이걸 해야해?'. 이런 걸 자동으로 하는 툴이 있지 않을까? 있다. 발표자분은 스크립트를 직접 작성하지 않고 Ansible이라는 자동화 툴을 채택했다. 많은 사람들이 쓰는 툴이고, 멱등성이 보장되며, yaml으로 쉽게 작성할 수 있는 점, 그리고 자료를 쉽게 찾을 수 있는 점을 고려해 해당 툴을 선택했다고 한다.

\n

설치의 귀찮음은 해결됐다, 하지만 문제가 하나 해결되면 또 새로운 문제가 생기는 법 아니겠는가? 고객사에서 새로운 요청사항이 들어왔다: 서버 여러대 쓰면서 관리 가능한가요?. 문제를 해결해야 한다. 문제에 대한 해답은? 예상하셨다시피 Kubernetes.

\n

발표자분은 처음에 Kubernetes가 소 잡는 칼이 아닐까하는 걱정을 했었다. 그런데 막상 써보니 소 잡는 칼이 아닌 만능 기관총이었다. 그래서 많이 좋았다고 한다. 그러나 Kubernetes를 쓰니 manifest 문제와 Kubernetes 설치의 귀찮음, local-path-porivder 문제가 생겼다.

\n

발표자는 이 문제들을 Helm, Kuberspray로 해결했다. 특히 Kuberspray가 Ansible 기반이라서 조합이 매우 환상적이었다. 그리고 local-path-provider 문제는 RockCeph로 해결했다고 한다.

\n

Infra as Code

\n

인프라를 Yaml 파일들과 코드로 관리하다 보면 고객사별로 여러 차이가 생기는 데 이에 대해 Git Branch로 관리했다고 한다. Dev, Alpha, Beta, Release등의 브랜치를 만들고, 그 맨 위에 납품사별 브랜치를 만들어서 merge하는 식으로 했다고 한다.

\n

Flutter와 AI 이야기

\n

네번째 세션부터는 Flutter와 AI 관련 세션을 들었다. 재밌는 것도 있고 그냥 그랬던 것도 있다.

\n

네번째 세션 — Flutter 환경에서 테스트하기

\n

네번째 세션은 Flutter 환경에서 테스트하는 이야기로, Blackbox testing/Whitebox testing의 개념, 그리고 Unit testing/Widget testing/Integration testing의 개념을 설명하고 Flutter에서의 테스트 방법을 설명한 뒤, 멀티 플랫폼 환경에서의 Flutter 앱 테스트 방법을 설명하는 세션이다.

\n

들으면서 느낀 것은, Dart 테스트 라이브러리가 다른 언어의 유명한 라이브러리랑 사용법이 굉장히 유사해서 꽤 친숙하게 느껴졌다는 점, 그리고 Flutter의 Widget testing 관련 라이브러리가 꽤 잘 짜여져있다는 점이였다.

\n

그리고 발표 마지막에 소개한 Recording 테스트 기법과 AI Testing 테스트 기법이 인상적이었다. 리코딩 기법은 앱 이용(탭이라던지 스크롤 등)을 기록한 뒤 이를 다른 기기들에서 똑같이 하는 기법인데, 들으면서 매크로 생각이 났다. 그리고 AI Testing 기법은 말 그대로 인공지능으로 테스트하는 기법이다. 인공지능이 확실히 많이 발달한 게 느껴진다.

\n

다섯번째/여섯번째 세션 - AI, Deno

\n

다섯번째와 여섯번째 세션은 각각 Stable Diffusion과 초해상도 이미지 복원에 관한 이야기이다. Diffusion 모델에 관한 간략한 설명을 듣고 해상도 향상 AI에 관해 들었다. Text-to-Image AI에 넣을 prompt 파는 사이트가 있다고 해서 좀 많이 놀랐다. 그것 말고는 딱히 할 말이 없다... AI 돌아가는 거 보면 신기하긴 하더라. 필자가 AI에 대해 잘 아는 편이 아닌지라, 수학적이거나 학문적인 내용이 나오면 그냥 그렇구나하면서 들었던 것 같다.

\n

마지막 AI세션 끝나고 종료식 장소로 이동해서 Deno 세션을 잠깐 들었는데 이것도 잠깐이었지만 재밌었다. Deno deploy가 생각외로 빠르다는 점과 Deno Fresh 프레임워크에 관한 내용 등이 인상적이었다.

\n

행사 종료식

\n

아무리도 13시부터 20시까지 하는 행사다보니 사람들이 중간에 많이 빠져나갔다. 그래서 경품 당첨확률이 매우 높았는데, 그럼에도 불구하고 난 한 번도 당첨이 안 됐다. 아무래도 난 운이 없나보다.

\n

전체적인 후기

\n

전체적으로 재밌는 내용이 많았다. 서두에서 말했듯 전체적으로 얕은 수준의 발표들이라서 이해하는 데 큰 어려움은 없었다. 다만 AI 관련 세션은 내용을 얕게는 이해했지만 그 외에는 학문적인 내용이라서 완벽하게 이해하진 못했다. 개인적으로 Deno를 이용해서 한 번 어플리케이션을 개발해보면 괜찮겠다는 생각이 들었다.

\n

가기 편한 곳에서 개발자행사가 열리니 참석하기 편했다. 앞으로도 가기 편한 곳에서 개발자행사가 자주 열렸으면 좋겠다.

", "url": "https://blog.litehell.info/post/review_of_gdg_songdo_devfest_songdo_2022", "title": "GDG Devfest Songdo 2022 다녀온 후기", "summary": "GCP와 Flutter, AI에 관한 이야기", "image": "https://gravatar.com/avatar/837266b567b50fd59e72428220bf69b1", "date_modified": "2022-11-20T03:26:51.094Z" }, { "id": "watermark_into_website", "content_html": "

워터마크

\n

워터마크란 무엇일까? 워터마크는 이미지나 비디오등에 삽입되는 표지로, 주로 저작권 정보를 삽입하거나 보안상의 이유로 이용한 사람이 누구인지를 표시할 때 이용된다.

\n

웹사이트에 워터마크를 삽입하는 방법은 뭐가 있을까? 가장 쉬운 방법은 배경에 넣는 방법이다. 그러나 배경에 워터마크를 넣으면 배경 위의 글자가 워터마크를 가린다. 이보다 더 나은 방법이 있지 않을까? 오늘은 필자가 웹사이트에 워터마크를 넣은 이야기를 짧게 풀어보고자 한다.

\n

구상

\n

워터마크를 넣으려면 어떻게 해야할까?

\n

첫째, 워터마크는 글자나 사진 등에 의해 가려서는 안 된다. 단순하게 background CSS로 워터마크를 넣으면 (위치를 교묘하게 하지 않는 이상) 앞서 말했듯 글자나 사진에 의해 가려질 것이다.

\n

둘째, 워터마크는 최대한 제거하기 어렵게 해야한다. 빈 공간 한 구석에 워터마크가 덩그러니 있다면 그냥 그림판으로 지워버리면 된다. 글자나 사진과 같은 컨텐츠 위에 덧붙여진 워터마크가 지우려는 사람 입장에서 가장 짜증날 것이다.

\n

위 두가지를 만족하게 하는 방법은 간단하다. HTML의 모든 요소들의 맨 위에서 희미한 워터마크를 무한반복하는 요소를 만들면 된다.

\n

스케치

\n

간단히 생각해보자. 먼저 서버측에서 다음과 같은 워터마크를 생성하는 /watermark 엔드포인트를 만든다. 해당 예시의 배경색은 완전한 투명으로 하고, 글자에도 70% 정도의 불투명도를 적용한다.

\n

\"워터마크

\n

이제 모든 요소들의 위에서 위 워터마크를 삽입하면 된다. 방법은 간단하다. 다음 HTML과 CSS를 삽입하면 된다. background-repeat 속성은 기본값이 repeat이므로 굳이 설정할 필요가 없다.

\n
<div class=\"simple_watermark\"></div>\n
\n
.simple_watermark {\n    z-index: 999;\n    background: url(\"/watermark\");\n    position: fixed;\n    left: 0;\n    top: 0;\n    width: 100%;\n    height: 100%;\n}\n
\n

간단하다. 그러나 위 방법의 한가지 문제점이 있다. 웹사이트가 클릭이 안 된다. 생각해보면 당연한 일이다. 모든 요소들의 맨 위에 아무것도 없는 워터마크 요소가 덩그라니 있을테고, 클릭하면 해당 요소를 클릭하게 될테니 클릭이 정상적으로 안 되는 게 정상이다. 그렇다면 웹 사이트를 정상적으로 이용할 수 있게 하려면 어떻게 해야할까?

\n

pointer-events CSS

\n

포인터가 해당 워터마크 요소를 투과하도록 하면 된다. pointer-events CSS를 이용하면 된다. pointer-events 속성이 none이 되면 포인터가 해당 요소를 뚫고 들어가게 된다.

\n
\n

none 값의 경우 요소가 포인터 이벤트의 대상이 아님을 가리키는 동시에, 이벤트가 요소를 \"뚫고\" 들어가 \"아래\" 요소를 대상으로 하도록 만듭니다.

\n

-- MDN pointer-events 문서

\n
\n

물론, 자식 요소들 중 하나가 pointer-events 속성을 명시적으로 허용했다면, 해당 CSS는 의미가 없게 된다. 그러나 위 예시의 HTML에는 자식 요소가 없으므로 신경쓰지 않아도 된다.

\n

따라서 CSS에 pointer-events: none; 한 줄을 추가하면 완벽해진다.

\n
.simple_watermark {\n    z-index: 999;\n    background: url(\"/watermark\");\n    pointer-events: none;\n    position: fixed;\n    left: 0;\n    top: 0;\n    width: 100%;\n    height: 100%;\n}\n
\n

결론

\n

웹사이트에 워터마크를 간단하게 넣는 방법을 알아보았다. 위와 같이 pointer-events CSS를 곁들이면 간단한 투명 워터마크를 만들 수 있다. 물론 투명 워터마크를 위해서는 위 예시 이미지와 같이 워터마크 자체가 투명해야 한다. 이를 위해 레스터 이미지로 작업하는 게 귀찮을 수 있다. 그렇다면 SVG를 이용해 투명 워터마크를 만들면 된다. SVG로 만드는 편이 더 간단하기도 하다.

\n

누군가는 이렇게 물을 수 있다: \"개발자도구를 이용하면 워터마크를 삭제할 수 있지 않느냐?\". 이 글에서 해당 질문에는 답변하지 않겠다. 이러한 것들까지 어렵게(정확히는 귀찮게) 하는 것은 독자의 몫이다.

", "url": "https://blog.litehell.info/post/watermark_into_website", "title": "웹사이트에 워터마크 추가하기", "summary": "캡쳐를 추적하는 스누라이프처럼", "image": "https://blog.litehell.info/watermark_example.png", "date_modified": "2022-03-28T06:45:14.399Z" }, { "id": "retrospective_of_2021", "content_html": "

들어가는 말

\n

블로그를 만들었는데 쓸거리가 없다. 하기야 군대에서 스마트폰만 만지막거리며 놀고 있으니 그럴 것이다. 블로그를 썩히고 있는 것이 눈에 보여서 뭐라도 쓸까 생각하다가 회고를 안 한 것이 떠올라 이제서야 쓰려한다.

\n

돌이켜보면 애매한 타이밍이긴 하다. 2월이니 연초는 맞긴 맞는데, 회고를 쓸 타이밍은 아닌 것 같은 타이밍. 보통 회고는 1월 초에 쓰는 게 무언의 관례니까. 뭐 어떻겠는가, 타이밍이 어쨌든간에 내가 쓰고 싶을 때 쓰면 되는 것이다.

\n

작년을 간략하게 요약하면 코로나와 군대의 해였다. 코로나때문에 집에서만 지내다가 군대에 들어간 순간 시간이 사라졌다. 올해에는 코로나 팬데믹이 끝나기를 소망하며, 지금부터 회고를 시작한다.

\n

마지막 불꽃 (1~3월)

\n

2021년 3월에 입대가 결정됐다. 3월에 군대가서 전역하면 내년 12월, 복학하기에 큰 무리없는 시기이다. 이를 위해 자격증을 따고 헌혈을 하고 학교증명서를 뗐다. 군대도 스펙을 준비해야 한다니, 우리나라 사람들은 줄세우기를 참 좋아하는 것 같다. 군대와 관련된 이야기를 하면 무심코 어릴 적의 기억이 떠오른다, 한때 통일돼서 군대 안 가기를 기도하던 어린 시절이... 나에게도 그런 순수한 시절이 있었으매, 세월은 느리게 가는 듯하면서도 빠르게 흘러감을 상기한다.

\n

이제 영장의 주사위는 굴러졌으니, 3월에 군대를 가기 전에 열심히 코딩하고 친구들 만나고 놀고 먹고 다니기로 결심했다. 훈련소에 들어가면 다시는 못 만날 친구들이오, 하기 어려울 코딩일텐데, 지금 최대한으로 해야 후회하지 않을테니까.

\n

따라서 코딩을 적극적으로 하기 시작했다. 교내 커뮤니티 서비스인 어디로PR도 내고, 지금 보고 있는 블로그도 이때 완성했다. 현재 만화두레 홈페이지을 만드는 데 쓰인 fullcards 프로젝트도 1월부터 3월까지 firebase를 배우며 만들었다. (관련 블로그 글) 관계형 DB 스카마를 설계하기가 귀찮아서 firebase를 골랐는데, 빠르게 개발하는 데엔 나쁘지 않은 선택지이다. 지금 잘 굴러가는 만화두레 회원관리 사이트(백엔드, 프론트엔드)도 이때 많은 버그를 수정하고 여러 기능들을 개선했다.

\n

그리고 2~3월동안 내 서버에서 돌아가던 모든 것들을 도커화했다. 여러 노드를 유동적으로 만들어야 하거나 로드밸런싱이 필요한 규모의 서비스는 없었기에, 단순하게 docker-compose를 이용해 서버내 서비스들을 각각 하나의 컨테이너들로 도커화했다. 그 전에는 pm2를 썼는데, Docker를 쓰니 확실히 편하긴 했다.

\n

군대

\n

3월에 진주로 내려와 입대했다. 코로나19 때문에 입영식같은 행사는 없었다. 차에서 내려 부모님과 헤어진 후 조교들의 지시에 따라 PCR검사를 받고 터벅터벅 생활관으로 걸어갔다. 그 걸어가는 길의 첫 인상은 주머니에 손 넣지 말고 걸으라던 조교의 큰 목소리였다.

\n

기초군사훈련을 마치고 특기교육을 받았다. 특기학교에서의 큰 변화는 자판기와 기가지니, 그리고 간이 BX가 있다는 점이었다. 수업이 끝나고 쉬는 시간이면 기가지니로 아이돌 노래를 귀에 못이 박히도록 듣고, 간이 BX에서 주전부리를 사 먹으며 특기교육을 받았다. 자판기는 내 심심한 입을 달래주는 좋은 절친이었다. 특기교육은 전공자 기준으로 그리 어려운 내용이 없기에 성적도 무난하게 받을 수 있었다.

\n

자대에서는 코딩을 쉬고 독서와 그림 그리기를 하고 있다. 코딩이야 어처피 전역하면 질리도록 할테니 부대 내에 있을때는 쉬고 싶었다. 아, 군내에서 해커톤도 열어서 신기했다.

\n

마무리

\n

회고를 하려했는데 딱히 할 게 없다. 아무래도 코로나와 군대때문에 그런 것 같다. 어서빨리 전역해서 재밌고 즐거운 것들을 하고 싶다.

", "url": "https://blog.litehell.info/post/retrospective_of_2021", "title": "2021년의 회고", "summary": "코로나와 군대", "image": "https://gravatar.com/avatar/837266b567b50fd59e72428220bf69b1", "date_modified": "2022-02-12T12:16:55.084Z" }, { "id": "first_use_of_firebase", "content_html": "

발단

\n

전 글에서 동아리 인증 시스템을 만들 때, 동아리 소개 홈페이지도 같이 만들었다. 동아리 홈페이지를 만들어두면 나중에 홍보할 때 써먹을 수 있겠다는 판단이 들어서였다. 그래서 그때 당시(2020년 초)에 GitHub PagesCreative 템플릿을 이용해 간단히 제작했다.

\n

그 다음 연도(2021년 초)에 회장이 바뀌고 회장 전화번호를 수정하면서 Bootstrap 의존성을 걷어내고 순수 CSS와 HTML, JS(+jQuery)로만 재작성했다. 그 때 당시에는 1페이지의 홈페이지로, 간단한 동아리 소개와 회장단 연락처, 동아리 가입신청 링크만 있었다.

\n

이때 당시에는, 홈페이지 수정이 1년에 한 두번만 있을 것이라고 생각했었다. 그래서 그냥 간단하게 GitHub Pages를 이용해서 게시했다. 어처피 내가 곧 군대를 가게 될지라도 수정해주고 가면 될 것이라 판단도 있었다.

\n

수정 가능한 형태

\n

그러나 새 회장은 홈페이지를 보다 더 적극적으로 이용하고자 했다. 그래서 내게 홈페이지를 자신이 수정 가능하게 해달라는 부탁을 했고, 나는 공부도 해볼 겸 나쁘지 않겠다 싶어서 그 요청을 수락했다.

\n

웹사이트 디자인

\n

먼저 웹 사이트를 수정 가능한 형태로 하려면 웹 사이트 디자인을 먼저 생각해야 한다. 앞서 말한 2020년 초 디자인이 간단한 카드형태였는데, 그때의 디자인을 살리면 괜찮겠다고 생각했다. 그래서 웹 사이트를 \"제목, 내용\" 이 두가지로 이루어진 \"카드\"들의 집합으로 정의하고 디자인했다.

\n

결과는 꽤 그럴싸하게 나왔다. 간단한 CSS 애니메이션을 주니 보기 좋더라. 나는 디자인 지식이 전무한 공학도이기 때문에 근사한 디자인보다는 그럴싸한 디자인이 최선이었다.

\n

RDB를 써볼까?

\n

처음에는 MariaDB같은 관계형 데이터베이스를 이용하려 했다. 근데 MariaDB를 이용하려면 먼저 데이터베이스 스카마를 설계해야 하는 데 이게 너무 귀찮았다.\n그래서 MongoDB를 써볼까란 생각도 했었는데 MongoDB를 서버에 설치하는 것도 귀찮았다. 그래서 '어떻게 할까...' 생각하고 있었는데 마침 교내 학술동아리 발표에서 Firebase에 관해 말하더라. 생각해보니 Firebase를 써보는 것도 나쁘지 않겠다는 생각이 들었다.

\n

Firebase를 써보았다.

\n

그래서 Firebase를 썼다. Firebase는 Google에서 출시한 데이터베이스/스토리지/호스팅 등을 한 데 몪아놓은 서비스로, 번잡한 서버구성 없이 바로 사용할 수 있다는 것이 특징이다.\nFirebase의 데이터베이스는 두 가지 종류가 있다. 가장 먼저 나온 것이 실시간 데이터베이스이고, 그 다음에 나온 것이 Cloud Firestore이다. 실시간 데이터베이스는 데이터를 하나의 JSON 트리로 보고, Cloud Firestore를 데이터를 여러개의 JSON 문서들의 집합(MongoDB를 떠올리면 이해가 쉽다)로 본다.\n어처피 동아리 소개 홈페이지는 하나의 페이지로 이루어져 있기에, 하나의 페이지를 만드는데 필요한 데이터는 하나일 것이라고 생각했다. 따라서 실시간 데이터베이스를 채택했다.

\n

Thounghts in React

\n

이번 동아리 소개 홈페이지를 만들 때는 React.js를 채택했다. 저번에는 Vue를 썼으니 이번에는 React를 써보자는 것이 그 이유였다.\n이번 홈페이지를 만드는 데 있어 복잡도가 그리 높지 않았기에, Redux와 같은 스토어를 이용하지 않았다. 따라서 데이터가 아래로 내려갔다가 위로 다시 올라오는 방식으로 모든 코드를 작성했다.

\n

본 홈페이지에서 필요한 페이지들은 다음과 같다.

\n\n

메인 페이지와 미리보기 페이지는 데이터를 편집할 필요가 없기 때문에 단방향의 데이터 흐름만 구현하면 된다. 따라서 해당 페이지들은 Firebase 데이터베이스에서 데이터를 받아 하위 컴포넌트에 전달하는 방식으로 이루어져 있다. 다만 약간의 차이점이 있다면 메인 페이지는 데이터를 한 번만 받고 (once 메소드) 미리보기 페이지는 데이터가 바뀌면 다시 받는다는 점 (on 메소드)이 있다.

\n

그러나 편집 페이지는 데이터를 수정해야 하므로 양방향의 데이터 흐름이 필요하다. 따라서 데이터를 하위 컴포넌트에 전달해 표현하고, 수정이 발생하면 하위 컴포넌트에서 상위 컴포넌트로 계속 올라가다 종착지에서 데이터를 수정하는 React스러운 사고방식으로 작성했다.

\n

하위 컴포넌트에서 텍스트 입력 등 변경사항이 발생해 상위 컴포넌트로 올라갈 때 새로운 변경사항이 반영된 데이터를 같이 전달한다. 모든 하위 컴포넌트들은 자신이 담당하는 데이터만을 가지므로 이는 \"웹사이트 전체 데이터\"의 일부이다. 이 데이터를 전달받은 상위 컴포넌트는 이 전달받은 데이터를 자신이 관리하는 데이터(prop의 값)에 병합한 데이터를 자신의 상위 컴포넌트로 올려보낸다. 이 상위 컴포넌트들은 하위 컴포넌트보다 더 많은 데이터를 관리하므로, 데이터의 규모는 점점 커지게 된다. 따라서 이를 계속 반복하다 보면, 새로운 변경사항이 반영된 \"웹사이트 전체의 데이터\"를 받게 된다. 이 \"웹사이트 전체의 데이터\"를 Firebase 실시간 데이터베이스에 저장하게 함으로써 편집 기능을 구현했다.

\n

데이터베이스에 저장한 이후의 데이터를 표현하는 방식에 관해서는, 데이터베이스에 값이 저장되는 순간 Firebase에서 value 이벤트를 발생시키고, 이에 연결된 이벤트 리스너가 페이지의 prop를 수정하게 된다. 따라서 페이지의 prop이 수정됨에 따라 하위 컴포넌트들의 prop도 같이 수정되게 되므로 별도의 특별한 코드가 필요하지 않았다.

\n

파일 핸들링

\n

Firebase는 파일 저장을 위한 Storage 기능도 지원한다. Storage에 저장할 때 mime-type만 지정하면 이미지 표시에는 딱히 큰 지장이 없으므로 파일 이름을 cuid 라이브러리를 이용해 난수로 저장하게 했다. 다만 그러면 편집 페이지에서 볼 때 불편한 점이 있으므로 커스텀 메타데이터로 본래 파일이름도 같이 저장하게 했다.

\n

Firebase와 인증 통합

\n

Firebase에서 OpenID Connect를 지원했기에, 기존 동아리의 인증 시스템과 Firebase를 통합하는 것은 그리 어렵지 않았다. 다만 의외의 복병은 Firebase가 Id Token Authorization Grant만을 지원한다는 점이였다. 따라서 Authorization code grant만을 지원하던 인증 시스템을 수정하는 번거로움이 약간 있었다.

\n

Firebase의 인증을 통합할때는 Google Identity Platform을 이용하면 된다. 데이터베이스나 스토리지의 보안 규칙을 작성할 때 auth.token.firebase.sign_in_attributes 속성으로 ID 토큰의 커스텀 속성에 접근할 수 있으므로 이를 이용해 기존 인증 시스템의 권한 관리도 통합할 수 있었다.

\n

결론

\n

Firebase는 서비스를 빠르게 개발하는 데에 좋은 것 같다.

", "url": "https://blog.litehell.info/post/first_use_of_firebase", "title": "Firebase 처음 써본 이야기", "summary": "데이터베이스 스카마 설계하기 귀찮을 때", "image": "https://gravatar.com/avatar/837266b567b50fd59e72428220bf69b1", "date_modified": "2021-02-28T13:40:29.000Z" }, { "id": "oauth2_and_oidc", "content_html": "

들어가는 말

\n

얼떨결에 동아리에서 자리를 하나 맡게 되면서 사이트를 인수인계받았다. 오픈나무을 약간 수정해서 자체적으로 운영하던 사이트였는데, 회원들의 사적인 정보가 담겨있었다. 이걸 그냥 개방된 채로 뒀다가 사고가 나면 어떻게 될 지 상상하고 싶지 않더라.\n그래서 사이트를 운영하기에 앞서 다음과 같이 수정해야할 필요가 있었다.

\n\n

오픈나무 수정

\n

첫번째 목표는 오픈나무에서 제공하는 ACL기능을 이용해서 달성할 수 있다. (ACL 기능을 도구로까지 확장하면 더 완벽히 틀어막을 수 있다.)\n다만 두번째 목표부터는 쉽지 않았다. 두번째 목표를 달성하려면 회원가입을 위키 소유자가 통제할 수 있는 기능이 필요했는데, 오픈나무에는 그런 기능이 없었다. 그래서 직접 회원가입 승인 기능을 추가했다. 세번째 목표는 이메일 인증을 강제하고 도메인을 제한하는 방향으로 접근했다. 이걸 하려면 오픈나무에서 제공하는 이메일 화이트리스트 기능을 쓰면 되는데, 사소한 문제가 있었다. 오픈나무가 네이버, G메일, 다음, 카카오 메일을 강제로 자동추가한다는 점이다. (하드코딩이라 삭제도 안 된다.) 그래서 약간의 수정으로 이 문제를 해결했다. 또한 이메일 발송시에 구글을 안 쓰는데 메일 발송 설정에서 구글만을 지원해서 이것도 수정했다.

\n

게시판을 만들까?

\n

그렇게 위키를 수정해서 굴리고 있었는데 문득 게시판이 있으면 좋겠다는 생각이 들었다.\n근데 귀찮다고 XE만 깔고 통치면 위키랑 XE가 아이디가 따로 놀잖아.

\n

이거는 보기가 별로 예쁘지 않다. 그래서 통합 로그인을 구축하기로 했다.

\n

접근방식

\n

로그인을 통합하기 위해서는, 아이디와 비밀번호 등 인증 정보를 관리하는 서비스가 필요했다. 이 문단에서는 이를 통합 로그인 제공자라고 통칭한다.

\n

위키밖에 없는 상황이니, 통합 로그인 제공자가 있을리 없었다. 이런 때에는 두가지 방법으로 접근할 수 있다.

\n\n

각자 장단점이 있다. 위키에서 통합 로그인을 제공하도록 하는 첫번째 방법은 아래와 같은 장점이 있다.

\n\n

두번째 방법의 장단점은 다음과 같다.

\n\n

나는 두번째 방법을 택했다. 그 이유로는 위키를 수정하는 과정이 번거로울 것 같아 차라리 새로 개발하는 게 더 좋을 것 같았고, 그 과정 속에서 공부가 더 많이 될 것 같았다. 인증 통합에 이용할 표준으로는 OpenID Connect Core 1.0을 채택했는데, SAML2.0같은 다른 표준들은 복잡해 보였기 때문이다. (일단 SAML2.0은 XML을 써서 귀찮지만 OpenID Connect Core 1.0은 OAuth2.0 기반이고 JSON을 쓴다.)

\n

프로토타이핑

\n

두번째 방법을 택한 나는 일단 최단기간 안에 개발하기 위해 내게 익숙했던 언어와 프레임워크를 택했다. TypeScript 언어와 koajs 프레임워크를 이용하고, 템플릿 엔진으로 pugjs 라이브러리를 이용했다. OpenID Connect Core 1.0 개발은 oidc-provider 라이브러리를 이용했다.

\n

근데 빠르게 개발한 것이라 그런가 문제가 있었다. 먼저 소스코드가 좀 많이 더러웠고, oidc-provider 라이브러리가 생각 이상으로 무거웠다. oidc-provider가 생성하는 세션과 홈페이지 자체적인 세션이 따로따로 노는 것도 약간 마음에 안 들었다.

\n

그래도 일단 사이트를 만들긴 했으니 만든 사이트를 사용하고, 나중에 기회가 되면 사이트를 제대로 다시 만들기로 마음먹었다.

\n

리빌딩

\n

2020년 연말에 여유가 남아 사이트 재개발을 하기 시작했다. TypeScript를 이용하되 지난 번과 달리 프론트엔드랑 백엔드를 분리하기로 마음먹었다. 프로토타이핑때 백엔드랑 프론트엔드를 합친 \"전통적인 방식\"으로 개발했더니 소스코드가 영 보기 좀 그랬다.

\n

그러려면 프론트엔드랑 백엔드 사이에 통신하는 API 스펙이 필요하다. 단순하게는 JSON 기반의 REST API를 이용하는 방법이 있다. 근데 이런 방식은 경험상 개발하다가도 스스로 API 스펙을 헷갈리기 쉽고, 문서화하기가 굉장히 귀찮았다.

\n

그래서 어떻게 할까 생각하는데 문득 2020 오픈소스 컨트리뷰톤에서 참가했던 HackaTalk 프로젝트가 떠올랐다. HackaTalk의 서버 기술스택은 Prisma-GraphQL Nexus로 이루어진 GraphQL 기반의 기술스택이다. Prisma는 GraphQL 개발시에 쓰면 편한 ORM이고, GraphQL Nexus는 GraphQL Schema를 작성할 때 쓰는 Code-First 철학의 라이브러리이다. GraphQL Nexus를 쓰면 Nexus에서 TypeScript Type Definition 파일을 자동으로 생성해줘 코딩할 때 인텔리센스와 TypeScript 컴파일러의 도움을 받을 수 있다.

\n

이런 기술스택을 쓰면 괜찮겠다는 생각이 들어서, 백엔드는 Prisma와 GraphQL Nexus 기반에 GraphQL Yoga를 이용해 만들기로 했다. 프론트엔드는 Nuxt.js를 이용하고, GraphQL 요청은 @nuxtjs/apollo를 이용했다.

\n

또한 이번 개발때는 oidc-provider와 같은 라이브러리를 이용하지 않고 OAuth2와 OpenID Connect Core 부분을 직접 개발하기로 마음먹었다.

\n

OAuth2?

\n

OAuth2 (RFC6749)란 리소스 소유자(주로 사용자)와 리소스 서버(주로 API 제공자), 리소스를 이용하고자 하는 HTTP 서비스(이하 클라이언트, 주로 서드파티) 간의 상호작용을 정의한 표준이다. 정보통신표준화위원회 RFC6749 번역본의 한글 내용요약을 인용하면 다음과 같다.

\n
\n

공개 인증 2.0 프레임워크는 리소스 소유자를 대신하여 리소스 소유자와 HTTP 서비스간에 승인된 상호작용에 의하거나 또는 그 자신을 대신하여 얻은 접근권한을 제삼의 응용에게 제삼의 응용에게 허여하여, HTTP 서비스에 대한 제한적 접근을 획득하도록 제공을 한다. 본 표준은 공개 인증 1.0에 기술된 프로토콜을 대신한다.

\n

-- 출처 : TTAE.IF-RFC6749

\n
\n

OAuth2는 거의 모든 웹 서비스에서 API 인증 방식으로 쓰이는 범용적인 표준이다. OAuth2가 없을 적에는 클아이너트가 리소스 소유자(주로 사용자)의 접근이 제한된 리소스(예시를 들자면 이메일 주소 같은 것들)에 지속적으로 접근하려면 리소스 소유자의 자격증명(credentials, 주로 비밀번호)을 바람직하지 않은 방법(예시: 평문)으로 저장해야 하는 문제점이 있었다. 또한 클라이언트가 비밀번호를 평문으로 저장하는 경우, 추후 접근 허가를 철회하거나 서드파티를 통제하기도 어렵다는 문제점도 있었다.

\n

OAuth2는 이러한 문제점을 해결하기 위해 Access Token(엑세스 토큰)이란 개념을 도입한다. 액세스 토큰은 클라이언트가 접근이 제한된 리소스를 취득하기 위해 사용하는 자격증명으로, 토큰 그 자체로는 어떠한 의미도 지니지 않는다. 이 액세스 토큰은 OAuth2에서 정의하는 여러가지 방식에 따라 취득할 수 있는데, 리소스 소유자가 인증을 담당하는 Authorization 서버(혹은 리소스 서버, Authorization 서버와 리소스 서버는 동일할 수 있다.)에 직접 인증하는 방식이 거의 대부분이기에 서드파티에 자격증명을 줄 필요가 없다는 장점이 있다.

\n

Authorization Code Grant

\n

Authorization Code Grant는 클라이언트가 Access Token을 얻기 위한 방법 중 하나이다. 아래 그림은 RFC6749 Section 4.1의 Figure 3을 한국어로 옮긴 그림이다.

\n

\"RFC6749

\n

그림의 설명하자면 다음과 같다. 먼저 클라이언트가 User-Agent(웹브라우저)를 클라이언트 식별자와 리다이렉션 주소가 담긴 Authorization Request 주소로 리다이렉트시키면(그림에서 (A) 단계), Authorization 서버에서는 사용자에게 인증을 요구한다(그림에서 (B) 단계). 사용자가 인증을 완료하면 Authorization 서버는 리다이렉트 주소에 Authorization Code를 code 매개변수로 추가해 User-Agent를 그 주소로 리다이렉트시킨다. (그림에서 (C) 단계) 그러면 클라이언트는 code 매개변수로 Authorization Code를 얻게 되고, 이 코드를 Authorization 서버에 보내 엑세스 토큰과 교환할 것을 요청한다. (그림에서 (D) 단계) 그러면 Authorization 서버는 Authorization Code를 확인하고 그 코드가 올바르다면 액세스 토큰을 발급해 클라이언트에게 제공하게 된다. (그림에서 (E) 단계)

\n

쉽게 말해, Authorization 서버가 사용자를 확인하면, Authorziation 서버는 클라이언트에게 Authorization Code를 주고, 클라이언트는 그 Authorization Code를 엑세스 토큰과 교환하는 방식이다.

\n

Authorization Code Grant는, 코드를 주지 않고 바로 엑세스 토큰을 주는 Implicit Grant와 달리, 리소스 소유자에게 엑세스 토큰을 노출하지 않는다는 장점이 있다. 다만 Authorization Code Grant는 클라이언트 인증이 요구되므로 JavaScript 등으로 클라이언트 사이드에서 작동하는 웹 어플리케이션보다는 서버(백엔드)가 있는 웹 사이트에 적합하다.

\n

OAuth2 인증의 구현

\n

OAuth2 인증을 구현하기 위해 백엔드단에서 GraphQL 서버말고 HTTP 서버도 열어야 할 필요가 있다. 따라서 HTTP 서버를 열고 GraphQL 서버와 다른 포트에서 요청을 받도록 수정했다. (어처피 리버스 프록시 쓰면 포트 달라도 서비스하는 데엔 상관없다.)

\n

OAuth2 인증을 구현하려면, 외적으로는 인증을 하는 Endpoint와(이하 인증 Endpoint, 보통 /authorize와 같은 이름으로 구현함) 토큰을 교환하거나 발급하는 Endpoint(/token과 같은 이름을 구현함.) 두가지를 구현해야 한다.

\n

그런데 앞서 말했듯 프론트엔드와 백엔드가 분리되어 있다. 그러니 당연히 사용자의 인증정보(세션)도 프론트엔드단에서 관리하고 있으므로, 백엔드에서 프론트엔드를 거치고 않고 직접 받는 HTTP 서비스에서는 프론트엔드의 인증 정보를 바로 편하게 받을 수 없었다. (물론 가능은 하겠지만... '굳이?'라는 생각이 들었다. 그리고 그렇게 하면 코드가 생각보다 복잡해질 것 같았다.)\n그래서, oidc-provider 라이브러리가 /interaction/[Some ID Here]와 같이 리다이렉트하던 것에서 힌트를 받아 다음과 같이 구현했다.

\n
    \n
  1. 인증 Endpoint에 요청이 들어오면
  2. \n
  3. 요청받은 내용을 전부 DB에 저장하고 그 DB 데이터의 Primary ID를 받아서
  4. \n
  5. 프론트엔드가 담당하는 페이지로 ID와 같이 전송한다. (예시 : /authorize_oauth2/[Some ID Here])
  6. \n
  7. 그러면 프론트엔드에서 GraphQL 서버로의 요청을 통해 인증 요청을 처리한다.
  8. \n
\n

즉, HTTP API 서버의 인증 Endpoint는 요청받은 매개변수를 DB에 저장해 프론트엔드로 넘기는 역할만 하고, 실질적인 인증 처리는 프론트엔드와 GraphQL 서버단에서 이루어지도록 하는 것이다. 이렇게 함으로써 인증 Endpoint와 관련된 문제를 간단히 해결했다.

\n

OpenID Connect

\n

이제 OpenID Connect를 구현해야 한다. OpenID Connect는 OAuth2 프로토콜 위에서 동작하는 표준으로, 사용자에 대한 간단한 정보를 얻고 사용자를 인증할 수 있도록 하는 간단한 레이어 표준이다. openid scope를 포함한 OAuth2 Authorization Request를 보냄으로써 시작하며, JSON Web Token 표준을 이용해 사용자를 인증해주는 ID Token을 발급한다.

\n

쉽게 말하면, 내가 Authorization Request를 보낼때 scope에 openid도 포함시키면, 서버에서 액세스 토큰 줄때 ID 토큰도 같이 주고, 이 액세스 토큰과 함께 userinfo Endpoint(후술)에 접근하면 사용자에 관한 정보(닉네임이라던지)도 주는 것에 관한 표준이라는 것이다.

\n

OpenID Connect를 사용하는 목적과 범위을 통합 인증으로만 극한한다면, OpenID Connect Core 표준의 일부분(Section 3.1, Section 5.3, Section 9, 갱신 토큰도 지원한다면 Section 12도)과 OpenID Connect RP-Initiated Logout 1.0 - draft 01만 구현해도 실사용에는 크게 무리가 없다. OpenID Connect Discovery에서 Section 4 (Obtaining OpenID Provider Configuration Information)도 구현하면 서드파티에서 통합 인증을 설정할 때 편하다. OpenID Connect Dynamic Discovery는 전체적인 서비스 운영을 폐쇄적으로 할 생각이라면 굳이 구현할 필요가 없다.

\n

OpenID Connect Core는 다음과 같이 기존 OAuth2 구현을 확장하고 추가적인 userinfo Endpoint와 jwks를 제공하는 Endpoint를 만들어 구현할 수 있다.

\n
    \n
  1. Authorization request 처리시 OpenID Connect Core 스펙에서 정의하는 prompt 매개변수, id_token_hint 매개변수, login_hint 매개변수를 처리하도록 확장한다.\n
      \n
    • prompt 매개변수는 간단하게 prompt=none인데 인증이 안 된 경우라면 무조건 login_required 오류를 반환하도록 구현하면 된다.
    • \n
    • id_token_hint나 login_hint는 id_token_hint와 login_hint에서 가리키는 사용자와 현재 로그인된 사용자가 다르다면 오류를 반환하도록 구현하면 된다.
    • \n
    • state가 있다면 리다이렉트할 때 쿼리 매개변수로 state를 넣어 리다이렉트한다.
    • \n
    \n
  2. \n
  3. 토큰 Endpoint에서 엑세스 토큰을 발급할때 openid scope가 포함됐다면 ID 토큰도 함께 발급한다.\n
      \n
    • ID Token 발급시에는 특별한 경우가 아니라면 RS256 알고리즘으로 서명해야 한다. (OpenID Connect Core 1.0 Section 3.1.3.7. ID Token Validation 문단에서 default of RS256라는 표현으로 명시하고 있음.)
    • \n
    • nonce가 있다면 id_token 발급할 때 nonce를 포함한다.
    • \n
    • state가 있다면 리다이렉트할때 쿼리 매개변수로 state를 넣어 리다이렉트한다.
    • \n
    \n
  4. \n
  5. userinfo endpoint는 액세스 토큰의 scope에 따라 적절한 claim(name이나 nickname 같은 것들)들을 JSON 객체로 반환하도록 구현한다.
  6. \n
  7. jwks를 반환하는 endpoint는 말 그대로 ID Token 발급시 사용하는 jwks의 공개키들을 jwks 형식으로 반환하도록 구현한다.
  8. \n
\n

RP-Initiated Logout도 구현하기로 했다면, 다음과 같은 행동을 하는 HTTP Endpoint를 하나 더 구현해야 된다.

\n
    \n
  1. id_token_hint 있으면 검증하고
  2. \n
  3. 사용자를 로그아웃시킨 뒤
  4. \n
  5. id_token_hint와 post_logout_redirect_uri가 동시에 주어졌다면 post_logout_redirect_uri가 허가된 주소인지 검증하고, 만약 그렇다면 post_logout_redirect_uri로 리다이렉트한다.\n
      \n
    • 동시에가 중요하다. id_token_hint없이 post_logout_redirect_uri가 주어졌다면 무효한 요청이다.
    • \n
    • 요청 매개변수로 state가 주어졌다면 state도 같이 쿼리 매개변수로 붙어서 리다이렉트한다.
    • \n
    \n
  6. \n
\n

RP-Initiated Logout 표준은 OP에서 로그아웃하는 것에 대한 표준이므로, 다른 RP들에 관해서는 언급하고 있지 않다. OP에서 로그아웃할 때 다른 RP들도 로그아웃하는 것을 원한다면 OpenID Connect Session Management 1.0이나 OpenID Connect Back-Channel Logout, OpenID Connect Front-Channel Logout등의 Draft들 중 마음에 드는 것을 골라 구현하거나, 아니면 직접 자신의 방법으로 구현해야 한다.

\n

추가적으로, OpenID Connect Discovery 표준도 구현하기로 했다면, 그냥 적절한 OpenID Provider Configuration Information을 반환하는 /.well-known/openid-configuration HTTP Endpoint를 구현하면 된다. 시간이 남으면 표준에서 제시하는 Webfinger 관련 API도 구현해도 되지만, 사이트 관리를 폐쇄적으로 할 계획이라면 굳이 그럴 필요가 없다.

\n

구현 끝

\n

위와 같이 구현하면 간단한 SSO 기능을 제공하는 사이트를 만들 수 있다. 이렇게 SSO를 제공하는 사이트에 회원관리 기능과 가입신청 받는 기능도 덧붙여서 간단한 동아리 회원관리 사이트를 만들었다.

\n

Nextjs로 SPA를 구현해서 그런가 깔끔하더라. 수정해야 할 게 지금도 좀 있긴 하지만 예전보단 보기 좋아졌다.

\n

그래서 게시판은?

\n

게을려서 아직 안 만들었다. 언젠가 만들지 않을까?\n...농담이고, 사실 만들려고 했다가 '단체채팅방이 있는데 굳이 만들어야 할까?'라는 생각이 들어 만들지 않았다. 그래도 이 홈페이지를 개발하면서 많은 도움이 됐다.

\n

", "url": "https://blog.litehell.info/post/oauth2_and_oidc", "title": "OAuth 2.0과 OpenID Connect Core 1.0", "summary": "동아리 홈페이지와 통합 인증에 관한 이야기", "image": "https://blog.litehell.info/rfc6749_section4.1.1_fig3.svg", "date_modified": "2021-01-25T09:27:00.000Z" }, { "id": "loremipsum", "content_html": "

Lorem ipsum

\n

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean faucibus tristique dolor sit amet pharetra. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nulla non aliquam ligula. Fusce at turpis enim. Praesent nec sagittis sapien, in euismod odio. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Praesent in ultricies lectus. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Morbi eleifend quis ex in tempor. Donec vestibulum, leo id semper tempus, leo lacus semper quam, id tincidunt orci libero sed eros. Nam pretium ante in rutrum rutrum. Donec et bibendum metus. Vivamus tellus ipsum, condimentum in massa nec, egestas bibendum elit. Morbi tortor velit, elementum tincidunt iaculis sed, tristique vel tellus. Cras ultricies sed lorem non faucibus.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
PraesentnecmetusaliquetDuis
accumsanrisusquissodalesdignissim
purusClassaptenttacitipulvinar
sociosquadlitoratorquentelit
perconubianostraperin
\n

Nulla pharetra

\n

lectus quis sapien tempor venenatis. Maecenas bibendum aliquam diam, sit amet semper massa sagittis a. Ut cursus fermentum erat, ac molestie orci. Fusce id enim bibendum, sollicitudin lacus nec, facilisis magna. Etiam volutpat maximus lorem condimentum varius. Cras suscipit pellentesque odio, quis vestibulum quam tristique eu. Pellentesque vulputate, neque sed ultrices blandit, urna velit facilisis mi, in dignissim tortor neque ac velit. Praesent laoreet facilisis augue, quis fringilla velit. Cras vel maximus quam, nec tempor lacus. Sed non dictum lectus. Nunc interdum nisl ante, nec condimentum turpis elementum non. Cras euismod massa nec nisi ultricies faucibus. Aliquam erat volutpat.

\n
console.log('Hello, world! Hello, world! Hello, world! Hello, world! Hello, world! Hello, world! Hello, world! Hello, world! Hello, world! Hello, world! Hello, world!')\n
\n

Donec quis ullamcorper nisi

\n

Mauris scelerisque finibus dui, eu ultricies lorem ultricies vitae. Vivamus ante ante, vestibulum ac venenatis eget, iaculis nec enim. Sed laoreet augue blandit rutrum finibus. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Aliquam scelerisque, sapien non mollis tempus, diam metus congue metus, eget elementum lorem nisi non ex. Fusce interdum tortor ligula. Quisque sed iaculis dui, sed aliquet ante. Donec commodo non purus a tristique. In non mi augue. Sed sollicitudin odio neque, et tincidunt lectus pulvinar et. Curabitur lorem dolor, malesuada eu efficitur ut, pulvinar eu nunc.

\n
    \n
  1. Phasellus elementum sem in mauris tincidunt iaculis.
  2. \n
  3. Donec pellentesque purus non est congue malesuada.
  4. \n
  5. Fusce pretium elit sed facilisis commodo.
  6. \n
\n

Phasellus

\n

molestie risus vitae lorem consequat, sed suscipit neque posuere. In fringilla tincidunt ante. Interdum et malesuada fames ac ante ipsum primis in faucibus. In maximus at odio eget tempus. Integer finibus neque massa, sit amet gravida est hendrerit quis. Vivamus ac orci mollis, porta magna non, viverra massa. Ut varius ex metus, sit amet mollis tortor lobortis id. Aliquam gravida blandit turpis, a egestas enim volutpat ut.

\n\n
Sed et auctor ex
\n

Nullam ut finibus nunc. Proin elementum nisl eu dui hendrerit, vel varius neque egestas. Nunc elit neque, mattis vel lectus ut, commodo eleifend massa. Ut et lorem vestibulum, sodales erat pharetra, viverra mauris. Praesent fermentum pharetra odio in aliquet. Pellentesque nibh turpis, iaculis porttitor finibus a, vestibulum ac nisl. Aliquam vitae fringilla enim. Pellentesque elementum mi sem, at lobortis nisl euismod non. Fusce id fermentum diam. Mauris egestas pellentesque elit. Ut interdum massa eu nisl finibus, quis mollis tortor mattis. In aliquet congue augue, vitae fringilla libero vestibulum sit amet. Suspendisse id ante tincidunt, sollicitudin tellus a, tristique odio. Praesent vel pellentesque sapien, quis aliquet lectus.

\n
\n

Mauris erat neque, feugiat sit amet felis a, sagittis feugiat turpis. Curabitur quis urna nec risus iaculis tincidunt. Quisque elit nibh, sodales vitae ante a, luctus efficitur est. Sed diam dolor, malesuada id aliquam eget, pellentesque id mauris. Aliquam quis diam ut velit euismod viverra. Pellentesque risus orci, sodales vel velit ac, vestibulum rhoncus magna. Morbi laoreet arcu et lectus tristique, sit amet posuere augue semper. Nunc suscipit, diam non semper rutrum, tellus orci fermentum tortor, in luctus metus eros vitae augue.\nFusce aliquam sit amet ligula ut auctor. Cras dictum magna ac volutpat consectetur. Donec ornare nulla ac mi auctor, varius posuere leo malesuada. Integer faucibus nisi at velit ultricies auctor quis in urna. Donec accumsan porttitor tortor, eget semper ex. Cras at volutpat nisi, in vulputate augue. Nam ultrices tincidunt sapien vitae finibus. Etiam urna sem, vestibulum sit amet erat quis, accumsan condimentum turpis.

\n
\n

Cras ac orci ac ligula feugiat condimentum eu quis mi. Nullam libero dolor, venenatis sed diam non, cursus aliquam massa. Integer vel neque eget est imperdiet molestie ut a risus. Ut a arcu est. Mauris non odio nunc. Fusce id augue dolor. Curabitur eget magna risus.

\n
Mauris iaculis,
\n

quam vitae mollis convallis, mi urna pharetra dolor, hendrerit varius dui lacus ac elit. Donec maximus lacus id ullamcorper posuere. Nullam tincidunt venenatis eleifend. Integer consequat hendrerit metus non condimentum. Sed id nibh quam. Curabitur fermentum lacus ut velit maximus, sed aliquet dui elementum. Quisque ac maximus risus. Etiam fringilla ipsum leo, ut finibus justo venenatis id. Nulla id dapibus purus. Nullam rhoncus leo et enim gravida eleifend. Suspendisse arcu ipsum, venenatis sed luctus at, aliquet at turpis. Ut et ipsum a nunc ornare eleifend eu eu elit. Fusce fermentum mi sed ante placerat, sed placerat massa placerat. Vestibulum vulputate, felis eu gravida ultricies, ante metus tincidunt orci, eget volutpat erat purus id arcu. Integer vehicula ultricies varius. Donec lacinia ultricies nibh sit amet varius.\n\"Cloud\"

\n

Aequare vertar Victoria suos iugum tu nec

\n

Lorem markdownum attonitum quid. Sed diluvio, distulit facietque nabat\nmedicamine prohibebere dubitat dant viribus Neptunus audieris?

\n
if (rawBiosDual) {\n    java += realCmykStorage;\n    payload_dynamic_markup = rosetta;\n    linux *= home - lte_flash_flops(animated_lcd_cycle, degaussPeopleware,\n            tftp_mode_mode);\n}\nif (character) {\n    cardData = spamCad;\n    reader.transfer_optical_program = 213527;\n    token_nntp += dropUddiPerl.hit(editor.hostBoot(leopardUnmountBcc, disk),\n            4, 781471);\n}\ndigital_pci_install += pharmingClipboardMemory;\nfile_cable.flops_wep(encodingIsaUps, 3, topologyVersion);\n
\n

Illi exitium sumere succedit

\n

Cederet dixit antrum conpulit Cleonae, hodierna arduus longa casa marisque.\nTalia custos fidem costis, enim, sic modo novercae reperire positamque agmen\nprotinus! Aevumque ipsum caelitibus sors quinque amor; nec se mea solita credere\nlumina leto corpore. Conorque subito, ducere prius aedificat deduxit inbellibus\nsolet habet superest genu recepta?

\n

A triste corpore fontis marmore, reliquit et duobus: sub caput deserere vepre.\nNostra fiat fugiens in latet texitur heros vidi conubia! Ubi utile carissima\nvindicet gente plaustri illic, tyranno sit exercet tamen iamque.

\n

Superos si securus delicta exemplumque tamen inridet

\n

Magni caede ad Euagrus dum quod, senioribus, alimenta. Potui et vero divae\nimagine moenia eventusque quondam volumine vires tamen. Nubigenasque sibi:\nnumina nostroque lanugine metuens repono\nnotissima a precari possumus eripiat meae, sed?\nAliena et oreris, die faciunt erat mortales manusque colorem cuspide omnia et.\nGradive nec templo sati: quarum dedimus saevarum versi novissima quoque et\niuvenaliter confundimur placidissime longa cantusque.

\n

Ferox nec sono resistite capax incubuit et

\n

Repulsa praedivite urbemque tuae: moenibus animorum navita cum, ingreditur\nmutabile. Iactatam novat ignoscite puppim. Bene animi diva geminato. Amissa\nconscendunt dubitabat, paro hunc sensit, obstipuit Xanthique quoque guttis, nec\ncapillos!

\n

Adiacet hoc opem tepido fecerat hi videri oculis latrantibus meum: velatus\nrestabat. Uno caedit succurritis procul: quae viribus nomina ab funeribus forma;\nvulnera populos. Umerique accipe; arvo cuius tulit rapiunt haec; sensit tincta\nmarisque.

", "url": "https://blog.litehell.info/post/loremipsum", "title": "Lorem ipsum", "summary": "블로그 테스트를 위한 Lorem ipsum", "image": "https://blog.litehell.info/cloud.jpg", "date_modified": "2020-01-23T17:36:00.000Z" } ] }