KUR Creative


Blog프로젝트

블로그 프로젝트를 위해 작성한 How to Solve it 식 설계 표현

설계 표현에 대해서 개삽질을 하던 때가 있었다. 그런 무수한 삽질로부터 얻은 결론은?

혼자 하는 프로젝트면

  1. 기본적으로 처음에는 설계를 작성하지 마라
  2. 전체 설계를 작성할 생각이 들었다면 작성하지 마라
  3. 코드로 1:1 매핑되는 구체적인 설계(사실 설계가 아니다)는 A4에 쓰고 코드를 짠 뒤 버린다

왜냐면 처음 할 때는 아무리 뻔한 프로젝트라도 설계가 미친듯이 변하기 마련이라 그렇다.
PoC를 하면서 어느 정도 안정이 되어갈 때 쯤 아주 아주 조금씩 설계 표현을 작성해야 한다.

진짜 필요할 때쯤

  1. 3번 이상 다시 생각해보고 정말 필요하면 하나의 문제에 대해서만 작성한다
  2. 전체에 대한 설계는 가능하면 만들지 않는다. 굳이 만든다면 그림 한 장 정도로 땡 친다.

그래서 블로그 프로젝트를 할 때, 정말로 까다로운 문제에 대해서만 How to Solve it 식으로 설계 표현을 작성했다.

How to Solve it 식 설계 표현

다음은 블로그 프로젝트를 하면서 작성한 설계 표현(문제 시트)들이다. 날것 그대로 가져 왔다.


무엇을 만들고 무엇을 leverage할 것인가?

미지 Unknowns
1 무엇을 만들 것인가?
2 무엇을 leverage할 것인가?

조건 Conditions
네이버 블로그 서비스 수준의 편의성
1 기기 Sync: 컴, 폰에서 간단히 글 쓰고 조작할 수 있어야 한다
2 md로 작성한 글을 웹으로 수월하게 보여줄 수 있어야 한다
3 md를 원하는 형태로 변경할 수 있어야 한다

4 포스트 히스토리를 저장할 수 있다
5 SyncThing에 의해 발생하는 찌꺼기 파일(conflict..)은 사용자에게 보이지 않아야 한다

사실 Facts

물음 Questions
AWS Lambda로 nginx(Publisher)를 대신할 수 있을까? 혹은 그 외 저렴하고 안정적인 정적 서빙 서비스는?
Syncthing을 클라우드에서 더 저렴하게 사용하려면?

해답
poc0
Syncthing, Obsidian을 leverage -> C1

poc1
Publishable 도입

poc2 (maybe 0.0.1)
md를 html로 바꾸는 모듈(Updater)만 만든다 -> C3
updater에서 html 폴더가 업데이트될 때마다 html 폴더와 md 폴더를 깃 커밋 하여 히스토리 관리하기

날로 먹는 것

만드는 것


가장 중요한 문제이자 내 블로그의 가장 독특한 특징 - 날로먹기 에 대한 문제 시트다.
마지막 해답인 poc2를 보면 결국 죄다 날로 먹고 직접 만드는 건 마크다운 파서와 상태 관리 로직이 끝이다.

이렇게 날로 먹기 때문에 짧은 시간 안에 매우 품질(성능, 확장성, 등)이 좋은 프로그램을 만들 수 있었다.

그리고 PoC는 실제로 프로그램을 구현하고 피드백을 받아본 것이다. 설계 변경이 2번 있고 나니 poc2가 되었고, 23년 현재 블로그 업데이터는 poc2를 그대로 쓰고 있다.


배포

미지 Unknowns
U1 배포 전략?

조건 Conditions
C1 항상 https 연결이 가능해야 한다
2 보안 - 뚫기 어렵고 털려도 피해가 없어야 한다
3 간단함 - 방법을 까먹어도 간단히 처리할 수 있고 다시 보면 알 수 있어야 한다
4 자동화 - 간단한 명령으로 배포를 수행할 수 있다

5 SyncThing을 처리할 수 있어야 한다
6 Lightsail에서 작동해야 한다

7 https로 볼 수 있어야 한다
8 유지 비용은 쌀수록 좋다

사실 Facts
certbot을 쓰면 간단히 https를 띄울 수 있다

f1 github action을 쓰면 yaml에 aws콘솔을 쓰는 방식으로 ls 배포를 자동화할 수 있다: https://brewagebear.github.io/ligthsail-ci-cd-setup/

f2 nginx는 워커 프로세스(서버) 여럿을 만들 수 있다


이건 해답을 미지 바로 옆에다 써버려서 딱히 해답 섹션이 없다. 그래도 된다. 형식은 중요하지 않다..


poc2 Updater

전제조건 <- #무엇을-만들고-무엇을-leverage할-것인가?

미지 Unknowns
1 코드의 구조
2 페이지들을 다형성으로 묶는다/묶지 않는다
3 데이터 처리 공정

조건 Conditions
1 md가 삭제되면 변환 결과인 html도 삭제되어야 함 <- F1
2 post가 private이 되어도 변환 결과인 html은 삭제되어야 함

3 고쳐야 하는 코드를 찾기 쉬워야 한다
3a 각각 하나의 페이지와 관련된 로직은 각각 한 곳에 모여있어야 함
3b 여러 페이지나 로직이 공유하는 로직은 공유 ns에 모여있어야 함

사실 Facts
0 정적 파일 서빙에 의존하므로, updater만 만들면 된다
1 서빙되는 정적 파일이 존재 하는 것이 public이다
2 페이지마다 업데이트 조건이 다르다
3 차후 글에 tag를 달고, tag별로 모아 보는 페이지를 넣을 수 있다

4 어떤 페이지(home, tags)들은 다른 페이지(post)에 의존한다

해답
U2: 안 쓴다!

U3: md-dir => posts,happends -> site => html-dir


이 문제 시트는 위 문제: #무엇을-만들고-무엇을-leverage할-것인가?의 해답에 의존적이다. 이 문제는 leverage 문제가 결국 다른 해답을 내놓게 되면 아무런 쓸모가 없어질 수 있다. 그런 낭비에 대비해야하기 때문에, 설계 표현은 간결해야 하고, 되도록 그냥 안 쓰는 게 낫고.. 정말 필요할 때만 써야 한다.

U1 코드 구조에 대해서는 아예 해답을 작성하지 않았다. 코드 구조처럼 구체적이고 변경이 많은 건 사실 미지로도 넣지 않는 것이 좋다.

이 문서에서는 실제로 여러 해답 간의 트레이드 오프를 보여준다. home, archive 등의 페이지 엔티티가 상속하는 Publishable 프로토콜을 추가할 것인가? 그러지 않을 것인가?
갓-바와 OOP에 뇌가 절여진 사람이라면 묻지도 따지지도 않고 Publishable 인터페이스를 추가하고 각 페이지를 인터페이스 구체화를 통해 구현했을 것이다. 하지만 나는 poc1에서 그거 추가했었는데 별로 재미를 못 봤었다.
그런 경험과 그 외의 지식까지 곁들여서 트레이드 오프를 해 보았고, 결국 Publishable 없이 구현하기로 결정한다.(문제 시트에 결국 무엇을 결정했는지는 나오지 않는다. 그런 거 필요 없다!)

설계 그대로 프로그래밍하기

U2와 U3의 결정이 합쳐져, 매우 깔끔하고 확장하기 쉬운 유연한 구조로 사이트를 구현할 수 있었다.

src/clj/kur/blog/updater.clj

(defn classify-posts
  "Return post groups classified by file system change(delete, write, as-is)
   NOTE: unchanged(post)s inherit loaded text from old-posts."
  [old-posts now-posts]
  (let [mergeds
        (vec (post-diff/merge-and-assoc-happened old-posts now-posts))

        {unchangeds true changeds false}
        (group-by #(= ::post-diff/as-is (:happened %)) mergeds)

        {to-deletes ::post/delete!, to-writes ::post/write!}
        (group-by post/how-update-html changeds)

        map-rm-hap (fn [posts] (map #(dissoc % :happened) posts))]
    {:unchangeds (map-rm-hap unchangeds)
     :to-deletes (map-rm-hap to-deletes)
     :to-writes (map-rm-hap to-writes)}))

(defn site
  "Return commands to maintain html files of site
   commands are [[f & args]*]"
  [unchanged-posts post-to-delete loaded-posts-to-write html-dir]
  (let [public-posts (sort-by :id #(compare %2 %1)
                              (concat unchanged-posts
                                      loaded-posts-to-write))
        html-path #(str (fs/path html-dir %))]
    (concat
     (map (fn [post]
            [spit (html-path (post/html-file-name post))
             (look-post/html (post/title-or-id post) (:text post))])
          (remove policy/admin-post? loaded-posts-to-write))
     [[spit (html-path "404.html") (look-error/page-404 public-posts)]
      [spit (html-path "50x.html") (look-error/page-50x public-posts)]
      [spit (html-path "home.html") (look-home/html public-posts)]
      [spit (html-path "archive.html") (look-archive/html public-posts)]
      [spit (html-path "subscribe.html") (look-subscribe/html)]
      [spit (html-path "tags.html") (look-tags/html public-posts)]
      [spit (html-path "guests.html") (look-guests/html)]]
     (map (fn [post]
            [fs/delete-if-exists (html-path (post/html-file-name post))])
          post-to-delete))))

(defn update! [site]
  (run! (fn [[f & args]] (apply f args)) site))

classify-posts는 블로그의 유일한 상태인 post 집합의 현재와 과거 스냅샷을 비교하여 변경이 없는 것, 삭제할 것, 수정/생성할 것으로 나눈다.

site 함수에서는 이렇게 분류한 post들을 받는다. site는 이들(xxx-posts 등)을 받아서 html 파일(들)을 만드는 방법을 정의한다. 그 방법은 update!에서 처리할 수 있는 DSL(함수와 인자를 담은 벡터의 시퀀스)로 표현된다.

$ tree src
src
├── clj
│   └── kur
│       ├── blog
│       │   ├── look
│       │   │   ├── archive.clj
│       │   │   ├── error.clj
│       │   │   ├── guests.clj
│       │   │   ├── home.clj
│       │   │   ├── post.clj
│       │   │   ├── subscribe.clj
│       │   │   ├── tags.clj
│       │   │   └── template.clj
│       │   ├── main.clj
│       │   ├── monitor.clj
│       │   ├── obsidian
│       │   │   ├── frontmatter.clj
│       │   │   └── tag.clj
│       │   ├── page
│       │   │   ├── post
│       │   │   │   ├── diff.clj
│       │   │   │   ├── md2x.clj
│       │   │   │   └── name.clj
│       │   │   ├── post.clj
│       │   │   └── tags.clj
│       │   ├── policy.clj
│       │   ├── state.clj
│       │   └── updater.clj

home, archive 등 각각의 데이터와 페이지의 로직은 각각 pagelook 네임스페이스에서 정의한다. 예상했듯이, 이것들을 하나의 Publishable 프로토콜(인터페이스)로 묶을 이유가 전혀 없다.

src/clj/kur/blog/main.clj

(defn -main [config-path]
  (let [{:keys [md-dir html-dir fs-wait-ms]
         :as config} (load-config :file config-path)
        state (atom (state/initial))
        update! #(state/update! state (state/current @state config))]

    ;; Initialize
    (uf/delete-all-except-gitkeep html-dir)
    (update!)

    ;; Monitor and Update
    (monitor/monitor update! fs-wait-ms md-dir)))

이 모든 과정에서 사이드 이펙트는 배제되어 있으며, 최종적으로 mainmonitor가 호출하는 update!에서 사이드 이펙트를 일으켜 html을 생성/수정/삭제한다.

결론

(개인) 프로젝트를 위한 설계표현은 무조건 간결해야 한다.
내 오랜 개인 프로젝트 경험 상 간결하지 않은 설계 표현은 없느니만 못하다.

얼마나 간결해야 하는가? 미래의 내가 알아볼 수 있을 정도면 충분하다.
개인 프로젝트의 협업자는 과거, 현재, 미래의 나이기 때문이다.


그렇다면 여럿이서 할 때는? 그건 좀 더 해보고 이야기 해 줄게 ㅎㅎ

#proj프로젝트#sw-design설계#problem-solving문제해결
이전글Blog프로젝트다음글
kur2303062124Archivekur2303160753