리액트로 뽀모도로 타이머 만들기

김민규·2023년 1월 6일
1
post-thumbnail

왜 뽀모도로인가

udemy로 react 강의를 듣다가 진도율이 50% 까지 찬 걸 알게되었다. 지금까지 공부한게 머릿속에 있는게 맞는지 확인도 할 겸 간단한 프로그램 하나 만들어 보는게 많은 도움이 될 것 같았다.

그래서 당장 내가 배운 걸 활용해서 만들어 볼만한게 뭐가 있을지를 생각해봤다.

무료 API 활용 사이트

처음에는 무료 API 사이트를 사용해 영화 검색 프로그램 비슷한 걸 만들까 생각했다. 강의 내용 중에서도 있었던 내용이기도 하고 서버 통신도 좀 더 익숙해지지 않을까 싶기도 했다.

하지만 기왕 만들거라면 내가 실제로 평소에 쓸만한 프로그램을 만들고 싶다는 생각에 일단은 보류하기로 했다.

영단어장

그래서 평소에 쓸만한 프로그램이면 뭐가 있을까 하다가 영단어장이 떠올랐다. 그런데 머릿속에서 구상해보니 영단어장은 아무래도 페이지간 변화가 잦을 것 같아서 지금보단 react router까지 진도를 나간 후에 만드는게 좀 더 맞을 것 같아서 조금 미루기로 했다.

뽀모도로 타이머

그래서 결국에 결정한 주제는 뽀모도로 타이머.
12년 동안 교육청의 의무교육을 받은 토종 한국인으로 살면서 50분 공부 10분 휴식이라는 사이클 없이는 버틸 수 없는 몸이 되어버린 나는 고등학교를 졸업하고도 공부를 할 때 휴식시간과 집중시간을 분류해서 공부를 한다.

이런 타입의 시관 관리법을 보통 뽀모도로라고 하는데 프란체스코 시릴로라는 대학생이 1980년대에 제안한 시관 관리 방법론으로 토마토 스파게티를 의미하는 뽀모도로를 만들 때 사용하던 토마토 모양의 타이머에서 본 따 설계되었다.

- 토마토 모양의 뽀모도로 타이머 -

현재에도 많은 뽀모도로 타이머 어플리케이션들이 존재하고 나도 실제로 Focus-to-do라는 어플을 자주 사용하고 있다. 따라서 이러한 뽀모도로 타이머를 만들어보자라는 생각을 하게 되었다.

뭐 하나 쉽게 되는 일이 없네

프로그램 구상

사실 처음부터 프로그램을 만들어보는게 처음이라 그런지 어떤것부터 시작해야 할 지가 막막했다. 일단 create-react-app은 했는데 이제부터 뭘 해야할 지가 생각이 안났다.

그래서 우선 구현 기능 목록 부터 생각해봤다.

1. 시간 데이터를 가진 수정 가능한 컴포넌트 리스트 구현

2. 타이머 구현

3. 컴포넌트 데이터 타이머에 적용시키기

크게 보자면 위 3가지가 주요 기능이였다.

위의 3가지를 작성하고 떠오른게 아무래도 전역 상태관리를 사용해야 할 것 같은 생각이 났다.

단순히 입력값을 받고 타이머를 작동시킨다면 prop의 이동이 크지 않겠지만 todo 리스트의 컴포넌트에서 타이머까지 데이터를 이동시켜야 하니 상태관리 라이브러리를 하나 선택하는 것이 더 나은 방향이라 생각하였고 최근에 공부하였기도 하고 store 분리가 간편한 Redux Toolkit을 사용하기로 결정하였다.

UI Design

사실 기능 구현 말고도 UI를 어떻게 만들지도 난관이었다.

Figma를 지금부터 배울 수도 없고 어떡하지 생각하다가 그냥 단순하게 만들기로 했다.

UI를 단순화 하는 대신에 그나마 보기에 좀 괜찮았으면 싶어서 예전에 보고 이쁘다고 생각했던 뉴모피즘을 적용시켜보기로 했다.

사실 뉴모피즘은 나같은 초보가 만들기에는 살짝 무거운 느낌이 있었는데 다행히도 뉴모피즘 css를 생성해주는 사이트가 있어서 많은 도움을 받았다.

- 뉴모피즘 CSS 마크업 생성 사이트 neumorphism.io -

neumorphism.io 링크

디바이스 반응?

기본 마크업을 하면서 디바이스 반응을 어떻게 해야할 지를 생각해보았다. 처음엔 미디어쿼리를 사용해서 모바일 반응을 할 예정이었지만, 어차리 데스크탑과 모바일의 레이아웃 구조가 바뀌는 것도 아니니 굳이 미디어쿼리를 사용해서 해상도에 따른 반응을 할 필요는 없다고 느껴졌다.

대신에 컴포넌트들의 width를 설정할 땐 뷰포트를 기준으로한 vw를 적용시켜서 반응하도록 작성하였다.

- 해상도별 타이머 레이아웃 -

타이머 표현법

타이머 코드는 기본적으로 00 : 00 형식의 문자열로서 관리되기에 이를 어떻게 카운트 할지도 고민이었다. 구글에 그냥 타이머 예시를 긁어올까 생각하다가 그냥 타이머 카운트 전용 유틸리티 함수를 작성하기로 하였다.

// counter.js

const counter = (time) => {
  const [min, sec] = time.split(" : ");
  if (+sec > 0) {
    let changeSec = `${+sec - 1 + ""}`;
    if (sec.length === 1 || sec[0] === "0" || sec === "10")
      changeSec = `0${changeSec}`;
    return `${min} : ${changeSec}`;
  }
  if (+sec === 0) {
    let changeMin = `${+min - 1 + ""}`;
    if (min.length === 1 || min[0] === "0" || min === "10")
      changeMin = `0${changeMin}`;
    if (+min > 0) return `${changeMin} : ${"59"}`;
    else return "00 : 00";
  }
};

나도 안다, 코드에서 구린내가 난다는걸.

하지만 나름대로 나에게는 최선이었다.

인터벌 코끼리를 리액트에 넣는 방법

사실 이 부분에서 가장 많은 애를 먹었다. JS에서 타이머를 구현한다면 setInterval을 사용하는건 사실상 필수불가결한데 문제는 리액트의 특성에 있었다. 타이머를 정지시키려면 clearInterval로 인터벌을 제거해야하는데 그렇다면 대체 어떻게 setInterval을 지정할 것인가가 가장 큰 고민이었다.

최종적으로 선택한 방법은 Redux의 상태로 인터벌을 부여하기.

컴포넌트 내에서 인터벌 생성시 인터벌이 계속 반복 생성되는 문제도 있었고 다른 컴포넌트에서 인터벌을 제거할 방법이 도저히 떠오르지 않아 위와 같은 방법을 선택했다.

인터벌이 부여되는 위치인 타이머 컨트롤러 컴포넌트에서 상태로 인터벌을 dispatch하는 방법으로 작성하였다.



// TimerController.js

const startHandler = () => {
  if (currentTime === "00 : 00") return;
  dispatch(
    timerActions.setCustomInterval(
      setInterval(() => {
        dispatch(timerActions.count());
      }, 1000)
    )
  );
};


//timerSlice.js

const timerSlice = createSlice({
  name: "timer",
  initialState: {
    settedTime: "",
    time: "00 : 00",
    interval: 0,
  },
  reducers: {
    setCustomInterval: (state, actions) => {
      state.interval = actions.payload;
    },
    clearCustomInterval: (state) => {
      clearInterval(state.interval);
    },
    count: (state) => {
      state.time = counter(state.time);
      if (state.time === "00 : 00") {
        const alram = new Audio(alramSound);
        alram.loop = false;
        alram.play();

        state.active = false;
        clearInterval(state.interval);
        state.time = state.settedTime;
      }
    },
  },
);

코드로 설명하자면 dispatchaction을 아예 setInterval의 반환값을 보내면 timerSlice의 상태 중 하나인 interval에 해당 값이 부여된다. 이는 Redux에서 관리하므로 렌더링과는 상관 없이 항상 같은 메모리 주소에 위치하며 slice 내에서 직접 참조할 수도 있다.

이후에 타이머가 00:00이 되거나 STOP 혹은 RESET 등 인터벌의 제거가 필요한 시점에서 clearCustomInterval 액션을 통해서 인터벌의 직접적인 조작이 가능하게 만들었다.

사실 이것보다 좋은 방법이 있을 것 같지만 나의 두뇌와 현재 내가 아는 지식으로서는 이정도가 한계였다.

그리고 카운트가 00 : 00이 될 시 알람을 재생되고 기존 선택한 타이머의 시간으로 초기화 되도록 구현하였다.

알람 사운드는 아이폰의 기본 벨소리 중에서 선택하였다.

사용자 데이터 보관

리스트의 경우 가장 기본적인 localhost를 사용하였다. todo 목록이 업데이트 될 때마다 로컬에 새로운 데이터를 JSON 형식으로 저장하는 방식으로 작성하였다.

그리고 페이지 진입시 useEffect를 통해 최초 상태 업데이트를 실시하는데 받아온 값을 반복문으로 추가하는 대신에 아예 초기 데이터를 바꿔버리는 액션 함수를 작성하였다.


// todoSlice.js

const todoSlice = createSlice({
  name: "todo",
  initialState: {
    todos: [],
    editTargetId: null,
  },
  reducers: {
    add: (state, action) => {
      const newTodo = action.payload;
      state.todos.push(newTodo);
      localStorage.setItem("todos", JSON.stringify(state.todos));
    },
    delete: (state, action) => {
      state.todos = state.todos.filter((todo) => {
        return todo.id !== action.payload;
      });
      localStorage.setItem("todos", JSON.stringify(state.todos));
    },
    setEditedId: (state, action) => {
      state.editTargetId = action.payload;
    },
    edit: (state, action) => {
      state.todos = state.todos.map((todo) => {
        if (todo.id === action.payload.id) {
          todo = action.payload.changedTodo;
        }
        return todo;
      });
      localStorage.setItem("todos", JSON.stringify(state.todos));
    },
    setup: (state, action) => {
      state.todos = action.payload;
    },
  },
  
  
// TodoList.js
  
useEffect(() => {
    const initTodos = JSON.parse(localStorage.getItem("todos"));
    if (initTodos) dispatch(todoActions.setup(initTodos));
  }, [dispatch]);

모달을 통해 값 입력받기

처음에 사용자에게 값을 입력받을땐 단순하게 prompt를 사용하여 값을 받도록 구현하였다. 하지만 모달을 사용하는 편이 좀 더 직관적이고 사용성이 좋을 것이라 생각해서 모달을 통해 값을 받아오기로 계획을 바꿨다.

모달에도 상태를 설정하여 모달의 mode 상태를 만들고 모드에 따라 액션이 달라지도록 구현하였다.

- Modal Layout -

데이터 수정시 useRef로 기본값 입력하기

Todo를 변경 시키기 위해서 모달을 띄울때 입력창에 기존 데이터를 입력시켜놓고 싶었다.

그래서 처음에 제목,분,초에 대한 useRef를 생성 후 인풋 태그에 할당했더니 참조한 current 값이 undefined가 나왔다.

알아보니 DOM이 생성되기 전에 참조를 읽어서 해당 DOM이 존재하지 않으니 나타나는 현상이었다.

그렇기에 최초 마운트 시에만 실행하도록 useEffect에 의존성을 추가하지 않고 참조된 DOM에 기존 데이터를 할당하도록 구현하였다.

// TodoEditor.js

  const titleRef = useRef();
  const minRef = useRef();
  const secRef = useRef();

  const edittedTime = useSelector((state) => state.timer.settedTime);
  const currentTitle = useSelector((state) => state.timer.title);
  const [currentMin, currentSec] = edittedTime.split(" : ");

  useEffect(() => {
    if (modalMode === "edit") {
      titleRef.current.value = currentTitle;
      minRef.current.value = currentMin;
      secRef.current.value = currentSec;

      setTitle(currentTitle);
      setMin(currentMin);
      setSec(currentSec);
    }
  }, []);

최종 결과물

TO DO 입력 및 타이머 컨트롤

TO DO 수정 및 타이머 종료

종료시 알람음이 나온다.

TO DO 삭제

입력값 유효성 검사

아쉬운 점

내 자취방보다 지저분한 코드

처음으로 맨땅에 프로그램을 구현해보다보니 가장 강하게 든 생각이 하나 있다.

세상사 내맘대로 되는게 하나도 없다!

정말로 하나를 수정하면 다른곳이 터지고 하나를 수정하면 다른 곳이 터져나가니 정말로 신경쓸게 한두가지가 아니었다.

그렇다보니 구현에 급급해지고 반사작용으로 점점 코드에서 구린내가 심해지는걸 느꼈다.

강의에서 배운 useMemouseCallback이나 커스텀 훅 같은걸 써볼까 싶었지만, 잘못 썼다간 내가 감당할 수 없는 상황이 일어날까봐 써볼 엄두가 안났다. 다음에 뭔가를 만들땐 좀 더 공부해서 써봐야겠다는 생각이 들었다.

작업기간은 약 이틀정도 소요됐다.

Github code

profile
Error Driven Development

0개의 댓글