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

홍대입구역 8번 출구 더치 맥주 ‘뉴욕야시장’

술, 담배, 커피를 좋아하는 취향이기에 와인으로 숙성한 드립 커피를 좋아합니다. 한번에 좋아하는 것을 두 가지나 즐길 수 있으니까요.

반대로 즐길 수 있는 더치 맥주가 있기에 마셔 보았습니다.

홍대입구역 8번 출구 근처에 오픈한지 얼마 안된 ‘뉴욕야시장’을 찾았습니다.

IMG_0153

핑거 스테이크? 독특한 안주네요.
외에도 독특한 안주가 많습니다.

IMG_0152IMG_0155

술도 종류가 다양하군요. 리타 칵테일도 즐겨보고 싶지만 오늘은 맥주가 목적이니 패스합니다.

IMG_0158

IMG_0157

안주로 플래터 세트를 시킵니다.

IMG_0161IMG_0162IMG_0163

다양한 안주를 세트로 즐길 수 있어서 좋았지만 다음엔 좀 더 특이한 안주를 먹어 보고 싶습니다. 오늘은 일단 이걸로 만족(?)하죠.

IMG_0156IMG_0160IMG_0159

술은 우선 생크림맥주를 시킵니다. 뭔가 기대를 하고 시켰는데 생각보다 별로였어요. 크림 때문에 맥주를 마시기 힘들고 생크림의 단맛이 맥주 맛과 잘 섞이지 않아서 오히려 맥주 맛이 쓰게 느껴집니다. 비추합니다.

다음은…

IMG_0154

닭갈비 입니다. 스테이크로 양을 많이 채웠더군요. 치즈와 닭갈비는 찾기 힘듭니다.
매운 안주를 찾는다면 추천입니다.

IMG_0169IMG_0168

그리고 더치 맥주!

맛있었습니다. 다음엔 아마도 술은 더치 맥주만 마실 것 같네요.
독특한 안주도 그렇지만 더치 맥주 덕에 다시 찾을 생각입니다.

홍대 점심 식사 ‘회생식당’ 스끼야끼

IMG_0139.jpg

점심 메뉴는 4가지 밖에 없는 회생식당.

IMG_0134.jpg

참치 비빔밥, 스끼야끼, 찹스테이크, 닭갈비 정식 이렇게 4가지 메뉴만 제공하는데 스끼야끼를 시켜 보았습니다.

저 유부 주머니에는 모찌가 들어 있어서 쫄깃한 맛이 있었습니다.

함께 간 일행들이 주문한 닭갈비 정식.

IMG_0135.jpg

덮밥 처럼 나오는데 비벼서 쌈으로 먹을 수 있습니다.

IMG_0136.jpg

IMG_0138.jpg

미역국도 함께 나왔어요.

IMG_0137.jpg

또 하나, 찹스테이크 메뉴.

메뉴는 적었지만 양도 많고 맛도 있어서, 별식이 먹고 싶을때 좋은 식당이었습니다.

생일로 만나이를 구하고 연령대 처리하기

한국 나이는 생년으로만 처리하면 되나 만나이는 생일도 필요하다.
중국이나 일본 등 동양권도 이제는 만나이만 사용하기 때문에 한국식 나이 샘법은 ‘한국 나이’로만 불리고 있다.

$birthday = '1970-01-01';
$birthdayDate = DateTime::createFromFormat('Y-m-d', $birthday);
$now = new DateTime();
$diff = $now->diff($birthdayDate);

$age = $diff->y;

여기에 간단히 연령대를 구하는 것을 추가하면,

$ages = 0;
if ($age > 10) {
    $ages = ((int)($age / 10)) * 10;
}

10대 미만 어린이에 대해선 시스템에 따라 개별 처리를 해주면 된다.

아주 간단하게 모바일 웹페이지 상단에 앱스토어 배너 넣기

요즘 스마트폰이 활성화 되면서 모바일 서비스를 제공시 iOS, Android 등의 스마트폰용 앱 주력으로 사용하는 경우가 많습니다.
하지만 앱 설치 유도, 접근성 등으로 모바일 웹페이지도 함께 제공을 할 수 밖에 없죠.
물론 주력 서비스는 앱이기 때문에 다양한 방법으로 모바일 웹페이지에서 앱으로의 이동을 유도합니다.

iOS에서는 아주 간편하게 해결 할 수 있습니다.

<meta name=”apple-itunes-app” content=”app-id=앱아이디”>

Meta Tag 하나를 추가하여

이런 형태의 배너를 모바일 웹페이지에 추가할 수 있습니다.

Meta Tag 내에 넣어야 하는 앱아이디는 앱스토어 링크에서 알 수 있습니다.

https://itunes.apple.com/us/app/apple-store/id375380948?mt=8

~store/id 뒤에 있는 것을 사용하면 됩니다. 375380948

 

앱을 설치하기 전에는 위 배너가 앱스토어로 이동되지만 앱을 설치한 후에는 앱으로 열어 줍니다.
앱을 연 주소를 가지고 앱에서 대응을 하려면

AppDelegate.swift 에 application(_:open:options:) 를 추가한 후 URL에 맞게 대응할 수 있습니다.

https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1623112-application

 

‘Guideline 2.5.1 – Performance – Software Requirements’ ‘Your app uses or references the following non-public APIs:’ 리젝에 대응하는 방법.

애플에서 허용하지 않는 API를 사용하는 경우에 앱이 리젝 당합니다.
당연히 사용하지 말라고 한 API를 사용한 코드를 제거하고 다시 심사요청을 하면 되겠죠.

그런데 iOS 앱 개발자들은 일반적인 앱을 만들 경우 비허용/비공개 API를 사용할 일이 잘 없습니다.
Xcode에서 검색을 해도 안 나오겠죠.
가져다 쓴 라이브러리나 타 서비스 SDK에서 사용한 경우일 것입니다.
간단하게 외부 바이너리 라이브러리에서 비허용 API를 사용하고 있는 지 확인하는 방법을 알려 드리겠습니다.

프로젝트 폴더 혹은 외부 바이너리 라이브러리가 있는 폴더에서
터미널 명령으로

grep -R ‘애플에서 알려 준 non-public API’ *

예를 들자면 광고 대행사 SDK를 많이 가져다 쓰면 발생할 수 있는 리젝 사유인 LSApplicationWorkspace

grep -R ‘LSApplicationWorkspace’ *

어느 광고 대행사 SDK때문에 리젝된 것인지 알 수 있습니다.