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}`
            }
        };
    }
}

주석이 필요 없는 코드.key

%ec%a3%bc%ec%84%9d%ec%9d%b4-%ed%95%84%ec%9a%94%ec%97%86%eb%8a%94-%ec%bd%94%eb%93%9c-002

주석 자체가 나쁜 것은 아니다. 개발자는 주석을 남기는 것을 귀찮아 한다. 그럼에도 주석을 작성해야 되겠다 생각이 들었다면 그것은 나쁜 코드를 작성하고 보완을 하려는 목적일 가능성이 매우 높다. 코드를 보완하는 것이 훨씬 낫다. 다시 말하지만 주석 자체가 나쁜 것은 아니다. 하지만 주석으로 보완하지 않는 코드를 작성하는 습관이 선행되어야 한다 주석을 잘 다는 습관은 그 다음에 익혀도 된다.

%ec%a3%bc%ec%84%9d%ec%9d%b4-%ed%95%84%ec%9a%94%ec%97%86%eb%8a%94-%ec%bd%94%eb%93%9c-003

개발자라면 일정에 쫓겨 나쁜 코드를 작성한 후 나중에 수정해야지 라는 생각을 가진 경험이 있을 것이다. 그리고 개발자라면 그 ‘나중’이 단 한번도 온 적이 없다는 것을 경험으로 알 것이다.

%ec%a3%bc%ec%84%9d%ec%9d%b4-%ed%95%84%ec%9a%94%ec%97%86%eb%8a%94-%ec%bd%94%eb%93%9c-004

선언 의도를 파악할 수 있는 이름, 의미가 있는 이름. 불필요하게 단순화 시킨 이름은 검색이 어렵다. 발음이 쉽지 않은 이름은 커뮤니케이션을 어렵게 한다. 동일한 행위에 대해서는 동일한 단어를 사용하는 것이 좋다. get receive fetch -> receive / 그러나 통일성을 위해 강박적으로 단어를 통일 시켜서도 안된다. 동일한 행위라도 차이가 있다면 구분을 해주어야 한다. add insert append

%ec%a3%bc%ec%84%9d%ec%9d%b4-%ed%95%84%ec%9a%94%ec%97%86%eb%8a%94-%ec%bd%94%eb%93%9c-005%ec%a3%bc%ec%84%9d%ec%9d%b4-%ed%95%84%ec%9a%94%ec%97%86%eb%8a%94-%ec%bd%94%eb%93%9c-006

%ec%a3%bc%ec%84%9d%ec%9d%b4-%ed%95%84%ec%9a%94%ec%97%86%eb%8a%94-%ec%bd%94%eb%93%9c-007

마지막으로 강조하고 싶은 것은 코드는 만들때 잘 만드는 것도 중요하지만 실상 코드는 수정, 추가를 거치면서 나쁜 코드로 가속화 된다. 남이 만든 코드든 자신이 만든 코드든 수정, 추가할때 더욱 유의해야 한다. pull 받은 코드는 push 할때 더욱 깨끗하게.

%ec%a3%bc%ec%84%9d%ec%9d%b4-%ed%95%84%ec%9a%94%ec%97%86%eb%8a%94-%ec%bd%94%eb%93%9c-008

반복된 디버깅에 지쳐 있다면 코드부터 뜯어 고치자. 같은 방식으로 개발하면서 버그가 발생하고 유지보수가 어렵다고 투덜 거리는 건 의미 없다.

구글 포토(Google Photos)에 올려진 사진, 컴퓨터로 전체 다운로드 받는 방법.

구글 포토는 무제한(고품질 사이즈 변환) 용량과 라이브 포토(아이폰 6s이상)를 지원하는 막강한 사진 클라우드 서비스입니다.
그러나 사진은 개개인에게 있어 매우 중요한 것이기 때문에 클라우드 서비스에 의존 했다가 사진을 다 날려 버리지 않을까? 다른 클라우드 서비스를 이용할때 옮길 수 있나 고민이 많이 될 것입니다.

구글은 이런 사용자들의 고민을 위해 모든 사진을 통째로 컴퓨터에 다운로드 받을 수 있는 서비스를 제공하고 있습니다.

https://takeout.google.com/settings/takeout

구글 포토 뿐 아니라 구글에 가입하여 이용 중인 서비스는 모두 위 URL에서 Export  할 수 있습니다.
사실 위 서비스를 찾게 된 것은 Google Play Music에 업로드 한 음원들을 다운로드 할 방법이 없을까 해서 찾았던 것인데, 음원이라는 특성 때문인지 해당 서비스는 전체 다운로드 서비스를 제공하지 않네요. music.google.com에서도 다운로드 횟수는 제한되어 있으므로 구글 뮤직 서비스를 이용하는 분은 꼭 필히 음원을 별도로 보관하셔야 겠습니다.

1

https://takeout.google.com/settings/takeout 으로 접속하면 위와 같은 화면을 보실 수 있습니다. 여러개의 구글 계정을 이용하신다면 우측 상단의 프로필 사진을 눌러 Export하려는 계정이 제대로 선택되어 있는 지 확인을 하셔야 합니다.

Google Photos만 다운로드 할 것이기 때문에 ‘Select None’으로 전체 선택을 해제해 줍니다.

2

Google Photos만 선택 후 ‘Next’

3

대부분의 경우 Google Photos에서 다운로드 받을 파일은 용량이 매우 크기 때문에 자체적으로 압축을 하는데 압축이 완료되면 압축 파일을 다운로드 할 수 있는 URL을 해당 이메일 계정으로 보내 줍니다.

Delivery method에서는 이 외에 추가적인 선택을 할 수 있도록 해주는데

  • Add to Drive : 구글 드라이브에 압축 파일들을 추가 합니다.
  • Add to Dropbox : 드롭박스에 압축 파일들을 업로드 합니다.
  • Add to OneDrive : 마이크로소프트 원드라이브에 압축 파일들을 업로드 합니다.

위 옵션들을 선택하지 않을 거면 그냥 ‘Send download link via email’을 선택하세요.

마지막으로 ‘Create archive’를 누른 후 압축 후 올 이메일을 기다리시면 됩니다.
이메일로 다운로드 링크를 제공하므로 해당 링크에서 파일들을 전부 다운로드 받으면 끝!

전 총 12.4GB로 2기가씩 나눠서 총 7개의 압축 파일이 생성되었네요.

알리익스프레스에서 구매한 무선 충전(qi) 외장 배터리와 아이폰 6s plus 무선 충전하기. 

알리익스프레스에서 $16.99에 산 qi 무선 충전 외장 배터리가 약 3주 만에 도착했습니다.

흔히 안드로이드폰을 충전할때 사용하는 USB 케이블로 충전을 하고,
일반 USB 충전 케이블을 꽂아 기기들을 충전할 수 있고 남은 용량이 표시 되는 건 여타 외장(보조) 배터리와 다를 바 없습니다.
(용량은 6000 mAh)

하지만! 얘는 qi 무선 충전을 지원 한다는 것!

img_0424

사진엔 보이지 않지만 우측에 있는 작은 전원 버튼을 누르면 남은 용량 확인이 가능하고, 무선 충전 모드가 작동 됩니다. 폰을 올려 놓고 전원 버튼만 눌러 주면 무선 충전 시작!

배터리에 외부 전원이 연결된 상태에서는 전원 버튼을 누르지 않아도 무선 충전이 가능합니다.
배터리와 핸드폰을 동시에 충전 가능하고 배터리를 무선 충전 패드로 이용하는 것도 가능한 거죠.

그런데 말입니다.

제가 사용하는 폰은 아이폰 6s plus입니다. qi 무선 충전 기능은 애초에 없죠.
그래서 구입한 것이 바로 qi 무선 충전 패치.

img_0425

사실 qi 무선 충전이 지원되는 외장 보조 배터리를 구매한 이유가 바로 이 패치 때문입니다.
라이트닝 케이블을 꽂는 곳에 꽂아서 사용하는 것이기 때문에 이걸 사용하게 되면 유선 충전이 힘들어 집니다.
처음엔 소형 슬림한 무선 충전 패드를 사서 들고 다닐까 고민 했지만.
아무리 생각해도 너무 불편할 것 같아서 무선 충전 외장 보조 배터리를 사는 것으로 결정했습니다.
슬림 무선 충전 패드가 약 2만원 하므로 금액은 거의 같은 것 같네요.

아래는 실제 무선 충전을 하는 모습입니다.

img_0429img_0428

아이폰 7에서는 qi 무선 충전 기능이 있었으면 좋겠네요.
참고로 qi는 무선 충전 국제 표준 방식입니다.
애플에서 무선 충전 기능을 넣어도 독자 규격이면 가지고 있는 무선 충전 관련 장비들은 쓸모가 없어 지죠. 그럴 일은 없었으면 좋겠지만…