-
Notifications
You must be signed in to change notification settings - Fork 47
[5팀 진재윤] Chapter3-2. 디자인 패턴과 함수형 프로그래밍 그리고 상태 관리 설계 #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jy0813
wants to merge
62
commits into
hanghae-plus:main
Choose a base branch
from
jy0813:main
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
배포링크
과제의 핵심취지
과제에서 꼭 알아가길 바라는 점
기본과제
Component에서 비즈니스 로직을 분리하기
비즈니스 로직에서 특정 엔티티만 다루는 계산을 분리하기
뷰데이터와 엔티티데이터의 분리에 대한 이해
entities -> features -> UI 계층에 대한 이해
Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요?
주어진 hook의 책임에 맞도록 코드가 분리가 되었나요?
계산함수는 순수함수로 작성이 되었나요?
Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요?
주어진 hook의 책임에 맞도록 코드가 분리가 되었나요?
계산함수는 순수함수로 작성이 되었나요?
특정 Entitiy만 다루는 함수는 분리되어 있나요?
특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요?
데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요?
심화과제
이번 심화과제는 Context나 Jotai를 사용해서 Props drilling을 없애는 것입니다.
어떤 props는 남겨야 하는지, 어떤 props는 제거해야 하는지에 대한 기준을 세워보세요.
Context나 Jotai를 사용하여 상태를 관리하는 방법을 익히고, 이를 통해 컴포넌트 간의 데이터 전달을 효율적으로 처리할 수 있습니다.
Context나 Jotai를 사용해서 전역상태관리를 구축했나요? (Zustand 사용)
전역상태관리를 통해 domain custom hook을 적절하게 리팩토링 했나요?
도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거했나요?
전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드가 되었나요?
과제 셀프회고
회고에 앞서, FSD 아키텍처 개념을 적용하여 과제를 진행한 구조 입니다! 모든 구조를 다 적기에는 PR 이 너무 방대해질거같아 계층 구조와 폴더 구조만 정리하였습니다.
레이어 계층 구조
Basic 구조
Advanced 구조 (Basic + Zustand)
이 과제를 시작할 때 저는 FSD 아키텍처가 뭔지도 제대로 몰랐습니다. 그냥 폴더 구조를 잘 나누면 되겠지 라는 안일한 생각으로 시작했습니다.그런데 막상 1100줄짜리
App.tsx를 마주하니, 어디서부터 손을 대야 할지 막막했습니다. 어디서부터 분리해야 하지?FSD 문서를 읽어보니 레이어가 6개나 있었습니다.
app,pages,widgets,features,entities,shared, 각각의 역할은 대충 이해했는데, 문제는 뭘 먼저 분리해야 하는가였습니다. (Processes 도 있었다는데 더 이상 사용되지 않다고 봤습니다!)처음엔 shared부터 건드렸습니다. 항상 회사에서도 프로젝트를 시작하면 공통 UI 부터 만들었기에 공통으로 쓰는 것부터 빼면 나머지가 명확해지겠지 라는 논리였습니다.
Button,Input,Select같은 UI 컴포넌트들을shared/ui로 옮기고,formatCurrency같은 유틸 함수를shared/lib로 옮겼습니다.그리고 이 때
shared/lib을 나누면서 순수함수에 대해서도 처음 생각 해본건데 아래와 같은 재밌는 얘기도 나눠서 좋았습니다.다시 돌아와서,
shared를 정의하고 이제 막상 해보니App.tsx에서 갈아 끼울 만한 게 별로 없었습니다.shared는 말 그대로 도구 모음이지, 비즈니스 로직이 아니었습니다.Button을 아무리 예쁘게 분리해도, 1100줄짜리 스파게티는 그대로였습니다. 방향이 틀렸다는 걸 깨달았고, 처음에는refactoring(hint)폴더에 있는걸 보고 그 구조 그대로 그냥 따라하자! 엔티티를 알고 모델을 알고 이런거 알면 그냥 이번 과제에서 원하는건 가져가는거 아니야? 라는 생각으로 만들어놓은shared를 지우고..막상 또refactoring(hint)를 하려다보니... 어차피 이것도 결국은 힌트가 있더라도 다 알고 나누고 적용해야하는거면 그냥 원래대로 FSD 아키텍쳐를 적용해보는게 좋은거 아닌가..?라는 생각이 들었습니다. 그래서 접근을 바꿨습니다. 이 앱에서 관리하는 상태가 뭐지? 부터 정리했습니다.
상태(State)를 기준으로 다시 생각하기
상태를 나열하고 보니, 이게 곧 도메인이었습니다.
FSD에서entities가 도메인 핵심 데이터를 다룬다고 했는데, 바로 이거였고. 상품, 장바구니, 쿠폰. 각각이 독립적인 데이터 덩어리이고, 이걸 중심으로 폴더를 나누면 되겠구나 싶었습니다.여기서 첫 번째 기준이 생겼습니다. 상태가 곧 도메인이고, 도메인이 곧
entities폴더의 이름이 된다.그런데
entities안에 뭘 넣어야 하지?entities/cart폴더를 만들긴 했는데, 이 안에 뭘 넣어야 할지 또 헷갈렸습니다.useCart hook? CartItem 컴포넌트? getCartTotal 함수? 전부 장바구니 관련이긴 한데, 성격이 다 달라서 힘들었습니다...
FSD문서에서세그먼트(segment)라는 개념을 봤습니다.model,ui,api. 이걸 보고 나름의 기준을model,lib,ui,config로 정했습니다.근데 솔직히 이 기준도 처음부터 명확했던 건 아니었습니다.
createProduct()같은 팩토리 함수는 lib일까 model일까?한참 고민하다가, 데이터의 탄생을 다루니까
model이라고 결론을 내리고 정답인지는 모르겠지만, 일관된 기준을 세우고 그걸 따르는 게 중요하다고 생각했습니다.가장 혼란스러웠던 건
entities와features의 경계였고, 둘 다 비즈니스 로직을 다루는 것 같은데, 뭐가 다른 거지?addProduct,removeProduct같은 함수가 대표적이었습니다.상품을 추가하는 건 기능(feature)처럼 느껴지고, 동시에 상품 배열을 조작하는 데이터 로직이라는 생각이 들었습니다.
고민 끝에 저희 5팀 대장 수범님의 리얼월드 비유 방식이 상당히 좋아보여서 ai에게 비유를 하나 만들어달라하니,
Entity는 냉장고,Feature는 요리사라고 바로 역할을 분담해주더니 설명해줬습니다.냉장고는 누가 문을 열든 상관없이 재료를 보관하고 꺼내주는 역할만 하고 닭고기 넣어줘, 우유 꺼내줘 같은 요청에 응답할 뿐, 그걸로 뭘 만들지는 관심 없고, 반면 요리사는 냉장고에서 재료를 꺼내 실제로 요리를 완성합니다.
사용자가 저녁 뭐 먹지? 라고 물으면 그에 맞는 시나리오를 실행한다는 리얼월드의 비유가 생기니까 애매했던 것들이 제자리를 찾아갔습니다.
폴더 구조를 어느 정도 잡고 나니, 다음 문제가 나타났습니다.
onShowToast함수를 연결하려는데,App에서 만든 함수가 버튼까지 가려면 4단계를 거쳐야 하는데 기본과제인basic에서 전역상태 관리를 제한해놓으니 중간에 있는ShopPage와ProductListWidget은 이 함수를 쓰지도 않는데 그냥 아래로 던지기만하고 전달만 하는 역할을 하고 있었습니다.그래도 심화과제인
advanced에서는 전역상태 관리를 사용 가능하여,Toast처럼 앱 어디서든 호출되어야 하는 것이나Cart처럼 여러 페이지에서 공유하는 것들은 전역상태를 적용가능했습니다.searchTerm전역상태 관리를 할까하다가 검색어는HeaderWidget에서 입력하고ShopPage안의ProductListWidget에서 사용하는데, 둘이 직접 데이터를 주고받을 수 없으니까, 공통으로 감싸는App에서searchTerm상태를 관리하고 각각에Props로 내려주는 방식을 썼습니다.이것도 그냥 전역으로 올리면 편하지 않나? 싶기도 했지만, 검색어는 쇼핑 페이지에서만 의미 있는 값이니까 굳이 전역일 필요가 없다고 판단했습니다. 그래서 3단계 이내의 전달은 허용, 그 이상이면 전역상태로 만들었습니다. 아마 이게 주요 학습 목표중 하나인
Props의 명시성과 전역 상태 관리의 편의성 사이의 균형점 찾기 라고 생각했습니다.zustand로Store를 만들고 나서 또 하나 고민이었던게 있었습니다.컴포넌트에서
Store를 직접 import할 것인가, 아니면Hook으로 감쌀 것인가. 처음엔 그냥Store바로 쓰면 코드가 짧은데 라고 생각했는데FSD원칙을 다시 읽어보니,Store는shared레이어에 있고Feature/Widget은 그보다 상위 레이어이고,상위 레이어가 하위 레이어를 의존하는 건 맞지만, 구현 세부사항에 직접 의존하면 나중에 바꾸기 어려워진다는 걸 보고
entities에Domain Hook에Store를 감싸서 사용하였습니다. 컴포넌트는useCart()만 알면 되고 내부에서zustand를 쓰든Jotai를 쓰든 신경 쓸 필요가 없고 나중에 상태 관리 라이브러리를 바꾸더라도Hook내부만 수정하면 되니 편리해진 코드가 되었습니다.돌아보며 이 과제에서 가장 많이 성장한 부분은 역시 어느 과제와 마찬가지로 코딩 실력이 아니라 왜? 라고 묻는 습관이었습니다. 왜 이 함수는 여기 있어야 하지? 왜 이건 전역이어야 하고 저건
props여야 하지? 왜 이 레이어는 저 레이어를 import하면 안 되지?처음엔 남들이 좋다니까, 그렇게 하라고였던 것들이, 직접 삽질하고 나니 아, 이래서 그랬구나가 되었습니다.
과제를 하면서 내가 알게된 점, 좋았던 점은 무엇인가요?
분리의 시작점은 "상태"였다.
처음엔
shared폴더부터 정리하면 될 줄 알았는데, 그건 도구 정리였지 구조 설계가 아니었습니다. 진짜 분리는 이 앱이 관리하는 상태가 뭐지? 라는 질문에서 시작해야 했습니다.상태를 나열하니 도메인이 보였고, 도메인을 중심으로 폴더를 나누니 자연스럽게
FSD구조가 잡혔습니다.entities/cart,entities/product,entities/coupon각각이 독립적인 데이터 영역이 되었습니다.멘토링 시간에 과제에 대해서 어떻게 분리하는지 보여주신 준일코치님과 해당 멘토링을 보고 공유해주신 2팀 나리님 감사합니다!
비유가 이해를 돕는다
냉장고(Entity)와 요리사(Feature) 비유를 만들고 나서 경계가 명확해졌습니다.
추상적인 개념을 익숙한 것에 대입하니까, 이건 냉장고 역할이야 하고 바로 판단이 됐습니다.
의존성 방향도 마찬가지였습니다. 상품은 장바구니 없이 존재할 수 있지만, 장바구니는 상품 없이 존재할 수 없다. 이 한 문장으로
getRemainingStock이 왜Cart도메인에 있어야 하는지 설명할 수 있게 되었습니다.우리 5팀 대장 수범님 감사합니다! 6주차 코드리뷰에 보여주신 리얼월드 전략 넘 좋습니당!
순수함수의 안도감
getCartSummary,getMaxDiscount같은 계산 로직을 순수함수로 빼두니 마음이 편해졌습니다. 입력이 같으면 출력이 항상 같으니까, 다른 곳에서 무슨 일이 벌어지든 이 함수만큼은 믿을 수 있었습니다.완벽한 분리 보다 일관된 기준
모든 경계가 아 이거는 이 경계야! 하고 명확할 순 없었습니다. 애매한 경우도 많긴했습니다. 중요한 건 내 나름의 기준을 세우고, 그걸 일관되게 적용하는 것이었습니다. 정답이 없는 영역에서는 일관성을 따르는게 중요하다는걸 배웠습니다.
이번 과제에서 내가 제일 신경 쓴 부분은 무엇인가요?
레이어 간 의존성 방향 지키기
FSD의 핵심 규칙 중 하나가 상위 레이어는 하위 레이어만 import할 수 있다 입니다.app→pages→widgets→features→entities→shared이 방향을 거스르면 안 됩니다.처음엔 솔직히 당연한 소리 아닌가? 그냥 import 안 하면 되는 거잖아 라고 생각했습니다. 왜 이렇게까지 강조하는지 잘 와닿지 않았습니다.
그런데 전역 상태를 적용하면서 생각이 바뀌었고 Zustand Store를 여기저기서 직접 import하다 보니, 나중에
Store구조를 바꾸려면 의존하는 파일을 전부 수정해야 하는 상황이 됐습니다.구현 세부사항에 직접 의존하면 바꾸기 어려워진다는 게 이런 거구나, 체감이 됐습니다.
그래서 의존성 방향을 의식하면서 다시 정리했는데,
Store는shared에 두고,Domain Hook으로 감싸서entities에서 노출하고,Features나Widgets는Hook만 사용하게. 이렇게 규칙을 지키고 나니 전역 상태가 들어와도 코드 흐름이 예측 가능해졌습니다.어떤 파일을 열어도 얘가 의존하는 건 아래 레이어뿐이구나 라는 확신이 생겼습니다.
함수를 쪼개되, 조합할 수 있게 만들기
순수함수를 분리하면서 고민한 건 어디까지 쪼갤 것인가였습니다. 예를 들어
getPriceLabel함수가 있습니다.이 함수는
getStockLabel을 먼저 호출하고, 결과가 있으면(품절이면) 그걸 반환하고, 없으면 가격을 반환합니다.처음엔 그냥 여기서 product.stock === 0 체크하면 되는 거 아냐? 라고 생각했습니다. 근데 getStockLabel은 다른 곳에서도 쓰입니다.
재고 표시 뱃지에서도 쓰이고, 버튼 비활성화 조건에서도 쓰입니다. 만약
getPriceLabel안에서 재고 체크 로직을 직접 작성했다면, 같은 로직이 여러 군데 흩어졌을텐데... 재고 판단 기준이 바뀌면? 전부 찾아서 수정해야 합니다. 작은 함수를 만들되, 그걸 조합해서 더 큰 함수를 만드는 방식. 이게 순수함수의 진짜 장점이라는 걸 깨달았습니다. 각각은 한 가지 일만 하고, 필요하면 레고처럼 조립하면 된다! (코치님 아니라면 말씀해주세요..!)작은 기준들이 일관성을 만든다
config와 data 파일을 분리할 때도 기준을 세웠습니다. 예를 들어
BULK_DISCOUNT_THRESHOLD = 10과initialProducts배열. 둘 다 코드에 박혀있는 값이라 비슷해 보이는데, 성격이 달랐습니다.BULK_DISCOUNT_THRESHOLD는 비즈니스 규칙입니다.10개 이상 사면 할인 이라는 정책이고, 기획이 바뀌지 않는 한 이 숫자는 절대 안 바뀝니다. 반면
initialProducts는 상태의 초기값이고, 앱이 시작할 때 이 데이터로 상태가 만들어지고, 사용자가 상품을 추가하거나 삭제하면 달라집니다.그래서 전자는
config에, 후자는model에 두기로 했습니다. 남들이 보면 그게 그거 아니야? 라고 할 수도 있는데 이런 작은 기준들이 쌓이면 이 폴더엔 이런 성격의 파일이 있겠구나 라는 예측이 가능해지고 그게 코드베이스의 일관성이되어 파악이 쉬워집니다.배운 건 써먹어야 내 것이 된다
저번 주차에 배운
cn유틸리티 함수를 이번에 공통 컴포넌트 만들 때 써봤습니다. 기본 스타일을 정의해두고, 사용하는 쪽에서 필요하면 덮어쓸 수 있게 하는 건데Tailwind는 같은 속성의 클래스가 여러 개 있으면 뭐가 적용될지 예측이 어려운데,cn이 이걸 알아서 정리해줘서 편했습니다.Date.now()로ID를 만들다가 중복이 터졌을 때, 어차피 경고 인거같은데? 테스트는 통과하고 넘어갈 수도 있었어요. 그래도crypto.randomUUID()로 바꿨습니다. 왜냐면 5주차에ID는 리스트 렌더링할 때key로 쓰이는데key가 중복되면 React의 Reconciliation이 제대로 동작하지 않다는걸 배웠기 때문입니다.이번 과제를 통해 앞으로 해보고 싶은게 있다면 알려주세요!
함수형 프로그래밍 제대로 공부해보기
이번 과제에서 순수함수의 장점을 조금이나마 체감했습니다.
getCartSummary처럼 입력만 같으면 항상 같은 결과가 나오는 함수는 테스트하기도 쉽고, 어디서 불러도 부수효과가 없으니 사용하기 좋습니다.작은 함수를 조합해서 큰 함수를 만드는 것도 아토믹 디자인 이 생각나기도 하고 재밌었습니다. 근데 솔직히 지금은 순수함수가 좋다더라 정도만 아는 수준이여서, 확실히 왜 좋은지, 어디까지 적용해야 하는지, 액션과 계산을 어떻게 분리하는 게 맞는지 깊이 있게 이해하진 못했습니다.
쏙쏙 들어오는 함수형 코딩 같은 책을 항해분들과 스터디를 하면서 이론적 기반을 쌓아보고 싶습니다.
리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 :)
이게 질문을 할만한건지 잘 모르겠는데..우선 다른분들보니 저와 다르게 전역상태로 관리하시는분들이 있어서 질문 드립니다!
Toast나Cart처럼 앱 전체에서 접근해야 하는 건Zustand로,searchTerm처럼 특정 페이지 안에서만 의미 있는 건Props로 유지했는데, 검색어는HeaderWidget에서 입력하고ShopPage안의ProductListWidget에서 사용하는데 둘이 직접 데이터를 주고받을 수 없으니까, 공통으로 감싸는App에서searchTerm상태를 관리하고 각각에Props로 내려주는 방식을 썼는데Props와 전역상태관리 중 어떤게 더 좋은 선택일까요?Header를widgets으로 분류 했는데, 최상위 조립 레이어로 보고App Layer로 만드는게 더 좋은 선택이였을까요? 레이어 계층 구조가 화살표 순서가 아니고 의존 가능 방향이지만 그래도Pages Layer와Widgets Layer로 같이App.tsx에 존재하는데App Layer로 분류했으면 이질감이 안들었을까 라고 회고 하면서 생각이 들었습니다.