💡 읽기전에 주의 사항
1. 기본적인 TDD와 JUnit, Mockito 라이브러리에 대한 이해를 요구합니다. (초급자용이 아닙니다.)
2. 아래 포스트는 정답이 아닙니다.
단위 테스트에는 목킹을 최대한 자제하는 고전파와 목킹을 적극 권장하는 런던파가 존재합니다.
하지만 최근 들어서 목킹이 안티 패턴이라는 말이 기정사실화 되어감에 따라, 제 경험을 바탕으로 고전파 쪽으로 치우친 이야기를 하고 있습니다.
여전히 좋은 TDD에 대해서는 알아가는 과정에 있으니, 참고하실 때 유의하시길 바라는 마음에 서두에 남겨둡니다.
시작하기 앞서, 해결책은 카카오페이 기술 블로그에서 가장 많이 영감을 받았습니다.
진심으로 감사드립니다.
1. Introduction
📌 Am I truly parcticing TDD?
나는 요 근래 제대로 된 TDD를 해본 적이 없다.
보다 정확히 이야기하면 할 수가 없었다.
분명 TDD가 어렵다는 것은 설계에 문제가 있다는 신호라는데,
그러기엔 난 최근까지도 수많은 리팩토링을 거쳐가며 아키텍처를 견고하게 만들었다.
그럼에도 이 문제는 당췌 해결이 되지 않는 난제였다.
실제로 내가 작성한 코드를 보며, 문제 원인들을 살펴보자.
(실제 코드를 넣다보니 상당히 긴 코드를 첨부하게 되었습니다. 로직을 이해하실 필요는 전혀 없고, 의존 객체들을 어디서 사용하고 있는지 정도만 파악하고 넘어가주시면 됩니다.)
@Slf4j
@DomainService
@RequiredArgsConstructor
public class DeviceTokenRegisterService {
private final UserRdbService userRdbService;
private final DeviceTokenRdbService deviceTokenRdbService;
@Transactional
public DeviceToken execute(Long userId, String deviceId, String deviceName, String deviceToken) {
User user = userRdbService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND));
return getOrCreateDevice(user, deviceId, deviceName, deviceToken);
}
/**
* 사용자의 디바이스 토큰을 생성합니다.
* 만약, 이미 등록된 디바이스 토큰이 존재한다면, 해당 토큰을 갱신하고 반환합니다.
*/
private DeviceToken getOrCreateDevice(User user, String deviceId, String deviceName, String deviceToken) {
return deviceTokenRdbService.readDeviceByToken(deviceToken)
.map(originalDeviceToken -> updateDevice(user, deviceId, originalDeviceToken))
.orElseGet(() -> createDevice(user, deviceId, deviceName, deviceToken));
}
private DeviceToken updateDevice(User user, String deviceId, DeviceToken originalDeviceToken) {
if (!originalDeviceToken.getDeviceId().equals(deviceId) && originalDeviceToken.isActivated()) {
throw new DeviceTokenErrorException(DeviceTokenErrorCode.DUPLICATED_DEVICE_TOKEN);
}
originalDeviceToken.handleOwner(user, deviceId);
return originalDeviceToken;
}
private DeviceToken createDevice(User user, String deviceId, String deviceName, String deviceToken) {
deactivateExistingTokens(user.getId(), deviceId);
DeviceToken newDeviceToken = DeviceToken.of(deviceToken, deviceId, deviceName, user);
return deviceTokenRdbService.createDevice(newDeviceToken);
}
/**
* 특정 사용자의 디바이스에 대한 기존 활성 토큰들을 비활성화합니다.
* 새로운 토큰 등록 시 호출되어 하나의 디바이스에 하나의 활성 토큰만 존재하도록 보장합니다.
*/
private void deactivateExistingTokens(Long userId, String deviceId) {
List<DeviceToken> userDeviceTokens = deviceTokenRdbService.readByUserIdAndDeviceId(userId, deviceId);
userDeviceTokens.stream()
.filter(DeviceToken::isActivated)
.forEach(DeviceToken::deactivate);
}
}
다음과 같은 비즈니스 규칙으로 이루어진 서비스가 있다고 생각해보자.
- 사용자에게 등록된 device token이 없다면 추가로 갱신한다.
- device token이 이미 존재한다면, 소유자 정보를 갱신하고 last_sign_in_at을 업데이트한다.
- 같은 {userId, deviceId}에 대해 새로운 토큰이 발급될 수 있지만, 활성화된 토큰은 단 하나여야 한다.
- {deviceId, token} 조합은 시스템 전체에서 유일해야 한다.
이 테스트 코드는 어떻게 작성해야 할까?
@ExtendWith(MockitoExtension.class)
public class DeviceTokenRegisterServiceTest {
@Mock
private UserRdbService userRdbService;
@Mock
private DeviceTokenRdbService deviceTokenRdbService;
@InjectMocks
private DeviceTokenRegisterService deviceTokenRegisterService;
@Test
@DisplayName("새로운 토큰 등록 시 올바른 정보로 생성됩니다")
void shouldCreateNewTokenWithCorrectInformation() {
// given
User user = UserFixture.GENERAL_USER.toUserWithCustomSetting(1L, "jayang", "Yang", UserFixture.GENERAL_USER.getNotifySetting());
String expectedToken = "token1";
String expectedDeviceId = "device1";
String expectedDeviceName = "Android";
given(userRdbService.readUser(user.getId())).willReturn(Optional.of(user));
given(deviceTokenRdbService.readDeviceByToken(expectedToken)).willReturn(Optional.empty());
given(deviceTokenRdbService.readByUserIdAndDeviceId(user.getId(), expectedDeviceId)).willReturn(List.of());
given(deviceTokenRdbService.createDevice(any())).willAnswer(invocation -> invocation.getArgument(0));
// when
DeviceToken result = deviceTokenRegisterService.execute(user.getId(), expectedDeviceId, expectedDeviceName, expectedToken);
// then
assertEquals(expectedToken, result.getToken());
assertEquals(expectedDeviceId, result.getDeviceId());
assertEquals(expectedDeviceName, result.getDeviceName());
assertEquals(user, result.getUser());
}
}
이런 식으로 작성해볼 수 있을 것이다.
DB에 접근하는 영역은 mockito 같은 mock 라이브러리를 사용하여 처리하고, 나는 순수하게 도메인 로직만을 테스트 하면 되는 것이다.
위 테스트는 실제로 성공하며, 의도도 나름대로 명확히 표현하고 있다.
그러나 이게 정말 TDD라고 볼 수 있을까?
2. Anti Pattern
📌 Mockist Testing Is an Anti-Pattern
얼마전에 링크드인을 뒤적거리다가, 영감을 받게 해준 피드를 접하게 되었다.
'Mock을 자제해야한다?' 당연히 알고 있었다.
문제는 자제하라는 게 어느 정도인지 몰라서, 나 정도면 괜찮지 않을까 싶었을 뿐이다.
그러나 Mockist 테스팅이 안티 패턴이라고 못 박는 것은 내게 있어 충격적이었다.
"과도한 Mock이 TDD의 본질을 해칠 우려가 있다"는 것과 "Mock은 비효율적이고 비생산적인 패턴이다"라고 이야기하는 것은 상당히 큰 차이가 존재하기 때문이다.
📌 What's the problem?
위 테스트는 정말 많은 문제가 존재하지만, 그 중 결정적인 문제는 올바른 TDD로 인해 탄생한 코드가 맞냐는 근본적인 질문이다.
실제로 나는 저 코드를 TDD가 아니라, 동작하는 코드를 모두 만들고 검증의 목적으로 구현했다.
즉, 전형적인 TLD(Test Last Development)에 불과하다.
그렇다면 위 테스트 코드의 문제점은 무엇일까?
TDD의 원칙들을 열거하면서 알아보자.
(근본적 문제 원인은 unit 3에서 다루겠습니다. 그래야 좀 더 와닿습니다.)
1️⃣ 테스트는 다른 코드나 외부 시스템에 의존하지 않아야 한다.
@ExtendWith(MockitoExtension.class)
public class DeviceTokenRegisterServiceTest {
@Mock
private UserRdbService userRdbService;
@Mock
private DeviceTokenRdbService deviceTokenRdbService;
...
}
어처구니 없이 들릴 수도 있겠지만, 타켓 서비스 외의 다른 서비스 구현체를 불러온다는 점에서 이미 문제가 발생한다.
심지어 Mock을 사용하더라도 피할 수 없다.
만약 UserRdbService 클래스 자체 스펙 혹은 메서드의 변화가 발생했다고 생각해보자.
@DomainService
public class UserRdbService {
private final UserRepository userRepository;
@Transactional(readOnly = true)
public Optional<User> readUser(Long id, Long otherData) { // 조회 시, otherData를 추가로 필요
return userRepository.find(id, otherData);
}
}
UserRdbService에서 readUser()의 파라미터가 수정되었을 때, 영향이 여기저기 전파되는 건 당연한 일이다.
그러나 TDD에서는 이런 일이 발생해선 안 된다.
테스트 타겟인 DeviceTokenRegisterService도 아닌, UserRdbService에 의해 디바이스 토큰 등록 테스트가 실패한다는 것은 좋은 단위테스트가 아님을 경고하는 신호다.
🤔 딜레마
우리가 Mock을 왜 사용하는가?
DeviceTokenRegisterService 클래스를 생성하기 위해선, 멤버 변수에 해당하는 의존성을 관리해줄 필요가 있다.
그리고 하위 모든 클래스를 주입할 수는 없으니 가짜 클래스를 만들어서 주입해주는데, 이것 자체가 문제라고 한다면 대체 어떻게 해야 한다는 거지?
2️⃣ 테스트는 되도록 Mock을 사용하는 것을 지양해야 한다.
💡 좋은 단위 테스트는 리팩토링으로 내부 구조만 변했다면 테스트는 여전히 통과해야 한다.
위 관점을 오해해선 안 된다.
간혹 리팩토링과 스펙 수정의 차이를 오해해서 다음과 같은 문제가 발생하는 경우가 있는데,
기능이 변경된 경우엔 당연히 테스트에 실패해야 한다.
사용자 정보를 조회하는 서비스에서 다음과 같은 변경이 있었다고 가정해보자.
@Transactional(readOnly = true)
public User readUser(Long id) {
User user = userRepository.find(id);
// if (user == null) throw NotFoundException();
return user; // 없으면 그냥 null을 반환하도록 수정
}
예를 들어, readUser()의 반환 값으로 Optional<User>가 아닌 User를 반환하고 있었다.
기존에는 존재하지 않으면 예외를 발생시키다가 갑자기 null을 반환하도록 수정하면, 당연히 단위 테스트도 실패해야 한다.
readUser()는 당연히 null을 반환하지 않을 거라 예상했으므로, 기존 테스트에서도 이러한 예외처리는 수행하고 있지 않을 것이기 때문이다.
문제는 Mock 객체는 이러한 중요한 계약(contract) 변경을 감지하기가 어렵다.
given(userRdbService.readUser(1L)).willReturn(expect);
Mock은 우리가 명시적으로 정의한 결과를 반환할 뿐이기 때문에, 실제 구현체의 계약 변경이 테스트에 자연스럽게 반영이 되질 않는다.
이는 readUser()는 언제나 null을 반환하지 않는다는 외부 코드의 구체적인 스펙에 의존하고 있기 때문에 발생하는 문제다.
그 말은 즉슨, 실패했어야 할 테스트가 통과하게 됨으로써, 실제 운영 환경에서 심각한 결과를 초래할 우려가 있다.
이로 인해, 개발자는 더이상 TDD를 신뢰할 수 없게 되고, 다시 코드의 수정과 리팩토링에 조심스러워질 수밖에 없게 된다.
3️⃣ 테스트 코드는 "어떻게"가 아니라 "무엇을"에 집중해야 한다.
TDD는 구현에 앞서 "무엇을 해야 하는지"를 테스트로 먼저 표현한다.
이는 비즈니스 요구사항을 코드로 표현하는 첫 단계이며, 순수하게 입력값과 기대하는 결과에만 집중해야 한다.
예를 들어, "사용자에게 디바이스 토큰이 존재하지 않으면, 새로 생성한다"라는 기능을 개발할 때는 다음과 같이 시작해야 한다. (세세한 단계는 생략)
@Test
void whenRegisteringNewToken_ShouldCreateNewDeviceToken() {
// given
User user = new User(1L);
String expectToken = "token1";
// when
DeviceToken actual = deviceTokenRegisterService.register("device1", "Android", expectToken, user);
// then
assertTrue(actual.isActivated());
assertEquals(expectToken, actual.getToken());
assertEquals(user, actual.getOwner());
}
그리고 내 테스트를 다시 살펴보자.
Mockito 라이브러리가 TDD를 가장 어렵게 만드는 특징이 이것이다.
- 내부 구현 변경으로 특정 메서드 호출이 제거되면, 의미없는 목킹에 대한 예외를 일으킨다.
- 성능 개선 등을 이유로 조회를 하나로 합치거나, 분리했을 때, 기존 모킹은 의미 없이 실패하게 된다.
- 목킹 순서가 변경되어도 예외를 일으킨다.
- ex. deviceTokenRdbService.readDeviceByToken()과 userRdbService.readUser() 순서 변경으로 인해 테스트가 실패한다.
- (1), (2)에서 다룬 이야기 포함.
그저 input에 대한 output을 검증하고 싶을 뿐인데, 개발자는 필연적으로 내부 흐름을 추적하게 된다.
- 이런 과정에선 어떤 서비스가 어떤 데이터에 대해 모킹을 하게 되지?
- 이 모킹은 어떤 순서로 호출되어야 하지?
이러한 문제들은 TDD의 본질을 훼손한다.
TDD는 "무엇을 해야 하는지"에 집중함으로써 코드의 설계를 이끌어내야 하는데, 과도한 Mock으로 인해 우리를 구현 세부사항에 종속시켜버리는 문제를 낳는다.
결과적으로 개발자는 비즈니스 로직이 아닌, Mock 설정에 더 많은 시간을 쏟아야만 하고,
이는 테스트 작성 자체를 포기하게 만드는 악순환으로 이어질 수 있다.
📌 Summary of Issues
문제점들을 정리해보자.
- Mock은 TDD의 본질을 해친다.
- "무엇을 해야 하는지"보다 "어떻게 구현되어 있는지"에 집중하게 만든다.
- 구현 변경에 취약해진다.
- 실제로 검증해야 할 비즈니스 규칙보다, 테스트 환경 설정에 더 많은 코드와 노력이 필요하다.
- 리팩토링 내성이 떨어진다.
- 내부 구현 변경만으로도 테스트가 실패할 수 있다.
- verify() 같은 검증 구문이 구현 세부 사항에 종속된다.
- 메서드 호출 순서 변경만으로도 테스트가 깨질 수 있다.
- 중요한 계약 변경을 감지하기 어렵다.
- Mock은 우리가 정의한 동작만을 수행하므로, 실제 구현체의 계약 변경이 테스트에 반영되지 않는다.
- 실패해야 할 테스트가 여전히 통과할 수 있다.
📌 Non-Negotiable Points
여기까지 읽으면서 '그럼 이렇게 하면 되지 않을까?'라는 생각이 들었을 수도 있다.
실제로 몇 가지 시도해보려고 찾아본 대안들이 있었으나, 오히려 이 과정을 통해 타협할 수 없는 몇 가지 항목들을 발견하게 되었다.
대표적인 예시들을 통해, 왜 이런 접근이 한계를 가질 수밖에 없는 지 살펴보자.
1️⃣ @Mock 대신 다형성을 이용해서 근본적인 문제를 해결할 수 없다.
많은 개발자들이 TDD의 의존성 문제를 해결하기 위해 인터페이스와 Stub(혹은 Mock, Fake) 구현체를 사용하는 방식을 제안한다.
@ExtendWith(MockitoExtension.class)
public class DeviceTokenRegisterServiceTest {
private UserService userStubService;
...
}
@TestComponent
public class UserStubService implements UserService {
@Override
@Transactional
public Optional<User> readUser(Long id) {
return Optional.of(new User(id));
}
}
언뜻 보면 이 방식이 Mock 사용의 문제점을 해결할 수 있을 것처럼 보이지만, 실제로는 근본적인 문제를 해결하지 못한다.
- 여전히 "어떻게"에 집중하게 만든다.
- Stub이든 Mock이든, 여전히 구현 세부사항을 지속적으로 추적해야 할 수밖에 없다.
- 유지/보수 비용이 증가할 수 있다.
- 실제 구현체(UserRdbService)의 계약이 수정을 즉각적으로 반영하지 못한다.
- 새로운 메서드가 추가/수정/삭제될 때마다, 테스트를 위한 구현체도 업데이트 해야 한다.
- 여전히 외부 코드의 변경에 테스트 코드에 변경이 전파될 수 있다.
- 테스트의 신뢰성 문제는 여전히 존재한다.
- Stub은 실제 구현체의 복잡한 동작을 정확히 모사하기 어렵다.
2️⃣ 서비스 계층은 통합 테스트로 다루는 것이 합리적이다.
Service는 본질으로 여러 Infrastructure 요소들을 조율하는 역할을 수행한다.
그 말은 직접적으로든, 간접적으로든 Repository를 의존하게 되어, 데이터베이스와의 상호작용, 트랜잭션 관리, 보안 검사, 성능 테스트 등 다양한 관점에서 테스트를 수행해보아야 한다.
많은 개발자들이 이 문제를 해결하기 위해 Repository를 실제 DB에 연결하지 않고, in-memory DB나 ConcurrentHashMap을 활용한 Fake Repository 사용하는 경우가 있다.
하지만 이는 다음과 같은 한계에 부딪히게 된다.
- JPA의 영속성 컨텍스트와 같은 복잡한 메커니즘을 구현하기가 어렵다.
- drity checking이나, lazy loading과 같은 Hibernate의 핵심 기능들을 Fake 객체로 완벽하게 모사하는 것은 사실상 불가능하다. (라이브러리를 아예 새로 만들 게 아니라면)
- 서비스 계층에서 자주 마주치는 동시성 문제나 엣지 케이스를 테스트하기가 매우 까다롭다.
- 실제 데이터베이스의 트랜잭션 격리 수준이나 락(lock) 메커니즘을 ConcurrentHashMap으로는 정확히 재현할 수 없다.
- 예를 들어, 다음과 같은 시나리오를 데이터베이스와의 상호작용 없이 제대로 테스트하기란 매우 어렵다.
- 두 사용자가 동시에 같은 디바이스 토큰을 등록하려 할 때 (ex. 낙관적 락이 동작하는가?)
- 토큰 등록과 동시에 사용자 정보가 업데이트될 때의 트랜잭션 동작
- JPA의 영속성 컨텍스트가 관리하는 Entity의 상태 변화
(물론 위 예시는 Aggregate Root를 잘못 나누었기에, DDD 관점에서 수정해야 한다고 이의를 제기할 수 있으나, 극단적인 상황을 과장해서 이야기했을 뿐입니다.)
따라서 서비스 계층은 실제 데이터베이스를 사용한 통합 테스트로 검증하는 것이 현실적이며 신뢰할 수 있다.
이는 단순히 편의성의 문제를 떠나, 테스트 신뢰성과 직결되는 문제다.
이러한 한계점들을 인지한 상태에서, 우리는 어떻게 더 나은 접근을 할 수 있다는 것일까?
3. Seperate Business Logic From Service
📌 Purpose
위에서 나온 문제점들을 통합하면, 테스트를 다음과 같이 수정해야 한다.
- Service는 통합 테스트로 테스트한다.
- 도메인 규칙은 가능한한 Mock을 최소화한다.
그러나 상식적으로 이런 제약 조건이 걸리면, 대체 무슨 수로 TDD를 하라는 말인가?
하지만 조금만 더 깊이 생각해보면, 첫 번째 제약 조건이 오히려 힌트를 제공하고 있다.
Service 계층의 인프라스트럭처에 대한 의존을 땔래야 땔 수 없다면, 도메인 규칙만을 따로 분리하면 되지 않을까?
📌 First Class Object
일급 객체(first-class object)란, 다른 객체들에 일반적으로 적용 가능한 연산을 모두 지원하는 객체를 가리킨다.
비즈니스 로직의 본질은 결국 무엇인가?
데이터베이스나 외부 환경으로부터 독립적인, 순수하게 도메인 규칙을 표현하는 것을 의미한다.
예를 들어, "디바이스 토큰 등록"은 "사용자에게 기존의 토큰이 존재하지 않으면, 새로운 토큰을 등록한다"라는 순수한 규칙들이다.
TDD는 input에 대한 output을 정의함으로써, 개발자가 "무엇을 테스트 하는지"에 초점을 맞추도록 하는 방법론이다.
이는 곳 우리의 테스트가 마치 순수 함수처럼 동작해야 한다는 것과 일맥 상통한다.
이러한 관점에서 볼 때, 도메인 로직은 자연스럽게 일급 객체의 형태를 가지게 된다는 점이다.
말이 너무 어려운데, "디바이스 토큰 등록"이라는 비즈니스 로직을 제대로 된 TDD를 통해서 다시 구현해보도록 하자.
(도메인 규칙 3개 정도만 적용해서, TDD가 가능하다는 것을 보여드릴 수 있을 정도만 진행합니다.)
📍 테스트 목표
• 도메인 규칙을 표현할 클래스: DeviceTokenRegisterCollection
• 입력: 사용자(Owner) 정보 및 Device Token 정보
• 출력: Owner에게 할당된 DeviceToken (이미 등록했거나, 새로 발급한 데이터)
📌 도메인 규칙1: 사용자에게 기존의 토큰이 존재하지 않으면, 새로운 토큰을 등록한다.
🔴 빨간 불 띄우기
위 테스트는 반드시 실패한다.
왜냐하면, DeviceTokenCollection이라는 클래스가 존재하지 않기 때문이다.
🟢 초록불 띄우기
public class DeviceTokenCollection {
public DeviceToken register() {
return null;
}
}
그래서 DeviceTokenCollection 클래스를 만들고, register() 메서드를 정의하여 빨간불을 없앴다.
🔴 빨간 불 띄우기
@Test
@DisplayName("새로운 토큰 등록 시 올바른 정보로 생성됩니다")
void when_user_has_no_token_should_create_new_token() {
// given
DeviceTokenCollection deviceTokenCollection = new DeviceTokenCollection();
User user = UserFixture.GENERAL_USER.toUserWithCustomSetting(1L, "jayang", "Yang", UserFixture.GENERAL_USER.getNotifySetting());
String expectedToken = "token1";
String expectedDeviceId = "재서의 까리한 플립";
String expectedDeviceName = "Galaxy Flip 6";
// when
DeviceToken actual = deviceTokenCollection.register(user, expectedDeviceId, expectedDeviceName, expectedToken);
// then
assertEquals(expectedToken, actual.getToken());
assertEquals(expectedDeviceId, actual.getDeviceId());
assertEquals(expectedDeviceName, actual.getDeviceName());
assertEquals(user, actual.getUser());
}
이번엔 input값에 대해서 올바른 결과를 반환하는 지 검증하는 코드를 추가했으나, 당연히 또 빨간불이 들어온다.
🟢 초록불 띄우기..?
public class DeviceTokenCollection {
public DeviceToken register(User user, String deviceId, String deviceName, String deviceToken) {
return null;
}
}
이렇게만해도 테스트 케이스의 빨간 불은 지워지니, 실행을 해보면 어떻게 될까?
애석하게도 테스트는 실패한다.
🟢 초록불 띄우기
public class DeviceTokenCollection {
public DeviceToken register(User user, String deviceId, String deviceName, String deviceToken) {
return DeviceToken.of(deviceToken, deviceId, deviceName, user);
}
}
빨간불을 끄기 위해, 반환값으로 DeviceToken을 추가해보자.
드디어 테스트가 성공적으로 동작함을 발견할 수 있게 되었다!
👇 기존 코드와 비교
여기서 매개변수가 null이거나, 유효하지 않은 입력이 들어올 때에 대한 예외 시나리오를 먼저 테스트 케이스를 작성하고 가는 게 맞지만,
이번 포스팅에서 담기엔 자질구레하게 내용이 너무 길어지므로 생략한다.
📌 도메인 규칙2: 동일한 deviceToken으로 등록된 데이터가 비활성화 상태로 존재하면, 소유자 정보와 마지막 로그인 시간을 갱신하고, 토큰을 활성화 한다.
이번에는 DeviceTokenCollection에게 같은 token이름을 갖는 DeviceToken 상태가 추가되어야 한다.
🤔 DeviceToken의 특징
아~~~주 간략하게 설명을 하면, DeviceToken은 Device 별로 고유하다.
하지만 DeviceToken은 몇 가지 주의 사항이 있는데,
• DeviceToken은 주기적으로 변경된다. (기존의 DeviceToken은 유효하지 않게 된다.)
• DeviceToken은 사용자가 아닌 사용자의 기기를 식별한다. (사용자가 여러 기기로 로그인하면, 사용자별로 여러 DeviceToken이 할당된다.)
• 여러 명의 사용자가 하나의 기기를 공유하면, DeviceToken은 고유하되, 소유자 정보만 수정되어야 한다.
🔴 빨간 불 띄우기
DeviceTokenCollection deviceTokenCollection = new DeviceTokenCollection(existingToken);
deviceToken은 테이블 전체에서 고유해야 하므로, 상태 또한 복수개가 존재할 일이 없다.
따라서 위와 같이 표현할 수는 있지만, 이렇게 하면 당연히 빨간 불이 발생한다.
public class DeviceTokenCollection {
private final DeviceToken deviceToken;
public DeviceTokenCollection(DeviceToken token) {
this.deviceToken = token;
}
public DeviceToken register(User user, String deviceId, String deviceName, String deviceToken) {
return DeviceToken.of(deviceToken, deviceId, deviceName, user);
}
}
그래서 DeviceTokenCollection의 생성자를 추가하면 되는데, 이러면 도메인 규칙1 테스트에서 빨간불이 들어온다.
🟢 초록불 띄우기..?
이 문제를 가장 빠르게 해결하는 방법은 기본 생성자도 추가하는 것이다.
public class DeviceTokenCollection {
...
public DeviceTokenCollection() {
this.deviceToken = null;
}
...
}
이것만으로도 테스트는 모두 통과하지만, 중요한 건 필터링 로직을 아직 추가하지 않았다는 점이다.
그래서 새로 추가한 테스트에서 초록불이 나오긴 하지만, 올바른 테스트가 아니다.
🟢 초록 불 띄우기
public DeviceToken register(User user, String deviceId, String deviceName, String deviceToken) {
if (this.deviceToken != null && this.deviceToken.getToken().equals(deviceToken)) {
this.deviceToken.handleOwner(user); // 마지막 로그인 시간 갱신은 handleOwner 내부에 포함되어 있다.
return this.deviceToken;
}
return DeviceToken.of(deviceToken, deviceId, deviceName, user);
}
Collection의 deviceToken은 null일 수 있으므로, 위와 같은 예외 처리를 통해 올바른 결과를 이끌어 낼 수 있다.
🔵 리팩토링
다음 단계로 넘어가기 전에 코드를 리팩토링하면, 다음과 같이 표현해볼 수도 있다.
public DeviceToken register(User user, String deviceId, String deviceName, String token) {
DeviceToken existingDeviceToken = this.getDeviceTokenByToken(token);
if (existingDeviceToken != null) {
existingDeviceToken.handleOwner(user);
return existingDeviceToken;
}
return DeviceToken.of(token, deviceId, deviceName, user);
}
private DeviceToken getDeviceTokenByToken(String token) {
if (this.deviceToken != null && this.deviceToken.getToken().equals(token)) {
return this.deviceToken;
}
return null;
}
getDeviceTokenByToken()의 null 반환을 허용한 이유는 반환값이 클래스 외부로 전파될 일이 없기 때문이다.
따라서 생성 비용이 비싼 Optional을 굳이 채택하지 않았다.
👇 기존 테스트와 비교
이렇게 해도 기존의 모든 테스트가 통과함을 확인했으니, 다음 단계로 넘어가보자.
📌 도메인 규칙3: 이미 존재하는 DeviceToken 정보가 활성화되어 있고, 다른 deviceId를 가진다면 중복 예외를 발생시킨다.
이건 상황을 조금 이해해야 할 수 있는데, 굳이 따지자면 보안 규칙에 해당한다.
- 활성화 상태인 deviceToken이 다른 deviceId에게 중복되는 경우는 존재하지 않는다.
- 아직도 유효한 deviceToken 정보에 대해, 다른 기기에서 소유권을 앗아가는 경우는 악의적인 공격이라 볼 수 있다.
- 만약, 이를 허용하면 "정상 사용자"에게 전달되어야 할 푸시 알림이 "해커"에게 전달될 우려가 있다.
🔴 빨간 불 띄우기
테스트 케이스를 먼저 작성해보자.
@Test
@DisplayName("다른 디바이스에서 이미 사용 중인 활성화 토큰으로 등록을 시도하면 예외가 발생합니다")
void should_throw_duplicate_exception_when_token_exists_for_different_device_id() {
// given
User owner = UserFixture.GENERAL_USER.toUserWithCustomSetting(1L, "jayang", "Yang", UserFixture.GENERAL_USER.getNotifySetting());
User hacker = UserFixture.GENERAL_USER.toUserWithCustomSetting(2L, "another", "User", UserFixture.GENERAL_USER.getNotifySetting());
String token = "token1";
DeviceToken existingToken = DeviceToken.of(token, "token1", "Android", owner);
DeviceTokenCollection deviceTokenCollection = new DeviceTokenCollection(existingToken);
// when & then
assertThrows(DeviceTokenErrorException.class, () -> deviceTokenCollection.register(hacker, "HACKER_DEVICE_ID", "Android", token));
}
정상 사용자의 토큰을 등록해두고, hacker가 활성화된 토큰에 대한 다른 deviceId로 낚아채려 하는 경우 예외가 발생하길 바랬으나,
당연히 이번에도 빨간 불을 먼저 마주하게 된다.
🟢 초록불 띄우기
빨간불을 가장 빠르게 끄는 방법은 token이 겹칠 때, 예외 조건을 하나 더 추가해주면 된다.
public DeviceToken register(User user, String deviceId, String deviceName, String token) {
DeviceToken existingDeviceToken = this.getDeviceTokenByToken(token);
if (existingDeviceToken != null) {
if (!existingDeviceToken.getDeviceId().equals(deviceId) && existingDeviceToken.isActivated()) {
throw new DeviceTokenErrorException(DeviceTokenErrorCode.DUPLICATED_DEVICE_TOKEN);
}
...
}
}
🔵 리팩토링
이제 이걸 다시 리팩토링하면, 다음과 같이 표현할 수 있는데 기존 코드와 매우 유사하게 보이기 시작한다.
public class DeviceTokenRegisterCollection {
...
public DeviceToken register(User user, @NonNull String deviceId, @NonNull String deviceName, @NonNull String token) {
DeviceToken existingDeviceToken = this.getDeviceTokenByToken(token);
return (existingDeviceToken != null)
? this.updateDevice(user, deviceId, existingDeviceToken)
: this.createDevice(user, deviceId, deviceName, token);
}
...
}
👇 기존 테스트와 비교
📌 How do we user first-class objects?
💡 Service 계층은 Infrastructure와 통합하여 테스트하고, 핵심 도메인 규칙은 일급 객체로 관리한다.
위와 같이 TDD를 수행하면, 우리는 비로소 기존의 문제에서 벗어나 올바른 테스트를 수행할 수 있음을 알게 된다.
그러나 영속화는 어떻게 할 것이며, Tx 관리는 어떻게 해야하는 걸까?
어떻게 도메인 규칙 클래스와 서비스 클래스의 조화를 성사시킬 수 있을 것인가?
해답은 매우 단순하다.
서비스 계층에서 순수한 도메인 규칙을 담은 일급 객체를 생성하여 조율하면 끝난다.
다음은 이러한 접근 방식을 보여주는 실제 예시에 해당한다.
@Slf4j
@DomainService
@RequiredArgsConstructor
public class DeviceTokenRegisterService {
private final UserRdbService userRdbService;
private final DeviceTokenRdbService deviceTokenRdbService;
@Transactional
public DeviceToken execute(Long userId, String deviceId, String deviceName, String deviceToken) {
// 이전 코드
// User user = userRdbService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND));
// return getOrCreateDevice(user, deviceId, deviceName, deviceToken);
// 수정된 코드
// 데이터 준비: 외부 시스템과의 상호작용
Optional<User> user = userRdbService.readUser(userId); // user not found 예외는 비즈니스 규칙이므로, collection에서 처리한다.
Optional<DeviceToken> existingDeviceToken = deviceTokenRdbService.readDeviceByToken(deviceToken);
// 핵심 비즈니스 규칙 실행: 순수한 도메인 로직
return new DeviceTokenRegisterCollection(existingDeviceToken.orElse(null))
.register(user.orElse(null), deviceId, deviceName, deviceToken);
}
}
만약 정말 올바른 일급 객체를 구현했다면, 언제나 동일한 input에 대해 output을 반환할 것이므로 아무런 문제가 되지 않는다.
이 구조의 장점은 다음과 같이 이야기할 수 있다.
- 관심사의 명확한 분리
- 도메인 규칙은 DeviceTokenRegisterCollection에 순수하게 존재한다.
- 트랜잭션과 영속성 관리는 서비스 계층에서 담당한다.
- 테스트 용이성
- 도메인 규칙은 단위 테스트로 완벽하게 검증할 수 있다.
- 서비스 계층은 통합 테스트를 통해 전체 흐름을 검증할 수 있다.
- 유지보수성 향상
- 비즈니스 규칙 변경은 일급 객체만 수정하면 된다.
- 인프라스트럭처 변경은 서비스 계층만 수정하면 된다.
이렇게 하면, 우리는 드디어 "테스트하기 쉬운 코드가 좋은 코드"라는 TDD의 근본적인 원칙에 부합하는 테스트를 수행할 수 있게 되는 것이다.
4. Conclustion
📌 Test Driven Development!!
최근들어, 모킹 때문에 올바른 TDD를 제대로 수행하지 못 해서 상당히 스트레스를 많이 받았었다.
때문에 온갖 디자인 패턴부터 멀티 모듈 아키텍처를 대규모 리팩토링하는 등의 노력을 했지만, 본질적인 문제는 해결이 되질 않았었다.
그런데 이번에 드디어 뭔가 해결의 실마리를 얻게 된 것 같아서 너무 감격스럽다....
TDD는 단순히 "테스트를 먼저 작성하는 것"이 아니다.
오히려 우리에게 "좋은 코드란 무엇인가?"라는 근본적인 질문을 끊임없이 던지고, 그에 대해 고민하게 만든다.
이번에 TDD가 내게 준 교훈은 무엇인가?
- Mock의 과도한 사용은 TDD의 본질을 해친다.
- "무엇을 해야 하는지"에 집중해야 할 테스트가 "어떻게 구현되어 있는지"를 검증하게 만든다.
- 우리는 테스트가 주는 가장 큰 가치인 코드의 설계를 이끌어내는 것을 잃게 될 수 있다.
- 서비스 계층으로부터 도메인 규칙을 순수하게 표현 가능한 객체를 분리하는 것이 좋다.
- 서비스 계층은 JPA와 트랜잭션 같은 복잡한 메커니즘을 포함한다. 이를 Fake나 Mock으로 완벽하게 모사하려는 시도는 너무 수고스럽고, 테스트의 신뢰성을 해칠 수 있다.
- 외부 의존성으로부터 자유로운 일급 객체를 분리하면, 우리는 비즈니스 로직을 더 명확히 이해하고, 테스트하기 쉽게 만들어준다.
아, 물론 여기서 "TDD가 알려준 게 아니라, 카카오 기술 블로그가 알려준 거잖아요!"라고 한다면
TDD는 내게 분명히 질문을 했고, 내 실력이 미숙해 스스로 답을 찾아내지 못했을 뿐이다.
아무튼 그럼.
아무튼 TDD 짱임.
난 지금부터 다시 TDD와 한 몸이 된다.
지금부터 TDD를 욕하는 자는 나를 욕하는 것과 동일한 어쩌구 저쩌구..