๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
๐Ÿ–ฅ๏ธ TurtleMartํ”„๋กœ์ ํŠธ/โœ๏ธ TIL

[TIL] ํ”„๋กœ์ ํŠธ 15์ผ์ฐจ.. ํ”„๋กœ์ ํŠธ ๋ฆฌํŒฉํ† ๋ง ๋ฐ ๊ณ ๋„ํ™” ๊ณ ๋ฏผํ•˜๊ธฐ!

by carrot0911 2025. 6. 4.

๐ŸŒž ์˜ค๋Š˜์€ ์–ด๋–ค ํ•˜๋ฃจ์˜€์ง€..

์˜ค๋Š˜์€ ํŒ€์›๋ถ„์ด ํ’€๋ฆฌํ€˜ ์˜ฌ๋ฆฌ๊ณ  merge ๋œ ์ฝ”๋“œ๋ฅผ pull ๋ฐ›์•„์„œ ๋‚ด ์ฝ”๋“œ๋ฅผ ์˜ฎ๊ธฐ๋Š” ์ž‘์—…์„ ํ–ˆ๋‹ค.

์ง€๊ธˆ๊นŒ์ง€๋Š” ๋‚ด๊ฐ€ ๊ฐœ์ธ์ ์œผ๋กœ ํ…Œ์ŠคํŠธ Controller๋ฅผ ๋งŒ๋“ค๊ณ  ๊ฑฐ๊ธฐ์„œ ์ž‘์—…์„ ์ง„ํ–‰ํ•˜๊ณ  ์žˆ์—ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ด์ œ๋Š” ํŒ€์›์˜ ์ฝ”๋“œ์™€ ํ•ฉ์ณ์•ผ ํ•˜๋‹ˆ ๋‚ด ์ฝ”๋“œ๊ฐ€ ์ž˜ ๋™์ž‘ํ•  ์ˆ˜ ์žˆ๋„๋ก ์˜ฎ๊ธฐ๋Š” ์ž‘์—…์„ ํ–ˆ๋‹ค!
์™„๋ฒฝํ•˜๊ฒŒ ๋‹ค ์˜ฎ๊ธฐ๋ ค๊ณ  ํ–ˆ์ง€๋งŒ ๊ฒฐ์ œ ํŒŒํŠธ์—์„œ ๋„˜์–ด์˜ค๋Š” DTO๋ฅผ ์˜ˆ์ƒํ•  ์ˆ˜ ์—†์–ด์„œ ๋กœ์ง์„ ๋‹ค ์ž‘์„ฑํ•˜์ง€ ๋ชปํ•˜๊ณ  ์šฐ์„ ์€ ํ‹€๋งŒ ์žก์•„๋‘๊ณ  ํŒ€์›์˜ ์–‘์‹๋Œ€๋กœ ๋‚˜๋จธ์ง€ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ–ˆ๋‹ค.

case INVENTORY_DECREASE -> {
    // todo ๊ฒฐ์ œ ํŒŒํŠธ์—์„œ ์ „๋‹ฌ๋˜๋Š” payload ๊ตฌ์กฐ ํ™•์ธ ํ›„ DTO ์ •์˜
    // log.info("๐Ÿ“ฅ Kafka ์žฌ๊ณ  ๊ฐ์†Œ ๋ฉ”์‹œ์ง€ ์ˆ˜์‹ : {}", message);

    // ์žฌ๊ณ  ๊ฐ์†Œ ๋กœ์ง ์ง„ํ–‰
    // productOptionCombinationService.decreaseProductOptionCombinationInventory(payload.orderId());
    // log.info("๐Ÿ‘‰ ์žฌ๊ณ  ๊ฐ์†Œ ์„ฑ๊ณต! ๋ชจ๋“  ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ฐจ๊ฐ์ด ์ •์ƒ์ ์œผ๋กœ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.");

    // todo ๋ฐฐ์†ก ์ƒ์„ฑ ๋ฉ”์‹œ์ง€ ๋ฐœํ–‰
    // kafkaTemplate.send(deliveryTopic, request);
    // log.info("\uD83D\uDCE4 Kafka ๋ฐฐ์†ก ์ƒ์„ฑ ๋ฉ”์‹œ์ง€ ์ „์†ก: {}", request);
}
@Slf4j
@Component
@RequiredArgsConstructor
public class DeliveryKafkaListener {

    private final DeliveryService deliveryService;

    @KafkaListener(topics = "${kafka.topic.delivery}", groupId = "${spring.kafka.consumer.delivery.group-id}")
    public void listenDeliveryCreate(@Header(KafkaHeaders.RECEIVED_KEY) String key, String value) {
        // ๋ฉ”์‹œ์ง€ ์ „์ฒด๋ฅผ OperationWrapperDto๋กœ ํŒŒ์‹ฑ
        OperationWrapperDto wrapperDto = JsonHelper.fromJson(value, OperationWrapperDto.class);

        // payload ํ•„๋“œ ์ถ”์ถœ
        String payload = wrapperDto.payload();

        // payload๋ฅผ CreateDeliveryRequest๋กœ ๋‹ค์‹œ ํŒŒ์‹ฑ
        // todo CreateDeliveryRequest ๋Œ€์‹  WrapperRequest๋กœ ๋‹ค์‹œ ์ž‘์„ฑ ํ•„์š”
        CreateDeliveryRequest request = JsonHelper.fromJson(payload, CreateDeliveryRequest.class);
        log.info("๐Ÿ“ฅ Kafka ๋ฐฐ์†ก ์ƒ์„ฑ ๋ฉ”์‹œ์ง€ ์ˆ˜์‹ : {}", request);

        deliveryService.createDelivery(request);
        log.info("๐Ÿ‘‰ ๋ฐฐ์†ก ์ƒ์„ฑ ์„ฑ๊ณต! ๋ฐฐ์†ก ์ƒ์„ฑ์ด ์ •์ƒ์ ์œผ๋กœ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.");
    }
}

๊ทธ๋‹ค์Œ์€ ํ’€๋ฆฌํ€˜ ํ”ผ๋“œ๋ฐฑ๋ฐ›์€ ๋ถ€๋ถ„์„ ์ˆ˜์ •ํ–ˆ๋‹ค!

KafkaProducerConfig์—์„œ ProducerFactory<String, KafkaMessage<InventoryDecreasePayload>>๋กœ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์—ˆ๋Š”๋ฐ, ProducerFactory<String, Object>๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด์„œ ํ•˜๋‚˜๋กœ ํ†ต์ผํ•ด ์ฃผ์—ˆ๋‹ค.

๋˜ ์ค‘๋ณต์œผ๋กœ ์‚ฌ์šฉ๋˜๋Š” ๋ถ€๋ถ„์„ ๋ฉ”์„œ๋“œ๋กœ ์ถ”์ถœํ–ˆ๋‹ค.

Map<String, Object> configProps = new HashMap<>();
configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);

์ด ๋ถ€๋ถ„์ด ๊ณ„์† ๊ฒน์ณ์„œ ์‚ฌ์šฉํ•˜๊ธฐ ๋•Œ๋ฌธ์— ๋ฉ”์„œ๋“œ๋กœ ์ถ”์ถœํ•ด์„œ ๊ด€๋ฆฌํ•ด ์ค˜๋„ ๊ดœ์ฐฎ์„ ๊ฑฐ ๊ฐ™๋‹ค๊ณ  ํ•ด์„œ ๋”ฐ๋กœ ๋ฉ”์„œ๋“œ๋กœ ์ถ”์ถœํ–ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ ํ๋ฆ„์„ ์ข€ ๋” ๊ณ ๋ฏผํ•˜๋Š” ์‹œ๊ฐ„์„ ๊ฐ€์กŒ๋‹ค.
์ง€๊ธˆ์€ Kafka๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์ง€๋งŒ ๋ญ”๊ฐ€ ๋™๊ธฐ ๋А๋‚Œ์ด ์žˆ์–ด์„œ ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ฒƒ์ด ๋” ํšจ์œจ์  ์ผ์ง€ ๊ณ ๋ฏผํ•ด ๋ณด๊ณ , ๊ทธ๊ฒƒ๋„ ๋จธ๋ฆฌ์—์„œ ์ž˜ ์žกํžˆ์ง€ ์•Š์•„์„œ Canva์—์„œ ์ง€๊ธˆ ๊ตฌ์กฐ๋ฅผ ๊ทธ๋ ค๊ฐ€๋ฉด์„œ ์ •๋ฆฌํ•˜๊ณ  ํ๋ฆ„์„ ์žก์•„๊ฐ”๋‹ค.

๊ทธ ๊ฒฐ๊ณผ ์Šฌ๋ž™ ์•Œ๋ฆผ ๋ฉ”์‹œ์ง€๋ฅผ ํ†ตํ•ด ์˜ˆ์™ธ ์ƒํ™ฉ์„ ์•Œ๋ ค์ฃผ๋ฉด ์ข‹์„ ๊ฒƒ ๊ฐ™๋‹ค๋Š” ์•„์ด๋””์–ด๊ฐ€ ๋– ์˜ฌ๋ผ์„œ ์Šฌ๋ž™ ์•Œ๋ฆผ์— ๋Œ€ํ•ด ์ฐพ์•„๋ณด๊ณ  ํ•˜๋ฃจ๋ฅผ ๋งˆ๋ฌด๋ฆฌํ–ˆ๋‹ค.

 

๐Ÿ’ก ์ƒˆ๋กญ๊ฒŒ ์•Œ๊ฒŒ ๋œ ๋‚ด์šฉ์€ ๋ญ๊ฐ€ ์žˆ๋”๋ผ..?!

โœ…  ์›นํ›…(Webhook)์ด๋ž€?

์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์ž๋™ HTTP ์š”์ฒญ ์‹œ์Šคํ…œ

์›นํ›…์€ ๋ง ๊ทธ๋Œ€๋กœ "์›น(Web)์— ๊ฐˆ๊ณ ๋ฆฌ(Hook)๋ฅผ ๊ฑธ์–ด๋‘”๋‹ค"๋Š” ์˜๋ฏธ์ด๋‹ค.
์ฆ‰, ํŠน์ • ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ, ์„ค์ •ํ•ด ๋‘” URL๋กœ ์ž๋™์œผ๋กœ HTTP ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ๊ฒƒ์ด ์›นํ›…์ด๋‹ค.

โœ…  ์™œ ์‚ฌ์šฉํ•˜๋Š” ๊ฑธ๊นŒ?

โ“ ๊ธฐ์กด ๋ฐฉ์‹

  • ์ผ์ • ์ฃผ๊ธฐ๋งˆ๋‹ค ์„œ๋ฒ„๊ฐ€ ๊ณ„์† ๋ฌผ์–ด๋ด์•ผ ํ•ด.
  • ์˜ˆ: "์ƒˆ ์ฃผ๋ฌธ ์ƒ๊ฒผ์–ด?", "์—…๋ฐ์ดํŠธ ๋์–ด?"๋ฅผ 5์ดˆ๋งˆ๋‹ค ๊ณ„์† ์š”์ฒญ

โœ… ์›นํ›… ๋ฐฉ์‹

  • ์ด๋ฒคํŠธ๊ฐ€ ์‹ค์ œ๋กœ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ๋งŒ ์š”์ฒญ์„ ๋ณด๋ƒ„
  • ์„œ๋ฒ„ ๋ถ€ํ•˜ ๊ฐ์†Œ, ๋น ๋ฅธ ์‘๋‹ต, ์‹ค์‹œ๊ฐ„ ์ฒ˜๋ฆฌ์— ๋งค์šฐ ์œ ๋ฆฌ

โœ…  ์›นํ›… ๋™์ž‘ ๊ตฌ์กฐ

[์–ด๋–ค ์„œ๋น„์Šค] --- ์ด๋ฒคํŠธ ๋ฐœ์ƒ ---> [๋„ˆ์˜ ์„œ๋ฒ„๋กœ HTTP POST ์š”์ฒญ]

์˜ˆ์‹œ: GitHub ์›นํ›…

  • GitHub ์ €์žฅ์†Œ์—์„œ Push ๋ฐœ์ƒ → ๋“ฑ๋กํ•œ ์„œ๋ฒ„์˜ /github/webhook์— ์ž๋™์œผ๋กœ POST ์ „์†ก

์˜ˆ์‹œ: Slack ์›นํ›…

  • ๋„ˆ์˜ ์„œ๋ฒ„์—์„œ ๋ฐฐ์†ก ์‹คํŒจ ๋ฐœ์ƒ → Slack์ด ๋ฐœ๊ธ‰ํ•œ Webhook URL๋กœ POST → ์•Œ๋ฆผ ์ „์†ก

โœ…  ์›นํ›… vs API

๊ตฌ๋ถ„ Webhook API
์ž‘๋™ ๋ฐฉ์‹ ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์ž๋™ ์ „์†ก ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์š”์ฒญํ•ด์•ผ ์‘๋‹ต
๋ฐ์ดํ„ฐ ํ๋ฆ„ ์„œ๋ฒ„ โž ํด๋ผ์ด์–ธํŠธ ํด๋ผ์ด์–ธํŠธ โž ์„œ๋ฒ„
์˜ˆ์‹œ "๋ฐฐ์†ก ์‹คํŒจ" ์•Œ๋ฆผ ์ž๋™ ์ „์†ก ํ”„๋ก ํŠธ์—์„œ /delivery API ํ˜ธ์ถœ

โœ…  ์›นํ›…์ด ์‚ฌ์šฉํ•˜๋Š” ๊ธฐ์ˆ 

  • ๋Œ€๋ถ€๋ถ„ HTTP POST ์š”์ฒญ
  • ๋ณธ๋ฌธ์€ ์ผ๋ฐ˜์ ์œผ๋กœ JSON
  • ํ—ค๋”์— ์ธ์ฆ ํ† ํฐ์ด๋‚˜ ์‹œ๊ทธ๋‹ˆ์ฒ˜๋ฅผ ํฌํ•จํ•ด์„œ ๋ณด์•ˆ ์œ ์ง€
POST /webhook HTTP/1.1
Content-Type: application/json

{
  "event": "DELIVERY_FAILED",
  "orderId": 12345,
  "reason": "์žฌ๊ณ  ์—†์Œ"
}

 

โœ…  Slack Webhook ์˜ˆ์‹œ

Slack์€ Incoming Webhook ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ด.

  • ์‚ฌ์šฉ์ž๊ฐ€ ๋งŒ๋“  ๋ฉ”์‹œ์ง€๋ฅผ Slack ์ฑ„๋„๋กœ ์ž๋™ ์ „์†ก ๊ฐ€๋Šฅ
  • Slack์ด ๋ฐœ๊ธ‰ํ•˜๋Š” Webhook URL๋กœ ๋ฉ”์‹œ์ง€๋ฅผ POST ํ•˜๋ฉด ์ฑ„๋„์— ๋œธ
POST <https://hooks.slack.com/services/T000/B000/abcxyz>
Content-Type: application/json

{
  "text": "โŒ ๋ฐฐ์†ก ์‹คํŒจ: ์ฃผ๋ฌธ ๋ฒˆํ˜ธ 12345"
}

 

โœ…  ์›นํ›… ๊ตฌ์„ฑ ์š”์†Œ

๊ตฌ์„ฑ ์š”์†Œ ์„ค๋ช…
์ด๋ฒคํŠธ ํŠธ๋ฆฌ๊ฑฐ๊ฐ€ ๋˜๋Š” ํ–‰์œ„ (์˜ˆ: ์ฃผ๋ฌธ ์‹คํŒจ, GitHub push ๋“ฑ)
Webhook URL ์ด๋ฒคํŠธ ๋ฐœ์ƒ ์‹œ ํ˜ธ์ถœํ•  ์™ธ๋ถ€ ์ฃผ์†Œ (๋ฐ›๋Š” ์ชฝ ์„œ๋ฒ„ ์ฃผ์†Œ)
Payload ํ•จ๊ป˜ ์ „์†กํ•  ๋ฐ์ดํ„ฐ (JSON ๋“ฑ)
Header ๋ณดํ†ต Content-Type์ด๋‚˜ ์ธ์ฆ ํ† ํฐ ํฌํ•จ

โœ…  ์›นํ›…์„ ์‚ฌ์šฉํ•˜๋Š” ๋Œ€ํ‘œ์ ์ธ ์„œ๋น„์Šค๋“ค

์„œ๋น„์Šค Webhook ์‚ฌ์šฉ ์‚ฌ๋ก€
GitHub ์ฝ”๋“œ Push, PR, Issue ์ƒ์„ฑ ์‹œ ์•Œ๋ฆผ ์ „์†ก
Slack ์™ธ๋ถ€ ์•ฑ์ด Slack ์ฑ„๋„์— ๋ฉ”์‹œ์ง€ ์ „์†ก
Stripe ๊ฒฐ์ œ ์„ฑ๊ณต/์‹คํŒจ ์‹œ ๋„ˆ์˜ ์„œ๋ฒ„๋กœ ์•Œ๋ฆผ
KakaoPay, Toss ๊ฒฐ์ œ ์Šน์ธ/์ทจ์†Œ ์ด๋ฒคํŠธ ์ „์†ก
Notion ํŽ˜์ด์ง€ ๋ณ€๊ฒฝ ์ด๋ฒคํŠธ ์ „์†ก (๋ฒ ํƒ€)

โœ…  ์‹ค๋ฌด์—์„œ์˜ ์‚ฌ์šฉ ์˜ˆ์‹œ (Spring ๊ธฐ์ค€)

@RestController
@RequestMapping("/webhook")
public class SlackWebhookReceiver {

    @PostMapping
    public ResponseEntity<Void> receiveWebhook(@RequestBody WebhookPayload payload) {
        System.out.println("๐Ÿ“ฉ ๋ฐ›์€ ์›นํ›…: " + payload);
        return ResponseEntity.ok().build();
    }
}

๋˜๋Š” ์•Œ๋ฆผ์„ ๋ณด๋‚ด๋Š” ์—ญํ• ์ด๋ผ๋ฉด:

restTemplate.postForEntity("<https://hooks.slack.com/>...", request, String.class);

 

โœ…  ์ฃผ์˜ํ•  ์  (๋ณด์•ˆ)

  • Webhook URL์€ ๋…ธ์ถœ๋˜๋ฉด ๋ˆ„๊ตฌ๋“  ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด๋‚ผ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ๋น„๊ณต๊ฐœ ์œ ์ง€ํ•ด์•ผ ํ•จ
  • Slack์ด๋‚˜ GitHub ๋“ฑ์€ ๋ณดํ†ต ์„œ๋ช…(signature)์„ ํ•จ๊ป˜ ๋ณด๋‚ด์„œ ์š”์ฒญ์˜ ์ง„์œ„๋ฅผ ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ์Œ

โœ… ํ•œ ๋ฌธ์žฅ ์š”์•ฝ

์›นํ›…์€ "์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ ์ž๋™์œผ๋กœ ์•Œ๋ฆผ์„ ์™ธ๋ถ€ ์‹œ์Šคํ…œ์— ๋ณด๋‚ด์ฃผ๋Š” ๊ธฐ์ˆ "์ด์•ผ.

 

๐Ÿ—“๏ธ ๋‚ด์ผ์€ ๋ญ ํ•˜์ง€?!

โœ”๏ธ Slack ์•Œ๋ฆผ ๊ตฌํ˜„ํ•˜๊ธฐ
โœ”๏ธ TIL ์ž‘์„ฑํ•˜๊ธฐ