

TeamCity에는 빌드를 시작하거나 완료하는 등 이벤트가 발생하였을 때 webhook으로 받아볼 수 있는 기능이 있습니다. 이를 Slack 메세지로 보내야 하는데, TeamCity에서 송신하는 JSON 형식이랑, Slack 에서 수신받는 JSON 형식이랑 달라서 중간에서 가공을 할 필요가 있습니다.

즉 중간에서 JSON 포맷을 변환해주는 브로커 역할이 필요한거죠.
브로커란? 다양한 컴퓨터나 서비스 간의 통신을 중개하거나 조정하는 역할을 합니다
간단한 입, 출력 정도 다루면 되기 때문에 코드 자체는 ... 파일 한 개를 넘지 않을 것입니다.
TeamCity에서 보내는 Webhook JSON 포맷 확인하기
webhook.site를 이용하면 TeamCity에서 보내는 Webhook 메세지를 쉽게 확인할 수 있습니다.

처음에 webhook.site에 접속하면 고유 URL이 표시될 것입니다. TeamCity에서 Webhook 주소를 Your unique URL
에 있는 주소로 설정하면 TeamCity Webhook 메세지가 여기에 표시될 것입니다.

TeamCity에서 Webhook을 설정하려면 프로젝트에서 Edit Project > Parameters 경로로 들어간 후, Add new parameter로 파라미터를 3개 추가해야 합니다.
teamcity.internal.webhooks.enable
teamcity.internal.webhooks.events
teamcity.internal.webhooks.url
수신받을 이벤트는 세미콜론(;
)으로 구분해주시면 됩니다. 자세한 사항은 TeamCIty 공식 문서(https://www.jetbrains.com/help/teamcity/teamcity-webhooks.html)를 확인해주세요.

TeamCity에서 Webhook 설정을 완료했다면 테스트로 빌드를 실행해보시면 됩니다.

그러면 webhook.site에서 TeamCity에서 보내는 메세지를 확인할 수 있을 거에요. 이 데이터를 참고하면서 webhook 브로커를 만들어 볼겁니다.
Webhook 브로커 구현을 위한 도구 선택하기
간단한 로직이기 때문에 성능이나 커뮤니티의 규모 등은 모두 제외하고 되도록이면 적은 갯수의 파일로 구현할 수 있는 도구에 가중치를 두었습니다.
Bash Shell Script | Python | Node.js | deno | |
---|---|---|---|---|
예상 구현 방식 | ncat + jq | flask + requests | express + axios | std/http + fetch |
난이도 | ★★★★★ | ★☆☆☆☆ | ★☆☆☆☆ | ★★☆☆☆ |
파일 수 | 1개 예상(main.sh) | 2개 예상(requirements.txt, main.py) | 4개 예상(package.json, package-lock.json, node_modules, main.js) | 1개 예상(main.js) |
장점 | 리눅스 친화적, 백엔드 팀원이 유지보수 가능할 것으로 보임 | 사용법이 매우 간단함 | 프론트엔드 팀원에게 매우 친숙함 | 프론트엔드 팀원에게 친숙함, fetch가 내장되어 있음, 의존성을 URL로 표기할 수 있음 |
단점 | http 입, 출력을 다루는 것이 매우 복잡함 | 팀원 중 파이썬을 능숙히 사용할 수 있는 사람이 없음 | 외부 의존성을 사용하게 될 시 4개 파일이 무조건 있어야 함 | 커뮤니티 규모가 작음 |
평가 | ★☆☆☆☆ | ★★★☆☆ | ★★☆☆☆ | ★★★★☆ |
deno는 의존성을 URL로 표기하여 사용할 수 있습니다. 이 점이 지금 상황에서 큰 장점이라고 생각했습니다.
대부분의 언어가 외부 의존성을 사용할 때 의존성을 관리해주는 파일(requirements.txt
, package.json
등)이 필요합니다. 반면 deno는 URL로 부터 import를 할 수 있기 때문에 의존성을 사용하더라도 파일을 하나로 유지할 수 있습니다.
// std/http 예제 코드
// https://deno.land/manual@v1.36.3/examples/http_server
import { serve } from "https://deno.land/std@0.200.0/http/server.ts";
const port = 8080;
const handler = (request: Request): Response => {
const body = `Your user-agent is:\n\n${
request.headers.get("user-agent") ?? "Unknown"
}`;
return new Response(body, { status: 200 });
};
console.log(`HTTP webserver running. Access it at: http://localhost:8080/`);
await serve(handler, { port });
구현
server.js 파일을 하나 만들고 아래처럼 작성하면 됩니다.
import { load } from "https://deno.land/std@0.200.0/dotenv/mod.ts";
import { serve } from "https://deno.land/std@0.200.0/http/server.ts";
await load({ export: true });
const getEnv = (envName, defaultValue = undefined) => {
const envValue = Deno.env.get(envName) ?? defaultValue;
if (envValue === undefined) {
throw new Error(`${envName} 값을 설정해야 합니다`);
}
return envValue;
}
const PORT = Number(getEnv('PORT', 80));
const SLACK_WEBHOOK_URL = getEnv('SLACK_WEBHOOK_URL');
const SLACK_CHANNEL = getEnv('SLACK_CHANNEL');
const SLACK_USERNAME = getEnv('SLACK_USERNAME', 'Notification');
const SLACK_ICON_EMOJI = getEnv('SLACK_ICON_EMOJI', ':ghost:');
const handler = async (request) => {
/** @type {import('./types').WebhookMessage} */
const body = await request.json();
if (!('eventType' in body)) {
return new Response(JSON.stringify({ message: 'Not a valid webhook message format' }), { status: 400 });
}
console.log(`Webhook 이벤트 발생: ${body.eventType}`);
console.log(JSON.stringify(body));
if (body.eventType === 'BUILD_FINISHED') {
const message = {
channel: SLACK_CHANNEL,
username: SLACK_USERNAME,
icon_emoji: SLACK_ICON_EMOJI,
text: `${body.payload.status === 'SUCCESS' ? '✅빌드 성공' : '❌빌드 실패'}하였습니다. (${body.payload.buildType.name})`,
};
await fetch(SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message),
});
}
return new Response(JSON.stringify({ message: 'success' }), { status: 200 });
};
console.log(`HTTP webserver running on port ${PORT}`);
await serve(handler, { port: PORT });
TeamCity에서 받은 Webhook 메세지의 정보를 꺼내어 Slack Webhook으로 보내는 코드입니다. Slack Webhook URL, 채널명, 표시될 이름 및 아이콘은 환경변수로 받고 있습니다. dotenv도 적용해두었기 때문에 .env에 작성해도 됩니다.
(동작 자체는 굉장히 단순하기 때문에 크게 설명할 내용이 없네요.)
version: "3"
services:
teamcity:
# ...
webhook-broker:
image: denoland/deno
restart: unless-stopped
volumes:
- ./:/app
command: run --allow-net --allow-read --allow-env /app/server.js
environment:
- 'SLACK_CHANNEL=#테스트채널'
- 'SLACK_WEBHOOK_URL=YOUR_SLACK_WEBHOOK_URL'
- 'SLACK_USERNAME=TeamCity'
- 'SLACK_ICON_EMOJI=:teamcity:'
deno 런타임을 별도로 설치하셔도 좋지만 TeamCity가 있는 docker-compose.yml이 있다면 위와 같이 service를 추가하기만 하면 됩니다. docker-compose.yml 파일에 같이 두시면 TeamCity측에서 http://webhook-broker
로 쉽게 접근이 가능하기 때문에 여러모로 편리합니다.
SLACK_WEBHOOK_URL
은 나중에 Slack Incoming Webhook 앱을 추가한 뒤 생성되는 Webhook URL을 여기에 넣어주시면 됩니다.
TeamCity Webhook의 메세지를 → Webhook 브로커로

TeamCity에서 Edit Project > Parameters 로 진입할 수 있습니다.
TeamCity webhook 메세지가 우리가 만든 Webhook 브로커로 가게끔 설정합니다. 아까 설정했던 webhook.site의 URL을 http://webhook-broker
로 바꿔주기만 하면 됩니다.
Webhook 브로커의 메세지를 → Slack Webhook으로


Slack의 Incoming Webhook 앱을 추가하고 알림을 받기를 원하는 채널로 설정해주세요.

Webhook URL이 생성되는데, 우리가 만든 Webhook 브로커가 이 URL로 메세지를 전송할 수 있게끔 SLACK_WEBHOOK_URL
환경변수에 이 URL을 넣어주시면 되겠습니다.
동작 확인
docker-compose.yml 에 작성하셨다면 docker compose up webhook-broker
명령으로 우리가 만든 webhook 브로커를 띄울 수 있습니다.


- TeamCity의 Webhook 메세지를 잘 수신하고,
- Slack의 Webhook URL로 메세지를 잘 보내는 것을
확인할 수 있었습니다! 혹여나 잘 안된다면 Webhook broker의 로그를 보고 어느 부분에 문제가 발생하였는지 확인해보세요.
Rich한 메세지 만들기: Slack Block Kit

https://app.slack.com/block-kit-builder
메세지를 좀 더 rich하게 꾸미고 싶다면 Slack Block Kit을 사용하시면 됩니다.
![]() |
![]() |
---|
Node.js와 비교

실제 node.js로 작성했을 때의 디펙토리 모습입니다. 프로젝트 폴더 깊숙한 곳에 코드를 위치시키는데, 하나의 작은 목적에 비해 파일이 너무 많습니다 ... (Docker 관련 파일을 제외하더라도 말입니다)

반면, deno는 의존성 관리를 위한 파일이 전혀 없으므로 많아봤자 2개 정도입니다. (다른 하나는 타입을 위한 파일)
적은 갯수의 파일이 가장 큰 목적이었기 때문에, 굉장히 만족스럽습니다!