본문 바로가기
회고

테스트 30분 → 3분: MSA 인증 시스템과 E2E 자동화 플랫폼 20시간 구축 썰

by Renechoi 2025. 7. 13.

0. 이 글의 탄생 배경

이 글은 "어떻게 하면 시스템 전체를 쉽게 테스트할까?"라는 질문에서 시작된 개인 프로젝트 기록입니다.

 

여러 서비스가 얽힌 복잡한 흐름을 검증하다 보면, 개발자와 QA의 경계는 모호해지고 반복 작업만 늘어납니다. 개인 프로젝트를 하는 과정에서도 만난 이 문제를 해결하고자 E2E 테스트 플랫폼을 만들어봤습니다. 레고 블록처럼 테스트를 조립하는 아이디어부터 실제 구현, 그리고 AI를 활용해 완성하기까지의 과정을 담았습니다. 상세한 과정과 후기부터 테스트와 개발 생산성에 대한 생각을 공유하고자 합니다.


 


1. "제 서비스에선 잘 되는데요?" - 마이크로서비스의 함정

개발자 농담이라고 자주 회자되는 표현이 있습니다. "내 로컬에선 되는데?"

 

실제 개발 환경에서는 이 농담이 "내 서비스에선 잘 되는데?"로 진화합니다. 그리고 이건 농담이 아니라 진짜 문제가 됩니다.

보이지 않는 벽, 서비스와 서비스 사이

퇴근 후와 주말을 활용해 팀원들과 함께 스터디 관리 플랫폼을 만들고 있었습니다. 개발자들의 스터디를 체계적으로 관리할 수 있는 서비스를 직접 만들어보자는 취지였는데요. MSA 구조로 설계했고, 저는 그 중에서 gateway와 user-service, 그리고 인증/인가 부분을 맡았습니다.

백엔드 개발자에게 '테스트'는 익숙한 단어입니다. 보통 단위(Unit) 테스트통합(Integration) 테스트의 영역까지를 책임지곤 하는 것 같습니다. 제가 맡은 user-service가 제공해야 할 API 엔드포인트들에 대해 Cucumber를 이용해 꼼꼼하게 인수 테스트 코드를 작성했습니다. 회원가입, 로그인, 프로필 조회 등 모든 시나리오가 제 로컬 환경에서는 완벽하게 초록 불을 띄웠습니다.

 

 

Feature: 회원 관리
  Scenario: 신규 회원 가입
    Given 유효한 회원 정보로
    When 회원가입을 요청하면
    Then 회원가입이 성공한다

 

하지만 이 '완벽함'은 딱 이 서비스 경계 안에서만 유효합니다.

 

사용자의 실제 여정은 user-service에서 시작되지 않습니다. 실제로는 클라이언트 → API 게이트웨이 → User-Service 순서로 요청이 들어옵니다.

  1. 클라이언트 → Gateway (회원가입 요청)
  2. Gateway → User Service (요청 전달)
  3. User Service → Gateway (응답 + JWT 토큰)
  4. Gateway → 클라이언트 (최종 응답)
  5. 클라이언트 → Gateway (토큰으로 다른 API 호출)

 



특히 게이트웨이를 거치면서 JWT 같은 인증 토큰이 생성되고, 검증되고, 파싱되어 다음 서비스로 전달되어야 했습니다. 즉, user-service의 인수 테스트가 아무리 성공해도, 게이트웨이와 엮이는 순간 완전히 다른 이야기가 펼쳐지는 겁니다.

 

이 전체 흐름을 어떻게 테스트하죠? 각 서비스의 인수 테스트만으로는 이 연계 과정에서 발생하는 문제를 잡을 수 없습니다.

백엔드 개발자의 테스트 범위, 어디까지인가

전통적으로 백엔드 개발자의 테스트는 이런 계층으로 나뉠 것입니다.

  • 단위 테스트: 개별 메서드나 클래스 테스트
  • 통합 테스트: DB 연동, 외부 API 호출 등
  • 인수 테스트: 하나의 서비스 내에서 전체 시나리오

그런데 그러면 전체 시스템을 관통하는 E2E 테스트 의 역할은 누구한테 있는 것일까요?

 

"이거... 내가 해야 하는 건가? QA가 해야 하는 건가?"

 

 




2. 개발자와 QA 사이, 그 모호한 경계

하나의 서비스가 완벽하다는 것이 전체 시스템의 완벽을 의미하지는 않습니다. 당연히 그래서 협업 프로세스가 있는 것일 텐데요.

 

그럼에도 개발자가 어떤 기능 혹은 서비스를 개발했다면 그 기능에 대한 품질 보증 책임은 최소한 1차적으로는 만든 이에게 있을 것입니다.

 

문제는 그 보증은 전체적인 흐름에서 유의미해야 한다는 것입니다.

 

"그럼 이 전체 흐름은 대체 누가, 어떻게 테스트해야 하는걸까요?"

 

가령 '사용자가 회원가입 후, 로그인해서 토큰을 받고, 그 토큰으로 자신의 프로필 정보를 조회한다'는 지극히 평범한 시나리오를 생각해봅시다. 이 흐름은 최소 3개의 API 호출이 순서대로, 그리고 성공적으로 이어져야만 완성됩니다. 여기서 책임의 경계가 모호해지기 시작합니다. 개발자는 각 API의 기능은 테스트했지만, 이들의 '연결'까지 주도적으로 검증하기는 애매합니다. 그렇다고 QA 엔지니어에게 넘기자니, 약간 찝찝하기도 합니다.

 

사실 이런 답답함과 고민은 이번이 처음이 아니었습니다. 결제 시스템을 생각해 봅시다. 결제 시스템은 우리 내부의 여러 서비스는 물론, 수많은 외부 PG사, 카드사와 얽혀 하나의 흐름을 만들어냅니다. 작은 장애 하나가 미치는 파급효과가 엄청났기에 테스트의 중요성은 이루 말할 수 없습니다. 지금 회사에서 담당하고 있는 시스템 하나만 해도 10개가 넘는 국내외 마켓과 연동되어 있어, 수많은 결제 트랜잭션 시나리오를 모두 검증해야 합니다. 하나의 기능을 테스트하기 위해 실제 모바일 기기에서 직접 결제를 시도하고, 목업(Mock) 결제 플랫폼과 게임 서버를 넘나들며 흩어진 로그를 하나하나 추적하는 과정이 필요합니다. 하지만 목업 환경만으로는 모든 엣지 케이스를 잡아낼 수 없기에 라이브 전에는 반드시 실제 결제로 전체 유저 여정을 테스트해야 합니다. 이 모든 과정을 거치다 보면, 기능 개발 시간만큼이나 테스트에 엄청난 시간을 쏟아붓고 있는 자신을 발견하게 됩니다.

 

그러다 보니 이렇게 복잡하게 얽힌 시스템의 전체 여정을 누군가 대신, 그리고 '자동으로' 검증해줄 수는 없을까 하는 열망이 마음 한편에 항상 자리 잡고 있었습니다.

"이건 개발자가 테스트해야 하나, QA가 해야 하나?"

이번 스터디 플랫폼에서도 비슷한 상황이 펼쳐집니다. 간단해 보이는 "로그인 후 스터디 생성" 시나리오 하나만 봐도 다음과 같은 스텝들을 나열할 수 있습니다.

  1. Gateway로 로그인 요청
  2. User Service에서 인증 처리
  3. JWT 토큰 발급 및 Gateway 전달
  4. 클라이언트가 토큰 저장
  5. 토큰을 포함해 Study Service로 스터디 생성 요청
  6. Gateway에서 토큰 검증
  7. Study Service에서 사용자 권한 확인
  8. 스터디 생성 후 알림 서비스 호출

 



이쯤 되면 정말 애매해집니다.

 

"내가 만든 User Service는 잘 돌아가. 근데 전체 플로우는... 이거 내 책임 맞아?"

 

개발자 입장에서는 자기 서비스만 잘 돌아가면 된다고 생각하기 쉽습니다. 하지만 사용자는 "로그인이 안 돼요"라고만 말하지, "User Service의 JWT 발급은 정상인데 Gateway의 인증 필터에서 토큰 파싱이 실패해요"라고 말하지 않죠.

반복적인 Postman 작업, 그 끝은 어디인가

만약에 이를 포스트맨으로 테스트 하는 시나리오를 생각해봅시다.

1. 회원가입 API 호출
2. 응답에서 이메일 복사
3. 로그인 API 호출에 붙여넣기
4. 응답에서 토큰 복사
5. 환경 변수에 저장
6. 다음 API 호출...

 

한두 번은 괜찮을 겁니다. 하지만 이걸 하루에 수십 번씩, 그것도 조금씩 다른 시나리오로 반복하다 보면...

 

"아, 또 토큰 복사하는 거 깜빡했네"
"어? 이메일이 뭐였더라?"
"이 시나리오 어제 테스트한 건데 어떻게 했더라..."

 

과 같은 상황이 벌어질 수 있습니다.

 

또 더 큰 문제는 팀원들과의 공유 문제입니다.

 

"어제 테스트한 그 시나리오 다시 해볼 수 있을까요?"
"아... 그게 제 로컬 Postman에만 있어서... 제가 다시 한번 설명드릴게요."

 

이런 상황에서 우리에게 필요한 건 단순한 API 테스트 도구가 아니라, 시나리오를 공유하고 재사용할 수 있는 플랫폼이 아닐까 하는 생각을 하게 됩니다.

E2E 테스트 플랫폼, 그 시작

그래서 구상하기 시작했습니다.

 

"만약 각각의 API 호출을 레고 블록처럼 만들어두고, 이걸 조립해서 시나리오를 만들 수 있다면?"

 

"이전 단계의 결과를 자동으로 다음 단계에 전달할 수 있다면?"

 

"누구나 클릭 몇 번으로 복잡한 시나리오를 실행할 수 있다면?"

 

막연한 아이디어였지만, 테스트 자동화에 대한 어떤 갈증을 해소할 수 있을 것 같았습니다. 수많은 외부 연계와 복잡한 트랜잭션 흐름도 시각적으로 구성하고 자동화할 수 있는 도구를 만들 수 있을까요? 




3. 그래서 만들었어요, '레고 블록' 같은 테스트 플랫폼


아이디어는 명확해졌습니다. 코딩 없이, 누구나 쉽게, 여러 서비스에 걸친 시나리오를 테스트할 수 있는 도구. 저는 이 아이디어를 '레고 블록'이라는 컨셉으로 풀어내기로 했습니다.

1) 컨셉: 스텝(Step)을 조립해 시나리오(Scenario)를 만든다!

여기서 '레고 블록' 하나는 개별 API 호출, 즉 스텝(Step)에 해당합니다. 회원가입, 로그인, 프로필 조회 같은 각각의 기능이죠. 이 스텝들은 YAML 파일로 간단하게 정의할 수 있어, 누구나 쉽게 새로운 블록을 추가할 수 있습니다.

 

그리고 이 블록들을 순서대로 착착 조립하면, 하나의 완성된 작품, 즉 시나리오(Scenario)가 탄생합니다. '회원가입 후 로그인해서 프로필을 조회하는' 사용자 여정 전체가 하나의 시나리오가 되는 셈입니다.

 

 


2) 구현: "코딩 대신, 마우스로 클릭하고 끌어다 놓으세요"

이 컨셉을 현실로 만들기 위해, 저는 사용자가 마주할 화면(UI)에 많은 공을 들였습니다. 여기서 UI라 함은 화려한 디자인 부분보다는 사용자 입장에서의 요구사항이 어떻게 반영되는지에 대한 부분을 의미합니다. 

 

(물론 이 경우 사용자는 저 자신입니다만...)

 

 

 

 

 

 

1. 시나리오 빌더: 드래그 앤 드롭으로 만드는 테스트

 

화면은 크게 두 부분으로 나뉩니다. 왼쪽에는 우리가 미리 정의해 둔 '회원가입', '로그인' 같은 수많은 스텝(블록)들이 목록으로 준비되어 있습니다. 사용자는 이 중에서 원하는 스텝을 골라 오른쪽의 '시나리오 구성' 캔버스 영역으로 그냥 끌어다 놓기만 하면 됩니다. 순서를 바꾸고 싶으면 위아래로 옮기면 됩니다. 

 

 

 




 

 

 

 

2. 자동 변수 매핑: 수동 작업의 종말

 

이 플랫폼의 가장 강력한 기능은 바로 '연결'의 자동화입니다. 예를 들어 '로그인' 스텝을 실행하면 응답(Response)으로 accessToken이 날아옵니다. 기존에는 이 값을 복사해서 다음 API인 '프로필 조회'의 요청 헤더에 수동으로 붙여넣어야 했죠.

 

하지만 이제 그럴 필요가 없습니다. 플랫폼이 '로그인' 스텝의 응답에서 accessToken 값을 알아서 추출해 {{login.accessToken}} 같은 변수로 만들어줍니다. 사용자는 그저 '프로필 조회' 스텝의 헤더 설정 창에서 이 변수를 선택만 해주면 됩니다. 마치 마법처럼, 모든 연결이 자동으로 이루어집니다.

 

 

 

 

3. 원클릭 실행과 시각적 결과

 

모든 조립이 끝나면, 남은 건 '실행' 버튼을 누르는 것뿐입니다. 버튼을 누르면, 플랫폼이 시나리오에 따라 스텝들을 순차적으로 실행하며 진행 상황을 실시간으로 보여줍니다. 각 스텝은 성공 시 초록색 ✅, 실패 시 빨간색 ❌으로 표시되어 한눈에 결과를 파악할 수 있죠. 특정 스텝을 클릭하면 당시의 자세한 요청(Request)과 응답(Response) 정보까지 투명하게 확인할 수 있습니다.

 

시나리오를 실행하는 화면입니다.

 

 

 

 

 

 

 

 

당연히 실패 케이스에 대해서도 각 스텝별로 혹은 시나리오 별로 조합해서 확인할 수 있습니다. 

 

 

 

 

실행 결과를 저장해서 통계를 내거나 관리자의 트리거 없이 완전 자동화하는 영역은 차후 고도화 영역으로 남겨두었습니다. 

 

그럼 이제 백엔드에서는 어떻게 작동하는지 살펴보겠습니다.

1. 스텝(Step) - 가장 작은 단위의 정의

먼저 각각의 API 호출을 YAML로 정의했습니다. YAML은 개발자가 아닌 사람도 읽고 이해할 수 있을 정도로 직관적이므로 비개발자의 소통을 염두에 둔 데이터 포맷입니다.

 

id: register-success
name: Successful User Registration  
description: 새로운 사용자 등록 (유효한 데이터)
method: POST
url: /api/users/register
headers:
  Content-Type: application/json
body:
  email: "{{email}}"
  password: "{{password}}"
  name: "{{name}}"
expect:
  status: 201
extract:
  registeredEmail: $.email
  accessToken: $.accessToken

 

여기서 핵심은 {{변수}} 문법과 extract 부분입니다. 이전 단계의 결과를 자동으로 다음 단계로 전달할 수 있는 마법의 시작이 되는 키입니다.

 

2. 변수 치환의 마법: ExecutionContext

{{email}}, {{password}} 같은 변수들이 어떻게 실제 값으로 바뀔까요? ExecutionContext라는 저장소를 사용합니다.

public class ExecutionContext {
    private final Map<String, Object> variables = new HashMap<>();

    // 변수 저장
    public void setVariable(String key, Object value) {
        variables.put(key, value);
    }

    // 템플릿 문자열의 변수를 실제 값으로 치환
    public String resolveVariables(String template) {
        if (template == null) return null;

        String result = template;
        // {{email}} → test123@example.com 으로 변환
        for (Map.Entry<String, Object> entry : variables.entrySet()) {
            String placeholder = "{{" + entry.getKey() + "}}";
            result = result.replace(placeholder, String.valueOf(entry.getValue()));
        }

        // 특수 변수 처리
        result = result.replace("{{timestamp}}", String.valueOf(System.currentTimeMillis()));
        result = result.replace("{{random}}", String.valueOf(Math.random()));

        return result;
    }
}

 

실행 중에는 이런 일이 벌어집니다.

  1. 사용자가 입력한 초기값들이 컨텍스트에 저장됩니다 (예: email = "test123@example.com")
  2. 스텝 실행 시 {{email}}이 포함된 템플릿을 만나면
  3. ExecutionContext가 이를 실제 값인 "test123@example.com"으로 바꿔줍니다

 

3. 스텝 실행기(StepExecutor): 실제 API 호출의 핵심

이제 정의된 스텝을 실제로 실행하는 부분을 살펴보겠습니다.

@Service
public class StepExecutor {

    public StepResult execute(Step step, ExecutionContext context) {
        // 1단계: 변수 치환
        String resolvedUrl = context.resolveVariables(step.getUrl());
        Object resolvedBody = resolveBody(step.getBody(), context);

        // 2단계: HTTP 요청 실행
        ResponseEntity<String> response = executeHttp(
            step.getMethod(), 
            gatewayUrl + resolvedUrl,
            resolvedBody,
            step.getHeaders()
        );

        // 3단계: 응답 검증
        boolean success = validateResponse(response, step.getExpect(), context);

        // 4단계: 값 추출 (다음 스텝을 위해)
        if (success && step.getExtract() != null) {
            Map<String, Object> extracted = extractValues(
                response.getBody(), 
                step.getExtract()
            );
            // 추출된 값들을 컨텍스트에 저장
            extracted.forEach(context::setVariable);
        }

        return StepResult.builder()
            .success(success)
            .statusCode(response.getStatusCode())
            .responseBody(response.getBody())
            .extractedValues(extracted)
            .build();
    }
}

 

실행 과정을 시각화하면 이렇습니다.

 

[YAML 스텝 정의]
       ↓
[변수 치환] {{email}} → test123@example.com
       ↓
[HTTP 요청] POST /api/users/register
       ↓
[응답 검증] status: 201 ✓
       ↓
[값 추출] accessToken → context에 저장
       ↓
[다음 스텝으로 전달]


4. 응답 검증의 지능화

단순히 상태 코드만 확인하는 게 아닙니다. 응답 본문의 세부 내용까지 검증합니다.

private boolean validateResponse(ResponseEntity<String> response, 
                               Map<String, Object> expectations,
                               ExecutionContext context) {
    // 상태 코드 검증
    if (expectations.containsKey("status")) {
        int expectedStatus = (int) expectations.get("status");
        if (response.getStatusCode().value() != expectedStatus) {
            return false;
        }
    }

    // 응답 본문 검증 (JsonPath 사용)
    if (expectations.containsKey("body")) {
        Map<String, Object> bodyExpectations = 
            (Map<String, Object>) expectations.get("body");

        for (Entry<String, Object> entry : bodyExpectations.entrySet()) {
            String jsonPath = "$." + entry.getKey();
            Object actualValue = JsonPath.read(response.getBody(), jsonPath);
            Object expectedValue = entry.getValue();

            // 변수가 포함된 경우 치환 후 비교
            if (expectedValue instanceof String) {
                expectedValue = context.resolveVariables((String) expectedValue);
            }

            if (!Objects.equals(actualValue, expectedValue)) {
                log.error("검증 실패: {} - 예상값: {}, 실제값: {}", 
                    jsonPath, expectedValue, actualValue);
                return false;
            }
        }
    }

    return true;
}

 

예를 들어, 회원가입 API의 응답이 이러한 상황에서

{
  "email": "test123@example.com",
  "name": "Test User",
  "message": "User registered successfully"
}

 

YAML의 expect 부분이 이렇게 정의되어 있으면,

expect:
  status: 201
  body:
    email: "{{email}}"  # 요청한 이메일과 동일한지 확인
    message: "User registered successfully"

 

시스템이 자동으로 응답의 email이 요청 시 보낸 email과 같은지 검증합니다.

5. 데이터 추출의 자동화: JsonPath의 활용

extract 부분은 JsonPath를 사용해 응답에서 필요한 값을 뽑아냅니다.

private Map<String, Object> extractValues(String responseBody, 
                                         Map<String, String> extractPaths) {
    Map<String, Object> extracted = new HashMap<>();

    extractPaths.forEach((key, jsonPath) -> {
        try {
            // $.data.accessToken 같은 경로로 값을 추출
            Object value = JsonPath.read(responseBody, jsonPath);
            extracted.put(key, value);

            log.info("값 추출 성공: {} = {}", key, value);
        } catch (Exception e) {
            log.warn("값 추출 실패: {} (경로: {})", key, jsonPath);
        }
    });

    return extracted;
}

 

로그인 응답에서 토큰을 추출하는 실제 예시는 아래와 같습니다. 

extract:
  accessToken: $.data.accessToken
  refreshToken: $.data.refreshToken
  userEmail: $.data.email

 

응답이 이렇게 오면

{
  "data": {
    "accessToken": "eyJhbGciOiJIUzI1NiIs...",
    "refreshToken": "eyJhbGciOiJIUzI1NiIs...",
    "email": "test123@example.com"
  }
}

 

자동으로 이런 변수들이 생성됩니다:

  • {{accessToken}} = "eyJhbGciOiJIUzI1NiIs..."
  • {{refreshToken}} = "eyJhbGciOiJIUzI1NiIs..."
  • {{userEmail}} = "test123@example.com"


6. 시나리오 실행: 스텝들의 오케스트레이션

개별 스텝들이 모여 시나리오가 되면, 이를 순차적으로 실행하는 로직이 필요합니다.

@Service
public class ScenarioExecutor {
    private final StepExecutor stepExecutor;

    public ScenarioResult executeScenario(Scenario scenario, 
                                         Map<String, Object> initialContext) {
        ExecutionContext context = new ExecutionContext();

        // 초기 컨텍스트 설정 (사용자 입력값)
        initialContext.forEach(context::setVariable);

        List<StepResult> results = new ArrayList<>();
        boolean allSuccess = true;

        // 각 스텝을 순차적으로 실행
        for (ScenarioStep scenarioStep : scenario.getSteps()) {
            Step step = stepRepository.findById(scenarioStep.getStepId());

            log.info("스텝 실행 시작: {} ({}번째)", 
                step.getName(), scenarioStep.getOrder());

            try {
                StepResult result = stepExecutor.execute(step, context);
                results.add(result);

                if (!result.isSuccess()) {
                    log.error("스텝 실패: {}", step.getName());
                    allSuccess = false;
                    break; // 실패 시 중단
                }

                // 성공한 경우, 추출된 값들을 컨텍스트에 추가
                if (result.getExtractedValues() != null) {
                    result.getExtractedValues().forEach((key, value) -> {
                        // step1.accessToken 형태로 저장
                        String contextKey = String.format("step%d.%s", 
                            scenarioStep.getOrder(), key);
                        context.setVariable(contextKey, value);

                        // 편의를 위해 키 이름만으로도 접근 가능하게
                        context.setVariable(key, value);
                    });
                }

            } catch (Exception e) {
                log.error("스텝 실행 중 오류: {}", e.getMessage());
                allSuccess = false;
                break;
            }
        }

        return ScenarioResult.builder()
            .scenarioId(scenario.getId())
            .success(allSuccess)
            .stepResults(results)
            .totalDuration(calculateTotalDuration(results))
            .build();
    }
}

 

실행 흐름을 보면,

1. 회원가입 스텝 실행
   → email, name 추출 → context에 저장

2. 로그인 스텝 실행  
   → {{email}} 자동 치환 → 요청
   → accessToken 추출 → context에 저장

3. 프로필 조회 스텝 실행
   → Authorization: Bearer {{accessToken}} 자동 치환
   → 프로필 정보 확인


7. 실시간 피드백: 진행 상황 전달

사용자가 기다리지 않도록, 각 스텝의 실행 상황을 실시간으로 전달합니다.

@RestController
public class TestExecutionController {

    @PostMapping("/api/tests/run/scenario/{scenarioId}")
    public ResponseEntity<ScenarioResult> runScenario(
            @PathVariable String scenarioId,
            @RequestBody Map<String, Object> context) {

        // 비동기 실행을 위한 준비
        CompletableFuture<ScenarioResult> future = 
            CompletableFuture.supplyAsync(() -> {
                return scenarioExecutor.executeScenario(
                    scenarioRepository.findById(scenarioId), 
                    context
                );
            });

        // 타임아웃 설정 (30초)
        try {
            ScenarioResult result = future.get(30, TimeUnit.SECONDS);
            return ResponseEntity.ok(result);
        } catch (TimeoutException e) {
            return ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT)
                .body(createTimeoutResult());
        }
    }
}


8. 에러 처리와 디버깅 지원

문제가 발생했을 때 원인을 쉽게 파악할 수 있도록, 상세한 로그와 에러 메시지를 제공합니다.

public class StepResult {
    private boolean success;
    private int statusCode;
    private String requestUrl;
    private Object requestBody;
    private Map<String, String> requestHeaders;
    private String responseBody;
    private Map<String, String> responseHeaders;
    private String errorMessage;
    private long durationMs;

    // 디버깅을 위한 상세 정보
    private String resolvedUrl;  // 변수 치환 후 실제 URL
    private Object resolvedBody;  // 변수 치환 후 실제 요청 본문
}

 

실패 시 이런 정보를 볼 수 있습니다.

❌ 로그인 실패
원인: 응답 검증 실패
예상 상태 코드: 200
실제 상태 코드: 401
요청 URL: http://localhost:8080/api/auth/login
요청 본문: {"email": "test123@example.com", "password": "***"}
응답 본문: {"error": "Invalid credentials"}
실행 시간: 89ms


9. 확장성을 위한 설계

새로운 기능이 추가되어도 기존 코드를 수정하지 않고 확장할 수 있도록 설계했습니다.

// 커스텀 검증 로직 추가 가능
public interface ResponseValidator {
    boolean validate(ResponseEntity<String> response, 
                    Map<String, Object> expectations,
                    ExecutionContext context);
}

// 커스텀 데이터 추출기 추가 가능
public interface DataExtractor {
    Map<String, Object> extract(String responseBody, 
                               Map<String, String> paths);
}

// 동적 변수 생성기 추가 가능
public interface DynamicVariableProvider {
    String getVariableName();
    Object generateValue();
}

 

예를 들어, UUID를 생성하는 변수를 추가하고 싶다면,

@Component
public class UuidVariableProvider implements DynamicVariableProvider {
    public String getVariableName() { return "uuid"; }
    public Object generateValue() { return UUID.randomUUID().toString(); }
}

 

이제 {{uuid}}를 사용할 수 있습니다!


단순함 속의 강력함

백엔드의 구현은 복잡해 보일 수 있지만, 핵심은 단순합니다.

  1. YAML로 정의된 스텝을 읽고
  2. 변수를 치환하고
  3. HTTP 요청을 실행하고
  4. 응답을 검증하고
  5. 필요한 데이터를 추출해서
  6. 다음 스텝에 전달한다

이 간단한 원리가 반복되면서, 복잡한 시나리오도 자동으로 실행할 수 있게 됩니다. 마치 레고 블록을 하나씩 쌓아가듯, 작은 스텝들이 모여 거대한 테스트 시나리오를 완성하는 것입니다.

 


 



4. AI 코드 생산성에 대한 경험과 생각

이 작업은 사실 MSA로 작동할 시스템 전체 중 앞단의 환경을 구상하는 과정에서 서브 작업으로 진행한 내용입니다. API 게이트웨이, user-service 를 먼저 작업했고 깃헙 액션으로 미니 PC에 CI/CD를 먼저 작업했었습니다. 그런 뒤 이 E2E 테스트 플랫폼의 프론트엔드와 백엔드를 구현한 것입니다. 

 

 

이 모든 것, 즉 마이크로서비스에서의 인증 시스템과 로컬과 서버에서 작업 -> 배포 파이프라인까지 정상화해서 이제 비즈니스 개발에만 집중하면 되는 환경을 만들기까지 걸린 순수 작업 시간을 계산해보니 넉넉하게 잡아도 20시간이 채 걸리지 않은 듯 합니다. E2E 플랫폼은 자기전에 기획을 던져놓고 아침에 일어나서 만든 것을 고려하면 3시간도 안걸려서 완성까지 한 셈입니다. 제가 생각해도 말이 안되는 속도입니다.

 

(실제로 CI/CD에서 삽질한 걸린 시간이 길어서 이정도라 AWS 사용했다면 더 빨랐을지도...)

 

 

이게 어떻게 가능했을까요? 단연 AI 덕분입니다. 그 중에서도 Claude Code의 압도적인 도움 때문입니다. 1년 전만 해도 AI를 사용한다는 것은 개발자의 자존심을 건드리거나 공정한 실력 경쟁이 아닌 '치팅'처럼 여겨지기도 했던 것 같습니다. 패러다임 전환이 컨센서스를 갖춰가는데 고작 6개월도 안걸렸다는 사실이 놀랍습니다. 이 변화의 흐름은 더 빠르게 가속화될 것이라 생각합니다.

 

 

Github Insight 기능을 사용하려면 public이거나 upgrade를 해야 한대서, 작업 통계를 클로드 코드에게 산출해달라고 했더니 다음과 같은 결과를 내주네요.

 

 

생산성 지표를 요약하면 

 

- 전체 코드 생산성: 627 라인/시간

- 기능 구현 속도: 3.1 기능/시간

- 총 구현 기능 수: 91개

 

라고 합니다. 

 

잠깐 사이드 트랙이지만, 제 생각에 적어도 코드에 있어서 1황은 클로드 코드가 맞는 것 같습니다. Grok은 안써봐서 제외하고, 나머지 모델들 Gpt, Codex, Gemini, Gemini Cli에 비하면 압도적입니다. 그렇게 생각을 굳히게 된 데에는 다음의 글에서 작성한 경험도 큰 몫이 있습니다.

 

 

프론트 제가 한번 세팅해봤는데요 (← 프론트 1도 모르는 백엔드 개발자)

이 글의 탄생 배경 혼자서 뚝딱거려 만든 프론트엔드 작은 토이 프로젝트가 있었습니다. 처음에는 그저 제 컴퓨터에서 돌아가기만 하면 만족이었죠. 하지만 이 프로젝트를 더 이상 혼자가 아닌,

upcurvewave.tistory.com

 

 

특히 이번 프로젝트의 핵심인 E2E 테스트 플랫폼의 '화면'을 그리는 과정에서 놀라는 일에 이제는 무뎌졌습니다. 전형적인 백엔드 개발자 입장이라면 화면(UI)을 만드는 일은 늘 막막하고 어렵게 느껴져야 합니다. 과거 같았으면 간단한 관리자 페이지 하나를 만드는 데도 며칠 밤낮을 헤맸을 겁니다.

 

하지만 이제는 순수 자연어 명령 몇마디로 충분히 만족할만한 결과물이 순식간에 나오기 때문에 무언가를 구현해야 할 때 거리낌이 없고 두려움도 없습니다. 

 

현재 회원가입과 로그인, 프로필 조회 이러한 스텝들이 개별적으로 잘 정의는 되어 있는 상황인데요, 사용자 입장에서는 여러 스텝을 조합해서 해보고 싶은 니즈가 있습니다. 따라서 시나리오 섹션에서는 이 스텝들을 사용자가 마음대로 조합하고 순서도 설정하고, 이전 스텝에서 보낸 요청과 그 응답 그리고 그 컨텍스트를 이어서 다음 스텝으로 연결하는 이 시나리오를 구성해서 사용할 수 있으면 좋겠습니다. 이런게 가능할까요 ? 구상해주세요

 

이렇게 질문하면 클로드 코드는 완성된 코드를 바로 내놓는 대신, 드래그 앤 드롭 UI 컨셉, 상태 관리에 적합한 라이브러리(React-DND) 추천, 그리고 각 컴포넌트가 어떻게 상호작용해야 하는지에 대한 청사진을 먼저 제시합니다. 

 

그리고 이 구체적인 기획안을 바탕으로 코드를 요청하면, 비로소 상태 관리 로직과 이벤트 핸들러까지 포함된, 그야말로 '살아있는' 코드를 작성하기 시작합니다. 결국 저는 AI가 제안한 똑똑한 설계도 위에서 '말 몇마디로' 원하는 화면을 얻은 셈입니다.

 

이 경험을 통해 확신이 드는 생각은 다음과 같습니다. "백엔드 개발자가 '저는 프론트엔드는 잘 몰라서요'라고 말하는 시대는 끝났다." 아이디어와 명확한 기획만 있다면, 이제 기술적인 구현 장벽은 AI 덕분에 현저히 낮아졌습니다.

 

"UI가 좀 불편한데요?"
"아... 저 백엔드 개발자라서 프론트는 잘..."

 

이제 그런 변명은 통하지 않습니다.

 

Claude에게 자연어로 설명하면, 프로덕션 레벨의 UI가 뚝딱 나옵니다. TypeScript 타입 정의부터 에러 처리, 성능 최적화까지 알아서 해줍니다.

 

실제로 이번 프로젝트의 프론트엔드 코드를 보면 아래와 같습니다.

  • Custom Hook을 활용한 깔끔한 상태 관리
  • React Query를 이용한 서버 상태 동기화
  • 메모이제이션을 통한 성능 최적화
  • 완벽한 타입 안정성

단순히 Html과 Js만으로도 충분했지만 굳이 리액트를 사용한 것은 그게 너무나 쉽기 때문이었습니다.

 

확실히 개발자의 역할은 달라지고 있습니다. 더 이상 모든 악기를 직접 연주할 필요가 없어졌습니다. 대신 어떤 음악을 만들 것인지 명확한 '목적'과 '방향'을 제시하고, AI라는 뛰어난 연주자들을 지휘하는 '오케스트레이터'의 역할이 더 중요해지는 듯 합니다. 

 

"AI가 다 해주는데 개발자가 왜 필요해?"

 

이 질문에 답으로 이렇게 이야기해볼 수 있을까요?

 

"AI는 HOW를 잘 알지만, WHAT과 WHY는 여전히 인간의 영역입니다."

 

이번 E2E 플랫폼도 마찬가지였습니다.

  • WHAT: 여러 서비스를 걸친 시나리오 테스트가 필요하다
  • WHY: 수동 테스트는 비효율적이고 공유가 어렵다
  • HOW: React로 UI를 만들고, Spring Boot로 실행 엔진을 구현한다

AI는 마지막 HOW를 도와줬을 뿐입니다. 처음 두 개를 정의하는 것, 그것이 바로 개발자의 핵심 가치입니다.

 

한편, 이런 생산성의 폭발적인 증가는 단순히 '개발이 편해졌다'는 의미를 넘어섭니다. 이는 곧 개발자에게 주어지는 책임의 무게가 달라진다는 뜻이기도 합니다.

 



5. 개발자의 변명이 사라지는 시대, 테스트에 대한 단상을 곁들인


마지막으로 테스트에 대한 단상을 끄적여보면서 글을 마무리할까 합니다.

 

"만약 시간이 절대적으로 부족하다면, 수많은 단위 테스트와 단 하나의 전체 인수/E2E 테스트 중 무엇을 선택해야 할까요?"

 

개인적으로 저는 인수테스트가 훨씬 중요하다고 생각합니다. 물론, 단위 테스트는 쉽게 그리고 많이 짤 수 있습니다. 이번에 구현한 user-service에도 100개가 넘는 테스트 케이스가 있고 그중 60%는 단위 테스트입니다.

 

// 이런 단위 테스트가 과연...
@Test
void 이메일_검증_성공() {
    assertTrue(EmailValidator.isValid("test@example.com"));
}

// 이것보다 가치 있을까?
Scenario: 회원가입 후 로그인 성공
  Given 신규 사용자 정보로 회원가입을 완료하고
  When 동일한 인증 정보로 로그인하면
  Then JWT 토큰이 발급되고 사용자 정보를 조회할 수 있다

 

시스템에 대한 진짜 '확신'을 주는 것은 결국 전체 흐름을 검증하는 후자라고 생각합니다. 개별 부품이 정상임을 100번 증명하는 것보다, 그 부품들이 조립된 자동차가 실제로 앞으로 나아간다는 것을 한 번 증명하는 것이 사용자에게는 더 큰 의미를 갖기 때문입니다.

 

단위 테스트가 개발자의 코드를 안심시킨다면, 인수/E2E 테스트는 사용자의 경험을 보증합니다.

 

하지만 현실에서는 '속도'라는 가치와 부딪히며 테스트가 타협의 대상이 되곤 했습니다. '빠른 출시'와 '높은 안정성' 사이의 오랜 딜레마 속에서, '시간 부족'은 가장 현실적인 이유이자 편리한 방패였습니다.

 

"이번 릴리즈 일정에 맞추려면, 일단 핵심 기능 구현에만 집중해야 할 것 같아..."

 

어느 정도는 사실이었습니다. 비즈니스 요구사항을 빠르게 구현하는 것과 기술적으로 완벽한 코드를 짜는 것은 늘 상충하는 가치처럼 여겨졌죠.

 

하지만 AI는 이 해묵은 딜레마의 전제 자체를 바꾸고 있습니다. 이제 AI는 단순히 코드를 대신 짜주는 도구를 넘어, 개발 전 과정을 함께하는 '페어 프로그래머'에 가깝습니다.

 

이번 프로젝트에서 저 역시 AI와 끊임없이 대화하며 개발을 진행했습니다. 막연한 아이디어를 던지면 구체적인 설계와 코드를 제안받았고, 예기치 못한 버그를 만나면 로그와 함께 상황을 중계하여 해결책을 찾았습니다. 제가 놓친 엣지 케이스를 AI가 먼저 제안하고, 지루한 테스트 데이터 생성을 도맡아주기도 했죠. 이 과정은 마치 옆자리의 시니어 개발자와 핑퐁을 치며 문제를 해결해나가는 것과 같았습니다.

 

이처럼 '시간' '기술 구현 능력'이라는 제약이 빠르게 허물어지면서, 우리는 자연스럽게 새로운 질문과 마주하게 됩니다.

기술이 더 이상 개발의 발목을 잡지 않을 때

AI를 사용하면 할 수록 느끼는 것은, 기술적 제약은 사라지고 있다는 사실입니다. 기술의 난이도와 격차는 점점 무의미해지고 있는 것 같습니다.

  • 백엔드 개발자인 제게 낯설었던 프론트엔드 UI 구현 → AI가 길을 제시
  • 반복적이고 지루한 테스트 코드 작성 → AI가 가속
  • 한 번만 설정하면 되는 인프라/배포 스크립트 → 템플릿과 AI로 해결

물론 이렇게 단순하게 말하면 마치 모든 개발이 쉬워진 것처럼 들립니다. 프론트엔드만 해도 상태 관리, 성능 최적화, 접근성, 크로스 브라우징, 반응형 디자인... 끝이 없죠. 백엔드도 마찬가지구요. 데이터베이스 설계와 최적화, 트랜잭션 관리, 캐싱 전략, 메시지 큐, 분산 시스템의 복잡성, 그리고 운영의 영역까지.

 

AI가 이 모든 복잡성을 해결해줄 수 있을까요? 솔직히 잘 모르겠습니다. 

 

하지만 확실한 것은, AI가 우리에게 더 많은 시간과 여유를 선물하고 있다는 사실입니다. 반복적인 작업에서 해방되어, 정말 중요한 문제에 집중할 수 있게 되었습니다. 이는 더 중요한 질문에 집중할 수 있다는 뜻입니다.

 

"이 기능이 정말 필요한가? 사용자의 워크플로우를 개선하는가? 더 나은 경험을 제공할 방법은 없을까?"

 

이 글의 소재가 된 E2E 테스트 플랫폼도 마찬가지입니다. 그저 "개발자가 테스트하느라 낭비하는 시간을 줄이고 싶었을" 뿐입니다. 그리고 이 일을 완수해서 막연히 원했던 무언가를 손에 쥐기 까지 대단한 기획이 필요한 것도 아니었습니다. 간단한 목표와 모호한 구상에서 시작해, 대화를 주고받으며 선명해지는 과정에서 방향을 조금씩 잡아주었을 뿐입니다.

 

글의 처음으로 돌아가 보면, 저는 "어떻게 하면 시스템 전체를 쉽게 테스트할까?"라는 질문으로 시작했습니다. 사실 답은 의외로 단순합니다.

 

"쉽게 테스트하려면, 테스트를 쉽게 만들면 된다."

 

레고 블록처럼 조립할 수 있는 스텝, 자동으로 연결되는 변수, 한 번의 클릭으로 실행되는 시나리오. 이 모든 것이 가능해진 건 AI라는 강력한 도구 덕분이었습니다. 여기서 주목하고 싶은 건, 필요했던 것이 기술적 지식이 아니었다는 점입니다. 더 중요한 건 이런 도구를 만들어야겠다고 생각한 그 순간의 '불편함'과 '필요', 그리고 그에 대한 자각과 선택과 의지, 그런 것들인 것 같습니다.

 

기술의 격차는 줄어들고 개발은 더 쉬워질 겁니다. 하지만 "무엇이 불편한가?", "어떻게 하면 더 나은 경험을 만들 수 있을까?"라는 질문은 여전히 인간의 몫으로 남을 것입니다. 혹은 그러기를 희망합니다. 

 

어쩌면 앞으로는, 이런 질문을 던지고 답을 찾아가는 사람을 우리가 '개발자'라고 부르게 될지도 모르겠습니다.

 

 

반응형