
์ฒ์์๋ โ๊ทธ๋ฅ ERROR ์ ๋ถ Slack์ผ๋ก ์๋ฉด ๋๊ฒ ์ง?โ ๋ผ๊ณ ์๊ฐํ๋ค.
Logback์์ Slack Webhook๋ง ๋ถ์ด๋ฉด ๋๋ ์ค ์์๋ค.
ํ์ง๋ง ๋ง์ ๊ตฌํํด๋ณด๋ ํ๋ฃจ์๋ ์์ญ, ์๋ฐฑ ๊ฑด์ ์๋ฆผ์ด ์ธ๋ ธ๊ณ , ์ง์ง ์ค์ํ ์๋ฌ๋ ์ก์ ์์ ๋ฌปํ๋ฒ๋ ธ๋ค. ์๋ฆผ์ด ๋ง๋ค๋ ๊ฑด ์์ฌ์ด ์๋๋ผ, ์คํ๋ ค ๋ถ์์ด์๋ค. โํน์ ์ง์ง ์ค์ํ ๊ฑธ ๋์น๊ณ ์๋ ๊ฑด ์๋๊น?โ ํ๋ ์์ฌ์ด ๊ณ์ ๋ฐ๋ผ๋ค๋ ๋ค.
๊ทธ๋ฌ๋ ์ค Kafka์ Logstash๋ฅผ ๋ถ์ฌ๋ณธ ์๊ฐ, ์์ผ๊ฐ ๋ฌ๋ผ์ก๋ค.
๐ ๋จ์ํ ๋ก๊ทธ๋ฅผ โ๋ณด๋ด๋ ๊ฒโ์ด ์๋๋ผ, ์ด๋ค ๋ก๊ทธ๋ฅผ ๋ณด๋ด์ผ ํ ์ง ์ ํํ ์ ์๋ค๋ ๊ฑธ ๊นจ๋ฌ์๋ค. ํํฐ๋ง, ์ง๊ณ, ์กฐ๊ฑด๋ถ ์ ์กโฆ ์ด ๋ชจ๋ ๊ฑธ ์ ํ๋ฆฌ์ผ์ด์
์ด ์๋ ํ์ดํ๋ผ์ธ์์ ํ ์ ์์๋ค.
์ด๋ฒ ๊ธ์์๋ Logback์์ ์์ํด Logstash, Slack, Kibana๊น์ง ์ด์ด์ง๋ ๊ณผ์ ์ ๊ธฐ๋กํ๋ ค ํ๋ค.
Slack ์ฑ ๊ด๋ฆฌ ํ์ด์ง์์ From scratch๋ก ์ฑ์ ๋ง๋ค๊ณ Incoming Webhooks๋ฅผ ์ผฐ๋ค. ์ด ๋ฐฉ์์ ์ฑ๋ ์ฐ๊ฒฐ๊ณผ Webhook ๋ฐ๊ธ ๊ณผ์ ์ ๋จ๊ณ๋ณ๋ก ํ์ธํ ์ ์์ด ์ด๋ฐ ์ค์๋ฅผ ์ค์ฌ์คฌ๋ค.

Manifest ๋ฐฉ์์ ๊ตฌ์ฑ ์ด์์ ์ข์ง๋ง ์ด๊ธฐ ๋๋ฒ๊น
๋๋๊ฐ ์ฌ๋ผ๊ฐ๋ค. ์ด๋ฒ์ ์ฒซ ์ค์ ์ด์ด์ ๋จ์ํ ๊ธธ์ ํํ๋ค.

๋ฐ๊ธ๋ Webhook URL์ ์ฝ๋์ ์ง์ ๋ฐ์ง ์๊ณ ํ๊ฒฝ๋ณ์๋ก ์จ๊ฒผ๋ค. SLACK_WEBHOOK_URL=https://hooks.slack.com/services/***** ํํ๋ก ๊ด๋ฆฌํ๋ค.
์ฒ์์๋ Logback HttpAppender๋ก Slack์ ๋ฐ๋ก ๋ถ์๋ค.
root ๋ก๊ฑฐ์ ๊ฑธ์ด์ ERROR๋ง ์ ์กํ๊ฒ ํ๋ค.
<appender name="SLACK" class="net.logstash.logback.appender.HttpAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder" />
<url>${SLACK_WEBHOOK_URL}</url>
<connectTimeout>10000</connectTimeout>
<readTimeout>10000</readTimeout>
<includeCallerData>true</includeCallerData>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
</appender>
ํ์ง๋ง ์ต์ ๋ฒ์ ์์๋ HttpAppender๊ฐ ์ ๊ฑฐ๋๊ฑฐ๋ ๋ถ๋ฆฌ๋ผ ๋์ํ์ง ์์๋ค.
๊ทธ๋ฆฌ๊ณ ๋ ์ค์ํ ๊ฑด, ์ค๋ น ์ ๋ถ๋๋ผ๋ ์ฌ์๋, Rate Limit, ๋์ ๋ณ๊ฒฝ(SlackโSentry/Discord) ๊ฐ์ ๋ฌธ์ ๋ฅผ ์ ํ๋ฆฌ์ผ์ด์
์ฝ๋ ์์ผ๋ก ๋์ด์ค๊ณ ์ถ์ง ์์๋ค.
์ฌ๊ธฐ์ ๊ฒฐ๋ก ์ ๋๋ค.
๐ ์ ํ๋ฆฌ์ผ์ด์ ์ ๋ก๊ทธ๋ฅผ ๋จ๊ธฐ๋ ์ผ์๋ง ์ง์คํ๊ณ ,
๐ ์์งยท๊ฐ๊ณตยท์ ์ก์ Logstash ๊ฐ์ ํ์ดํ๋ผ์ธ์ด ๋ด๋นํด์ผ ํ๋ค.
์ต์ข ์ค๊ณ๋ ์ด๋ ๊ฒ ์ ๋ฆฌ๋๋ค.
| ๊ตฌ์ฑ ์์ | ์ญํ |
|---|---|
| Logback | JSON ๋ก๊ทธ ์์ฑ โ Kafka๋ก ์ ์ก |
| Logstash | Kafka ์์ง โ ํํฐ๋งยท์๊ณ์น ํ๋จ โ ES ์์ธ + Slack ์๋ฆผ |
| Slack | Logstash๊ฐ ๋ณด๋ธ ์์ฝ ์๋ฆผ๋ง ๋ฐ์ |
| Elasticsearch/Kibana | ์ ์ฒด ๋ก๊ทธ ๋ณด๊ด + ์๊ฐํ/๋ถ์ |
๊ธฐ์กด ELK ์ถ๋ ฅ์ Slack http output๋ง ์น๋ ๋ฐฉ์์ด์๊ณ , ์ ํ๋ฆฌ์ผ์ด์
์ ๊น๋ํ๊ฒ ๋ถ๋ฆฌ๋๋ค.
์๋ ELK ์ถ๋ ฅ์ ๋จ์ํ๊ฒ ์ด๋ ๊ฒ ์์ํ๋ค.
input {
kafka {
bootstrap_servers => "kafka:9092"
topics => ["app-logs"]
group_id => "logstash-consumer"
codec => json
}
}
filter {
date { match => ["timestamp","ISO8601"] target => "@timestamp" remove_field => ["timestamp"] }
}
output {
elasticsearch { hosts => ["http://elasticsearch:9200"] index => "app-logs-%{+YYYY.MM.dd}" }
stdout { codec => json }
}
Slack์ ๋ฌด์์ ์คํํธ๋ ์ด์ค๋ฅผ ๋ค ๋ณด๋ผ ์๋ ์์๋ค. ๋ฉ์์ง ๊ธธ์ด ์ ํ๋ ์๊ณ , ์์งํ ๊ธด ๋ก๊ทธ๋ ์ฝ๋ ์๊ฐ ๋์ด ๋ฏธ๋๋ฌ์ก๋ค.
๊ทธ๋์ Slack์๋ ํต์ฌ ์์ฝ + ์์ 3~5์ค ์คํํธ๋ ์ด์ค + TraceId + Kibana ๋งํฌ๋ง ๋ณด๋ด๊ณ , ์ ์ฒด ๋ก๊ทธ๋ Kibana์์ ๋ณด๋๋ก ๋ถ๋ฆฌํ๋ค.
์ด๋ ๊ฒ ํ๋ฉด Slack์ ๋น ๋ฅธ ๊ฐ์ง์ฉ, Kibana๋ ๊น์ ๋ถ์์ฉ์ผ๋ก ์ญํ ์ด ๋ช ํํด์ก๋ค.
# stack_trace 5์ค ์์ฝ
if [stack_trace] {
ruby {
code => '
if event.get("stack_trace")
stack = event.get("stack_trace").split("\n")[0..4].join("\n")
event.set("stack_trace_short", stack)
end
'
}
}
# Slack ๋ฉ์์ง ํ
ํ๋ฆฟ
if [level] == "ERROR" {
mutate {
add_field => {
"slack_message" => ":rotating_light: *ERROR ๋ฐ์!*%{NEWLINE}*Service:*
LogSentry%{NEWLINE}*Message:* %{message}%{NEWLINE}*TraceId:* %
{traceId}%{NEWLINE}*User:* %{userId}%{NEWLINE}*URI:* %{uri}%
{NEWLINE}%{stack_trace_short}%{NEWLINE}
<http://localhost:5601/app/discover#/?_a=(query:
(language:kuery,query:'traceId:\"%{traceId}\"'))|Kibana์์ ์ ์ฒด ๋ก๊ทธ ํ์ธ>"
}
}
}
}
Slack์ ์ ์ํ ๊ฐ์ง์, Kibana๋ ์ ๋ฐ ๋ถ์์ ์ต์ ํ๋๊ฒ ๋ง์ด๋ค.
Slack ์๋ฆผ์ ์์ฝํ๋ค๋ฉด, ์ด์ ์์ฒ ๋ก๊ทธ๋ฅผ ํผํผํ๊ฒ ๋จ๊ธธ ์ฐจ๋ก์๋ค.
KafkaAppender๋ฅผ ํตํด Logback์์ JSON ๋ก๊ทธ๋ฅผ Kafka๋ก ์ ์กํ๊ณ , MDC(traceId/spanId)์ stack_trace ํ๋๋ฅผ ํฌํจ์์ผฐ๋ค.
์ด๋ ๊ฒ ํ๋ฉด Logstash์์ ๋ฉ์์ง๋ฅผ ๊ฐ๊ณตํ ๋๋, Kibana์์ ์ ์ฒด ํ๋ฆ์ ๋ณผ ๋๋ ๋น ์ง์์ด ์ฐ๊ฒฐํ ์ ์๋ค.
<appender name="KAFKA_JSON" class="com.github.danielwegener.logback.kafka.KafkaAppender">
<topic>app-logs</topic>
<encoder class="com.github.danielwegener.logback.kafka.encoding.LayoutKafkaMessageEncoder">
<layout class="net.logstash.logback.layout.LogstashLayout">
<includeMdc>true</includeMdc>
<includeException>true</includeException>
<includeCallerData>false</includeCallerData>
</layout>
</encoder>
<producerConfig>XXXX ์ดํ์๋ต</producerConfig>
...
</appender>
stack_trace ์ ์Elasticsearch๋ ๊ธฐ๋ณธ์ ์ผ๋ก ๋์ ๋งคํ(dynamic mapping)์ผ๋ก ์ ํ๋๋ฅผ ์๋ ์ธ์ํ๋ค.
ํ์ง๋ง ์ด์ ํ๊ฒฝ์์๋ ํ๋ ํ์
์ด ์๋ชป ์กํ๊ฑฐ๋, ๊ฒ์ยท์ง๊ณ๊ฐ ๊ผฌ์ผ ์ํ์ด ์์ด์ ์ธ๋ฑ์ค ํ
ํ๋ฆฟ์ stack_trace ํ๋๋ฅผ ๋ช
์์ ์ผ๋ก ํฌํจ์์ผฐ๋ค.

์ด๋ ๊ฒ ํ ํ๋ฆฟ์ ํฌํจํด๋๋ฉด Kibana Discover์ Lens์์ ์์ ์ ์ผ๋ก ์กฐํยทํํฐ๋งํ ์ ์๋ค.
_
โ
์ค์ ๋ก ์๋ฌ API๋ฅผ ํธ์ถํ์ ๋ stack_trace ํ๋๊ฐ ์ ๋ค์ด์ค๋ ๊ฒ๋ ํ์ธํ๋ค.

์ฒ์์ โ๋ชจ๋ ERROR๋ฅผ Slack์ผ๋ก!โ ๋ผ๊ณ ๋จ์ํ๊ฒ ์ ๊ทผํ์ง๋ง, ์๋ฆผ ํญํ์ ๋ต์ด ์๋์๋ค.
Slack์ ๊ธ๋ฐฉ ์๋๋ฌ์์ง๊ณ , ์ค์ํ ์๋ฌ์กฐ์ฐจ ์ก์์ ๋ฌปํ๋ฒ๋ ธ๋ค.
๊ทธ๋์ ์ ๋ต์ ๋ฐ๊ฟจ๋ค. BizLogger ํ์ค ๋ก๊น ์ ALERT ๋ง์ปค๋ฅผ ๋์ ํ๊ณ , Logstash์์ ์๊ณ์น ์กฐ๊ฑด์ ๊ฑธ์ด ์๋ฏธ ์๋ ์ด๋ฒคํธ๋ง Slack์ผ๋ก ๋ณด๋ด๋๋ก ํ๋ค.
์๋ฅผ ๋ค์ด, ์ฝ๋์์๋ ์ด๋ ๊ฒ ๊ตฌ๋ถํ๋ค.
// ์ผ๋ฐ ์์ธ โ Kibana์์๋ง ํ์ธ
BizLogger.error("ํ์ ๊ฐ์
์ค ๋๋ค์ ์ค๋ณต ๋ฐ์: {}", nickname);
// ์ค์ํ ์์ธ โ Slack ์๋ฆผ๊น์ง ์ ์ก
BizLogger.alert("๐จ ๊ฒฐ์ API ํธ์ถ ์คํจ! ์ฃผ๋ฌธ๋ฒํธ: {}", orderId);
์ฆ, ๋จ๋ฐ์ฑ ์์ธ๋ Kibana์๋ง ๋จ๊ธฐ๊ณ , ๋ฐ๋ณต๋๊ฑฐ๋ ์น๋ช ์ ์ธ ์ด๋ฒคํธ๋ง Slack์ ์ธ๋ฆฌ๊ฒ ํ๋ค.
# ALERT ๋ง์ปค ๊ฐ์ง
if "ALERT" in [tags] {
mutate { add_field => { "alert_flag" => "true" } }
}
# URI ์๊ณ์น: 10๋ถ ๋ด 5ํ
aggregate {
task_id => "%{uri}"
code => "
map['count'] ||= 0
map['count'] += 1
event.set('error_count_uri_10m', map['count'])
"
timeout_task_id_field => "uri"
timeout => 600
push_previous_map_as_event => false
timeout_code => "event.set('error_count_uri_10m', 0)"
}
# traceId ์๊ณ์น: 10๋ถ ๋ด 3ํ
aggregate {
task_id => "%{traceId}"
code => "
map['count'] ||= 0
map['count'] += 1
event.set('error_count_trace_10m', map['count'])
"
timeout_task_id_field => "traceId"
timeout => 600
push_previous_map_as_event => false
timeout_code => "event.set('error_count_trace_10m', 0)"
}
output {
elasticsearch {
hosts => ["http://elasticsearch:9200"]
index => "app-logs-%{+YYYY.MM.dd}"
}
stdout { codec => json }
# Slack ์๋ฆผ ์กฐ๊ฑด
if (("ALERT" in [tags] or [error_count_uri_10m] >= 5 or [error_count_trace_10m] >= 3) and [level] in ["ERROR", "WARN"]) {
http {
url => "${SLACK_WEBHOOK_URL}"
http_method => "post"
format => "json"
mapping => {
"text" => ":rotating_light: ERROR ์๋ฆผ (์์ฝ๋ณธ + Kibana ๋งํฌ)"
}
}
}
}
โ
์ต์ข ์ ์ผ๋ก ๊ฒฐ๊ณผ๋ ์ด๋ ๊ฒ ๋์๋ค.
BizLogger.error โ ์๊ณ์น ํต๊ณผ ์, Slack ์๋ฆผ

BizLogger.alert โ Slack ์๋ฆผ + Kibana ๋งํฌ

Slack์์ ๋งํฌ ํด๋ฆญ โ Kibana๋ก ์ด๋ํด TraceId ๊ธฐ์ค ์ ์ฒด ํ๋ฆ ์ถ์

์ ์ด์ ๋จ์ํจ์ ๋ฏฟ๊ณ Logback ์ง๊ฒฐ์ ํํ์ง๋ง, ์ด์์ ๋ค๋ฅด๊ฒ ๊ตด๋ ๋ค.
์๋ฆผ์ ๋ง๋ค๊ณ ์ข์ ๊ฒ ์๋๋ผ, ์ ๋๋ก ์ค๋ ๊ฒ ์ค์ํ๋ค๋ ๊ฑธ ์ด๋ฒ์ ๋๊ผ๋ค.
๊ทธ๋์
ALERT ๋ง์ปค์ ์๊ณ์น๋ฅผ ์น์ผ๋ ์๋ฆผ์ ๋ ์๋๋ฝ๊ณ ๋ ์ ํํด์ก๊ณ , ์์ธ ๋ถ์์ Kibana ๋งํฌ๋ฅผ ํตํด ๋ ๋นจ๋ผ์ก๋ค.
์ด๋ฒ ๊ณผ์ ์ ํตํด ๋ฐฐ์ด ๊ฑด, ๋จ์ํ ๊ธฐ์ ์ ๋ถ์ด๋ ๊ฒ ์๋๋ผ ์ด์์์ ์์ ์์ โ์ด๋ค ์๋ฆผ์ด ์ง์ง ํ์ํ๊ฐโ๋ฅผ ๊ณ ๋ฏผํ๋ ๊ฒ ๋ ํฐ ์ค๊ณ ํฌ์ธํธ๋ผ๋ ์ ์ด์๋ค.