• 본 시리즈에서는 How to GraphQL의 Tutorial 문서들을 차례대로 번역합니다.
  • 본 시리즈는 GraphQL Basic and Advanced 시리즈에서 이어집니다. GraphQL을 처음 접하는 분들은 해당 시리즈를 먼저 읽고 오시는 것을 추천드립니다.
  • 이 글은 GraphQL-Node Tutorial - Authentication을 번역한 글입니다.
  • 오역 또는 의역이 있을 수 있습니다. 양해 부탁드리며, 수정할 필요한 부분은 댓글로 요청해주세요.

인증

이번 장에서는 GraphQL 서버의 사용자들이 인증을 받을 수 있도록 회원가입 및 로그인 기능을 구현합니다.

User 모델 추가하기

제일 먼저 할 일은 데이터베이스에 사용자 데이터를 표현하는 것입니다. Prisma 데이터 모델에 User 타입을 추가하면 됩니다.

새로 만드는 User 타입과 기존에 존재하던 Link 타입 간에 관계를 추가해서, 한 User가 여러 Link게시할 수 있다는 것을 표현하는 것도 좋겠군요.

prisma/datamodel.prisma 파일을 열어서 아래와 같이 수정합니다.
($ .../hackernews-node/prisma/datamodel.prisma

type Link {
  id: ID! @id
  description: String!
  url: String!
  postedBy: User          // 수정
}

type User {               // 수정
  id: ID! @id             // 수정
  name: String!           // 수정
  email: String! @unique  // 수정
  password: String!       // 수정
  links: [Link!]!         // 수정
}                         // 수정

Link 타입에 관계 필드postedBy를 새롭게 추가했고, 이 필드는 User 인스턴스를 가리키고 있습니다. User 타입은 이제 links 필드를 통하여 Link로 이루어진 목록을 유지합니다. 이런 식으로, SDL을 사용하여 일대다 관계를 표현할 수 있습니다.

데이터 모델 파일을 수정할 때마다 반드시 배포를 다시 해주어야 합니다. 그래야 Prisma API가 수정 사항을 적용받고, 데이터베이스 스키마 또한 대응하여 갱신됩니다.

프로젝트의 최상위 디렉토리에서 아래의 명령어를 실행합니다.
($ .../hackernews-node)

prisma deploy

이렇게 하고 나면 Prisma API가 최신 상태로 갱신됩니다. 자동 생성되는 Prisma 클라이언트도 갱신해주어야 합니다. 그래야 새로 추가된 User 모델에 대한 CRUD 메서드를 노출시켜줄 수 있습니다.

동일한 디렉토리에서 아래의 명령어를 실행합니다.
($ .../hackernews-node)

prisma generate

데이터 모델을 수정하고 prisma deploy 명령을 실행할 때마다 항상 명시적으로 prisma generate를 실행해야하는 것은 조금 번거롭습니다. 일을 쉽게 만드려면, prisma deploy를 실행할 때마다 항상 호출되는 Post-Deployment Hook을 설정할 수 있습니다.

prisma.yml 파일의 마지막 줄에 다음 내용을 추가합니다.
($ .../hackernews-node/prisma/prisma.yml

hooks:
  post-deploy:
    - prisma generate

이제 Prisma 클라이언트는 데이터 모델이 수정되어서 변경 사항을 데이터베이스에 마이그레이션할 때마다 자동으로 다시 생성됩니다.

GraphQL 스키마 확장하기

스키마 주도 개발의 과정을 기억하시나요? 제일 먼저 할 일은 API에 추가할 새로운 동작을 스키마 정의에 추가하여서 확장하는 것입니다. 이번에 추가할 동작은 signuplogin 뮤테이션입니다.

src/schema.graphql 파일에서 어플리케이션 스키마를 열고, Mutation 타입의 내용을 아래와 같이 수정합니다.
($ .../hackernews-node/src/schema.graphql

type Mutation {
  post(url: String!, description: String!): Link!
  signup(email: String!, password: String!, name: String!): AuthPayload // 수정
  login(email: String!, password: String!): AuthPayload                 // 수정
}

다음으로, 동일 파일에서 User 타입에 맞추어 AuthPayload 타입을 추가합니다.

동일하게 src/schema.graphql 파일에서 아래와 같이 타입을 추가합니다.
($ .../hackernews-node/src/schema.graphql

type AuthPayload {
  token: String
  user: User
}

type User {
  id: ID!
  name: String!
  email: String!
  links: [Link!]!
}

signuplogin 뮤테이션은 비슷하게 동작합니다. 회원가입 혹은 로그인한 사용자의 정보와 token을 반환합니다. token은 이후에 GraphQL API에 전송될 요청에 대한 인증에 사용됩니다. 이 정보들은 AuthPayload 타입으로 취합됩니다.

마지막으로, UserLink 타입이 서로 양방향 관계를 이룬다는 것을 반영해야 합니다. schema.graphql 파일 내의 Link 모델에 postedBy 필드를 추가합니다.
($ .../hackernews-node/src/schema.graphql

type Link {
  id: ID!
  description: String!
  url: String!
  postedBy: User       // 수정
}

리졸버 함수 구현하기

새로운 동작을 추가하여 스키마 정의를 확장했다면, 리졸버 함수 또한 추가로 구현해야 합니다. 그 전에, 코드를 리팩터링하여 모듈화하겠습니다.

각 타입에 대한 리졸버들을 각각의 파일들로 분리하겠습니다.

우선, resolvers 라는 이름의 새로운 디렉토리를 만들고, 해당 디렉토리에 Query.js, Mutation.js, User.js, Link.js 파일을 추가합니다. 아래의 명령어를 사용하여 추가합니다.
($ .../hackernews-node)

mkdir src/resolvers
touch src/resolvers/Query.js
touch src/resolvers/Mutation.js
touch src/resolvers/User.js
touch src/resolvers/Link.js

다음으로, feed 리졸버의 구현 내용을 Query.js 파일로 이동합니다.

Query.js 파일에 아래의 함수 정의를 추가합니다.
($ .../hackernews-node/src/Query.js

function feed(parent, args, context, info) {
  return context.prisma.links()
}

module.exports = {
  feed,
}

여기까지는 상당히 간단명료합니다. 기존의 기능을 그대로 유지하되, 각각의 함수들을 별도의 전용 파일에 작성하고 있습니다. 다음은 Mutation 리졸버입니다.

인증 리졸버 추가하기

Mutation.js 파일에 다음과 같이 loginsignup 리졸버를 추가합니다. (post 리졸버는 잠시 후에 추가하겠습니다)
($ .../hackernews-node/src/Mutation.js

async function signup(parent, args, context, info) {
  // 1
  const password = await bcrypt.hash(args.password, 10)
  // 2
  const user = await context.prisma.createUser({ ...args, password
  })

  // 3
  const token = jwt.sign({ userId: user.id }, APP_SECRET)

  // 4
  return {
    token,
    user,
  }
}

async function login(parent, args, context, info) {
  // 1
  const user = await context.prisma.user({ email: args.email })
  if (!user) {
    throw new Error('No such user found')
  }

  // 2
  const valid = await bcrypt.compare(args.password, user.password)
  if (!valid) {
    throw new Error('Invalid password')
  }

  const token = jwt.sign({ userId: user.id }, APP_SECRET)

  // 3
  return {
    token,
    user,
  }
}

module.exports = {
  signup,
  login,
  post,
}

주석으로 표시해놓은 번호들을 바탕으로, 무슨 일이 벌어지고 있는지 하나씩 짚어보도록 하겠습니다. 우선 signup부터 시작하죠.

  1. signup 뮤테이션에서는, 우선 User의 비밀 번호를 암호화합니다. 암호화에는 brcypt 라이브러리가 사용되는데, 곧 설치하도록 하겠습니다.

  2. 다음으로, prisma 클라이언트를 사용하여 데이터베이스에 새로운 User를 저장합니다.

  3. APP_SECRET 값으로 서명된 JWT를 생성합니다. 여기서 사용되는 APP_SECRET는 별도로 설정해야 하며, jwt 라이브러리도 설치해야 합니다.

  4. 마지막으로, GraphQL 스키마에 정의된 AuthPayload 객체 형태에 부합하도록, tokenuser를 포함하는 객체를 반환합니다.

이제 login 뮤테이션을 살펴보겠습니다.

  1. 새로운 User 객체를 생성하는 것이 아니라, prisma 클라이언트를 사용하여 기존의 User 레코드를 검색하여 반환합니다. User 레코드를 검색할 때에는 login 뮤테이션에 인자로 전달되는 email 주소가 사용됩니다. 만약 해당 이메일 주소에 대응하는 User가 존재하지 않는다면, 이에 대응하는 오류를 반환합니다.

  2. 다음으로, 제공된 비밀 번호와 데이터베이스에 저장된 비밀 번호를 비교합니다. 둘이 일치하지 않으면, 오류를 반환합니다.

  3. 마지막에는 tokenuser를 반환합니다.

이제 구현을 마무리해봅시다.

프로젝트에 필요한 새로운 의존성을 추가합니다.
($ .../hackernews-node)

yarn add jsonwebtoken bcryptjs

다음으로, 이후에 재사용될 몇몇 유틸리티 기능들을 만들겠습니다.

src 디렉토리에 util.js라는 파일을 생성합니다.
($ .../hackernews-node)

touch src/utils.js

다음으로, 아래의 코드를 작성합니다.
($ .../hackernews-node/src/utils.js

const jwt = require('jsonwebtoken')
const APP_SECRET = 'GraphQL-is-aw3some'

function getUserId(context) {
  const Authorization = context.request.get('Authorization')
  if (Authorization) {
    const token = Authorization.replace('Bearer ', '')
    const { userId } = jwt.verify(token, APP_SECRET)
    return userId
  }

  throw new Error('Not authenticated')
}

module.exports = {
  APP_SECRET,
  getUserId,
}

APP_SECRET은 사용자들에게 발급해줄 JWT를 서명하는 데에 사용됩니다.

getUserId 함수는 인증이 요구되는 리졸버(post 등)에서 호출할 수 있는 헬퍼 함수입니다. 이 함수는 우선 context 객체로부터 Authorization 헤더를 가져옵니다. 여기에는 User의 JWT가 들어있습니다. 다음으로 해당 JWT를 검증하고, 그로부터 해당 User의 ID를 가져옵니다. 만약 어떤 이유로 인하여 도중에 실패한다면, 이 함수는 예외를 던집니다. 따라서 인증을 필요로하는 리졸버를 "보호"하는 데에 이 함수를 사용할 수 있습니다.

모든 것이 제대로 작동하도록, Mutation.js의 최상단에 다음과 같이 import 구문을 추가합니다.
($ .../hackernews-node/src/resolvers/Mutation.js

const bcrypt = require('bcryptjs')
const jwt = require('jsonwebtoken')
const { APP_SECRET, getUserId } = require('../utils')

이제, 작은 문제 단 하나가 남았습니다. 우리는 context 객체 내의 request 객체에 접근하고 있습니다. 하지만 context를 처음 초기화할 때, 우리는 prisma 클라이언트 인스턴스만을 추가했었죠. 아직 request 객체가 추가되지 않았습니다.

index.js 파일을 열고, GraphQLServer의 초기화 코드를 다음과 같이 수정합니다.
($ .../hackernews-node/src/index.js

const server = new GraphQLServer({
  typeDefs: './src/schema.graphql',
  resolvers,
  context: request => {
    return {
      ...request,
      prisma,
    }
  },
})

이번에는 requestcontext 객체에 직접 추가하지 않고, context 객체를 반환하는 함수로 그 형태를 바꿨습니다. 이러한 접근 방식의 장점은 바로 GraphQL 쿼리(또는 뮤테이션)를 전달하는 HTTP 요청을 context에 직접 추가할 수 있다는 점입니다. 이렇게 되면 리졸버에서 Authorization 헤더를 읽고, 요청을 보낸 사용자가 해당 요청에 상응하는 동작을 수행할 수 있는지 여부를 검증할 수 있게 됩니다.

post 뮤테이션에 대하여 인증 요구하기

인증 흐름을 테스트해보기 전에, 스키마와 리졸버가 완전하게 잘 설정되었는지 확인해야 합니다. 우선, post 리졸버가 아직 작성되지 않았습니다.

Mutation.js 파일을 열고, post 리졸버 함수를 다음과 같이 작성합니다.
($ .../hackernews-node/src/resolvers/Mutation.js

function post(parent, args, context, info) {
  const userId = getUserId(context)
  return context.prisma.createLink({
    url: args.url,
    description: args.description,
    postedBy: { connect: { id: userId } },
  })
}

index.js에서 구현한 것과 2가지 차이점이 있습니다.

  1. 이제는 getUserId 함수를 사용하여 User의 ID를 가져오고 있습니다. 이 ID는 JWT 토큰에 저장된 것이며, 이 토큰은 서버로 들어오는 HTTP 요청의 authorization 헤더에 설정되어있습니다. 따라서 어떤 UserLink를 생성했는지 알 수 있습니다. userId를 가져오는 데에 실패할 경우 예외로 이어지고, 해당 함수 스코프는 createLink 뮤테이션이 실행되기 전에 종료된다는 점을 기억하세요. 이 경우, GraphQL 반환값에는 단지 해당 사용자가 인증되지 않았다고 하는 오류가 포함될 것입니다.

  2. 다음으로 userId를 사용하여, 생성될 Link와 이 Link를 생성한 User를 연결합니다. 이 과정은 중첩된 객체 쓰기를 통하여 이루어집니다.

관계 리졸브하기

GraphQL 서버를 작동시켜서 새로운 기능을 테스트하기 앞서 해야 할 일이 또 하나 있습니다. UserLink 간의 관계가 올바르게 리졸브되는지 확인하는 것입니다.

UserLink 타입에서 스칼라 값의 경우 리졸버를 생략했던 것 기억하시나요? 본 튜토리얼의 초반부에서 볼 수 있었던 간단한 패턴은 아래와 같습니다.

Link: {
  id: parent => parent.id,
  url: parent => parent.url,
  description: parent => parent.description,
}

하지만, 이런 방식으로는 리졸브할 수 없는 2개의 필드를 새롭게 GraphQL 스키마에 추가했습니다. Link 타입의 postedBy 필드, 그리고 User 타입의 links 필드가 바로 그것입니다. 이 필드들은 명시적으로 구현되어야 합니다. GraphQL 서버는 이 필드에 대하여 어디서 데이터를 가져와야 하는지 알아서 추론해낼 수 없습니다.

Link.js 파일을 열고, postedBy 관계를 리졸브합니다.
($ .../hackernews-node/src/resolvers/Link.js

function postedBy(parent, args, context) {
  return context.prisma.link({ id: parent.id }).postedBy()
}

module.exports = {
  postedBy,
}

postedBy 리졸버에서는 우선 prisma 클라이언트를 사용하여 Link를 불러오고, 다음으로 해당 Link에 대하여 postedBy 메서드를 호출합니다. 이 리졸버는 schema.graphql에 정의된 Link 타입이 가지는 postedBy 필드를 리졸브해야 하므로, postedBy 라는 이름을 가져야 함을 기억하시기 바랍니다.

links 관계 또한 비슷한 방식으로 리졸브하면 됩니다.

User.js 파일을 열고, links 관계를 리졸브합니다.
($ .../hackernews-node/src/resolvers/Link.js

function links(parent, args, context) {
  return context.prisma.user({ id: parent.id }).links()
}

module.exports = {
  links,
}

모아서 하나로 만들기

아주 좋아요! 마지막으로 할 일은 index.js에 새로운 리졸버 구현 결과들을 추가하는 겁니다.

index.js 파일을 열고, 리졸버를 포함하는 모듈을 불러오는 구문을 최상단에 추가합니다.
($ .../hackernews-node/src/index.js

const Query = require('./resolvers/Query')
const Mutation = require('./resolvers/Mutation')
const User = require('./resolvers/User')
const Link = require('./resolvers/Link')

다음으로, resolvers 객체의 정의를 다음과 같이 수정합니다.
($ .../hackernews-node/src/index.js

const {
  Query,
  Mutation,
  User,
  Link
}

다 됐습니다. 이제 인증 흐름을 테스트할 준비가 끝났습니다! 🔓

인증 흐름 테스트

가장 먼저 할 일은 signup 뮤테이션을 테스트해서 새로운 User가 데이터베이스에 잘 생성되는지 확인하는 것입니다.

  • 시작에 앞서 우선 서버를 다시 시작하시기 바랍니다. 우선 CTRL+C를 입력하고, node src/index.js를 실행하면 됩니다. 다음으로, http://localhost:4000으로 접속하여 GraphQL Playground를 확인합니다.

참고로, 이미 Playground가 열린 브라우저 페이지가 있다면, 이것은 "재사용"해도 됩니다. 여기서는 서버를 다시 시작하는 것이 중요합니다. 그래야 우리가 추가한 구현이 실제로 적용됩니다.

이제, 새로운 User를 생성하는 다음 뮤테이션을 전송합니다.

mutation {
  signup(
    name: "Alice"
    email: "alice@prisma.io"
    password: "graphql"
  ) {
    token
    user {
      id
    }
  }
}

서버 측 응답에서 인증 token을 복사하고, 새로운 탭을 열어 Playground에 접속합니다. 새 탭의 왼쪽 하단에 위치한 HTTP HEADERS 패널을 열고 Authorization 헤더를 설정합니다. 예전에 Prisma Playground에서 했던 그것과 비슷한 방식입니다. 아래의 코드에서 __TOKEN__ 으로 표시해놓은 부분의 값을 복사한 토큰값으로 대체하면 됩니다.

{
   "Authorization": "Bearer __TOKEN__"
}

이제 해당 탭에서 쿼리 또는 뮤테이션을 전송할 때마다 그 안에는 인증 토큰이 함께 포함되어 전송됩니다.

Authorization 헤더를 포함한 상태로, GraphQL 서버에 아래 뮤테이션을 전송합니다.

mutation {
  post(
    url: "www.graphqlconf.org"
    description: "An awesome GraphQL conference"
  ) {
    id
  }
}

V1hp4ID.png

서버가 이 뮤테이션을 받으면, post 리졸버를 호출하여 제공된 JWT를 검증합니다. 또한, 새로 생성되는 Link는 아까 signup 뮤테이션을 통하여 전달된 User에 연결됩니다.

모두 잘 작동하는지 확인하기 위하여, login 뮤테이션을 보내겠습니다.

mutation {
  login(
    email: "alice@prisma.io"
    password: "graphql"
  ) {
    token
    user {
      email
      links {
        url
        description
      }
    }
  }
}

이 뮤테이션은 다음과 유사한 응답을 반환합니다.

{
  "data": {
    "login": {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjanBzaHVsazJoM3lqMDk0NzZzd2JrOHVnIiwiaWF0IjoxNTQ1MDYyNTQyfQ.KjGZTxr1jyJH7HcT_0glRInBef37OKCTDl0tZzogekw",
      "user": {
        "email": "alice@prisma.io",
        "links": [
          {
            "url": "www.graphqlconf.org",
            "description": "An awesome GraphQL conference"
          }
        ]
      }
    }
  }
}

Quiz

User 타입은 이미 Prisma 데이터베이스 스키마(datamodel.prisma)의 일부이고 거기에서 가져와 사용하면 되는데도 불구하고 또다시 정의한 이유로 올바른 것은?

  • User 타입은 graphql-import의 동작 방식으로 인하여 가져올 수 없다.
  • 잠재적으로 민감한 정보를 클라이어트 어플리케이션에게 숨기기 위하여
  • 사용자가 비밀 번호를 초기화할 수 있도록
  • GraphQL 명세의 요구 사항이다.