티스토리 뷰

 

 
 

들어가며


5월 초반에 2주 동안 진행했던 기초 프로젝트를 마치고, 어느새 중급 프로젝트까지 마쳤다. 이번엔 기초 프로젝트 때 보다 시간이 금방 지나간 것 같아 시원섭섭하다.
 
기초 프로젝트 때는 너무 적고 싶은게 많아서 그랬는지, 미루고 미루다 보니 결국 적지 못했다. 그래서 이번 프로젝트는 꼭 회고를 적고야 말겠다는 의지를 가지고 팀 미팅이 끝난 뒤 부리나케 카페로 달려왔다.
 
이번 프로젝트는 저번 프로젝트보다 걱정이 앞섰다. 기술 스택 중 하나인 Tailwind css도 처음 사용해보고, Next.js는 아직 익숙치 않은 상태였기 때문이었다. 하지만 걱정했던 것과는 다르게 너무 재밌게 프로젝트에 참여했고, 많은 것들을 배울 수 있었다! :)
 
 
 
 

Problem, Keep, Try


 
나는 이번 프로젝트에서, 기초 프로젝트 팀 회고로 나왔던 Try를 얼마나 개선하고 적용해보았을까?
기초 프로젝트 팀 회고로 나왔던 Try는 아래와 같다.

1. 학습의 의미로 Jira 같은 이슈 트래킹 툴을 써보자.
2. 코드 리뷰 시 의견이 충돌하는 경우 페어 프로그래밍을 진행해보자.
3. 일주일 중 작업할 수 있는 시간을 제외하고, 작업이 끝난 뒤 하루 정도는 리팩토링하는 시간을 갖자.
4. 프로젝트 시작 전에 기술 세미나를 주최해본다. (쓰게 될 기술들, 컨벤션 규칙 등)

이 중 내가 팀에게 제안했던 의견은 3, 4 번이다. 1번의 경우 개인적으로 깃헙 프로젝트 말고 다른 이슈 트래킹 툴을 사용해보고 싶었는데 노션+깃허브로 과반수 의견이 채택되어 실행하지 못했다.
 
3번째 Try 또한 제대로 실행되지 못했고, 각자 기능 구현이 끝나고 하루 정도 개인적으로 리팩토링을 진행하는 방식을 사용하게 되었다.
 
3번의 Try가 왜 제대로 실천되지 못했을까를 생각해봤는데, 모든 팀원들이 맡은 일들이 마감이 되게끔 task를 잘게 쪼개지 못했기 때문이라고 생각한다. 한 마디로 task 분배가 적절치 못했던 것 같다. 그래서 다음 프로젝트에서는 task 분배를 적절하게 해서 일괄적으로 일주일 중 하루는 리팩토링 하는 날로 정하는 것으로 진행해보고 싶다.
 
마지막 Try에 대해 말해보자면, 프로젝트 시작 전 간단히 기술 스터디를 진행했었는데 개인적으로 너무 좋은 액션이었다고 생각한다. 어떤 기술을 사용하기 전에는 반드시 왜 그 기술을 사용해야하는지에 대한 명확한 이유가 있어야 한다고 생각하는데, 스터디를 통해서 기술 사용에 대한 근거를 디테일하게 찾아볼 수 있었고, 더불어 팀원들의 의견을 들으면서 생각지도 못한 해당 기술의 장단점에 대해 알 수 있어서 시야가 넓어지는 기분이었다.
 
한 가지 아쉬운 점은 팀 위키를 만들어 문서화를 했으면 하는 것이다. 그럼 좀 더 체계적으로 진행해볼 수 있었을 것 같다. 다음 프로젝트 때 기술 스터디를 진행한다면 이 부분을 보완해서 진행해보고 싶다.
 
따라서 이번 프로젝트의 Problem, Keep, Try를 정리해보자면 아래와 같다.
 

Problem
- 이슈 트래커로 github과 노션을 사용했는데, 프로젝트 진행에 있어서 효율적으로 사용하지 못했다.

Keep
- 익숙한 것에 안주하지 않고 새로운 기술이나 시도들을 하려고 노력했다.

Try
- Jira 같은 이슈 트래킹 툴을 써보자.
- 일주일 중 작업할 수 있는 시간을 제외하고, 하루는 리팩토링 하는 시간을 갖자.
- 팀 위키를 작성해보자.

 
이슈 트래커에 대한 부분은 계속 고민을 해봐야 할 것 같다. 개발 인원이 많지 않아 데일리 스크럼으로 각자의 상황을 대부분 알고 있기 때문에 이슈 트래커를 제대로 활용하지 못했던 것 같은데, 이러한 경우 전체적인 진척도를 한 눈에 알 수 있는 방법이 없다.
 
이 부분에 대해서 멘토님께 질문도 해보고 고민해본 결과, 데일리 스크럼 이후 칸반보드에 진행 중인 작업, 완료된 작업, 대기 중인 작업을 바로 업데이트 하는 것을 규칙으로 정하는 것도 좋을 것 같다. 아니면 다른 이슈 트래킹 툴을 사용하며 효율적인 방법을 찾아보는 것도 방법일 것 같다.
 
 
 
 

Flow Chart


(자세한 내용은 아래 피그마에서 볼 수 있다.)
https://www.figma.com/design/IsjTkJ0wsJNAlqkyCdTexB/Wiki-Viki-Flow?node-id=0-1&t=XWFYNDLLgFtLRuCP-0
 
나는 기초 프로젝트 때 처음으로 Flow Chart라는 것을 접했다. 당시 팀원 중 한 분이 Flow Chart와 Wire Flow를 만들어주셔서 프로젝트의 흐름을 쉽게 이해할 수 있었던 좋은 기억이 있다.
 
따라서 이번 프로젝트에서도 프로젝트를 시작하기 전에 개인적으로 Flow Chart를 만들어보았다. 이유는 제공된 피그마 시안과 요구 사항 리스트만으로는 전체적인 흐름을 알기가 어려웠기 때문이다.
 
Flow Chart는 크게 다음과 같이 구분했다.

  • Page: 이용자가 방문할 수 있는 모든 페이지를 말한다.
  • Section: 페이지 내의 섹션을 말한다. 예를들어 홈페이지 내의 소개 섹션, 기능 설명 섹션 등이 해당된다.
  • 버튼: 각 버튼이 어떤 페이지나 섹션으로 이동하는지, 또는 어떤 모달을 여는지를 표시한다.
  • Modal: 버튼 클릭시 나타나는 모달창이다.

Flow Chart를 완성한 뒤에는 팀내에 공유해서 같은 어려움을 겪는 팀원들에게 도움이 되고자했다.

아쉬운 점은 Flow Chart를 만들 담당자를 정하고, 이를 바탕으로 개발을 진행 한 것이 아니라, 내가 스스로 불편함을 느껴서 만들고 팀에게 공유한거라 팀 내에서 활발하게 논의되진 못했다는 점이다. 다음 프로젝트 때는 팀원들에게 Flow Chart를 만드는 게 어떨지 먼저 물어본 뒤 공통의 task로 설정하면 더 효율적으로 작업할 수 있을 것 같다.
 

 

 
Tailwind CSS


 
처음 Tailwind CSS를 접했을 때는 끝도 없이 늘어나는 클래스들로 인해 코드가 매우 지저분해 보였다. 또한 자주 사용하는 유틸리티 클래스를 외워야 한다는 점이 번거롭게 느껴졌었다.

결론부터 말하자면, 지금까지 styled-component를 사용해왔지만 Tailwind CSS도 좋은 대안이 될 수 있을 것이라 생각한다.
 
내가 styled-component를 사용했던 가장 큰 이유 두 가지였다.

첫째로, 따로 css 파일을 오픈 할 필요가 없어 편리한점
둘째로, props를 사용해 css 속성을 동적으로 조작할 수 있다는 점


하지만 작성해야 하는 스타일이 많아질 수록 JS 파일이 너무 길어지고 복잡해지는 문제가 발생했다. 그래서 스타일만 모아놓은 파일을 생성하고 해당 컴포넌트에 import 하는 방식으로 사용하게 되었는데, 나중에 스타일이 추가되거나 수정이 필요한 경우에는 CSS 파일과 JS를 옮겨다니며 작업해야해서, 내가 느꼈던 styled-component의 가장 큰 장점을 잃은 것 같아 아쉬웠다.
 
Tailwind CSS는 styled-component와 동일하게 따로 스타일시트를 생성 할 필요가 없다. 또한 클래스명을 짓느라 머리를 꽁꽁 싸매지 않아도 된다. 또 여러 컴포넌트에서 사용하는 클래스들은 @apply 속성으로 지정해두면 재사용이 가능해서 유용했다.
 

@layer components {
  /* Form */
  .label {
    @apply absolute left-5 text-lg-regular transition-300 transform top-1/2 -translate-y-1/2 pointer-events-none text-gray-500;
  }

  .input {
    @apply w-full h-[50px] rounded-xl ring-1 ring-gray-400 px-5 py-3.5 placeholder-transparent outline-none hover:bg-primary-green-100 transition-300;
  }
}


 
초반에 언급했던 유틸리티 클래스도 기존에 쓰던 css 속성들과 크게 다르지 않아 몇 번 사용해보면 쉽게 외워지기 때문에 적응하는데 어려움이 없었다.
 
이번 프로젝트에서 Tailwind css를 사용해보면서 스타일을 관리하는 새로운 접근 방식을 경험할 수 있어서 좋았다. 또한 클래스명을 짓지 않아도 되는 점이 특히나 좋았다.
 
 
 
 

useAxiosFetch 훅 개선


로그인과 회원가입 페이지를 맡으면서, API 요청을 위해 아래와 같은 커스텀 훅을 만들었다.

const useAxiosFetch = <T>({
  options,
  skip = false,
  deps = [],
  includeAuth = false,
}: RequestConfig) => {
  const [data, setData] = useState<AxiosResponse<T> | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState<string | null>(null);
  const [statusCode, setStatusCode] = useState<number | null>(null);

  const axiosFetch: AxiosFetch = async (args) => {
    setIsLoading(true);
    
    try {
      const response = await axiosRequester({ ...options, ...args }, includeAuth);

      if (includeAuth) {
        const accessToken = response.data.accessToken;
        const refreshToken = response.data.refreshToken;

        if (accessToken && refreshToken) {
          document.cookie = `accessToken=${accessToken}`;
          document.cookie = `refreshToken=${refreshToken}`;
        }
      }

      setData(response);
      return response;
    } catch (err) {
      if (err instanceof AxiosError) {
        setIsError(err.response?.data?.message || err.message);
        setStatusCode(err.response?.status || null);
      }
      return err;
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    if (skip) {
      return;
    }

    axiosFetch();
  }, deps);

  return { data, isLoading, isError, statusCode, axiosFetch };
};

export default useAxiosFetch;

 

  
이 훅은 options, skip, deps, iscludeAuth 이렇게 총 4개의 인자를 받을 수 있게 설정했다.
각 파라미터에 대한 설명은 아래와 같다.

1. options: URL, method, header 등의 값이 들어간다.
2. skip: 요청을 건너뛸지의 여부를 결정하는 불리언 값이다. true로 설정되면 초기 페이지 로드 시 데이터를 페칭하지 않는다.
3. deps: 의존성 배열이다.
4. includeAuth: 요청에 인증 토큰을 포함할지 여부를 결정하는 불리언 값이다. true로 설정되면 axiosRequester 함수가 쿠키에서 accessToken을 찾아 Authorization 헤더에 추가한다.

 

 
사용 예제는 아래와 같다. 로그인 요청을 보내기 위해 사용한 코드인데, skip: true로 초기 데이터 페칭을 막고, access token이 헤더에 포함되어야 하기 때문에 includeAuth을 true로 설정했다.

// login.tsx
const { isLoading, isError, statusCode, axiosFetch } = useAxiosFetch({
    skip: true,
    options: {
      method: 'post',
      url: 'auth/signin',
    },
    includeAuth: true,
  });

 
 
 
 
그런데 기능 구현을 마친 뒤 리팩토링하는 과정에서 훅을 각각 get 요청과 post, patch, delete 요청 때 쓰이는 훅으로 분리하는게 더 나을 것 같다는 생각이 들었다.
 
이유는 get 요청의 경우 payload에 맞게 데이터를 페칭해서 state에 넣은 뒤 사용해야 하고, 나머지의 경우는 필요한 시점에 호출만 하면 되기 때문이다. 따라서 각각의 사용 패턴이 매우 다른데 나는 하나의 훅 안에서 모든 것을 해결하려고 하다보니 두 가지의 문제가 있었다.
 
첫번째는 get 요청 시에는 필요없는 axiosFetch 함수를 같이 리턴하게 되고,
두번째는 get 요청을 제외한 나머지의 경우에는 get 요청으로 인해 작동되는 것을 막기 위해 skip 이라는 파라미터를 넣어서 억지로 막는 형태 되었다.
 
따라서 함수가 두 가지 역할을 동시에 만족하려해서 일어난 일이라 생각해서 위 훅을 useQueryuseMutation 두 개의 훅으로 분리했다.

// useQuery.ts
const useQuery = <T>({ options, deps = [], includeAuth = false }: RequestConfig) => {
  const [data, setData] = useState<AxiosResponse<T> | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState<string | null>(null);
  const [statusCode, setStatusCode] = useState<number | null>(null);

  useEffect(() => {
    (async () => {
      setIsLoading(true);

      try {
        const response = await axiosRequester({ ...options }, includeAuth);

        if (includeAuth) {
          const accessToken = response?.data?.accessToken;
          const refreshToken = response?.data?.refreshToken;
  
          if (accessToken && refreshToken) {
            document.cookie = `accessToken=${accessToken}`;
            document.cookie = `refreshToken=${refreshToken}`;
          }
        }

        setData(response);
        return response;
      } catch (err) {
        if (err instanceof AxiosError) {
          setIsError(err.response?.data?.message || err.message);
          setStatusCode(err.response?.status || null);
        }
        return err;
      } finally {
        setIsLoading(false);
      }
    })();
  }, deps);

  return { data, isLoading, isError, statusCode };
};

export default useQuery;
// useMutation.ts
const useMutation = ({ options, includeAuth = false }: RequestConfig) => {
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState<string | null>(null);
  const [statusCode, setStatusCode] = useState<number | null>(null);

  const mutation: Mutation = async (args) => {
    setIsLoading(true);
    setIsError(null);
    setStatusCode(null);

    try {
      const response = await axiosRequester({ ...options, ...args }, includeAuth);

      if (includeAuth) {
        const accessToken = response?.data?.accessToken;
        const refreshToken = response?.data?.refreshToken;

        if (accessToken && refreshToken) {
          document.cookie = `accessToken=${accessToken}`;
          document.cookie = `refreshToken=${refreshToken}`;
        }
      }

      return response;
    } catch (err) {
      if (err instanceof AxiosError) {
        setIsError(err.response?.data?.message || err.message);
        setStatusCode(err.response?.status || null);
      }
      return err;
    } finally {
      setIsLoading(false);
    }
  };

  return { isLoading, isError, statusCode, mutation };
};

export default useMutation;

 
 

 
또한 어떤 기능을 가진 함수인지 명확하게 나타내기 위해 useLoginData 로 한 번 더 감싸주었다.

// loginApi.ts
const useLoginData = () => {
  const { isLoading, isError, statusCode, mutation } = useMutation({
    options: {
      method: 'post',
      url: 'auth/signin',
    },
    includeAuth: true,
  });

  return { isLoading, isError, statusCode, mutation };
};

export default useLoginData

 
 


이렇게 하면 아래와 같이 깔끔하게 사용할 수 있다.

// Login.tsx
const LoginPage = () => {
  const { isError, statusCode, axiosFetch } = useLoginData();
  ...
}


 
 

사실 프로젝트 시작 전 Tanstack-Query 라이브러리를 이용해서 구현하자는 의견도 나왔는데, 굳이 라이브러리를 쓰지 않아도 충분히 구현할 수 있을 것 같은 결론이 내려져 사용하지 않았다. 그 때 Tanstack-Query에 대해 찾아보면서 useQuery, useMutation 훅을 제공한다는 것을 알게되었고, 요청에 따라 다른 훅을 사용할 수 있다는 관점을 처음 얻게 되었다.
 
앞으로는 모든 것을 해결하는 만능 훅을 만드려고 하기 보다는 하나의 역할을 하는 훅을 만드는데 집중해야겠다.
 
번외로 이번에 라이브러리를 사용하지 않아서 좀 더 나은 코드를 짜는 기회를 얻은 것 같아 유익했다고 생각한다. 라이브러리를 사용했다면 개발 속도도 향상되고 여러모로 편리했겠지만 이런 고민을 할 수 있는 기회는 얻기 어렵지 않았을까 생각이 든다.
  
 
 
 
 

react-hook-form (feat. forwardRef)


나는 여러 공통 컴포넌트 중 input과 label 공통 컴포넌트 UI를 맡았다.

처음에는 아래와 같이 label과 input을 하나의 컴포넌트로 만들어, 이메일 필드의 경우 InputWithLabelEmail 컴포넌트를 사용하고, 패스워드 필드의 경우 InputWithLabelPassword  컴포넌트를 사용하도록 코드를 짰다.

const InputWithLabelEmail = ({ id, label, placeholder }: InputWithLabelProps) => {
  return (
    <div className="authContainer">
      <label htmlFor={id} className="authLabel">
        {label}
      </label>
      <input
        id={id}
        placeholder={placeholder}
        className="authInput"
      />
    </div>
  );
};

export default InputWithLabelEmail;

const InputWithLabelPassWord = ({ id, label, type, placeholder }: InputWithLabelProps) => {
  return (
    <div className="authContainer">
      <label htmlFor={id} className="authLabel">
        {label}
      </label>
      <input id={id} type={type} placeholder={placeholder} className="authInput" />
    </div>
  );
};

 
 
 
 
하지만 만약 이런 컴포넌트가 10개라면? 100개라면? 100개의 'InputWithLabel어쩌구' 컴포넌트를 만들어야 한다. 따라서 input과 label을 컴포넌트로 각각 분리하고 결합하는, 아토믹 디자인을 적용했다.
 
이후에 react-hook-form을 사용해서 폼 상태를 관리하기 위해 forwardRef를 사용해서 ref 객체의 값을 전달했다.

// Input.tsx
const Input = forwardRef<HTMLInputElement, InputProps>(({ id, name, type, placeholder, className, ...props }, ref) => {
  return (
    <input
      id={id}
      name={name}
      type={type}
      placeholder={placeholder}
      ref={ref}
      className={className}
      {...props}
    />
  );
});

export default Input;

// Label.tsx
const Label = ({ htmlFor, label, className }: LabelProps) => {
  return (
    <>
      <label htmlFor={htmlFor} className={className}>
        {label}
      </label>
    </>
  );
};

export default Label;

 
처음에는 forwardRef를 사용하지 않고 props를 전달하려고 했는데 다음과 같은 에러를 마주했다.

함수형 컴포넌트는 refs를 받을 수 없습니다. 해당 ref에 접근하려고 하면 실패할 것입니다. React.forwardRef()를 사용하시겠습니까?

 
forwardRef? 어디서 한 번 봤는데..
 
생각해보니 참여하고 있는 모던 리액트 딥다이브 스터디에서 공부했던 내용이었다. 공부한 지 얼마 안되어서 그런지 기억이 휘발되지 않고 남아있었다. 그래서 바로 책을 펼쳐서 해당 내용을 읽어봤다.
 
해당 내용을 읽고난 뒤, 다음과 같이 2가지를 생각하게됐다.
 
1. forwardRef를 작성하지 않고도 ref 예약어를 제외한 다른 props를 넘긴다면 잘 작동한다는 것
2. forwardRef를 사용하는 경우 ref 받고자 하는 컴포넌트의 두 번째 인수로 ref를 전달받아야 하는데, 그렇다면 react-hook-form 에서 ref를 전달해주는 주체는 무엇일까?
 
1번은 책에서도 나와있듯이, 권장하지 않는 방법이다. 이유는 ref를 전달하는 것에 일관성이 없기 때문이다. 따라서 forwardRef를 사용해서 확실하게 ref를 전달할 것임을 예측시키도록 하는 게 바람직하다.

// Bad Code
import { useEffect, useRef } from "react";

const ForwardRef = ({ parentRef }) => {
  useEffect(() => {
    console.log(parentRef);
  }, [parentRef]);

  return <div></div>;
};

const ParentForwardRef = () => {
  const inputRef = useRef();
  return (
    <>
      <input ref={inputRef} />
      <ForwardRef parentRef={inputRef} />
    </>
  );
};

 
 
 
 
2번에 대한 답을 찾기 위해서는 먼저 register 함수가 어떻게 동작하는지 살펴볼 필요가 있다. register 함수는 react-hook-form의 useForm 의 반환값으로 얻을 수 있는 함수로, register에 파라미터를 넣어 호출하면 아래와 같은 값을 반환한다.

const { onChange, onBlur, name, ref } = register('firstName');

 
따라서 register 함수가 반환한 값을 spread operator를 이용해서 props로 전달해주는 것이다.
 
이렇게 register 함수 내부에서 생성한 ref가 input에 전달되어 폼 유효성 검사가 진행되는데, 내가 작성한 Input 컴포넌트를 보면 ref 요소가 전달이 되지 않아서 에러가 발생했었다.
 
에러를 해결한 이후에는, InputWithLabel 이라는 컴포넌트를 생성해서 Input 컴포넌트와 Label 컴포넌트를 import 해주었다.

const AuthInputWithLabel = <T extends FieldValues>({
  id,
  name,
  label,
  type,
  register,
  rules,
  errors,
  ...props
}: InputWithLabelProps<T>) => {
  const [inputType, setInputType] = useState(type);
  const { value: password, handleToggle: togglePassword } = useBoolean();

  const handleIconClick = () => {
    togglePassword();
    setInputType(password ? 'password' : 'text');
  };

  const hasError = !!errors[name];
  const errorMessages = hasError ? (errors[name]?.message as string) : '';

  return (
    <div className="mb-6 flex flex-col gap-2.5">
      <div className="formContainer">
        <Input
          id={id}
          type={inputType}
          {...props}
          {...register(name, rules)}
          className={`input ${hasError ? 'inputError' : ''}`}
        />
        <Label htmlFor={id} label={label} className={`label ${hasError && 'labelError'}`} />
        {type === 'password' && (
          <span className={`checkPassword ${hasError ? 'top-1/3' : ''}`} onClick={handleIconClick}>
            {password ? <UnLockIcon /> : <LockIcon />}
          </span>
        )}
        {hasError && <span className="errorMessage">{errorMessages}</span>}
      </div>
    </div>
  );
};

export default AuthInputWithLabel;

 
 
 
그리고 원하는 컴포넌트에 아래와 같이 사용했다.

<InputWithLabel
    id="email"
    name="email"
    label="이메일"
    type="text"
    placeholder="이메일을 입력해 주세요."
    register={register}
    rules={{
      required: REQUIRED_MESSAGE,
      pattern: emailPattern,
    }}
    errors={errors}
/>

사실 모던 리액트 딥다이브 책을 공부하면서 이해되지 않는 부분도 많고, 양도 방대해서 쉽지 않다고 느꼈었다. 그래도 꾸준히 읽어왔는데 이렇게 도움이 될 줄 몰랐다. 앞으로는 좀 더 꼼꼼히 집중해서 읽어보아야겠다.
 
 
 
 
 

Next.js의 미들웨어


로그인 페이지 작업을 맡으면서 로그인 여부에 따라 다른 페이지를 보여주어야했다.

Next.js를 사용하지 않았다면 React의 Router를 이용했겠지만, Next.js는 파일 기반 라우팅을 사용하므로 어떻게 구현할 지 고민이 됐다. 그래서 공식문서를 찾아보다가 Next.js에서 미들웨어를 제공한다는 것을 알게되었다.

구현한 코드는 아래와같다.
쿠키에 accessToken 값이 있는 경우, 로그인과 회원가입 페이지에 접속하면 랜딩페이지로 리다이렉트 되게끔 했다.

import { NextRequest, NextResponse } from 'next/server';

export const middleware = (request: NextRequest) => {
  const accessToken = request.cookies.get('accessToken');
  const { pathname } = request.nextUrl;

  if ((pathname === '/login' || pathname === '/signup') && accessToken) {
    return NextResponse.redirect(new URL('/', request.nextUrl));
  }
};

export const config = {
  matcher: [
    '/login',
    '/signup',
  ],
};

 
Next에서 미들웨어라는 기능을 알게 되어 유익했다는 것과 더불어, localStorage(Web Storage)와 Cookie에 대한 명확한 차이를 공부할 수 있어서 좋았다.아래에 알게 된 점을 간략하게 적어보자면,
 
1. 쿠키는 매번 서버로 전송된다.
웹 사이트에서 쿠키를 설정하면 이후 모든 웹 요청은 쿠키 정보를 포함하여 서버로 전송된다. 반면 Web Storage는 저장된 데이터가 클라이언트에 존재할 뿐 서버로 전송은 되지 않는다.
 
2. 쿠키는 개수와 용량에 있어서 제한이 있다
하나의 사이트에서 저장할 수 있는 최대 쿠키 수는 20개이며, 최대 크기는 4KB로 제한되어 있다. 반면 Web Storage는 이러한 제한이 없다.
 
3. 쿠키는 만료일자가 있다.
쿠키는 만료일자를 지정하게 되어 있어 언젠간 제거된다. 만약 만료일자를 지정하지 않으면 세션 쿠키가 된다. 반면 Web Storage는 만료기간의 설정이 없기 때문에 한 번 저장한 데이터는 영구적으로 존재한다.
 
Next의 미들웨어의 경우, SSR에서 동작하기 때문에 localStorage에 직접 접근할 수 없다. localStorage는 브라우저의 클라이언트 측에서만 사용 가능한 API로, 서버 측 코드에서는 접근이 불가능하다. 따라서 클라이언트, 서버 모두 접근 가능한 Cookie에 토큰을 저장해야 한다.
 
기존에는 Cookie와 localStorage가 클라이언트 저장소라는 것만 알고 있었다. 특히 cookie를 다뤄 본 적이 많이 없어서 특징을 잘 몰랐었고, 알더라도 잘 와닫지 않았었는데 이번 기회에 알게되어 좋은 기회였다고 생각한다.
 
 
 
 
 

이미지 최적화


Next.js는 공식문서가 굉장히 잘 되어 있다.
 
그래서 추가적으로 블로그나 아티클을 읽지 않아도 이해가 쉽게 잘 되는 편인데, 이번에 이미지 최적화 부분을 읽으며 많은 도움이 되었다. 

나는 랜딩페이지를 작업하면서 이 페이지가 다른 페이지보다 들어가는 이미지의 개수가 많아 이미지 최적화를 했을 경우 유의미한 결과를 얻을 수 있을 것 같다고 생각해서 여러가지를 시도해봤는데, 그 과정들을 작성해보려고 한다.
 
먼저 이번 프로젝트에서 시도했던 이미지 최적화에 대해 말하기 전에, 먼저 기초 프로젝트에서 했었던 이미지 최적화는 다음과 같다.
 
1. 파일 확장자 webp로 변환: webp로 변경하면 파일 크기가 줄어들어 초기 로딩 속도가 증가한다

png
webp

 
 
 
2. 커스텀 훅: 이용자가 페이지에 처음 진입 시 필요한 리소스를 모두 다운 받는 것이 아니라 이미지가 뷰포트에 진입할 때 까지 로딩을 지연시켰다.

// useLazyImageObserver.js
export default function useLazyImageObserver({ src }) {
  const [imgSrc, setImgSrc] = useState("");
  const imgRef = useRef(null);

  useEffect(() => {
    let observer;

    if (imgRef && !imgSrc) {
      observer = new IntersectionObserver(
        (entries) => {
          entries.forEach((entry) => {
            if (entry.isIntersecting) {
              setImgSrc(src);
              if (imgRef.current) observer.unobserve(imgRef.current);
            }
          });
        },
        { threshold: 0.25 },
      );
      observer.observe(imgRef.current);
    }

    return () => {
      observer && observer.disconnect();
    };
  }, [imgRef, imgSrc, src]);

  return { imgSrc, imgRef };
}
// WebpLoader.jsx
export default function WebpLoader({ src, webpSrc, alt }) {
  return (
    <picture>
      {webpSrc && <source srcSet={webpSrc} type="image/webp" alt={alt} />}
      <img
        src={src}
        alt={alt}
        onError={(event) => {
          event.target.src = "https://via.placeholder.com/200/cccccc/cccccc";
          event.onerror = null;
        }}
      />
    </picture>
  );
}

function LazyModeWebpLoader({ src, webpSrc, alt }) {
  const { imgRef, imgSrc } = useLazyImageObserver({ src: webpSrc || src });

  const handleImgError = (e) => {
    e.target.src = defaultImg;
  };

  return (
    <picture>
      {webpSrc && <source srcSet={imgSrc} type="image/webp" alt={alt} />}
      <img ref={imgRef} src={imgSrc} alt={alt} onError={handleImgError} />
    </picture>
  );
}

WebpLoader.Lazy = LazyModeWebpLoader;

 
 
 
그런데 Next.js 에서는 이러한 기능들을 제공한다.
 
그래서 이번 프로젝트에서는 따로 확장자를 변환하거나 커스텀 훅을 만들 필요가 없어서 편리했다. 대신 Next.js에서 제공하는 Image 컴포넌트를 사용해야 이러한 기능들을 이용할 수 있기 때문에 이미지 사용시에는 될 수 있으면 Image 컴포넌트를 사용하는 것이 좋다.
 
 
Next.js 공식문서를 보면 어떠한 방식으로 이미지 최적화를 하고 있는지 나와있으며 요약하자면 다음과 같다.

1. 크기 최적화(Size Optimization)
2. CLS(Cumulative Layout Shift) 방지(Visual Stability)
3. 빠른 페이지 로드(Faster Page Loads)
4. 원격 이미지 크기 조정(Asset Flexibility)

 
아래부터는 이미지 최적화를 위해 추가한 속성들에 대해 다뤄보겠다.
 
 
 

1. fill

fill은 이미지의 크기를 부모 요소에 따라 조정할 수 있다. 따라서 반응형에 따라 다른 크기의 이미지를 보여줄 때 사용하면 유용하다. 이 경우에는 부모요소에 position: 'relative' 를 추가해주어 이미지가 부모 요소의 크기에 맞게 확장되도록 해야한다.

<div className="relative h-[398px] w-[336px] md:h-[590px] md:w-[498px]">
  <Image
    src={CursorImage}
    alt="cursor image"
    fill
  />
</div>

 
 
 
 

2. sizes

fill 속성을 지정한 후에 콘솔창에 아래와 같은 알림창이 떴었다.

Image with src "/_next/static/media/cursor-image.62b4cb0b.png" has "fill" but is missing "sizes" prop. Please add it to improve page performance. Read more: https://nextjs.org/docs/api-reference/next/image#sizes

 
페이지 퍼포먼스 향상을 위해 fill 속성을 사용 할 경우 sizes prop을 추가하라고 한다. 왜일까 이유가 궁금해서 링크를 들어가보았다.
 
공식문서에는 다음과 같이 적혀있다.

First, the value of sizes is used by the browser to determine which size of the image to download, from next/image's automatically generated srcset. When the browser chooses, it does not yet know the size of the image on the page, so it selects an image that is the same size or larger than the viewport. The sizes property allows you to tell the browser that the image will actually be smaller than full screen. If you don't specify a sizes value in an image with the fill property, a default value of 100vw (full screen width) is used.

Second, the sizes property changes the behavior of the automatically generated srcset value. If no sizes value is present, a small srcset is generated, suitable for a fixed-size image (1x/2x/etc). If sizes is defined, a large srcset is generated, suitable for a responsive image (640w/750w/etc). If the sizes property includes sizes such as 50vw, which represent a percentage of the viewport width, then the srcset is trimmed to not include any values which are too small to ever be necessary.

 
요약하자면 sizes 속성은 어떤 사이즈의 이미지를 로드 할 때 뷰포트를 기준으로 기본으로 생성된 srcset으로부터 로드하는데, 만약 따로 설정하지 않을 경우 기본적으로 100vw, 즉 뷰포트 전체 width로 인식해 가져온다.
 
실제로 width, height를 설정하지 않고 fill props를 사용해 로드했더니 너비가 전체 뷰포트로 불러와진 것을 확인할 수 있었다. 실제 사이즈는 498px인데 1920px이 불러와지게 되어 비효율적이다.

 
 
 
기본적으로 사이즈가 고정되어 있으면 1x, 2x 두 개의 srcSet을 생성해 사용한다. 하지만 fill 속성일 경우 기본적으로 설정되는 srcSet을 사용해 뷰포트를 가지고 인식한 뒤 로드한다.
 

srcSet란 뷰포트 너비에 따라 로드 될 이미지 후보들을 설정하는 css 속성이다. 이때 srcset에 작성된 이미지 파일들은 뷰포트 너비에 맞는 이미지를 로드하여 렌더링되며, 렌더링될 최적화 너비는 sizes CSS 속성을 통해 작성할 수 있다.

 
따라서 next.config,js 파일의 imageSizesdeviceSizes를 알맞게 설정해서 사용하면 된다. 기본값은 아래와 같다.

// next.config.js
module.exports = {
    images: {
        // 기기의 너비 중단점 목록
        deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840]
    
        // 이미지 가로 너비(px) 목록, 
        // deviceSizes 배열과 연결되어 이미지 srcset 생성하는데 사용
        imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
    },
}

 

나는 deviceSize를 [480, 769, 1280, 1920, 2048, 3840]로 변경했다.
 
 
 
중요한 점은 반드시 지정한 breakpoint 대로 이미지가 로드되는 것이 아니라는 것이다.

<Image
    src={CursorImage}
    alt="cursor image"
    fill
    sizes="(min-width: 769px) 498px, (min-width: 480px) 336px, 336px"
    placeholder="blur"
    priority={true}
/>

 
만약 뷰포트가 750px인 경우에는 336px이 아닌 480px이 로드되는데, 그 이유는 지정된 srcSet에서 해당 사이즈보다 크지만 가장 작은 사이즈를 찾아 로드하기 때문이다.
 
따라서 커스텀한 브레이크 포인트를 사용하는 것은 상관없지만 실제로 뷰포트에 따라 어떤 사이즈의 이미지가 로드될지를 잘 생각해서 srcset을 커스텀 해야할 것 같다. 이후에 리팩토링한다면 deviceSize를 좀 더 세밀하게 나누어야 할 것 같다.
 
이후에 sizes 속성을 지정하고나니 너비가 설정한대로 조정 된 것을 볼 수 있다.

 
 
 
 
 
또한 각 파일의 크기도 줄어들었다.
로드 속도의 경우는 사실 개선되었다고 보긴 어렵지만, 표본이 많았더라면 더 유의미한 결과를 낼 수 있었을 것 같다.

적용 전

 

적용 후

 
 
 
 

3. placeholder="blur"

앞서 언급한 바와 같이 Next.js 공식문서에 따르면 Next.js는 가져온 파일을 기반으로 너비와 높이를 자동으로 결정하기 때문에 CLS(Cumulative Layout Shift)를 방지해준다.

CLS란 어떤 웹 사이트에 방문했을 때 이미지가 로드되기 전까지의 높이가 0이었다가 이미지가 로드된 후 이미지만큼 영역이 늘어서 레이아웃이 흔들리는 것을 말한다. 한 마디로 페이지 접속 시 초기에는 이미지가 없다가 이미지 로드 후 갑자기 이미지가 생겨서 레이아웃이 바뀌는 것을 말한다.

 
로컬 이미지(정적 이미지)의 경우에는 빌드 타임에 import 된 이미지 파일을 기준으로 자동으로 너비와 높이를 지정하고, base 64로 인코딩 된 blur 이미지가 생성되기 때문에 별도의 작업없이 placeholder="blur"를 사용할 수 있다.
 
아래는 속성을 적용하지 않은 상태와 적용한 상태이다. 이 경우 hight 속성을 지정했기 때문에 CLS가 일어나지는 않았지만 로드 중에 빈 화면을 보여주는 것보다 훨씬 자연스러운 것 같다.

 
 
 
 

4. priority

priority 속성은 Next.js에서 이미지의 로딩 우선 순위를 지정하는 속성이다. true로 설정 할 경우 lazy loading이 비활성화된다.

Should only be used when the image is visible above the fold. Defaults to false.

 
따라서 이 속성을 랜딩 페이지가 로드 될 때 가장 먼저 보이는 이미지에 부여해서 다른 이미지보다 먼저 로드되도록 해서 사용자 경험을 향상시켰다.
 


 
이렇게 프로젝트도 마치고, 회고도 끝내니 시원섭섭하다.
 
이번 프로젝트에서 힘들었던 점을 딱 한가지만 꼽는다면, 노트북의 성능이 좋지 않아서(램이 4기가..) 작업이 쉽지 않았던 것이다. 특히 랜딩페이지를 작업할 때, 이미지 로드 중에 갑자기 모든 프로그램이 종료되거나 컴퓨터가 아예 멈춰버려서 시간이 딜레이 된 경우도 있었다. 그래서 고급 프로젝트 때는 이런 일 없도록 노트북을 새로 구매 할 예정이다. 🥲
 
다음 프로젝트 이후에는 또 얼마나 성장할 지 기대된다ㅎㅎ 화이팅! 💪