Ch.9 사례: AI가 자신 있게 틀린 코드들¶
Ch.7에서 "AI는 자신 있게 틀린다(Hallucination)"고 했다. 이번에는 실제로 AI가 만든 코드에서 CS 관점으로 문제를 찾아내는 연습을 한다. 다섯 가지 사례를 보여주겠다.
9-1. 사례 A: 동시성을 무시한 코드¶
AI에게 "상품 재고를 차감하는 API를 만들어줘"라고 했다. AI가 준 코드:
@app.post("/orders")
async def create_order(item_id: int, quantity: int):
item = await db.get(Item, item_id)
if item.stock >= quantity:
item.stock -= quantity
await db.commit()
return {"status": "success", "remaining": item.stock}
raise HTTPException(400, "재고 부족")
코드가 깔끔하다. 문법 오류도 없고, 로직도 맞아 보인다. curl로 한 번 호출하면 잘 동작한다.
그런데 이 코드를 운영에 올리면? CS 관점에서 뭐가 문제일지 먼저 짚어보고 펼치자.
해답 펼쳐보기
시간 스레드A 스레드B
─────────────────────────────────────────
t1 item.stock = 10 (읽기)
t2 item.stock = 10 (읽기)
t3 10 >= 3 → True
t4 10 >= 3 → True
t5 item.stock = 7 (쓰기)
t6 item.stock = 7 (쓰기) ← 원래 4여야 함
t7 commit
t8 commit
Ch.5에서 봤다. Race Condition이다. 두 스레드가 동시에 stock = 10을 읽고, 각자 3을 빼서 7로 쓴다. 6을 빼야 하는데 3만 빠졌다.
뭘 놓쳤는가?¶
AI는 "재고를 차감한다"는 기능은 구현했지만, "동시 접근"이라는 조건을 고려하지 않았다. 왜? 프롬프트에 동시성 관련 키워드가 없었기 때문이다. AI는 단일 요청 기준으로 정상 동작하는 코드를 만든 거다.
CS를 아는 사람은 이 코드를 보는 순간 "읽고 쓰기 사이에 다른 스레드가 끼어들 수 있다"는 걸 안다. SELECT ... FOR UPDATE나 Atomic 연산으로 바꿔야 한다는 걸 안다. CS를 모르면 "잘 되는데 뭐가 문제야?"라고 넘어가고, 운영에서 재고가 음수가 된 다음에야 알게 된다.
9-2. 사례 B: 성능을 무시한 코드¶
AI에게 "사용자 목록에서 특정 이메일을 가진 사용자를 찾아줘"라고 했다. AI가 준 코드:
def find_user_by_email(users: list[User], email: str) -> User | None:
for user in users:
if user.email == email:
return user
return None
문법 오류 없고, 로직도 맞다. 사용자가 100명이면 잘 동작한다.
사용자가 100만 명이면? CS 관점에서 뭐가 문제일지 먼저 짚어보고 펼치자.
해답 펼쳐보기
| 사용자 수 | 평균 검색 시간 |
|---|---|
| 100 | < 1ms |
| 10,000 | ~5ms |
| 1,000,000 | ~500ms |
100만 건 리스트를 처음부터 끝까지 순회하는 O(n) 검색이다. 이걸 요청마다 하면 API가 느려진다.
뭘 놓쳤는가?¶
AI는 "찾는다"라는 기능은 구현했지만, 데이터 규모를 고려하지 않았다. Ch.10에서 다루는 "자료구조 선택"의 문제다.
CS를 아는 사람은 "리스트 순회는 O(n)이다. Dict(Hash Map)로 바꾸면 O(1)이다"라는 걸 안다:
아니면 더 근본적으로, "100만 건이면 메모리에 전부 올리지 말고 DB에서 인덱스로 조회해야 한다"는 판단을 내릴 수 있다.
9-3. 사례 C: 불필요한 추상화¶
AI에게 "설정 값을 관리하는 코드를 만들어줘"라고 했다. AI가 준 코드:
from abc import ABC, abstractmethod
class ConfigProvider(ABC):
@abstractmethod
def get(self, key: str) -> str: ...
class EnvironmentConfigProvider(ConfigProvider):
def get(self, key: str) -> str:
return os.environ.get(key, "")
class FileConfigProvider(ConfigProvider):
def __init__(self, path: str):
self.path = path
with open(path) as f:
self._data = json.load(f)
def get(self, key: str) -> str:
return self._data.get(key, "")
class ConfigProviderFactory:
@staticmethod
def create(provider_type: str) -> ConfigProvider:
if provider_type == "env":
return EnvironmentConfigProvider()
elif provider_type == "file":
return FileConfigProvider("config.json")
raise ValueError(f"Unknown provider: {provider_type}")
Abstract class, 구체 구현 2개, Factory 패턴. 디자인 패턴을 제대로 적용한 것처럼 보인다.
그런데 이 프로젝트에서 설정 소스가 환경 변수 하나뿐이라면? CS 관점에서 뭐가 문제일지 먼저 짚어보고 펼치자.
해답 펼쳐보기
한 줄이면 되는 걸 40줄로 만들었다.
뭘 놓쳤는가?¶
AI는 "확장 가능한 설계"를 했지만, "지금 필요한 것"을 하지 않았다. 이건 소프트웨어 공학에서 말하는 YAGNI(You Ain't Gonna Need It) 원칙 위반이다. 미래에 설정 소스가 추가될 수도 있지만, 그때 리팩토링해도 된다.
AI는 훈련 데이터에서 "디자인 패턴 적용 예시"를 많이 봤기 때문에, 물어보면 패턴을 적용한다. 적용이 필요한 상황인지 아닌지는 개발자가 판단해야 한다.
9-4. 사례 D: 페이지네이션의 OFFSET 함정¶
AI에게 "게시판 목록 페이지네이션을 만들어줘"라고 했다. AI가 준 코드:
@app.get("/posts")
def list_posts(page: int = 1, size: int = 20):
offset = (page - 1) * size
posts = (
db.query(Post)
.order_by(Post.created_at.desc())
.offset(offset)
.limit(size)
.all()
)
return posts
깔끔하다. page=1, page=2일 때 잘 동작한다. QA 환경에서도 통과한다.
문제는 운영에서 게시글이 100만 건 쌓인 다음에 누군가 page=10000을 요청할 때다. CS 관점에서 뭐가 문제일지 먼저 짚어보고 펼치자.
해답 펼쳐보기
page=1 → OFFSET 0, LIMIT 20 → 빠름
page=100 → OFFSET 1980, LIMIT 20 → 그럭저럭
page=10000 → OFFSET 199980, LIMIT 20 → 매우 느림
OFFSET이 큰 페이지를 요청하면 DB는 199,980건을 일단 읽어서 정렬한 다음, 앞의 199,980건을 버리고 뒤의 20건만 돌려준다. 읽을 게 없는 게 아니다. 다 읽어서 버린다.
뭘 놓쳤는가?¶
AI는 "페이지네이션"이라는 기능은 구현했지만, OFFSET이 DB 내부에서 어떻게 동작하는지를 고려하지 않았다. 일반적인 강의에서도 OFFSET 페이지네이션을 가장 먼저 가르치기 때문에, AI도 그렇게 학습돼 있다.
CS를 아는 사람은 "OFFSET은 N건을 읽고 버리는 연산이다. 데이터가 많아지면 선형으로 느려진다"는 걸 안다. Cursor 기반(Seek Method)으로 바꾸면 인덱스를 그대로 활용해서 O(log N)으로 끝난다:
@app.get("/posts")
def list_posts(cursor: int | None = None, size: int = 20):
q = db.query(Post).order_by(Post.id.desc())
if cursor is not None:
q = q.filter(Post.id < cursor)
posts = q.limit(size).all()
next_cursor = posts[-1].id if posts else None
return {"items": posts, "next_cursor": next_cursor}
마지막 본 id를 다음 요청에 실어 보낸다. WHERE id < cursor는 인덱스로 바로 찾기 때문에 데이터가 100만 건이든 1억 건이든 일정한 시간이 걸린다. Ch.14에서 인덱스를 본격적으로 다룬다.
9-5. 사례 E: 비밀번호 해싱의 보안 취약점¶
AI에게 "회원가입 시 비밀번호를 안전하게 저장하는 코드를 만들어줘"라고 했다. AI가 준 코드:
import hashlib
def hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def verify_password(password: str, hashed: str) -> bool:
return hash_password(password) == hashed
코드가 짧고 깔끔하다. "비밀번호를 평문으로 저장하면 안 된다"는 가장 기초적인 보안 원칙은 지켰다. SHA-256은 암호학적으로 안전한 해시 함수이기도 하다.
그런데 이 코드를 운영에 쓰면 안 된다. 왜? CS 관점에서 뭐가 문제일지 먼저 짚어보고 펼치자.
해답 펼쳐보기
| 항목 | 이 코드 | 문제 |
|---|---|---|
| Salt | 없음 | 같은 비밀번호는 같은 해시. Rainbow Table 공격 가능 |
| 연산 비용 | 매우 낮음 | GPU로 초당 수십억 번 brute-force 가능 |
| 알고리즘 | 범용 해시 | 비밀번호 해싱용으로 설계된 게 아님 |
SHA-256은 빠른 해시다. 파일 무결성 검증 같은 용도에는 적합하지만, 비밀번호 저장에는 정확히 그 "빠름"이 약점이 된다. 공격자가 유출된 DB를 받아서 GPU 클러스터로 brute-force를 돌리면 초당 수십억 개의 후보를 검증할 수 있다.
뭘 놓쳤는가?¶
AI는 "비밀번호 해싱"이라는 기능은 구현했지만, "비밀번호 해싱은 일반 해싱과 다른 요구 조건이 있다"는 걸 모른다(또는 무시한다). 정답은 bcrypt, scrypt, Argon2 같은 비밀번호 전용 해시 함수다:
import bcrypt
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode()
def verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode(), hashed.encode())
bcrypt는 세 가지를 자동으로 처리한다.
- Salt를 매번 새로 생성해서 해시 결과에 포함시킨다. 같은 비밀번호도 매번 다른 해시가 나온다.
- rounds 파라미터로 연산 비용을 의도적으로 늘린다(Key Stretching). 검증 한 번에 100ms 정도 걸리게 만든다.
- 비밀번호 전용으로 설계되었기 때문에 GPU 가속에도 저항성이 있다.
Ch.23에서 OWASP Top 10과 함께 자세히 다룬다.
9-6. 다섯 사례의 공통점¶
| 사례 | AI가 한 것 | AI가 못 한 것 | 검증에 필요한 CS |
|---|---|---|---|
| A | 재고 차감 기능 구현 | 동시성 고려 | Race Condition, Lock (Ch.5) |
| B | 검색 기능 구현 | 성능 고려 | Time Complexity, 자료구조 (Ch.10) |
| C | 디자인 패턴 적용 | 적절성 판단 | YAGNI, 관심사의 분리 (Ch.20) |
| D | 페이지네이션 구현 | DB 동작 이해 | OFFSET vs Seek Method (Ch.14) |
| E | 해싱 함수 적용 | 도메인 요구사항 | bcrypt, Key Stretching (Ch.23) |
AI는 "기능"을 만드는 데는 뛰어나다. 하지만 "그 기능이 실제 환경에서 안전하고 효율적인가"를 판단하는 건 사람의 몫이다. 그리고 그 판단에 필요한 게 CS 지식이다.
흥미로운 점은 다섯 사례의 공통 패턴이다. AI는 "가장 흔한 형태의 정답"을 학습했기 때문에, 그 정답이 통하지 않는 경계 조건(동시 접근, 대용량 데이터, 깊은 트리, 큰 OFFSET, 공격자 시나리오)을 자동으로 고려하지는 않는다. 그 경계 조건을 알고 있는 게 CS 키워드의 역할이다.
다음에서 AI가 자주 틀리는 패턴을 좀 더 체계적으로 정리한다.