새소식

Welcome to the tech blog of Junior Backend Developer Seulbin Kang!

Backend

(BE 개발자 3개월차) 현업에서 고민한 테스트 작성 (feat. 12,500줄의 테스트 코드)

  • -

👀 들어가기 전에

드디어 MVP 개발이 끝나고, 테스트 코드 작성까지 완료한 시점에서 이에 대해 회고를 시작해보려한다.

 

테스트 코드에 대해 주변 개발자들과 이야기를 나눠보면 생각은 정말 가지각색이다.

실제로도 아래와 같은 질문들을 수 없이 받아왔다.

  • 이거 진짜 꼭 필요해?
  • 기능만 잘 돌아가면 되는거 아니야?
  • 테스트 하나 실패했다고 빌드가 깨지는게 이게 맞아?
  • 어차피 기획 바뀔 건데 왜 이렇게 까지해?

(실제로 들었던 이야기다...)

마침 진행하던 프로젝트에서 테스트 코드 작성을 마무리한 시점이기도 했고, 그동안 내가 테스트에 대해 어떤 고민을 했고 왜 이렇게까지 테스트가 중요하다고 생각하고 집착(?)하게 되었는지를 한 번 정리해보고 싶어 이 글을 쓰게 되었다.


👀 본론

테스트 진짜 왜 필요할까?

이 질문에 답하려면 사실 두 가지 경험이 모두 필요하다고 생각한다.

  1. 테스트 없이 서비스를 운영해본 경험
  2. 테스트가 있는 상태로 서비스를 운영해본 경험

나는 운 좋게도(?) 두 가지를 모두 경험해봤다.

사이드 프로젝트를 진행하며 테스트 코드 없이 기능 구현에만 집중해서 기능만 돌아가도록 한 후 배포해본 경험도 있었고 (물론,,1달만 이었으며 사용자는 프론트 개발자가 다였다.) 반대로 테스트 코드를 집요하게 작성하여 커버리지 100%를 목표로 프로젝트를 진행했던 경험도 있다. 

내가 읽었던 TDD 책

 

 

처음에는 나 역시 "테스트는 있어야지 !", "TDD가 좋다더라"정도의 막연한 인식만 가지고 있었다.

솔직히 말하면 이해했다기보다는 그냥 받아들였던 것 같다.

하지만 테스트 커버리지를 끝까지 채워가며 실제로 테스트 코드가 프로젝트를 지켜주는(?) 경험을 하고 나서는 생각이 완전히 바뀌었다.

그 경험 이후로는 회사에서 이번에 진행한 프로젝트에서도 테스트 코드를 기본 전제처럼 작성하게 되었다.


테스트의 본질은 '안정성'이다.

 

테스트 코드를 작성하는 이유는 결국 하나다.

서비스의 안정성을 위해서.

 

그리고 이 안정성은 시간과 노력을 투자할 충분한 가치가 있다.

실제로 하루에 1300줄씩 테스트 코드를 썼었다...

 

이번에 회사에서 진행한 프로젝트에서 내가 작성한 테스트코드는 총 약 12,500줄 정도였다.

물론 단순히 숫자가 중요한 것은 아니다. 하지만 이 숫자는 결국 내가 얼마나 많은 로직을 검증 가능한 상태로 만들었는지를 보여주는 지표라고 생각한다.


테스트 코드는 결국 아키텍처를 바꾼다

테스트 코드를 본격적으로 작성하면서 가장 크게 느낀 점은 이것이었다.

테스트 코드는 코드의 구조를 강제로 드러낸다.

 

테스트를 작성하기 어려운 코드는 대부분 다음과 같은 특징을 가진다.

  • 책임이 과도하게 몰려 있는 클래스
  • 외부 의존성이 강하게 강결합 되어있는 구조
  • 생성자에서 너무 많은 것을 주입받는 객체

결국 이런 코드에서 테스트를 작성하려 하면 '이 메서드 하나 테스트하려고 이걸 다 준비해야되는건가?' 싶은 상황이 오게 만든다.

그렇게 테스트를 작성하다 보면 자연스럽게 아래와 같은 질문을 하게 된다.

  • 이 객체는 왜 이것까지 알고 있어야 하지?
  • 이 의존성은 정말 여기 있어야만 할까?
  • 이 로직은 책임상 도메인으로 내려보내는게 맞지 않을까? 

결국 테스트는 의존성을 줄이고 추상화를 도입하고 역할을 분리하도록 만든다.

그리고 그 결과로 코드 베이스는 점점

  • 변경에 강해지고
  • 영향 범위가 예측 가능해지며
  • 리팩토링에 대한 부담이 줄어든다.

테스트가 있어서 안심되는 것이 아니라 시스템 자체의 안정성이 올라가는 경험이다.


실제 이번 프로젝트에서 겪었던 변화

실제로 이번 프로젝트에서 테스트 코드가 코드 구조를 어떻게 바꾸는지를 직접 경험할 수 있었다.

프로젝트 초반에 작성했던 코드의 구조는 대략적으로 다음과 같다.

WallpaperService 하나에서

  • Wallpaper와 관련된 검증
  • 데이터 로딩
  • 파일 업로드
  • 파일 타입 체크
  • 기타 비즈니스 로직

이 모든 것을 하나의 서비스 트랜잭션 안에서 처리하고 있었다.

아래 코드처럼 말이다. (작성된 코드는 어디까지나 예시일 뿐입미다)

@Service
@Transactional
@RequiredArgsConstructor
public class WallpaperService {

    private final WallpaperRepository wallpaperRepository;
    private final FileStorageClient fileStorageClient;

    public ResponseDto createWallpaper(CreateWallpaperRequest request, MultipartFile file) {

        // 1. 입력값 검증 로직

        // 2. 파일 타입 검증 로직

        // 3. 파일 업로드

        // 4. 엔티티 생성

        // 5. 데이터 저장
        
        // 6. 반환

    }
}

 

기능은 정상적으로 동작했고 겉으로 보기에는 큰 문제도 없어 보였다.

하지만 이 구조에서 그대로 테스트 코드를 작성하려는 순간 문제가 더욱이 명확해졌다.

WallpaperService의 특정 로직 하나를 테스트하려고 하면 

  • DB 접근을 위한 Repository 준비
  • 파일 업로드를 위한 외부 스토리지 의존성
  • 파일 타입 검증 로직
  • 각종 설정값과 부가 의존성

단 하나의 메소드를 검증하기 위해 서비스가 알고 있는 모든 의존성을 함께 끌고 와야 하는 구조였다.

반면, 서비스의 책임이 명확하게 분리되어 있다면 서비스 테스트의 초점 역시 훨씬 명확해진다.

서비스 레이어에서는 입력값이 적절히 전달되고 각 책임을 가진 컴포넌트들이 올바르게 조합되어 기대한 결과가 반환되는지만을 검증하면 된다.

 

결국 테스트를 작성하는 과정에서 다음과 같은 선택을 하게 되었다.

  • 서비스 레이어의 책임을 최소화하고
  • 외부 의존성은 명확한 경계를 가진 컴포넌트로 분리하고
  • 핵심 비즈니스 규칙은 도메인으로 내려보냈다.

그렇게 리팩토링된 코드는 아래와 같다. (예시일 뿐이며 실제 서비스 코드와는 다릅니다😎 )

 

Domain 코드

@Entity
@Table(name = "wallpaper")
@Getter
@NoArgsConstructor
public class Wallpaper {

	@Id
    @Column(name = "wallpaper_id", nullable = false)
    @GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id
    
    @Column(name = "title")
    private String title;
    
    @Column(name = "image_url")
    private String imageUrl;

    public Wallpaper(String title, String imageUrl) {
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("title is required");
        }
        this.title = title;
        this.imageUrl = imageUrl;
    }
}

 

파일 업로드 책임 분리

public interface FileUploader {
    String upload(MultipartFile file);
}


@Component
public class ImageFileUploader implements FileUploader {

    @Override
    public String upload(MultipartFile file) {
        //File validation 로직
        
        // 실제 업로드 로직
        return "https://test.example.com/image.png";
    }
}

 

Service 코드

@Service
@Transactional
@RequiredArgsConstructor
public class WallpaperService {

    private final WallpaperRepository wallpaperRepository;
    private final FileUploader fileUploader;

    public ResponseDto createWallpaper(CreateWallpaperRequest request, MultipartFile file) {

        // 1. 입력값 검증 로직 (위임)

        // 2. 파일 타입 검증 로직 (위임)

        // 3. 파일 업로드 (위임)

        // 4. 엔티티 생성 (도메인의 책임)

        // 5. 데이터 저장 (위임)
        
        // 6. 반환

    }
}

 

 

결국 서비스 레이어에서는 입력값이 적절히 전달되고 각 책임을 가진 컴포넌트들이 올바르게 조합되어 기대한 결과가 반환되는지만을 검증하면 된다.

 


테스트가 실패하면 빌드가 실패하는데?

 

테스트 하나 실패했다고 빌드가 안되는게 맞아?

 

테스트 이야기를 하다보면 나오는 이야기 중 하나이다.

하지만 나는 이번 프로젝트를 진행하면서 실제로 사용자가 사용하는 서비스를 경험하며 이 구조 자체가 운영 관점에서 엄청난 이점이라는 걸 확실히 체감했다.

 

테스트 실패 = 빌드 실패라는 규칙은 개발자를 괴롭히기 위한 장치가 아니라고 생각한다.

검증되지 않은 코드는 운영 환경으로 올리지 않겠다는 일종의 약속이다.

(물론 빌드를 테스트 없이 돌려버릴 수도 있다...하지만 그럼 테스트를 작성한 의미가 없어진다.)

 

이 구조 덕분에 기획이 바뀌었을 때 혹은 기존 코드가 수정되었을 때 이전 동작이 유지되는지 즉시 검증할 수 있었고, 빠르게 실패시키고 빠르게 리팩토링할 수 있었다.


운영 환경에서 가장 위험한 순간

 

운영중인 서비스에서 가장 위험한 순간은 문제가 생긴 이후가 아니라 문제가 생긴 줄도 모르고 배포되는 순간이다.

테스트가 없거나 테스트 실패를 무시한 채 배포가 허용되는 환경에서는 아래와 같은 일이 너무 쉽게 발생한다.

  • 사소한 리팩토링이 기존 기능을 깨트린다.
  • 특정 조건에서만 터지는 버그가 운영에서 발견된다.
  • 문제를 재현하지 못해 원인 분석이 길어진다.

결국 코드를 다시 하나하나 뜯어보거나 코드를 전체 롤백 시켜야 하는 것이다.

반대로 테스트 실패 = 빌드 실패가 되는 순간 이 모든게 해결된다.

  • 변경 사항은 반드시 기존 동작을 만족해야 하고
  • 깨지는 순간은 항상 CI 단계에서 발견된다.

결국 운영 환경은 검증이 완료된 코드가 올라가게 된다.

개발 단계에서 해당 서비스를 미리 검증함으로써 결국 사용자 경험은 더 좋아질 수밖에 없다고 생각한다.


테스트 결국 책임의 문제

 

테스트하면 떠오르는 사람이 있다. 바로 TDD로 유명한 Kent Beck이다. Kent Beck은 테스트에 대해 이렇게 말한다.

Testing is not the point. The point is about responsibility.
Write tests until fear is transformed into boredom

 

테스트는 책임의 경계다.

Kent Beck이 말한 책임이란 단순 버그를 잡는 것이 아니다.

  • 이 로직은 누가 책임지는가
  • 이 실패는 어느 계층의 문제인가
  • 이 변경의 영향 범위는 어디까지인가

테스트는 이 질문에 코드로 답하게 만든다

테스트를 작성하다 보면 자연스럽게 이런 상황이 반복된다.

  • 이 테스트를 여기서 쓰는게 맞나? 
  • 이 검증은 서비스의 책임인가? 도메인의 책임인가?
  • 이 의존성은 왜 여기에 묶여 있는가?

즉, 테스트는 책임이 불분명한 구조를, 아키텍처를 변경하게 만든다.


👀 결론 : 내가 테스트를 바라보는 방식

 

운영을 경험해보면서 나는 테스트의 중요성을 더더욱이 느끼고 있다.

테스트 실패는 불편함이 아니라 안전장치이며

테스트를 통해 변경된 코드의 품질은 그렇지 않은 코드에 비해 확실히 올라간다.

 

기획은 바뀔 수도 있다. 요구사항도 바뀔 수 있다.

하지만 신뢰할 수 없는 코드를 배포했을 때의 공포는 이루 말할 수 없다.

테스트 커버리지

 

이번 프로젝트에서도 테스트 커버리지를 70% ~ 80% 정도를 채웠다.

그래서 나는 앞으로도 테스트 코드를 꼼꼼히 작성할 것이다. 나의 편리함을 위해서 😭

 

 

 

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.