๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
๐Ÿ‘จ‍๐Ÿ’ป web.dev/devops

RSS ๊ตฌ๋… ์š”์•ฝ LLM Slack Bot ๋งŒ๋“ค๊ธฐ

by HandHand 2025. 10. 11.

 

๐Ÿ“Œ Overview

์Šฌ๋ž™ ๊ฐœ์ธ ์ฑ„๋„์—์„œ ์—ฌ๋Ÿฌ ๊ธฐ์ˆ  ๋ธ”๋กœ๊ทธ๋ฅผ RSS ํ”ผ๋“œ๋กœ ๊ตฌ๋… ์ค‘์ธ๋ฐ์š”, ๋ฌธ์„œ ์ „์ฒด ๋‚ด์šฉ์„ ์š”์•ฝํ•ด์„œ ๊ฐ ๊ธ€์— ๋Œ“๊ธ€๋กœ ์ •๋ฆฌํ•ด ๋‘๋ฉด, ๋‹ค์–‘ํ•œ ์•„ํ‹ฐํด์„ ๋” ๋น ๋ฅด๊ณ  ํšจ์œจ์ ์œผ๋กœ ์ดํ•ดํ•˜๋Š” ๋ฐ ๋„์›€์ด ๋  ๊ฒƒ ๊ฐ™๋‹ค๋Š” ์ƒ๊ฐ์ด ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค.

 

ํŠนํžˆ ์ผ๋ถ€ ํ…Œํฌ ๋ธ”๋กœ๊ทธ๋Š” ๊ธฐ์ˆ ๋ฟ ์•„๋‹ˆ๋ผ ๋””์ž์ธ, ๋ฐ์ดํ„ฐ, ํ”„๋กœ๋•ํŠธ ๋“ฑ ์—ฌ๋Ÿฌ ๋ถ„์•ผ์˜ ์ฝ˜ํ…์ธ ๋„ ํ•จ๊ป˜ ๋‹ค๋ฃจ๊ธฐ ๋•Œ๋ฌธ์—, ์š”์•ฝ๊ณผ ํ•จ๊ป˜ ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„๋กœ ๋ถ„๋ฅ˜๊นŒ์ง€ ํ•ด์ฃผ๋ฉด ํ›จ์”ฌ ์œ ์šฉํ•  ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.

 

์Šฌ๋ž™ AI ์š”์•ฝ ๊ธฐ๋Šฅ์ด Pro ์š”๊ธˆ์ œ์—์„œ๋งŒ ์ œ๊ณต๋˜๋‹ค ๋ณด๋‹ˆ, ์ง์ ‘ ๊ฐ„๋‹จํ•œ ์•ฑ์„ ๊ตฌํ˜„ํ•ด๋ณด๋ ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค.

 

๐Ÿ“Œ Architecture

 

 

RSS ๊ตฌ๋… ์ค‘์ธ ๋ธ”๋กœ๊ทธ์— ์ƒˆ๋กœ์šด ๊ธ€์ด ๋ฐœํ–‰๋˜๋ฉด ์ด๋ฅผ ํŠน์ • ๊ตฌ๋… ์ฑ„๋„์— ๊ณต์œ ํ•ฉ๋‹ˆ๋‹ค.
์ด๋Š” RSS Slack App ์„ ํ†ตํ•ด ๊ตฌ์„ฑํ•ฉ๋‹ˆ๋‹ค.

 

๐Ÿ’ก RSS ์•ฑ ์„ค์น˜ ๋ฐ ๊ตฌ๋… ๋ฐฉ๋ฒ•์€ ๋‹ค์Œ ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”.
๐Ÿ‘‰ ๋ฌธ์„œ ๋งํฌ

ํŠน์ • ์ฑ„๋„์— ์ƒˆ๋กœ์šด ๋ฉ”์‹œ์ง€๊ฐ€ ๋“ฑ๋ก๋˜๋ฉด ํ•ด๋‹น ์ด๋ฒคํŠธ๋ฅผ ๊ฐ์ง€ํ•˜๊ณ  AWS Lambda์—์„œ ์šด์˜ ์ค‘์ธ Slack Bot์— ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.
Slack Bot ์€ ๋ฉ”์‹œ์ง€ ๋‚ด์˜ ์ฒจ๋ถ€๋œ ๋ฌธ์„œ ๋งํฌ๋ฅผ ํ†ตํ•ด์„œ ์›๋ณธ ๋ฌธ์„œ๋ฅผ ํƒ์ƒ‰ํ•˜๊ณ  ์š”์•ฝ๋œ ๋ฉ”์‹œ์ง€๋ฅผ ์ƒ์„ฑํ•ด์„œ ์ฑ„๋„์— ์‘๋‹ตํ•ฉ๋‹ˆ๋‹ค.

 

๐Ÿ“Œ Slack Bot Message Handling

Slack Bolt for JavaScript

Slack ์—๋Š” Node.js ํ™˜๊ฒฝ์—์„œ ๋™์ž‘ํ•˜๋Š” ๋ด‡์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋Š” ๊ณต์‹ ํ”„๋ ˆ์ž„์›Œํฌ์ธ Bolt ๊ฐ€ ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค.

ํ•ด๋‹น ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ํ†ตํ•ด ์›ํ•˜๋Š” ์š”๊ตฌ์‚ฌํ•ญ์„ ์ถฉ์กฑํ•˜๋Š” ๋ด‡ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

 

๐Ÿ’กBolt ๋ฅผ ํ†ตํ•ด Slack Bot์„ ๊ตฌ์„ฑํ•˜๋Š” ๋ณด๋‹ค ์ž์„ธํ•œ ๋‚ด์šฉ์€ ๋‹ค์Œ ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”.
๐Ÿ‘‰ Bolt for JavaScript

Slack Events API

Slack Events API ๋Š” slack bot์ด Slack ํ™œ๋™์„ ๊ฐ์ง€ํ•˜๊ณ  ์ ์ ˆํ•œ ์‘๋‹ต์„ ์ œ๊ณตํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•˜๋Š” API์ž…๋‹ˆ๋‹ค.

Socket ๋ชจ๋“œ์™€ public HTTP endpoint ๋ชจ๋“œ ์ค‘ ํ•˜๋‚˜๋ฅผ ์„ ํƒํ•ด์„œ ์ ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ €๋Š” ๋ณ„๋„์˜ ๋ฐฉํ™”๋ฒฝ์€ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์ง€ ์•Š๊ณ  production ์•ฑ์„ AWS Lambda ํ™˜๊ฒฝ์—์„œ ์šด์˜ํ•  ์˜ˆ์ •์ด๊ธฐ ๋•Œ๋ฌธ์— HTTP ๋ฐฉ์‹์„ ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.

๋กœ์ปฌ ๊ฐœ๋ฐœํ™˜๊ฒฝ์˜ ๊ฒฝ์šฐ public HTTP endpoint๋ฅผ ์ œ๊ณตํ•˜๊ธฐ ์œ„ํ•ด์„œ reverse proxy๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ•˜๋Š”๋ฐ ์ด๋ฅผ ์œ„ํ•ด์„œ ngrok ์†”๋ฃจ์…˜์„ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋‘ ๋ชจ๋“œ์˜ ์ฐจ์ด์ ์€ ์ด ๋ฌธ์„œ ์—์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

 

Demo ์•ฑ ์˜ˆ์‹œ

 

Event message Parsing

ํ˜„์žฌ RSS ๊ตฌ๋… ์ค‘์ธ ๋ธ”๋กœ๊ทธ์˜ ๋ฐœํ–‰ ์†Œ์‹์€ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํ˜•์‹์œผ๋กœ ์ „๋‹ฌ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

 

 

message ๋ฆฌ์Šค๋„ˆ์—์„œ๋Š” ์ด ๋ฉ”์‹œ์ง€๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํ˜•์‹์œผ๋กœ ์ „๋‹ฌ๋ฐ›๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

 

{
  text: '<https://medium.com/daangn/%EC%9B%B9%EC%95%B1-%EC%84%9C%EB%B2%84-%EB%A1%9C%EA%B9%85-%EA%B0%9C%EC%84%A0%EA%B8%B0-10e819a39a1d?source=rss----4505f82a2dbd---4|์›น์•ฑ ์„œ๋ฒ„ ๋กœ๊น… ๊ฐœ์„ ๊ธฐ>\n' +
    '์•ˆ๋…•ํ•˜์„ธ์š”! ๋™๋„ค์ƒํ™œํŒ€ ํ”„๋ก ํŠธ์—”๋“œ ์—”์ง€๋‹ˆ์–ด ์ธํ„ด ๋ง์ปค(Linker)์˜ˆ์š”. ์ €ํฌ ํŒ€์€ ๋™๋„ค ์ •๋ณด์™€ ์ด์•ผ๊ธฐ๋ฅผ ์ž์œ ๋กญ๊ฒŒ ๋‚˜๋ˆ„๋Š” ์ปค๋ฎค๋‹ˆํ‹ฐ, ๋™๋„ค์ƒํ™œ์„ ๋งŒ๋“ค๊ณ  ์žˆ์–ด์š”.๋™๋„ค์ƒํ™œ์€ ๋ชจ๋ฐ”์ผ ๊ธฐ๊ธฐ ์„ฑ๋Šฅ์ด ์ข‹์ง€ ์•Š๊ฑฐ๋‚˜ ์›น๋ทฐ ๋กœ๋”ฉ์ด ๋А๋ฆฐ ํ™˜๊ฒฝ์—์„œ๋„ ์‚ฌ์šฉ์ž์—๊ฒŒ ์›ํ™œํ•œ ๊ฒฝํ—˜์„ ์ œ๊ณตํ•˜๊ธฐ ์œ„ํ•ด Streaming SSR*์„ ๋„์ž…ํ–ˆ์–ด์š”. ์ด๋Ÿฐ ๊ตฌ์กฐ์—์„œ ๋””๋ฒ„๊น…์„ ์ˆ˜์›”ํ•˜๊ฒŒ ํ•˜๊ธฐ ์œ„ํ•ด ์„œ๋ฒ„ ๋กœ๊น…์„ ์ ๊ทน์ ์œผ๋กœ ํ™œ์šฉํ•ด ์™”์ฃ . ๊ทธ๋Ÿฐ๋ฐ ์„œ๋น„์Šค ๊ทœ๋ชจ๊ฐ€ ์ปค์ง€๋ฉด์„œ ์„œ๋ฒ„ ๋‚ด๋ถ€์—์„œ ์ถœ๋ ฅ๋˜๋Š” ๋กœ๊น…์ด ๋งŽ์•„์ง€์ž ๋ช‡ ๊ฐ€์ง€ ๋ฌธ์ œ๋ฅผ ์ง๋ฉดํ•˜๊ฒŒ ๋์–ด์š”.',
 // ...
}

 

๋ฉ”์‹œ์ง€ ๊ตฌ์กฐ๋ฅผ ๋œฏ์–ด๋ณด๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.

  • ์ œ๋ชฉ ์˜์—ญ์€ ๋งํฌ ํ˜•์‹์ธ ๊ฒฝ์šฐ | ๊ตฌ๋ถ„์ž๋ฅผ ํ†ตํ•ด์„œ ๋งํฌ์™€ ํ‘œ์‹œํ˜•์‹์„ ๊ตฌ๋ถ„
  • ๋ฉ”์‹œ์ง€์— ๋ณธ๋ฌธ์ด ํฌํ•จ๋œ ๊ฒฝ์šฐ์—๋Š” ์ œ๋ชฉ ์˜์—ญ์— \n(๊ฐœํ–‰๋ฌธ์ž) ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ  ์ดํ›„์— ๋ณธ๋ฌธ ๋‚ด์šฉ์ด concat ๋˜์–ด์„œ ์ „๋‹ฌ

 

์šฐ๋ฆฌ์—๊ฒŒ ํ•„์š”ํ•œ ๊ฒƒ์€ ๋ณธ๋ฌธ์ด ์•„๋‹Œ ์›๋ฌธ์˜ ๋งํฌ์ด๊ธฐ ๋•Œ๋ฌธ์— ์ ์ ˆํ•œ ํŒŒ์‹ฑ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

์šฐ์„  \n ๋ฌธ์ž๋ฅผ ๊ธฐ์ค€์œผ๋กœ split ํ•œ ๋‹ค์Œ title, content ์˜์—ญ์„ ๊ตฌ๋ถ„ํ•ด ์ค๋‹ˆ๋‹ค.

 

const [title, content] = rawMessage.split('\n')

 

์ด์ œ title ์—๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํ˜•์‹์œผ๋กœ ๋ฌธ์ž์—ด์ด ๋“ค์–ด์˜ค๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

 

<https://medium.com/daangn/%EC%9B%B9%EC%95%B1-%EC%84%9C%EB%B2%84-%EB%A1%9C%EA%B9%85-%EA%B0%9C%EC%84%A0%EA%B8%B0-10e819a39a1d?source=rss----4505f82a2dbd---4|์›น์•ฑ ์„œ๋ฒ„ ๋กœ๊น… ๊ฐœ์„ ๊ธฐ>

 

์—ฌ๊ธฐ์„œ ์‹ค์ œ ๋งํฌ ์˜์—ญ๋งŒ ์ถ”์ถœํ•˜๋Š” ๋ฐฉ๋ฒ•์œผ๋กœ ์ •๊ทœํ‘œํ˜„์‹์„ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ „๋ฐฉํƒ์ƒ‰(look-ahead)๊ณผ ํ›„๋ฐฉํƒ์ƒ‰(look-behind)์„ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

 

const sourceLink = title.match(/(?<=<).*(?=\|)/g)[0]

 

Message replies

Bolt API์—์„œ ํŠน์ • ๋ฉ”์‹œ์ง€์— ๋Œ“๊ธ€์„ ์ถ”๊ฐ€ํ•˜๋ ค๋ฉด say ํ˜ธ์ถœ ์‹œ thread_ts ๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

์—ฌ๊ธฐ์„œ thread_ts ๋Š” ๋Œ“๊ธ€์„ ์ถ”๊ฐ€ํ•  ์Šค๋ ˆ๋“œ์˜ ๊ณ ์œ  ID ๊ฐ’์ด๋ฉฐ ์ด๋Š” ๋ฉ”์‹œ์ง€ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ํ†ตํ•ด ์กฐํšŒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

 

app.message(/.*/, async ({ event, message, say }) => {
  // ...

  await say({
    text: `์ถ”์ถœ๋œ ์›๋ฌธ ๋งํฌ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™๋„ค์š”: ${sourceLink}`,
    thread_ts: message.ts,
  })
})

 

 

๋ณด๋‹ค ์ž์„ธํ•œ ๋‚ด์šฉ์€ Message ๊ณต์‹๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”.

 

๐Ÿ“Œ LLM์„ ํ™œ์šฉํ•ด์„œ ์•„ํ‹ฐํด ์š”์•ฝํ•˜๊ธฐ

LLM Model ์„ ์ •

๋ฌด๋ฃŒ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๊ณต๊ฐœ LLM API ํ”Œ๋žซํผ๋“ค์ด ๋ช‡ ๊ฐ€์ง€ ์žˆ์Šต๋‹ˆ๋‹ค.

ํฌ๊ฒŒ GitHub Models ์™€ Google AI Studio ๋ฅผ ๊ณ ๋ฏผํ•ด ๋ดค๋Š”๋ฐ, Google์—์„œ ์ œ๊ณตํ•˜๋Š” Gemini ๋ชจ๋ธ์ด Free Tier์—์„œ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” input token์˜ ๋ฒ”์œ„๊ฐ€ ๋” ์ปค์„œ ์ด๊ฑธ ํ™œ์šฉํ•ด ๋ณด๊ธฐ๋กœ ํ–ˆ์Šต๋‹ˆ๋‹ค.

 

์‚ฌ์šฉ๋ฒ•์— ๋Œ€ํ•œ ์ž์„ธํ•œ ๋‚ด์šฉ์€ ๋‹ค์Œ ๊ณต์‹ ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”.

https://ai.google.dev/gemini-api/docs

 

Gemini API  |  Google AI for Developers

Gemini Developer API ๋ฌธ์„œ ๋ฐ API ์ฐธ์กฐ

ai.google.dev

 

Prompt ๊ตฌ์„ฑ

์ตœ์ดˆ์—๋Š” Gemini์— ์—ญํ• ์„ ๋ถ€์—ฌํ•˜๊ณ  ์‘๋‹ต ์‹œ ๊ณ ๋ คํ•ด์•ผ ํ•  ์‚ฌํ•ญ, ๊ทธ๋ฆฌ๊ณ  ์‘๋‹ต ํ˜•์‹์„ ์ „๋‹ฌํ•ด ์ฃผ๊ณ  ์ฐธ์กฐํ•ด์•ผ ํ•  ๋ฌธ์„œ ๋งํฌ๋ฅผ ์ „๋‹ฌํ•ด ์ฃผ๋Š” ๋ฐฉ์‹์œผ๋กœ ์ ‘๊ทผํ–ˆ์Šต๋‹ˆ๋‹ค.

 

const response = await ai.models.generateContent({
  model: 'gemini-2.5-pro',
  contents: generatePrompt(resourceLink),
  config: {      
      systemInstruction: 'You are senior software engineer and you are summarizing a blog post for your team.', 
      thinkingConfig: {
          temperature: 0, // lower temperature for deterministic output
          thinkingBudget: 0, // disable thinking and reduce latency cause it is not needed when summarizing (maybe?) 
        },      
        tools: [{ urlContext: {} }],    
    },
})

function generatePrompt(resourceLink) {
  return `
  [Summary Guidelines]
  Please follow the guidelines below when summarizing:
    •    Read the blog post at the link provided and summarize the key points as if you’re sharing it with your team.
    •    Avoid unnecessary embellishments—keep the writing clear and concise.
    •    Use simple and clear language.
    •    Do not infer or add any information that is not explicitly mentioned in the blog post.
    • Do not include any additional information or context outside of the blog post.
    •    No greetings or closing remarks are necessary.
    •    Response in Korean.
    • Do not use bold or italics in the response.

  [Exception Handling Guidelines]
  If the blog post is not accessible or does not contain sufficient information, please respond with:
    •    "์•„ํ‹ฐํด์— ์ ‘๊ทผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."

  [Summary Format]
  Please format the summary as follows:
    • Infer Category of the blog post from content.
      •    Category should be one of the following with backticks:
        'AI', 'Web Development', 'Mobile Development', 'DevOps', 'Cloud Computing', 'Data Science', 'Cybersecurity', 'Software Engineering', 'Programming Languages', 'Frameworks and Libraries'.
    • Summarize content should be in bullet points.
      • This should be after the category.
      •    Use bullet points for each key point.
      •    Each bullet point should be concise.
    • Use headings to separate different sections of the summary.
      • Use sub-categorize with sub-headings (ex. h2, h3 ...).
      • Use sub-categorize actively to make the summary more readable.

  [Summary Example]
  Here is an example of how the summary should look. follow the same format:
  •    Category: \`AI\`  

  •    Heading 1
    - Sub-point 1
    - Sub-point 2

  •    Heading 2
    - Sub-point 1
    - Sub-point 2

  ... and so on.

  [Blog Post to Summarize]
  Read the blog post at the following link:
    •    ${resourceLink}
  `
}

 

Gemini API์—๋Š” URL Context๋ผ๋Š” ์‹คํ—˜์ ์ธ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

https://ai.google.dev/gemini-api/docs/url-context

 

URL context  |  Gemini API  |  Google AI for Developers

Live API์— ์ƒˆ๋กœ์šด ๋„ค์ดํ‹ฐ๋ธŒ ์˜ค๋””์˜ค ๋ชจ๋ธ์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ž์„ธํžˆ ์•Œ์•„๋ณด๊ธฐ ์ด ํŽ˜์ด์ง€๋Š” Cloud Translation API๋ฅผ ํ†ตํ•ด ๋ฒˆ์—ญ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์˜๊ฒฌ ๋ณด๋‚ด๊ธฐ URL context URL ์ปจํ…์ŠคํŠธ ๋„๊ตฌ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด URL ํ˜•

ai.google.dev

 

์ด๋ฅผ ํ†ตํ•ด ์‹ค์ œ ์•„ํ‹ฐํด์˜ ๋‚ด์šฉ์— ์ ‘๊ทผํ•ด์„œ ์ฝ˜ํ…์ธ ์— ๋Œ€ํ•œ ์š”์•ฝ๋ณธ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋‹ค๋งŒ ์•„์ง ์‹คํ—˜์ ์ธ ๋‹จ๊ณ„๋ผ์„œ ๊ทธ๋Ÿฐ์ง€ ์ผ๋ถ€ ์•„ํ‹ฐํด์€ ์œ ํšจํ•œ ์ ‘๊ทผ์„ ํ•˜์ง€ ๋ชปํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

 

 

url-context ๋„๊ตฌ๋ฅผ ํ†ตํ•ด ์•„ํ‹ฐํด์— ์ ‘๊ทผ์ด ๋ถˆ๊ฐ€๋Šฅํ•œ ์ƒํƒœ๋ผ๋ฉด ์ง์ ‘ ์•„ํ‹ฐํด์„ ํฌ๋กค๋งํ•ด์„œ ์š”์•ฝ๋ณธ์„ ์ƒ์„ฑํ•˜๊ณ  ์‹ถ์—ˆ์Šต๋‹ˆ๋‹ค.

function calling์„ ํ†ตํ•ด์„œ ๊ธฐ๋Šฅ์„ ์„ธ๋ถ„ํ™”ํ•˜๋ ค๊ณ  ํ–ˆ๋Š”๋ฐ, Gemini API๋Š” ํ˜„์žฌ function calling๊ณผ URL context(๋˜๋Š” tool) ๊ธฐ๋Šฅ์„ ๋™์‹œ์— ํ•œ ์š”์ฒญ์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋Š” ์ œ์•ฝ์‚ฌํ•ญ์ด ์žˆ์Šต๋‹ˆ๋‹ค.

 

await ai.models.generateContent({
    model: 'gemini-2.5-flash',
    config: {
        tools: [{ urlContext: {}, functionDeclarations: [someFunction] }] // โŒ
    }
})

 

์ด๋Ÿฌํ•œ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด์„œ ํ”„๋กฌํ”„ํŠธ ๋‹จ๊ณ„๋ฅผ ๋‚˜๋ˆ„๋Š” ๋ฐฉ์‹์œผ๋กœ ์ ‘๊ทผํ–ˆ์Šต๋‹ˆ๋‹ค.

  1. ๋จผ์ € url-context tool์„ ์ด์šฉํ•ด์„œ ํ•ด๋‹น ์ฝ˜ํ…์ธ ์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋Š”์ง€ ํŒ๋‹จํ•œ๋‹ค.
  2. ์ ‘๊ทผ์ด ๊ฐ€๋Šฅํ•œ ์ƒํƒœ๋ผ๋ฉด, ๊ธฐ์กด url-context๋ฅผ ํ™œ์šฉํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ์š”์•ฝ
  3. ์ ‘๊ทผ์ด ์–ด๋ ค์šด ์ƒํƒœ๋ผ๋Š” ์‘๋‹ต ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ›์œผ๋ฉด ์ง์ ‘ ํฌ๋กค๋งํ•ด์„œ ์š”์•ฝ๋ณธ์„ ํ”„๋กฌํ”„ํŠธ์— ์ „๋‹ฌ
  4. ํฌ๋กค๋Ÿฌ๋กœ ์ ‘๊ทผ์ด ์•ˆ ๋˜๋Š” ์ƒํƒœ๋ผ๋ฉด ์š”์•ฝ๋ถˆ๊ฐ€ ์ฒ˜๋ฆฌ

 

const response = await ai.models.generateContent({
    /** */
})

if (isFailureResponse(response)) {
    /** ํฌ๋กค๋ง ํ›„ ์•„ํ‹ฐํด ์ž„๋ฒ ๋”ฉํ•˜์—ฌ ์žฌ์š”์ฒญ */
} else {
    /** ์ •์ƒ์‘๋‹ต ๋ฐ˜ํ™˜ */
}

 

crawlee ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์ด์šฉํ•ด์„œ ๊ฐ„๋‹จํ•œ ํฌ๋กค๋ง ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค.

 

export async function crawling({ url, onFinish, onFailure }) {
  const crawler = createCrawler({ onFinish, onFailure })

  await crawler.run([url])
}

function createCrawler({ onFinish, onFailure }) {
  const config = new Configuration({
    /**
     * ๐Ÿ’ก NOTE:
     * Do not persist storage in this crawler.
     * This is because the crawler is used in a serverless environment,
     * and we do not want to persist any data between invocations.
     * If you need to persist data, use a different crawler with a persistent storage configuration.
     */
    persistStorage: false,
  })
  const crawler = new CheerioCrawler(
    {
      async requestHandler({ $ }) {
        const article = $('article').text()

        onFinish(article)
      },
      async failedRequestHandler() {
        onFailure()
      },
    },
    config
  )

  return crawler
}

 

์„œ๋ฒ„๋ฆฌ์Šค ํ™˜๊ฒฝ์—์„œ ๊ฐ invokation ๊ฐ„์— ๋ฐ์ดํ„ฐ๋ฅผ ์˜์†์ ์œผ๋กœ ์œ ์ง€ํ•  ํ•„์š”๋Š” ์—†๊ธฐ ๋•Œ๋ฌธ์— persistStorage ์˜ต์…˜์€ ์‚ฌ์šฉํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

Slack Event API ์—๋Š” ์‘๋‹ต ์ง€์—ฐ์‹œ๊ฐ„ 3์ดˆ ์ด๋‚ด์— 2xx status code๋กœ ์‘๋‹ตํ•˜์ง€ ์•Š์œผ๋ฉด ์ด๋ฒคํŠธ ์‘๋‹ต ์‹คํŒจ๋กœ ๊ฐ„์ฃผํ•˜๊ณ  3๋ฒˆ ์žฌ์‹œ๋„ํ•ฉ๋‹ˆ๋‹ค.

 

์ด๋Ÿฌํ•œ ์ œ์•ฝ ๋•Œ๋ฌธ์— ์•„ํ‹ฐํด์„ ์กฐํšŒํ•˜๊ณ  ์ฝ˜ํ…์ธ ๋ฅผ ์š”์•ฝํ•˜๋Š” ๊ณผ์ •์ด ๊ธธ์–ด์ง€๋Š” ๊ฒฝ์šฐ ์˜๋„ํ•˜์ง€ ์•Š๊ฒŒ Lambda ํ•จ์ˆ˜๊ฐ€ ์žฌ์‹œ๋„๋˜๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

Lambda๋Š” ์‹คํ–‰ ์‹œ๊ฐ„๊ณผ ํšŸ์ˆ˜์— ๋”ฐ๋ผ ๊ณผ๊ธˆ๋˜๊ธฐ ๋•Œ๋ฌธ์— ์ด ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•  ํ•„์š”๊ฐ€ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

 

์š”์ฒญ์— ๋Œ€ํ•œ ์‘๋‹ต์„ ์ฒ˜๋ฆฌํ•˜๋Š” ํ”„๋กœ์„ธ์Šค์™€ ์‹ค์ œ ์š”์•ฝ์„ ์ฒ˜๋ฆฌํ•˜๋Š” ํ”„๋กœ์„ธ์Šค๋ฅผ ๋ถ„๋ฆฌํ•˜๋Š” ๋“ฑ์œผ๋กœ ํ•ด๊ฒฐํ•  ์ˆ˜๋„ ์žˆ์ง€๋งŒ ๋” ๋‹จ์ˆœํ•˜๊ฒŒ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด์„œ Message ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ์—์„œ ์ „๋‹ฌ๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” ์žฌ์‹œ๋„ ํšŸ์ˆ˜๋ฅผ ์ฐธ๊ณ ํ•˜๊ธฐ๋กœ ํ–ˆ์Šต๋‹ˆ๋‹ค.

Slack Event API๋Š” ์š”์ฒญ ์žฌ์‹œ๋„ ์‹œ์— HTTP ํ—ค๋”์— x-slack-retry-num ๋ฅผ ์ถ”๊ฐ€ํ•ด์„œ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  ์ด ๊ฐ’์€ bolt sdk ๋‚ด์—์„œ๋Š” context.retryNum ์œผ๋กœ ์ฐธ์กฐ๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

 

// bolt-js ์†Œ์Šค์ฝ”๋“œ
// https://github.com/slackapi/bolt-js/blob/9337e0616f058f1459ef1ad922dfba38048dac3b/src/receivers/HTTPModuleFunctions.ts#L13

export const extractRetryNumFromHTTPRequest = (req: IncomingMessage): number | undefined => {
  let retryNum: number | undefined;
  const retryNumHeaderValue = req.headers['x-slack-retry-num'];
  if (retryNumHeaderValue === undefined) {
    retryNum = undefined;
  } else if (typeof retryNumHeaderValue === 'string') {
    retryNum = Number.parseInt(retryNumHeaderValue, 10);
  } else if (Array.isArray(retryNumHeaderValue) && retryNumHeaderValue.length > 0) {
    retryNum = Number.parseInt(retryNumHeaderValue[0], 10);
  }
  return retryNum;
};

 

context.retryNum ๊ฐ€ 0๋ณด๋‹ค ํฐ ๊ฒฝ์šฐ ์žฌ์‹œ๋„ ์š”์ฒญ์œผ๋กœ ๊ฐ„์ฃผํ•˜๊ณ  early-return ํ•ฉ๋‹ˆ๋‹ค.

 

app.message(/.*/, async ({ context, event, message, say }) => {
  const isRetry = isRetryEvent(context)

  if (isRetry) {
    // If this is a retry event, we do not want to process it again.
    // This is to prevent duplicate processing of the same event.
    return
  }

  // ...
}

function isRetryEvent(context) {
  const isRetry = (context.retryNum ?? 0) > 0

  return isRetry
}

 

๐Ÿ“Œ Slack Bot ๋ฐฐํฌํ•˜๊ธฐ

serverless framework

AWS Lambda๋ฅผ ํ†ตํ•ด์„œ ์„œ๋ฒ„๋ฆฌ์Šค ํ™˜๊ฒฝ์„ ์ง์ ‘ ๊ตฌ์ถ•ํ•˜๋Š” ๋Œ€์‹  AWS CloudFormation์„ ํ†ตํ•ด์„œ ํ…œํ”Œ๋ฆฟ ํŒŒ์ผ์„ ํ†ตํ•ด ๋ฆฌ์†Œ์Šค๋ฅผ ํ• ๋‹น ๋ฐ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•์„ ์ œ๊ณตํ•ด ์ค๋‹ˆ๋‹ค.

serverless.yaml ํŒŒ์ผ์„ ์ƒ์„ฑํ•œ ๋’ค์— ํ•„์š”ํ•œ ๋ฆฌ์†Œ์Šค ํƒ€์ž…์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค.

 

service: slack-bot-clippy

frameworkVersion: '4'

useDotenv: true

provider:
  name: aws
  runtime: nodejs22.x
  region: ap-northeast-2
  environment:
    SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET}
    SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN}
    AWS_CLIENT_TIMEOUT: 30000
    GEMINI_API_KEY: ${env:GEMINI_API_KEY}

functions:
  slack:
    handler: app.handler
    maximumRetryAttempts: 0
    timeout: 20
    events:
      - http:
          path: slack/events
          method: post
plugins:
  - serverless-offline

 

๋กœ์ปฌํ™˜๊ฒฝ์—์„œ AWS Lambda ๋™์ž‘์„ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด์„œ serverless-offline ํ”Œ๋Ÿฌ๊ทธ์ธ์„ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํ•ด๋‹น ํ”Œ๋Ÿฌ๊ทธ์ธ์€ ๋กœ์ปฌํ™˜๊ฒฝ์—์„œ API Gateway์™€ Lambda ๋™์ž‘์„ ์—๋ฎฌ๋ ˆ์ด์…˜ํ•ฉ๋‹ˆ๋‹ค.

๋ฐฐํฌ๋Š” ๋‹ค์Œ ์ปค๋ฉ˜๋“œ๋กœ ๊ฐ„ํŽธํ•˜๊ฒŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

 

serverless deploy

 

 

์ƒ์„ฑ๋œ endpoint๋ฅผ Slack App ์„ค์ •์—์„œ Event Subscription Request URL๋กœ ์ง€์ •ํ•ด ์ฃผ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

ํ•ด๋‹น endpoint๋Š” API Gateway๋ฅผ ํ†ตํ•ด ์ œ๊ณต๋˜๋Š” API Endpoint๊ฐ€ ๋ฉ๋‹ˆ๋‹ค.

 

 

์ดํ›„์— RSS ๊ตฌ๋… ์ค‘์ธ ์ฑ„๋„์— clippy ๋ด‡์„ ์ถ”๊ฐ€ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

 

๐Ÿ“Œ ๋งˆ๋ฌด๋ฆฌ

 

 

์ด๋ฒˆ ๊ธฐํšŒ์— AWS Lambda ํ™˜๊ฒฝ์„ ์ง์ ‘ ๊ตฌ์„ฑํ•˜๊ณ , LLM ๋ชจ๋ธ์„ ํ™œ์šฉํ•œ ๊ฐ„๋‹จํ•œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜๋„ ๋งŒ๋“ค์–ด๋ดค์Šต๋‹ˆ๋‹ค.

๋น„๊ต์  ๋‹จ์ˆœํ•œ ์š”์•ฝ ์ž‘์—…์ด๋ผ์„œ ํ”„๋กฌํ”„ํŠธ ํ•œ๋‘ ์ค„๋กœ ๋๋‚  ์ค„ ์•Œ์•˜๋Š”๋ฐ, ์›ํ•˜๋Š” ํ˜•ํƒœ๋กœ ์‘๋‹ต์„ ์ •ํ™•ํ•˜๊ฒŒ ์–ป์–ด๋‚ด๋ ค๋ฉด ์˜ˆ์ƒ๋ณด๋‹ค ํ›จ์”ฌ ์„ธ๋ฐ€ํ•œ ํ”„๋กฌํ”„ํŠธ ์„ค๊ณ„๊ฐ€ ํ•„์š”ํ–ˆ์Šต๋‹ˆ๋‹ค.

๋ฐ˜์‘ํ˜•

๐Ÿ’ฌ ๋Œ“๊ธ€