AI에게 내 코드를 리팩토링 시켜봤다
이 글을 쓰게 된 계기는 단순합니다. AI 코딩 도구가 좋아졌다는 이야기를 매일 듣고 있으니, 직접 확인해 보고 싶었습니다. 그래서 실험을 했습니다. HoBom 시스템에서 제가 직접 작성한 코드를 AI에게 주고, 리팩토링과 기능 확장을 맡겨 봤습니다. 새로 만드는 것이 아니라, 이미 존재하는 코드를 개선하거나 확장하는 작업입니다. 실무에서 AI를 쓴다면 이런 방식이 훨씬 현실적이니까요. 결론부터 말하면, 패턴을 따르는 데는 능숙했고, 패턴 뒤의 맥락에는 무관심했습니다.
실험 대상: HoBom의 메시지 전송 시스템
먼저, 실험 대상이 된 코드를 소개하겠습니다. hobom-internal-backend는 Kafka를 통해 메시지를 수신하고, 메시지 타입에 따라 다른 전략으로 발송하는 구조를 가지고 있습니다. Strategy Pattern을 사용합니다.
interface MessageSenderStrategy {
fun supports(type: MessageType): Boolean
fun send(command: DeliverHoBomMessageCommand)
}
이 인터페이스를 구현한 전략이 Spring의 DI를 통해 리스트로 주입되고, SendHoBomMessageService가 타입에 맞는 전략을 찾아 실행합니다.
@Service
class SendHoBomMessageService(
private val strategies: List<MessageSenderStrategy>,
) : SendHoBomMessageUseCase {
override fun invoke(command: DeliverHoBomMessageCommand) {
val strategy = strategies
.firstOrNull { it.supports(command.type) }
?: throw UnsupportedOperationException(
"Unsupported message type: ${command.type}"
)
strategy.send(command)
}
}
Kafka에서 메시지를 수신하는 핸들러는 이렇게 생겼습니다.
@Component
class DeliverHoBomMessageHandler(
private val saveHoBomMessageDeliveryHistoryUseCase:
SaveHoBomMessageDeliveryHistoryUseCase,
private val sendHoBomMessageUseCase: SendHoBomMessageUseCase,
) : KafkaMessageHandler<DeliverHoBomMessageCommand> {
override fun handle(message: DeliverHoBomMessageCommand) {
sendHoBomMessageUseCase.invoke(message)
saveHoBomMessageDeliveryHistoryUseCase.invoke(message)
}
}
현재 MessageType에는 두 가지 타입이 정의되어 있습니다.
enum class MessageType(
override val value: String,
override val description: String,
) : DescribableEnum {
PUSH_MESSAGE("PUSH_MESSAGE", "Web Push Notification"),
MAIL_MESSAGE("MAIL_MESSAGE", "Mail Push Message"),
}
그리고 이 중 MAIL_MESSAGE 전략만 구현되어 있는 상태였습니다.
@Component
class MailMessageSender(
private val sendMailPort: SendMailPort,
) : MessageSenderStrategy {
override fun supports(type: MessageType): Boolean {
return type == MessageType.MAIL_MESSAGE
}
override fun send(command: DeliverHoBomMessageCommand) {
if (!isValidEmail(command.recipient)) {
log.warn(
"Invalid email address: {}, skipping mail delivery",
MaskUtils.maskRecipient(command.recipient)
)
return
}
sendMailPort.send(command.toMail())
}
}
MailMessageSender는 SendMailPort라는 Outbound Port에 의존합니다. Service 레이어가 외부 세계(SMTP)를 직접 다루지 않고, Port를 통해 Adapter에 위임하는 Hexagonal Architecture 구조입니다.
AI에게 시킨 것: PushMessageSender 전략 추가
이 코드를 AI에게 보여주고, 이렇게 요청했습니다.
"PUSH_MESSAGE 타입에 대한 MessageSenderStrategy 구현체를 추가해 줘. 푸시 알림을 사용자에게 보내야 해."
AI는 기존 MailMessageSender의 패턴을 정확히 따라서 PushMessageSender를 생성했습니다. supports()에서 PUSH_MESSAGE를 반환하고, send()에서 알림을 발송하는 구조. 여기까지는 훌륭했습니다. 기존 패턴을 읽고 동일한 형태로 확장하는 능력은 확실합니다. 하지만 문제는 알림을 어떻게 보내느냐에서 발생했습니다.
AI가 놓친 것 - 세 가지
1. 푸시를 hobom-internal-backend에서 직접 보내려 했다
AI는 PushMessageSender 안에서 FCM이나 WebSocket을 직접 호출하는 코드를 작성했습니다. 하지만 HoBom의 알림 흐름은 그렇게 동작하지 않습니다. hobom-internal-backend는 알림을 직접 사용자에게 보내지 않습니다. 대신, Feign 클라이언트를 통해 for-hobom-backend의 내부 API를 호출하여 알림을 저장하고, 클라이언트가 Polling으로 조회하는 구조입니다.
hobom-internal-backend (PUSH_MESSAGE 전략 실행)
│
└── Feign → for-hobom-backend POST /internal/notifications
│
└── 알림 저장만 수행 (outbox 생성 X)
│
Client polling으로 조회
AI는 "푸시를 보내라"는 요구사항에 대해 가장 직관적인 구현을 선택했을 뿐입니다. 메시지를 전송하는 서버와 알림을 저장하는 서버가 다르다는 시스템 맥락을 알 수 없었습니다.
2. 외부 API를 호출했다
"Feign으로 for-hobom-backend를 호출하라"고 다시 지시하자, AI는 코드를 수정했습니다. 하지만 이번에는 외부 API (/api/v1/...)를 호출하는 코드를 작성했습니다.
이것은 단순한 경로 실수가 아닙니다. HoBom에서 외부 API는 Outbox를 생성합니다. 내부 API는 Outbox 없이 저장만 수행합니다. 만약 PushMessageSender가 외부 API를 호출하면 이런 일이 발생합니다.
- Kafka 메시지 수신 → PushMessageSender 실행
- 외부 API 호출 → 알림 저장 + Outbox 생성
- hobom-event-processor가 Outbox Polling → Kafka 발행
- 다시 1번으로 → 무한루프
이 함정은 코드만 봐서는 알 수 없습니다. for-hobom-backend의 /internal/... 경로와 /api/v1/... 경로가 왜 나뉘어 있는지, 그 설계 의도를 이해해야만 피할 수 있습니다.
3. 에러 핸들링을 DeliverHoBomMessageHandler에 넣었다
AI에게 "발송 실패 시 처리도 추가해 달라"고 요청하자, DeliverHoBomMessageHandler에 try-catch와 재시도 로직을 추가했습니다.
override fun handle(message: DeliverHoBomMessageCommand) {
try {
sendHoBomMessageUseCase.invoke(message)
} catch (e: Exception) {
retry(message, maxAttempts = 3) // AI가 추가한 코드
}
saveHoBomMessageDeliveryHistoryUseCase.invoke(message)
}
하지만 HoBom에서 Kafka 메시지 실패에 대한 보상은 이 핸들러의 책임이 아닙니다. 실패한 이벤트는 hobom-event-processor의 DLQ 메커니즘이 담당합니다. Redis에 dlq:[category]:[event-id] 형태로 저장되고, 72시간의 TTL을 가지며, 별도의 관리 API를 통해 수동 재발행할 수 있는 구조입니다.
단순 재시도와 DLQ 기반 보존은 근본적으로 다른 전략입니다. 전자는 "지금 당장 다시 해봐"이고, 후자는 "실패를 인정하고, 추적 가능하게 보존한 뒤, 사람이 판단해서 재발행해"입니다. 이 차이는 장애를 직접 겪어본 뒤에야 내릴 수 있는 선택입니다.
AI가 잘한 것 — 이것도 솔직히 인정해야 한다
비판만 하면 공정하지 않습니다.
AI는 기존 패턴을 읽고 따르는 능력이 뛰어났습니다. MailMessageSender의 구조를 보고 동일한 형태의 PushMessageSender를 만드는 것, supports()와 send()를 올바르게 구현하는 것, SendMailPort처럼 Port를 분리해야 한다는 것까지 정확히 따라했습니다.
즉, 패턴이 이미 확립된 코드베이스에서 동일한 패턴의 확장은 AI가 충분히 잘 해냅니다. 보일러플레이트를 줄이는 데는 확실히 효과적이었습니다.
문제는 패턴 바깥의 맥락입니다.
그래서 무엇이 대체 불가능한가
세 가지 실험에서 공통적으로 드러난 것은 이것입니다. AI는 "무엇을 만들 것인가"에는 강하지만, "이 코드가 시스템 안에서 어떤 역할을 하는가"에는 답하지 못합니다.
- PushMessageSender가 Feign으로 for-hobom-backend를 호출해야 하는 이유 → 알림 저장소가 다른 서버에 있다는 시스템 구조에서 나온 결정
- 내부 API를 호출해야 하는 이유 → 외부 API가 Outbox를 생성하여 무한루프가 발생했던 경험에서 나온 결정
- 에러 핸들링이 핸들러가 아닌 DLQ에 있는 이유 → 자동 재시도가 장애를 증폭시켰던 경험에서 나온 결정
전부 코드에는 드러나지 않는, 운영에서 나온 판단입니다.
솔직한 고백
여기서 한 가지 솔직해져야 할 것 같습니다. HoBom의 현재 구성을 나열해 보겠습니다.
- for-hobom-backend: NestJS / MongoDB / gRPC
- hobom-event-processor: Go / Kafka / Redis
- hobom-internal-backend: Kotlin / Spring Boot / PostgreSQL
- hobom-space-backend: .NET / ASP.NET Core / PostgreSQL
4개의 서버. 4개의 언어. 2종의 데이터베이스. 메시지 큐와 캐시까지. 이걸 2명이 운영하고 있습니다.
위 실험에서 AI가 hobom-internal-backend에서 직접 푸시를 보내려 한 것, 사실 틀린 선택이 아닐 수 있습니다. 서버 간 Feign 호출 없이 직접 처리하면, 네트워크 홉이 하나 줄고, 내부/외부 API 구분이라는 복잡도도 사라집니다.
AI의 단순한 선택이 틀리고, 저의 복잡한 선택이 맞다고 단정할 수 없습니다. Transactional Outbox Pattern이 주는 원자성 보장이, 2인 팀 사이드 프로젝트에서 정말 그만한 가치가 있는 것인지. gRPC 인증을 위해 x-api-key 인터셉터를 양쪽에 구현하고 관리하는 비용이, 직접 호출 대비 정당화되는 것인지.
이 질문들에 대해 저는 아직 확실한 답을 가지고 있지 않습니다.
AI가 대체할 수 없는 것은 단순히 "구조적 판단"이 아닙니다. "내 판단이 정말 맞는가?"라고 되묻는 행위 자체입니다. AI는 자기가 생성한 코드를 의심하지 않습니다. 하지만 엔지니어는 자기 설계를 의심해야 합니다.
결론
AI에게 내 코드를 맡겨본 결과, AI가 대체할 수 없는 것은 코딩 능력이 아니라 맥락에 기반한 판단이라는 것을 경험 했습니다. MessageSenderStrategy 패턴을 따라 새로운 전략을 만드는 것은 AI가 잘합니다. 하지만 그 전략이 어떤 서버를, 어떤 경로로, 왜 호출해야 하는지는 답하지 못합니다.
- 이 API가 내부 API여야 하는 이유
- 이 실패가 즉시 재시도가 아니라 DLQ로 가야 하는 이유
- 이 알림이 이 서버가 아니라 저 서버에 저장되어야 하는 이유
이 판단들은 코드에 적혀 있지 않습니다. 시스템을 운영하며 실패하고, 복구하고, 회고한 사람의 머릿속에만 존재합니다.