Go together

If you want to go fast, go alone. If you want to go far, go together.

React, 스프링, AWS로 배우는 웹개발 101

프론트엔드 개발

NowChan 2022. 3. 16. 11:15

브라우저는 서버에 있는 자원을 사용자의 컴퓨터로 다운로드한 후 브라우저에서 실행시킨다.

 

js 라이브러리인 React.js를 이용해 프론트엔드를 개발한다. React를 사용하려면 Node.js를 이용해야 한다. Node.js는 브라우저 밖에서도 자바스크립트를 컴파일하고 실행하도록 돕는다.

 

Node.js는 구글 크롬의 V8 자바스크립트 엔진을 실행한다. 우리는 자바스크립트로 된 node 서버를 이용해 프론트엔드 서버를 개발한다. 우리의 프론트엔드 서버는 요청이 왔을 때 HTML, JS, CSS를 리턴한다.

 

NPM

 

NPM(Node Package Manager)은 Node.js의 패키지 관리 시스템이다. gralde이 maven repository에서 라이브러리를 다운받는 것과 비슷하게 npmjs(https://www.npmjs.com)에서 Node.js 라이브러리를 설치한다. NPM은 Node.js를 설치할 때 함께 설치된다.

 

sudo apt install nodejs   // nodejs -v

sudo apt install npm   

npm version
/*
{ npm: '6.14.15',
  ares: '1.15.0',
  brotli: '1.0.7',
  cldr: '36.1',
  http_parser: '2.9.3',
  icu: '66.1',
  modules: '64',
  napi: '5',
  nghttp2: '1.40.0',
  node: '10.19.0',
  openssl: '1.1.1d',
  tz: '2021a3',
  unicode: '13.0',
  uv: '1.34.2',
  v8: '6.8.275.32-node.55',
  zlib: '1.2.11' }
*/

 

test-project 폴더를 만들고 거기에 npm, 프로젝트를 초기화한다. node 프로젝트를 초기화하면 패키지 이름, 버전 등 기본적인 정보를 입력하면 package.json 파일을 만들어 준다. package.json에는 프로젝트의 메타데이터가 들어간다.

 

mkdir test-project
cd test-project

npm init

 

npm으로 react를 설치하자. react 패키지는 node_modules 디렉터리 아래에 install 된다. 또한 package.json에 install한 패키지가 명시된다. dependencies에 추가된 라이브러리는 이후 프로덕션 배포에 사용된다.

 

npm install react
// package.json
{
  "name": "test-project",
  "version": "1.0.0",
  "description": "test-project",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "react": "^17.0.2"
  }
}

 

Node.js는 gradle과 달리 dependencies에 있는 라이브러리를 자동으로 install 하지 않는다. git repository에서 코드를 다운 받았다면 dependencies에 있는 라이브러리를 아래 코드로 설치하자.

 

npm install

 

'todo-react-app'이라는 프로젝트 디렉터리에 React.js app을 생성한다.

 

npx create-react-app todo-react-app

 

아래 코드로 React app을 실행시켜보자.

 

cd todo-react-app

npm start

 

localhost:3000

 

잘 실행됨을 알 수 있다.

 

create-react-app을 이용해 리액트 애플리케이션을 설치하면 기본적으로 생성되는 파일들이 있다.

 

package.json

 

이 프로젝트의 메타데이터로 사용할 node.js 패키지 목록 등을 포함한다.

 

 

public 디렉터리 아래의 파일

 

index.html은 http://localhost:3000이 처음으로 리턴하는 HTML 파일이다. 리엑트에서 html 파일은 index.html 하나 밖에 없다. 다른 페이지는 React.js를 통해 생성되고 index.html에 있는 root 엘리먼트 아래에 동적으로 렌더링된다.

 

<div id="root"></div>

 

src 디렉터리 아래의 파일

 

index.js는 index.html과 함께 처음으로 실행되는 js 코드이다. 이 js 코드가 리엑트 컴포넌트를 root 아래에 추가한다. App.js는 기본으로 생성된 리엑트 컴포넌트다.

 

 

material-ui 패키지 설치

 

material-ui 패키지를 사용하면 따로 UI를 위한 컴포넌트나 CSS를 작성하지 않아도 된다. 공식 사이트에서 자세한 정보를 확인할 수 있다.( https://material-ui.com )

 

  • meterial-ui/core 패키지는 material-ui를 사용하기 위한 코어 패키지이다.
  • material-ui/icons 패캐지는 아이콘을 위한 패키지이다.

 

[material-ui/core 설치]
$ npm install @material-ui/core

[material-ui/icons 설치]
$ npm install @material-ui/icons

 

 

브라우저의 동작 원리

 

브라우저 주소창에 웹 주소를 입력해 HTTP GET 요청을 웹 서버로 전송하고, HTML을 결과로 받는다. 브라우저는 파싱 후 렌더링 과정을 거쳐서 텍스트로 된 HTML을 브라우저에 보여준다.

 

파싱 단계에서 브라우저가 하는 일은 크게 3가지이다.

  • HTML을 트리 자료 구조 형태인 DOM(Domain Object Model) 트리로 변환한다.
  • 리소스(Image, CSS, Script)를 다운로드한다. 특히 CSS → CSSOM(CSS Obejct Model) 트리로 변환한다.
  • 다운받은 자바스크립트를 인터프리트, 컴파일, 파싱 · 실행한다.

 

렌더링 단계에서도 브라우저가 하는 일은 크게 3가지이다.

  • DOM(내용) 트리와 CSSOM(디자인) 트리를 합쳐 렌더 트리를 만든다.
  • 렌더 트리의 각 노드가 브라우저의 어디에, 어떤 크기로 배치될지 레이아웃을 정한다.
  • 렌더 트리의 각 노드를 브라우저 스크린에 그려준다.

 

렌더링 단계를 거치면 사용자는 브라우저상에서 시각화된 HTML 파일을 볼 수 있게 된다.

 

 

React.js

 

React.js, Angular.js, Vue.js는 대중적인 SPA 프레임워크다. SPA(Single Page Application)은 한 번 웹 페이지를 로딩하면, 새로고침하지 않는 이상 페이지를 새로 로딩하지 않는 애플리케이션이다.

 

React의 SPA의 동작 원리는 index.html index.js root 엘리먼트 아래에 React.js가 생성한 HTML 엘리먼트 추가root 엘리먼트 하위 엘리먼트 렌더링으로 진행된다.

 

브라우저가 index.html을 로딩 중일 때는 흰 화면만 보인다. HTML의 <body></body>를 렌더링하다 보면 마지막에 bundle.js를 로딩하게 된다. bundle.js는 npm start를 실행했을 때 만들어졌는데, index.js를 내부에 포함한다. 따라서 index.js 속 ReactDom.render() 함수가 실행된다. 이 render() 함수가 HTML 엘리먼트를 눈에 보이는 리엑트 첫 화면으로 바꿔주는 것이다.

 

ReactDOM.render(
   <React.StrictMode>
      <App />
   </React.StrictMode>,
   document.getElementById('root')
);

 

렌더링된 하위 엘리먼트는 render() 함수의 매개변수의 <App/> 부분이다. 우리가 페이지를 바꾸고 싶다면, root의 하위 엘리먼트를 다른 HTML로 수정하면 된다. 브라우저의 자바스크립트는 fetch, ajax 등의 함수로 서버에 데이터를 요청하고 받은 데이터로 자바스크립트 내에서 HTML을 재구성한다. 이렇게 서버에 새 HTML 페이지를 요청하지 않고, 자바스크립트가 동적으로 HTML을 렌더링하는 과정을 Clinent-Side Rendering이라 하고, 이렇게 만든 클라이언트 애플리케이션을 SPA라고 한다. 

 

 

React 컴포넌트

 

[App.js]

import logo from './logo.svg';
import './App.css';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}

export default App;

 

하단의 export default App을 통해 App 컴포넌트를 다른 컴포넌트에서 사용할 수 있다. 컴포넌트는 javascript 함수 또는 클래스로 구현할 수 있다.

 

JSX 문법은 한 파일에서 JS, HTML을 함께 사용할 수 있도록 만든다. App 컴포넌트는 렌더링 부분인 HTML과 로직 부분인 JS를 포함하는 JSX를 리턴한다. 이 JSX는 Babel이라는 라이브러리(transpiler)가 빌드 시 javascript로 번역해준다.

 

 

[Index.js]

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

 

ReactDOM.render() 함수의 매개변수로 <App />을 주면 App 컴포넌트를 렌더링한다. <App /> 컴포넌트의 render() 함수가 반환한 JSX를 렌더링해서 DOM 트리를 만든다.

 

ReactDOM.render의 두 번째 매개변수로 받는 root element 아래에 첫 번째 매개변수로 받은 React 컴포넌트를 추가하라는 뜻이다. root 엘리먼트는 index.html에 정의돼 있다. React로 만든 모든 컴포넌트는 이 root 엘리먼트 하위에 추가된다.

 

 

프론트엔드 서비스 개발

 

Todo 리스트

 

Todo 컴포넌트를 만들고 App.js의 render() 함수에 Todo 컴포넌트를 추가한다. 

 

[Todo.js]

import React from 'react';

class Todo extends React.Component{
    render(){
        return(
            <div className="Todo">
                <input type="checkbox" id="todo0" name="todo0" value="todo0"/>
                <label for="todo0">Todo 컴포넌트 만들기</label>
            </div>
        )
    }
}

export default Todo;

 

[App.js]

import logo from './logo.svg';
import './App.css';
import Todo from './Todo';

function App() {
  return (
    <div className="App">
      <Todo />
    </div>
  );
}

export default App;

 

원하는 만큼 Todo 컴포넌트를 App div 아래 적어서 재사용할 수 있다.

 

API에서 받아올 Todo 리스트를 출력해야하므로 필요한 매개변수를 생성자(constructor)를 통해 넘길 수 있도록 Todo.js, App.js를 수정하겠다.

 

[App.js]

class App extends React.Component{
  constructor(props){
    super(props);
    this.state={
      item: {id: 0, title: "Hello World 1", done: true},
    };
  }

  render(){
    return (
      <div className="App">
        <Todo item={this.state.item}/>
      </div>
    );
  }
}

 

[Todo.js]

class Todo extends React.Component{
    construcor(props){
        super(props);
        this.state={
            item: props.item
        };
    }

    render(){
        return(
            <div className="Todo">
                <input type="checkbox" id={this.state.item.id} name={this.state.item.id} checked={this.state.item.done}/>
                <label id={this.state.item.id}>{this.state.item.title}</label>
            </div>
        );
    }
}

 

super를 이용해 props 오브젝트를 초기화시킬 수 있다.

 

this.state는 리액트가 관리하는 오브젝트로, 자바스크립트 내 변수를 state 오브젝트에서 관리한다. state에서 변수가 변경되는 것을 탐지하면, 리엑트가 변경된 변수를 HTML에 다시 렌더링한다.

 

App.js에서 <Todo /> 컴포넌트에 작성한 매개변수(item)를 Todo.js의 생성자에서 props로 넘겨받을 수 있다. 따라서 Todo.js는 super()를 통해 props를 초기화하고, 이렇게 넘겨 받은 props.item으로 this.state.item을 초기화해준다. (App.js도 컴포넌트이므로 같은 원리로 작동한다.)

 

기존의 item을 2개 담은 items 배열로 바꾸고, Todo Component 2개를 출력하겠다. 

 

[App.js]

class App extends React.Component{
  constructor(props){
    super(props);
    // item -> items
    this.state={
      items: [{id: 0, title: "Hello World 1", done: true},                
              {id: 1, title: "Hello World 2", done: false},
             ],
    };
  }

  render(){

    // js가 제공하는 map 함수를 이용해 배열을 반복해 <Todo /> 컴포넌트 생성
    var todoItems = this.state.items.map((item, idx)=>(
      <Todo item={item} key={item.id} />
    ));
    
    // 생성된 컴포넌트 리턴
    return <div className="App">{todoItems}</div>;

  }
}

 

 

주의할 점은 .map( (매개변수) => {} )이 아닌 .map( (매개변수) => () )이다. 

 

 

material ui를 이용한 디자인

 

material ui는 UI를 위한 컴포넌트를 제공합니다. Todo.js와 App.js를 아래와 같이 수정하겠다.

 

[Todo.js]

import { ListItem, ListItemText, InputBase, Checkbox } from '@material-ui/core';

    render(){
        const item = this.state.item;
        return(
            <ListItem>
                <Checkbox checked={item.done}/>
                <ListItemText>
                    <InputBase 
                        inputProps={{"aria-label": "naked"}} 
                        type="text"
                        id={item.id}
                        name={item.id}
                        value={item.title}
                        multiline={true}
                        fullWidth={true}
                    />
                </ListItemText>
            </ListItem>
        );
    }

export default Todo;

 

[App.js]

import { Paper, List } from "@material-ui/core";

  render(){
    var todoItems = this.state.items.length > 0 && (
    <Paper style={{ margin: 16 }}>    
      <List>
        {this.state.items.map((item, idx) => (
          <Todo item={item} key={item.id} />
        ))}
      </List>
    </Paper>
    );

    return <div className="App">{todoItems}</div>;

  }

 

localhost:3000

 

UI가 훨씬 깔끔해졌다.

 

 

Todo 추가

 

Todo 추가를 위한 UI와 벡엔드 콜을 대신할 가짜 Mock 함수를 작성하고, 이벤트 핸들러 함수를 만들어 UI에 연결하겠다.

 

Todo 추가 모듈을 만들기 위해 AddTodo.js를 생성하고 App.js에 컴포넌트를 추가하겠다.

 

UI 부분

 

[AddTodo.js]

import React from "react";
import {TextField, Paper, Button, Grid} from "@material-ui/core";

class AddTodo extends React.Component{
    constructor(props){
        super(props);
        this.state = {
            item: {title:""}    // 사용자의 입력을 저장할 오브젝트
        };
    }

    render(){
        return(
            <Paper style={{margin:16, padding:16}}>
                <Grid container>
                    <Grid xs={11} md={11} item style={{paddingRight: 16}}>
                        <TextField placeholder="Add Todo here" fullWidth/>
                    </Grid>
                    <Grid xs={1} md={1} item>
                        <Button fullwidth color="secondary" variant="outlined">
                            +
                        </Button>
                    </Grid>
                </Grid>
            </Paper>
        );
    }
}

export default AddTodo

 

[App.js]

import { Paper, List, Container } from "@material-ui/core";
import AddTodo from './AddTodo';

    return <div className="App">
              <Container maxWidth="md">
                <AddTodo />
                <div className="TodoList">{todoItems}</div>
              </Container>
            </div>;

 

UI 완성

 

Add 핸들러 추가

 

  • onInputChange : 사용자가 input 필드에 키 하나를 입력할 때마다 실행되며 input 필드에 담긴 문자열을 자바스크립트 오브젝트에 저장한다.
  • onButtonClick : 사용자가 + 버튼을 클릭할 때 실행되며 onInputChange에서 저장하고 있던 문자열을 리스트에 추가한다.
  • entertKeyEventHandler: 사용자가 input 필드 상에서 엔터 또는 리턴키를 눌렀을 때 실행되며 기능은 onButtonClick과 같다.

 

자바스크립트에서 Event 클래스의 e 오브젝트(event 오브젝트)는 어떤 일이 일어났을 때 상태와 그 일에 대한 정보를 담고 있다. 사용자가 키보드를 두드리면 TextField 컴포넌트에서는 Event e가 발생한다. 자바스크립트는 이벤트가 발상할 때마다 onChange()를 실행한다. 이런 함수를 이벤트 핸들러 함수라고 한다.

 

input 필드에 입력하는 정보를 컴포넌트 내부에 임시적으로 저장하려고 this.state={item:{}}로 상태 변수를 초기화했다. TextField의 props(속성) 중 onChange에 핸들러 함수 onInputChange를 연결하면 사용자가 키보드를 누를 때마다 입력한 정보를 item에 저장한다.

 

onInputChage가 실행되면 Event 오브젝트 e가 매개변수로 넘어온다. 이 오브젝트의 target.value에는 InputField에 입력된 글자가 담겨있다. 이 글자를 thisItem.title에 담고 setState를 통해 item을 업데이트해서 Todo 아이템을 임시로 저장한다.

 

[AddTodo.js]

    // class AddTodo 중
    onInputChange = (e) =>{
        const thisItem = this.state.item;
        thisItem.title = e.target.value;
        this.setState({ item: thisItem});
        console.log(thisItem);
    }
    
    // render() 중
                        <TextField placeholder="Add Todo here" 
                                   fullWidth
                                   onChange={this.onInputChange}
                                   value={this.state.item.title}
                                   />

 

결과

 

Add 함수 작성

 

AddTodo 컴포넌트는 상위 컴포넌트인 App.js의 items에 접근할 수 없다. 다라서 App 컴포넌트에 add() 함수를 추가하고 add 함수를 AddTodo 컴포넌트의 props로 넘겨주어야 한다. 

 

[App.js]

  // class App 중
  add = (item) => {
    const thisItems = this.state.items;
    item.id = 'ID-' + this.state.items.length; // key를 위한 ID 추가
    item.done = false; // done 초기화
    thisItems.push(item); // 리스트에 아이템 추가
    this.setState(thisItems); // 업데이트
    console.log("item: ", this.state.items)
  }
  
  // render() 중
    // 생성된 컴포넌트 리턴
    return <div className="App">
              <Container maxWidth="md">
                <AddTodo add={this.add}/>
                <div className="TodoList">{todoItems}</div>
              </Container>
            </div>;
  }

 

onKeyPress()는 키보드의 키가 눌릴 때마다 실행되는 이벤트 핸들러이다.

 

[AddTodo.js]

   // class AddTodo 중
    onButtonClick = () =>{
        this.add(this.state.item);
        this.setState({item: {title: ""}});  // TextField 초기화
    }
    
    enterKeyEventHandler = (e) => {
        if(e.key == 'Enter'){
            this.onButtonClick();
        }
    }
    
   // render() 중
        <Grid xs={11} md={11} item style={{paddingRight: 16}}>
            <TextField placeholder="Add Todo here" 
                       fullWidth
                       onChange={this.onInputChange}
                       value={this.state.item.title}
                       onKeyPress={this.enterKeyEventHandler}
                       />
        </Grid>
        <Grid xs={1} md={1} item>
            <Button fullWidth color="secondary" 
                    variant="outlined"
                    onClick={this.onButtonClick}
            >
                +
            </Button>
        </Grid>

 

결과

Todo 삭제

 

Todo.js의 Todo item 옆에 삭제 버튼 UI를 만들겠다.

 

[Todo.js]

import { ListItem, ListItemText, InputBase, Checkbox, 
         ListItemSecondaryAction, IconButton } from '@material-ui/core';
import {DeleteOutlined} from '@material-ui/icons';

	// render() 중
        <ListItemSecondaryAction>
            <IconButton aria-label="Delete Todo">
                <DeleteOutlined/>
            </IconButton>
        </ListItemSecondaryAction>

 

App.js에 add() 함수를 만들었던 것처럼 삭제하는 데 필요한 delete() 함수를 만들고 Todo의 props로 전달하겠다.

 

[App.js]

  // Class App 중
  delete = (item) => {
    const thisItems = this.state.items;
    console.log("Before Update Items: ", thisItems);
    const newItems = thisItems.filter(e => e.id !== item.id);
    this.setState({items: newItems}, () =>{
      // 디버깅 콜백
      console.log("Update Items:", this.state.items);
    });
    
  }
  
  // render() 중
  <Todo item={item} key={item.id} delete={this.delete}/>

 

Todo.js에 deleteEventHandler를 만들어 props로 넘겨 받은 delete와 연동해서 IconButton 컴포넌트의 onClick 이벤트 핸들러로 등록하겠다.

 

[Todo.js]

   // class Todo 중
    constructor(props){
        super(props);
        this.state={
            item: props.item,
            key: props.key,
        };
        this.delete = props.delete;
    }

    deleteEventHandler = () => {
        this.delete(this.state.item);
    }

   // render 중
   <IconButton aria-label="Delete Todo" onClick={this.deleteEventHandler}>

 

 

결과

 

add, delete 기능이 잘 작동하므로 테스팅용으로 추가된 Hello World1, 2는 이제 삭제해도 된다.

 

 

Todo 수정

title 수정

 

Todo.js의 InputBase 컴포넌트의 readOnly 플레그(특정 동작을 수행할지 말지 결정하는 변수)에 boolean 값을 주어 수정 가능 여부를 설정할 수 있다. 이와 같은 정보는 material-ui 공식 사이트에서 확인할 수 있다.

 

[Todo.js]

 // class Todo 중
    constructor(props){
        super(props);
        this.state={
            item: props.item,
            key: props.key,
            readOnly: true,
        };
        this.delete = props.delete;
    }

    offReadOnlyMode = () => {
        console.log("Event!", this.state.readOnly);
        this.setState({readOnly: false}, () =>{
            console.log("ReadOnly? ", this.state.readOnly);
        });
    }

 // render 중
         <InputBase 
            inputProps={{"aria-label": "naked",
                          readOnly: this.state.readOnly}} 
            onClick={this.offReadOnlyMode}
            type="text"
            id={item.id}
            name={item.id}
            value={item.title}
            multiline={true}
            fullWidth={true}
        />

 

Todo Item을 클릭시 커서가 readOnly:false가 되어 깜박이는 것을 알 수 있다.

 

커서가 깜박인다고 수정이 가능한 것은 아니다. AddTodo 컴포넌트에서 처럼 사용자가 키보드의 키로 입력할 때마다 item을 새 값으로 변경해줘야 한다. enterkey를 누를 때 readOnly:true로 변경되게 만들어 수정 완료를 알려준다.

 

[Todo.js]

    // class Todo 중
    editEventHandler = (e) => {
        const thisItem = this.state.item;
        thisItem.title = e.target.value;
        this.setState({item: thisItem});
    }

    enterKeyEventHandler = (e) => {
        if(e.key === 'Enter'){
            this.setState({readOnly: true});
        }
    }
    
    // render 중
            <InputBase 
                inputProps={{"aria-label": "naked",
                              readOnly: this.state.readOnly}} 
                onClick={this.offReadOnlyMode}
                onChange={this.editEventHandler}
                onKeyPress={this.enterKeyEventHandler}
                type="text"
                id={item.id}
                name={item.id}
                value={item.title}
                multiline={true}
                fullWidth={true}
            />

 

수정이 잘 됨을 확인할 수 있다.

 

Checkbox 수정

 

체크박스에 변화가 있을 때 item.done을 반대로 설정하는 이벤트 핸들러 함수를 Checkbox 컴포넌트에 연결한다.

 

   // class Todo 중
    checkboxEventHandler = (e) => {
        const thisItem = this.state.item;
        thisItem.done = !thisItem.done;
        this.setState({item: thisItem});
    }
    
   // render 중
   <Checkbox checked={item.done} onChange={this.checkboxEventHandler}/>

 

잘 됨을 알 수 있다.

 

서비스 통합

 

프론트엔드에서 벡엔드에 API를 요청하는 코드를 작성하겠다. 자바스크립트의 fetch API를 사용해 Todo 아이템을 불러와 추가, 수정, 삭제할 것이다.

 

백엔드에서는 CORS 문제를 해결해줘야 한다.

 

componentDidMount

 

리엑트는 HTML DOM 트리의 다른 버전인 ReactDOM(Virtual DOM)을 갖고 있다. 컴포넌트의 state가 변하면 ReactDOM은 이를 감지하고 변경된 부분의 HTML을 바꿔준다. 이를 렌더링이라고 한다.

 

렌더링이 맨 처음 일어나는 순간, 즉 ReactDOM 트리가 존재하지 않는 상태에서 처음으로 각 컴포넌트의 render() 함수를 콜해 자신의 DOM 트리를 구성하는 것을 마운팅(mounting)이라고 한다. 마운팅 과정에서 생성자(constructor)와 render() 함수를 부른다.

 

한편, 마운팅을 마친 후 부르는 함수가 하나 더 있다. componentDidMount라는 함수다. componentDidMount 함수에 백엔드 API 콜 부분을 구현해야 한다.

 

생성자에 API 콜을 하지 않는 이유는 아직 마운팅이 다 되지 않은 상태 즉, 컴포넌트의 프로퍼티 존재 여부를 모르는 상태에서 API 콜을 통해 상태를 변경하면 예기치 못한 방향으로 동작할 수 있다.

 

[App.js]

  componentDidMount() {
    const requestOptions ={
      method : "GET",
      headers: {"Content-Type": "application/json"}
    };

    fetch("http://localhpst:8080/todo", requestOptions)
      .then(response => response.json())
      .then(
        (response) => {
          this.setState({
            item: response.data,
          });
        },
        (error) => {
          this.setState({
            error,
          });
        }
      );
  }

 

CORS 에러 메시지 (개발자도구 - console)

Access to manifest at 'localhost:3000' (redirected from 'localhost:3030/manifest.json') from 
origin 'localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header
is present on the requested resource.

 

 

CORS

 

CORS는 크로스-오리진 리소스 셰어링(Cross-Origin Resource Sharing)으로 처음 리소스를 제공한 도메인(Origin)이 현재 요청하려는 도메인과 다르더라도 요청을 허락해 주는 웹 보안 방침이다.

 

componentDidMount 함수의 fetch 부분에서 localhost:8080으로 브라우저는 2개의 요청을 보낸다. 

 

  1. 첫 번째 요청은 OPTIONS 메서드 요청 → 리소스(/todo)에 어떤 HTTP 메서드를 사용할 수 있는지 확인 가능
  2. 두 번째 요청은 우리가 원래 보내려고 했던 요청

 

리액트 애플리케이션이 실행하는 HTTP 요청 중 todo 요청이 2개 있는 것을 확인할 수 있다.

 

(첫 번째 요청) 브라우저는 HTTP OPTIONS 요청 헤더에 이 요청의 Origin(Parent Server, 위 사진에서 Request Headers에 Origin 부분)을 함께 보낸다. 만약 요청의 Origin이 자신과 같은 Origin이라면 요청을 수행하고 아니면 거절한다.

 

CORS가 가능하려면 백엔드에서 CORS 방침 설정을 해줘야 한다. com.example.demo.config 패키지 아래 CORS 관련 설정 코드를 작성한다. addCorsMappings 메서드로 백엔드 서버의 모든 경로(/**)에 대해 Origin이 localhost:3000인 경우 GET, POST, ..., DELETE 메서드를 이용한 요청을 허용한다. 또 모든 헤더와 인증에 관한 정보(Credential)도 허용한다.

 

[WebMvcConfig.java]

package com.example.demo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration // 스프링 빈 등록
public class WebMvcConfig implements WebMvcConfigurer{
    private final long MAX_AGE_SECS = 3600;

    @Override
    public void addCorsMappings(CorsRegistry registry){
        // 모든 경로에 대해
        registry.addMapping("/**")
            // Origin이 http://localhost:3000에 대해
            .allowedOrigins("http://localhost:3000")
            // GET, POST, PUT, PATCH, DELETE, OPTIONS 메서드를 허용한다.
            .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
            .allowedHeaders("*")
            .allowCredentials(true)
            .maxAge(MAX_AGE_SECS);
    }
}

 

codespace에서 local 서버를 2개 가동하면 CPU 사용량이 오버되어서 테스트하지 못했습니다.

 

 

자바스크립트 Promise

 

fetch 메서드는 Promise를 리턴한다. Promise는 비동기 오퍼레이션(Asynchronous Operation)에서 사용한다. 자바스크립트는 싱글 스레드 환경에서 동작한다. HTTP 요청을 백엔드로 보냈는데 백엔드가 이를 처리하는 데 1분이 걸리면 내 브라우저는 1분간 아무것도 못하는 상태가 된다.

 

이런 문제를 극복하고자 대부분의 자바스크립트 엔진은 자바스크립트 스레드 밖에서 이런 오퍼레이션(Web API)을 실행한다. 자바스크립트 스레드 밖에서 오퍼레이션을 실행하고 받은 응답을 처리하는 부분이 콜백 함수이다.

 

XMLHttpRequest는 콜백 함수 내에서 또 다른 HTTP 요청을 하면, 두 번째 요청을 위한 콜백을 또 작성해야한다. 이렇게 되면 코드가 굉장히 복잡해지는 데, 이것을 콜백 지옥(Callback Hell)이라 한다.

 

Promise는 콜백 지옥 문제를 해결해준다. Promise는 Promise 오브젝트에 명시된 사항들을 실행시켜 주겠다는 약속이다. Promise에는 3가지 상태가 있다.

 

  1. Pending : 오퍼레이션 대기 상태
  2. Resolve : 오퍼레이션 수행 완료 및 요청한 값 전달
  3. Reject : 오퍼레이션 중 에러

 

function exampleFunction(){
	return new Promise((resolve, reject) => {
       var oReq = new XMLHttpRequest();
       oReq.open("GET", "http://localhost:8080/todo");
       oReq.onload = function () {
          resolve(oReq.response); // Resolve 상태
       };
       oReq.onerror = function () {
          reject(oReq.response);  // Reject 상태
       };
       oReq.send(); // Pending 상태
    }
}

exampleFunction()
  .then((r) => console.log("Resolved" + r))
  .catch((e) => console.log("Rejected" + e));

 

resolve(operation 성공)는 then의 매개변수 속 함수가 실행되고, reject(operation 실패)는 catch의 매개변수 속 함수가 실행된다.

 

 

Fetch API

 

우리 프로젝트에서는 fetch 함수를 이용한다. 자바스크립트에서 제공하는 fetch 메서드는 API 서버로 http 요청을 송·수신하도록 돕는다. fetch는 url, options를 매개변수로 받고 Promise 오브젝트를 리턴한다.

 

options = {
   method: "POST",
   headers: [
      ["Content-Type", "application/json"]
   ],
   body: JSON.stringify(data)
};

fetch("localhost:8080/todo", options)
   .then(response => {
      // response 수신 시 하고 싶은 직업
   })
   .catch(e => {
      // 에러가 났을 때 하고 싶은 직업
   })

 

이후 프로덕션 도메인을 사용할 것이므로 애플리케이션이 사용할 백엔드 URI를 가져오도록 구현할 것이다. 프론트엔드 프로젝트의 src 디렉터리 아래 app-config.js를 생성하겠다.

 

[api-config.js]

let backendHost;

const hostname = window && window.location && window.location.hostname;

if(hostname === "localhost"){
    backendHost = "http://localhost:8080";
}

export const API_BASE_URL = `${backendHost}`;

 

프론트엔드 src 디렉터리 아래 ApiService에 백엔드로 요청을 보내는데 사용할 유틸리티 함수를 작성한다. 즉, add, delete 등의 API를 호출할 때 공통적으로 필요한 코드를 분리해 작성하겠다.

 

[ApiService.js]

import { API_BASE_URL } from "./api-config";

export function call (api, method, request){
    options = {
        headers: new Headers({
            "Content-Type": "application/json",
        }),
        url: API_BASE_URL + api,
        method: method,
    };
    if (request){
        // GET method
        options.body = JSON.stringify(request);
    }
    return fetch(options.url, options)
       .then((response) => response.json().then((json) => {
           if(!response.ok){
               // response.ok가 true이면 정상 응답 아니면 에러 응답
               return Promise.reject(json);
           }
           return json;
       }));
}

 

App 컴포넌트에서 ApiService에서 작성한 call 메서드로 API 콜을 하겠다.

 

[App.js]

import {call} from './ApiService';

 // class APP 중
 componentDidMount() {
    call("/todo", "GET", null).then((response) => 
       this.setState({items: response.data})
    );
  }

  add = (item) => {
    call("/todo", "POST", item).then((response) => 
      this.setState({items: response.data})
    );
  };

  delete = (item) => {
    call("/todo", "DELETE", item).then((response) =>
      this.setState({items: response.data})
    );
  };

 

백엔드 DB에서 Todo 리스트를 가져오므로 새로고침하거나 프론트엔드 애플리케이션을 재시작해도 Todo item이 사라지지 않는다.

 

 

Todo Update 수정

 

프론트엔드 UI 부분에서만 동작하는 mock() 함수에서는 사용자가 키보드키에서 입력하면 editEventHandler 함수가 title을 수정했다. 하지만, API를 이용하려면 UI 뿐만 아니라 변경된 내용을 서버로 보내주어야 한다.

 

[App.js]

  // class App 중
  update = (item) => {
    call("/todo", "PUT", item).then((response) =>
      this.setState({items: response.data})
    );
  }
	
    // render 중
      <Todo item={item} 
            key={item.id} 
            delete={this.delete}
            update={this.update}      
      />

 

[Todo.js]

  // class Todo 중
    constructor(props){
		...
        this.update = props.update; //
    }
    
    enterKeyEventHandler = (e) => {
        if(e.key === 'Enter'){
            this.setState({readOnly: true});
            this.update(this.state.item);
        }
    }

    checkboxEventHandler = (e) => {
        const thisItem = this.state.item;
        thisItem.done = !thisItem.done;
        this.setState({item: thisItem});
        this.update(this.state.item);
    }

 

codespace에서 리엑트, spring boot를 동시에 작동시키면 둘 중 한 서버가 다운되어서 테스팅하지 못했습니다. 정상적으로 동작한다면 수정까지 로컬환경에서 동작하는 완전한 Todo 애플리케이션을 완성했습니다.

 

 

정리

 

백엔드 - 프론트엔드를 연결하면서 만난 CORS 에러를 백엔드 애플리케이션에 addCorsMappings 메서드를 오버라이딩 하며 해결했습니다. 이 메서드에서는 어떤 Origin에서 어떤 Methods로 CORS를 허락할지 설정했다.

 

ApiServce.js에 fetch 함수를 이용해 HTTP 요청을 보내는 코드를 작성했다. fetch가 반환하는 Promise 오브젝트의 응답을 받아 Todo 리스트를 갱신해주는 작업도 했다.

 

저자의 말을 적자면, 3장까지 오는 여정에 들인 그 노력에 경의를 표한다고 말했다.

 


궁금한 점

  • material-ui의 Paper, List, Container, Grid(xs, md) 공부하기
  • 함수 바인딩?
  • callback, promise 공부하기
  • {item: props.item}으로 받은 것은 setState할 때 상위 컴포넌트 App의 items의 요소를 변경하는 지?
  • material-ui Component 섹션 둘러보기

 

알게된 점

  • newArray = Array.filter(element => true/false)로 true면 항목을 newArray에 포함시킨다.
  • JSON.stringify()는 객체를 문자열로 바꿔준다.
console.log(JSON.stringify({ x: 5, y: 6 }));
// expected output: "{"x":5,"y":6}"

 

 

출처: React, 스프링, AWS로 배우는 웹 개발 101

'React, 스프링, AWS로 배우는 웹개발 101' 카테고리의 다른 글

인증 프론트엔드 통합  (0) 2022.04.08
프로덕션 배포  (0) 2022.04.07
인증 백엔드 통합  (0) 2022.03.28
백엔드 개발  (0) 2022.02.27
ToDoList APP을 개발하기 전  (0) 2022.02.26