포스트

'에러를 읽는 AI' - Gemini와 Slack으로 만든 자동 오류 분석 시스템 Solomon

Spring Boot 기반으로 AI를 활용한 오류 분석 효율화를 개선한 사례

'에러를 읽는 AI' - Gemini와 Slack으로 만든 자동 오류 분석 시스템 Solomon

1. 들어가며

현재 사내의 로그 수집은 HeimdallOpenTelemetry(w. sigNoz)를 기반으로 이루어지고 있습니다.
오류 관제의 대부분은 Heimdall의 룰(rule) 에 따라 Slack 채널로 전달되며, 실제 장애 발생 시 관련 로그가 아래와 같이 공유 되고 있습니다. (이미지 참조)

기존 오류 메시지 예시


그러나 기존 메시지 템플릿으로 모니터링을 진행하면서 다음과 같은 불편함이 있었습니다.

불필요하게 긴 Stack Trace
(메시지가 너무 길어 Slack 상에서 잘리는 경우 다수 발생)

오류 원인 파악의 어려움
(핵심 예외가 묻혀 한눈에 보기 힘듦)

이러한 이유로 “오류를 한눈에, 빠르게 파악할 수 있는 방식은 없을까?” 하는 고민이 시작되었습니다. AI가 로그를 분석해 오류 원인을 요약하고, 핵심 정보를 바로 보여줄 수 있다면 개발자는 원인 분석에 드는 시간을 크게 줄일 수 있을 것이라 판단했습니다.

2. 스펙

분류 기술 역할
백엔드 Spring Boot 3.x (JDK 21) 애플리케이션 프레임워크
AI 엔진 Gemini API (Java SDK) 오류 분석 및 답변 생성
Slack 통합 Slack Bolt for Java socket mode를 통한 이벤트 수신
배포 환경 Kubernetes  

3. Flow

🚀이번에 구축한 시스템은 오류 감지부터 자동 응답까지 완전 자동화된 파이프라인으로, Heimdall에서 발생한 이벤트를 Slack으로 알리고, Gemini를 통해 자동으로 분석/답변을 생성하도록 설계했습니다.

📡 Heimdall → Server (Webhook) 먼저 Heimdall에서 오류나 이벤트가 감지되면, 해당 내용을 Webhook을 통해 서버로 전송합니다. 이때 서버는 요청을 수신하자마자 “OK” 응답을 반환하여 Heimdall 측에서 대기 없이 빠르게 다음 작업으로 넘어갈 수 있도록 합니다. (즉 비동기 처리 구조로 설계되어 있습니다.)

⚙️ Server 내부 비동기 처리

  1. Webhook 응답 이후, 서버는 실제 처리를 백그라운드 비동기 작업으로 수행합니다.
    이 비동기 작업은 다음과 같은 순서로 진행됩니다:

  2. Slack 알림 전송 (post.message)
    수신한 오류 메시지를 지정된 Slack 채널로 전송하여, 팀원들이 즉시 인지할 수 있도록 합니다.

  3. Gemini 응답 생성 (generate answer)
    Slack에 전송된 메시지를 기반으로, Gemini 모델을 호출해 오류 원인이나 해결책을 자동으로 분석합니다.

  4. Slack 스레드 답변 (post.message(thread))
    Gemini가 생성한 분석 결과를 원본 Slack 메시지의 스레드 형태로 다시 전송합니다. 이를 통해 한 메시지 내에서 오류 내용과 분석 결과를 한눈에 확인할 수 있습니다.

💡 핵심 포인트

  • Webhook 즉시 응답 → Heimdall은 블로킹 없이 빠른 처리가 가능
  • 비동기 기반 설계 → Slack 전송과 Gemini 호출이 서버 응답 속도에 영향을 주지 않음
  • Slack + Gemini 통합 → 오류 발생 → 자동 분석 → 답변 공유까지 완전 자동화
  • 스레드 방식 메시징 → Slack 내에서 관련 정보가 깔끔하게 정리됨

4. 구현 내용

1. Gemini API 연동을 위한 Gradle 종속성 추가

1
2
3
4
5
6
build.gradle
...
    // gemini
    implementation 'com.google.genai:google-genai:1.24.0'
...

1
2
3
4
5
6
application.yml
...
genai:
 api: #########################  # https://aistudio.google.com 에서 key 발급
 model: gemini-flash-lite-latest # https://ai.google.dev/gemini-api/docs/models?hl=ko   
...

👉 Google의 Gemini API를 활용하기 위한 필수 패키지 구성 및 환경 설정

2. Slack 오류 원문 Stack Trace 간소화 로직 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
public class StacktraceTrimmer {

    private static final int messageLimit = 500;

    /**
     * stacktrace: 전체 스택 트레이스(예: Throwable#printStackTrace 결과)
     * maxFrames: 각 예외(혹은 Caused by) 당 보여줄 최대 프레임 수
     */
    public static String extractEssential(String stacktrace, int maxFrames) {
        if (stacktrace == null || stacktrace.isBlank())
            return "";

        // stacktrace 가 500자 미만이면 그대로 반환
        if (stacktrace.length() < messageLimit) {
            return stacktrace;
        }

        String[] lines = stacktrace.split("\\r?\\n");
        StringBuilder out = new StringBuilder();

        Pattern exceptionLine = Pattern.compile("^[^\\s].*(?:Exception|Error|Throwable|Failure).*");
        Pattern atLine = Pattern.compile("^\\s*at\\s+(.+)$");
        Pattern causedBy = Pattern.compile("^Caused by:.*$");
        Pattern servicePattern = Pattern.compile("^\\s*Service*$");

        Pattern appPattern = null;

        boolean seenFirstException = false;
        int i = 0;
        while (i < lines.length) {
            String line = lines[i];

            // 예외/오류 첫 줄 감지
            if (!seenFirstException && exceptionLine.matcher(line).matches()) {
                seenFirstException = true;
                out.append(line).append('\n');

                // 다음 라인들에서 프레임 수집
                i++;
                i = collectFrames(lines, i, out, atLine, causedBy, appPattern, servicePattern, maxFrames);
                continue;
            }

            // 만약 이미 첫 예외를 봤다면, 다른 'Caused by' 블록들도 처리
            if (seenFirstException && causedBy.matcher(line).matches()) {
                out.append(line).append('\n');
                i++;
                i = collectFrames(lines, i, out, atLine, causedBy, appPattern, servicePattern, maxFrames);
                continue;
            }

            if (servicePattern.matcher(line).matches()) {
                out.append(line).append('\n');
                i++;
                i = collectFrames(lines, i, out, atLine, causedBy, appPattern, servicePattern, maxFrames);
                continue;
            }

            i++;
        }

        String result = out.toString().trim();
        if (result.isEmpty()) {
            // 포맷이 달라서 잡히지 않을 경우: fallback으로 맨 앞 10라인 반환
            StringBuilder fb = new StringBuilder();
            for (int k = 0; k < Math.min(10, lines.length); k++) {
                fb.append(lines[k]).append('\n');
            }
            fb.append("... (truncated)");
            return fb.toString();
        }

        return result;
    }

    private static int collectFrames(String[] lines,
                                     int idx,
                                     StringBuilder out,
                                     Pattern atLine,
                                     Pattern causedBy,
                                     Pattern appPattern,
                                     Pattern servicePattern,
                                     int maxFrames) {
        List<String> appFrames = new ArrayList<>();
        List<String> otherFrames = new ArrayList<>();

        int consumed = 0;
        while (idx + consumed < lines.length) {
            String l = lines[idx + consumed];

            if (causedBy.matcher(l).matches() || l.trim().isEmpty() || !atLine.matcher(l).matches()) {
                // 프레임 블록 끝(다음이 Caused by 이거나 빈줄 혹은 비-at 라인)
                break;
            }

            // at line
            String frameText = l.trim(); // "at com.xxx... (File.java:123)"
            if (appPattern != null && appPattern.matcher(frameText).find()) {
                appFrames.add(frameText);
            } else {
                otherFrames.add(frameText);
            }

            consumed++;
        }

        // 우선 애플리케이션 프레임을 최대 maxFrames개 채우고, 모자르면 other로 채운다.
        int remaining = maxFrames;
        for (String f : appFrames) {
            if (remaining <= 0)
                break;
            out.append("\t").append(f).append('\n');
            remaining--;
        }
        if (remaining > 0) {
            for (String f : otherFrames) {
                if (remaining <= 0)
                    break;
                out.append("\t").append(f).append('\n');
                remaining--;
            }
        }

        // 만약 프레임이 더 있었으면 축약 표시
        int totalFrames = appFrames.size() + otherFrames.size();
        if (totalFrames > maxFrames) {
            out.append("\t... ").append(totalFrames - maxFrames).append(" more frames (truncated)\n");
        }
        return idx + consumed;
    }
}

👉 불필요한 라인 제거 및 핵심 예외 위치만 추출하도록 개선

3. Slack 오류 메시지 자동 발송 모듈 개발

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SlackService.java
...
   /**
	 * https://docs.slack.dev/reference/methods/chat.postMessage/
	 */
    private SlackPostMessageResponse sendSlack(ObjectNode requestBody, boolean isErrorHelper) {
        return webClient
                .post()
                .uri(String.format("%s/%s", "https://slack.com/api/", SLACK_POST_MESSAGE_URI))
                .header("Authorization", String.format("Bearer %s", botToken)
                .bodyValue(requestBody)
                .retrieve()
                .bodyToMono(SlackPostMessageResponse.class)
                .timeout(Duration.ofSeconds(120)) // 타임아웃 설정
                .block();
    }
...

👉 Heimdall Webhook으로 전달된 오류 로그를 Slack 채널로 즉시 전송

4. Gemini 기반 오류 분석 및 답변 생성 기능

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
GeminiService.java

    /**
     * gemini api 통한 답변 생성
     * @param errorMessage
     * @return
     */
    public String generateAnswer(String errorMessage, String sessionId) {
        String question =
                """
                너는 SpringBoot/Java 디버깅 전용 AI 비서다. 답변은 항상 아래 순서·형식으로, 각 문장(항목)은 100자 이내로 작성해라.
                
                1.오류에 대한 원인
                • 핵심키워드 : 각 키워드별 간단 해설 (한 줄, 100자 이내)
                
                2.오류 발생 지점
                • 파일명 : 몇번째 라인에서 오류 발생했는지 (패키지명 생략, 사용자 파일만 기재)
                
                3.해결 방안
                • 해결 방법 (오류 메시지의 정보만으로 구체적 조치 제시)
                • 해결 예시 (실제 적용 가능한 코드/명령/설정 + 시나리오: 상황→문제→해결 흐름)
                
                출력 규칙: 슬랙 스타일 사용 — 강조/헤더는 * 한 개만, 리스트는 • 사용 (반드시 지킬 것)
                절대 일반론으로 끝내지 말고, 바로 적용 가능한 실전 해법으로 마무리하라. :
                """ + errorMessage;

        GenerateContentResponse responseBody;
        try {
            Chat chat = geminiClient.chats.create(model);
            responseBody = chat.sendMessage(question);

            log.info("Gemini API 응답 수신 완료");
        } catch (ClientException e) {
            log.warn("Gemini API 호출 중 오류 발생 : {}", e.getMessage());
            return "오류에 대한 답변 작성이 불가능해요";
        }

        if (responseBody.text() == null) {
            return "";
        }

        return responseBody.text().replace("```java", "```");
    }

👉 전송된 오류 로그를 Gemini에 전달하여 핵심 원인과 요약 메시지 생성

5. Slack 스레드 내 AI 답변 자동 등록 기능

1
2
3
4
5
6
7
8
9
10
    private SlackPostMessageResponse sendSlack(ObjectNode requestBody, String threadTs, String channelId) {
        requestBody.put("channel", channelId);
        requestBody.put("mrkdwn", true);

        if (threadTs != null) {
            requestBody.put("thread_ts", threadTs); // 3번의 SlackPostMessageResponse 응답값에 있는 thread_ts
        }

        return sendSlack(requestBody, threadTs != null); // 3번 로직 재활용
    }

👉 AI가 생성한 분석 결과를 원본 오류 메시지의 스레드로 자동 등록하여, 개발자가 Slack 내에서 바로 원인과 해결 방향을 확인 가능

5. 중간 결과

개선 전 오류 메시지 개선 후 오류 메시지
👈 개선 전 (Full Stack Trace) │ 개선 후 (요약 + 파일 위치 표시) 👉


개선 후 오류 메시지
gemini API를 활용한 AI 답변

🔍 개선 전 (Before)

  • Slack 메시지로 전체 스택 트레이스가 그대로 전달됨
  • 로그가 길어 오류의 핵심 원인 파악에 시간 소요
  • 동일한 오류라도 스택 포맷에 따라 확인이 어려움

✅ 개선 후 (After)

  • 핵심 오류 메시지와 발생 파일·라인 정보만 추출
  • 메시지 길이 단축 → 가독성 향상
  • Slack에서 바로 오류 원인과 위치 해결 방안 확인 가능

💡 결과적으로, 단순히 메시지 포맷을 개선한 것만으로도 개발자가 오류를 분석하고 대응하는 속도가 눈에 띄게 향상되었습니다.

6. 운영하면서 느낀 한계점

Solomon AI Assistant를 약 한 달간 운영해보면서 한 가지 불편함을 경험했습니다. 오류 답변에 대해 추가 질문을 하고 싶을 때가 있었지만, 시스템상 답변을 이어서 받을 수 없어 결국 다른 AI 툴을 통해 추가 질문을 진행해야 했습니다.

7. 개선 사항

이후 확인해본 결과 Slack Event Subscription을 통해 Bot과 상호 작용이 가능하였고 추가로 기존 질문에 대한 세션을 유지하고자 캐시에 채팅 세션을 담아서 해당 채팅에 대한 세션이 있으면 해당 채팅세션을 통한 질문을 하여 기존 질문까지 포함하여 좀 더 정확한 답변을 도출되게 하고자 하였습니다.

개선 후 플로우
개선사항 반영 플로우

8. 구현 내용

1. Slack Event Subscription 설정 및 Bot 이벤트 핸들러 구현 (w. Slack Socket Mode)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Bot 이벤트 핸들러 구현에는 크게 2가지 방법이 있습니다.
// 1. Event Subscription URL 등록
// 2. Slack Socket Mode
// 해당 서비스 환경에서는 url이 외부에 오픈되어있지 않기에 Slack Socket Mode를 사용했습니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class SlackSocketModeListener implements CommandLineRunner {
    private final App slackApp;

    @Value("${slack.app.token}")
    private String slackAppToken;

    @Override
    public void run(String... args) {
        log.info("✅ Slack Bolt App, Socket Mode로 연결을 시작합니다...");
        try {
            SocketModeApp socketApp = new SocketModeApp(slackAppToken, slackApp);
            socketApp.startAsync(); // 기본 start를 할 경우 k8s 환경에서 liveness probe 및 readiness probe 'out of service' 발생
            log.info("✅ Slack Bolt App, Socket Mode 연결이 완료되었습니다.");
        } catch (Exception e) {
            log.error("Slack Socket Mode fail Reason :{}", e.getMessage());
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Component
public class GeminiSlackThreadHandler {
    // U로 시작하고 0-9, A-Z로 구성된 ID 태그를 찾는 정규 표현식
    private static final Pattern USER_MENTION_PATTERN = Pattern.compile("<@[UWEF][A-Z0-9]+>");

    private final Set<String> processedEvents = Collections.newSetFromMap(new ConcurrentHashMap<>());

    private final SlackService slackService;
    private final GeminiService geminiService;

    public GeminiSlackThreadHandler(App app, SlackService slackService, GeminiService geminiService) {
        this.slackService = slackService;
        this.geminiService = geminiService;
        registerAppMention(app);
    }

   // Slack Bot 멘션 이벤트
    public void registerAppMention(App app) {
        app.event(AppMentionEvent.class, (req, ctx) -> {
            AppMentionEvent event = req.getEvent();
            Response ackResponse = ctx.ack();

            String rawText = event.getText();
            String cleanedText = USER_MENTION_PATTERN.matcher(rawText).replaceAll("").trim();

            String threadTs = event.getThreadTs() != null ? event.getThreadTs() : event.getEventTs(); // 스레드 내에서 멘션을 걸었다면 해당 스레드에 추가 답변 아니면 채널에 답변

            String answer = geminiService.generateAnswer(cleanedText, threadTs); // 4. Gemini 기반 오류 분석 및 답변 생성 기능 로직 재사용
						
            if (answer == null) {
                return ackResponse;
            }

            slackService.postMessageToSlack(answer.replace("```java", "```"), threadTs, event.getChannel()); // 3. Slack 오류 메시지 자동 발송 모듈 개발 로직 재사용
            return ackResponse;
        });
    }
}		

2. 채팅 세션 캐싱 로직 구현 (우선은 Caffeine 캐시 사용)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Service
@RequiredArgsConstructor
public class ChatCacheManagerService {
    private final Client geminiClient;
    private final CacheManager cacheManager;

    private static final String CACHE_NAME  = "chatSessionCache";

    @Value("${genai.model}")
    private String model;

   // 기존에 발송한 내역이 있다면 해당 chat 세션 사용 그렇지 않다면 새로운 세션 사용 
    @Cacheable(value = CACHE_NAME, key = "#sessionId") // 여기서 sessionId는 slack thread_ts
    public Chat getOrCreateChatSession(String sessionId) {
        return geminiClient.chats.create(model);
    }

    public boolean isChatCached(String sessionId) {
        Cache cache = cacheManager.getCache(CACHE_NAME);
        if (cache ==  null)  {
            return false;
        }

        return cache.get(sessionId) != null;
    }
}

3. 추가 질문 시 기존 세션 조회 및 답변 연결 로직 적용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
   // gemini api 통한 답변 생성
    public String generateAnswer(String errorMessage, String sessionId) {
        String question =
                """
                너는 SpringBoot/Java 디버깅 전용 AI 비서다. 답변은 항상 아래 순서·형식으로, 각 문장(항목)은 100자 이내로 작성해라.
                
                1.오류에 대한 원인
                • 핵심키워드 : 각 키워드별 간단 해설 (한 줄, 100자 이내)
                
                2.오류 발생 지점
                • 파일명 : 몇번째 라인에서 오류 발생했는지 (패키지명 생략, 사용자 파일만 기재)
                
                3.해결 방안
                • 해결 방법 (오류 메시지의 정보만으로 구체적 조치 제시)
                • 해결 예시 (실제 적용 가능한 코드/명령/설정 + 시나리오: 상황→문제→해결 흐름)
                
                출력 규칙: 슬랙 스타일 사용 — 강조/헤더는 * 한 개만, 리스트는 • 사용 (반드시 지킬 것)
                절대 일반론으로 끝내지 말고, 바로 적용 가능한 실전 해법으로 마무리하라. :
                """ + errorMessage;

       // 해당 세션에 맞는 key가 존재한다면 이전 질문에 대한 세션이 존재하여 다른 프롬프트 사용
        if (chatCacheManagerService.isChatCached(sessionId))  {
            question = """
                    질문에 대한 내용을 700자 이내로 작성해라
                    출력 규칙: 슬랙 스타일 사용 — 강조/헤더는 * 한 개만, 리스트는 • 사용 (반드시 지킬 것)
                    추가로 개발 외 다른 질문을 하면 답변하지 말 것 :
                   """ + errorMessage;
        }

        GenerateContentResponse responseBody;
        try {
            Chat chat = chatCacheManagerService.getOrCreateChatSession(sessionId); // 기존 세션 사용 or 새로 생성 및 캐시 저장
            responseBody = chat.sendMessage(question);

            log.info("Gemini API 응답 수신 완료");
        } catch (ClientException e) {
            log.warn("Gemini API 호출 중 오류 발생 : {}", e.getMessage());
            return "오류에 대한 답변 작성이 불가능해요";
        }

        if (responseBody.text() == null) {
            return "";
        }

        return responseBody.text().replace("```java", "```"); // 이후  slackService.postMessageToSlack() 통해 슬랙에 답변 발송
    }

9. 결과

개선 후 오류 메시지1 개선 후 오류 메시지2
Solomon 앱이 이전 질문을 기억해서 간단한 질문에도 이전 질문 코드를 활용해서 답변을 해줌

10. 마치며

현재 서비스를 운영하면서 아쉬운 점이 있다면, MCP를 활용해 실제 코드 기반으로 오류 메시지를 분석하고 보다 정확한 답변을 제공하지 못한 점입니다.
추후 여건이 마련되면 MCP를 도입하고 그 과정과 효과를 다시 블로그에서 소개할 수 있으면 좋겠습니다.

끝까지 읽어주셔서 진심으로 감사합니다. :)

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.