본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 10. 21:15

단순한 코드가 아닌 설계 계약(Design Contracts)을 제공했을 때 AI 코드 리뷰가 훨씬 좋아진 이유 (Fable5 리뷰)

요약

AI가 생성한 코드가 로컬 환경에서는 작동하더라도 운영 환경의 제약 조건을 반영하지 못하는 문제를 지적합니다. 설계 계약(Design Contracts)을 통해 프로덕션 환경의 복잡성과 예외 상황을 명시함으로써 AI 코드 리뷰와 생성 품질을 높이는 방법을 다룹니다.

핵심 포인트

  • AI는 로컬에서 그럴듯해 보이지만 운영상 위험한 코드를 생성할 수 있음
  • SMTP 전송과 같은 복잡한 프로토콜은 단계별 실패 처리가 필수적임
  • 단순 구현 대신 설계 계약을 제공하여 AI에게 운영 제약 조건을 명시해야 함
  • 프로덕션 환경의 제약 조건(TCP 커넥션, 포트 압박 등)을 AI에게 인지시켜야 함

최근에 저는 PooledMailKit이라는 작은 .NET 라이브러리를 만들었습니다.

이것은 MailKit을 기반으로 구축된 SMTP 커넥션 풀 (connection pool)입니다.

NuGet:

dotnet add package PooledMailKit

https://www.nuget.org/packages/PooledMailKit

언뜻 보기에 이것은 단순한 유틸리티 라이브러리처럼 들릴 수 있습니다.

SMTP 커넥션을 재사용합니다.
모든 메시지마다 SmtpClient를 생성하고 폐기(dispose)하는 것을 방지합니다.
커넥션 오버헤드 (connection overhead)를 줄입니다.

하지만 제가 이것을 만든 진짜 이유는 성능 때문이 아니었습니다.

진짜 이유는 AI가 생성한 SMTP 코드가 로컬(locally)에서는 올바르게 보이지만, 운영상(operationally)으로는 여전히 안전하지 않았기 때문입니다.

원래의 문제: 로컬에서 올바른 코드는 충분하지 않다

시작점은 이메일을 전송하는 배치 시스템 (batch system)이었습니다.

아이디어는 간단했습니다:

기존 배치 서비스를 통해 이메일을 보내는 대신, 이를 수정하여 이메일을 직접 보낼 수 있을까?

코드를 살펴보니, 그것은 본질적으로 샘플 수준의 SMTP 코드였습니다.

클라이언트를 생성합니다.
연결합니다.
인증합니다.
전송합니다.
폐기(Dispose)합니다.

그런 종류의 코드는 개발 단계에서는 작동할 수 있습니다.

테스트를 통과할 수도 있습니다.

하지만 프로덕션 트래픽 (production traffic) 환경에서는 문제가 발생합니다.

메시지마다 SMTP 커넥션을 생성하고 폐기하면 다음과 같은 상황에 쉽게 직면할 수 있습니다:

  • 많은 수의 수명이 짧은 TCP 커넥션
  • TIME_WAIT 누적
  • 에페머럴 포트 (ephemeral port) 압박
  • 장애 발생 시 커넥션 스톰 (connection storms)
  • SMTP 서버가 느려지거나 사용할 수 없을 때의 좋지 않은 동작

AI는 이런 종류의 코드를 매우 쉽게 생성할 수 있습니다.

코드가 명백하게 틀린 것은 아닙니다.

컴파일도 됩니다.
메일도 보냅니다.
깔끔해 보입니다.

하지만 SMTP 전송의 운영적 현실을 인코딩(encode)하지 못합니다.

그것이 첫 번째 교훈이었습니다.

AI는 로컬에서 그럴듯한 구현 (locally plausible implementation)을 만들어내는 데 능숙합니다.
하지만 그 제약 조건들을 명시적으로 만들지 않는 한, 프로덕션 제약 조건 (production constraints)을 자동으로 알지는 못합니다.

SMTP 전송이 보기보다 까다로운 이유

SMTP 전송에는 미묘한 문제가 있습니다.

전송 작업은 단 하나의 원자적 작업 (atomic action)이 아닙니다.

프로토콜 단계 (protocol stages)를 거치게 됩니다:

  • connect
  • authenticate
  • MAIL FROM
  • RCPT TO
  • DATA
  • 메시지 본문 전송 (message body transmission)
  • 최종 서버 응답 (final server response)

실패가 발생하는 단계가 중요합니다.

메시지 본문이 전송되기 전에 연결이 실패한다면, 재시도 (retry)를 해도 안전할 수 있습니다.

하지만 DATA 단계가 시작된 후에 실패가 발생한다면, 클라이언트는 서버가 메시지를 수락했는지 여부를 알 수 없을 수도 있습니다.

그 시점에서 무작정 재시도하는 것은 중복 이메일을 생성할 수 있습니다.

즉, 견고한 SMTP 송신기는 단순히 다음과 같이 말할 수 없습니다:

예외가 발생했으니, 재시도한다

예외가 어느 단계에서 발생했는지 알아야 합니다.

또한 다음과 같은 서로 다른 종류의 실패를 구분해야 합니다:

  • 일시적인 SMTP 실패 (temporary SMTP failures)
  • 영구적인 SMTP 실패 (permanent SMTP failures)
  • 인증 실패 (authentication failures)
  • 수신자 거부 (recipient rejection)
  • 호스트 연결 실패 (host connectivity failure)
  • 모호한 post-DATA 실패 (ambiguous post-DATA failure)

해당 라이브러리가 안전한 재시도 동작을 제공한다고 주장하려면, 이러한 구분은 선택 사항이 아닙니다.

PooledMailKit: 이 과정에서 탄생한 라이브러리

PooledMailKit은 운영 부하 상황에서 SMTP 송신을 더 안전하게 만들기 위해 만들어졌습니다.

목표는 다음과 같았습니다:

  • 제한된 동시성 (bounded concurrency)
  • 연결을 위한 무제한 대기 방지 (no unbounded waiting for a connection)
  • SMTP 연결 재사용 (SMTP connection reuse)
  • 멀티 호스트 장애 조치 (multi-host failover)
  • 재연결 폭풍을 방지하기 위한 재연결 쿨다운 (reconnect cooldown to avoid reconnect storms)
  • 손상된 SMTP 세션의 재사용 방지 (no reuse of broken SMTP sessions)
  • 모호한 post-DATA 실패 후 무작정 재시도 방지 (no blind retry after ambiguous post-DATA failures)
  • 운영을 위한 낮은 카디널리티 메트릭 (low-cardinality metrics for operations)

따라서 이 라이브러리는 단순한 커넥션 풀 (connection pool)이 아닙니다.

이는 SMTP 송신을 둘러싼 '전송 안전 경계 (delivery-safety boundary)'입니다.

이러한 차이점은 나중에 중요해졌습니다.

AI를 사용하여 구축했지만, 맹목적인 코드 생성기로 사용하지는 않았다

개발 흐름은 AI의 도움을 받았습니다.

하지만 저는 단순히 AI에게 "SMTP 풀을 작성해줘"라고 요청하지 않았습니다.

그렇게 했다면 아마 SmtpClient를 감싸는 보기 좋은 래퍼 (wrapper) 정도를 만들어냈을 것이고, 운영상의 고려 사항 대부분을 놓쳤을 것입니다.

대신, 작업은 여러 계층으로 나뉘었습니다:

  1. 라이브러리가 방지해야 할 실패 사례를 정의합니다.
  2. SMTP 세션, 재시도 분류 (retry classification), 풀링 동작 (pooling behavior), 그리고 메트릭 (metrics)에 관한 설계 문서 (design documents)를 작성합니다.
  3. AI에게 해당 문서들을 바탕으로 구현하도록 요청합니다.
  4. 결과를 검토합니다.
  5. 실패 모드 (failure modes)에 대한 테스트를 추가합니다.
  6. 반복합니다.

중요한 부분은 1단계와 2단계였습니다.

운영상의 기대 사항이 외부화 (externalized)된 후에야 AI는 유용해졌습니다.

제가 계속해서 목격하고 있는 패턴은 다음과 같습니다:

인간이 암묵적인 판단 (implicit judgment)을 명시적인 계약 (explicit contracts)으로 전환할 때 AI는 훨씬 더 강력해집니다.

설계 계약 (The design contracts)

리뷰를 진행하기 전, 프로젝트에는 다음과 같은 사항들을 기술한 설계 문서들이 있었습니다:

제한된 동시성 (Bounded concurrency)

풀 (pool)은 MaxPoolSize를 강제해야 합니다.

임대 (Lease) 획득에는 타임아웃 (timeout)이 있어야 합니다.

무한 대기는 허용되지 않습니다.

재시도 분류 (Retry classification)

SMTP 실패는 분류되어야 합니다.

어떤 실패는 재시도 가능 (retryable)합니다.
어떤 것은 불가능합니다.
어떤 것은 모호하며 자동으로 재시도해서는 안 됩니다.

DATA 경계 (DATA boundary)

DATA가 시작된 이후의 실패는 프로토콜 결과가 확인되지 않는 한 위험합니다.

서버가 완료된 DATA 페이로드 (payload)를 명시적으로 거부한다면, 해당 메시지는 수락되지 않은 것입니다.

DATA가 시작된 후 최종 응답이 오기 전에 연결이 끊어지면, 결과는 모호합니다.

재연결 쿨다운 (Reconnect cooldown)

재연결 쿨다운 (Reconnect cooldown)은 호스트가 다운된 것으로 보일 때 연결 생성을 억제해야 합니다.

잘못된 수신자와 같은 메시지 수준의 거부 (message-level rejection)에 의해 트리거되어서는 안 됩니다.

멀티 호스트 페일오버 (Multi-host failover)

기본 SMTP 호스트가 연결을 생성할 수 없는 경우, 풀은 동일한 획득 흐름 (acquire flow) 내에서 다른 적격한 호스트를 시도해야 합니다.

메트릭 계약 (Metrics contract)

메트릭은 안정적인 이름과 낮은 카디널리티 태그 (low-cardinality tags)를 사용하여 풀 상태와 전송 결과를 노출해야 합니다.

이 문서들은 검토 가능한 계약 (reviewable contracts)이 되었습니다.

그리고 그것이 AI 리뷰의 품질을 변화시켰습니다.

Fable5를 이용한 리뷰

초기 구현 이후, 저는 Fable5로 소스 코드를 리뷰했습니다.

그 결과는 저를 놀라게 했습니다.

리뷰는 스타일에 관한 것이 아니었습니다.

주로 네이밍 (naming), 널 체크 (null checks), 또는 일반적인 정리 (cleanup)에 관한 것도 아니었습니다.

Fable5는 구현 내용을 설계 문서 (design documents)와 비교하였고, 코드가 문서화된 계약 (contract)을 실제로 이행하지 않는 부분들을 찾아냈습니다.

이것이 중요한 지점입니다.

단순히 코드만을 리뷰한 것이 아니라, 계약 (contract)을 리뷰한 것입니다.

발견 사항 1: SMTP 단계 추적 (stage tracking)이 실제로 구현되지 않음

설계서에는 발신자가 SMTP 단계 (stage)를 기반으로 실패를 분류하도록 되어 있었습니다.

하지만 실제 프로덕션 코드 (production code)에서는 단계 인지 예외 (stage-aware exceptions)가 실제로 부착되지 않고 있었습니다.

그 결과, SendAsync 도중 발생하는 대부분의 실패가 마치 DataStarted 단계에서 발생한 것처럼 처리되었습니다.

이로 인해 중요한 분류 경로 (classification path)에 도달할 수 없게 되었습니다.

시도 예산 (attempt budget) 내에서 재시도 가능해야 했던 일시적인 SMTP 4xx 실패들이 사실상 전혀 재시도되지 않았습니다.

설상가상으로, 명령 거부 (command rejections)가 모호한 DATA 이후 실패 (post-DATA failures)로 보고되고 있었습니다.

이는 모호한 전송 결과 (ambiguous send outcomes)에 대한 지표 (metric)를 부풀렸습니다.

코드는 구조화되어 보였습니다.

분류기 (classifier)도 존재했습니다.

열거형 (enum)도 존재했습니다.

테스트 (tests)도 존재했습니다.

하지만 프로덕션 경로 (production path)가 단계 정보 (stage information)를 분류기 (classifier)에 연결하지 못하고 있었습니다.

이것은 단순한 버그가 아니었습니다.

설계 계약 (design contract)의 핵심 부분이 작동하지 않는 상태임을 의미했습니다.

발견 사항 2: DATA 완료 거부가 모호한 것으로 처리됨

리뷰 과정에서 미묘한 SMTP 의미론 (semantics) 문제도 발견되었습니다.

MailKit이 MessageNotAccepted를 보고할 때, 이는 완료된 DATA 페이로드 (payload)에 대한 서버의 응답입니다.

결과는 이미 알려진 상태입니다.

서버가 메시지를 수락하지 않은 것입니다.

이것은 모호한 (ambiguous) 것으로 분류되어서는 안 됩니다.

올바른 동작은 다음과 같습니다:

  • 완료된 DATA에 대한 4xx 응답: 재시도 가능한 일시적 실패 (retryable temporary failure)
  • 완료된 DATA에 대한 5xx 응답: 영구적 실패 (permanent failure)

두 경우 모두 SMTP 트랜잭션 (transaction)은 깔끔하게 완료되었습니다.

연결 (connection)은 재사용 가능한 상태로 유지될 수 있습니다.

기존의 동작은 모호한 실패 지표 (ambiguous failure metrics)를 부풀렸고, 운영 분석 (operational analysis)의 정확도를 떨어뜨렸습니다.

이것이 중요한 이유는 지표 (metrics)가 단순한 숫자가 아니기 때문입니다.

지표는 운영자 (operators)가 시스템을 이해하는 방식을 결정합니다.

만약 지표가 “DATA 이후의 모호한 실패(ambiguous post-DATA failures)가 증가하고 있음”이라고 말한다면, 운영자 (operator)는 중복 전송 (duplicate-send) 위험을 의심할 수 있습니다.

하지만 해당 이벤트가 실제로는 이미 알려진 거절 (rejections)이라면, 그 지표는 거짓을 말하고 있는 것입니다.

발견 사항 3: 메시지 수준의 실패가 호스트를 재연결 대기 상태(reconnect cooldown)로 만듦

이것은 가장 강력한 발견 중 하나였습니다.

구현상으로는 리스 (lease)가 폐기될 때마다 재연결 대기 (reconnect cooldown)를 적용했습니다.

이는 잘못된 수신자, 호출자 취소 (caller cancellation), 또는 하나의 오래된 유휴 연결 (stale idle connection)에서의 Keep-alive 실패가 호스트 전체의 새로운 연결 생성을 억제할 수 있음을 의미했습니다.

하지만 수신자 거절 (recipient rejection)이 SMTP 호스트가 건강하지 않다는 것을 의미하지는 않습니다.

이것들은 서로 다른 개념입니다:

  • 이 연결을 폐기 (discard this connection)
  • 이 호스트를 건강하지 않음으로 표시 (mark this host as unhealthy)
  • 새로운 연결 생성을 억제 (suppress new connection creation)

이것들은 하나로 통합되어서는 안 됩니다.

예를 들어, 550 수신자 거절은 메시지 수준의 결과 (message-level outcome)입니다.

이것은 호스트 수준의 연결 실패 (host-level connectivity failure)가 아닙니다.

만약 풀 (pool)이 이를 호스트 실패로 취급한다면, 간헐적인 잘못된 수신자로 인해 유효한 풀의 크기가 줄어들 수 있으며, 결국 피할 수 있었던 PoolExhausted 오류로 나타나게 됩니다.

이것은 운영상의 버그 (operational bug)이지, 구문 버그 (syntax bug)가 아닙니다.

그리고 이것은 정확히 코드를 설계 의도 (design intent)와 비교했을 때만 드러나는 종류의 버그입니다.

발견 사항 4: 단일 획득 (acquire) 과정 내에서 장애 조치 (failover)가 발생하지 않음

설계 문서에는 다음과 같은 흐름이 기술되어 있었습니다:

  1. 기본 호스트 (primary host) 시도
  2. 연결 생성 실패
  3. 해당 호스트를 대기 상태 (cooldown)로 전환
  4. 다른 적격한 호스트 시도
  5. 성공하거나 획득 마감 시간 (acquire deadline)까지 계속 진행

하지만 구현체는 연결 생성에 실패했을 때 즉시 예외를 던졌습니다.

이는 호출자가 전송 수준의 재시도 (send-level retries)를 활성화한 경우에만 멀티 호스트 장애 조치 (multi-host failover)가 작동함을 의미했습니다.

그것은 문서화된 동작이 아니었습니다.

풀에는 멀티 호스트 설정 (multi-host configuration)이 있었습니다.

하지만 설정이 되어 있는 것과 장애 조치가 제대로 작동하는 것은 같은 것이 아닙니다.

이것 또한 계약 불일치 (contract mismatch)였습니다.

발견 사항 5: 웜 풀 (warm-pool) 채우기 실패가 전송 실패를 유발할 수 있음

MinPoolSize는 풀을 따뜻하게 유지 (keep the pool warm)하기 위해 존재합니다.

이미 유휴 연결(idle connection)이 사용 가능한 상태라면, 전송을 위한 강한 의존성(hard dependency)이 되어서는 안 됩니다.

하지만 획득(acquire) 또는 임대 반환(lease return) 중 발생하는 리필(refill) 실패는 호출자(caller)에게 전파될 수 있습니다.

최악의 경우, 정리(cleanup) 또는 리필 경로가 나중에 실패했다는 이유로 서버가 수락한 전송을 실패로 보고할 수 있습니다.

이는 잘못된 경계(boundary) 설정입니다.

풀 내부의 유지 관리(maintenance) 실패가 SMTP 전송 실패로 보고되어서는 안 됩니다.

발견 사항 6: 수락된 전송이 실패로 보고될 수 있음

이 문제는 특히 위험합니다.

서버가 메시지를 수락한 후, 임대(lease)를 풀로 반환하는 것은 정리(cleanup) 작업입니다.

만약 정리가 실패하더라도, 전송은 여전히 성공으로 보고되어야 합니다.

그렇지 않으면, 호출자가 SMTP 서버에 의해 이미 수락된 메시지를 재시도할 수 있습니다.

이는 중복 이메일을 생성합니다.

해결책은 성공 경로의 임대 완료를 최선 노력(best-effort) 방식으로 만드는 것이었습니다.

SMTP 전송 결과와 풀 정리 결과는 반드시 분리되어 있어야 합니다.

0.1.1.1 릴리스

리뷰를 바탕으로 동작 수정 릴리스인 PooledMailKit 0.1.1.1을 준비했습니다.

공개 API(public API)는 변경되지 않았습니다.

동작 방식이 설계 계약(design contract)에 부합하도록 변경되었습니다.

주요 수정 사항은 다음과 같습니다:

SMTP 단계 추론 (SMTP stage inference)

이제 송신자(sender)가 SmtpCommandException.ErrorCode로부터 단계 정보를 도출합니다.

  • SenderNotAcceptedRecipientNotAcceptedEnvelopeStarted로 매핑됩니다.
  • MessageNotAcceptedDataCompleted로 매핑됩니다.
  • 알 수 없는 명령 실패는 보수적으로 유지됩니다.

DATA 완료 거부가 더 이상 모호하지 않음

MessageNotAccepted는 이제 알려진 결과로 분류됩니다.

  • 4xx: 재시도 가능한 일시적 실패 (retryable temporary failure)
  • 5xx: 영구적 실패 (permanent failure)

SMTP 트랜잭션이 깔끔하게 완료되면 연결은 재사용 가능한 상태로 유지됩니다.

재연결 냉각 시간(Reconnect cooldown)은 연결 생성 실패에만 적용됨

메시지 수준의 실패는 더 이상 호스트를 재연결 냉각 상태로 만들지 않습니다.

이를 통해 잘못된 수신자나 호출자의 취소로 인한 잘못된 호스트 억제(host suppression)를 방지하는 동시에, 실제 연결 실패에 대한 재연결 폭풍 억제(reconnect-storm suppression) 기능을 보존합니다.

acquire 내부에서 발생하는 페일오버 (Failover)

한 호스트에 대한 연결 생성이 실패할 경우, acquire 루프는 다른 적격한 호스트로 계속 진행할 수 있습니다.

이를 통해 멀티 호스트 페일오버 (multi-host failover)가 문서화된 대로 작동하게 됩니다.

Warm-pool 리필은 전송 경로(send path)에서 최선 노력 (best-effort) 방식으로 수행됩니다

MinPoolSize 리필 실패가 더 이상 다른 방식으로 진행될 수 있었던 전송 (sends)을 실패하게 만들지 않습니다.

WarmupAsync는 여전히 실패를 명시적으로 보고합니다.

수락된 전송 (Accepted sends)은 성공 상태를 유지합니다

성공적인 전송 이후의 리스 (Lease) 정리 작업은 최선 노력 (best-effort) 방식으로 수행됩니다.

정리 작업의 실패가 더 이상 수락된 SMTP 메시지를 보고된 전송 실패로 전환시키지 않습니다.

검증 (Validation)

이번 릴리스는 .NET 타겟 프레임워크 전반에 걸쳐 검증되었습니다.

테스트 스위트는 다음을 포함했습니다:

  • 유닛 테스트 (unit tests)
  • 컴포넌트 테스트 (component tests)
  • smtp4dev를 이용한 Docker 기반 통합 테스트 (integration tests)
  • 스트레스 테스트 (stress tests)
  • 수동 스트레스 테스트 (manual stress tests)

최종 검증 결과는 다음과 같습니다:

  • 유닛 (unit): 60개 통과
  • 컴포넌트 (component): 10개 통과
  • 통합 (integration): 8개 통과
  • 수동 스트레스 (manual stress): 9개 통과

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0