// SSR 방식

import { ReactNode, useEffect } from "react";
import SearchableLayout from "@/components/SearchableLayout";
import { InferGetServerSidePropsType } from "next";
import BookItem from "@/components/BookItem";
import { GetServerSidePropsContext } from "next";
import fetchBooks from "@/library/fetchBooks";
import { BookData } from "@/types";

 export const getServerSideProps = async (
   context: GetServerSidePropsContext
 ) => {
   const q = context.query.q;
   const books = await fetchBooks(q as string);

   return {
     props: { books },
   };
 };

 export default function SearchPage({
   books,
 }: InferGetServerSidePropsType<typeof getServerSideProps>) {
   return (
     <div>
       {books.map((book) => (
         <BookItem key={book.id} {...book} />
       ))}

     </div>
   );
 }

 

// SSG 방식

import { ReactNode, useState, useEffect } from "react";
import SearchableLayout from "@/components/SearchableLayout";
import BookItem from "@/components/BookItem";
import fetchBooks from "@/library/fetchBooks";
import { BookData } from "@/types";
import { useRouter } from "next/router";


export default function SearchPage() {
  const [books, setBooks] = useState<BookData[]>([]);

  const router = useRouter();
  const q = router.query.q;

  const fetchSearchResult = async () => {
    const data = await fetchBooks(q as string);
    setBooks(data);
  };

  useEffect(() => {
    if(q) {
      fetchSearchResult()
    }
  }, [q]);

  return (
    <div>
      {books.map((book) => (
        <BookItem key={book.id} {...book} />
      ))}
    </div>
  );
}

SearchPage.getLayout = (page: ReactNode) => {
  return <SearchableLayout>{page}</SearchableLayout>;
};

 

 

GetServerSidePropsContext

  • Next.js에서 서버 측 렌더링(SSR) 시 사용되는 컨텍스트 객체로, 요청과 관련된 정보를 제공
  • 서버 측에서만 사용 가능

특징

  • getServerSideProps 함수의 첫 번째 매개변수로 제공됨
  • URL의 쿼리 파라미터, 동적 경로 매개변수, 요청/응답 객체 등 서버 측 정보를 포함
  • 주로 SSR에서 데이터를 페칭하거나 URL에 따라 조건부 렌더링을 구현할 때 사용

 

 

 

useRouter()

  • React Hook으로, Next.js의 클라이언트 측에서 라우팅 정보를 가져오고 라우팅을 제어하기 위해 사용
  • 클라이언트 측에서만 사용 가능

특징

  • 브라우저 환경에서 실행되며, 현재 경로, 쿼리 파라미터, 라우팅 관련 메서드 등을 제공
  • 주로 페이지 컴포넌트나 클라이언트 사이드 렌더링(CSR)에서 사용

 

 

 

 

 

 

 

 

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개 호출이 모두 포함된 배열을 완성한다.

 

 

 

 

 

 

 

 

 

공공 API를 호출하는 로직을 작성했는데, 뒤이어 CORS 에러가 발생했다.

 

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

const GardenList = () => {
  const [gardenList, setGardenList] = useState<GardenItem[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchGardenList = async () => {
      try {
        const apiKey = process.env.REACT_APP_API_KEY;
        const response = await axios.get(`http://api.nongsaro.go.kr/service/garden/gardenList?apiKey=${apiKey}&pageNo=1&numOfRows=50`);
        
        const items = response.data?.response?.body?.items?.item || [];
        setGardenList(items);
      } catch (error) {
        console.error('Error fetching garden list:', error);
        setError("An error occurred while fetching the data.");
      } finally {
        setLoading(false);
      }
    };

    fetchGardenList();
  }, []);

 

 

 

CORS 에러란?

CORS (Cross Origin Resource Sharing): 교차 출처 리소스 공유

 

간단히 말해 출처가 같은 경우에만 서버간 정보 공유를 할 수 있다는 정책이다.

 

 

 

URL은 아래와 같은 구조로 이루어져있다.

http://www.example.com:80/about?q=likes

프로토콜   Host   포트번호   Path   Query string

 

 

추가로, 특정 번호로 명시된게 아니라면 기본 포트번호는 보통 생략되어있다.

http는 80번, https는 443번이 디폴트이다.

 

 

 

그렇다면 이 중 동일 출처는 무엇일까?

프로토콜, 호스트, 포트번호가 같아야 동일 출처로 인정된다.

브라우저 콘솔창에 location.origin을 입력하면 출처를 확인할 수 있다.

 

 

 

+

출처가 같은지 비교하는 로직은 서버가 아닌 브라우저에서 수행한다.

따라서 CORS 정책을 위반하는 요청을 보내더라도, 서버에서는 정상적으로 응답한다.

후에 브라우저에서 이 응답을 분석하여 CORS 정책을 준수했는지 확인한다.

 

 

 

 

 

해결 방법

CORS 헤더를 통해 서버가 요청을 허용하겠다는 신호를 브라우저에게 보내준다.

 

서버단에서 Access-Control-Allow-Origin이라는 헤더에 요청을 허용할 출처(Origin)를 명시할 수 있다.

Access-Control-Allow-Origin 헤더에 유효한 값을 포함하면, 브라우저는 이를 신뢰한다.

  • 사용 예: Access-Control-Allow-Origin:http://www.example.com (특정 도메인만 허용함)
  • Access-Control-Allow-Origin: * 을 사용하여 모든 출처의 요청을 허용할 수도 있지만, 보안 상 권장되지 않는다.

 

위 방법은 백엔드 지식이 있어야 사용 가능해 보인다.

(Node.js를 아직 접해보지 못한 필자는 2시간 정도 애쓰다가... 다음 방법을 선택했다)

 

 

 

그러나 나는 프론트엔드 밖에 모른다면...

이미 만들어져있는 프록시 서버, 그 중 cors-anywhere 를 이용해보자.

 

 

cors-anywhere란?

브라우저가 아닌, 중간의 프록시 서버에서 요청을 중계하는 방식으로 CORS 에러를 해결해준다.

cors-anywhere 서버가 요청을 대신 보내고, 서버에서 응답을 받아 클라이언트로 전달한다...

즉, 브라우저가 다른 출처에 직접적으로 접근하지 않아 CORS 에러를 우회하는 것.

 

 

작동 방식은 아래와 같다.

  1. 클라이언트에서 API 요청을 보내려 하는 주소 앞에 cors-anywhere 프록시 URL을 추가한다.
  2. 프록시 서버가 요청을 대신 보내고, 받은 응답을 클라이언트로 다시 전송한다.
  3. 브라우저는 응답을 받기만 하기 때문에 CORS 에러 없이 데이터를 받아올 수 있게 된다.

 

다음은 설치 및 사용 방법이다.

// 현재 프로젝트 루트 디렉토리로 이동
git clone https://github.com/Rob--W/cors-anywhere.git
cd cors-anywhere // 내부 cors-anywhere 디렉토리로 이동
npm install // 의존성 설치

node server.js // 서버 실행

 

위처럼 설치 및 실행에 성공했다면, 기존 코드에 헤더를 덧붙인다.

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

const GardenList = () => {
  const [gardenList, setGardenList] = useState<GardenItem[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchGardenList = async () => {
      try {
        const apiKey = process.env.REACT_APP_API_KEY;
        const response = await axios.get(`http://localhost:8080/http://api.nongsaro.go.kr/service/garden/gardenList?apiKey=${apiKey}&pageNo=1&numOfRows=50`);
        				// 헤더를 덧붙인다!
        const items = response.data?.response?.body?.items?.item || [];
        setGardenList(items);
      } catch (error) {
        console.error('Error fetching garden list:', error);
        setError("An error occurred while fetching the data.");
      } finally {
        setLoading(false);
      }
    };

    fetchGardenList();
  }, []);

 

 

이제 평소처럼 로컬 서버를 실행하면, CORS 에러가 해결된 것을 확인할 수 있다.

yarn start

 

 

 

 

 

+ 한가지 더

참고로 로컬서버를 내린다거나, 브라우저를 끈 뒤 다시 실행하는 경우 CORS 에러가 다시 나타난다.

이것은 cors-anywhere 프록시 서버 또한 내려갔는데, 다시 실행하지 않았기 때문이다.

침착하게 프록시 서버를 올리고, 그 후 로컬 서버를 올려 테스트하자.

 

 

 

 

참고

Brie.log

이모저모

 

 

 

지난 글에서 키보드 입력값을 받아 각 버튼에 매핑된 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>
  );
}

 

 

 

 

+ Recent posts