AWS를 이용한 실시간 채팅 자음퀴즈 앱서비스 구현

2019-06-27

.

Data_Engineering_TIL_(20190626) / AWS한국사용자모임(AWSKRUG) 세미나 참가결과

study program : https://www.meetup.com/ko-KR/awskrug/events/262569597

학습 시 참고한 URL : https://github.com/yebonkim/android-realtime-quiz

[실습목표]

  • AWS 서비스들을 이용하여 Websocket 실시간 안드로이드 초성퀴즈 앱 서비스를 구현

  • 서비스 이용 시뮬레이션

1) 앱을 실행하면 먼저 닉네임을 입력하고 게임시작 버튼을 누를 수 있는 activity가 생성, 사용자는 닉네임을 임의로 입력하고 게임시작 버튼을 누른다.

2) 게임시작 버튼을 눌러주는 activity 생성, 사용자는 게임시작 버튼을 눌러서 채팅방으로 입장한다.

3) 실시간 채팅방이 앱을 통해 전시가 사용자는 자유롭게 채팅을 할 수 있다. 이때 특정유저가 화면상단의 초성을 문자열을 입력해서 맞출경우 다음 초성으로 화면상단의 초성문제가 바뀌는 서비스를 사용자들에게 제공한다.

ex) ‘ㅊㅅ’ 이라는 제시어를 화면상단에 전시하고 어떤 사용자가 ‘초심’이라고 채팅창에 입력해서 초성의 정답을 맞출경우 다음 초성문제가 전시되는 구조

1

[서비스구현 아키텍처]

2

  • 아키텍처 구성별 기능

1) IAM : 서비스 구현을 위하여 필요한 AWS 권한 부여

2) dynamo DB : 유저가 앱을 이용하면서 발생시키는 각종 데이터를 저장하는 DB

2-1) User 테이블 : 서비스 제공 시 웹소켓을 사용하기 때문에 유저가 서비스 접근 시 connection 아이디를 부여해 User 테이블에 저장시키고, 서비스를 종료 시 해당 connection 아이디를 User 테이블에서 제거할 것이다.

User 테이블 answerCnt는 connection 아이디별로 초성퀴즈 정답을 얼마나 맞췄는지도 저장한다. (이번 실습에서는 구현이 안되어 있는데 추후 이부분을 추가적으로 보완할 수 있다.)

3

2-2) Chat 테이블 : 아래와 같이 timestamp, content(채팅 메시지), room(채팅방을 디폴트로 general로 해놨는데 추후 어플리케이션을 보완하여 커스터마이징 할 수 있다.), username(유저이름) 4가지 필드가 있다. timestamp가 파티션키다.

4

2-3) Game 테이블 : 게임을 진행하면서 필요한 정보들이 있는데 람다함수로는 데이터를 저장할 수 없기 때문에 만든 테이블.

answeredWords는 정답을 맞춘 단어들이다. 다른사람이 똑같은 단어를 입력했을때 먼저 입력한 사람이 있음에도 정답처리가 되면 안되기 때문에 만든것이다.

nowConsonant는 지금현재 맞춰야 할 초성

nowWordidx는 몇번째 단어를 맞추고 있는지 표현. 안드로이드 서비스 제공시에는 필요가 없지만 서버 동작시 필요하기 때문에 구현

5

3) 클라이언트와 서버(API gateway) 간 데이터 이동 및 동작

안드로이드 클라이언트에서 유저네임과 컨텐츠(채팅메세지)를 날려주면 이 데이터가 api 게이트웨이를 타고 람다까지가서 구현된 프로그래밍에 의해 처리가 된다. 만약에 안드로이드 클라이언트에서 ‘강아지’라는 컨텐츠를 날려서 ‘ㄱㅇㅈ’라는 초성을 맞추게 되면 아래 그림과 같은 채팅 및 정답 메세지 그리고 다음 초성을 안드로이드 클라이언트로 날려주게 된다.

6

[실습 간 사용한 프로그래밍 언어]

  • AWS labmda server : Node.js

  • 안드로이드 클라이언트 : Java

[실습 구현 간 자주발생하는 애러정리]

1) 파이썬으로 람다함수 구현 시 오류 발생

람다를 파이썬으로 코딩 시 안드로이드 서비스 구현을 위해 ‘Apigatewaymanagementapi’를 boto 툴을 이용해서 사용하게 되면 아래와 같은 오류메세지가 발생한다.

결론은 boto3, boto4 버전문제

해결방법 : 람다레이어에 환경을 구성해주는 방법, 람다에 소스를 탑재 시 boto3, boto4 소스까지 같이 올리는 방법

** 이번 실습에서는 그래서 람다를 코딩할때 노드js를 사용할 것이다.

7

2) AWS 접근권한 문제

사례1) 람다에 서비스를 탑재했을때 클라우드 와치로 로그를 보고 싶은데 람다와 클라우드 와치의 접근권한을 주지 않으면 로그가 쌓이지 않는다.

사례2) API게이트웨이에서 Apigatewaymanagementapi 관련 권한을 주지 않을 경우 클라이언트에서 서버를 접근해서 메세지를 보냈는데 아무이유없이 리턴값이 없다.

3) 메세지를 주고 받을때 데이터 형식문제

대소문자를 정확하게 구분하지 않고 입력하면 오류가 발생한다.

4) Async, await 문제

람다는 정상적으로 작동하는데 막상 다이나모 디비에 들어가니까 데이터가 쌓여있지 않는 문제 발생 Async와 await를 잘못걸어서 sink가 안맞아서 발생한 문제이다.

[개선 및 보완사항]

1) 단어 리스트 저장위치

이번 실습에서는 초성퀴즈 정답데이터를 람다함수안에 하드코딩으로 집어넣어 버렸는데 실제 서비스를 구현할때는 elastic search 같은 서비스를 활용할 필요가 있다.

2) 한 초성에 하나의 단어만 존재한다고 가정

‘ㅅㄹ’이 있으면 사람, 서랍, 서리, 사랑 등 정답이 여러개가 존재하지만 이번에 구현한 실습에서는 ‘사람’이라고 정답을 딱 하나만 설정을 해두었다.

3) 불필요한 activity 존재

닉네임을 입력하고 게임시작 버튼을 누르면 바로 아래 그림에서 우측화면과 같이 채팅할 수 있는 화면이 나오기를 바랬으나 사용자가 connect를 한 다음에 바로 broadcasting 메세지로 게임에 대한 데이터를 보냈으나 클라이언트가 확인하지 못하는 현상이 발생하였다.

8

4) game room에 대한 개념이 미비

이번 실습에서는 특정 사용자가 게임에 접속했는데 자기 혼자만 있다면 모든 게임데이터를 초기화 시키고 시작하는 것으로 구현함

9

[실습 세부과정]

step1) AWS IAM role 생성

권한부여 서비스 목록 : IAM ,APIGateway, Lambda, DynamoDB, Cloud Watch

주의사항은 지역을 서울로 설정한다.

step 1-1) 정책생성

iam 서비스 접속하여 아래 그림과 같이 정책을 만들어준다. (정책 메뉴 -> 정책 생성)

10

  • 위의 그림에서 정책부여시 입력해야 할 json 코드

apigateway의 모든 액션, logs는 클라우드와치의 로그, 람다의 모든기능, 다이나모 디비의 모든 기능을 권한을 주었다.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "apigateway:*",
                "logs:*",
                "lambda:*",
                "dynamodb:*"
            ],
            "Resource": "*"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": "execute-api:ManageConnections",
            "Resource": "arn:aws:execute-api:*:*:**/@connections/*"
        }
    ]
}
  • 위의 json 코드에서 주의사항

1) 해당 세션에서는 세션의 용이함을 위해 사용할 서비스의 모든 권한을 열어주고 있습니다. 실제 서비스에서는 적절히 권한을 다시 설정해야한다.

2) 해당 세션에서는 서비스에 직접적으로 연관된 권한이 아닌 [execute-api:ManageConnections] 정책을 사용하고 있는데 이 정책을 생략하면 WebSocket이 정상적으로 작동하지 않으니 주의해야한다.

11

정책을 json 코드로 입력해주고 위의 그림과 같이 이름을 입력하고 정책을 생성해준다.

step 1-2) 역할(Role) 생성

정책을 만들었으니 람다에 할당해줄 역할을 만들어줄 것이다.

아래 그림과 같이 진행하면 되고, 두가지 정책을 할당할 것이다.

12

step2) 다이나모 디비 생성

테이블 생성시 대소문자 구분을 잘 해줘야한다.

[생성할 테이블 정보]

User : Game과 Chat 데이터 Broadcast를 위해서 Websocket connectionId를 저장. Partition Key : connectionId(문자열)

Chat : Chat 데이터를 저장. partition Key : timestamp(문자열)

Game : Game 데이터를 저장하기 위한 테이블. 현재 문제 초성, 현재 문제 index, 이미 나온 정답 등을 저장. partition Key : id(번호)

먼저 다이나모 디비 서비스 콘솔로 접속한다.

콘솔에서 테이블 생성을 누르고 아래 그림과 같이 만들어준다.

13

이런식으로 아래 그림과 같이 게임테이블까지 생성해준다.

14

step3) Websocket연결 기능 생성

람다를 만들어놓으면 접근을해야 하는데 람다는 특별하게 주소가 없어서 먼저 api게이트웨이를 구성하고 그 게이트웨이를 람다와 연결시켜주는 방식으로 해줘야 한다. 클라이언트가 api게이트웨이에 접속하고 람다에 접근할 수 있는 구조가 되는 것이다.

step3-1) API 게이트를 생성

api 게이트웨이 서비스 콘솔 접속 후 ‘시작’ 버튼 클릭, 그리고 아래 그림과 같이 api게이트웨이 생성

15

게이트웨이를 생성하면 connect(웹소켓을 연결할때 들어오는 통로), disconnect(웹소켓을 해제할때 나가는 통로), default(채팅메세지와 게임데이터를 주고받는 통로)가 생성된다.

step3-2) Connection 관리 Lambda 생성

람다서비스 콘솔 접속 -> 함수생성 클릭 -> 새로작성 클릭 -> 아래 그림과 같이 옵션 설정

16

아래 그림과 같이 전시가 되면 정상적으로 람다가 생성된 것이다.

17

스크롤을 내리면 나오는 함수 코드 파트에 아래 소스를 그대로 복사 붙여넣기 해준다.

아래 그림과 같이 소스를 복사 붙여넣기 한 후 [저장] 버튼이 활성화 되었다면 [저장] 버튼을 눌러준다.

18

[lambda에서 참고사항]

index.handler는 어떤파일로 들어와서 어떤 함수를 처음 실행시킬 것인지에 대한 설정이다.

[아래 코드설명]

먼저 aws-sdk 라이브러리를 가져온다. 그리고 다이나모 디비도 가져온다.

이벤트라는 데이터가 핸들러로 데이터가 들어온다. 이벤트 안에 우리가 필요한 거의 모든 정보가 들어있다.

event.requestContext.connectionId를 하게 되면 웹소켓의 커넥션 아이디를 가져올 수 있다.

이벤트 타입은 api게이트웨이에서 커넥트와 디스커넥트를 연결해줬는데 그거를 구분하기 위해서 받아온 것이다.

그래서 if문으로 커넥트인지 디스커넥트인지 구분한다.

커넥트가 되었을때 유저테이블에다가 커넥트 아이디를 넣어주게 된다.

answerCnt는 하나도 맞춘게 없이 시작하니까 0으로 넣어준다.

putToDyDB는 코드 맨 밑에 구현되어 있는 함수이다.

디스커넥트 부분은 반대로 다이나모디비에 있는 얘들을 날려주는 기능을 구현했다.

const AWS = require('aws-sdk')

const ddb = new AWS.DynamoDB.DocumentClient()

exports.handler = async (event, context) => {
  let connectionData
  
  const connectionId = event.requestContext.connectionId
  const eventType = event.requestContext.eventType

  if (eventType === "CONNECT") {
    console.log("Connect Requested")

    let params = {
        TableName: "User",
        Item: {
            connectionId: connectionId,
            answerCnt: 0
        }
    }
    await putToDyDB(params)

  } else if (eventType === "DISCONNECT") {
    console.log("Disconnect Requested")

    let params = {
        TableName: "User",
        Key: {
            connectionId: connectionId
        }
    }
    
    await deleteFromDyDB(params)
  } else {
    return { statusCode: 404, body: "illegal access" }
  }

  return { statusCode: 200, body: 'Data sent.' }
}

async function putToDyDB(params) {
    await ddb.put(params, function(err, data) {
      if (err) {
          console.error("Unable to add item to '" + params.TableName + "' Table. Error JSON:", JSON.stringify(err, null, 2))
      } else {
          console.log("Added item to '" + params.TableName + "' Table:", JSON.stringify(data, null, 2))
      }
    }).promise()
}

async function deleteFromDyDB(params) {
    await ddb.delete(params, function(err, data) {
      if (err) {
          console.error("Unable to delete item from '" + params.TableName + "' Table. Error JSON:", JSON.stringify(err, null, 2))
      } else {
          console.log("Deleted item from '" + params.TableName + "' Table:", JSON.stringify(data, null, 2))
      }
    }).promise()
}

스크롤을 조금 더 내려 아래 그림과 같이 제한시간을 [30]초를 입력해준다. 그리고 저장버튼을 눌러준다.

19

step3-3) API Gateway에 Lambda 연결

api 게이트웨이 콘솔 접속 -> 아래 그림과 같이 빨간색 박스 클릭

20

아래 그림과 같이 컨넥트와 디스컨넥트 수정 후 저장

21

아래 그림과 같이 api게이트웨이를 배포(엔드포인트 등록)하고 웹소켓 URL을 확인한다.

22

아래 그림과 같이 터미널을 열어서 wscat을 설치하고, api 게이트웨이 배포된 것이 잘 작동되는지 확인한다.

Websocket연결이 완료되었다면 람다 함수 코드에 의하여 Disconnect가 되기 전까지 DynamoDB테이블의 [User] 테이블에 connectionId가 존재하게 된다.

이렇게 테스트 한 내용은 또한 아래 그림과 같이 클라우드와치에서 로그기록을 통해 확인이 가능하다.

23

step4) Websocket 게임 및 채팅 기능 구현

step4-1) 아까 위에서 구현했던 AWS Lambda를 아래와 같이 수정해준다.

먼저 람다 서비스 콘솔 접속 -> android-realtime-lambda 클릭 -> 아래 그림과 같이 수정

수정할 코드는 아래와 같다.

23-1

먼저 단어리스트가 삽입되어 있다.

게임파라미터(게임에 대한 정보들)를 다이나모 디비와 통신하기 위해서 게임파라미터를 상단에 선언하였다.

커넥트부분에 최초 게임에 접속한 경우에 대해서 게임데이터를 초기화 하는 것을 아까 람다 코드와 비교했을때 추가된 부분이다.

유저 테이블에서 커넥션 아이디만 검색해서 받아왔을때 그 데이터가 하나남았으면 한명만 남았다는 의미이고 이걸 리턴 트루를 해주면 그 상황에 따른 처리를 해줄 것이다.

콘소넌트 함수는 하단에 구현이 되어 있다. 단어에서 따로 초성만 때오는 역할이다.

메세지 부분은 데이터가 들어오게 되면 메세지로 들어오게 된다. event.body에 유저네임과 컨텐츠가 들어가게 된다. 그걸 제이슨형태로 넣어줬기 때문에 json.parse로 페이로드 처리해주는것이다.

람다함수에서 받는 메세지는 유저네임이랑 컨텐츠 밖에 없다. 그래서 이걸 모두같았는지 검사하는 부분이 있고 이 부분을 만족하지 않으면 오류를 전시해준다.

get gamedata는 메세지를 받아서 is answer라는 곳을 통해서 지금 컨소넌트와 사용자가 보낸거랑 같냐 확인시켜준다. 또한 특정 사용자가 이미 맞춘 단어가 아닌지도 알려준다.

정답을 맞추면 특정사용자의 정답수를 늘려주고 다음 게임으로 넘어가게 해준다.

사용자가 정답을 맞췄다고 하면 다음 초성을 앱에 띄워줘야 하는데 그래서 브로드캐스트 게임데이터를 해주게 된다. 브로드캐스트 메세지는 게임에 참여한 모든 사용자들에게 보내줘야 하기 때문에 해준것이다.

먼저 유저테이블에서 커넥션 아이디를 불러온다. 그러면 현재 접속되어있는 유저아이디를 전부 가져오는 것이다. 그거를 도와주는게 apimanagementapi다. connection 함수를 통해서 커넥션아이디에 게임데이터를 보내주게 된다.

const AWS = require('aws-sdk')

const ddb = new AWS.DynamoDB.DocumentClient()

var word = [
  "강아지", "고양이", "선풍기", "가방", "서랍", "책상", "방향", "영어", "의자", "사진"
]

var game_params = {
  TableName: "Game",
  Item: {
    id: 1,
    nowWordIdx : 0,
    answeredWords: ", ",
    nowConsonant: getConsonant(word[0])
    }
}

exports.handler = async (event, context) => {
  const connectionId = event.requestContext.connectionId
  const eventType = event.requestContext.eventType

  if (eventType === "CONNECT") {
    console.log("Connect Requested")

    let params = {
      TableName: "User",
      Item: {
        connectionId: connectionId,
        answerCnt: 0
      }
    }
    await putToDyDB(params)

    if(await isOne()) {
      game_params.Item.nowWordIdx = 0
      game_params.Item.nowConsonant = getConsonant(word[0])
      game_params.Item.answeredWords = ", "
      await putToDyDB(game_params)
    }
  } else if (eventType === "DISCONNECT") {
    console.log("Disconnect Requested")

    let params = {
      TableName: "User",
      Key: {
        connectionId: connectionId
      }
    }
    
    await deleteFromDyDB(params)
  } else if (eventType === "MESSAGE") {
    let isJson = true
    var payload
    var keys
    try {
      payload = JSON.parse(event.body)
      keys = Object.keys(payload)
    }catch(e) {
      isJson = false
    }
      
    if(isJson === true && keys.includes("content") && keys.includes("username")) {
      let params = {
        TableName: "Chat",
        Item: {
          "room": "general",
          "content": payload.content,
          "timestamp": new Date().toISOString(),
          "username": payload.username
        }
      }
      await putToDyDB(params)
      await getGameData().then(async (result) => {
        if(isAnswer(payload.content)) {
          await addScore(connectionId)
          await updateGameData(payload.content)
        }
        await broadcastMsg(event, payload)
      })
    }
    
    await broadcastGameData(event)
  } else {
    return { statusCode: 404, body: "illegal access" }
  }

  return { statusCode: 200, body: 'Data sent.' }
}

async function putToDyDB(params) {
  await ddb.put(params, function(err, data) {
    if (err) {
      console.error("Unable to add item to '" + params.TableName + "' Table. Error JSON:", JSON.stringify(err, null, 2))
    } else {
      console.log("Added item to '" + params.TableName + "' Table:", JSON.stringify(data, null, 2))
    }
  }).promise()
}

async function deleteFromDyDB(params) {
  await ddb.delete(params, function(err, data) {
    if (err) {
      console.error("Unable to delete item from '" + params.TableName + "' Table. Error JSON:", JSON.stringify(err, null, 2))
    } else {
      console.log("Deleted item from '" + params.TableName + "' Table:", JSON.stringify(data, null, 2))
    }
  }).promise()
}

async function getFromDyDB(params) {
  let result = null
  await ddb.get(params, function(err, data) {
    if (err) {
      console.error("Unable to get item from '" + params.TableName + "' Table. Error JSON:", JSON.stringify(err, null, 2))
    } else {
      console.log("Got item from '" + params.TableName + "' Table:", JSON.stringify(data, null, 2))
      result = data
    }
  }).promise()
  return result
}

async function getGameData() {
  var params = {
    TableName: "Game",
    Key: {
      id: 1
    }
  }

  await getFromDyDB(params).then((result) => {
    game_params.Item.nowWordIdx = result.Item.nowWordIdx
    game_params.Item.answeredWords = result.Item.answeredWords
  })
}

async function isOne() {
  try {
    connectionData = await ddb.scan({ TableName: "User", ProjectionExpression: 'connectionId' }).promise()
  } catch (e) {
    return { statusCode: 500, body: e.stack }
  }

  if(connectionData.Count == 1) {
    return true
  } else {
    return false
  }
}

async function addScore(connectionId) {
  let params = {
    TableName: "User",
    Key: {
      connectionId: connectionId
    }
  }

  await getFromDyDB(params).then(async (result) => {
    const newAnswerCnt = result.Item.answerCnt + 1
    let params = {
      TableName: "User",
      Item: {
        connectionId: connectionId,
        answerCnt: newAnswerCnt
      }
    }
    await putToDyDB(params)
  })
}

function isAnswer(content) {
  if(game_params.Item.answeredWords.includes(content) == true) {
    return false
  }
  if(word[game_params.Item.nowWordIdx] !== content) {
    return false
  }
  return true
}

async function updateGameData(content) {
  console.log(game_params)
  game_params.Item.answeredWords += (", " + content)
  game_params.Item.nowWordIdx++
  game_params.Item.nowWordIdx %= word.length
  game_params.Item.nowConsonant = getConsonant(word[game_params.Item.nowWordIdx])

  if(game_params.Item.nowWordIdx == 0) {
    game_params.Item.answeredWords = ""
  }

  await putToDyDB(game_params)
}

async function broadcastGameData(event) {
  try {
    connectionData = await ddb.scan({ TableName: "User", ProjectionExpression: 'connectionId' }).promise()
  } catch (e) {
    return { statusCode: 500, body: e.stack }
  }
    
  const apigwManagementApi = new AWS.ApiGatewayManagementApi({
    endpoint: event.requestContext.domainName + '/' + event.requestContext.stage
  })
    
  const postCalls = connectionData.Items.map(async ({ connectionId }) => {
    try {
      await apigwManagementApi.postToConnection({ ConnectionId: connectionId, Data: JSON.stringify(game_params.Item) }).promise()
    } catch (e) {
      return { statusCode: 500, body: e.stack }
    }
  })

  try {
    await Promise.all(postCalls)
  } catch (e) {
    return { statusCode: 500, body: e.stack }
  }
}


async function broadcastMsg(event, payload) {
  try {
    connectionData = await ddb.scan({ TableName: "User", ProjectionExpression: 'connectionId' }).promise()
  } catch (e) {
    return { statusCode: 500, body: e.stack }
  }
    
  const apigwManagementApi = new AWS.ApiGatewayManagementApi({
    endpoint: event.requestContext.domainName + '/' + event.requestContext.stage
  })
    
  const postCalls = connectionData.Items.map(async ({ connectionId }) => {
    try {
      await apigwManagementApi.postToConnection({ ConnectionId: connectionId, Data: JSON.stringify(payload) }).promise()
    } catch (e) {
      return { statusCode: 500, body: e.stack }
    }
  })

  try {
    await Promise.all(postCalls)
  } catch (e) {
    return { statusCode: 500, body: e.stack }
  }
}

function getConsonant(str) {
  let consonant = ["ㄱ","ㄲ","ㄴ","ㄷ","ㄸ","ㄹ","ㅁ","ㅂ","ㅃ","ㅅ","ㅆ","ㅇ","ㅈ","ㅉ","ㅊ","ㅋ","ㅌ","ㅍ","ㅎ"]
  let result = ""
  for(let i = 0 ; i < str.length ; i++) {
    let code = str.charCodeAt(i) - 44032
    if(code > -1 && code < 11172) {
        result += consonant[Math.floor(code/588)]
    }
  }
  return result
}

step4-2) API Gateway 일부수정

Connect, Disconnect외에도 데이터를 받을 수 있도록 람다함수를 API Gateway에 연결한다.

api 게이트웨이 접속 -> android-realtime-api 클릭 -> 아래와 같이 수정

24

API Gateway와 Lambda의 연결이 완료된 것이고 [작업]버튼을 눌러 [API 배포]를 선택해준다.

이전 Websocket Connecton에서 배포해주었지만 $default를 새로 연결하였으니 다시 배포해야한다.

25

배포후 웹소켓 url 확인

26

잘 작동되는지 다시한번 테스트

27

step5) Android 서비스에 연결하기

안드로이드 소스설치 및 수정방법 참고 URL : https://github.com/yebonkim/android-realtime-quiz/blob/master/guide/Android_guide.md

사전에 구현된 안드로이드 서비스를 적당한 폴더에 깃클론을 이용하여 아래와 같이 다운받는다

28

안드로이드 스튜디오를 실행하여 테스트를 진행해본다.

29