인수테스트를 중심으로 안정망 구축하기

희운·2026년 2월 16일

문제 상황 및 배경

몇개월 전 진행했던 프로젝트인 미팀을 몇개월 만에 다시 살려보고자 남아있는 팀원들이랑 방향성에 대해 논의해 보았다.
우선적으로 테스트 코드가 전무하니, 테스트를 구축과 동시에 프로덕션 코드를 리펙토링 하기로 하였다.
팀원이 많이 이탈했기 때문에, 팀원들이 작성한 코드마저 현재 남은 인원으로 리펙토링을 진행해야 했고 이 방대한 양의 프로덕션 코드를 리펙토링을 할 생각을 하니 시작하기도 전에 막막함과 피로감이 스멀스멀 올라왔다.
현재 우리 서비스는 전혀 예상하지 못한 곳에서 장애가 존재했고, 프로젝트 기간중 상당한 시간을 예상하지 못한 장애를 해결하는데 시간을 보냈다.
예상하지 못한 장애를 미리 예방하고 싶고, 그러기 위해서는 단위테스트와 통합테스트가 구축이 되어야 한다고 판단했다.
하지만 현재 미팀 서비스의 프로덕션 코드에 단위테스트와 통합테스트를 도입하기에는, 클래스마다 역할과 책임은 분리 되어있지 않았기에, 테스트를 구축하는 작업 역시 많이 리소스가 필요했다.

인수테스트를 통한 안정망 구축


이를 해결하기위해 먼저 API 기반의 E2E 인수테스트를 우선적으로 구축하고, 구축한 안정망을 통하여 프로덕션 코드를 리펙토링을 진행하기로 결심했다.
현재 상황에서는 프로덕션 코드와 관계 없이 테스트를 구축할수 있고 이 구축한 테스트의 보호 안에서 하나의 모듈의 대한 리펙토링을 진행후에 수시로 테스트를 진행하여 회귀 버그 또한 테스트를 통해 확인할 수 있다고 판단했다.
위의 사진과 같이 우선적으로 테스트를 통과를 목표를 잡고 모든 인수테스트가 통과하면 그 이후로는 자신감있게 리펙토링을 진행할 수 있다. 내가 생각한 작업 순서를 다음과 같다

  • 현재 프로젝트 시스템 분석
  • 시스템 분석을 통한 리스크 분석 및 리스크 매트릭스 작성
  • 리스크 매트릭스를 바탕으로 인수조건 도출
  • 인수조건을 바탕으로 Gherkin 시나리오 작성
  • Gherkin 시나리오를 바탕으로 인수테스트 구축
  • 인수테스트 통과를 목표로 프로덕션 코드 수정
  • 프로덕션 코드 리펙토링

시스템 분석 & 리스크 분석

프로젝트를 진행하지 몇개월의 시간이 흘렀고, AI 도구 또한 많이 발전되었다고 생각해 AI 에이전트의 도움을 받아 시스템을 분석하고, 이를 바탕을 리스크를 분석해 보았다.
먼저 리스크 평가 기준으로 비즈니스 임팩트, 변경 빈도, 버그 발생 가능성 등을 종합적으로 고려하여 리스크 매트릭스를 작성하였다.

AI 도구에게 우선순위를 받기 전, 현재 프로젝트에서 종합적으로 먼저 수정과 리펙토링을 해야되는 부분은 인증/인가와 관련된 부분이라고 판단했다. AI 에이전트 역시 내가 생각한것과 같이 가장 우선순위가 높은 시나리오로 평가했다.

인수 조건 도출하기 & Gherkin 시나리오 작성하기

먼저 우선순위가 높은 순서에 맞게 인수조건을 도출하였다.
인수조건을 바탕으로 Gherkin 시나리오를 작성할 계획이라 인수조건을 명확하게 작성 해야한다고 판단했다.
인수조건들은 5W1H(누가, 무엇을, 언제, 어디서, 왜, 어떻게) 원칙으로 구체화하여 명확한 인수 조건을 정의했다.

이 정의한 인수조건을 바탕으로 Gherkin 시나리오를 작성했다.

feature 파일


위의 사진은 feature 파일에 Gherkin 시나리오를 작성한 결과물이다. 하나의 시나리오에 맞는 테스트 코드를 인수테스트로 작성하였다.
이 또한 AI 에이전트를 이용하여 작성하였다. 위의 feature 파일은 최종 시나리오지만, 처음에 작성한 시나리오에는 다음과 같은 문제가 있었다.
  • 내부 구현과 관련된 용어
  • 비개발 용어
  • 시나리오 자체가 지나치게 세부적

feature 파일은 목적에 따라 사용 범위가 다르겠지만, 이 문서는 팀 전체가 공유할 수 있는 팀 컨벤션으로서 역할을 할 수도 있다고 생각한다. 나는 이 문서를 통해 프론트엔드 개발자와 요구사항에 대한 싱크를 맞추는데 사용하였다.
위에 작성한 것이 항상 문제가 되는건 아니지만, feature 파일의 역할에 따라 문제가 될수도 있다고 생각한다.
또한, 지나치게 세부적인 시나리오를 만들어서 테스트를 작성해야하는지도 의문이다.
인수테스트의 목적은 결국 사용자 시나리오에 맞게 검증하는 용도라는 생각이 드는데, 모든 예외케이스에 대해서 작성한다거나 동시성 문제와 같은 문제는 오히려 통합테스트 or 단위테스트 or 슬라이스 테스트가 더 역할에 부합할것이다.

Cucumber 라이브러리를 통해서 시나리오의 하나의 스텝마다 메서드를 1대1 대응 시켜서 테스트 코드를 작성할수 있었다. 이 라이브러리는 feature 에서 작성한 "값"들을 메서드 파라미터로 넘겨서 사용할수 있고, feature 파일 내부에서 곧 바로 테스트를 실행시킬수 있다.(플러인 설치)

시나리오를 작성할때는 비개발 용어와, 내부 구현과 관련없는 시나리오로 작성하려고 노력하였다.

"인증 토큰을 발급받는다" 와 같은 스텝은 개발을 모르는 사람이라면 이해하기 어렵지만, 현재 미팀에서는 인가/인증과 관련된 로직에 알수 없는 버그들이 존재하기 때문에 해당 도메인에는 최대한 구체적으로 시나리오를 작성하려고 노력하였다.
또한, 내부 개발과 관련된 용어를 사용할경우에 프로덕션 코드의 수정이 발생할 경우
시나리오의 수정 또한 발생할수 있기 때문에 최대한 비지니스 용어를 이용하여 시나리오를 작성하는게 좋을것이다.

인수테스트 작성하기

인수테스트를 작성할때는 RestAssured 를 이용하여 테스트를 작성하였다.
추후에 프로덕션 코드를 리펙토링을 해야하기 때문에 E2E 기반 블랙박스 테스트를 하는것이 좋다고 판단하였고, 리펙토링을 하는 과정에서 코드가 수정되어도 영향을 받지 않기 때문에 이 방법을 선택하였다.

위의 사진과 같은 API 호출을 통한 부분은 별도의 도메인과 관련된 API 클래스에서 호출할수 있도록 위임을 하였다.
API 호출에 대한 부분을 스텝 메서드에 둘 경우 가독성 또한 떨어진다고 판단하였고, 테스트 코드를 보는 입장에서는 로그인 요청 구현방식 보다는 로그인 요청을 한다는 사실이 더 중요할것이다.
즉, 스텝에서는 한글 메서드명을 통해서 해당 메서드가 로그인을 요청한다는 사실만 알도록 하였다.

TestContext 이용하기


Cucumber 를 사용하지 않고 하나의 테스트 메서드에 인수 테스트를 작성할 경우 지역변수를 사용하여 값을 공유하면 될것이다. 하지만 Cucumber 를 사용할 경우 스텝 별로 메서드를 작성하기 때문에 값을 공유할 무언가가 필요하다.
Context 라는 개념은 무엇인가를 담아서 저장하는 개념으로 사용한다.
나는 TestContext 를 이용하여 스텝 간의 값을 전달거나 공유하도록 하였다.

Cucumber 에서는 @ScenarioScope 를 이용하여 하나의 시나리오가 끝나면
TestContext 를 초기화를 해준다. 별도의 clear 해주는 작업없이 이용가능한다.
도메인 별로 공유하는 값들은 이너 클래스를 이용하여 내부적으로 공유하도록 해주었다.

도메인 별로 inner 클래스를 이용하여 값을 저장하도록 구현하였지만, 도메인 별로 저장해야할 값이 많아질 경우에는 별로의 클래스로 분리하는것이 좋아보인다.

최소한의 수정으로 인수테스트 통과하기


프로젝트 지원 기능은 서비스의 핵심 기능이기 때문에, 비교적 다양한 시나리오를 작성했다.
다만 처음부터 모든 시나리오를 검증하기보다는,
Happy Case 2~3개, Error Case 2~3개, Edge Case 2~3개 정도로 시작하는 것이 적절하다고 판단했다.


    @Counted("project.apply")
    @Override
    public ApplicationResponse apply(Long projectId, Long memberId, ApplicationRequest request) {
        Project project = projectRepository.findActiveById(projectId)
                .orElseThrow(() -> new CustomException(ErrorCode.PROJECT_NOT_FOUND));

        validateProjectNotCompleted(project);

        // 프로젝트 리더는 자신의 프로젝트에 지원 불가
        if (project.getCreator().getId().equals(memberId)) {
            throw new CustomException(ErrorCode.APPLICATION_SELF_PROJECT_FORBIDDEN);
        }

        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND));

        if (applicationRepository.existsByProjectAndApplicant(project, member)) {
            throw new CustomException(ErrorCode.PROJECT_APPLICATION_ALREADY_EXISTS);
        }

        if (projectMemberRepository.existsByProjectIdAndMemberId(projectId, memberId)) {
            throw new CustomException(ErrorCode.PROJECT_MEMBER_ALREADY_EXISTS);
        }

        JobPosition jobPosition = jobPositionRepository.findByCode(request.jobPositionCode())
                .orElseThrow(() -> new CustomException(ErrorCode.JOB_POSITION_NOT_FOUND));

        // 해당 프로젝트에서 해당 포지션으로 모집 중인지 확인
        recruitmentStateRepository.findAvailableByProjectIdAndJobPosition(projectId, jobPosition)
                .orElseThrow(() -> new CustomException(ErrorCode.RECRUITMENT_POSITION_NOT_AVAILABLE));

        ProjectApplication application = ProjectApplication.create(
                project,
                member,
                jobPosition,
                request.motivation()
        );

        ProjectApplication savedApplication = applicationRepository.save(application);

        Long receiverId = project.getCreator().getId();
        Long actorId = member.getId();

        // 1. 팀장에게 지원 알림 저장
        Notification applyNotification = createNotification(
                project.getCreator(), project, actorId, NotificationType.PROJECT_APPLY, savedApplication.getId()
        );
        notificationRepository.save(applyNotification);

        // 2. 지원자에게 지원 완료 알림 저장
        Notification myApplyNotification = createNotification(
                member, project, actorId, NotificationType.PROJECT_MY_APPLY, savedApplication.getId()
        );
        notificationRepository.save(myApplyNotification);

        // 프로젝트 리더 알림 발행 (SSE)
        eventPublisher.publishEvent(new NotificationEvent(
                receiverId,
                project.getId(),
                actorId,
                NotificationType.PROJECT_APPLY,
                savedApplication.getId()
        ));

        // 지원자 알림 발행 (SSE)
        eventPublisher.publishEvent(new NotificationEvent(
                actorId,
                project.getId(),
                actorId,
                NotificationType.PROJECT_MY_APPLY
        ));

        log.info("프로젝트 지원 완료 - projectId: {}, applicantId: {}, jobPositionCode: {}",
                projectId, memberId, request.jobPositionCode());

        return ApplicationResponse.from(savedApplication);
    }

프로젝트 지원 메서드를 보면 하나의 메서드에서 너무 많은 역할을 수행하고 있다.
프로젝트 지원 처리뿐만 아니라, 검증 로직과 알림 처리까지 함께 포함되어 있어 책임이 과도하게 집중된 상태였다.

이러한 구조를 안정망 없이 리팩토링한다고 생각하면 상당히 부담스러운 작업이다.
하지만 인수 테스트라는 안정망을 구축해두었기 때문에,
기능이 깨지더라도 빠르게 버그를 탐지하고 수정할 수 있는 환경을 만들 수 있었다.
AI 에이전트에게 리팩토링을 맡기기 전에,
먼저 내가 원하는 방향을 정리한 컨벤션 문서를 작성하고 이를 Claude.md에 포함시켰다.
Claude Code에서는 @Convention.md와 같은 방식으로
컨텍스트에 포함된 문서를 참조할 수 있도록 구성했다.
처음에는 하나의 endpoint를 직접 리팩토링한 뒤,그 결과를 문서화하고 이를 기반으로 전체 리팩토링을 진행했다.
컨벤션 문서를 작성할 때는 단순한 설명보다 원하는 구조를 담은 예시 코드를 함께 제공하는 것이 가장 중요하다고 생각한다.

이후 해당 컨벤션을 기반으로 리팩토링을 진행했다.
아래는 프로젝트 지원 메서드를 리팩토링한 결과이다.




    @Counted("project.apply")
    @Override
    public ApplicationResponse apply(Long projectId, Long memberId, ApplicationRequest request) {
        // 1단계: 엔티티 조회
        Project project = projectRepository.findActiveById(projectId)
                .orElseThrow(() -> new CustomException(ErrorCode.PROJECT_NOT_FOUND));
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND));
        JobPosition jobPosition = jobPositionRepository.findByCode(request.jobPositionCode())
                .orElseThrow(() -> new CustomException(ErrorCode.JOB_POSITION_NOT_FOUND));

        // 2단계: 권한 검증
        validateNotSelfApplication(project, memberId);

        // 3단계: 비즈니스 규칙 검증
        validateApplyPrecondition(project, member, projectId, memberId);
        validateRecruitmentPosition(projectId, jobPosition);

        // 4단계: 지원서 생성 및 저장
        ProjectApplication savedApplication = createAndSaveApplication(project, member, jobPosition, request.motivation());

        // 5단계: 알림 발행 및 응답
        publishApplyNotifications(project, member, savedApplication);

        log.info("프로젝트 지원 완료 - projectId: {}, applicantId: {}, jobPositionCode: {}",
                projectId, memberId, request.jobPositionCode());

        return ApplicationResponse.from(savedApplication);
    }

역할을 분리하고 각 책임을 위임하면서,기존보다 훨씬 가독성이 좋아지고 구조가 명확해졌다.다만 이 과정에서 private 메서드가 많이 생성되었는데,
이는 하나의 클래스가 너무 많은 책임을 가지고 있다는 신호일 수도 있다.

따라서 메서드가 더 늘어난다면,관련된 책임을 별도의 클래스로 분리하는 방향도 고려해야 한다.
리펙토링을 완료했으니, 프로젝트 지원과 관련된 인수테스트를 돌려보자.

모든 테스트가 성공적으로 통과하는 것을 확인할 수 있었다.만약 인수 테스트가 없었다면,Swagger를 통해 다음과 같은 과정을 모두 수동으로 검증해야 했을 것이다.

  • 지원 요청
  • 지원 결과 확인
  • 프로젝트 리더 알림 확인
  • 지원자 알림 확인
  • 프로젝트 인원 변화 확인

이 과정을 매번 반복하는 것은 상당한 시간과 비용이 드는 작업이며,생각만 해도 부담스러운 과정이다.이전까지는 배포 전 수동 테스트에 많은 시간을 투자했지만,인수 테스트를 도입하면서 검증 시간을 크게 줄일 수 있었다.이번 작업의 목적은 프로덕션 코드 리팩토링이었지만,테스트 코드 또한 하나의 코드이기 때문에 유지보수가 가능한 형태로 작성하는 것이 매우 중요하다.그렇지 않으면 테스트 코드 역시 결국 방치되고 버려질 수 있기 때문에,테스트 코드 작성에도 많은 시간을 투자했다.테스트 코드 작성 과정은 다음 글에서 정리해보려고 한다.

profile
기록하는 공간

0개의 댓글