해당 글은 소프트웨어 마에스트로 15기 과정에서 진행 중인 대학생을 위한 설문조사 서비스 "설문이용"을 개발하며 겪은 경험을 정리한 것입니다.
이전에 프로젝트를 진행했을 때는 개발 기간이 짧아 빠르게 기능을 개발하는 것에 중점을 두었다. 그러다 보니 기능이 추가될 수록 예상치 못한 버그가 자주 발생하게 되었다.
이번에 진행하게된 설문이용은 6개월 동안 개발을 하게되었고, 개발이 끝난 후에도 지속적으로 운영 및 유지보수를 하는 것을 목표로 하고있다.
그래서 우리 팀은 개발 비용이 조금 더 들더라도 안정적이고 유지보수가 편한 설계를 할 수 있는 방법을 찾게 되었다.
우리 팀은 개발 비용이 조금 더 들더라도 유지보수가 편하고, 안정적인 설계를 할 수 있는 방법을 찾기 위해 다양한 개발 방법론을 찾아보고, 멘토님들께도 조언을 구했다.
그 결과, 우리 팀은 개발 비용이 높아지지만 복잡성을 줄이고 유지보수가 편한 구조를 설계할 수 있는 도메인 주도 개발(DDD) 방식을 도입하기로 결정했다.
또한, 개발 비용이 높아지지만 안정성을 올리고 추후 리팩토링에 유리한 테스트 주도 개발(TDD) 방식을 도입하기로 결정했다.
상세한 과정은 아래와 같다.
설계할 설문 도메인을 살펴보면, 하나의 설문에는 여러개의 섹션이, 하나의 섹션에는 여러개의 질문이 있는 형태이다.
또한, 사용자는 설문에 참여해서 응답을 제출할 수 있고, 위와 같이 응답에 따라 다른 섹션으로 이동할 수 있도록 라우팅 설정 기능을 제공한다.
설문 도메인의 요구사항들을 바탕으로 위와 같이 설문 도메인 클래스들을 설계하였다.
DDD의 주요 개념 중 하나인 Aggregate를 도입하여, 설문과 관련된 객체를 하나의 설문 Aggregate로 묶었다.
또한, 설문 Aggregate root를 Survey
로 설정하여 하위 객체에 직접 접근하지 못하도록 하여 하위 객체를 캡슐화하고, 예상치 못한 변경을 막았다.
data class Survey(
val id: UUID,
val title: String,
val sections: List<Section>,
// 그 외의 속성들
) {
// Survey Class 생성 시 유효성 검증 진행
init {
require(sections.isNotEmpty()) { throw InvalidSurveyException() }
require(isSectionsUnique()) { throw InvalidSurveyException() }
require(isSurveyStatusValid()) { throw InvalidSurveyException() }
require(isFinishedAtAfterPublishedAt()) { throw InvalidSurveyException() }
require(isSectionIdsValid()) { throw InvalidSurveyException() }
}
/** 설문의 응답 순서가 유효한지, 응답이 각 섹션에 유효한지 확인하는 메서드 */
fun validateResponse(surveyResponse: SurveyResponse) { /* 메서드 코드 */ }
fun updateContent(/* 매개 변수들 */): Survey { /* 메서드 코드 */ }
fun start(): Survey { /* 메서드 코드 */ }
fun finish(): Survey { /* 메서드 코드 */ }
// 그 외의 메서드들
}
Survey
클래스의 코드는 위와 같다. 특징은 아래와 같다.
Survey
클래스 생성 시 유효성 검증을 진행하여, 항상 유효한 Survey
클래스만 존재할 수 있도록 했다.Survey
에 모아두어 응집도가 상승하고, 재사용성이 향상되었다.설문이용에서 설문이 끝나는 경우는 1. 추첨권이 소진되거나, 2. 설문 마감일이 되는 경우가 있다.
두 경우 다 동일하게 설문을 종료 상태로 업데이트 하는 기능이 필요하다.
이러한 기능을 여러 Service 계층에 각자 구현하게되면 아래와 같다.
하지만 위의 경우, 설문 종료 로직을 수정하는 경우, 각 서비스의 설문 종료 코드를 수정해야한다. 즉 수정 범위가 증가하게 되어 유지 보수가 어려워진다.
하지만, 설문 종료 메서드를 Survey 클래스에 구현하고, 각 Service 계층에서는 Survey 클래스의 메서드를 사용한다면 어떨까? 이를 그림으로 표현하면 아래와 같다.
위의 경우는 Service 계층에선 Survey
클래스의 메서드를 호출하기만 하면 되므로 Service 계층의 복잡도가 감소한다.
또한 설문 종료 로직을 수정하기 위해선 Survey 클래스의 finish 메서드만 수정하면 되고, 추후 설문 종료 기능이 필요한 곳이 생기면 Survey 클래스의 finish 메서드를 재사용하면 되므로 유지 보수성이 향상된다.
설문이용에서는 위와 같이 선택지에 따라 다른 섹션으로 이동하도록 설정할 수 있다.
이러한 상황에서, 선택지에 따라 이동할 섹션을 존재하지 않는 섹션으로 수정한다면 어떻게 될까?
우리는 정리한 설문에 대한 요구사항을 바탕으로 위와 같이 테스트 코드를 작성했다.
그리고, 테스트 코드가 통과하도록 최대한 빠르게 클래스의 메서드를 구현했다.
이 과정에서, 테스트 코드 작성을 놓친 부분이 있는지 확인하기 위해 Jacoco를 통해 테스트 커버리지를 측정하였고, domain 패키지의 테스트 커버리지를 100%로 달성하였다.
이후에는 테스트 커버리지를 100% 유지하면서 지속적으로 리팩토링을 진행하였다.
domain 패키지의 테스트 커버리지를 100% 달성하여, 도메인 클래스의 메서드를 활용하는 서비스 코드의 안정성도 같이 향상되었다.
실제로 테스트 코드 작성 이후, 핵심 도메인 로직에 대한 버그 발생 빈도가 크게 줄었다.
리팩토링을 진행할 때 아무리 변경을 해도 테스트 코드를 통해 기존과 같은 동작을 한다는 것을 확인할 수 있어서 안정적이고 더 효율적으로 리팩토링을 진행할 수 있었다.
앞서서 설문 도메인을 설계하고, 구현 까지 완료했지만 아래와 같은 이유로 리팩토링을 진행하기로 결정했다.
리팩토링 과정이 담긴 PR 링크
기존에는 질문 인터페이스 하나와 질문 유형별 구현체의 구조였다.
하지만, 객관식 질문들의 공통 요구사항과 객관식 질문 중 선택지 기반 라우팅의 대상이 될 수 있는 단일 선택 질문에 대한 요구사항이 있는 등 추후 질문 확장 시 코드 중복이 우려되어 세분화를 진행했다.
이를 통해, 앞으로 주관식을 추가하면 TextQuestion
인터페이스 구현, 객관식을 추가하면 단일 선택이면 SingleChoiceQuestion
, 다중 선택이면 MultipleChoiceQuestion
인터페이스를 구현하면 되므로 질문 유형 추가에 대한 확장성이 증가했다.
기존에는 기타 선택지를 null로 구분하였는데, 이 때 null의 의미가 모호하여 코드의 가독성이 떨어졌었다.
이제 sealed 클래스를 활용하여 명시적으로 기타 선택지임을 알 수 있어 더 유지보수와 가독성이 좋아졌다.
섹션 ID도 마찬가지로, 기존에는 마지막 섹션 ID를 null로 구분하여 의미가 모호해서 코드의 가독성이 떨어졌었다.
이제 sealed 클래스를 활용하여 명시적으로 마지막 섹션 ID임을 알 수 있어 더 유지보수와 가독성이 좋아졌다.
빠르게 기능을 개발하는 것에 집중했던 이전 프로젝트들과 다르게 우리의 목표에 맞는 다양한 개발 방법론을 도입하여 설계 & 개발을 진행하니까 다양한 장점을 겪을 수 있었다.
도메인 주도 개발을 통해선 좋은 설계가 미래의 개발에 얼마나 큰 영향을 주는지 깨달을 수 있었고, 테스트 주도 개발을 통해선 테스트 코드의 든든함을 깨달을 수 있었다.
하지만, 체계적인 설계를 위해 초반에 많은 시간이 소요되었고, 요구사항이 변경되는 경우 테스트 코드 또한 변경해야 해서 개발 비용이 증가할 수 있는 등 단점에 대해서도 겪을 수 있었다.
DDD나 TDD를 무조건적으로 적용하기 보다는, 프로젝트의 목표를 잘 고려해서 적용해야겠다는 생각이 들었다.