2016년 12월 7일 수요일

NodeJS로 SQS 다루기

지난 포스팅 Amazon Simple Queue Service (SQS)에서는 AWS 콘솔에서 SQS의 큐를 생성하고 메시지를 보내고 받는 방법에 대해 다뤘다. 이번 포스팅에서는 NodeJS에서 SQS를 다루는 방법에 대해 알아본다.

SQS의 기본 흐름은 다음과 같다. 이 흐름에 따라 코드를 작성할 예정이다.

프로젝트 저장소

https://github.com/icelancer/sqs-example/

환경 변수

SQS에 메시지를 보낼 때 필요한 환경 변수는 다음과 같다.
'use strict';

module.exports = {
  // Amazon credentials
  aws: {
    region: process.env.AWS_REGION || 'ap-northeast-2',
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_KEY
  },

  sqs: {
    apiVersion: '2012-11-05',
    queueUrl: process.env.SQS_URL
  }
};
실질적으로 필요한 값은 AWS_ACCESS_KEY_ID, AWS_SECRET_KEY, SQS_URL 이다.
SQS_URL은 aws 콘솔에서 확인 가능하다.




Sender

메시지를 보내는 코드는 다음과 같다.
'use strict';

const aws = require('aws-sdk');
const env = require('./environment');

const QUEUE_URL = env.sqs.queueUrl;

// AWS Configuration
aws.config.update(env.aws);

// SQS 객체 생성
const sqs = new aws.SQS(env.sqs.apiVersion);

const PARAMS = {
  QueueUrl: QUEUE_URL,
  MessageBody: 'Hello',
  DelaySeconds: 0,
};

sqs.sendMessage(PARAMS).promise()
  .then(() => { console.log('Message 전송 성공'); })
  .catch(error => { console.error(error); });

sendMessage의 파라메터는 PARAMS에 정의해 놓았는데 QueueUrl과 MessageBody, MessageAttributes를 제외하고는 따로 지정하지 않으면 SQS를 만들 때 입력한 설정값을 따른다.

MessageAttributes는 이 코드에서는 사용하지 않았다. MessageBody는 String만 가능한데 다양한 타입의 데이터를 보내고 싶을 때는 MessageAttributes를 사용하면 된다.
{
  MessageAttributes: {
    "City": {
      DataType: "String",
        StringValue: "Any City"
    },
    "Greeting": {
      BinaryValue: <Binary String>,
        DataType: "Binary"
    },
    "Population": {
      DataType: "Number",
        StringValue: "1250800"
    }
  }
}
개인적으로는 MessageAttributes 보다는 MessageBody를 자주 사용하는데 String이라는 제약이 있다보니 객체를 JSON.stringify 메서드를 이용해 문자열로 변환해서 전달한다.
const message = {
  value: 'Hello',  
  number: 1234
};

const PARAMS = {
  QueueUrl: QUEUE_URL,
  MessageBody: JSON.stringify(message),
  DelaySeconds: 0,
};
그 외 파라메터에 대한 내용은 aws 공식 문서를 보면 자세하게 나와있다.
http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/SQS.html#sendMessage-property

Receiver

메시지 수신은 Sender보다는 복잡한데 Receiver는 메시지를 수신한 후 큐에서 해당 메시지를 지우는 역할도 해야한다.
'use strict';

const aws = require('aws-sdk');
const _ = require('lodash');
const env = require('./environment');

const QUEUE_URL = env.sqs.queueUrl;

// AWS Configuration
aws.config.update(env.aws);

// SQS 객체 생성
const sqs = new aws.SQS(env.sqs.apiVersion);

const PARAMS = {
  QueueUrl: QUEUE_URL,
  MaxNumberOfMessages: 10
};

/**
 * SQS에서 받은 메시지를 콘솔에 출력한다.
 **/
function onReceiveMessage(messages) {
  if (_.isNil(messages.Messages) === false) {
    messages.Messages.forEach(message => {
      console.log(message.Body);
    });
  }

  return messages;
}

/**
 * SQS에서 받은 메시지를 삭제한다.
 **/
function deleteMessages(messages) {
  if (_.isNil(messages.Messages)) {
    return;
  }

  // SQS 삭제에 필요한 형식으로 변환한다.
  const entries = messages.Messages.map((msg) => {
    return {
      Id: msg.MessageId,
      ReceiptHandle: msg.ReceiptHandle,
    };
  });

  // 메시지를 삭제한다.
  return sqs.deleteMessageBatch({
    Entries: entries,
    QueueUrl: QUEUE_URL,
  }).promise();

}

sqs.receiveMessage(PARAMS).promise()
  .then(onReceiveMessage)
  .then(deleteMessages)
  .catch(error => {
    console.error(error);
  });


sqs.receiveMessage()를 호출하면 Queue에 있는 메시지를 가져온다. 파라메터로 전달한 PARAMS는 다음처럼 정의했는데 MaxNumberOfMessages는 한 번에 가져올 최대 메시지의 수이다.

const PARAMS = {
  QueueUrl: QUEUE_URL,
  MaxNumberOfMessages: 10
};

주의할 점은 MaxNumberOfMessages를 10으로 설정했다고해도 정확하게 10건을 가져오는 건 아니다. 실제 테스트해보니 Queue에 쌓인 데이터가 많을 때는 10건을 가져올 확률이 높지만 데이터가 적을 수록 10건을 다 채우지 않고 3~4건 정도만 가져올 때가 많다.
설정 가능한 값은 1~10까지이다.

/**
 * SQS에서 받은 메시지를 콘솔에 출력한다.
 **/
function onReceiveMessage(messages) {
  if (_.isNil(messages.Messages) === false) {
    messages.Messages.forEach(message => {
      console.log(message.Body);
    });
  }

  return messages;
}

onReceiverMessage에서는 Queue에서 받은 메시지를 화면에 출력하는 역할을 한다. Queue에서 받은 메시지 형식은 다음과 같다.
{
  ResponseMetadata: { RequestId: 'ab97e6a0-72af-511a-9ba3-b6609a355741' },
  Messages: [{
    Body: "Hello",
    MessageId: "d6790f8d-d575-4f01-bc51-40122EXAMPLE", 
    ReceiptHandle: "AQEBzbVv...fqNzFw=="
  }, {
    Body: "Hello",
    MessageId: "d6790f8d-d575-4f01-bc51-40122EXAMPLE", 
    ReceiptHandle: "AQEBzbVv...fqNzFw=="
  }]
}
Queue에 데이터가 없을 때는 Messages 없이 ResponseMetadata만 반환한다. 따라서 메시지를 처리하기 전에 Messages가 있는지 먼저 확인 후 처리해야 한다.
receiveMessage에 대한 더 상세한 내용은 AWS API 문서를 참고한다.
http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/SQS.html#receiveMessage-property

다음으로는 메시지를 삭제해야 하는데 삭제 처리를 하지 않으면 해당 메시지는 다시 큐에 들어가게 된다.
/**
 * SQS에서 받은 메시지를 삭제한다.
 **/
function deleteMessages(messages) {
  if (_.isNil(messages.Messages)) {
    return;
  }

  // SQS 삭제에 필요한 형식으로 변환한다.
  const entries = messages.Messages.map((msg) => {
    return {
      Id: msg.MessageId,
      ReceiptHandle: msg.ReceiptHandle,
    };
  });

  // 메시지를 삭제한다.
  return sqs.deleteMessageBatch({
    Entries: entries,
    QueueUrl: QUEUE_URL,
  }).promise();

}

메시지를 단건으로 삭제할 때는 deleteMessage를 사용하지만 여러 건을 한꺼번에 지우고 싶다면 deleteMessageBatch 메서드를 호출한다. 삭제할 때는 MessageId와 ReceiptHandle 값이 필요한데 Queue에서 받은 메시지에서 이 값을 추출해서 deleteMessageBatch를 호출할 때 함께 넘겨주면 된다.
인자의 형식은 다음과 같다
{
  Entries: [{
    Id: "FirstMessage", 
    ReceiptHandle: "AQEB1mgl...Z4GuLw=="
   }, {
    Id: "SecondMessage", 
    ReceiptHandle: "AQEBLsYM...VQubAA=="
   }], 
  QueueUrl: "https://sqs.us-east-1.amazonaws.com/80398EXAMPLE/MyQueue"
 };

deleteMessageBatch에 대한 더 상세한 내용은 AWS API를 참고한다.
http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/SQS.html#deleteMessageBatch-property

전체 소스 코드는 https://github.com/icelancer/sqs-example/에서 확인할 수 있다.

2016년 12월 4일 일요일

Amazon Simple Queue Service (SQS)

SQS(Simple Queue Service)는 AWS에서 제공하는 메시지 큐(이하 MQ) 서비스이다. SQS는 이름 그대로 간단한 큐를 제공하는 서비스로 다른 MQ 미들웨어에서 지원하는 많은 기능을 지원하지 않는다. 그럼에도 불구하고 내가 SQS를 실제로 사용하는 이유는 다음과 같다.

편리함
다른 MQ처럼 설치할 필요가 없고 AWS에서 서비스로 제공하고 있어 사용량에 따른 Scaling을 염려할 필요가 없다. MQ를 사용하는 만큼만 돈을 지불하면 되는 방식이라 초기 사용에도 큰 부담이 없다.

안정성
서버 장애에 대한 걱정이 없다. MQ를 사용할 때 High Availability를 위해 클러스터링과 같이 고려해야 할 요소가 몇 가지 있는데 이에 대한 사전 지식 없이도 사용할 수 있다.

배치 처리
다른 MQ처럼 단건으로 메시지를 생산하고 소비하는 방식이 아닌 배치로 생산하고 소비할 수 있다. 단, 배치의 크기는 최대 10건으로 제한적이다.

SQS에서 Queue 만들기


SQS 메인 화면에서 Create New Queue 버튼을 누르면 아래의 화면이 뜬다.


다양한 옵션이 있는데 이 옵션에 대한 설명은 다음과 같다.
  • Queue Name: 사용할 큐의 이름
  • Default Visibility Timeout: receiver가 메시지를 가져가면 다른 receiver가 읽을 수 없는 상태로 유지하는 시간. (최소 0초 ~ 최대 12시간)
  • Message Retention Period: 삭제되지 않은 메시지를 큐에 보관하는 기간. (최소 1분 ~ 14일)
  • Maximum Message Size: 메시지 최대 크기 (최소 1KB ~ 최대 256KB)
  • Delivery Delay: 메시지를 받은 후 Receiver에게 전달하기 전까지의 지연 시간 (최소 0초 ~ 최대 15분)
  • Receive Message Wait Time: Queue가 비어있을 때 Receiver가 queue에 메시지가 들어오기를 기다릴 시간. (최소 0초 ~ 최대 20초)
Dead Letter Queue Settings에 대해 알아보기 전에 Dead Letter Queue에 대해 먼저 알아보자.
Dead Letter Queue는 처리하지 못한 메시지가 저장되는 곳이다. 그럼 처리하지 못했다는 의미는 무슨 말일까? Receiver가 처리하겠다고 가져간 후에 Default Visibility Timeout 내에 Queue에서 삭제되지 않은 메시지를 의미한다. 이런 메시지는 Dead Letter Queue Settings의 설정 값에 따라 지정한 횟수에 다다르면 Queue에서 제거되고 Dead Letter Queue에 저장된다.
  • Use Redrive Policy: Maximum Receivers에 입력한 횟수를 넘어서면 메시지를 Dead Letter Queue로 이동시키겠다는 설정.
  • Dead Letter Queue: 큐 이름
  • Maximum Receivers: 메시지가 재시도 횟수 (최소 1회 ~ 최대 1000회)
이번 포스팅에서는 전체 흐름을 쉽게 파악하기 위해 SQS 콘솔에서 메시지를 보내고 받고 또 지워본다. NodeJS를 이용한 SQS 연동은 다음 포스팅에서 다룰 예정이다.

전체 흐름


SQS에 메시지 보내기

생성한 blog-queue를 선택하고 Queue Actions -> Send a Message를 클릭하면 다음의 팝업이 뜬다.

전송할 메시지를 입력한 후에 Send Message를 클릭하면 메시지가 전송된다.
팝업에서 "Delay delivery of this message by" 를 체크하고 메시지를 보내면 입력한 시간만큼의 딜레이 후에 Receiver가 메시지를 가져갈 수 있다.

메시지를 보내면 다음의 그림처럼 메시지 1개가 있다고 표시된다. 


큐 목록에 Messages AvailableMessages in Flight 이 있는데 각각의 의미는 다음과 같다.
  • Messages Available: Receiver가 가져갈 수 있는 메시지의 수
  • Messages in Flight: 이미 다른 Receiver가 가져간 메시지의 수
Messages in Flight에 표시되는 메시지는 Queue를 만들 때 설정했던 Default Visibility Timeout 시간이 지나면 Messages Available에 카운팅된다.

SQS에서 메시지 조회


View/Delete Messages를 클릭하면 다이얼로그가 뜨는데 "Start Polling For Messages" 버튼을 누르면 다음처럼 전송한 메시지가 나타난다.


이렇게 메시지를 가져오면 Messages in Flight에는 카운팅이 올라간다.


SQS에서 메시지 삭제하기

SQS에서는 처리가 완료되었으면 해당 메시지를 직접 삭제해야 한다.
그림처럼 메시지를 조회 후 체크를 한 후 오른쪽 하단의 "Delete 1 Message"를 누르면 된다.

Message Queue는 왜 사용해야 하는가?

일반적인 서버-클라이언트 구조에서는 사용자가 요청을 하면 서버는 그에 대한 처리를 한 후 사용자에게 응답을 한다. 간단한 서버 구조에서는 굳이 Message Queue(이하 MQ)를 사용할 필요가 없다. 우선 MQ를 적용하려면 RabbitMQ, Kafka, ActiveMQ등 다양한 MQ 중에서 시스템 목적에 맞는 MQ를 선정해야 하고 또 서버에 MQ를 설치해야 한다. 설치로 끝이 아니라 그 사용 방법 및 라이브러리 사용법도 익혀야 하며 MQ가 지원하는 다양한 옵션 중에 시스템 목적에 맞는 옵션을 찾아 설정해야 하고 주고 받을 메시지 구조도 정의해야 할 뿐만 아니라 다양한 전달 방식 중 시스템 목적에 맞는 방식을 선정해야 한다. 단순히 서버에서 처리하면 이런 불편함 없이 간단하게 해결할 수 있는데 왜 MQ를 사용해야 할까?

애플리케이션/시스템 간의 통신

서버 간에 데이터를 주고 받거나 어떤 작업을 요청을 할 때는 항상 시스템 장애를 염두에 두어야 한다. 서버가 갑자기 죽거나 서버 점검 등으로 다운타임이 발생하는 동안에는 요청을 보낼 수가 없다. 요청하는 서버에서 failover 처리를 해놓고 연계 시스템이 다시 살아났을 때 요청을 보내는 방법도 있지만 MQ를 이용하면 더욱 간편하게 처리할 수 있다.
출처: rabbitmq.com
P는 C에 직접 요청하는 것이 아닌 MQ에 전달한다. 그럼 C는 MQ로 부터 요청 데이터를 수신해서 처리한다. 만약 C가 요청을 받을 수 없을 수 없는 상황이라면 해당 요청은 C가 받을 때까지 MQ에 머무르게 된다.
물론 이런 상황에서 MQ에 다운타임이 발생하면 무용지물이 되어버리겠지만, 많은 MQ가 고가용성을 위해 클러스터링 등을 지원한다.

서버 부하가 많은 작업

이미지 처리, 비디오 인코딩, 대용량 데이터 처리와 같은 작업은 메모리와 CPU를 많이 사용한다. 이러한 작업은 동시에 처리할 수 있는 양이 상당히 한정적이어서 필요하다고 무작정 요청을 처리할 수는 없다. 이 때에도 MQ를 사용하면 편리한데 처리해야할 작업을 MQ에 넣어두고 서버는 자신이 동시에 처리할 수 있는 양에 따라 하나의 작업이 끝나면 다음에 처리할 작업을 MQ에서 가져와 처리하면 된다.

부하분산

MQ를 통해 부하분산 처리도 가능하다. 지금까지 설명은 하나의 서버에 대해서만 설명했다.
출처: rabbitmq.com
그림처럼 여러 대의 서버가 하나의 큐를 바라보도록 구성하면 처리할 데이터가 많아져도 각 서버는 자신의 처리량에 맞게 태스크를 가져와 처리할 수 있다. 이러한 구조는  horizontal scaling에 유리하다.

데이터 손실 방지

MQ를 사용하지 않는다면 외부에서 받은 요청을 메모리에 저장했다가 들어온 순서대로 처리하게 할 수도 있다. 하지만 어떠한 이유로 서버가 다운되어 버리면 메모리에 쌓아둔 요청은 모두 없어지고 만다. MQ를 사용하면 이를 방지할 수 있는데 MQ로부터 가져온 태스크를 일정 시간이 지나도록 처리했다고 다시 MQ에 알려주지 않으면 MQ는 이 태스크를 다시 큐에 넣어 다시 처리할 수 있도록 한다.


MQ를 사용할 때 얻을 수 있는 잇점은 많지만 적재적소에 사용해야 한다. 요청 결과를 즉시 응답해야할 때에는 MQ는 어울리지 않는다. 주로 요청과는 별개로 처리할 수 있는 비동기 처리에 어울린다. 또한 서버에서 간단하게 처리할 수 있는 일을 MQ를 통해 전달하면 필요없는 오버헤드될 수도 있다.

2016년 11월 27일 일요일

NVM으로 NodeJS 설치하기

NodeJS를 설치하는 가장 일반적인 방법은 NodeJS 공식 홈페이지에서 바이너리를 다운받아 설치하는 방법이다.  최초 설치가 간편하다는 장점은 있지만 다양한 노드 버전을 오가며 개발할 때는 매번 새로 설치해야 하는 문제가 생겨 오히려 더 불편하다.
이에 이번 포스팅에서는 NVM(Node Version Manager)를 소개하고자 한다.

NVM은 Bash Script를 이용하여 다양한 노드의 버전을 사용할 수 있게 해준다. 물론 Bash Script를 이용하기 때문에 공식적으로 윈도우즈는 지원하지 않는다. 윈도우즈 사용자는 다음의 프로젝트를 이용하면 된다.


NVM 설치하기
NVM을 설치하려면 우선 c++ 컴파일러가 필요하다. OS X에서는 Xcode가 있으면 되고 우분투에서는 build-essential과 libssl-dev가 설치되어 있으면 된다.
설치는 curl와 wget을 통해 할 수 있다.
wget -qO- https://raw.githubusercontent.com/creationix/nvm/v0.32.1/install.sh | bash
curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.32.1/install.sh | bash
터미널에서 nvm 명령어를 입력해서 설치가 잘 되었는지 확인해보자.


간혹 OSX에서 nvm: command not found가 뜨기도 하는데 .bash_profile이 로컬에 없어서 발생하는 문제이다. nvm은 설치할 때 .bash_profile, .zshrc, .profile, .bashrc에 스크립트를 추가하는데 설치시에 이 파일이 없으면 스크립트를 추가할 수 없다.  다음의 명령어를 입력해 .bash_profile을 만들어 준 후에 다시 설치하면 잘 된다.
touch ~/.bash_profile
NVM으로 NodeJS 설치하기
최신 버전 노드 설치하기
nvm install node
특정 버전의 노드 설치하기
nvm install 6.9.1
NVM으로 사용하는 Node 버전 바꾸기
nvm으로 사용하는 노드 버전을 바꾸려면 우선 해당 버전이 nvm으로 설치가 되어 있어야 한다. 로컬에 노드 4.4.0 버전이 설치되어 있다면 다음의 명령어로 변경할 수 있다.
nvm use 4.4.0
nvm use 명령어는 임시적으로 사용하는 노드의 버전을 바꿀뿐 터미널을 나갔다가 다시 들어오면 최신 버전의 노드로 사용하는 노드 버전이 변경되어 있다. 기본 노드 버전을 변경하려면 다음의 명령어를 이용한다.

nvm alias default {노드버전}


NVM으로 설치한 Node 목록 조회
설치된 노드의 버전은 다음의 명령어로 조회할 수 있다.
nvm ls
nvm list

녹색으로 표시된 버전이 현재 사용 중인 버전이다.
바이너리로 설치한 Node 사용하기
nvm이 아닌 NodeJS 공식 홈페이지에서 다운받아 설치한 node도 사용할 수 있다. nvm ls 명령어로 조회시 "system"이라 표시되는 버전이 직접 설치한 노드이다. 사용할 노드 버전을 바꿀 때랑 명령어는 똑같다.

nvm use system

nvm의 기본 노드로 사용하려면 nvm alias default system이라는 명령어를 입력하면 된다.

NVM으로 설치한 Node 제거하기
노드를 설치할 때 nvm install {버전}을 사용했으니 지울 때도 비슷하게 nvm uninstall {버전} 명령어를 사용하면 된다. 단 현재 사용 중인 노드는 제거할 수 없으니 다른 버전으로 바꾼 뒤에 삭제하도록 한다.


2016년 3월 6일 일요일

ES6 Template String

ES6부터는 Template String(Template Literals)을 사용할 수 있다. Template String은 자바스크립트에서 문자열을 보다 쉽게 다룰 수 있도록 해준다.

템플릿 스트링은 backtick( `` ) 문자를 사용한다. 
var greeting = `Yo World!`;


문자열 치환(String Substitution)

그동안 자바스크립트에서 변수의 값을 이용해 문자열을 만드려면 + 기호를 통해 문자열을 합치는 방법을 주로 
그동안 자바스크립트에서 변수 값을 이용해 문자열을 만들려면 + 기호를 통해 문자열을 합쳐서 사용하거나 배열의 join 메서드를 주로 이용했다.
es6에서는 backtick으로 생성한 문자열과 placeholder만 있으면 간편하게 치환할 수 있다.

// Simple string substitution
var name = "Brendan";
console.log(`Yo, ${name}!`);

// => "Yo, Brendan!"

이렇게 변수를 치환하는 것 뿐만 아니라 표현식도 가능하다.

var a = 10;
var b = 10;
console.log(`JavaScript first appeared ${a+b} years ago. Crazy!`);

//=> JavaScript first appeared 20 years ago. Crazy!

console.log(`The number of JS MVC frameworks is ${2 * (a + b)} and not ${10 * (a + b)}.`);
//=> The number of JS frameworks is 40 and not 200.

표현식 뿐만 아니라 함수도 가능하다.

function fn() { return "I am a result. Rarr"; }
console.log(`foo ${fn()} bar`);
//=> foo I am a result. Rarr bar.

다중 문자열 (Multiline Strings)
ES6 이전까지 여러 줄의 문자열을 만드려면 다음처럼 해야했다.

var message = [
    "Multiline ",
    "string"
].join("\n");

let message = "Multiline \n" +
    "string";

ES6에서는 간단하게 다음처럼 하면 된다.

let message = `Multiline
string`;

console.log(message);           // "Multiline
                                //  string"

Tagged Templates
태그 템플릿은 템플릿 리터럴을 변환해 원하는 문자열로 변경할 수 있게 해준다.

var query = '보정동까페거리';
var type = 'json';

var params = parameter`query=${query}&type=${type}`


여기서 parameter는 템플릿 태그명이다.

태그 선언하기
태그는 템플릿 리터럴을 처리하는 함수이다. 함수의 첫번째 인자에는 템플릿 리터럴이 넘어오고 그 다음부터는 리터럴에서 사용하는 데이터가 넘어온다.

태그 함수는 다음과 같은 형태로 작성하면된다.
function parameter(literals, ...substitutions) {
    // return a string
}

사용하는 자바스크립트 엔진에서 아직 가변 인자(rest arguments)를 지원하지 않는다면 다음처럼 정의해서 사용할 수도 있다.
function parameter(literals) {
  var substitutions = [].slice.call(arguments, 1);

  // return a string
}

변수명
literals[ 'query=', '&type=', '' ]
substitutions[ '보정동까페거리', 'json' ]

여기서 주의할 점은 literals는 substitutions보다 항상 1개 더 많다. substitutions.length === literals.length - 1 이다.

parameter 메서드를 완성하면 다음과 같다.
function parameter(literals) {
  var result = literals[0];
  var substitutions = [].slice.call(arguments, 1);

  for (var i = 0; i < substitutions.length; i++) {
    result += encodeURIComponent(substitutions[i]) + literals[i + 1];
  }

  return result;
}

참고로 Template Strings은 NodeJS v4.0.0부터 지원한다.

참고자료



2015년 2월 21일 토요일

Google Cloud Messaging (GCM) 구현하기 - 3/4

클라이언트 앱 만들기

"Google Cloud Messaging (GCM) 구현하기 - 1/4"에서 설명했듯이 GCM의 구성 요소는 서드 파티 서버와 GCM Connection Server, 클라이언트 앱으로 이루어진다. "Google Cloud Messaging (GCM) 구현하기 - 2/4"에서는 GCM Connection Server를 다뤘고 이번 포스팅에서는 클라이언트 앱을 다룬다.

GCM 클라이언트 앱 요구사항
  • 구글 플레이 스토어 앱이 설치된 안드로이드 2.2 이상의 디바이스
  • 구글 플레이 서비스가 제공하고는 GCM의 새로운 기능을 사용하려면 안드로이드 2.3 이상이어야 함.
  • GCM은 연결된 구글 서비스를 이용함. 따라서 3.0 이하 버전에서는 디바이스와 구글 계정이 연동되어 있어야 함. 4.0.4 이상부터는 구글 계정 연동이 필요 요소는 아님.


1. 프로젝트 생성하기

안드로이드 스튜디오에서 프로젝트를 생성한다.
프로젝트 이름은 AndroidGcmClient로 정하고 Blank Activity를 선택했다.


2. Google Play Service 프로젝트에 추가하기

build.gradle 파일을 열어 dependency에 구글 플레이 서비스를 추가한다.
dependencies {
    compile 'com.google.android.gms:play-services:6.5.87'
}


3. 애플리케이션 메니페스트 수정하기

클라이언트 앱에서 GCM을 이용하려면 퍼미션을 추가해야 한다. 안드로이드가 생성한 기본 템플릿 프로젝트에서 추가된 부분은 4~11번째 줄까지이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.icelancer.androidgcmclient" >
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.GET_ACCOUNTS" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />

    <permission android:name="com.icelancer.androidgcmclient.permission.C2D_MESSAGE"
        android:protectionLevel="signature" />
    <uses-permission android:name="com.icelancer.androidgcmclient.permission.C2D_MESSAGE" />

    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        <activity
            android:name=".MainActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

각 퍼미션의 의미
  • "android.permission.INTERNET": 인터넷 연결을 의미한다. 이는 서드 파티 서버와 GCM Connection Server 연결에 필요하다.
  • "android.permission.GET_ACCOUNTS": 안드로이드 4.0.4 이상에서는 필요 없지만, 그 이하 버전에서는 구글 계정과 반드시 연결되어 있어야만 푸쉬 메시지를 보낼 수 있기 때문에 필요한 퍼미션이다. 앱의 최소 버전을 4.0.4로 설정했다면 추가하지 않아도 된다.
  • "android.permission.WAKE_LOCK": 디바이스 화면이 꺼져 있거나, 잠겨 있을 때 화면을 켜고 푸쉬 메시지를 표시할 때 필요하다. wake lock이 필요 없다면 추가하지 않아도 된다.
  • "com.google.android.c2dm.permission.RECEIVE": 안드로이드 앱이 GCM Connection Server에 메시지를 받겠다고 등록하고 또 메시지를 받을 때 필요한 퍼미션이다.
  • "어플리케이션 패키지" + ".permission.C2D_MESSAGE": GCM Connection Server가 전송한 메시지를 다른 앱이 받아서 처리할 수 없도록 추가하는 퍼미션이다. 애플리케이션 패키지는 manifest의 package 속성에 입력한 값을 넣도록 한다.

4. 구글 플레이 서비스 체크하기 (최소 버전 4.0.4 이상 앱 에서는 패스~)

MainActivity에 구글 플레이 서비스 확인 로직을 추가한다.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class MainActivity extends ActionBarActivity {
    private final static int PLAY_SERVICES_RESOLUTION_REQUEST = 9000;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        if (checkPlayServices()) {

        }
    }

    @Override
    protected void onResume() {
        super.onResume();
        checkPlayServices();
    }

    private boolean checkPlayServices() {
        int resultCode = GooglePlayServicesUtil.isGooglePlayServicesAvailable(this);
        if (resultCode != ConnectionResult.SUCCESS) {
            if (GooglePlayServicesUtil.isUserRecoverableError(resultCode)) {
                GooglePlayServicesUtil.getErrorDialog(resultCode, this,
                        PLAY_SERVICES_RESOLUTION_REQUEST).show();
            } else {
                Log.i("ICELANCER", "This device is not supported.");
                finish();
            }
            return false;
        }
        return true;
    }
}

checkPlayService 메서드
이 메서드에서는 구글 플레이 서비스를 이용할 수 있는지 확인하고 이용할 수 없다면 에러 다이얼로그를 띄워 사용자가 구글 플레이 스토어를 설치하거나 설정에서 활성화하도록 가이드한다.

만든 checkPlayService 메서드는 onCreateonResume에서 호출한다. onCreate에서는 구글 플레이 서비스 사용 가능 여부를 확인하지 않고 GCM을 사용하지 못하도록 방지하는 역할을 하며 onResume에서는 뒤로 가기 버튼 등을 통해 다시 MainActivity에 돌아왔을 때 다시 한 번 체크하는 역할을 한다.


5. GCM 등록하기

GCM 서버로 부터 메시지를 받기 전에 먼저 GCM 서버에 등록 과정을 거쳐야 한다. 등록이 완료되면 GCM 서버는 등록 아이디(registration ID)를 발급해주는데 추후 사용하려면 이를 저장해두어야 하고 반드시 외부에 등록 아이디가 유출되어서는 안된다. SENDER_ID의 값은 구글 API 콘솔의 "프로젝트 번호"를 넣는다.


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class MainActivity extends ActionBarActivity {
    private final static int PLAY_SERVICES_RESOLUTION_REQUEST = 9000;

    // SharedPreferences에 저장할 때 key 값으로 사용됨.
    public static final String PROPERTY_REG_ID = "registration_id";

    // SharedPreferences에 저장할 때 key 값으로 사용됨.
    private static final String PROPERTY_APP_VERSION = "appVersion";
    private static final String TAG = "ICELANCER";

    String SENDER_ID = "Your-Sender-ID";

    GoogleCloudMessaging gcm;
    SharedPreferences prefs;
    Context context;

    String regid;
    private TextView mDisplay;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        context = getApplicationContext();

        if (checkPlayServices()) {
            gcm = GoogleCloudMessaging.getInstance(this);
            regid = getRegistrationId(context);

            if (regid.isEmpty()) {
                registerInBackground();
            }
        } else {
            Log.i(TAG, "No valid Google Play Services APK found.");
        }
    }
#27: GoogleCloudMessaging 클래스의 인스턴스를 생성한다.
#28: 기존에 발급받은 등록 아이디를 가져온다.
#30: 기존에 발급된 등록 아이디가 없으면 registerInBackground 메서드를 호출해 GCM 서버에 발급을 요청한다.

getRegistrationIdregisterInBackground를 구현해야하는데 우선 getRegistrationId를 먼저 구현한다.
getRegistrationId 메서드는 SharedPreference에 등록 아이디가 저장되어 있는지 확인하고 없으면 빈 문자열을 있으면 기존에 등록된 등록 아이디를 반환하는 역할을 수행한다. 또한 앱 버전이 등록 아이디를 발급받은 시점과 달라도 빈 문자열을 반환한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
    private String getRegistrationId(Context context) {
        final SharedPreferences prefs = getGCMPreferences(context);
        String registrationId = prefs.getString(PROPERTY_REG_ID, "");
        if (registrationId.isEmpty()) {
            Log.i(TAG, "Registration not found.");
            return "";
        }
        
        // 앱이 업데이트 되었는지 확인하고, 업데이트 되었다면 기존 등록 아이디를 제거한다.
        // 새로운 버전에서도 기존 등록 아이디가 정상적으로 동작하는지를 보장할 수 없기 때문이다.
        int registeredVersion = prefs.getInt(PROPERTY_APP_VERSION, Integer.MIN_VALUE)
        int currentVersion = getAppVersion(context);
        if (registeredVersion != currentVersion) {
            Log.i(TAG, "App version changed.");
            return "";
        }
        return registrationId;
    }

    private SharedPreferences getGCMPreferences(Context context) {
        return getSharedPreferences(MainActivity.class.getSimpleName(),
                Context.MODE_PRIVATE);
    }

    private static int getAppVersion(Context context) {
        try {
            PackageInfo packageInfo = context.getPackageManager()
                    .getPackageInfo(context.getPackageName(), 0);
            return packageInfo.versionCode;
        } catch (PackageManager.NameNotFoundException e) {
            // should never happen
            throw new RuntimeException("Could not get package name: " + e);
        }
    }
#2: 이전에 저장해둔 등록 아이디를 SharedPreferences에서 가져온다.
#3: 저장해둔 등록 아이디가 없으면 빈 문자열을 반환한다.
#13: 이전에 등록 아이디를 저장한 앱의 버전과 현재 버전을 비교해 버전이 변경되었으면 빈 문자열을 반환한다.

등록 아이디가 없거나 앱 버전이 변경되면 registerInBackground 메서드를 호출한다. 메서드 이름에서 알 수 있듯이 백그라운드에서 호출하는 이유는 GCM의 register(), unregister() 메서드가 수행하는 중에 애플리케이션의 동작에 영향이 없도록 하기 위함이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
    private void registerInBackground() {
        new AsyncTask<Void, Void, String>() {
            @Override
            protected String doInBackground(Void... params) {
                String msg = "";
                try {
                    if (gcm == null) {
                        gcm = GoogleCloudMessaging.getInstance(context);
                    }
                    regid = gcm.register(SENDER_ID);
                    msg = "Device registered, registration ID=" + regid;

                    // 서버에 발급받은 등록 아이디를 전송한다.
                    // 등록 아이디는 서버에서 앱에 푸쉬 메시지를 전송할 때 사용된다.
                    sendRegistrationIdToBackend();

                    // 등록 아이디를 저장해 등록 아이디를 매번 받지 않도록 한다.
                    storeRegistrationId(context, regid);
                } catch (IOException ex) {
                    msg = "Error :" + ex.getMessage();
                    // If there is an error, don't just keep trying to register.
                    // Require the user to click a button again, or perform
                    // exponential back-off.
                }
                return msg;
            }

            @Override
            protected void onPostExecute(String msg) {
                mDisplay.append(msg + "\n");
            }

        }.execute(null, null, null);
    }

    private void storeRegistrationId(Context context, String regid) {
        final SharedPreferences prefs = getGCMPreferences(context);
        int appVersion = getAppVersion(context);
        Log.i(TAG, "Saving regId on app version " + appVersion);
        SharedPreferences.Editor editor = prefs.edit();
        editor.putString(PROPERTY_REG_ID, regid);
        editor.putInt(PROPERTY_APP_VERSION, appVersion);
        editor.commit();
    }

    private void sendRegistrationIdToBackend() {

    }
#10: gcm.register 메서드가 호출되면 등록이 완료된다.
#36: SharedPreferences에 발급받은 등록 아이디를 저장해 등록 아이디를 여러 번 받지 않도록 하는 데 사용한다.
#46: 등록 아이디를 서드 파티 서버(앱이랑 통신하는 서버)에 전달한다. 서드 파티 서버는 이 등록 아이디를 사용자마다 따로 저장해두었다가 특정 사용자에게 푸쉬 메시지를 전송할 때 사용한다.

이제 MainActivity에서 할 일은 끝났다.
완료된 소스 코드는 다음 링크에서 확인할 수 있다.
MainActivity: https://github.com/icelancer-blog/android_gcm_client/blob/master/app/src/main/java/com/icelancer/androidgcmclient/MainActivity.java
activity_main.xml: https://github.com/icelancer-blog/android_gcm_client/blob/master/app/src/main/res/layout/activity_main.xml

이제 다음으로 할 일은 메시지를 받기 위해 BroadcastReceiver와 IntentService를 구현하는 일이다.

6. 메시지 받기

GCM 서버로부터 메시지를 받아 사용자에게 보여주려면 BroadcastReceiver와 IntentService를 활용해야 한다.
BroadcastReceiver는 안드로이드 시스템에서 발생하는 수많은 액션(배터리 용량 부족, SMS 수신 등등)이 발생하면 이를 수신해 처리하는 역할을 한다. 여기에서는 안드로이드 기기가 GCM 메시지를 수신하면 이를 수신하는 데 사용한다.

public class GcmBroadcastReceiver extends WakefulBroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        // Explicitly specify that GcmIntentService will handle the intent.
        ComponentName comp = new ComponentName(context.getPackageName(),
                GcmIntentService.class.getName());
        // Start the service, keeping the device awake while it is launching.
        startWakefulService(context, (intent.setComponent(comp)));
        setResultCode(Activity.RESULT_OK);
    }
}
startWakefulService 메서드를 호출해 GcmIntentService를 호출한다. GcmBroadcastReceiver는GCM 메시지를 수신하면 단순히 GcmIntentService를 실행하는 역할만 수행한다고 보면 된다.
다음은 IntentService를 구현한다. IntentService에서는 GCM 서버에서 받은 메시지를 실제 화면에 보여준다.


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
public class GcmIntentService extends IntentService {
    public static final String TAG = "icelancer";
    public static final int NOTIFICATION_ID = 1;
    private NotificationManager mNotificationManager;
    NotificationCompat.Builder builder;

    public GcmIntentService() {
//        Used to name the worker thread, important only for debugging.
        super("GcmIntentService");
    }

    @Override
    protected void onHandleIntent(Intent intent) {
        Bundle extras = intent.getExtras();
        GoogleCloudMessaging gcm = GoogleCloudMessaging.getInstance(this);
        // The getMessageType() intent parameter must be the intent you received
        // in your BroadcastReceiver.
        String messageType = gcm.getMessageType(intent);

        if (!extras.isEmpty()) {
           if (GoogleCloudMessaging.
                    MESSAGE_TYPE_MESSAGE.equals(messageType)) {
               // This loop represents the service doing some work.
               for (int i=0; i<5; i++) {
                   Log.i(TAG, "Working... " + (i + 1)
                           + "/5 @ " + SystemClock.elapsedRealtime());
                   try {
                       Thread.sleep(5000);
                   } catch (InterruptedException e) {
                   }
               }
               Log.i(TAG, "Completed work @ " + SystemClock.elapsedRealtime());
               // Post notification of received message.
               sendNotification("Received: " + extras.toString());
               Log.i(TAG, "Received: " + extras.toString());
            }
        }
        // Release the wake lock provided by the WakefulBroadcastReceiver.
        GcmBroadcastReceiver.completeWakefulIntent(intent);
    }

    // Put the message into a notification and post it.
    // This is just one simple example of what you might choose to do with
    // a GCM message.
    private void sendNotification(String msg) {
        mNotificationManager = (NotificationManager)
                this.getSystemService(Context.NOTIFICATION_SERVICE);

        PendingIntent contentIntent = PendingIntent.getActivity(this, 0,
                new Intent(this, MainActivity.class), 0);

        NotificationCompat.Builder mBuilder =
                new NotificationCompat.Builder(this)
                        .setSmallIcon(R.drawable.gcm)
                        .setContentTitle("GCM Notification")
                        .setStyle(new NotificationCompat.BigTextStyle()
                                .bigText(msg))
                        .setContentText(msg);

        mBuilder.setContentIntent(contentIntent);
        mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());
    }
}
24~31번째 줄은 이곳에서 필요한 작업을 하면 된다고 넣은 부분이고 실제로 사용자에게 알림을 주는 부분은 34번째 줄이다. 54번째 줄의 아이콘은 알림시 보여줄 아이콘을 넣으면 된다.
클라이언트 로직이 마무리 되었다. 이제 각자 구현하려는 기능에 맞게 sendNotification 메서드를 수정하면 된다.

마지막으로 남은 작업은 메니페스트 파일에 BroadcastReceiver와 IntentService를 추가해주기만 하면 된다.
<application ...>
        <receiver
            android:name=".GcmBroadcastReceiver"
            android:permission="com.google.android.c2dm.permission.SEND" >
            <intent-filter>
                <action android:name="com.google.android.c2dm.intent.RECEIVE" />
                <category android:name="com.example.gcm" />
            </intent-filter>
        </receiver>
        <service android:name=".GcmIntentService" />
    </application>

이제 테스트를 해보자.
"Google Cloud Messaging (GCM) 구현하기 - 2/4"에서 API를 발급받았는데, 테스트하려면 API 키가 필요하다. 그리고 앱을 실행하면 등록 아이디가 화면에 표시되는데 그 등록 아이디도 필요하다.



터미널에서 다음의 명령어를 실행하면 된다. API키에는 발급받은 API 키를 입력하고 등록 아이디는 클라이언트 앱이 GCM 서버로부터 받은 등록 아이디를 입력한다.

curl --header "Authorization: key=API키” --header Content-Type:"application/json" https://android.googleapis.com/gcm/send  -d "{\"registration_ids\”:[\”등록 아이디\”]}”


잠시 뒤 앱에서 확인해보면 푸쉬 알림이 온 것을 확인할 수 있다.

전체 소스는 github에서 확인할 수 있다.
https://github.com/icelancer-blog/android_gcm_client