
어쩌다보니 저번 글에서 인텔리제이에서 UI이벤트를 처리하는 과정에 대해서 다루다보니, 다소 지체됐다.
저번 글을 통해서 우리가 만들고자 하는 핵심 기능이 잘 동작하는 것을 눈으로 확인할 수 있었다.
기능 목록과 UseCase까진 개발 초반에 작성을 완료했었는데, 도메인 모델에 대해선 아직 설계를 안했었다.
따라서 이번엔 간단하게 도메인 모델을 설계한 후에, 그에 맞게 코드를 리팩토링하면서 고민했던 점들에 대해서 다뤄보고자 한다.
이 다이어그램은 일반적인 도메인 모델 다이어그램에서 각 객체간 메시지와 인자, 그에 대한 반환값이 추가된 형태이다.
이러한 형태는 "객체지향의 사실과 오해"에서 처음 접한 방식으로, 객체 간의 상호작용을 좀 더 명확하게 볼 수 있다고 생각하여 적용해보았다.
우리의 플러그인은 "커밋메시지를 생성해라"라는 시스템 책임을 수행하여야 한다. 그리고 이것은 다시 더 작은 책임들로 나눠져 각 객체에게 할당된다. 몇 개의 주요한 책임을 요약하면 다음과 같다.
주요 도메인의 책임
CommitMessageComposer: 액션을 받아 커밋 메시지 생성 프로세스를 조합
Commit: 현재 커밋의 상태를 확인하고 관련 정보를 제공
Diff: 코드 변경사항을 상세히 분석하여 차이점을 추출
이 다이어그램에는 우리 시스템의 핵심 도메인과 그들 간의 관계가 명확히 담겨있다고 생각하지만, 초기엔 실제 구현과 차이가 있을 수 있다.
우선 더 구체적인 설계를 하기 보단, 도메인 모델을 기반으로 리팩토링하면서 구체적인 객체를 식별해보자. 도메인 모델은 추후에 점진적으로 개선할 예정이다.
class CommitMessageGenerateAction : DumbAwareAction() {
private val logger = Logger.getInstance(CommitMessageGenerateAction::class.java)
override fun actionPerformed(event: AnActionEvent) {
val project = event.project ?: return
// CommitWorkflowHandler를 통해 현재 커밋 상태에 접근
val commitWorkflowHandler = event.getData(VcsDataKeys.COMMIT_WORKFLOW_HANDLER) as? AbstractCommitWorkflowHandler<*, *>
?: return
// 사용자가 선택한 변경사항만 가져옴
val includedChanges: List<Change> = commitWorkflowHandler.ui.getIncludedChanges()
if (includedChanges.isEmpty()) {
logger.info("No changes selected")
return
}
// 변경사항을 순회하며 간단한 diff 정보를 생성
val diff = buildString {
includedChanges.forEach { change ->
val beforePath = change.beforeRevision?.file?.path ?: "unknown"
val afterPath = change.afterRevision?.file?.path ?: "unknown"
logger.info("Change: $beforePath -> $afterPath")
append("Change: $beforePath -> $afterPath\n")
}
}
// 생성된 diff 정보를 로그에 기록 (디버깅 및 모니터링 목적)
logger.info("Generated commit message from diff:\n $diff")
}
}
이 코드만 본다면, "굳이 리팩토링이 필요한가?"라는 생각이 들 수도 있다. 코드 라인도 짧을 뿐만 아니라 코드들 자체도 상당히 직관적이기 때문이다.
하지만, 이 코드 베이스에 새로운 기능이 추가된다고 생각해보자.
예를 들어, 변경 내역을 더 상세히 분석하거나, 커밋 메시지 템플릿을 적용하는 것과 같은 동작을 수행해야 할 수도 있다.
이러한 구현이 추가될 때마다 actionPerformed 메서드는 급격히 복잡해질 것이다. 그리고 이것은 가독성 저하, 확장성 감소, 테스트 용이성 감소 등등을 초래하고 말 것이다.
따라서, 도메인 모델이 나온 이 시점에 한번 리팩토링을 진행하는 것이 좋다고 생각하였다.
class CommitMessageGenerateAction : DumbAwareAction() {
private val logger = Logger.getInstance(CommitMessageGenerateAction::class.java)
private val commitMessageService = CommitMessageService(
CommitWorkflowHandlerProviderImpl(),
DiffProviderImpl()
)
override fun actionPerformed(e: AnActionEvent) {
e.project?.let {
commitMessageService.generateCommitMessage(e)
} ?: run {
logger.warn("프로젝트가 null입니다")
}
}
}
이곳에 있던 책임을 DiffProvider와 CommitWorkFlowHandlerProvider로 분산시켜 트리거의 역할만 수행하도록 단순하게 만들었다.
CommitMessageService를 필드에 선언함과 동시에 각 구현체의 인스턴스를 전달하여 초기화하였다.
이 방식으로 CommitMessageService입장에선 필요로 하는 구체적인 구현 객체들이 외부에서 주입되도록 만들었다.
class CommitMessageService(
private val commitWorkflowHandlerProvider: CommitWorkflowHandlerProvider,
private val diffProvider: DiffProvider
) {
private val logger = Logger.getInstance(CommitMessageService::class.java)
fun generateCommitMessage(e: AnActionEvent) {
val commitWorkflowHandler = commitWorkflowHandlerProvider.getCommitWorkflowHandler(e) ?: run {
logger.warn("CommitWorkflowHandler가 null입니다")
return
}
val diff = diffProvider.getDiff(commitWorkflowHandler)
if (diff.isNotEmpty()) {
logger.info("diff에서 추출한 변경내역: $diff")
}
}
}
이곳은 시스템 전반의 흐름을 조정하는 엔트리포인트의 역할을 수행한다.
"변경 내역을 가져온다"라는 책임은 "CommitWorkFlowHandler를 가져온다."라는 책임으로 약간 변경되었다.
이 커밋 핸들러는 그대로 DiffProvider에 넘어가서 Diff를 추출할 때 쓰인다.
interface CommitWorkflowHandlerProvider {
fun getCommitWorkflowHandler(e: AnActionEvent): AbstractCommitWorkflowHandler<*, *>?
}
CommitWorkFlowHandler를 가져오기 위한 객체를 Provider로 선언해주었다. 이를 통해 Diff를 추출하는 책임과 커밋 핸들러를 가져오는 책임을 분리해주고자 하였다.
class CommitWorkflowHandlerProviderImpl : CommitWorkflowHandlerProvider {
override fun getCommitWorkflowHandler(e: AnActionEvent): AbstractCommitWorkflowHandler<*, *>? {
return e.getData(VcsDataKeys.COMMIT_WORKFLOW_HANDLER) as? AbstractCommitWorkflowHandler<*, *>
}
}
실제 구현체로, e.getData를 통해서 실제 핸들러를 가져온다. AbstractCommitWorkflowHandler를 가져오는 이유는 이곳에 설명해두었다.
interface DiffProvider {
fun getDiff(commitWorkflowHandler: AbstractCommitWorkflowHandler<*, *>): String
}
AbstractCommitWorkflowHandler를 인자로 받아서 Diff를 추출하는 책임을 수행하는 객체이다.
class DiffProviderImpl : DiffProvider {
private val logger = Logger.getInstance(DiffProviderImpl::class.java)
override fun getDiff(commitWorkflowHandler: AbstractCommitWorkflowHandler<*, *>): String {
val includedChanges: List<Change> = commitWorkflowHandler.ui.getIncludedChanges()
return if (includedChanges.isEmpty()) {
logger.info("No changes selected")
""
} else {
buildString {
includedChanges.forEach { change ->
val diff = DiffUtils.generateDiff(change)
append(diff)
}
}
}
}
}
DiffProvider에 대한 구현체이다. 이 곳에선 커밋 핸들러에 접근하여 변경내역을 추출한 후에, 적절히 포맷팅을 수행하는 동작까지를 수행한다.
변경내역을 비교하여 Diff를 생성하는 코드는 DiffUtils에서 수행한다.
object DiffUtils {
fun generateDiff(change: Change): String {
val beforeContent = getContent(change.beforeRevision)
val afterContent = getContent(change.afterRevision)
return buildString {
appendLine("--- Before")
appendLine("+++ After")
val beforeLines = beforeContent.split("\n")
val afterLines = afterContent.split("\n")
for (i in 0 until maxOf(beforeLines.size, afterLines.size)) {
when {
i >= beforeLines.size -> appendLine("+ ${afterLines[i]}")
i >= afterLines.size -> appendLine("- ${beforeLines[i]}")
beforeLines[i] != afterLines[i] -> {
appendLine("- ${beforeLines[i]}")
appendLine("+ ${afterLines[i]}")
}
else -> appendLine(" ${beforeLines[i]}")
}
}
}
}
private fun getContent(revision: ContentRevision?): String {
return revision?.content ?: ""
}
}
간단하게 구현된 Diff를 추출하는 코드이다. Change객체를 통해 이전 내용과 이후 내용을 직접 라인별로 비교하는 동작을 수행한다.
일단, 언뜻 보기엔 잘 이루어진 것 처럼 보인다.
Action클래스 한 곳에서 수행되던 변경내역 가져오기와 Diff 추출이라는 두 책임이 잘 분배되었으며, 생성자 주입 및 캡슐화를 통해서 확장성을 챙겼다.
하지만, 다소 애매한 부분이 많이 보인다.
예를 들어서 변경내역을 가져오는 책임이 커밋 핸들러를 가져오는 책임으로 축소되었다던가, 혹은 유틸 클래스에서 너무 복잡한 로직을 다룬다던가 하는 것들이 적절해보이지 않는다.
지금부터 현재의 리팩토링에 대해 SDK에서의 측면과 객체지향에서의 측면을 모두 살펴보면서 문제점을 살펴보자.
우리는 이전에 서비스 객체를 단순히 코틀린 코드로만 구현하였고, 그외에 어떤 동작도 추가하지 않았다.
class CommitMessageService(
private val commitWorkflowHandlerProvider: CommitWorkflowHandlerProvider,
private val diffProvider: DiffProvider
) {
private val logger = Logger.getInstance(CommitMessageService::class.java)
fun generateCommitMessage(e: AnActionEvent) {
val commitWorkflowHandler = commitWorkflowHandlerProvider.getCommitWorkflowHandler(e) ?: run {
logger.warn("CommitWorkflowHandler가 null입니다")
return
}
val diff = diffProvider.getDiff(commitWorkflowHandler)
if (diff.isNotEmpty()) {
logger.info("diff에서 추출한 변경내역: $diff")
}
}
}
하지만, 인텔리제이 플랫폼에서는 서비스 객체를 등록하는 특별한 메커니즘이 존재한다. 서비스를 플랫폼에 등록함으로써 생명주기가 적절히 관리되며, 필요한 곳에서 효율적으로 서비스를 획득하여 사용할 수 있다.
서비스가 인텔리제이 플랫폼 내부의 객체로써 동작하게 만드려면 두 가지 방법 중 하나를 선택하여야 한다.
인텔리제이 플랫폼에서 서비스를 등록하는 방법
- plugin.xml에 등록하기
- 클래스에
@Service어노테이션 붙이기
CommitMessageService와 그 하위의 구현체들은 결국 위 방법 중 하나의 방법을 선택해서 인텔리제이 플랫폼에서 서비스로 인식할 수 있도록 만들어주어야 한다.
위 두 방법은 상황에 맞게, 그리고 객체의 특성에 맞게 자율적으로 선택하면 된다.
1번 방법은 서비스가 여러 구현체로 확장될 수 있는 경우에 사용할 수 있다. 위와 같이 인터페이스와 그에 대한 구현체를 plugin.xml에 등록하여 사용할 수 있다.
2번 방법은 1번의 상황이 아닌 경우, 즉 단일 구현체로 남을 가능성이 높은 서비스들에 대해 적용할 수 있다. 그리고 이러한 서비스들을 인텔리제이 플랫폼에선 Light Services라고 부르고 있다.
1번과 2번 중 어느 방법을 선택할지는 절대적인 기준이 없다. 서비스 객체의 특성, 프로젝트의 요구사항, 그리고 향후 확장 가능성 등을 종합적으로 고려하여 상황에 맞는 선택을 하면 된다.
공식 문서에서는 서비스 인스턴스를 미리 클래스의 필드로 저장하는 것을 피하고, 항상 필요한 위치에서만 직접 얻어서 사용하라고 경고하고 있다.
불필요한 메모리 점유 및 여러 예외 상황을 방지하기 위해서 이러한 전략을 채택하고 있는 것으로 보인다.
서비스는 다음과 같은 방법으로 필요한 시점에 획득하여 사용할 수 있다.
//어플리케이션 수준의 서비스
val applicationService = service<MyAppService>()
//프로젝트 수준의 서비스
val projectService = project.service<MyProjectService>()
인텔리제이 플랫폼에선 생성자 주입 역시 권장하고 있지 않다.
특히, Light Services에선 생성자 주입을 아예 지원하지 않고 있다.
만약 무턱대고 쓴다면, 서비스 객체의 생명주기가 플랫폼에 의해 제대로 관리되지 않아, 예기치 않은 동작이나 메모리 누수와 같은 문제가 발생할 수 있다.
우리가 수정해야 될 부분은 아래의 두 가지 이다.
- 서비스 객체 등록하기
- 서비스를 올바른 위치에서 가져와서 사용하기
스프링과 같은 프레임워크에서 흔히 사용되는 생성자 주입 방식을 무분별하게 적용하기보다는, IntelliJ Platform의 특성을 충분히 이해하고 그에 맞는 설계를 하는 것이 중요하다는 것을 느꼈다.
interface CommitWorkflowHandlerProvider {
fun getCommitWorkflowHandler(e: AnActionEvent): AbstractCommitWorkflowHandler<*, *>?
}
이 인터페이스는 AnActionEvent와 AbstractCommitWorkflowHandler라는 매우 구체적인 타입에 강하게 결합되어 있다. 이로 인해 이 인터페이스를 사용하여 다양한 구현체를 만드는 것은 사실상 어렵다.
실제로 AnActionEvent를 파라미터로 받아 AbstractCommitWorkflowHandler를 반환하는 다른 구현체는 현실적으로 존재하지 않을 가능성이 높다.
이러한 특수한 타입에 대한 의존성은 인터페이스의 재사용성을 크게 제한하며, 테스트와 유지보수를 어렵게 만들 수 있다.
따라서, 현재의 인터페이스는 객체의 다형성을 효과적으로 활용하기 하기 보다는 복잡도를 증가시키는 설계이다
일반적으로 구현이 변경되는 경우는 주로 요구사항이 변경되거나, 혹은 기존보다 더 좋은 성능의 구현으로 대체할 수 있을 때 발생한다.
이러한 관점에서 봤을 때, CommitWorkflowHandlerProvider는 변경될 가능성이 낮다. 인텔리제이 플랫폼의 API가 바뀌지 않는 이상, getData()를 통해 커밋 핸들러를 가져오는 현재의 메커니즘은 유지될 것이기 때문이다.
결론적으로, CommitWorkflowHandlerProvider에 대해서 인터페이스를 쓰는 것은 불필요한 결정이라고 생각한다.
근본적인 문제는 객체의 설계에 있을 수 있다.
이 문제는 아래에서 살펴보자.
위의 설계와 달리 리팩토링을 했음에도 Commit 도메인을 구현하는 구현체가 명확하게 드러나지 않는다.
새로 생긴 CommitWorkflowHandlerProvider는 단순히 커밋 핸들러를 제공하는 역할만을 수행한다.
그렇다면 "체크된 항목의 변경 내역을 가져오는 책임"은 어디로 갔을까?
class DiffProviderImpl : DiffProvider {
private val logger = Logger.getInstance(DiffProviderImpl::class.java)
override fun getDiff(commitWorkflowHandler: AbstractCommitWorkflowHandler<*, *>): String {
// 이 위치에서 변경내역을 조회하는 것이 올바를까?
val includedChanges: List<Change> = commitWorkflowHandler.ui.getIncludedChanges()
return if (includedChanges.isEmpty()) {
logger.info("No changes selected")
""
} else {
buildString {
includedChanges.forEach { change ->
val diff = DiffUtils.generateDiff(change)
append(diff)
}
}
}
}
}
DiffProvider의 구현체인 이 클래스는 본래 변경내역으로부터 Diff를 추출하는 책임만을 수행해야 한다.
그러나 현재는 커밋 핸들러를 통한 변경내역 조회까지 수행하고 있어, 단일 책임 원칙(SRP)을 위반하고 있다. 이는 클래스의 응집도를 낮추고 유지보수를 어렵게 만들 수 있다.
//너무 작은 책임이 할당되었다.
interface CommitWorkflowHandlerProvider {
fun getCommitWorkflowHandler(e: AnActionEvent): AbstractCommitWorkflowHandler<*, *>?
}
//Diff를 생성할 때 핸들러 전체가 필요할까?
interface DiffProvider {
fun getDiff(commitWorkflowHandler: AbstractCommitWorkflowHandler<*, *>): String
}
여기서 CommitWorkflowHandlerProvider와 DiffProvider를 다시 살펴보자.
CommitWorkflowHandlerProvider 인터페이스는 커밋 핸들러를 제공하는 역할만을 수행하고 있으며, 이 커밋 핸들러는 그대로 DiffProvider에 전달된다.
이는 DiffProvider가 커밋 핸들러를 통한 변경내역 조회까지 수행해야 한다는 것을 암시한다. 그러나 Diff를 추출하기 위해 필요한 것은 Change 객체들이지 커밋 핸들러가 아니다.
즉, 현재의 커밋 핸들러를 파라미터로 사용하는 설계는 책임의 경계를 모호하게 만들고 있으며, 이는 각 객체의 역할과 책임을 불명확하게 한다.
처음 코드를 접했을 때, getData()를 통해 커밋 핸들러를 가져오는 동작이 다소 길고 복잡하게 느껴졌다.
이것을 해소하기 위해 해당 로직을 provider라는 객체 뒤로 추상화를 하는 것에만 집중하였고, 이 과정에서 Commit이라는 도메인 개념이 맡고 있던 책임이 여기저기 분산되어 버린 것이다.
우리가 해결할 문제는 "아래 코드를 어느 객체에서 수행할 것이냐?"이다.
val includedChanges = commitWorkflowHandler.ui.getIncludedChanges()
그리고 우리가 선택할 수 있는 사안은 아래의 3가지이다.
DiffProvider에서 수행하기
-> 현행 유지
- Commit 도메인의 책임을 수행하는 새로운 객체 만들기
-> 현재의 객체들은 유지하고, 새로운 객체에서 수행하도록
- 기존의
CommitWorkflowHandlerProvider를 확장해서 커밋 핸들러 조회와
그를 통해 변경내역 조회를 같이 수행하는 서비스 객체 만들기
1번은 DiffProvider가 두 개의 책임을 갖기 때문에 적절치 않다.
결정적으로, 테스트를 짤 때 복잡한 핸들러 객체까지 고려해야 하는 골치아픈 상황이 생길 것이다.
2번은 언뜻 보기엔 솔깃할 수 있으나, 그렇게 할 경우 너무 많은 코드들이 생겨나서 복잡도가 증가할 것이다.
3번이 가장 적절한 방법으로 여겨진다.
커밋 핸들러를 조회하는 책임과 변경내역을 조회하는 책임은 서로 연관되어있기 때문에 같이 관리되는 것이 코드의 응집도를 높이는 해결책이라고 생각한다.
@Service(Service.Level.PROJECT)
class CommitService {
fun getCheckedChanges(e: AnActionEvent): List<Change> {
val handler = getHandler(e) ?: return emptyList()
return handler.ui.getIncludedChanges()
.takeIf { it.isNotEmpty() }
?: throw NoChangesException()
}
private fun getHandler(e: AnActionEvent): AbstractCommitWorkflowHandler<*, *>? {
return e.getData(VcsDataKeys.COMMIT_WORKFLOW_HANDLER) as? AbstractCommitWorkflowHandler<*, *>
}
}
CommitService는 연관성이 높은 두 로직들을 캡슐화하여 코드의 응집도를 높였다.
이러한 리팩토링으로 "Diff를 추출하는 로직"과 "변경내역을 조회하는 로직"이 명확히 분리되었다. 이는 각 컴포넌트에 대한 단위 테스트를 더 쉽고 독립적으로 수행할 수 있게 해준다.
특히 DiffProvider는 이제 CommitService로부터 필요한 데이터만을 받아 처리할 수 있어, 테스트 시 복잡한 CommitWorkflowHandler 객체를 모킹할 필요가 없어졌다.
이 사진은 IntelliJ의 Changes항목을 더블클릭하면 나오는 창이다. 현재 버전과 이전 버전의 차이점(Diff)을 비교하여 시각적으로 보여주고 있다.
인텔리제이에서 제공하는 이 기능과 유사하게, 우리가 개발 중인 플러그인에서도 위 사진과 같이 바뀐 버전과 이전 버전의 내용을 비교하여 차이점을 추출하는 동작이 필요하다.
그렇다면 이 책임은 어느 객체가 가져야 할까?
object DiffUtils {
fun generateDiff(change: Change): String {
val beforeContent = getContent(change.beforeRevision)
val afterContent = getContent(change.afterRevision)
return buildString {
appendLine("--- Before")
appendLine("+++ After")
val beforeLines = beforeContent.split("\n")
val afterLines = afterContent.split("\n")
for (i in 0 until maxOf(beforeLines.size, afterLines.size)) {
when {
i >= beforeLines.size -> appendLine("+ ${afterLines[i]}")
i >= afterLines.size -> appendLine("- ${beforeLines[i]}")
beforeLines[i] != afterLines[i] -> {
appendLine("- ${beforeLines[i]}")
appendLine("+ ${afterLines[i]}")
}
else -> appendLine(" ${beforeLines[i]}")
}
}
}
}
private fun getContent(revision: ContentRevision?): String {
return revision?.content ?: ""
}
}
현재 구현에서는 DiffUtils라는 유틸 클래스에서 이전 내용과 현재 내용을 비교하여 Diff를 생성하는 책임을 수행하고 있다.
유틸 클래스는 일반적으로 상태를 가지지 않고, 범용적으로 사용되는 단순한 로직들을 관리하는 용도로 사용한다. (ex. 문자열 처리)
이 코드의 경우, 어떻게 보면 beforeContent와 afterContent라는 문자열에 대한 비교만을 진행하기 때문에 비지니스 로직보단 유틸 쪽에 가까워보이긴 한다.
하지만, 단순히 유틸성으로만 보기엔 메서드가 복잡하고 범용적이지 않다.
또한, "문자열 비교"라는 행위는 여러 구현방식이 존재할 수 있다.
이러한 특징을 고려하였을 때, 해당 로직은 인스턴스 레벨에서 비지니스 로직으로 관리되는 것이 좀 더 합당해 보인다.
그렇다면 이 비지니스 로직은 어디에 위치하는 것이 적절할까?
이 시점에서 Diff의 개념과 그 책임에 대해 다시 생각해볼 필요가 있다.
도메인 모델 다이어그램을 보면, beforeContent와 afterContent를 비교하는 로직은 Diff 도메인에서 수행하는 것이 적절해 보인다.
그렇다면 DiffProvider에 문자열 비교 로직을 추가하는 것으로 충분할까?
만약 Diff를 단순히 "다른 문장(코드)들의 집합"으로만 정의한다면, 그것이 올바른 해결책일 수도 있다.
문제는 Diff 도메인은 "다른 문장들의 집합"뿐만 아니라 변경 상태, 파일의 경로와 같이 "변경 자체에 대한 메타데이터"도 포함될 수도 있다는 것이다.
따라서 Diff 도메인의 구체적인 범위와 구성 방식에 대해 명확히 정의할 필요가 있다.

여러 조사를 거쳐, 나는 단순히 차이점만 다루던 기존의 Diff 도메인을 확장하여 더 구체적인 내용을 담도록 결정하였다.
우리가 Change로부터 뽑아야 할 정보는 "변경 내용 요약"과 "상세 변경 내용"으로 구체화된 것이다.
또한, 이 부분은 변경의 여지가 많기 때문에 변경 가능성을 고려하여 설계를 진행해야 한다.
직접적인 문자열 비교는 이제 DiffProvider에서 할 필요가 없어졌다.
DiffProvider는 그저 "변경 내용 요약"과 "상세 변경 내용"을 조합하는 역할을 수행하고, 실제 문자열 비교 로직은 "상세 변경 내용"을 생성하는 별도의 객체에서 수행하게 된다.
이렇게 함으로써 책임을 더 명확히 분리하고, 각 객체의 역할을 더 구체화할 수 있다.
결론을 정리하면 다음과 같다.
- Diff 도메인을 단순히 "두 버전 간 다른 문자열들의 집합"으로만 볼 것이 아닌, 변경 전체를 나타내는 모든 데이터의 집합으로 확장하여 생각해야 한다.
- 따라서 Diff 도메인에는 여러 구현체들이 속해 있을 수 있다.
- 문자열 비교 로직은 유틸이 아닌 "상세 변경 내용"을 다루는 객체에서 수행하자.
처음엔 단순히 "문자열 비교가 유틸메서드인가?"라는 의문으로 시작해서 나중엔 도메인 개념의 확장으로 이어졌다.
지금까지 말한 내용을 반영해서 여러 정보를 다루는 Diff 도메인의 구현체 중 일부를 살펴보자.
@Service(Service.Level.PROJECT)
class DiffService(private val project: Project) {
fun getDiff(changes: List<Change>): String {
val summary = project.service<DiffSummaryGenerator>().generate(changes)
val detailedChanges = project.service<DiffDetailGenerator>().generate(changes)
return buildString {
appendLine(DiffSummaryConstants.DEFAULT_TITLE)
appendLine()
appendLine(summary)
appendLine()
appendLine(detailedChanges)
appendLine()
appendLine(DiffSummaryConstants.ADDITIONAL_NOTES)
}.trimEnd()
}
}
Diff를 하나의 통합된 결과물로 보고 "요약"과 "상세 변경 내용"으로 분리하였으며, 각각에 대해선 별도의 구현체가 존재한다.
이러한 분리는 각 부분의 독립적인 변경과 확장을 용이하게 한다.
문자열 비교 로직은 DiffDetailGenerator의 구현체에서 수행하게 될 것이다. 이를 통해 문자열 비교 알고리즘의 변경이나 개선이 필요할 때 다른 부분에 영향을 주지 않고 해당 부분만 수정할 수 있다.
원래 한 포스트에서 변경된 코드까지 전부 작성하려 했으나, 너무 길어졌다.
여기서 다룬 문제점을 개선한 전체 코드는 다음 포스팅에서 다룰 예정이다.
도메인 모델 다이어그램을 그렸던 적은 학교 설계과목 이외엔 잘 없었다가,
이번에 "객체지향의 사실과 오해"를 읽고 개인 프로젝트에도 적용해보았다.
다이어그램만 놓고 봤을 땐 처음으로 그럴듯하게 그려봤다는 생각에 정말 뿌듯했지만, 막상 이것을 기반으로 리팩토링을 진행할 땐 머리에 쥐가 나는 줄 알았다.
특히 도메인 모델과 구현 사이에 왜 차이가 발생했는지에 대해 글로 풀어서 적는것이 정말 쉽지 않았다. (Diff의 구현체가 여러 개인 상황)
다음 글에서는 리팩토링된 코드와 함께 전역 예외처리와 Notification을 다뤄 볼 것이다.
책임할당은 어렵네요.. 객체지향에 대해 다시 공부해봐야겠어요. 잘 읽고 갑니다!