카테고리 없음

[생활코딩 밤공부 1기] React(3) - 이벤트와 effect

조앤박 2023. 8. 1. 15:16

생활코딩 밤공부 React편을 듣고 정리한 내용입니다.

강의에 덧붙여 제가 이해한 내용을 같이 첨부하였습니다.

3편의 목차는 다음과 같이 구분하였습니다.

1. 구조분해 할당이란?
2. 이벤트란?
3. useEffect란?
컴포넌트에 값을 전달해 주기 위해서 props를 사용한다.
값을 변경하기 위해서는 state를 사용한다.

지난 시간을 정말 간단히 요약하자면 위와 같다.

그런데, 우리가 만든 카운터 컴포넌트가 1억개가 있고, 그 안의 properties가 전부 다 다르다고 생각하면

너무너무 유지보수가 하기 귀찮아질 것이다.

그래서 우리는 다음과 같은 방식으로, props에 어떤 값들이 있는지를 명시해 주기로 했다.

1. 구조분해 할당이란?

function Counter(props){
 let title = props.title;
 let initValue = props.initValue;
 return { ... }
}

그리고 이 속성들을 한번에 묶어 줄 수도 있다.

function Counter({title, initValue}){
 return (...)
}

이렇게 명확하게 써 줄수도 있다. 물론 props.initValue도 initValue로 바꿔 써 줄수 있다.

이것을 '구조'(배열이나 객체)를 '분해'해서 지역변수로 '할당'하는 테크닉,

줄여서 '구조분해할당'이라고 한다.

 

let과 const

다시 카운터 컴포넌트가 재렌더링 될 수 있도록 useState 훅을 사용해서 컴포넌트를 나타내보자.

function Counter({title, initValue}){
 let countState = useState(initValue);
 let count = countState[0];
 let setCount = countState[1];
 function up(){
  setCount(count + 1);
 }
 return (...)
}

initValue를 초기값으로 가지는 상태를 countState로 두었고, 그 0번째가 값, 1번째가 값을 바꾸는 함수라는 것은 지난시간에 공부했다.

그런데 생각해보면 count는 props라 read only인데,

누군가가 setCount(count+1)을 count = count + 1을 주거나, count의 값을 변경할 경우

우리는 에러를 반환함으로써 이 값을 변경하면 안 된다고 대응해야 한다.

자바에서 은닉을 위해 setter 함수를 도입했던 것과 비슷한 느낌이 들었다.

자바에서 우리가 은닉을 위해 setter함수를 만들고 접근제어자를 private로 설정해 준 것을 기억하자.

리액트도 똑같이 상태 훅을 사용하고, 변수를 let이 아닌 const(constant variable)로 선언해주면 된다.

 

function Counter({title, initValue}){
 const countState = useState(initValue);
 const count = countState[0];
 const setCount = countState[1];
 function up(){
  setCount(count + 1);
 }
 return (...)
}

또 다른 구조분해할당의 예로, 배열도 이와 같이 설정해 줄 수 있다.

function Counter({title, initValue}){
 const [count, setCount] = useState(initValue);
 function up(){
  setCount(count + 1);
 }
 return (...)
}

코드가 훨씬 간결하고 깔끔해졌음을 알 수 있다.

 

2. 이벤트란?

그런데 곰곰이 생각을 해 보니, 1씩만 증가하는 것은 너무 멋이 없는 카운팅 어플리케이션이라는 생각이 들어서

카운터에 수치를 설정해두고, 그 수치대로 증가/감소할 수 있게 카운터를 바꾸고 싶었다.

그래서 input 태그를 하나 만들었다.

function Counter({title, initValue}){
 const [count, setCount] = useState(initValue);
 function up(){
  setCount(count + 1);
 }
 return (
  <div>
   <h1>{title}</h1>
   <button onClick={up}>+</button>
   <input type = "number" value = {1}/>
   {count}
  </div>
 )
}

그러면 이제 기존의 버튼에서 숫자와 버튼까지 생겼는데,

문제가 발생했다. 숫자가 변하지 않는다. 왜 그럴까?

답은 간단하다. 아까와 같이, value는 언제나 1이기 때문에 컴포넌트가 변하지 않는 것이다.

그래서 이 input 태그에도, 상태를 주기로 했다.

function Counter({title, initValue}){
  const [count, setCount] = useState(initValue);
  const [step, setStep] = useState(1);
  function up(){
    setCount(count + 1);
  }
  return (
    <div>
      <h1>{title}</h1>
      <button onClick = {up}>+</button>
      <input type = "number" value = {step}/>
      {count}
    </div>
  );
}

그래도 인풋박스의 값은 달라지지 않는다. 우리가 라이브 창에서 값을 바꾼다고 해도 사실 step의 값은 변하지 않기 때문이다.

그럴 때 사용하는것이 onChange 이벤트이다.

우리가 당장 필요한건 '어떤 값을 바꿀 때 마다 그 값이 step이 되어야 한다'이다.

그래서 onChange에 이름이 없는 애로우 펑션(Arrow function)을 준다.

이 애로우 펑션은 항상 첫 번째 파라미터로 이벤트 객체를 줘야 한다.

 

>App.js > function Counter

<input type = "number" value = {step} onChange = {(evt) => {
 console.log(evt)
}}>

이 이벤트를 콘솔에 찍어보면 어떻게 될까?

이 이벤트의 정보 중, 가장 중요한 것이 target이다.

이 target는 항상 현재 onChange가 호출된 태그를 반환한다.

evt.target를 콘솔에 찍어보자.

 이제 이 evt.target.value를 step의 값이 될 수 있도록 코드를 설정해보자.

아마 이 value는 typeof 로 찍어보면 string이 나올 것이기 때문에,

Number 함수를 이용해서 파싱을 해주도록 하자.

 

>App.js > function Counter

<input type = "number" value = {step} onChange = {(evt) => {
 setStep(Number(evt.target.value));
}}>

이제 우리가 설정하는 대로 카운터 계수가 숫자를 뱉어내기 시작했다 :) 박수~

우리가 작동하는 로봇을 만들기 위해 팔(count)과 어깨(step)를 만들어 주었으니,

이제 이 둘을 엮어서 유기적으로 돌아가게 (step만큼 count가 올라가는) 해 주는 작업을 해준다.

방법은 간단하게, setCount에서 1씩 올라가던 걸, step만큼씩 올라가게 하면 된다!

function up(){
 setCount(count + step);
}

마지막으로, 이 Arrow function을 이 카운터가 아닌 다른곳에서도 사용할 수 있게 하기 위해, 또 어떤 역할을 하는 함수인지 알게 하기 위해 함수를 추출해보자.

function Counter({title, initValue}){
  const [count, setCount] = useState(initValue);
  const [step, setStep] = useState(1);
  function up(){
    setCount(count + step);
  }
  const stepHandler = (evt) => {
   setStep(Number(evt.target.value));
  }
  return (
    <div>
      <h1>{title}</h1>
      <button onClick = {up}>+</button>
      <input type = "number" value = {step} onChange = {stepHandler}/>
      {count}
    </div>
  );
}

지금까지 했던 것을 정리해보자.

사용자의 입력을 받는 input 컨트롤에서는 state를 만들어서 onChange로 값의 변화를 연결해주어야 한다. 

 

이번에는 우리가 더했던 결과를 출력해보자.

계산 결과를 계속 출력해 나가야 하므로, 이에 적합한 데이터 타입은 '리스트나 어레이'가 되어야 할 것이다.

const [history, setHistory] = useState([5,5]);

우리는 배열 안에 계속 결과값을 넣어가고, 그 결과값을 출력해야한다.

이럴 때 사용하는 것이 map함수로, map 함수의 특징은 첫 번째 파라미터로 함수가 들어간다.

또한 map함수는 처음부터 마지막 원소까지 한 번씩 돌아가며 실행하기 때문에 내부에 function을 만들어준다.

그리고 함수는 다음과 같이 각 조건이 맞는다면(파라미터가 한개 뿐이거나, return이 한 개뿐이거나 등)

자바의 람다함수처럼 일정 부분을 생략해 줄 수 있다.

num = [5,10,15];
num2 = num.map(function(e){
 return e*10;
})

num = [5,10,15];
num2 = num.map(e => e*10);

위와 아래는 같은 결과([50,100,150])를 반환하며, 원본과 복제본을 사용하는 차이에 대해서도 공부해 놓도록 하자.

이제 이 리스트를 호출해보자.

function Counter({title, initValue}){
 ...
 return (
  <div>
   ...
   <ol>
    {history.map(e => <li>{e}</li>)}
   </ol>
  </div>
 )
}

리액트는 자동으로 배열 안의 값을 풀어서 나타내 주기 때문에, 위와 같은 코드로 나타내어 동적인 리스트를 만들어 줄 수 있다.

그리고 리액트는 한 가지 규칙이 더 있다. 목록을 동적으로 만들 때에는, 리액트가 각 목록을 추적할 수 있도록 유니크한 식별자를 주어야 한다.

그게 바로 "key prop"이고, map의 두번째 파라미터로 인덱스를 주고, key prop을 넣어준다.

<ol>
 <history.map((e, index)=> <li key = {index}>e</li>)>
</ol>

이제 추가 버튼을 누를 때마다 이 history에도 내용이 들어가게 해 보자.

function Counter(...){
 ...
 function up(){
  const newCount = count + step;
  setCount(newCount);
  history.push(newCount);
  setHistory(history);
 }
 ...
 return (
  <div>
   ...
   <ol>
    {history.map((e, index) => <li key = {index}>{e}</li>)}
   </ol>
  </div>
 )
}

완성한 코드는 이쯤 될 것이다. 작동도 잘 된다 ! 박수 :)~~

 

그런데 하나 궁금한 것이 , 

up함수에서 setCount(newCount)를 주석처리하면 컴포넌트가 리렌더링 되지 않는다. 왜 그럴까?

생각해보면 카운트가 변하고 히스토리에 넣어서 값이 변했으면 컴포넌트가 리렌더링 되어야 하는데..

 

이것은 리액트의 성질에 기반한다.

리액트는 복사본이 바뀌어야 하는데, 원본이 바뀌면 이게 바뀌었다고 인식하지 않는다. 이게 무슨 말일까?

예를 들어,

a = [1,2];
b = a;
b.push(3)
console.log(a,b);

이렇게 하면 원본까지 갈려서 a도 [1,2,3], b도 [1,2,3]의 배열이 출력된다.

a를 쓰다가 b로 교체를 하면, 원본이 교체되지 않는다고 인식한다.

a = [1,2];
b = a;
b = [1,2,3];
console.log(a,b);

반대로, 이렇게 콘솔에 찍으면 a는 [1,2], b는 [1,2,3]의 배열이 출력된다.

a를 쓰다가 b로 교체를 하면, 원본이 교체되었다고 인식한다는 것이다.

 

다시 위의 코드로 돌아가보자.리액트는 이전의 history의 값과, 새로운 history의 값이 다를 때만 컴포넌트를 리렌더링 시킨다.그런데 우리는 history.push(newCount)를 함으로써 원본 자체를 바꾸어버렸기 때문에 리액트 입장에서는 '값이 바뀌지 않았구나'라고 생각하게 된다는 것이다.

 

결국 상태의 값이 배열이나 객체같은 어떤 값을 담고 있는 컨테이너면, 원본을 수정하지 말고 복제본을 수정해야 한다.

그래서 만약 히스토리를 리렌더링하고 싶으면 '복제본을 만든다'의 뜻인

const newHistory = [...history];

이렇게 복제본을 만들어서 데이터를 갈아주면 된다.

 

지금 내용을 다시 한 번 정리해보자.

1. 상태의 값이 배열, 객체와 같은 값의 컨테이너인 경우 상태를 복제한 후에 데이터를 추가, 수정, 삭제 해야 한다.
2. 그래야 리액트는 이전의 상태와 이후의 상태가 변경되었다는 것을 알 수 있다.
3. 이를 immutability(불변성)라고 한다. 불변하게 데이터를 다루면 소프트웨어가 훨씬 더 예측가능해진다.
4. 배열의 태그로 만들 때는 map함수를 사용한다.
5. 리액트에는 각 배열의 원소의 추적값인 key값을 제공해야 한다.
6. key값은 목록 안에서만 유일하면 된다.

 

3. useEffect란?

카운터를 만들었는데, 손이 아프다… 그래서 자동으로 카운터를 클릭하게 하고 싶다.

어떻게 만들면 좋을까?

일단은 예전에 만들었던 것과 같은 함수를 하나 만들어보자.

function CounterUseEffect(){
 const [count, setCount] = useState(0);
 return (
  <div>
   <h1>useEffect Counter</h1> {count}
  </div>
)}

이제 setInterval을 이용해서 count를 자동으로 올라가게 해보자.

setInterval(() => {}, 1000)

을 이용하면, 1초에 한번씩 setInterval함수가 동작하게 할 수 있다.

function CounterUseEffect(){
 const [count, setCount] = useState(0);
 setInterval(() => {
  setCount(count+1);
 }, 1000)
 return (
  <div>
   <h1>useEffect Counter</h1> {count}
  </div>
)}

으로 코드를 짜 놓으면, 처음에는 잘 올라가는가 싶더니

이렇게 혼란스러워한다.

setInterval을 하면 1초마다 setCount가 실행되고,

setCount가 실행되면 CounterUseEffect가 재렌더링 되고, 또 setCount가 실행되니까

이런 현상이 발생하는 것이다.

이렇듯, 컴포넌트 바깥쪽에서 일어나는 이런 일들을 side effect라고 한다.

그리고 이런 사이드 이펙트들은 useEffect를 이용해 별도로 격리시켜야 한다.

import {useEffect} from 'react';
function CounterUseEffect(){
 const[count, setCount] = useState(0);
 useEffect(() => {
  setInterval(() => {
   setCount(count+1);
  }, 1000)
 }, []);
 return (
  <div>
   <h1>useEffect Counter</h1> {count}
  </div>
)}

useEffect는 첫번째 파라미터로 함수를 받는다.

이렇게 격리시키면, 컴포넌트 안에서 누가 사이드 이펙트인지, 그리고 실행되는 타이밍을 핸들링할 수 있다는 장점이 있다.

우리는 setInterval을 단 한번만 사용하고 싶으므로, 두번째 파라미터에 빈 배열을 넣어주면 된다.

setInterval에 console.log(’interval’);을 찍어보면

counterUseEffect는 한번만 발동하고, 내부의 interval은 계속 콘솔에 찍히는 것을 볼 수 있다.

function up(){
	console.log(count);
	setCount(count + 1);
	console.log(count);
	setCount(count + 1);
	console.log(count);
	setCount(count + 1);
	console.log(count);
}

count의 초기값을 10으로 잡고,

up을 실행시켰을 때 콘솔에는 어떻게 찍힐까?

이렇게 10만 4번 찍히게 된다. 나는 3을 올리고 싶었는데 왜 이렇게 됐을까?

리액트는 한번 값이 변할때마다 컴포넌트를 재렌더링시키는게 아니라,

변경사항을 모아놨다가 한번에 처리한다.

따라서 count는 up함수가 재랜더링되어야 11로 바뀌는 것이다.

이를 비동기 또는 배치처리라고 한다.

따라서 이 문제를 해결하기 위해서는 값을 직접 주는 것이 아니라 함수를 넣어줘야 한다.

setCount((oldCount) => oldCount + 1);

참고로, set함수의 값은 값이 들어갈 수도 있고, 함수가 들어갈 수도 있다.

다시 돌아가서, CounterUseEffect의 setInterval 함수를 보자.

function setInterval(() => {
	console.log('interval');
	setCount(count+1);
},1000)

JS의 특징은 ‘함수가 정의된 시점에 count값을 가지고 있다가 함수를 다시 호출하면 count값을 기억하고 있다’는 것이다.

Interval 함수가 처음 정의될 때 우리는 const [count, setCount] = useState(0);으로 정의했으므로 계속 count값은 0으로 기억하고 있는 것이다.

이러한 특성을 closure, 클로저라고 한다.

클로저는 동봉한다는 뜻으로, 함수가 정의될 때 그 함수가 사용하는 변수를 ‘동봉’해 놓는 것이라고 생각하면 쉽다.

그래서 이 신선하지 않은(오래된) count를 사용하지 않고 가장 직전에 바뀐 값을 가져오려면 다음과 같이 세팅하면 된다.

바로 파라미터에 콜백함수를 넣으면 된다.

그런데 setCount를 바꾸고 실행하면, count가 2씩 올라가는 것을 확인할 수 있다.

이는 useEffect의 콜백함수의 return함수의 특성때문이다.

useEffect의 콜백함수는 리턴값을 가질 수 있는데, 이 return값은 반드시 함수여야 한다.

이 return 뒤에 오는 함수는, useEffect를 정리하는 기능을 가진다. 이 함수의 실행조건은 다음과 같다.

1. 컴포넌트가 지워졌을 때
2. UseEffect가 한번 더 실행됐을 때

그래서, cleanInterval로 이 인터벌을 끊어주어야 한다.

useEffect(() => {
 const id = setInterval(() => {
  setCount(oldCount => oldCount + 1);
 }, 1000)
 return (() => {
  clearInterval(id);
 })
},[])

지금까지 한 내용을 정리해보자.

부작용은 useEffect에 격리한다.
- 부작용을 쉽게 파악할 수 있다.
- 테스트를 할 때 유리하다.
- 부작용의 실행 타이밍을 제어할 수 있다.
- 타이밍을 제어할 때에는 두번째 파라미터가 없으면 컴포넌트와 함께 실행된다. 그러나 빈 배열이면 딱 한번 실행된다. 값이 있으면 그 값이 변경되었을 때 실행된다.

함수가 정의될 때 함수 내에서 사용되는 변수는 함수 안에 동봉된다(Closure).
set함수의 입력값은 값이거나, 함수이다.
함수의 파라미터는 신선한 상태의 값이다.
리턴값이 새로운 상태가 된다.
클로저의 영향을 받지 않고 상태를 직접 가지고 오기 때문에 콜백함수를 이용하여 안전하게 상태를 제어할 수 있다. useEffect의 리턴값은 정리할 때 사용되거나(unmount), 재실행될 때 자동으로 호출된다.

 

추가

어떻게 리액트에서 디자인을 하는가?

1. 인라인 방식으로 style 태그 먹이기

function Counter({title, initValue}){
  ...
  const style = { border: '10px solid black', padding:10, backgroundColor:'tomato' };
  return <div style={style}>
    <h1>{title}</h1>
    <button onClick={up}>+</button> 
    <input type="number" value={step} onChange={stepHandler}/>
    {count}
    <ol>
      {history.map((e,index)=><li key={index}>{e}</li>)}
    </ol>
  </div>
}
인라인 방식으로 스타일을 줄 때는 style props를 사용한다.
값은 객체이다.

또한 VSC의 ‘피킹’ 기능을 이용하여 App.js에서 모든 style props의 설정을 변경할 수 있다.

 

> app.css

h1{
 font-size : 50px;
}

2. MUI Library

리액트의 어마어마한 컴포넌트 생태계에서는 내가 컴포넌트를 잘 만드는 것도 실력이지만, 남이 만든 컴포넌트를 적재적소에서 잘 가져다 쓰는 것도 실력이다.

http://mui.com에서 Material MUI를 가져다 써 보자.

설치하는 방법은 간단하다. 터미널에서 다음과 같은 명령어를 입력만 해 주면 된다.

npm install @mui/material @emotion/react @emotion/styled

> package.json

"dependencies": {
    "@emotion/react": "^11.11.1",
    "@emotion/styled": "^11.11.0",
    "@mui/material": "^5.14.1",
    "@testing-library/jest-dom": "^5.17.0",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^13.5.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-scripts": "5.0.1",
    "web-vitals": "^2.1.4"
  },

package.json에 mui와 관련된 dependencies가 들어와 있다면 설치는 완료!

이제 다양한 라이브러리들을 적용하여 css를 꾸밀 수 있다.

function App() {
  return (
    <Container>
      <Grid container maxWidth="xl">
        <Grid item xs={12} sm={6} md={3}>
          <Counter title="불면증 카운터" initValue={10}></Counter>
        </Grid>
        <Grid item xs={12} sm={6} md={3}>
          <CounterUseEffect></CounterUseEffect>
        </Grid>
        <Grid item xs={12} sm={6} md={3}>
          <CounterUseEffect></CounterUseEffect>
        </Grid>
        <Grid item xs={12} sm={6} md={3}>
          <CounterUseEffect></CounterUseEffect>
        </Grid>
      </Grid>
    </Container>
  );
}

위와 같은 Container 컴포넌트를 가져와서, 반응형 화면에서 우리의 어플리케이션이 계속 가운데에 위치하게끔 만들어주었다. 또 Grid를 이용하여 화면을 분할하였다. 가장 바깥쪽의 Grid에는 container를 명시해 주어야 하고, 내부는 item으로 감싸준다.

xs와 같은 내용은 docs의 Breakpoint에 가서 정보를 확인할 수 있다.

Default breakpoints

Each breakpoint (a key) matches with a fixed screen width (a value):

  • xs, extra-small: 0px
  • sm, small: 600px
  • md, medium: 900px
  • lg, large: 1200px
  • xl, extra-large: 1536px

These values can be customized.

 

이렇게 리액트에 대한 기본 정리들을 해 보았다.

궁금하신/잘못된 내용은 댓글에 적어주세요 :)