본문으로 건너뛰기

· 약 12분
최지훈

출처 : Interview_Question_for_Beginner

Array (배열)

배열은 데이터들이 메모리 공간에서 연속적으로 저장되어 있는 자료구조입니다.

배열의 장점으로는 원소의 인덱스 값을 알고 있으면 Big-O(1)의 시간 복잡도로 원소 검색이 가능합니다. 하지만 삭제 또는 삽입의 경우 배열의 빈 공간으로 원소들을 shift 해줘야 하며 Big-O(n)의 시간 복잡도가 요구됩니다.

Linked List

Linked List는 값과 주소로된 노드들이 순차적으로 연결되어 있는 자료구조입니다.

Linked List는 배열의 단점을 해결합니다. Linked List의 원소들은 본인 다음 원소의 주소를 기억하고 있기 때문에 해당 부분만 다른 값으로 바꿔주면 Big-O(1)의 시간 복잡도로 삭제 및 삽입이 가능합니다. 하지만 원소 삭제 및 삽입 전체 동작을 봤을 때, 동작에 해당하는 원소를 검색하는 과정에서 첫번째 원소부터 순차적으로 검색하기 때문에 Big-O(n)의 시간이 추가적으로 발생합니다.

결국 linked list는 검색, 삽입, 삭제에 대해서도 Big-O(n)의 시간 복잡도가 요구됩니다. 그럼에도 사용하는 이유는 Tree에서 사용되었을 때 그 유용성이 드러나기 때문입니다.

Stack

Stack은 먼저 들어간 원소가 나중에 나오는 First In Last Out 구조의 자료구조입니다.

Queue

Queue는 먼저 들어간 데이터가 먼저 나오는 First In First Out 구조의 자료구조입니다. 한쪽에서 데이터 삽입이 가능하면 다른 한 쪽에서는 삭제가 가능합니다.

Deque, Double-ended Queue (덱)

덱은 큐 2개를 겹쳐놓은것과 같기 때문에 Double ended Queue라고 부르며 양쪽에서 데이터의 입출력이 가능한 자료구조입니다.

Hash Table

Hash Table은 키를 해시값으로 매핑하고, 해시값을 index 삼아 데이터의 key와 value를 함께 저장하는 자료구조입니다.

보통 key보다 해시값이 더 적기 때문에 메모리 효율성이 좋습니다.

단점으로는 서로 다른 key가 동일한 해시값을 가지는 해시 충돌 문제가 일어날 수 있습니다.

충돌 문제 해결법으로는

첫 번째 방법은 Separate Chaning입니다. 체인법은 해당 버킷에 데이터가 이미 있다면 연결 리스트와 같이 다음 노드를 가리키는 노드를 추가하는 방법입니다. 이로인해 해시 테이블의 탐색, 삽입, 삭제의 시간복잡도는 평균적으로 Big-O(1)을 요구하지만, 충돌이 자주 일어나면 최악의 경우 Big-O(n)이 요구됩니다.

두 번째 방법은 Open Addressing입니다. Open Addressing은 버킷에 데이터가 이미 있다면 다른 주소에 데이터를 저장할 수 있도록하는 방법입니다. 이때 다른 주소에 저장된 데이터를 액세스(삽입, 삭제, 탐색)하는 방식에는 크게 세 가지가 있습니다. 선형 탐사는 버킷에 다른 데이터가 저장되어 있으면 고정 폭 만큼 옮겨 다음 버킷을 액세스합니다. 제곱 탐사는 버킷에 다른 데이터가 저장되어 있으면 제곱수 폭 만큼 옮겨 다음 버킷을 액세스합니다. 이중 해시는 버킷에 다른 데이터가 저장되어 있으면 또 다른 해시함수에서 나온 값의 폭 만큼 옮겨 다음 버킷을 액세스합니다.

Direct-Address Table

Direct-Address Table은 key의 개수와 해시값의 개수가 동일한 해시 테이블입니다.

Hash Table과 같이 충돌은 나지 않지만 실제 사용되는 key가 적을 경우 메모리 효율성이 떨어집니다.

Dictionary

Dictionary는 key와 value를 한 쌍으로 하는 값을 저장하는 자료구조입니다.

cf 1. Hash 알고리즘 고득점 Kit

cf 2. Hash 대표 알고리즘 문제 (베스트 엘범 Level 3)

# python
from collections import defaultdict

def solution(genres, plays):
answer = []

# 총 play 수와 장르 별 노래 및 고유 번호 dictionary로 저장
d_total_plays = defaultdict(int)
d_genres = defaultdict(list)
i = 0

for key, value in zip(genres, plays):
d_total_plays[key] += value
d_genres[key].append([value, i])
i += 1

# 노래가 많이 재생된 장르순 배열 생성
l_total_plays = []

for key, value in d_total_plays.items():
l_total_plays.append([value, key])

# 노래가 많이 재생된 장르부터 고유 번호 탐색
for i in sorted(l_total_plays)[::-1]:
key = i[1]
l_genres = sorted(d_genres[key])[::-1]

# 노래가 하나인 경우 하나만 수록
if (len(l_genres) == 1):
answer.append(l_genres[0][1])
else:
# 재생 횟수가 같은 노래는 고유 번호가 낮은 노래 수록
if (l_genres[1][0] == l_genres[0][0]) and (l_genres[1][1] < l_genres[0][1]):
answer.append(l_genres[1][1])
answer.append(l_genres[0][1])
else:
answer.append(l_genres[0][1])
answer.append(l_genres[1][1])

return answer

재귀함수와 DFS 그리고 Stack (깊이 우선 탐색)

재귀함수

def recursive(n):
if n == 0:
return
else:
print(n, end = ' ')
recursive(n-1)

recursive(5) # 5, 4, 3, 2, 1

def recursive(n):
if n == 0:
return
else:
recursive(n-1)
print(n, end = ' ')

recursive(5) # 1, 2, 3, 4, 5

재귀함수는 Stack 자료구조와 같이 동작한다.

cf 1. n! 구하기

def factorial(n):
if n == 0 or n == 1:
return 1
else:
return n * factorial(n - 1)

factorial(5)

# Stack 처럼 생각
# factorial(1) = 1
# factorial(2) = 2 * factorial(1)
# factorial(3) = 3 * factorial(2)
# factorial(4) = 4 * factorial(3)
# factorial(5) = 5 * factorial(4)

cf 2. 피보나치 수열 구하기

# 피보나치 수열 :  [1, 1, 2, 3, 5, 8 ...]

def Fibonacci(n):
if n == 1 or n == 2:
return 1
else:
return Fibonacci(n-2) + Fibonacci(n-1)

Fibonacci(5)

DFS 그리고 Queue (깊이 우선 탐색)

  • 이진트리의 깊이 우선 탐색

cf. 부모 노드 2 = 왼쪽 노드 부모 노드 2 + 1 = 오른쪽 노드

# 이진트리의 깊이 우선 탐색

def DFS(v):
if v > 7:
return
else:
print(v, end = ' ')
DFS(v * 2)
DFS(v * 2 + 1)

DFS(1) # 1, 2, 4, 5, 3, 6, 7

BFS (너비 우선 탐색 (레벨 탐색))

출발 지점에서 도착 지점까지 가는 최단 경로, 최소 비용 등을 구할 때.

  • 이진트리의 너비 우선 탐색
from collections import deque

def BFS():
Q = deque()
Q.append(1)
L = 0

while Q:
n = len(Q)
print(L, ' : ')
for i in range(n):
v = Q.leftpop()
print(v, end = ' ')
for nv in [v*2, v*2 + 1]:
# 7 까지만 탐색 (Level 2 까지)
if nv > 7:
continue
Q.append(nv)
print()
L += 1

BFS()

응용 예시

# 아래 규칙에 따라 0지점에서 출발하여 특정 위치로 갈 수 있는 최소 이동 횟수 구하기
# 한번 이동할 때 +1, 1-, +5 만큼 이동할 수 있다. (음수 좌표는 갈 수 없다.)

from collections import deque

def DFS(destination):
Q = deque()
Q.append(0)
L = 0
memo = []

while Q:
n = len(Q)

for i in range(n):
v = Q.popleft()

for nv in [v - 1, v + 1, v + 5]:
if nv == destination: return L + 1

if nv >= 0 and not (nv in memo):
Q.append(nv)
memo.append(nv)
L += 1

print(DFS(10)) # 2
print(DFS(14)) # 4
print(DFS(25)) # 5
print(DFS(24)) # 6
print(DFS(345)) # 69

cf. 같은 문제 DFS vs. BFS

# n*n 영역에서 1인 영역 개수 구하기

# DFS
dx = [-1, 0, 1, 0]
dy = [0, 1, 0, -1]

def DFS(x, y, board):
for i in range(4):
nx = x + dx[i]
ny = y + dy[i]

if nx >= 0 and ny >= 0 and nx < len(board) and ny < len(board) and board[nx][ny] == 1:
board[nx][ny] = 0
DFS(nx, ny, board)


def solution(board):
answer = 0
n = len(board)

for i in range(n):
for j in range(n):
if board[i][j] == 1:
answer += 1
DFS(i, j, board)

return answer

solution([[0, 1, 1, 0, 0], [0, 1, 1, 0, 0], [0, 1, 0, 0, 0], [0, 0, 0, 1, 0], [0, 0, 1, 1, 0]]) # 2

# BFS
from collections import deque

dx = [-1, 0, 1, 0]
dy = [0, 1, 0, -1]

def BFS(Q, x, y, board):
Q.append([x, y])

while Q:
n = len(Q)

for _ in range(n):
v = Q.popleft()

for i in range(4):
nx = v[0] + dx[i]
ny = v[1] + dy[i]

if nx >= 0 and ny >= 0 and nx < len(board) and ny < len(board) and board[nx][ny] == 1:
board[nx][ny] = 0
Q.append([nx, ny])

def solution(board):
answer = 0
Q = deque()
n = len(board)

for i in range(n):
for j in range(n):
if board[i][j] == 1:
answer += 1
BFS(Q, i, j, board)

return answer

solution([[0, 1, 1, 0, 0], [0, 1, 1, 0, 0], [0, 1, 0, 0, 0], [0, 0, 0, 1, 0], [0, 0, 1, 1, 0]]) # 2

그래프 (Gragh)

  • G(V, E) : Vertex (정점), Edge (간선)

인접 행렬로 그래프 표현

# 무방향 그래프

edge = [[1, 2], [1, 3], [2, 4], [2, 5], [3, 4]] # 입력 정보
gragh = [[0] * (n+1) for _ in range(n+1)]

for [a, b] in edge:
gragh[a][b] = 1
gragh[b][a] = 1

print(gragh[3][4]) # 1
print(gragh[4][3]) # 1
# 방향 그래프
# 행에서 열로 이동

edge = [[1, 2], [1, 3], [2, 5], [3, 4], [4, 2]] # 입력 정보
gragh = [[0] * (n+1) for _ in range(n+1)]

for [a, b] in edge:
gragh[a][b] = 1

print(gragh[3][4]) # 1
# 가중치 방향 그래프
# 행에서 열로 이동

edge = [[1, 2, 2], [1, 3, 4], [2, 5, 5], [3, 4, 5], [4, 2, 2]] # 입력 정보
gragh = [[0] * (n+1) for _ in range(n+1)]

for [a, b, c] in edge:
gragh[a][b] = c

print(gragh[3][4]) # 5

인접 리스트로 그래프 표현

# 무방향 그래프

edge = [[1, 2], [1, 3], [2, 4], [2, 5], [3, 4]] # 입력 정보
gragh = [[] for _ in range(n+1)]

for [a, b] in edge:
gragh[a].append(b)
gragh[b].append(a)

print(gragh[2]) # [1, 4, 5]
# 방향 그래프

edge = [[1, 2], [1, 3], [2, 5], [3, 4], [4, 2]] # 입력 정보
gragh = [[] for _ in range(n+1)]

for [a, b] in edge:
gragh[a].append(b)

print(gragh[2]) # [5]
# 가중치 방향 그래프

edge = [[1, 2, 2], [1, 3, 4], [2, 5, 5], [3, 4, 5], [4, 2, 2]] # 입력 정보
gragh = [[] for _ in range(n+1)]

for [a, b, c] in edge:
gragh[a].append([b, c])

print(gragh[2]) # [[5, 5]]

· 약 10분
최지훈

들어가며

글을 읽는 이유

  • FE와 BE 협업 프로젝트로 야놀자 클론 프로젝트를 한 경험이 있습니다.

  • 이때 가장 어려움을 겪었던 점은 공통 컴포넌트를 사용하는 페이지가 늘어나면서 공통 컴포넌트에 점점 많은 조건이 추가 되었고, 이로인해 더 이상 공통 컴포넌트라고 부를 수 없을 만큼 코드 복잡도가 높아졌던 점입니다.

  • 이에 프로젝트 종료 후 유지 보수에 어려움을 겪고있어 해당 글을 통해 컴포넌트를 분리하는 기준과 방법을 알아보고 싶습니다.

요약

  1. 컴포넌트는 웹 앱을 구성 하는 데 있어 가장 작은 단위이다.

  2. 컴포넌트를 나누는 기준

    A. 재사용성 컴포넌트

    • HTML 요소를 고려한 컴포넌트 분리
    • 중복을 고려한 컴포넌트 분리 (props, children 활용하기)

    B. 복잡성 컴포넌트

    • 컴포넌트가 여러 책임을 가질 때 분리
    • 컴포넌트에 비지니스 로직이 있을 때 분리

    C. 리렌더링 컴포넌트

    • 컴포넌트와 관련이 없는 상태가 있을 때 분리

주요 내용

컴포넌트에 대한 전반적인 내용

컴포넌트

  • 웹 앱을 구성 하는 데 있어 가장 작은 단위

컴포넌트를 만들 때 가장 많이 발생하는 실수 다섯 가지

  1. 복잡한 컴포넌트를 만든다.
  2. 하나의 컴포넌트에 여러 책임을 추가한다.
  3. 몇몇 동작하는 부분을 결합하여 컴포넌트를 만든다.
  4. 비지니스 로직을 컴포넌트에 추가한다.

언제 나눠야 할까?

컴포넌트를 만드는 기준, 즉 나누는 기준 중 가장 많이 선택되는 이유는 (A) 재사용성과 (B) 복잡성입니다.

컴포넌트를 나누는 기준

A. 재사용성 컴포넌트

  1. HTML 요소를 고려한 컴포넌트 분리

    function ListComponent(...) {
    return (
    <ul>
    <li>
    <h3>...</h3>
    <p>...</p>
    </li>
    <li>
    <h3>...</h3>
    <p>...</p>
    </li>
    </ul>
    );
    }

    위 코드의 컴포넌트를 분리한다고 했을때, li 태그로 묶은 ItemComponent 보다, 리스트 요소 말고 다른 곳에서도 사용할 수 있는 SomethingComponent를 하나의 컴포넌트로 묶는 것이 좋다.

    function ItemComponent(...) {
    return (
    <li>
    <h3>...</h3>
    <p>...</p>
    </li>
    );
    }

    // 보다

    function SomethingComponent(...) {
    return (
    <>
    <h3>...</h3>
    <p>...</p>
    </>
    );
    }
  2. 중복을 고려한 컴포넌트 분리 (props, children 활용하기) 이렇게 둘 이상의 컴포넌트에서 사용할 재사용 가능한 컴포넌트를 만들 때 가장 큰 특징 중 하나는 조건문 입니다.

    완벽하게 같은 걸 사용하면 문제가 안 되지만 서로 다른 부분이 있다면 조건문이 들어가게 됩니다. 우리가 현실에서 마주하는 재사용 컴포넌트는 아래와 같이 점점 거대해지곤 합니다.

    function Page1() {
    return (
    <ul>
    <li>
    <Card ... />
    </li>
    </ul>
    );
    }

    function Page2() {
    return (
    <ul>
    <li>
    <Card ... />
    </li>
    </ul>
    );
    }

    function Card(props) {
    const [a, setA] = useState(props.a ? props.foo : props.bar);
    const condition1 = props.a && !props.b;

    return (
    <section>
    <h3>...</h3>
    <p>가격...</p>
    <div>
    <button>{a ? 'fooValue' : 'barBalue'}</button>
    </div>
    {props.showSummary && <p>요약...</p>}
    {condition1 && <div>...</div>}
    </section>
    );
    }

    Page1 컴포넌트와 Page2 컴포넌트는 Card 컴포넌트에 의존적입니다.

    만약 Page1과 Page2 뿐만 아니라 더 많은 컴포넌트가 Card 컴포넌트를 사용할수록 문제는 더 심각해집니다.

    이런 문제는 왜 발생했을까요? 그리고 어떤 문제를 일으킬까요?

    가장 먼저 컴포넌트가 반환하는 요소의 중복을 추출해서 재사용해야 한다는 접근 방법이 문제의 발단일 수 있습니다.

    추출한 컴포넌트 내부에 사용하는 방법에 따라 조건문이 추가된다는 건, 사용하는 컴포넌트들이 서로 다른 수정의 이유를 갖는 다는 걸 의미합니다. 즉, 중복 제거와 재사용의 대상이 아닙니다. 따라서 처음에 조건문이 들어갈 때부터 산불의 작은 불씨가 시작된 것이었습니다.

    이 문제들을 해결하는 방법 중 하나는 재사용하려는 컴포넌트에는 정말 공통적인 것들만 남겨두고 사용하는 컴포넌트의 고유한 것은 속성(props)으로 전달하는 것입니다.

    function Page1() {
    return (
    <ul>
    <li>
    <Card
    summary={<p>요약...</p>}
    />
    </li>
    </ul>
    );
    }

    function Page2() {
    return (
    <ul>
    <li>
    <Card ... />
    </li>
    </ul>
    );
    }

    function Card(props) {
    return (
    <section>
    <h3>...</h3>
    <p>가격...</p>
    {props.summary}
    </section>
    );
    }

    이렇게 상태나 조건문 등의 결합이 사라진 것만으로도 Page1만의 특징인 summay는 Page1이 관리하고 Card는 이에 대해 신경 쓸 필요가 없습니다.

    특히나 이 방법은 props drilling을 피하거나 컴포넌트의 제어를 역전하는 등 좋은 점을 더 많이 갖고 있고 공식문서에서도 소개하는 만큼 반드시 숙지하고 있을 필요가 있습니다.

B. 복잡성 컴포넌트

  1. 컴포넌트가 여러 책임을 가질 때 분리

    function Page(props) {
    // 선택한 탭을 변경하면 보여주는 내용을 변경합니다.
    // 페이징을 다룹니다.
    // 단어를 검색을 합니다.
    // 검색 조건 토글을 다룹니다.
    // 등등
    }

    이렇게 되면 기능 간에 결합이 강하게 발생해서 수정이 쉽지않습니다.

    그렇기 때문에 컴포넌트를 책임에 맞게 나눠서 단순화 해야 합니다.

    Page 컴포넌트가 탭, 검색, 페이징 그리고 이 정보들을 취합해 컨텐츠를 보여주는 등 모든 책임을 갖지 않도록 해야 합니다.

  2. 컴포넌트에 비지니스 로직이 있을 때 분리

    일반적으로 유저 인터페이스(UI)와 비지니스 로직은 변경의 속도, 즉 빈도가 다릅니다. 이때 컴포넌트에 비지니스 로직이 포함되어있다면 빈번한 UI 변경에 따라 자주 영향을 받을 수 있습니다.

    따라서 UI와 비지니스 로직을 적절하게 분리하는 건 소프트웨어를 오랫동안 유지보수 하는 데 있어서 아주 중요합니다.

C. 리렌더링 컴포넌트

  1. 컴포넌트와 관련이 없는 상태가 있을 때 분리

    하나의 컴포넌트 안에서 서로 영향을 주지 않는 상태가 여럿 있으면 불필요한 렌더링이 발생하는 문제입니다.

    function Page1() {
    const [카드 호버 상태, set카드 호버 상태] = useState(false);
    const [탭 호버 상태, set탭 호버 상태] = useState('none');

    return (
    ...
    <ul></ul>
    ...
    <ul>카드</ul>
    ...
    );
    }

    이 코드에서 탭과 카드는 서로 영향을 주지 않습니다. 하지만 탭에 호버를 하면 카드들이 렌더링되고 카드에 호버를 하면 탭이 렌더링 됩니다.

    function Page1() {
    return (
    ...
    <Tab>
    ...
    <ul>
    ...
    <li><Card><li>
    ...
    </ul>
    ...
    );
    }

    function Tab() {
    const [탭 호버 상태, set탭 호버 상태] = useState('none');

    return (
    <ul></ul>
    );
    }

    function Card() {
    const [카드 호버 상태, set카드 호버 상태] = useState(false);

    return (
    <section>...</section>
    );
    }

· 약 12분
최지훈

들어가며

글을 읽는 이유

  • “렌더링 퍼포먼스를 개선하는 방법”, “좋은 리액트 프로젝트 폴더 구조”, “훅을 잘 사용하는 방법”과 같은 기술에 대한 글은 찾기 쉽습니다. 하지만 “어떻게 하면 프로젝트를 잘 유지할 수 있는가”에 대한 글은 찾기가 어렵습니다.

요약

  1. 개발중 무의식으로 따르는 습관
  • 큰 고민없이 사용하는 상태는 ‘유지보수 하기 어려운 코드’를 만들어 낸다.

  • 리액트에서 다루는 상태란 View의 상태이다.

  • 페이지를 구성하는 값들 중 상태는 무엇인지 잘 구분하고 관리하자.

  1. View 로직과 비지니스 로직
  • 어떻게 보여줄지 논의하는 것은 View 로직이며 이를 제외하면 모두 비지니스 로직이다.
  1. 로직 예시 (input과 조건에 따른 메세지)
  • 비밀번호를 입력 받는 input은 View 로직이며 불일치 조건에 대한 로직은 비지니스 로직이다.

  • 이 두 로직을 함수나 컴포넌트로 잘 분리하는 것이 중요합니다.

  1. 로직 분리 방법 (중요)
  • 먼저 어느 시점에서 비지니스 로직을 통해 데이터를 가져오거나 가공할지 판단하고 해당 데이터를 View 로직의 상태에 담는다.

  • 비지니스 로직은 하위 컴포넌트의 변경에 영향을 받지 않는 페이지 수준에서 관리한다.

  1. 로직 분리 효과
  • 비지니스 상태에 따른 렌더링 흐름을 제어 가능

  • 관심사의 분리로 인한 커뮤니케이션 능률 향상

  • 로직 별 독립적인 테스트 가능

  • 로직 별 독립적인 일정 관리 가능

주요 내용

1. 무의식적 개발 습관

프론트엔드를 개발할 때 습관이 있습니다. 그 중 이 글의 주제와 관련되어있는 대표적인 습관은 상태 관리 입니다. 우린 큰 고민 없이 상태를 사용합니다.

큰 고민없이 사용하는 상태는 ‘유지보수 하기 어려운 코드’를 만들어 냅니다. 이 문제의 가장 근본적인 원인은 상태에 대한 이해에 있습니다.

흔히 리액트에서 다루는 상태란 View의 상태입니다. 즉, 값을 변경하면 View를 업데이트 하는 걸로 간주하여 렌더링을 하게 됩니다. 따라서 페이지를 구성하는 값들 중 상태는 무엇인지 잘 구분하고 관리하는 것만으로도 불필요한 렌더링을 줄이고, 코드를 상당히 개선할 수 있습니다.

2. View 로직과 비지니스 로직

이번 글에선 협업 관점에서 View 로직과 비지니스 로직을 알아보겠습니다.

우리가 생각하는 것보다 View 로직과 비지니스 로직은 명확하게 구분됩니다.

예를 들어, ‘추가 상품을 3개 이상 구매하면 최종 결제 금액에서 1,000원을 제(할인)한다. 보여줄 땐 할인 전 가격과 할인 후 가격을 노출하고, 할인 전 가격은 작고 흐릿하게, 할인 후 가격은 크고 굵게 노출한다.’라고 논의를 진행했다고 하면, 이 문장은 아래와 같이 두 로직으로 분리할 수 있습니다.

  • 비지니스 로직
    추가 상품을 3개 이상 구매하면 최종 결제 금액에서 1,000원을 제(할인)한다.

    = 다른 애플리케이션에서도 성립하는 사업 규칙

  • View 로직
    할인 전 가격과 할인 후 가격을 노출하고, 할인 전 가격은 작고 흐릿하게, 할인 후 가격은 크고 굵게 노출한다.

    = 특정 어플리케이션 자체. 사업 규칙에 강하게 의존하고 변경 가능성이 높다.

즉 우리가 만드는 서비스와 관련된 이야기를 할 때,어떻게 보여줄지 논의하는 것은 View 로직이며, 이를 제외하면 모두 비지니스 로직입니다.

3. 로직 예시 (input과 조건에 따른 메세지)

로직 분리와 관련해서 가장 많은 대화와 피드백을 주고 받은 건 input과 조건에 따른 메세지 입니다.

지금까지 내용을 토대로 input과 에러 메세지를 다루는 간단한 예시를 살펴보겠습니다.

비밀번호를 받는 간단한 input 컴포넌트가 있습니다.

export default function Page() {

return (
<>
<h1>어떤 페이지 입니다.</h1>
...
<form onSubmit={...}>
...
<InputPassword />
...
</form>
...
</>
);
}

export default function InputPassword() {
const [password, setPassword] = useState('');
const [isValid, setIsValid] = useState(false);

const onChangeHandler = (event) => {
setPassword(event.target.value);
setIsValid(event.target.value.length >= 8);
};

return (
<>
<label htmlFor="password">비밀번호</label>
<input
id="password"
type="password"
value={password}
onChange={onChangeHandler}
/>
<p>{isValid ? '' : '비밀번호는 8자 이상 입력해야 합니다.'}</p>
</>
);
}

여기서 ‘비밀번호가 8자 이상이어야 한다.’는 조건은 비지니스 로직 입니다. 그렇기 때문에 아래와 같이 분리하는 것이 좋습니다.

const isValidPassword = (password) => {
if (password.length < 8) {
return false;
}

return true;
};

export default function InputPassword() {
const [password, setPassword] = useState('');
const [isValid, setIsValid] = useState(false);

const onChangeHandler = (event) => {
setPassword(event.target.value);
setIsValid(isValidPassword(event.target.value));
};

return (
<>
<label htmlFor="password">비밀번호</label>
<input
id="password"
type="password"
value={password}
onChange={onChangeHandler}
/>
<p>{isValid ? '' : '비밀번호는 8자 이상 입력해야 합니다.'}</p>
</>
);
}

이제 isValidPassword의 인터페이스가 바뀌지 않는 이상, 비지니스 로직와 관련된 변경 사항은 isValidPassword만 수정하면 됩니다.

예를 들어, 비밀번호의 최소 자릿수가 8자리에서 12자리로 바뀐다면 아래와 같이 isValidPassword만 수정합니다.

const isValidPassword = (password) => {
if (password.length < 12) {
return false;
}
...
};

4. 로직 분리 방법

비지니스 로직은 어떤 수준에서 어떻게 관리되어야 할까요?

가장 먼저 컴포넌트 수준에서 사용되는 건 불가능 하진 않지만 어려운 점이 많습니다.

문제는 한 페이지에서 다루는 비지니스 로직은 컴포넌트 단위로 움직이지 않는다는 사실입니다. 만약 다른 컴포넌트에서 추가 구매와 관련된 비지니스 로직의 상태를 가져와야 한다면, 상위 컴포넌트를 통해 전달하고 전달 받는 방식이 되어야 합니다.

그렇기 때문에 비지니스 로직은 하위 컴포넌트의 변경에 영향을 받지 않는 페이지 수준에서 관리 되어야 합니다.

그렇다면 페이지 수준에서 비지니스 로직을 어떻게 다루면 좋을까요?

페이지의 세션이 유지되는 동안 비지니스 로직의 상태가 유지되도록 해야 합니다.

리액트의 경우 Context API를 활용한 커스텀 hook을 활용하는 방법이 있습니다.

const useMapCount = () => {
const businessLogic = React.useContext(BusinessLogicContext);
const [count, setCount] = React.useState(businessLogic.count);

const setCountIfEven = () => {
if (businessLogic.count % 2 === 0) {
setCount(businessLogic.count);
}
};

return {
count,
increase: businessLogic.increase,
setCountIfEven,
};
}

const Counter = () => {
const { count, increase, setCountIfEven } = useMapCount();

return (
<div>
<button type="button" onClick={() => {
increase();
setCountIfEven();
}}>
increase
</button>
<div>
<div>count in state : {count}</div>
</div>
</div>
);
};

이처럼 비지니스 로직과 View 로직을 분리하면 비지니스 상태에 따른 렌더링 흐름을 제어할 수 있습니다.

또한 비지니스 상태가 렌더링 흐름에 포함되어 있지 않기 때문에 비지니스 로직의 사용을 수정해도 View에 제한적인 영향을 줍니다. 이는 어떤 환경에서든 조금 더 장수할 수 있는 프로젝트를 만드는 데 도움을 줍니다.

5. 로직 분리 효과

  1. 로직 분리의 가장 큰 효과는 관심사의 분리 입니다.
  • 관심사를 잘 분리하면 서비스를 운영하는 구성원들과 소통할 때 커뮤니케이션이 명확해 집니다.
  1. 또 다른 효과는 효율적인 테스트 입니다.
  • 분리 하기 이전엔 테스트를 작성하다보면 View까지 테스트를 작성해야 했습니다. 하지만 이렇게 로직을 분리하면 View와 독립적으로 테스트를 작성할 수 있고, 참고할 좋은 레퍼런스가 충분히 많습니다.
  1. 또 다른 효과는 효율적인 일정 관리 입니다.
  • 로직을 분리하면 비지니스 로직 코드 작성 시간과 뷰 로직 코드 작성 시간을 분리하여 생각할 수 있습니다.

· 약 5분
최지훈

들어가며

  • React-Query를 활용한 서버 상태 관리 글을 작성하면서 공부한 내용을 실제 야놀자 클론 코딩 프로젝트에 적용해 보았습니다.
  • 컴포넌트와 React-Query hook, axios 로직을 분리하여 관리했던 경험과 장단점을 함께 다룹니다.

실제 적용 내용

행동

  • React-Query hook 사용법을 이해하는 것을 넘어서 어떻게 하면 효율적으로 관리할 수 있을지 고민해 보았습니다.
  • React-Query에서 제공해주는 hook인 useQuery와 useMutation을 리액트 컴포넌트 안에서 바로 사용할 수 있지만, hook에 사용되는 코드가 비지니스 로직이라면 컴포넌트와 분리할 필요가 있다고 판단하였습니다.
  • 이에 React-Query hook에 작성되는 코드가 View 로직에 가까운지 비지니스 로직에 가까운지 생각해 보았습니다.
const Cart = () => {
const {
data: cartData,
isLoading,
} = useQuery({
queryKey: ["fetchCarts"],
queryFn: async (): Promise<CartData> => {
const { data }: { data: FetchCartResult } = await authInstance.get("carts");
return data.data;
},
});

return !isLoading && cartData ? (
<장바구니 View>
) : (
<로딩 View>
);
};

export default Cart;
  • 위 코드가 지금은 간단해 보이는 코드이지만, 만약 조금이라도 View 혹은 비지니스 로직이 추가되면 아주 복잡한 컴포넌트가 될 가능성이 높다고 생각하였습니다.
  • 따라서 View에 영향을 주는 부분과 그렇지 않는 코드 (비지니스 로직)을 아래와 같이 분리해 보았습니다.

View 로직

  • API 요청 이후 최종적으로 받아오는 cartData
  • API 요청 상태의 로딩 상태를 알려주는 isLoading
  • Cart 컴포넌트의 return 값

비지니스 로직

  • useQuery의 queryKey, queryFn 또한 이후에 사용될 수도 있는 options

  • axios를 활용한 비동기 요청 함수

  • 비지니스 로직에서도 useQuery에서 onError, onSuccess 등과 같은 options들이 추가적으로 사용될 수 있기 때문에 비동기 요청 함수 또한 따로 분리하는 것이 좋다고 판단하였습니다.

  • 위와 같이 로직을 분리하여 생각하고 아래와 같이 각 로직을 관리해 보았습니다.

// 컴포넌트

const Cart = () => {
const {
data: cartData,
isLoading,
} = useFetchCarts();

return !isLoading && cartData ? (
<장바구니 View>
) : (
<로딩 View>
);
};

export default Cart;
// React-Query hook

export const useFetchCarts = () =>
useQuery({
queryKey: ["fetchCarts"],
queryFn: () => fetchCarts(),
});
// axios API 호출

export const fetchCarts = async (): Promise<CartData> => {
const { data }: { data: FetchCartResult } = await authInstance.get("carts");

return data;
};

효과

  • 컴포넌트에서는 view 로직을, hook과 비동기 처리에서는 비지니스 로직을 따로 집중해서 관리할 수 있게 되었습니다.

  • 컴포넌트의 경우 공통 컴포넌트로 사용헤야하는 상황이 오면 쉽게 대응할 수 있게 되었습니다.

  • 또한 에러 처리를 할 때, 404 페이지로 이동하거나 toast를 보여준다거나 하는 View와 관련된 에러는 컴포넌트에서 관리하고, 이 외의 에러처리에 대해서는 useQuery의 onError를 활용하여 에러 관리를 할 수 있게 되었습니다.

    // 컴포넌트

    const Cart = () => {
    const {
    data: cartData,
    isLoading,
    isError
    } = useFetchCarts();

    if (isError) {return "View 에러 처리"}

    return !isLoading && cartData ? (
    <장바구니 View>
    ) : (
    <로딩 View>
    );
    };

    export default Cart;
    // React-Query hook

    export const useFetchCarts = () =>
    useQuery({
    queryKey: ["fetchCarts"],
    queryFn: () => fetchCarts(),
    onError: "View 이외 에러 처리 함수"
    });

· 약 6분
최지훈

들어가며

💡 React Query와 상태관리 (배민근)를 시청한 이유

  1. 야놀자 클론 코딩 프로젝트를 진행하면서 장바구니 구현을 담당하였고, 서버 상태 관리에 React Query를 도입하기 위해 시청하게 되었습니다.
  2. 짧은 개발 기간 안에 구현을 해야했으므로 이미 많은 사람들이 인정한 정보를 빠르게 습득하고, 문법상으로 변경된 부분이나 추가적으로 필요한 부분은 공식 문서를 읽어 공부하는 방식을 선택하였습니다.

💡 요약

  • Client State와 Server State 구분하기.
  • React-Query 사용 시 주요 개념 네 가지인 Queries (useQuery), Mutations (useMutation), Query Invalidation, Caching과 Synchronization를 기억하자.

주요 내용

FE에서 상태관리란?

상태

  • 주어진 시간에 대한 시스템을 나타내는 것(객체 등의 데이터)으로 언제든지 변경될 수 있다.
  • 상태들은 시간에 따라 변화한다.

상태 관리

  • 상태를 관리하는 방법이다.
  • 리액트에선 상태 관리가 단반향 바인딩으로 진행되므로 props drilling 이슈가 존재한다.

Client State vs. Server State

Client State

  • Client에서 소유하며 온전히 제어 가능하다.
  • 초기값 설정이나 조작에 제약사항 없다.
  • 다른 사람과 공유되지 않으며 Client 내에서 사영자 인터렉션에 따라 변할 수 있다.
  • 항상 Client 내에서 최신 상태로 관리된다.

Server State

  • client에서 제어하거나 소유되지 않은 원격의 공간에서 관리되고 유지된다.
  • Fetching이나 Updating에 비동기 API가 필요하다.
  • 다른 사람과 공유되는 것으로 사용자가 모르는 사이에 변경될 수 있다.

React-Query

소개

  • fetching, caching, synchronizing and updating server state (global state와 관련없이)

대표 예시 (공식 문서 예제)

import { QueryClient, QueryClientProvider, useQuery } from "react-query";

const queryClient = new QueryClient();

export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
)
}

function Example() {
const { isLoading, error, data } = useQuery('repoData', () => fetch('api').then(res => res.json()));

if (isLoading) return 'Loading...'

if (error) return 'An error has occurred: ' + error.message

return (
<>
<h1>{data.name}</h1>
<p>{data.description}</p>
</>
)
}

주요 개념 네 가지

  1. Queries (useQuery)
  2. Mutations (useMutation)
  3. Query Invalidation
  4. Caching과 Synchronization

1. Queries (useQuery)

  • Queries는 데이터 Fetching 용이다.
// 반환값 및 options
// 공식 문서 : https://tanstack.com/query/v4/docs/react/reference/useQuery

const {
data,
error,
isError,
isLoading,
isSuccess,
status,
...
} = useQuery({
queryKey,
queryFn,
cacheTime,
onError,
onSuccess,
staleTime,
useErrorBoundary,
...
})

// ex
const { data: cartData, error, status } =useQuery({
queryKey: ["fetchCarts"],
queryFn: fetchCarts,
})
  • React Query는 queryKey에 따라 query caching을 관리한다.
  • queryFn는 Primise를 반환하는 함수로, 데이터를 resolve하거나 error를 throw 한다.
  • useQuery를 여러개 선언하여 사용해도 병렬적으로 사용이 가능하다.

2. Mutations (useMutation)

  • Mutations은 데이터를 생성/수정/삭제하는 용도이다.
// 반환값 및 options는 useQuery와 유사하고 더 적다.
// 공식 문서 : https://tanstack.com/query/v4/docs/react/reference/useMutation

// ex
useMutation({
mutationFn: deleteCarts,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["fetchCarts"] }),
})
  • 다음 내용에 나오겠지만, onSuccess와 queryClient.invalidateQueries()를 활용하면 mutate 후 명시한 queryKey를 가진 query 상태들을 업데이트(refatch) 할 수 있다.

3. Query Invalidation

  • useMutation을 통해 데이터를 업데이트하고 나면, 기존에 캐시된 데이터를 교체(무효화, invalidate)해주어야 하는데, queryClient의 invalidateQueries 메서드를 사용한다.
  • 이러면 해당 queryKey를 가진 query는 stale(신선하지 않은) 취급되고, 현재 rendering 되고 있는 query들은 백드라운드에서 refetch 된다.

4. Caching과 Synchronization

cacheTime

  • 사용자가 없을 때 카운트된다.
  • 사용자가 창을 나갔을 때, 메모리에 얼마만큼 있을건지 (default 5분, 해당 시간 이후 GC에 의해 처리)

staleTime

  • 사용자가 화면에 있을 때 카운트된다.
  • 얼마의 시간이 흐른 후에 데이터를 stale 취급할 것인지 (default 0)

refetchOnMount, refetchOnWindowFocus, refetchOnReconnect (default true)

  • Mount, window focus, reconnect 시점에 data가 stale이라고 판단되면 모두 refetch

React Query의 API 호출 관리법

  • 동일한 쿼리를 사용하는 A, B 컴포넌트가 있을 때, A 컴포넌트가 mount되고 staleTime 안에 B 컴포넌트가 mount 되면 API 호출이 발생하지 않는다. 이는 QueryClient 내부적으로 Context를 사용하여 query를 전역적으로 다루기 때문이다.

    React Query의 API 호출 관리법

영상을 보고

💡 실제 적용 사례

React-Query hook 관리하기 (feat. axios) 글로 이어지는 내용입니다.

· 약 16분
최지훈

들어가며

💡 프론트엔드에서 의존성 살펴보기 (이문기)를 읽는 이유

  1. 야놀자 클론 코딩 프로젝트를 진행하면서 숙소 리스트를 공통 컴포넌트로 묶는 과정에서 어려움을 겪었습니다.
  2. 각 페이지마다 필요한 조건을 하나씩 추가하다보니 더 이상 공통 컴포넌트의 역할을 할 수 없게 되었습니다.
  3. 이에 어떤 부분을 공통적으로 관리하고 묶어야하는지 알기 위해 의존성에 대해 공부해보게 되었습니다.

💡 요약

  • 공통 부분만 잘 묶어도 의존성 문제는 눈에띄게 사라진다.
  • 공통 부분이 될 수 있는 점은 변수(상태), 함수(매개변수와 출력값의 타입), 컴포넌트, 타입이 있으며, 그중에서 타입이 가장 큰 틀을 짜는 순간에서 가장 중요한 부분이다.
  • 여러 곳에서 함께 사용하는 컴포넌트는 시간이 갈수록 전달받는 속성과 조건문이 추가되면서 수정하기엔 몸집이 너무 커지는 현상이 생깁니다. 이런 형태의 의존성 문제를 해결하는 방법 중 한 가지는 의존성 역전입니다.

주요 내용

A. 의존성이란?

의존성(dependency)은 단어 그대로 의존 관계를 설명하는 용어 입니다.

‘A 컴포넌트는 B 컴포넌트에 의존한다.’는 'A 컴포넌트가 동작하기 위해 B 컴포넌트가 필요하다.'라는 뜻과 같습니다.

function ComponentA() {
return (
...
<ComponentB>...</ComponentB>
...
);
}

개발 시 고려해야하는 의존성은 변수, 함수, 컴포넌트, 타입 의존성이 있습니다.

A-a. 변수 의존성

변수의 의존성과 관련된 사례에는 무엇이 있고 어떻게 개선할 수 있는지 방법을 알아보겠습니다.

  • 사례 1: 변수 의존성이 넓은 경우 (변수에 접근할 수 있는 범위가 넓은 경우)

    // 바닐라 JS 버전

    let discount = 0.1;
    let price = 10000;

    $discountInput.addEventListener('change', (event) => {
    // 할인율 변경 이벤트
    discount = Number(event.target.value) / 100;
    const discounted = price * (1 - discount);

    const $result = document.querySelector('#result');

    $result.textContent = `${discounted}`;
    });

    $priceInput.addEventListener('change', (event) => {
    // 가격 변경 이벤트
    price = Number(event.target.value);
    const discounted = price * (1 - discount);

    const $result = document.querySelector('#result');

    $result.textContent = `${discounted}`;
    });

    let으로 선언된 값이 어떻게 사용되는지 알아보기 위해서 살펴야 하는 반경이 넓습니다.

    두 이벤트 리스너를 확인해야하고, price가 숫자일 수도 있고 문자열일 수도 있다면 사용하는 곳에서 모든 가능성에 대비해야 합니다.

    • 해결

      변수에 접근할 수 있는 범위, 즉 스코프를 제한하여 일정 부분 해소할 수 있습니다.

      (() => {
      // 즉시실행 함수를 통해 변수에 접근할 수 있는 범위를 제한합니다.
      let discount = 0.1;
      let price = 10000;

      // 할인율과 가격을 변경하는 함수를 만듭니다.
      // 이렇게 함으로써 discount와 price에 정해진 처리를 통해 값이 할당되는 걸 보장할 수 있습니다.
      const setDiscount = (value) => {
      discount = Number(value) / 100;
      };

      const setPrice = (value) => {
      price = Number(value);
      };

      $discountInput.addEventListener('change', (event) => {
      // 할인율 변경 이벤트
      setDiscount(event.target.value);
      const discounted = price * (1 - discount);

      const $result = document.querySelector('#result');

      $result.textContent = `${discounted}`;
      });

      $priceInput.addEventListener('change', (event) => {
      // 가격 변경 이벤트
      setPrice(event.target.value);
      const discounted = price * (1 - discount);

      const $result = document.querySelector('#result');

      $result.textContent = `${discounted}`;
      });
      })();

      위와 같이 즉시 실행 함수로 discount와 price의 사용 범위를 감싸면 즉시 실행 함수의 외부에서 discount와 price에 접근하는 걸 제한할 수 있고 수정이 발생할 때 살펴봐야 하는 범위도 제한할 수 있습니다.

      또한 setDiscount 그리고 setPrice 처럼 변수에 값을 할당하는 방법을 제한함으로써 값을 사용하는 곳에서 예상 가능한 값을 사용할 수 있게 됩니다.

    // 리액트 버전

    function Page() {
    const [price, setPrice] = useState(10000);
    const [discount, setDiscount] = useState(0.1);

    return (
    <>
    <input
    onChange={(event) => {
    setPrice(Number(event.target.value));
    }}
    />
    <input
    onChange={(event) => {
    setDiscount(Number(event.target.value) / 100);
    }}
    />

    <p>할인된 가격: {price * (1 - discount)}</p>
    </>
    );
    }
    • 해결

      Page 컴포넌트에는 더 많은 변수와 컴포넌트들이 생길 수 있고 이 변수와 컴포넌트들이 price와 discount를 조작하거나 가져가 사용할 수 있기 때문에 PriceAndDiscount 컴포넌트로 분리합니다.

      또한 price와 discount가 예상 가능한 형태로 수정되는 걸 보장하기 위해 각 변수를 위한 훅을 만들어 줍니다.

      const usePrice = (initialValue) => {
      const [price, setPriceAction] = useState(initialValue);
      const setPrice = (value) => {
      // setPrice처럼 price에 값을 할당 할 때 Number로 형변환 하는 걸 보장합니다.
      setPriceAction(Number(value));
      };

      return [price, setPrice];
      };

      // useDiscount도 usePrice와 같은 방법으로 작성합니다.
      const useDiscount = (...) => {...};

      function PriceAndDiscount() {
      const [price, setPrice] = usePrice(10000);
      const [discount, setDiscount] = useDiscount(0.1);

      return (
      <>
      <input
      onChange={(event) => {
      setPrice(event.target.value);
      }}
      />
      <input
      onChange={(event) => {
      setDiscount(event.target.value);
      }}
      />

      <p>할인된 가격: {price * (1 - discount)}</p>
      </>
      );
      }

      function Page() {
      return (
      <>
      <PriceAndDiscount />
      ...
      </>
      );
      }

      지금까지의 과정을 요약하면 캡슐화라고 할 수 있습니다.

A-b. 함수, 컴포넌트 의존성

여러 곳에서 함께 사용하는 컴포넌트는 시간이 갈수록 전달받는 속성과 조건문이 추가되면서 수정하기엔 몸집이 너무 커지는 현상이 생깁니다.

이런 형태의 의존성 문제를 해결하는 방법 중 한 가지는 의존성 역전입니다.

예를들어 다양한 곳에서 동일한 유틸 함수 getNumber에 의존할 때, getNumber가 바뀌면 사용하는 곳 모두 변경 가능성에 노출되기 때문에 문제가 생길 수 있습니다.

function getNumber(str) {
return str.replace(/\D/g, '');
}
$inputPhoneNumber.addEventListener('change', (event) => {
// 전화번호를 입력할 때 숫자가 아닌 값을 제거합니다.
const phoneNumber = event.target.value;

event.target.value = getNumber(phoneNumber);
});

$inputPrice.addEventListener('change', (event) => {
// 가격을 입력할 때 숫자가 아닌 값을 제거합니다.
const price = event.target.value;

event.target.value = getNumber(price);
});

이러한 의존성의 방향을 반대로 바꿀 수 있다면 유틸 함수의 수정으로 인해 사용하는 곳의 코드가 바뀌지 않아도 됩니다. 의존성의 방향을 바꾸는 의존성 역전은 아래와 같이 사용합니다.

function getNumber(str) {
return str.replace(/\D/g, '');
}

/**
* 값과 파서를 입력받아 값을 전화번호 형식에 맞게 파싱합니다.
* @param {string} value
* @param {(value: string): string} parser
* @returns {string}
*/
function parsePhoneNumber(value: string, parser: (value: string) => string) {
return parser(value);
}

$inputPhoneNumber.addEventListener('change', (event) => {
const phoneNumber = event.target.value;

event.target.value = parsePhoneNumber(phoneNumber, getNumber);
});

이 코드가 이전과 달라진 점은 parsePhoneNumber 함수 입니다. 이 함수는 value라는 문자열과 문자열을 전달받아 문자열을 반환하는 parser 함수를 매개변수로 사용하고 있습니다.

만약 parsing하는 방식이 변경된다면, 아래와 같이 변경할 수 있습니다.

$inputPhoneNumber.addEventListener('change', (event) => {
const phoneNumber = event.target.value;

event.target.value = parsePhoneNumber(
phoneNumber,
(str) => str.replace(/[^0-9-]/g, ''),
);
});

위 방법을 통해 전화번호 입력 이벤트 리스너는 parsePhoneNumber의 존재로 인해 getNumber에 직접적으로 의존하지 않습니다.

오히려 ‘getNumber’가 parsePhoneNumber의 ‘두 번째 인자는 문자열을 전달받아 문자열을 반환하는 함수이어야 합니다.’라는 규칙에 의존합니다. 이 규칙을 지키지 않는다면 getNumber는 parsePhoneNumber에 의해 사용될 수 없습니다.

물론 문맥이 비슷한 함수나 컴포넌트처럼 묶어야하지만, 이처럼 매개변수 및 반환 타입이 동일한 조건도 의존성으로 볼 수 있습니다.

A-c. 타입 의존성

가장 많이 경험하는 사례 중 API 요청에 대한 응답값을 타입으로 관리하는 경우를 예로 들어보겠습니다.

export type PostResponse = {
id: number;
title: string;
content: string;
likes: number;
createdAt: Date;
updatedAt: Date;
userId: number;
nickname: string;
comments: {
id: number;
content: string;
createdAt: Date;
updatedAt: Date;
userId: number;
nickname: string;
}[];
};

export const fetchPost = (postId: number) => {
return fetch(`/api/posts/${postId}`)
.then((res) => res.json())
.then((data: PostResponse) => {
return data;
});
};

import { fetchPost } from '../api/fetchPost';
import type { PostResponse } from '../api/fetchPost';
import { PostDetail, Comments } from './components';

export function PostDetailPage() {
const [loading, setLoading] = useState(true);
const [post, setPost] = useState<PostResponse | null>(null);

useEffect(() => {
fetchPost(postId)
.then((data) => {
setPost(data);
})
.catch((error) => {
console.error(error);
// error 처리
})
.finally(() => {
setLoading(false);
});
}, []);

if (loading) {
return <p>loading...</p>;
}

return (
<div>
<PostDetail post={post} />
<Comments comments={post?.comments} />
</div>
);
}

import { type PostResponse } from './PostResponse';

export function PostDetail({ post }: { post: PostResponse | null }) {
// ...
}

export function Comments({ comments }: { comments?: PostResponse['comments'] }) {
// ...
}

위 코드에 동일한 fetchPost 함수로 API를 호출하는 컴포넌트가 추가되면, PostResponse 타입이 변하면 6개의 컴포넌트가 영향을 받습니다.

PostDetail, Comments, EditPanel, Editor 컴포넌트의 경우, PostResponse 타입에서 comments만 필요로하지만, PostResponse 타입 전체에 의존하고 있습니다. PostResponse에서 모든 컴포넌트가 공통으로 사용되는 타입을 분리할 필요가 있습니다.

// types.ts
type Post = {
id: number;
title: string;
content: string;
likes: number;
createdAt: Date;
updatedAt: Date;
userId: number;
nickname: string;
};

// api/fetchPost.ts
import type { PostDetail } from '../types';

export type PostResponse = Post & {
comments: {
id: number;
content: string;
createdAt: Date;
updatedAt: Date;
userId: number;
nickname: string;
}[];
};

// components
import type { Post } from '../types';

export function PostDetail({ post }: { post?: Post }) {
// ...
}

이에 쉽게 바뀌지 않고 자주 변경되지 않는 속성을 모은 Post 타입을 따로 분리합니다. 이를통해 의존성의 갯수가 늘어나더라도 PostResponse 타입에 의존하는 것보다 Post 타입에 의존하는 것이 더 높은 안정성을 제공할 수도 있습니다.

따라서 아래 그림 처럼 우린 코드를 볼 때 개별 컴포넌트나 타입들이 Post에 의존한다는 개념이 아니라 프론트엔드 전체 코드가 Post라는 도메인에 기반을 둔 타입에 의존한다는 개념으로 이해할 수 있게 됩니다.

또한 필요하다면 각 컴포넌트에서 독립적으로 타입 의존성을 관리할 수 있습니다.

import type { Post } from '../types';

export function PostDetail({
post,
readonly,
}: {
post?: Omit<Post, 'userId'>;
readonly?: boolean;
}) {
// ...
}

타입의 어떤 부분을 공통적으로 관리하고 어떤 부분을 각 함수, 변수, 컴포넌트 등이 스스로 관리할지 결정하는 건 코드가 처해있는 상황과 코드를 관리하는 구성원들의 논의를 통해 결정해야 합니다.

글을 읽고

💡 문제 원인 분석

  • 공통 컴포넌트로 묶으려고 시도했던 각 페이지에서 요구하는 props와 상태, 함수, 컴포넌트, 타입을 하나씩 확인해 보았습니다.
  • 공통 컴포넌트가 시간이 지날수록 수정하기 어려웠던 이유는 타입 의존성를 고려하지 않았기 때문이었습니다.
  • 장바구니에서 페이지에서의 숙소 리스트와 예약 내역 확인 페이지에서 사용하는 숙소의 정보가 UI 측면에서는 동일했지만, 결제 전과 결제 후의 숙소 데이터 관리 방식이 달라지기 때문에 타입이 서로 달랐습니다.
// 장바구니 페이지에서의 숙소 리스트 타입

export interface RoomOption {
cartProductId: number;
roomOptionId: number;
name: string;
thumbnailImage: string;
capacity: number;
pricePerNight: number;
reservationStartDate: string;
reservationEndDate: string;
stayDuration: number;
transportation?: string;
totalPrice?: number;
}
// 예약 내역 확인 페이지에서의 숙소 리스트 타입

export interface PaymentRoomOption {
paymentProductId: number;
accommodationId: number;
roomOptionId: number;
name: string;
thumbnailImage: string;
capacity: number;
pricePerNight: number;
totalPrice: number;
reservationStartDate: string;
reservationEndDate: string;
stayDuration: number;
numberOfGuest: number;
transportation: string;
}

💡 해결

  • 장바구니 페이지에서 RoomOption 타입을 사용하는 숙소 리스트의 경우 모두 공통 컴포넌트로 쉽게 묶을 수 있었습니다.
  • 예약 내역 확인 페이지에서의 숙소 리스트 컴포넌트의 경우 독립적으로 관리하는 것으로 결정하였습니다.
  • PR 링크