GitLab과 Mattermost에 letsencrypt를 끼얹자.

아래 내용은 GitLab을 Ubuntu 16.04에서 $ sudo apt-get install gitlab-ce 명령으로 설치한 기준으로 작성되었습니다.

GitLab과 Rocket.Chat은 나쁘지 않은 조합이었으나 GitLab이 이미 설치되어 있다면 수월하게 Mattermost를 설치하고 관리할 수 있는데 Rocket.Chat을 굳이 사용하여 관리 포인트를 늘릴 이유는 없을 것 같았다.
그래서 Rocket.Chat을 제거하고 Mattermost를 설치 했다.

Mattermost와 GitLab은 계정 정보 공유가 가능했기 때문에 Mattermost에서 GitLab의 데이타에 접근할때 추가적인 로그인 없이 가능하여 이용하는 입장에서도 편리함이 추가되었다. 이 점은 Slack보다 잇점이었기 때문에 업무용 채팅은 Mattermost로 확정했다.

설치는 이미 설치된 GitLab 서버에 한 줄만 추가하여 주면 되었기에 매우 쉽다.

$ sudo vim /etc/gitlab/gitlab.rb

mattermost_external_url "http://(mattermost.domain.com)"

Mattermost 주소로 사용할 URL만 넣어 준 후

$ sudo gitlab-ctl reconfigure

GitLab 재설정 명령만 넣어 주면 된다.

GitLab과 Mattermost에 letsencrypt를 설정하여 https로 이용하는 방법은, certbot을 이용한다.
letsencrypt를 이용해보았다면 certbot은 익숙할 것이다.
certbot은 letsencrypt에서 발급하는 인증서를 자동으로 발급/갱신하여 주는데 아래의 방법을 이용하면 GitLab과 Mattermost에서도 자동으로 발급/갱신 기능을 이용할 수 있다.

우선 certbot을 설치 한다.

$ sudo add-apt-repository ppa:certbot/certbot
$ sudo apt-get update
$ sudo apt-get install certbot

다음은 letsencrypt에서 인증서 부정 발급을 막기 위해 발급하려는 도메인에 실제 연결된 서버인지 확인하기 위한 과정이 있는데, 이를 위해 GitLab이 제어하는 nginx에 조금의 설정이 필요하다.

우선 certbot이 자동생성하는 검증 파일이 생성될 디렉토리를 생성한다.

$ sudo mkdir -p /var/www/letsencrypt

해당 경로로 http 원격 접근이 가능하도록 GitLab의 nginx 설정을 수정한다.

$ sudo vim /etc/gitlab/gitlab.rb

nginx['custom_gitlab_server_config'] = "location ^~ /.well-known { root /var/www/letsencrypt; }"
mattermost_nginx['custom_gitlab_mattermost_server_config'] = "location ^~ /.well-known { root /var/www/letsencrypt; }"

GitLab 뿐 아니라 Mattermost도 인증서 발급받기 위해 두 줄을 추가해 준다.

$ sudo gitlab-ctl reconfigure

nginx를 재설정 & 재시작 해준다.

이제 certbot에 GitLab과 Mattermost에 연결된 두 도메인에 대한 검증, 발급 등 자동 설정 명령을 내린다.

$ sudo certbot certonly –webroot –webroot-path=/var/www/letsencrypt -d (gitlab.domain.com)
$ sudo certbot certonly –webroot –webroot-path=/var/www/letsencrypt -d (mattermost.domain.com)

IMPORTANT NOTES:
– Congratulations! …

같은 문구를 두 번 보았다면 설정이 제대로 된 것이다. 중요한 것은 Congratulations! 부분이다.

자 이제 발급된 그리고 자동으로 갱신될 인증서를 GitLab 설정에 추가하고 GitLab을 재설정 한 후 https를 즐기면 된다.

$ sudo vim /etc/gitlab/gitlab.rb

다시 GitLab 설정을 열어, 기존에 설정된 http로 시작하는 url을 https로 수정한다.

external_url "https://(gitlab.domain.com)"
mattermost_external_url "https://(mattermost.domain.com)"

http가 https로 리다이렉트 되는 설정을 추가 한다.

nginx['redirect_http_to_https'] = true
mattermost_nginx['redirect_http_to_https'] = true

마지막으로 인증서 경로를 설정한다.

nginx['ssl_certificate'] = "/etc/letsencrypt/live/(gitlab.domain.com)/fullchain.pem"
nginx['ssl_certificate_key'] = "/etc/letsencrypt/live/(gitlab.domain.com)/privkey.pem"
mattermost_nginx['ssl_certificate'] = "/etc/letsencrypt/live/(mattermost.domain.com)/fullchain.pem"
mattermost_nginx['ssl_certificate_key'] = "/etc/letsencrypt/live/(mattermost.domain.com)/privkey.pem"

$ sudo gitlab-ctl reconfigure

GitLab을 재설정하고 https로 연결이 잘 되는 지 확인하자. 잘 접속이 된다면 모든 과정은 끝났다.

추가적으로 GitLab과 Mattermost가 이미 연동되면서 설정된 url이 http로 되어 있는데 리다이렉트 설정이 되어 있어 큰 문제는 없지만 가급적이면 변경해 주는 것이 좋다.

GitLab에서는 Admin area > Applications > System OAuth applications 에서 수정해주면 되며
Mattermost에서는 System Console > AUTHENTICATION > GitLab 에서 수정해주면 된다.

Advertisements

대세 업무용 메신저 Slack을 오픈소스 Rocket.Chat으로 갈아타자. + GitLab 연동

실리콘밸리를 중심으로 IT업계에 업무용 메신저로 대세가 된지 오래된 Slack은 유료 서비스입니다.
Slack이 주는 기능들은 유료 서비스라도 충분히 지불을 할만한 가치가 있죠.
하지만 주로 Slack을 사용하는 IT업계의 특징은 기술자들이 사내에 이미 존재한다는 것이죠.
이러한 재원을 이용하여 유료인 Slack이 아닌 무료 솔루션을 이용하는 방법은 충분히 시도해볼만합니다.

물론 Slack은 그 값어치를 충분히 하기 때문에 비용을 지불하는 것을 아끼지 않길 권하지만 사용자 수 만큼 증가하는 Slack의 비용 정책은 어느 정도 규모가 있는 업체에서는 충분히 부담이 될 수 있다 봅니다.

그래서 Slack의 클론인 Rocket.Chat 오픈소스 프로젝트가 시작된 것일 수도 있죠.

그리고 사내의 중요 정보가 외부 서비스에 보관된다는 것도 보수적인 성향이라면 꺼려질 수 있습니다.

이제 부터 정말 편하고 빠른 Rocket.Chat 설치 방법을 소개해드리겠습니다.
아래의 내용은 모두 Ubuntu 16.04 기준으로 작성되어 있습니다.

우선 Rocket.Chat을 원활하게 운용하기 위해서는 가상 서버 기준으로 2 Cores에 4GB램 이상이어야 합니다.
MongoDB와 node.js를 사용하기 때문에 이를 미리 설치하여야 합니다만,

좀 더 쉽게 snap를 이용하는 방법을 선택하겠습니다.

# sudo snap install rocketchat-server

끝!

snap이 설치되어 있지 않다면

# sudo apt-get install snapd

먼저 설치해주시면 됩니다. snap은 64비트만 지원하므로 이 부분은 미리 해결해 두셔야 합니다.

snap으로 설치한 Rocket.Chat은 우선 2개의 서비스를 관리할 수 있게 제공하는데 MongoDB와 Rocket.Chat Server 입니다.

# sudo service snap.rocketchat-server.rocketchat-mongo restart
# sudo service snap.rocketchat-server.rocketchat-server restart

위 명령으로 각가의 서비스를 재구동할 수 있습니다.
서비스 상황을 살펴 볼땐 systemctl 을 이용하시면 됩니다.

# sudo systemctl status snap.rocketchat-server.rocketchat-mongo.service
# sudo systemctl status snap.rocketchat-server.rocketchat-server.service

로그를 살펴 볼땐 journalctl 을 이용하시면 되구요.

# sudo journalctl -u snap.rocketchat-server.rocketchat-mongo
# sudo journalctl -u snap.rocketchat-server.rocketchat-server

자, 이제 진짜 끝! 이지만…
여기서 끝내기엔 Rocket.Chat을 100% 이용하지 못하는 것입니다.
Rocket.Chat은 웹 기반이긴 하지만 Windows, Mac, Linux, Android, iOS 등의 클라이언트를 제공하고 있습니다. 이들 클라이언트는 https만을 지원하기 때문에 여기까지만 Rocket.Chat을 설치, 설정하였다면 http만 서비스 하기 때문에 클라이언트앱을 이용할 수 없는 거죠.

그래서 snap으로 설치하였다면 간편하게 https를 세팅할 수 있도록 http2, https를 기본 지원하는 Caddy를 지원하고 있습니다.

# sudo rocketchat-server.initcaddy

위 명령으로 간단하게 Rocket.Chat과 Caddy와의 연동이 끝났습니다.
Caddy의 설정도 쉽습니다.

# sudo vim /var/snap/rocketchat-server/current/Caddyfile

설정 파일을 열어서

http://:8080
proxy / localhost:3000 {
  websocket
  transparent
}

http://:8080 부분을 도메인으로 변경하여 주면 됩니다.
Caddy는 letsencrypt를 별도의 추가적인 설정없이 자동 지원하기 때문에 위 설정에 도메인만 넣어 주면 https 설정이 끝납니다. 물론 입력한 도메인이 해당 서버로 제대로 연결되어 있어야겠죠.

domain.com
proxy / localhost:3000 {
  websocket
  transparent
}

Caddy를 재구동하여 브라우저에서 https로 접속 후 제대로 된다면 클라이언트앱과의 연결을 시도해보시면 됩니다.

# service snap.rocketchat-server.rocketchat-caddy restart

마지막으로 하나만 더 덧붙이자면 Slack의 장점 중 하나는 다양한 프로젝트 관련 서비스들과 연동입니다. Slack은 워낙 유명한 서비스들이기 때문에 타 서비스들도 지원을 하고 있죠.
앱 리뷰가 남겨 졌을때 Slack으로 메세지를 받거나, 팀 구성원이 Git Push를 했을때 Slack으로 Commit을 확인하는 등 편리한 연동이 많죠.

Slack이나 Hipchat 등은 잘 지원하는 서비스들이 Rocket.Chat은 지원하지 않는 경우가 많습니다.
여기에 대해 Rocket.Chat은 Webhook 기능을 강화하여 대응을 하고 있습니다.
Webhook inbound를 javascript를 이용하여 대응할 수 있도록 기능이 제공되고 있죠.
제가 사용하고 있는 프로젝트 관리+Git 솔루션인 GitLab도 Rocket.Chat을 공식 지원하진 않지만 Webhook outbound를 제공하기 때문에 이것을 이용하여 Rocket.Chat과 연동할 수 있습니다.

심지어 GitLab에 대응하는 javascript도 이미 누군가 만들어서 공개해 놓았더군요.

https://rocket.chat/docs/administrator-guides/integrations/gitlab/

해당 Javascript를 인용하여 GitLab과의 연동방법을 소개해 놓은 Rocket.Chat 문서입니다.
단지 저 스크립트는 Wiki 생성/수정 시 남기는 Comment를 Rocket.Chat으로 전달해주지 않아, 조금 수정한 스크립트를 아래에 남겨 놓겠습니다.

위 문서에도 명시되어 있지만 해당 스크립트의 원 제작자는 Jonathan Gotti입니다.

/* eslint no-console:0, max-len:0 */
// see  for full json posted by GitLab
const MENTION_ALL_ALLOWED = false; //  ref.replace(/^refs\/(?:tags|heads)\/(.+)$/, '$1');
const displayName = (name) => (name && name.toLowerCase().replace(/\s+/g, '.'));
const atName = (user) => (user && user.name ? '@' + displayName(user.name) : '');
const makeAttachment = (author, text, color) => {
    return {
        author_name: author ? displayName(author.name) : '',
        author_icon: author ? author.avatar_url : '',
        text,
        color: color || NOTIF_COLOR
    };
};
const pushUniq = (array, val) => ~array.indexOf(val) || array.push(val); // eslint-disable-line

class Script { // eslint-disable-line
    process_incoming_request({ request }) {
        try {
            let result = null;
            const channel = request.url.query.channel;
            const event = request.headers['x-gitlab-event'];
            switch (event) {
                case 'Push Hook':
                    result = this.pushEvent(request.content);
                    break;
                case 'Merge Request Hook':
                    result = this.mergeRequestEvent(request.content);
                    break;
                case 'Note Hook':
                    result = this.commentEvent(request.content);
                    break;
                case 'Confidential Issue Hook':
                case 'Issue Hook':
                    result = this.issueEvent(request.content, event);
                    break;
                case 'Tag Push Hook':
                    result = this.tagEvent(request.content);
                    break;
                case 'Pipeline Hook':
                    result = this.pipelineEvent(request.content);
                    break;
                case 'Build Hook': // GitLab = 9.3.0
                    result = this.buildEvent(request.content);
                    break;
                case 'Wiki Page Hook':
                    result = this.wikiEvent(request.content);
                    break;
                default:
                    result = this.unknownEvent(request, event);
                    break;
            }
            if (result && result.content && channel) {
                result.content.channel = '#' + channel;
            }
            return result;
        } catch (e) {
            console.log('gitlabevent error', e);
            return this.createErrorChatMessage(e);
        }
    }

    createErrorChatMessage(error) {
        return {
            content: {
                username: 'Rocket.Cat ErrorHandler',
                text: 'Error occured while parsing an incoming webhook request. Details attached.',
                icon_url: '',
                attachments: [
                    {
                        text: `Error: '${error}', \n Message: '${error.message}', \n Stack: '${error.stack}'`,
                        color: NOTIF_COLOR
                    }
                ]
            }
        };
    }

    unknownEvent(data, event) {
        return {
            content: {
                username: data.user ? data.user.name : (data.user_name || 'Unknown user'),
                text: `Unknown event '${event}' occured. Data attached.`,
                icon_url: data.user ? data.user.avatar_url : (data.user_avatar || ''),
                attachments: [
                    {
                        text: `${JSON.stringify(data, null, 4)}`,
                        color: NOTIF_COLOR
                    }
                ]
            }
        };
    }
    issueEvent(data, event) {
        if (event === 'Confidential Issue Hook' && IGNORE_CONFIDENTIAL) {
            return false;
        }
        const project = data.project || data.repository;
        const state = data.object_attributes.state;
        const action = data.object_attributes.action;
        let user_action = state;
        let assigned = '';

        if (action === 'update') {
            user_action = 'updated';
        }

        if (data.assignee) {
            assigned = `*Assigned to*: @${data.assignee.username}\n`;
        }

        return {
            content: {
                username: 'gitlab/' + project.name,
                icon_url: project.avatar_url || data.user.avatar_url || '',
                text: (data.assignee && data.assignee.name !== data.user.name) ? atName(data.assignee) : '',
                attachments: [
                    makeAttachment(
                        data.user, `${user_action} an issue _${data.object_attributes.title}_ on ${project.name}.
*Description:* ${data.object_attributes.description}.
${assigned}
See: ${data.object_attributes.url}`
                    )
                ]
            }
        };
    }

    commentEvent(data) {
        const project = data.project || data.repository;
        const comment = data.object_attributes;
        const user = data.user;
        const at = [];
        let text;
        if (data.merge_request) {
            const mr = data.merge_request;
            const lastCommitAuthor = mr.last_commit && mr.last_commit.author;
            if (mr.assignee && mr.assignee.name !== user.name) {
                at.push(atName(mr.assignee));
            }
            if (lastCommitAuthor && lastCommitAuthor.name !== user.name) {
                pushUniq(at, atName(lastCommitAuthor));
            }
            text = `commented on MR [#${mr.id} ${mr.title}](${comment.url})`;
        } else if (data.commit) {
            const commit = data.commit;
            const message = commit.message.replace(/\n[^\s\S]+/, '...').replace(/\n$/, '');
            if (commit.author && commit.author.name !== user.name) {
                at.push(atName(commit.author));
            }
            text = `commented on commit [${commit.id.slice(0, 8)} ${message}](${comment.url})`;
        } else if (data.issue) {
            const issue = data.issue;
            text = `commented on issue [#${issue.id} ${issue.title}](${comment.url})`;
        } else if (data.snippet) {
            const snippet = data.snippet;
            text = `commented on code snippet [#${snippet.id} ${snippet.title}](${comment.url})`;
        }
        return {
            content: {
                username: 'gitlab/' + project.name,
                icon_url: project.avatar_url || user.avatar_url || '',
                text: at.join(' '),
                attachments: [
                    makeAttachment(user, `${text}\n${comment.note}`)
                ]
            }
        };
    }

    mergeRequestEvent(data) {
        const user = data.user;
        const mr = data.object_attributes;
        const assignee = mr.assignee;
        let at = [];

        if (mr.action === 'open' && assignee) {
            at = '\n' + atName(assignee);
        } else if (mr.action === 'merge') {
            const lastCommitAuthor = mr.last_commit && mr.last_commit.author;
            if (assignee && assignee.name !== user.name) {
                at.push(atName(assignee));
            }
            if (lastCommitAuthor && lastCommitAuthor.name !== user.name) {
                pushUniq(at, atName(lastCommitAuthor));
            }
        }
        return {
            content: {
                username: `gitlab/${mr.target.name}`,
                icon_url: mr.target.avatar_url || mr.source.avatar_url || user.avatar_url || '',
                text: at.join(' '),
                attachments: [
                    makeAttachment(user, `${mr.action} MR [#${mr.iid} ${mr.title}](${mr.url})\n${mr.source_branch} into ${mr.target_branch}`)
                ]
            }
        };
    }

    pushEvent(data) {
        const project = data.project || data.repository;
        const web_url = project.web_url || project.homepage;
        const user = {
            name: data.user_name,
            avatar_url: data.user_avatar
        };
        // branch removal
        if (data.checkout_sha === null && !data.commits.length) {
            return {
                content: {
                    username: `gitlab/${project.name}`,
                    icon_url: project.avatar_url || data.user_avatar || '',
                    attachments: [
                        makeAttachment(user, `removed branch ${refParser(data.ref)} from [${project.name}](${web_url})`)
                    ]
                }
            };
        }
        // new branch
        if (data.before == 0) { // eslint-disable-line
            return {
                content: {
                    username: `gitlab/${project.name}`,
                    icon_url: project.avatar_url || data.user_avatar || '',
                    attachments: [
                        makeAttachment(user, `pushed new branch [${refParser(data.ref)}](${web_url}/commits/${refParser(data.ref)}) to [${project.name}](${web_url}), which is ${data.total_commits_count} commits ahead of master`)
                    ]
                }
            };
        }
        return {
            content: {
                username: `gitlab/${project.name}`,
                icon_url: project.avatar_url || data.user_avatar || '',
                attachments: [
                    makeAttachment(user, `pushed ${data.total_commits_count} commits to branch [${refParser(data.ref)}](${web_url}/commits/${refParser(data.ref)}) in [${project.name}](${web_url})`),
                    {
                        text: data.commits.map((commit) => `  - ${new Date(commit.timestamp).toUTCString()} [${commit.id.slice(0, 8)}](${commit.url}) by ${commit.author.name}: ${commit.message.replace(/\s*$/, '')}`).join('\n'),
                        color: NOTIF_COLOR
                    }
                ]
            }
        };
    }

    tagEvent(data) {
        const project = data.project || data.repository;
        const web_url = project.web_url || project.homepage;
        const tag = refParser(data.ref);
        const user = {
            name: data.user_name,
            avatar_url: data.user_avatar
        };
        let message;
        if (data.checkout_sha === null) {
            message = `deleted tag [${tag}](${web_url}/tags/)`;
        } else {
            message = `pushed tag [${tag} ${data.checkout_sha.slice(0, 8)}](${web_url}/tags/${tag})`;
        }
        return {
            content: {
                username: `gitlab/${project.name}`,
                icon_url: project.avatar_url || data.user_avatar || '',
                text: MENTION_ALL_ALLOWED ? '@all' : '',
                attachments: [
                    makeAttachment(user, message)
                ]
            }
        };
    }

    createColor(status) {
        switch (status) {
            case 'success':
                return '#2faa60';
            case 'pending':
                return '#e75e40';
            case 'failed':
                return '#d22852';
            case 'canceled':
                return '#5c5c5c';
            case 'created':
                return '#ffc107';
            case 'running':
                return '#607d8b';
            default:
                return null;
        }
    }

    pipelineEvent(data) {
        const project = data.project || data.repository;
        const commit = data.commit;
        const user = {
            name: data.user_name,
            avatar_url: data.user_avatar
        };
        const pipeline = data.object_attributes;

        return {
            content: {
                username: `gitlab/${project.name}`,
                icon_url: project.avatar_url || data.user_avatar || '',
                attachments: [
                    makeAttachment(user, `pipeline returned *${pipeline.status}* for commit [${commit.id.slice(0, 8)}](${commit.url}) made by *${commit.author.name}*`, this.createColor(pipeline.status))
                ]
            }
        };
    }

    buildEvent(data) {
        const user = {
            name: data.user_name,
            avatar_url: data.user_avatar
        };

        return {
            content: {
                username: `gitlab/${data.repository.name}`,
                icon_url: '',
                attachments: [
                    makeAttachment(user, `build named *${data.build_name}* returned *${data.build_status}* for [${data.project_name}](${data.repository.homepage})`, this.createColor(data.build_status))
                ]
            }
        };
    }

    wikiPageTitle(wiki_page) {
        if (wiki_page.action === 'delete') {
            return wiki_page.title;
        }

        return `[${wiki_page.title}](${wiki_page.url})`;
    }

    wikiEvent(data) {
        const user_name = data.user.name;
        const project = data.project;
        const project_path = project.path_with_namespace;
        const wiki_page = data.object_attributes;
        const wiki_page_title = this.wikiPageTitle(wiki_page);
        const wiki_page_message = wiki_page.message;
        const action = wiki_page.action;

        let user_action = 'modified';

        if (action === 'create') {
            user_action = 'created';
        } else if (action === 'update') {
            user_action = 'edited';
        } else if (action === 'delete') {
            user_action = 'deleted';
        }

        return {
            content: {
                username: project_path,
                icon_url: project.avatar_url || data.user.avatar_url || '',
                text: `The wiki page ${wiki_page_title} was ${user_action} by ${user_name} / ${wiki_page_message}`
            }
        };
    }
}

Xbox One 골드 회원 무료 게임 2016/06/16 ~ 07/15 The Crew 소개.

Xbox Live Gold 회원에게 무료로 제공되는 6월 두번째 Xbox One 게임은 The Crew 입니다.
2014년 UbiSoft에서 출시한 오픈 월드 온라인 레이싱 게임입니다.
실제로 해보면 GTA 5에서 레이싱 부분만 떼어낸 듯한 느낌인데 오픈 월드라는 장르적 특성 때문이 아닐까 합니다.

그럼 오픈 월드로 레이싱 외 북미 전역을 다닐 수 있고 차를 수집하고 파츠 업그레이드를 하는 재미를 얻을 수 있는 The Crew를 잠시 살펴 보겠습니다.

Sat_Jun_18_21-10-01_UTC%2B0900_2016

게임 시작과 함께 주인공은 경찰에게 쫓기고 있습니다.

Sat_Jun_18_21-10-35_UTC%2B0900_2016

저 빨간 트럭을 타고 경찰을 따돌려야 합니다. 우측 하단의 미니맵을 보며 체크포인트만 따라가면 쉽게 경찰이 따돌려 집니다. 스토리 상 진행되는 내용이기 때문에 실패하지 않죠.

경찰을 따돌리고 차를 갈아 탄 주인공은

Sat_Jun_18_21-10-56_UTC%2B0900_2016

바로 이 차로 레이싱에 참가합니다.

Sat_Jun_18_21-33-14_UTC%2B0900_2016

레이싱에서 이긴 주인공에게 형이 다가와서 차로 어딘가로 태워 달라 합니다.
형의 부탁이니 당연히 들어줍니다.

Sat_Jun_18_21-34-59_UTC%2B0900_2016

게임 진행 중 목적지(Waypoint)는 우측 하단 미니맵에서도 길이 표시 되지만 게임 화면에서도 차량 위로 표시가 되기 때문에 오픈월드 특성상 길이 정해져 있지 않지만 목적지를 쉽게 찾아 갈 수 있습니다.

Sat_Jun_18_21-35-34_UTC%2B0900_2016

목적지에 도착한 형은 뒤이어 나타난 누군가와 대화를 합니다.

Sat_Jun_18_21-35-56_UTC%2B0900_2016

그리고

Sat_Jun_18_21-36-31_UTC%2B0900_2016

돌아오는 형을….

차에 탄 누군가가 총으로 쏴 버립니다.

Sat_Jun_18_21-36-52_UTC%2B0900_2016

총을 버리고 떠나는 범인.

Sat_Jun_18_21-37-25_UTC%2B0900_2016

쓰러진 형을 안고 절규하는 주인공은
곧이어 나타난 FBI 요원에게 형의 살인범으로 체포됩니다.

Sat_Jun_18_21-40-26_UTC%2B0900_2016

바로 이 새끼. 이 새끼가 나쁜놈이죠.

주인공은 꼼짝없이 누명을 쓰고 감옥에 갇힙니다.

Sat_Jun_18_21-40-54_UTC%2B0900_2016

5년 후 Zoe라는 여자가 찾아 옵니다. 이 여자는 형의 살인범이 누구인지 알려 주고 그와 연루된 부패 FBI 요원이 흑막임을 알려 줍니다.

Sat_Jun_18_21-43-50_UTC%2B0900_2016

바로 저 새끼. 저 새끼가 나쁜 새끼.

Zoe도 FBI요원입니다. 그녀는 부패 FBI 요원을 체포하고 불법 레이싱, 무기 거래, 도박 등을 저지르는 범죄 조직을 소탕하길 원합니다.
바로 그러기 위해 주인공에게 협조를 요구하기 위해 온 것이었죠.
주인공은 그렇게 형의 복수를 하기 위해 정의 구현을 위해 5년만에 세상 밖으로 나옵니다.

우선 주인공은 레이싱으로 범죄 조직에 침투하기 위해 차를 구매합니다.

처음에 게이머는 4종의 차 중 하나를 구매할 수 있는데, The Crew는 실제 제조사의 실제 차량이 나옵니다. 저는 4종 중 디자인이 가장 마음에 든 닛산 차량을 구매 했습니다.

Sat_Jun_18_21-47-07_UTC%2B0900_2016

오! 예뻐!

차량을 구매한 후 파츠 개조를 할 수 있는 곳으로 이동합니다.

Sat_Jun_18_21-47-37_UTC%2B0900_2016

게임 내의 이동은 이런식입니다.

  • Set waypoint : 웨이포인트를 찍어서 직접 이동.
  • Fast travel : 근처로 바로 이동.
  • Enter/Play : 해당 이벤트 실행.

참고로 직접 이동 시 보행자를 차량으로 치려 하면 GTA 시리즈와 달리 칠 수 없습니다. 보행자들이 스파이더맨급 인지 능력과 순발력으로 피해 버립니다. ㅎㅎ
그리고 GTA와 마찬가지로 위험한 운전을 하면 경찰에 추격을 받게 되는데 경찰의 추격이 위협적이지 않고 잡혔을때의 핸디캡도 크지 않기 때문에 게임에서 큰 요소가 되진 않습니다.

Street Tuner로 이동하면 차량을 커스텀할 수 있습니다. 처음이기 때문에 기본적으로 모든 파트가 담긴 Starter Kit을 제공받습니다.

칼라도 변경할 수 있는데 스포츠카는 역시 빨강이죠.

Sat_Jun_18_21-47-59_UTC%2B0900_2016

각 파츠는 메인스토리/서브미션 등 레이싱에서 승리하면 업그레이드 할 수 있습니다.

Sat_Jun_18_21-48-28_UTC%2B0900_2016

그 다음 이동하는 곳은 ‘본부'(HQ Detroit)입니다. 드디어 튜토리얼이 끝났군요.

본부에서는 차량, 콜렉션, 진행도, 파트너 등에 대한 정보를 살펴 볼 수 있는데.

본부의 Workshop에서는 차량의 자세한 부분들을 살펴 볼 수 있습니다.

Sat_Jun_18_21-48-41_UTC%2B0900_2016

고급 차량의 근사한 내부도 볼 수 있으며 (사실 의자를 살펴 보는 것입니다.)

Sat_Jun_18_21-48-51_UTC%2B0900_2016

범퍼등 차량의 구성 요소를 하나씩 뜯어서 볼 수 있고

Sat_Jun_18_21-49-29_UTC%2B0900_2016

겉을 들어내고 파츠도 구경이 가능합니다. 차덕후 들에겐 충분히 흥분 시킬 만한 요소일 것 같네요.

Sat_Jun_18_21-49-40_UTC%2B0900_2016

Collection에서는 수집한 차량들을 모아 볼 수 있습니다.

Sat_Jun_18_21-50-28_UTC%2B0900_2016

그리 많은 차량이 존재하는 것은 아닙니다. The Crew는 실제 브랜드와 차량이 나오는데 라이센스 비용 때문이 아닐까 싶네요. 오픈월드 특성상 제작 비용도 많이 들었을 테니까요.

Sat_Jun_18_21-50-39_UTC%2B0900_2016

Progression에서는 메인스토리 진행도를 확인할 수 있습니다.

Sat_Jun_18_21-50-54_UTC%2B0900_2016

메인스토리 컷씬들도 다시 볼 수 있습니다.

Sat_Jun_18_21-51-03_UTC%2B0900_2016

그 외에도 서브 미션을 통해 매니아 레이싱 유저들을 만족 시킬만한 여러 서브 게임들을 제공합니다.

몬스터 트럭을 몰아 볼 수도 있고

Sat_Jun_18_21-51-20_UTC%2B0900_2016

슈퍼카를 이용한 드래그 레이싱도 즐길 수 있습니다.

Sat_Jun_18_21-51-30_UTC%2B0900_2016

하지만 무엇보다 북미 전역을 차량을 이용해 달려 볼 수 있는 오픈월드라는 점이 The Crew의 가장 큰 특성일 것 같네요.

슈퍼카는 좋아하지만 레이싱 자체를 좋아하지 않기 때문에, 레이싱 유저들의 입장에선 어떤 게임인지에 대해선 쓰질 못한 것 같네요.

하지만 북미 전역을 다닐 수 있다는 점, 실제 브랜드/실제 자동차, 차량을 내부 파츠까지 자세히 살펴 볼 수 있다는 점에서 레이싱 유저들이 한번쯤은 접해볼만한 게임일 것이라 생각합니다

*본 게임은 GameSpot에서 10점 만점에 5점을 받았습니다.

Xbox 360 골드 회원 무료 게임 2016/06/16 ~ 06/30 XCOM Enemy Unknown (한글자막지원) 추천.

Xbox Live Gold 회원에게 무료로 제공되는 6월 두번째 Xbox 360 게임은 XCOM Enemy Unknown 입니다
최근 출시한 XCOM 2의 공식 전작이며 1993년 MS-DOS용으로 출시했던 게임의 리부트 버전이죠. (원래 시리즈는 X-COM 리부트 하면서 – 를 빼버림.)
간단히 게임 소개를 하자면 외계인의 침공에 맞서 전세계가 힘을 모아 XCOM 부대를 창설합니다.
바로 이 XCOM의 지휘관이 되어 전 세계를 외계인으로부터 지키는 것이 내용입니다.
(하지만 XCOM 2의 스토리로 인해 무엇을 해도 미래는 정혀져 있다….)

*Xbox 360은 일반적인 방법으로는 스크린샷을 찍을 수 없어서 아래의 스크린샷은 모두 구글 검색으로 가져온 것임을 밝힙니다.
*Xbox One 하위 호환으로 본 게임은 Xbox One에서도 플레이할 수 있습니다.

xcom_eu_360_m

게임 진행은 부대전술 턴 방식 전투가 메인인데.
전투에서 얻은 외계인 무기와 시체를 이용하여 연구 개발하여 전투력을 올릴 수 있습니다.
병사들은 아무리 열심히 키워도 죽으면 끝이지만… 남는 것은 기술뿐.

d0132254_514aa44521d51

게임 난이도는 쉬움이나 보통을 추천 드립니다. 흔히 하던 SRPG 게임에 익숙하고 잘하였다고 클래식을 선택하면 곤란합니다. 클래식이라는 건 도스시절 오리지날 엑스컴의 난이도를 말하는 것인데.
당시 아재들이 피눈물을 흘렸다는 것을 생각한다면 절대 추천하지 않습니다.
보통 난이도까지는 사용자의 승리를 위해 내부적으로 보정이 되어 있습니다. 사실 보통은 쉬움, 쉬움은 매우쉬움 난이도 인 것이죠.
근데 그래도 보통 난이도도 어렵습니다. 갈수록 어려워집니다.
그렇다면… 클래식은?

게임을 시작하게 되면 민간인들이 외계인들의 공격에 학살당하는 모습이 나오고.
부관이 나와 상황을 설명합니다.

Central_Officer_Bradford

이 인물은 XCOM 2에서도 등장하는 중요인물이며 끊임없이 일거리를 던지고 잔소리를 하는 인물이니 가능한 빨리 익숙해 집시다. ㅎㅎ

자 그럼 부관의 명령을 받고 출동!!!! 하면 전투에 대한 튜토리얼이 시작됩니다.

d0132254_514aa4541c5da

한 턴에 각 유닛은 두번의 행동만을 할 수 있다는 것과 엄폐의 중요성에 대해 설명해 줍니다.

XCOM(EU)_DeltaSquad_survivor

멋지게 외계인들을 사살하고 전투 튜토리얼을 마칩시다.

전투 튜토리얼을 마치게 되면 발음이 특이한 여자 과학자를 만나게 됩니다.

aab431132f9d3a747cd4225e0b768410

그녀는 외계인들의 무기와 시체를 가지고 연구를 합니다.

xcomgame2012-10-1321-18-48-901

연구실에서 연구된 장비는 기술실에서 생산하여 각 병사들에게 제공할 수 있습니다.
S.C.O.P.E를 장착하게 되면 수류탄을 장비할 수 없습니다. 이유는 잘 모르겠지만 어쨋든 그렇습니다.
그러니 쓸데 없이 돈 낭비하지 말고 적당히 생산합시다.

 

전투 튜토리얼을 마치고 얻은 당신의 첫 일병은

f0059347_5062a036ce77c

이렇지만.

계속 연구 개발하다 보면.

frame_0002_1280w

이런 장비들로 무장한 신병을 가질 수 있습니다.

그럼. 외계인으로부터 세계를 지켜 주세요. 사령관님.

 

*참고로 XCOM Enemy Unknown은 Xbox 뿐 아니라 iOS(iPhone/iPad), Android, Windows, PS Vita, PS3, 맥(OS X) 등 다양한 플랫폼을 지원하므로 Xbox가 없다고 하더라도 즐길 수 있습니다.