안녕하세요! 올리브영에서 프론트엔드 개발자로 일하고 있는 재미입니다.
오늘은 지난 3월 10일 올리브영 앱에 런칭한 W CARE 서비스의 프론트엔드를 TDD로 개발한 후기를 남겨보고자 합니다.
W CARE 서비스란?
W CARE 서비스는 에브리데이 우먼케어를 표방하는 서비스입니다. 별도 설치없이 올리브영 APP 하나로 여성의 월경 주기 관리부터 주기 맞춤 상품 추천까지 원스톱으로 해결할 수 있는 서비스입니다. 한눈에 자신의 월경 주기를 확인할 수 있는 동그란 대시보드, 월경 정보를 편리하게 입력할 수 있는 캘린더, 주기 맞춤형 상품 큐레이션 등이 특장점입니다.
앞서 말한 동그란 대시보드는 많은 정보를 제공합니다. 오늘이 나의 월경 주기 중 어느 시점에 있는지, 과거에 겪었고 현재에 겪고 있고 미래에 겪을 예정일 월경 이벤트들, 관련된 날짜 정보들을 한 눈에 쉽게 확인할 수 있습니다.
TDD 방법론을 도입한 이유
다시 개발 얘기로 돌아와서, 리액트 컴포넌트로 렌더링하기 이전에 서비스 사용자로부터 월경 관련 정보를 입력받고 이를 토대로 월경 이벤트를 판별하고 관련 정보를 생성해야 했습니다. 이러한 월경 이벤트 판별 알고리즘을 개발하면서 TDD 방법론을 적용하였습니다.
여성의 월경, 다소 생소하고 복잡한 도메인
사실 처음 W CARE 서비스 개발을 시작했을 땐 다소 어려움을 겪었습니다. 여성의 월경이라는 도메인은 상대적으로 생소했기 때문입니다. 초기 기획 리뷰를 들었을 때도 쉽게 이해되지 않았습니다. 그러나 그 와중에도 여성의 월경 이벤트를 정확하게 계산, 판별해내는 알고리즘을 구현한다면 높은 복잡도의 코드가 만들어질 것은 쉽게 예상할 수 있었습니다.
기획 리뷰를 해주시던 PO분께서도 많은 여성들이 스스로 자신의 월경 이벤트를 계산한다기보단 시중 앱이 계산해주는 것에 의존하는 편이라고 말하셨을 정도였습니다.
이러한 상황에서 신뢰도 높은 코드를 생산하기 위해선 돌다리도 두들겨가며 건너듯 점진적으로 코드를 검증 및 알고리즘을 구체화해가며 개발하는 것이 최선이었습니다.
요구사항이 명확하고 변경 사항에 견고했던 초기 기획
W CARE 기획서는 요구사항이 명확한 기획서였습니다. 물론 초기 기획이어서 서비스의 모든 기능이 구체화되어있진 않았습니다. 그러나 개발의 시작점이 될 월경 이벤트 판별 알고리즘은 명확하고 구체적으로 정리되어있었습니다.
또한 월경 이벤트의 일반적인 케이스 뿐만 아니라 미처 고려하지 못해 최악의 경우 버그를 일으킬 수도 있는 예외적인 케이스까지 꼼꼼히 알고리즘에 반영되어있어 변경 사항에 견고할 것이라는 생각이 들었습니다.
사실 정석적인 TDD 방법론을 사용하면 이론적으로 요구사항 변경 사항에도 견고한 소프트웨어 개발이 진행됩니다. 그러나 첫 시도에 완벽하긴 어렵다고 생각하여 이러한 환경도 고려하였습니다.
유닛 테스트로 테스트하기 쉬운 알고리즘
사이드이펙트가 난무하는 브라우저 환경에서의 리액트 컴포넌트를 테스트하는 것보다 정해진 알고리즘을 유닛 테스트로 테스트하는 것이 TDD 방법론을 적용하여 개발하기 상대적으로 수월합니다.
전체 알고리즘을 관심사에 따라 작은 함수들로 분할합니다. 그리고 정해진 입력에 정해진 출력을 만들어내도록 테스트를 작성하고 코드를 구성합니다. 이렇게 검증된 입출력에 따라 함수들이 유기적으로 연결되게 하여 모듈을 구성할 수 있습니다.
해보고 싶은 마음
TDD 방법론을 실무에 적용하고 싶은 마음도 컸습니다. 그 동안 TDD 방법론의 효용성에 대해선 많이 들어왔고 머리로는 이해하고 있었으나 마음속으론 완벽히 납득하지 못하고 있었습니다. 직접 적용해보고 판단하고 싶다는 생각이 들었습니다.
또한 올리브영 프론트엔드에서 TDD를 큰 비중으로 적용한 첫 프로젝트에 대한 욕심도 있었습니다.
구체적인 테스트 전략 및 테스트 코드 작성
월경 이벤트 판별 알고리즘
월경 이벤트 판별 알고리즘에서는 생애 첫 월경 시작일부터 생애 마지막 월경 종료일이 도래할 때까지의 기간을 월경 주기라는 더 짧은 기간 단위로 무수히 나눕니다. 첫 번째 주기, 두 번째 주기 ….. N-1 번째 주기, N번 째 주기까지 연속성 있게 진행됩니다. 사람들이 ‘월경을 일시적으로 하지 않아요’ 라고 생각하는 경우도 ‘월경이 지연됐다’ 라고 판단하고 있습니다.
이렇게 잘게 나뉘어 만들어진 월경 주기는 더 짧은 기간 월경이벤트로 구성되어 있습니다. 월경 시작일부터 월경종료일까지의 기간을 ‘월경 중’ 이벤트, 가임기 시작일로부터 가임기 종료일까지의 기간을 ‘가임기’, PMS(월경 전 증후군) 시작일로부터 다음 월경 시작예정일까지의 기간을 ‘PMS’ 이벤트라고 부릅니다.
예시의 월경 주기는 상대적으로 규칙적인 월경 주기입니다. 월경 시작일부터 다음 월경 시작 예정일까지의 기간도 넉넉한 편(28일 정도의 기간)이죠. 사실 모든 여성이 늘 이런 월경 주기를 겪는다면 월경 판별 알고리즘의 복잡도가 높진 않았을 것입니다. 각 월경이벤트가 겹치는 경우가 없기 때문입니다. 구현과 테스트 모두 손쉬웠을 겁니다. 그러나 여성의 월경 주기는 여러 요인(신체적 요인, 정신적 요인 등)에 따라 크게 변화하기도 합니다.
월경 시작일부터 다음 월경 시작 예정일까지의 기간이 짧은(15일 정도의 기간) 경우 가임기 이벤트와 월경 이벤트가 겹치기도 하며(이 경우 월경 중에 배란일이 도래하기도 합니다), PMS 기간 동안 가임기가 시작되기도 합니다.
월경 이벤트가 겹치는 것입니다. 어떤 월경 이벤트를 우선하여 내부 로직을 구성할지, 사용자에게 정보를 제공할지, 어떤 상품을 추천할지 우선순위를 정해야 하는 등 요구사항이 복잡해집니다. 자연스럽게 구현 또한 복잡해집니다. 이러한 복잡한 경우의 수들을 다루기 위해 현재 알고리즘 내엔 23개의 월경 이벤트가 정의되어있습니다.
출처 - Kent C. Dodds의 블로그
복잡한 케이스도 놓치지 않기 위해 가장 일반적인 케이스부터 시작하여, 예외적인 케이스들까지 ‘실패하는 테스트 코드 작성 → 테스트를 통과하는 동작 코드 작성 → 피드백 → 코드 개선’ 의 사이클을 반복하며 점진적으로 개발을 진행했습니다.
인간의 머리로 모든 경우의 수를 기억하기보단 정적 코드로 만들어 컴퓨터가 기억하고 검증하도록 역할을 위임한 것입니다. TDD 방법론의 한 사이클을 지난 경우의 수는 테스트가 실패하지않는 한 더 이상 신경쓰지 않아도 되는 것입니다.
경계값 분석(Boundary value analysis)
출처 - guru99
경계값 분석은 값이 속한 범위에 따라서 프로그램의 동작이 달라지는 경우, 범위의 경계값(Boundary value)을 포함한 테스트 케이스를 만들어 검증하는 방법입니다.
W CARE 서비스에서는 PMS, 월경 중, 가임기 등 여러 가지 월경 이벤트가 있습니다. 그중 월경 중 이벤트에 대한 요구사항을 분석해보았는데요, 월경 중 이벤트에는 특정한 경계값들이 형성되어 있었습니다. 경계값으로 테스트 케이스를 만들어 검증하면 됩니다.
월경 시작일이 10월 1일, 월경 종료일이 10월 7일인 경우를 예를 들어봅시다. 오늘이 10월 1일부터 10월 7일까지의 기간에 내에 속해 있다면, 오늘 여성은 월경을 겪고 있는 것입니다. 그러나 오늘이 9월 30일이거나 10월 8일이면 오늘 여성은 월경을 겪고 있지 않은 것입니다.
이 경우 9월 30일, 10월 1일, 10월 7일, 10월 8일은 경계값들입니다. 월경 시작일에 관련된 경계값들(9월 30일, 10월 1일)을 활용해 먼저 실패하는 테스트 코드를 작성해보도록 하겠습니다. 예시 코드는 이해하기 쉽도록 실제 코드보다 간결하게 정리되었습니다. 또한 예약어가 아닌 변수명, 프로퍼티명은 한글로 대체하였습니다.
describe("일반케이스체커 클래스 테스트", () => {
...
// 월경 중 상태인지 확인하는 메소드 테스트
context("월경중상태인지체크 메소드 테스트", () => {
it("현재 날짜가 월경 시작일의 날짜보다 이전이라면 메소드는 false를 반환해야한다.", () => {
// 데이터 클래스는 월경 이벤트 판별 알고리즘에 필요한 데이터를 별도로 관리하기위한 것입니다.
const 데이터인스턴스 = new 데이터({
오늘날짜: new Date(2020, 8, 30), // Date 객체에서의 월(month)은 인덱스 0부터 시작하므로 9월을 입력하면 8을 입력해야함
월경시작일: new Date(2020, 9, 1),
월경종료일: new Date(2020, 9, 7),
});
// 코어 클래스는 데이터 클래스의 인스턴스를 주입받아(즉, 데이터를 받아) 월경 이벤트 판별 알고리즘을 실행할 준비를 합니다.
const 코어인스턴스 = new 코어(데이터인스턴스);
// 일반케이스체커 클래스의 월경중상태인지체크 메소드는 현재 월경 중 이벤트 상태인지 판별합니다.
const 결과 =
일반케이스체커.월경중상태인지체크(코어인스턴스);
expect(결과).toBe(false);
});
it("현재 날짜가 월경 시작일의 날짜와 일치하면 메소드는 true를 반환해야한다.", () => {
...
});
});
...
});
경계값들의 앞뒤 범위를 검증하는 방식으로 누락되는 범위가 없도록하여 알고리즘 코드의 신뢰성을 높이고자 하였습니다.
이제 실패하는 테스트들이 작성되어있으니 테스트가 통과되도록 코드를 작성해보도록 합시다.
export class 일반케이스체커 {
/**
* 월경 중 상태인지 확인하는 메소드
*/
static 월경중상태인지체크(코어인스턴스: 코어) {
// 여기서 사용되는 계산기 클래스는 테스트 검증을 통과한 모듈
const 월경시작일까지의소요일 =
계산기.월경시작일까지의소요일계산하기(코어인스턴스);
// 현재 날짜로부터 실제월경시작일까지의 소요일이 0일 이하이면
// 월경 중인 것으로 판단한다.
return (
월경시작일까지의소요일 <= 0
);
}
}
이렇게 코드를 작성하면 월경 시작일에 대한 테스트를 통과하게 됩니다. 월경 중 이벤트 상태인지 정확히 판단하기 위해서 월경 종료일에 대한 테스트를 추가해봅시다.
describe("일반케이스체커 클래스 테스트", () => {
...
// 월경 중 상태인지 확인하는 메소드 테스트
context("월경중상태인지체크 메소드 테스트", () => {
...
it("현재 날짜가 월경 종료일의 날짜와 일치하면 메소드는 true를 반환해야한다.", () => {
const 데이터인스턴스 = new 데이터({
오늘날짜: new Date(2020, 9, 7),
월경시작일: new Date(2020, 9, 1),
월경종료일: new Date(2020, 9, 7),
});
const 코어인스턴스 = new 코어(데이터인스턴스);
const 결과 =
일반케이스체커.월경중상태인지체크(코어인스턴스);
expect(결과).toBe(true);
});
it("현재 날짜가 월경 종료일의 날짜보다 이후라면 메소드는 false를 반환해야한다.", () => {
...
});
});
...
});
새롭게 테스트를 추가했으니 기존 코드는 실패할 것입니다. 테스트를 성공적으로 통과하기 위해 코드를 변경해봅시다.
export class 일반케이스체커 {
/**
* 월경 중 상태인지 확인하는 메소드
*/
static 월경중상태인지체크(코어인스턴스: 코어) {
// 여기서 사용되는 계산기 클래스는 테스트를 통한 검증을 통과한 모듈
const 월경시작일까지의소요일 =
계산기.월경시작일까지의소요일계산하기(코어인스턴스);
const 월경종료일까지의소요일 =
계산기.월경종료일까지의소요일계산하기(코어인스턴스);
// 현재 날짜로부터 실제월경시작일까지의 소요일이 0일 이하이고 월경종료일 소요일이 0일 이상이면
// 월경 중인 것으로 판단한다.
return (
월경시작일까지의소요일 <= 0 &&
월경종료일까지의소요일 >= 0 &&
);
}
}
월경 종료일에 대한 테스트를 통과하면서 모든 테스트를 통과하는 코드가 완성(코드 개선은 선택사항입니다.)되었습니다. 월경 시작일에 대한 테스트 통과 이후 월경 종료일에 대한 테스트를 작성했습니다만 월경 시작일에 대한 테스트는 의식하지 않았습니다.
이전에 이미 테스트를 작성함으로써 그 역할을 위임했기 때문입니다. 코드 개선에 의한 예상치 못한 버그가 발생하더라도 정적으로 코딩된 테스트가 실패하면서 우리에게 실패 피드백을 줍니다.
그러므로 저희 개발자들은 사이드 이펙트에 대한 우려를 덜하면서 자신감있게 코드를 개선할 수 있습니다. 이러한 점 요구사항이 더욱 복잡해지고 고려해야 할 케이스가 많아질수록 빛을 발합니다.
이는 미처 고려하지 못한 요구사항이나 요구사항의 변경을 반영할 때도 마찬가지입니다. 또한 테스터블한 코드를 작성하려다 보면 확장성 있고 변경 사항에 개방되어있는 코드가 만들어지기 때문에 더욱 유리합니다.
마지막으로 실제 리액트 컴포넌트에 프롭스(props)로 사용하는 객체를 반환하는 클래스에 대한 테스트 코드 및 로직 코드를 제시합니다. 테스트로 검증이 끝난 모듈들이 모여 최종적으로 객체를 반환합니다.
export class 프롭스산출기 {
static 프롭스산출하기(코어인스턴스: 코어) {
const 월경이벤트 = 코어인스턴스.월경이벤트얻기();
const { 월경주기 } = 코어인스턴스.필드들;
const 월경시작일까지의소요일 =
계산기.월경시작일까지의소요일계산하기(코어인스턴스);
...
switch (월경이벤트) {
...
// 월경중 이벤트를 겪고있다면
case 월경이벤트열거형.월경중:
// 리액트 컴포넌트에서 프롭스로 사용할 객체를 반환한다.
// W CARE 메인홈 대시보드는 클릭하면 동전뒤집기하듯 앞뒤가 반전됩니다.
return {
앞면표시들: {
첫번째표시: "월경 시작",
두번째표시: `${
Math.abs(월경시작일까지의소요일) + 1
}일차`,
},
뒷면표시들: {
첫번째표시: `평균 주기 ${월경주기}일 중`,
두번째표시: `${
Math.abs(월경시작일까지의소요일) + 1
}일차`,
},
};
...
}
}
}
}
describe("프롭스산출기 클래스 테스트", () => {
...
context("프롭스산출하기 메소드 테스트", () => {
...
it("현재 날짜가 월경시작일의 날짜와 일치한다면 메소드는 목객체와 같은 객체를 반환해야한다.", () => {
const 목객체: 프롭스산출하기반환타입 = {
// W CARE 메인홈 대시보드는 클릭하면 동전뒤집기하듯 앞뒤가 반전됩니다.
앞면표시들: {
첫번째표시: "월경 시작",
두번째표시: "1일차",
},
뒷면표시들: {
첫번째표시: "평균 주기 28일 중",
두번째표시: "1일차",
},
};
const 데이터인스턴스 = new 데이터({
오늘날짜: new Date(2020, 9, 1),
월경시작일: new Date(2020, 9, 1),
월경종료일: new Date(2020, 9, 7),
});
const 코어인스턴스 = new 코어(데이터인스턴스);
const 결과 = 프롭스산출기.프롭스산출하기(코어인스턴스);
expect(결과).toEqual(목객체);
});
it("현재 날짜가 월경시작일의 날짜보다 1일 이후에 있다면 메소드는 목객체와 같은 객체를 반환해야한다.", () => {
...
});
...
});
...
});
이렇게 만들어진 월경 이벤트 판별 알고리즘 구현체를 커스텀 훅으로 래핑하여 리액트 컴포넌트에서 사용하고 있습니다. 리액트 컴포넌트는 산출된 프롭스를 받아 아래와 같이 그대로 렌더링합니다.
판별된 월경 이벤트는 메인보드 상단의 증상안내 영역, 하단의 상품 큐레이션 영역에도 각각 활용됩니다!
실제로 느낀 TDD의 장점
테스트 코드를 먼저 작성하는 것이 초기 설계와 알고리즘 구체화에 도움을 줌
여성의 월경이라는 도메인은 제게 상대적으로 생소하였습니다. 그러나 테스트 코드를 작성하면서 일반적인 케이스 뿐만 아니라 특수하고 예외적인 케이스들도 고려하는 등 좀 더 거시적인 시야에서 개발에 임할 수 있었습니다.
무엇보다 알고리즘을 기획한 PO 님과의 협업에 큰 도움이 되었습니다. 머릿속에 추상적으로 멤돌던 알고리즘이 테스트 코드를 통해 구체화되면서 스스로 모호하게 알고 있거나 모르고 있던 부분, 명확히 알고 있던 부분이 구별되기 시작했습니다.
이를 통해 PO 님께 모호하거나 모르는 부분에 대해 정확히 질문할 수 있었습니다. 덕분에 협업 과정에서의 비효율을 최소화할 수 있었습니다.
또한 테스트 코드를 성공시키기위해서 테스터블한 코드를 작성하려하였고 이는 좋은 초기 설계에 큰 도움이 되었습니다. 자연스럽게 관심사 분리, 코드 간 의존성 최소화, 코드의 추상화에 대해 고민하게 되었기 때문입니다.
제가 실제 코드에서 일반케이스체커
, 계산기
, 프롭스산출기
등과 같이 관심사에 따라 코드를 분리하였는데요, 모듈 간 메시지들이 정의된 인터페이스에 따라 유기적으로 교환되도록 설계함으로써 코드 간 의존성을 최소화할 수 있었습니다. 그리고 적절한 추상화로 실제 코드 내 변경 사항이 생기더라도 입출력만 변경되지 않는다면 테스트 코드를 변경할 필요가 없어졌습니다.
결국 코드에 대한 디버깅과 유지보수가 압도적으로 쉬워졌습니다. 만약 절차적 코드들로 구현됐다면 상상도 못 할 일이었습니다.
코드 변경에 따른 사이드 이펙트를 빠르게 발견하고 고칠수 있었음
협업을 진행하다 보면 자신이 짠 코드가 다른 작업자에 의해 변경되어 문제가 생기는 경우가 있습니다. 최악의 경우 프로덕트 완성까지 아무도 알지 못하고 방치되다가 실제 서비스 운영 중에 발견될 수도 있습니다.
그러나 이번 프로젝트에선 테스트 코드의 실패 피드백을 통해 코드 변경에 따른 사이드 이펙트를 빠르게 발견하고 고칠 수 있었습니다.
이러한 점 덕분에 새로운 요구사항을 반영하거나 코드를 개선하는 데 있어 자신감 있게 임할 수 있었습니다.
정말 말 그대로 UT(unit test, 유닛테스트) 만세입니다.
작업물의 높은 코드 품질(낮은 결함률)을 이룰 수 있었음
개발이 완료되면 QA(Quality Assurance, 품질보증) 과정을 거치게 됩니다. 그리고 QA 과정에서 무척 많은 버그가 발견됩니다.
W CARE 서비스도 많은 버그가 발견되었으나 테스트를 적용된 코드와 테스트를 적용하지 않은 코드 간 버그 발생 정도가 차이가 있었습니다.
월경 이벤트 판별 알고리즘에 발생한 버그는 전체 버그 중 약 1% 미만이었습니다. 이 1%의 버그도 테스트가 미적용된 코드의 버그를 고칠 때보다 빠르고 안전하게 고칠 수 있었습니다.
앞으로의 발전 방향
W CARE 서비스 개발에 TDD 방법론을 적용해보았을 때 매우 만족스러운 결과를 얻을 수 있었습니다. 앞으로도 TDD 방법론을 적극 사용하고자 합니다.
그런데도 제가 느꼈던 TDD의 단점은 자칫하면 테스트 코드 작성하는 것 자체에 대한 비용이 커질 수 있다는 것입니다.
이를 해결하기 위해 좀 더 쉽게 테스트 코드를 사용하기 위한 도구를 도입, 자주쓰이는 보일러플레이팅 코드를 스니펫에 등록, 코파일럿을 사용한 AI 자동완성 사용 등 생산성을 끌어올려 볼 생각입니다.
또한 현재까지는 리액트 컴포넌트보다 함수 단위의 유닛 테스트에만 집중하였지만 이후 리액트 컴포넌트, 커스텀 훅 테스트 적용에 욕심내볼 생각입니다. 결국 사용자에게 최종적으로 노출되는 UI를 적절히 렌더링하는 것이 중요하기 때문입니다.