1부. Go 언어
2부. 프론트엔드
4장. React.js 와 프론트엔드 개발
npm install -g create-react-app
create-react-app frontend
create-react-app 으로 리액트 애플리케이션 프로젝트를 만드는 것은 꽤나 오래걸린다.
이제 실행을 해보자.
npm start
src 아래의 모든 파일을 삭제하자.
아래와 같이 이미지를 추가한다.
ReactDOM.render() 함수를 사용해 리액트 엘리먼트를 문서 객체 모델 (DOM) 로 변환한다.
- public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css" integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB" crossorigin="anonymous">
<meta name="theme-color" content="#000000">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<title>React App</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/js/bootstrap.min.js" integrity="sha384-smHYKdLADwkXOn1EmN1qk/HfnUcbVRZyYmZ4qpPea6sjB/pTJ0euyQp0Mk8ck+5T" crossorigin="anonymous"></script>
</body>
</html>
- src/index.js
import React from "react"
import ReactDOM from "react-dom"
class Card extends React.Component {
render() {
const img = "img/strings.png"
const imgalt = "string"
const desc = "A very authentic and beautiful instrument!!"
const price = 100
const productName = "Strings"
return (
<div className="col-md-6 col-lg-4 d-flex align-items-stretch">
<div className="card mb-3">
<img className="card-img-top" src={img} alt={imgalt}/>
<div className="card-body">
<h4 className="card-title">{productName}</h4>
Price: <strong>{price}</strong>
<p className="card-text">{desc}</p>
<a href="#" className="btn btn-primary">Buy</a>
</div>
</div>
</div>
)
}
}
ReactDOM.render(<Card/>, document.getElementById('root'))
리액트 애플리케이션 설계
리액트 애플리케이션은 서로 통신하는 여러 컴포넌트로 구성된다.
리액트 애플리케이션은 서로 통신하는 여러 컴포넌트로 구성된다.
우선 여러 컴포넌트의 진입점 역할을 하는 메인 컴포넌트가 필요하다.
리액트 커뮤니티는 컴포넌트의 상속보다 컴포지션을 선호한다.
컴포지션 (Composition) 이란 모든 컴포넌트 클래스가 React.Component 를 상속받고 부모 컴포넌트가 자식 컴포넌트를 렌더링한다는 뜻이다.
상품페이지에는 상품 목록을 나타내는 CardContainer 컴포넌트와 개별 상품을 나타내는 Card 컴포넌트가 있다.
CardContainer 컴포넌트는 부모이고 Card 컴포넌트는 자식이다.
CardContainer 컴포넌트 코드를 살펴보기 전에 CardContainer 에서 Card 로 상품정보를 어떻게 넘기는지 알아보자.
가능한 최상위 부모 컴포넌트에서 데이터를 관리하고 자식 컴포넌트로 전달하는 구조가 가장 효율성이 높다.
이 구조를 통해 자식 컴포넌트들과 부모 컴포넌트는 서로 동기화될 수 있다.
리액트에서 부모와 자식 컴포넌트 간의 데이터 전달은 props 객체를 사용한다.
props 는 properties (속성) 의 줄임말이다.
Card 컴포넌트를 작성해보자.
- index.js
class Card extends React.Component {
render() {
return (
<div className="col-md-6 col-lg-4 d-flex align-items-stretch">
<div className="card mb-3">
<img className="card-img-top" src={this.props.img} alt={this.props.imgalt}/>
<div className="card-body">
<h4 className="card-title">{this.props.productName}</h4>
Price: <strong>{this.props.price}</strong>
<p className="card-text">{this.props.desc}</p>
<a href="#" className="btn btn-primary">Buy</a>
</div>
</div>
</div>
)
}
}
코드를 보면 this.props 를 통해 props 객체에 접근한다.
카드 2장을 포함하는 간단한 CardContainer 컴포넌트를 만들어보자.
- index.js
import React from "react"
import ReactDOM from "react-dom"
class Card extends React.Component {
render() {
return (
<div className="col-md-6 col-lg-4 d-flex align-items-stretch">
<div className="card mb-3">
<img className="card-img-top" src={this.props.img} alt={this.props.imgalt}/>
<div className="card-body">
<h4 className="card-title">{this.props.productName}</h4>
Price: <strong>{this.props.price}</strong>
<p className="card-text">{this.props.desc}</p>
<a href="#" className="btn btn-primary">Buy</a>
</div>
</div>
</div>
)
}
}
class CardContainer extends React.Component {
render() {
return(
<div>
<Card key='1' img="img/strings.png" alt="strings" productName="Strings" price='100.0' desc="A very authentic and beautiful instrument!!"/>
<Card key='2' img="img/redguitar.jpeg" alt="redg" productName="Red Guitar" price='299.0' desc="A really cool red guitar that can produce super cool music!!"/>
</div>
)
}
}
ReactDOM.render(<CardContainer/>, document.getElementById('root'))
- 리스트를 태그로 뿌리기
class CardContainer extends React.Component {
render() {
const cards = [
{
"id": 1,
"img": "img/strings.png",
"imgalt": "strings",
"desc": "A very authentic and beautiful instrument!!",
"price": '100.0',
"productName": "Strings",
},
{
"id": 2,
"img": "img/redguitar.jpeg",
"imgalt": "redg",
"desc": "A really cool red guitar that can produce super cool music!!",
"price": '299.0',
"productName": "Red Guitar",
},
]
const cardItems = cards.map(card => <Card key={card.id} {...card}/>)
return(
<div>
<div>{cardItems}</div>
</div>
)
}
}
똑같이 화면에 뿌려지는 것을 볼 수 있다.
... 구문은 해당 객체의 모든 속성을 Card 컴포넌트로 전달한다. card 객체의 속성명과 Card 컴포넌트에서 접근하는 props 의 필드명이 동일하기 때문에 에러가 발생하지 않는다.
card 객체에는 key 대신 id 필드가 있기 때문에 key 속성은 직접 추가해야 한다.
const cardItems = cards.map(card => <Card {...card}/>)
이렇게 작성해도 무관하다.
state
props 를 사용하면 컴포넌트에서 컴포넌트로 데이터를 전달할 수 있다.
하지만 앞서 'props' 절에서 작성한 예제처럼 프로덕션 버전에 데이터를 하드코딩할 수는 없다.
실제로는 서버 측 API 를 통해 데이터를 받아야하지만 cards.json 파일에서 악기정보를 읽는다.
리액트에서 데이터는 state 객체에 저장한다.
다음 절에서는 state 객체를 초기화하고 설정하는 방법을 알아본다.
state 객체 초기화
state 객체는 리액트 컴포넌트의 생성자 (constructor) 에서 초기화한다. 상품 페이지에서 필요한 데이터는 상품 정보다.
다음과 같이 state 객체를 초기화한다.
class CardContainer extends React.Component {
constructor(props) {
// 부모 컴포넌트로 props 전달
super(props)
// 컴포넌트의 state 객체 초기화
this.state = {
cards: []
}
}
컴포넌트 생성자의 매개변수는 props 객체다. 가장 먼저 생성자를 통해 부모 React.Component 객체에 props 를 전달한다.
두 번째로 state 객체를 초기화한다. state 객체는 컴포넌트의 로컬 객체이며 다른 컴포넌트와 공유되지 않는다.
위 예제의 state 객체에는 악기 카드 목록을 나타내는 cards 를 저장한다.
state 설정
state 객체에 악기카드 목록을 저장해보자.
리액트 state 객체에 값을 저장할 때는 컴포넌트 클래스의 setState() 메서드를 사용한다.
다음과 같이 최신 브라우저에서 많이 쓰는 fetch() 메서드를 사용해 cards.json 파일에서 데이터를 읽고 state 객체에 저장한다.
fetch('cards.json')
.then(res => res.json())
.then(result => {
this.setState({
cards: result
})
})
리액트에는 라이프 사이클 (life cycle) 메서드라는 개념이 있다. 라이프 사이클 메서드란 해당 컴포넌트의 라이프 사이클 이벤트가 발생할 때마다 실행되는 메서드다. 예를 들어 컴포넌트가 마운트되면 (componentDidMount() 메서드를 호출한다.
이 메서드를 오버라이드하면 컴포넌트가 마운트될 때마다 해당 코드가 실행된다.
외부 소스에서 전달받은 데이터로 state 를 초기화하는 로직은 componentDidMount() 메서드에 넣는 것이 좋다.
- cards.json 파일 읽어서 뿌리기 - index.js
import React from "react"
import ReactDOM from "react-dom"
class Card extends React.Component {
render() {
return (
<div className="col-md-6 col-lg-4 d-flex align-items-stretch">
<div className="card mb-3">
<img className="card-img-top" src={this.props.img} alt={this.props.imgalt}/>
<div className="card-body">
<h4 className="card-title">{this.props.id}. {this.props.productName}</h4>
Price: <strong>{this.props.price}</strong>
<p className="card-text">{this.props.desc}</p>
<a href="#" className="btn btn-primary">Buy</a>
</div>
</div>
</div>
)
}
}
class CardContainer extends React.Component {
constructor(props) {
// 부모 컴포넌트로 props 전달
super(props)
// 컴포넌트의 state 객체 초기화
this.state = {
cards: []
}
}
componentDidMount() {
fetch('cards.json')
.then(res => res.json())
.then(result => {
this.setState({
cards: result
})
})
}
render() {
const cards = this.state.cards
let items = cards.map(card => <Card {...card}/>)
return(
<div className="container pt-4">
<h3 className="'text-center text-primary">Products</h3>
<div className="pt-4 row">{items}</div>
</div>
)
}
}
ReactDOM.render(<CardContainer/>, document.getElementById('root'))
개발 툴
리액트 커뮤니티는 매우 열저적이다. 페이스북은 리액트 앱 디버깅과 문제해결에 사용할 수 있는 다양한 툴을 공개했다.
아래 링크에서 확인할 수 있다.
깃허브: https://github.com/facebook/react-devtools
개발 툴은 크롬과 파이어폭스 익스텐션이나 독립적인 앱 형태로 제공된다.
툴을 사용하면 쉽게 리액트 앱을 분석하고 문제를 해결할 수 있다.
개발 툴에 어떤 기능이 있는지 충분히 사용해보길 권한다.
5장. GoMusic 프론트엔드 개발
GoMusic 은 다음과 같이 대부분의 온라인 쇼핑몰에 있는 기능을 지원한다.
- 사용자가 원하는 상품 주문
- 세일 품목과 프로모션을 보여주는 프로모션 페이지
- 사용자별 개인 설정을 위한 계정 생성과 로그인
5장에서 구현하는 프론트엔드는 다음과 같은 3개의 메인 컴포넌트로 구성된다.
- 모든 사용자가 접속할 수 있는 메인 페이지
- 상품 주문과 계정 생성, 로그인할 때 띄우는 모달 윈도 (modal window)
- 로그인한 사용자의 개인설정을 보여주는 사용자 페이지
5장에서 다루는 내용은 다음과 같다.
- 리액트 애플리케이션 개발
- 스트라이프 (Stripe) 를 사용해 프론트엔드에 신용카드 결제 처리
- 모달 윈도 추가
- 라우트 (route) 설계
탐색 메뉴
리액트 프레이워크에서 react-router-dom 패키지를 이용하면 쉽게 탐색메뉴를 만들 수 있다.
npm install --save react-router-dom
reactstrap 패키지를 설치한다.
이 패키지는 부트스트랩 프레임워크의 일부 기능을 리액트 컴포넌트를 통해 제공한다.
Modal 컴포넌트를 사용하여 리액트에서 모달 윈도를 쉽게 생성할 수 있다.
터미널을 실행하고 프로젝트 메인 폴더에서 다음 명령어를 실행한다.
npm install --save reactstrap
리액트 스프랑리프 엘리먼트의 기능은 다음과 같다.
- UI 엘리먼트는 신용카드 번호와 유효기간, CVC 번호, 우편번호 등의 신용카드 정보를 입력받는다.
- 입력된 카드번호가 Master 또는 Visa 인지 확인하는 등의 토큰 ID 값을 발급한다. 해당 ID 를 백엔드에 저장하고 사용한다.
스프라이프 리액트 패키지를 설치한다. 터미널을 열고 gomusic 프로젝트 폴더에서 다음 명령어를 실행한다.
npm install --save react-stripe-elements
public/index.html 파일의 태그 바로 전에 다음 코드를 추가한다.
<script src="https://js.stripe.com/v3/"></script>
이 코드는 사용자가 GoMusic 애플리케이션을 브라우저에서 실행하면 자동으로 스프라이트 라이브러리를 불러온다.
src 폴더에 CreditCards.js 파일을 만들고 개발에 필요한 패키지를 임포트한다.
- App.js
import React from "react";
import CardContainer from "./ProductCards";
import Nav from './Navigation'
import {SignInModalWindow, BuyModalWindow} from './modalwindows'
import About from "./About";
// import Orders from './orders'
import {BrowserRouter as Router, Route} from "react-router-dom"
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
user: {
loggedin: false,
name: "",
}
};
// this.showSignInModalWindow = this.showSignInModalWindow.bind(this);
// this.toggleSignInModalWindow = this.toggleSignInModalWindow.bind(this);
// this.showBuyModalWindow = this.showBuyModalWindow.bind(this);
// this.toggleBuyModalWindow = this.toggleBuyModalWindow.bind(this);
}
handleSignedIn(user) {
this.setState({
user: user
});
}
showSignInModalWindow() {
const state = this.state
const newState = Object.assign({}.state,{showSignInModalWindow:true})
this.setState(newState)
}
showBuyModalWindow(id, price) {
const state = this.state
const newState = Object.assign({}, state, {showBuyModal:true, productid: id, price: price})
this.setState(newState)
}
toggleSignInModalWindow() {
// const start = this.state
// const newState = Object.assign({}, state, {showSignInModal:!state.showBuyModal})
// this.setState(newState)
}
componentDidMount() {
fetch('user.json')
.then(res => res.json())
.then((result) => {
console.log('Fetch...');
this.setState({
user: result
});
});
}
render() {
return (
<div>
<Router>
<div>
<Nav user={this.state.user} showModalWindow={this.showSignInModalWindow}/>
<div className="container pt-4 mt-4">
<Route exact path="/" render={() => <CardContainer location='cards.json' showBuyModal={this.showBuyModalWindow}/>}/>
<Route exact path="/promos" render={() => <CardContainer location='promos.json' promo={true} showBuyModal={this.showBuyModalWindow}/>}/>
<Route path="/about" Component={About}/>
</div>
</div>
</Router>
</div>
)
}
}
export default App;
- Navigation.js
import React from "react"
import {NavLink} from 'react-router-dom'
export default class Navigation extends React.Component {
buildLoggedInMenu() {
return (
<div className="navbar-brand order-1 text-white my-auto">
<div className="btn-group">
<button type="button" className="btn btn-success dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Welcome {this.props.user.name}
</button>
<div className="dropdown-menu">
<a className="btn dropdown-item" role="button">Sign Out</a>
</div>
</div>
</div>
);
}
render() {
// 메뉴를 표현하는 코드
return (
<div>
<nav className="navbar navbar-expand-lg navbar-dark bg-success fixed-top">
<div className="container">
<button type="button" className="navbar-brand order-1 btn btn-success" onClick={() => {
this.props.showModalWindow()
}}>Sign in</button>
<div className="navbar-collapse" id="navbarNavAltMarkup">
<div className="navbar-nav">
<NavLink className="nav-item nav-link" to="/">Home</NavLink>
<NavLink className="nav-item nav-link" to="/promos">Promotions</NavLink>
<NavLink className="nav-item nav-link" to="/about">About</NavLink>
</div>
</div>
</div>
</nav>
</div>
)
}
}
- ProductCards.js
import React from "react"
class Card extends React.Component {
render() {
const priceColor = (this.props.promo) ? "text-danger" : "text-dark"
const sellPrice = (this.props.promo) ? this.props.promotion : this.props.price
return (
<div className="col-md-6 col-lg-4 d-flex align-items-stretch">
<div className="card mb-3">
<img className="card-img-top" src={this.props.img} alt={this.props.imgalt}/>
<div className="card-body">
<h4 className="card-title">{this.props.productName}</h4>
Price: <strong className="{priceColor}">{sellPrice}</strong>
<p className="card-text">{this.props.desc}</p>
<a className="btn btn-success text-white" onClick={() => {this.props.showBuyModal(this.props.ID,sellPrice)}}>Buy</a>
</div>
</div>
</div>
)
}
}
export default class CardContainer extends React.Component {
constructor(props) {
super(props);
this.state = {
cards: []
};
}
componentDidMount() {
console.log("props", this.props)
fetch(this.props.location)
.then(res => res.json())
.then((result) => {
this.setState({
cards: result
});
});
}
render() {
const cards = this.state.cards
let items = cards.map(card => <Card key={card.id} {...card} promo={this.props.promo} showBuyModal={this.props.showBuyModal}/>)
return(
<div>
<div className="mt-5 row">
{items}
</div>
</div>
)
}
}
- public/cards.json
[{
"id" : 1,
"img" : "img/strings.png",
"imgalt":"string",
"desc":"A very authentic and beautiful instrument!!",
"price" : 100.0,
"productname" : "Strings"
}, {
"id" : 2,
"img" : "img/redguitar.jpeg",
"imgalt":"redg",
"desc":"A really cool red guitar that can produce super cool music!!",
"price" : 299.0,
"productname" : "Red Guitar"
},{
"id" : 3,
"img" : "img/drums.jpg",
"imgalt":"drums",
"desc":"A set of super awesome drums, combined with a guitar, they can product more than amazing music!!",
"price" : 17000.0,
"productname" : "Drums"
},{
"id" : 4,
"img" : "img/flute.jpeg",
"imgalt":"flute",
"desc":"A super nice flute combined with some super nice musical notes!!",
"price" : 210.0,
"productname" : "Flute"
},{
"id" : 5,
"img" : "img/blackguitar.jpeg",
"imgalt":"Black guitar",
"desc":"An awesome guitar that will product amazing sound!!",
"price" : 200.0,
"productname" : "Black Guitar"
},{
"id" : 6,
"img" : "img/saxophone.jpeg",
"imgalt":"Saxophone",
"desc":"A great saxophone for a great musician!!",
"price" : 1000.0,
"productname" : "Saxophone"
}]
- public/promos.json
[{
"id" : 3,
"img" : "img/drums.jpg",
"imgalt":"drums",
"desc":"A set of super awesome drums, combined with a guitar, they can product more than amazing music!!",
"price" : 17000.0,
"productname" : "Drums"
},{
"id" : 4,
"img" : "img/flute.jpeg",
"imgalt":"flute",
"desc":"A super nice flute combined with some super nice musical notes!!",
"price" : 210.0,
"productname" : "Flute"
},{
"id" : 5,
"img" : "img/blackguitar.jpeg",
"imgalt":"Black guitar",
"desc":"An awesome guitar that will product amazing sound!!",
"price" : 200.0,
"productname" : "Black Guitar"
},{
"id" : 6,
"img" : "img/saxophone.jpeg",
"imgalt":"Saxophone",
"desc":"An great saxophone for a great musician!!",
"price" : 1000.0,
"productname" : "Saxophone"
}]
About 페이지와 Buy, Sign In 모달을 마무리해야 한다.
3부. Go 웹 API 와 미들웨어
6장. Gin 프레임워크 기반 Go RESTful 웹 API
URL
http://www.example.com/user?id=1
이 URL 은 다음과 같은 세 가지 주요 구성요소로 이뤄져 있다.
- 서버주소: 프로토콜과 서버 도메인 주소의 조합
- 상대경로: 서버 주소의 상대경로
- 쿼리: 요청하는 자원에 대한 정보
Gin 프레임워크
Gin 프레임워크는 고성능 RESTful API 개발에 많이 사용되는 유명한 Go 기반의 오픈 소스 프레임워크이다.
상세한 내용은 https://github.com/gin-gonic/gin 에서 확인할 수 있다.
Gin 프레임워크는 성능도 높고 실제로 RESTful API 를 구현하는 데 사용할 수 있는 간단하고 쉬운 API 를 제공한다.
모델과 데이터베이스 레이어
데이터베이스에 관련된 코드를 데이터베이스 레이어라고 부른다.
모델
데이터베이스 레이어 설계 시 가장 먼저 데이터 모델 (data model) 이 필요하다.
GoMusic 애플리케이션은 온라인에서 상품을 판매하는 단순한 쇼핑몰이기 때문에 다음 세 가지 모델로 나눌 수 있다.
- 상품 (Product)
- 고객 (Customer)
- 주문 (Customer order)
- 구조체 정의 - models.go
type Product struct {
Image string `json:"img"`
ImagAlt string `json:"imgalt"`
Price float64 `json:"price"`
Promotion float64 `json:"promotion"`
ProductName string `json:"productname"`
Description string `json:"desc"`
}
type Customer struct {
FirstName string `json:"firstname"`
LastName string `json:"lastname"`
Email string `json:"email"`
LoggedIn bool `json:"loggedin"`
}
type Order struct {
Product
Customer
CustomerID int `json:"customer_id"`
ProductID int `json:"product_id"`
Price float64 `json:"sell_price"`
PurchaseDate time.Time `json:"purchase_date"`
}
위 `json:"..."` 구문은 구조체 태그 (struct tag) 라는 문법이다.
Go 구조체를 정의할 때 JSON 구조체 태그를 사용하지 않으면 기본 설정된 규칙을 기반으로 각 필드를 JSON 필드로 변환한다.
예를 들어 모든 필드명의 첫 글자를 소문자로 변환한다. 일반적으로 JSON 형식을 완전히 제어하려면 구조체 태그를 사용하는 것이 좋다.
Order 구조체는 Go 언어의 임베딩 (embedding) 기능을 사용한다.
데이터베이스 레이어 인터페이스
go mod init backend/src
dblayer 에서 models 패키지를 임포트하려면 go.mod 파일이 있어야 한다.
- dblayer.go
import "backend/src/src/models"
type DBLayer interface {
GetAllProducts() ([]models.Product, error)
GetPromos() ([]models.Product, error)
GetCustomerByName() (models.Customer, error)
GetCustomerByID(int) (models.Product, error)
GetProduct(uint) (models.Product, error)
AddUser(models.Customer) (models.Customer, error)
SignInUser(username, password string) (models.Customer, error)
SignOutUserById(int) error
GetCustomerOrdersByID(int) ([]models.Order, error)
}
7장에서 데이터베이스 레이어를 구현한다.
라우팅 정의
URL 은 API 의 자원을 가리키는 경로 (라우트) 이기 때문에 이 작업을 라우팅 정의 (defining rouing) 라고 한다.
RESTful API 기능별로 라우팅을 정의해보자.
go get -u github.com/gin-gonic/gin
- RESTful API 의 진입점 함수
func RunAPI(address string) error {
r := gin.Default()
r.GET("/relativepath/to/url", func(c *gin.Context) {
// 로직 구현
})
}
Gin 을 사용하려면 Gin 엔진 객체가 필요하다. 이 객체는 URL 을 정의하고 HTTP 메서드를 지정할 때 사용할 객체다.
익명함수 func(c *gin.Context){} 에는 조건을 충족하는 클라이언트 요청을 처리하는 작업을 정의한다.
- 라우팅
func RunAPI(address string) error {
r := gin.Default()
r.GET("/relativepath/to/url", func(c *gin.Context) {
// 로직 구현
})
// 상품 목록
r.GET("/products", func(c *gin.Context) {
// 클라이언트에게 상품 목록 반환
})
// 프로모션 목록
r.GET("/promos", func(c *gin.Context) {
})
// 사용자 로그인 POST 요청
r.POST("/users/signin", func(c *gin.Context) {
// 사용자 로그인
})
// 사용자 추가 POST 요청
r.POST("/users", func(c *gin.Context) {
// 사용자 추가
})
// 사용자 로그아웃 POST 요청
/*
아래 경로는 사용자 ID 를 포함한다. ID 는 사용자마다 고유한 값이기 때문에
와일드카드 (*) 를 사용한다. ':id' 는 변수 id 를 의미한다.
*/
r.POST("/user/:id/signout", func(c *gin.Context) {
// 해당 ID 의 사용자 로그아웃
})
// 구매 목록 조회
r.GET("/user/:id/orders", func(c *gin.Context) {
// 해당 ID 의 사용자의 주문내역 조회
})
// 결제 POST 요청
r.POST("/users/charge", func(c *gin.Context) {
// 신용카드 결제 처리
})
}
핸들러 구현
이제 클라이언트의 요청을 처리하는 작업을 정의하자. 이를 핸들러 (handler) 라고 한다.
코드의 확장성을 높이고자 핸들러의 모든 메서드를 포함하는 인터페이스를 만든다.
- 핸들러 인터페이스
type HandlerInterface interface {
GetProducts(c *gin.Context)
GetPromos(c *gin.Context)
AddUser(c *gin.Context)
SignIn(c *gin.Context)
SignOut(c *gin.Context)
GetOrders(c *gin.Context)
Charge(c *gin.Context)
}
다음은 모든 메서드가 있는 Handler 구조체를 정의한다.
type Handler struct {
db dblayer.DBLayer
}
func NewHandler() (*Handler, error) {
// Handler 객체에 대한 포인터 생성
return new(Handler), nil
}
데이터베이스 레이어 타입의 초기화를 위해 이 생성자의 구현을 앞으로 계속해서 추가한다.
우선은 Handler 의 메서드를 정의하자.
상품 목록 조회
func (h *Handler) GetProducts(c *gin.Context) {
if h.db == nil {
return
}
products, err := h.db.GetAllProducts()
if err != nil {
/*
첫 번째 매개변수는 HTTP 상태 코드, 두 번째는 응답의 바디
*/
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, products)
}
프로모션 목록 조회
func (h *Handler) GetPromos(c *gin.Context) {
if h.db == nil {
return
}
promos, err := h.db.GetPromos()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, promos)
}
사용자 로그인과 신규가입
err := c.ShouldBindJSON(&customer)
이 메서드는 HTTP 요청 바디에서 JSON 문서를 추출하고 객체로 디코딩한다.
위의 경우는 이 객체는 고객 데이터 모델을 나타내는 *models.Customer 타입이다.
func (h *Handler) SignIn(c *gin.Context) {
if h.db == nil {
return
}
var customer models.Customer
err := c.ShouldBindJSON(&customer)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
customer, err = h.db.AddUser(customer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, customer)
}
로그아웃 요청
func (h *Handler) SignOut(c *gin.Context) {
if h.db == nil {
return
}
p := c.Param("id")
// p 는 문자형. 정수형으로 변환해야 함
id, err := strconv.Atoi(p)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err = h.db.SignOutUserById(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
사용자의 주문 내역 조회
func (h *Handler) GetOrders(c *gin.Context) {
if h.db == nil {
return
}
// id 매개변수 추출
p := c.Param("id")
id, err := strconv.Atoi(p)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 데이터베이스 레이어 메서드 호출과 주문내역 조회
orders, err := h.db.GetCustomerOrdersByID(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, orders)
}
신용카드 결제 요청
데이터베이스 관련 작업 외에 여러 작업을 수행하는 핸들러다. 스트라이프 API 를 사용해 사용자의 신용카드로 결제한다.
이 부분은 7장에서 자세히 설명한다.
우선은 다음과 같은 핸들러를 정의한다.
func (h *Handler) Charge(c *gin.Context) {
if h.db == nil {
return
}
}
정리
./backend/src/rest/rest.go 파일을 열고 핸들러와 라우팅을 매핑한다.
func RunAPI(address string) error {
r := gin.Default()
h, _ := handler.NewHandler()
r.GET("/products", h.GetProducts)
r.GET("/promos", h.GetPromos)
r.POST("/users/signin", h.SignIn)
r.POST("/users", h.AddUser)
r.POST("/user/:id/signout", h.SignOut)
r.GET("/user/:id/orders", h.GetOrders)
r.POST("/users/charge", h.Charge)
return r.Run(address)
}
마지막 줄의 r.Run(address) 는 RESTful API 서버가 HTTP 클라이언트 요청을 기다리도록 반드시 API 핸들러와 라우팅 정의 뒤에 호출해야 한다.
/user/ 와 /users 로 시작하는 라우팅은 다음과 같이 Group() 메서드를 사용해 리팩토링할 수 있다.
func RunAPI(address string) error {
r := gin.Default()
h, _ := handler.NewHandler()
r.GET("/products", h.GetProducts)
r.GET("/promos", h.GetPromos)
userGroup := r.Group("/user")
{
userGroup.POST("/:id/signout", h.SignOut)
userGroup.GET("/:id/orders", h.GetOrders)
}
usersGroup := r.Group("/users")
{
usersGroup.POST("/charge", h.Charge)
usersGroup.POST("/signin", h.SignIn)
usersGroup.POST("", h.AddUser)
}
return r.Run(address)
}
위와 같은 방식을 그룹 라우팅 (grouping routes) 이라고 부른다.
URL 의 일부를 꽁유하는 HTTP 라우팅은 같은 코드 블록으로 묶을 수 있다.
핸들러를 매개변수로 전달받는 함수이기 때문에 함수명을 RunAPIWithHandler() 로 수정하면 의미가 확실해진다.
func RunAPIWithHandler(address string, h HandlerInterface) error {
}
RunAPIWithHandler() 기본 상태를 나타내는 RunAPI() 함수를 만든다.
이 함수는 HandlerInterface 의 기본 구현을 사용한다.
- 기본상태
func RunAPI(address string) error {
h, err := NewHandler()
if err != nil {
return err
}
return RunAPIWithHandler(address, h)
}
backend/src 폴더의 main.go 파일에서 RunAPI() 를 호출한다.
func main() {
log.Println("Main log....")
log.Fatal(rest.RunAPI("127.0.0.1:8000"))
}
이제 완성된 백엔드와 앞에서 작성한 리액트 프론트엔드를 어떻게 연결할 수 있을까 ?
방법은 간단하다.
리액트 프론트엔드의 root 폴더에 있는 package.json 파일에 다음 필드를 추가한다.
"proxy": "http://127.0.0.1:8000/"
이 필드를 추가하면 프론트엔드는 모든 요청을 프록시 (proxy) 주소로 포워딩한다.
RunAPI() 함수에서 address 매개변수의 값을 127.0.0.1:8000 으로 설정하면 프론트엔드의 요청을 받기 시작한다.
7장. Gin 과 리액트 기반 고급 웹 애플리케이션
데이터베이스 레이어
이제 각 메서드를 구현하고 데이터베이스 레이어를 완성시키자.
메서드를 구현하기 전에 먼저 데이터베이스가 필요하다.
관계형 데이터베이스
다음 절에는 MySQL 을 설정한다.
설정
- 설치
brew install mysql
- 시작
brew services restart mysql
PostgresQL 을 설치하고 싶지만 책에서 MySQL 을 사용하니 똑같이 사용하도록 하자.
데이터베이스 설계를 마무리하고 다음 절에서는 데이터베이스와 상호작용하는 코드를 작성해본다.
ORM
ORM 을 사용하면 대부분의 프로그래밍 언어에서 데이터베이스 테이블은 객체로, 쿼리는 메서드로 표현할 수 있다.
코드를 작성하기 전에 우선 ORM 을 지원하는 Go 오픈소스 패키지인 GORM (Go Object-Relational Mapping) 을 알아보자.
GORM
GORM 패키지는 Go 언어에서 가장 많이 사용되는 ORM 패키지다.
자세한 설명은 https://gorm.io/ 에서 확인할 수 있다.
먼저 GORM 패키지를 설치한다.
go get -u github.com/jinzhu/gorm
ORM 에서 데이터베이스 테이블을 사용하려면 테이블의 칼럼 구조를 정확하게 표현하는 모델 객체와 테이블의 메타정보가 필요하다.
예를 들어 행이 업데이트되거나 삭제, 생성된 시간을 기반으로 데이터베이스와 애플리케이션을 동기화한다.
GORM 이 제공하는 gorm.Model 구조체에는 행의 id 와 created_at, updated_at, deleted_at 필드가 있다.
데이터를 나타내는 구조체에는 gorm.Model 을 임베드하는 것이 좋다.
gorm 태그는 해당 필드의 칼럼 이름을 나타낸다.
json 구조체 태그는 해당 필드의 JSON 필드명을 나타낸다.
이론적으로 모든 필드에 구조체 태그를 붙여야 할 필요는 없다.
다만 혼란을 피할 수 있어 실용적이다.
GORM 은 어떻게 Customer 구조체가 customers 테이블에 해당하는지 알 수 있을까 ?
일반적으로 GORM 은 구조체 이름의 첫 글자를 소문자로 바꾸고 끝에 's' 를 붙인다.
Customer 는 customers 가 된다.
TableName() 메서드를 사용해 구조체가 나타내는 테이블의 이름을 직접 설정하는 방법도 있다.
다음과 같이 사용한다.
func (Customer) TableName() string {
return "customers"
}
프로젝트 폴더의 models.go 파일을 열고 products 와 orders 테이블의 모델을 수정한다.
- models.go
type Product struct {
gorm.Model
Image string `json:"img"`
ImagAlt string `json:"imgalt" gorm:"column:imgalt"`
Price float64 `json:"price"`
Promotion float64 `json:"promotion"` // sql.NullFloat64
ProductName string `json:"productname" gorm:"column:productname"`
Description string
}
func (Product) TableName() string {
return "products"
}
type Customer struct {
FirstName string `json:"firstname"`
LastName string `json:"lastname"`
Email string `json:"email"`
LoggedIn bool `json:"loggedin"`
}
type Order struct {
gorm.Model
Product
Customer
CustomerID int `gorm:"column:customer_id"`
ProductID int `gorm:"column:product_id"`
Price float64 `json:"sell_price" gorm:"column:price"`
PurchaseDate time.Time `json:"purchase_date" gorm:"column:purchase_date"`
}
func (Order) TableName() string {
return "orders"
}
다음 절에서는 데이터베이스 레이어를 구현한다.
데이터베이스 레이어 구현
- 설치하기
go get github.com/go-sql-driver/mysql
- DBLayer 인터페이스를 구현하는 Go 구조체
type DBORM struct {
*gorm.DB
}
이 구조체는 *gorm.DB 타입을 임베드한다.
*gorm.DB 타입을 통해 GORM 의 메서드를 호출한다.
새 구조체의 생성자가 필요하다. 생성자는 임베드된 *gorm.DB 를 초기화한다.
gorm.Open() 함수를 호출하면 *gorm.DB 타입을 초기화한다.
이 함수는 데이터베이스 종류 (mysql) 와 연결 문자열을 매개변수로 전달받는다.
연결 문자열은 해당 데이터베이스로 연결할 때 필요한 정보를 담고 있다.
생성자의 사용성을 높이고자 데이터베이스 이름과 연결 문자열을 하드코딩하지 않는다.
대신 다음과 같이 생성자에 매개변수로 전달한다.
func NewORM(dbname, con string) (*DBORM, error) {
db, err := gorm.Open(dbname, con)
return &DBORM{
DB: db,
}, err
}
마지막으로 DBLayer 에 인터페이스의 메서드를 구현하자.
GetAllProducts() 메서드부터 구현한다.
- 상품목록 조회
func (db *DBORM) GetAllProducts() (products []models.Product, err error) {
return products, db.Find(&products).Error
}
위와 같이 메서드를 호출하면 products 테이블에 select * from products 쿼리를 실행하고 결과를 반환한다.
메서드가 전달한 인수가 []models.Product 타입이기 때문에 products 테이블을 조회한다.
다음은 프로모션 중인 상품을 반환하는 GetPromos() 메서드를 작성한다.
where 조건이 있는 간단한 select 문이다. Where() 메서드와 앞에서 사용한 Find() 메서드를 함께 사용하면 된다.
func (db *DBORM) GetPromos() (products models.Product, err error) {
return products, db.Where("promotion IS NOT NULL").Find(&products).Error
}
Where() 메서드는 쿼리의 조건을 나타내는 Go 구조체 값을 매개변수로 전달받는다.
아래의 GetCustomerByName 메서드를 보면 사용자 이름과 성을 매개변수로 전달받고 사용자 정보를 반환한다.
- 이름으로 고객 조회
func (db *DBORM) GetCustomerByName(firstname, lastname string) (customer models.Customer, err error) {
return customer, db.Where(&models.Customer{FirstName: firstname, LastName: lastname}).Find(&customer).Error
}
where 구문이 문자열 대신에 이름과 성을 나타내는 Go 구조체의 Where() 의 매개변수로 전달한다.
아래 쿼리와 같은 의미다.
SELECT *
FROM customers
WHERE firstname='..' AND lastname='..'
다음은 사용자 ID 로 사용자 정보를 조회하는 GetCustomerByID() 메서드를 구현한다.
이번에는 Where 와 Find 의 조합 대신 쿼리의 조건을 만족하는 첫 번째 결과만 반환하는 First 메서드를 사용한다.
- ID 로 고객 조회
func (db *DBORM) GetCustomerByID(id int) (customer models.Customer, err error) {
return customer, db.First(&customer, id).Error
}
GetProduct() 는 반환하는 값이 사용자 대신 상품이라는 점 외에는 위 메서드와 같다.
- ID 로 상품 조회
func (db *DBORM) GetProduct(id int) (product models.Product, err error) {
return product, db.First(&product, id).Error
}
지금까지 쿼리를 수행하고 결과를 반환하는 메서드를 작성했다.
이제 데이터를 삽입하고 수정하는 메서드를 작성한다.
다음은 새로운 사용자의 정보를 데이터베이스에 삽입하는 AddUser() 메서드다.
이 메서드는 사용자의 패스워드를 해싱 (hashing) 하고 로그인 상태로 설정한다.
GORM 은 데이터베이스에 데이터를 삽입하는 Create() 메서드를 제공한다.
- 로그인
func (db *DBORM) SignInUser(email, pass string) (customer models.Customer, err error) {
if !checkPassword(pass) {
return customer, errors.New("Invalid password")
}
// 사용자 행을 나타내는 *gorm.DB 타입
result := db.Table("Customers").Where(&models.Customer{Email: email})
// loggedin 필드 업데이트
err = result.Update("loggedin", 1).Error
if err != nil {
return customer, err
}
// 사용자 행 반환
return customer, result.Find(&customer).Error
}
SignOutUserById() 메서드는 ID 에 해당하는 사용자를 로그아웃 처리한다.
앞서 작성한 메서드와 같은 방식으로 메서드를 작성한다.
- 로그아웃
func (db *DBORM) SignOutUserById(id int) error {
// ID 에 해당하는 사용자 구조체 생성
customer := models.Customer{
Model: gorm.Model{
ID: uint(id),
},
}
// 사용자의 상태를 로그아웃 상태로 업데이트한다.
return db.Table("Customers").Where(&customer).Update("loggedin", 0).Error
}
마지막으로 특정 사용자의 주문내역을 조회하는 GetCustomerOrdersByID() 메서드를 구현한다.
- 주문내역 조회
func (db *DBORM) GetCustomerOrdersByID(id int) (orders []models.Order, err error) {
return orders, db.Table("orders").Select("*")
.Joins("join customers on customers.id = customer_id")
.Joins("join products on products.id = product_id")
.Where("customer_id=?", id).Scan(&orders).Error
}
데이터베이스 레이어가 거의 완성됐다. 다음 절에서는 미들웨어를 설명한다.
미들웨어
미들웨어 (middleware) 는 소프트웨어 개발 분ㅇ야에서 많은 것을 의미한다.
하지만 이 책에서 설명하는 미들웨어는 HTTP 요청을 처리하는 핸들러 실행 전에 실행되는 코드를 의미한다.
RESTful API 에서 /products 경로의 정의를 다시 살펴보자.
URL 과 GetProducts 핸들러를 매핑했다.
r.GET("/products", h.GetProducts)
API 가 실행되는 순서는 다음과 같다.
- /products 상대 URL 경로로 HTTP GET 요청이 들어온다.
- GetProducts() 메서드를 호출한다.
간단하게 정의하면 웹 API 미들웨어는 1번과 2번 사이 또는 그 뒤에 실행되는 코드다.
기술적으로 정의하면 미들웨어는 핸들러 메서드를 감싸는 또 다른 HTTP 핸들러 함수다.
GetProducts() 메서드를 캡슐화하고 메서드 호출 전후에 새로운 코드를 추가하자.
Gin 프레임워크에는 기본적으로 2개의 미들웨어가 포함돼 있다.
하지만 필요하다면 직접 미들웨어를 정의해도 된다.
Gin 웹 서버에서 사용할 수 있는 2개의 기본 미들웨어는 로거 (logger) 미들웨어와 리커버리 (recovery) 미들웨어다.
로거 미들웨어는 이름 그대로 애플리케이션이 실행되는 동안 모든 활동을 로그로 남긴다.
반면에 리커버리 미들웨어는 애플리케이션에서 패닉이 발생하면 500 번 HTTP 에러코드로 응답하는 미들웨어다.
Gin 프레임워크에는 다양한 오픈소스 미들웨어가 있다.
자세한 내용은 다음 링크를 참조하라.
https://github.com/gin-gonic/contrib
커스텀 미들웨어
Gin 프레임워크를 사용해 커스텀 미들웨어를 직접 구현하고 애플리케이션에 원하는 기능을 추가할 수 있다.
첫 번째 단계로 미들웨어 코드를 작성한다.
웹 API 미들웨어는 HTTP 핸들러를 감싸는 또 다른 HTTP 핸들러다.
미들웨어 구현은 다음과 같다.
- 미들웨어
func MyCustomerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 요청을 처리하기 전에 실행할 코드
// 예제 변수 설정
c.Set("v", "123")
// c.Get("v) 를 요청하면 변수 값을 확인할 수 있다.
// 요청 처리 로직 실행
c.Next()
// 이 코드는 핸들러 실행이 끝나면 실행된다.
// 응답코드 확인
status := c.Writer.Status()
// status 를 활용하는 코드 추가
}
}
func MyCustomerLogger() gin.HandlerFunc {
return func(c *gin.Context) {
fmt.Println("**********************")
c.Next()
fmt.Println("**********************")
}
}
두 번째 단계로 Gin 엔진에 미들웨어를 추가한다.
다음 두 가지 방식으로 추가할 수 있다.
- Gin 의 기본 미들웨어는 유지하고 MyCustomLogger() 라는 새로운 커스텀 미들웨어를 추가한다.
func RunAPIWithHandler(address string, h HandlerInterface) error {
r := gin.Default()
r.Use(MyCustomerLogger())
- 기본 미들웨어는 사용하지 않고 새로운 커스텀 미들웨어를 추가한다.
r := gin.New()
r.Use(MyCustomLogger())
2 개 이상의 미들웨어가 필요하다면 다음과 같이 Use() 메서드의 인수로 전달한다.
r := gin.New()
r.Use(MyCustomLogger1(), MyCustomLogger2(), MyCustomLogger3())
다음 절에서는 웹 애플리케이션 보안을 알아본다.
보안
안전한 웹 애플리케이션은 웹 클라이언트 (브라우저) 와 웹 서버의 통신 데이터를 암호화한다.
즉, 프론트엔드와 백엔드가 주고받는 데이터를 암호화한다.
앞서 설명했듯이 웹 클라이언트와 웹 서버는 HTTP 프로토콜을 통해 통신한다.
TLS (Transport Layer Security) 레이어를 사용하면 HTTP 를 더 안전하게 만들 수 있다.
HTTP 와 TLS 의 조합을 HTTPS 라고 부른다.
HTTP 보안에 사용하는 SSL 이라는 또 하나의 프로토콜이 있지만 TLS 가 더 최신 기술이며 보안성이 더 높다.
인증서와 개인 키
HTTP 원리는 다음과 같다.
- 웹 클라이언트와 웹 서버가 서로 신뢰할 수 있는지 확인한다.
- 신뢰는 핸드셰이크 (handshake), 인증서, 개인 키를 바탕으로 한다.
- 웹 클라이언트와 웹 서버는 암호화 키 사용을 동의한다.
- 합의한 키를 사용해 클라이언트와 서버는 통신 내용을 암호화한다.
클라이언트와 서버의 신뢰성 확인
서버는 디지털 인증서 (digital certificate) 로 응답한다.
디지털 인증서 (또는 공개 키 인증서) 는 공개 키의 소유권을 증명하는 전자문서이다.
디지털 인증서는 신뢰할 수 있는 제 3자가 서명 발행한 전자문서다.
신뢰할 수 있는 제 3 자는 인증기관 (CA, Certificate Authority) 이라고 부른다.
대부분의 CA 는 서비스를 유료로 제공하지만 Let's Encrypt (https://letsencrypt.org/) 처럼 무료로 제공하는 단체도 있다.
큰 단체나 공공기관은 직접 인증서를 발급한다.
이 과정을 자체 서명이라고 하며, 이런 인증서를 자체 서명 인증서 (self-signed certificate) 라고 부른다.
웹 클라이언트는 일반적으로 자신이 알고있는 CA 목록을 갖고있다. 웹 클라이언트가 웹 서버와 연결을 시도하면 웹 서버는 디지털 인증서로 응답한다. 클라이언트는 이 인증서에 명시된 발급기관이 자신의 목록에 있는지 확인하고, 알고있고 신뢰할 수 있는 발급기관이라면 인증서에 공개키를 사용해 통신한다.
이 공개키를 사용해 클라이언트와 서버가 대칭 암호화 통신에 사용하는 공유 암호화 키 (공유 비밀 키 또는 세션 키) 를 공유한다.
암호화 키 사용과 동의
2 단계와 3 단계에서 사용하는 암호화 키를 대칭 암호 (symmetric cipher) 라고 부른다.
클라이언트와 서버가 같은 키를 사용해 암호화와 복호화를 한다는 뜻이다.
이 방식을 대칭 암호화 (symmetrical cryptography) 라고 한다.
개발 단계에서 이 키는 볼 수 없는 키다.
다음 절에서는 Gin 프레임워크에서 HTTPS 를 지원하는 방법을 살펴본다.
Gin 프레임워크와 HTTPS
r := gin.Default()
r.Run(address)
HTTP 대신 HTTPS 를 지원하려면 한 부분만 수정하면 된다.
Run() 대신 RunTLS() 메서드를 사용한다.
이 메서드의 매개변수는 다음과 같다.
- 백엔드 웹 서비스의 HTTPS 주소
- 인증서 파일
- 비공개 키 파일
r.RunTLS(address, "cert.pem", "key.pem")
자체 서명 인증서를 발급해보자.
여러가지 방법이 있다. 일반적으로 OpenSSL 툴을 사용해 테스트용 자체 서명 인증서를 발급한다.
하지만 Go 언어의 기본 라이브러리가 제공하는 기능을 활용해보자.
프로젝트 폴더에서 다음 명령어를 실행한다.
- Windows
go run %GOROOT%/src/crypto/tls/generate_cert.go --host=127.0.0.1
- Mac / Linux
go run $GOROOT/src/crypto/tls/generate_cert.go --host=127.0.0.1
이 명령어는 tls 패키지 폴더에 있는 Go 언어가 제공하는 툴을 실행한다.
이 툴은 인증서를 발급할 때 사용한다.
%GOROOT% 는 Go 언어의 기본 환경변수다.
리눅스 환경이라면 $GOROOT 를 사용해야 한다.
127.0.0.1 호스트 (루프백 로컬 호스트) 의 인증서가 생성된다.
이 툴에는 아래와 같은 다양한 플래그와 옵션이 있다.
-ca
자체 서명 인증서 여부
-duration duration
인증서 유효기간 (기본값 8760h0m0s)
-ecdsa-curve string
ECDSA 키 생성 알고리즘. 유효한 값은 P224, P256 (권장), P384, P521
-host string
인증서를 생성할 호스트 이름과 IP (콤마로 구분)
-rsa-bits int
RSA 키 크기. --ecdsa-curve 옵션과 같이 사용할 수 없음 (기본값 2048)
-start-date string
생성일자 (Jan 1 15:04:05 2011 으로 포맷)
앞의 명령어를 실행하면 인증서와 비공개 키 파일이 실행한 폴더에 생성된다.
원하는 위치로 파일을 복사하고 코드에서 사용하면 된다.
테스트하는 동안 리액트 애플리케이션이 HTTPS 를 지원하게 하려면 애플리케이션을 시작할 때 다음 명령어를 실행한다.
set HTTPS=true&&npm start
다음 절에서는 패스워드 해싱을 설명한다.
패스워드 해싱
패스워드 해싱 (password hashing) 은 사용자 계정의 패스워드를 보호하는 매우 중요한 보안 기술이다.
패스워드 해싱은 두 가지 간단한 단계로 구성된다.
- 사용자의 패스워드를 해싱하고 이 해시를 저장한다.
- 해싱은 단방향 암호화 기법이다.
- 사용자가 입력한 패스워드를 검증한다.
- 로그인 요청에 포함된 패스워드를 해싱하고 이 값을 저장한 패스워드 해시 값과 비교한다.
패스워드 해싱은 데이터베이스가 해킹 당하더라도 악의적인 해커는 원래 값을 알 수 없기 때문에 패스워드를 안전하게 보호한다.
이제 패스워드 해싱을 구현해보자. 데이터베이스 레이어에 다음 로직을 추가한다.
패스워드 해싱 구현
dblayer.go 파일에 다음 코드를 추가한다.
var ErrINVALIDPASSWORD = errors.New("Invalid password")
func hashPassword(s *string) error {
if s == nil {
return errors.New("Reference provided for hashing password is nil")
}
// bcrypt 패키지에서 사용할 수 있게 패스워드 문자열을 바이트 슬라이스로 변환한다.
sBytes := []byte(*s)
// GenerateFromPassword() 메서드는 패스워드 해시를 반환한다.
hashedBytes, err := bcrypt.GenerateFromPassword(sBytes, bcrypt.DefaultCost)
if err != nil {
return err
}
// 패스워드 문자열을 해시 값으로 바꾼다.
*s = string(hashedBytes[:])
return nil
}
위 함수는 bcrypt 패키지를 사용한다. 이 패키지는 패스워드 해싱에 주로 사용된다.
bcrypt 는 1990 년대에 설계된 유명한 해싱 기법이다.
OpenBSD 운영체제의 기본 패스워드 해싱 기법이며, 많은 프로그래밍 언어에서 지원한다.
bcrypt 패키지는 패스워드 해시와 일반 문자열을 비교하는 메서드도 제공한다.
go get golang.org/x/crypto/bcrypt
앞에서 작성한 AddUser() 는 패스워드를 해싱하고 데이터베이스에 저장하는 hasgPassword() 메서드를 호출한다.
다음과 같이 AddUser() 메서드를 수정한다.
- 사용자 추가
func (db *DBORM) AddUser(customer models.Customer) (models.Customer, error) {
hashPassword(&customer.Pass)
customer.LoggedIn = true
err := db.Create(&customer).Error
customer.Pass = ""
return customer, err
}
이것으로 패스워드 해싱의 구현이 완료됐다.
customer 객체를 반환하기 전에 보안을 위해서 패스워드 문자열을 지우는 것을 잊지 말자.
패스워드 비교
func checkPassword(existingHash, incomingPass string) bool {
// 해시와 패스워드 문자열이 일치하지 않으면
// 아래 메서드는 에러를 반환한다.
return bcrypt.CompareHashAndPassword([]byte(existingHash),
[]byte(incomingPass)) == nil
}
로그인을 요청한 사용자의 저장한 패스워드 해시를 가져오는 코드를 SignInUser 메서드에 추가하자.
해시와 요청 값이 일치하지 않으면 에러를 반환한다.
일치한다면 사용자의 loggedin 필드를 true 로 설정하고 로그인시킨다.
- 로그인
func (db *DBORM) SignInUser(email, pass string) (customer models.Customer, err error) {
// 사용자 행을 나타내는 *gorm.DB 타입 할당
result := db.Table("Customers").Where(&models.Customer{Email: email})
// 입력된 이메일로 사용자 정보 조회
err = result.First(&customer).Error
if err != nil {
return customer, err
}
// 패스워드 문자열과 해시 값 비교
if !checkPassword(customer.Pass, pass) {
// 불일치 시 에러 반환
return customer, ErrINVALIDPASSWORD
}
// 공유되지 않도록 패스워드 문자열은 지운다.
customer.Pass = ""
// loggedin 필드 업데이트
err = result.Update("loggedin", 1).Error
if err != nil {
return customer, err
}
// 사용자 행 반환
return customer, result.Find(&customer).Error
}
마지막으로 로그인 실패를 처리하는 간단한 코드를 HTTP 웹 핸들러에 추가한다.
handlers.go 파일을 열고 다음과 같이 SignIn 메서드를 수정한다.
- 로그인
func (h *Handler) SignIn(c *gin.Context) {
if h.db == nil {
return
}
var customer models.Customer
err := c.ShouldBindJSON(&customer)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
customer, err = h.db.SignInUser(customer.Email, customer.Pass)
if err != nil {
// 잘못된 패스워드인 경우 forbidden http 에러 반환
if err = dblayer.ErrINVALIDPASSWORD {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, customer)
}
다음 절에서는 신용카드 결제를 처리하는 방법을 살펴본다.
신용카드 결제
5장에서 이미 신용카드 결제 요청 로직을 프론트엔드에 구현했다.
이제 백엔드 로직을 구현한다.
프론트엔드는 스트라이프 API (https://stripe.com/docs/api) 를 통해 신용카드 결제를 백엔드에 요청한다.
프로덕션 환경이라면 스트라이프 계정을 만들고 API 키를 발급받아야 하지만 테스트 환경에서는 신용카드 결제 서비스가 필요한 애플리케이션 개발 용도로 제공하는 테스트용 스트라이프 API 키와 신용카드 번호를 사용해도 된다.
이 신용카드 번호와 토큰의 자세한 내용은 다음 링크에서 확인할 수 있다.
https://stripe.com/docs/testing
프론트엔드는 다음과 같이 스트라이프 API 를 통해 토큰을 생성한다.
- CreditCards.js
async handleSubmit(event) {
event.preventDefault();
let id = ""
console.log("Handle submit called, with name: " + this.state.value);
let { token } = await this.props.stripe.createToken({ name: this.state.name });
if (token == null) {
console.log("invalid token");
this.setState({ status: FAILEDSTATE });
return;
}
id = token.id
let response = await fetch("/users/charge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: id,
customer_id: this.props.user,
product_id: this.props.productid,
sell_price: this.props.price,
rememberCard: this.state.remember !== undefined,
useExisting: this.state.useExisting,
})
})
// 응답이 ok 면 작업 성공
if (response.ok) {
console.log("Purchase Complete!");
this.setState({ status: SUCCESSSTATE });
} else {
this.setState({status: FAILEDSTATE})
}
// document.getElementsByClassName('#modal').modal('hide');
}
백엔드에서 신용카드 결제 요청 처리
- 프론트엔드가 보낸 토큰을 처리하는 *stripe.CustomerParams 타입 인스턴스를 생성한다.
- SetToken() 메서드를 사용해 토큰을 설정한다.
- stripe.CustomerParams 타입을 전달받는 *stripe.Customer 타입 인스턴스를 생성한다.
- customer.New() 함수를 사용한다.
- 수량, 통화 등의 결제정보를 저장하는 *stripe.ChargeParams 타입 인스턴스를 생성한다.
- *stripe.ChargeParams 타입은 스트라이프 사용자 ID 없이 생성할 수 없다.
- 이 값은 2단계의 *stripe.Customer 인스턴스에 저장돼 있다.
- 해당 신용카드 정보를 다음에 다시 사용하고 싶다면 스트라이프 사용자 ID 를 저장한다.
- 마지막 *stripe.ChargeParams 타입 매개변수를 받는 charge.New() 메서드를 호출하고 결제를 요청한다.
스트라이프 사용자 ID 는 데이터베이스에서 사용자를 참조하는 실제 사용자 ID 와 다르다.
대부분의 코드는 handler.go 파일의 charge 핸들러 메서드 안에 작성한다.
이 메서드의 현재 구현은 다음과 같다.
- charge 메서드
func (h *Handler) Charge(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "server database error"})
return
}
}
이 메서드에 구현해야 하는 기능은 다음과 같다.
- HTTP 요청에서 결제정보를 얻는다.
- 저장된 신용카드를 사용할 때 해당 스트라이프 사용자 ID 를 찾아 결제를 요청한다.
- 새로운 신용카드 정보는 결제요청 전에 데이터베이스에 저장한다.
우선 프론트엔드에서 전달받은 데이터를 나타내는 구조체를 다음과 같이 정의한다.
request := struct {
models.Order
Remember bool `json:"rememberCard"`
UseExisting bool `json:"useExisting"`
Token string `json:"token"`
}{}
위 코드는 Go 구조체를 정의하는 동시에 초기화한다.
Go 언어에서 빠르게 구조체를 사용할 수 있는 유용한 문법이다.
이제 스트라이프 코드를 작성해보자. 첫 단계로 스트라이프 API 키를 설정한다.
개발단계에서는 테스트 키를 사용하면 된다.
아래 링크에서 키를 확인해보자.
https://stripe.com/docs/keys#obtain-api-keys
다음은 결제요청처리 2단계의 *stripe.ChargeParams 타입 인스턴스를 생성한다.
- charge 핸들러
func (h *Handler) Charge(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "server database error"})
return
}
request := struct {
models.Order
Remember bool `json:"rememberCard"`
UseExisting bool `json:"useExisting"`
Token string `json:"token"`
}{}
err := c.ShouldBindJSON(&request)
// 파싱 중 에러 발생 시 보고 후 반환
if err != nil {
c.JSON(http.StatusBadRequest, request)
return
}
chargeP := &stripe.ChargeParams{
Amount: stripe.Int64(int64(request.Price)),
Currency: stripe.String("usd"),
Description: stripe.String("GoMusic charge..."),
}
stripeCustomerID := ""
if request.UseExisting {
// 저장된 카드 사용
fmt.Println("Getting credit card id...")
// 스트라이프 사용자 ID 를 데이터베이스에서 조회하는 메서드
stripeCustomerID, err = h.db.GetCreditCardCID(request.CustomerID)
if err != nil {
println(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
} else {
cp := &stripe.CustomerParams{}
cp.SetSource(request.Token)
customer, err := customer.New(cp)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
stripeCustomerID = customer.ID
}
if request.Remember {
// 스트라이프 사용자 id 를 저장하고 데이터베이스에 저장된 사용자 ID 와 연결한다.
err = h.db.SaveCreditCardForCustomer(request.CustomerID, stripeCustomerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
/* 동일 상품 주문 여보 확인 없이 새로운 주문으로 가정 */
// *stripe.ChargeParams 타입 인스턴스에 스트라이프 사용자 ID 를 설정한다.
chargeP.Customer = stripe.String(stripeCustomerID)
// 신용카드 결제요청
_, err = charge.New(chargeP)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
err = h.db.AddOrder(request.Order)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
방금 추가한 charge() 핸들러는 미완성된 데이터베이스 메서드를 호출한다.
- GetCreditCardCID(): 스트라이프 사용자 ID 조회
- SaveCreditCardForCustomer(): 스트라이프 사용자 ID 저장
- AddOrder(): 주문 저장
위 메서드를 dblayer.go 파일의 데이터베이스 레이어 인터페이스에 다음과 같이 추가한다.
프론트엔드 구조
- index.js: 리액트 애플리케이션의 진입점으로, App 컴포넌트를 호출한다.
- App.js: 리액트 애플리케이션의 메인 컴포넌트로, 다른 모든 컴포넌트를 결합한다.
- 이 파일의 App 컴포넌트는 사용자 로그인과 로그아웃 등의 중요한 작업을 처리한다.
- Modalwindows.js: 로그인과 신규 사용자 가입, 상품 구매 모달 윈도와 같은 애플리케이션의 모든 모달 윈도를 처리한다.
- Navigation.js: 페이지 간의 이동에 필요한 탐색 메뉴를 담당한다.
- creditcards.js: 프론트엔드의 신용카드 결제 로직을 처리한다.
- productcards.js: 상품카드목록을 출력한다. 일반 상품과 프로모션을 모두 출력한다.
- orders.js: 로그인한 사용자의 주문내역을 출력한다.
- About.js: About 페이지를 출력한다.
다음 절에서 프론트엔드와 백엔드를 연결해보자.
프론트엔드와 백엔드 연결
백엔드에 요청을 보내려면 우선 package.json 파일에 proxy 필드를 추가해야 한다.
proxy 필드의 값을 백엔드 API 서버의 주소로 설정한다. GoMusic 의 백엔드 서버는 8000번 로컬 포트를 사용한다.
"proxy": "http://127.0.0.1:8000/",
프론트엔드는 fetch() 메서드를 호추하고 백엔드로 요청을 보낸다.
이 메서드는 특정 URL 로 HTTP 요청을 보낸다.
사용자의 결제내역은 다음과 같이 요청한다.
fetch(this.props.location)
.then(res => res.json())
.then((result) => {
this.setState({
orders: result
})
})
다음 절에서는 애플리케이션에서 쿠키를 사용하는 방법을 알아본다.
쿠키 사용
사용자 정보와 로그인 상태 등의 정보를 파일에서 읽지 않고 브라우저 쿠키에서 읽도록 프론트엔드 코드를 수정하자.
프론트엔드는 js-cookie 자바스크립트 패키지를 사용해 쿠키에 사용자 정보를 저장하고 페이지와 리액트 컴포넌트에서 이 정보를 읽는다.
npm install js-cookie --save
다음과 같이 쿠키에 값을 저장한다.
cookie.set("user", userdata) // 쿠키에 값을 저장한다.
const user = cookie.getJSON("user") // 쿠키에 저장된 값을 읽는다.
사용자가 로그아웃하면 다음과 같이 쿠키에 해당 정보를 반영해 로그인한 사용자가 없음을 나타낸다.
cookie.set("user", {loggedin:false})
리액트 애플리케이션에서 쿠키 사용과 사용자 로그아웃 처리는 다음 링크의 App 컴포넌트 구현을 참고하라.
- App.js
import React from 'react';
import CardContainer from './ProductCards';
import { BrowserRouter as Router, Route } from "react-router-dom";
import Nav from './Navigation';
import { SignInModalWindow, BuyModalWindow } from './modalwindows';
import About from './About';
import Orders from './orders';
import cookie from 'js-cookie';
class App extends React.Component {
constructor(props) {
super(props);
const user = cookie.getJSON("user") || {loggedin:false};
this.state = {
user: user,
showSignInModal: false,
showBuyModal: false
};
this.handleSignedIn = this.handleSignedIn.bind(this);
this.handleSignedOut = this.handleSignedOut.bind(this);
this.showSignInModalWindow = this.showSignInModalWindow.bind(this);
this.toggleSignInModalWindow = this.toggleSignInModalWindow.bind(this);
this.showBuyModalWindow = this.showBuyModalWindow.bind(this);
this.toggleBuyModalWindow = this.toggleBuyModalWindow.bind(this);
}
handleSignedIn(user) {
console.log("Sign in happening...");
const state = this.state;
const newState = Object.assign({},state,{user:user,showSignInModal:false});
this.setState(newState);
}
handleSignedOut(){
console.log("Call app signed out...");
const state = this.state;
const newState = Object.assign({},state,{user:{loggedin:false}});
this.setState(newState);
cookie.set("user",{loggedin:false});
}
showSignInModalWindow(){
const state = this.state;
const newState = Object.assign({},state,{showSignInModal:true});
this.setState(newState);
}
toggleSignInModalWindow() {
const state = this.state;
const newState = Object.assign({},state,{showSignInModal:!state.showSignInModal});
this.setState(newState);
}
showBuyModalWindow(id,price){
const state = this.state;
const newState = Object.assign({},state,{showBuyModal:true,productid:id,price:price});
this.setState(newState);
}
toggleBuyModalWindow(){
const state = this.state;
const newState = Object.assign({},state,{showBuyModal:!state.showBuyModal});
this.setState(newState);
}
//location='user.json'
render() {
return (
<div>
<Router>
<div>
<Nav user={this.state.user} handleSignedOut={this.handleSignedOut} showModalWindow={this.showSignInModalWindow}/>
<div className='container pt-4 mt-4'>
<Route exact path="/" render={() => <CardContainer location='/products' showBuyModal={this.showBuyModalWindow}/>} />
<Route path="/promos" render={() => <CardContainer location='/promos' promo={true} showBuyModal={this.showBuyModalWindow}/>} />
{this.state.user.loggedin ? <Route path="/myorders" render={()=><Orders location={'/user/'+this.state.user.ID+'/orders'}/>}/> : null}
<Route path="/about" component={About} />
</div>
<SignInModalWindow handleSignedIn={this.handleSignedIn} showModal={this.state.showSignInModal} toggle={this.toggleSignInModalWindow} />
<BuyModalWindow showModal={this.state.showBuyModal} toggle={this.toggleBuyModalWindow} user={this.state.user.ID} productid={this.state.productid} price={this.state.price}/>
</div>
</Router>
</div>
);
}
}
export default App;
다음 절에서는 프론트엔드 애플리케이션을 프로덕션 환경에 배포하는 방법을 소개한다.
프론트엔드 애플리케이션 배포
메인 폴더에서 npm run build 명령어를 실행하면 이 스크립트가 실행된다.
이 명령어는 프론트엔드 코드를 몇 개의 정적 파일로 컴파일한다. Go 애플리케이션은 이 파일을 클라이언트에게 전달한다.
컴파일된 파일은 리액트 앱의 루트폴더 아래 build 폴더에 생성된다.
이 폴더의 리액트 앱 build 폴더라고 부른다. Go 애플리케이션에서 프론트엔드 파일에 접근할 수 있도록 build 폴더를 알맞은 경로에 복사한다.
Go 애플리케이션에서 리액트 앱 build 폴더에 접근하는 코드를 작성하자.
우선 다음 명령어를 실행하면 static Gin 미들웨어가 설치된다.
- static Gin 미들웨어 설치
go get github.com/gin-contrib/static
HTTP 라우팅을 정의한 backend/src/rest.go 파일을 열고 static 미들웨어를 임포트한다.
- rest.go/RunAPIWithHandler 의 일부
func RunAPIWithHandler(address string, h HandlerInterface) error {
r := gin.Default()
r.Use(MyCustomerLogger())
r.Use(static.ServeRoot("/", "../public/build"))
첫 번째 매개변수는 웹 애플리케이션의 HTTP 루트 URL 이고, 두 번째 매개변수는 리액트 build 폴더의 경로다.
모든 상품사진을 저장한 img 폴더는 build 폴더 안으로 옮긴다.
웹 애플리케이션에서 필요한 모든 파일은 build 폴더로 이동한다.
go build 또는 go install 명령어를 실행하면 Go 앱의 실행파일이 생성된다.
마지막으로 이 실행파일과 리액트 build 폴더를 배포한다.
8장에서는 애플리케이션의 유닛 테스트를 작성하고 메서드를 테스트한다.
나아가 벤치마크를 통해 애플리케이션의 성능을 측정한다.
8장. 웹 API 테스트와 벤치마킹
8장에서 다루는 내용은 다음과 같다.
- Go 목킹 (mocking) 타입
- Go 유닛 테스트
- Go 벤치마킹
Go 테스트
테스트 패키지의 설명은 https://pkg.go.dev/testing 에서 확인할 수 있다.
목킹
목킹은 소프트웨어 유닛 테스트에서 많이 사용된다.
GoMusic 에서 판매하는 모든 상품목록을 가져오는 GetProducts() 메서드를 테스트한다.
- 상품목록 조회
func (h *Handler) GetProducts(c *gin.Context) {
if h.db == nil {
return
}
products, err := h.db.GetAllProducts()
if err != nil {
/*
첫 번째 매개변수는 HTTP 상태 코드, 두 번째는 응답의 바디
*/
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, products)
}
이 메서드는 데이터베이스에서 조회한 상품정보를 HTTP 응답에 첨부한다.
GetProducts() 메서드의 유닛테스트는 데이터베이스에 연결하지 않고 메서드의 기능을 테스트할 수 있어야 한다.
나아가 몇 가지 시나리오도 필요하다.
GetProducts() 메서드의 유닛 테스트는 메서드의 기능만을 테스트한다.
데이터베이스와의 연결은 중요하지 않다.
모의 (mock) 객체는 특정 동작을 흉내내거나 가짜로 실행하는 객체다.
h.db.GetAllProducts() 를 예로 들면 데이터베이스 질의 결과 대신 모의 데이터를 반환하는 모의 객체를 사용해 GetProducts() 를 유닛테스트한다.
데이터베이스 레이어의 모의객체는 DBLayer 인터페이스를 구현하지만 실제로 데이터베이스에 연결은 하지 않는다.
모의 db 타입 구현
- backend/src/dblayer/mockdblayer.go
type MockDBLayer struct {
err error
products []models.Product
customers []models.Customer
orders []models.Order
}
MockDBLayer 구조체에는 다음과 같은 4개의 필드가 있다.
- err: 에러를 발생시키는 시나리오일 때 설정한다.
- products: 상품목록을 저장하는 필드다.
- customers: 사용자 목록을 저장하는 필드다.
- orders: 테스트용 구입내역을 저장하는 필드다.
- 생성자
func NewMockDBLayer(products []models.Product, customers []models.Customer, orders []models.Order) *MockDBLayer {
return &MockDBLayer{
products: products,
customers: customers,
orders: orders,
}
}
모의 데이터가 정의된 MockDBLayer 를 다음과 같이 초기화한다.
MockDBLayer 의 메서드가 반환하는 에러를 처리하는 메서드가 필요하다.
에러 발생 처리 코드의 테스트는 매우 중요하다.
모의객체의 에러를 설정하는 메서드를 아래와 같이 정의한다.
func (mock *MockDBLayer) SetError(err error) {
mock.err = err
}
func (mock *MockDBLayer) GetAllProducts() ([]models.Product, error) {
// 에러 반환
if mock.err != nil {
return nil, mock.err
}
// 상품목록 반환
return mock.products, nil
}
가장 먼저 에러가 설정된 테스트인지 확인한다.
에러가 설정됐다면 해당 에러를 반환한다. 아니라면 모의 객체의 상품목록을 반환한다.
많은 데이터를 반환하는 메서드는 슬라이스를 사용하고 특정 데이터를 반환하는 메서드는 맵을 사용하는 것이 좋다.
모의 객체를 제공하는 여러 Go 언어 기반의 오픈소스 프로젝트가 있다.
찾아보도록 하자.
Go 유닛 테스트
테스트하려는 패키지가 있는 폴더에 파일명이 _test.go 로 끝나는 새로운 파일을 생성한다.
예를 들어 rest 패키지의 GetProducts() 메서드를 테스트하려면 해당 폴더에 handler_test.go 파일을 생성한다.
이 파일은 일반 빌드에는 포함되지 않고 유닛 테스트를 실행할 때만 빌드된다.
go test 명령어를 실행하면 _test.go 로 끝나는 파일만 빌드하고 실행한다.
테스트 대상 패키지의 폴더가 아닌 경로에서 go test 명령어를 실행하는 경우 패키지 경로를 직접 설정해야 한다.
특정 함수를 추가하려면 다음 규칙을 따라야 한다.
- 함수명은 Test 로 시작
- Test 다음에 오는 첫 번째 문자는 대문자
- *testing.T 타입을 매개변수로 전달
- handler_test.go
func TestHandler_GetProducts(t *testing.T) {
// 로그가 너무 많이 쌓이지 않게 테스트 모드로 전환
gin.SetMode(gin.TestMode)
mockdbLayer := dblayer.NewMockDBLayerWithData()
h := NewHandlerWithDB(mockdbLayer)
const productsURL string = "/products"
}
테이블 주도 개발
유닛테스트는 함수와 메서드가 특정 인스와 에러 조건을 예상대로 처리하는지 테스트한다.
보통 다양한 인수를 전달하고자 if 문을 여러 차례 반복하는 비효율적이고 거대한 코드를 작성한다.
이 문제를 해결할 수 있는 테이블 주도 개발 (table-drive development) 이라는 보편적인 디자인 패턴을 소개한다.
서브테스트를 나타내는 Go 구조체 배열을 선언한다.
- 서브테스트 구조체
tests := []struct {
name string
inErr error
outStatusCode int
expectedRespBody interface{}
}{
}
구조체의 각 필드가 나타내는 값은 다음과 같다.
- name: 서브테스트 이름
- inErr: 에러 입력
- outStatusCode: HTTP 핸들러의 기대 HTTP 상태코드
- GetProducts() 핸들러가 실제로 반환하는 상태코드가 이 값과 다르면 유닛테스트는 실패한다.
- expectedRespBody: 기대 HTTP 응답 내용
- 이 필드는 상품목록 슬라이스나 에러일 수 있기 때문에 interface{} 타입으로 선언한다.
테이블 주도 개발의 장점은 높은 확장성이다.
test 테이블의 서브테스트를 실행하기 전에 에러 메시지를 나타내는 구조체를 정의한다.
- 에러메시지 구조체
type errMSG struct {
Error string `json:"error"`
}
간단한 서브테스트를 다음과 같이 정의한다.
tests := []struct {
name string
inErr error
outStatusCode int
expectedRespBody interface{}
}{
{
"getproductsnoerrors",
nil,
http.StatusOK,
mockdbLayer.GetMockProductData(),
},
{
"getproductswitherror",
errors.New("get products error"),
http.StatusInternalServerError,
errMSG{Error: "get products error"},
},
}
Errorf 메서드는 에러메시지를 출력하고 해당 테스트 케이스는 실패한다.
메시지만 출력하려면 Logf 메서드를 사용하면 된다.
Fail 메서드는 테스트케이스를 즉시 실패로 처리한다.
t.Errorf 메서드는 t.Logf 와 t.Fail 메서드의 조합이다.
Go 의 reflect 패키지를 사용하면 HTTP 응답내용을 쉽게 비교할 수 있다.
reflect.DeepEqual() 함수를 사용하면 두 개의 값을 완전히 비교하고 서로 같은 값인지 확인할 수 있다.
- 유닛 테스트
func TestHandler_GetProducts(t *testing.T) {
// 로그가 너무 많이 쌓이지 않게 테스트 모드로 전환
gin.SetMode(gin.TestMode)
mockdbLayer := dblayer.NewMockDBLayerWithData()
h := NewHandlerWithDB(mockdbLayer)
const productsURL string = "/products"
type errMSG struct {
Error string `json:"error"`
}
tests := []struct {
name string
inErr error
outStatusCode int
expectedRespBody interface{}
}{
{
"getproductsnoerrors",
nil,
http.StatusOK,
mockdbLayer.GetMockProductData(),
},
{
"getproductswitherror",
errors.New("get products error"),
http.StatusInternalServerError,
errMSG{Error: "get products error"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 서브테스트 실행
mockdbLayer.SetError(tt.inErr)
// 테스트 요청 생성
req := httptest.NewRequest(http.MethodGet, productsURL, nil)
// http response recorder 생성
w := httptest.NewRecorder()
_, engine := gin.CreateTestContext(w)
// get 요청 설정
engine.GET(productsURL, h.GetProducts)
engine.ServeHTTP(w, req)
response := w.Result()
if response.StatusCode != tt.outStatusCode {
t.Errorf("Received Status code %d does not match expected status code %d", response.StatusCode, tt.outStatusCode)
}
var respBody interface{}
// 에러가 발생한 경우 응답을 errMsg 타입으로 변환
if tt.inErr != nil {
var errmsg errMSG
json.NewDecoder(response.Body).Decode(&errmsg)
// 에러 메시지를 respBody 에 저장
respBody = errmsg
} else {
// 에러가 없을 경우 응답을 product 타입의 슬라이스로 변환
products := []models.Product{}
json.NewDecoder(response.Body).Decode(&products)
// 디코딩한 상품목록을 respBody 에 저장
respBody = products
}
if !reflect.DeepEqual(respBody, tt.expectedRespBody) {
t.Errorf("Received HTTP response body %+v does not match expected HTTP response Body %+v", respBody, tt.expectedRespBody)
}
})
}
}
Go 언어는 서브테스트를 병렬로 실행하는 기능을 제공한다. 다음과 같이 서브테스트에서 t.Parallel() 을 호출하면 된다.
서브테스트를 병렬로 실행할 때 서로 공유하는 데이터를 동시에 변경하지 않도록 조심해야 한다. 예를 들어 위 코드를 보면 서브테스트 코드 밖에서 MockDBLayer 객체를 초기화하고 공유한다.
특정 서브테스트에서 이 객체의 에러상태를 변경하면 수행 중인 다른 서브테스트가 영향을 받을 수 있다.
벤치마킹
Go 언어에서 벤치마킹 함수를 작성할려면 다음 규칙을 따라야 한다.
- 함수명은 Benchmark 로 시작한다.
- Benchmark 바로 뒤의 글자는 대문자여야 한다.
- *testing.B 타입을 매개변수로 전달받는다.
- *testing.B 타입은 코드를 쉽게 벤치마킹할 수 있는 기능을 제공한다.
- 패스워드 해싱 벤치마크 - orm_text.go
func BenchmarkHashPassword(b *testing.B) {
text := "A String to be Hashed"
for i := 0; i < b.N; i++ {
hashPassword(&text)
}
}
go test -bench .
-bench 옵션을 사용하려면 대상 함수의 이름을 정규표현식으로 표현해야 한다.
모든 함수를 다 실행하려면 . 를 사용한다.
위는 문자열을 벤치마킹 바로 직전에 초기화한다.
매우 쉽고 간단한 방법이다.
하지만 초기화가 복잡하고 많은 시간이 소요된다면 다음과 같이 초기화한 후 벤치마킹 전에 b.ResetTimer() 를 호출하는 것이 좋다.
func BenchMarkSomeFunction(b *testing.B) {
someHeavyInitialization()
b.ResetTimer()
for i := 0; i < b.N; i++ {
SomeFunction()
}
}
*testing.B 타입의 RunParallel() 메서드를 사용하면 여러 벤치마킹 함수를 병렬로 실행한다.
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
// 나머지 코드
}
})
다음 명령어를 사용한다.
go test -cpu
9장. 클라우드 네이티브 애플리케이션과 리액트 네이티브 프레임워크
9장에서 다루는 내용은 다음과 같다.
- 클라우드 네이티브 애플리케이션
- 리액트 네이티브 프레임워크
클라우드 네이티브 애플리케이션
클라우드 네이티브 애플리케이션이이란 확장 가능한 분산 인프라에서 실행되는 애플리케이션이다.
항상 이용할 수 있고 신뢰할 수 있으며, 업데이트를 실시간으로 반영할 수 있어야 한다.
또한 부하로 인해 오작동하지 않아야 한다.
일반적으로 이중화와 로드 밸런싱 등의 다양한 기술에 의존한다.
클라우드 네이티브 애플리케이션의 중요한 개념인 마이크로서비스, 컨테이너, 서버리스 애플리케이션, 지속적인 배포 를 알아보자.
마이크로서비스
마이크로서비스 개념은 모든 작업을 같은 곳에 정의하는 모놀리식 애플리케이션 방식과는 정반대 개념이다.
서버리스 애플리케이션
아마존사의 AWS Lambda (https://aws.amazon.com/ko/lambda/) 서비스는 전 세계의 수 많은 애플리케이션이 사용하는 서비스다.
사용자는 이 서비스를 통해 특정 작업을 수행하는 함수를 원격으로 호출할 수 있다.
AWS Lambda 같은 서비스를 FaaS (Funxtion as a Service) 라고도 부른다.
지속적인 배포
지속적인 배포란 빠르고 짧은 주기로 소프트웨어를 배포하는 것을 의미한다.
지속적인 배포는 단순히 소프트웨어 툴 사용에 대한 문제가 아닌 조직 전체가 따라야 하는 사고방식을 포함한다.
수정사항을 신속하게 빌드, 테스트, 배포할 수 있는 프로세스가 필요하다.
이 과정의 최대한 많은 부분을 자동화하면 장점을 극대화할 수 있다.
모바일 앱 개발 분야에서 많은 인기를 끌고있는 리액트 네이티브 프레임워크 (React Native Framework) 를 살펴보자.
리액트 네이티브 프레임워크
리액트와 리액트 네이티브 사이의 유사성
- 자바스크립트 ES6 를 사용한다.
- render() 메서드가 있는 리액트 컴포넌트를 사용한다.
- 리액트 엘리먼트를 사용한다.
- JSX 를 사용해 비주얼 엘리먼트를 생성한다.
리액트와 리액트 네이티브 사이의 차이점
- 리액트 네이티브는 고유한 특수 JSX 구문을 사용해 UI 컴포넌트를 생성한다.
- 기본적으로 CSS 와 HTML 은 사용하지 않는다.
- 리액트 네이티브는 특수한 라이브러리를 통해 모바일 기기와 상호작용 한다.
- 예를 들어 기기의 카메라와 가속도 센서를 제어하려면 리액트 네이티브용 패키지가 필요하다.
- 리액트 네이티브 앱은 배포하는 방식이 다르다.
엑스포
엑스포 Expo (https://expo.dev/) 는 리액트 네이티브 모바일 애플리케이션을 쉽게 개발할 수 있는 인기 있는 오픈소스 툴체인이다.
카메라 액세스, 파일 시스템이나 푸시알림과 같은 중요한 기능을 포함하는 SDK 를 제공한다.
'Backend > 노트' 카테고리의 다른 글
쉽고 빠른 Go 시작하기 (0) | 2023.02.27 |
---|---|
Gin (0) | 2022.10.15 |
한 번에 끝내는 Node.js 웹 프로그래밍 초격차 패키지 Online - 2 (1) | 2022.09.20 |
Tucker 의 Go 언어 프로그래밍 (0) | 2022.08.14 |
한 번에 끝내는 Node.js 웹 프로그래밍 초격차 패키지 Online (0) | 2022.07.06 |