지난 글에서 언급했듯이, 어떻게 하면 이전 요소가 뷰포트 상단을 기준으로 고정되고 스크롤 하면 그 다음 요소가 이전 요소 위에 카드처럼 덮이면서 쌓여나가도록 만들 수 있을까?

 

GSAP의 Pin 개념을 익힌다면 가능하다.

  • pinning의 기준점은 trigger 요소의 start 와 일치하고, scroller-start에 고정된다.
  • trigger 요소의 end 점이 scroller-end에 도달하면 pin이 풀리게 된다.

 

아래 영상에 나오는 tomato 색 div에 주목해보자.

 

 

1. pin 적용 전

gsap.to(".c", {
    scrollTrigger: {
      trigger: ".c",
      start: "top center",
      end: "top 100px",
      scrub: 1,
      markers: true,
    },
    x: 1200,

    ease: "none",
    duration: 3,
  });

 

 

2. pin 적용 후

gsap.to(".c", {
    scrollTrigger: {
      trigger: ".c",
      start: "top center",
      end: "top 100px",
      scrub: 1,
      pin: true <- 바뀐 부분
      markers: true,
    },
    x: 1200,
    ease: "none",
    duration: 3,
  });

 

 

 

위처럼 스크롤했을 때 c블럭이 scroller-start 지점에 들어가는 순간부터 애니메이션이 진행되는데, Y좌표는 시작점에서 벗어나지 않다가 scroller-end 지점을 만나고서야 일반적인 스크롤이 되는 것을 확인할 수 있었다.

 

 

 

 


 

 

 

 

이제 조금 더 응용해서 만들고자 했던 것에 가까워져 보자.

기존의 div 구성은 같되 너비와 높이가 화면에 가득차게 만들었다.

그리고 .box 라는 공통 클래스를 기준으로 배열을 생성하고, 요소마다 각각 ScrollTrigger을 달아준다.

gsap.utils.toArray(".box").forEach((box, i) => {
    ScrollTrigger.create({
        trigger: box,
        start: "top top",
        markers: true,
    })
})

 

 

이렇게 하면 일반적인 스크롤과 다를 바 없다.

이제 pin을 추가해보자.

gsap.utils.toArray(".box").forEach((box, i) => {
    ScrollTrigger.create({
        trigger: box,
        start: "top top",
        pin: true,
        markers: true,
    })
})

 

 

이전 블럭의 end 포인트가 scroller-end 지점을 나가기 전까지는 화면에 고정되어 있는 것처럼 보인다.

그러나 그 end 포인트가 나가면 해당 블럭도 다시 스크롤되어 올라간다.

이것까지 막으면 정말 레이어가 쌓이는 것처럼 만들 수 있을 것으로 보인다.

이 점은 pinSpacing 속성을 활용해준다.

gsap.utils.toArray(".box").forEach((box, i) => {
    ScrollTrigger.create({
        trigger: box,
        start: "top top",
        pin: true,
        pinSpacing: false,
        markers: true,
    })
})

 

원했던 대로 각 블럭이 카드가 쌓이듯 동작하는 것을 확인할 수 있다.

pinSpacing의 경우, boolean 값을 받는다.

  • true : Default. 고정된 요소가 차지하고 있던 공간을 그대로 유지한다.
  • false : 고정된 요소의 자리를 제거하고, 다른 요소가 그 자리를 차지한다.

 

 


 

 

 

 

마지막으로 스크롤했을 때 요소의 최상단 및 최하단에 자동으로 snap 되도록 해보자.

 

 

 

프로젝트 초반부터 꼭 구현하고 싶었던 애니메이션 중 하나는-

아티스트 검색 창에서 사진을 스크롤 했을 때, 해당 이미지가 스크롤 양에 상관 없이 화면 상단 또는 하단에 snap 되고, 이전 / 다음 사진이 레이어 카드처럼 쌓여나가는 방식이다. (ZARA 홈페이지 메인화면을 참고했다)

이 부분을 구현하기 위해 position: sticky 를 사용하여 스크롤 이벤트를 감지하여 생성된 div가 뷰포트 상단에 맞춰 쌓여나가도록 하려 했다.

또한 1초 동안 이벤트 핸들러를 제거하여 지나치게 빠른 스크롤을 방지하려 했다.

그러나 스크롤을 조금만 하더라도 이전 / 다음 div가 스냅되듯이 나타나는 방식을 구현하는 데 어려움을 겪고 있었다.

 

이 때 우연히 GSAP 이라는 라이브러리를 알게 되었다.

 

공식 홈페이지의 튜토리얼 영상을 보고 그대로 따라하며 익혀보려 한다.

 

 

 

우선 플러그인을 설치해준다.

yarn add gsap

 

 

 

튜토리얼 영상에서는 vanila JS를 사용하기에, 오랜만에 새로운 프로젝트를 생성해 따라하며 실습해보았다.

 

기본적인 HTML, CSS, JS 구조를 생성하고, CSS파일과 JS파일, GSAP을 연결한다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>gsap test project</title>
    <link rel="stylesheet" href="styles.css">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.11.0/gsap.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.11.0/ScrollTrigger.min.js"></script>
  </head>
  <body>
    <div class="box a">a</div>
    <div class="box b">b</div>
    <div class="box c">c</div>
    <script src="script.js"></script>
  </body>
</html>

 

body {
    font-family: Arial, sans-serif;
    margin: 0;
    padding: 0;
    background-color: #f0f0f0;
}

.box {
    width: 100px;
    height: 100px;
}

.a {
    background-color: aqua;
    margin-bottom: 300px;
}

.b {
    background-color: antiquewhite;
    margin-bottom: 300px;
}

.c {
    background-color: tomato;
    margin-bottom: 300px;
}

 

 

 

 

사용법은 매우 직관적이고 간단하다.

gsap.to("해당 애니메이션을 입힐 클래스 또는 ID", {
  scrollTrigger: 화면에 보여졌을 때 실행할 기준 클래스 또는 ID,
  x: 가로축 이동 끝 좌표 (단위는 px, %, 함수 모두 가능),
  rotation: 각도,
  duration: 지속시간,
});

 

객체 형태로 작성하기 때문에 ',' 를 잊지 말고 써주자.

 

 

 

더 나아가 scrollTrigger의 값을 객체로 입력하면 더욱 다양하고 유연한 동작을 끌어낼 수 있다.

gsap.to("해당 애니메이션을 입힐 클래스 또는 ID", {
  scrollTrigger: {
    trigger: "뷰포트 등장 기준 클래스 또는 ID",
    toggleActions: "정방향진입 정방향퇴장 역방향진입 역방향퇴장", 
  },
  x: 가로축 이동 끝 좌표 (단위는 px, %, 함수 모두 가능),
  rotation: 각도,
  duration: 지속시간,
});

 

 

toggleActions 에 들어가는 4가지 요소에 주목해보자.

초기값은 아래와 같다.

toggleActions: "play none none none"

 

재생 옵션은 다음과 같다.

  • play : 일반 재생
  • pause : 뷰포트에서 보이지 않을 경우 그 자리에서 정지
  • resume : 정지 시점에서부터 다시 재생
  • reverse : 거꾸로 재생 (보통 다시 scroll up할 때 적용)
  • restart : 처음부터 다시 재생
  • reset : 초기 상태로
  • complete : 완료 상태로
  • none: 아무 동작도 하지 않음

 

 

 

이것을 바탕으로 c 블럭이 화면에 나타난 경우 c 블럭에 다음 조건의 애니메이션을 재생시켜보자.

1. c 블럭에 GSAP 지정

2. c 블럭이 화면에 나타난 경우 애니메이션 실행

3. 정방향으로 스크롤 했을 때 재생

4. 뷰포트에서 사라졌을 때 애니메이션 잠시 멈춤

5. 역방향으로 스크롤해서 c 블럭이 다시 뷰포트에 나타난 경우 거꾸로 재생

gsap.to(".c", {
  scrollTrigger: {
    trigger: ".c",
    toggleActions: "play pause reverse none",
  },
  x: 1000,
  rotation: 360,
  duration: 3,
});

 

 

 


 

 

 

 

참고로 뷰포트의 진입, 퇴장 기준점은 뷰포트 최상단과 최하단이 default로 지정되어 있다.

만일 이 좌표를 바꾸고 싶다면?

scrollTrigger의 객체값에 start end 속성을 추가해주자.

 

두 기준점이 정확히 어디인지 시각적으로 확인하기 위해 markers 속성을 추가하면 편하다.

gsap.to(".c", {
  scrollTrigger: {
    trigger: ".c",
    toggleActions: "play pause reverse none",
    markers: true,
    start: "요소의기준점 뷰포트기준점",
    end: "요소의기준점 뷰포트기준점",
  },
  x: 1000,
  rotation: 360,
  duration: 3,
});

 

start과 end의 값으로는 top, bottom, center 뿐 아니라 해당 요소의 최상단을 기준으로 px 및 % 단위도 사용할 수 있음을 참고하자.

또한 end의 경우, '+=' 를 사용하여 start값의 상대값을 입력할 수 있다.

예를 들어, 요소의 중심을 시작점으로 삼고 100px아래에서 애니메이션을 끝내고 싶다면 아래와 같이 작성한다.

gsap.to(".c", {
  scrollTrigger: {
    trigger: ".c",
    toggleActions: "play pause reverse none",
    markers: true,
    start: "center 80%",
    end: "+=100",
  },
  x: 1000,
  rotation: 360,
  duration: 3,
});

 

그러나 반응형으로 구현한다면 고정값을 매기는 것이 부적합할 수 있다.

이 때 end값에 함수를 직접 넣을 수도 있다.

(개인적으로 GSAP의 가장 멋진 부분이라고 생각한다)

 

참고: end값을 하나만 입력하면 그 값은 트리거 요소의 end point로 간주되고, 뷰포트의 end point는 start에서 지정한 값과 일치한다.

gsap.to(".c", {
  scrollTrigger: {
    trigger: ".c",
    toggleActions: "play pause reverse none",
    markers: true,
    start: "center 80%",
    end: () => "+=" + document.querySelector(".c").offsetWidth,
  },
  x: 1000,
  rotation: 360,
  duration: 3,
});

 

위와 같이 작성한다면 end점은 c블럭의 오프셋너비 / 2 만큼 아래에 지정된다. 

start point가 center이기 때문에 2로 나눈 것이다.

 

 

물론 적용할 대상, trigger 대상, endTrigger 대상 모두 다른 요소로 지정할 수도 있다.

 

 

 


 

 

 

 

그렇다면 스크롤을 위 아래로 짧게 이동할때마다 애니메이션이 왔다갔다(?) 하게 해보자.

scrub 속성을 통해 구현할 수 있다.

gsap.to(".c", {
  scrollTrigger: {
    trigger: ".c",
    start: "top center",
    end: "top 100px",
    scrub: boolean 또는 초단위
  },
  x: 1000,
  rotation: 360,
  duration: 3,
});

 

이렇게 하면 가로로 이동하는 도중 역방향 스크롤이 감지되면 즉시 애니메이션이 reverse 된다. 

scrub의 값으로 들어간 초는 latency를 나타낸다.

start point와 end point까지 거리에 비례하여 애니메이션 타임라인이 진행된다.

다시 말해, start와 end의 거리가 500px 이고 250px만큼 스크롤 했다면, 애니메이션은 절반만큼 진행되어 있다.

 

이 타임라인을 변수로 선언하고 재사용한다면 다음과 같다.

 

let customTimeline = gsap.timeline({  
	scrollTrigger: {
        trigger: ".c",
        start: "top center",
        end: "top 100px",
        scrub: 1,
       }
  });

customTimeline.to(".c", {
  x: 1000,
  rotation: 360,
  duration: 3,
  ease: "none",
});

 

 

기본적인 사용 방법을 익혔으니, 다음 글에서 구현하고 싶었던 기능을 구현해보자.

 

 

 

어떤 단어를 입력했을 때, 미리 입력해둔 스타일의 도안을 이미지로 받아볼 수 있도록 DALL.E API 를 사용해보았다.

 

 

 

 

DALL.E API key 발급 방법

 

아래 링크에 로그인 한 뒤 간단히 받아볼 수 있다. 

https://platform.openai.com/api-keys

 

주의할 점

 1. 한 번 생성하면 다시 열 수 없고, 지운 뒤 새로 받아야 하는 번거로움이 있으니 잘 저장해두자.

 

 2. 현재 ChatGPT 구독 여부와는 별개로 플랜을 신청해야 한다.

     (현 시점 ChatGPT 4o를 구독하고 있으나, API를 사용하기 위해서는 추가 결제를 해야했다.)

 

 3. 이 점을 해결하지 않으면 API key를 적용하더라도 400 Error를 반환한다.

 

 4. 분 당 5장의 이미지를 제공하니, 지나치게 잦은 생성은 지양해야 한다. 

    (현재 만드는 서비스는 테스트 용도이고, 생성 시간이 꽤 오래 걸려 아직은 걱정하지 않기로 했다)

 

 

 


 

 

 

1.

이제 자신의 프로젝트에 axios를 의존성 패키지로 설치한다.

yarn add axios

 

 

2.

GenerateImageApi.ts 파일을 생성한다.

이 파일에 아래처럼 api를 호출하는 로직을 작성한다.

//GenerateImageApi.ts

import axios from 'axios';

const GenerateImageApi = async (prompt: string, color: boolean): Promise<string> => {
// 전달할 props와 반환되는 Promise 객체의 타입 지정

  const apiKey = process.env.REACT_APP_OPENAI_API_KEY;
  // 프로젝트 루트 디렉토리의 .env 파일에 자신의 API키 저장
  const url = 'https://api.openai.com/v1/images/generations';
  // 생성될 이미지의 url

  const headers = {
    'Authorization': `Bearer ${apiKey}`,
    	// Bearer란? OAuth 2.0 인증에서 사용되는 방법. 토큰을 전달하여 서버에서 사용자의 인증 및 권한 확인
    'Content-Type': 'application/json',
    	// JSON으로 데이터 형식 변환
  };

  const data = {
    prompt: `${prompt} //추가 프롬프트를 고정하고 싶다면 여기에 작성//, 
             ${color ? 'colorful' : 'black and white'}`, // 흑백, 컬러 여부
    n: 1,	// 생성할 이미지 수
    size: '256x256',	// 이미지 크기
    response_format: 'url',
  };

  // fufilled
  try {
    const response = await axios.post(url, data, { headers });	// 요청을 보낼 url, 요청과 함께 보낼 data, 설정 객체(header)
    return response.data.data[0].url;
  } 
  // rejected
  catch (error) {
    console.error('Error generating image:', error);
    throw error;
  }
};

export default GenerateImageApi;

 

 

3.

이제 이미지 생성을 진행할 페이지 파일을 만들어 주고,

(루트 파일에 Routing 과정은 생략하겠다)

이미지 생성 요청의 성공 / 실패에 따른 handleSubmit 함수를 작성해준다.

//GenerateImage.tsx

import { useState } from 'react';
import GenerateImageApi from '../components/api/GenerateImageApi';

const GenerateImage: React.FC = () => {
  const [prompt, setPrompt] = useState<string>('');
  const [imageUrl, setImageUrl] = useState<string>('');

  const handleSubmit = async (e: React.FormEvent) => {	
  // 단순히 제출이 아니라, 제출 후 데이터 요청을 해야하므로 async 처리
    e.preventDefault();
    // fufilled
    try {
      const url = await GenerateImageApi(prompt, color); // 위에서 작성한 api 호출 함수 실행
      setImageUrl(url);	// 생성한 이미지에 대한 url 생성
    } 
    // rejected
    catch (error) {
      console.error('Error:', error);
    }
  };
  }

 

 

4.

마지막으로 유저에게 보여질 ui에 이미지 생성용 input, img, button 태그 등을 구성하여 return문에 추가한다.

(아래 코드는 기능 구현만 이룬 것으로, 모든 스타일링은 default HTML 스타일이다)

 

GenerateImageApi.ts 파일에서 만든 흑백과 컬러 옵션을 checkbox를 통해 결정할 수 있도록 했다.

 

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={prompt}
          onChange={(e) => setPrompt(e.target.value)}
          placeholder="Enter your prompt"
          required
        />
        <label>
          <input
            type="checkbox"
            checked={color}
            onChange={(e) => setColor(e.target.checked)}
          />
          Color
        </label>
        <button type="submit">Generate Image</button>
      </form>
      {imageUrl && <img src={imageUrl} alt="Generated" />}
    </div>
  );

 

 

 

 

 

 

 

전체 코드

아래는 GenerateImage.tsx의 전체 코드이다.

import { useState } from 'react';
import GenerateImageApi from '../components/api/GenerateImageApi';

const GenerateImage: React.FC = () => {
  const [prompt, setPrompt] = useState<string>('');
  const [color, setColor] = useState<boolean>(false);
  const [imageUrl, setImageUrl] = useState<string>('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      const url = await GenerateImageApi(prompt, color);
      setImageUrl(url);
    } 
    catch (error) {
      console.error('Error:', error);
    }
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={prompt}
          onChange={(e) => setPrompt(e.target.value)}
          placeholder="Enter your prompt"
          required
        />
        <label>
          <input
            type="checkbox"
            checked={color}
            onChange={(e) => setColor(e.target.checked)}
          />
          Color
        </label>
        <button type="submit">Generate Image</button>
      </form>
      {imageUrl && <img src={imageUrl} alt="Generated" />}
    </div>
  );
};

export default GenerateImage;

 

 

 

 

사족...

-오늘의 프롬프트 일기-

 

1. 영어로 입력한 경우 정확도가 현저히 올라간다. (이건 GPT와 마찬가지)

2. 간결하고 명확하게 작성해야한다.

     -  '~ 한 느낌', '마치 건축가처럼...' 보다는 '청사진 스타일', '앤디워홀 스타일' 이라고 써야 한다.

     -  다만 이러한 명사가 들어가면 그 스타일에 매우 갇혀버리는 경향을 보인다.

3. 사이즈와 관계 없이 때때로 이미지가 잘린다. 

     -  'leaving empty spave around the edges at least 10px to avoid being cropped' 라고 작성하면 때때로 나아진다.

4. icon style이라고 입력하면 매우 단순화시킨다.

5. line style이라고 입력하면 목판화처럼 뚝딱거리거나, 목탄화 같은 이미지를 만든다.

 

개선해야할 점

1. 유저에게서 한글 입력이 들어오는 경우

2. 로컬에 저장된 값을 바탕으로 이미지를 임의로 생성하는 경우

     -  나의 경우, '비행기'를 몇 번 시도한 후 refresh 했는데도 전혀 다른 input에 대해 몇 차례에 걸쳐 비행기를 그려냈다.

3. color 옵션에 대해 소극적으로 대처하는 경우

      색을 조화롭게 입힌다기 보다는, 같은 그림을 그저 검정 잉크에서 주황 잉크로 그리듯이 단순한 변화만 나타났다

 

 

 

 

가입 페이지에서 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>
    </>
  )
}

+ Recent posts