블로그 프로젝트를 위해 작성한 How to Solve it 식 설계 표현
설계 표현에 대해서 개삽질을 하던 때가 있었다. 그런 무수한 삽질로부터 얻은 결론은?
혼자 하는 프로젝트면
- 기본적으로 처음에는 설계를 작성하지 마라
- 전체 설계를 작성할 생각이 들었다면 작성하지 마라
- 코드로 1:1 매핑되는 구체적인 설계(사실 설계가 아니다)는 A4에 쓰고 코드를 짠 뒤 버린다
왜냐면 처음 할 때는 아무리 뻔한 프로젝트라도 설계가 미친듯이 변하기 마련이라 그렇다.
PoC를 하면서 어느 정도 안정이 되어갈 때 쯤 아주 아주 조금씩 설계 표현을 작성해야 한다.
진짜 필요할 때쯤
- 3번 이상 다시 생각해보고 정말 필요하면 하나의 문제에 대해서만 작성한다
- 전체에 대한 설계는 가능하면 만들지 않는다. 굳이 만든다면 그림 한 장 정도로 땡 친다.
그래서 블로그 프로젝트를 할 때, 정말로 까다로운 문제에 대해서만 How to Solve it 식으로 설계 표현을 작성했다.
How to Solve it 식 설계 표현
- 문제를 분석하고, 문제의 요소를 미지/조건/사실 로 나눈다.
- 마지막으로 해답을 여러개 넣고 트레이드오프해 본다.
- 각 문장에 대한 인덱스(문장 시작 부분의 숫자 등)는 ref가 필요하면 넣는다. (안 넣어도 된다)
- 그 외에 걍 대충 알아서 해라. 중요한 건 간결함이지 형식이 아니다.
오직 간결한 설계 표현만이 살아 남는다.
다음은 블로그 프로젝트를 하면서 작성한 설계 표현(문제 시트)들이다. 날것 그대로 가져 왔다.
무엇을 만들고 무엇을 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 -> C1poc1
Publishable 도입poc2 (maybe 0.0.1)
md를 html로 바꾸는 모듈(Updater)만 만든다 -> C3
updater에서 html 폴더가 업데이트될 때마다 html 폴더와 md 폴더를 깃 커밋 하여 히스토리 관리하기날로 먹는 것
- 에디터: Obsidian
- 데이터 Sync: Syncthing
- 프론트엔드 서버: Nginx 정적 파일 서빙에 의존 -> C2
만드는 것
- md를 html로 바꾸는 변환기(converter) -> C3
- Sync되는 md, 리소스 폴더로부터 변환한 html 폴더 관리(updater, C/R/U/D)
가장 중요한 문제이자 내 블로그의 가장 독특한 특징 - 날로먹기 에 대한 문제 시트다.
마지막 해답인 poc2를 보면 결국 죄다 날로 먹고 직접 만드는 건 마크다운 파서와 상태 관리 로직이 끝이다.
이렇게 날로 먹기 때문에 짧은 시간 안에 매우 품질(성능, 확장성, 등)이 좋은 프로그램을 만들 수 있었다.
그리고 PoC는 실제로 프로그램을 구현하고 피드백을 받아본 것이다. 설계 변경이 2번 있고 나니 poc2가 되었고, 23년 현재 블로그 업데이터는 poc2를 그대로 쓰고 있다.
배포
미지 Unknowns
U1 배포 전략?
- U1a 배포? - nginx(https://github.com/nginx-clojure/nginx-clojure) -> nginx에서 https하는 법?
- U1b 자동화? - github action(애초에 private이라..)
- c deps.edn 사용법? (uberjar 생성, dev/test deps 격리 등등)
조건 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/
- 보아 하니 터미널이 있고 yaml에 명령어를 적는 방식이라 못하는 게 없어 보인다
- release가 있을 때만 작동하게 할 수 있나? 가능
f2 nginx는 워커 프로세스(서버) 여럿을 만들 수 있다
- nginx 밖에서 업데이터(파일 수정) 프로세스를 생성해야 한다.
- 업데이터 disable/enable할 수 있거나 jar를 따로 만들거나.
이건 해답을 미지 바로 옆에다 써버려서 딱히 해답 섹션이 없다. 그래도 된다. 형식은 중요하지 않다..
poc2 Updater
전제조건 <- #무엇을-만들고-무엇을-leverage할-것인가?
- md -> html 변환기와 C/R/U/D 업데이터만 만든다
- 정적 파일 서빙에 의존한다
미지 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: 안 쓴다!
- 안쓴다 근거
- 인터페이스가 동일하지도 않다(post 외에는 state를 볼 뿐 경로는 필요 없다)
- post를 제외한 페이지들은 다른 페이지에 의존적이어서 write 순서가 중요해질 수 있다
- state가 오로지 post로만 구성된다면 Page 엔티티가 딱히 필요하지 않다: state에 모든 Page를 모아둘 필요가 없다.
- 다형성으로 묶을 경우 유연성이 떨어질 수 있다. state가 Record로 이뤄진다는 속박에서 벗어나니 코드가 더 자유롭다.
- 쓴다 근거
- 페이지라는 공통점, 엔티티 = 페이지 하나
- 공통적으로 write? write! 가 필요
- 코드가 op로 구분되어 있어 보기 편함
U3: md-dir => posts,happends -> site => html-dir
- poc1과 차이점
- posts: post만 포함한다.
- site: posts 외 다른 페이지까지 포함(아마 seq) html-dir에 write!
- post의 C/R/U/D는 old, new state와 happend를 보고 판단
- poc1과 공통점
- 기존 state처럼 posts는 md-dir 변경 시 매번 생성(happend도)
이 문제 시트는 위 문제: #무엇을-만들고-무엇을-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 등 각각의 데이터와 페이지의 로직은 각각 page
와 look
네임스페이스에서 정의한다. 예상했듯이, 이것들을 하나의 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)))
이 모든 과정에서 사이드 이펙트는 배제되어 있으며, 최종적으로 main
과 monitor
가 호출하는 update!
에서 사이드 이펙트를 일으켜 html을 생성/수정/삭제한다.
결론
(개인) 프로젝트를 위한 설계표현은 무조건 간결해야 한다.
내 오랜 개인 프로젝트 경험 상 간결하지 않은 설계 표현은 없느니만 못하다.
얼마나 간결해야 하는가? 미래의 내가 알아볼 수 있을 정도면 충분하다.
개인 프로젝트의 협업자는 과거, 현재, 미래의 나이기 때문이다.
그렇다면 여럿이서 할 때는? 그건 좀 더 해보고 이야기 해 줄게 ㅎㅎ