멀티 어카운트 NACL 차단 자동화 도구 운영 및 개선 경험

2 hours ago 1

0x00 들어가며

보안팀의 중요한 일 중 하나는 공격 IP를 지금 당장 막는 일 입니다. SOC에서 수상한 트래픽이 잡혔을 때, SOAR가 자동으로 차단 액션을 트리거할 때, 분석가가 직접 손으로 차단 요청을 넣을 때. 단일 AWS 계정 환경이라면 NACL이나 Security Group에 룰 하나 추가하면 끝나는 단순한 일이지만, 운영 중인 AWS 계정이 수십 개에 이르면 이야기가 달라집니다.

우아한형제들은 서비스와 목적별로 AWS 계정을 분리해 운영하고 있고, 각 계정마다 별도의 VPC와, 트래픽 방향에 따라 나뉜 NACL이 존재합니다. 공격 IP 하나를 차단하려면 수십 개 계정의 NACL을 동시에 손봐야 합니다. 처음엔 분석가가 콘솔에 들어가서 한 계정씩 룰을 적용하는 방식이었지만, 차단 IP가 늘어날수록 누락도 늘어나고 차단까지 걸리는 시간도 길어졌습니다. 이걸 자동화한 것이 WOOWA-SOC-ACL-IR입니다. 이 도구는 이전에 기술블로그에서 포스팅한 GuardDuty 이벤트 분석으로 살펴보는 침해대응 아키텍처 고도화 사례 글에서 NACL-IR이라는 이름으로 침해대응 아키텍처의 한 축으로 짧게 소개한 적이 있는데, 본 글에서는 이 도구 자체에 집중해 설명드리려 합니다.

멀티 어카운트 환경에서 보안 자동화를 고민하시는 분, 또는 이미 비슷한 시스템을 운영하면서 자잘한 상태 불일치(drift)나 경쟁 상태(race condition)에 고민이 있는 분들께 도움이 되길 바랍니다.

0x01 처음엔 단순한 시작

초기 WOOWA-SOC-ACL-IR의 목표는 단순했습니다. "이 IP를 막아주세요"라고 요청을 받으면, 보안팀이 AWS 계정의 NACL에 deny 룰을 추가하는 것. 그게 전부였습니다.

아키텍처도 그 목표에 맞춰 가능한 한 단순하게 짰습니다.

그림 1. 초기 WOOWA-SOC-ACL-IR 아키텍처

그림 1. 초기 WOOWA-SOC-ACL-IR 아키텍처

분석가나 SOAR가 API Gateway로 차단 요청을 보내면, 페이로드는 SQS FIFO 큐로 들어갑니다. SQS 컨슈머(consumer) 람다가 큐에서 메시지를 꺼내 NACL 차단 람다를 비동기로 호출하고, 차단 람다는 STS AssumeRole로 각 계정에 진입해 NACL에 deny 룰을 추가합니다. 추가된 룰의 메타데이터(어느 계정의 어느 NACL인지, 어느 룰 번호로 어느 IP를 어느 시점에 등록했는지)는 보안 계정의 DynamoDB(DDB) 테이블에 한 줄씩 기록됩니다. 차단 요청 한 건마다 람다 한 번이 모든 계정을 순회하는, 그야말로 단순한 fan-out 구조입니다.

NACL 자동화를 본격적으로 운영하려면 NACL 슬롯 한도부터 짚어볼 필요가 있습니다. AWS NACL의 규칙 수 한도(Rules per network ACL)는 기본 20개이며, AWS Service Quotas를 통해 최대 40개까지 증가시킬 수 있습니다. AWS는 한도를 늘릴 경우 네트워크 처리 비용이 함께 증가할 수 있음을 안내하지만, 우아한형제들 보안팀은 인바운드용 NACL과 아웃바운드용 NACL을 분리 운영하면서 자동화 대상이 되는 NACL에 한해 이 한도를 40개로 증설해두었고, 지금까지 별다른 성능 이슈를 경험한 적은 없습니다. 덕분에 각 NACL이 한 방향에 대해 21 ~ 50번 범위를 온전히 침해대응 슬롯으로 쓸 수 있습니다. 그중에서도 침해대응 목적으로는 다른 보안 정책과의 우선순위 충돌을 피하기 위해 21 ~ 50번 범위 30개 슬롯을 약속해두었고, WOOWA-SOC-ACL-IR도 이 범위만 사용합니다.

그림 2. 슬롯 교체(rotation) 방식
그림 2. 슬롯 교체(rotation) 방식

만약 30개 슬롯이 모두 차 있다면, 가장 오래된 차단 항목(DynamoDB에 가장 먼저 기록된 룰)을 삭제하고 그 자리에 새 차단 항목을 채우는 교체(rotation) 방식으로 슬롯을 관리했습니다.

이 정도가 초기 WOOWA-SOC-ACL-IR의 전부였습니다. 차단 요청이 들어오면 멀티 계정에 deny 룰을 적용하고, 슬롯이 모자라면 오래된 것부터 밀어내는, 그야말로 단순한 자동화였습니다. 분석가가 콘솔을 계정마다 일일이 클릭하지 않아도 된다는 점만으로도 처음엔 충분히 큰 가치를 가져다주었습니다.

0x02 운영하다 보니 알게 된 것들

차단 자동화를 한참 운영하다 보면, 처음엔 미처 생각하지 못했던 상황들이 하나둘 등장합니다. 차단한 IP가 이후 오탐이었다는 게 밝혀져 차단을 풀어야 하는 일이 생기고, 그러다 보면 DDB와 NACL이 미묘하게 어긋나는 일도 함께 생깁니다. 이런 상황들이 반복되면서 WOOWA-SOC-ACL-IR에 두 가지 기능을 추가하였습니다. 하나는 모든 차단을 한 번에 해제하는 alldelete이고, 다른 하나는 NACL과 DDB의 정합성을 점검하는 sync_check입니다.

alldelete의 등장

처음 자동화에는 단일 IP를 차단하거나 단일 IP를 해제하는 기능까지는 있었지만, 21~50번 슬롯에 들어가 있는 룰을 한꺼번에 비우는 방법은 없었습니다. 단일 IP 차단/해제는 update/delete trigger로 처리할 수 있었지만, 운영을 이어가면서 슬롯 자체를 통째로 비우고 초기 상태로 돌려놓고 싶은 순간들이 생기게 되었습니다. NACL과 DDB의 룰 번호가 미묘하게 어긋나게 되는 경우, 의도치 않은 상태로 슬롯이 채워져 후속 차단이 자꾸 꼬이는 경우 등입니다.

그래서 차단 요청 페이로드에 trigger=alldelete라는 옵션을 두고, 이 trigger가 들어오면 차단 람다가 21~50번 범위에 등록된 모든 deny 룰과 그에 해당하는 DynamoDB 항목을 한 번에 비우도록 동작을 추가했습니다. 분석가는 API 호출 한 번으로 모든 계정의 침해대응 슬롯을 깨끗하게 비울 수 있게 되었고, 자동화의 운영 부담이 한 단계 줄어들었습니다.

sync_check의 등장

시간이 지나면서 또 다른 종류의 문제가 보이기 시작했습니다. 분석가가 차단 이력을 확인하러 DDB를 봤더니, 분명히 차단되어 있는 것으로 기록된 IP가 실제 NACL에는 등록되어 있지 않거나, 반대로 NACL에는 룰이 존재하는데 DDB에는 그 기록이 없는 경우가 산발적으로 발견된 것입니다. 보안 운영의 관점에서 보면 이는 단순한 표시 오차가 아니라 차단 자체가 의도대로 적용되지 않은 사건일 수 있고, 후속 차단의 룰 번호 회전도 어긋나게 만들 수 있어 개선이 필요했습니다.

이 상태를 빨리 감지하기 위해 trigger=check 동작을 새로 만들었습니다. check는 차단 람다가 모든 계정을 순회하면서, 각 계정의 NACL에 등록된 21~50번 룰 번호 목록과 DDB에 기록된 룰 번호 목록을 비교하고, 둘이 일치하지 않는 계정과 차이 나는 룰 번호를 슬랙에 보고하도록 했습니다. 분석가가 임의로 호출할 수도 있고, 새 차단 요청이 들어왔을 때 update를 진행하기 전에 시스템이 스스로 한 번 sync_check를 먼저 돌리는 방식으로도 동작합니다. 만약 한 계정이라도 어긋나 있다면 update를 진행하지 않고 알림만 보내, 사람의 확인을 거치고 정합성을 맞춘 다음 다시 시도하도록 했습니다.

단순했던 자동화가 도구가 되어 가다

alldelete와 sync_check가 더해지면서 자동화는 단순한 fan-out에서 침해대응 운영의 한 축을 담당하는 도구로 자리 잡았습니다. update / delete / alldelete / check 네 가지 trigger만으로 분석가는 차단의 등록, 해제, 일괄 해제, 정합성 점검까지 API 한 줄로 처리할 수 있게 되었습니다. 다만 운영이 길어질수록 sync_check가 알려주는 어긋남이 사람의 실수만으로 발생하는 것이 아니라는 사실이 점점 분명해졌고, 차단 요청이 정상적으로 처리된 것처럼 보이는 상황에서도 NACL과 DDB가 미묘하게 어긋나 있거나 alldelete 직후에도 일부 룰이 NACL에 남아 있는 사례가 관찰되기 시작했습니다.

0x03 그래도 깨지는 부분들

sync_check가 알려주는 어긋남이 점점 자주 보이게 되면서, 자동화 코드 자체를 다시 들여다보지 않을 수 없었습니다. 그 과정에서 발견된 함정들은 대부분 단순한 코드 버그라기보다 분산 시스템의 미묘한 특성에 가까웠고, 한 번 발견된 뒤에는 다른 비슷한 자동화에서도 같은 일이 일어날 수 있겠다고 느끼게 만드는 종류였습니다. 이 챕터에서는 운영하면서 만난 함정들을 발견된 순서에 가깝게 정리해 보려 합니다.

NACL은 만들어졌는데 DDB에 기록되지 않다

sync_check가 가장 자주 알려준 사례는 "NACL에는 룰이 있는데 DDB에는 같은 룰 번호가 없다"는 패턴이었습니다.

차단 람다는 create_network_acl_entry로 NACL에 deny 룰을 추가한 뒤, put_item으로 DDB에 메타데이터를 기록하는 두 단계로 동작합니다. 그런데 NACL 생성을 담당하는 함수가 호출 결과에 따라 True 또는 False를 반환하도록 만들어져 있었음에도, 호출부에서는 이 반환값을 검사하지 않고 곧바로 DDB 기록 단계로 넘어가는 구조였습니다.

# 변경 전 try: create_netacl_rule(...) # 반환값(False) 무시 create_ddb_rule(...) # NACL이 실패해도 그대로 실행됨 except Exception as e: ...

Python의 try/except는 예외가 발생했을 때만 동작할 뿐, 함수가 예외를 던지지 않고 return False로 정상 종료하는 경우는 잡지 못하기 때문에, NACL 생성이 조용히 실패한 경우에도 DDB에는 차단된 것으로 기록이 남았습니다. 게다가 NACL이 정상 응답(HTTP 200)을 받은 직후 바로 readback으로 룰의 실제 존재를 확인하는 단계도 없어서, AWS API의 일시적인 일관성 지연으로 룰이 잠시 안 보이는 순간 역시 같은 결과로 이어질 수 있었습니다.

이를 해결하기 위해 두 가지를 함께 적용했습니다. 첫째, NACL 생성 직후 describe_network_acls로 같은 CidrBlock의 룰이 실제로 등록되었는지 짧은 간격으로(최대 9초가량) 확인하는 readback 루프를 추가했습니다. 재시도 횟수와 간격은 AWS API의 일시적 지연을 흡수하면서도 전체 fan-out 시간을 과도하게 늘리지 않는 선에서 운영 경험으로 정한 값입니다. 둘째, 호출부에서는 헬퍼 함수의 반환값이 False이면 명시적으로 예외를 던져 DDB 기록 단계로 넘어가지 않도록 보호했습니다.

# 변경 후 — NACL 생성 후 readback 검증 (eventual consistency 대응) for attempt in range(6): rules = get_nacl_rules(...) if any(r['CidrBlock'] == CidrBlock for r in rules): return True time.sleep(1.5) return False # 호출부 가드 nacl_created = create_netacl_rule(...) if not nacl_created: raise Exception("NACL not confirmed, skip DDB") create_ddb_rule(...)

한 계정의 어긋남이 람다 전체를 멈추다

수십 개 계정을 순회하며 차단을 처리하는 코드는 한 계정에 sync 불일치가 발견되면 exit()를 호출하도록 만들어져 있었습니다. 의도는 "어긋난 상태에서 update를 진행하면 더 망가질 수 있으니 멈추자"였습니다.

# 변경 전 for account in all_accounts: result = sync_check(account, ...) if result[1] > 0: exit() # SystemExit — 람다 invocation 통째로 종료

문제는 exit()가 던지는 SystemExit이 일반적인 흐름 제어용 예외가 아니라는 점입니다. try/except Exception으로는 잡히지 않고, 코드의 어느 위치에서 호출되느냐에 따라 그 호출이 에러로 종료되기도 했습니다. 무엇보다 "왜 멈췄는지"가 코드 흐름상 분명하게 드러나지 않아, 운영 중에 동작을 예측하고 추적하기 어려웠습니다. 의도한 것은 "이번 차단을 진행하지 말자"였는데, 실제로는 프로세스를 비정상 종료시키는, 의도보다 거친 방식으로 표현되어 있었던 것입니다.

그래서 exit()를 일반 흐름의 return과 continue로 바꿨습니다. 어긋남이 발견된 계정만 건너뛰고 슬랙으로 알림을 보내되, 나머지 계정의 처리는 그대로 이어가도록 해서, "무엇을 멈추고 무엇을 계속할지"를 코드가 명시적으로 드러내게 했습니다.

# 변경 후 for account in all_accounts: try: result = sync_check(account, ...) if result[1] > 0: update_error_value += 1 slack_append("계정 {} 어긋남 - 건너뜀".format(account)) continue except Exception as e: update_error_value += 1 continue

일부 계정의 실패가 성공으로 위장되다

위의 함정을 풀고 나니, 운영 알림에서 또 다른 이상한 패턴이 보이기 시작했습니다. 차단 람다는 모든 계정을 순회한 뒤 "성공" 메시지를 슬랙에 한 줄로 정리해 보내도록 되어 있었는데, 실제로는 몇 개 계정에서 실패가 발생했음에도 슬랙에는 깔끔한 성공 메시지만 떨어지는 경우가 있었습니다.

원인은 실패 카운터 변수의 누적 방식이었습니다. 차단 람다는 각 계정의 처리 결과로 받은 update_error_value를 합산하도록 설계되어 있었지만, Python의 int는 불변 객체이기 때문에 헬퍼 함수에 값으로 전달되면 호출자 측의 변수는 변하지 않았습니다. 헬퍼 함수가 반환하는 값을 매번 다시 받지 않으면 누적이 되지 않는 구조였습니다.

# 변경 전 update_error_value = 0 for account in all_accounts: update_result = nacl_update(account, ..., update_error_value, ...) # 반환값을 변수에 다시 받지 않음 → update_error_value는 계속 0 # 루프 끝나고 마지막 결과로 덮어씀 update_error_value = update_result[1] # 마지막 계정의 값만 남음 if update_error_value == 0: slack['fields'] = [] # 누적된 실패 메시지 전부 삭제 slack['fields'].append("성공!")

즉 마지막 계정이 성공한 경우에는 중간에 어느 계정이 실패했더라도 update_error_value가 0이 되어, 그동안 누적된 실패 알림까지 함께 지워지고 "성공"만 남았습니다. 운영자 입장에서는 부분 실패가 일어났는지조차 알기 어려운 상태였습니다.

해결은 헬퍼 함수의 반환값을 매번 다시 받아서 누적하는 것이었습니다. 추가로 한 계정의 예외가 for-loop 자체를 중단시키지 않도록 계정 단위 try/except 격리도 함께 적용했습니다.

# 변경 후 update_error_value = 0 for account in all_accounts: try: update_result = nacl_update(account, ..., update_error_value, ...) except Exception as e: update_error_value += 1 continue if isinstance(update_result, tuple): slack_message, update_error_value, ruleno_check = update_result

패치를 적용한 직후 슬랙 알림의 양이 일시적으로 늘어났지만, 이는 새로 생긴 문제가 아니라 그동안 묻혀 있던 실패가 정직하게 드러난 결과였습니다.

alldelete 직후의 잔여 룰

sync_check는 alldelete 직후에도 흥미로운 패턴을 보여주었습니다. "방금 모두 비웠는데 왜 일부 룰이 남아 있지?" 하는 상황입니다. 슬랙에는 분명히 GOOD! all-delete-report로 떴는데, 그 직후 update를 시도하면 어김없이 sync 불일치가 보고되곤 했습니다.

그림 3. alldelete 직후 sync_check가 보고한 잔여 룰 예시

그림 3. alldelete 직후 sync_check가 보고한 잔여 룰 예시 (슬랙)

alldelete 동작은 21~50번 범위의 룰을 한 번에 비우는 과정에서, 각 룰 번호마다 delete_network_acl_entry API를 한 번씩 호출합니다. 수십 개 계정과 슬롯 수를 곱하면 한 번의 alldelete에 수백 번의 API 호출이 발생하고, 그중 일부가 일시적인 throttle이나 5xx 응답을 받는 경우는 통계적으로 충분히 일어날 수 있습니다. 그런데 코드는 이런 일시 실패에 대한 재시도가 없었고, 누락 사실 자체도 슬랙에 보고되지 않은 채 종료되었습니다.

# 변경 전 for rule_no in var_range: response = ec2.delete_network_acl_entry(...) if response['HTTPStatusCode'] == 200: delete_rule.append(rule_no) else: logger.info("error ...") # 재시도 없음, 슬랙 알림 없음

이를 해결하기 위해 다음 세 가지를 함께 적용했습니다.

첫째, 단일 룰 삭제를 헬퍼 함수로 분리하고 try/except로 감싸 일시 실패를 추적할 수 있도록 했습니다.

둘째, 1차 시도 후 2초 대기를 두고 NACL을 다시 조회해, 1차 실패분과 재조회 시점에 남아 있는 잔여분을 합쳐서 한 번 더 삭제를 시도합니다.

셋째, 두 번의 시도 후에도 남은 룰이 있다면 슬랙에 FAIL! leftover after retry: [...]로 명확하게 보고하도록 했습니다. "GOOD!"으로 위장되는 일이 더는 없도록 만든 것이 핵심입니다.

# 변경 후 # 1차 시도 for rule_no in var_range: ok = _delete_one_nacl_rule(...) (delete_rule if ok else failed_rule).append(rule_no) # 2초 대기 후 leftover 재조회 → 합쳐서 2차 시도 time.sleep(2) leftover = get_nacl_rules(...) for rule_no in sorted(set(failed_rule) | set(leftover)): ok = _delete_one_nacl_rule(...) ... # 그래도 남으면 정직하게 보고 if still_failed: slack_append("FAIL! leftover after retry: {}".format(still_failed))

check 직후의 sync 어긋남: race condition

마지막으로 발견된 패턴은 가장 까다로웠습니다. 차단 람다는 update를 시작하기 전에 모든 계정에 대해 sync_check를 먼저 돌리도록 되어 있는데, 이 sync_check가 분명히 syncperfect라고 보고했음에도 그 직후 update 단계에서 같은 시점에 sync 어긋남이 다시 보고되는 사례가 발견되었습니다.

처음엔 sync_check 로직 자체에 문제가 있는 것이 아닌지 의심했지만, CloudWatch Logs를 들여다보니 같은 시각에 다른 차단 요청이 함께 처리되고 있다는 사실이 보였습니다. 차단 요청이 짧은 간격으로 들어오면 SQS 컨슈머가 메시지를 빠르게 꺼내 다음 처리를 시작하고, 그 결과로 NACL 차단 람다 두 개가 거의 동시에 실행되는 상황이 만들어졌습니다. 그중 한 람다가 NACL에 룰을 추가한 직후 DDB에 기록을 마치기 전의 짧은 시간이 있는데, 마침 그 시점에 다른 람다의 sync_check가 그 중간 상태를 관찰하면 "NACL과 DDB가 어긋난다"고 잘못 판단하는 일이 일어났던 것입니다.
그림 4. race condition이 발생하는 두 람다의 타임라인
그림 4. race condition이 발생하는 두 람다의 타임라인

즉 이건 코드 버그가 아니라 분산 시스템에서 익숙한 race condition이었고, 해결은 NACL 차단 람다의 동시 실행 자체를 차단하는 방향이어야 했습니다. SQS FIFO가 큐 차원에서 메시지 순서를 잡아주고 있었지만, 그것만으로는 이미 호출된 람다들이 겹쳐 실행되는 것까지 막지는 못했습니다. 그래서 AWS Lambda의 reserved concurrency를 1로 설정해, 한 NACL 차단 람다는 어느 시점에도 동시 한 개만 실행되도록 강제했습니다. 단일 람다 안에서는 sync_check → update가 순서대로 진행되기 때문에, 중간 상태가 다른 람다의 관찰 대상이 되는 일 자체가 사라집니다.

그림 5. Lambda 콘솔의 Reserved concurrency 설정

그림 5. Lambda 콘솔의 Reserved concurrency 설정

분당 차단 요청 수가 reserved=1의 처리 속도를 넘어서면 적체가 누적될 수 있다는 점이 새로운 우려였지만, AWS Lambda의 비동기 invoke가 자동으로 최대 6시간까지 재시도하는 여유가 있어 단기 적체에는 견딜 수 있었습니다. 다만 차단까지의 지연 시간은 늘어날 수 있어, SQS 큐의 메시지 적체 상황을 별도로 모니터링하는 알람을 함께 두어 추세를 관찰하고 있습니다.

챕터를 닫으며

이번 챕터에서 다룬 함정들은 한 가지 공통점이 있습니다. 코드 자체는 "동작하는 것처럼 보였다"는 것입니다. 단위 동작은 정상이고, 운영 메시지에도 GOOD으로 표시되고, 분석가가 보기에는 차단이 처리된 것 같았지만, 실제로는 NACL과 DDB가 한쪽만 갱신되거나, 일부 계정이 누락되거나, 일시적인 중간 상태가 다른 처리에 영향을 주고 있었습니다.

이런 종류의 함정은 코드를 작성하는 시점이 아니라 운영하는 시점에야 비로소 보이고, 그것도 한참 운영을 지속해야 비슷한 패턴이 두 번, 세 번 반복되며 윤곽이 드러납니다. 다음 챕터에서는 이 함정들을 풀어낸 뒤 무엇이 어떻게 달라졌는지를 정리해 보겠습니다.

0x04 무엇이 달라졌나

0x03에서 정리한 함정들을 풀어낸 뒤, WOOWA-SOC-ACL-IR의 운영은 몇 가지 측면에서 눈에 띄는 변화를 보였습니다. 패치가 새로운 기능을 추가한 것은 아닙니다. 차단 자동화의 외형은 그대로이고, 분석가가 호출하는 API와 슬랙으로 전달되는 알림의 형식도 거의 그대로입니다. 다만 그 외형 아래에서 동작이 더 정직해지고, 부분 실패가 가려지지 않고, 한 차단 요청이 다른 차단 요청에 영향을 주지 않게 된 것이 이번 사이클의 변화입니다.

정확성: 동작이 보고와 일치하다

가장 큰 변화는 "성공으로 보이는 실패"가 사라진 것입니다. 패치 전에는 NACL이 만들어지지 않았는데도 DDB에 차단 기록이 남거나, 일부 계정이 누락되었는데도 마지막 계정이 성공이면 슬랙에 "성공"만 떨어지는 일들이 있었습니다. 이제는 NACL이 재조회(readback)로 확인된 경우에 한해 DDB에 기록되고, 부분 실패가 있으면 그 사실이 정직하게 슬랙에 누적되어 노출됩니다. 운영자가 "차단됐다고 표시된 항목은 실제로 차단되어 있다"라고 신뢰할 수 있는 상태에 가까워졌습니다.

관찰성: 묻혀 있던 실패가 드러나다

패치 적용 직후 일시적으로 슬랙 알림이 평소보다 많아 보이는 시기가 있었습니다. 이는 새로 발생한 문제가 아니라 그동안 묻혀 있던 부분 실패가 정직하게 드러난 결과였습니다. 어느 계정의 AssumeRole이 일시 실패했는지, alldelete 직후에 어느 룰이 남았는지, 어떤 IP가 디스패치 직전에 throttle로 누락되었는지 같은 정보가 슬랙 메시지에서 직접 식별 가능한 형태로 나오기 시작했고, 이는 후속 정리 작업의 출발점이 되었습니다.

성능: 적체와 처리량의 재정렬

SQS 컨슈머 람다의 처리 시간은 메시지당 약 72초에서 약 7초 수준으로 줄었습니다. 처음 자동화를 만들었던 시점에는 "다음 메시지가 너무 빨리 진행되지 않도록"이라는 의도로 컨슈머가 메시지당 70초 가량을 대기하도록 만들어져 있었는데, 운영을 다시 들여다보니 이 직렬화 책임은 SQS FIFO의 MessageGroupId가 이미 담당하고 있다는 사실이 분명해졌습니다. 컨슈머의 긴 대기는 비동기 invoke 직후의 짧은 안전 마진으로 줄이고, NACL 차단 람다의 동시 실행은 reserved concurrency로 제어하는 것이 더 정확한 책임 분담이었습니다. 추가로 모든 AWS SDK 호출에는 botocore의 adaptive 재시도 모드를 적용해, 멀티 계정 fan-out 과정에서 발생하는 throttle 신호에 따라 클라이언트가 재시도 횟수와 요청 속도를 동적으로 조절하며 일시적인 API throttle을 자동으로 흡수하도록 했습니다.

변화 한눈에 보기

정량과 정성을 함께 정리하면 다음과 같습니다.

항목 패치 전 패치 후
NACL 실패 시 DDB 기록 여부 그대로 기록(오탐) NACL 확인된 경우에만 기록
한 계정 실패 시 다른 계정 처리 람다 invocation 전체 종료 해당 계정만 건너뜀
일부 실패의 슬랙 가시성 마지막 계정 성공이면 가려짐 계정별 결과를 정직하게 누적·노출
alldelete 직후 잔여 룰 산발적 누락 후 "GOOD!"으로 위장 자동 1회 재시도 + 잔여 시 명확한 FAIL 알림
check 직후 update의 sync 어긋남 동시 실행 람다와의 race로 산발 발생 reserved concurrency=1로 차단 람다 직렬화
같은 IP 수십 번 재요청 시 알림 수십 번(계정 수)의 Fail-IP already in DDB 메시지 한 줄의 idempotent skip 요약
컨슈머 메시지당 소요 약 72초 약 7초
AWS API 일시 throttle 대응 즉시 실패 adaptive 재시도로 자동 흡수

결과적으로 자동화의 외형은 거의 그대로지만, 동일한 차단 요청이 들어왔을 때 그 결과가 더 정확하고 정직하게 보고되는 시스템이 되었습니다.

0x05 균등한 보호와 부분 실패 사이에서

WOOWA-SOC-ACL-IR을 처음 만들 때 가졌던 기조 중 하나는, 보안팀이 관리하는 모든 AWS 계정이 동일한 수준의 보호를 받아야 한다는 것이었습니다. 어느 한 계정만 차단이 누락된다면 그 계정은 공격에 그대로 노출되는 셈이고, 멀티 어카운트 환경에서는 가장 약한 고리가 시스템 전체의 보안 수준을 결정합니다. 그래서 자동화 코드의 흐름도 자연스럽게 "한 계정이 누락되는 것보다는 모두 멈추는 것이 안전하다"는 방향으로 작성되어 있었습니다. 0x03에서 살펴본 함정 중 "한 계정의 어긋남이 람다 전체를 멈추다"가 바로 그 흐름의 직접적인 결과였습니다.

그런데 운영하다 보니 이 결정에 한 가지 함정이 있다는 사실이 분명해졌습니다. 한 계정의 어긋남 때문에 그 차단 요청을 통째로 중단하면, 같은 요청에 포함된 다른 정상 계정들까지 이번 IP에 대한 차단을 놓치게 됩니다. 결과적으로 한 계정의 보호 공백을 막으려던 결정이 다른 계정들의 보호 공백으로 번지는 일이 일어났습니다. "모두 보호받지 못하느니 한 계정만 양보하자"가 오히려 균등 원칙을 더 잘 지키는 결정에 가까웠습니다.

이번 사이클에서 내린 결정도 그 방향에 가깝습니다. 어긋남이 발견된 계정은 update를 건너뛰되, 나머지 계정의 처리는 그대로 이어가도록 했습니다. 그 대신 누가 누락되었는지를 슬랙에 정직하게 노출해, 운영자가 다시 맞춰주기 위한 작업의 출발점이 되도록 했습니다. 다만 단순한 양보로 끝내기엔 아쉬움이 남았습니다. 1차 fan-out에서 누락된 계정이 있다면, 그 자리에서 한 번 더 시도해 균등 보호를 회복할 수 있는 여지가 있기 때문입니다.

한 단계 더: 누락 계정 즉시 retry

그래서 update 흐름에 한 단계를 추가했습니다. 1차 fan-out이 끝난 직후, 실패한 계정 목록을 별도로 추적해두고, 짧은 대기(5초)를 둔 다음 그 계정들에 대해서만 한 번 더 update를 시도하도록 했습니다. 이미 차단된 다른 계정의 처리는 건드리지 않으며(이미 등록된 IP는 0x03에서 정리한 idempotent skip 흐름으로 자연스럽게 통과합니다), 누락된 계정에 대해서만 짧은 재시도 기회를 주는 방식입니다.
그림 6. 누락 계정 즉시 retry 흐름
그림 6. 누락 계정 즉시 retry 흐름

# 1차 fan-out에서 실패한 계정만 별도 추적 failed_accounts = [] for account in all_accounts: prev_error = update_error_value try: update_result = nacl_update.lambda_handler(...) except Exception as e: failed_accounts.append(account); continue if isinstance(update_result, tuple): slack_message, update_error_value, ruleno_check = update_result if update_error_value > prev_error: failed_accounts.append(account) # 누락된 계정만 5초 대기 후 1회 retry if failed_accounts: time.sleep(5) retry_targets = list(failed_accounts) failed_accounts = [] for account in retry_targets: # ... 재시도 ... if not failed_accounts: slack_append("[*] retry로 복구됨 — 균등 보호 회복") else: slack_append("[!] FAIL! - 재시도 후에도 차단 누락: {}".format(failed_accounts))

retry 횟수를 1회로 제한한 데에는 이유가 있습니다. 일시적인 STS throttle이나 AWS API의 잠깐의 오류라면 5초 대기 후 한 번의 재시도로 거의 모두 회복되는 편입니다. 그러나 retry로도 풀리지 않는 누락이라면 보통 더 구조적인 이유(계정의 권한 문제, 누적된 drift, 일시 장애의 장기화 등)가 있고, 이런 경우에는 자동 재시도를 더 반복하기보다 슬랙에 정직하게 보고하고 운영자의 확인을 거치는 편이 더 안전합니다. 무한 루프나 람다 실행 시간 폭증 같은 부작용도 같이 막을 수 있습니다.

시간을 두고 수렴하는 균등 보호

retry가 들어가면서 균등 보호의 정의도 한 단계 다듬어졌습니다. 단일 시점의 완벽한 동기는 분산 시스템 환경에서 항상 보장하기 어려우니, 시간을 두고 수렴하는 균등 보호로 원칙을 다시 정의하게 된 것입니다. 1차 fan-out으로 대부분의 계정을 즉시 보호하고, retry로 일시적 누락을 짧은 시간 안에 회복하고, retry 후에도 남는 누락은 가시화한 뒤 사후 정합화로 보완하는 흐름입니다.

앞으로의 개선 과제인 자동 조정(reconcile) 메커니즘은 이 흐름의 마지막 조각이라고 할 수 있습니다. NACL과 DDB의 어긋남을 시스템이 스스로 정합화하고, 어느 계정에서 어떤 IP가 누락되었는지를 메타데이터로 추적해 자동으로 다시 차단 요청을 발사하는 단계까지 갖춰지면, 균등 보호는 운영자의 개입 없이도 자연스럽게 회복되는 속성이 될 수 있으리라 기대하고 있습니다.

0x06 마무리하며

WOOWA-SOC-ACL-IR을 처음 만들었을 때는 작고 단순한 fan-out 자동화에 가까웠습니다. 차단 요청을 받아 모든 계정의 NACL에 deny 룰을 적용하는, 단순한 도구였습니다. 그런데 운영을 하면서 alldelete가 더해졌고, sync_check가 더해졌고, 그러는 사이 코드 안에 묻혀 있던 함정들이 하나둘 모습을 드러냈습니다. 이번 글에 정리한 다섯 가지 함정은 그중 가장 인상 깊게 남은 사례들이고, 한 사이클 안에 모두 풀어낸 결과가 0x04의 표와 0x05의 균등 보호 회복 흐름이라고 할 수 있습니다.

운영하며 얻은 가장 큰 깨달음은, 이런 함정의 상당수가 코드 그 자체의 결함이라기보다 분산 시스템의 미묘한 특성에서 비롯되었다는 것입니다. 동일한 NACL을 두 개의 람다가 거의 동시에 들여다보면 무슨 일이 일어나는지, AWS API의 일시적인 일관성 지연이 어떻게 DDB와 NACL을 한쪽만 갱신된 상태로 만들 수 있는지, throttle 한 번이 어떻게 alldelete 잔여로 누적되는지 같은 일들은 코드를 작성하는 시점에는 좀처럼 떠올리기 어렵습니다. 한 번 운영해서도 잘 보이지 않고, 비슷한 패턴이 두 번 세 번 반복되어야 비로소 윤곽이 드러나는 종류의 함정들이었습니다.

그래서 이번 글에 정리한 함정과 해결 방법은 특정 코드의 정답이라기보다, 비슷한 자동화를 운영하는 분들이 "혹시 우리도 이런 일이 일어나고 있지는 않은가" 하고 한 번 짚어볼 수 있는 체크리스트에 가깝습니다. 그리고 WOOWA-SOC-ACL-IR에 남은 과제도 아직 적지 않습니다. 0x05에서 짚은 reconcile 메커니즘 외에도, 차단 요청의 출처(자동/수동, 어느 SOAR 룰에서 발사된 차단인지)를 메타데이터로 함께 저장하는 가시화, DDB TTL을 활용한 차단 룰의 자동 만료 같은 것들이 다음 개발 과제로 남아 있습니다. 이번 개선 과제를 진행하며 시스템이 더 정직해진 것이, 다음 손볼 부분이 어디인지를 더 명확하게 보여주는 출발점이 되어주리라 기대하고 있습니다.

또한 이번 글에서는 차단 자체의 동작에 집중했지만, WOOWA-SOC-ACL-IR은 차단 결과를 일, 주간 리포트로 보안팀에 전달하여 가시성을 제공하고 TIP(Threat Intelligence Platform)에 자동 등록하는 사후 처리 흐름도 함께 담당하고 있습니다. 이 부분은 차단 자동화와는 결이 조금 다른 영역이라 기회가 닿는다면 별도의 글로 다루어보고 싶습니다.

이번 글에서 다룬 함정들은 어떤 코드가 만들어내든 분산 시스템에서 충분히 일어날 수 있는 종류의 미묘함입니다. 단위 동작은 정상이고, 로그에는 200이 찍히고, 슬랙에는 GOOD이 떠 있는데, 실제 상태는 그와 조금씩 어긋나 있는 것. 이런 어긋남은 코드를 아무리 꼼꼼히 읽어도 잘 보이지 않고, 오직 그 시스템을 오래 지켜본 사람만이 "뭔가 이상하다"는 감각으로 먼저 알아챕니다. 그래서 이번 사이클에서 제가 한 일의 절반은 코드를 고치는 것이었지만, 나머지 절반은 시스템이 자기 상태를 정직하게 말하도록 만드는 것이었습니다. 실패를 성공으로 포장하지 않고, 누락을 조용히 삼키지 않고, 어긋났으면 어긋났다고 말하게 하는 것. 자동화가 똑똑해지는 것보다, 자동화가 정직해지는 것이 운영자에게는 더 큰 안도였습니다.

코드를 짜는 일은 점점 더 빠르고 쉬워지고 있습니다. 그러나 그 코드가 수십 개 계정 위에서, 수백 번의 API 호출 사이에서, 눈에 보이지 않는 짧은 순간에 어떻게 어긋나는지를 알아채고, 의심하고, 진단해 가는 과정은 여전히 그 시스템을 가까이서 지켜본 사람의 역할입니다. 이번 사이클에서 가장 오래 걸린 일도 코드를 고치는 것이 아니라, "왜 이런 일이 벌어지는가"를 이해하는 것이었습니다.

멀티 어카운트 환경에서 보안 자동화를 운영하시는 분들께, 이 글이 "우리 시스템은 지금 정직하게 말하고 있는가"를 한 번 돌아보는 작은 계기가 되었으면 합니다.

Read Entire Article