데보션앱 소개페이지 바로가기
로그인 선택

신고하기

CLOSE
신고사유 (대표 사유 1개)
상세내용 (선택)
0/200
  • 신고한 게시글은 더 이상 보이지 않습니다.
  • 이용약관과 운영정책에 따라 신고사유에 해당하는지 검토 후 조치됩니다.
  • 허위 신고인 경우, 신고자의 서비스 이용이 제한될 수 있으니 유의하시어 신중하게 신고해 주세요.
(이 회원이 작성한 모든 댓글과 커뮤니티 게시물이 보이지 않고, 알림도 오지 않습니다.)

미리보기

커뮤니티

      1,234

      badge 23.06.15

      글 등록

      카테고리를 선택해주세요.

      DEVOTEE를 활성화 시키면
      지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.

      버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.

      임시저장함에 저장되었습니다. 저장일시 : 2022.5.17 14:29:08

      임시저장함

      제목을 선택하시면 이어서 작성이 가능하며,
      최대 20건까지 저장합니다.
      컨텐츠 유형, 제목, 저장일시, 삭제로 이뤄진 임시저장 목록
      컨텐츠 유형 제목 저장일 삭제

      데보션 블로그 게재 요청

      CLOSE
      • *
      • *

      본인인증

      효율적인 데보션 서비스 이용 및
      고객님의 소중한 개인정보보호를 위해
      본인인증을 진행해주세요. 본인인증 미 진행 시 로그인이 제한됩니다.
      본인인증 실패

      본인인증 로그인에 실패하였습니다.
      회원이 아니시거나 본인인증 등록이
      완료되지 않은 사용자입니다.

      회원정보 연결

      WAF 자동 차단 에이전트 개발기 - 공격자가 차단 대상을 고를 수 있었던 이유

      꼬마집사 26.08.24
      124 2 2
      DEVOTEE 요약
      데보션 운영팀은 보안 관제 티켓을 기반으로 웹 공격자 IP를 자동 차단하는 시스템을 구축하며, 속도보다 '자사 IP 오차단 방지'를 최우선으로 한 안전 실패(fail-safe) 구조를 설계했습니다. 개발 및 리뷰 과정에서 공격자가 주입한 문자열로 인한 DoS 위협 등 다양한 결함이 발견되었으나, 본문 파싱 대신 정형화된 필드를 읽고 코드로 allowlist를 강제하여 보안 허점을 근본적으로 해결했습니다. 그 결과 실제 배포 후 수많은 초기 전제가 깨졌음에도 실패 시 사람에게 넘어가는 철저한 안전장치 덕분에, 단 한 건의 장애 없이 새벽 시간대의 공격을 즉각 차단하는 성과를 거두었습니다.

      새벽 4시 53분,아무도 안 깨어 있을 때

      보안관제센터가 공격 탐지 티켓을 올렸습니다.

      5분 뒤 공격자 IP는 두 개의 방화벽에 등록됐고, 티켓에는 조치 완료 댓글이 달렸습니다. 사람은 자고 있었습니다.

      04:53:56 차단 완료 : 130.49.215.90/32

      05:39:04 차단 완료 : 122.10.17.49/32


      개발자 커뮤니티 데보션(DEVOCEAN) 을 운영하다 보면 웹 공격 시도가 꾸준히 들어옵니다. SQL 인젝션, 검증되지 않은 리다이렉트, 파라미터 조작 같은 것들입니다.


      보안관제센터가 이런 공격을 탐지하면 JIRA로 티켓이 올라옵니다. 담당자는 이렇게 대응합니다.

      1. 티켓에서 출발지(공격자) IP를 확인합니다.

      2. AWS WAF의 IP set에 그 IP를 추가합니다.

      3. 티켓에 "차단했습니다" 댓글을 답니다.

      단순 반복 작업입니다. 자동화하기로 했습니다.


      그런데 이 작업의 난이도는 "IP를 넣는 것"에 있지 않습니다. "잘못 넣지 않는 것"에 있습니다. 자사 IP를 실수로 차단하면 그 순간 서비스 장애입니다.

      스크린샷 2026-08-22 오후 11.35.49.png

      이 글은 그 자동화를 만들면서 겪은 일의 기록입니다.

      결론부터 말하면, 구현 시점에 세운 가정은 일곱 개가 틀렸고 보안 리뷰에서는 실제로 악용 가능한 결함이 나왔습니다.

      그런데도 운영에서 사고는 한 건도 없었습니다. 그 이유가 이 글의 핵심입니다.


      1. 설계: 자동화가 가장 많이 해야 하는 일은 '막지 않는 것'

      차단 자동화라고 하면 보통 "얼마나 빨리 막느냐"를 떠올립니다.

      실제로는 반대입니다. 잘못 막았을 때의 비용이 압도적으로 크기 때문에, 설계의 대부분이 거부 조건에 들어갑니다.


      IP 하나가 차단되기까지 통과해야 하는 관문을 여섯 개 두었습니다.

      관문

      거부 조건

      allowlist

      자사 공인 대역·모니터링·파트너 IP

      예약 대역

      사설(RFC1918), 루프백, 링크로컬, CGNAT

      중복

      이미 등록된 IP

      CIDR 표기

      203.0.113.0/24 처럼 대역으로 기재된 경우

      티켓당 상한

      한 티켓에서 N개 초과

      실행당 상한

      한 주기에서 N개 초과

      01-gates.png

      여기에 더해 dry_run을 기본값 true로 두었습니다. 검증이 끝나기 전에는 WAF와 JIRA에 아무것도 쓰지 않습니다.

      이 설계가 나중에 프로젝트를 구했습니다.


      2. 보안 리뷰가 찾은 것: 공격자가 차단 대상을 고를 수 있었다

      구현을 마친 뒤 보안 리뷰를 별도로 돌렸습니다. 실제로 악용 가능한 결함이 나왔습니다.

      문제가 된 코드

      초기 파서는 "출발지 IP", "Source IP" 같은 라벨 뒤에 오는 IP를 신뢰했습니다. 라벨 목록은 이랬습니다.

      _ATTACKER_LABELS = (
          r"출발지\s*(?:IP|아이피)",
          r"공격자\s*(?:IP|아이피)",
          r"source\s*ip",
          r"client\s*ip",        # 문제
          r"src[_\s-]?ip",       # 문제
          r"remote[_\s-]?addr",  # 문제
      )
      
      # 라벨과 값 사이 구분자. '=' 와 개행을 모두 허용했습니다.
      _SEPARATOR = r"[\s:=|>\-→]{0,10}"


      왜 위험한가

      핵심은 관제 티켓 본문에 탐지된 HTTP 요청이 그대로 인용된다는 점입니다. 즉 본문의 일부는 공격자가 내용을 정할 수 있는 데이터입니다.


      공격자가 이런 요청을 보내면,

      GET /login?client_ip=203.0.113.10 HTTP/1.1

      그 문자열이 티켓 본문에 실립니다. 그리고 파서는 client_ip=203.0.113.10을 "신뢰할 수 있는 공격자 IP 표기"로 읽습니다.


      리뷰어가 실제 모듈을 실행해 재현한 결과입니다.

      input : 로그: "GET /a HTTP/1.1" 200 - "-" "curl/8.0 remote_addr=203.0.113.10"
      result: source=labeled  trusted=True  WOULD BLOCK -> ('203.0.113.10/32')

      trusted=True이므로 "라벨 없는 IP는 차단하지 않는다"는 방어선을 그대로 우회합니다.


      공격자가 203.0.113.10 자리에 자사 NAT 아웃바운드 IP나 CDN 오리진 IP를 넣으면, 우리 시스템이 우리 서비스를 차단합니다. 자기 자신을 향한 DoS입니다.

      02-trust-boundary.png


      근본 원인은 신뢰 경계 설정 오류

      코딩 실수가 아닙니다. "티켓은 관제팀이 쓴 문서"라는 암묵적 가정이 문제였습니다.

      실제로는 티켓 = 관제팀이 쓴 부분 + 공격자가 정한 부분이었습니다.

      수정

      # 1. 로그·HTTP 헤더 필드명으로도 쓰이는 토큰을 라벨에서 제거
      _ATTACKER_LABELS = (
          r"출발지\s?(?:\([^)\n]{0,12}\))?\s?(?:IP|아이피)",
          r"공격자\s?(?:\([^)\n]{0,12}\))?\s?(?:IP|아이피)",
          r"source\s?ip",
      )
      
      # 2. 라벨은 줄 머리에서만 인정합니다.
      #    관제 양식의 항목명은 줄 처음에 오지만, 주입 문자열은 줄 중간에 박힙니다.
      _LINE_PREFIX = r"^[ \t>*_|]{0,4}"
      
      # 3. 구분자에서 '='를 빼고 개행을 넘지 않습니다.
      #    '='는 URL 쿼리스트링과 구분되지 않고,
      #    개행을 넘으면 표의 빈 셀일 때 다음 줄 값을 잘못 가져옵니다.
      _SEPARATOR = r"[:|>\-→]?[ \t]*"
      
      _LABELED_RE = re.compile(
          _LINE_PREFIX + "(?:" + "|".join(_ATTACKER_LABELS) + ")" + _SEPARATOR + r"(?P<value>[^|\n]*)",
          re.IGNORECASE | re.MULTILINE,
      )

      여기에 하나를 더 추가했습니다. 원래 README에 "allowlist를 반드시 채우세요"라고 적어두었는데, 리뷰어가 지적했습니다.

      문서에만 있는 안전장치는 안전장치가 아닙니다.

      코드로 강제하도록 바꿨습니다.

      if not agent.dry_run and not guard.allowlist:
          raise ConfigError(
              "dry_run이 false인데 allowlist가 비어 있습니다. "
              "오차단 방지를 위해 자사 공인 대역을 먼저 등록하세요."
          )

      실제로 배포 중에 이 검증에 한 번 걸렸습니다.


      코드 리뷰에서 나온 fail-open

      같은 시점에 돌린 코드 리뷰에서는 다른 종류의 결함이 나왔습니다.

      # 문제: 화이트리스트에 없는 값을 전부 False로 처리했습니다.
      def _as_bool(value, default):
          return str(value).strip().lower() in ("1", "true", "yes", "on")
      WAFIP_DRY_RUN='ture'   -> dry_run=False  (실차단)
      WAFIP_DRY_RUN='dry'    -> dry_run=False  (실차단)

      오타 하나가 안전장치를 통째로 끕니다. 인식할 수 없는 값은 기본값으로 넘기지 말고 예외를 던져야 합니다.

      def _as_bool(value, default, where):
          if value is None or value == "":
              return default
          if isinstance(value, bool):
              return value
          text = str(value).strip().lower()
          if text in _TRUE_VALUES:
              return True
          if text in _FALSE_VALUES:
              return False
          raise ConfigError(f"'{where}' 값은 true/false여야 합니다: {value!r}")

      같은 패턴의 버그가 정수 설정에도 있었습니다.

      int(value or 20)"아무것도 차단하지 마라"는 의도로 넣은 0을 기본값 20으로 되돌립니다.

      or 폴백은 "명시적으로 비움"과 "설정하지 않음"을 구분하지 못합니다.


      3. 배포: 가정 일곱 개가 전부 깨졌습니다

      여기서부터가 진짜입니다. 실제 데이터를 보기 전에는 알 수 없던 것들입니다.

      가정

      실제

      증상

      프로젝트 키를 알고 있다

      다른 프로젝트였음

      JQL 0건

      제목에 "웹 취약점 공격"

      한글 '웹'이 아니라 영문 Web

      JQL 0건

      공격자 IP는 본문에 있다

      별도 커스텀 필드에 있음

      어떤 티켓에서도 IP를 못 찾음

      WAF는 하나

      CloudFront와 리전, 스코프가 달라 IP set 공유 불가

      한쪽 경로만 차단

      status not in (Closed, Done)

      JIRA가 한글 로컬라이즈됨

      JQL 실패 가능

      labels != X로 처리 완료 제외

      라벨이 없는 신규 이슈까지 제외됨

      신규 이슈 누락

      라벨을 붙일 수 있다

      프로젝트 화면 구성에 labels 필드가 없음

      HTTP 400


      특히 뼈아팠던 것: 공격자 IP의 위치

      에이전트가 어떤 티켓에서도 IP를 찾지 못했습니다. 로그는 이랬습니다.

      {"level": "WARNING", "message": "[TICKET-1] 이슈에서 IP를 찾지 못했습니다."}

      파서 버그를 의심했지만 아니었습니다. 공격자 IP가 본문이 아니라 구조화된 커스텀 필드에 담겨 있었습니다.

      Src_ip:  203.0.113.9 (RU / EXAMPLE-ORG)
      Rawdata: Parameter: ID=167772&boardType=techBlog1) AND SLEEP(6)-- ...

      본문(Rawdata)에는 SQL 인젝션 페이로드만 있고 IP는 없었습니다.


      필드 ID를 찾을 때는 이름을 추측하는 대신 IP 값이 실제로 들어 있는 필드를 직접 찾는 방법이 확실합니다.

      curl -sS -H "Authorization: Bearer $JIRA_TOKEN" \
        'https://your-jira/rest/api/2/issue/TICKET-1?expand=names' | python3 -c "
      import json, re, sys
      d = json.load(sys.stdin); names = d['names']; f = d['fields']
      IP = re.compile(r'(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])')
      for k, v in f.items():
          if v is None: continue
          if IP.search(str(v)): print(k, '|', names.get(k), '|', str(v)[:90])
      "
      customfield_XXXXX | Src_ip | 203.0.113.9 (RU / EXAMPLE-ORG)

      이 발견은 뜻밖의 이득을 줬습니다. 구조화된 필드만 읽으면 되니, 본문 파싱을 아예 끌 수 있게 된 것입니다.

      2장에서 다룬 인젝션 경로가 원천 차단됐습니다.

      잠깐, 이건 관제 쪽 설계가 좋았던 덕입니다.


      자동화가 성립한 결정적 조건은 관제 티켓이 정형화되어 있었다는 점입니다.

      SK텔레콤 보안관제센터(SMC)가 올리는 티켓에는 탐지 시나리오, 로그 소스, 탐지 건수, 대상 도메인, 그리고 출발지 IP가 각각 별도 필드로 담겨 옵니다.

      원본 페이로드도 따로 분리돼 있습니다.


      만약 이 정보가 자유 서술형 본문에만 있었다면, 파싱에 의존할 수밖에 없고 그건 2장의 인젝션 위험을 안고 가는 길입니다.

      정형 필드 하나가 위협 모델 전체를 바꿉니다.

      jira:
        ip_field: customfield_XXXXX
      guard:
        require_ip_field: true   # 본문 파싱 결과를 신뢰하지 않습니다


      JQL에서 자주 밟는 함정

      세 가지를 기록해 둘 만합니다.

      첫째, 다중값 필드의 !=는 EMPTY를 제외합니다.

      -- 문제: 라벨이 하나도 없는 신규 이슈가 조회되지 않습니다.
      labels != processed
      
      -- 수정
      (labels IS EMPTY OR labels NOT IN (processed, needs-review))

      둘째, 상태 이름은 로컬라이즈됩니다.

      -- 한글 JIRA에서는 동작하지 않을 수 있습니다.
      status not in (Closed, Done)
      
      -- 언어와 무관하게 동작합니다.
      statusCategory != Done

      셋째, 검색어는 실제 문자열을 그대로 복사해야 합니다.

      한글 '웹'으로 검색해서 0건이 나왔는데, 실제 표기는 영문 Web이었습니다.


      CloudFront와 리전 WAF는 IP set을 공유하지 않습니다

      AWS WAF는 스코프가 두 가지입니다.

      스코프

      리전

      ARN 경로

      CLOUDFRONT

      항상 us-east-1

      global/ipset/

      REGIONAL

      리소스가 있는 리전

      regional/ipset/

      03-two-waf.png

      CDN 앞단과 ALB 앞단에 각각 WAF가 있다면 IP set이 별개입니다. 한쪽만 등록하면 다른 경로가 열려 있습니다.


      여기서 중요한 설계 판단이 하나 나옵니다. "이미 등록됨" 판정을 모든 IP set의 교집합으로 해야 합니다.

      @staticmethod
      def _registered_everywhere(existing: dict[str, set[str]]) -> frozenset[str]:
          """모든 IP set에 이미 들어 있는 항목만 '등록됨'으로 본다.
      
          합집합으로 판정하면 CloudFront에만 있고 ALB에는 없는 IP를
          '이미 등록됨'으로 처리해, 그쪽 차단이 영구 누락된다.
          """
          sets = list(existing.values())
          return frozenset(set.intersection(*sets)) if sets else frozenset()

      실제로 두 IP set의 내용은 달랐습니다(161건과 263건). 교집합이 아니었다면 누락이 발생했을 상황입니다.


      4. 일곱 번 틀렸는데 사고가 없었던 이유

      주목할 점은 모든 실패가 "0건" 또는 "수동 확인 요청"으로 나타났다는 것입니다. 잘못된 IP를 차단한 경우는 한 번도 없었습니다.

      안전 실패(fail-safe) 설계를 기본값으로 둔 덕분입니다.

      • dry_run 기본값 true, 인식 불가 값은 예외

      • 라벨을 못 찾으면 차단하지 않고 댓글로 사람에게 넘김

      • allowlist가 비면 실차단 모드 전환 자체를 거부

      • 검증 기간에는 IAM에 읽기 권한만 부여 (dry_run에서는 UpdateIPSet을 호출하지 않으므로)

      이게 왜 중요하냐면, 실제 데이터로만 검증되는 종류의 가정들이 있기 때문입니다.

      티켓의 실제 형식, 필드 위치, 표기 언어 같은 것들은 스테이징에서 완벽히 검증할 방법이 없습니다.

      안전 실패가 있으면 운영 환경에서 반복 검증할 수 있습니다. 없으면 완벽히 검증한 뒤에만 켤 수 있는데, 그건 대개 불가능합니다.


      검증 명령을 도구에 내장하세요

      배포 시간을 크게 줄인 것은 CLI 서브커맨드였습니다.

      wafip show        # 설정 출력 + 대상 IP set 조회
      wafip check-ip X  # 특정 IP의 판정 결과만 확인 (변경 없음)
      wafip once        # 1회만 폴링

      특히 show의 오류 메시지에 호출 신원이 그대로 찍혀서 IAM 디버깅에 별도 도구가 필요 없었습니다.

      AccessDeniedException: User: arn:aws:sts::...:assumed-role/agent-role/i-xxxx
      is not authorized to perform: wafv2:GetIPSet on resource: ...

      역할이 제대로 붙었는지, 정책이 빠졌는지가 한 줄로 판별됩니다.


      배포 담당자가 각 단계를 독립적으로 확인할 수 있어야 합니다. 마지막에 한꺼번에 확인하면 원인 분리가 안 됩니다.


      5. 내가 만들지 않은 자동화가 반응했습니다

      운영 첫날 새벽에 두 건이 정상 차단됐습니다. 그런데 라벨 부여에서 HTTP 400이 났습니다.

      Field 'labels' cannot be set. It is not on the appropriate screen, or unknown.

      수정하면서 커밋 메시지에 이슈 키를 적었습니다. "첫 두 건(TICKET-1, TICKET-2)은 정상 차단됐으나..." 같은 식으로요.

      푸시하고 몇 분 뒤, 그 커밋 메시지 전문이 해당 관제 티켓의 댓글로 올라갔습니다. 토요일 밤, 참조자 15명에게 메일이 나갔습니다.

      저장소에 GitLab-JIRA 연동이 걸려 있었습니다. 커밋 메시지에서 이슈 키를 인식하면 커밋 내용을 그 티켓에 자동으로 붙이는 기능입니다.

      개발 프로젝트에서는 유용하지만, 여기서는 티켓이 관제 대응 기록이었고 수신자가 보안 조직 전체였습니다.


      방지책으로 커밋 훅을 만들었습니다.

      #!/bin/sh
      # .githooks/commit-msg
      keys=$(grep -v '^#' "$1" | grep -oE '\b(PROJ|SEC)-[0-9]+\b' | sort -u)
      if [ -n "$keys" ]; then
          echo "커밋 거부: 커밋 메시지에 JIRA 이슈 키가 있습니다." >&2
          echo "$keys" | sed 's/^/  - /' >&2
          exit 1
      fi

      그런데 이것도 부족했습니다. 연동은 커밋 메시지뿐 아니라 브랜치명, MR 제목·본문, MR 댓글에도 반응합니다. 훅은 그중 하나만 막습니다.

      04-hook-coverage.png

      근본 차단은 GitLab 프로젝트 설정에서 연동의 댓글 기능을 끄는 것이었습니다.

      Settings → Integrations → Jira → "Enable comments" 해제

      방지책을 만들 때도 "이 통제가 경로 전부를 덮는가"를 따져야 한다는 걸, 방지책을 만들면서 놓쳤습니다.


      6. 정리: 다섯 가지 교훈

      1) 위협 모델을 먼저 씁니다 — "누가 이 입력을 통제하는가"

      이 프로젝트의 핵심 결함은 코딩 실수가 아니라 신뢰 경계 설정 오류였습니다. 외부 데이터를 소비하는 자동화라면, 입력마다 **"이 문자열을 누가 정할 수 있는가"**를 명시적으로 따져야 합니다.

      2) 작성과 검토를 다른 패스로 분리합니다

      보안 리뷰와 코드 리뷰를 각각 별도로 돌렸고, 둘 다 스스로는 찾지 못한 결함을 냈습니다. 같은 맥락 안에서 자기 코드를 검토하면 같은 가정을 반복합니다.

      3) 안전 실패를 기본값으로 두면 운영에서 배울 수 있습니다

      가정이 일곱 개 틀렸는데도 사고가 없었던 유일한 이유입니다. 모든 실패 경로가 "아무것도 하지 않고 사람에게 넘김"으로 수렴하도록 설계했습니다.

      4) 문서에만 있는 안전장치는 안전장치가 아닙니다

      "allowlist를 먼저 채우세요"는 지켜지지 않을 수 있습니다. 코드가 거부하도록 바꾼 뒤에야 실효가 생겼습니다.

      5) 내 출력이 어디로 흘러가는지 봅니다

      이 프로젝트에서 두 번 데였습니다. 에이전트 댓글 하나가 15명에게 메일로 나간다는 것, 그리고 커밋 메시지가 티켓에 자동 게시된다는 것.

      후자는 내가 만든 자동화가 아니라 이미 거기 있던 자동화였고, 그래서 시야에 없었습니다.


      티켓 시스템, 채팅 연동, 메일 알림, CI 트리거. 출력이 닿는 모든 곳이 증폭기가 될 수 있습니다.


      마치며: 새벽 4시 53분

      설계부터 운영까지 하루가 걸렸고, 테스트 98건에 커밋 7개가 남았습니다.

      가장 인상적이었던 순간은 운영 첫날 새벽에 온 알림 메일이었습니다.

      04시 53분에 관제 통보가 올라왔고, 수 분 안에 공격자 IP가 두 방화벽에 등록되고 티켓에 회신까지 나갔습니다.

      아침에 출근해서 봤다면 네 시간쯤 열려 있었을 경로입니다.


      다만 자동화의 값어치는 속도가 아니라 신뢰도에서 나옵니다. 잘못 차단하는 자동화는 없느니만 못합니다.

      그래서 이 프로젝트에서 가장 시간을 많이 쓴 부분도 "어떻게 빨리 막을까"가 아니라 "어떻게 잘못 막지 않을까"였습니다.


      혹시 비슷한 자동화를 만드신다면, 첫 줄을 쓰기 전에 이 질문부터 던져 보시길 권합니다.

      이 입력의 어느 부분을 공격자가 정할 수 있는가?


      데보션에서 해 본 것들 중 하나

      이번 보안 차단 에이전트는 데보션을 운영하면서 시도해 본 자동화 중 하나입니다. 그 전에도 몇 가지가 있었습니다.

      시도

      무엇을 자동화했나

      데보션 알리미

      DEVOCEAN에 등록되는 블로그, 뉴스, AI블로그를 아침10시에 이메일로 발송해주는 에이전트

      RAG 자동화 에이전트

      DEVOCEAN에 등록된 모든 컨텐츠(블로그, 커뮤니티)를 임베딩하고, 매일 한번씩 변경 사항을 파악해서 추천 블로그와 검색 시 사용할 수 있게 하는 Agentic RAG 에이전트

      DEVOTEE

      매일 아침 IT 소식을 모아 한국어 글로 쓰고 발행하는 에이전트 (AI 블로그 작성)

      보안 차단 에이전트

      관제 통보에 사람 없이도 즉시 대응하도록 (지금 이 블로그^^)

      특히 DEVOTEE는 수집부터 발행까지를 하나의 파이프라인으로 묶은 사례입니다.

      GitHub Actions가 매일 아침 정해진 시각에 파이프라인을 돌립니다. 여러 기술 소식 소스에서 글감을 모으고, 오늘 쓸 주제를 고르고, 한국어 본문을 작성하고, 품질을 검증한 뒤 데보션에 올립니다.

      devotee-infographic-light (1).gif

      네 가지 모두 성격은 다르지만 출발점은 같습니다.

      사람이 반복해서 하던 일 중에, 기계가 더 잘할 수 있는 부분을 골라내는 것입니다.


      특히 이번 건은 "빠르게 하는 자동화"가 아니라 "틀리지 않는 자동화"를 만드는 연습이었습니다.

      관문 여섯 개, 안전 실패 설계, 별도 리뷰 두 번 — 전부 속도를 포기하고 신뢰도를 산 선택입니다.


      데보션이라는 서비스를 직접 운영하고 있다는 점이 이런 시도를 가능하게 합니다.

      실제 트래픽이 있고, 실제 공격이 들어오고, 실패하면 실제로 아픕니다. 그 조건이 아니었다면 이 글의 3장(가정 일곱 개가 깨진 과정)은 나오지 않았을 겁니다.


      앞으로도 데보션에서 해 볼 수 있는 일이 많이 남아 있습니다.


      이제 어떤 에이전트를 만들 것인가

      요즘 에이전트를 만드는 일이 흔해졌습니다. 다만 대부분은 보여주는 것에서 멈춥니다.

      05-agent-levels.png

      1단계부터 3단계까지는 마지막에 항상 사람이 있습니다.

      에이전트가 아무리 잘 정리해 줘도, 사람이 읽고 판단하고 콘솔에 들어가 버튼을 눌러야 일이 끝납니다.


      성과를 재기 어려운 것도 그래서입니다. 조회성 에이전트의 지표는 대개 활동량입니다.

      조회 수, 알림 발송 건수, 생성한 문서 수. 에이전트가 얼마나 바빴는지는 말해 주지만, 무엇이 달라졌는지는 말해 주지 않습니다.


      이번 에이전트의 지표는 성격이 다릅니다.

      지표

      통보에서 차단 완료까지

      수 분

      야간·주말 대응을 위한 사람 개입

      없음

      오차단

      0건

      일을 끝까지 했기 때문에 성과가 그 자체로 지표가 됩니다.


      권한을 위임한다는 것

      4단계로 올라가려면 넘어야 할 선이 하나 있습니다.

      내 권한을 에이전트에게 주는 것입니다.


      이 에이전트는 제 계정 토큰으로 움직이고, 제 이름으로 티켓에 댓글을 답니다.

      운영 WAF의 차단 목록을 직접 바꿉니다. 제가 자고 있는 동안에요.


      그래서 이 글의 대부분이 안전장치 이야기였습니다.

      관문 여섯 개, 기본값 dry run, allowlist 강제, 별도 리뷰 두 번, 그리고 실패하면 아무것도 하지 않고 사람에게 넘기는 설계.

      권한을 받은 에이전트는 그만큼의 설계 부담을 집니다.

      그 대가를 치를 생각이 없다면 3단계에 머무르는 편이 낫습니다.


      할 수 있는 것과 해야 하는 것

      만들기 쉬운 에이전트와 정말 필요한 에이전트는 대체로 다릅니다.

      요약봇과 검색봇이 많이 만들어지는 데는 이유가 있습니다.

      만들기 쉽고, 틀려도 아무 일이 일어나지 않기 때문입니다.


      반대로 조직에 정말 필요한 자동화는 대개 위험한 쪽에 있습니다.

      실제 시스템을 바꾸고, 틀리면 장애가 나고, 그래서 아무도 손대지 않은 채 사람이 계속 반복하고 있습니다.

      이번 건도 그랬습니다. 작업 자체는 어렵지 않은데 아무도 자동화하지 않고 있었던 이유는, 잘못 막으면 서비스가 멈추기 때문이었습니다.


      할 수 있는 것을 하는 에이전트가 아니라, 해야 하는 것을 하는 에이전트를 만들었으면 합니다.

      그러려면 권한을 넘기는 결정과, 그 권한을 안전하게 다루는 설계가 함께 가야 합니다.


      이 글이 그 설계에 참고가 되면 좋겠습니다.

      댓글 0

      DEVOTEE를 활성화 시키면
      지금 작성한 댓글에 AI가 댓글을 달아줍니다.

      꼬마집사 님의 최신 블로그

      더보기
      동영상 기고하기