본문으로 건너뛰기

왜 React는 Server Component를 만들게 되었을까?

· 약 19분
Hyunmo Ahn
Front End Engineer @ Line+

Purpose

React 18이 2022년에 출시되었고, Nextjs App router가 2023년에 출시되어 이미 케케묵은 조사이지만, nextjs page router와 app router를 쓰며 어떤 점이 달라졌고, 왜 달라졌는지 이유를 찾던 중 app router를 왜 사용하게 되었는지 이유를 찾게 된 글이 있다. 그 글을 읽고 이해한 바를 정리하고자 한다.

Story

글을 읽기 전에 다음과 같은 상황을 가정해보자.

새로운 회사로 이직을 하기 위해 Frontend 개발자 면접자리에서 다음과 같은 질문을 받았다고 하자.
아니면, 새로운 프로젝트 혹은 기존 프로젝트에서 nextjs app router를 사용하자고 팀원들을 설득을 하는 자리이거나,
혼자서 nextjs의 page router와 app router 중 어떤 기술이 적합한지 고민하고 있는 상황이라고 하자.

단순 app router가 새롭게 나온 기술이고, Frontend 개발자로써 app router를 사용해보자는 답안도 충분히 설득력이 있을 수 있다. 하지만, 아래와 같은 질문의 답을 이해하고 있다면, 조금 더 설득력 있는 답변을 할 수 있을 것이다.

Question
  • Q. SSR(Server Side Rendering)과 CSR(Client Side Rendering)의 차이점은 무엇인가?
  • Q. 기존 방식 (nextjs page router)에서 어떤 한계가 nextjs app router 방식을 만들게 되었을까?
  • Q. React에서 Suspense를 사용하는 이유는 무엇일까?

위 질문의 답을 알고 있다면, 본문을 읽지 않아도 무방할 것이고 조금 더 신뢰도가 있는 글을 원한다면 React 18 Architecture 소개 글을 보아도 좋을 것이다. 본문을 읽지 않고 답만 알고 싶다면 Result를 먼저 살펴보자.

다음 본문은 Architecture 글을 읽고, 이해한 바를 필자의 의견을 섞어 정리한 글이다.

Local Storage 용량 초과

· 약 5분
Hyunmo Ahn
Front End Engineer @ Line+

Introduction

프로젝트에서 LocalStorage를 사용했을 때 용량을 고려한 적이 있나요?

localStorage의 용도에 따라 다르겠지만, 대부분의 경우는 용량을 고려하지 않고 사용하게 됩니다. 하지만, 데이터의 성격이 계속해서 쌓이는 구조의 데이터를 저장한다면 데이터는 localStorage의 용량을 초과할 수 있습니다.

초과하면 과연 어떻게 동작할까요?

정답은 Throw 에러 입니다.

Figure 1Local Storage Exceed Error

Solution

위 에러가 발생하면, setStorage 구문은 throw로 인해 종료가 되고 에러 전파를 진행하게 됩니다.

프로젝트에 에러 전파를 잘 처리하고 있다면, 에러 메세지와 함께 에러 로그가 수집 될 것이고 전파를 잘 처리 하지 않았다면 프로젝트가 멈출 수 있습니다.

이러한 상황을 방지하기 위해서는 try-catch 구문을 사용해서 에러를 잡아내고, 에러를 처리하는 로직을 추가해야 합니다.

const setStorage = (key, value) => {
try {
window.localStorage.setItem(key, value);
} catch (e) {
// Stop Throw Error
console.error('Local Storage Exceed Error', e);
}
}

물론 에러가 발생했다는 것은 LocalStorage의 용량을 초과했다는 것이므로 앞으로 사용자는 계속해서 Local Storage Exceed Error가 발생하고, 데이터를 저장하지 못할 것 입니다.

따라서 에러처리 뿐 아니라 데이터를 지워주는 작업까지 추가해주어야합니다.

const setStorage = (key, value) => {
try {
window.localStorage.setItem(key, value);
} catch (e) {
// Stop Throw Error
console.error('Local Storage Exceed Error', e);
window.localStorage.clear();
}
}

React의 이벤트 전파

· 약 13분
Hyunmo Ahn
Front End Engineer @ Line+

Introduction

React 이벤트 핸들러 방식과 vanilla Javascript의 이벤트 핸들러 방식을 혼용해서 사용할 때 이벤트 전파가 의도한대로 동작하지 않을 수 있다. 예를 들면 아래와 같이 button1, button2 구조에서 button2를 클릭했을 때 button1의 이벤트 핸들러도 실행되는 문제가 발생한다.

const buttonEl = document.getElementById('button1');
buttonEl.addEventListener('click', () => {
console.log('button1 clicked');
});

const handleClick = (e) => {
e.stopPropagation();
console.log('button2 clicked');
};

return (
<button id='button1'>
<button id='button2' onClick={handleClick}>Click me</button>
</button>
);
// When button2 is clicked
button2 clicked
button1 clicked

Solution

React에서 이벤트 전파는 vanilla Javascript에서 사용하는 이벤트 전파와 다르다. React에서 이벤트 전파를 delegation 방식으로 처리하기 때문이다. (comment)

React에서는 (React 17 이후로) 이벤트 리스너를 rootDOM에 등록해서 사용한다. 따라서 React끼리의 event listener는 생각한 대로 DOM 구조에 따라 이벤트가 전파가 전달되지만 vanilla Javascript의 이벤트 전파는 생각대로 동작하지 않는다.

가능하다면 두가지 이벤트를 섞어서 쓰지 않는 방향이 좋겠지만, 3rd party library를 사용하거나 제어하지 못하는 부분의 이벤트는 제어하기 힘들기 때문에 동작 방식에 맞춰 이벤트 전파를 막아야한다.

// Unify event listener to vanilla Javascript
const buttonEl = document.getElementById('button1');
buttonEl.addEventListener('click', () => {
console.log('button1 clicked');
});

const button2El = document.getElementById('button2');
button2El.addEventListener('click', (e) => {
e.stopPropagation();
console.log('button2 clicked');
});

return (
<button id='button1'>
<button id='button2'>Click me</button>
</button>
);

// Or
// Unify event listener to React
const handleClick1 = (e) => {
console.log('button1 clicked');
};

const handleClick = (e) => {
e.stopPropagation();
console.log('button2 clicked');
};

return (
<button id='button1' onClick={handleClick1}>
<button id='button2' onClick={handleClick}>Click me</button>
</button>
);

Nextjs에서 server action은 순차적으로 실행된다

· 약 8분
Hyunmo Ahn
Front End Engineer @ Line+

Introduction

nextjs에서 server action은 client 환경에서도 server를 통해 함수를 호출하기 위한 방법으로 사용된다. 이 때, server action은 순차적으로 실행되어 여러 함수를 호출하더라도 하나씩 실행되므로 사용에 유의해야한다.

Behavior

간략하게 요약 된 예시를 보자. 예시는 두가지 함수가 존재한다.

'use server';
import { format } from 'date-fns';

export const callServerFn = async (count)=> {
return new Promise((resolve) => setTimeout(() => {
const time = format(new Date(), 'HH:mm:SS.sss');
console.log(`Server Call #${count}: `, time);
resolve(count)
}, 1000));
}

callServerFn 함수는 응답에 1초가 걸리는 함수이고, 'use server'를 사용하며 server action으로 사용된다. 해당 로직은 next server 에서 이루어진다. 서버 응답 타이밍을 확인하기 위해 log를 찍고있다.

// client.tsx
'use client';

export const CallButton = () => {
const handleClick = async () => {
const result = await Promise.all([
callServer(1),
callServer(2),
callServer(3),
])
}

return (<button onClick={handleClick}>Hello World</button>);
}

CallButton 컴포넌트는 버튼을 클릭하면 callServer 함수를 3번 호출하고, 모든 함수가 완료되면 결과를 반환한다.

Server Call #1:  01:23:22.010
Server Call #2: 01:23:24.011
Server Call #3: 01:23:25.012

버튼을 눌렀을 때 분명 3개의 호출이 동시에 이루어졌지만, log 결과는 하나씩 순차적으로 1초에 한번씩 호출되는 것을 확인 할 수 있다.

CSS로 line-spacing을 구현하는 방법

· 약 4분
Hyunmo Ahn
Front End Engineer @ Line+

Introduction

CSS에서는 줄 간격을 조정하는데 line-height를 사용한다. 하지만 내가 경험했던 프로젝트에서 line-spacing을 지원해야하는 요구사항이 있었다. 이에 대해 CSS로 line-spacing을 구현하는 방법에 대해서 설명하고자 한다.

Line Spacing

Line Gap

Line Gap

Line Gap

Line Spacing (5px)

Line Height

Line Gap

Line Gap

Line Gap

Line Height (fontSize + 5px)

Cheat Sheet

여백이 위, 아래로 나눠서 적용되는 line-height와 달리, line-spacing은 줄 사이에만 여백을 적용한다. 이 때, line-height와의 차이는 줄의 처음과 끝의 여백이 들어가지 않는다는 점이다.

이를 이용해서 margin을 이용하여 line-spacing을 구현할 수 있다.

const lineHeight = 20; // origin line-height
const lineSpacing = 10;
const margin = - (lineSpacing / 2);

const LineSpacingText = () => {
return (
<p style={{
marginTop: `${margin}px`,
marginBottom: `${margin}px`,
lineHeight: `${lineHeight + lineSpacing}px`,
}}>
Hello, World!
</p>
);
};

Playground

Line Spacing 0px

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet.

Line Spacing (0px)

Line Height (0px + fontSize)

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet.

Line Height (0px + fontSize)

nested component를 피해야 하는 이유

· 약 5분
Hyunmo Ahn
Front End Engineer @ Line+

Introduction

프로젝트를 진행하면서 불필요하게 re-render가 발생하는 문제를 만났다.

단순히 render를 하는 것 뿐 아니라 매 render시마다 DOM을 새롭게 다시 그리고 있던 것이 문제였는데, 원인은 nested component로 사용한 코드였다.

따라서 이 글에서는 nested component를 사용하는 것을 피해야 하는 이유를 설명하고자 한다.

Cheat Sheet

// Bad
const List = ({ hasWrapper, borderStyle, children }) => {
const Border = () => {
return <p style={borderStyle}>{children}</p>;
}

if (hasWrapper) {
return (
<Wrapper>
<Border />
</Wrapper>
);
}

return <Border/>;
};
// Good
const List = ({ hasWrapper, borderStyle, children }) => {
if (hasWrapper) {
return (
<Wrapper>
<Border borderStyle={borderStyle}>
{children}
</Border>
</Wrapper>
);
}

return (
<Border borderStyle={borderStyle}>
{children}
</Border>
);
};

const Border = ({ borderStyle, children }) => {
return <p style={borderStyle}>{children}</p>;
}

위와 같이 Border의 코드를 재사용하고, List의 props를 재사용하기 위해 nested component를 사용하게 될 수 있다. 하지만 이런 경우 children은 불필요하게 매 render시마다 새롭게 DOM이 그려지게 된다.

callback function props로 인한 re-render를 피하는 법

· 약 11분
Hyunmo Ahn
Front End Engineer @ Line+

React를 사용하다 보면 function을 component props로 넘길 때 re-render를 피하기 위해 reference 관리에 주의해야 한다. 대부분 useCallback을 사용해서 불필요한 re-render를 피하게 되지만 좋은 대안을 발견하게 되어 이 글을 쓰게 되었다.

영감을 받은 코드는 radix-ui/primitivesuseCallbackRef 이며 이 글에서는 useCallbackRef를 쓰는 케이스와 동작을 이야기 할 예정이다.

아래의 코드는 앞으로 설명 할 useCallbackRef 코드이다. 과연 어떤 상황에서 사용 할 것 같은가?

// useCallbackRef.js
import { useRef, useEffect, useMemo } from 'react';

export function useCallbackRef(callback) {
const callbackRef = useRef(callback);

useEffect(() => {
callbackRef.current = callback;
});

return useMemo(() => ((...args) => callbackRef.current?.(...args)), []);
}

i18n과 typescript

· 약 40분
Hyunmo Ahn
Front End Engineer @ Line+

Intro

현재 참여하고 있는 프로젝트에서 typescript와 i18next을 사용하는데, i18n JSON 파일의 type check를 강하게 적용했던 경험을 정리하고자 한다.

About I18n

먼저 i18n을 어떤식으로 활용하고 있었는지 소개하자면 다음과 같다.

i18n은 같은 화면에서 여러 언어로 된 Text를 보여주기 위해 사용하는 것으로, 하나의 key, 여러개의 value를 가지고 언어에 맞게 보여주는 것을 의미한다.

const i18nJSON = {
'simple-example': 'This is example text',
'values-example': 'I need to show {0} value',
'line-break-example': 'Hello. \n I am FE developer'
} as const

첫번째로 위와 같은 Key-Value 형태의 객체가 존재한다. 객체는 JSON이 될 수도 있고, typescript의 객체가 될 수도 있다.

import tFunction from 'utils';

tFunction('simple-example') // This is example text
tFunction('values-example', [15]) // I need to show 15 value
tFunction('line-break-example') // Hello <br /> I am FE developer

두번째로 tFunction를 사용해서 i18n key를 집어 넣어서 key에 맞는 string을 가져오며, 케이스에 따라서는 values-example과 같이 변수를 넣어 Text별로 맞는 value를 포함한 string을 리턴하는 경우도 있다. 마지막 line-break-example에서는 \n 줄바꿈 문자를 인식하여 <br/> 태그로 변환해서 React에서 줄바꿈을 할 수 있도록 사용하고 있다.

주의

본 article은 i18n을 적용하기 위한 방법으로 tFunction이라는 함수를 가져다 쓴다. JS로직이 아닌, Type 레벨에서의 내용을 주로 다루기 때문에 실제 내부 로직이 어떻게 되는지는 다루지 않을 예정이다. i18next.t와 같은 함수와 같은 역할을 한다고 보면 될 것이다.

How about I18n return type

여기서, tFunction에서 return 된 값이 어떤 값으로 나오는지 알 수 있다. 그렇다면 각 return값의 type은 어떻게 될까?

import tFunction from 'utils';

tFunction('simple-example')
// This is example text
// string
tFunction('values-example', [15])
// I need to show 15 value
// string
tFunction('line-break-example')
// Hello <br /> I am FE developer
// ReactElement

L3, L6는 string 타입이라고 보아도 무방하다. 하지만 L9에서는 i18n text에 \n가 있기 때문에 ReactElement가 리턴된다.

노트

ReactElement로 리턴하지 않고, string을 리턴하면서 줄바꿈을 지원하기 위해서는 dangerouslySetInnerHTML를 사용해야한다. 하지만 여러 한계가 있으므로 string 대신에 ReactElement로 리턴한다.

만약 values-example의 변수로 15가 아니라 a tag와 같은 JSX를 넣는다면, values-example의 리턴 타입 또한 달라진다.

import tFunction from 'utils';

tFunction('values-example', [15])
// I need to show 15 value
// string

tFunction('values-example', [<a href="/about">more</a>])
// I need to show <a href="/about">more</a> value
// ReactElement

위와 같이 같은 values-example I18n key를 사용하더라도 values로 어떤 값이 오느냐에 따라서 타입이 달라져야한다. a tag 또한 <br/>과 마찬가지로 string이 아니라 ReactElement인 컴포넌트로 리턴해야 하기 때문이다.

What is matter?

자, i18n Text 타입이 상황에 따라 string, ReactElement로 달라진다는 것을 알았다. 그렇다면 어떤 것이 문제일까?

tFunction은 위에서 이야기 한 것과 같이 똑똑하게 타입을 추론해주지 않는다는 점이다.

여기서 type이 적절히 추론되지 않는다면 어떤 문제가 있을까?

<input
type='input'
placeholder={tFunction('line-break-example')} // type error
/>

여러가지 경우가 있겠지만, 대표적으로는 HTML tag를 사용할 때 string으로 정의 된 attribute에 string이 아닌 i18n 값이 포함 될 수 있다. 이러면, placeholder에 xlt text 대신 [object Object]가 보이게 된다.

물론, placeholder에 a tag가 들어가거나 \n 같은 줄바꿈이 들어가는 케이스가 명백히 이상하다. 하지만 i18n key를 잘못 사용하는 경우도 있고, i18n text가 잘못 등록 된 경우도 있을 수 있다.

즉, typescript를 사용하는데 타입이 제대로 추론 되지 않는 다는 점에 있어서 위와 같은 문제점이 있고 또 다른 문제를 야기 할 수 있다.

이번 article에서는 i18n text의 format 별로 타입을 정의하는 방법에 대해서 이야기 해 볼 예정이다. i18n을 예로 들었지만 기본적으로는 typescript Template Literal Types과 관련된 이야기이다.

사전지식
  • typescript 4.1+ 에 대한 전반적인 지식
  • Template Literal Types에 대한 관심
  • i18n system 사용 경험 (optional)

Front-end에서 OAS generator를 어떻게 쓰면 좋을까?

· 약 34분
Hyunmo Ahn
Front End Engineer @ Line+

최근 프로젝트에 OAS-generator를 도입해서 사용하고 있다. 사용하기로 결정할 때 고민이나 확인이 필요한 점이 많았고, 적용 이후 좋은 점도 많은 것 같아서 OAS-Generator 사용 경험에 대해 글을 쓰려한다.

아마 OAS Generator가 무엇인지 궁금한 분들, 알고는 있지만 도입에 고민이 되시는 분들, 이미 사용하고 있지만 잘 사용하고 있는지 망설여지는 분들이 이 글을 읽고 좋은 모티브나 경험을 배워갔으면 좋겠다.

이 글에서 말하고 있는 건 다음과 같다.

  • OAS-generator 가 어떤 것인지.
  • OAS-generator 를 사용했을 때의 장단점.
  • OAS-generator 를 어떻게 사용해야하는지.
    • 설정
    • 커스텀 템플릿
  • 최적화
사전지식
  • Rest API로 Front-end 개발을 진행해 본 경험
  • Mustache 문법을 읽을 수 있는 지식 (Optional)
    • 글을 이해하는데는 필요없지만 실제로 사용한다면 꼭 알아야한다.

immer 내부 살펴보기

· 약 45분
Hyunmo Ahn
Front End Engineer @ Line+

이 글은 기본적으로 immer에 대해서 알아보는 시간을 가진다. 만약 immer를 잘 모르는 분들은 아래 챕터를 먼저 읽어보는 것을 권장한다.

무엇이 궁금할까?

Question

Q1. immer는 객체 mutable하게 바꾸는 방식을 어떻게 immutable한 방식으로 바꾸어주고 있을까?

immer는 mutable하게 변경하는 객체 built-in method를 사용하더라도 immutable하게 데이터를 반환해주는 기능을 한다. 이 기능이 내부적으로 어떤 방식으로 이루어지는지 알아본다.

아래 코드 예시는 immer 공식 문서에 존재하는 basic example을 가져온 것이다.

import produce from 'immer';

const baseState = [
{
title: "Learn TypeScript",
done: true,
},
{
title: "Try Immer",
done: false,
},
]

const nextState = produce(baseState, (draft) => {
draft.push({ title: "Tweet about It" });
draft[1].done = true;
})

console.log(baseState === nextState) // false
console.log(nextState)
/*
[
{
title: "Learn TypeScript",
done: true,
},
{
title: "Learn TypeScript",
done: true,
},
{
title: "Tweet about It",
},
]
*/
Question

Q2. immer는 어떻게 structural sharing을 사용하는걸까?

*structural sharing: 객체를 copy할 때 변경되지 않은 객체는 reference를 동일하게 사용하는 방식.

객체를 immutable하게 업데이트 한다는 것은 기존 객체를 새로운 객체로 복사한다는 것이다. 즉, 복사에 비용이 발생한다. immer는 객체를 복사할 때, 변경되지 않은 reference는 재사용하는 structural sharing 방식을 사용해서 객체를 복사한다. immer에서는 어떤방식을 사용해서 structural sharing을 사용하고 있는지 알아본다.

Question

Q3. immer에서는 produce함수 내에서 draft를 직접 업데이트하는 방식이 아니라 return을 통해서 데이터를 업데이트하는 경우가 있는데, 이런 경우에 로직이 다른지?

immer를 사용할 때, 위에서 제시한 mutable한 객체 변경 방식이 아닌, 새로운 객체를 리턴하는 경우가 있다. 이는 immer와는 무관하게 immutable하게 Javascript에서 객체를 반환해주는 방식과 동일하다. immer에서는 이러한 방식을 공식적으로 허용하고 있고 두 방식, mutable하게 객체를 변경하는 방식과 immutable하게 객체를 변경시키는 방식 모두 혼용해서 쓰는 개발자도 많을 것 이다. 이러한 방식 차이는 immer에서 어떤 로직차이를 발생시키는지 알아본다.

// mutable method
const nextState = produce(baseState, (draft) => {
draft.push({ title: "Tweet about It" });
draft[1].done = true;
})

// immutable method
const nextState = produce(baseState, (draft) => {
return {
...baseState,
{ ...baseState[1], done: true },
{ title: "Tweet about It" },
}
})
사전지식
  • immer 혹은 redux-toolkit을 사용해 본 경험
  • Proxy에 대한 이해 (optional)