[개발 일지] Simple Board

타키탸키·2022년 11월 24일
0

개발 일지

목록 보기
8/11

참고

간단한 리액트 게시판 만들기 : WYSIWYG ckeditor - by FaLaner

프로젝트 소개

새로운 프로젝트에서 REACT와 GO를 연동한 웹 개발을 계획함에 따라 REACT와 GO를 연동하는 법을 익힐 겸, 간단한 토이 프로젝트를 진행하기로 했다.

REACT를 다뤄본 적이 없어 다른 분이 진행한 토이 프로젝트를 기반으로 작업을 시작했다.

해당 프로젝트의 백엔드는 Node.js의 Express 프레임워크로 구현되었는데 이를 GO 코드로 리팩토링 해보려 한다.

데이터베이스로는 MySQL을 사용했다.

결과물 예시

사용자가 게시판에 글을 적고 입력 버튼을 누르면, 해당 내용이 백엔드 서버를 통해 MySQL DB에 저장된다. 이 페이지는 랜더링될 때마다 DB에 저장된 data를 백엔드 서버를 거쳐 불러온다.


과정 진행

  • 프론트엔드 구현에 관한 내용은 기존 프로젝트와 동일하게 진행되었으므로 위 포스트를 참고하는 것을 권장한다

  • 이 포스트에서는 Express로 구현된 백엔드 서버를 Go로 재구현하는 것에 초점을 두려 한다


서버 환경 구축하기

프로젝트 폴더에서 client 폴더와 server라는 폴더를 생성하여 REACT 관련 내용을 client 폴더에 옮겼다. 그 후, server라는 폴더에 main.go 파일을 추가했다.

다음으로, 간단한 go 서버를 구현했다.

<main.go>

package main

import (
	"fmt"
    "net/http"
)

func main() {
	http.HandleFunc("/", index)
    http.ListenAndServe(":8080", nil)
}

func index(w http.ResponseWriter, req *http.Request) {
	fmt.Println("server is running...")
}

위는 8080포트를 통해 서버를 오픈하는 코드이다. 서버를 오픈하면 콘솔에 server is running...이라는 문구가 출력된다.


Database 생성하기

  • MySQL을 설치한다. 설치 방법은 아래 포스트를 참고하면 된다.

https://bit.ly/3Ezlzkr

❗ 설치 도중, Type and Networking을 설정하는 단계에서 3306 Port 충돌이 일어나면 아래 포스트를 통해 문제를 해결할 수 있다.

https://bit.ly/3V3UVHk

  • CMD를 통해 MySQL에 접속한다.
mysql -u root -p

❗ 만약, mysql이라는 명령어를 찾을 수 없다는 에러가 뜨면 아래 포스팅을 참고하면 된다.

https://m.blog.naver.com/c6369/220625848670

  • 데이터베이스를 생성한다.
CREATE DATABASE simpleboard;

❗ MySQL은 코드 끝에 세미콜론을 붙여야 한다. 만약 코드 입력 시에 화살표가 등장하면, 세미콜론을 입력하지 않아 다음 내용을 더 입력하라는 의미이므로 입력을 마치고 싶으면 세미콜론을 추가로 입력하면 된다.

  • 테이블을 생성한다
use simpleboard;

CREATE TABLE simpleboard (
-> idx INT(10) NOT NULL AUTO_INCREMENT,
-> title CHAR(100) NOT NULL,
-> content TEXT NOT NULL,
-> PRIMARY KEY(idx)
-> );

❗ 이때, use simpleboard를 꼭 입력해야 한다. 그렇지 않으면, 대상 db를 찾을 수 없다는 에러가 뜬다.

  • 예시로, 서버를 통해 db에 data를 추가하는 코드를 작성해본다.

<main.go>

import (
	"database/sql"
    
    _ "github.com/go-sql-driver/mysql"
)

func main() {
	http.HandleFunc("/", get)
    http.ListenAndServe(":8080", nil)
}

var db *sql.DB
var err error

func get(w http.ResponseWriter, req *http.Request) {
	db, err = sql.Open("mysql", "root:1234@tcp(127.0.0.1:3306)/simpleboard") // 1
    if err != nil {
    	log.Fatalln(err)
    }
    
    conn, err := db.Query(
    "INSERT INTO simpleboard (title, content) VALUES (?,?)", "LOL", "재밌다ㅋㅋ") // 2
    if err != nil {
    	log.Fatalln(err)
    }
    
    conn.Close() // 3
	fmt.Printf("Connection 연결 종료: %+v\n", db.Stats())

	db.Close()
	fmt.Printf("DB 연동 종료: %+v\n", db.Stats())
}

1. sql.Open

  • 인자로 드라이버 종류(DBMS)와 Connection 정보(data source name)를 받고 sql.DB 객체를 반환한다
  • Connection 정보(data source name)
    • host명:비밀번호@프로토콜(Ip:Port)/database명
  • sql.Open은 실제 DB Connection을 Open하지 않는다
    • 실제 DB Connection은 Query 등과 같이 실제 DB 연결이 필요한 시점에 이루어진다

2. db.Query

  • 쿼리문을 인자로 받고 쿼리 결과 행을 리턴한다
  • 조회를 위해 하나의 행을 리턴할 경우에는 db.QueryRow를 사용한다
    • 하나의 행을 삽입하는 것은 db.Query로도 가능
  • 이때, MySQL은 ?(Placeholder)를 통해 Parameterized Query를 사용한다
    • SQL Injection과 같은 문제를 방지하기 위해 파라미터를 문자열 결합이 아닌 별도의 파라미터로 대입시키는 방식이다
    • 위의 예제에서 ?에는 파라미터로 넘긴 각 문자열(LOL, 재밌다)이 대입된다

3. .Close

  • 데이터베이스와 Query를 사용한 후에는 반드시 닫아야 한다
    • 닫지 않으면, 동시성 문제가 발생할 수 있다
    • 특히, timeout 에러를 발생시킬 수 있다
  • 보통은 defer를 통해 프로세스가 진행된 후 닫을 수 있도록 지연시킨다

<결과>

  • 서버 프로그램(main.go) 실행 후, localhost:8080에 접속
  • MySQL에 접속하여 database를 확인한다

게시판에 입력된 내용을 DB에 저장하기

axios 모듈 설치

이 과정을 진행함에 앞서, AJAX와 Axios의 개념에 대해 짚고 넘어가려 한다.

AJAX

AJAX란 비동기 자바스크립트와 XML (Asynchronous JavaScript And XML)을 말합니다. 간단히 말하면, 서버와 통신하기 위해 XMLHttpRequest 객체를 사용하는 것을 말합니다.

- MDN Web Doc

더 자세한 설명은 MDN 문서를 통해 확인 가능하다. 쉽게 말해, AJAX는 클라이언트와 서버의 데이터 통신을 위한 기능이라고 보면 된다.

클라이언트에서 서버에 데이터를 요청하려면 API(GET, POST 등)가 필요한데 이를 사용하기 위해서는 자바스크립트의 내장 객체인 XMLRequest나 다른 HTTP Client가 필요하다.

일반적으로 HTTP Client로써 Fetch API를 많이 사용하지만 REACT에서는 주로 Axios를 사용하는 경향이 있다. 두 HTTP Client의 차이점을 알고 싶다면 다음 포스트를 참고하면 된다.

https://velog.io/@shin6403/React-axios%EB%9E%80-feat.-Fetch-API

이제, GO로 만든 서버와 REACT로 만든 클라이언트를 연동할 차례이다. 이를 위해, client 폴더에 axois 모듈을 설치하고 다음 코드를 추가한다.

<App.js>

import Axios from 'axios';

...

const submitReview = ()=>{
    Axios.post('localhost:8080/api/insert', { // 1
      title: gameContent.title,
      content: gameContent.content
    }).then(()=>{
      alert('등록 완료!');
    })
  };

...

<button
	className="submit-button"
	onClick={submitReview} // 2
	>입력</button>

1. Axios.post

  • 인자로 url과 JSON 형태의 객체를 넘긴다
    • REACT의 기본 Port는 3000번이므로 url은 백엔드 서버에 맞게 8000번으로 설정해야 한다
  • POST 방식이므로, 버튼 클릭을 통해 사용자가 입력한 내용을 서버에 전송한다
  • 전송이 완료되면 알림창을 통해 등록이 완료되었음을 알린다

2. onClick={eventhandler}

  • 버튼 클릭에 대한 이벤트 핸들러로 앞서 작성한 submitReview 함수를 넘긴다

Form 데이터 DB에 전송하기

다시, 서버 프로그램으로 돌아와 Axios.post에 대응하는 POST 핸들러를 추가한다.

import (
    "encoding/json"
)

func main() {
	http.HandleFunc("/api/insert", insert) // 1
    http.ListenAndServe(":8080", nil)
}

var db *sql.DB
var err error

type User struct { // 2
	Title   string
	Content string
}

func insert(w http.ResponseWriter, req *http.Request) {
	var u User

	err = json.NewDecoder(req.Body).Decode(&u) // 3
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	title := u.Title
	content := u.Content

	db, err = sql.Open("mysql", "root:0000@tcp(127.0.0.1:3306)/simpleboard")
	if err != nil {
		log.Fatalln(err)
	}

	// 4
	conn, err := db.Query("INSERT INTO simpleboard (title, content) VALUES (?,?)", title, content)
	if err != nil {
		log.Fatalln(err)
	}

	conn.Close()
	fmt.Printf("Connection 연결 종료: %+v\n", db.Stats())

	db.Close()
	fmt.Printf("DB 연동 종료: %+v\n", db.Stats())
}

1. http.HandleFunc("/api/insert", insert)

  • Axios.post('localhost:8080/api/insert', ...)으로부터 받은 요청에 응답하기 위한 함수이다

2. type User struct

  • Form에 입력된 사용자 input 데이터를 담을 구조체이다
    • ❗❗❗이때, 구조체 멤버의 변수명은 반드시 대문자로 적어야 한다❗❗❗
    • 소문자로 만들 경우, private가 되어 외부(JSON)에서 접근이 불가능하다
    • 소문자로 작성된 구조체를 JSON으로 넘기면 외부에서는 {}으로만 인식된다
    • 자세한 내용은 해당 포스트를 참고 바란다

3. json.NewDecoder.Decode

  • Axios.post로부터 넘어오는 데이터는 JSON 형식으로 되어있다
    • 이를 GO-value 즉, GO에서 사용 가능한 데이터 구조로 바꾸려면 디코딩 과정이 필요하다
  • req.body는 request message의 body 부분을 의미하며, 이로부터 사용자 input을 추출한다
    • 추출된 input은 Go-value로 디코딩되어 User 타입의 u 변수에 담긴다

4. db.Query

  • User 구조체로부터 값을 대입받아 쿼리의 파라미터로 넘겨준다

cors 패키지 설치

위 과정까지 마치면, 다음의 절차를 수행한다.

  1. 서버 프로그램(main.go)을 실행한다
  2. REACT 서버(ex:) yarn start)를 실행한다
  3. 게시판에 내용을 적고 버튼을 누른다

그 결과.....?!

❗❗아무 것도 동작하지 않는다!!❗❗

왜 아무 것도 동작하지 않을까?

React 서버를 실행하면서 열린 브라우저에서 오른쪽 마우스를 클릭하고 검사를 누르면 다음과 같은 에러를 확인할 수 있다.

이는 CORS라는 정책으로부터 비롯된 에러이다.

CORS 정책이란 무엇인가?

교차 출처 리소스 공유 (CORS)

교차 출처 리소스 공유(Cross-Origin Resource Sharing, CORS)는 추가 HTTP 헤더를 사용하여, 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제입니다. 웹 애플리케이션은 리소스가 자신의 출처(도메인, 프로토콜, 포트)와 다를 때 교차 출처 HTTP 요청을 실행합니다.

교차 출처 요청의 예시: https://domain-a.com의 프론트 엔드 JavaScript 코드가 XMLHttpRequest를 사용하여 https://domain-b.com/data.json을 요청하는 경우.

- MDN Web Doc

더 자세한 설명은 MDN 문서를 통해 확인할 수 있다.

요컨대, 서로 다른 출처로부터 리소스를 주고 받는 경우에 보안상의 이유로 CORS 정책에 따라 코드를 작성해야 된다는 말이다.

앞서, React 서버가 사용하는 포트(3000)와 백엔드 서버가 사용하는 포트(8080)가 다르다고 언급한 적이 있다. 즉, REACT와 백엔드 서버는 서로 다른 출처로부터 리소스를 공유하게 되므로 이들이 통신할 때에는 CORS 처리가 필요하다.

Go에서의 CORS는 패키지를 통해 처리가 가능하다.

  • 콘솔에서 GOPATH의 src로 이동하여 아래 command를 입력하면 CORS 패키지를 설치할 수 있다
go get github.com/rs/cors

CORS 패키지와 관련된 정보는 CORS gihub에서 확인 가능하다.

❗ CORS 설치 후, import 에러가 발생할 수 있다. 이는 관련 패키지가 설치되지 않아 발생하는 에러이므로 examples과 wrapper 두 폴더를 과감히 삭제해준다. 두 폴더가 없더라도 CORS 패키지는 정상적으로 동작한다. 단, gin 프레임워크가 설치되어 있다면 wrapper 폴더는 남겨두어야 한다.

  • CORS 패키지 설치에 따라 서버 프로그램의 코드를 아래와 같이 변경한다
import (
	"github.com/rs/cors"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("/api/insert", insert)
	mux.Handle("/favicon.ico", http.NotFoundHandler()) // 2

	handler := cors.Default().Handler(mux) // 1

	http.ListenAndServe(":8080", handler)
}

1. cors.Default.Handler

  • cors 패키지를 사용함에 따라 DefaultServeMux가 아닌 새로운 mux를 생성한다
    • mux는 multiplexer의 약어로, 쉽게 하나의 서버라고 생각하면 된다
    • http.NewServeMux를 통해 mux를 생성할 수 있다
  • 1번의 결과로 나온 handler를 http.ListenAndServe의 두 번째 파라미터로 넘겨준다

2. mux.Handle("/favicon.ico", http.NotFoundHandler())

  • favicon.ico
    • 웹페이지에 접속했을때, 상단 탭에 보여지는 아이콘
    • 즐겨찾기에 웹페이지를 등록할때도 사용된다
    • 웹사이트를 대표하는 로고(logo)와 비슷한 개념
  • favicon 경로가 맞지 않는 경우, 관련 페이지를 찾을 수 없다는 404 에러를 발생시킨다
    • http.NotFoundHandler를 통해 이 에러를 방지할 수 있다

코드를 수정한 후, 다시 위 절차대로 진행해보자.

버튼을 클릭하면 등록 완료라는 알림창이 뜰 것이다. 정말 등록이 잘 되었는지 확인하기 위해 데이터베이스를 다시 조회해본다.

<결과>

  • 새로 입력한 데이터가 DB에 잘 저장된 것을 확인할 수 있다

DB에서 데이터를 가져와서 게시판에 보여주기

이번에는 반대로 DB로부터 데이터를 가져와서 Form에 띄우는 작업을 진행해 볼 것이다.

<main.go>

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("/api/get", get) // 1
	mux.HandleFunc("/api/insert", insert)
	mux.Handle("/favicon.ico", http.NotFoundHandler())

	handler := cors.Default().Handler(mux)

	http.ListenAndServe(":8080", handler)
}

...

type Data struct { // 2
	Index   string
	Title   string
	Content string
}

...

func get(w http.ResponseWriter, req *http.Request) {

	datas := []*Data{}

	w.Header().Set("Content-Type", "text/html; charset=utf-8") // 3

	db, err = sql.Open("mysql", "root:0000@tcp(127.0.0.1:3306)/simpleboard")
	if err != nil {
		log.Fatalln(err)
	}

	conn, err := db.Query("SELECT * FROM simpleboard;")
	if err != nil {
		log.Fatalln(err)
	}

	defer conn.Close()

	for conn.Next() { // 4
		var data Data
		conn.Scan(&data.Index, &data.Title, &data.Content) // 5

		datas = append(datas, &data)
	}

	json.NewEncoder(w).Encode(datas) // 6

	defer db.Close()

}

1. mux.HandleFunc("/api/get", get)

  • Axios.get('http://localhost:8080/api/get')으로부터 받은 요청에 응답하기 위한 함수이다

2. type Data struct

  • DB에 저장된 데이터를 담을 구조체이다
    • User와 마찬가지로 멤버의 변수명은 반드시 대문자로 적어야 한다
    • JSON으로 인코딩하여 서버에 전송할 구조체이므로 유의해야 한다

3. w.Header().Set("Content-Type", "text/html; charset=utf-8")

  • 응답 헤더를 설정하는 코드이다
    • 헤더를 작성하기 전, 응답 헤더에 대한 설정을 먼저 해야 한다
  • content type
    • POST 요청 헤더를 작성할 때, 유의해야 하는 요소이다
    • 단순 조회를 위한 헤더의 경우에는 text/html로 설정해도 된다
    • JSON을 사용하는 헤더의 경우, application/json으로 설정해야 한다
      • application/json은 RestFul API를 사용하기 위한 형식이다

4. for conn.Next()

  • 복수의 쿼리 결과 행을 불러오는 db.Query를 사용한 경우, 반복문을 통해 한 줄씩 행을 이동하며 작업을 진행해야 한다
    • .Next 메서드를 통해 다음 행이 있는지 확인할 수 있다
    • 행이 끝나면 반복문이 종료된다

5. conn.Scan()

  • Data 구조체의 멤버 변수를 넘겨 DB로부터 얻은 데이터를 저장한다
  • Data 구조체 배열 datas에 위에서 얻은 Data 구조체를 하나씩 저장한다
    • Data 구조체 하나가 쿼리 결과의 한 행(하나의 사용자 input 정보)을 의미한다

6. json.NewEncoder().Encode()

  • DB로부터 불러온 데이터를 GO-value인 struct에 저장했다
    • 이를 Form으로 전송하기 위해 JSON으로 바꾸려면 인코딩 과정이 필요하다
  • w는 응답을 위한 스트림이며, 이를 통해 JSON으로 인코딩된 data를 전송한다

다음으로, REACT 서버 코드를 수정한다

❗ 원래의 프로젝트와 다른 내용이 있으므로 아래 코드를 꼭 확인해야 한다

<App.js>

import { useEffect, useState } from 'react';

...

const [viewContent, setViewContent] = useState([]); // 1

...

useEffect(()=>{ // 2
    Axios.get('http://localhost:8080/api/get').then((response)=>{
      setViewContent(response.data)
    })
  }, [viewContent])

...

return (
  ...
  <div className="App">
      <h1>Game Review</h1>
      <div className='game-container'>
        {viewContent.map(element => // 3
          <div style={{ border: '1px solid #333' }}>
            <h2>{element.Title}</h2>
            <div>
             {ReactHtmlParser(element.Content)}
            </div>
            </div>
            )}
      </div>
  )

1. useState([])

  • 함수 기반 컴포넌트에서 상태를 관리할 수 있도록 하는 REACT Hook
    • 이전에는 상태 관리를 클래스 기반(this.state)으로 해야 했다
    • 그러한 번거로움을 극복하고자 나온 기능이다
  • 형태: const [<상태 값 저장 변수>, <상태 값 갱신 함수>] =useState(<상태 초기 값>)
    • 상태 값 갱신 함수는 setter와 유사한 기능을 한다
    • 이를 사용하지 않고 직접 변수를 다른 상태 값으로 할당하면 화면에 반영되지 않는다
    • 상태 값이 변하면 갱신된 값을 상태 값 저장 변수에 넣어준다
    • 상태 초기 값을 지정할 수 있다

2. useEffect()

  • REACT 컴포넌트가 랜더링될 때마다 특정 작업을 실행할 수 있도록 하는 REACT Hook
  • 첫번째 인자에는 수행할 함수를, 두번째 인자에는 함수를 실행시킬 조건이 배열로 들어간다
    • 두번째 인자를 빈 배열로 두면, 새로고침을 해야 변화가 반영된다
    • 두번째 인자에 값을 넘기면, 해당 값이 변할 때마다 (함수가 실행되며) 변화가 적용된다

3. { viewContent.map() }

  • viewContent의 setter 함수인 setViewContent가 GET 메서드를 통해 DB에 저장된 데이터를 가져온다
    • 이 데이터는 JSON으로, key-value 형태를 가진다
    • 따라서, map을 통해 각각의 element(key-value 쌍)에 접근하여 값을 조작할 수 있다
    • ❗ 이때, 구조체 정의에 따라 반드시 대문자로 값을 불러와야 한다
      • 소문자로 불러올 경우, 값을 인식하지 못한다
      • string을 인자로 받는 ReactHtmlParser에서 TypeError를 반환한다
      • Express와 같이 html parser가 내장되어 있는 경우에는 소문자로 접근해도 문제가 없다

여기까지 진행하고 프로그램을 실행하면, 프로젝트의 최종 결과물을 확인할 수 있다. 게시판에 데이터를 입력하고 버튼을 클릭하면, DB에 데이터가 저장되고 REACT가 저장된 결과를 서버로부터 불러와 실시간으로 랜더링하여 화면에 띄워준다.


결론

알게 된 것

  • 백엔드 서버 프로그램을 먼저 실행시키고 REACT 서버를 실행해야 한다
    • REACT가 항시 DB의 데이터를 요청하므로 DB에서 데이터를 불러오는 백엔드 서버가 먼저 실행되어야 한다
  • REACT와 백엔드 서버를 연결하려면 CORS 정책을 따라야 한다
    • 서로 다른 출처에서 리소스를 공유하기 위한 장치가 필요하다
    • 대부분 모듈이나 패키지로 구현되어 있다
  • Axios는 Restful API를 위한 도구이다
    • REACT는 Axios 혹은 Fetch와 같은 HTTP Client를 통해 백엔드와 API 통신을 할 수 있다
    • REACT에서 Axios를 통해 백엔드 포트 번호가 담긴 url로 메서드를 호출하면, 백엔드에서 동일 url에 대한 라우팅 처리를 한다
  • BobyParser의 역할
    • 백엔드에서 프론트엔드로부터 넘어온 데이터를 DB에 저장할 때나 그 반대의 동작을 수행할 때, JSON 형태의 데이터를 어떻게 가공하는지가 매우 중요하다
      • JSON 상하차😂😂😂
    • BodyParser를 이용하면, 해당 절차를 더 간단히 수행할 수 있다
    • 그렇지 않으면, JSON 인코딩(GET)과 디코딩(POST) 절차를 직접 구현해야 한다
  • GO를 활용할 때, 구조체의 변수는 반드시 대문자로 정의해야 한다
    • 변수명을 소문자로 정의하면서, private된 변수를 읽어올 수 없어 문제를 찾는데 한동안 애먹었다
    • 처음에는 JSON 파싱 과정의 문제인가 싶어 한참동안 그 부분만 파헤쳤었다
    • 결국에는 TypeError를 발생시킨 ReactHtmlParser로부터 실마리를 찾아 문제를 해결할 수 있었다
      • ❗ 문제가 발생할 때는 등잔 밑이 어둡다는 생각으로 에러로부터 원인을 찾아보도록 해보자
profile
There's Only One Thing To Do: Learn All We Can

0개의 댓글