유랑하는 나그네의 갱생 기록

だけど素敵な明日を願っている -HANABI, Mr.children-

Devlog/SpringBoot

테스트 코드가 알려주는 객체의 책임과 구조의 미학

Madirony 2026. 2. 6. 17:07
반응형

 
 

 
 

Intro

좋은 소프트웨어란 무엇인가?

얼마 전까지 저는 테스트 코드에 대한 의문을 품고 있었습니다.
 

"이미 예외 케이스까지 생각해서 기능 구현은 다 해놨는데, 예측이 되는 결과에 대해서 테스트 코드를 대체 왜 작성해야 하는거지?"
"개발자가 예측하지 못하는 케이스는 악의적인 유저의 행동 패턴이 대부분 아닐까?"

 
이전에도 테스트 코드를 작성해 본적은 있지만, 사실 개발을 도와주는 도구라기보다는 유지보수의 짐에 가까웠습니다. 부끄럽지만 그 당시 작성했던 코드를 살짝 훑어보겠습니다. (백엔드 개발에 처음으로 발을 들였던 그 당시의 코드라 정말 이상한 코드..)
 
 
1. 외부 의존성과의 강한 결합 (Dependency Hell)
가장 큰 문제는 테스트가 실제 DB와 프레임워크에 끈적하게 달라붙어 있었다는 점입니다.

@SpringBootTest(classes = Application.class) // 1. 무거운 프레임워크 의존
public class MonsterAttackApplicationServiceTests {
    @Autowired
    private InfraRepository monsterInfraRepository; // 2. 실제 저장소 의존

    @Test
    public void testMonsterGetInfo() {
        // 3. 실제 DB에 1번 데이터("네파리안")가 있어야만 성공하는 '운빨' 테스트
        MonsterDTO monster = monsterAttackApplicationService.getInfo(monsterInfraRepository.findMonsterBySequence(1));
        assertEquals("네파리안", monster.getMonsterName());
    }
}

@SpringBootTest로 인해 테스트 하나 돌릴 때마다 스프링을 통째로 띄워야 했습니다.
 
또한, findMonsterBySequence(1)처럼 실제 DB의 특정 데이터에 의존하다 보니, 누군가 데이터를 수정하는 순간 제 로직이 멀쩡해도 테스트는 깨져버렸습니다.
 
단위 테스트니 통합 테스트니 할 것도 없이 무작정 프레임워크 띄워버리고 테스트를 박아버렸던 것이었죠.
 
 
 
2. 행위가 아닌 '상태(값)' 검증에 치중 (Magic Number)
그다음, 로직의 흐름이나 규칙을 검증하는 것이 아니라, 결과로 나오는 '숫자' 그 자체에 집착했습니다.

@Test
public void testAttackToUser(){
    // ... 준비 과정 생략 ...
    monsterAttack = monsterAttackApplicationService.attackToUser(monsterAttack);
    
    // 왜 하필 1099인가? 몬스터 공격력이 1만 바뀌어도 이 테스트는 실패합니다.
    assertEquals(1099, monsterAttack.getUserCurrentHP());
}

1099라는 숫자가 어떻게 도출되었는지 코드만 봐선 알 수 없습니다. 비즈니스 로직(공격력 계산 공식)을 검증하는 게 아니라 현재 DB 값 기준의 결과만 확인하다 보니, 기획이 조금만 변경되어도 테스트 코드를 일일이 수정해야만 했죠.
 
 
 
3. 비대해진 준비 과정 (Bloated Given Clause)
또한, 단 하나의 기능을 검증하기 위해 거쳐야 하는 사전 작업이 너무나도 길고 복잡했습니다.

@Test
public void testInitMonsterAttackDTO(){
    // Given: 준비해야 할 게 너무 많음
    MonsterDTO monster = monsterAttackApplicationService.getInfo(monsterInfraRepository.findMonsterBySequence(1));
    User user = userApplicationService.getUserBySequence(1);
    UserInfoDTO userInfo = userApplicationService.getInfo(user);
    MonsterAttackDTO monsterAttack = monsterAttackApplicationService.initMonsterAttackDTO(monster, userInfo);
    
    // Then: 정작 검증하고 싶었던 건 이름 확인뿐...
    assertEquals("네파리안", monsterAttack.getMonster().getMonsterName());
    assertEquals("소드마스터", monsterAttack.getUser().getName());
}

코드가 참 어지럽죠? Given 절이 비대해지는 것은 설계가 강하게 결합되어 있다는 강력한 신호입니다. 너무 뚱뚱해서 위고비 처방을 내려야할 지경입니다. 테스트를 짜기 위해 유저 조회 서비스, 몬스터 조회 서비스, DTO 변환기 등을 줄줄이 소환해야 했고, 이는 결국 테스트 작성의 귀찮음으로 이어졌습니다. 심지어 뭘 테스트를 해야할지 모르는 상태에서 작성하여 방황하고 있는 것이 보이기도 하네요.
 
 
테스트 코드에 대한 지식이 없었던 상태에서, 잘못된 접근과 설계로 저는 그만 TDD에 대한 흥미를 잃고야 말았습니다. 지금에서야 되돌아보니 Testable하지 않았던 코드가 문제였던 것입니다.
 
하지만 이번에 TDD와 도메인 주도 설계(DDD)의 개념을 깊이 접하게 되면서, TDD는 사실 테스트를 위한 도구가 아니라 설계를 위한 도구라는 사실을 알게 되었습니다.
 
 
결국 테스트가 어려웠던 이유는 로직이 기술적 의존성(Framework, DB)에 깊숙이 빠져 있었기 때문이었습니다. 그래서 저는 이번에 TDD를 기반으로 한 아키텍처의 근본적인 체질 개선을 선택했습니다.
 

  1. 원시 타입에 대한 집착(Primitive Obsession)을 버리고 VO(Value Object)를 도입해 객체 스스로를 방어하게 만들었습니다.
  2. 프레임워크 속에서 도메인을 안전하게 격리시켜 스프링 없이도 빠르게 실행되는 순수 도메인 로직을 구축했습니다.
  3. 의존성 역전 원칙(DIP)을 통해 인프라의 변화가 비즈니스 로직을 흔들지 못하도록 설계했습니다.

 
참고로 단위 테스트는 개별적으로 10ms(0.01초) 이내에 종료되어야 한다고 합니다. 수천 개의 테스트를 언제든 즉시 실행할 수 있어야 개발자가 두려움 없이 코드를 수정할 수 있기 때문입니다. 테스트가 느려지거나 외부 환경에 의존하는 순간 몰입은 깨지고, 테스트는 다시 기피 대상이 됩니다.
 
 

TDD ilustration by Denise from https://quii.gitbook.io/

서론은 쓰면 쓸수록 늘 길어지네요. 이제 실패하는 테스트를 먼저 작성하고(Red), 이를 통과시킨 뒤(Green), 더 나은 구조로 개선해 나가는(Refactor) TDD의 리듬을 따라 그 과정을 공유하고자 합니다.


본론

Step 1. 가장 작은 단위부터 시작하기

주어진 요구사항은 [ 1.회원가입 2.내 정보 조회 3.비밀번호 수정 ]으로 간단한 구현이었습니다. 구현이야 하라고 하면 참 쉽습니다. 하지만 나중에 프로젝트의 사이즈가 커지면 확장 가능한 유연한 설계가 필요합니다. 지금 작성한 코드가 바로 Legacy Code가 될 수도 있으니까요.
 
저는 무작정 유저 엔티티를 만들기 보다는 그보다 훨씬 작은 단위(비밀번호 같은)부터 TDD로 접근했습니다. 그러다보니 각 클래스와 책임을 어떻게 나눌지가 코드를 통해 보이기 시작했습니다.
 
 

1-1. Primitive Obsession (기본형 집착) 탈출

개발 초기에는 습관적으로 모든 데이터를 String이나 int 같은 원시 타입(Primitive Type)으로 처리하려 했습니다. 예전과 같았다면 비밀번호는 String password, 생년월일은 String birthDate로 선언하고, 서비스 계층 여기저기에서 if 문으로 검증 로직을 반복해두고 그냥 놔뒀겠죠.
 
리팩토링 구루(Refactoring Guru)에서는 이를 Primitive Obsession(기본형 집착)이라고 부릅니다. 도메인의 의미 있는 개념을 단순한 자료형으로만 다루다 보니, 로직이 흩어지고 중복이 발생하며 관리가 어려워지는 현상입니다.
 
저는 이 문제를 해결하기 위해 VO(Value Object) 패턴을 적극 도입했습니다. Password, BirthDate, MemberId 등을 자신의 유효성을 스스로 증명하는 객체로 만들고자 했습니다.
 
 

1-2. VO가 VO를 만났을 때 (Refactor)

처음 Password 객체를 구현할 때는 생년월일 포함 여부를 검증하기 위해 두 개의 String을 받았습니다.

// 초기 구현: 생년월일을 단순 String으로 처리 (Smell...)
public static Password of(String rawPassword, String birthDate) {
    // birthDate가 "1990-01-01"인지 "900101"인지 여기서 검증해야 할까요?
    // 비밀번호 객체가 날짜 포맷팅 책임까지 지는 게 맞을까요?
    validateBirthDateNotIncluded(rawPassword, birthDate);
    return new Password(rawPassword);
}

테스트를 통과시켰지만(Green), 뭔가 이상했습니다. Password 클래스가 문자열로 된 생년월일의 형식을 검증하고 파싱하는 책임까지 떠안게 된 것입니다.
 
저는 즉시 String birthDate를 BirthDate VO로 승격시키고, Password가 이를 의존하도록 구조를 바꿨습니다.

// 개선 후: 확실한 타입(BirthDate)과 협력
public static Password of(String rawPassword, BirthDate birthDate) {
    // 이제 Password는 날짜 형식을 신경 쓸 필요가 없습니다.
    // BirthDate 객체는 생성 시점에 이미 유효성이 검증되었기 때문입니다.
    validateBirthDateNotIncluded(rawPassword, birthDate.getValue());
    return new Password(rawPassword);
}

이제 Member 엔티티에는 원시 타입(Primitive Type)이 없고 검증된 객체들의 조합으로 깔끔하게 재구성되었습니다.
 
 

1-3. 책임의 이동: 비밀번호는 누가 바꾸나?

비밀번호를 변경하는 기능을 구현할 때 또 한 번 고민에 빠졌습니다.
 

"비밀번호 변경은 유저의 행위니까
Member 엔티티에 로직이 있어야 하지 않을까?"

 
 
처음엔 Member 내부에 암호화 로직과 검증 로직을 전부 넣으려 했습니다. 하지만 그렇게 하면 Member가 너무 비대해지고, 암호화 방식에 강하게 결합됩니다.
 
그렇기 때문에 "행위의 주체는 Member지만, 변경의 규칙은 Password가 안다"는 결론을 내렸습니다. 그래서 Member는 요청만 하고, 실제 검증과 상태 변경은 Password에게 위임했습니다.

// Member.java (Entity)
public void updatePassword(String currentRaw, String newRaw, PasswordEncoder encoder) {
    // Member는 "내 비밀번호 바꿔줘"라고 Password에게 메시지만 던집니다.
    this.password = this.password.change(currentRaw, newRaw, encoder);
}

// Password.java (VO)
public Password change(String currentRaw, String newRaw, PasswordEncoder encoder) {
    // 1. 현재 비밀번호 일치 검증
    if (!encoder.matches(currentRaw, this.encodedPassword)) {
        throw new CoreException(ErrorType.PASSWORD_MISMATCH);
    }
    // 2. 새 비밀번호 정책 검증 (자가 검증)
    return Password.ofEncoded(encoder.encode(newRaw));
}

이렇게 설계하니 Member 엔티티 테스트는 단순히 위임 여부만 확인하면 되고 , 복잡한 정책 검증은 PasswordTest에서 철저하게 검증하는 역할과 책임의 분리가 완성되었습니다.
 
 


 
 

Step 2. 도메인은 무엇에도 의존하지 않는다

VO를 도입해 객체의 책임을 나눴지만, 여전히 해결해야 할 과제가 있었습니다. 바로 외부 기술에 대한 의존성입니다.
 
비밀번호를 암호화하려면 BCrypt 같은 암호화 알고리즘이 필요하고, 회원을 저장하려면 DB가 필요합니다. 하지만 도메인 로직(Member, MemberService)이 Spring Security나 Hibernate 같은 구체적인 기술에 직접 의존하게 되면, 기술이 바뀔 때마다 도메인 코드도 함께 수정해야 하는 번거로움이 생길 것입니다.
 
이 문제를 해결하기 위해 의존성 역전 원칙(DIP, Dependency Inversion Principle)을 적용했습니다.
 
 

2-1. 암호화 기술 격리하기

처음에는 BCryptPasswordEncoder를 도메인 서비스에서 직접 사용하려 했습니다. 하지만 그렇게 하면 MemberService 테스트를 할 때마다 무거운 암호화 라이브러리를 동작시켜야 하고 테스트 속도 저하로 이어지게 됩니다.
 
그래서 도메인 패키지 내부에 PasswordEncoder라는 추상화된 인터페이스를 정의했습니다.

// domain/member/PasswordEncoder.java (Interface)
public interface PasswordEncoder {
    String encode(String rawPassword);
    boolean matches(String rawPassword, String encodedPassword);
}

// infra/BCryptPasswordEncoder.java (Implementation)
@Component
public class BCryptPasswordEncoderAdapter implements PasswordEncoder {
    private final BCryptPasswordEncoder delegate = new BCryptPasswordEncoder();
    // ... 실제 구현은 스프링 시큐리티에게 위임
}

이렇게 하면 도메인 로직은 "어떤 알고리즘을 쓰는지" 전혀 알 필요가 없습니다. 그저 "암호화해줘"라고 요청만 하면 됩니다.
 
 

2-2. Fake 객체를 활용한 빠른 테스트

이렇게 인터페이스를 분리한 덕분에, 테스트 코드에서는 BCrypt 대신 Fake 객체를 사용할 수 있게 되었습니다.

private final PasswordEncoder fakeEncoder = new PasswordEncoder() {
            @Override
            public String encode(String rawPassword) {
                return "encoded:" + rawPassword;
            }

            @Override
            public boolean matches(String rawPassword, String encodedPassword) {
                return encodedPassword.equals("encoded:" + rawPassword);
            }
        };
        // ...

MemberTest에서 이 fakeEncoder를 사용함으로써, 복잡한 해싱 연산 없이 순수하게 도메인 로직(비밀번호 변경 흐름)만 검증할 수 있게 되었습니다.
 
 

2-3. 저장소 패턴(Repository Pattern)의 재정의

DB 접근도 마찬가지입니다. JpaRepository를 서비스에서 바로 쓰면, 서비스가 JPA라는 기술에 종속됩니다. 저는 도메인 계층에 순수한 MemberRepository 인터페이스를 두고, 인프라 계층에서 이를 구현하도록 구조를 잡았습니다.

// domain/member/MemberRepository.java
public interface MemberRepository {
    Member save(Member member);
    Optional<Member> findById(MemberId id);
}

// infra/MemberJpaRepository.java
public interface MemberJpaRepository extends JpaRepository<Member, Long>, MemberRepository {
    // JPA가 구현체를 자동 생성
}

이 구조 덕분에 나중에 DB 변경을 하더라도 도메인 로직인 MemberService는 수정할 필요가 없게 되었습니다.
 
 


 
 

Step 3. 뚱뚱한 서비스를 가볍게

도메인 객체들을 POJO로 만들고 의존성도 격리했지만, 여전히 MemberService에는 스프링의 흔적이 남아있었습니다.

// 일반적인 서비스 계층의 모습
@Service
@Transactional
public class MemberService {
    // ...
}

이 @Transactional과 @Service 어노테이션 때문에, 비즈니스 로직만 테스트하고 싶어도 스프링 컨텍스트를 로딩해야 했습니다. 또한 테스트 실행 속도도 느려지게 되겠죠.
 
저는 "비즈니스 로직과 트랜잭션 관리는 책임이 다르다"고 생각했습니다. 그래서 과감하게 서비스 계층을 둘로 쪼개기로 결정했습니다.
 
 

3-1. Facade 패턴 도입

먼저, 트랜잭션 관리와 흐름 제어만을 담당하는 MemberFacade를 애플리케이션 계층(Application Layer)에 만들었습니다.

@Component
@RequiredArgsConstructor
public class MemberFacade {
    private final MemberService memberService;

    // 트랜잭션의 시작과 끝은 여기서 관리합니다.
    @Transactional
    public Member signup(MemberCommand.Signup command) {
        return memberService.signup(command);
    }
}

이 Facade는 비즈니스 로직을 전혀 모릅니다. 단지 문을 열고(트랜잭션 시작), 도메인 서비스에게 일을 시키고, 문을 닫는(트랜잭션 종료) 역할만 수행합니다.
 

"... 굳이 이렇게까지 나눠야 해?"

 
라고 생각할 수도 있습니다. 하지만 만약 '회원가입 성공 시 가입 축하 쿠폰을 발급한다'는 요구사항이 추가된다면 어떨까요?
 

  • MemberService는 회원 가입만 담당하고,
  • CouponService는 쿠폰 발급만 담당하고,
  • MemberFacade가 이 둘을 조율하며 하나의 트랜잭션으로 묶어줍니다.

 
비즈니스가 확장될 때 Facade는 서로 다른 도메인 서비스들을 연결하는 역할을 합니다. 확장성을 생각해본다면 필요한 분리라고 생각합니다.
 
 

3-2. 순수해진 MemberService (POJO)

Facade가 트랜잭션을 가져간 덕분에, MemberService에서는 모든 스프링 어노테이션을 걷어낼 수 있었습니다.

// @Service도, @Transactional도 없는 순수 자바 클래스
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    public Member signup(MemberCommand.Signup command) {
        // 순수한 비즈니스 로직에만 집중
        // ...
        return memberRepository.save(member);
    }
}

이제 MemberService는 프레임워크가 없어도 동작하는 완벽한(?) POJO가 되었습니다. 하지만..
 

??? 근데 Lombok 어노테이션은 왜 남겨둠? 저것도 걷어내야 완벽한 거 아님?

 
라는 생각이 들었습니다.
하지만 Spring 어노테이션과의 차이점을 알면 납득이 되는 이유를 찾았습니다.
 

Spring 어노테이션은 런타임에 프레임워크(컨테이너)의 개입을 강제하지만,
Lombok은 컴파일 시점에 표준 자바 코드를 생성해주는 도구일 뿐입니다.

 
런타임 시점에 MemberService는 외부 의존성 없는 평범한 자바 객체가 되므로, 테스트의 독립성을 해치지 않으면서 코드의 간결함을 유지하기 위해 Lombok은 허용하는 실용적인 노선을 택했습니다.
 

class MemberServiceTest {
    @Test
    void signup_success() {
        // Spring 없이 순수 자바 객체로 테스트 (속도 < 0.1s)
        MemberService memberService = new MemberService(mockRepository, mockEncoder);
        
        // ... 검증 로직 ...
    }
}

덕분에 이제.. 순수 자바 객체(Plain Old Java Object)로 테스트할 수 있게 되었습니다.
 
 


 
 

Conclusion. 테스트 코드가 알려준 것들

글을 시작하며 던졌던 "좋은 소프트웨어란 무엇인가?"라는 질문에 대해 이렇게 답하고 싶습니다. "좋은 소프트웨어란, 변경에 유연하게 대응할 수 있는 코드"라고요. 아니면 Testable한 코드? 심심하면 나오는 면접 단골 질문이기도 합니다.
 
테스트 코드를 짜다 보니 자연스럽게 깨달은 사실이 있습니다. 테스트하기 쉬운 코드를 작성하려고 노력했을 뿐인데, 결과적으로 SOLID 원칙을 지키고 있었다는 점입니다.
 
테스트가 불가능해서 외부 의존성을 인터페이스로 분리하다 보니 DIP와 DI가 자연스럽게 적용되었습니다.
또한 테스트 셋업이 너무 복잡해서 클래스를 쪼개다 보니 높은 응집도(High Cohesion)와 느슨한 결합도(Loose Coupling)를 가진 구조가 만들어졌습니다. 덕분에 단위 테스트 작성을 용이하게 할 수 있었습니다.
 
억지로 디자인 패턴에 맞추지 않고도 테스트를 통해 코드의 문제점을 발견하고 고쳐나가니 좋은 설계를 만들어낼 수 있었습니다.
 
또한, "테스트 커버리지 100%가 무조건 정답인가?"에 대해서도 다시 생각하게 되었습니다. 아무리 커버리지가 95%, 99%라 하더라도 검증되지 않은 1%의 예외 케이스가 서비스 전체를 무너뜨릴 수 있기 때문입니다.
 
프로젝트의 구조와 도메인에 대한 깊은 이해도를 바탕으로, "진짜 핵심적인 로직이 무엇인가?"를 파악하고 그곳에 집중적인 테스트를 설계하는 역량이 필요함을 느꼈습니다. Claude가 알아서 모든 경우에 대한 코드를 작성하기에는 토큰도 그렇고 여러모로 한계가 있기 때문..
 
코드 리뷰를 보니 아쉬웠던 건 놓친 부분이 정말 많았습니다. 테스트 커버리지, 검증 로직, 동시성 제어 등 이 작은 요구사항에 신경써야 할 부분들이 정말 많았구나 싶었습니다. 또한 Claude Code를 다뤄보는 건 처음이라, Claude.md를 맛있게 커스텀하지 못한 게 가장 아쉽습니다.
 
다음에는 좀 더 이러한 부분들을 신경쓰면서 프로젝트를 고도화 해봐야겠습니다. 관련된 개념들도 좀 더 Deep-Dive하게!


P.S.

새로운 환경에서 다시 환경 설정하느라 시간이 많이 지체가 됐습니다. 다음 라운드는 좀 더 여유를 가지고 진행해야겠습니다.
아 그리고 출퇴근하면서 강의를 하나 들었는데, TDD를 그나마 조금 더 친근하게 접근할 수 있었습니다.
 

이 링크를 통해 구매하시면 제가 수익을 받을 수 있어요. 🤗
https://inf.run/b42Zw

Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트| 김우근 - 인프런 강의

현재 평점 4.9점 수강생 2,398명인 강의를 만나보세요. Spring에 테스트를 넣는 방법을 알려드립니다! 더 나아가 자연스러운 테스트를 할 수 있게 스프링 설계를 변경하는 방법을 배웁니다. Spring에

www.inflearn.com


https://inf.run/1e4rj

Java/Spring 주니어 개발자를 위한 오답노트| 김우근 - 인프런 강의

현재 평점 4.9점 수강생 1,260명인 강의를 만나보세요. 스프링이랑 JPA를 조금 다룰 줄 알게 된 당신, 앞으로 성장하기 위해 무엇을 어떻게 공부해야 할까요? 혹시 설계 공부를 해보겠다고 디자인

www.inflearn.com

 

반응형
TOP