AI가 작성한 코드는 동작한다. 에러도 발생하지 않는다. 그럼에도 불구하고 망가진 5가지 형태
요약
AI가 작성한 코드가 에러 없이 작동하더라도, 실제 운영 환경에서 문제가 발생할 수 있는 5가지 형태의 잠재적 버그 유형을 분석합니다. 특히 루프 내 I/O 문제나 JWT 설계 시 고려해야 할 계정 상태 관리 등, 테스트로는 발견하기 어려운 비즈니스 로직 및 성능 문제를 다룹니다.
핵심 포인트
- 루프 안의 await는 요소 수만큼 통신하여 성능 저하를 일으킬 수 있습니다.
- JWT는 서명과 기한만 검증하며, 서버 측 계정 상태(탈퇴/동결)를 반영하지 못합니다.
- 날짜 처리 시 시간대(Timezone) 차이로 인해 예상치 못한 날짜 오류가 발생할 수 있습니다.
- 테스트 통과 여부와 관계없이 비즈니스 요구사항을 고려한 설계 검토가 필수적입니다.
코드를 작성하는 시간이 지난 1년간 눈에 띄게 줄었습니다.
대신 늘어난 것은, 나온 코드를 읽고 '이대로 괜찮은가'를 결정하는 시간입니다. 그리고 결정하는 쪽의 어려움은 전혀 줄지 않았습니다.
이 글은, 리뷰에서 발견하기 어려운 5가지 형태를 코드로 나열한 것입니다. 공통점은 하나 있습니다.
모두 그 자리에서는 에러가 발생하지 않습니다.
테스트는 통과합니다. 로컬에서는 동작합니다. 운영 환경에서도 한동안은 동작합니다.
1. 루프 안에 I/O가 있을 때
const products = await db.product.findMany({ take: 100 });
const withCategory = await Promise.all(
products.map(async (p) => ({
...
깔끔한 코드입니다. Promise.all로 병렬화까지 되어 있습니다.
DB 조회는 101번 실행됩니다.
map 안의 await는 배열 요소마다 한 번씩 통신을 합니다. 코드에는 단 한 줄만 작성되어 있어, 루프 안에 I/O가 있다는 것이 시각적으로 사라져 있습니다.
개발 환경의 시드 데이터가 10건이라면, 11번 만에 8ms입니다. 아무도 알아차리지 못합니다. 운영 환경에서 상품이 100건이 되면 101번입니다. 카테고리 조회가 3ms라고 해도, 왕복만으로 300ms가 됩니다.
에러는 발생하지 않습니다. 단지 느려질 뿐입니다.
찾아내기 위해 필요한 지식은, 'map 안의 await는 요소 수만큼 통신한다'라는 한 줄입니다. 그리고 물어봐야 할 질문은 하나입니다.
이 배열은 운영 환경에서 몇 건이 될까요?
코드만으로는 절대 알 수 없습니다. 아는 것은 그 기능의 요구사항을 알고 있는 사람뿐입니다.
2. 로그아웃할 수 없는 JWT
// 로그인
const token = jwt.sign({ sub: user.id }, SECRET, { expiresIn: "7d" });
res.cookie("token", token, { httpOnly: true });
...
동작합니다. 로그아웃을 누르면 로그인 화면으로 돌아가고, 리로드해도 돌아가지 않습니다.
하지만 토큰 자체는 7일 동안 유효한 상태입니다.
JWT의 검증은 '서명이 올바른지'와 '기한 내인지'만 확인합니다. 서버의 상태를 보지 않습니다. 그것이 JWT 설계 그 자체이며, 그렇기에 DB를 참조하지 않아 빠릅니다.
clearCookie가 하는 것은, 해당 브라우저에서 복사본을 지우는 것뿐입니다. 토큰 자체가 어딘가에서 무효화된 것이 아닙니다.
의미가 달라지는 경우는 다음과 같습니다.
- 탈퇴한 이용자가 탈퇴 후에도 7일 동안 그대로 사용할 수 있다
- 부정 이용으로 동결시킨 계정이, 동결 후에도 사용할 수 있다
- 유출된 토큰이, 알아차린 시점부터 7일 동안 살아남는다
모두 에러가 아닙니다. 올바른 서명의, 기한 내의 토큰이므로 시스템 입장에서는 완벽하게 정상적인 요청입니다.
탈퇴한 사람을 지금 당장 차단할 수 있습니까?
여기에 '예'라고 답할 수 없는 설계라면, 유효 기간을 짧게 하거나(15분 등), 만료 목록을 두거나, 세션을 DB에 두어야 합니다. 무엇을 선택할지는 지키고 있는 자산의 가치로 결정됩니다. 사내 메모장 앱과 결제가 있는 서비스에서는 같은 답이 나오지 않습니다.
3. '오늘' 날짜가 틀어질 때
const today = new Date().toISOString().slice(0, 10);
일별 집계에서 자주 보이는 작성 방식입니다.
toISOString()은 UTC를 반환합니다.
일본 시간의 0시부터 9시 사이에는 이 today가 어제를 가리킵니다. 즉 매일, 하루 중 9시간 동안 전날로 집계됩니다.
골치 아픈 점은, 테스트에서는 나오지 않는다는 것입니다. CI는 UTC로 동작하므로 통과합니다. 개발자가 낮에 수동으로 실행해도, 그때 JST는 9시를 넘었기 때문에 정상적으로 보입니다.
알아차리는 것은, 월별 숫자가 영업 담당자의 손에 있는 숫자와 맞지 않다고 들었을 때입니다. 그리고 과거 데이터는 어느 해석으로 들어왔는지 알 수 없게 됩니다.
이 '하루'는 누구에게의 하루입니까?
'날짜'는 시각의 일종이 아니라, 어느 타임존(Time Zone)으로 나눌 것인가에 대한 요구사항입니다. 이용자가 일본에 있는 서비스라면, 일본 시간으로 나누어야 합니다. 글로벌이라면, 어디서 나눌지 결정하고 기록해야 합니다. 결정하지 않고 작성할 수는 없습니다.
4. 논리 삭제를 넣은 날부터, 모든 쿼리가 기억해야 한다
await db.user.update({
where: { id },
data: { deletedAt: new Date() },
...
탈퇴를 물리적 삭제가 아닌 논리적 삭제로 하는 것은 흔히 발생하는 판단이며, 그 자체로는 타당합니다.
문제는, 이 줄을 작성하는 순간부터 앱 내의 모든 쿼리가 '삭제됨'을 기억해야 한다는 것입니다.
// WHERE를 하나라도 빼먹으면, 탈퇴한 사람이 나옴
const members = await db.user.findMany({ where: { deletedAt: null } });
그리고 잊어버려도 에러는 발생하지 않습니다. 탈퇴한 사람이 목록에 섞여 나올 뿐입니다.
또 하나, 조용히 효력을 잃는 것이 있습니다.
UNIQUE (email)
논리적 삭제에서는 행이 남아있기 때문에, 탈퇴한 사람의 이메일 주소가 점유된 상태로 남게 됩니다. 같은 사람이 다시 등록하려고 하면 '이 이메일 주소는 사용 중입니다'라는 메시지가 나옵니다. 하지만 본인은 그런 계정을 가지고 있다고 생각하지 않습니다.
삭제한 후, 같은 이메일 주소로 등록할 수 있습니까?
답변이 '아니요'여도 괜찮다면, 그대로 두면 됩니다. '예'여야 한다면, UNIQUE (email, deleted_at)
로 하거나, 삭제 시 주소를 보관하거나, 애초에 물리적 삭제를 해야 합니다. 요건을 묻지 않고는 결정할 수 없습니다.
5. 상한이 없는 병렬 처리
await Promise.all(users.map((u) => sendWelcomeEmail(u)));
1번과 반대되는 이야기입니다. 병렬화는 옳습니다. 상한이 없다는 것이 문제입니다.
사용자가 20명일 때는 완벽하게 작동합니다. 5,000명이 된 날, 5,000개의 처리가 동시에 시작됩니다.
DB의 커넥션 풀이 10이라면, 거기서 막힙니다. 그리고 막히는 것은, 이 처리만 때문이 아닙니다. 같은 풀을 사용하는 모든 요청이 기다려집니다.
장애로 표출되는 곳은 전혀 관계없는 화면입니다. '상품 목록이 열리지 않는다'라는 보고에서, 이메일 전송 루프까지 도달하는 과정이 깁니다.
동시에 몇 개가 실행됩니까? 그 상한선은 어디서 정해져 있습니까?
'어디에도 쓰여 있지 않다'면, 그것은 배열의 크기로 결정되어 있다는 의미입니다. 배열의 크기는 대개 사용자 수입니다.
공통점은 '에러가 발생하지 않는다'
5가지 모두 예외도, 빨간 로그도, 실패한 테스트도 내지 않습니다.
타입도 통과합니다. TypeScript는 '이 배열이 실제 환경에서 5,000건이 될 것'을 모르고, '오늘(today)'이 누구의 오늘인지도 모릅니다.
그리고 여기가 본론입니다.
AI가 이것을 모르는 것이 아닙니다.
'N+1 문제가 무엇인가요?'라고 물으면, 교과서 그대로 완벽한 설명이 돌아옵니다. 'JWT로 로그아웃을 구현하려면'이라고 물으면, 만료 목록 이야기까지 나옵니다. 설명은 할 수 있습니다. 그럼에도 불구하고, 작성할 때는 실수합니다.
모르는 것은 지식이 아니라, 문맥이기 때문입니다.
- 이 배열이 실제 환경에서 몇 건이 될지
- 이 '1일'이 누구에게의 하루인지
- 이 토큰이 지키고 있는 것이 얼마만큼의 가치인지
- 이
email
이 재등록될 수 있는지
이것을 알고 있는 것은, 그 기능을 만들기로 결정한 사람뿐입니다. 그리고, 묻지 않는 한 나오지 않습니다.
그래서, 리뷰에서 물어볼 것은 5가지로 좁힐 수 있다
코드를 전부 읽는 것은 현실적이지 않게 되었습니다. 작성되는 양이 늘었기 때문입니다. 대신, 어디를 의심할지에 대한 목록을 가지고 있으면, 읽는 시간 배분이 달라집니다.
- 이 배열은 실제 환경에서 몇 건입니까? (루프 안에 I/O가 없는지)
- 탈퇴한 사람을 지금 당장 막을 수 있습니까? (만료 경로가 있는지)
- 이 '1일'은 누구에게의 하루입니까? (경계는 어느 타임존인지)
- 삭제한 후, 같은 값으로 등록할 수 있습니까? (제약 조건이 '삭제된 것'을 보고 있는지)
- 동시에 몇 개가 실행됩니까? (상한은 어디서 정해져 있는지)
모두 코드를 읽어도 답이 나오지 않는 질문입니다. 답을 가지고 있는 것은 사람이므로, 물어보면 나옵니다.
이것은 AI의 문제가 아니다
만약을 대비하여 말씀드리자면, 여기에 나열한 5가지 형태는 사람이 작성해도 똑같이 발생합니다. 저 역시 모두 경험해 본 적이 있습니다.
제 서비스에서 결제를 Stripe의 Sandbox 환경에서 실전(Production)으로 전환했을 때, 사용자 2명에 대해 고객 레코드가 22개 생성되어 있었습니다. 요청할 때마다 고객을 만들었기 때문입니다. 오류는 발생하지 않았습니다. 제가 알아차린 것은 실전에 전환하기 전에 대시보드를 우연히 살펴봤을 때였습니다.
게다가 그 수정 과정에서 다음 버그를 만듭니다. '저장된 고객 ID가 있으면 그것을 사용한다'고 코딩했더니, Stripe 측에서 삭제된 ID를 참조하여 결제 화면이 열리지 않게 되었습니다. 존재 여부를 확인한 후에 사용해야 한다는 한 줄의 코드가 빠져 있었던 것입니다.
이상했던 것은 버그의 종류가 아니라 양이었습니다. 코드 작성 속도가 빨라진 만큼, 의심하는 속도가 따라가지 못하고 있습니다.
의심하는 속도는 도구로는 높아지지 않습니다. '여기서 위험하다'고 알고 있는 지점의 수에 달려있습니다.
저는 이런 '작동하는 것처럼 보이지만 실제로는 성립하지 않은' 경우들을 모은 사이트를 만들고 있습니다. 프레임워크 사용법이 아니라, 구조와 설계 판단을 다루고 있습니다.
이 글에 나온 5가지 주제는 각각 독립된 글로 되어 있습니다 (N+1, JWT, 시간대(Timezone), 논리 삭제, 병렬 처리와 I/O). 사고 사례로 시작해서, 다이어그램을 움직여 구조를 보고, 마지막으로 '당신이라면 어떻게 결정할지'를 작성하는 구성입니다. 설명은 모두 무료입니다.
결제 관련 이야기는 다른 글에도 썼습니다. 타임아웃(Timeout)을 '실패'로 처리했을 때 무슨 일이 일어나는지, Webhook으로 400 에러 코드를 반환해서는 안 되는 경우는 언제인지에 대한 내용입니다.
토론 (Discussion)

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