본 글은 https://feature-sliced.design/docs/reference/slices-segments 해석한 글이다.

 

Slices

슬라이스는 Feature-Sliced Design의 조직 계층 구조에서 두 번째 레벨이다. 슬라이스의 주요 목적은 제품, 비즈니스 또는 애플리케이션에 대한 의미에 따라 코드를 그룹화하는 것이다.

 

슬라이스의 이름은 애플리케이션의 비즈니스 도메인에 따라 직접 결정되므로 표준화되어 있지 않다. 예는 다음과 같다.

  • 사진 갤러리의 슬라이스 :  `photo`, `create-album`, `gallery-page` 등
  • 소셜 네트워크의 슬라이스 :  `post`, `add-user-to-friends`, `news-feed` 등

밀접하게 연관된 슬라이스는 구조적으로 디렉터리에 그룹화할 수 있지만 다른 슬라이스와 동일한 격리 규칙을 적용해야 하며, 해당 디렉터리에 코드 공유가 없어야 한다.

Shared와 App 레이어에는 슬라이스가 포함되어 있지 않다. 그 이유는 Shared에는 비즈니스 로직이 전혀 포함되어 있지 않아야 하므로 제품에 대한 의미가 없고, App에는 전체 애플리케이션과 관련된 코드만 포함되어야 하므로 분할이 필요하지 않기 때문이다.

Public API rule on slices(슬라이스에 대한 public API 규칙)

슬라이스 내에서는 코드를 매우 자유롭게 구성할 수 있으며, 슬라이스에서 좋은 public API를 제공하는 한 문제가 발생하지 않는다. 이는 슬라이스에 대한 public API 규칙으로 강제된다:

모든 슬라이스(및 슬라이스가 없는 레이어의 세그먼트)에는 public API 정의가 포함되어야 한다.

이 슬라이스/세그먼트 외부의 모듈은 슬라이스/세그먼트의 내부 파일 구조가 아닌 public API만 참조할 수 있다.

public API의 근거와 public API를 만드는 모범 사례에 대한 자세한 내용은 public API 참조를 읽어보자.

Segments

세그먼트는 조직 계층 구조의 세 번째이자 마지막 단계로, 기술적 특성에 따라 코드를 그룹화하는 것이 목적이다.

 

몇 가지 표준화된 세그먼트 이름이 있다:

  • `ui` : UI 컴포넌트, 데이터 포매팅 함수
  • `model` : 비즈니스 로직과 데이터 스토리지, 이 데이터를 조작하는 함수
  • `lib` : 보조 및 인프라 코드
  • `api` : 외부 API와의 통신, 백엔드 API 메서드

사용자 지정 세그먼트는 허용되지만 신중하게 만들어야 한다. 사용자 지정 세그먼트가 가장 일반적으로 사용되는 위치는 슬라이스가 적합하지 않은 App 레이어와 Shared 레이어다.

Examples

Layer `ui` `model` `lib` `api`
Shared UI 키트 보통 사용되지 않음. 여러 관련 파일의 유틸리티 모듈.
개별 헬퍼를 사용해야 하는 경우 `lodash-es`와 같은 유틸리티 라이브러리를 사용하는 것이 좋다.
인증( authentication)또는 캐싱(caching)과 같은 추가 기능을 갖춘 기초적인 API 클라이언트
Entities 대화형 요소용 슬롯이 있는 비즈니스 엔티티의 스켈레톤(더미 데이터?) 이 엔티티의 인스턴스 데이터 저장소 및 해당 데이터를 조작하기 위한 함수이다.
이 세그먼트는 서버 측 데이터를 저장하는 데 가장 적합하다. TanStack Query 또는 다른 암시적 저장 방법을 사용하는 경우 이 세그먼트를 생략할 수 있다.
스토리지와 관련이 없는 이 엔티티의 인스턴스를 조작하는 함수 백엔드와 쉽게 통신할 수 있도록 Shared의 API 클라이언트를 사용하는 API 메서드
Features 사용자가 이 기능을 사용할 수 있도록 하는 대화형 요소 필요한 경우 비즈니스 로직 및 인프라 데이터 저장소(예: 현재 앱 테마). 실제로 사용자에게 가치를 창출하는 코드이. `model` 세그먼트의 비즈니스 로직을 간결하게 설명하는 데 도움이 되는 인프라 코드 백엔드에서 이 기능을 나타내는 API 메서드.
엔티티에서 API 메서드를 작성할 수 있다.
Widgets 엔티티와 피처를 독립된 UI 블록으로 구성한다.
오류 경계 및 로딩 상태도 포함할 수 있다.
필요한 경우 인프라 데이터 스토리지. Non-business 상호 작용(예: 제스처) 및 블록이 페이지에서 작동하는 데 필요한 기타 코드 일반적으로 사용되지 않지만 중첩된 라우팅 컨텍스트(예: Remix)에서 데이터 로더를 포함할 수 있다.
Pages 엔티티, 기능 및 위젯을 전체 페이지로 구성한다.
오류 경계 및 로딩 상태도 포함할 수 있다.
보통 사용되지 않음. Non-business  상호 작용(예: 제스처) 및 페이지가 완전한 사용자 경험을 제공하는 데 필요한 기타 코드 SSR 지향 프레임워크용 데이터 로더

 

본 글은 https://feature-sliced.design/docs/reference/layers를 해석한 글이다.

 

레이어는 Feature-Sliced Design에서 조직 계층 구조의 첫 번째 레벨이다. 레이어의 목적은 책임(how much responsibility it needs)와 의존 모듈하는 모듈( how many other modules in the app it depends on)에 따라 코드를 분리하는 것이다.

Note
이 페이지에서 모듈은 애플리케이션 내부 모듈을 의미한다. - 파일 또는 index 파일이 있는 폴더. npm 패키지와 혼동하지 마세요.

모든 레이어에는 코드에서 모듈이 가질 책임 규모를 결정하는 데 도움이 되는 특별한 의미론적(semantic) 의미가 있다. 레이어의 이름과 의미는 FSD으로 빌드된 모든 프로젝트에서 표준화되어 있다.

총 7개의 레이어가 있으며, 책임과 의존도가 가장 높은 것부터 가장 낮은 것까지 배열되어 있다:

  1. App
  2. Processes (deprecated)
  3. Pages
  4. Widgets
  5. Features
  6. Entities
  7. Shared

모든 프로젝트에서 위의 모든 레이어를 사용할 필요는 없으며, 프로젝트에 가치를 더한다고 생각되는 경우에만 레이어를 추가하면 된다.

Import rule on layers(레이어 가져오기 규칙)

레이어는 응집력이 높은 모듈 그룹인 슬라이스(slices)로 구성된다. FSD는 낮은 결합도를 선호하므로 슬라이스 간 종속성은 레이어 가져오기 규칙에 의해 규제된다:

슬라이스 내 모듈은 다른 슬라이스가 바로 아래 레이어에 있는 경우에만 가져올 수 있다.

예를 들어, `~/features/aaa`가 있다면 `aaa`는 슬라이스다. 그리고 `~/features/aaa/api/request.ts`파일은 `~/features/bbb` 내 모듈 코드를 가져오기 할 수 없다. 그러나  `~/entities`, `~/shared`의 코드와  `~/features/aaa` 내 모든 형제 코드는 가져오기할 수 있다.

Layer definitions

Shared

프로젝트 또는 비즈니스의 세부 사항과 분리된 격리된 모듈 / 컴포넌트 / 추상화. 경고: utility dump처럼 취급하지 말기!

이 레이어는 다른 레이어와 달리 슬라이스로 구성되지 않으며, 대신 세그먼트로 직접 구성된다.

 

content 예시:

  • UI kit
  • API Client
  • 브라우저 API로 작업하는 코드

Entities : 엔티티

프로젝트의 본질(essence)을 함께 형성하는 현실 세계의 개념이다. 일반적으로 비즈니스에서 제품을 설명하는 데 사용하는 용어다.

이 레이어의 각 슬라이스에는 static UI Element, data stores 및 CRUD 작업이 포함된다.

 

slice 예시:

SNS 예 Git 프론트 예(github)
- User
- Post
- Group
- Repository
- File
- Commit
TIP
Git 프론트엔드의 예에서 repository에 파일이 포함됨을 알 수 있다. 따라서 repository는 다른 엔티티(entitiy)가 중첩되어 있는 상위 엔티티가 된다. 이는 엔터티의 일반적인 상황이며, 레이어에 대한 가져오기 규칙을 위반하지 않고 이러한 상위 레벨 엔터티를 관리하기가 어려울 때가 있다.

다음은 이 문제를 극복하기 위한 몇 가지 제안이다:
- 엔티티의 UI에는 하위 수준 엔티티를 삽입할 위치에 대한 슬롯이 포함되어야 한다.
- 엔티티 인터렉션과 연관된 비즈니스 로직은 features에 배치해야 한다(대부분의 경우).
- 데이터베이스 엔티티의 타입은 Shared 레이어 내 API client 옆으로 추출할 수 있다.

Features : 피처

사용자가 비즈니스 엔티티와 상호 작용하여 가치 있는 결과를 얻기 위해 애플리케이션에서 수행할 수 있는 작업이다. 또한 앱이 사용자를 대신하여 가치를 창출하기 위해 수행하는 작업도 포함된다.

이 계층의 각 슬라이스에는 가치를 창출하는 작업을 가능하게 하는 인터랙티브 UI 요소, 내부 상태 및 API 호출이 포함될 수 있다.

 

슬라이스 예시:

SNS 예 Git 프론트 예(github) 사용자를 대신해 수행하는 작업
- 인증
- Post 생성
- Group 가입
- File 수정
- Comment 남기기
- Branch 병합
- 다크 모드 감지
- 백그라운드 계산 수행
- User-Agent 기반 작업

Widgets : 위젯

엔티티 및 feature와 같은 하위 수준 단위 구성에서 나온 자급자족형(self-sufficient) UI 블록이다.

이 레이어는 엔티티 UI에 남은 슬롯을 다른 엔티티와 features의 인터랙티브 요소로 채울 수 있는 방법을 제공한다. 따라서 일반적으로 비즈니스 로직을 이 레이어에 두지 않고 대신 features에 유지한다. 이 레이어의 각 슬라이스에는 바로 사용할 수 있는 UI 컴포넌트와 제스처, 키보드 상호 작용 등과 같이 비-비즈니스 로직(non-business)이 포함되기도 한다.

그러나 때로는 이 레이어에 비즈니스 로직을 두는 것이 더 편리할 수도 있다. 일반적으로 Widgets이 (대화형 데이터 테이블과 같이) 상호 작용이 매우 풍부하고 그 안의 비즈니스 로직이 다른 곳에서 사용되지 않는 경우에 적용한다.

 

슬라이스 예시:

SNS 예 Git 프론트 예(github)
- Post Card
- User profile header(with actions)
- repository 내 파일들의 목록(with actions)
- 스레드 내 코멘트
- repository card
TIP
중첩 라우팅 시스템(예: Remix의 라우터)을 사용하는 경우 플랫(flat) 라우팅 시스템에서 Pages 레이어를 사용하는 것과 같은 방식으로 Widgets 레이어를 사용하여 관련 데이터 가져오기, 로딩 상태 및 오류 경계가 포함된 완전한 인터페이스 블록을 만드는 것이 유용할 수 있다. 같은 방식으로 이 레이어에 페이지 레이아웃을 저장할 수 있다.

Pages

(웹 사이트와 같은) 페이지 기반 애플리케이션의 경우 전체 페이지, (모바일 앱과 같은 ) 화면 기반 애플리케이션의 경우 화면/활동(activities)이다.

이 레이어는 위젯과 구성적 특성(compositional nature)이 비슷하지만 규모가 더 크다. 이 레이어의 각 슬라이스에는 라우터에 연결할 준비가 된 UI 컴포넌트가 포함되며, 때로는 데이터 가져오기 로직 및 오류 처리가 포함되어 있다.

 

슬라이스 예제:

SNS 예 Git 프론트 예(github)
- 뉴스 피드
- 게시판 페이지
- 사용자 공개 프로필
- repoistory 페이지
- 사용자 repositoires
- repository 내 브랜치들

Processes

주의
이 레이어는 더이상 사용되지 않는다.
현재 버전의 스펙에서는 이 레이어를 피하고 대신 `features` 및 `app`으로 콘텐츠를 옮길 것을 권장한다.

다중 페이지 인터렉션을 위한 탈출 해치(escpate hatches).

이 레이어는 의도적으로 정의되지 않은 상태(undefined)로 남겨져 있다. 대부분의 애플리케이션은 이 레이어를 사용해서는 안 되며 라우터 수준 및 서버 수준 로직을 app 레이어에 유지해야 한다. app 레이어가 유지 관리가 불가능할 정도로 커져서 언로드(unloading)가 필요한 경우에만 이 레이어를 사용하는 것이 좋다.

App

기술적 측면(예: context providers)과 비즈니스 측면(예: analytics) 모두에서 앱 전반에 걸친 모든 종류의 문제를 다룬다.

이 레이어에는 일반적으로 Shared와 같은 슬라이스가 포함되지 않고 세그먼트가 직접 포함된다.

 

컨텐츠 예제:

  • Styles
  • Routing
  • Store and 다른 context providers
  • Analytics initialization
본 글은 https://feature-sliced.design/docs/get-started/overview 를 해석한 글이다.

기능별로 나눠진 설계(FSD : Featured-Sliced Design)은 프런트엔드 스캐폴딩(scaffolding)을 위한 아키텍처 방법론이다.

  • 스캐폴딩(scaffolding) : 

간단히 말해, 코드 구성에 대한 규칙 및 컨벤션 모음이다. 이 방법론의 주요 목적은 끊임없이 변화하는 비즈니스 요구 사항에 직면하여 프로젝트를 더 이해하기 쉽고 체계적으로 만드는 것이다.

 

Is it right for me? : 나에게 적합한가)

FSD는 모든 사이즈의 팀과 프로젝트에서 구현 가능하다. 그러나 몇 가지 유의 사항이 있다:

  • 이 방법론은 프론트엔드 프로젝트에서만 적용된다. 만약 백엔드 아키텍처를 찾고 있다면 클린 아키텍처를 고려해라.
  • 이 방법론은 사용자 대면(user-facing) 애플리케이션에만 적용된다. 대규모 UI 키트를 설계하는 방법에 대한 영감은 Material UI를 참조해라.
  • 단일 페이지로 구성된 매우 단순한 앱은 FSD의 이점이 필요하지 않을 수 있으며 오버헤드로 인해 어려움을 겪을 수 있다. 하지만 FSD는 좋은 사고방식(way of thinking)을 장려하므로 원하는 경우 아주 작은 프로젝트에 자유롭게 사용할 수 있다.
  • Google Cloud 관리 대시보드 크기의 거대한 앱에는 사용자 지정(custom) 아키텍처가 필요하다. 그래도 FSD를 기반으로 할 수 있다

FSD는 특정 프로그래밍 언어, UI 프레임워크 또는 상태 관리자를 강제하지 않는다. - 직접 개발하거나 몇 가지 예제를 참조하라.

 

기존 프로젝트가 있다면, 두려워하지마라. - FSD는 점진적으로 적용할 수 있다. 다만 팀이 현재 아키텍처로 인해 어려움을 겪고 있는지 확인해야 하며, 그렇지 않다면 전환할 가치가 없을 수도 있다. 마이그레이션 가이드는 마이그레이션 섹션을 참조하세요.

 

Basics : 기초

FSD에서 프로젝트는 여러 레이어(layers) 로 구성되며, 각 레이어는 여러 슬라이스(slices)로 구성되고, 각 슬라이스는 여러 세그먼트(segments)로 구성된다.

레이어는 모든 프로젝트에 걸쳐 표준화되어 있으며 수직으로 배열되어 있습니다. 한 레이어의 모듈은 바로 아래 레이어의 모듈과만 상호 작용할 수 있다. 현재 7개의 레이어가 있다(아래에서 위로):

  1. `shared` - 프로젝트/비즈니스의 세부 사항과 분리된 재사용 가능한 기능(예: UIKit, libs, API)
  2. `entities` - 비즈니스 엔티티(예: User, Product, Order)
  3. `features` - 사용자 인터렉션, 사용자에게 비즈니스 가치를 가져다주는 액션들(예: SendComment, AddToCart, UsersSearch)
  4. `widgets` - 엔티티(entities)와 기능(features)을 의미 있는 블록으로 결합하는 구성(compositional) 레이어(예: IssuesList, UserProfile)
  5. `pages` -  엔티티(entities), 기능 (features) 및 위젯(widgets)에서 전체 페이지를 구성하는 구성 (compositional) 레이어
  6. `processes(deprecated)` - 복잡한 페이지 간 시나리오. (예: authentication)
  7. `app` - 앱 전체 설정, 스타일 및 공급자

그런 다음 비즈니스 도메인 별로 코드를 분할하는 슬라이스(slices)가 있다. 이렇게 하면 논리적으로 관련된 모듈을 서로 가깝게 유지하여 코드베이스를 쉽게 탐색할 수 있다. 슬라이스는 같은 레이어에 있는 다른 슬라이스를 사용할 수 없으므로 응집력이 높고 결합력이 낮다.

각 슬라이스는 차례로 세그먼트(segments)로 구성된다. 세그먼트는 슬라이스 내의 코드를 기술적 목적에 따라 분리하는 데 도움이 되는 작은 모듈이다. 가장 일반적인 세그먼트는 `ui`, `model`(store, actions), `api`, `lib`(utils/hooks)이지만, 필요에 따라 일부를 생략하거나 더 추가할 수 있다.

Note
대부분의 경우, API 클라이언트가 스토리지(GraphQL, TanStack Query 등)인 경우를 제외하고는 
shared 레이어에만 `api` 및 `config`을 배치하는 것이 좋다.

Example : 예시

소셜 네트워크 애플리케이션이라고 생각해 보자.

  • `app/` : 라우팅, 스토어 및 글로벌 스타일 설정을 포함한다.
  • `pages/` : 애플리케이션에서 각 페이지에 대한 라우팅 컴포넌트를 포함하며, 대부분의 로직이 거의 없다.

애플리케이션 내부에서 뉴스 피드에 있는 포스트 카드(post card)를 생각해 보자.

  • `widgets` : 백엔드의 호출과 관련 있는 콘텐츠 및 상호 작용 가능한 버튼이 포함된 "조립형(assembled)" 포스트 카드를 포함한다.
  • `features` : 카드의 상호작용(예: 좋아요 버튼)과 상호작용을 처리하는 로직을 포함한다.
  • `entities` : 콘텐츠 및 상호 작용 가능한 요소를 위한 슬롯(slots)이 있는 카드의 셸(shell)을 포함한다. 글 작성자를 나타내는 타일(tile)도 여기에 있지만 다른 슬라이스에 있다.

Advantages : 장점

  • 통일성
    코드는 영향 범위(layers), 도메인(slices), 기술 목적(segments)에 따라 구성된다.
    이를 통해 초보자도 쉽게 이해할 수 있는 표준화된 아키텍처가 만들어진다.
  • 제어된 로직 재사용
    각 아키텍처 컴포넌트는 각가의 목적과 예측 가능한 종속성(dependencies)이 있다.
    이는 DRY 원리를 따르는 것과 적응 가능성(adaptation possibilities) 사이의 균형을 유지할 수 있다.
  • 변경 및 리팩토리에 대한 안정성
    특정 레이어에 있는 모듈은 같은 레이어 또는 그 위의 레이어에 있는 다른 모듈을 사용할 수 없다.
    따라서 예기치 않은 결과 없이 격리된 수정이 가능하다.
  • 비즈니스 및 사용자 요구사항에 대한 오리엔테이션
    앱이 비즈니스 도메인으로 분할되면 코드를 탐색하여 모든 프로젝트 기능을 발견하고 더 깊이 이해할 수 있다.

Incremental adoption(점진적 도입)

FSD의 강점은 구조 분해에 있다. 최상의 상태에서 코드의 모든 부분을 거의-결정론적(near-deterministically)으로 찾을 수 있다. 그러나 분해 레벨은 매개변수이므로 각 팀은 이를 조정해 단순한 채택과 이점 사이에서 균형을 맞출 수 있다.


다음은 경험을 바탕으로 기존 코드베이스를 FSD로 마이그레이션 하기 위한 제안된 전략이다:

  1. `app`과 `shared` 레이어의 윤곽을 그려 기초를 만드는 것부터 시작해라. 대개 이러한 레이어는 가장 작은 레이어이다.
  2. FSD 규칙을 위반하는 종속성이 있어도 기존 UI를 `widgets`과 `pages`로 모두 나누어라.
  3. `features`와 `entities`로 분리하고 페이지와 위젯을 로직이 있는 레이어에서 순순한 구성의 레이어로 전환하여 분해의 정밀도를 점진적으로 높여라.

프로젝트의 특정 부분만 리팩터링 하거나 리팩터링 하면서 새로운 대형 엔티티를 추가하는 것은 자제하는 것이 좋다.

본 내용은 https://profy.dev/article/react-folder-structure를 해석한 것이다.

 

리액트 폴더 구조는 리액트의 비편향적인 접근으로 인해 수년동안 논의되어 왔으며, 개발자들은 "파일을 어디에 넣어야 할까? 코드를 어떻게 정리해야할까?"라고 질문했다.

 

나는 리액트 프로젝트 구성에 대한 아주 대중적인 접근법을 찾았다.

  • 컴포넌트, 컨텍스트, 훅과 같은 파일 타입별로 그룹화하기
  • 컨텍스트, 훅 등을 위한 전역 폴더를 사용해 페이지별로 그룹화하기
  • 관련 컴포넌트, 컨텍스트 및 훅를 배치하여 페이지별로 그룹화하기
  • feature(기능)별로 그룹화하기

이 글은 코드가 커짐에 따라 폴더 구조가 어떻게 진화했는지, 이로 인해 발생한 문제할 수 있는 문제, React Job Simulator의 설계를 feature(기능) 기반 폴더 구조로 변경하기 위한 도전에 대해 알아본다.

최종 feature(기능) 기반 폴더 구조가 포함된 예제 프로젝트는 Github repository에서 확인할 수 있다.

모든 세부사항을 다루기보다는 큰 그림에 집중할 예정이다. 이야기를 정리하고 개념일 설명하기 위해 가상의 스타트업이 차세대 큰 제품(todo app)개발하는 여정으로 따라가 볼 것이다.

 

프로토타입 : 파일 타입별로 그룹화하기

첫번째로 간단한 할일 항목 목록을 만든다. 리액트 문서에 따르면 폴더 구조를 결정하는데 5분도 걸리지 않는다. 이 문서에서 가장 간단한 해결책은 "타입별로 파일을 그룹화"하는 접근이다. 컴포넌트는 `components`폴더에, 훅은 `hooks` 폴더에, 컨텍스트는 `contexts`폴더에 넣는다. 우리는 컴포넌트, 스타일, 테스트 등에 따라 폴더를 생성한다.

└── src/
    ├── components/
    │   │   # 가독성을 위해 내부 파일들은 생략
    │   ├── button/
    │   ├── card/
    │   ├── checkbox/
    │   ├── footer/
    │   ├── header/
    │   ├── todo-item/
    │   └── todo-list/
    │       ├── todo-list.component.js
    │       └── todo-list.test.js
    ├── contexts/
    │   └── todo-list.context.js
    └── hooks/
        └── use-todo-list.js

이는 초보자가 시작하기에 간단한 방법이다. 하지만 이 간단한 방법이 오래 지속되지는 않을 것이다.

 

투자: 더많은 파일 → 중첩

새로운 기능으로 투자자에게 깊은 인상을 심어줄 필요가 있었기 때문에 할일 항목의 편집을 지원하기로 결정했다.  할 일을 편집할 수 있는 폼과 폼을 표시하는 모달을 추가한다.

└── src/
    ├── components/
    │   ├── button/
    │   ├── card/
    │   ├── checkbox/
    │   │   # 할일 항목을 편집할 수 있는 양식이 있는 모달
    │   ├── edit-todo-modal/
    │   ├── footer/
    │   ├── header/
    │   ├── modal/
    │   ├── text-field/
    │   │   # 모달에서 보여줄 양식
    │   ├── todo-form/
    │   ├── todo-item/
    │   │   # 편집 모달은 할일 항목의 상단에 보임
    │   └── todo-list/
    │       ├── todo-list.component.js
    │       └── todo-list.test.js
    ├── contexts/
    │   ├── modal.context.js
    │   └── todo-list.context.js
    └── hooks/
        ├── use-modal.js
        ├── use-todo-form.js
        └── use-todo-list.js

컴포넌트 폴더가 복잡해지고 있다. 컴포넌트를 그룹화하고 재배치해보자.

└── src/
    ├── components/
    │   ├── edit-todo-modal/
    │   │   ├── edit-todo-modal.component.js
    │   │   ├── edit-todo-modal.test.js
    │   │   │   # 재배치 -> todo-form은 edit-todo-modal 내부에서만 사용
    │   │   ├── todo-form.component.js
    │   │   └── todo-form.test.js
    │   ├── todo-list/
    │   │   │   # 재배치 -> todo-item은 todo-list에서만 사용
    │   │   ├── todo-item.component.js
    │   │   ├── todo-list.component.js
    │   │   └── todo-list.test.js
    │   │   # 간단한 ui 컴포넌트는 한 곳에 모음
    │   └── ui/
    │       ├── button/
    │       ├── card/
    │       ├── checkbox/
    │       ├── footer/
    │       ├── header/
    │       ├── modal/
    │       └── text-field/
    ├── contexts/
    │   ├── modal.context.js
    │   └── todo-list.context.js
    └── hooks/
        ├── use-modal.js
        ├── use-todo-form.js
        └── use-todo-list.js

 이 구조는 부모와 자식 컴포넌트들을 함께 배치하고 `ui`폴더에 일반적인 UI 컴포넌트를 그룹화함으로써 더 나은 개요를 제공한다.

폴더를 접으면 더 깔끔한 구조가 명확해진다.

└── src/
    ├── components/
    │   ├── edit-todo-modal/
    │   ├── todo-list/
    │   └── ui/
    ├── contexts/
    └── hooks/

성장: 페이지가 필요하다

스타트업은 더 성장했다. 우리는 대중들에게 앱을 런칭하고 소수의 사용자를 확보했다. 물론, 사용자들이 곧바로 불만을 제기하기 시작했다. 가장 중요한 것은 자신만의 할일 항목을 만들고 싶다는 것이다.

 

그래서 우리는 양식을 통해 할일을 생성하는 두번째 페이지를 추가한다. 운좋게도 우리는 기존 할일 편집 양식을 재사용할 수 있다.

또한 사용자 인증이 필요하며공유되는 todo-form을 컴포넌트 폴더로 다시 이동해야한다.

 

└── src/
    ├── components/
    │   │   # 여러 페이지를 가질 수 있음
    │   ├── create-todo-page/
    │   ├── edit-todo-modal/
    │   ├── login-page/
    │   │   # todo-list가 보여지는 곳
    │   ├── home-page/
    │   ├── signup-page/
    │   │   # 이 양식은 create page와 edit modal 사이에서 공유됨
    │   ├── todo-form/
    │   ├── todo-list/
    │   │   ├── todo-item.component.js
    │   │   ├── todo-list.component.js
    │   │   └── todo-list.test.js
    │   └── ui/
    ├── contexts/
    │   ├── modal.context.js
    │   └── todo-list.context.js
    └── hooks/
        │   # 인증을 다룸
        ├── use-auth.js
        ├── use-modal.js
        ├── use-todo-form.js
        └── use-todo-list.js

현재 폴더 구조에 대해 어떻게 생각하나요? 몇몇개의 이슈가 있다.

 

1. `components`폴더는 복잡해졌다. 하지만 구조를 평평하게 유지하면서 이 문제를 피할 수 없다? 따라서 이문제는 무시한다.

2. `components`폴더는 혼합된 다른 컴포넌트를 포함한다.

  • 페이지(앱의 진입점)
  • 잠재적인 사이드 이펙트가 있는 복잡한 컴포넌트
  • 간단한 UI 컴포넌트 ex) 버튼

해결책: 별도의 `pages` 폴더를 생성하고, 모든 페이지 컴포넌트와 그 하위 컴포넌트들을 옮긴다. 여러 페이지에서 사용되는 컴포넌트는 `components` 폴더에 보관한다.

└── src/
    ├── components/
    │   │   # 이 폼은 home과 create-todo 페이지에서 사용
    │   ├── todo-form/
    │   │   # components 폴더를 평평하게 만들기 위해 이 폴더는 분해하지 않는다.
    │   └── ui/
    ├── contexts/
    │   ├── modal.context.js
    │   └── todo-list.context.js
    ├── hooks/
    │   ├── use-auth.js
    │   ├── use-modal.js
    │   ├── use-todo-form.js
    │   └── use-todo-list.js
    └── pages/
        ├── create-todo/
        ├── home/
        │   ├── home-page.js
        │   │   # 재배치 -> edit modal은 home page에서만 사용
        │   ├── edit-todo-modal/
        │   └── todo-list/
        │       ├── todo-item.component.js
        │       ├── todo-list.component.js
        │       └── todo-list.test.js
        ├── login/
        │   # don't forget the legal stuff :)
        ├── privacy/
        ├── signup/
        └── terms/

이 깔끔한 구조는 신규 개발자가 모든 페이지를 식별하는 데 도움이 되며 코드베이스를 조사하거나 애플리케이션을 디버깅할 수 있는 진입점을 제공한다. 많은 개발자가 비슷한 구조를 사용한다.

 

세계 정복: colocation

우리의 할일 앱은 최고의 앱이 되었다. 팀과 코드베이스가 성장하면서 우리는 몇몇 도전에 직면했다.

└── src/
    ├── components/
    ├── contexts/
    │   ├── modal.context.js
    │   ├── ...  # imagine more contexts here
    │   └── todo-list.context.js
    ├── hooks/
    │   ├── use-auth.js
    │   ├── use-modal.js
    │   ├── ...  # imagine more hooks here
    │   ├── use-todo-form.js
    │   └── use-todo-list.js
    └── pages/

전역 `hooks`와 `contexts` 폴더는 복잡해지고 복잡한 컴포넌트의 코드가 여러 폴더에 흩어져 종속성을 추적하기가 더 어려워졌다.

 

해결책 : colocation! 가능한 `hooks`와 `contexts`를 해당 컴포넌트 옆으로 이동한다.

└── src/
    ├── components/
    │   ├── todo-form/
    │   └── ui/
    ├── hooks/
    │   │   # not much left in the global hooks folder
    │   └── use-auth.js
    └── pages/
        ├── create-todo/
        ├── home/
        │   ├── home-page.js
        │   ├── edit-todo-modal/
        │   └── todo-list/
        │       ├── todo-item.component.js
        │       ├── todo-list.component.js
        │       ├── todo-list.context.js
        │       ├── todo-list.test.js
        │       │   # colocate -> 이 훅은 todo-list 컴포넌트에서만 사용
        │       └── use-todo-list.js
        ├── login/
        ├── privacy/
        ├── signup/
        └── terms/

 

전역 `contexts` 폴더를 제거하고, `use-auth`와 전역 `hooks` 폴더만 남았다. 이 구조를 통해 한 기능에 속한 모든 파일을 한 번에 파악할 수 있다.

 

그러나, 여전히 이슈가 남아있다:

1. "todo" 전체 코드는 여러 폴더에 걸쳐 퍼져있다.

2. `todo-list` 컴포넌트가 `home` 폴더에 있는지 여부가 불분명하다.

└── src/
    ├── components/
    ├── hooks/
    └── pages/
        ├── create-todo/
        ├── home/
        ├── login/
        ├── privacy/
        ├── signup/
        └── terms/

 

출구 : 기능별로 그룹화하기

10억 달러 규모의 스타트업을 매각하면서 사용자들은 새로운 기능을 요구하고 있다. 할 일 항목(예: 업무 및 장보기 목록)을 위한 별도의 프로젝트를 원한다. 우리는 'project' 엔티티를 추가하고 페이지와 컴포넌트를 변경한다.

└── src/
    ├── components/
    │   ├── todo-form/
    │   │   # home과 project 페이지에서 공유됨
    │   ├── todo-list/
    │   │   ├── todo-item.component.js
    │   │   ├── todo-list.component.js
    │   │   ├── todo-list.context.js
    │   │   ├── todo-list.test.js
    │   │   └── use-todo-list.js
    │   └── ui/
    └── pages/
        ├── create-project/
        ├── create-todo/
        │   # 프로젝트 목록과 모든 할일의 개요를 보여줌
        ├── home/
        │   ├── index.js
        │   ├── edit-todo-modal/
        │   └── project-list/
        ├── login/
        ├── privacy/
        │   # 프로젝트에 속한 할일 목록을 보여줌
        ├── project/
        ├── signup/
        └── terms/

꽤 깔끔해졌으나 몇가지 이슈가 있다.

  • `pages` 폴더에서 이 앱에 할일, 프로젝트 및 사용자가 있는 것이 명확하지 않다. 우리의 두뇌는 먼저 `create-todo`(todo 엔티티) 또는 `login`(user 엔티티) 같은 폴더 명을 처리하여 중요하지 않은 항목(개인정보 및 약관)과 분리해야한다.
  • 컴포넌트의 용도에 따라 공유 `components` 폴더에 컴포넌트를 배치하는 것은 제멋대로 느껴진다??. 우리는 컴포넌트를 찾으려면 해당 컴포넌트가 어디에 몇 번 사용되었는지 알아야한다.
    • 해석 : 컴포넌트가 어디에서 사용되는 지 알 수 없다.

폴더 구조를 조정하고 기능별로 파일을 그룹화해보자.

 

"Feature"는 광범위한 용어이다. 이 경우 엔티티(`todo`, `project`, `user`)와 `ui`폴더(button, form fields, 등과 같은 컴포넌트)를 사용할 것이다.

 

└── src/
    ├── features/
    │   │   # todo "feature" 는 할일과 관련된 것을 포함
    │   ├── todos/
    │   │   │   # 관련 모듈을 내보내는 데 사용되는데, 일명 public API(자세한 내용은 잠시 후에 설명).
    │   │   ├── index.js
    │   │   ├── create-todo-form/
    │   │   ├── edit-todo-modal/
    │   │   ├── todo-form/
    │   │   └── todo-list/
    │   │       │   # 컴포넌트의 public API (todo-list 컴포넌트와 훅을 내보낸다)
    │   │       ├── index.js
    │   │       ├── todo-item.component.js
    │   │       ├── todo-list.component.js
    │   │       ├── todo-list.context.js
    │   │       ├── todo-list.test.js
    │   │       └── use-todo-list.js
    │   ├── projects/
    │   │   ├── index.js
    │   │   ├── create-project-form/
    │   │   └── project-list/
    │   ├── ui/
    │   │   ├── index.js
    │   │   ├── button/
    │   │   ├── card/
    │   │   ├── checkbox/
    │   │   ├── header/
    │   │   ├── footer/
    │   │   ├── modal/
    │   │   └── text-field/
    │   └── users/
    │       ├── index.js
    │       ├── login/
    │       ├── signup/
    │       └── use-auth.js
    └── pages/
        │   # 페이지 폴더에 남은 것은 간단한 JS 파일뿐
        │   # 각 파일은 페이지를 나타냄(Next.js 처럼)
        ├── create-project.js
        ├── create-todo.js
        ├── index.js
        ├── login.js
        ├── privacy.js
        ├── project.js
        ├── signup.js
        └── terms.js

각 폴더에 `index.js` 파일을 모듈 또는 컴포넌트의 "barrel files 또는 "public API"로 소개한다. 이 새로운 "기능별 그룹" 폴더 구조는 이전 문제를 해결한다.

 

결론 : Feature-Driven 폴더 구조와 Screaming 아키텍처

Screaming Architexture에서 Bob Martin은 "아키텍처는 사용된 프레임워크가 아닌 시스템을 독자(코드를 보는 사람)에게 알려야 한다"고 말한다.

 

초기 폴더 구조는 파일을 유형별로 그룹화했다: 이는 "나는 리액트 앱이야"라고 말하는 것과 같다.

└── src/
    ├── components/
    ├── contexts/
    └── hooks/

 

이와 대조적으로 최종 결과물인 feature-driven 폴더 구조: 이는 "나는 프로젝트 관리 도구야"라고 말하는 것과 같다. 이는 Bob의 비전과 같다.

└── src/
    ├── features/
    │   ├── todos/
    │   ├── projects/
    │   ├── ui/
    │   └── users/
    └── pages/
        ├── create-project.js
        ├── create-todo.js
        ├── index.js
        ├── login.js
        ├── privacy.js
        ├── project.js
        ├── signup.js
        └── terms.js

게다가 이 구조는 새로운 개발자가 쉽게 코드 베이스를 배울 수 있게 만들어 줄 두가지 진입점(`features`, `pages`)을 제공한다. 추가로, 전역 `contexts`와 `hooks` 폴더가 제거하여 잠재적인 쓰레기 처리장(dumping ground)를 줄였다.

 

폴더 구조는 깔끔하고 설명이 명확하며 적응력이 뛰어나다. 기능 중심의 폴더 구조로 시작하면 앱을 장기적으로 정리하는 데 도움이 된다.

기능 중심 폴더 구조에 대해 자세히 알아보려면 다음 리소스를 확인하세요:

 

'React > 서비스 폴더 구조' 카테고리의 다른 글

FSD - Reference - Slices and segments  (0) 2023.10.31
FSD - Reference - Layers  (1) 2023.10.17
FSD - Overview  (1) 2023.10.14

Describing the UI

Your First Component

Importing and Exporting Components

Writing Markup with JSX

Javascript in JSX with Curly Braces

Passing Props to a Component

2023.03.05 - [React/Learn React] - Describing the UI ☞ Conditional Rendering

Rendering Lists

2022.09.24 - [React/Learn React] - Describing the UI ☞ Keeping Components Pure

Adding Interactivity

Responding to Events

State: A Component's Memory

2022.09.24 - [React/Learn React] - Adding Interactivity ☞ Render and Commit

2022.09.25 - [React/Learn React] - Adding Interactivity ☞ State as a Snapshot

2022.12.04 - [React/Learn React] - Adding Interactivity ☞ Queueing a Series of State Updates

Updating Objects in State

Updating Arrays in State

Managing State

2022.09.25 - [React/Learn React] - Managing State ☞ Reacting to Input with State

2022.10.01 - [React/Learn React] - Managing State ☞ Choosing the State Structure

2022.10.01 - [React/Learn React] - Managing State ☞ Sharing State Between Components

2023.04.01 - [React/Learn React] - Managing State ☞ Preserving and Resetting State

2022.10.03 - [React/Learn React] - Managing State ☞ Extracting State Logic into a Reducer

2022.10.30 - [React/Learn React] - Managing State ☞ Passing Data Deeply with Context

2022.10.31 - [React/Learn React] - Managing State ☞ Scaling Up with Reducer and Context

Escape Hatches

2022.12.04 - [React/Learn React] - Escape Hatches ☞ Referencing Values with Refs

2023.04.23 - [React/Learn React] - Escape Hatches ☞ Manipulating the DOM with Refs

Synchronizing with Effects

You Might Not Need an Effect

Lifecycle of Reactive Effects

Separating Events from Effeects

Removing Effect Dependencies

Reusing Logic with Custom Hooks

https://react.dev/learn/manipulating-the-dom-with-refs 를 읽고 정리한다.

 

리액트는 렌더링 된 결과물을 매칭하기 위해 DOM을 자동적으로 업데이트한다. 그래서 대부분의 컴포넌트는 따로 조작할 필요가 없다. 그러나 가끔, 리액트가 관리하는 DOM 요소를 접근하는 경우도 있다.

  • 예를 들어 node에 focus, scroll, 또는 node의 사이즈나 위치를 측정하는 일이 있다.

리액트에서는 이런 것들을 할 수 있는 내장된 방법이 없다, 그래서 DOM node에 ref가 필요하다.

 

node의 ref 얻기

1. useRef 훅을 import한다.

import { useRef } from 'react';

2. 컴포넌트 내부에서 useRef 훅을 사용해 ref를 정의한다.

const myRef = useRef(null);

3. DOM node에 ref 어트리뷰트로 전달한다.

<div ref={myRef}>

 

useRef 훅은 current로 불리는 단일 속성을 가지는 객체를 반환한다.

  • 초기에 myRef.currentnull이다.
  • 그 후 리액트가 DOM node를 생성할 때, 리액트는 node의 참조값을 myRef.current에 넣는다. 그런 다음 DOM node에 접근하여 이벤트 핸들러나 내재된 browser API를 사용할 수 있다.
// You can use any browser APIs, for example:
myRef.current.scrollIntoView();

 

예: text input에 focus 주기

버튼을 누르면 text input에 focus를 주도록 구현해 보자.

 

 

1. useRef 훅을 사용해 inputRef 를 정의한다.

2. <input ref={inputRef}>로 전달한다. 이는 리액트에게 inputRef.current<input>의 DOM 노드를 전달하라는 의미이다.

3. handleClick 함수에서, inputRef.current를 사용해 input DOM을 읽고 inputReft.current.focus()focus()를 호출한다.

4. handleClick 이벤트 핸들러를 <button>onClick으로 전달한다.

 

DOM 조작은 ref를 사용하는 가장 흔한 경우이지만, timer ID와 같은 리액트 밖의 것을 저장하기 위해 사용되기도 한다. state과 유사하게 ref는 렌더링사이에 유지된다. Ref는 state 변수와 비슷하지만 설정 시 리렌더링을 트리거하지 않는다. 이는 Referencing Values with Refs에서 확인할 수 있다.

예: 요소에 Scroll 하기

컴포넌트에서 ref는 여러 개가 있을 수 있다. 예에서는 3개 이미지가 있다. 각 버튼은 DOM node에서 브라우저의 scrollIntoView()를 호출하여 이미지를 중앙에 배치한다.

 

DEEP DIVE: ref 콜백을 사용해 ref 목록을 관리하기

위의 예를 보면 refs의 수가 사전 정의되어 있다. 그러나, 가끔 목록의 각 아이템에 ref가 필요한데 아이템의 총개수를 모를 수 있다. 이때 아래의 코드는 동작하지 않는다.

<ul>
  {items.map((item) => {
    // Doesn't work!
    const ref = useRef(null);
    return <li ref={ref} />;
  })}
</ul>

그 이유로 훅은 컴포넌트의 최상위 레벨에서만 호출할 수 있기 때문이다. 그래서 루프 내부나 조건 또는 map() 호출 내부에서 useRef를 호출할 수 없다.

 

1. 한 가지 가능한 방법은 부모 요소의 ref를 얻고, querySelectorAll과 같은 DOM 조작 메서드를 사용하여 개별 자식 노드를 "찾는" 것이다. 그러나 이 방법은 깨지기 쉬우며, DOM 구조가 변경되면 중단될 수 있다.

 

2. 다른 해결책으로는 ref 어트리뷰트에 함수를 전달하는 것이다. 이는 ref callback으로 불린다. 리액트는 ref가 설정되면 DOM node의 ref callback을 호출하고, 지울 때가 되면 null로 호출한다. 이를 통해 자신의 배열이나 맵(Map)을 유지 관리하고 index나 ID로 모든 참조에 접근할 수 있다.

이 예제는 이 접근 방법을 통해 긴 목록에서 임의의 노드를 스크롤하는 방법을 보여준다.

 

 

이 예에서 itemsRef는 단일 DOM node를 가지지 않는다. 대신 item ID에서 DOM node로 접근할 수 있는 Map을 가진다.(Ref는 모든 값을 가질 수 있다!) 모든 list item의 ref callback은 Map을 업데이트한다.

<li
  key={cat.id}
  ref={node => {
    const map = getMap();
    if (node) {
      // Add to the Map
      map.set(cat.id, node);
    } else {
      // Remove from the Map
      map.delete(cat.id);
    }
  }}
>

이렇게 하면 나중에 Map에서 개별적인 DOM node를 읽을 수 있다.

 

다른 컴포넌트의 DOM node로 접근하기

내장 컴포넌트(<input />)에 ref를 넣을 경우, 리액트는 해당 ref의 current 프로퍼티를 해당 DOM 노드(브라우저 내 실제 <input />)로 설정한다.

 

그러나 <MyInput />과 같이 자신의 컴포넌트에 ref를 넣고 싶은 경우, 기본적으로 null을 얻게 된다. 다음은 이를 보여주는 예시이다. 버튼을 클릭해도 input에 focus가 되지 않는 것을 확인할 수 있다.

 

 

이슈 확인을 돕기 위해, 리액트는 콘솔에 아래와 같은 에러를 출력한다.

Warning: Function components cannot be given refs.
Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

리액트가 기본적으로 다른 컴포넌트의 DOM node에 접근하는 것을 허용하지 않기 때문에 발생한다. 심지어 자식도 마찬가지이다! 이는 의도적이다.

ref는 탈출구이기 때문에 아주 조금만 사용해야 한다. 다른 컴포넌트의 DOM node를 수동으로 조작하면 코드가 훨씬 더 깨지기 쉬워진다.(취약해진다.)

 

만약 자신의 DOM 노드를 내보내길(export) 원하는 컴포넌트들은 forwardRef API를 사용해 구현할 수 있다.

  • 이때, 컴포넌트는 자신의 ref를 자식 중 하나만 "전달" 가능하다. 따라서 내보낼 자식을 선택해야 한다.

MyInput forwardRef API를 사용하는 방법은 다음과 같다.

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});

이는 아래와 같이 동작한다.

1. <MyInput ref={inputRef} />는 리액트가 inputRef.current에 해당 DOM node에 넣으라고 지시한다. 그러나 이를 선택할지는 MyInput 컴포넌트의 선택에 달려있으며, 기본적으로 그렇지 않다.

2. MyInput 컴포넌트는 forwardRef를 사용해 정의되어 있다. 이는 props 다음에 선언되는 두 번째 ref인수로 inputRef를 받도록 선택된다.

3. MyInput은 받은 ref를 그 안에 있는 <input>으로 전달한다.

 

이제 버튼을 클릭하면 input에 focus가 동작한다.

 

 

디자인 시스템에서 button, input 등과 같은 낮은 수준의 컴포넌트는 해당 ref를 DOM node로 전달하는 것은 흔한 패턴이다. 반면에, 고수준의 컴포넌트는 DOM 구조에 대한 우발적인 종속성을 피하기 위해 일반적으로 해당 DOM node를 노출(export) 하지 않는다.

 

DEEP DIVE : imperative handle를 사용해 API의 하위 집합 노출하기

위 예에서 MyInput은 원본 DOM input 요소를 노출한다. 이는 부모 컴포넌트가 focus()를 호출할 수 있도록 허락한다. 이는 또한 부모 컴포넌트가 다른 것들도 할 수 있다. 예를 들어, CSS style도 변경할 수 있다. 드문 경우로, 노출되는 기능을 제한하고 싶을 수도 있다. useImperativeHandle로 이를 수행할 수 있다.

 

 

여기 MyInputrealInputRef은 실제 input DOM node를 가진다. 그러나, useImperativeHandle은 리액트가 부모 컴포넌트에게 ref 값으로 고유한 특수 객체를 공급하도록 지시한다. 그래서 Form 컴포넌트 내에 inputRef.cureent는 오직 focus 메서드만 가질 수 있다. 이 경우에, ref "handle"은 DOM node가 아니라 useImperativeHandle 호출 내에서 생성한 사용자 지정 객체이다.

 

리액트가 ref 설정하는 시점 : 커밋 중 DOM 업데이트가 되었을 때

리액트에서 모든 업데이트는 두 단계로 나뉜다.

  • 렌더링 하는 동안, 리액트는 컴포넌트를 호출하여 화면에 무엇이 표시되어야 하는지 파악한다.
  • 커밋하는 동안, 리액트는 DOM에 변경사항을 적용한다.

보통 렌더링동안 ref에 접근하는 것을 원하진 않는다. 그것은 DOM node를 가진 ref에 대해서도 마찬가지이다.

  • 첫 번째 렌더링 동안, DOM node는 아직 생성되지 않았고, 그래서 ref.cureent는 null이 될 것이다.
  • 그리고 변경사항이 렌더링 될 동안, DOM node는 아직 변경사항이 적용되지 않았다. 따라서 ref를 읽기에는 너무 이르다.

리액트는 커밋하는 동안 ref.current를 설정한다.

  • DOM이 업데이트되기 전, 리액트는 영향을 받는 ref.current 값을 null로 설정한다.
  • DOM이 업데이트된 후, 리액트는 즉시 연관된 DOM node를 ref에 설정한다.

보통 이벤트 핸들러를 통해 ref에 접근한다. 만약 ref로 무언가를 하고 싶지만 이를 수행할 특정 이벤트가 없는 경우, Effect가 필요할 수 있다. Effect에 대해서는 다음 페이지에서 설명하겠다.

 

DEEP DIVE : flushing state는 flushSync와 동기적으로 업데이트된다.

새 할 일을 추가하고, 목록의 마지막 자식까지 화면을 아래로 스크롤하는 코드를 고려해 보자. 항상 마지막으로 추가한 항목 바로 앞에 있던 할 일로 스크롤되는 문제를 확인할 수 있다.

 

 

이 문제는 아래 두 줄과 연관된다.

setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();

리액트에서 state 업데이트는 큐로 수행한다. 일반적으로 이는 사용자가 원하는 것이다. 그러나, 여기에서는 setTodos가 DOM에 즉시 업데이트가 되지 않기 때문에 발생한 문제이다. 그래서 마지막 요소로 목록을 스크롤할 때는 아직 할 일이 추가되지 않은 상태이다. 이것이 스크롤이 항상 한 항목씩 "뒤처지는" 이유이다.

 

이 문제를 해결하기 위해, 리액트에게 DOM을 동기적으로 업데이트("flush")하게 강제할 수 있다. 이를 수행하기 위해, react-dom을 통해 flushSync를 임포트 하고 state 업데이트를 flushSync 호출로 감싼다.

flushSync(() => {
  setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();

이는 flushSync로 감싸진 코드가 실행된 직후 리액트가 DOM을 동기적으로 업데이트하도록 지시한다. 그 결과, 스크롤을 시도할 때 마지막 할 일은 이미 DOM에 존재한다.

 

ref를 이용한 DOM 조작 모범 사례

ref는 탈출구이다. "리액트 밖으로 나가야 할 때"에만 이를 사용해야 한다.

 

일반적인 예로는 focus, scroll 위치를 관리하거나 리액트가 노출하지 않는 브라우저 API를 호출하는 것이 있다. focus, scroll과 같은 비파괴적인 동작을 붙이는 경우에는 문제가 발생하지 않는다.

하지만 DOM을 수동적으로 수정하려고 하면, React가 수행하는 변경 사항과 충돌할 위험이 있다.

 

이 문제를 설명하기 위해, 이 예시에는 환영 메시지와 두 개의 버튼이 있다. 첫 번째 버튼은 조건부 렌더링state를 사용해 그 존재여부를 전환한다. 두 번째 버튼은 remove() DOM API를 사용해 리액트가 제어할 수 없는 DOM에서 강제로 제거한다.

 

"Toggle with setState"을 여러 번 누르면 메시지는 사라지고 나타나기를 반복할 것이다. 그리고 "Remove from the DOM"을 누르면 강제로 메시지가 지워진다. 마지막으로 "Toggle with setState" 눌러보자

 

 

DOM 요소를 수동으로 지운 후, 다시 보여주기 위해 setState를 사용하면 충돌이 난다. 이는 사용자가 DOM을 변경했고, 리액트가 이를 계속 올바르게 관리하는 방법을 모르기 때문이다.

 

리액트가 관리하는 DOM node를 변경하는 것을 피해라. 리액트가 관리하는 요소를 수정하거나 자식을 추가/삭제하는 것은 일관성 없는 시각적 결과 또는 위와 같은 충돌이 발생할 수 있다.

 

그러나, 이를 전혀 할 수 없다는 이야기가 아니라 주의가 필요하다는 이야기이다. 리액트가 업데이트할 이유가 없는 DOM의 일부를 안전하게 수정할 수 있다. 예를 들어, JSX에 일부 <div>가 항상 비어있다면 React는 그 자식들을 건드릴 이유가 없다. 따라서 수동적으로 요소를 추가/제거하는 것이 안전하다.

 

https://react.dev/learn/preserving-and-resetting-state를 읽고 정리한다.

state는 컴포넌트 사이에 격리된다.

리액트는 UI 트리에서 어떤 컴포넌트가 어떤 state에 속해 있는지를 추적한다.

개발자는 리렌더링 사이에 state 보존(preserve) 또는 초기화(reset) 시기를 제어할 수 있다.

UI tree

브라우저들은 UI를 모델링하기위해 많은 트리 구조를 사용한다.

리액트 또한 UI를 관리하고 모델링하기위해 트리 구조를 사용한다.

  1. React는 JSX로부터 UI 트리를 만든다.
  2. 그런 다음 React DOM은 해당 UI와 일치하도록 브라우저 DOM 요소들을 업데이트한다.

컴포넌트로 리액트는 UI 트리를 생성하며, DOM을 렌더링하기 위해 React DOM이 UI 트리를 사용한다.

state는 트리 내 위치와 엮여있다.

컴포넌트 state가 주어지면, 컴포넌트 내부에 state가 "존재"한다고 생각할 수 있다. 하지만 state는 실제로 리액트 내부에 보관된다. 리액트는 UI 트리에서 해당 컴포넌트의 위치에 따라 보유하고 있는 각 state를 올바른 컴포넌트와 연결한다.

 

아래의 코드에서 <Counter /> JSX 태그는 오직 한번만 존재하지만, 서로 다른 위치에 렌더링된다.

export default function App() {
  const counter = <Counter />;
  return (
    <div>
      {counter}
      {counter}
    </div>
  );
}

 

트리로 보면 아래와 같다.

 

트리에서 각각 고유한 위치에 렌더링되기 때문에, 두 가지 Counter들은 분리되어 있다.

이는 일반적으로 리액트를 사용하기 위해 필수적으로 알아야하는 개념은 아니지만, 어떻게 동작하는지를 이해하는 데 유용하다.

 

리액트에서 화면의 각 컴포넌트는 완전히 격리된 state를 가진다.

  • 예를 들어, 두 Counter 컴포넌트를 나란히 렌더링하게 되면, 각각 고유한 독립적인 scorehover state를 가진다.

그래서 위의 코드 결과에서 Counter를 각각 눌러보면 서로 영향을 주지 않는 것을 확인할 수 있다.

 

리액트는 같은 위치에서 같은 컴포넌트를 렌더링하는 한 state는 유지된다.

이를 확인하기 위해 (1) 두 Counter를 증가시키고, (2)"Render the secound counter" 체크 박스에 체크를 없애 두번째 컴포넌트만 지운 후, (3) 다시 체크를 해서 두번째 컴포넌트를 추가해보자.

  • (2) 두 번째 컴포넌트에 대한 렌더링을 중단하는 순간, 해당 state는 완전히 사라진다!
    • 리액트는 컴포넌트가 지워지면 그 state도 사라지기 때문에 이러한 현상이 발생한다.
  • (3) 다시 체크 박스를 클릭하면, 두 번째 컴포넌트가 렌더링되고, 그 state가 처음부터 초기화되고 DOM에 추가된다.

 

즉, 리액트는 컴포넌트가 UI 트리에서 해당 위치에 렌더링되는 한 그 state를 보존한다. 만약 제거되거나 같은 위치에 다른 컴포넌트가 렌더링된다면, 리액트는 해당 컴포넌트의 state를 버린다.

동일 위치에 같은 컴포넌트가 있을 경우, state가 보존(유지)한다.

이 예제에서 두가지 다른 Counter tag가 있다.

 

체크 박스를 여러번 눌러도, counter state는 리셋되지 않는다. isFancytrue인지 false인지와 상관없이, 최상위 App 컴포넌트로부터 반환된 div의 첫번째 자식으로 항상 <Counter />를 가지기 때문이다.

 

App state를 변경하더라도 Counter는 동일한 위치에 존재하기 때문에 그 상태를 초기화하지 않느다.

Pitfall : 중요한 것은 JSX 마크업이 아니라 UI 트리에서의 위치라는 것을 기억하자!

아래 컴포넌트는 if를 기준으로 두가지 다른 <Counter />를 반환한다.

체크 박스를 누르면 state가 리셋되기를 기대하겠지만, 이는 틀렸다! 두 <Counter /> 태그가 모두 같은 위치에 렌더링되기 때문이다.

리액트는 함수 내 조건에 따라 어디에 위치하는 지를 알 수 없다. 리턴하는 tree 만 "볼 수"있다.

두 경우 모두 App 컴포넌트가 첫번째 자식으로 <Counter />를 가진 <div>를 리턴하기 때문에 리액트가 동일한 <Counter />로 간주한다.

루트의 첫번째 자식의 첫번째 자식이 같은 "주소"를 갖는다고 생각하면 된다.

이는 로직을 어떻게 구성하든 상관없이 리액트가 이전과 다음 렌더링을 매칭하는 방법이다.

동일한 위치에 있는 다른 컴포넌트가 있을 경우, state가 초기화된다.

동일한 위치에서 다른 컴포넌트(<p>)로 전환하는 것은 기존 컴포넌트를 제거 후 다른 컴포넌트를 렌더링하는 것과 같다.

  • 기존 컴포넌트를 제거할 때, 그 아래의 전체 트리(컴포넌트, state, ...)도 모두 제거된다.

즉, 동일한 위치에서 다른 컴포넌트로 전환하면 기존 컴포넌트의 state가 초기화된다.

  • 컴포넌트 타입이 달라도 동일하게 적용된다.

리렌더링 사이에 state를 유지하려면, 트리 구조가 렌더링할 때마다 일치해야한다.

  • 만약 구조가 다르다면, 위에서 말한 것처럼 리액트는 트리에서 컴포넌트가 제거될 때 state를 제거하므로 state는 제거된다.

예) 체크 박스를 클릭시 <Counter><p> 전환

카운터 내 버튼을 통해 카운터의 점수를 올린 후 체크 박스를 두 번 클릭하면, 카운터가 새로 렌더링될 때 점수는 0으로 초기화되는 것을 볼 수 있다.

pitfall : 컴포넌트 함수 정의를 중첩시키면 안된다.

  • 컴포넌트 함수 정의 중첩이란?
    • 컴포넌트 함수 정의 내부에 다른 컴포넌트 함수를 정의하는 것

문제점 : 의도치 않은 버그가 생기거나 성능 문제가 발생한다.

  • 컴포넌트 함수 정의가 중첩되면 상위 컴포넌트의 렌더링될 때(=상태 변경될 때)마다 내부에 정의된 컴포넌트 함수가 새로 생성된다. 이로 인해 내부에 정의된 컴포넌트의 상태가 초기화된다.이 때문에 버그나 성능 문제가 발생하게 된다.
  • 예) MyTextField 컴포넌트가 MyComponent 컴포넌트 내부에 정의
    • 버튼을 누르면 MyComponent 컴포넌트의 counter state가 변경되며 리렌더링이 발생한다. 이때 MyComponent가 새로 생성되면서, 내부의 input state가 초기화된다. 따라서 버튼을 누를 때마다 input state가 사라진다.

해결 방법 : 컴포넌트 함수를 최상위 레벨에서 선언하고 정의를 중첩시키지 않아야 한다.

 

동일한 위치에서 state를 초기화하기

기본적으로 리액트는 동일한 위치에 있는 컴포넌트의 state를 보존한다. 가끔 동일한 위치의 컴포넌트의 state를 초기화하고 싶은 때가 있다.

 

예) 두 명의 플레이어가 매 턴 동안 점수를 추적할 수 있는 앱

  • 현재 플레이어가 변경되어도 점수가 변경되지 않고 유지되는 문제가 있다.
    • Counter 모두 동일한 위치에 나타나기 때문에, 리액트에서는 동일한 Counterperson prop만 변경되는 것으로 인식하기 때문이다.

 

만약 컴포넌트 state를 초기화하고 싶으면, 아래 두 가지 방법 중 하나를 사용할 수 있다.

  1. 다른 위치에 컴포넌트를 렌더링한다.
  2. 각 컴포넌트에 명시적 고윳값인 key를 부여한다.

옵션 1 : 다른 위치에 컴포넌트 렌더링하기

두 개의 Counter를 독립적으로 만드려면, 서로 다른 위치에 렌더링하면된다.

 

즉, 위 예시를 해결하기위해 아래와 같이 코드를 수정하면 된다.(전체 코드 보기)

{isPlayerA &&
  <Counter person="Taylor" />
}
{!isPlayerA &&
  <Counter person="Sarah" />
}
  • isPlayerAtrue이면, 첫 번째 위치에는 Counter가 포함되고, 두 번째 위치는 비어있다.
  • "Next player" 버튼을 클릭하면, 첫 번째 위치는 지워지고, 두 번째 위치에 Counter가 포함된다.

Counter의 state는 DOM에서 제거될 때마다 소멸되기 때문에 버튼을 클릭할 때마다 state가 초기화된다.

 

이 해결 방법은 동일한 위치에서 소수의 독립적인 컴포넌트를 렌더링하고 싶을 때 편리하다.

  • 예시에서는 독립적으로 렌더링할 컴포넌트가 두개 뿐이여서 이 방법을 사용하는 것이 번거롭지 않다.

옵션 2 : key를 사용해 state를 초기화하기

이 방법은 특히 form을 다룰 때 유용하다.

 

예) 왼쪽(ContactList)에 수신자를 선택하고 오른쪽(Chat)에 input과 보내기 버튼이 존재하는 채팅 앱

  • input에 내용을 추가한 후, 왼쪽에 수신자를 변경할 때 input의 내용이 초기화되지 않는 문제가 있다.

key를 추가해 문제를 해결할 수 있다.

<Chat key={to.id} contact={to} />

다른 수신자를 선택할 때, Chat 컴포넌트가 그 아래 트리의 모든 state를 포함해 처음부터 다시 생성된다. 리액트는 DOM 엘리먼트를 재사용하는 대신 다시 생성한다.

  • 현재 예제는 수신자에 따라 input state를 가지므로, 수신자를 기준으로 key를 부여하는 것이 합리적이다.

DEEP DIVE : 컴포넌트가 지워지더라도 state를 유지하기

실제 채팅 앱에서는 사용자가 이전 수신자를 다시 선택할 때 input state를 복구하고 싶을 수도 있다. 이를 가능하게 하는 몇 가지 방법이 있다.

  • 모든 채팅을 다 렌더링하고, CSS를 사용해 숨길 수 있다.
    • 트리에서 채팅이 지워지지 않아 각 state도 유지된다.
    • 특징
      • 간단한 UI를 만들고 싶을 때 좋다.
      • 숨겨진 트리가 많거나 많은 DOM 노드를 포함할 때 매우 느릴 수 있다.
  • 각 수신자에서 작성한 메시지의 state를 상위 컴포넌트로 끌어올려서 보관할 수 있다.
    • 상위 컴포넌트가 정보를 보관하므로 하위 컴포넌트가 지워더라도 state가 유지된다.
    • 특징
      • 가장 일반적인 해결책
  • 리액트 state외 그 밖의 소스를 사용할 수 있다.
    • 예를 들어 사용자가 실수로 페이지를 닫아도 메시지가 유지되기를 원할 수 있다. 이를 구현하기 위해 localStorage를 읽어 상태를 초기화하고 초안도 저장하도록 만들 수 있다.

https://react.dev/learn/conditional-rendering을 읽고 정리한다.

컴포넌트는 종종 조건에 따라 다른 것을 보여줄 필요가 있다.

리액트에서는 JS 문법(if, &&, ? :)을 사용해 조건에 따라 JSX를 렌더링 할 수 있다.

조건에 따라 JSX를 반환하기

PackingList 컴포넌트는 여러 Item들을 렌더링 한다.

Item 컴포넌트들은 isPacked prop이 true가 되면 checkmark(✔)를 추가하고 싶다고 하자.
이는 if/else 문 사용해 해결할 수 있다.

if (isPacked) {
  return <li className="item">{name} ✔</li>;
}
return <li className="item">{name}</li>;

 

조건에 따라 아무것도 반환하지 않기(null)

몇 가지 상황에서, 아무것도 렌더링 하고 싶지 않을 수도 있다. 예를 들어, isPacked===true인 아이템을 보여주고 싶지 않을 수도 있다. 하지만 컴포넌트는 항상 무언가를 반환해야 하므로, 이 경우 null을 리턴할 수 있다.

if (isPacked) {
  return null;
}
return <li className="item">{name}</li>;

실제로 null을 반환하는 것은 일반적이지 않다(렌더링 하려는 개발자를 놀라게 할 수 있기 때문ㅜㅜ).
더 자주 사용되는 방법으로, 부모 컴포넌트의 JSX에서 조건에 따라 특정 컴포넌트를 포함하거나 제외하는 방법이 사용된다. 그 내용은 다음 문단에서 설명하겠다.

조건에 따라 JSX를 포함하기

이전 예제에서는 컴포넌트를 반환하는 것으로 JSX 트리를 컨트롤했다. 렌더 출력에서 아래와 같은 중복이 발생한다.
아래 두 가지 코드는 거의 비슷하다.

<li className="item">{name} ✔</li>
<li className="item">{name}</li>

이러한 중복은 최악은 아니지만, 코드를 유지 보수하기 어렵게 만들 수 있다.

  • 만약 className을 변경하고 싶다면? 두 번 고쳐야 한다!

반복 작업을 제거하기 위해 JSX에서 조건 연산자를 사용할 수 있다.

조건 연산자 사용하기(? :)

isPacked === true이면, name + ' ✔'를 렌더링 하고, 아니면 name을 렌더링 한다.

<개선 전>

if (isPacked) {
  return <li className="item">{name} ✔</li>;
}
return <li className="item">{name}</li>;

<개선 후>

return (
  <li className="item">
    {isPacked ? name + ' ✔' : name}
  </li>
);

다른 HTML 태그에서 item의 텍스트를 더 복잡하게 만들고 싶다고 하자.
케이스마다 많은 JSX 중첩을 쉽게 하기 위해 줄 바꿈과 괄호를 사용해 추가할 수 있다.


이 스타일은 간단한 조건일 때 적합하다. 만약 컴포넌트가 조건을 가진 마크업이 중첩되고 너무 많으면 지저분해지기 때문에 자식 컴포넌트를 추출하여 정리하는 것을 고려해야 한다. React에서 마크업은 코드의 일부임으로 변수나 함수와 같은 도구를 사용해 복잡한 표현식을 정리할 수 있다.

And 연산자(&&)

또 다른 일반적인 방법은 JS의 AND 연산자를 사용하는 것이다. 리액트 컴포넌트들에서, 조건이 true일 때만 아니면 일부 JSX를 렌더링 하고 그렇지 않으면 아무것도 렌더링 하지 않을 때 자주 사용된다.
&&를 사용해 조건적으로 isPackedtrue일 때 체크 마크를 렌더링 할 수 있다.

return (
  <li className="item">
    {name} {isPacked && '✔'}
  </li>
);

&& 문법은 왼쪽의 조건이 true일 때만 오른쪽의 값을 반환한다. 하지만, 왼쪽의 조건이 false라면, 전체 조건식을 false가 된다.
리액트는 false를 JSX 트리에서 "hole"로 인식하며, 이는 null 또는 undefined와 같다. 그리고 이 부분은 아무것도 렌더링 하지 않는다.

Pitfall : && 왼편에 숫자를 넣지 마라.

조건을 테스트하기 위해, JS는 자동적으로 왼편을 불리언으로 변환한다.
그러나, 왼편에 0이 있으면 리액트는 0을 렌더링 하게 된다.

예를 들어, 흔히 하는 실수로 messageCount && <p>New messages</p>같이 코드를 작성한다.
이 코드는 messageCount0일 때 아무것도 렌더링 하지 않는 것으로 생각하지 싶지만, 실제로는 0 자체를 렌더링 한다.

따라서 고치기 위해, 왼편을 불리언 값으로 만들어야 한다.
=>messageCount > 0 && <p>New messages</p>

변수를 이용해 조건적으로 JSX를 할당하기

코드를 작성할 때 위의 간단한 문법이 방해가 된다면, if문과 변수를 사용할 수 있다.

  1. 변수를 정의할 때 let사용하면 값을 재정의할 수 있으므로, 처음엔 보여주고 싶은 기본 내용(name)을 할당한다.
  2. let itemContent = name;
  3. if문을 사용해 isPackedtrue일 때만 itemContent를 재할당한다.
  4. if (isPacked) { itemContent = name + " ✔"; }
  5. 반환할 JSX 트리 안에 중괄호를 사용해 변수를 추가한다.(중괄호를 사용해 JS를 문법을 사용 가능)
  6. <li className="item"> {itemContent} </li>

이 스타일은 이전보다 간단하진 않지만, 가장 유연하다.

이전에는 텍스트만 사용했지만, JSX도 가능하다.

+ Recent posts