ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • AI 시스템 테스트, 무엇을 가짜로 두고 무엇을 진짜로 돌릴까 — mock 경계 원칙
    IT 2026. 7. 29. 23:00
    AI 시스템 테스트, 무엇을 가짜로 두고 무엇을 진짜로 돌릴까 — mock 경계 원칙

    개인 로컬 AI 서버에 작은 서비스들을 붙여 두고 운영하다 보면 곧 한 가지 벽에 부딪힌다. AI 애플리케이션은 외부 의존이 유독 많다. 대형 언어 모델 API(LLM API — 프롬프트를 보내면 텍스트를 생성해 돌려주는 외부 서비스), 벡터 DB(vector DB — 문장을 숫자 벡터로 바꿔 저장하고 의미가 비슷한 것을 찾아 주는 검색 엔진), 음성 인식·이미지 분석 모델처럼 GPU를 한참 점유하는 무거운 모델까지. 이 의존들을 테스트에서 매번 진짜로 호출하면 느리고, 비싸고, 무엇보다 불안정하다.

    그래서 자연스럽게 mock(목 — 진짜 객체 대신 미리 정한 값을 돌려주는 가짜 대역)으로 갈아끼운다. 그런데 여기서 흔한 함정이 하나 있다. 전부 mock하면 테스트가 통과하는데도 아무것도 검증하지 않는다. 이 글은 "무엇을 mock하고 무엇을 진짜로 돌릴 것인가"라는 경계를 어디에 그어야 하는지에 대한 이야기다.

    배경 — AI 코드의 의존은 두 종류로 나뉜다

    테스트 관점에서 AI 애플리케이션의 코드는 성격이 전혀 다른 두 덩어리로 갈린다. 하나는 우리가 만들 수 없는 부분이고, 다른 하나는 우리가 직접 짠 부분이다.

    diagram

    다이어그램 설명. 이 그림이 보여주는 핵심은, 사용자 요청이 들어와서 응답이 나가기까지 흐름의 양 끝과 중간을 우리가 짠 로직이 감싸고 있고, 그 중간 단계에서만 외부 의존을 잠깐 부른다는 점이다. 요청을 받아 프롬프트를 조립하는 것도 우리 코드, 외부에서 받은 원시 결과를 파싱하고 분기하고 후처리하는 것도 우리 코드다. 외부 서비스는 "프롬프트를 주면 텍스트를 돌려준다" 한 토막만 담당한다. 여기서 놓치기 쉬운 점은, 버그는 거의 항상 외부가 아니라 우리 코드 쪽에서 난다는 것이다. 외부 API는 이미 검증돼 운영되는 제품이고, 우리가 새로 짠 조립·파싱·분기 로직이야말로 처음 쓰는, 그래서 틀리기 쉬운 코드다.

    문제 — 다 mock하면 테스트가 껍데기가 된다

    의존이 무거우니 mock으로 바꾸자는 발상까지는 옳다. 문제는 경계를 너무 안쪽까지 밀어 넣을 때 생긴다. 함수 전체를 mock으로 감싸 버리면, 정작 검증하고 싶었던 우리 로직까지 가짜 뒤로 사라진다.

    # 안티패턴: 함수 전체를 mock으로 덮어 버린 테스트
    def test_answer_question():
        # 1단: 우리가 짠 answer_question 자체를 통째로 mock
        #       → 안에서 무슨 일이 일어나는지 전혀 실행되지 않음
        with mock.patch("app.answer_question", return_value="42"):
            result = answer_question("삶의 의미는?")
    
        # 2단: mock이 미리 정한 값을 그대로 돌려받아 비교
        #       → "mock이 mock대로 동작했다"만 확인한 셈
        assert result == "42"
        # 프롬프트 조립이 맞았는지, 응답 파싱이 맞았는지는 아무것도 검증 못 함
    

    코드 설명. 이 코드는 "통과하지만 아무것도 검증하지 못하는" 테스트의 전형이다. 우리가 검증하고 싶은 대상인 함수 자체를 mock으로 덮어 버렸기 때문에, 그 안의 프롬프트 조립·파싱·분기 로직은 한 줄도 실행되지 않는다. 마지막 단언문이 확인하는 것은 오직 "내가 미리 넣어 둔 가짜 반환값이 그대로 나왔다"는 사실뿐이다. 이런 테스트는 초록불이 켜지므로 안심하게 만들지만, 실제로는 우리 로직이 깨져도 절대 빨간불이 되지 않는다. 가장 위험한 종류의 테스트다 — 없는 것보다 나쁘다. 없으면 적어도 "검증 안 됨"을 알지만, 이건 "검증됨"이라고 거짓 신호를 보낸다.

    방법 — 경계는 "우리가 만들 수 없는 곳"에 긋는다

    원칙은 한 문장으로 요약된다. 외부 호출은 mock하되, 우리가 짠 로직은 실제로 실행해 검증한다. mock의 경계를 함수 안쪽이 아니라, 우리 코드와 외부 서비스가 만나는 그 지점에 정확히 긋는 것이다.

    # 권장: 외부 호출만 mock, 우리 로직은 진짜로 실행
    def test_answer_question():
        # 1단: 외부 LLM '호출'만 가짜로 — 우리가 만들 수 없는 부분
        fake_llm = mock.Mock()
        fake_llm.complete.return_value = "정답은 42입니다."
    
        # 2단: 우리가 짠 함수는 진짜로 실행 (mock 안 함)
        #       → 프롬프트 조립 · 응답 파싱 · 후처리가 실제로 돌아감
        result = answer_question("삶의 의미는?", llm=fake_llm)
    
        # 3단: 외부에 '무엇을 어떻게' 넘겼는지 검증 (프롬프트 조립 로직)
        sent_prompt = fake_llm.complete.call_args[0][0]
        assert "삶의 의미는?" in sent_prompt
    
        # 4단: 외부 응답을 '어떻게 파싱'했는지 검증 (후처리 로직)
        assert result == "42"   # "정답은 42입니다." → 숫자만 뽑는 파서가 실제로 동작
    

    코드 설명. 앞의 안티패턴과 비교하면 단 한 가지가 다르다 — mock의 대상이 "우리 함수"가 아니라 "그 함수가 부르는 외부 호출"로 바뀌었다. 가짜 LLM은 정해진 문장 하나만 돌려주지만, 그 문장을 받아 숫자만 뽑아내는 파서, 질문을 프롬프트에 끼워 넣는 조립 로직은 전부 진짜로 실행된다. 그래서 외부에 넘긴 프롬프트가 맞는지, 받은 응답을 제대로 파싱했는지를 실제로 검증할 수 있다. 핵심 트레이드오프는, 가짜 LLM이 돌려줄 문장을 우리가 직접 정해 줘야 한다는 점이다. 이 가짜 응답을 실제 API가 주는 모양과 비슷하게 맞춰 두는 것이 이 방식의 생명선이며, 뒤에서 다룰 계약 불일치 문제로 이어진다.

    무엇을 mock/real/제외로 보낼지 — 결정 트리

    막상 파일을 열면 "이건 mock인가 real인가"가 매번 헷갈린다. 판단을 자동화하려면 한 가지만 물으면 된다 — "이걸 진짜로 실행했을 때, mock 자체 말고 검증할 우리 로직이 남는가?"

    diagram

    다이어그램 설명. 이 결정 트리는 모든 파일을 세 갈래 중 하나로 보낸다. 먼저 "외부 API나 무거운 모델 호출인가?"를 묻는다. 그 호출을 단순히 감싸기만 하는 순수 래퍼(wrapper — 외부 라이브러리를 그대로 호출만 하는 얇은 코드)라면, 진짜로 실행해도 검증할 게 mock밖에 없으니 테스트 대상에서 제외한다. 같은 외부 호출이라도 그 주변에 우리 로직이 섞여 있다면, 호출 지점만 가짜로 바꾸고 나머지는 실제로 돌린다. 외부 호출이 아예 없는 순수 우리 코드라면 무조건 진짜로 실행해 검증한다. 여기서 가장 흔한 오해는 "외부를 부르니까 무조건 제외"라고 단정하는 것이다 — 같은 파일 안에 응답을 가공하는 분기 하나만 있어도 그건 우리 버그가 살 수 있는 자리이므로 제외가 아니라 mock+검증 대상이다. 핵심 질문은 항상 "실행 후 mock 말고 무엇이 남는가"다.

    전부 mock vs 경계 mock — 무엇을 실제로 검증하나

    같은 함수를 두 방식으로 테스트했을 때, 실제로 검증되는 영역이 얼마나 달라지는지 나란히 보면 차이가 분명해진다. 비교를 위해 두 다이어그램을 따로 둔다.

    diagram

    ▲ 방식 A — 전부 mock

    다이어그램 설명. 첫 번째 방식은 우리 함수를 통째로 가짜로 덮은 경우다. 미리 넣어 둔 반환값이 그대로 나오는지만 비교하므로, 실제로 검증되는 우리 로직은 0이다. 테스트는 초록불이지만 우리 코드의 어떤 버그도 잡지 못한다. 빠르긴 하지만 그 속도는 "아무것도 안 해서 빠른" 것이라 가치가 없다.

    diagram

    ▲ 방식 B — 경계 mock

    다이어그램 설명. 두 번째 방식은 외부와 만나는 호출 지점만 가짜로 바꾸고 나머지는 전부 실제로 돌린 경우다. 프롬프트를 어떻게 조립했는지, 외부 응답을 어떻게 파싱하고 분기했는지가 모두 실행되므로, 검증 영역이 우리 로직 전체로 넓어진다. 두 방식 모두 외부를 진짜로 부르지 않아 빠르고 안정적이라는 점은 같다 — 차이는 오직 "그 빠른 테스트가 실제로 무언가를 검증하느냐"에 있다. 같은 비용으로 한쪽은 의미가 0이고 다른 쪽은 우리 버그를 잡는다.

    커버리지는 우리 코드에만 — first-party 측정

    경계를 잘 그어도, 커버리지(coverage — 테스트가 코드의 몇 퍼센트를 실제로 실행했는지 재는 지표)를 잘못 측정하면 숫자가 거짓말을 한다. 외부 API 래퍼처럼 mock밖에 검증할 게 없는 파일까지 분모에 넣으면, 커버리지를 올리려고 의미 없는 mock 테스트를 양산하게 된다.

    그래서 커버리지는 우리가 작성한 코드(first-party)에만 측정한다. 외부 서비스를 단순히 감싸는 래퍼는 측정 분모에서 제외(omit)한다.

    diagram

    다이어그램 설명. 이 그림은 프로젝트 코드를 커버리지 분모에 넣을 것과 뺄 것으로 가르는 기준을 보여준다. 우리가 직접 짠 조립·파싱·분기·후처리 로직만 측정에 반영하고, 외부 API 래퍼나 브라우저 자동화, 외부 명령을 단순 호출하는 subprocess(별도 프로세스로 외부 프로그램을 실행하는 호출) 래퍼, 그리고 요청을 넘기기만 하는 서버 스캐폴딩(scaffolding — 로직 없이 라우팅 골격만 잡는 코드)은 분모에서 뺀다. 이렇게 하면 커버리지 숫자가 "우리 코드 중 실제로 검증된 비율"이라는 정직한 의미를 갖는다. 주의할 점은 제외 기준이 "외부를 부르느냐"가 아니라 "테스트해도 mock 외에 검증할 게 있느냐"라는 것 — 로직이 섞인 핸들러는 외부를 불러도 분모에 남겨 두고 mock으로 감싸 테스트한다. 제외는 어디까지나 "순수 래퍼"에만 적용한다.

    왜 이게 중요한가. first-party로 좁히면 커버리지 80%가 "우리 로직의 80%가 실제로 검증됨"을 뜻하게 된다. 분모에 외부 래퍼를 섞으면 같은 80%가 "절반은 의미 없는 mock 테스트"일 수 있어, 숫자를 믿을 수 없게 된다. 측정 대상을 정직하게 좁히는 것이 곧 지표의 신뢰를 지키는 일이다.

    효과와 트레이드오프 — 빠른데 의미 있는 테스트, 그리고 계약 불일치

    경계 mock + first-party 측정을 적용하면 세 가지를 동시에 얻는다. 외부를 진짜로 부르지 않으니 빠르고, 네트워크·GPU 상태에 흔들리지 않으니 안정적이고, 우리 로직을 실제로 실행하니 의미 있는 커버리지(진짜 우리 버그를 잡는)를 확보한다.

    하지만 공짜는 아니다. 가장 큰 트레이드오프는 계약 불일치(contract drift)다. 우리가 만든 가짜 응답이 실제 API가 주는 응답과 어긋나면, 테스트는 초록불인데 실제 운영에서는 깨진다.

    diagram

    다이어그램 설명. 이 그림은 mock의 가장 위험한 실패 모드를 보여준다. 어느 날 외부 API가 응답 필드명이나 형식을 바꿨는데, 우리 가짜 응답은 옛 형식 그대로 남아 있는 상황이다. 테스트는 옛 형식을 기준으로 짜였으니 변함없이 통과하지만, 실제 운영에서는 바뀐 형식을 파싱하지 못해 장애가 난다. 초록불과 운영 장애 사이의 점선이 바로 이 "괴리"다. 여기서 놓치기 쉬운 함정은, 이 문제가 우리 로직의 버그가 아니라 가짜와 진짜의 형식 차이에서 비롯된다는 점이다. 그래서 아무리 우리 로직을 잘 테스트해도 이 종류의 깨짐은 잡히지 않는다 — 별도의 보완책이 필요하다.

    보완책은 계약 테스트(contract test — 우리 mock이 실제 API의 응답 형식과 여전히 일치하는지를 가끔 진짜 호출로 검증하는 테스트)다. 평소 빠른 테스트는 전부 mock으로 돌리되, 외부 의존마다 "실제 응답이 우리가 가정한 형식과 같은가"만 확인하는 소수의 테스트를 따로 둔다. 이건 느리고 비싸므로 매 커밋이 아니라 하루 한 번 같은 주기로 분리해 돌린다. 이렇게 하면 빠른 mock 테스트의 이점은 그대로 누리면서, 계약이 어긋나는 순간만 골라 잡아낼 수 있다.

    정리

    AI 시스템 테스트의 핵심은 "mock이냐 real이냐"를 함수 단위로 정하는 게 아니라, 경계를 정확히 우리 코드와 외부 서비스가 만나는 지점에 긋는 것이다. 우리가 만들 수 없는 외부 호출은 가짜로 두되, 우리가 짠 조립·파싱·분기·후처리는 반드시 진짜로 실행해 검증한다. 커버리지는 우리 코드에만 측정해 숫자가 정직하게 만들고, mock하면 mock밖에 안 남는 순수 래퍼는 아예 제외한다. 그리고 mock이 실제와 어긋나는 계약 불일치는 소수의 느린 계약 테스트로 따로 막는다. 이 경계 하나만 제대로 그으면, 테스트는 빠르면서도 실제로 우리 버그를 잡는 도구가 된다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.