가입 페이지에서 19세 미만의 생년월일을 입력한 경우, 경고 메세지를 담은 모달을 띄우려 한다.

 

무선 모달창의 구성과 애니메이션을 구현하고, 체크박스를 선택해야만 모달을 닫을 수 있도록 만들었다.

아래는 그 결과물이다.

 

 

 

 

나이에 따라 팝업 되는 기능을 추가하기에 앞서, 

모달 컴포넌트를 재사용할 것을 대비해 모달 내 요소들을 props로 바꾸고, 다른 컴포넌트에 적용하는 실험을 해보았다.

import { useState, useEffect, ReactNode } from "react";
import ReactDOM from "react-dom";
import s from "../stores/styling";

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  modalTitle: string;
  children: ReactNode;
  showCheckbox?: boolean;
  checkboxText?: string;
}

const Modal: React.FC<ModalProps> = ({ isOpen, onClose, modalTitle, children, showCheckbox, checkboxText }) => {
  
  const [understand, setUnderstand] = useState(false);	
  //체크박스 체크 여부 저장
  const [animationClosing, setAnimationClosing] = useState(false);	
  //닫히는 애니메이션울 클래스 추가하는 방식으로 조건부 렌더링

  if (!isOpen) return null;

  const handleUnderstandChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setUnderstand((prevUnderstand) => !prevUnderstand);	//보통 prev라고 쓰지만, 알아보기 쉽게 바꾸어 썼다.
  };

  const handleModalClose = () => {
    if (!showCheckbox || understand) { // 체크박스가 없는 포맷이거나 understand에 체크가 돼있을 때
      setAnimationClosing(true)	// 모달 닫음으로 처리
      setTimeout(() => {
        setAnimationClosing(false);
        onClose();
      }, 800)	//애니메이션 duration과 같게 쓴다.
    }
  };

  const portalElement = document.getElementById("portal");
  if (!portalElement) return null;


  
  return ReactDOM.createPortal(
    <>
      <s.Modal className={`modal-overlay ${animationClosing ? "closing" : ""}`}>
        <s.Modal className={`modal-wrapper ${animationClosing ? "closing" : ""}`}>
          <s.Modal className="circle">
            <s.WarnIcon />
          </s.Modal>
          <s.Echo className="wrapper">
            <s.Echo className="circle00" />
            <s.Echo className="circle01" />
            <s.Echo className="circle02" />
            <s.Echo className="circle03" />
          </s.Echo>
          <s.Modal className="modal-container">
            <s.StyledH1 className="warning">{modalTitle}</s.StyledH1>
            <s.Modal className="text-box">{children}</s.Modal>
            {showCheckbox && <s.Modal className="checkbox-container">
              <s.Input
                type="checkbox"
                id="understand"
                checked={understand}
                onChange={handleUnderstandChange}
                className="modal-check"
              />
              <s.Label
                htmlFor="understand"
                className="understand"
              >
                {understand ? (
                  <s.CheckboxAfterIcon className="checkbox-icon-checked" />
                ) : (
                  <s.CheckboxBeforeIcon className="checkbox-icon" />
                )}
                {checkboxText}
              </s.Label>
            </s.Modal>}
            <s.Button className="Round" onClick={handleModalClose}>
              닫기
            </s.Button>
          </s.Modal>
        </s.Modal>
      </s.Modal>
    </>,
    portalElement
  );
};

export default Modal;

 

 

재사용 테스트를 거쳐보니,

props는 손쉽게 사용할 수 있지만 매번 이벤트 핸들러와 state를 다시 써줘야하는 번거로움이 있었다.

 


 

이 점을 해결하기 위해 Custom Hook을 사용하여 캡슐화하고, 코드 중복을 더 줄여보자.

 

사족: 기존 파일과 빈 파일 두개를 나란히 두고 한줄 한줄 이식하듯이(?) 작성하니 보다 이해하기 쉬웠다.

 

 

우선 커스텀 훅의 뼈대를 잡는다.

커스텀 훅은 use... 로 시작하며, return 문에 해당 훅이 생성한 값이나 함수를 넣는다.

const useModal = () => {

};


return {

};

export default useModal;

 

 

기존 코드에서 useState문과 관련된 부분을 모두 가져오고,

return문에 재사용할 state와 함수를 객체 형태로 반환한다. (배열도 가능하다)

import { useState } from "react";

const useModal = () => {
  const [understand, setUnderstand] = useState(false);
  const [animationClosing, setAnimationClosing] = useState(false);

  const handleUnderstandChange = (
    event: React.ChangeEvent<HTMLInputElement>
  ) => {
    setUnderstand((prevUnderstand) => !prevUnderstand);
  };

  const handleModalClose = () => {
    if (understand) {
      setAnimationClosing(true);
      setTimeout(() => {
        setAnimationClosing(false);
        onClose(); // Ensure the onClose callback is called when the modal is closed
      }, 800);
    }
  };

  return {
    understand,
    animationClosing,
    handleUnderstandChange,
    handleModalClose,
  };
};

export default useModal;

 

 

그리고 인자를 넘겨준다.

나는 여기서 많이 헤멨는데, 아무래도 인수의 개념이 탄탄히 잡히지 않아 그랬던 것으로 보인다.

 

 

간단히 말하자면-

 

1. 커스텀 훅에서 처리할 상태와 함수를 정리해서 보내주면

2. Modal 컴포넌트에서 이것을 인수 (여기서는 모달 열림, 닫힘) 에 따라 처리한다.

import { useState } from "react";
import { UseModalProps } from "../types/ModalProps";

interface UseModalProps {	// 인수 타입 선언
  isOpen: boolean;
  onClose: () => void;
}

const useModal = ({ isOpen, onClose }: UseModalProps) => {
  const [understand, setUnderstand] = useState(false);
  const [animationClosing, setAnimationClosing] = useState(false);

  const handleUnderstandChange = (
    event: React.ChangeEvent<HTMLInputElement>
  ) => {
    setUnderstand((prevUnderstand) => !prevUnderstand);
  };

  const handleModalClose = () => {
    if (understand) {
      setAnimationClosing(true);
      setTimeout(() => {
        setAnimationClosing(false);
        onClose(); 
      }, 800);
    }
  };

  return {
    isOpen,
    onClose,
    understand,
    animationClosing,
    handleUnderstandChange,
    handleModalClose,
  };
};

export default useModal;

 

 

 

여기서 바로 컴포넌트로 넘겨주어도 좋지만, 커스텀 훅에서 써둔 타입 선언부를 컴포넌트에서도 다시 작성해야하는 번거로움을 발견했다.

// 수정 전

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  children: ReactNode;
  modalTitle: string;
  showCheckbox?: boolean;
  checkboxText?: string;
}

const Modal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  children,
  modalTitle,
  showCheckbox,
  checkboxText,
}) => {

  const {
    understand,
    animationClosing,
    handleUnderstandChange,
    handleModalClose,
  } = useModal({ isOpen, onClose });
  
  // 후략

 

 

따라서 이번 기회에 타입을 별도 파일로 분리해보았다.

서로 겹치는 부분은 extends 를 사용하여 가급적 겹치지 않게 했다.

// ModalProps.ts
import { ReactNode } from "react";

interface CommonProps {
  isOpen: boolean;
  onClose: () => void;
}

export interface ModalProps extends CommonProps {
  children?: ReactNode;
  modalTitle?: string;
  showCheckbox?: boolean;
  checkboxText?: string;
}

export interface UseModalProps extends CommonProps {}

 

 

다시 커스텀 훅으로 돌아와 정리해둔 타입을 불러와 선언해준다.

import { useState } from "react";
import { UseModalProps } from "../types/ModalProps";


const useModal = ({ isOpen, onClose }: UseModalProps) => {
  const [understand, setUnderstand] = useState(false);
  const [animationClosing, setAnimationClosing] = useState(false);

  const handleUnderstandChange = (
    event: React.ChangeEvent<HTMLInputElement>
  ) => {
    setUnderstand((prevUnderstand) => !prevUnderstand);
  };

  const handleModalClose = () => {
    if (understand) {
      setAnimationClosing(true);
      setTimeout(() => {
        setAnimationClosing(false);
        onClose(); 
      }, 800);
    }
  };

  return {
    isOpen,
    onClose,
    understand,
    animationClosing,
    handleUnderstandChange,
    handleModalClose,
  };
};

export default useModal;

 

 

첫단계에서 함께한 Modal 컴포넌트 파일에서도 커스텀 훅으로 넘겨준 모든 state와 함수를 지우고, 타입을 선언해준다.

import ReactDOM from "react-dom";
import s from "../stores/styling";
import useModal from "../hooks/ModalHook";
import { ModalProps } from "../types/ModalProps"; 


const Modal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  children,
  modalTitle,
  showCheckbox,
  checkboxText,
}) => {

  const {
    understand,
    animationClosing,
    handleUnderstandChange,
    handleModalClose,
  } = useModal({ isOpen, onClose });


  if (!isOpen) return null;

  const portalElement = document.getElementById("portal");
  if (!portalElement) return null;

  return ReactDOM.createPortal(
    <>
      <s.Modal className={`modal-overlay ${animationClosing ? "closing" : ""}`}>
        <s.Modal
          className={`modal-wrapper ${animationClosing ? "closing" : ""}`}
        >
          <s.Modal className="circle">
            <s.WarnIcon />
          </s.Modal>
          <s.Modal className="modal-container">
            <s.StyledH1 className="warning">{modalTitle}</s.StyledH1>
            <s.Modal className="text-box">{children}</s.Modal>
            <s.Modal className="checkbox-container">
              <s.Input
                type="checkbox"
                id="understand"
                checked={understand}
                onChange={handleUnderstandChange}
                className="modal-check"
              />
              <s.Label htmlFor="understand" className="understand">
                {understand ? (
                  <s.CheckboxAfterIcon className="checkbox-icon-checked" />
                ) : (
                  <s.CheckboxBeforeIcon className="checkbox-icon" />
                )}
                {checkboxText}
              </s.Label>
            </s.Modal>
            <s.Button className="Round" onClick={handleModalClose}>
              닫기
            </s.Button>
          </s.Modal>
        </s.Modal>
      </s.Modal>
    </>,
    portalElement
  );
};

export default Modal;

 

이제 다른 컴포넌트에 삽입해보면 이전과 동일한 기능과 효과를 보여주지만 보다 간결하고 깔끔한 코드가 된다.

컴포넌트를 이식(?) 해주고 실제로 여닫히는 상황을 state에 저장할 수 있도록 처리해주면 완성된다.

//LoginPage.tsx

import Modal from "../components/Modal";

const LoginPage = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);
  
  const handleOpenModal = () => {
    setIsModalOpen(true);
  };

  const handleCloseModal = () => {
    setIsModalOpen(false);
  };
  
	//...중략...
}
  return (
  <>
  {/*...중략...*/}
	<Modal
      isOpen={isModalOpen}
      onClose={handleCloseModal}
      modalTitle={"잠깐!"}
      showCheckbox={true}
      checkboxText={"이해했습니다."}
    >
      <s.StyledP className="modal">
        19세 미만 회원의 경우,
        <br />
        예약 및 시술이 제한될 수 있습니다.
      </s.StyledP>
    </Modal>
  </>
 )
}

export default LoginPage;

 

 

스크롤을 내릴 때, 마치 엔딩 크레딧처럼 평면적으로 스크롤이 내려가는 것보다, 브라우저 영역에 들어가거나 나갈 때 transition이 일어나도록 수정하려 한다.

 

intersection-observer라는 브라우저 내장 기능을 통해 구현할 수 있다.

 

 

 

우선 테스트 뼈대를 잡았다.

참고로, 아래 Div와 Image 태그는 margin-top: 1000px; 값을 가져, 충분히 스크롤해야 나타나도록 설정해두었다.

import s from '../stores/styling'
import image from '../assets/images/artistregister-background.jpg'

export default function ScrollTest() {

  return (
    <>
        <s.Div className='test'>테스트</s.Div>
        <s.Image className='test' src={image} alt="photo"/>
    </>
  )
}

 

 

 

그리고 useRef를 사용해 DOM element에 직접 접근한다.

import { useRef } from 'react'
import s from '../stores/styling'
import image from '../assets/images/artistregister-background.jpg'

export default function ScrollTest() {
    const myref = useRef<HTMLDivElement>(null);
  return (
    <>
        <s.Div className='test' ref={myref}>테스트</s.Div>
        <s.Image className='test' src={image} alt="photo"/>
    </>
  )
}

 

 

 

 

useEffect 훅으로 IntersectionObserver을 설정한다. 이를 통해 특정 요소가 뷰포트에 들어오거나 나가는 것을 감지한다.

import { useRef, useEffect } from 'react'
import s from '../stores/styling'
import image from '../assets/images/artistregister-background.jpg'

export default function ScrollTest() {
    const myref = useRef<HTMLDivElement>(null);
    useEffect(() => {
        const observer = new IntersectionObserver((entries) => {    //entry: observed item의 리스트.
            const entry = entries[0];
            console.log('entry:', entry);
        })
        observer.observe(myref.current)
    }, [])
  return (
    <>
        <s.Div className='test' ref={myref}>테스트</s.Div>
        <s.Image className='test' src={image} alt="photo"/>
    </>
  )
}

 

 

 

그러나 위처럼 작성하면  Argument of type 'HTMLDivElement | null' is not assignable to parameter of type 'Element'. 에러가 나타난다. 

myref.current 이 null인지 확인하고, null이 아닐 때만 observe할 수 있게 처리한다.

 

import { useRef, useEffect } from 'react'
import s from '../stores/styling'
import image from '../assets/images/artistregister-background.jpg'

export default function ScrollTest() {
    const myref = useRef<HTMLDivElement>(null);
    useEffect(() => {
        const observer = new IntersectionObserver((entries) => {    //entry: observed item의 리스트.
            const entry = entries[0];
            console.log('entry:', entry);
        })
        if (myref.current) {
            observer.observe(myref.current)
        }
        return () => {
            if (myref.current) {
                observer.unobserve(myref.current)
            }
        }
    }, [])
  return (
    <>
        <s.Div className='test' ref={myref}>테스트</s.Div>
        <s.Image className='test' src={image} alt="photo"/>
    </>
  )
}

 

 


 

사실 여기에서 조금 의문이 들었다.

if 문을 처리할 때, myref.current 가 null 인 경우를 else 처리하면 되지 않나?

결론적으로는 안된다.

   if (myref.current) {
   	observer.observe(myref.current)
   }
   else {
   	oberserver.unobserve(myref.current)
   }

 

 if (myref.current === null) {
             observer.unobserve(myref.current)
 }
 else {
	observer.observe(myref.current)
 }

 

위 두 코드는 myref.current 가 null 인지 확인하는 것처럼 보이지만, 

첫번째 코드는 null일 때 else 문이 반드시 실행된다.

두번째 코드도 비슷하게 직접 null인지 확인하고 unobserve를 실행한다.

 

observer.unobserve는 관찰 중인 실제 DOM 요소가 있을 때만 호출되어야 하기 때문에, 위 두 코드는 잘못된 접근이다.

 


 

따라서 

import { useRef, useEffect } from 'react'
import s from '../stores/styling'
import image from '../assets/images/artistregister-background.jpg'

export default function ScrollTest() {
    const myref = useRef<HTMLDivElement>(null);
    useEffect(() => {
        const observer = new IntersectionObserver((entries) => {    //entry: observed item의 리스트.
            const entry = entries[0];
            console.log('entry:', entry);
        })
        if (myref.current) {	// myref.current 가 true 일때만 실행 = null이 아닌 경우 실행
            observer.observe(myref.current)
        }
        return () => {	// cleanup 함수
            if (myref.current) {
                observer.unobserve(myref.current)
            }
        }
    }, [])
  return (
    <>
        <s.Div className='test' ref={myref}>테스트</s.Div>
        <s.Image className='test' src={image} alt="photo"/>
    </>
  )
}

 

 

위처럼 작성하니, 콘솔에서도 뷰포트에 요소가 들어오면 true, 나가면 false를 반환하는 것을 확인할 수 있었다.

 

 

카카오맵 API를 정상적으로 호출한 후, 이번에는 이 컴포넌트를 재사용 가능하게 만들어 보려 한다.

단순히 복사하는 것보다 props를 넘겨줌으로써 코드 반복을 피하고, Contact 페이지에 여러 오피스 위치를 동시에 보여주도록 한다.

 

우선 조금 전 카카오맵을 불러온 코드이다.

 

import React, { useEffect } from "react";
import s from "../stores/styling";


// 'Property 'kakao' does not exist on type 'Window & typeof globalThis'.' 오류에 대한 해결법.
// kakao 객체가 window에 있다고 declare로 명시한다.
declare global {
  interface Window {
    kakao: any;
  }
}

interface FetchMapProps {
  id: string;
  latitude: number;
  longitude: number;
}

const FetchMap:React.FC<FetchMapProps> = ({ id, latitude, longitude}) => {

//useEffect를 통해 컴포넌트 마운트 될 때 <script>태그를 동적으로 추가하는 법
  useEffect(() => {
    const loadKakaoMapScript = () => {
      return new Promise<void>((resolve, reject) => {
        const script = document.createElement("script");    //<script>태그 생성
        script.type = "text/javascript";
        //api키는 프로젝트 루트 디렉토리 .env 파일에 저장. CRA 보안 정책
        script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.REACT_APP_KAKAO_MAP_KEY}&libraries=services&autoload=false`;
        
        //onload (then 역할)와 onerror (catch 역할)은 <script>요소의 이벤트 핸들러 속성으로 제공됨.
        script.onload = () => { //성공
          resolve();
        };
        script.onerror = () => {    //실패
          reject(new Error("Kakao Map script loading error"));
        };
        document.head.appendChild(script);  //생성된 <script>를 HTML <head>에 추가
      });
    };

    //지도 초기화
    const initMap = () => {

      if (!window.kakao || !window.kakao.maps) {
        console.error("Kakao maps library is not loaded.");
        return;
      }

      let container = document.getElementById(id); // 지도를 담을 영역의 DOM 레퍼런스
      let options = {
        center: new window.kakao.maps.LatLng(latitude, longitude), // 위도, 경도
        level: 2, // 지도의 레벨(확대, 축소 정도)
      };
      
      // kakao 객체가 window 하위 객체라고 정의해야하므로 window.kakao라고 표기
      new window.kakao.maps.Map(container, options); // 지도 생성 및 객체 리턴
    } 

    loadKakaoMapScript()
      .then(() => { //지도 로드 성공 시, 지도 초기화
        window.kakao.maps.load(initMap); // TypeError: window.kakao.maps.LatLng is not a constructor 에러 해결지점
      })
      .catch((error) => {   //지도 로드 실패 시, 로그 메세지
        console.error(error);
      });
  }, []);   //마운트 시 한번만 수행

  return <s.Map id="{id}" className="map" />;
};

export default FetchMap;

 

마지막 return 문은 원래 id="map" 이었다. 

그러나 이것을 테스트 겸 단순 복사+붙여넣기 했더니 다른 컴포넌트 요소는 잘 복사가 되었지만, 지도 부분만 복사되지 않았다.

 

이유는 document.getElementById(id) 부분, 즉 id에 따라 지도를 뿌려준다고 설정해두었는데, 이 id가 "map"으로 고정되어버려 한 번만 로드되는 것이었다.

 

id="{id}" 로 바꾸어 생성되는 <s.Map> 블록마다 id를 직접 부여해주면 지도가 복사된다.

 

 


 

 

문제 없이 될 것이라 기대했지만, TypeError: Cannot read properties of null (reading 'currentStyle') 가 나타났다.

 

1. useEffect 문이 해당 id가 매겨진 div를 찾으려는데 ---- document.getElementById(id) 

2. 해당 div는 아직 렌더링되지 않아서 ---- null

3. 매핑되지 못하고 null 값을 리턴한다.

라는 오류였다.

 

 

그렇다면 useEffect문이 컴포넌트가 완전히 로드된 이후에만 처리하도록 고쳐보자.

import React, { useEffect, useRef } from "react";
import s from "../stores/styling";

declare global {
  interface Window {
    kakao: any;
  }
}

interface FetchMapProps {
  id: string;
  latitude: number;
  longitude: number;
}

const FetchMap: React.FC<FetchMapProps> = ({ id, latitude, longitude }) => {
  const mapContainerRef = useRef<HTMLDivElement | null>(null);	
  // mapContainerRef라는 ref 객체 생성
  // 이 객체는 current라는 속성을 가지고 있고, 초기값은 null이다.
  // 이 객체의 current 속성은 HTMLDivElement 또는 null일 수 있다.

  useEffect(() => {
    const loadKakaoMapScript = () => {
      return new Promise<void>((resolve, reject) => {
        const script = document.createElement("script");
        script.type = "text/javascript";
        script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.REACT_APP_KAKAO_MAP_KEY}&libraries=services&autoload=false`;
        script.onload = () => {
          resolve();
        };
        script.onerror = () => {
          reject(new Error("Kakao Map script loading error"));
        };
        document.head.appendChild(script);
      });
    };

    const initMap = () => {
      if (!window.kakao || !window.kakao.maps) {
        console.error("Kakao maps library is not loaded.");
        return;
      }

      if (mapContainerRef.current) {
        const container = mapContainerRef.current;
        const options = {
          center: new window.kakao.maps.LatLng(latitude, longitude),
          level: 2,
        };
        new window.kakao.maps.Map(container, options);
      }
    };

    loadKakaoMapScript()
      .then(() => {
        window.kakao.maps.load(initMap);
      })
      .catch((error) => {
        console.error(error);
      });
  }, [id, latitude, longitude]); //마운트 시 한번만 수행되던 것을 props가 바뀔때마다 실행

  return <s.Map ref={mapContainerRef} id={id} className="map" />;
  //mapContainerRef 객체의 current 속성이 가리키는 곳 지정
};

export default FetchMap;

 

useRef는 useState와는 달리 값이 변경되어도 컴포넌트가 리렌더링 되지 않는다.

따라서 DOM 요소를 직접 조작해야 하는 경우 유용하게 쓰일 수 있다.

 

이렇게 하니 컴포넌트를 두번, 세번 재사용 할 때 잘 동작한다.

그러나 사용되는 횟수에 따라 매번 API 호출이 일어나는 것으로 확인했다.

여러 좌표를 한번에 불러와 뿌려버릴 수는 없을까?

 


 

 

이때는 script 태그가 이미 추가되었는지 확인하고, 이미 로드된 경우 API 호출을 막아야 한다.

기존 코드의 경우, useEffect문 내에서 컴포넌트가 마운트 될때마다 loadKakaoMapScript 함수를 호출하여 Promise 객체를 새로 생성했다. 

이 부분을 먼저 useEffect문 바깥으로 꺼내고, 전역으로 script 로드 상태를 관리해보겠다.

let isScriptLoaded = false; // 스크립트 로드 상태 관리. 초기값 false

const loadKakaoMapScript = () => {
  return new Promise<void>((resolve, reject) => {
    if (isScriptLoaded) {
      resolve(); // 이미 로드되었다면 패스
      return;
    }

    const script = document.createElement("script");
    script.type = "text/javascript";
    script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.REACT_APP_KAKAO_MAP_KEY}&libraries=services&autoload=false`;
    script.onload = () => {
      isScriptLoaded = true;
      resolve();
    };
    script.onerror = () => {
      reject(new Error("Kakao Map script loading error"));
    };
    document.head.appendChild(script);
  });
};

 

 

완성된 전체 코드이다.


import React, { useEffect, useRef } from "react";
import s from "../stores/styling";

declare global {
  interface Window {
    kakao: any;
  }
}

interface FetchMapProps {
  id: string;
  latitude: number;
  longitude: number;
}

let isScriptLoaded = false; // 스크립트 로드 상태 관리 (초기값 false)
const scriptLoadPromise = loadKakaoMapScript();

function loadKakaoMapScript() {
  // useEffect 바깥에서 Promise 객체를 생성, 전역으로 관리
  return new Promise<void>((resolve, reject) => {
    if (isScriptLoaded) { //이미 스크립트 로드가 돼있다면 이 과정을 스킵
      resolve();
      return;
    } 
    else { //스크립트 로드가 안돼있는 경우
      const script = document.createElement("script"); //script 태그 추가 (아직 추가되지 않고, JS 객체로 존재)
      script.type = "text/javascript";
      script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.REACT_APP_KAKAO_MAP_KEY}&libraries=services&autoload=false`;
      //src속성으로 로드할 외부 JS파일 url 지정
      script.onload = () => { //script 태그의 onload 이벤트 핸들러 설정
        //스크립트 로드 시도
        isScriptLoaded = true; //성공 시 true
        resolve();
      };

      script.onerror = () => { //실패 시 에러 처리
        reject(new Error("Kakao Map script loading error"));
      };
      document.head.appendChild(script);  //세부사항 성공, 실패 여부 상관없이 script태그 추가
    }
  });
}

const FetchMap: React.FC<FetchMapProps> = ({ id, latitude, longitude }) => {
  const mapContainerRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const initMap = () => { //지도 초기화
      if (!window.kakao || !window.kakao.maps) {  //라이브러리가 올바르게 초기화 됐는지 확인하고, 안됐다면 에러 처리
        console.error("Kakao maps library is not loaded.");
        return;
      }

      if (mapContainerRef.current) {  
        const container = mapContainerRef.current; //HTML에 접근. current는 레퍼런스가 가리키는 실제 DOM 요소이다.
        const options = {
          center: new window.kakao.maps.LatLng(latitude, longitude),
          level: 2,
        };
        new window.kakao.maps.Map(container, options);
      }
    };

    scriptLoadPromise //스크립트가 로드된 상태가 확인되면
      .then(() => { //지도를 초기화한다.
        window.kakao.maps.load(initMap);
      })
      .catch((error) => { //로드가 안됐다면 에러 처리
        console.error(error);
      });
  }, [id, latitude, longitude]);  // id값, 위도, 경도가 바뀔 때마다 실행 (여러개의 지도 생성 가능)

  return <s.Map ref={mapContainerRef} id={id} className="map" />; // 
  // useRef를 쓰지 않고 그냥 div를 사용하면? --> 컴포넌트 마운트 전에 DOM요소에 접근해 문제 발생.
  // useRef는 마운트 이후에도 DOM 요소에 직접 접근할 수 있게 해준다.
};

export default FetchMap;

 

script 로드 여부를 전역으로 관리하여 중복된 네트워크 호출을 막고, 지도를 효율적으로 여러번 그릴 수 있도록 구성했다.

 

Contact 페이지에서는 임의의 주소를 입력하고, 해당 서비스를 운영하는 사무실인 것처럼 구성하려 한다.

이를 위해 카카오맵 API를 호출했다.

 

구현 방법은 다른 공식 문서에 비해 가독성도 높고, 구현하기 간단했다.

 

https://apis.map.kakao.com/

 

 

 

 

자바스크립트로 구현 방법이 설명되어 있는데, 이것을 타입스크립트로 바꿔보았다.

많은 글에서 <script> 태그를 index.html에 삽입하라고 쓰여 있지만, 동적으로 태그를 추가할 수 있도록 구현해보았다.

import { useEffect } from "react";
import s from "../stores/styling";

const FetchMap = () => {

//useEffect를 통해 컴포넌트 마운트 될 때 <script>태그를 동적으로 추가하는 법
  useEffect(() => {
    const loadKakaoMapScript = () => {
      return new Promise<void>((resolve, reject) => {
        const script = document.createElement("script");    //<script>태그 생성
        script.type = "text/javascript";
        //api키는 프로젝트 루트 디렉토리 .env 파일에 저장. CRA 보안 정책
        script.src = `//dapi.kakao.com/v2/maps/sdk.js?autoload=false&appkey=${process.env.REACT_APP_KAKAO_MAP_KEY}&libraries=services`;
        
        //onload (then 역할)와 onerror (catch 역할)은 <script>요소의 이벤트 핸들러 속성으로 제공됨.
        script.onload = () => { //성공
          resolve();
        };
        script.onerror = () => {    //실패
          reject(new Error("Kakao Map script loading error"));
        };
        document.head.appendChild(script);  //생성된 <script>를 HTML <head>에 추가
      });
    };

    //지도 초기화
    const initMap = () => {

      if (!window.kakao || !window.kakao.maps) {
        console.error("Kakao maps library is not loaded.");
        return;
      }

      let container = document.getElementById("map"); // 지도를 담을 영역의 DOM 레퍼런스
      let options = {
        center: new window.kakao.maps.LatLng(33.450701, 126.570667), // 지도 중심 좌표
        level: 3, // 지도의 레벨(확대, 축소 정도)
      };
      
      // kakao 객체가 window 하위 객체라고 정의해야하므로 window.kakao라고 표기
      let map = new window.kakao.maps.Map(container, options); // 지도 생성 및 객체 리턴
    };

    loadKakaoMapScript()
      .then(() => { //지도 로드 성공 시, 지도 초기화
        initMap();
      })
      .catch((error) => {   //지도 로드 실패 시, 로그 메세지
        console.error(error);
      });
  }, []);   //마운트 시 한번만 수행

  return <s.Map id="map" />;
};

export default FetchMap;

 

정석대로 따라간 것처럼 보이지만, 

'Property 'kakao' does not exist on type 'Window & typeof globalThis'. 라는 오류가 나타난다.

이것은 kakao 객체가 window에 있는지 확인할 수 없기 때문이다.

따라서 전역으로 선언하여 kakao 객체를 알아볼 수 있도록 declare global을 해준다.

// 'Property 'kakao' does not exist on type 'Window & typeof globalThis'.' 오류에 대한 해결법.
// kakao 객체가 window에 있다고 declare로 명시한다.
declare global {
  interface Window {
    kakao: any;
  }
}

 

 

이럼에도 불구하고 TypeError: window.kakao.maps.LatLng is not a constructor 라는 또다른 에러가 나타난다.

 

* 많은 글에서 스크립트 파라미터, 즉 src 부분에 &autoload=false 을 추가해야 한다는 조언을 받았는데...

   나의 경우 달라지는 점이 보이지 않았다.

 

한참의 서칭 끝에 해결법을 알아냈다.

 

window.kakao.maps가 아직 로드되지 않았는데 initMap이 먼저 실행되어 충돌이 일어나는 것으로 보였다.

따라서 initMap이 실행되는 시점을 정확하게 명시해보았다.

 

//...전략

 loadKakaoMapScript()
      .then(() => { 
        window.kakao.maps.load(initMap);
        //window.kakao.maps가 로드됐을 때 initMap 실행
      })
      .catch((error) => {
        console.error(error);
      });
  }, []);
  
  //...후략

 

 

 

 

비로소 브라우저에 지도가 온전히 나타나는 것을 확인할 수 있다.

 

채용 페이지는 기본적으로 캐러셀 이미지와 하단에 채용 중인 포지션을 열거하는 방식으로 기획했다.

캐러셀의 원리를 알지 못하니, 라이브러리를 사용하기 보다는 순수 자바스크립트로 구현해보기로 했다.

 

참고 링크

 

 

 

 

우선 기본적인 뼈대를 return문 안에 구축한다. 

가운데에 캐러셀 영역이 있고, 양 옆에 이전, 다음 버튼으로 구성되어 있다.

 return (
    <>
      <s.Button 
        disabled={disabled}
        onClick={() => {
          direction.current = 'left'
          handleSwipe('left')
          buttonControll()
        }}>
        이전
      </s.Button>

      <s.Carousel className='carousel-wrapper'>
        <s.Carousel 
          className='carousel-box' 
          style={{transform: `translateX(${-100 * currentIndex}%)`, transition}}
        >
          {slides.map(({ url, id }, idx) => (
            <s.Carousel 
              key={idx} 
              className='carousel-item' 
              style={{backgroundImage: `url(${url})`}}
            >
              {id}
            </s.Carousel>
          ))}
        </s.Carousel>
      </s.Carousel>

      <s.Button 
        disabled={disabled}
        onClick={() => {
          direction.current = 'right'
          handleSwipe('right')
          buttonControll()
        }}>
        다음
      </s.Button>
    </>
  )

 

 

 

 

불러올 이미지 5개를 준비하고, 각각 ID를 부여했다.

useState로 초기 데이터를 배열 형태로 세팅한다.

    const [item, setItem] = useState([
        { id: 1, url: 'https://images.app.goo.gl/gvhe3VaycwCW6JfZ6' },
        { id: 2, url: 'https://images.app.goo.gl/8bM8GiwpkkzfaSnK7' },
        { id: 3, url: 'https://images.app.goo.gl/SfhtK1KJouYkoAp76' },
        { id: 4, url: 'https://images.app.goo.gl/Xtgy3ug9XT7RxY9n9' },
        { id: 5, url: 'https://images.app.goo.gl/rv8qvSwbz8NWjTi67' },
      ])

 

 

 

 

보여질 캐러셀 이미지 앞뒤로 가짜 데이터를 생성한다. 

이를 통해 좌우 끝 데이터에 도달한 후 다시 돌아올 때, 자연스럽게 무한 스와이프 되는 듯한 transition을 구현할 수 있다.

 

fakeData가 2이므로, 초기 state는 2로 지정해야 첫번째 이미지가 나타난다.

 

push : 배열의 맨 뒤에 요소를 추가한 후 새로운 길이 반환

pop : 배열의 맨 뒤 요소를 제거하고, 제거된 요소를 반환

shift : 배열의 첫번째 요소 제거

unshift : 배열의 첫번째 요소 추가

 

마지막으로 세 배열 (fakeFront, item, fakeLast) 을 합쳐 새로운 배열을 만들면 무한히 순환되는 슬라이드를 만들 수 있다.

    const [currentIndex, setCurrentIndex] = useState(2)
    const fakeData = 2;
    const setSliders = () => {
        const fakeFront = []
        const fakeLast = []

        let index = 0;
        while (index < fakeData) {
            fakeLast.push(item[index % item.length])
            fakeFront.unshift(item[item.length - 1 - (index % item.length)])
            index++
        }
    return [...fakeFront, ...item, ...fakeLast]
    }

 

 

위에서 많이 헤멨는데, 결국 while문은 배열을 얼마동안 순회할 것인지 정해주는 로직이었다.

index % item.length 를 통해 index가 배열의 길이를 초과하지 않고 순환할 수 있다.

item.length - 1 - (index % item.length) 또한 마찬가지이다.

 

에를 들어,

item.length가 5일 때, index가 5라면 index % item.length는 0이 되므로, index가 0, 1, 2, 3, 4, 5, 6, 7... 일 때,

나머지는 0, 1, 2, 3, 4, 0, 1, 2, 3, 4... 로 반복된다.

 

<예시>

const item = [
  { id: 1, url: 'https://images.app.goo.gl/gvhe3VaycwCW6JfZ6' },
  { id: 2, url: 'https://images.app.goo.gl/8bM8GiwpkkzfaSnK7' },
  { id: 3, url: 'https://images.app.goo.gl/SfhtK1KJouYkoAp76' },
  { id: 4, url: 'https://images.app.goo.gl/Xtgy3ug9XT7RxY9n9' },
  { id: 5, url: 'https://images.app.goo.gl/rv8qvSwbz8NWjTi67' },
]

const fakeData = 2;
const setSliders = () => {
  const fakeFront = []
  const fakeLast = []

  let index = 0;
  while (index < fakeData) {
    fakeLast.push(item[index % item.length])
    fakeFront.unshift(item[item.length - 1 - (index % item.length)])
    index++
  }
  return [...fakeFront, ...item, ...fakeLast]
}

console.log(setSliders())
// 출력: 
// [
//   { id: 4, url: 'https://images.app.goo.gl/Xtgy3ug9XT7RxY9n9' },
//   { id: 5, url: 'https://images.app.goo.gl/rv8qvSwbz8NWjTi67' },
//   { id: 1, url: 'https://images.app.goo.gl/gvhe3VaycwCW6JfZ6' },
//   { id: 2, url: 'https://images.app.goo.gl/8bM8GiwpkkzfaSnK7' },
//   { id: 3, url: 'https://images.app.goo.gl/SfhtK1KJouYkoAp76' },
//   { id: 4, url: 'https://images.app.goo.gl/Xtgy3ug9XT7RxY9n9' },
//   { id: 5, url: 'https://images.app.goo.gl/rv8qvSwbz8NWjTi67' },
//   { id: 1, url: 'https://images.app.goo.gl/gvhe3VaycwCW6JfZ6' },
//   { id: 2, url: 'https://images.app.goo.gl/8bM8GiwpkkzfaSnK7' }
// ]

 

 

이제 스와이프 방향이 왼쪽인지 오른쪽인지 판별하도록 한다.

첫번째 if 문의 경우, 일반적으로 0 미만의 인덱스는 없으나, 첫번째 슬라이드에서 왼쪽으로 스와이프 했을 때의 조건을 명시한 것이다. 

index = slides.length -1 로 설정하여 마지막 슬라이드로 이동시킨다.

 

비슷하게, index === slides.length - 1 은 마지막 슬라이드에서 오른쪽으로 이동할 때의 조건이다.

  const handlerSlider = (index: number) => {
    if (index < 0) {
      direction.current = 'left'
      index = slides.length - 1;
      setOffTransition(true)
    } else if (index === slides.length - 1) {
      direction.current = 'right'
      index = slides.length - 1;
      setOffTransition(true)
    }
    setCurrentIndex(index)
  }
  
  //방향 판별
  const handleSwipe = (direction: 'left' | 'right') => {
    console.log('클릭')
    const newIndex = direction === 'left' ? currentIndex - 1 : currentIndex + 1
    handlerSlider(newIndex)
  }

 

 

 

자연스러운 캐러셀을 구현하기 위해서는 transition을 때에 따라 끄고 켜야 한다. 

첫번째에서 마지막, 또는 마지막에서 첫번째 슬라이드로 넘어가는 순간 transition을 그대로 유지한다면 부자연스러운 전환이 일어난다. 짧은 시간동안 setOff 함으로써 자연스러운 전환을 구현한다.

 

또한 transition이 마무리되기 전에 이전, 다음 버튼을 빠르게 연타하는 경우, 오류가 날 수 있다.

슬라이드를 일관되지 않게 노출시키거나, 애니메이션이 중첩될 수 있다. 이를 방지하기 위해 1초간 연타를 방지한다.

  const slides = setSliders()
  const [offTransition, setOffTransition] = useState(false)
  const direction = useRef('left')
  const transition = offTransition ? '0s' : '0.5s'; //슬라이딩 속도
  const [disabled, setDisabled] = useState(false)

  const buttonControll = () => {
    //버튼 사용 직후 1초간 비활성화 (연타 방지)
    setDisabled(true)
    setTimeout(() => setDisabled(false), 1000)
  }
  

  useEffect(() => {
    console.log('Slide ID:', slides[currentIndex].id)
     //오른쪽 마지막 인덱스일 경우
    if ( direction.current === 'right' && currentIndex === slides.length - 1 ) {
      //transition 잠시 껐다가 0번 인덱스로
      setTimeout(() => {
        setOffTransition(true)
        setCurrentIndex(2)  // fakedata가 2개이기 때문에 index(2)가 첫번째 id
      }, 1000)

      //0.1초 후 transition 다시 켜기
      setTimeout(() => {
        setOffTransition(false)
      }, 1100)

    //0번에서 마지막으로 넘어갈 경우
    } else if ( direction.current === 'left' && currentIndex === slides.length - 1 ) {
      //위 조건과 겹치지 않도록 먼저 transition을 끄고 넘어간 뒤 0.01초 후 켜서 눈속임
      setTimeout(() => {
        setOffTransition(false)
        setCurrentIndex(slides.length - 2)
      }, 10)
    }
  }, [currentIndex])

 

 

 

 

아래는 전체 코드이다.

 

import React, { useEffect, useRef, useState } from 'react'
import s from '../stores/styling'

export default function Carousel() {

    const [item, setItem] = useState([
        { id: 1, url: 'https://images.app.goo.gl/gvhe3VaycwCW6JfZ6' },
        { id: 2, url: 'https://images.app.goo.gl/8bM8GiwpkkzfaSnK7' },
        { id: 3, url: 'https://images.app.goo.gl/SfhtK1KJouYkoAp76' },
        { id: 4, url: 'https://images.app.goo.gl/Xtgy3ug9XT7RxY9n9' },
        { id: 5, url: 'https://images.app.goo.gl/rv8qvSwbz8NWjTi67' },
      ])

    const [currentIndex, setCurrentIndex] = useState(2)
    const fakeData = 2;
    const setSliders = () => {
        const fakeFront = []
        const fakeLast = []

        let index = 0;
        while (index < fakeData) {
            fakeLast.push(item[index % item.length])
            fakeFront.unshift(item[item.length - 1 - (index % item.length)])
            index++
        }
    return [...fakeFront, ...item, ...fakeLast]
    }

  const handlerSlider = (index: number) => {
    if (index < 0) {
      direction.current = 'left'
      index = slides.length - 1;
      setOffTransition(true)
    } else if (index === slides.length - 1) {
      direction.current = 'right'
      index = slides.length - 1;
      setOffTransition(true)
    }
    setCurrentIndex(index)
  }
  
  //방향 판별
  const handleSwipe = (direction: 'left' | 'right') => {
    console.log('클릭')
    const newIndex = direction === 'left' ? currentIndex - 1 : currentIndex + 1
    handlerSlider(newIndex)
  }

  const slides = setSliders()
  const [offTransition, setOffTransition] = useState(false)
  const direction = useRef('left')
  const transition = offTransition ? '0s' : '0.5s'; //슬라이딩 속도
  const [disabled, setDisabled] = useState(false)

  const buttonControll = () => {
    //버튼 사용 직후 1초간 비활성화 (연타 방지)
    setDisabled(true)
    setTimeout(() => setDisabled(false), 1000)
  }
  

  useEffect(() => {
    console.log('Slide ID:', slides[currentIndex].id)
     //오른쪽 마지막 인덱스일 경우
    if ( direction.current === 'right' && currentIndex === slides.length - 1 ) {
      //transition 잠시 껐다가 0번 인덱스로
      setTimeout(() => {
        setOffTransition(true)
        setCurrentIndex(2)  // fakedata가 2개이기 때문에 index(2)가 첫번째 id
      }, 1000)

      //0.1초 후 transition 다시 켜기
      setTimeout(() => {
        setOffTransition(false)
      }, 1100)

    //0번에서 마지막으로 넘어갈 경우
    } else if ( direction.current === 'left' && currentIndex === slides.length - 1 ) {
      //위 조건과 겹치지 않도록 먼저 transition을 끄고 넘어간 뒤 0.01초 후 켜서 눈속임
      setTimeout(() => {
        setOffTransition(false)
        setCurrentIndex(slides.length - 2)
      }, 10)
    }
  }, [currentIndex])

  return (
    <>
      <s.Button 
        disabled={disabled}
        onClick={() => {
          handleSwipe('left')
          buttonControll()
        }}>
      이전
      </s.Button>

      <s.Carousel className='carousel-wrapper'>
        <s.Carousel 
          className='carousel-box' 
          style={{transform: `translateX(${-100 * currentIndex}%)`, transition}}
          //각 슬라이드를 전체 화면 너비의 100%만큼 이동
        >
          {slides.map( ({ url, id }, idx) => {
            return (
              <s.Carousel 
                key={idx} 
                className='carousel-item' 
                style={{backgroundImage: `url(${url})`}}
              >
                {id}
              </s.Carousel>
            )
          })}
        </s.Carousel>
      </s.Carousel>

      <s.Button 
        disabled={disabled}
        onClick={() => {
          handleSwipe('right')
          buttonControll()
        }}>
      다음
      </s.Button>
    </>
  )
}

 

 

 

슬라이드는 버튼 동작에 따라 잘 넘어가는 것으로 나타난다.

그러나 슬라이드가 한바퀴 이상 순회한 뒤, 버튼을 지속적으로 클릭했을 때 슬라이드가 연달아 겹치거나 순서가 뒤바뀌는 현상이 나타나 콘솔 메세지를 통해서도 확인해보았다.

 

 

 


 

 

 

알아보니 setTimeout을 사용하여 offTransition상태를 업데이트 하는 부분에서 충돌이 발생한 것으로 보였다.

또한 불필요한 상태 업데이트를 최소화해야 했다.

 

 

먼저 첫번째보다 왼쪽, 마지막보다 오른쪽으로 넘어간 경우에 direction을 설정하도록  핸들러 함수를 수정했다.

offTransition 함수 또한 생략했다.

const handlerSlider = (index: number) => {
  if (index < 0) {
    index = slides.length - 1;
  } else if (index >= slides.length) {
    index = 0;
  }
  setCurrentIndex(index)
}

 

 

다음은 useEffect 부분이다.

기존에는 방향에 따라 다르게 설정했는데, 이번에는 방향과 현재 인덱스를 한번 더 명시하여 이에 따라 설정했다.

사용감을 위해 지속 시간을 좀 더 줄였다.

 

여기서 slides.length - 3slides.length - 2 는 fakeData가 2이기 때문에 나오는 값이다.

useEffect(() => {
  console.log('Slide ID:', slides[currentIndex].id)
  if (currentIndex === 1 && direction.current === 'left') {
    setTimeout(() => {
      setOffTransition(true)
      setCurrentIndex(slides.length - 3)
    }, 500)
    setTimeout(() => {
      setOffTransition(false)
    }, 600)
  } else if (currentIndex === slides.length - 2 && direction.current === 'right') {
    setTimeout(() => {
      setOffTransition(true)
      setCurrentIndex(2)
    }, 500)
    setTimeout(() => {
      setOffTransition(false)
    }, 600)
  }
}, [currentIndex])

 

 

핸들러 함수에 direction.current을 명시한다.

<s.Button 
  disabled={disabled}
  onClick={() => {
    direction.current = 'left'
    handleSwipe('left')
    buttonControll()
  }}>
  이전
</s.Button>

<s.Button 
  disabled={disabled}
  onClick={() => {
    direction.current = 'right'
    handleSwipe('right')
    buttonControll()
  }}>
  다음
</s.Button>

 

 

 

 

최종적으로 다음과 같은 코드가 완성되었고, 오류 없이 잘 나타나는 것을 확인했다.

import React, { useEffect, useRef, useState } from 'react'
import s from '../stores/styling'

export default function Carousel() {

  const [items, setItems] = useState([
    { id: 1, url: 'https://images.app.goo.gl/gvhe3VaycwCW6JfZ6' },
    { id: 2, url: 'https://images.app.goo.gl/8bM8GiwpkkzfaSnK7' },
    { id: 3, url: 'https://images.app.goo.gl/SfhtK1KJouYkoAp76' },
    { id: 4, url: 'https://images.app.goo.gl/Xtgy3ug9XT7RxY9n9' },
    { id: 5, url: 'https://images.app.goo.gl/rv8qvSwbz8NWjTi67' },
  ])

  const [currentIndex, setCurrentIndex] = useState(2)
  const fakeData = 2;
  const [offTransition, setOffTransition] = useState(false)
  const [disabled, setDisabled] = useState(false)
  const direction = useRef('left')

  const setSliders = () => {
    const fakeFront = []
    const fakeLast = []
  
    let index = 0;
    while (index < fakeData) {
      fakeLast.push(items[index % items.length])
      fakeFront.unshift(items[items.length - 1 - (index % items.length)])
      index++
    }
    return [...fakeFront, ...items, ...fakeLast]
  }

  const slides = setSliders()

  const handlerSlider = (index: number) => {
    if (index < 0) {
      index = slides.length - 1;
    } else if (index >= slides.length) {
      index = 0;
    }
    setCurrentIndex(index)
  }
  
  const handleSwipe = (direction: 'left'|'right') => {
    console.log('클릭')
    const newIndex = direction === 'left' ? currentIndex - 1 : currentIndex + 1
    handlerSlider(newIndex)
  }

  const buttonControll = () => {
    setDisabled(true)
    setTimeout(() => setDisabled(false), 1000)
  }

  useEffect(() => {
    console.log('Slide ID:', slides[currentIndex].id)
    if (currentIndex === 1 && direction.current === 'left') {
      setTimeout(() => {
        setOffTransition(true)
        setCurrentIndex(slides.length - 3)
      }, 500)

      setTimeout(() => {
        setOffTransition(false)
      }, 600)
    } else if (currentIndex === slides.length - 2 && direction.current === 'right') {
      setTimeout(() => {
        setOffTransition(true)
        setCurrentIndex(2)
      }, 500)

      setTimeout(() => {
        setOffTransition(false)
      }, 600)
    }
  }, [currentIndex])

  const transition = offTransition ? '0s' : '0.5s';

  return (
    <>
      <s.Button 
        disabled={disabled}
        onClick={() => {
          direction.current = 'left'
          handleSwipe('left')
          buttonControll()
        }}>
        이전
      </s.Button>

      <s.Carousel className='carousel-wrapper'>
        <s.Carousel 
          className='carousel-box' 
          style={{transform: `translateX(${-100 * currentIndex}%)`, transition}}
        >
          {slides.map(({ url, id }, idx) => (
            <s.Carousel 
              key={idx} 
              className='carousel-item' 
              style={{backgroundImage: `url(${url})`}}
            >
              {id}
            </s.Carousel>
          ))}
        </s.Carousel>
      </s.Carousel>

      <s.Button 
        disabled={disabled}
        onClick={() => {
          direction.current = 'right'
          handleSwipe('right')
          buttonControll()
        }}>
        다음
      </s.Button>
    </>
  )
}

 

기획 중인 서비스 페이지 중, 검색 페이지 (현재명 Article)의 무한 스크롤을 만들었다.

 

원래대로라면 div block 하나에 이미지 API, 이름 API, 기사 API를 하나씩 포함하는 것으로 구상했다.

그러나 이것을 무한히 복사하거나 임의로 단순 복제한다면 같은 API를 여러번 불러와야 하므로 과부하가 생길 것으로 예상했다.

 

곧장 props로 넘기려니 혼란스러워, 우선 궁극적으로는 각 데이터 매핑을 한다고 생각하고, 무한 스크롤 먼저 구현하기로 했다.

 

import React, { useState, useEffect } from 'react'
import s from '../stores/styling'

const Article: React.FC = () => {
  const [articles, setArticles] = useState<number[]>([1, 2])

  useEffect(() => {
    const handleScroll = () => {
      if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 500) {
        setArticles(prevArticles => [
          ...prevArticles,
          prevArticles.length + 1,
          prevArticles.length + 2
        ])
      }
    }

    window.addEventListener('scroll', handleScroll)
    return () => window.removeEventListener('scroll', handleScroll)
  }, [])

  return (
    <s.ArticleWrapper>
      <h1>Article</h1>
      <s.ArticleMidWrapper>
      {articles.map((article, index) => (
        <s.ArticleDiv key={index} className='article-cards'>
          테스트 {article}
        </s.ArticleDiv>
      ))}
      </s.ArticleMidWrapper>
    </s.ArticleWrapper>
  )
}

export default Article

 

1. useState를 사용하여 각 block에 넘버링을 하기로 했다.

2. useEffect 훅을 적용한다. (component 마운트 시 한 번만 수행)

 - 브라우저 창의 높이 (window.innerHeight) 와 윈도우 상단에서 현재까지 스크롤된 픽셀 (scrollY) 을 더하고,

 - 문서 전체 높이 (document.body.offsetHeight) 에서 임의로 500px을 빼서 하단에 가까운 지점을 설정한다.

 - 이 둘을 비교하여 전자가 같거나 더 크다면, 즉 현재 스크롤이 하단에 가까워졌는지 확인한다.

 

 - 위 조건을 만족한 경우, setArticles 를 호출하여 상태를 업데이트한다.

 - ...prevArticles 를 통해 기존의 articles 배열을 복사한다.

 - 기존 배열의 길이에 1과 2를 추가한다.

 

3. 이벤트가 발생할 때마다 handleScroll을 호출한다.

4. component가 언마운트 될 때 eventListener를 해제해준다. (메모리 누수 방지, component를 사용하지 않을 때 호출하지 않음)

 

 

 

Class로 모듈화 하기

import { makeDOMwithProperties } from '/directory/test.js'

	const noticeDOM = makeDOMwithProperties ('div', {
        innerHTML : '내용 없음',
        className : 'product-list-con'
    });

 

export const makeDOMwithProperties = (domType, propertyMap) => {
    //domType : div, a , li...
    //propertyMap : {"className" : "product-card", "alt"...}

    //Object.keys() 란? --- 키 값을 배열로 넘김
    //예시) Object.keys(propertyMap) ---> ["className", "alt"] 
    
    const dom = document.createElement(domType)
    Object.keys(propertyMap).forEach((key) => {
        dom[key] = propertyMap[key];
    });

    return dom;
}

export const appendChildrenList = (target, childrenList) => {
    if (!Array.isArray(childrenList)) return; //early return이라고 한다. if/else 구문은 길어지기 때문에, 아닌 경우만 먼저 솎아낸다.

    childrenList.forEach((child) => {
        target.appendChild(child); //childrenList의 배열에 들어있는 children을 붙임
    })
};

 

 

 

 


 

 

 

브라우저 저장소

 

LocalStorage

  • 브라우저를 닫아도 데이터가 유지된다.
  • 동일한 도메인에서만 접근할 수 있다. (Same-Orgin Policy)
  • 간단한 인터페이스로 사용하기 쉽다.

 

  • 브라우저 당 약 5MB 정도의 저장 공간만 가질 수 있다.
  • 문자열만 저장 가능하며, 객체나 배열 등의 데이터 구조를 저장할 수 없다.

 

 

SessionStorage

 

  • 임시로 데이터를 저장하여 같은 탭이나 창 간 데이터를 공유할 수 있다. 
  • 동일한 도메인에서만 접근할 수 있다. (Same-Orgin Policy)

 

  • 브라우저를 닫으면 (세션을 종료하면) 데이터가 삭제된다.

 

 

IndexedDB

 

  • 객체 지향형 데이터 베이스로 구조화된 데이터를 저장할 수 있다.
  • 비동기적(Asynchronized)으로 데이터를 읽고 쓸 수 있기 때문에 대량의 데이터를 처리할 수 있다.
  • 동일한 도메인에서만 접근할 수 있다. (Same-Orgin Policy)

 

  • 사용자에게 데이터 관리 책임이 있다.
  • 사용하기 복잡하다.
  • 구형 브라우저에 지원되지 않을 수 있다.

'개념 정리' 카테고리의 다른 글

javascript - reduce()  (1) 2024.10.28
새로운 리액트 프로젝트 생성하기  (0) 2024.10.16
HTML의 noreferrer, nopener, nofollow  (0) 2024.01.21
Flex와 Grid  (0) 2024.01.21
반응형 작업과 mediaquery 최소화  (0) 2024.01.21

 

 

noreferrer, nopener, nofollow는 웹 보안 및 사용자의 개인정보보호 강화를 위해 사용되는 속성이다.

 

 

noreferrer

하이퍼링크를 통해 이동할 때, 현재 페이지의 referrer 정보를 전달하지 않도록 한다.

현재 페이지의 주소가 외부 사이트로 전달되는 것을 방지한다.

 

referrer 정보란?

이전 페이지의 URI를 나타내는 정보. 어떤 사이트에서 유입되었는지 분석하여 사용자의 트래픽 추적이 가능하다.

 

 

 

nopener

하이퍼링크로 열린 새 창이 자신을 열지 않도록 하는 속성.

새 창이 열릴 때, 열린 창이 부모 창(이전 창) 에 대한 제어 권한을 갖지 못하게 한다. 

+ CSRF 공격을 막을 수 있다.

 

CSRF 공격이란?

Cross-Site Request Forgery의 약자.

사용자가 의도하지 않은 요청을 악의적인 웹 사이트를 통해 다른 웹 사이트에 전송한다. 

 

<script>
	window.opener = null;
</script>

 

 

 

nofollow

rel 속성에 사용되며, 검색 엔진이 해당 링크를 따라가지 않도록 지시한다.

검색 엔진 최적화(SEO)에서 해당 외부 사이트로의 링크가 현재 사이트의 검색 엔진 순위에 영향을 주지 못하도록 한다.

신뢰할 수 없는 사이트로 이동할 때, 검색 엔진이 해당 사이트로의 링크가 전달되는 것을 방지한다.

 

SEO란?

Search Engine Optimization의 약자. 웹 사이트가 검색 엔진에서 노출되기 용이하게 최적화하는 프로세스.

 

 

 

 

 

그 외 보안 이슈에 대한 처리

최신 브라우저 지원

HTTPS 사용

정기적인 보안 업데이트 및 보안 취약점 최소화 등

+ Recent posts