[크래프톤 정글 3기] 1/6(토) TIL

ClassBinu·2024년 1월 6일
0

크래프톤 정글 3기 TIL

목록 보기
81/120

next 빌드 후 실행 안 되는 문제 해결
nest strategy 및 다른 개념 구체적으로 알아보기
테스트 코드 작성

Next

fetchPost() 중복으로 중복 렌더링

엄격 모드를 끄니까 해결 됨.
개발 과정에서만 두번씩 호출되는 것 같음.

  1. 엄격 모드(Strict Mode)로 인한 이중 호출
    React의 엄격 모드(Strict Mode)는 개발 모드에서 컴포넌트의 라이프사이클 메소드를 두 번 실행하여 부수 효과(side effects)를 포착하는 데 사용됩니다. 이는 개발 도구로서의 역할을 하며 프로덕션 빌드에서는 발생하지 않습니다.

해결책: 프로젝트가 엄격 모드로 실행되고 있는지 확인하고, 엄격 모드가 필요하지 않다면 해당 설정을 비활성화해보세요.

/** @type {import('next').NextConfig} */
const nextConfig = { reactStrictMode: false };

module.exports = nextConfig;

GPT 없으면 이런 버그 어떻게 해결했을까
엄격 모드 문제가 아니라 그냥 fetch쪽 로직 문제였음. (머쓱)

무한 스크롤 문제 해결

문제1. 재호출이 너무 제한적인 조건에서만 이루어짐

원인: 재호출 조건을 너무 타이트하게 잡음
스크롤이 바닥에 왔을 때만 작동

    if (
      window.innerHeight + document.documentElement.scrollTop !==
      document.documentElement.offsetHeight
    ) return;

해결: 하단에서 100픽셀 위만큼 왔을 때 fetch 호출

  const handleScroll = () => {
    if (
      window.innerHeight + document.documentElement.scrollTop >=
      document.documentElement.offsetHeight - 100
    ) {
      setOffset((prevOffset) => prevOffset + limit);
    }
  };

문제2. 핸들러 너무 많이 작동

원인: 문제1을 해결하니까 특정 범위 안에서 스크롤 움직일 때마다 fetch가 호출되어서 생기는 문제
해결: settimeout으로 스로틀링을 걸거나, 플래그로 fetching중에는 함수가 실행되지 않도록 하면 됨. 플래그로 해결해 봄.

fetch함수와 핸들러 조건을 다음과 같이 개선

const fetchPosts = async () => {
    setIsLoading(true);
    try {
      const response = await fetch(
        `${process.env.NEXT_PUBLIC_SERVER_API}/posts?offset=${offset}&limit=${limit}`
      );
      const newPosts = await response.json();
      setPosts((prevPosts) => [...prevPosts, ...newPosts]);
    } catch (error) {
      console.error(error);
    } finally {
      setIsLoading(false);
    }
  };

  const handleScroll = () => {
    if (
      window.innerHeight + document.documentElement.scrollTop >=
        document.documentElement.offsetHeight - 100 &&
      !isLoading
    ) {
      setOffset((prevOffset) => prevOffset + limit);
    }
  };

문제3. 문제2 해결 안됨.

원인: 통신 속도가 빨라서 특정 영역 내에서 작은 스크롤에서 이벤트가 트리거 됨.
해결: 스로틀링 구현(함수 실행 후 특정 시간 동안 미실행)
100ms 내에서는 fetch 함수 1회만 실행

// 이건 디바운싱 코드
let debounceTimer: ReturnType<typeof setTimeout>;
  const handleScroll = () => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
      if (
        window.innerHeight + document.documentElement.scrollTop >=
        document.documentElement.offsetHeight - 100
      ) {
        setOffset((prevOffset) => prevOffset + limit);
      }
    }, 100);
  };

  useEffect(() => {
    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);
  }, []);

  useEffect(() => {
    fetchPosts();
  }, [offset]);

결국 물리적으로 일정 시간 묶어놓는 게 정답이었다..

문제4. 불필요한 fetch

문제: 마지막 게시글까지 불러온 후 하단 100px안에서 스크롤이 조작되면 불러올 게시글이 없음에도 offset이 계속 변경되면서 불필요한 fetch가 실행됨.
해결: fetch후 호드된 게시글이 limit보다 적은 경우 모든 게시글을 불러온 것으로 판단하고 이벤트가 발동되지 않도록 함.

  const fetchPosts = async () => {
    console.log(`before offset: ${offset}`);
    try {
      const response = await fetch(
        `${process.env.NEXT_PUBLIC_SERVER_API}/posts?offset=${offset}&limit=${limit}`
      );
      const newPosts = await response.json();

      if (newPosts.length < limit) {
        setAllDataLoaded(true);
        console.log(`fetchPosts in allDataLoaded: ${allDataLoaded}`);
      }

      setPosts((prevPosts) => [...prevPosts, ...newPosts]);
    } catch (error) {
      console.error(error);
    }
  };

  let lastInvocation = 0; // invacation = 발동
  const throttleInterval = 100;

  const handleScroll = () => {
    const now = Date.now();

    if (now - lastInvocation > throttleInterval) {
      if (
        window.innerHeight + document.documentElement.scrollTop >=
          document.documentElement.offsetHeight - 100 &&
        !allDataLoaded
      ) {
        console.log(`handleScroll in allDataLoaded: ${allDataLoaded}`);
        setOffset((prevOffset) => prevOffset + limit);
      }
      lastInvocation = now;
    }
  };

문제5. 문제 4 해결 안됨.

문제: 핸들러 함수 내에서 allDataLoaded 상태가 업데이트가 안됨.

  const handleScroll = () => {
    const now = Date.now();

    if (now - lastInvocation > throttleInterval) {
      if (
        window.innerHeight + document.documentElement.scrollTop >=
          document.documentElement.offsetHeight - 100 &&
        !allDataLoaded
      ) {
        console.log(`handleScroll in allDataLoaded: ${allDataLoaded}`);
        setOffset((prevOffset) => prevOffset + limit);
      }
      lastInvocation = now;
    }
  };

원인: 함수가 생성된 시점의 클로저 상태를 유지 하기 때문
handleScroll함수는 이벤트 리스너 등록될 시점의 allDataLoaded 상태를 기억하고 이후 상태가 변경되어도 갱신된 값을 반영하지 않음.
해결:
1. useEffect내부에서 함수 정의: allDataLoaded 상태가 변경될 때마다 handleScroll함수가 다시 생성됨.
2. 상태 갱신을 위한 참조 사용: useRef훅으로 allDataLoaded 최신 상태를 추적하는 참조(Ref)를 만듦.

  const lastInvocation = useRef(0); // invacation = 발동
  const throttleInterval = 100;

  useEffect(() => {
    const handleScroll = () => {
      const now = Date.now();

      if (now - lastInvocation > throttleInterval) {
        if (
          window.innerHeight + document.documentElement.scrollTop >=
            document.documentElement.offsetHeight - 100 &&
          !allDataLoaded
        ) {
          console.log(`handleScroll in allDataLoaded: ${allDataLoaded}`);
          setOffset((prevOffset) => prevOffset + limit);
        }
        lastInvocation = now;
      }
    };
    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);
  }, [allDataLoaded]);

문제6. lastInvocation 사라짐

원인: 재렌더링 시 기존 일반 변수 형태의 lastInvocation값이 사라짐.
해결: useRef() 훅으로 참조 값 만들어서 접근

  const lastInvocation = useRef(0); // invacation = 발동
  const throttleInterval = 100;

  useEffect(() => {
    const handleScroll = () => {
      const now = Date.now();

      if (now - lastInvocation.current > throttleInterval) {
        if (
          window.innerHeight + document.documentElement.scrollTop >=
            document.documentElement.offsetHeight - 100 &&
          !allDataLoaded
        ) {
          console.log(`handleScroll in allDataLoaded: ${allDataLoaded}`);
          setOffset((prevOffset) => prevOffset + limit);
        }
        lastInvocation.current = now;
      }
    };
    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);
  }, [allDataLoaded]);

ref는 변수명.current로 접근 가능
ref를 통해 컴포넌트 생명 주기 안에서는 참조 값을 안정적으로 유지할 수 있음.(일부 컴포넌트가 재렌더링 되면서 내부 값이 초기화 되더라도 참조 값을 통해서 안정적으로 값 유지)

문제7. 빠른 스크롤 이벤트 미작동

원인: 스로틀 인터벌 100ms 이내에 이벤트 발동 구간을 마우스 스크롤이 빠르게 지나가면 함수가 실행되지 않음.
해결:
1. 인터벌 시간 단축 -> 이벤트 자주 호출 문제
2. 스크롤 방향 인식 -> 근본적인 해결 아님.
3. 디바운스 방식으로 변경 -> 세로형 모니터에서 최초 로딩 부족한 문제 있으나 fetch 포스트 개수를 늘리면 됨.

여러 이슈로 무한 스크롤은 스로틀링보다는 디바운스 방식이 적절하다고 판단함.

문제8. 뒤로가기 시 스크롤 위치 초기화

원인: 뒤로가기 후 다시 목록으로 돌아오면 재렌더링 되면서 스크롤이 초기화
해결: 게시판 리스트 관련 상태는 context로 전역적으로 관리
이렇게 하면 따로 스크롤 위치 기억 안해도 제대로 뒤로가기 됨

// context
"use client";

import { createContext } from "react";

interface AppContextType {
  isLoggedIn: boolean;
  setIsLoggedIn: (isLoggedIn: boolean) => void;
  offset: number;
  setOffset: Function;
  posts: Array<any>;
  setPosts: Function;
  allDataLoaded: boolean;
  setAllDataLoaded: Function;
}

export const AppContext = createContext<AppContextType>({
  isLoggedIn: false,
  setIsLoggedIn: () => {},
  offset: 0,
  setOffset: () => {},
  posts: [],
  setPosts: () => {},
  allDataLoaded: false,
  setAllDataLoaded: () => {},
});
// provider
"use client";

import React, { useEffect, useState } from "react";

import { AppContext } from "./AppContext";

export const AppProvider = ({ children }) => {
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const [offset, setOffset] = useState(0);
  const [posts, setPosts] = useState([]);
  const [allDataLoaded, setAllDataLoaded] = useState(false);

  useEffect(() => {
    const accessToken = sessionStorage.getItem("accessToken");
    if (accessToken) {
      setIsLoggedIn(true);
    }
  }, []);

  return (
    <AppContext.Provider
      value={{
        isLoggedIn,
        setIsLoggedIn,
        offset,
        setOffset,
        posts,
        setPosts,
        allDataLoaded,
        setAllDataLoaded,
      }}
    >
      {children}
    </AppContext.Provider>
  );
};
// page
"use client";

import { useContext, useEffect, useRef, useState } from "react";

import { AppContext } from "@/contexts/AppContext";
import { PostListCard } from "@/components/post/postListCard";

export default function PostListPage() {
  const [isLoading, setIsLoading] = useState(false);
  const limit = 20;
  const { offset, setOffset } = useContext(AppContext);
  const { posts, setPosts } = useContext(AppContext);
  const { allDataLoaded, setAllDataLoaded } = useContext(AppContext);

  const fetchPosts = async () => {
    if (posts.length > 0 && posts.length > offset) {
      return; // 이미 충분한 게시글이 로드되었으므로 fetch하지 않습니다.
    }

    if (isLoading) {
      console.log("already loading");
      return;
    }
    setIsLoading(true);
    try {
      const response = await fetch(
        `${process.env.NEXT_PUBLIC_SERVER_API}/posts?offset=${offset}&limit=${limit}`
      );
      const newPosts = await response.json();

      if (newPosts.length < limit) {
        setAllDataLoaded(true);
      }
      // 중복 제거 로직
      setPosts((prevPosts) => {
        const existingPostIds = new Set(prevPosts.map((post) => post.id));
        const uniqueNewPosts = newPosts.filter(
          (post) => !existingPostIds.has(post.id)
        );
        return [...prevPosts, ...uniqueNewPosts];
      });
    } catch (error) {
      console.error(error);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    let debounceTimer: NodeJS.Timeout;
    const handleScroll = () => {
      clearTimeout(debounceTimer);
      debounceTimer = setTimeout(() => {
        if (
          window.innerHeight + document.documentElement.scrollTop >=
            document.documentElement.offsetHeight - 400 &&
          !allDataLoaded
        ) {
          setOffset((prevOffset) => prevOffset + limit);
        }
      }, 100);
    };

    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);
  }, [allDataLoaded]);

  useEffect(() => {
    fetchPosts();
  }, [offset]);

  return (
    <>
      {posts.map((post) => (
        <PostListCard
          key={post.id}
          id={post.id}
          title={post.title}
          content={post.content}
        />
      ))}
    </>
  );
}

문제 9. useEffect() 경고

  useEffect(() => {
    fetchPosts();
  }, [offset]);

React Hook useEffect has a missing dependency: 'fetchPosts'. Either include it or remove the dependency array.eslintreact-hooks/exhaustive-deps
const offset: number

원인: useEffect는 의존 배열에 해당 effect 내부에 영향을 명시된 모든 상태를 입력해 줘야 한다고 함.

해결:

  useEffect(() => {
    fetchPosts();
  }, [offset, fetchPosts]);

게시판 구현 진짜 힘들었다.
특히 무한 스크롤과 뒤로가기..
근데 결국 해냈다!🥳

무한 스크롤 안정적으로 구현 완료🥳

쉽지 않았다.. 점심 이후까지 투자해서 안정적으로 구현함!
관련 개념들도 많이 배웠음!
리액트 hooks를 좀더 찾아봐야겠음.
할게 너무 많다..
왜 풀스택 개발자는 불가능에 가까운 영역인지 알았음.

클로저

클로저는 함수가 선언될 때의 렉시컬 환경(Lexical Environment)에 대한 참조를 유지하는 함수입니다. 이로 인해 함수는 자신이 생성될 때의 환경을 "기억"하고, 나중에 이 환경에 있는 변수에 접근할 수 있습니다.

클로저 작동 방식

함수 정의: 함수가 정의될 때, 해당 함수는 자신이 선언된 렉시컬 환경에 대한 참조를 저장합니다. 이 환경에는 함수의 매개변수, 지역 변수, 그리고 함수를 둘러싼 외부 스코프의 변수들이 포함됩니다.

함수 호출: 함수가 호출될 때, 이 함수는 자신이 정의될 당시의 렉시컬 환경에 저장된 변수에 접근할 수 있습니다. 이 때문에 함수는 외부 스코프의 변수를 "기억"하고 이에 접근할 수 있습니다.

클로저 예시

function createFunction() {
  let localVariable = 'I am a local variable';

  return function() {
    console.log(localVariable);
  };
}

const myFunction = createFunction();
myFunction(); // 출력: "I am a local variable"

위 예시에서 myFunction은 createFunction에 의해 반환된 내부 함수입니다. 이 내부 함수는 createFunction의 렉시컬 환경에 대한 참조를 유지하므로, localVariable에 접근할 수 있습니다. 이것이 클로저의 기본 예시입니다.

클로저 중요성

데이터 은닉과 캡슐화: 클로저는 외부로부터 숨겨진 변수를 만들 수 있어, 데이터 은닉과 캡슐화에 유용합니다.

지속적인 상태 유지: 클로저는 함수가 실행될 때마다 자신의 렉시컬 환경을 기억하기 때문에, 상태를 유지하고 이를 활용하는 데에도 유용합니다.

클로저에 대해서 심화 학습 필요..

디바운싱과 스로틀링

이벤트 처리 기법.
고성능 자바스크립트 애플리케이션에서 이벤트 핸들러가 너무 자주 호출되는 것을 방지

디바운싱

연속된 이벤트 호출들 사이에 대기 시간 설정
설정 시간 동안 추가 이벤트 호출 없으면 마지막(또는 첫 번째)이벤트를 처리
이벤트가 끝난 후 함수가 한 번 호출되도록 보장
예) 입력 필드 검색창에서 타이핑 멈추면 검색 결과 불러오기

스로틀링

정해진 시간 간격으로 이벤트 핸들러 호출 제한
예) 스크롤 이벤트

무한 스크롤에서는 디바운싱보다 스로틀링이 더 적합하다.
왜냐면 디바운싱을 스크롤을 맨 밑으로 내려가서 일정 시간 대기 후에 fetch를 실행하는데, 만약 사용자가 빠르게 스크롤을 계속 내리면 fetch가 안 될 수 있다.
결국 마지막으로 내려가면 멈출수 밖에 없기 때문에 겉으로 드러나는 효과는 비슷할 수 있지만, 만약 초기 화면 높이가 높아서(모니터를 세로로 쓰는 등) 게시글이 없는데 스크롤이 긴 경우에는 이런 문제가 발생할 수 있다. 그래서 무한 스크롤에는 특정 시간 동안 fetch가 1회만 실행되도록 스로틀링을 거는 방식이 더 적합하다.

리액트 렌더링

리액트는 여러 이유로 렌더링이 여러 번 일어날 수 있음.
개발 엄격모드 일 수도 있음.

useEffect를 사용하면 사이드 이펙트 제한 가능

  useEffect(() => {
    const decodedToken = decodeJWT();
    console.log(decodedToken);
  }, []);

1회성 렌더링이 필요할 때는 useEffect() 활용!

리액트 컴포넌트 철학

하위 컴포넌트에는 UI만 구성
비지니스 로직이 필요한 경우 최상단 컴포넌트에서 작업 후 props로 하위 컴포넌트로 내려주기

리액트에서는 종종 "컴포넌트는 가능한 한 멍청하게(dumb)"라는 철학을 따르는 것이 좋다고 권장됩니다. 이는 하위 컴포넌트들이 주로 UI와 관련된 역할에 집중하고, 복잡한 비즈니스 로직이나 데이터 처리는 상위 컴포넌트에서 처리한 후 props를 통해 하위 컴포넌트로 전달하는 방식을 의미합니다.

게시자 여부에 따른 삭제 노출 구현 시 JWT 디코딩을 하위 컴포넌트에서 했다가, 댓글 카드에 재활용이 불가능하다는 걸 꺠닫고 다시 최상위 컴포넌트로 다 옮겨서 props로 내려줌.

props

props로 기본적으로 1개의 인수이다. 그래서 객체로 묶어서 내린다. 완전히 쪼개지 않고 객체 안의 객체로 보내면 많은 양의 상태도 적은 변수명으로 내려보낼 수 있음!
예를 들어 post를 제목, 내용, 작성자를 하나하나 쪼개서 내려보내는 게 아니라 post 객체 자체를 props 객체 안에 포함시켜서 내려보내면 됨!

UI 디테일

AI에 아바타를 넣으니까 느낌이 다르다.
이제 진짜 AI가 인류의 새로운 종이 되지 않을까..

Next 로그아웃

로그아웃을 해도 전역 context가 남아 있어서 '나의 게시글'이 이전 계정의 내용으로 남아있음.
그냥 새로고침으로 로그아웃 해도 되냐고 하니까 GPT가 '더 우아한 방식으로 처리'할 수 있다고 함.

context에 초기화 함수를 넣어놓고, 이걸 호출하면 되는 것!

근데 로그아웃하면 무한 루프 버그 있음.
이건 내일 수정한다.
가기 직전에 수정함. useEffect 의존성 배열 빼니까 고쳐짐.

"use client";

import { useContext, useEffect } from "react";

import { AppContext } from "@/contexts/AppContext";
import { useRouter } from "next/navigation";

export default function LogoutPage() {
  const router = useRouter();
  const { isLoggedIn, setIsLoggedIn, clearAllData } = useContext(AppContext);

  useEffect(() => {
    sessionStorage.removeItem("accessToken");
    sessionStorage.removeItem("refreshToken");
    clearAllData();
    setIsLoggedIn(false);

    router.push("/");
  }, []);
}

마이 포스트 무한 루프

무한 스크롤 로직 때문에 기존 작성한 글이 없으면 무한 fetch오류 발생.
이거는 진짜 내일 수정한다!

0개의 댓글