Project/삐삐(BIBI: Best Interior)

[최적화] Pagenation 구현, 정적리소스 서버 구축

2워노 2023. 10. 5. 09:32

📝 도입 배경

  • 본 프로젝트 (삐삐:Best Interior)는 인테리어 정보 교환을 위한 커뮤니티로, 고화질의 사진 리소스를 사용하는 작업이 많았고, 서버에서 고용량의 사진 데이터를 받아오는 과정에서 발생 할 수 있는 사용자 불편함을 고려해야 하는 상황이었습니다.
  • 또한, 고화질의 사진 리소스들은 불러오는데 많은 네트워크 비용이 들고, 장애 발생 가능성 또한 고려해야만 했습니다.
  • 이러한 상황을 고려하여, 2가치 최적화 방안을 도입하였습니다.

 

🔨 최적화 방안 - (1). Pagenation 구현

  • 고용량의 사진 데이터를 받아오면서 발생할 긴 로딩을 방지하기 위해, Pagenation을 구현하였음
  • 각 페이지(쇼룸, 팁)에 12개의 개시글 단위로 Pagenation을 구현하였으며, 이를 통해 리소스가 큰 요청들을 분할하여 네트워크 트래픽을 최적화 하였음
  • 즉, 한번에 모든 데이터를 불러오는게 아니라 12개단위로 끊어서 GET요청을 보냈음
  • 이와 더불어 무한스크롤 기능을 구현하여 동적으로 추가 컨텐츠들을 로드함으로써 서버 부하와 초기 로딩시간을 줄였으며, 페이지 이탈율을 줄여 사용자 경험을 향상시키고자 하였음

 

// 새로운 페이지 데이터를 불러오는 함수
  const loadMoreData = async (url) => {
    try {
      toast.loading("로딩중..."); // 데이터 로딩 중 토스트 메시지 표시
      const res = await api({ ...configParams, url });
      if (res.data.isLast === false) {
        setShowroomData((prevData) => [...prevData, ...res.data.data]);
      } else {
        setShowroomData((prevData) => [...prevData, ...res.data.data]);
        // 마지막 페이지 설정
        setIsLastPage(res.data.isLast);
      }
      toast.dismiss(); // 로딩 메시지 닫기
    } catch (error) {
      console.error("Error loading more data:", error);
      toast.error("데이터를 불러오는 중에 오류가 발생했습니다."); // 에러 시 토스트 메시지 표시
      toast.dismiss(); // 에러 메시지 닫기
    }
  };
// IntersectionObserver를 사용하여 스크롤 감지

  useEffect(() => {
    // IntersectionObserver를 생성하고 등록
    if (!isLastPage && !loading && isFirstPageRendered.current == true) {
      // isLast가 false일 때만 IntersectionObserver 등록
      const newObserver = new IntersectionObserver(
        ([entry]) => {
          if (entry.isIntersecting && !loading) {
            page.current += 1; // 페이지 번호 증가
            const updatedUrl = `/feed/${feedCode}/${inputValue}${filterCode}?page=${page.current}`;
            loadMoreData(updatedUrl); // 새로운 페이지 데이터를 불러오는 함수 호출
          }
        },
        {
          threshold: 0.1, // 스크롤이 target에 도달하면 새로운 데이터를 요청하는 함수 실행
        }
      );

      // 현재 컴포넌트의 target 요소를 설정
      if (target.current) {
        newObserver.observe(target.current);
      }

      // 컴포넌트가 언마운트될 때 Observer를 해제
      return () => {
        if (target.current) {
          newObserver.unobserve(target.current);
        }
      };
    }
  }, [filterCode, loading, searchKeyworld, isLastPage]);
  • 또한, 응답 데이터로 다음페이지 존재여부라는 isLast 필드값을 받아서, isLast가 true일때는 더이상 추가 api 요청을 안하도록 최적화 하였음.

📸 (1) 구현 결과

무한스크롤 구현 화면

 

🔨 최적화 방안 - (2). 정적리소스 서버 구현

  • 유저들의 프로필 사진이나, 작성한 게시글의 cover 사진 및 본문의 사진과 같은 정적리소스들의 네트워크 장애 발생 가능성을 고려하여 API 서버와 따로 분리, 정적리소스를 위한 서버(EC2)를 별도로 구축하였음.
  • 예를들어, 유저가 글작성 과정에서 커버이미지 사진을 업로드시, 커버이미지를 별도의 정적리소스 서버로 업로드하기 위한 요청을 보내고, 해당 요청이 정상적으로 수행되면,
  • API서버는 정적리소스 서버에 업로드한 커버이미지 파일의 URL을 반환하고,
  • 글작성을 완료하면 커버이미지 값으로 정적리소스 서버의 이미지 URL값을 저장하는 방법으로 구현하였음.
{
    "data": [
        {
            "feedId": 12,
            "createdDateTime": "2023-09-14T03:37:34.692896",
            "modifiedDateTime": "2023-09-14T03:37:34.692896",
            "title": "title1",
            "content": "content1",
            "views": 0,
            // 정적리소스 서버의 이미지파일 url을 DB에 저장
            "coverPhoto": "https://bbibbiimage.s3.ap-northeast-2.amazonaws.com/archive/4a898f29-f9e5-4146-b7da-2e34b63b5889.png",
            "roomType": "TYPE02",
            "roomTypeName": "아파트",
            "roomSize": "SIZE03",
            "roomSizeName": "20평",
            "roomCount": "COUNT05",
            "roomCountName": "5개 이상",
            "roomInfo": "INFO01",
            "roomInfoName": "원룸",
            "location": "LOCATION03",
            "locationName": "충청",
            "memberId": 1,
            "nickname": "nickname1",
            "memberImage": "image2",
            "myIntro": "intro1",
            "likeCount": 0,
            "likeYn": null,
            "repliesCount": 0,
            "bookMarkCount": 0,
            "bookMarkYn": null,
           "followYn":true,
            "replies": null
        }
	]
}

 

이미지파일 업로드 로직 일부 ( 커버사진 )
 const imageUpload = (e) => {
    const file = e.target.files[0];
    const reader = new FileReader();
    const formData = new FormData();
    formData.append("coverPhotoImage", file);

    reader.onloadend = () => {
      setCoverImage(reader.result);
    };

    if (file) {
      reader.readAsDataURL(file);
    }

    axios
      .post(`${process.env.REACT_APP_API_URL}/imageUpload/coverImage`, formData)
      .then((response) => {
        setCoverImage(response.data);
      })
      .catch((error) => {
        window.alert("이미지 업로드에 실패하였습니다.");
        setCoverImage(null); //coverimage값 초기화
      });
  };
  • API서버에서 정적리소스 서버로 해당 이미지를 업로드하는 endpoint로 요청을 보내고, API 서버에서 정적리소스 서버로 이미지를 업로드 한 요청이 성공할 경우, 해당 이미지의 URL값을 받아서 상태로 저장하는 로직

 

🔨 최적화 방안 - (3). 임시저장 기능 구현

  • 모든 이미지들은 정적리소스 서버에 저장되기 때문에, 중복 요청을 보내게 될경우 불필요한 네트워크 비용이 발생한 가능성이 있음
  • 예를들어, 한 유저가 게시물을 작성하는 과정에서 정상적으로 각종 이미지를 업로드 완료하였으나, 실수로 페이지를 닫게 되어 다시 똑같은 글을 작성하는 경우를 가정할 경우
  • 해당 이미지는 이미 정적리소스 서버에 저장되어 있으나, 똑같은 글을 다시 작성하게 되면서 동일한 이미지를 다시 정적리소스 서버에 저장하게 됨으로써 불필요한 네트워크 비용이 발생하게 되는 상황을 고려해야 함
  • 이를 위해, 글 작성 과정에서 임시저장 기능을 추가하여 중복 요청 가능성을 줄이고자 하였음

 

임시저장 기능 구현 코드 일부( 로컬스토리지에 저장하는 방식 )
// 임시저장 클릭했을때 실행되는 핸들러함수 = > 로컬스토리지에 저장
  const saveToLocalStorage = () => {
    try {
      const tempSaveData = {
        coverImage, // 정적리소스 서버의 URL
        title,
        editorContent, // 본문 컨텐츠 내용 + 정적리소스 서버의 URL 
        selectedValues,
        createdAt: new Date(), // 현재시간까지 저장
      };

      localStorage.setItem(
        "tempSaveShowroomData",
        JSON.stringify(tempSaveData)
      );

      // 성공메세지
      toast.success("임시저장이 완료되었습니다!");
    } catch (error) {
      //실패메세지
      // Display an error toast notification if something went wrong
      toast.error("임시저장에 실패하였습니다.");
    }
  };
 // 로컬스토리지에 임시저장값이 있으면 해당값 불러오기 위한 useEffect
  useEffect(() => {
    const savedData = localStorage.getItem("tempSaveShowroomData");

    setTimeout(() => {
      if (savedData) {
        const tempSaveData = JSON.parse(savedData);
        const createdAtString = tempSaveData.createdAt;
        const createdAtDate = new Date(createdAtString);
        const formattedDate = createdAtDate.toLocaleString();

        const userConfirmed = window.confirm(
          `(${formattedDate}) 
  작성중인 글을 불러오시겠습니까? 
  취소를 누를 경우 작성중인 글은 삭제됩니다.`
        );
        if (userConfirmed) {
          const parsedData = JSON.parse(savedData);
          setCoverImage(parsedData.coverImage);
          setTitle(parsedData.title);
          setEditorContent(parsedData.editorContent);
          setSelectedValues(parsedData.selectedValues);
          toast.success("작성중인 글을 불러왔습니다.");
        } else {
          // 취소시 삭제
          localStorage.removeItem("tempSaveShowroomData");
          toast.error("작성중인 글을 삭제하였습니다.");
        }
      }
    }, 300);
  }, []);

 

📸 (2) ~ (3) 구현 결과

  • 정적리소스 서버 이용 Part : 커버사진, 본문 내의 이미지
  • 임시저장의 경우, 이전에 임시저장된 내용이 있는 경우 confirm창을 띄우고, 확인을 누를 경우 임시저장된 데이터를 불러오도록 설정

정적리소스 서버 및 임시저장 기능 구현 화면

 

🌱 느낀점

  • 기존의 진행했던 토이프로젝트의 경우 기능 구현에만 초점을 맞추어 작업하다 보니, 최적화 부분을 신경쓰지 못했는데 이번 기회에 성능 개선과 최적화 작업을 진행하며 사용자 경험을 개선할 수 있어서 한단계 더 성장한것 같았다!
  • 정적리소스 같은 서버 인프라 관련 내용은 BE나 devOps에서 하는거라고 생각 할 수 있으나, 시장이 필요로하는 개발자로 성장하기 위해서는 해당 지식을 알고 있는 것이 경쟁력이 있을거라고 생각한다.
  • 또한, 서비스에서 발생할 수 있는 여러 장애상황(ex: 네트워크 장애 등)에 대한 대비책을 마련해본 경험은 실무에서도 충분히 활용할 수 있지 않을까 생각한다.