안녕하세요 프론트엔드 개발자 Garden, 오소현입니다:)
저는 요즘 항해 플러스 프론트엔드 3기 코스 과정에 참여하면서 공부하고 있는데요!
오늘은 그 6주차의 회고를 진행해보려고 합니다 !
이번주차는 테오 코치님의 클린코드 파트 마지막 발제를 진행했는데요!
이번주차가 제일 과제 역대급 핵불이었습니다... 바로 토픽으로 살펴보시져 ..
저는 이번 과제에 들어가면서 FSD 분류 기준에 대해 많이 고민을 해본 ...! 끝에 저만의 기준을 세울 수 있었습니다 :>
Entity
를 정의한 기준제가 생각한 Entity는 다음과 같습니다!
데이터의 모델 - 타입, 순수하게 데이터와 관련된 부분, 액션과 이벤트가 없는 데이터를 그리는 UI, 기본적인 CRUD 작업을 위한 인터페이스
따라서 위와 같은 기준으로 생각을 해보면서 제 과제의 Entity 구조를 살펴보겠습니다
📂 엔티티 폴더 구조
┣ 📂entities
┃ ┣ 📂comment
┃ ┃ ┣ 📂api
┃ ┃ ┃ ┣ 📜createComment.ts
┃ ┃ ┃ ┣ 📜deleteComment.ts
┃ ┃ ┃ ┣ 📜fetchComments.ts
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┣ 📜likeComment.ts
┃ ┃ ┃ ┗ 📜updateComment.ts
┃ ┃ ┣ 📂model
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┗ 📜types.ts
┃ ┃ ┗ 📂ui
┃ ┃ ┃ ┣ 📜ReactionText.tsx
┃ ┃ ┃ ┗ 📜index.ts
┃ ┣ 📂post
┃ ┃ ┣ 📂api
┃ ┃ ┃ ┣ 📜createPost.ts
┃ ┃ ┃ ┣ 📜deletePost.ts
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┗ 📜updatePost.ts
┃ ┃ ┗ 📂model
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┗ 📜types.ts
┃ ┣ 📂tag
┃ ┃ ┣ 📂api
┃ ┃ ┃ ┣ 📜fetchTag.ts
┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┗ 📂model
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┗ 📜types.ts
┃ ┗ 📂user
┃ ┃ ┣ 📂api
┃ ┃ ┃ ┣ 📜getAllUsers.ts
┃ ┃ ┃ ┣ 📜getUserById.ts
┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┣ 📂lib
┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┣ 📂model
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┗ 📜types.ts
┃ ┃ ┗ 📂ui
┃ ┃ ┃ ┣ 📜UserInfoText.tsx
┃ ┃ ┃ ┗ 📜index.ts
먼저 엔티티는 도메인 별로 나눠봐야한다고 생각했는데요! 제가 분류한 도메인의 기준은 게시글
, 댓글
, 태그
, 사용자
였습니다.
특히 태그는 게시글에 종속되었다고도 볼 수 있지만 프로젝트 내에서 Selector 와 함께 URL 상태에 의존하고 있기에 중요한 도메인으로 생각했고, 유저도 유저의 정보를 단일 조회, 특정 유저로 댓글 수정 삭제 등록 등등 중요하다고 생각했기에 전체적으로 4개의 엔티티를 설정했습니다
각각의 폴더에는 주로 api 호출 함수, 데이터의 타입, 오직 데이터만을 그리는 각각의 도메인에 관련한 UI를 배치하였습니다 !
Features
를 정의한 기준제가 생각한 Feature는 다음과 같습니다!
여러개의 엔티티를 조합해야하는 기능에 대한 로직, 액션과 이벤트가 있는 도메인의 UI, 여러개의 기능을 담당해 기능이 아니라 행동으로 넘어가는 로직들
따라서 위와 같은 기준으로 생각을 해보면서 제 과제의 Features 구조를 살펴보겠습니다
📂 Features 폴더 구조 (옴청 길어서,, 조심하세용!) ┣ 📂features
┃ ┣ 📂comment
┃ ┃ ┣ 📂api
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┣ 📜useMutationCommentCreate.ts
┃ ┃ ┃ ┣ 📜useMutationCommentDelete.ts
┃ ┃ ┃ ┣ 📜useMutationCommentUpdate.ts
┃ ┃ ┃ ┗ 📜useQueryCommentList.ts
┃ ┃ ┣ 📂config
┃ ┃ ┃ ┣ 📜commentFormValue.ts
┃ ┃ ┃ ┣ 📜commentInitialValue.ts
┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┣ 📂hooks
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┣ 📜useCommentForm.ts
┃ ┃ ┃ ┗ 📜useSelectedComment.ts
┃ ┃ ┗ 📂ui
┃ ┃ ┃ ┣ 📂add
┃ ┃ ┃ ┃ ┣ 📜AddCommentButton.tsx
┃ ┃ ┃ ┃ ┣ 📜CreateCommentModal.tsx
┃ ┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┃ ┣ 📂delete
┃ ┃ ┃ ┃ ┣ 📜DeleteCommentButton.tsx
┃ ┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┃ ┣ 📂like
┃ ┃ ┃ ┃ ┣ 📜LikeCommnetButton.tsx
┃ ┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┃ ┣ 📂update
┃ ┃ ┃ ┃ ┣ 📜UpdateCommentButton.tsx
┃ ┃ ┃ ┃ ┣ 📜UpdateCommentModal.tsx
┃ ┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┃ ┣ 📜CommentInfo.tsx
┃ ┃ ┃ ┗ 📜index.ts
┃ ┣ 📂filter
┃ ┃ ┣ 📂config
┃ ┃ ┃ ┗ 📜initialValues.ts
┃ ┃ ┣ 📂hooks
┃ ┃ ┃ ┗ 📜useSearchParams.ts
┃ ┃ ┣ 📂lib
┃ ┃ ┃ ┣ 📜buildURLPath.ts
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┣ 📜parseURLParams.ts
┃ ┃ ┃ ┗ 📜updateURL.ts
┃ ┃ ┗ 📂model
┃ ┃ ┃ ┣ 📜filterStore.ts
┃ ┃ ┃ ┗ 📜index.ts
┃ ┣ 📂modal
┃ ┃ ┣ 📂hooks
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┗ 📜useModal.ts
┃ ┃ ┗ 📂ui
┃ ┃ ┃ ┗ 📜ModalProvider.tsx
┃ ┣ 📂post
┃ ┃ ┣ 📂api
┃ ┃ ┃ ┣ 📜readPost.ts
┃ ┃ ┃ ┣ 📜useMutationPostCreate.ts
┃ ┃ ┃ ┣ 📜useMutationPostDelete.ts
┃ ┃ ┃ ┣ 📜useMutationPostUpdate.ts
┃ ┃ ┃ ┗ 📜useQueryPostList.ts
┃ ┃ ┣ 📂config
┃ ┃ ┃ ┣ 📜pageValue.ts
┃ ┃ ┃ ┣ 📜selectedPostValue.ts
┃ ┃ ┃ ┗ 📜sortValue.ts
┃ ┃ ┣ 📂hooks
┃ ┃ ┃ ┣ 📜usePoatModals.ts
┃ ┃ ┃ ┣ 📜usePostForm.ts
┃ ┃ ┃ ┣ 📜useSearchPost.tsx
┃ ┃ ┃ ┗ 📜useSelectedPost.ts
┃ ┃ ┣ 📂model
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┗ 📜types.ts
┃ ┃ ┗ 📂ui
┃ ┃ ┃ ┣ 📂add
┃ ┃ ┃ ┃ ┣ 📜AddPostButton.tsx
┃ ┃ ┃ ┃ ┣ 📜CreatePostModal.tsx
┃ ┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┃ ┣ 📂pagenation
┃ ┃ ┃ ┃ ┣ 📜PostNavigationButtons.tsx
┃ ┃ ┃ ┃ ┣ 📜PostPageSize.tsx
┃ ┃ ┃ ┃ ┣ 📜PostPagination.tsx
┃ ┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┃ ┣ 📂search
┃ ┃ ┃ ┃ ┗ 📜PostSearchFilters.tsx
┃ ┃ ┃ ┣ 📂table
┃ ┃ ┃ ┃ ┣ 📜PostLikeInfoTableCell.tsx
┃ ┃ ┃ ┃ ┣ 📜PostReactInfoTableCell.tsx
┃ ┃ ┃ ┃ ┣ 📜PostTitleInfoTableCell.tsx
┃ ┃ ┃ ┃ ┣ 📜PostUserInfoTableCell.tsx
┃ ┃ ┃ ┃ ┗ 📜PostsTableBody.tsx
┃ ┃ ┃ ┣ 📂update
┃ ┃ ┃ ┃ ┣ 📜UpdatePostButton.tsx
┃ ┃ ┃ ┃ ┣ 📜UpdatePostModal.tsx
┃ ┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┃ ┗ 📜ShowPostDetailModal.tsx
┃ ┣ 📂tag
┃ ┃ ┣ 📂api
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┗ 📜useQueryTags.ts
┃ ┃ ┗ 📂hooks
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┗ 📜useTags.ts
┃ ┗ 📂user
┃ ┃ ┣ 📂api
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┣ 📜useQueryPostAuthor.ts
┃ ┃ ┃ ┗ 📜useUserCache.ts
┃ ┃ ┣ 📂config
┃ ┃ ┃ ┣ 📜authorValue.ts
┃ ┃ ┃ ┗ 📜selectedUserValue.ts
┃ ┃ ┣ 📂model
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┗ 📜useSelectedUser.ts
┃ ┃ ┗ 📂ui
┃ ┃ ┃ ┗ 📜UserDetailModal.tsx
과제를 진행하다보니까 Feature는 아 뭔가 애매한데..? 싶으면 Feature인가? 라고 생각했던 것 같습니다 ㅎㅎ
담당하는 기능과 로직, UI가 많아지면서 Feature 폴더 내부에서도 기능 단위로 세분화해 분리해보았습니다!
예시로 features/Comment를 보며 한번 살펴보겠습니다 :)
┃ ┣ 📂comment
┃ ┃ ┣ 📂api
┃ ┃ ┃ ┣ 📜index.ts
┃ ┃ ┃ ┣ 📜useMutationCommentCreate.ts
┃ ┃ ┃ ┣ 📜useMutationCommentDelete.ts
┃ ┃ ┃ ┣ 📜useMutationCommentUpdate.ts
┃ ┃ ┃ ┗ 📜useQueryCommentList.ts
위에서 보셨겠지만 api 호출에 관한 로직을 이미 엔티티에 선언을 했었는데요! 저는 React Query를 사용한 데이터 fetching 로직은 다시 features에 선언을 해서 사용하였습니다. 이게 마지막까지 고민한 폴더 구조였는데요!
원래는 현재 엔티티에 있는 fetch 함수들과 함께 feature에 같이 정의를 해보려고 했습니다
// API 로직
export const createCommentApi = async (newComment: NewComment): Promise<Comment> => {
// ... API 호출 로직
}
// 데이터 fetching 로직
export const useQueryCommentList = (postId: number) => {
// ... React Query 로직
}
위와 같이 한 파일 내부에 정의해서 사용하려고 했으나 분리한 이유는 엔티티에 분리한 단일 기능 fetch 함수들이 언제든지 다른 features에서 응용될 수 있다고 생각했습니다 따라서 현재 구조와 같이 둘을 분리하였는데 어떤 방식이 더 FSD 구조에 적합할지 궁금했었습니다!
이어서 다음 구조를 한번 살펴보겠습니다 바로 Config 파일의 존재인데요...!
┃ ┃ ┣ 📂config
┃ ┃ ┃ ┣ 📜commentFormValue.ts
과제를 진행하다보니 도메인들에서 쿼리를 요청할때나, 상태를 관리할 때 해당 컴포넌트의 초기값을 선언해줘야할 때를 만났습니다. 이러한 경우들에 대해서 각 도메인 별로 필요한 경우 Config 폴더를 만들어서 상수처럼 관리를 해오고 있었습니다.
이러한 경우에는 1) Shared로 빼야할지 아니면 2) 특정 도메인의 기능을 위한 것이므로 현재와 같은 구조가 좋을 지,
3) 아예 이런 목적을 위한 파일은 FSD에서 부적절 한지 궁금했었습니다
┃ ┃ ┗ 📂ui
┃ ┃ ┃ ┣ 📂add
┃ ┃ ┃ ┃ ┣ 📜AddCommentButton.tsx
┃ ┃ ┃ ┃ ┣ 📜CreateCommentModal.tsx
┃ ┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┃ ┣ 📂delete
┃ ┃ ┃ ┃ ┣ 📜DeleteCommentButton.tsx
┃ ┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┃ ┣ 📂like
┃ ┃ ┃ ┃ ┣ 📜LikeCommnetButton.tsx
┃ ┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┃ ┣ 📂update
┃ ┃ ┃ ┃ ┣ 📜UpdateCommentButton.tsx
┃ ┃ ┃ ┃ ┣ 📜UpdateCommentModal.tsx
┃ ┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┃ ┣ 📜CommentInfo.tsx
┃ ┃ ┃ ┗ 📜index.ts
features에 들어가는 UI 컴포넌트를 만들다보니 이를 확연하게 구분하려면 기능 별로 UI를 구분해보자! 라는 생각이 들었습니다.
따라서 다른 개발자가 댓글을 새로 생성하는 로직을 파악하고 싶다면 features/Comment/ui/add
이런 폴더 구조를 가져야 한다고 생각해 전체적인 기능 별로 컴포넌트를 분리해 보았습니다 FSD의 기능별로 구분한다는 의미가 요렇게 쓰이는 게 맞을 지 궁금했습니다
Shared
를 정의한 기준제가 생각한 Shared는 다음과 같습니다!
의미 그대로 공통. 프로젝트 내에서 재사용성이 매우 강한 역할, 비즈니스 내용이 덜한 것
따라서 위와 같은 기준으로 생각을 해보면서 제 과제의 Shared 구조를 살펴보겠습니다
📂 Shared 폴더 구조 ┣ 📂shared
┃ ┣ 📂hooks
┃ ┃ ┗ 📜useLoading.ts
┃ ┣ 📂model
┃ ┃ ┗ 📜types.ts
┃ ┣ 📂ui
┃ ┃ ┣ 📂button
┃ ┃ ┃ ┣ 📜Button.tsx
┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┣ 📂card
┃ ┃ ┃ ┣ 📜Card.tsx
┃ ┃ ┃ ┣ 📜CardContent.tsx
┃ ┃ ┃ ┣ 📜CardHeader.tsx
┃ ┃ ┃ ┣ 📜CardTitle.tsx
┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┣ 📂dialog
┃ ┃ ┃ ┣ 📜Dialog.tsx
┃ ┃ ┃ ┣ 📜DialogContent.tsx
┃ ┃ ┃ ┣ 📜DialogHeader.tsx
┃ ┃ ┃ ┣ 📜DialogTitle.tsx
┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┣ 📂input
┃ ┃ ┃ ┣ 📜Input.tsx
┃ ┃ ┃ ┣ 📜SearchInput.tsx
┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┣ 📂loading
┃ ┃ ┃ ┣ 📜Loading.tsx
┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┣ 📂select
┃ ┃ ┃ ┣ 📜SelectContent.tsx
┃ ┃ ┃ ┣ 📜SelectItem.tsx
┃ ┃ ┃ ┣ 📜SelectRoot.tsx
┃ ┃ ┃ ┣ 📜SelectTrigger.tsx
┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┣ 📂table
┃ ┃ ┃ ┣ 📜Table.tsx
┃ ┃ ┃ ┣ 📜TableBody.tsx
┃ ┃ ┃ ┣ 📜TableCell.tsx
┃ ┃ ┃ ┣ 📜TableHead.tsx
┃ ┃ ┃ ┣ 📜TableHeader.tsx
┃ ┃ ┃ ┣ 📜TableRow.tsx
┃ ┃ ┃ ┗ 📜index.ts
┃ ┃ ┗ 📂textarea
┃ ┃ ┃ ┣ 📜Textarea.tsx
┃ ┃ ┃ ┗ 📜index.ts
┃ ┗ 📂utils
┃ ┃ ┗ 📜highlightText.tsx
저는 주로 Shared에는 전 프로젝트에서 사용되는 BaseComponent, 로딩 처리와 같은 커스텀 훅이 들어가야한다고 생각했고 위와 같이 분리했습니다!
Widget / Page
를 정의한 기준이 두개 가 정의하기 가장 어려운 구조였는데, 우선 Page는 이번 프로젝트에 페이지가 하나 뿐이기도 했고, 비즈니스 로직이 없는 하위 컴포넌트들로 이루어지도록 구현하고 싶었습니다. 캐시 처리를 할때에도 최상단 Page에서 할까 고민이 많았는데 위젯으로 결국 이관하였습니다
페이지에서 사용하기 좋은 하위 레이어 구조를 갖는 것, 프로젝트 전역에서 사용되는 큰 단위의 컴포넌트,
따라서 위와 같은 기준으로 생각을 해보면서 제 과제의 Shared 구조를 살펴보겠습니다
📂 Widget과 Page 폴더 구조 ┣ 📂widgets
┃ ┣ 📂layout
┃ ┃ ┗ 📂ui
┃ ┃ ┃ ┣ 📜Footer.tsx
┃ ┃ ┃ ┗ 📜Header.tsx
┃ ┗ 📂post
┃ ┃ ┗ 📂ui
┃ ┃ ┃ ┣ 📜PostTotalTable.tsx
┃ ┃ ┃ ┣ 📜PostsHeader.tsx
┃ ┃ ┃ ┣ 📜PostsTableContainer.tsx
┃ ┃ ┃ ┣ 📜PostsTableHeader.tsx
......
┣ 📂pages
┃ ┗ 📜PostsManagerPage.tsx
전체적으로 리팩토링한 파일이 많았는데 제가 모두 features에 선언하는 바람에 귀여운 폴더 구조가 되었습니다 핳
게시글이 큰 게시판 테이블이 있고 각각의 셀 단위의 영역들은 features에서 구현하고 이를 조합하는 구조로 위젯을 구현하였습니다!
저는 우선,, 이번 주차 심화 과제를 진행하는게 생소한게 많아 러닝커브와 동반한 어지러움의 회오리가아...🌀🌀🌀
특히 제시문에 있던 캐싱을 관리하는 전략에 대해서 고민을 많이 해보았는데 여러가지 자료를 바탕으로 3가지를 생각해 보았습니다.
export const userCache = (() => {
const cache = new Map<number, User>()
// ... 캐시 관리하는 메소드들 etc...
})()
위의 유저 데이터를 대상으로 관리햐는 캐싱 전략은 사용자의 데이터를 메모리에 직접 저장하고 관리하는 역할을 하고 있고 아예 이 프로젝트 내에서는 사용자들의 정보가 변경되지는 않기 때문에 이와 같이 커스텀 기반으로 구현해보았습니다.
자주 변경되지 않는 데이터를 다루거나, 해당 데이터가 서비스 내에서 다양하게 참조되어 사용될때 유리한 방식이라고 해서 도입하였는데 해당 상황에 좋은 구현 방법인지 궁금합니다:)
useQuery({
queryKey: ["comment"],
queryFn: () => fetchComments(postId),
})
제가 댓글의 상태를 가지고 React Query 캐싱을 진행하였는데 그 이유는 댓글의 상태는 실시간성으로 등록, 수정, 삭제함과 동시에 상태가 반영되어야 하니까 댓글 상태를 가져오는 쿼리 요청시에 해당 방법을 사용해 보았습니다.
제가 해당 전략을 가지고 실시간성 데이터를 관리하기에 적합하도록 구현하였는지, 해당 상황에 좋은 구현 방법인지 궁금합니다!
//새로 추가한 댓글을 기존 캐시에 추가
const addCommentToCache = (newComment: Comment, oldData: CommentResponse) => ({
...oldData,
comments: [
// 옛날 캐시에 추가...
]
})
// 뮤테이션 요청이 성공할 때 갱신하는 로직
useMutation({
mutationFn: createCommentApi,
onSuccess: (data) => {
queryClient.setQueryData(["comment"], oldData => ...)
}
})
이번 전략은 2번과 비슷하면서도 생각한 유형이 달랐습니다. 새로운 데이터가 추가되면 서버 응답을 기다리지 않고 바로 UI가 업데이트되면 좋은 기능에 추가하였습니다. 요것도 상황에 맞게 잘 쓴건가..? 궁금합니다 !
전체적으로 다양한 캐싱 전략의 방법을 읽어보면서 요런 상황에는 어떨까 고민을 많애보았습니다 개인적으로 심화과제는 거의 학습에 할애한 시간이 압도적으로 높아서 일관성있는 캐시 전략이 더 좋을 것 같았지만 다양하게 도입을 해보았던 것 같습니다 ㅎㅎ
코치님께서는 어떤 캐시 전략을 주로 사용하시나요..? 스스로 공부해보면 좋을 내용도 궁금합니다!
이번주차 과제는 진짜 FSD구조, tanstackquery, 캐싱 전략 등등 진짜 너~~무 러닝커브가 길었고 과제를 제출 직전까지 진행하고 있어서 전날엔 아예 밤새고 출근하고 역대급 주차였습니다... 눈물을 닦고 말해보겠습니다
정말 힘든주차여서 패스만 하자..패스는 할수있을까? 이러면서 울면서 했던거 같아요
PASS 조차 기대를 안했는데 둘다 BEST를 ㅠㅠ 정말 감사합니다
다음주는 대망의 소문이 무서운 테스트 코드 주차입니다...
진짜 많이 걱정되고 제가 패스할수 잇을까......요.. 화이팅
저는 현재 5주차를 진행하고 있지만 3주동안 정말 열심히 몰입해서 공부하고 있는데요!
바로 다음 기수인 항해 플러스 프론트엔드 코스 4기를 모집하고 있다고 하여 공유드립니다!
현재는(24.10.26)에는 슈퍼 얼리버드 기간으로 약 46% 할인을 받을 수 있습니다!
저도 3기 입과할때 슈퍼 얼리버드 기간에 합류해 추천인 할인까지해서 제일 최대 할인된 가격에 합류할 수 있었습니다 ㅎㅎ
또한 추천인 제도로 [추천인] 코드에 “fWHY9o”를 입력하시면 20만 원 추가 할인 혜택이 있으니 결제하실때 꼭 추천인 할인 코드도 함께 입력해주세요!
제 항해 플러스 프론트엔드 후기 글을 보고 궁금한 사항이 있으시다면 댓글이나, 벨로그 프로필 이메일, 링크드인으로 문의주세요 :)