
이번에 Spring 프로젝트를 진행하면서 GCM 서버를 CCS 방식으로 구현하여 보았습니다. CCS 방식은 구글 서버와 지속적인 커넥션을 유지하며 비동기 양방향 통신을 하는 XMPP방식의 엔드포인트를 의미합니다. XMPP의 비동기적인 특성에 의해 적은 리소스로 더 많은 메시지를 발송할 수 있으며 양방향 통신을 지원하기 때문에 서버에서 디바이스로 메시지를 보내는것뿐 아니라 반대로 디바이스에서 서버로 응답 메시지를 되돌려 보낼 수도 있습니다. 이 때에 디바이스는 메시지 수신을 위해 연결했던 커넥션을 재활용하므로 배터리를 아낄 수 있습니다. 이렇게 좋은 장점들이 있지만 저는 우선 단방향 통신이면 충분한것 같습니다.
1. gradle에 필요한 라이브러리 추가하기
프로젝트가 gradle 프로젝트일경우 다음과 같이 smack 라이브러리를 dependencies 에 추가합니다. gradle 프로젝트가 아닐경우 적당히 라이브러리를 다운받아 프로젝트에 추가하시면 됩니다.
dependencies {
  ...
  compile "org.igniterealtime.smack:smack-core:4.0.4"
  compile "org.igniterealtime.smack:smack-tcp:4.0.4"
}
2. smack 클라이언트 클래스 작성
smack을 이용하여 구글 서버와 CCS 방식으로 통신하기 위해 클라이언트 클래스의 작성이 필요합니다. 깊게 생각할 것 없이 구글에서 제공하는 코드를 사용합니다.
public class SmackCcsClient {
    private static final Logger logger = Logger.getLogger("SmackCcsClient");
    private static final String GCM_SERVER = "gcm.googleapis.com";
    private static final int GCM_PORT = 5235;
    private static final String GCM_ELEMENT_NAME = "gcm";
    private static final String GCM_NAMESPACE = "google:mobile:data";
    static {
        ProviderManager.addExtensionProvider(GCM_ELEMENT_NAME, GCM_NAMESPACE,
                new PacketExtensionProvider() {
                    @Override
                    public PacketExtension parseExtension(XmlPullParser parser) throws
                            Exception {
                        String json = parser.nextText();
                        return new GcmPacketExtension(json);
                    }
                });
    }
    private XMPPConnection connection;
    /**
     * Indicates whether the connection is in draining state, which means that it
     * will not accept any new downstream messages.
     */
    protected volatile boolean connectionDraining = false;
    /**
     * Sends a downstream message to GCM.
     *
     * @return true if the message has been successfully sent.
     */
    public boolean sendDownstreamMessage(String jsonRequest) throws
            NotConnectedException {
        if (!connectionDraining) {
            send(jsonRequest);
            return true;
        }
        logger.info("Dropping downstream message since the connection is draining");
        return false;
    }
    /**
     * Returns a random message id to uniquely identify a message.
     *
     * <p>Note: This is generated by a pseudo random number generator for
     * illustration purpose, and is not guaranteed to be unique.
     */
    public String nextMessageId() {
        return "m-" + UUID.randomUUID().toString();
    }
    /**
     * Sends a packet with contents provided.
     */
    protected void send(String jsonRequest) throws NotConnectedException {
        Packet request = new GcmPacketExtension(jsonRequest).toPacket();
        connection.sendPacket(request);
    }
    /**
     * Handles an upstream data message from a device application.
     *
     * <p>This sample echo server sends an echo message back to the device.
     * Subclasses should override this method to properly process upstream messages.
     */
    protected void handleUpstreamMessage(Map<String, Object> jsonObject) {
        // PackageName of the application that sent this message.
        String category = (String) jsonObject.get("category");
        String from = (String) jsonObject.get("from");
        @SuppressWarnings("unchecked")
        Map<String, String> payload = (Map<String, String>) jsonObject.get("data");
        payload.put("ECHO", "Application: " + category);
        // Send an ECHO response back
        String echo = createJsonMessage(from, nextMessageId(), payload,
                "echo:CollapseKey", null, false);
        try {
            sendDownstreamMessage(echo);
        } catch (NotConnectedException e) {
            logger.log(Level.WARNING, "Not connected anymore, echo message is not sent", e);
        }
    }
    /**
     * Handles an ACK.
     *
     * <p>Logs a INFO message, but subclasses could override it to
     * properly handle ACKs.
     */
    protected void handleAckReceipt(Map<String, Object> jsonObject) {
        String messageId = (String) jsonObject.get("message_id");
        String from = (String) jsonObject.get("from");
        logger.log(Level.INFO, "handleAckReceipt() from: " + from + ", messageId: " + messageId);
    }
    /**
     * Handles a NACK.
     *
     * <p>Logs a INFO message, but subclasses could override it to
     * properly handle NACKs.
     */
    protected void handleNackReceipt(Map<String, Object> jsonObject) {
        String messageId = (String) jsonObject.get("message_id");
        String from = (String) jsonObject.get("from");
        logger.log(Level.INFO, "handleNackReceipt() from: " + from + ", messageId: " + messageId);
    }
    protected void handleControlMessage(Map<String, Object> jsonObject) {
        logger.log(Level.INFO, "handleControlMessage(): " + jsonObject);
        String controlType = (String) jsonObject.get("control_type");
        if ("CONNECTION_DRAINING".equals(controlType)) {
            connectionDraining = true;
        } else {
            logger.log(Level.INFO, "Unrecognized control type: %s. This could happen if new features are " + "added to the CCS protocol.",
            controlType);
        }
    }
    /**
     * Creates a JSON encoded GCM message.
     *
     * @param to RegistrationId of the target device (Required).
     * @param messageId Unique messageId for which CCS will send an
     *         "ack/nack" (Required).
     * @param payload Message content intended for the application. (Optional).
     * @param collapseKey GCM collapse_key parameter (Optional).
     * @param timeToLive GCM time_to_live parameter (Optional).
     * @param delayWhileIdle GCM delay_while_idle parameter (Optional).
     * @return JSON encoded GCM message.
     */
    public static String createJsonMessage(String to, String messageId,
                                           Map<String, String> payload, String collapseKey, Long timeToLive,
                                           Boolean delayWhileIdle) {
        Map<String, Object> message = new HashMap<String, Object>();
        message.put("to", to);
        if (collapseKey != null) {
            message.put("collapse_key", collapseKey);
        }
        if (timeToLive != null) {
            message.put("time_to_live", timeToLive);
        }
        if (delayWhileIdle != null && delayWhileIdle) {
            message.put("delay_while_idle", true);
        }
        message.put("message_id", messageId);
        message.put("data", payload);
        return JSONValue.toJSONString(message);
    }
    /**
     * Creates a JSON encoded ACK message for an upstream message received
     * from an application.
     *
     * @param to RegistrationId of the device who sent the upstream message.
     * @param messageId messageId of the upstream message to be acknowledged to CCS.
     * @return JSON encoded ack.
     */
    protected static String createJsonAck(String to, String messageId) {
        Map<String, Object> message = new HashMap<String, Object>();
        message.put("message_type", "ack");
        message.put("to", to);
        message.put("message_id", messageId);
        return JSONValue.toJSONString(message);
    }
    /**
     * Connects to GCM Cloud Connection Server using the supplied credentials.
     *
     * @param senderId Your GCM project number
     * @param apiKey API Key of your project
     */
    public void connect(long senderId, String apiKey)
            throws XMPPException, IOException, SmackException {
        ConnectionConfiguration config =
                new ConnectionConfiguration(GCM_SERVER, GCM_PORT);
        config.setSecurityMode(SecurityMode.enabled);
        config.setReconnectionAllowed(true);
        config.setRosterLoadedAtLogin(false);
        config.setSendPresence(false);
        config.setSocketFactory(SSLSocketFactory.getDefault());
        config.setDebuggerEnabled(false);
        connection = new XMPPTCPConnection(config);
        connection.connect();
        connection.addConnectionListener(new LoggingConnectionListener());
        // Handle incoming packets
        connection.addPacketListener(new PacketListener() {
            @Override
            public void processPacket(Packet packet) {
                logger.log(Level.INFO, "Received: " + packet.toXML());
                Message incomingMessage = (Message) packet;
                GcmPacketExtension gcmPacket =
                        (GcmPacketExtension) incomingMessage.
                                getExtension(GCM_NAMESPACE);
                String json = gcmPacket.getJson();
                try {
                    @SuppressWarnings("unchecked")
                    Map<String, Object> jsonObject =
                            (Map<String, Object>) JSONValue.parseWithException(json);
                    // present for "ack"/"nack", null otherwise
                    Object messageType = jsonObject.get("message_type");
                    if (messageType == null) {
                        // Normal upstream data message
                        handleUpstreamMessage(jsonObject);
                        // Send ACK to CCS
                        String messageId = (String) jsonObject.get("message_id");
                        String from = (String) jsonObject.get("from");
                        String ack = createJsonAck(from, messageId);
                        send(ack);
                    } else if ("ack".equals(messageType.toString())) {
                        // Process Ack
                        handleAckReceipt(jsonObject);
                    } else if ("nack".equals(messageType.toString())) {
                        // Process Nack
                        handleNackReceipt(jsonObject);
                    } else if ("control".equals(messageType.toString())) {
                        // Process control message
                        handleControlMessage(jsonObject);
                    } else {
                        logger.log(Level.WARNING,
                                "Unrecognized message type (%s)",
                                messageType.toString());
                    }
                } catch (ParseException e) {
                    logger.log(Level.SEVERE, "Error parsing JSON " + json, e);
                } catch (Exception e) {
                    logger.log(Level.SEVERE, "Failed to process packet", e);
                }
            }
        }, new PacketTypeFilter(Message.class));
        // Log all outgoing packets
        connection.addPacketInterceptor(new PacketInterceptor() {
            @Override
            public void interceptPacket(Packet packet) {
                logger.log(Level.INFO, "Sent: {0}", packet.toXML());
            }
        }, new PacketTypeFilter(Message.class));
        connection.login(senderId + "@gcm.googleapis.com", apiKey);
    }
    /**
     * XMPP Packet Extension for GCM Cloud Connection Server.
     */
    private static final class GcmPacketExtension extends DefaultPacketExtension {
        private final String json;
        public GcmPacketExtension(String json) {
            super(GCM_ELEMENT_NAME, GCM_NAMESPACE);
            this.json = json;
        }
        public String getJson() {
            return json;
        }
        @Override
        public String toXML() {
            return String.format("<%s xmlns=\"%s\">%s</%s>",
                    GCM_ELEMENT_NAME, GCM_NAMESPACE,
                    StringUtils.escapeForXML(json), GCM_ELEMENT_NAME);
        }
        public Packet toPacket() {
            Message message = new Message();
            message.addExtension(this);
            return message;
        }
    }
    private static final class LoggingConnectionListener
            implements ConnectionListener {
        @Override
        public void connected(XMPPConnection xmppConnection) {
            logger.info("Connected.");
        }
        @Override
        public void authenticated(XMPPConnection xmppConnection) {
            logger.info("Authenticated.");
        }
        @Override
        public void reconnectionSuccessful() {
            logger.info("Reconnecting..");
        }
        @Override
        public void reconnectionFailed(Exception e) {
            logger.log(Level.INFO, "Reconnection failed.. ", e);
        }
        @Override
        public void reconnectingIn(int seconds) {
            logger.log(Level.INFO, "Reconnecting in %d secs", seconds);
        }
        @Override
        public void connectionClosedOnError(Exception e) {
            logger.info("Connection closed on error.");
        }
        @Override
        public void connectionClosed() {
            logger.info("Connection closed.");
        }
    }
}
3. Spring Sender 컴포넌트 작성
위에서 작성한 SmackCssClient는 한번 초기화 되면 구글 서버와 연결을 맺고 커넥션을 지속합니다. 이 하나의 커넥션으로 다수의 메시지를 보낼 수 있으며 외부의 요인에 의해 연결이 끊어질 경우 자동으로 다시 재연결을 하게 됩니다. 그러므로 Spring에서 컴포넌트로 등록하여 이 객체(빈)의 관리를 컨테이너에 맞기고 생성 직후 커넥션을 맺고 비동기 매소드인 send를 통해 딜레이 없이 메시지를 발송할 수 있도록 하였습니다. (@Async가 사용가능하도록 구현되어있어야 합니다)
페이로드를 만드는 부분에서 키로 msg를 사용하는데 그것은 현재의 프로젝트에 맞춰 적절히 구현하시면 됩니다.
@Component
public class GcmCcsSender {
    private static final Logger LOG = LoggerFactory.getLogger(GcmCcsSender.class);
    private static final long senderId = {SENDER_ID};
    private static final String password = "{PASSWORD}";
    private SmackCcsClient mCssClient = new SmackCcsClient();
    @PostConstruct
    public void init() {
        try {
            mCssClient.connect(senderId, password);
        } catch (Exception e) {
            LOG.error(e.getLocalizedMessage());
        }
    }
    @Async
    public void send(String registrationId, String message) {
        try {
            Map<String, String> payload = new HashMap<String, String>();
            payload.put("msg", message);
            String jsonMessage = mCssClient.createJsonMessage(registrationId, mCssClient.nextMessageId(), payload, "", 10000L, true);
            mCssClient.sendDownstreamMessage(jsonMessage);
        } catch (Exception e) {
            LOG.error(e.getLocalizedMessage());
        }
    }
}
 4. 메시지 발송하기
다음은 메시지 발송을 위한 단순한 예시입니다. 적당한 방법으로 서버에서 수집한 사용자들의 registrationId를 이용하여 메시지를 발송하시면 됩니다.
@RestController
@RequestMapping("/test")
public class TestController {
    @Autowired
    private GcmCcsSender mGcmCcsSender;
    @RequestMapping(value = "/{registrationId}", method = RequestMethod.GET)
    public void monsters(@PathVariable("registrationId") String registrationId) {
        String message = "TEST MESSAGE";
        mGcmCcsSender.send(registrationId, message);
    }
}
참고 : http://developer.android.com/google/gcm/ccs.html