만다라트 작성하기
지난 글에서 말한대로 이번 만다라트 프로젝트는 한 달 안에 완성하는 것이 목표이므로 목표 달성을 도울 수 있는 최소 기능만을 뽑아 와이어 프레임을 짜보았다.
새 만다라트 만들기
우선, 사용자가 로그인을 하면 나의 만다라트 페이지에서 자신이 작성한 만다라트 목록을
볼 수 있고, 새로운 만다라트 또한 만들 수 있다.
제목 및 기간 선택
새 만다라트 만들기 버튼을 누르면 필수적으로 제목과 기간을 적어야 한다.
핵심 목표..!!
새 만다라트 생성 시에는 가장 중요한 핵심 목표가 자동으로 포커스되어 나타난다.
목표 설정
핵심 목표를 한 줄로 간단하게 적은 뒤에는 목표 달성 기간 등을 설정해준다.
목표를 달성해가면서 수시로 코멘트를 달고, 달성 여부를 체크할 수도 있다.
보상 설정
무작정 목표만 세워놓으면 달성하기가 싫어질 수도 있으니 목표 달성 시에 자신에게 줄
보상을 설정해놓자
춘천 당일치기 여행..!
목표 달성 비율별로 보상을 설정할 수 있다.
최대 8개의 보상 설정 가능
하나의 목표에 대해 최대 8 개의 보상을 설정해놓을 수 있다.
천국과 지옥 만다라트
설정해놓은 목표 달성 기간이 끝나면 달성률에 따라 자동으로 만다라트가 천국 혹은 지옥으로 분류되어 결과를 보여준다.
아직 와이어 프레임 외에 GUI 디자인이 나오지 않은 상태라 일단 백엔드부터 빠르게 개발에 돌입했다.
올해부터 공부를 시작한 GraphQL을 활용하고자 graphql-yoga를 기반으로
서버를 개발하였다.
우선, package.json은 다음과 같다.
{
"name": "mandalart-server",
"version": "1.0.0",
"description":
"Server for the Mandalart based on GraphQL, Typescript, Nodejs",
"main": "index.js",
"repository": "https://github.com/Nexters/mandalart-server.git",
"author": "heeinso <heeinso@snu.ac.kr>",
"license": "MIT",
"devDependencies": {
"@types/cors": "^2.8.4",
"@types/helmet": "^0.0.38",
"@types/morgan": "^1.7.35",
"@types/node": "^10.5.3",
"babel-runtime": "^6.26.0",
"dotenv": "^6.0.0",
"gql-merge": "^0.0.6",
"graphql-to-typescript": "^0.1.2",
"nodemon": "^1.18.3",
"ts-node": "^7.0.0",
"tslint-config-prettier": "^1.14.0",
"typescript": "^2.9.2"
},
"scripts": {
"predev": "yarn run types",
"dev": "cd src && nodemon --exec ts-node index.ts",
"pretypes":
"gql-merge --out-file ./src/schema.graphql ./src/api/**/*.graphql",
"types": "graphql-to-typescript ./src/schema.graphql ./src/types/graph.d.ts"
},
"dependencies": {
"class-validator": "^0.9.1",
"cors": "^2.8.4",
"graphql-tools": "^3.0.5",
"graphql-yoga": "^1.14.12",
"helmet": "^3.13.0",
"merge-graphql-schemas": "^1.5.3",
"morgan": "^1.9.0",
"pg": "^7.4.3",
"typeorm": "^0.2.7"
}
}
Typescript을 활용하기 위한 tscofig.json은 다음과 같다.
{
"compilerOptions": {
"baseUrl": ".",
"module": "commonjs",
"target": "es5",
"lib": ["es6", "dom", "esnext.asynciterable"],
"sourceMap": true,
"allowJs": true,
"moduleResolution": "node",
"rootDir": "src",
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": false,
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": true,
"noUnusedLocals": true,
"esModuleInterop": true,
"skipLibCheck": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"exclude": [
"node_modules",
"build",
"scripts",
"acceptance-tests",
"webpack",
"jest",
"src/setupTests.ts"
]
}
데이터베이스는 PostgreSQL을 사용하고 이를 GraphQL 서버와 쉽게 연동하기 위해 TypeORM을 활용한다.
ormConfig.ts은 다음과 같다.
import { ConnectionOptions } from 'typeorm'
const connectionOptions: ConnectionOptions = {
type: 'postgres',
database: 'mandalart',
synchronize: true,
logging: true,
entities: ['entities/**/*.*'],
host: process.env.DB_ENDPOINT,
port: 5432,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
}
export default connectionOptions
그 다음에는 우리가 작성할 모든 Type과 Query, Mutation 등을 합쳐서
schema로 서버의 entry point에 넘길 수 있도록 schema.ts를 설정해보자
import { GraphQLSchema } from 'graphql'
import { makeExecutableSchema } from 'graphql-tools'
import { fileLoader, mergeResolvers, mergeTypes } from 'merge-graphql-schemas'
import path from 'path'
const allTypes: GraphQLSchema[] = fileLoader(
path.join(__dirname, './api/**/*.graphql')
)
const allResolvers: string[] = fileLoader(
path.join(__dirname, './api/**/*.resolvers.*')
)
const mergedTypes = mergeTypes(allTypes)
const mergedResolvers = mergeResolvers(allResolvers)
const schema = makeExecutableSchema({
typeDefs: mergedTypes,
resolvers: mergedResolvers,
})
export default schema
모든 설정을 끝냈으니 이제 서버를 열어보자. app.ts는 다음과 같다.
import cors from 'cors'
import { GraphQLServer } from 'graphql-yoga'
import helmet from 'helmet'
import logger from 'morgan'
import schema from './schema'
class App {
public app: GraphQLServer
constructor() {
this.app = new GraphQLServer({
schema,
})
this.middlewares()
}
private middlewares = (): void => {
this.app.express.use(cors())
this.app.express.use(logger('dev'))
this.app.express.use(helmet())
}
}
export default new App().app
index.ts는 다음과 같다.
import dotenv from 'dotenv'
dotenv.config()
import app from './app'
import { createConnection } from 'typeorm'
import { Options } from 'graphql-yoga'
import connectionOptions from './ormConfig'
const PORT: number | string = process.env.PORT || 4000
const PLAYGROUND_ENDPOINT: string = '/playground'
const GRAPHQL_ENDPOINT: string = '/graphql'
const appOptions: Options = {
port: PORT,
playground: PLAYGROUND_ENDPOINT,
endpoint: GRAPHQL_ENDPOINT,
}
const handleAppStart = () => console.log(`Listening on port ${PORT}`)
createConnection(connectionOptions)
.then(() => {
app.start(appOptions, handleAppStart)
})
.catch(error => console.log(error))
만다라트 작성에 필요한 최소한의 구성 요소를 생각해보면 다음과 같다.
각각의 구성 요소에 대한 Type을 작성해보자.
User Type
Mandalart Type
Todo Type
SubTodo Type
각 Type에 대한 Entity는 다음과 같다.
// User.ts
import bcrypt from 'bcrypt'
import { IsEmail } from 'class-validator'
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
BeforeInsert,
BeforeUpdate,
OneToMany,
} from 'typeorm'
import Mandalart from './Mandalart'
import Todo from './Todo'
const BCRYPT_ROUNDS = 10
@Entity()
class User extends BaseEntity {
@PrimaryGeneratedColumn() id: number
@Column({ type: 'text', nullable: true })
@IsEmail()
email: string | null
@Column({ type: 'text' })
firstName: string
@Column({ type: 'text' })
lastName: string
@Column({ type: 'int', nullable: true })
age: number | null
@Column({ type: 'text', nullable: true })
password: string
@Column({ type: 'text', nullable: true })
profileImage: string | null
@CreateDateColumn() createdAt
@UpdateDateColumn() updatedAt
@Column({ type: 'text', nullable: true })
fbId: string
@OneToMany(type => Mandalart, mandalart => mandalart.user)
mandalarts: Mandalart[]
@OneToMany(type => Todo, todo => todo.user)
todos: Todo[]
get fullName(): string {
return `${this.firstName} ${this.lastName}`
}
public comparePassword(password: string): Promise<boolean> {
return bcrypt.compare(password, this.password)
}
@BeforeInsert()
@BeforeUpdate()
async savePassword(): Promise<void> {
if (this.password) {
const hashedPassword = await this.hashPassword(this.password)
this.password = hashedPassword
}
}
private hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, BCRYPT_ROUNDS)
}
}
export default User
// Mandalart.ts
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
} from 'typeorm'
import User from './User'
import Todo from './Todo'
@Entity()
class Mandalart extends BaseEntity {
@PrimaryGeneratedColumn() id: number
@Column({ type: 'text' })
name: string
@ManyToOne(type => User, user => user.mandalarts)
user: User
@OneToMany(type => Todo, todo => todo.mandalart)
todos: Todo[]
@Column({ type: 'date' })
startDate: Date
@Column({ type: 'date' })
endDate: Date
@CreateDateColumn() createdAt: string
@UpdateDateColumn() updatedAt: string
}
export default Mandalart
// Todo.ts
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
ManyToOne,
} from 'typeorm'
import Mandalart from './Mandalart'
import User from './User'
import SubTodo from './SubTodo'
@Entity()
class Todo extends BaseEntity {
@PrimaryGeneratedColumn() id: number
@Column({ type: 'text' })
title: string
@Column({ type: 'text', nullable: true })
description: string
@Column({ type: 'boolean', default: false })
isAchieved: boolean
@ManyToOne(type => User, user => user.todos)
user: User
@ManyToOne(type => Mandalart, mandalart => mandalart.todos)
mandalart: Mandalart
@OneToMany(type => SubTodo, subTodo => subTodo.todo)
subTodos: SubTodo[]
@CreateDateColumn() createdAt: string
@UpdateDateColumn() updatedAt: string
}
export default Todo
// SubTodo.ts
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
ManyToOne,
} from 'typeorm'
import Mandalart from './Mandalart'
import User from './User'
import Todo from './Todo'
@Entity()
class SubTodo extends BaseEntity {
@PrimaryGeneratedColumn() id: number
@Column({ type: 'text' })
title: string
@Column({ type: 'text', nullable: true })
description: string
@Column({ type: 'boolean', default: false })
isAchieved: boolean
@ManyToOne(type => User, user => user.todos)
user: User
@ManyToOne(type => Mandalart, mandalart => mandalart.todos)
mandalart: Mandalart
@ManyToOne(type => Todo, todo => todo.subTodos)
todo: Todo
@CreateDateColumn() createdAt: string
@UpdateDateColumn() updatedAt: string
}
export default SubTodo
이렇게 Type과 Entity들을 작성해놓고 터미널에서 yarn dev를 실행해보면 TypeORM이 다음과 같이 SQL을 짜준다!
TypeORM이 짜준 SQL문
그럼 본 프로젝트에서 가장 기본이 되는 만다라트를 생성하고 조회하는 기능을 구현해보자.
우선, api/Mandalart 디렉토리에 AddMandalart.graphql과 AddMandalart.resolvers.ts를 만들어주자
AddMandalart.graphql
// AddMandalart.resolvers.ts
import Mandalart from '../../../entities/Mandalart'
import User from '../../../entities/User'
import {
AddMandalartMutationArgs,
AddMandalartResponse,
} from '../../../types/graph'
import { Resolvers } from '../../../types/resolvers'
import privateResolver from '../../../utils/privateResolver'
const resolvers: Resolvers = {
Mutation: {
AddMandalart: privateResolver(
async (
_,
args: AddMandalartMutationArgs,
{ req }
): Promise<AddMandalartResponse> => {
const user: User = req.user
try {
await Mandalart.create({ ...args, user }).save()
return {
ok: true,
error: null,
}
} catch (error) {
return {
ok: false,
error: error.message,
}
}
}
),
},
}
export default resolvers
그런 다음 생성된 Mandalart를 조회하는 GetMyMandalarts Query를 만들어주자
GetMyMandalart.graphql
// GetMyMandalart.resolvers.ts
import User from '../../../entities/User'
import { GetMyMandalartsResponse } from '../../../types/graph'
import { Resolvers } from '../../../types/resolvers'
import privateResolver from '../../../utils/privateResolver'
const resolvers: Resolvers = {
Query: {
GetMyMandalarts: privateResolver(
async (_, __, { req }): Promise<GetMyMandalartsResponse> => {
try {
const user = await User.findOne(
{ id: req.user.id },
{ relations: ['mandalarts'] }
)
if (user) {
return {
ok: true,
error: null,
mandalarts: user.mandalarts,
}
} else {
return {
ok: false,
error: 'User not found',
mandalarts: null,
}
}
} catch (error) {
return {
ok: false,
error: error.message,
mandalarts: null,
}
}
}
),
},
}
export default resolvers
Query와 Mutation을 모두 작성했으니 이제 잘 돌아가는지 http://localhost:4000/playground에서 테스트 해보자!
AddMandalart
GetMyMandalarts
오늘은 한 달 동안 진행되는 만다라트 프로젝트에 대해 중간 개발 일지 성격으로 글을 작성해보았습니다. 아직 한창 개발 중인 프로젝트에 대해 글을 쓰려다보니 완결성이 없이 그저 지금까지 커밋한 내역들을 나열한 것처럼 보이네요… 프로젝트를 완성한 뒤에 보다 보기 좋게 튜토리얼 형식으로 글을 써보도록 하겠습니다. 그럼 다음 글에서는 부디 완성된 프로젝트를 가지고 글또 마지막을 장식해보도록 노력하겠습니다~ 🙋