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

 

 

 

 

 

배열의 각 요소에 대해 주어진 함수(callback)을 실행하여 단일한 결과값을 만들어낸다.

배열의 총합, 최대 or 최소값, 평균, 또는 배열을 객체로 변환할 때 유용하다.

 

 

기본형

array.reduce((accumulator) => { return.. }, initialValue);

 

  • 첫번째 매개변수: 배열의 각 요소에 대해 실행할 콜백함수
    • accumulator (누적값)
    • currentValue (현재 요소 값) : accumulator가 0이라면 1, 아닌 경우 0부터 시작
    • currentIndex (현재 인덱스)
    • array (배열 전체)
  • 두번째 매개변수: initialValue (선택 사항): accumulator의 초기값. 생략하면 첫 번째 요소가 초기값으로 설정된다.

 

 

예제

numbers 배열에 있는 모든 요소의 합을 구하는 경우, 다양한 방식이 있다.

// 메소드 없이 for문으로 순회
const numbers = [1, 2, 3, 4]
let total = 0
for (let i = 0, i <= numbers.length, i++) {
	total += numbers[i]
}
// forEach 메소드 활용
const numbers = [1, 2, 3, 4]
let total = 0
numbers.forEach((number) => {
	total = total + number
})

 

// reduce 사용한 경우
const numbers = [1, 2, 3, 4]

const total = numbers.reduce((accumulator, currentValue) => {
	return accumulator + currentValue
}, 0) // 0은 accumulator의 초기값

 

 

 

최대값을 구해보자.

// 최대값 구하기
const numbers = [8, 10, 24, 7]

numbers.reduce((accumulator, currentValue) => {
	if (accumulator < currentValue){
    	return currentValue	// accumulator의 값을 currentValue로 업데이트
    }
    else {
    	return accumulator
    }
}) //초기값을 지정하지 않았으므로 [0]번째부터 시작

 

 

이번에는 배열 내 객체 요소의 합을 구해보자.

const account = [
    {name: "A",
     cost: 100,
    },
    
    {name: "B",
     cost: 600,
    },
    
    {name: "C",
     cost: 20,
    },
]


account.reduce((accumulator, elem) => {
	return accumulator + account.cost
}, 0)

 

 

 

 

 

 

 

 

 

 

 

마음 같아서는 모든 키의 소리를 각각 녹음하고 싶었으나, 제작의 편의를 위해 크게 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>
  );

 

 

 

 

craco를 설치한다.

yarn add @craco/craco

 

 

package.json 의 script 객체를 아래와 같이 덮어쓴다.

"scripts": {
  "start": "craco start",
  "build": "craco build",
  "test": "craco test"
}

 

 

프로젝트 루트 디렉토리에 craco.config.ts 파일을 생성하고, 아래 내용을 추가한다.

module.exports = {
  style: {
    postcss: {
      plugins: [require('tailwindcss'), require('autoprefixer')],
    },
  },
};

 

 

그리고 아래 명령어를 통해 tailwind.config 파일을 생성한다.

yarn tailwindcss init -p

 

 

 

 

매번 프로젝트를 생성할 때마다 명령어를 잊어버려서 작성해둔다.

 

cd ~/Desktop

npx create-react-app new-project --template typescript

cd new-project

 

 

폴더 삭제

rm -rf project-name

 

 

Git 리포지토리 추가

git remote add origin https://github.com/<your-username>/my-new-repo.git

 

 

yarn 설치

brew install yarn
yarn --version

 

React Native에서 배경색, 폰트 색상 등의 기본값(default)은 app.json 파일에서 설정할 수 있다.

 

그러나 Stack.Navigator 를 사용하여 라우팅 구조를 만들면 이 기본값이 무시된다.

 

어떻게 수정할 수 있을까?

 

app.json에서 배경색을 설정해주었지만-
Stack.Navigator를 적용하자 app.json의 배경색이 무시된다.

 

 

 

 

 

Stack.Screen 태그의 속성에 주목해보자.

   <Stack.Screen
      name="Profile"  // 이 스크린의 이름
      component={ProfileScreen}	// 표시할 내용
      options={{
        title: "User Profile",	// 화면에 표시될 제목
        headerStyle: { backgroundColor: "#6200EA" },
        headerTintColor: "#FFFFFF",
        contentStyle: { backgroundColor: "#FAFAFA" },
        headerTitleAlign: "center",
        gestureEnabled: true
      }}
    />

 

주석에 표기한 것과 같이, Screen의 대표적인 속성을 알아두면 편리하다.

 

  • name : 해당 스크린의 이름. 다른 Screen에서 Navigating 할 때 작성할 좌표.
  • component : 표시할 컴포넌트의 내용
  • options : 객체 형태로 받는다. 전반적인 디자인 관련 속성을 모아둔 객체.
    • title : 헤더에 표시될 제목
    • headerStyle: 하위 객체에서 헤더의 스타일 결정
    • headerTintColor: 헤더 색. 디폴트는 #007AFF 
    • contentStyle: 스크린의 콘텐츠 영역에 대한 스타일을 하위 객체에서 결정. (전역적이지는 않음)
    • gestureEnabled: 스와이프를 통해 이전 화면으로 돌아가는 기능의 on, off

 

 

그러나 이렇게 스타일링을 하고 다른 스크린으로 넘어가면 이 스타일이 모든 스크린에 적용되지 않은 것을 확인할 수 있다.

이 때는 일일이 복사 + 붙여넣기 하기보다, Stack.Screen을 감싸고 있는 상위 Stack.Navigator에 이 options를 옮겨줄 수 있다.

<Stack.Navigator
  screenOptions={{
    headerStyle: { backgroundColor: "#6200EA" },  // 헤더 스타일
    headerTintColor: "#FFFFFF",  // 헤더 글씨 및 아이콘 색상
    contentStyle: { backgroundColor: "#FAFAFA" },  // 화면 배경 색상
    headerTitleAlign: "center",  // 헤더 제목 정렬
    gestureEnabled: true  // 스와이프 제스처 활성화
  }}
>

  <Stack.Screen 
    name="Profile" 
    component={ProfileScreen} 
    options={{ title: "User Profile" }}  // 특정 화면별 옵션 추가
  />
  
</Stack.Navigator>

 

이렇게 하면 화면을 옮겨다니더라도 스타일이 모두 적용된다.

 

 

 

 

앱의 폰트를 바꿔보자.

우선 사용하고자 하는 폰트 파일을 assets/font 폴더에 모아둔다.

 

 

 

 

 

 

터미널 창을 열어 다음과 같은 명령어를 입력한다.

npx expo install expo-font	// 폰트 설정 패키지
npm i react-native-global-props --save	// 특정 컴포넌트의 props 전역으로 설정하는 패키지
npm install --save-dev @types/react-native-global-props // react-native-global-props의 타입 정보

 

 

 

이제 App.tsx 파일로 돌아와 임포트 문을 추가하고,

import * as Font from "expo-font";
import { setCustomText } from "react-native-global-props";

 

 

아래와 같이 useEffect문을 작성해주면-

export default function App() {
  const [fontLoaded, setFontLoaded] = useState(false);	// 폰트 로드 상태

  useEffect(() => {
    // loadFonts 함수 작성
    async function loadFonts() {
    
      // 경로와 파일명에 유의할 것
      await Font.loadAsync({
        poppins100: require('./assets/fonts/Poppins-Thin.ttf'),
        poppins200: require('./assets/fonts/Poppins-ExtraLight.ttf'),
        poppins300: require('./assets/fonts/Poppins-Light.ttf'),
        poppins400: require('./assets/fonts/Poppins-Regular.ttf'),
        poppins500: require('./assets/fonts/Poppins-Medium.ttf'),
        poppins600: require('./assets/fonts/Poppins-SemiBold.ttf'),
        poppins700: require('./assets/fonts/Poppins-Bold.ttf'),
        poppins800: require('./assets/fonts/Poppins-ExtraBold.ttf'),
        poppins900: require('./assets/fonts/Poppins-Black.ttf'),
      });

      const customTextProps = {
        style: {
          fontFamily: "poppins400",	// 디폴트 폰트. 위 loadFonts 함수에서 선언한 것과 이름이 일치해야 한다.
        },
      };
      setCustomText(customTextProps);	// Text 컴포넌트의 스타일을 전역으로 설정
      setFontLoaded(true);
    }

    loadFonts();
  }, []);

  return <Screen />; // 보여줄 스크린 컴포넌트
}

 

 

앞으로 모든 Text 컴포넌트의 기본 폰트가 "poppins400"으로 명시한 파일과 일치하는 폰트로 설정된다.

 

 

만일 특정 컴포넌트에서 폰트 두께를 바꾸고 싶다면, 해당 컴포넌트에서 스타일링을 수정한다.

 

 

 

 


 

 

 

 

iOS에서는 오류 메세지가 없지만, 안드로이드 환경에서 아래 에러가 나타났다.

fontFamily "poppins300" is not a system font and has not been loaded through expo-font.

 

 

 

expo-font를 사용하여 폰트를 비동기적으로 로드하는 과정에서 미처 로드되지 않은 문제일 수 있다.

로드가 끝나지 않은 경우에 대해 처리를 해주도록 한다.

 

SplashScreen API를 통해 구현했다.

 

 

SplashScreen이란?

소위 말하는 로딩 화면이다. 

앱의 화면 이동 중, 또는 리소스 다운로드 등 로딩 시간이 필요한 경우 나타나는 화면을 말한다.

 

 

 

SplashScreen 설치

npm install expo-splash-screen

 

 

기본적인 사용법은 아래와 같다.

import React, { useEffect, useState } from 'react';
import { View, Text } from 'react-native';
import * as SplashScreen from 'expo-splash-screen';

export default function App() {
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    async function fetchData() {
      await SplashScreen.preventAutoHideAsync(); // 스플래시 화면 자동 숨김 방지

      // 서버에서 데이터 불러오는 작업 등 처리
      
      await new Promise(resolve => setTimeout(resolve, 3000)); // 3초 딜레이

      // 로딩 후 플래시 화면 숨기기
      setIsLoading(false);
      await SplashScreen.hideAsync();
    }

    fetchData();
  }, []);

  if (isLoading) {
    return null; // 로딩 중일 때는 스플래시 화면 유지
  }

  return (
    <View>
      <Text>데이터가 로드 성공</Text>
    </View>
  );
}

 

 

SplashScreen.preventAutoHideAsync()

기본적으로 SplashScreen은 앱이 로드되었을 때 자동으로 사라지게 된다. 

그러나 폰트 또는 데이터 로드 등 시간이 걸리는 작업의 경우, 작업이 미처 끝나기도 전에 스크린이 사라질 수 있다.

이런 경우 SplashScreen.preventAutoHideAsync() 함수를 통해 작업이 완료될 때까지 스크린을 유지하고, 이후 수동으로 화면을 숨기는 것이다.

 

 

 

따라서 아래와 같이 최종 코드가 완성되었다.

 

import { useState, useEffect } from "react";
import * as Font from "expo-font";
import { setCustomText } from "react-native-global-props";
import { StyleSheet, View } from "react-native";
import CategoryScreen from "./screens/CatetgoryScreen"
import * as SplashScreen from 'expo-splash-screen';

// 스플래시 화면 자동 숨김을 방지
SplashScreen.preventAutoHideAsync();

export default function App() {
  const [fontLoaded, setFontLoaded] = useState(false);

  useEffect(() => {
    async function loadFonts() {
      try {
        await Font.loadAsync({
          poppins100: require('./assets/fonts/Poppins-Thin.ttf'),
          poppins200: require('./assets/fonts/Poppins-ExtraLight.ttf'),
          poppins300: require('./assets/fonts/Poppins-Light.ttf'),
          poppins400: require('./assets/fonts/Poppins-Regular.ttf'),
          poppins500: require('./assets/fonts/Poppins-Medium.ttf'),
          poppins600: require('./assets/fonts/Poppins-SemiBold.ttf'),
          poppins700: require('./assets/fonts/Poppins-Bold.ttf'),
          poppins800: require('./assets/fonts/Poppins-ExtraBold.ttf'),
          poppins900: require('./assets/fonts/Poppins-Black.ttf'),
        });

        const customTextProps = {
          style: {
            fontFamily: "poppins900",
          },
        };
        setCustomText(customTextProps);

        setFontLoaded(true);
        // 폰트가 로드된 후 스플래시 화면 숨김
        await SplashScreen.hideAsync();
      } catch (error) {
        console.warn("Error loading fonts", error);
      }
    }

    loadFonts();
  }, []);

  if (!fontLoaded) {
    // 폰트가 로드되지 않았을 때는 아무 것도 렌더링하지 않음 (스플래시 화면 유지)
    return null;
  }

  return <Screen />;
}

 

 

 

 

에뮬레이터, 또는 Expo로 연결된 모바일 기기를 가로로 기울인다고 해서 화면이 자동으로 돌아가지는 않는다.

landscape 모드를 적용하고자 한다면, 이에 따라 설정과 스타일링을 바꿔줘야 한다.

 

 

 

우선 app.json파일을 열고, orientation 속성을 "default"로 바꿔준다.

  "expo": {
    "name": "navigate",
    "slug": "navigate",
    "version": "1.0.0",
    "orientation": "default",	//기본값은 portrait
    "icon": "./assets/icon.png",
    "userInterfaceStyle": "light",
    "splash": {
      "image": "./assets/splash.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },

 

 

참고로 orientation은 아래 값을 가질 수 있다.

  • default : 가로, 세로 모드 모두 지원
  • portrait : 세로 모드로 고정
  • landscape : 가로 모드로 고정
  • portrait_up : 세로 모드이되, 상단이 항상 위쪽을 향함
  • portrait_down : 세로 모드이되, 상단이 아래쪽을 향함
  • landscape_left : 가로 모드이되, 기기의 왼쪽이 위쪽을 향함
  • landscape_right : 가로 모드이되, 기기의 오른쪽이 위쪽을 향함

 

 

app.json의 변경 사항을 저장했다면, 에뮬레이터를 회전시켰을 때 정상적으로 반응하는 것을 확인할 수 있다.

컴포넌트로 돌아가 스타일링을 해보자.

 

 


 

 

 

현재 모드가 가로 모드인지 세로 모드인지 어떻게 추적할 수 있을까?

기본적으로 현재 화면의 가로, 세로 길이를 측정하고, 길이를 비교하여 조건을 적용하면 된다.

 

 

 

가로, 세로 길이 재는 방법은 두가지가 있다.

Dimensions

import { Dimensions, Text, View } from 'react-native';	//임포트 잊지 않을 것

const ScreenInfo = () => {
 const { width, height } = Dimensions.get('window');
  const isWideView = width > height;


  return (
    <View>
      <Text>{ isWideView ? 가로 모드 : 세로 모드 }</Text>
    </View>
  )
}

 

 

Dimensions의 get 메소드를 통해 가로와 세로의 길이를 구한다.

  • Dimensions.get('window') : 상태바나 네비게이션 바를 무시하고, 사용자가 상호작용할 수 있는 앱의 영역을 측정
  • Dimensions.get('screen') : 상태바와 네비게이션 바를 모두 포함한 전체 화면의 길이 측정
    • 다만 초기값만 가져온다는 단점이 있다.
    • 즉, 기기를 반복적으로 뒤집더라도 이 상태 변화를 감지하지 못하기 때문에 별도의 eventListener를 생성해야 한다.

 

 

 

useWindowDimensions()

import { useWindowDimensions, Text, View } from 'react-native';	//임포트 잊지 않을 것

const ScreenInfo = () => {
 const { width, height } = useWindowDimensions();
  const isWideView = width > height;


  return (
    <View>
      <Text>{ isWideView ? 가로 모드 : 세로 모드 }</Text>
    </View>
  )
}

 

useWindowDimenstions() 는 Dimensions와 유사하지만, 가로와 세로 변화를 실시간으로 감지하고 업데이트 해준다는 장점이 있다.

 

 

 


 

 

 

이것을 현재 실습 중인 프로젝트에 적용해보았다.

스타일링은 우선 StyleSheet (내장 라이브러리)를 사용했다.

 

import { StyleSheet, View, Text, Pressable, useWindowDimensions } from "react-native";

interface ProjectProps {
  title: string;
  color: string;
}

export default function Project({ title, color }: ProjectProps) {
    const { width, height } = useWindowDimensions()
    const isWideView = width > height;
  
    return (
    <View style={[styles.gridItem, isWideView && styles.gridItemWide]}>
      <Pressable>
        <View>
          <Text>{title}</Text>
        </View>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  gridItem: {
    flex: 1,
    margin: 16,
    height: 150,
    borderWidth: 1,
    borderColor: 'red',
  },

  gridItemWide: {
    height: 180,
    margin: 20,
  }
});

 

 

에뮬레이터를 회전할 때마다 덮어쓴 height와 margin이 잘 적용 및 해제되는 것을 확인할 수 있다.

 

 

 

 


 

 

 

작은 문제가 생겼다.

위 영상처럼 현재 데이터 리스트를 카드 형태로 FlatList에 순서대로 뿌려주고 있었다.

세로뷰에서는 2줄 씩 출력되던 것을 가로뷰에서는 3줄로 출력되도록 수정하고 싶었다.

따라서 아래와 같이 해당 출력 컴포넌트의 코드를 수정했다.

import { StyleSheet, FlatList, useWindowDimensions } from "react-native";
import { CATEGORIES } from "../assets/data/dummy";
import CategoryGridTile from "../components/CategoryGridTile";

function renderCategoryItem(itemData: any) {
  return (
    <CategoryGridTile title={itemData.item.title} color={itemData.item.color} />
  );
}

function CategoryScreen() {
    const { width, height } = useWindowDimensions()
    const isWideView = width > height;
  

  return (
    <FlatList
      data={CATEGORIES}
      keyExtractor={(item) => item.id}
      renderItem={renderCategoryItem}
      numColumns={isWideView ? 3 : 2}	// 출력될 줄 수에 삼항연산자를 적용했다.
    />
  );
}

export default CategoryScreen;

 

 

 

그러나 저장 후 가로뷰로 에뮬레이터를 뒤집는 순간, 아래 에러가 발생했다.

더보기

Invariant Violation: Changing numColumns on the fly is not supported. Change the key prop on FlatList when changing the number of columns to force a fresh render of the component.

위 에러는 FlatList의 numColumns를 실시간으로 변경할 수 없다는 뜻이다.

 

 

 

이런 경우는 해당 FlatList를 재렌더링을 하는 방식으로 줄 수를 바꿔야 한다.

 

 

 

그렇다면 어떻게 재렌더링을 트리거할 수 있을까?

기기를 회전했을 때 해당 리스트의 key를 바꿔서 다시 받아오는 방법으로 재렌더링이 가능하다.

 

//전략
  return (
    <FlatList
      data={CATEGORIES}
      keyExtractor={(item) => item.id}
      renderItem={renderCategoryItem}
      numColumns={isWideView ? 3 : 2}	// 삼항연산자 그대로 사용
      key={isWideView ? 'wide' : 'portrait'}  // FlatList의 key를 바꿔서 강제 리렌더링
    />
  );

 

 

위처럼 item 각각의 key는 이전과 마찬가지로 keyExtractor로 부여한다.

그리고 FlatList 덩어리 자체에 key를 추가하고, 이것을 가로, 세로 모드가 바뀔 때 다르게 적용하여 다시 렌더링되게 한다.

 

 

 

 

 

 

위 방법은 유저가 화면을 반복적으로 돌리는 경우 계속해서 컴포넌트 리렌더링이 일어나기 때문에 성능에 영향을 줄 수 있다.

 

다만, FlatList는 많은 데이터를 열거하더라도 보여지는 화면 영역에 대한 데이터를 우선 처리하기 때문에 성능 문제를 조금 줄일 수 있다.

만일 해당 컴포넌트가 복잡한 계산을 요하는 함수로 이루어져 있다면, useMemo() useCallback() 사용을 고려해보아도 좋겠다.

 

'React Native' 카테고리의 다른 글

Navigator 스타일링  (0) 2024.10.15
숫자 맞추기 게임 - 히스토리 상태 받아오기  (1) 2024.10.03
숫자 맞추기 게임  (8) 2024.09.30
React Native 기초  (0) 2024.09.26

 

 

타입스크립트로 작성하다 보면 경우에 따라 타입 선언 방식의 차이가 보인다.

 

interface AProps {
  title: string;
}

// FC: Functional Component
const AComponent: React.FC<AProps> = ({ title }) => {
  return (
    <h1>{title}</h1>
  )
}

 

interface BProps {
  title: string;
}

const BComponent = ({ title }: BProps) => {
  return (
    <h1>{title}</h1>
  )
}

 

 

위 A, B 컴포넌트의 props 타입 선언 방식은 조금 다르지만 같은 기능을 수행하는 것처럼 보인다. 

가장 큰 차이점은 아래와 같다.

 

React.FC 선언

  • chidren이 prop에 자동으로 포함되고, 사용할 수 있다.
  • 반환 타입이 ReactElement 으로 고정되어, 따로 반환 타입을 선언할 필요가 없다.
  • 다만 children이 자동으로 포함되기 때문에 children을 반환하지 않는 컴포넌트에서는 사용이 불필요할 수 있다.

직접 선언

  • 필요한 prop 만 사용하므로 코드의 의도를 보다 명확하게 표현할 수 있다.
  • 반환 타입을 명시하지 않으면 Typescript가 자동으로 추론한다. 
  • children이 필요하다면 따로 타입 선언을 해주면 된다. ( children: React.ReactNode; )

 

 

실제로 더욱 명확한 타입 선언을 위해 B 케이스 작성 방식을 선호하는 추세라고 하고, 가독성과 정확성이 우수해보인다.

 

프로그램이 이전에 던진 정수를 화면에 나타내어 쌓아볼 수 있을까?

이 추측 히스토리(?)를 배열 형태로 만들고, 상태 관리를 해보자.

 

const [rounds, setRounds] = useState([initialGuess]) // 배열에 최초 정수 하나로 시작

 

참고로 initialGuess를 설정하는 방법은 바로 이전 글에 메모해두었다.

 

 

 

이 상태를 유저가 up, down 버튼을 눌렀을 때, 즉 nextGuessHandler가 동작할 때마다 추가되도록 설정해준다.

const nextGuessHandler = () => {

// 중략

  const newRandomNum = generatFeNumBetween(
    minBoundary,
    maxBoundary,
    currentGuess
  );

  setCurrentGuess(newRandomNum);
    
  setRounds((prevRound) => [newRandomNum, ...prevRound]);
};

 

 

다시 말해 initialGuess가 50이라고 가정하고, 차례로 5, 12, 80, 40 이라는 랜덤 정수가 생성됐다면 배열은 아래처럼 변한다.

  1. [50] 
  2. [5, 50]
  3. [12, 5, 50]
  4. [80, 12, 5, 50]
  5. [40, 80, 12, 5, 50]

 

 

막연히 unshift 메소드를 사용하려 시도했는데, 오히려 스프레드 연산자를 사용하는 편이 직관적이어 보인다.

 

 

 

+

아래처럼 순서를 바꾼다면 배열의 맨 뒤에 추가되도록 만들 수도 있다.

  setRounds((prevRound) => [...prevRound], newRandomNum]);

 

 

 

이제 화면에 배열이 업데이트될 때마다 추가되도록 작성한다.

  <View>
   {rounds.map(rounds => <Text key={rounds}>{rounds}</Text>)}
  </View>

 

 

 

 

 

유저가 입력한 1 이상 99 이하의 정수 를 컴퓨터가 맞춰내는 게임을 만들고 있다.

 

  1. 유저가 입력한 정수를 제외하고 (즉, 프로그램은 첫번째 시도에서 정답을 맞출 수는 없다)
  2. 랜덤 정수를 생성한다.
  3. 이에 따라 유저가 Up or Down 을 입력하면
  4. 프로그램은 그 랜덤 정수를 최대값 or 최소값 으로 업데이트하여
  5. 새로운 랜덤 정수를 생성한다.
  6. 이를 정답을 맞출 때까지 반복한다.

 

(임포트 문은 생략했다)

// min, max : 최소값, 최대값
// exclude: 제외할 정수 (최초 입력값 & 이미 생성한 랜덤값)
const generateNumBetween = ( min: number, max: number, exclude: number): number => {
  const randomNum = Math.floor(Math.random() * (max - min)) + min; // 랜덤 정수 생성

  //컴퓨터는 첫 시도에서 답을 맞출 수 없음.
  if (randomNum === exclude) {
    //생성된 숫자가 제외돼야하는 정수와 같다면 다시 생성
    return generateNumBetween(min, max, exclude);
  } else {
    return randomNum;
  }
};

 

 

매개변수 min, max, exclude는 숫자형이고, 이들을 통해 실행된 함수 generateNumBetween은 숫자형을 반환함을 명시한다.

 

 

랜덤 정수를 생성한 방법을 보다 자세히 설명해보자면 아래와 같다.

 const randomNum = Math.floor(Math.random() * (max - min)) + min
 
 
 
 max - min // 범위 크기 설정 (예: 1부터 100이라면 범위는 99)
 
 Math.random() // 0부터 1사이의 난수 생성
 
 Math.random() * (max - min) // 범위 내의 난수 생성
 
 Math.floor() // 소수점 아래 자리 반올림
 
 Math.floor(Math.random() * (max - min)) // 생성한 랜덤 난수의 소수점 아래 자리 반올림
 
 + min // min 기준으로 원하는 범위로 이동 (현재는 100 - 1 = 99 -> 0부터 99 이므로, 1부터 100으로 교정)
 
 Math.floor(Math.random() * (max - min)) + min // 완성

 

 

이제 게임 화면을 구성한다.

// 타입 선언
interface GameScreenProps {
  userNumber: number;
  onGameOver: () => void;
}

  // 최소값, 최대값 변수 설정
  let minBoundary = 1;
  let maxBoundary = 100;

export default function GameScreen({ userNumber, onGameOver }: GameScreenProps) {
  // 첫번째로 추측할 랜덤 정수
  const initialGuess = generateNumBetween(minBoundary, maxBoundary, userNumber);

  const [currentGuess, setCurrentGuess] = useState(initialGuess);

  useEffect(() => {
    if (currentGuess === userNumber) {
      onGameOver()	// 정답을 맞춘다면 게임오버 화면으로
    }
  }, [currentGuess, userNumber, onGameOver]);	

  //유저가 +,-를 올바른 방향으로 사용하도록 유도
  const nextGuessHandler = (direction: string) => {
    if (
      (direction === "lower" && currentGuess < userNumber) ||
      (direction === "greater" && currentGuess > userNumber)
    ) {
      Alert.alert("정말인가요?", "다시 한 번 생각해보세요.", [
        { text: "아차차", style: "cancel" },
      ]);
      return;	// return을 쓰지 않으면 alert창이 뜨는 동시에 새로운 정수가 생성된다.
    }

    //수를 올릴 방향. 키울지 줄일지?
    if (direction === "lower") {
      maxBoundary = currentGuess;
    } else {
      minBoundary = currentGuess + 1;
    }
    console.log(minBoundary, maxBoundary); //범위값이 계속 업데이트 되는 것을 확인할 수 있다
    
    // 새로운 랜덤 정수 생성
    const newRandomNum = generateNumBetween( minBoundary, maxBoundary, currentGuess );
    setCurrentGuess(newRandomNum); //업데이트된 최소값, 최대값, 최근에 썼던 정수를 배제
  };

 

 

useEffect문의 의존성 배열에 currentGuess, userNumber, onGameOver 가 들어가는 이유?

currentGuess

  • currentGuess와 userNumber가 일치하는지 여부를 확인하여 게임을 종료해야 한다.
  • 따라서 currentGuess가 바뀔 때마다 if (currentGuess === userNumber) 를 검사한다.

userNumber

  • userNumber가 바뀐다는 것은 새로운 게임이 시작되었다는 것을 의미하므로, (유저가 게임 도중 바꾸는 경우 포함( 이 로직을 다시 실행해야한다

onGameOver

  • onGameOver 는 정답 맞췄을 때 실행되는 콜백함수이다.
  • 따라서 onGameOver의 함수 구현이 변경되었다면 useEffect문을 수행해야 한다.

 

 

 

 

마지막으로 리턴문을 작성한다.

  return (
    <View>
      <NumberContainer>{currentGuess}</NumberContainer>
      <View>
        <Text>Higher or Lower?</Text>
        <View>
          <Button onPress={nextGuessHandler.bind(this, "lower")}>
            -
          </Button>
          <Button onPress={nextGuessHandler.bind(this, "greater")}>
            +
          </Button>
        </View>
      </View>
    </View>
  );
}

 

 

 

 


 

 

 

 

그런데 여기서 프로그램이 정답을 맞춘 순간 Render Error: Maximun call stack size exceeded 가 반환된다.

 

 

 

 

이 문제는 렌더링 순서와 관련이 있다.

 

기본적으로 React의 렌더링 순서는 아래와 같다.

  1. 컴포넌트의 모든 함수를 실행
  2. useState 초기화
  3. useEffect 실행

 

이 경우에 대입하여 설명하자면, 렌더링은 아래와 같은 순서로 실행된다.

  1. generateNumBetween 함수를 실행하여 initialGuess 생성
  2. initialGuess 값을 받아 상태를 초기화
  3. initialGuess 와 currentGuess 에 초기값을 설정한 후 useEffect 함수 실행

 

결론

  1. useEffect문은 currentGuess가 변경될 때마다 실행되어 게임 종료 조건을 확인하는 것이 최종 목적인데-
  2. 정답을 고른 순간, nextGuessHandler가 실행되고 setCurrentGuess를 호출하여 상태를 업데이트하고 리렌더링을 준비한다.
  3. 그러나 정답은 minBoundary와 maxBoundary가 같은 경우이므로, 유효한 범위가 없기 때문에 nextGuessHandler는 새로운 랜덤 정수를 생성할 수 없다.
  4. 이 때 generateNumBetween는 유효한 범위를 찾을 때까지 재귀적으로 계속 호출되게 된다.
  5. 따라서 useEffect문보다 먼저 실행되는 generateNumBetween함수가 무한 루프에 걸려 useEffect문은 실행되지 못하고, 이에 따라 재렌더링이 미처 이루어지지 못한다.

 

 

 

해결 방법

절대적으로 안전한 방법일 것 같지는 않아, 추후 더 고민해보기

//이전 코드
const initialGuess = generateNumBetween(minBoundary, maxBoundary, userNumber);

//바꾼 코드
const initialGuess = generateNumBetween(1, 100, userNumber);

 

위처럼 초기 최소값, 최대값을 고정하여 하드코딩 하는 방법이 있다.

이렇게 하면 generateNumBetween은 항상 유효한 범위를 가질 수 있다.

따라서 유효한 범위는 지키는 선에서 에러를 반환하던 함수를 해결하고, useEffect문을 수행해 화면 전환을 이룰 수 있다.

 

React.ts 프로젝트를 거의 마무리하며, 비로소 타입, 컴포넌트, 상태 관리 등 웹 개발에서의 가장 기본적인 개념을 알게 됐다. 

다음 프로젝트는 또다른 프레임워크를 경험해보기 위해 React Native를 익히려 한다.

 

 

지금까지 사용해온 React.ts는 웹 애플리케이션을 만들 수 있지만, 모바일 애플리케이션을 직접 만들 수는 없다. 

모바일 애플리케이션을 개발하기 위해서는 추가적인 프레임워크가 필요한데, React Native가 대표적인 예이다.

주요 특징은 아래와 같다.

 

  • 브라우저가 아닌 네이티브 모바일 UI 컴포넌트 (Android, iOS)를 사용한다. 
  • 두가지 OS를 동시에 개발할 수 있다.
  • React와 코드 작성 방식이 유사하다.
  • 네이티브 모듈을 통한 성능 최적화가 가능하다.

 

 

그렇다면 회사에서 본 QA팀처럼 거의 모든 디바이스를 가지고 있어야 하나...? 나는 아이폰 한대 뿐인데...

 

 

 

 

 

 

이러한 문제점을 해결하기 위해 다양한 디바이스 환경을 쉽게 확인하도록 해주는 상위 프레임워크가 있다.

기본적으로 React Native CLI(Command Line Interface)와 Expo로 나뉜다.

 

 

React Native CLI

장점

  • 네이티브 코드에 접근 및 커스터마이징이 가능하다.
  • 더 많은 네이티브 기능(카메라, 지도 등) 및 모듈을 사용할 수 있다.

단점

  • 초기 설정이 복잡하다.
  • 별도의 Android와 iOS 빌드를 설정하고 배포해야 한다.

 

Expo

장점

  • 초기 구성이 이미 구성되어 있어 가벼운 앱을 빠르게 만들기에 유용하다.
  • 앱 푸쉬, 애플 로그인 등 기능이 탑재되어 있다.
  • Expo Go를 통해 실제 디바이스 환경을 빠르게 확인할 수 있다.

단점

  • 네이티브 코드를 수정할 수 없어 개발의 한계가 있다.
  • Expo에서 지원하지 않는 라이브러리를 사용할 수 없다.

 

 

 


 

 

 

 

이제 프로젝트를 생성해보자.

npm install -g expo-cli

 

이번에도 타입스크립트 탬플릿을 사용하려 한다.

npx create-expo-app MyNewApp --template blank-typescript

 

생성된 프로젝트를 실행시키기 위해서는 아래 명령어를 입력한다.

npx expo start

 

 


 

 

 

로컬 서버를 실행시키니 EMFILE: too many open files 에러가 발생했다. 

 brew update
 brew install watchman

 

위 방법으로 해결할 수 있다는 글이 많았으나, 나의 경우 해결되지 않았다.

프로젝트 폴더로 이동하여 node_modules 폴더를 삭제하고, 아래 명령어로 다시 설치했더니 정상적으로 실행할 수 있었다.

Stack Overflow

npm install

 

 

 

 

 

+ Recent posts