input 태그에 유저 입력을 받고, 식물 데이터 중 검색한 단어와 일부 일치하는 이름의 식물만을 필터링 하려 한다.

 

 

컴포넌트 구조는 아래와 같다.

src/
├── Pages
│   ├── Home.tsx
│   └── DetailPage.tsx
├── Components
├── Header
│   ├── Nav.tsx
│   └── Input.tsx
├── Content.tsx
│   └── GardenList.tsx
└── ...

 

 

덧붙여, 라우팅 구조는 아래와 같다.

<Router>
  <Header /> //모든 페이지에 동일하게 렌더링 
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/detail/:name" element={<DetailPage />} />
  </Routes>
</Router>

 

 

말하자면,

input에 들어간 입력값(State)을 최상위 컴포넌트(App) 까지 끌어올리고, 그 아래 요소들에 최소 3번 이상의 뎁스를 거쳐 전달해야한다는

것이다.

 

Props Drilling이 발생하고 말았다.

 

이것을 해결하기 위해 상태관리 라이브러리를 사용한다.

Redux, Recoil, Zustand 등 여러 라이브러리가 있는데, 현 시점에서 사용량이 가장 많은 Redux를 써보기로 했다.

 

 

 

 

 

 


 

 

 

 

 

Redux 어떻게 쓰는걸까?

 

  1. Redux 설치하기
  2. Store 생성하기
    • 2-1 초기상태 정의
    • 2-2 Action type 정의 및 Action 객체 생성
    • 2-3 Reducer 정의
    • 2-4 Store 내보내기
    • 2-5 기타
  3. Provider로 프로젝트와 스토어 연결하기
  4. useDispatch로 상태관리 하려던 부분 연결하기

 

1. Redux 설치하기

우선 해당 라이브러리를 설치한다.

// npm 사용 시
npm install redux react-redux

// yarn 사용 시
yarn add redux react-redux

 

 

설치가 되었다면 package.json 파일에 의존성이 추가되었는지 확인한다.

"dependencies": {
  "redux": "^4.2.1",
  "react-redux": "^8.0.5",
}

 

 

 

 

알아보니 원래 redux만 설치하는 것이 표준이었던 모양인데... 호환이 잘 되지 않는다.
최근에는 불변성 관리 등을 보다 효율적으로 해낼 수 있도록 업데이트된 redux toolkit 사용을 권장한다고 한다.
다음 글에서는 redux toolkit를 토대로 이 글을 다시 작성하고, 이 둘의 차이점을 짚어보려 한다.

 

 

 

 


 

 

 

 

2-1. Store 생성하기 - 초기상태 정의

src 폴더 하위에 redux 폴더를 생성하고, store.ts 파일을 추가한다.

 

모든 파일을 종류별로 묶어 폴더에 각각 저장하는 것이 좋지만, 이 글 작성의 편의를 위해 우선 store.ts에 모두 쓰도록 하겠다.

 

 

우선 나의 프로젝트의 경우, 관리하고자 하는 상태는 2가지이다.

  1. input 입력값
  2. 렌더링될 gardenData 배열

 

이를 바탕으로 초기값(initialState)을 할당해준다.

// 초기상태
const initialState = {
  gardenData: [],
  searchQuery: "",
};

 

 

2-2. Store 생성하기 - Action type 정의 및 Action 객체 생성

Redux에서 action 객체란?

상태 변경에 필요한 정보를 담은 상자라고 생각하면 쉽다.

이 상자 안에는 type, payload 두가지 상자가 있다.

 

// action 객체 기본형 예제

// action type 선언
const EXAMPLE_ACTION_OBJECT = "EXAMPLE_ACTION_OBJECT" // 이 둘의 철자가 반드시 같아야 한다

// action 생성
export const exampleActionObject = (payload: any) => ({ // payload의 타입은 임시로 선언했다
  type: EXAMPLE_ACTION_OBJECT,
  payload,
});

 

이로써 action 객체를 성공적으로 생성한 것이다!

 

 

세부 설명은 접은 글을 참고하기 바란다.

더보기

그래서 대체 Action 객체가 뭐냐고!

 

필자는 type과 payload의 역할이 무엇인지부터 이해하는데 며칠 헤매고 말았다.

 

type : 무슨 작업을 해야하는지 작성하는 칸

payload : 그 작업에 필요한 데이터를 넣는 칸

 

아무래도 type이 Typescript의 "type"과 헷갈린 듯하다.

그 타입이 아니라... "어떤 함수랑 연결할래?" 라는 뜻이다.

 

 

또한 첫 줄에 action type 선언부를 보면, 왼쪽 상수의 이름과 오른쪽 문자열 값이 동일해야한다.

이런 형태 또한 의아했다... '왜 그냥 string이라고 하지 않고?'

당연하다...

연결할 함수의 이름을 달아두는 것이다.

 

type이 아니라 name이라고 이해하면 빠르겠다.

 

풀어 쓰자면-

  type: action을 식별하는 역할. Reducer에서 이 type을 기준으로 상태를 변경한다.

  payload: action을 수행하는 데에 전달되는 매개변수

 

 

 

Action 객체에 대한 예제를 하나 덧붙인다.

const SET_SEARCH_QUERY = "SET_SEARCH_QUERY"	// 액션 타입 선언

const setSearchQuery = (example) => ({
  type: "SET_SEARCH_QUERY", // 액션 타입 기재
  payload: example          // 업데이트할 데이터를 payload로 전달
});

const action = setSearchQuery("장미")

console.log(action)
// 출력: { type: "SET_SEARCH_QUERY", payload: "장미" }

 

거듭 강조하자면 액션 타입 선언부의 좌항과 우항의 철자는 반드시 같아야 한다.

 

왜 상수로 선언할까?

그냥 문자열로 지정한다면 오타로 인한 오류 추적이 어려워진다.

이것을 상수로 선언함으로써 오타 가능성을 낮추고, 안정성을 높일 수 있다.

 

 

그렇다면 action 객체와 action type의 이름 또한 같아야 할까?

그렇지는 않다.

다만 코드 가독성을 높이기 위해 네이밍을 통일할 뿐이다.

 

 

 

 

 

이제 내 프로젝트에 맞춰 2가지 상태에 대한 action 객체를 생성해준다.

// 액션 타입 선언
const SET_GARDEN_DATA = "SET_GARDEN_DATA"; 	// 렌더링할 데이터 상태
const SET_SEARCH_QUERY = "SET_SEARCH_QUERY";	// 인풋 데이터 상태

// 액션 객체 생성
export const setGardenData = (payload: string) => ({
  type: SET_GARDEN_DATA,
  payload,
});

export const setSearchQuery = (payload: string) => ({
  type: SET_SEARCH_QUERY,
  payload,
});

 

 

 

 

 

2-3. Store 생성하기 - Reducer 정의

Reducer는 Redux에서 상태를 변경하는 순수 함수이다.

쉽게 말해 action 객체를 일종의 명령으로 받고, state새로운 값으로 받아 새로운 상태를 반환한다.

 

 

아래는 reducer 작성 예제이다.

// reducer 작성 방법

const reducer = (state, action) => {
  switch (action.type) {
    case "ACTION_TYPE_1":	// 액션 타입에 따라 다른 동작 수행
      // 상태 업데이트 로직
      return { ...state, updatedKey: newValue };

    case "ACTION_TYPE_2":
      // 상태 업데이트 로직
      return { ...state, anotherKey: anotherValue };

    default:
      // 아무 액션도 매칭되지 않으면 기존 상태 반환
      return state;
  }
};

 

 

단순히 switch 문을 통해 각 타입(실행할 함수)에 따라 분기처리를 하면 된다.

주의할 점은, Reducer는 불변성을 지키기 때문에 상태를 직접 변경하지 않으므로, 배열을 업데이트 하는 경우 스프레드 연산자를 통해 배열을 복사한 뒤 업데이트한다.

 

// 리듀서 정의
const gardenReducer = (state = initialState, action: any) => { // 초기값 적용, action의 타입은 임시
  switch (action.type) {
    case SET_GARDEN_DATA:	// 렌더링 데이터 배열 상태의 경우
      return {
        ...state, // 기존 상태를 복사한 후
        gardenData: action.payload, // gardenData의 상태를 업데이트
      };
    case SET_SEARCH_QUERY:	// 인풋 상태가 바뀐 경우
      return {
        ...state,
        searchQuery: action.payload, // searchQuery의 상태를 업데이트
      };
    default:
      return state;
  }
};

 

 

2-4. Store 생성하기 - Store 내보내기

createStore을 임포트하여 생성한 리듀서를 담아 내보낸다.

import { createStore } from "redux";

const store = createStore(gardenReducer);

export default store;

 

 

 

 

2-5. Store 생성하기 - 기타

더보기

이 부분은 optional하니 스킵해도 좋다.

 

 

reducer 함수가 여러개인 경우

combineReducers 를 임포트하여 합쳐준다.

현재 프로젝트의 경우 리듀서가 하나만 필요하지만, 두개라고 가정한다면 아래와 같다.

import { combineReducers } from "redux";

const userReducer = (state = { isLoggedIn: false }, action) => { // 로그인 여부 상태관리를 가정
  switch (action.type) {
    case "LOGIN":
      return { ...state, isLoggedIn: true };
    default:
      return state;
  }
};

const gardenReducer = (state = { gardenData: [] }, action) => {
  switch (action.type) {
    case "SET_GARDEN_DATA":
      return { ...state, gardenData: action.payload };
    default:
      return state;
  }
};

const rootReducer = combineReducers({ // 두개의 리듀서를 하나의 객체로 모아주기
  user: userReducer, // user 상태 관리
  garden: gardenReducer, // garden 상태 관리
});



// 이렇게 작성하면 전체 상태 트리는 아래와 같다.
{
  user: { isLoggedIn: false },
  garden: { gardenData: [] },
}

 

 

Store 내보내기 수정

createStore를 임포트하고, 리듀서를 담아준다.

import { createStore } from "redux";

const store = createStore(rootReducer);

export default store; 	// 완성된 스토어 내보내기

 

 

 

RootState 타입 정의

이대로 내보내고 사용하려 하면 RootState의 타입을 지정하라는 타입스크립트 에러가 나타난다.

따라서 아래 코드를 추가해줌으로써 해결한다.

 

export type RootState = ReturnType<typeof store.getState>;

 

 

 

 

아래는 GPT의 도움을 받은 부분으로, 추후 내용을 추가할 수 있다.

RootState란?
- Redux 스토어의 전체 상태 트리의 타입을 정의합니다.
- store.getState()의 반환 타입을 TypeScript가 추론하여, 전체 상태 구조를 RootState로 정의합니다.

ReturnType이란?
- TypeScript의 유틸리티 타입으로, 함수가 반환하는 값의 타입을 추론합니다.typeof store.getState의 반환 타입이 전체 - 상태 구조이므로 이를 RootState로 사용합니다.

 

 

const userReducer = (state = { isLoggedIn: false }, action) => {
  switch (action.type) {
    case "LOGIN":
      return { ...state, isLoggedIn: true };
    default:
      return state;
  }
};

const gardenReducer = (state = { gardenData: [] }, action) => {
  switch (action.type) {
    case "SET_GARDEN_DATA":
      return { ...state, gardenData: action.payload };
    default:
      return state;
  }
};

const rootReducer = combineReducers({
  user: userReducer, // user 상태 관리
  garden: gardenReducer, // garden 상태 관리
});

 

 

 

 

최종 코드

이로써 아래와 같이 store.ts 가 완성되었다.

그러나 이것은 현재 지원되지 않을 수 있는 구버전의 방식이기 때문에, 다음 글에서 신버전으로 다시 써보도록 하겠다.

(그렇다고 개념 자체가 달라지는 것은 아니라, 오히려 간소화시키는 작업이기 때문에 이 내용을 숙지하고 넘어가는 것이 좋다)

import { createStore, combineReducers } from "redux";

// 초기상태
const initialState = {
  gardenData: [],
  searchQuery: "",
};

// 액션 타입
const SET_GARDEN_DATA = "SET_GARDEN_DATA";
const SET_SEARCH_QUERY = "SET_SEARCH_QUERY";

// 액션 생성
export const setGardenData = (payload: string) => ({
  type: SET_GARDEN_DATA,
  payload,
});

export const setSearchQuery = (payload: string) => ({
  type: SET_SEARCH_QUERY,
  payload,
});


//리듀서 정의
const gardenReducer = (state = initialState, action: any) => {
  switch (action.type) {
    case SET_GARDEN_DATA:
      return {
        ...state, // 기존 상태를 복사한 후
        gardenData: action.payload, // gardenData 상태 업데이트
      };
    case SET_SEARCH_QUERY:
      return {
        ...state,
        searchQuery: action.payload, // searchQuery 상태 업데이트
      };
    default:
      return state;
  }
};

// 여러 리듀서를 관리할 수 있도록 combineReducers 사용
const rootReducer = combineReducers({
  garden: gardenReducer,
});

const store = createStore(rootReducer);

export default store;

// RootState 타입 정의
export type RootState = ReturnType<typeof store.getState>;

 

 

 

 

 

농사로 API를 활용하여 식물별 난이도, 성장 속도, 광도, 습도에 따라 필터링 할 수 있는 서비스를 만들고 있다.

총 3개의 API를 불러올텐데, 이들은 동일한 cntntsNo(컨텐츠 번호) 항목을 가진다.

따라서 이들을 각각 불러와 cntntsNo을 기준으로 합치고, 매핑해보도록 한다.

 

 

 


 

 

 

API 호출 로직은 아래와 같다.

우선 실내정원용 식물 목록을 호출한다.

  const [gardenList, setGardenList] = useState<GardenItemProps[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);
  const [pageNo, setPageNo] = useState<number>(1); 

  const fetchGardenList = async () => {
    setLoading(true); //작업이 끝날 때까지 로딩
    try {
      const apiKey = process.env.REACT_APP_API_KEY;
      const parser = new XMLParser();

      const gardenListResponse = await axios.get(
        `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenList?apiKey=${apiKey}&numOfRows=3&pageNo=${pageNo}`
      );

      // XML을 JSON으로 변환
      const gardenListJson = parser.parse(gardenListResponse.data);
      const gardenListItems = gardenListJson?.response?.body?.items?.item || [];


      setGardenList(gardenListItems);
    } 
    catch (error) {
      console.error("Error fetching garden list:", error);
      setError("An error occurred while fetching the data.");
    } 
    finally {
      setLoading(false); //로딩 끝
    }
  };

 

 

이 글에서 자세히 설명하지는 않겠지만, XML을 JSON으로 변환하는 데에는 fast-xml-parser을 사용했다.

 

 

 

 

 

 

이제 실내정원용 식물 상세, 실내정원용 식물 첨부파일 목록도 똑같이 불러온다.

(가독성을 위해 타입 선언은 잠시 생략했다)

//상세정보 목록
  const gardenDetailResponse = await axios.get(
    `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenDtl?apiKey=${apiKey}&cntntsNo=${cntntsNo}`
  );

  const gardenDetailJson = parser.parse(gardenDetailResponse.data);
  const detailInfo = gardenDetailJson?.response?.body?.item || {};

//첨부파일 목록
  const gardenFileListResponse = await axios.get(
    `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenFileList?apiKey=${apiKey}&cntntsNo=${cntntsNo}`
  );

  const gardenFileListJson = parser.parse(gardenFileListResponse.data);
    let fileList = gardenFileListJson?.response?.body?.items?.item || [];

 

 

 

 

 

 

이것을 위 fetchGardenList 함수에 옮겨심는다.

  const [gardenList, setGardenList] = useState<GardenItemProps[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);
  const [pageNo, setPageNo] = useState<number>(1); 

  const fetchGardenList = async () => {
    setLoading(true); //작업이 끝날 때까지 로딩
    try {
      const apiKey = process.env.REACT_APP_API_KEY;
      const parser = new XMLParser();
      
      //실내정원용 식물 목록
      const gardenListResponse = await axios.get(
        `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenList?apiKey=${apiKey}&numOfRows=3&pageNo=${pageNo}`
      );
      // XML을 JSON으로 변환
      const gardenListJson = parser.parse(gardenListResponse.data);
      const gardenListItems = gardenListJson?.response?.body?.items?.item || [];
    
    
      //상세정보 목록
      const gardenDetailResponse = await axios.get(
        `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenDtl?apiKey=${apiKey}&cntntsNo=${cntntsNo}`
      );
      // XML을 JSON으로 변환
      const gardenDetailJson = parser.parse(gardenDetailResponse.data);
      const detailInfo = gardenDetailJson?.response?.body?.item || {};

      //첨부파일 목록
      const gardenFileListResponse = await axios.get(
        `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenFileList?apiKey=${apiKey}&cntntsNo=${cntntsNo}`
      );
      // XML을 JSON으로 변환
      const gardenFileListJson = parser.parse(gardenFileListResponse.data);
        let fileList = gardenFileListJson?.response?.body?.items?.item || [];

      setGardenList(gardenListItems);
    } 
    catch (error) {
      console.error("Error fetching garden list:", error);
      setError("An error occurred while fetching the data.");
    } 
    finally {
      setLoading(false); //로딩 끝
    }
  };

 

 

지금 fetchGardenList를 호출하여 gardenList를 확인하면 어떻게 될까?

당연히 실내정원용 식물 목록만 나온다.

마지막 setGardenList에 GardenListItems (가공된 GardenListResponse) 가 반환되도록 써두었기 때문이다.

 

 

 

이제 이 3개의 API로 불러들인 데이터를 합쳐보자.

 

gardenListItems의 각 항목을 item으로 받고, 이 중 공통으로 가지고 있는 값인 cntntsNo를 요청 변수에 넣어준다.

이 점을 토대로 map 메소드와 Promise.all을 실행하면 일종의 반복문이 수행된다.

 

Promise.all이란?

Promise.all은 여러 개의 비동기 작업을 병렬로 실행하고, 모든 작업이 완료될 때까지 기다리는 메소드이다.

모든 작업이 fufilled 상태여야만 성공으로 간주하고 각각의 작업을 배열로 반환한다.

반면 하나라도 실패한다면 rejected를 반환한다.

 

아래처럼 여러 개의 비동기 작업을 수행할 때, 유용하게 사용할 수 있다.

  const [gardenList, setGardenList] = useState<GardenItemProps[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);
  const [pageNo, setPageNo] = useState<number>(1); 


  const fetchGardenList = async () => {
    setLoading(true);
    try {
      const apiKey = process.env.REACT_APP_API_KEY;

      const gardenListResponse = await axios.get(
        `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenList?apiKey=${apiKey}&numOfRows=3&pageNo=${pageNo}`
      );

      const parser = new XMLParser();
      const gardenListJson = parser.parse(gardenListResponse.data);
      const gardenListItems = gardenListJson?.response?.body?.items?.item || [];

      //각 item에 대해 gardenDtl와 gardenFileList API를 동시에 호출하여 병렬 작업
      const detailedItems: GardenItemProps[] = await Promise.all(
        gardenListItems.map(async (item: any) => {
          const cntntsNo = item.cntntsNo;

          const gardenDetailResponse = await axios.get(
            `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenDtl?apiKey=${apiKey}&cntntsNo=${cntntsNo}`
          );

          const gardenDetailJson = parser.parse(gardenDetailResponse.data);
          const detailInfo: DetailInfoProps = gardenDetailJson?.response?.body?.item || {};

          const gardenFileListResponse = await axios.get(
            `http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenFileList?apiKey=${apiKey}&cntntsNo=${cntntsNo}`
          );

          const gardenFileListJson = parser.parse(gardenFileListResponse.data);
          let fileList: FileItemProps[] =
            gardenFileListJson?.response?.body?.items?.item || [];
            
          if (!Array.isArray(fileList)) {
            fileList = [fileList];
          }

          return { //새로운 배열로 반환
            ...item,
            detailInfo,
            fileList,
          };
        })
      );

      setGardenList((prev) => [...prev, ...detailedItems]); //모든 데이터 묶기
    } 
    catch (error) {
      console.error("Error fetching garden list:", error);
      setError("An error occurred while fetching the data.");
    } 
    finally {
      setLoading(false);
    }
  };

 

 

 

 

코드 흐름

gardenListResponse를 호출하여 JSON으로 변환한 값을 gardenListItems에 저장한다.

나머지 2개의 호출을 각 gardenListItems의 개별 item의 cntntNo(식물 고유코드)에 매핑하여 새로운 배열을 만든다.

+ 이 때 fileList 데이터가 객체 형태인 경우가 있어, 별도로 배열화하는 로직을 추가했다.

마지막으로 기존 배열에 detailedItems 배열을 밀어넣어 3개 호출이 모두 포함된 배열을 완성한다.

 

 

 

 

 

 

 

 

 

 

지난 글에서 키보드 입력값을 받아 각 버튼에 매핑된 code 값을 매칭시키고, 이에 따라 오디오 잭생 및 동적으로 클래스를 추가하는 방법을 구현했다.

테스트를 통해 나타난 몇가지 오류 사항을 검토하고 고쳐본다.

 

 

 

오류

기대와는 다르게 몇몇 키가 작동하지 않는다.

제작 중인 키보드 배열과 테스트 중인 맥북의 배열이 다르다는 것을 감안하더라도, 문제되는 키는 다음과 같다.

 

  1. 좌, 우 Alt, Shift, Ctrl 키가 구분되지 않고, 모두 좌측 키로 인식함
  2. \ 키의 오디오는 출력되지만 클래스가 추가되지 않음
  3. Caps Lock이 한번에 3번 연달아 재생되고, 클래스가 제거되지 않음
  4. ESC, f1 ~ f12키에 preventDefault()가 적용되지 않음

 

 

 

참고로 Alt, Shift, Ctrl, Caps lock등과 같은 키는 통칭 Modifier 키 라고 한다.

 

 

 


 

 

 

좌우 Modifier key 구분하기

 

중복되는 키 중 shift 키를 예로 들어보겠다.

중복되는 키가 몇개 되지 않으니, 데이터에 location이라는 항목을 옵션으로 추가해준다.

type LocationType = "L" | "R"

interface Key {
  code: string;
  label: string;
  audio: AudioType;
  extraClass?: string;
  location?: LocationType;
}

const mainKeys: Key[][] = [
    [
      ...
      // location 외에는 모두 같은 조건이었다.
      { code: "shift", label: "SHIFT", audio: "B", location: "L" },
      { code: "shift", label: "SHIFT", audio: "B", location: "R" },
      ...
    ]
]

 

 

입력되는 값의 위치를 event.location으로 받아올 수 있다.

event.location === 1 : 왼쪽

event.location === 2 : 오른쪽

const handleKeyDown = (event: KeyboardEvent) => {
      const pressedKey = event.key.toLowerCase(); // 하단에 설명 첨부
      const pressedLocation =
        event.location === 1 ? "L" : event.location === 2 ? "R" : "";	// 좌: 1, 우: 2
		// 중요: location이 없는 경우 "" 처리해주지 않으면 추후 data-location이 작동하지 않음
      const audioFile = findAudioFile(pressedKey);

      if (audioFile) {
        event.preventDefault();
        playHandler(audioFile);

        // location이 있는 경우 왼쪽("L") 또는 오른쪽("R")으로 매핑
        const locationSelector = pressedLocation // "L" 또는 "R"에 맞춰 data-attribute 매핑
          ? `[data-location="${pressedLocation}"]`
          : "";
        const buttonElement = document.querySelector(
          `button[data-code="${pressedKey}"]${locationSelector}`
        );

        if (buttonElement) {
          buttonElement.classList.add("test");
          console.log(buttonElement, "추가");
        }
      }
    };

    const handleKeyUp = (event: KeyboardEvent) => {
      const pressedKey = event.key.toLowerCase();
      const pressedLocation =
        event.location === 1 ? "L" : event.location === 2 ? "R" : "";

      const locationSelector = pressedLocation
        ? `[data-location="${pressedLocation}"]`
        : "";
      const buttonElement = document.querySelector(
        `button[data-code="${pressedKey}"]${locationSelector}`
      );

      if (buttonElement) {
        buttonElement.classList.remove("test");
        console.log(buttonElement, "제거");
      }
    };
    
    
   ...
   
   return (
   //중략
     <div className="key-main-container">
        {mainKeys.map((row, rowIndex) => (
          <div className="key-main-row" key={rowIndex}>
            {row.map((label, labelIndex) => (
              <button
                className={`main-keys ${label.extraClass || ""} ${
                  mouseEnterOn ? "hoverEffect" : ""
                }`}
                key={labelIndex}
                data-code={label.code.toLowerCase()}
                data-location={label.location || ""}	// location 항목이 있는 경우 이를 따라 매핑된다.
              >
                <span className="key-span">{label.label}</span>
              </button>
            ))}
          </div>
        ))}
  	</div>
   )
  }

 

비교적 간단하게 좌, 우 중복 키를 구분하여 매핑할 수 있다.

여기서 중요한 점은, 해당 키보드의 배열과 무관하게 표준 키보드 입력값에 맞추어 location 값을 넣어줘야한다.

 

키보드 입력값 MDN 공식문서

 

 

 

위의 코드 시작 부분을 다시 한 번 주목하자.

const pressedKey = event.key.toLowerCase();
const pressedLocation =
event.location === 1 ? "L" : event.location === 2 ? "R" : "";

 

우리는 location이 있다면 받아온다는 조건을 걸었다.

우리가 아는 보통의 키보드에는 Shift, Alt, Ctrl 등이 2개씩 있고, 이것은 항상 location 값을 가진다.

입력한 키의 location 값을 받아오기로 했으니, 현재 사용하는 키보드 배열이 어떻든 브라우저는 두개 중 하나라고 인식한다.

 

따라서 만일 통상적으로 2개인 Modifier 키를

  • 현재 디자인 중인 가상 키보드 배열에 없거나,
  • 테스트 중인 키보드 (맥북 키보드 등) 에 없다는 이유로

location 값이 없는 것처럼 취급하면 인식하지 못하는 오류가 발생할 수 있다.

 

따라서 mainKeys 데이터로 돌아가, 중복될 수 있는 모든 키에 location 항목을 추가한다.

 

만일 보통은 2개지만 구현하고 있는 가상 키보드 배열에는 하나인 키가 있다면-

들어오는 키의 방향을 콘솔로 확인한 뒤, "L" 또는 "R"로 확정해준다.

 

 

 

 

 

 

 

 

"\" 키 클래스 추가하기

"\" 키는 code 값으로 그냥 넣을 수 없다. 자바스크립트의 이스케이프 문자와 중복되기 때문이다.

그래서 데이터 상의 code에는 "\\" 으로 표기했다.

{ code: "\\", label: "\\", audio: "D"}

 

그러나 이 경우 querySelector가 정확하게 인식하지 못하는 경우가 있다고 한다.

"\\" 자체를 특수문자로 인식하여 올바르게 해석하지 못할 수 있다.

따라서 앞뒤로 이스케이프 문자를 추가해줌으로써 올바르게 추적할 수 있도록 했다.

 

이제 pressedKey가 "\\" 인 경우에는 "\\\\"로 인식하도록 처리해준다.

const handleKeyUp = (event: KeyboardEvent) => {
      const pressedKey = event.key.toLowerCase(); // 입력된 키
      const pressedLocation =	// 좌우 구분
        event.location === 1 ? "L" : event.location === 2 ? "R" : "";

      const locationSelector = pressedLocation // location이 있는 키라면 좌우 구분
        ? `[data-location="${pressedLocation}"]`
        : "";

      const selectedKey = pressedKey === "\\" ? "\\\\" : pressedKey; // "//" 문자에 대한 처리

      const buttonElement = document.querySelector(
        `button[data-code="${selectedKey}"]${locationSelector}` // 새로운 변수명으로 교체
      );

 

 

 

 

 

 

 

 

 

Capslock 문제 해결하기

 

Capslock 키의 이벤트가 3번 연달아 실행되지만 클래스는 제거되지 않는 문제가 있다.

Capslock은 다른 키와는 다르게 상태 toggle형 키이기 때문에, keyDown과 keyUp이 순차적으로 실행된다기 보다는, 키를 떼더라도 그 상태가 유지된다.

또한 일부 브라우저에서는 Capslock을 한 번 누르더라도 이벤트가 여러번 실행될 수 있다.

 

이에 따라 event.getModifierState()를 통해 현재 capslock 상태인지 확인하고, 이미 활성화된 상태라면 keydown을 막아준다.

    const handleKeyDown = (event: KeyboardEvent) => {
      const pressedKey = event.key.toLowerCase();
      const pressedLocation =
        event.location === 1 ? "L" : event.location === 2 ? "R" : "";

      if (pressedKey === "capslock" && !event.getModifierState("CapsLock")) //추가
        return;
      const audioFile = findAudioFile(pressedKey);

      if (audioFile) {
        event.preventDefault();
        playHandler(audioFile);

 

이런 식으로 구현하면 오디오도 한 번, 클래스도 한 번만 추가된다.

그러나 keyUp을 하더라도 클래스가 제거되지 않아 계속 활성화된 것처럼 보이게 된다.

클래스를 토글하자니, 키를 한번 누르면 활성화되고 다시 한번 누르면 비활성화 되는 것은 의도한 방식이 아니다.

 

이 점은 단순하게 setTimeout( )을 통해 키가 눌리면 오디오를 재생, 클래스를 추가하고 0.15초 후 클래스가 해제되도록 구현했다.

    if (pressedKey === "capslock") {
        // CapsLock이 활성화된 상태가 아니라면 return하여 다중 토글을 방지
        if (!event.getModifierState("CapsLock")) return;

        const audioFile = findAudioFile(pressedKey);
        if (audioFile) {
          playHandler(audioFile); // CapsLock일 때도 오디오 재생
        }

        const buttonElement = document.querySelector(
          `button[data-code="capslock"]`
        );
        if (buttonElement) {
          buttonElement.classList.add("test");

          setTimeout(() => {
            buttonElement.classList.remove("test");
          }, 150);
        }
        return; // CapsLock 로직은 여기서 종료
      }

 

 

 

 

 

 

 

 

ESC, F1 ~ F12키 매핑하기

이 키들의 경우, preventDefault()를 무시하고 오디오 재생이나 클래스 추가를 하지 않고 원래 기능을 하는 문제가 있었다.

 

이것은 일부 브라우저와 운영 체제에서 특정 기능을 부여했기 때문에 preventDefault()가 항상 정상적으로 적용되지 않기 때문이다.

특히, 브라우저 자체 단축키로 사용되는 경우 이를 무시하고 브라우저가 우선하여 처리한다.

 

따라서 이 부분은 타이핑 모드에서는 비활성화된 듯한 디자인을 추가하여 (그레이 배경, 톤다운 배경, 알림 등) 사용하지 않도록 유도하기로 했다.

 

 

 

 

 

 

 

 

전체 코드

위 오류를 모두 해결한 Key 컴포넌트이다.

import { useEffect, useState } from "react";
import "../styles/styles.css";
import { Knob } from "primereact/knob";
import "primereact/resources/themes/lara-light-cyan/theme.css";
import mainKeys, {
  functionKeys,
  escKey,
  AudioType,
} from "../assets/data/keyArray";
import useAudioPlayer from "../components/hooks/AudioPlay";

interface KeysProps {
  onLightToggle: () => void;
  typingOn: boolean;
  onClickOn: boolean;
  mouseEnterOn: boolean;
  onKnobChange: (value: number) => void;
  onLightOn: boolean;
}

export default function Keys({
  onLightToggle,
  typingOn,
  onClickOn,
  mouseEnterOn,
  onKnobChange,
  onLightOn,
}: KeysProps) {
  const [knobValue, setKnobValue] = useState<number>(0);
  const { playHandler } = useAudioPlayer();

  useEffect(() => {
    if (!typingOn) return;

    const keyMap = new Map<string, AudioType>(
      mainKeys.flat().map((key) => [key.code, key.audio])
    );

    function findAudioFile(audioFile: string) {
      return keyMap.get(audioFile);
    }

    const handleKeyDown = (event: KeyboardEvent) => {
      const pressedKey = event.key.toLowerCase();
      const pressedLocation =
        event.location === 1 ? "L" : event.location === 2 ? "R" : "";

      if (pressedKey === "capslock") {
        // CapsLock이 활성화된 상태가 아니라면 return
        if (!event.getModifierState("CapsLock")) return;

        const audioFile = findAudioFile(pressedKey);
        if (audioFile) {
          playHandler(audioFile); // CapsLock일 때도 오디오 재생
        }

        const buttonElement = document.querySelector(
          `button[data-code="capslock"]`
        );
        if (buttonElement) {
          buttonElement.classList.add("test");

          setTimeout(() => {
            buttonElement.classList.remove("test");
          }, 150);
        }
        return; // CapsLock의 경우 여기서 종료
      }

      const audioFile = findAudioFile(pressedKey);
      if (audioFile) {
        event.preventDefault();
        playHandler(audioFile);

        const locationSelector = pressedLocation
          ? `[data-location="${pressedLocation}"]`
          : "";
        const selectedKey = pressedKey === "\\" ? "\\\\" : pressedKey;

        const buttonElement = document.querySelector(
          `button[data-code="${selectedKey}"]${locationSelector}`
        );

        if (buttonElement) {
          buttonElement.classList.add("test");
          console.log(buttonElement, "추가");
        }
      }
    };

    const handleKeyUp = (event: KeyboardEvent) => {
      const pressedKey = event.key.toLowerCase();
      const pressedLocation =
        event.location === 1 ? "L" : event.location === 2 ? "R" : "";

      const locationSelector = pressedLocation
        ? `[data-location="${pressedLocation}"]`
        : "";

      const selectedKey = pressedKey === "\\" ? "\\\\" : pressedKey;

      const buttonElement = document.querySelector(
        `button[data-code="${selectedKey}"]${locationSelector}`
      );

      if (buttonElement) {
        buttonElement.classList.remove("test");
        console.log(buttonElement, "제거");
      }
    };

    document.addEventListener("keydown", handleKeyDown);
    document.addEventListener("keyup", handleKeyUp);

    return () => {
      document.removeEventListener("keydown", handleKeyDown);
      document.removeEventListener("keyup", handleKeyUp);
    };
  }, [typingOn]);

  const handleKnobChange = (e: { value: number }) => {
    setKnobValue(e.value);
    onKnobChange(e.value);
  };

  return (
    <div className="key-container">
      <div className="quad-key-row">
        {escKey[0].map((esc) => (
          <button
            key={esc.code}
            onMouseEnter={
              mouseEnterOn ? () => playHandler(esc.audio) : undefined
            }
            onClick={onClickOn ? () => playHandler(esc.audio) : undefined}
            className={`esc ${mouseEnterOn ? "hoverEffect" : ""}`}
            data-code={esc.code.toLowerCase()}
          >
            <span className="key-span">{esc.label}</span>
          </button>
        ))}

        <div className="quad-container">
          {functionKeys.map((row, rowIndex) => (
            <div className="quad-row" key={rowIndex}>
              {row.map((label, labelIndex) => (
                <button
                  className={`quad-keys ${mouseEnterOn ? "hoverEffect" : ""}`}
                  key={labelIndex}
                  onMouseEnter={
                    mouseEnterOn ? () => playHandler("A") : undefined
                  }
                  onClick={onClickOn ? () => playHandler("A") : undefined}
                  data-code={label.code.toLowerCase()}
                >
                  <span className="key-span">{label.label}</span>
                </button>
              ))}
            </div>
          ))}
        </div>

        <button onClick={onLightToggle} className="wheel">
          <span>LED</span>
        </button>

        {onLightOn && (
          <Knob
            value={knobValue}
            width={2}
            min={0}
            max={70}
            showValue={false}
            onChange={handleKnobChange}
            strokeWidth={8}
            className={`knob ${onLightOn ? "knob-show" : "knob-hide"}`}
          />
        )}
      </div>

      <div className="key-main-container">
        {mainKeys.map((row, rowIndex) => (
          <div className="key-main-row" key={rowIndex}>
            {row.map((label, labelIndex) => (
              <button
                className={`main-keys ${label.extraClass || ""} ${
                  mouseEnterOn ? "hoverEffect" : ""
                }`}
                key={labelIndex}
                onMouseEnter={
                  mouseEnterOn ? () => playHandler(label.audio) : undefined
                }
                onClick={onClickOn ? () => playHandler(label.audio) : undefined}
                data-code={label.code.toLowerCase()}
                data-location={label.location || ""}
              >
                <span className="key-span">{label.label}</span>
              </button>
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}

 

 

 

지금까지 데이터 상의 label과 실제 키보드 입력값이 다른 경우를 새로운 specialKeyMap 객체에 저장하고, reduce( ) 를 통해 합쳐 처리했다.

 

하지만 코드 가독성이 떨어지고, 실제 label이 매겨진 버튼을 추적하는 것을 더욱 편하게 하기 위해 데이터를 조금 다듬었다.

 

interface Key {
  code: string;
  label: string;
  audio: AudioType;
  extraClass?: string;
}

const mainKeys: Key[][] = [
  [
    { code: "`", label: "~", audio: "A" },
    { code: "1", label: "1", audio: "A" },
    { code: "2", label: "2", audio: "A" },
    
    ...
  ]
]

 

code 라는 새로운 키를 생성하고, 여기에 실제 입력되는 값을 부여했다.

 

이제 할 일은

  1. 눌린 키의 정보를 받고,
  2. 이 정보를 데이터의 code와 같은지 비교하고,
  3. 이 code에 부여된 오디오를 연결하여
  4. 해당되는 <button>에 eventHandler를 추가한다.

 

 

이전에 작성해둔 오디오 재생 함수는 아래와 같다.

  // 오디오 객체 배열 생성 함수
  const createAudioArray = (audioSource: string, count: number) =>
    Array.from({ length: count }, () => new Audio(audioSource));

  // 오디오 맵 초기화
  const audioMap = useRef<{ [audioFileName in AudioType]: HTMLAudioElement[] }>(
    {
      A: createAudioArray(AAudio, 10),
      B: createAudioArray(BAudio, 10),
      C: createAudioArray(CAudio, 10),
      D: createAudioArray(DAudio, 10),
      E: createAudioArray(EAudio, 10),
    }
  );

  // 컴포넌트 마운트 시 오디오 파일 로드
  useEffect(() => {
    Object.values(audioMap.current).forEach((audioArray) =>
      audioArray.forEach((audio) => audio.load())
    );
  }, []);

  // 현재 재생할 복제본의 인덱스 저장
  const audioIndex = useRef<{ [audioFileName in AudioType]: number }>({
    A: 0,
    B: 0,
    C: 0,
    D: 0,
    E: 0,
  });

  // 오디오 재생
  const playHandler = (audio: AudioType) => {
    const audioArray = audioMap.current[audio];
    const index = audioIndex.current[audio];

    // 현재 인덱스의 오디오 객체 재생
    const audioElement = audioArray[index];
    audioElement.currentTime = 0;
    audioElement.play();

    // 인덱스 순환
    audioIndex.current[audio] = (index + 1) % audioArray.length;
  };

 

 

 

이제 새로운 useEffect문을 통해 작업해보자.

먼저 code와 audioFile을 나란히 찾을 수 있도록 mainKeys 데이터를 새로운 객체로 변환한다.

useEffect(() => {
    // code, audio가 매핑된 객체 생성
    const keyMap = new Map<string, AudioType>(
      mainKeys.flat().map((key) => [key.code, key.audio])
    );

	// code와 일치하는 오디오 파일 찾기
    function findAudioFile(audioFile: string) {
      return keyMap.get(audioFile);
    }
})

 

이제 keyDown과 keyUp에 대한 핸들러 함수를 작성한다.

	//눌렀을 때
    const handleKeyDown = (event: KeyboardEvent) => {
      const pressedKey = event.key.toLowerCase();
      const audioFile = findAudioFile(pressedKey);

      if (audioFile) { // 매칭되는 오디오 파일이 있다면
        event.preventDefault();	// 원래 키의 기능을 막고
        playHandler(audioFile); // 오디오를 재생한다

        // 해당하는 버튼 요소에 test 클래스 추가
        const buttonElement = document.querySelector(
          `button[data-code="${pressedKey}"]`
        );

        if (buttonElement) {
          buttonElement.classList.add("test");
          console.log(buttonElement, "추가");
        }
      }
    };

    //뗄 때
    const handleKeyUp = (event: KeyboardEvent) => {
      const pressedKey = event.key.toLowerCase();
      
      //추가했던 클래스 제거
      const buttonElement = document.querySelector(
        `button[data-code="${pressedKey}"]`
      );
      
      if (buttonElement) {
        buttonElement.classList.remove("test");
        console.log(buttonElement, "제거");
      }
    };

 


Data Attribute 활용

위에서 클래스를 동적으로 추가하면서 나온 data- 문법은 HTML에서 데이터 속성 (data attribute)을 사용하는 방식이다.

HTML요소에 추가적인 정보를 저장할 때 사용할 수 있다.

쉽게 말해 연결될 지점에 링크를 거는 것이다.

button 태그에 붙어있는 data-code의 값이 눌린 키와 같은지 확인하는 것이다.

활용법은 아래와 같다.

data- 로 작성하는 것이 일반적이고, 뒤의 키워드는 자유롭게 지정할 수 있다.

    const buttonElement = document.querySelector(
        `button[data-code="${pressedKey}"]`	// 이 값과
    );

	//후략

return (
    <button
        data-code={label.code.toLowerCase()} // 이 값이 같다면 querySelector에 선택되는 방식
    >
        <span>내용</span>
    </button>
)}

 

 

 

이제 핸들러 함수가 트리거 되는 경우를 작성한다.

    document.addEventListener("keydown", handleKeyDown); //키를 누른 경우 handleKeyDown 트리거
    document.addEventListener("keyup", handleKeyUp); //키를 뗀 경우 handleKeyUp 트리거

 

마지막으로 클린업 함수를 추가한다.

    return () => { // 클린업 함수
      document.removeEventListener("keydown", handleKeyDown);
      document.removeEventListener("keyup", handleKeyUp);
    };

 

아래는 전체코드이다.

  useEffect(() => {
    // code, audio가 매핑된 객체
    const keyMap = new Map<string, AudioType>(
      mainKeys.flat().map((key) => [key.code, key.audio])
    );

	// 오디오 파일 찾는 함수
    function findAudioFile(audioFile: string) {
      return keyMap.get(audioFile);
    }

    //눌렀을 때
    const handleKeyDown = (event: KeyboardEvent) => {
      const pressedKey = event.key.toLowerCase(); // 누른 키 받기
      if (pressedKey === "capslock" && !event.getModifierState("CapsLock")) return;
      const audioFile = findAudioFile(pressedKey);

      if (audioFile) {
        event.preventDefault();
        playHandler(audioFile);

        // data-로 연결된 버튼 요소에 test 클래스 추가
        const buttonElement = document.querySelector(
          `button[data-code="${pressedKey}"]`
        );
		
        // 존재한다면 클래스 추가
        if (buttonElement) {
          buttonElement.classList.add("test");
          console.log(buttonElement, "추가"); //디버깅용 콘솔
        }
      }
    };

    //뗄 때
    const handleKeyUp = (event: KeyboardEvent) => {
      const pressedKey = event.key.toLowerCase(); // 누른 키 받기
      //클래스 제거
      const buttonElement = document.querySelector(
        `button[data-code="${pressedKey}"]`
      );
      if (buttonElement) {
        buttonElement.classList.remove("test");
        console.log(buttonElement, "제거"); //디버깅용 콘솔
      }
    };

    document.addEventListener("keydown", handleKeyDown);
    document.addEventListener("keyup", handleKeyUp);

    return () => { // 클린업 함수
      document.removeEventListener("keydown", handleKeyDown);
      document.removeEventListener("keyup", handleKeyUp);
    };
  }, []);

 

 

나의 경우, 위 코드에 상위 컴포넌트에서 타이핑 모드를 설정한 경우(typingOn = true)에만 키보드 이벤트가 활성화 되도록 했다.

useEffect문 시작부분에 예외처리와 typingOn 상태 변화가 있을 때마다 작동하도록 의존성 배열에 추가했다.

  useEffect(() => {
    if (!typingOn) return; // 타이핑모드가 아닌 경우 실행하지 않음

	...
    
   }, [typingOn]); // 타이핑모드 변경사항이 있을 때마다 동작

 

 

이제 클래스가 각 키에 동적으로 추가되는 것을 확인할 수 있다.

 

 

 

 

 

 

오디오 객체를 10개 미리 생성하고 불러오는 부분이 헷갈려서 추가로 남겨보는 노트.

 

 type AudioType = "A" | "B" | "C" | "D" | "E";
 
// 오디오 객체 배열 생성 함수
  const createAudioArray = (audioSource: string, count: number) => // 파일 경로, 생성할 객체 수
    Array.from({ length: count }, () => new Audio(audioSource)); // count만큼의 audioSource경로를 따르는 Audio객체로 이루어진 배열 생성
                //유사배열 객체

  // 각 오디오 타입별로 미리 10개의 오디오 객체 생성
  const audioMap = useRef<{ [audioFileName in AudioType]: HTMLAudioElement[] }>({
    A: createAudioArray(AAudio, 10),
    B: createAudioArray(BAudio, 10),
    C: createAudioArray(CAudio, 10),
    D: createAudioArray(DAudio, 10),
    E: createAudioArray(EAudio, 10),
  });
  

  // 현재 재생할 복제본의 인덱스를 저장할 객체
  const audioIndex = useRef<{ [audioFileName in AudioType]: number }>({
    A: 0,
    B: 0,
    C: 0,
    D: 0,
    E: 0,
  });


  // 컴포넌트 마운트 시 미리 오디오 파일 로드
  useEffect(() => {
    Object.values(audioMap.current).forEach((audioArray) =>
      audioArray.forEach((audio) => audio.load())
    );
  }, []);

 

참고로, 원래 { [ key in AudioType ] : HTMLAudioElement[] } 라고 썼는데, 변수명을 알기 쉽도록 audioFileName으로 바꿨다.

여기서 audioFileName은 "A" | "B" | "C" | "D" | "E";

 

 

 

위를 통해 audioMap은 아래와 같은 형태의 객체를 반환한다.

{
  A: [Audio, Audio, Audio, ..., Audio], // 길이 10의 배열
  B: [Audio, Audio, Audio, ..., Audio], // 길이 10의 배열
  C: [Audio, Audio, Audio, ..., Audio], // 길이 10의 배열
  D: [Audio, Audio, Audio, ..., Audio], // 길이 10의 배열
  E: [Audio, Audio, Audio, ..., Audio]  // 길이 10의 배열
}

 

 

 

 

 

이제 각 키를 실제 키보드로 입력했을 때 지정한 오디오가 출력되도록 만들어 보자.

 

 

 

초기 아이디어

type AudioType = "A" | "B" | "C" | "D" | "E";

useEffect(() => {
  const handleKeyDown = (event: KeyboardEvent) => {
    const keyMap: { [key: string]: AudioType } = {	//문자열인 key: 값은 AudioType 중 하나
      a: "A",	// 우선 5개 키만 대충 지정한다
      b: "B",
      c: "C",
      d: "D",
      e: "E"
    };

    const audioType = keyMap[event.key];
    if (audioType) {	// keyMap에 누른 키가 존재하는지 확인
      playHandler(audioType);	// 있다면 해당 오디오 재생
    }
  };

  // window 객체에 이벤트 리스너 추가.
  // 각 button 요소를 추적하는 것이 아니라, 눌린 키가 keyMap객체에 있는지 확인하는 방식.
  window.addEventListener("keydown", handleKeyDown);

  return () => { // 클린업
    window.removeEventListener("keydown", handleKeyDown);
  };
}, []);

 

모든 키의 원래 이름(입력 시 브라우저가 해당 키라는 것을 인식하는 원본 네이밍)은 lowerCase로 작성되어야 한다.

따라서 event.key에 .toLowerCase() 를 추가해주고, 각 키를 다시 매핑해준다.

 

 

수정할 점 : mainKeys 배열 불러오기, 소문자로 변환하기

type AudioType = "A" | "B" | "C" | "D" | "E";

useEffect(() => {
  // mainKeys 배열에서 키보드 레이블과 오디오 타입을 짝지어주는 객체 생성하기
  const keyMap = mainKeys.flat().reduce((accumulator, key) => {
    accumulator[key.label.toLowerCase()] = key.audio;
    return accumulator;
  }, {} as { [key: string]: AudioType });

  const handleKeyDown = (event: KeyboardEvent) => {	// 키보드 이벤트 핸들러 생성
    const audioType = keyMap[event.key.toLowerCase()]; // 눌린 키의 레이블과 매칭되는 오디오 타입 찾기
    if (audioType) { // 방금 누른 키가 매칭되는 오디오의 키와 같다면
      playHandler(audioType); // 오디오 재생
    }
  };

  window.addEventListener("keydown", handleKeyDown); //handleKeyDown이라는 함수를 키보드 입력 시 실행

  return () => { // 클린업 함수
    window.removeEventListener("keydown", handleKeyDown);
  };
}, []);

 

 

여기서 문제가 또 발생한다.

데이터 배열의 label에 실제 이름과 다른 키에 대해서는 매핑이 되지 않아 소리가 재생되지 않는다.

또한 caps lock 이나 ctrl 등 실제 브라우저에서 역할이 있는(?) 키들은 오디오를 재생하지 않고 자신의 기능을 우선 수행한다.

이 부분을 event.preventDefault()로 방지하는 기능까지 추가해보자.

 

수정할 점 : 이름 다른 키 수정하기, 각 키의 원기능 방지하기

useEffect(() => {

    const specialKeyMap: { [key: string]: AudioType } = {
      // 데이터의 label과 실제 값이 다른 키를 새로운 객체에 정리
      " ": "E", // 스페이스바
      escape: "B", // esc
      backspace: "A",
      enter: "B",
      tab: "D",
      capslock: "D",
      arrowup: "A",
      arrowdown: "A",
      arrowleft: "A",
      arrowright: "A",
      control: "C", // ctrl
      meta: "C", // Windows 키 (macOS에서는 Command 키)
      alt: "C",
      fn: "C", 
      "`": "A", // 백틱
      "=": "A",
    };

    // 실제 키 이름과 맞추기 ---> mainKeys에 specialKeyMap를 더하기
    const keyMap = mainKeys.flat().reduce((accumulator, currentValue) => {
      const keyLabel = currentValue.label.toLowerCase(); // 각 키의 실제 이름
      accumulator[keyLabel] = currentValue.audio;
      return accumulator;
    }, specialKeyMap); // 초기값으로 지정했기 때문에 accumulator는 specialKeyMap를 갖고 출발

    const handleKeyDown = (event: KeyboardEvent) => {
      const keyName = event.key.toLowerCase();  // 현재 눌린 키
      const audioType = keyMap[keyName];

      if (audioType) {
        event.preventDefault(); // 원래 이 키의 기능 방지
        playHandler(audioType);
      }
    };

    document.addEventListener("keydown", handleKeyDown);

    return () => {
      document.removeEventListener("keydown", handleKeyDown);
    };
  }, []);

 

이번에야말로 모든 키에 대해 정상적으로 오디오가 출력되는 것을 확인할 수 있다.

 

 

 

전체 코드

import { useRef, useEffect, useState } from "react";
import "../styles/styles.css";
import mainKeys from "../assets/data/keyArray";
import { functionKeys } from "../assets/data/keyArray";
import { AudioType } from "../assets/data/keyArray";
import AAudio from "../assets/audio/A.mp3";
import BAudio from "../assets/audio/B.mp3";
import CAudio from "../assets/audio/C.mp3";
import DAudio from "../assets/audio/D.mp3";
import EAudio from "../assets/audio/E.mp3";

interface KeysProps {
  onLightToggle: () => void;
  typingOn: boolean;
  onClickOn: boolean;
  mouseEnterOn: boolean;
  onKnobChange: (value: number) => void;
  onLightOn: boolean;
}

export default function Keys({ onLightToggle, typingOn, onClickOn, mouseEnterOn, onKnobChange, onLightOn }: KeysProps) {
  const [knobValue, setKnobValue] = useState<number>(0);

  // 오디오 객체 배열 생성 함수
  const createAudioArray = (audioSrc: string, count: number) =>
    Array.from({ length: count }, () => new Audio(audioSrc));

  // 각 오디오 타입별로 미리 10개의 오디오 객체 생성
  const audioMap = useRef<{ [key in AudioType]: HTMLAudioElement[] }>({
    A: createAudioArray(AAudio, 10),
    B: createAudioArray(BAudio, 10),
    C: createAudioArray(CAudio, 10),
    D: createAudioArray(DAudio, 10),
    E: createAudioArray(EAudio, 10),
  });

  // 현재 재생할 복제본의 인덱스를 저장할 객체
  const audioIndex = useRef<{ [key in AudioType]: number }>({
    A: 0,
    B: 0,
    C: 0,
    D: 0,
    E: 0,
  });

  // 미리 오디오 파일 로드
  useEffect(() => {
    Object.values(audioMap.current).forEach((audioArray) =>
      audioArray.forEach((audio) => audio.load())
    );
  }, []);

  // 오디오 재생
  const playHandler = (audio: AudioType) => {
    const audioArray = audioMap.current[audio];
    const index = audioIndex.current[audio];

    // 현재 인덱스에 해당하는 오디오 객체 재생
    const audioElement = audioArray[index];
    audioElement.currentTime = 0; // 초기화
    audioElement.play();

    // 인덱스 순환하며 다음 오디오 재생
    audioIndex.current[audio] = (index + 1) % audioArray.length;
  };

  useEffect(() => {
    if (!typingOn) return; // 타이핑 모드에서만 실행

    const specialKeyMap: { [key: string]: AudioType } = {
      //데이터의 label과 실제 값이 다른 키
      " ": "E",
      escape: "B",
      backspace: "A",
      enter: "B",
	  ...
    };

    //실제 키 이름과 맞추기 ---> mainKeys에 specialKeyMap를 더하기
    const keyMap = mainKeys.flat().reduce((accumulator, currentValue) => {
      const keyLabel = currentValue.label.toLowerCase(); //소문자만 인식됨. 각 키의 실제 이름
      accumulator[keyLabel] = currentValue.audio;
      return accumulator;
    }, specialKeyMap); // 초기값으로 지정했기 때문에 accumulator는 specialKeyMap를 갖고 출발

    const handleKeyDown = (event: KeyboardEvent) => {
      const keyName = event.key.toLowerCase();  //현재 눌린 키
      const audioType = keyMap[keyName];

      if (audioType) {
        event.preventDefault(); //원래 이 키의 기능 방지
        playHandler(audioType);
      }
    };

    document.addEventListener("keydown", handleKeyDown);

    return () => {
      document.removeEventListener("keydown", handleKeyDown);
    };
  }, [typingOn]); //상위 컴포넌트에서 타이핑모드로 설정한 경우

  const handleKnobChange = (e: { value: number }) => {
    setKnobValue(e.value);
    onKnobChange(e.value);
  };

  return (
    <div className="key-container">
      <div className="quad-key-row">
        <button
          className={`esc ${!typingOn ? "hoverEffect" : ""}`}
        > // 타이핑 모드에서는 hover 스타일 제거
          <span className="key-span">ESC</span>
        </button>

        <div className="quad-container ">
          {functionKeys.map((row, rowIndex) => (
            <div className="quad-row" key={rowIndex}>
              {row.map((key) => (
                <button
                  className={`quad-keys ${!typingOn ? "hoverEffect" : ""}`}
                  key={key}
                >
                  <span className="key-span">{key}</span>
                </button>
              ))}
            </div>
          ))}
        </div>

        <button className="wheel">
          <span className="key-span">LED</span>
        </button>
      </div>

      <div className="key-main-container">
        {mainKeys.map((row, rowIndex) => (
          <div className="key-main-row" key={rowIndex}>
            {row.map((label, labelIndex) => (
                className={`main-keys ${label.extraClass || ""} ${ !typingOn ? "hoverEffect" : ""}`}
                key={labelIndex}								
              >
                <span className="key-span">{label.label}</span>
              </button>
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}

 

 

 

 

 

마음 같아서는 모든 키의 소리를 각각 녹음하고 싶었으나, 제작의 편의를 위해 크게 5가지로 나누어 소리를 녹음했다.

 

A: 정방형 비율에 가까운 일반 키

B: 엔터, Backspace, Shift 등 긴 키

C: Ctrl, Window 등 짧은 키

D: Tab, Caps Lock 등 중간 키

E: 스페이스바

 

 

 

각 녹음 파일의 잡음을 제거하고, 시작 및 끝 지점 (Duration)을 맞추어 추출한다. (재생 시 일정한 시간을 보장하기 위해)

 

 

 

지난 포스트에서 제작한 mainKeys 배열에 audio 항목을 추가하고, 각 키에 해당하는 오디오를 표시한다.

ESC와 FunctionKeys의 경우 소리가 모두 같다고 가정하여 배열에 작성하지 않고, 추후 함수에 "A" 로 통일했다.

export type AudioType = "A" | "B" | "C" | "D" | "E"; //	반드시 이 중 하나여야함을 명시

interface Key {
  label: string;
  audio: AudioType;
  extraClass?: string;
}

const mainKeys: Key[][] = [
...
  [
    { label: "CTRL", audio: "C", extraClass: "w-[8%]" },
    { label: "WIN", audio: "C", extraClass: "w-[7%]" },
    { label: "ALT", audio: "C", extraClass: "w-[7%]" },
    { label: "SPACE", audio: "E", extraClass: "w-[38.6%]" },
    { label: "FN", audio: "C", extraClass: "w-[7.2%]" },
    { label: "CTRL", audio: "C", extraClass: "w-[7.5%] mr-[3%]" },
    { label: "←", audio: "A", extraClass: "w-[5.6%]" },
    { label: "↓", audio: "A", extraClass: "w-[5.7%]" },
    { label: "→", audio: "A", extraClass: "w-[5.7%]" },
  ],
]

 

 

 

이제 오디오 Keys 컴포넌트에 오디오 파일을 불러오고, 오디오를 호출해보자.

import AAudio from "../assets/audio/A.mp3";
import BAudio from "../assets/audio/B.mp3";
import CAudio from "../assets/audio/C.mp3";
import DAudio from "../assets/audio/D.mp3";
import EAudio from "../assets/audio/E.mp3";


type AudioType = "A" | "B" | "C" | "D" | "E";

...

// 실제 오디오 파일과 AudioType을 연결
  const audioMap: { [key in AudioType]: string } = { // AudioType에 있는 요소 중 하나임을 명시
    A: AAudio,
    B: BAudio,
    C: CAudio,
    D: DAudio,
    E: EAudio,
  };

  const playHandler = (audio: AudioType) => {
    const audioFile = audioMap[audio];	// 지정한 오디오 파일을 인자에 맞게 가져옴(A~E 중 하나)
    const audioElement = new Audio(audioFile); //audioFile 경로를 사용하여 새로운 Audio 객체 생성
    audioElement.play(); // 함수 실행
  };

 

 

Audio 객체란?

  • JavaScript에서 제공하는 웹 오디오 API의 일부이다,
  • new Audio(audioFile) 처럼 파일 경로, 또는 URL을 인자로 받아 새로운 Audio 객체를 생성한다.
  • .play(), .pause(), volume, currentTime 등 메소드를 제공한다.

 

 

이제 이 핸들러 함수를 각 키에 연결하면 된다.

타이핑 버전, 클릭 버전, 호버 버전을 각각 만들 예정이지만, 우선 호버 (mouseEnter)으로 테스트해본다.

<div className="key-main-container">
        {mainKeys.map((row, rowIndex) => (
          <div className="key-main-row" key={rowIndex}>
            {row.map((label, labelIndex) => (
              <button
                onMouseEnter={playHandler(label.audio)} // 핸들러 함수 연결
                className={`main-keys ${label.extraClass || ""}`}
                key={labelIndex}
              >
                <span>{label.label}</span>
              </button>
            ))}
          </div>
        ))}
      </div>

 

 

 

이 상태에서 키보드에 마우스를 올려보니, 다음과 같은 에러가 콘솔창에 나타났다.

 

 

Uncaught (in promise) DOMException: The play method is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.
자동 재생은 사용자의 승인이 있어야 합니다. 사이트가 사용자에 의해서 활성화되지 않았으면 미디어의 소리가 나지 않습니다.

 

 

 

위 내용은 쉽게 말해서 유저의 승인 없이는 브라우저에서 소리를 자동 재생할 수 없다는 뜻이다.

브라우저에서 예상하지 못한 소리가 갑작스럽게 재생된다면 사용자의 경험을 저해할 수 있다.

따라서 사용자가 이를 인지하고, 어떤 액션을 취해야만 소리가 재생될 수 있다.

하다 못해 아무데나 클릭이라도 해야 재생된다.

 

 

우선 테스트이니, 화면을 한번 클릭하고 키 위에 마우스를 올려보면 오디오가 잘 재생되는 것을 확인할 수 있다.

 

 

 

 


 

 

 

 

오디오 파일의 앞 부분을 0.5초 미만으로 축소했음에도 불구하고, 매번 소리가 조금 늦게 재생되는 것을 느꼈다.

 

파헤쳐 보니, 핸들러 함수가 실행될 때마다 오디오 파일을 다시 로드하고, 새로운 Audio 객체를 생성하고 있었다.

 

파일 자체가 매우 작지만, 키보드 특성 상 빠르고 연속적으로 유저 상호작용이 일어날 때를 고려하면-

미리 오디오 파일을 로드해서 캐시하는 장점이 사라지고, 앞선 파일과 연속 재생이 어려워지며, 리소스 낭비로 이어진다.

 

 

이 객체를 최초 한 번만 생성하고 컴포넌트를 리렌더링 하더라도 유지하는 방법이 없을까?

이런 문제는 useRef를 통해 해결할 수 있다.

 

 

아래 두 코드를 비교해보자.

//기존 코드
  const audioMap: { [key in AudioType]: string } = {
    A: AAudio,
    B: BAudio,
    C: CAudio,
    D: DAudio,
    E: EAudio,
  };

  const playHandler = (audio: AudioType) => {
    const audioFile = audioMap[audio];
    const audioElement = new Audio(audioFile); 
    audioElement.play();
  };
//수정된 코드
  const audioMap = useRef<{ [key in AudioType]: HTMLAudioElement }>({ // 엄격한 타입 정의. 생성된 모든 Audio객체는 HTMLAudioElement 타입을 갖는다.
    A: new Audio(AAudio), // 최초 객체 생성하여 useRef에 저장
    B: new Audio(BAudio),
    C: new Audio(CAudio),
    D: new Audio(DAudio),
    E: new Audio(EAudio),
  });

  // 현재 오디오 객체 확인
  useEffect(() => {
  	console.log(audioMap.current)
  }, []);

  const playHandler = (audio: AudioType) => {
    const audioElement = audioMap.current[audio];
    audioElement.currentTime = 0; // 연속 재생을 위해 시간 초기화
    audioElement.play();
    console.log("재생", audio);
  };

 

 

이렇게 구성하면 객체는 최초 한 번만 생성되기 때문에 매번 Audio 객체가 새로 생성되는 것을 막을 수 있다.

 

 

다만, 여러 키를 계속해서 호버했더니 오디오가 심하게 끊긴다.

게다가 오히려 매번 객체를 생성할 때는 빠르게 입력하는 경우, 앞선 오디오가 끝나기도 전에 다음 오디오가 정상적으로 실행됐는데,

수정한 코드에서는 직전에 실행한 오디오가 모두 실행되기 전에는 다음 오디오가 재생되지 않았다.

 

 

키보드 특성 상 빠른 타이핑과 샷건 등 여러 키가 거의 동시에 눌리는 상황에도 부드러운 소리 재생을 원했다.

생성된 오디오 객체가 각각 하나 뿐이기 때문에 부자연스러운 재생이 이루어진 것이다.

 

 

 

그렇다면 오디오 객체를 여러개 생성하여 배열에 저장하고, 미처 다 재생되기 전에 또 재생하는 상황에서 그 다음 인덱스를 재생하는 방법은 어떨까?

 

 

 

 

 

우선 최초 Audio 객체를 여러개 중복으로 생성한다. 넉넉히 5개씩 생성해보았다.

  const audioMap = useRef<{ [key in AudioType]: HTMLAudioElement[] }>({ //배열 형태임을 추가 명시
    A: [new Audio(AAudio), new Audio(AAudio), new Audio(AAudio), new Audio(AAudio), new Audio(AAudio)],
    B: [new Audio(BAudio), new Audio(BAudio), new Audio(BAudio), new Audio(BAudio), new Audio(BAudio)],
    C: [new Audio(CAudio), new Audio(CAudio), new Audio(CAudio), new Audio(CAudio), new Audio(CAudio)],
    D: [new Audio(DAudio), new Audio(DAudio), new Audio(DAudio), new Audio(DAudio), new Audio(DAudio)],
    E: [new Audio(EAudio), new Audio(EAudio), new Audio(EAudio), new Audio(EAudio), new Audio(EAudio)],
  });

 

그리고 현재 재생할 오디오에 대한 인덱스를 저장할 객체를 만들어준다.

  const audioIndex = useRef<{ [key in AudioType]: number }>({
    A: 0,
    B: 0,
    C: 0,
    D: 0,
    E: 0,
  });

 

마지막으로 이 인덱스를 순환하며 playHandler를 재생한다.

  const playHandler = (audio: AudioType) => {
    const audioArray = audioMap.current[audio];	//useRef로 생성한 객체의 오디오 가져오기
    const index = audioIndex.current[audio]; //해당 인덱스
    
    // 현재 인덱스와 일치하는 오디오 객체를 재생
    const audioElement = audioArray[index];
    audioElement.currentTime = 0; // 초기화 해주지 않으면 그 다음 재생되는 오디오가 같은 시점에 resume된다
    audioElement.play();

    // 다음 인덱스로 이동. 마지막이라면 0으로 돌아가며 순환
    audioIndex.current[audio] = (index + 1) % audioArray.length;
  };

 

이제 그 전보다 딜레이가 많이 줄어든 것을 확인할 수 있다.

물론, 아주 마우스 커서를 아주 빠르게 움직이니 어느 정도 렉이 걸리는데... 어떻게 해야 더더욱 부드럽게 만들 수 있을지 고민해보아야겠다.

 

 

 

 

 


 

 

 

 

 

 

추가로, 위 객체 복제본을 생성하는 코드를 조금 더 효율적으로 작성할 수도 있다.

  const createAudioArray = (audioSrc: string, count: number) => 
    Array.from({ length: count }, () => new Audio(audioSrc));
  
  const audioMap = useRef<{ [key in AudioType]: HTMLAudioElement[] }>({
    A: createAudioArray(AAudio, 10),
    B: createAudioArray(BAudio, 10),
    C: createAudioArray(CAudio, 10),
    D: createAudioArray(DAudio, 10),
    E: createAudioArray(EAudio, 10),
  });

 

 

 

 

 

 

 

최근 키보드를 새로 샀다.

요즘은 축이 다양해지고 커스텀 옵션의 폭도 넓어져 실제로 사용해보지 않는 한 키감은 정확히 알 수 없으나, 소리만큼은 유튜브 등에서 후기 포스트를 통해 들어본다.

다만 이런 식으로 키보드를 사는 나에게 가장 어려운 점은- 녹음 환경이 달라, 같은 제품이라도 영상마다 소리가 많이 다르다는 점이다.

 

소리가 너무나 마음에 들어 키보드 없이 노트북만 들고 외출한 경우에도 듣고 싶었다

그래서 이 키보드의 소리를 최대한 객관적으로 들을 수 있는 가상 키보드 프로젝트를 시작해보았다.

 

 

 

 


 

 

 

 

 

이번에는 Typescript 기반의 React에 Vite를 사용하고, 스타일링은 Tailwind를 사용한다.

 

Vite 설치 방법

npm create vite@latest
react-ts //원하는 프레임워크와 템플릿 작성

 

Vite 개발 서버 실행 방법

npm run dev
yarn dev

 

 

 

 

 


 

 

 

 

 

 

 

우선 피그마로 키보드의 레이아웃을 잡고, LED가 켜진 상태, 꺼진 상태 이미지를 제작했다.

브라우저 크기에 따라 이미지 화질이 저하되는 것을 방지하기 위해 벡터 기반으로 그려 SVG로 추출했다.

 

 

 

무지개색 LED 레이어는 추후 마스크로 사용하여 움직이는 배경 애니메이션을 제작할 예정이다.

우상단의 원형 버튼은 LED를 끄고 켜는 기능을 한다.

 

 

위 이미지를 브라우저 중앙에 위치시키고, LED 관련 레이어를 같은 좌표에 위치시켰다.

Keys 컴포넌트에 해당 기능을 먼저 만들고, 상위 컴포넌트에 props로 넘겨주어 on/off 여부에 따라 다른 레이어가 나타나도록 했다.

//Keys.tsx

<button onClick={onLightToggle} className="wheel">
  <span>LED</span>
</button>
//App.tsx

function App() {

  const [lightOn, setLightOn] = useState<boolean>(false);
  const lightHandler = () => {
    setLightOn(!lightOn);
  };

...

return (
...
 <div className="base">
    <Keys
    	onLightToggle={lightHandler}	//넘겨준 onLightToggle prop에 적절한 함수 연결
    />
    <img src={keyboard} alt="keyboard" className="w-full h-auto" />
    	{lightOn ? (
    <img src={keyboardOn} alt="keyboard" className="on" />
    ) : (
    <img src={keyboardOff} alt="keyboard" className="off" />
    )}
 </div>
 
 )
 }

 

 

나머지 버튼을 레이아웃 위치에 맞게 정렬했다.

이해를 돕기 위해 버튼마다 임시로 주황색을 입혔다. 분홍색 사각형은 각각을 묶은 단위.

 

 

 

정렬 방법은- 스타일이 많이 다른 ESC 키와 LED 키는 별개로 다루고, Fuction 키 열과 나머지 Main열을 각각 배열로 만들었다.

모든 키가 같은 규격을 갖지 않고, 각 열마다 조금씩 차이가 있었다.

따라서 다른 길이나 간격이 필요한 경우, extraClass 항목을 추가하여 스타일링했다.

//keyArray.ts

interface Key {
  label: string;
  extraClass?: string;
}

export const functionKeys = [
  ["F1", "F2", "F3", "F4"],
  ["F5", "F6", "F7", "F8"],
  ["F9", "F10", "F11", "F12"],
];

export const mainKeys: Key[][] = [	//2차원 배열
  // 첫번째 줄
  [
    { label: "~" },
    { label: "1" },
    { label: "2" },
    { label: "3" },
    ...
  ],
  // 두번째 줄
  [
    { label: "TAB", extraClass: "w-[9.4%]" },
    { label: "Q" },
    ...
  ],
  
  ...
  
  ];

 

 

 

 

이제 이 키를 map메소드를 통해 화면에 뿌려준다.

return (
    <div className="key-container">
      <div className="quad-key-row">
        <button className="esc">
          <span>ESC</span>
        </button>

        <div className="quad-container">
          {functionKeys.map((row, rowIndex) => ( // functionKeys 배열을 가져와 반복
            <div className="quad-row" key={rowIndex}>
              {row.map((key) => (
                <button
                  className="quad-keys"
                  key={key}
         		>
                  <span>{key}</span>
                </button>
              ))}
            </div>
          ))}
        </div>

        <button onClick={onLightToggle} className="wheel">
          <span>LED</span>
        </button>
      </div>

      <div className="key-main-container">
        {mainKeys.map((row, rowIndex) => (	// mainKeys 배열을 가져와 열마다 반복
          <div className="key-main-row" key={rowIndex}>	// 각 열을 지정했다면
            {row.map((label, labelIndex) => (	// 그 내부 키를 반복하여 지정
              <button
                className={`main-keys ${label.extraClass || ""}`} // 항목에 extraClass가 있다면 추가
                key={labelIndex}
              >
                <span>{label.label}</span>
              </button>
            ))}
          </div>
        ))}
      </div>
    </div>
  );

 

 

 

이전에 구현했던 좋아요 기능에서 한가지 문제가 있었다.

firebase db에서 사용자가 좋아요 표시한 목록을 가져와 버튼 눌림 여부가 잘 표시되다가, 해당 페이지에서 새로고침을 하면 버튼 상태가 모두 초기화됐다. (db에는 정보가 잘 남아있는데도 불구하고)

 

가장 먼저 일차원적인 해결책을 생각해보았으나, 다음과 같은 문제가 있었다.

  •  로컬에 상태값을 저장해두기?
    • 로그아웃 한 경우, 이 값이 남아있을 수 있다. 
  • 해당 페이지 새로고침 방지?
    • 당장으로써는 좋을 수 있으나, 서비스 사용성을 생각한다면 이 기능은 회원가입 또는 정보 입력 등 호흡이 긴 페이지에서 유저의 노력(?)이 사라지지 않도록 방지하기 위함 등 보수적으로 사용하는 편이 좋다.

 

 

기본적으로 클라이언트에서 관리하는 전역 상태 관리는 새로고침 했을 때 초기화된다.

다시 말해, 로그인 정보나 인증 상태 등을 클라이언트 애플리케이션 내부에서 관리한다는 뜻이다. 현재 사용하고 있는 React와 같은 프레임워크에서는 이러한 상태를 전역 상태로 저장하여 기억한다. 그러나 새로고침을 한다면 클라이언트가 새로 렌더링되기 때문에 메모리 안의 상태가 모두 사라진다.

 

이 문제를 해결하기 위해 세션 저장소 또는 쿠키 등 영구적인 저장소를 이용해 유지해야 한다.

  • 세션(Session): 브라우저 탭 또는 윈도우가 열려있는 동안에만 데이터를 유지
  • 쿠키(Cookie): 서버 또는 클라이언트 측에서 일정 기간을 설정하여 유지.

 

 

우선 기존 로그인 로직을 확인해보니, 세션 스토리지를 잘 형성해두었다.

useEffect(() => {
    if (currentlyLoggedIn) {
      navigate("/profile"); // 이미 로그인된 상태라면 프로필 페이지로
    }
  }, [currentlyLoggedIn, navigate]);

  const handleSignIn = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    try {
      await setPersistence(auth, browserSessionPersistence);  // 세션에 유저 정보 저장
      const userCredential = await signInWithEmailAndPassword(
        auth,
        signInEmail,
        signInPw
      );
      console.log(userCredential);
      const user = userCredential.user;

      if (rememberMe) {
        // 체크된 경우
        localStorage.setItem("username", signInEmail);
      } else {
        localStorage.removeItem("username");
      }

      setCurrentlyLoggedIn(true);
      setIsLoggedIn(true);
      navigate("/"); // 성공 시 홈으로 이동
    } catch (error) {

 

기존 로그아웃 버튼은 아래와 같다.

import React from "react";
import { auth } from "../firebase/firebaseConfig";
import { useNavigate } from "react-router-dom";
import { useContext } from "react";
import { AuthContext } from "../context/AuthContext";
import s from "../stores/styling";
import useModal from "../hooks/ModalHook";
import Modal from "../components/Modal";

interface LogoutButtonProps {
  className?: string;
}

const LogoutButton: React.FC<LogoutButtonProps> = ({
  className,
}) => {
  const { setCurrentlyLoggedIn } = useContext(AuthContext);
  const { isModalOpen, handleOpenModal, handleCloseModal } = useModal();
  const navigate = useNavigate();


  const handleLogout = () => {
    setCurrentlyLoggedIn(false); // 로그아웃 처리
    handleCloseModal(); // 모달 닫기
    navigate("/"); // 로그아웃 후 홈으로 이동
  };
  
  return (
    <>
      <Modal
        isOpen={isModalOpen}
        onClose={handleCloseModal}
        modalTitle={"잠깐!"}
        showCheckbox={false}
        text={
          <s.StyledP className="modal-text">
            정말 로그아웃 하시겠습니까?
          </s.StyledP>
        }
        modalButtonClose={"취소"}
        addButton={true}
        modalButtonOption={"로그아웃"}
        onOptionClick={handleLogout}
      />

      <s.OutIcon onClick={handleOpenModal} />
    </>
  );
};

export default LogoutButton;

 

 

세션이 제대로 형성되었음에도 왜 이같은 오류가 나타나는 것일까?

문제는 await auth.signOut()으로 명확한 로그아웃을 설정해주지 않았다는 것이다.

 

 

로그아웃을 확실하게 설정하지 않은 경우, 다음과 같은 문제가 발생할 수 있다.

  • 세션에 상태가 제대로 저장되지 않아 로그인 정보가 손실될 수 있다.
  • 새로고침, 탭 다시 열기 등 동작을 수행했을 때 세션 정보가 불완전하게 남아있거나, 비정상적으로 초기화 될 수 있다.

 

따라서 아래와 같이 코드를 일부 수정했다.

  const handleLogout = async () => {
    try {
      await auth.signOut(); // Firebase 로그아웃 처리
      setCurrentlyLoggedIn(false); // 로그아웃 후 로컬 상태 업데이트
      navigate("/"); // 로그아웃 후 홈으로 이동
      handleCloseModal(); // 모달 닫기
    } catch (error) {
      console.error("로그아웃 중 오류 발생:", error);
    }
  };

 

위와 같이 로그아웃 로직을 정리해주니, 위시리스트 목록에 있는 요소의 버튼 상태가 페이지 새로고침 이후에도 정상적으로 표시됐다.

 

 


 

 

 

비슷한 맥락으로, 유저 프로필 페이지에서도 로그인을 했는데도 새로고침을 하는 순간 모든 userdata가 받아지지 않고 초기화되는 현상이 있었다.

아래는 기존 프로필 페이지에서 유저 정보를 가져오는 방법이다.

  useEffect(() => {
    const fetchUserData = async () => {
      if (auth.currentUser) {
        const userDocRef = doc(db, "users", auth.currentUser.uid);
        const userDoc = await getDoc(userDocRef);
        if (userDoc.exists()) {
          const data = userDoc.data();
          setUserData(data);
          setPhotoURL(data.photoURL);

        } else {
          console.log("No such document!");
        }
      }
    };
    fetchUserData();
  }, []);

 

위 코드의 문제점은, 유저의 로그인과 로그아웃 상태를 auth.currentUser를 직접 사용하여 확인하는데, 컴포넌트 마운트 시 한 번만 실행된다는 점이다. 

auth.currentUser가 존재할 때 데이터를 가져와야 하지만  auth.currentUser의 값이 즉시 업데이트 되지 않을 수 있다. 따라서 사용자 인증 상태가 변경된 경우 바로 반영되지 않을 가능성이 있다. 

만일 사용자의 로그인 상태가 바뀌면 이 변화를 감지하지 못하고, 새로고침을 해야 데이터를 다시 불러올 수 있다.

 

useEffect(() => {
  const unsubscribe = onAuthStateChanged(auth, (user) => {
    if (user) {
      // 유저가 로그인되어 있을 때만 데이터를 가져옴
      fetchUserData(user.uid);
    } else {
      setUserData(null); // 유저가 없다면 null로 설정
    }
  });
  return () => unsubscribe(); // 컴포넌트 언마운트 시 구독 해제
}, []);

 

수정된 코드에는 onAuthStateChanged()사용하여 인증 상태의 변화를 실시간으로 자동 감지하도록 보완했다.

이로써 사용자가 로그인, 또는 로그아웃 할 때마다 이벤트가 발생하기 때문에 별도의 새로고침을 할 필요가 없다.

 

 

 

 

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

 

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>
    </>
  )
}

 

기획 중인 서비스 페이지 중, 검색 페이지 (현재명 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를 사용하지 않을 때 호출하지 않음)

 

+ Recent posts