Archive

Effective Typescript - week 1 (item 02-03)

|

Effective Typescript - week 1 (item 02-03)


해당 포스트는 [이펙티브 타입스크립트](댄 밴더캄 지음, 장원호 옮김, 인사이트, 2021) 책을 읽으며 정리한 내용입니다.

Item02. 타입스크립트 설정 이해하기

타입스크립트 컴파일러 설정은 tsconfig.json에서 가능하다. 커맨드 라인보다 설정파일을 통하는 것이 좋다.

tsc --init 후 json 설정파일을 생성할 수 있다. 매우 디테일한 고급설정까지 가능하므로 설정을 이해하는 것은 중요하다.

  • noImplicitAny : 변수들이 미리 정의된 타입을 가져야하는지 여부
function add(a, b) { // <-- Error: 'a' 매개 변수에는 암시적으로 'any' 형식이 포함됩니다
  return a + b;
}

기존 자바스크립트를 타입스크립트로 마이그레이션하는 상황이 아니라면 위 옵션 설정을 해주는 것이 좋다.

  • strictNullChecks : null & undefined가 모든 타입에서 허용되는지 여부
const x: number = null; // <-- Error: 'null' 형식은 'number' 형식에 할당할 수 없습니다.
const a: number | null = null; // <-- 명시적으로 null 타입을 선언해주면 OK

위 옵션을 설정하려면 먼저 noImplicitAny를 설정해야한다. 가급적 프로젝트 초반에 설정하는 것이 좋다.

모든 체크를 설정하려면 strict를 설정하면 된다.


타입스크립트에서 변수 뒤에 ! 를 붙여 Null이 아닌 어선셜 연산자 혹은 확정 할당 어선셜 용도로 사용할 수 있다

Null이 아닌 어선셜 연산자 (Non-null assertion operator)

해당 변수가 null이 아니라고 컴파일러에 전달하여 일시적으로 null 제약조건을 완화한다

interface human {
  name: string | null;
}

let me: human = {
  name: null,
};

console.log(me.name.toString()); // <-- Error: 개체가 'null'인 것 같습니다.
console.log(me.name!.toString()); // OK

확정 할당 어선셜 (Definite Assignment Assertions)

값이 무조건 할당되어 있다고 컴파일러에 전달하여 변수, 객체 등을 사용할 수 있게 된다

let x: number;
console.log(x + x); // <-- Error: 'x' 변수가 할당되기 전에 사용되었습니다.
console.log(x! + x!); // OK

let a!: number;
console.log(a + a); // OK


모든 컴파일러 옵션은 여기에서 확인하도록 하자.


Item03. 코드 생성과 타입이 관계없음을 이해하기

타입스크립트 컴파일러는 JS로 트랜스파일, 타입 오류 체크 두 가지 역할을 수행한다.

두 가지 작업은 독립적이라 서로 영향을 미치지 않는다. 즉, 다음과 같은 특징을 가진다.

1. 타입 오류가 있는 코드도 컴파일이 가능하다

유효한 자바스크립트 코드라면 타입스크립트에서 타입 체크에 걸리더라도 컴파일된다.

이를 방지하고 싶다면 설정 파일에서 noEmitOnError를 설정한다.

2. 런타임에는 타입 체크가 불가능하다

interface Human {
  hp: number;
}
interface Wizard extends Human {
  mp: number;
}
type Point = Human | Wizard;

function buff(point: Point) {
  if (point instanceof Wizard) { // <-- Error: 'Wizard'은(는) 형식만 참조하지만, 여기서는 값으로 사용되고 있습니다
    return point.hp * point.mp; // <-- Error: 'Point' 형식에 'mp' 속성이 없습니다, 'Human' 형식에 'mp' 속성이 없습니다.
  } else {
    return point.hp * point.hp;
  }
}

instanceof 는 런타임에서 일어나는데 트랜스파일 후 타입이 제거되기 때문에 타입을 유지해야한다.

방법1: 해당 속성이 인자에 존재하는지 ?

if ("mp" in point) {
  // ...
}

방법2: 런타임에 접근 가능한 타입 정보를 명시적으로 저장하는 태그 기법 사용

interface Human {
  kind: "human";
  hp: number;
}
interface Wizard {
  kind: "wizard";
  hp: number;
  mp: number;
}
type Point = Human | Wizard;

function buff(point: Point) {
  if (point.kind === "wizard") {
    return point.hp * point.mp;
  } else {
    return point.hp * point.hp;
  }
}

Human과 Wizard는 각각 유니크한 kind를 가지므로 Point는 교집합이 없다. Human이거나 Wizard이거나, 둘 다 일 수 없다

이를 태그된 유니온(tagged union) 혹은 서로소 유니온이라고 불린다.

방법3: 아예 Human, Wizard를 class로 만들면 타입과 값 두 개 다 사용이 가능해진다.

3. 타입 연산은 런타임에 영향을 주지 않는다

function asNum(val: number | string): number {
  return val as number; // <-- 에러는 없다
}

// 컴파일 후
function asNum(val) {
  return val; // <-- 무슨 기능인지 알 수 없다
}

// 값을 정제하려면 런타임의 타입을 체크하고 연산을 통해 변환한다
function asNum(val: number | string): number {
  return typeof val === "string" ? Number(val) : val;
}

4. 런타임 타입은 선언된 타입과 다를 수 있다

비동기 후 응답의 값이라던지 선언된 타입이 바뀔 수 있음을 명심해야한다. 여기에 타입스크립트가 자바스크립트로 트랜스파일 되면 타입이 사라지기에 의도하지 않은 동작이 나올 수 있다.

5. 타입스크립트 타입으로는 함수를 오버로드 할 수 없다

함수의 타입을 여러 개 선언해도 구현체는 하나이기에 트렌스 파일 후 타입이 제거되면 구현체만 남게 된다.

6. 타입스크립트 타입은 런타임 성능에 영향을 주지 않는다

타입과 타입 연산자는 트랜스파일 단계에서 제거되므로 런타임 성능에 영향을 주지 않는다.




참고 자료


이펙티브 타입스크립트 - 댄 밴더캄 지음

Effective Typescript - week 1 (item 01)

|

Effective Typescript - week 1 (item 01)


해당 포스트는 [이펙티브 타입스크립트](댄 밴더캄 지음, 장원호 옮김, 인사이트, 2021) 책을 읽으며 정리한 내용입니다.

Item01. 타입스크립트와 자바스크립트의 관계 이해하기

duck typing : 동적 타이핑의 한 종류로, 객체의 변수 및 메소드의 집합이 객체의 타입을 결정하는 것

자바스크립트는 타입스크립트의 부분집합이다

타입스크립트는 컴파일도 자바스크립트, 실행도 자바스크립트로 이루어진다.

타입스크립트는 자바스크립트의 상위집합이다. 문제가 없는 자바스크립트 코드는 유효한 타입스크립트 코드라고 볼 수 있다.

.js 확장자를 가진 파일은 이미 타입스크립트라 할 수 있다. 확장자를 .ts라 바꾸더라도 달라지는 것이 없다.

하지만, 그 반대는 성립하지 않는다. 타입스크립트라고 해서 모두 자바스크립트라는 명제는 거짓이다. 타입스크립트가 타입을 명시하는 추가적은 문법을 가지기 때문.

타입스크립트 컴파일러는 자바스크립트에서도 돌아간다.


타입스크립트는 정적 타입 시스템이다

타입스크립트는 런타임에 발생할 오류를 미리 찾아낸다. 타입 체커가 모든 오류를 찾아내지는 않지만, 오탈자와 같은 문제를 찾아내기도 한다.

const options = [
  {
    name: "foo",
    age: "11",
  },
  {
    name: "foo2",
    age: "13",
  },
  {
    name: "bar",
    age: "25",
  },
];

for (const option of options) {
  console.log(option.agee); // <--- Error
}

스크린샷

하지만, 어떤게 오타인지까지 구분할 수 없기 때문에 명시적으로 타입구문을 적는 것이 옳다


타입스크립트 타입 시스템은 자바스크립트의 런타임 동작을 모델링한다

자바스크립트에서 문제가 없는 코드도 타입스크립트에서는 타입 체커에 의해 오류가 표시될 수 있다.

const a = null + 7; // <-- 자바스크립트 O, 타입스트립트 X

또, 타입 체커를 통과해도 런타임에서 오류가 나는 코드도 있다.

이들의 근본적인 원인은 타입스크립트가 이해하는 값의 타입과 실제 값이 다르기 때문이다.

타입스크립트를 사용하면 확실히 오류가 적은 코드를 작성할 수 있지만, 완벽히 사용자의 생각대로 동작하는 것은 아니기 때문에 도입할지 말지는 사용자 나름~



참고 자료


이펙티브 타입스크립트 - 댄 밴더캄 지음

React-Query 사용법

|

React-Query 사용법


1. 기본 사용

  • staleTime : re-fetch에 대한 trigger로 사용된다
  • cacheTime : 컴포넌트 unmount 후에 데이터를 언제까지 들고 있을 것인지
  • isFetching
  • isLoading
  • re-fetch되는 경우(stale 일 때)
    • query의 새 인스턴스 마운트 시
    • useQuery를 호출하는 컴포넌트 마운트 시
    • 윈도우 refocus 시
    • 네트워크가 다시 연결될 시
    • refetchInterval로 re-fetch 강제 실행
      • Automatic Polling
  • queryClient의 옵션으로 re-fetch를 핸들링 할 수 있다
    • refetchOnMount
    • refetchOnWindowFocus
    • refetchOnReconnect

2. Query Key

  • 쿼리를 캐싱하고 공유하기 위해 고유키를 사용한다
  • 날짜, 국가, 유저 등 고유 식별 데이터를 쿼리키에 사용하여 개인화 하는것이 중요
  • 사내 서비스가 국가별로 다른 도메인을 가지고 있는데 단일한 쿼리키를 사용해서 다른 국가에서도 캐싱된 이전 국가의 데이터가 불려와지는 이슈가 있었음
  • 이 쿼리키로 QueryClient에 저장된 데이터를 외부에서 꺼내올 수도 있어서 마치 redux같이 사용이 가능하다

3. Pre-fetch

  • prefetchQuery : queryClient의 메서드로 서버에서 데이터를 미리 받아서 캐시에 저장
    • useQueryClient 훅을 사용하여 생성된 queryClient에 접근할 수 있다
    • prefetch 커스텀 훅을 작성하여 사용
    • 사용자가 미리 들어갈만한 탭의 데이터를 초기에 캐시에 저장하여 그 탭에 들어갔을 시, 로딩을 기다릴 필요가 없다
    • cacheTime 안에 들어가면 캐싱된 데이터를 사용하지만 지나면 새로 re-fetch 해야함 (default : 5분)
import { useQuery, useQueryClient } from 'react-query';

async function getItems() {
    const { data } = await axios.get('/api');
    return data;
}

export function useItemsQuery() {
    const fallback = [];
    const { data = fallback } = useQuery('key', getItems);
    return data;
}

export function usePrefetchItems() {
    const queryClient = useQueryClient();
    queryClient.prefetchQuery('key', getItems);
}

// ---------------------------------------------------------

function App() {
    usePrefechtItems();
    
	return (
    	// ...
    )
}
  • setQueryData : queryClient의 메서드로 클라이언트의 데이터를 캐시에 저장
  • placeholderData : useQuery 옵션으로 initialData 옵션과 유사하게 데이터가 이미 있는것처럼 동작하지만 캐시에 저장 X
  • initialData : useQuery 옵션으로 클라이언트의 데이터를 사용하며 캐시에 저장

4. Mutation

  • useMutation :
    • 서버의 데이터를 바꾸는 네트워크 호출을 생성한다 (mutate function)
    • Query Key가 필요하지 않음
    • isLoading은 있지만 isFetching이 없다
    • 실패 시, default로 3번 retry하는 useQuery와 다르게 retry하지 않는게 default
import { useMutation } from 'react-query';

async function deletePost(postId) {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/postId/${postId}`,
    { method: 'DELETE' }
  );
  return response.json();
}

export function App() {
    const { mutate, isLoading, isError, isSuccess } = 
          useMutation((id) => deletePost(id));
    
    return (
    	<>
        	<button onClick={() => mutate(id)}>
        		Delete
        	</button>
        	{isLoading && <p>Loading...</p>}
			{isError && <p>Error...</p>}
			{isSuccess && <p>Success...</p>}
        </>
    )
}


  • 데이터 업데이트 시, queryKey prefix를 사용해 invalidate할 수 있다
  • 해당 prefix가 붙은 query들을 re-fetch하여 데이터를 다시 받아온다
  • queryClient의 invalidateQueries 사용
const queryClient = useQueryClient();

const { mutate } = useMutation(
	(id) => somethingUpdate(id),
    onSuccess: () => {
    	queryClient.invalidateQueries(['queryKey']);
	},
);


5. Infinite Scroll

  • useInfiniteQuery : data 안에 pages, pageParams 두 가지 속성을 가진다
  • 각 쿼리의 페이지 데이터들은 pages 배열 안에 들어있다
  • pageParams는 각 페이지를 가져오는데 사용하는 파라미터를 포함한다
  • pageParam의 current value는 react-query가 관리한다
  • getNextPageParam 옵션은 pageParam을 업데이트한다
  • fetchNextPage 함수로 다음 데이터를 가져올 수 있다
  • hasNextPagegetNextPageParam 함수의 return value를 base로 한다
  • isFetchingNextPage으로 일반 fetching인지 다음 페이지를 가져오는 fetching인지 구별할 수 있다
import { useInfiniteQuery } from 'react-query';
import InfiniteScroll from 'react-infinite-scroller';
import { Person } from './Person';

const initialUrl = 'https://swapi.dev/api/people/';
const fetchUrl = async (url) => {
    const res = await fetch(url);
    return res.json();
};

export function App() {
    const { data, fetchNextPage, hasNextPage, isLoading, isFetching } =
          useInfiniteQuery(
          	'sw-people',
            ({ pageParam = initialUrl }) => fetchUrl(pageParam),
            {
                getNextPageParam: (lastPage) => lastPage.next || undefined,
            }
          );
    
    if (isLoading) return <div>Loading...</div>;
    
    return (
    	<>
        	{isFetching && <div>Loading...</div>}
			<InfiniteScroll loadMore={fetchNextPage} hasMore={hasNextPage}>
				{data.pages.map((pageData) => {
        			return pageData.results.map((person) => {
                        return (
                        	<Person 
                            	key={person.name}
                                name={person.name}
                            />
                        )
                    })
    			})}
			</InfiniteScroll>
        </>
    )
}
  • 페이지 렌더링 시, data가 없으므로 isLoading 처리 필수 (초기 data.pages = undefined)
  • 인피니트 쿼리가 실행되며 처음엔 pageParam이 없으므로 initialUrl을 사용
  • getNextPageParam에 의해 다음 pageParam이 이전 페이지의 next url로 세팅이 된다
  • 어느 순간 lastPage.next가 없으면 hasNextPage가 undefined로 세팅된다
  • react-infinite-scroller 패키지 사용


6. useIsFetching & useIsMutating

  • 앱의 규모가 클 때, 여러곳에서 사용하는 react-query의 loading, fetching 상태를 하나의 hook으로 관리할 수 있다
  • useIsFetching은 react-query에서 제공하는 hook으로 현재 loading, fetching 중인 쿼리의 수를 반환한다
  • useMutation은 useIsMutating을 사용
  • 이 값이 0이 되면 현재 돌아가고 있는 쿼리가 없다는 뜻이다
  • 옵션으로 queryKey를 줘서 특정 쿼리만 트래킹하거나 filters 옵션으로 특정 쿼리를 필터링 할 수 있다
import { useIsFetching, useIsMutating } from 'react-query';

export function App() {
    const isFetching = useIsFetching();
    const isMutating = useIsMutating();
    
    // isMutating, isFetching이 0이면 false이므로 활성화된다
    const disabled = isFetching || isMutating ? true : false;
    
    return (
        <div>
          <button disabled={disabled}>버튼</button>
        </div>
    );
}


7. Error 핸들링

  • useQuery의 옵션인 onError로 에러 핸들링 할 수 있다
import { useQuery } from 'react-query';

export function useItems() {
	const fallback = [];
	const { data = fallback } = useQuery('key', fetch, {
		onError: (err) => {
			const title = err instanceof Error ? err.message : 'ERROR!';
            // toast 등 에러처리
		},
	})
}


  • useQuery 사용 시마다 각각 에러 핸들링을 하는 방법 외에 queryClient의 옵션을 사용해서 보다 중앙 집중적으로 사용할 수 있다
import { QueryClient } from 'react-query';

function queryErrorHandler(err) {
    // 쿼리 에러 시, 실행할 코드
}

function mutationErrorHandler(err) {
    // 뮤테이션 에러 시, 실행할 코드
}

export const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            onError: queryErrorHandler, 
        },
        mutations: {
          	onError: mutationErrorHandler, 
        },
    }
})


  • 이 밖에도 react의 useErrorBoundary와 함께 사용하는 방법 등이 있다


8. Custom Hook

  • useQuery 커스텀 훅 만들어 사용하기
import { useQuery } from 'react-query';

async function fetch() {
    const { data } = await axios.get('/api');
    return data;
}

export function useItems() {
    const fallback = [];
    const { data = fallback } = useQuery('key', fetch);
    return data;
}

// -------------------------------------------------------------------------

// 컴포넌트에서 사용하기
export function App() {
    const items = useItems();
    
    return (
        <>
        	{items.map((v) => <div key={v.id}>{v.name}</div>)}
        </>
    )
}


9. Filtering with the select option

  • useQuery의 select 옵션으로 데이터를 가공할 수 있다
  • select에는 함수를 적는데 fetching 후의 데이터를 인자로 받고 가공 후 반환하면 useQuery의 data로 다시 간다
  • 데이터가 바뀌거나 함수가 바뀌면 select가 실행된다
  • select function 작성 시, react 동작방식에 의해 매번 새로운 function이 되므로, useCallback으로 감싸준다


10. Optimistic UI

  • Optimistic UI : 서버 한 번 갔다 오면 UI 변화가 느려서 미리 바뀔 것이라 가정하고 UI부터 바꾸고 API 호출을 하는 것
  • API 호출이 실패하면 낭패를 보므로 rollback을 위해 query를 cancel해야한다

  • React-Query는 query를 cancel하기 위해 AbortController를 사용한다
  • AbortController를 사용하기 위해서는 React-Query version 3.30.0 이상이어야함
  • AbortSignal을 사용하기 위해서는 axios version 0.22.0 이상이어야 함
async function getItems(signal) {
    const { data } = await axios.get('/api', {
        signal,
    });
    
    return data;
}

const { data } = useQuery(
    'queryKey',
    ({ signal }) => getItems(signal);
)
  • query를 cancel하면 AbortController에 cancelEvent가 전달되고 signal을 구독하고 있는 모든 API 호출이 중단된다
  • queryClient의 cancelQueries를 사용한다
  • Optimistic Update Flow
    • useMutation 실행
    • onMutate로 일단 쿼리를 취소한다
    • 이전 데이터를 스냅샷한다
    • 새 데이터로 setQueryData를 사용하여 캐시 업데이트
    • 에러 발생 시 이전 데이터로 롤백
    • onSettled로 invalidate 실행 (onSuccess, onError 둘 다 실행된다)
const { mutate } = useMutation(
	(newUserData) => patchSomething(newUserData);
    {
    	// context를 return하여 onError에게 전달
    	onMutate: async (newData) => {
    		// 나가는 쿼리를 우선 취소
    		queryClient.cancelQueries('queryKey');
    
   			// 이전 데이터 스냅샷
    		const previousUserData = queryClient.getQueryData('queryKey');
    
    		// 캐시 업데이트 (Optimistically)
    		queryClient.setQueryData('queryKey', newData);
    
    		return { previousUserData };
		},
      	onError: (err, newData, context) => {
            // 캐시 롤백
            if (context.previousUserData) {
                queryClient.setQueryData('queryKey', context.previousUserData);
            }
        },
        onSuccess: () => {},
        onSettled: () => {
          // invalidate
          queryClient.invalidateQueries('queryKey');
        },
    },
);



참고 자료


React Query: Server State Management in React

리액트 웹 성능 최적화

|

리액트 웹 성능 최적화


웹 성능 결정 요소

  • 로딩 성능 : 리소스를 불러오는 성능

  • 렌더링 성능 : 불러온 리소스를 화면에 그려주는 성능


1. 브라우저의 렌더링 원리

  1. HTML => DOM, CSS => CSSOM 가공하여 tree 구조로 세팅

  2. tree를 조합하여 Render Tree 생성

  3. Layout 연산 (width, height 등)

  4. Layout 위에 Paint (color, shadow 등)

  5. Composite 단계에서 각 레이어를 합성

  • 이 전체 과정을 Critical Rendering Path 혹은 Pixel Pipeline이라 한다
  • width, height 등 크기 변경 시, 1~5 전 과정을 실행 => Reflow
  • color, background-color 등 색깔 변경 시, 3(Layout 연산) 스킵 => Repaint
  • Reflow
    • position, width, height, left, top, right, bottom, margin, padding, border, display, float, font-family, font-size, font-weight, line-height, overflow, min-height, text-align, vertical-align…
  • Repaint
    • background, background-*, border-radius, border-style, box-shadow, color, line-style, outline, outline-*, text-decoration…

2. Performance 패널을 이용한 분석

  • 개발자도구의 Performance 탭에서 분석이 가능
  • Frame Chart를 보며 시간이 오래 걸리는 컴포넌트, 함수를 리팩토링

3. Lighthouse 패널을 이용한 분석

  • 개발자도구의 Lighthouse로 퍼포먼스 점수 분석이 가능
  • Opportunities나 Diagnostics에 적힌 내용들을 수정하여 최적화

4. Network 패널을 이용한 분석

  • 개발자도구의 Network 패널을 이용해 network 통신 분석이 가능

5. Coverage 패널을 이용한 분석

  • 개발자도구에서 Coverage 패널로 리소스의 사용량을 체크해볼 수 있다

6. webpack-bundle-analyzer를 이용한 분석

  • webpack-bundle-analyzer 패키지 설치 후 사용
  • CRA는 cra-bundle-analyzer 사용
  • npm 패키지가 불필요한 페이지에서 사용될 경우 분리시킬 필요가 있다
  • code splitting 적용

7. 텍스트 압축

  • 로딩 성능 최적화
  • 서버에서 받는 리소스를 압축해서 받고 풀어서 클라에게 전송
  • Response Header에 Content-Encoding 항목이 있으면 압축된 것
  • 리소스를 제공하는 서버나 Router 서버에서 Compression을 적용
  • 무분별하게 압축하면 오히려 성능 저하가 올 수도 있음
  • 기본적으로 2KB 이상일 경우 compression 실행

8. 이미지/동영상 사이즈 최적화

  • 로딩 성능 최적화
  • 이미지 사이즈 최적화
    • 애초에 이미지 사이즈를 줄이거나 코드를 통해 이미지를 줄여라
    • 구글에서 제공하는 WEBP도 있음, 지원 브라우저 확인 필요
    • JPG보다 용량, 화질면에서 뛰어남
    • squoosh.app 구글에서 제공하는 컨버터
    • 실제 태그 크기가 300px 이라면 보통 2배로 해서 이미지를 600px로 리사이징
    • webp 지원하는 브라우저 분기 처리 방법
<!-- source 태그가 안먹히면 다음으로 img 태그가 먹힌다 -->

<picture>
	<source srcset="logo.webp" type="image/webp">
    <img src="logo.png" alt="logo">
</picture>
  • 비디오 사이즈 최적화
    • media.io 등 동영상 압축기를 사용
    • 구글에서 제공하는 WEBM 확장자 사용, 지원 브라우저 확인 필여
    • webm 지원하는 브라우저 분기 처리 방법
<!-- webm이 안먹히면 그 다음으로 mp4가 먹힌다 -->

<video>
	<source src="video.webm" type="video/webm">
    <source src="video.mp4" type="video/mp4">
</video>

9. 이미지 CDN을 통한 최적화

  • 로딩 성능 최적화

  • 이미지를 불러올 때, CDN으로 호출하여 직접 구현하거나 imgix 등 툴을 이용하여 필요한 크기로 줄여서 불러옴

10. 캐시 최적화

  • Lighthouse 검사 시, Serve static assets with an efficient cache policy 경고문
  • 브라우저의 캐싱방법은 메모리 캐시, 디스크 캐시가 있음
    • 메모리 캐시 : RAM에 정보를 저장하여 읽어들임
    • 디스크 캐시 : 파일 형태로 정보를 저장하여 꺼내서 읽어들임
  • 서버에서 리소스를 줄 때, cache-control을 헤더에 넣어서 캐시에 대한 설정이 가능
    • no-cache : 캐시를 사용하기 전, 서버에 검사 요청 후 사용 결정
    • no-store: 캐시 사용 안함
    • public : 모든 환경에서 캐시 사용 가능
    • private : 브라우저 환경에서만 캐시 사용, 외부 캐시 서버에서는 사용 불가
    • max-age : 캐시의 유효시간 (max-age=0은 no-cache와 같다, 만료가 되면 바로 지워버리는게 아니라 서버에 검사 요청하여 사용을 결정하기 때문)
  • 서버에서는 리소스가 만료된 것인지 어떻게 알까?
    • 리소스마다 ETag의 해시값이 있어서 이 값을 비교하여 확인한다

11. 이미지 preload

  • 로딩 성능 최적화
  • javascript의 Image Object를 이용하여 preload를 구현할 수 있다
import React, { useEffect } from 'react';

function App() {
    useEffect(() => {
        const img = new Image();
        img.src = 'https://경로';
    }, []);
    
    retrun (/* ... */)
};
  • img.src가 실행되는 순간 네트워크 통신을 하기 때문에 캐싱이 필요하다
  • 이미지의 cache-control 설정은 따로 찾아보도록..
  • 모든 이미지를 캐싱하여 보관하면 오히려 다른 성능에 영향일 미칠 수 있으므로, 제일 중요하고 큼지막한 이미지를 위주로 선정하자

12. 컴포넌트 preload

  • 로딩 성능 최적화
  • 사용자의 상호작용이 필요한 컴포넌트를 미리 load하여 가지고 있는다
  • 하지만 그 시점이 애매하다
  • 버튼의 경우, 버튼을 누를지 안누를지 모르기 때문
    • 버튼 위에 마우스를 올렸을 때 적용 가능
    • 최초 페이지 로드 후 모든 컴포넌트의 마운트 후에 적용 가능
  • 팩토리 패턴을 이용한 preload 방법
import React, { useEffect, Suspense, lazy } from 'react';

function lazyWithPreload(importFunction) {
    const Component = lazy(importFunction);
    Component.preload = importFunction;
    return Component;
};

const LazyModal = lazyWithPreload(() => import('./components/Modal'));

function App() {
    
    useEffect(() => {
        LazyModal.preload();
    }, []);
    
    return (
    	<Suspsnse fallback={null}>
        	<LazyModal />
        </Suspsnse>
    );
};
  • 컴포넌트의 마운트가 모두 완료된 후, lazy를 먹인 import를 실행하는 방법

13. Lazy Load

  • 로딩 성능 최적화
  • React의 suspense, lazy 사용
  • Next.js Dynamic import 사용
  • 최초 로딩 속도는 향상시킬 수 있으나 그 후에는 성능이 오히려 떨어질 수도 있다
  • 따라서, preload를 적용하여 이를 보완한다

14. Code Splitting

  • 로딩 성능 최적화
  • 불필요, 중복되는 코드가 없이 적절한 사이즈의 코드가 적절한 타이밍에 로드되도록

15. 이미지 Lazy Load

  • 로딩 성능 최적화

  • Intersection Observer를 사용하여 이미지 지연로딩 처리가 가능하다
  • 스크롤 이벤트는 매 스크롤 마다 함수가 실행되어 성능 최적화에 맞지 않다
import React, { useEffect, useRef } from 'react';

function App() {
    const ref = useRef(null);
    
    useEffect(() => {
  		const opts = {};
        const cb = (entries, observer) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    entry.target.src = entry.target.dataset.src;
                    observer.unobserve(entry.target);
                }
            });
        };
        const observer = new IntersectionObserver(cb, opts);
        
        observer.observe(ref.current);
    }, []);
    
    return (
    	<div>
        	<img data-src={"https://경로"} ref={ref} />
        </div>
    )
};
  • 스크롤을 내리다 이미지가 observe되는 순간 data 속성으로 박아놓은 경로를 src에 넣어준다
  • 나중에 로드해도 되는 이미지를 지연로딩 함으로서, 초기 로딩 속도를 개선할 수 있음

  • react-lazyload 패키지를 이용한 방법
import Lazyload from 'react-lazyload';

function App() {
    return (
    	<div>
        	<Lazyload>
            	<img src="" />
            </Lazyload>
        </div>
    );
}
  • react-lazyload 패키지는 Scroll Event를 기반으로 작성되어있다

16. 병목 코드 제거

  • 렌더링 성능 최적화
  • 로직의 연산 자체가 오래걸린다면 구조를 변경하는 최적화가 필요하다

17. repaint, reflow 줄이기

  • 렌더링 성능 최적화
  • GPU의 도움으로 repaint, reflow를 줄일 수 있다
  • transform, opacity 등 GPU가 관여할 수 있는 속성을 사용하여 Layout, paint 과정을 생략할 수 있다

18. 불필요한 CSS 제거 방법

  • Lighthouse의 Remove unused CSS 경고문
  • PurgeCSS를 사용하면 jsx태그와 css파일을 비교하여 사용하지 않은 className들을 필터링해준다
  • config 파일로 커스텀이 가능

19. Layout Shift 피하는 방법

  • 렌더링 성능 최적화
  • Layout Shift : 엘리먼트가 렌더링되면서 영역이 밀리는 현상
  • 개발자도구의 Performance 패널에서 Layout Shift 확인 가능
  • Lighthouse로 검사 시, Cumulative Layout Shift 수치를 0 ~ 1로 나타내어 준다\
  • 이미지 사이즈가 정해져있지 않거나, 동적으로 삽입된 콘텐츠, 웹폰트 등에 의해 발생
  • 이미지 사이즈를 정하는 방법
    • 이미지 태그 위에 부모태그를 감싸고 padding-bottom을 비율대로 설정
    • 이미지 태그를 width, height: 100%로 맞추고 position: absolute로 조정
    • 영역이 미리 잡혀있으므로 Layout Shift가 발생하지 않는다

20. useSelector 렌더링 문제 해결법

  • 렌더링 성능 최적화
function App() {
    const { a, b } = useSelector((state) => ({
        a: state.a,
        b: state.b,
    }));
}
  • 위처럼 useSelector 안에 Object로 Redux의 값을 가져오게 되면 useSelector 안의 콜백함수는 매번 다른 Object를 생성하게 되므로 a, b 이외의 전혀 상관없는 state가 바뀌었을때에도 불필요한 리렌더링이 발생하게 된다
  • Object 쪼개기
    • a, b가 string, bool 등이라면 이전 값과 비교했을때 같은 값이면 리렌더링이 안됨
function App() {
    const a = useSelector((state) => state.a);
    const b = useSelector((state) => state.b);
}
  • Equality Function 사용하기
    • react-redux에서 제공하는 shallowEqual을 두 번째 인자로 전달한다
    • shallowEqual은 a와 b 처럼 object의 최상위에 있는 값만 비교하여 값이 바뀌었을때에만 리렌더링이 일어난다
    • 주의할점은 a와 b가 object일 경우, 그 안의 값들까지 deep하게 비교하지 않는다는것
import { useSelector, shallowEqual } from 'react-redux';

function App() {
    const { a, b } = useSelector((state) => ({
        a: state.a,
        b: state.b,
    }), shallowEqual);
}

21. Redux Reselect를 통한 렌더링 최적화

  • 렌더링 성능 최적화
  • reselect 패키지로 selector를 만들어서 사용
  • input에 대한 리턴값을 캐싱해두고 state가 바뀌지 않으면 캐싱된 값을 재사용한다
import { createSelector } from 'reselect';

function App() {
    const selector = createSelector(
    	[
            (state) => state.a,
            (state) => state.b,
        ], // 첫번째 인자에 store에서 꺼내올 데이터
        (data, data2) => (
        	data === 'test'
            	? data2
            	: data2.filter((item) => item.id !== data);
        ) // 위에서 가져온 데이터를 가공할 콜백함수
    );
    
    const filtered = useSelector(selector);
}

22. memoization을 이용한 최적화

  • 렌더링 성능 최적화
  • input에 대해 return 값이 나오는 함수가 있을때, input에 대한 return 값을 캐싱해두고 동일한 input이라면 캐싱되어있는 값을 return한다
  • 중요한 것은 이 함수는 Pure Function이어야한다
  • 동일한 input이 반복적으로 입력되며, 로직 자체가 복잡한 구조를 가질때 적용해볼 수 있다
  • 아무데에서나 적용하면 결국 메모리 낭비로 이어질 수 있으므로 주의
const cache = {};

export function memoization(value) {
    if (cache.hasOwnProperty(value)) {
        return cache[value];
    }
    const returnValue = {
        a: 0,
        b: '',
    };
    
    // logic..
    
    cache[value] = returnValue;   
    return returnValue;
}
  • 함수마다 매번 이 캐싱 기능을 붙이는 것은 너무 번거로우므로 팩토리 패턴을 사용해서 util을 제작한다
  • 인자가 여러개면 결국 항상 새로운 배열이 생성되므로 key값이 매번 달라지게 된다
  • 생각해볼점
    • 여러개의 인자를 키값으로 활용하는 방안
    • 인자가 Object일때 동일한 key값으로 인식하는 방법
function memoiz(fn) {
    const cache = {};
    
    return function (...args) {
        if (args.length !== 1) {
            return fn(...args);
        }
        
        if (cache.hasOwnProperty(args)) {
        	return cache[args];
    	}
        
        const result = fn(...args);
        cache[args] = result;
        
        return result;
    };
}

23. 폰트 최적화

  • FOUT (Flash of Unstyled Text) : IE, Edge 등 텍스트 먼저 보여주고 웹폰트 적용
  • FOIT (Flash of Invisible Text) : Chrome 등 웹폰트 적용 전까지 텍스트 안보여줌
  • 폰트 적용시점 컨트롤
    • font-face의 font-display 속성 사용
@font-face {
    font-family: aaa;
    src: url('/경로');
    font-display: block;
}
  • fontfaceobserver 패키지로 폰트가 로드되는 시점을 캐치하여 애니메이션을 걸어서 사용자 거부감을 줄이는 등 활용이 가능

  • 폰트 사이즈 줄이기
  • 웹폰트 포맷 사용 : TTF/OFF > WOFF > WOFF2 (IE11 미지원)
  • 변환기 사용 transfonter.org
/* 지원하지 않는 브라우저 대응 */

@font-face {
    font-family: myfont;
    src: url('./경로/font.woff2') format('woff2'),
    	 url('./경로/font.woff') format('woff'),
         url('./경로/font.ttf') format('truetype');
}
  • local 폰트 사용
@font-face {
    font-family: myfont;
    src: local('myfont'),
    	 url('./경로/font.ttf');
}
  • Subset 사용 : 폰트 전체 중 필요한 글자만 잘라서 사용
    • 변환기의 Subsets, Characters 옵션에서 원하는 글자만 선택이 가능
  • Unicode Range 사용 : 설정한 글자가 나왔을때만 폰트 적용
@font-face {
    font-family: myfont;
    src: url('./경로/font.woff2') format('woff2');
    font-display: block;
    unicode-range: u+0041; /* A */
}
  • data-uri로 변환하여 사용
    • 변환기로 인코딩 시 base64 encoding 옵션을 체크
    • 변환해서 나온 css 파일을 열어서 src: url() 부분을 똑같이 적용한다
    • 네트워크 발생 없이 폰트를 불러오는 것을 볼 수 있음
  • font preload하여 사용
    • webpack 사용 시 빌드 시점에 preload가 적용되도록 preload-webpack-plugin 패키지 등으로 config 파일에 추가해야됨
<link rel="preload" href="폰트.woff2" as="font" type="font/woff2" crossorigin>



참고 자료


프론트엔드 개발자를 위한, 실전 웹 성능 최적화(feat.React)

Docker - 웹서버와 NginX

|

Docker - 웹서버와 NginX


1. 웹서버란?

  • 웹서버는 HTTP 요청을 읽어서 응답해주는 프로그램
  • 웹서버 프로그램을 서버상에 설치하여, 특정 HTTP 요청에 따라, 서비스를 제공해주는 방식으로 웹서비스를 구현
  • 상용으로 많이 쓰이는 웹서버 프로그램은 크게 apache, nginX가 있다

2. Apache와 NginX

  • Apache는 오픈 소스 프로젝트
  • Apache의 구동 방식
  • Prefork MPM(Multi Processing Module) 방식
    • HTTP 요청이 올 때마다, 프로세스를 복제하여, 각각 별도 프로세스에서 해당 HTTP 요청 처리
  • Worker MPM(Multi Processing Module) 방식
    • 하나의 HTTP 연결 후, 여러 요청 처리를 위해, 복제된 프로세스 내에서 쓰레드를 생성하여 여러 HTTP 요청을 처리하는 방식

  • NginX의 구동 방식

  • Event Driven 방식

    • 하나의 프로세스로 동작하며, HTTP 요청을 event로 비동기식으로 처리
      • 대부분의 HTTP 응답은 결국 html 파일을 서빙하므로 IO작업이다
      • 따라서, IO 작업으로 event를 포워딩하고 요청순이 아닌 요청이 끝난 순으로 처리
    • HTTP 요청마다 프로세스, 쓰레드 생성이 필요없으므로 리소스 관리에 장점이 있다
  • NginX 기본 사용법

  • AWS EC2 ubuntu 20.04, nginx 1.18.0 기준

  • 먼저 리눅스에 우분투 도커 컨테이너 생성 docker run -dit -p 80:8080 --name {이름} ubuntu:20.04

  • 해당 컨테이너로 접속 docker exec -it {이름} /bin/bash

  • 리소스 업데이트 apt-get update

  • 컨테이너에 nginx 설치 (버전 확인 필요) apt-get install nginx=1.18.0-0ubuntu1.2

  • 컨테이너 내에 설치된 nginx의 conf 파일을 찾는다 (컨테이너 접속 상태에서) find -name nginx.conf

  • vi 에디터로 실행 (vi가 없다면 vim 설치 필요) vi /etc/nginx/nginx.conf <— 경로 확인 필요

    • user : niginx를 돌리는 사람이 누구냐 (보통 www-data)

    • worker_process : 프로세스를 몇 개 만들꺼냐 (보통 auto)

    • pid : 시스템과 niginx 프로세스와의 연결고리 (기본으로 놔둠)

    • include : 설정이 여러 파일로 쪼개져있을 경우, 플러그인들 import시

    • events : worker_connections 이벤트를 몇 개까지 처리할꺼냐

    • http : 주로 여기 설정을 만힝 바꿈

      • nginx doc 참고

      • include /etc/nginx/conf.d/*.conf; conf.d 폴더에 내 사이트에 대한 conf 파일을 넣어서 넣는다

      • include /etc/nginx/sites-enabled/*; 기본으로 default 파일이 있는데 server 설정이 잡혀있다 sites-available의 default가 실제 파일 (심볼릭 링크가 걸려있는것)

        • default 파일의 server 설정 시 (sites-available의 default)

        • listen 8080 default_server

        • 연결할 포트 설정

        • default_server는 모든 웹서버 요청을 받겠다는 의미

        • server_name {도메인} 도메인이 없을 경우 _ (언더바)

        • index {index.html} 해당 웹서버 주소 요청 시, 디폴트로 응답할 index 파일명 설정

        • location / { try_files $uri $uri/ =404; }

        • ex)

          root /var/www/html;
                  
          location /blog {
              root /var/www
          }
                  
          # 123.0.0.1/blog/aaa.html로 접속시 root를 /var/www로 해서
          # /var/www/blog/aaa.html 파일을 찾으라는 소리 
                  
          location / {
              try_files $uri $uri/ =404;
          }
                  
          # 123.0.0.1/bbb/aaa.html로 접속시 /blog에 매칭이 안되므로
          # root인 /var/www/html/bbb/aaa.html을 찾는다
          
    • 설정을 바꿨다면 service nginx restart 명령어로 재시작


3. NginX Reverse Proxy

  • Proxy Server란 : 클라가 자신을 통해, 다른 네트워크 서비스에 접속하게 해줄 수 있는 서버
  • Forward Proxy란 : 클라가 외부 인터넷에 직접 접근하는 것이 아닌, 프록시 서버에 외부 인터넷 접근 요청을 하고, 요청에 대한 응답을 프록시 서버가 받은 후, 클라에게 전달
    • 클라가 자주 접속하는 외부 인터넷 서비가 있다면, 캐싱을 해서 성능 향상이 가능
  • Reverse Proxy란 : 클라가 리버스 프록시에 요청을 하면 관련 요청에 따라 적절한 내부 서버에 접속하여 결과를 받은 후, 클라에게 전달
    • 내부 DB 등에 직접 접속을 허용하지 않을 수 있으므로 보안에 유리
    • 요청 트래픽을 관리할 수 있는 로드 밸런싱 등에 유리

* NginX Reverse Proxy : 포트로 구분하기 예제

# docker-compose.yml

version: "3"

services:
	nginxproxy:
		image: nginx:1.18.0
		ports:
			- "8080:8080"
			- "8081:8081"
		restart: always
		volumes:
			- "./nginx/nginx.conf:/etc/nginx/nginx.conf"
			
	nginx:
		depends_on:
			- nginxproxy
		image: nginx:1.18.0
		restart: always
		
	apache:
		depends_on:
			-nginxproxy
		image: httpd:2.4.46
		restart: always

docker-compose로 프록시 서버와 2개의 웹서버를 준비

호스트 PC에 ./nginx/nginx.conf 파일을 준비

user nignx;
worker_processes auto;

# 에러 로그를 저장
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    # 데이터에 대한 타입들을 기재
    include /etc/nginx/mime.types;
    # 없는 타입에 대해 디폴트 타입 설정
    default_type application/octet-stream;
    # 로그파일에 대한 포맷 설정
    log_foramt main '$remote_addr - $remote_user [$time_local] "$request"'
        			'$status $body_bytes_sent "$http_referer"'
        			'"$http_user_agent" "$http_x_forwarded_for"';
    # 접속 기록에 대한 로그 저장
    access_log /var/log/nginx/access.log main;
    # 운영체제에서 커널영역의 작업내용을 유저영역으로 보내지 않고 바로 전송
    sendfile on;
    # 클라의 요청에 대한 응답 제한시간 설정
    keepalive_timeout 65;
    
    upstream docker-nginx {
        server nginx:80;
    }
    
    upstream docker-apache {
        server apache:80;
    }
    
    server {
        listen 8080;
        
        location / {
            proxy_pass http://docker-nginx;
            proxy_redirect off;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_fpr;
            proxy_set_header X-Forwarded_Host $server_name;
        }
    }
    
    server {
        listen 8081;
        
        location / {
            proxy_pass http://docker-apache;
            # 서버 응답 헤더의 주소 변경
            proxy_redirect off;
            # 어느 프록시에서 요청했는지
            proxy_set_header Host $host;
            # 클라이언트 IP 주소
            proxy_set_header X-Real-IP $remote_addr;
            # 어떤 프록시들을 거쳐서 왔는지
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_fpr;
            # 클라이언트의 호스트 이름
            proxy_set_header X-Forwarded_Host $server_name;
        }
    }
}

다른 설정은 기본 설정과 유사하게 따라가나, upstream, server 설정이 중요

upstream의 docker-nginx, docker-apache는 임의로 지은 이름

위 docker-compose로 만든 nginx 컨테이너의 80 포트를 사용하겠다는 의미

default.conf 설정과 겹치지 않도록 include /etc/nginx/conf.d/*.conf는 제거

외부 PC의 8080 접속에 대한 요청을 proxy_pass로 docker-nginx로 보내고 nginx의 80으로 연결한다. apache도 마찬가지.


* NginX Reverse Proxy : 경로로 구분하기 예제

포트로 구분하기 예제의 설정에서 다음 부분을 변경

# docker-compose.yml

services:
	nginxproxy:
		ports:
			- "80:80"

포트를 80포트만 열어준다 (나머지 동일)

nginx/nginx.conf 파일을 수정한다 (나머지 동일)

# /nginx/nginx.conf

 server {
        listen 80;
        
        location /nginx/ {
            proxy_pass http://docker-nginx;
            proxy_redirect off;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_fpr;
            proxy_set_header X-Forwarded_Host $server_name;
        }
    
        location /apache/ {
            proxy_pass http://docker-apache;
            proxy_redirect off;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_fpr;
            proxy_set_header X-Forwarded_Host $server_name;
         }
    }

/nginx에 대한 접속은 docker-nginx가 처리한다

이를 위해서 docker-compose를 실행하고 실행된 nginx에 접속하여 location root에 nginx 폴더를 추가해준다. (/etc/nginx/conf.d/default.conf에서 location root 확인)

테스트를 위해 index.html을 만든다

{EC2인스턴스 IP}/nginx/index.html 로 접속하여 잘 나오는지 테스트 !

아파치도 비슷하게 기본 루트에 apache 폴더를 만들고 테스트 가능


* NginX Reverse Proxy : 경로로 구분하기 예제2 (내부 서버에 요청하는 경로 변경)

{IP}/nginx/index.html로 프록시에 요청했을 때, 내부 nginx에서 {IP}/index.html로 요청한것처럼 변경하기

이를 위해서는 nginx.conf의 server 설정에서 location 안에 rewrite 옵션을 추가해야함

rewrite regex URL [flag]

  • regex : 정규표현식으로 매칭되는 URL 패턴 설정 (Perl의 정규표현식)
  • URL : 변경할 URL 설정
  • flag : 여러 location이 설정되어 있을 때, 변경된 URL이 다시 다른 location에 매칭될 수 있으므로 이를 어떻게 처리할지 설정
    • break : 변경된 URL이 다시 다른 location에 매칭되지 않도록 끝냄
server {
    location /nginx/ {
        # /nginx/ 다음에 오는 문자를 $1이라 하고, $1로 바꿈
        rewrite ^/nginx(.*)$ $1 break;
        proxy_pass http://docker-nginx;
        # ...
    }
}



참고자료


인프런 - 풀스택을 위한 도커와 최신 서버 기술(리눅스, nginx, AWS, HTTPS, flask 배포) [풀스택 Part3]