🦁멋쟁이사자처럼 백엔드 부트캠프 13기 🦁
TIL 회고 - [31]일차
🚀31일차에는 먼저 Express 서버로 수정/삭제 기능 구현을 모두 완료하고 useEffect와 useEffect에서 cleanUp하는 부분을 배울 수 있었다.
그 후 리액트로 구현했던 Todo 리스트 프로젝트를 조금 더 개선하여 "작성날짜"와 "할일완료유무" 등을 확인할 수 있도록 Memo 프로젝트를 진행하였다.
잘 구현할 수 없을 것 같던 투두리스트 프로젝트를 기능별로 하나씩 구현해보니 시간은 꽤 오래걸렸지만 원하는 기능을 구현해낼 수 있었다.회고를 통해 프로젝트를 구현하면서 했던 생각들을 정리하고자한다!
리액트에 Express 적용 (할일 추가)
express서버의 추가하는 부분 (index.js)
let idGen = 3;
router.post("/api/todos", function (req, res) {
let title = req.body.title;
let todo = { id: ++idGen, title: title, done: false };
todos.unshift(todo);
res.status(200).json(todo);
});
리액트 Todo의 추가하는 부분 (TodoBox 컴포넌트)
// 1. 할 일 추가
const addTodoList = (title) => {
const newTodo = {
title: title,
};
async function addTodo(newTodo) {
// 위에서 새로추가한 newTodo 리스트를 보내줌
await axios.post("<http://localhost:1577/api/todos>", newTodo); // 저장을 구현하는 메소드 (express의 index.js의 post와 일치시켜줌)
const result = await axios.get("<http://localhost:1577/api/todos>");
setTodoList(result.data);
}
addTodo(newTodo);
};
//import { useRef } from "react";
import { useEffect, useState } from "react";
- addTodoList()함수를 수정
- const id = useRef(Math.max(...todoList.map((todo) => todo.id)) + 1);
➡️id값은 express에서 idGen으로 생성(=서버가 만들어주고 있음)되고 있으므로 이 값또한 필요하지 않아 주석처리
➡️이렇게되면 useRef를 사용하고 있지 않으므로 import문에서 useRef를 빼주어도 된다.
➡️express에서 let idGen 변수에 저장되어있고, 기존에 저장되어있던 1, 2, 3번 데이터를 제외한
그 이후의 값을 받아와야하기때문에 let todo에서 id: ++idGen처럼 전위연산자를 통해
먼저 idGen값을 증가시키고 그 id에 값을 담을 수 있도록 한다. - todos.unshift(todo);
➡️unshift()를 통해 저장한 todo값을 todos 리스트에 추가하게된다. - async function addTodo(newTodo)
➡️async 비동기로 관리되는 addTodo()함수를 추가하고
위에서 만든 새로운 값들의 newTodo리스트를 인자로 받는다.
➡️저장한 데이터를 기준으로 다시 express서버로부터 새로운 데이터를 받아와서
리액트 Todo 프로젝트의 리스트에 갱신시켜주어야하므로 async/await를 통해 함수가 비동기로 관리되도록 하는 것 - axios.post()방식
➡️post()함수를 활용하여 데이터를 저장할 수 있다.
➡️ (express의 index.js의 post에 있는 URL과 일치시켜줌)
➡️첫번째 인자로는 저장할 주소를 요청한다. (localhost:1577/api/todos)
➡️두번째 인자로는 저장할 리스트를 보낸다. (newTodo) - await axios.get(URL)
➡️express서버로부터 가져온 데이터를 result에 데이터를 담는다. - setTodoList(result.data);
➡️useState의 set함수로 가져온 데이터 객체의 data부분 업데이트 - addTodo(newTodo)
➡️addTodo 함수 정의 후 새로운 newTodo를 addTodo()함수의 인자로 넣어 호출한다.
“새로운 할 일을 입력” 후 브라우저를 새로고침해도 그 데이터가 사라지지 않고
백엔드 서버에 데이터가 들어있기때문에 브라우저에 그대로 유지되고 있는 것을 확인가능
리액트에 Express 적용 (할일 삭제)
express서버의 삭제하는 부분 (index.js)
router.delete("/api/todos/:id", function (req, res) {
const id = parseInt(req.params.id);
todos = todos.filter((todo) => todo.id !== id);
res.status(200).json(todos);
});
- express에서 확인하면 router.delete(”/api/todos/:id”)처럼 id가 URL로 들어가야하기때문에
리액트 Todo의 삭제하는 부분 (TodoBox 컴포넌트)
// 2. 할 일 삭제
const deleteTodoList = (id) => {
async function deleteTodo(id){
await axios.delete(`http://localhost:1577/api/todos/${id}`);
const result = await axios.get("<http://localhost:1577/api/todos>");
setTodoList(result.data);
}
deleteTodo(id);
};
- await axios.delete(`http://localhost:1577/api/todos/${id}`);
➡️id값을 URL로 넣어주어야하기때문에 템플릿 리터럴 ` (백틱)을 활용하여 ${id}처럼 넣어준다. - 데이터 추가와 같이 result에 axios.get결과를 받아와 저장한 후
- deleteTodo(id) 로 호출하여 async 함수 실행을 명시해준다.
리액트에 Express 적용 (할일 수정)
express서버의 수정하는 부분 (index.js)
- index.js에 2가지의 patch가 구현되고 있는데
router.patch("/api/todos/:id", function (req, res) {
const id = parseInt(req.params.id);
const todo = todos.find((todo) => todo.id === id);
todo.done = !todo.done;
res.status(200).json(todos);
});
- "/api/todos:/id" 를 가지는 patch는 "추후 done속성을 추가하여 "완료된 할 일"을 수정해주는 역할을 한다.
router.patch("/api/todos", function (req, res) {
const id = req.body.id;
const title = req.body.title;
todos.map((todo) => {
if (todo.id === id) {
todo.title = title;
}
});
res.status(200).json({ result: "ok" });
});
- "/api/todos" 를 가지는 patch는 id, title값을 받아 수정해주고 있으므로 이 patch의 URL을 활용해야함
리액트 Todo의 수정하는 부분 (TodoBox 컴포넌트)
// 3. 할 일 수정
const updateTodoList = (todo) => {
async function updateTodo(todo) {
await axios.patch("<http://localhost:1577/api/todos>", todo);
const result = await axios.get("<http://localhost:1577/api/todos>");
setTodoList(result.data);
}
updateTodo(todo);
};
- await axios.patch("<http://localhost:1577/api/todos>", todo);
➡️async함수에서 patch()를 활용하여 todo를 전달해 수정


- express서버의 데이터를 리액트 Todo 프로젝트에 적용한 최종 결과이다.
- id값은 이 전의 테스트(수정, 삭제)로 인해 자동 증가되어 id:6번의 데이터만 남긴 결과이다.
- 브라우저를 새로고침하여도 추가, 삭제, 수정이 모두 잘 작동하고 있다.
➡️백엔드 서버를 재부팅하면 “리액트 공부하기”와 같은 데이터는 사라질 것이다.(=메모리에 저장되어있는 형태)
🚀+추가 : 브라우저 새로고침, 백엔드 서버 재부팅에도 영향이 없는 데이터를 관리하기 위해
데이터베이스를 활용하는 방법도 고려해볼 수 있다.
React 함수형 컴포넌트의 생명주기
- lifecycle (라이프 사이클)이라고도 하는 "생명주기"는 프로그램이 실행되어 생성되고 소멸될때까지의 주기를 의미
- ex.
1. 리액트에서 함수 호출하면
2. 해당함수가 실행
3. 그 후 JSX로 구현된 return()부를 만나서 컴포넌트가 화면에 마운팅(=Mounting)됨 - Mounting : 컴포넌트가 화면에 그려지는 형태 (화면에 데이터가 나타나는 것)
- Updating : 컴포넌트가 바뀐 값으로 화면에 다시 그려지는 형태
- UnMounting : 컴포넌트가 화면에서 삭제되는 형태 (화면에서 데이터가 사라지는 것)
- ⭐클래스형 컴포넌트에서 각각 다른 클래스로 Mounting, Updating, UnMounting 기능을 관리하고 있었다면
(ex. addTodoList(), deleteTodoList(), updateTodoList()…) - ⭐리액트의 함수형 컴포넌트에서는 useEffect()를 활용하여 이것들을 관리한다.
- useEffect()는 기본적으로 Mounting될때 동작하고, Updating이나 UnMounting은 조건에 따라 동작하게된다.
▶️실습 - useEffectExam 컴포넌트 : useEffect 생명주기 실습
const UseEffectExam = () => {
return <div>
</div>;
};
export default UseEffectExam;
- useEffect
➡️이 컴포넌트가 마운트, 업데이트, 마운트 해제될때 화면에 적용 - useEffect( () => {} )
➡️렌더링 될때마다 "매번 실행" - useEffect( () => {}, [유효배열])
➡️렌더링 될때마다 "매번 실행"
두번째 인자로 배열이 오는데 "dependency array" 라고 부름
즉 dependency array의 값이 변경될때마다 실행 - useEffect( () => {}, [ ])
➡️만약 두번째 인자를 빈 배열로 설정하게 되면 (= 비어있다면)
렌더링 시 최초 한번만 실행 (=최초 한번만 실행하고자 할때 이 방법 사용)
▶️실습 - useEffect( () ⇒ { } ) 의 생명주기
const UseEffectExam2 = () => {
console.log("UseEffectExam2 컴포넌트 렌더링!");
const [count, setCount] = useState(0);
useEffect(() => {
console.log("useEffect() 실행!!");
});
useEffect(() => {
console.log("useEffect() 일반 배열 [count] 실행!!");
}, [count]);
useEffect(() => {
console.log("useEffect() 빈 배열 [] 실행!!");
}, []);
return (
<div>
<span>count :: {count}</span>
<button
onClick={() => {
setCount(count + 1);
}}
>
UPDATE
</button>
</div>
);
};

- 버튼 클릭을 통해 useState로 상태관리가 되고 리렌더링되므로 체크해보면
- useEffect( () => {} )
➡️렌더링될때마다 실행됨을 알 수 있다. - useEffect( () => {}, [count])
➡️dependency array값(=count)이 바뀔 때 계속 useEffect를 통해 계속 실행되고 있음을 알 수 있다.
(count값을 증가시키는 setCount()함수때문에 count값이 계속 바뀌고 있음)

- 정리하자면 빈 배열일때는 최초 한번만 실행되고 그 이후로는 렌더링 되어도 실행되지 않음
▶️실습 - 리액트 Todo의 useEffect : TodoBox 컴포넌트
useEffect(() => {
async function getTodos() {
const result = await axios.get("<http://localhost:1577/api/todos>");
setTodoList(result.data);
}
getTodos();
}, []);
- TodoBox컴포넌트에서 useEffect( () ⇒ { } )를 통해 express서버의 리스트를 가져오는데
빈 배열이 두번째 인자로 들어왔으므로 "최초 실행 시 한번만 동작"하는 것 구현
useEffect()로 UnMounting기능구현
- useEffect( () ⇒ { return () ⇒ {clean up 코드}}, [] )
➡️이는 언마운팅될때 이 함수가 실행됨
return 부에 또 다른 함수가 들어오는 (함수 안의 함수) 형태 - clean up부분에 삭제 기능 구현 가능
UseEffectCleanUp 컴포넌트
- 타이머를 체크할 Timer컴포넌트 추가
const UseEffectCleanUp = () => {
const [showTimer, setShowTimer] = useState(false);
return <div>
{showTimer && <Timer/>}
<button onClick={() => setShowTimer(!showTimer)}>토글버튼</button>
</div>;
};
export default UseEffectCleanUp;
- const [showTimer, setShowTimer] = useState(false);
➡️showTimer를 상태관리 - <button onClick={() => setShowTimer(!showTimer)}>토글버튼</button>
➡️button의 람다함수 return부로 다시 setShowTimer() 함수가 위치
➡️이 함수의 기능은 부정연산자로 "!showTimer"를 해줌으로써
기본값으로 false를 가지는 showTimer값을 이벤트 발생 시 true/false로 다시 바꿔주는 역할을 수행 - showTimer && <Timer/>
➡️조건부 렌더링을 구현한 것으로 showTimer가 true일때 <Timer/> 컴포넌트가 “렌더링된다”
이 패턴은 조건에 따라 특정 컴포넌트를 동적으로 렌더링하거나 제거할 때 자주 사용
Timer 컴포넌트
import { useEffect } from "react";
const Timer = () => {
useEffect(() => {
setInterval(() => {
console.log("타이머 실행 중!");
}, 1000);
});
return (
<div>
<span>타이머 시작!</span>
</div>
);
};
export default Timer;
- setInterval()
➡️useEffect함수 안에 함수로 위치.
➡️1000ms (=1초)마다 “타이머 실행 중!”을 콘솔에 출력 - UseEffectCleanUp 컴포넌트에서 토글버튼이 눌리면 타이머 컴포넌트가 렌더링되어 "마운트"
- 다시 토글버튼을 눌러 타이머 컴포넌트를 언마운트해도 계속해서 “타이머 실행 중!” 콘솔 출력
➡️해결방법 : 타이머를 꺼줄 수 있는 기능 (종료 기능)이 cleanUp하는 코드이다.
이는 clearInterval()이 담당
Timer컴포넌트 - clearInterval 추가
const Timer = () => {
useEffect(() => {
const timer = setInterval(() => {
console.log("타이머 실행 중!");
}, 1000);
return () => {
clearInterval(timer);
console.log("타이머 종료!");
};
});
- const timer = setInterval(() => { }
➡️ timer 변수에 setInterval 함수의 기능을 담아줌 - return () => { clearInterval(timer); };
➡️useEffect()의 리턴부에서 clean up코드를 작성
➡️언마운팅될떄 이 함수 실행
timer변수는 setInterval 함수를 담고 있으므로 이 변수를 종료시킬 수 있음

- 결과 :
➡️토글버튼을 눌러 “타이머”를 활성시키면(=마운팅 Mounting) “타이머 실행 중!”이 발생
➡️다시 토글버튼을 눌러 “타이머” 컴포넌트를 비활성화시키면(=언마운팅 UnMounting) “타이머 종료”로 종료
리액트 함수형 컴포넌트 생명주기 정리
- 함수형 컴포넌트 호출
- 함수형 컴포넌트의 내부에서 실행
- return()으로 화면에 렌더링
- useEffect() 실행
- 만약 조건부가 있으면 (변경 혹은 컴포넌트의 소멸이 발생했을때 useEffect()실행)
렌더링 : Rendering, 리액트의 "컴포넌트 렌더링"은 컴포넌트 내 엘리먼트 요소들 (HTML 등)을 화면상에 그림 = 마운트
리렌더링 : re-Rendering, 리액트의 "컴포넌트 리렌더링"은 컴포넌트 내 엘리먼트 요소들 화면에 다시 그림 = 언마운트
deps의 값 (dependency array) |
구조 | 설명 |
값이 없을 경우 | useEffect( () => {} ) | 화면이 렌더링 된 이후 수행 리렌더링 시 다시 수행 |
빈 배열 | useEffect( () => {}, []) | 화면이 렌더링 된 이후에만 수행 |
배열 값이 존재하는 경우 | useEffect( () => {}, [값] ) | 화면이 렌더링 된 이후 수행 "값이 변경되었을때" 수행 |
useEffect 활용
- deps 배열이 비어있는 경우 컴포넌트가 사라질때 clean up 함수 호출
- useEffect의 마운트 : 외부 API요청, 라이브러리 사용, setInterval 반복작업, setTimeout 작업 예약 등을 수행
- useEffect의 언마운트 : setInterval, setTimeout을 사용하여 등록한 작업들을 clear ➡️clearInterval, clearTimeout
성능최적화를 위한 리액트 함수
useMemo
- 컴포넌트의 성능을 최적화시킬 수 있는 대표적인 리액트의 Hook중의 하나
- Memoization 활용
➡️기존 수행한 연산의 결과값을 저장해두고 동일한 입력이 들어오면 재활용하는 프로그래밍 기법
useCallback
- 컴포넌트의 성능을 최적화시킬 수 있는 대표적인 리액트의 Hook중의 하나
- 컴포넌트 내에서 함수를 생성하고 있다면 새로운 함수 참조값을 계속해서 만들어내고 있는 것 = 비효율적
➡️값이 수정될때에만 useCallback함수 사용으로 성능을 최적화 - 의존성 부분에 빈 배열을 주면 최초에 생성된 함수를 지속적으로 기억
➡️메모리에 새로 할당되지 않고 동일 참조 값을 재사용
🚀실습 - 메모 프로젝트
- NoteBox : 메모 데이터를 관리하며 전체 애플리케이션의 상태를 담당하는 컴포넌트
- NoteInput : 새로운 메모를 추가하기 위한 입력 컴포넌트
- NoteList : 모든 메모를 렌더링하는 컴포넌트
- Note 컴포넌트 : 단일 메모를 렌더링하며 수정/삭제 기능을 제공하는 컴포넌트
- +NoteCheck 컴포넌트 : 필터링된 메모를 출력할 수 있도록 필터를 교체하는 컴포넌트

NoteBox 컴포넌트
const [datalist, setDatalist] = useState([
{ id: 1, title: "자료구조", createdAt: new Date(), done: true },
{ id: 2, title: "코딩인터뷰", createdAt: new Date(), done: false },
{ id: 3, title: "안드로이드", createdAt: new Date(), done: false },
]);
- 샘플데이터에 createdAt 속성과 done 속성을 추가
const addNoteHandler = (title, date) => {
const newNote = {
id: id.current++,
title: title,
createdAt: date,
done: false, // 새로 추가된 메모는 "미완료"적용
};
setDatalist([...datalist, newNote]);
};
- "메모"를 추가하는 부분에서는 title, date를 받아서 데이터 추가
- 후위연산자를 활용해 useRef의 id값 증가
const deleteNoteHandler = (id) => {
setDatalist(datalist.filter((note) => note.id !== id));
// 일치하지 않는 것만 새 datalist로 만듦
};
- filter를 이용하여 note(NoteBox컴포넌트에 담긴 기존 데이터)의 id값과 삭제할 데이터의 id값을 비교하여
해당하지 않는 것만 새로운 배열로 저장
const updateNoteHandler = (afterNote) => {
// 노트 객체를 한번에 받아와서 id를 비교하여 title과 createdAt 수정 가능
const doneUpdate = datalist.map((beforeNote) => {
if (beforeNote.id === afterNote.id) {
return {
...beforeNote, // 기존 배열 먼저 배치
title: afterNote.title,
createdAt: new Date().toLocaleString(), // 수정된 현재 시점으로 작성시간 수정
};
}
return beforeNote; // 수정할 id가 datalist에 존재하지 않을 경우 "기존 리스트 리턴"
});
setDatalist(doneUpdate); // 수정한 리스트를 현재 리스트로 적용
};
- beforeNote (NoteBox컴포넌트에 담긴 기존 데이터), afterNote (수정된 데이터)
- doneUpdate 변수에 ...beforeNote로 기존 배열 요소를 먼저 저장 후
title은 새로 들어온 수정 값
createdAt은 수정된 시점으로 갱신
NoteInput 컴포넌트
if (e.key === "Enter") {
const uploadDate = new Date().toLocaleString();
addNote(e.target.value, uploadDate);
e.target.value = "";
}
- Enter를 입력하여 "메모"가 입력된 시점의 날짜/시간 정보를 uploadDate에 담아 addNote 함수에 인자로 전달
NoteList 컴포넌트
return (
<ul>
{datalist.map((note) => {
const formattedDate = note.createdAt.toLocaleString();
return (
<li key={note.id}>
<Note note={note} deleteNote={deleteNote} updateNote={updateNote} />
<h5 className="created">Created: {formattedDate}</h5>
</li>
);
})}
</ul>
);
- const formattedDate = note.createdAt.toLocaleString();
➡️formattedDate 변수에 new Date()가 담긴 전달된 데이터리스트를 toLocaleString()으로 변환
Note 컴포넌트
const toggleUpdateForm = () => {
setUpdateMode(true);
console.log("수정 가능한 (" + updateMode + ") 상태입니다!");
};
const cancelUpdateForm = () => {
setUpdateMode(false);
console.log("수정을 취소합니다. updateMode : " + updateMode);
};
const changeTitleHandler = (e) => {
setNewTitle(e.target.value);
};
const patchDataHandler = () => {
updateNote({ id: note.id, title: newTitle, createdAt: note.createdAt });
setUpdateMode(false);
};
- const cancelUpdateForm = () => {...}
➡️setUpdateMode(false)를 하여 다시 수정 폼을 언마운트시키는 기능 구현 - const patchDataHandler = () => {...}
➡️updateNote( { id, title, createdAt } )
함수를 호출하는데 title에는 newTitle로 갱신시켜주는 것을 제외하고는 기존의 데이터리스트 속성을 그대로 보냄

NoteCheck 컴포넌트
- 전체보기 / 완료된 메모 / 미완료 메모 에 대한 필터링을 수행하기 위한 컴포넌트
const NoteCheck = ({ setFilter }) => {
const showAllNoteHandler = () => {
setFilter("all");
};
const showDoneNoteHandler = () => {
setFilter("done");
};
const showNotDoneNoteHandler = () => {
setFilter("notDone");
};
return (
<>
<button onClick={showAllNoteHandler}>전체 보기</button>
<button onClick={showDoneNoteHandler}>완료된 메모</button>
<button onClick={showNotDoneNoteHandler}>미완료 메모</button>
</>
);
};
export default NoteCheck;
NoteBox컴포넌트에 "필터링 함수" 추가
const [filter, setFilter] = useState("all"); // 기본 값 (전체보기)
//...
const afterFiltering = () => {
if (filter === "done") {
// note.done자체가 boolean이므로 true인 done속성만 필터링 (완료)
return datalist.filter((note) => note.done);
}
if (filter === "notDone") {
// 부정연산자로 false인 done속성 필터링 (미완료)
return datalist.filter((note) => !note.done);
}
// done, notDone 필터링에서 거쳐지지 않은 datalist
return datalist; // 전체보기
};
return (
<div>
<h1 className="projectTitle">MyMeMo🐈</h1>
<NoteInput addNote={addNoteHandler} />
<NoteCheck setFilter={setFilter} />
<NoteList
datalist={afterFiltering()} // afterFiltering()의 반환값을 인자로 전달
deleteNote={deleteNoteHandler}
updateNote={updateNoteHandler}
/>
</div>
);
- filter를 useState로 상태관리를 하고 "all"이면 전체보기, "done"이면 완료된 메모, "notDone"이면 미완료된 메모를
마운트 할 수 있도록 적용 - const afterFiltering = () => {...}
➡️done속성은 true, false의 boolean이므로
filter함수에서 true이면 "done"을 반환
filter함수에서 false이면 "notDone"을 반환 - <NoteCheck setFilter={setFilter} />
➡️setFilter을 속성으로 전달하여 NoteCheck컴포넌트에서 필터 수행 - <NoteList datalist={afterFiltering()} .../>
➡️afterFiltering()의 반환값을 인자로 전달하여 NoteList에서 필터링된 리스트를 마운트
❓filter() 함수
➡️자바스크립트의 배열 함수로 "element 배열로부터 특정 조건을 만족하는 요소만 추출해 새로운 배열로 반환"
array.filter((element) => 조건식);
- 이 경우 조건식이 true인 요소만 필터링
"메모"프로젝트의 필터링을 예로 보면
if (filter === "done") {
return datalist.filter((note) => note.done);
}
- filter는 배열의 각 요소를 순회
- (note) => note.done
➡️매개변수 note : 배열의 한 요소
➡️note.done : 해당 요소의 done 속성 값 반환
➡️note.done===true : 조건을 만족하므로 필터링
즉 note.done이 true인 요소들만 새로운 배열로 만들어 반환

🚀 회고를 통해 "메모" 프로젝트를 구현해볼 수 있었다.
useEffect의 사용으로 생명주기를 "콘솔"로 테스트해보면서 언제 실행되고 소멸되는지를 알 수 있었다.
그에 따라 마운트, 언마운트 개념에 대해 알게되었다.
이번 프로젝트에서 고민하고 생각해내는 과정이 힘들었지만 기능을 하나하나 구현해나갈 수 있었다.
'Recording > 멋쟁이사자처럼 BE 13기' 카테고리의 다른 글
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_33일차_"스프링 DI/IoC" (1) | 2025.01.17 |
---|---|
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_32일차_"스프링 프레임워크" (0) | 2025.01.16 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_30일차_"리액트 Express" (0) | 2025.01.14 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_30일차_"리액트 Todo" (1) | 2025.01.14 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_29일차_"useState, useRef" (0) | 2025.01.13 |
🦁멋쟁이사자처럼 백엔드 부트캠프 13기 🦁
TIL 회고 - [31]일차
🚀31일차에는 먼저 Express 서버로 수정/삭제 기능 구현을 모두 완료하고 useEffect와 useEffect에서 cleanUp하는 부분을 배울 수 있었다.
그 후 리액트로 구현했던 Todo 리스트 프로젝트를 조금 더 개선하여 "작성날짜"와 "할일완료유무" 등을 확인할 수 있도록 Memo 프로젝트를 진행하였다.
잘 구현할 수 없을 것 같던 투두리스트 프로젝트를 기능별로 하나씩 구현해보니 시간은 꽤 오래걸렸지만 원하는 기능을 구현해낼 수 있었다.회고를 통해 프로젝트를 구현하면서 했던 생각들을 정리하고자한다!
리액트에 Express 적용 (할일 추가)
express서버의 추가하는 부분 (index.js)
let idGen = 3;
router.post("/api/todos", function (req, res) {
let title = req.body.title;
let todo = { id: ++idGen, title: title, done: false };
todos.unshift(todo);
res.status(200).json(todo);
});
리액트 Todo의 추가하는 부분 (TodoBox 컴포넌트)
// 1. 할 일 추가
const addTodoList = (title) => {
const newTodo = {
title: title,
};
async function addTodo(newTodo) {
// 위에서 새로추가한 newTodo 리스트를 보내줌
await axios.post("<http://localhost:1577/api/todos>", newTodo); // 저장을 구현하는 메소드 (express의 index.js의 post와 일치시켜줌)
const result = await axios.get("<http://localhost:1577/api/todos>");
setTodoList(result.data);
}
addTodo(newTodo);
};
//import { useRef } from "react";
import { useEffect, useState } from "react";
- addTodoList()함수를 수정
- const id = useRef(Math.max(...todoList.map((todo) => todo.id)) + 1);
➡️id값은 express에서 idGen으로 생성(=서버가 만들어주고 있음)되고 있으므로 이 값또한 필요하지 않아 주석처리
➡️이렇게되면 useRef를 사용하고 있지 않으므로 import문에서 useRef를 빼주어도 된다.
➡️express에서 let idGen 변수에 저장되어있고, 기존에 저장되어있던 1, 2, 3번 데이터를 제외한
그 이후의 값을 받아와야하기때문에 let todo에서 id: ++idGen처럼 전위연산자를 통해
먼저 idGen값을 증가시키고 그 id에 값을 담을 수 있도록 한다. - todos.unshift(todo);
➡️unshift()를 통해 저장한 todo값을 todos 리스트에 추가하게된다. - async function addTodo(newTodo)
➡️async 비동기로 관리되는 addTodo()함수를 추가하고
위에서 만든 새로운 값들의 newTodo리스트를 인자로 받는다.
➡️저장한 데이터를 기준으로 다시 express서버로부터 새로운 데이터를 받아와서
리액트 Todo 프로젝트의 리스트에 갱신시켜주어야하므로 async/await를 통해 함수가 비동기로 관리되도록 하는 것 - axios.post()방식
➡️post()함수를 활용하여 데이터를 저장할 수 있다.
➡️ (express의 index.js의 post에 있는 URL과 일치시켜줌)
➡️첫번째 인자로는 저장할 주소를 요청한다. (localhost:1577/api/todos)
➡️두번째 인자로는 저장할 리스트를 보낸다. (newTodo) - await axios.get(URL)
➡️express서버로부터 가져온 데이터를 result에 데이터를 담는다. - setTodoList(result.data);
➡️useState의 set함수로 가져온 데이터 객체의 data부분 업데이트 - addTodo(newTodo)
➡️addTodo 함수 정의 후 새로운 newTodo를 addTodo()함수의 인자로 넣어 호출한다.
“새로운 할 일을 입력” 후 브라우저를 새로고침해도 그 데이터가 사라지지 않고
백엔드 서버에 데이터가 들어있기때문에 브라우저에 그대로 유지되고 있는 것을 확인가능
리액트에 Express 적용 (할일 삭제)
express서버의 삭제하는 부분 (index.js)
router.delete("/api/todos/:id", function (req, res) {
const id = parseInt(req.params.id);
todos = todos.filter((todo) => todo.id !== id);
res.status(200).json(todos);
});
- express에서 확인하면 router.delete(”/api/todos/:id”)처럼 id가 URL로 들어가야하기때문에
리액트 Todo의 삭제하는 부분 (TodoBox 컴포넌트)
// 2. 할 일 삭제
const deleteTodoList = (id) => {
async function deleteTodo(id){
await axios.delete(`http://localhost:1577/api/todos/${id}`);
const result = await axios.get("<http://localhost:1577/api/todos>");
setTodoList(result.data);
}
deleteTodo(id);
};
- await axios.delete(`http://localhost:1577/api/todos/${id}`);
➡️id값을 URL로 넣어주어야하기때문에 템플릿 리터럴 ` (백틱)을 활용하여 ${id}처럼 넣어준다. - 데이터 추가와 같이 result에 axios.get결과를 받아와 저장한 후
- deleteTodo(id) 로 호출하여 async 함수 실행을 명시해준다.
리액트에 Express 적용 (할일 수정)
express서버의 수정하는 부분 (index.js)
- index.js에 2가지의 patch가 구현되고 있는데
router.patch("/api/todos/:id", function (req, res) {
const id = parseInt(req.params.id);
const todo = todos.find((todo) => todo.id === id);
todo.done = !todo.done;
res.status(200).json(todos);
});
- "/api/todos:/id" 를 가지는 patch는 "추후 done속성을 추가하여 "완료된 할 일"을 수정해주는 역할을 한다.
router.patch("/api/todos", function (req, res) {
const id = req.body.id;
const title = req.body.title;
todos.map((todo) => {
if (todo.id === id) {
todo.title = title;
}
});
res.status(200).json({ result: "ok" });
});
- "/api/todos" 를 가지는 patch는 id, title값을 받아 수정해주고 있으므로 이 patch의 URL을 활용해야함
리액트 Todo의 수정하는 부분 (TodoBox 컴포넌트)
// 3. 할 일 수정
const updateTodoList = (todo) => {
async function updateTodo(todo) {
await axios.patch("<http://localhost:1577/api/todos>", todo);
const result = await axios.get("<http://localhost:1577/api/todos>");
setTodoList(result.data);
}
updateTodo(todo);
};
- await axios.patch("<http://localhost:1577/api/todos>", todo);
➡️async함수에서 patch()를 활용하여 todo를 전달해 수정


- express서버의 데이터를 리액트 Todo 프로젝트에 적용한 최종 결과이다.
- id값은 이 전의 테스트(수정, 삭제)로 인해 자동 증가되어 id:6번의 데이터만 남긴 결과이다.
- 브라우저를 새로고침하여도 추가, 삭제, 수정이 모두 잘 작동하고 있다.
➡️백엔드 서버를 재부팅하면 “리액트 공부하기”와 같은 데이터는 사라질 것이다.(=메모리에 저장되어있는 형태)
🚀+추가 : 브라우저 새로고침, 백엔드 서버 재부팅에도 영향이 없는 데이터를 관리하기 위해
데이터베이스를 활용하는 방법도 고려해볼 수 있다.
React 함수형 컴포넌트의 생명주기
- lifecycle (라이프 사이클)이라고도 하는 "생명주기"는 프로그램이 실행되어 생성되고 소멸될때까지의 주기를 의미
- ex.
1. 리액트에서 함수 호출하면
2. 해당함수가 실행
3. 그 후 JSX로 구현된 return()부를 만나서 컴포넌트가 화면에 마운팅(=Mounting)됨 - Mounting : 컴포넌트가 화면에 그려지는 형태 (화면에 데이터가 나타나는 것)
- Updating : 컴포넌트가 바뀐 값으로 화면에 다시 그려지는 형태
- UnMounting : 컴포넌트가 화면에서 삭제되는 형태 (화면에서 데이터가 사라지는 것)
- ⭐클래스형 컴포넌트에서 각각 다른 클래스로 Mounting, Updating, UnMounting 기능을 관리하고 있었다면
(ex. addTodoList(), deleteTodoList(), updateTodoList()…) - ⭐리액트의 함수형 컴포넌트에서는 useEffect()를 활용하여 이것들을 관리한다.
- useEffect()는 기본적으로 Mounting될때 동작하고, Updating이나 UnMounting은 조건에 따라 동작하게된다.
▶️실습 - useEffectExam 컴포넌트 : useEffect 생명주기 실습
const UseEffectExam = () => {
return <div>
</div>;
};
export default UseEffectExam;
- useEffect
➡️이 컴포넌트가 마운트, 업데이트, 마운트 해제될때 화면에 적용 - useEffect( () => {} )
➡️렌더링 될때마다 "매번 실행" - useEffect( () => {}, [유효배열])
➡️렌더링 될때마다 "매번 실행"
두번째 인자로 배열이 오는데 "dependency array" 라고 부름
즉 dependency array의 값이 변경될때마다 실행 - useEffect( () => {}, [ ])
➡️만약 두번째 인자를 빈 배열로 설정하게 되면 (= 비어있다면)
렌더링 시 최초 한번만 실행 (=최초 한번만 실행하고자 할때 이 방법 사용)
▶️실습 - useEffect( () ⇒ { } ) 의 생명주기
const UseEffectExam2 = () => {
console.log("UseEffectExam2 컴포넌트 렌더링!");
const [count, setCount] = useState(0);
useEffect(() => {
console.log("useEffect() 실행!!");
});
useEffect(() => {
console.log("useEffect() 일반 배열 [count] 실행!!");
}, [count]);
useEffect(() => {
console.log("useEffect() 빈 배열 [] 실행!!");
}, []);
return (
<div>
<span>count :: {count}</span>
<button
onClick={() => {
setCount(count + 1);
}}
>
UPDATE
</button>
</div>
);
};

- 버튼 클릭을 통해 useState로 상태관리가 되고 리렌더링되므로 체크해보면
- useEffect( () => {} )
➡️렌더링될때마다 실행됨을 알 수 있다. - useEffect( () => {}, [count])
➡️dependency array값(=count)이 바뀔 때 계속 useEffect를 통해 계속 실행되고 있음을 알 수 있다.
(count값을 증가시키는 setCount()함수때문에 count값이 계속 바뀌고 있음)

- 정리하자면 빈 배열일때는 최초 한번만 실행되고 그 이후로는 렌더링 되어도 실행되지 않음
▶️실습 - 리액트 Todo의 useEffect : TodoBox 컴포넌트
useEffect(() => {
async function getTodos() {
const result = await axios.get("<http://localhost:1577/api/todos>");
setTodoList(result.data);
}
getTodos();
}, []);
- TodoBox컴포넌트에서 useEffect( () ⇒ { } )를 통해 express서버의 리스트를 가져오는데
빈 배열이 두번째 인자로 들어왔으므로 "최초 실행 시 한번만 동작"하는 것 구현
useEffect()로 UnMounting기능구현
- useEffect( () ⇒ { return () ⇒ {clean up 코드}}, [] )
➡️이는 언마운팅될때 이 함수가 실행됨
return 부에 또 다른 함수가 들어오는 (함수 안의 함수) 형태 - clean up부분에 삭제 기능 구현 가능
UseEffectCleanUp 컴포넌트
- 타이머를 체크할 Timer컴포넌트 추가
const UseEffectCleanUp = () => {
const [showTimer, setShowTimer] = useState(false);
return <div>
{showTimer && <Timer/>}
<button onClick={() => setShowTimer(!showTimer)}>토글버튼</button>
</div>;
};
export default UseEffectCleanUp;
- const [showTimer, setShowTimer] = useState(false);
➡️showTimer를 상태관리 - <button onClick={() => setShowTimer(!showTimer)}>토글버튼</button>
➡️button의 람다함수 return부로 다시 setShowTimer() 함수가 위치
➡️이 함수의 기능은 부정연산자로 "!showTimer"를 해줌으로써
기본값으로 false를 가지는 showTimer값을 이벤트 발생 시 true/false로 다시 바꿔주는 역할을 수행 - showTimer && <Timer/>
➡️조건부 렌더링을 구현한 것으로 showTimer가 true일때 <Timer/> 컴포넌트가 “렌더링된다”
이 패턴은 조건에 따라 특정 컴포넌트를 동적으로 렌더링하거나 제거할 때 자주 사용
Timer 컴포넌트
import { useEffect } from "react";
const Timer = () => {
useEffect(() => {
setInterval(() => {
console.log("타이머 실행 중!");
}, 1000);
});
return (
<div>
<span>타이머 시작!</span>
</div>
);
};
export default Timer;
- setInterval()
➡️useEffect함수 안에 함수로 위치.
➡️1000ms (=1초)마다 “타이머 실행 중!”을 콘솔에 출력 - UseEffectCleanUp 컴포넌트에서 토글버튼이 눌리면 타이머 컴포넌트가 렌더링되어 "마운트"
- 다시 토글버튼을 눌러 타이머 컴포넌트를 언마운트해도 계속해서 “타이머 실행 중!” 콘솔 출력
➡️해결방법 : 타이머를 꺼줄 수 있는 기능 (종료 기능)이 cleanUp하는 코드이다.
이는 clearInterval()이 담당
Timer컴포넌트 - clearInterval 추가
const Timer = () => {
useEffect(() => {
const timer = setInterval(() => {
console.log("타이머 실행 중!");
}, 1000);
return () => {
clearInterval(timer);
console.log("타이머 종료!");
};
});
- const timer = setInterval(() => { }
➡️ timer 변수에 setInterval 함수의 기능을 담아줌 - return () => { clearInterval(timer); };
➡️useEffect()의 리턴부에서 clean up코드를 작성
➡️언마운팅될떄 이 함수 실행
timer변수는 setInterval 함수를 담고 있으므로 이 변수를 종료시킬 수 있음

- 결과 :
➡️토글버튼을 눌러 “타이머”를 활성시키면(=마운팅 Mounting) “타이머 실행 중!”이 발생
➡️다시 토글버튼을 눌러 “타이머” 컴포넌트를 비활성화시키면(=언마운팅 UnMounting) “타이머 종료”로 종료
리액트 함수형 컴포넌트 생명주기 정리
- 함수형 컴포넌트 호출
- 함수형 컴포넌트의 내부에서 실행
- return()으로 화면에 렌더링
- useEffect() 실행
- 만약 조건부가 있으면 (변경 혹은 컴포넌트의 소멸이 발생했을때 useEffect()실행)
렌더링 : Rendering, 리액트의 "컴포넌트 렌더링"은 컴포넌트 내 엘리먼트 요소들 (HTML 등)을 화면상에 그림 = 마운트
리렌더링 : re-Rendering, 리액트의 "컴포넌트 리렌더링"은 컴포넌트 내 엘리먼트 요소들 화면에 다시 그림 = 언마운트
deps의 값 (dependency array) |
구조 | 설명 |
값이 없을 경우 | useEffect( () => {} ) | 화면이 렌더링 된 이후 수행 리렌더링 시 다시 수행 |
빈 배열 | useEffect( () => {}, []) | 화면이 렌더링 된 이후에만 수행 |
배열 값이 존재하는 경우 | useEffect( () => {}, [값] ) | 화면이 렌더링 된 이후 수행 "값이 변경되었을때" 수행 |
useEffect 활용
- deps 배열이 비어있는 경우 컴포넌트가 사라질때 clean up 함수 호출
- useEffect의 마운트 : 외부 API요청, 라이브러리 사용, setInterval 반복작업, setTimeout 작업 예약 등을 수행
- useEffect의 언마운트 : setInterval, setTimeout을 사용하여 등록한 작업들을 clear ➡️clearInterval, clearTimeout
성능최적화를 위한 리액트 함수
useMemo
- 컴포넌트의 성능을 최적화시킬 수 있는 대표적인 리액트의 Hook중의 하나
- Memoization 활용
➡️기존 수행한 연산의 결과값을 저장해두고 동일한 입력이 들어오면 재활용하는 프로그래밍 기법
useCallback
- 컴포넌트의 성능을 최적화시킬 수 있는 대표적인 리액트의 Hook중의 하나
- 컴포넌트 내에서 함수를 생성하고 있다면 새로운 함수 참조값을 계속해서 만들어내고 있는 것 = 비효율적
➡️값이 수정될때에만 useCallback함수 사용으로 성능을 최적화 - 의존성 부분에 빈 배열을 주면 최초에 생성된 함수를 지속적으로 기억
➡️메모리에 새로 할당되지 않고 동일 참조 값을 재사용
🚀실습 - 메모 프로젝트
- NoteBox : 메모 데이터를 관리하며 전체 애플리케이션의 상태를 담당하는 컴포넌트
- NoteInput : 새로운 메모를 추가하기 위한 입력 컴포넌트
- NoteList : 모든 메모를 렌더링하는 컴포넌트
- Note 컴포넌트 : 단일 메모를 렌더링하며 수정/삭제 기능을 제공하는 컴포넌트
- +NoteCheck 컴포넌트 : 필터링된 메모를 출력할 수 있도록 필터를 교체하는 컴포넌트

NoteBox 컴포넌트
const [datalist, setDatalist] = useState([
{ id: 1, title: "자료구조", createdAt: new Date(), done: true },
{ id: 2, title: "코딩인터뷰", createdAt: new Date(), done: false },
{ id: 3, title: "안드로이드", createdAt: new Date(), done: false },
]);
- 샘플데이터에 createdAt 속성과 done 속성을 추가
const addNoteHandler = (title, date) => {
const newNote = {
id: id.current++,
title: title,
createdAt: date,
done: false, // 새로 추가된 메모는 "미완료"적용
};
setDatalist([...datalist, newNote]);
};
- "메모"를 추가하는 부분에서는 title, date를 받아서 데이터 추가
- 후위연산자를 활용해 useRef의 id값 증가
const deleteNoteHandler = (id) => {
setDatalist(datalist.filter((note) => note.id !== id));
// 일치하지 않는 것만 새 datalist로 만듦
};
- filter를 이용하여 note(NoteBox컴포넌트에 담긴 기존 데이터)의 id값과 삭제할 데이터의 id값을 비교하여
해당하지 않는 것만 새로운 배열로 저장
const updateNoteHandler = (afterNote) => {
// 노트 객체를 한번에 받아와서 id를 비교하여 title과 createdAt 수정 가능
const doneUpdate = datalist.map((beforeNote) => {
if (beforeNote.id === afterNote.id) {
return {
...beforeNote, // 기존 배열 먼저 배치
title: afterNote.title,
createdAt: new Date().toLocaleString(), // 수정된 현재 시점으로 작성시간 수정
};
}
return beforeNote; // 수정할 id가 datalist에 존재하지 않을 경우 "기존 리스트 리턴"
});
setDatalist(doneUpdate); // 수정한 리스트를 현재 리스트로 적용
};
- beforeNote (NoteBox컴포넌트에 담긴 기존 데이터), afterNote (수정된 데이터)
- doneUpdate 변수에 ...beforeNote로 기존 배열 요소를 먼저 저장 후
title은 새로 들어온 수정 값
createdAt은 수정된 시점으로 갱신
NoteInput 컴포넌트
if (e.key === "Enter") {
const uploadDate = new Date().toLocaleString();
addNote(e.target.value, uploadDate);
e.target.value = "";
}
- Enter를 입력하여 "메모"가 입력된 시점의 날짜/시간 정보를 uploadDate에 담아 addNote 함수에 인자로 전달
NoteList 컴포넌트
return (
<ul>
{datalist.map((note) => {
const formattedDate = note.createdAt.toLocaleString();
return (
<li key={note.id}>
<Note note={note} deleteNote={deleteNote} updateNote={updateNote} />
<h5 className="created">Created: {formattedDate}</h5>
</li>
);
})}
</ul>
);
- const formattedDate = note.createdAt.toLocaleString();
➡️formattedDate 변수에 new Date()가 담긴 전달된 데이터리스트를 toLocaleString()으로 변환
Note 컴포넌트
const toggleUpdateForm = () => {
setUpdateMode(true);
console.log("수정 가능한 (" + updateMode + ") 상태입니다!");
};
const cancelUpdateForm = () => {
setUpdateMode(false);
console.log("수정을 취소합니다. updateMode : " + updateMode);
};
const changeTitleHandler = (e) => {
setNewTitle(e.target.value);
};
const patchDataHandler = () => {
updateNote({ id: note.id, title: newTitle, createdAt: note.createdAt });
setUpdateMode(false);
};
- const cancelUpdateForm = () => {...}
➡️setUpdateMode(false)를 하여 다시 수정 폼을 언마운트시키는 기능 구현 - const patchDataHandler = () => {...}
➡️updateNote( { id, title, createdAt } )
함수를 호출하는데 title에는 newTitle로 갱신시켜주는 것을 제외하고는 기존의 데이터리스트 속성을 그대로 보냄

NoteCheck 컴포넌트
- 전체보기 / 완료된 메모 / 미완료 메모 에 대한 필터링을 수행하기 위한 컴포넌트
const NoteCheck = ({ setFilter }) => {
const showAllNoteHandler = () => {
setFilter("all");
};
const showDoneNoteHandler = () => {
setFilter("done");
};
const showNotDoneNoteHandler = () => {
setFilter("notDone");
};
return (
<>
<button onClick={showAllNoteHandler}>전체 보기</button>
<button onClick={showDoneNoteHandler}>완료된 메모</button>
<button onClick={showNotDoneNoteHandler}>미완료 메모</button>
</>
);
};
export default NoteCheck;
NoteBox컴포넌트에 "필터링 함수" 추가
const [filter, setFilter] = useState("all"); // 기본 값 (전체보기)
//...
const afterFiltering = () => {
if (filter === "done") {
// note.done자체가 boolean이므로 true인 done속성만 필터링 (완료)
return datalist.filter((note) => note.done);
}
if (filter === "notDone") {
// 부정연산자로 false인 done속성 필터링 (미완료)
return datalist.filter((note) => !note.done);
}
// done, notDone 필터링에서 거쳐지지 않은 datalist
return datalist; // 전체보기
};
return (
<div>
<h1 className="projectTitle">MyMeMo🐈</h1>
<NoteInput addNote={addNoteHandler} />
<NoteCheck setFilter={setFilter} />
<NoteList
datalist={afterFiltering()} // afterFiltering()의 반환값을 인자로 전달
deleteNote={deleteNoteHandler}
updateNote={updateNoteHandler}
/>
</div>
);
- filter를 useState로 상태관리를 하고 "all"이면 전체보기, "done"이면 완료된 메모, "notDone"이면 미완료된 메모를
마운트 할 수 있도록 적용 - const afterFiltering = () => {...}
➡️done속성은 true, false의 boolean이므로
filter함수에서 true이면 "done"을 반환
filter함수에서 false이면 "notDone"을 반환 - <NoteCheck setFilter={setFilter} />
➡️setFilter을 속성으로 전달하여 NoteCheck컴포넌트에서 필터 수행 - <NoteList datalist={afterFiltering()} .../>
➡️afterFiltering()의 반환값을 인자로 전달하여 NoteList에서 필터링된 리스트를 마운트
❓filter() 함수
➡️자바스크립트의 배열 함수로 "element 배열로부터 특정 조건을 만족하는 요소만 추출해 새로운 배열로 반환"
array.filter((element) => 조건식);
- 이 경우 조건식이 true인 요소만 필터링
"메모"프로젝트의 필터링을 예로 보면
if (filter === "done") {
return datalist.filter((note) => note.done);
}
- filter는 배열의 각 요소를 순회
- (note) => note.done
➡️매개변수 note : 배열의 한 요소
➡️note.done : 해당 요소의 done 속성 값 반환
➡️note.done===true : 조건을 만족하므로 필터링
즉 note.done이 true인 요소들만 새로운 배열로 만들어 반환

🚀 회고를 통해 "메모" 프로젝트를 구현해볼 수 있었다.
useEffect의 사용으로 생명주기를 "콘솔"로 테스트해보면서 언제 실행되고 소멸되는지를 알 수 있었다.
그에 따라 마운트, 언마운트 개념에 대해 알게되었다.
이번 프로젝트에서 고민하고 생각해내는 과정이 힘들었지만 기능을 하나하나 구현해나갈 수 있었다.
'Recording > 멋쟁이사자처럼 BE 13기' 카테고리의 다른 글
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_33일차_"스프링 DI/IoC" (1) | 2025.01.17 |
---|---|
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_32일차_"스프링 프레임워크" (0) | 2025.01.16 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_30일차_"리액트 Express" (0) | 2025.01.14 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_30일차_"리액트 Todo" (1) | 2025.01.14 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_29일차_"useState, useRef" (0) | 2025.01.13 |