(리팩터링) 일급 컬렉션
📝 작성 배경
예전부터 좋아했던 리팩터링
방식 이기도 하고, 혼자 사이드 프로젝트를 진행하면서 한번쯤은 글로 정리해 보면 좋을것 같아서 글을 작성하게 되었다.
Java
를 사용할 때도 종종 사용하면서 좋은 방식이라고 생각했었는데 Kotlin
의 Extensions
함수와 함께 사용하니 더욱 좋은것 같아 소개해보려 한다.
👓 선 3줄 요약
Kotlin
의Extensions
함수로 서비스 클래스의 변환 로직을 외부로 분리하여 코드를 더 깔끔하게 만들 수 있다.- 일급 컬렉션을 활용하면 관련 비즈니스 로직을 하나의 객체 안에 캡슐화하여 응집도를 높이고 안전하게 관리할 수 있다.
- 두 기법을 함께 사용하면 서비스 클래스의 코드량을 효과적으로 줄이고 각 코드의 책임을 명확히 분리하여 유지보수성을 크게 향상시킬 수 있다.
✨ Information
1️⃣ Extensions 함수
Kotlin
의 Extensions
함수란 기존의 클래스, 인터페이스를 수정하거나 상속( 또는 구현) 하지 않고 새로운 기능을 추가할 수 있는 방법이다. 수정할 수 없는 타사 라이브러리나 인터페이스에도 새로운 기능을 추가할 수 있다.
간단한 예로는
1
2
3
4
5
6
7
8
9
fun String.addHello(): String {
return "Hello, $this!"
}
fun main() {
println("word".addHello())
}
// 출력 결과: Hello, word!
별도 수정 없이 기본적으로 제공되는 클래스에 대해 위와 같이 기능 추가가 가능하다.
매우 편리한 방식이지만, 사용할때 몇가지 주의할점이 있다. 자세한 내용은 별도의 포스팅을 통해 정리할 예정이다.
2️⃣ 일급 컬렉션
일급 컬렉션이란 컬렉션을 하나의 객체로 감싸서 사용하는것을 말한다.
단순히 List
, Set
, Map
같은 걸 그대로 사용하지 않고, 컬렉션 + 관련된 비즈니스 로직
을 하나의 클래스로 묶어 캡슐화 하는 효과가 있다.
간단한 예로는
1
2
3
4
5
6
7
8
9
// 도메인 클래스
data class Order(val item: String, val price: Int)
// 일급 컬렉션
class Orders(val orders: List<Order>) {
fun totalPrice(): Int = orders.sumOf { it.price }
fun count(): Int = orders.size
fun getOrders(): List<Order> = orders.toList()
}
위와 같이 관련된 비즈니스 도메인 컬렉션을 처리해야 하는 로직을 Orders
라는 일급 컬렉션 안에 배치하면서 관련된 로직을 한곳에 모을 수 있고(캡슐화) 컬렉션의 노출 없이 복사본을 사용하도록 하여 안전하게 관리할 수 있게 된다.
♻️ 리팩터링
내가 일급 컬렉션을 사용하는 주된 방식은 Service
클래스의 코드 양을 줄이고, 관련된 로직을 한곳에서 관리 할 수 있도록
만들때 사용한다.
아래의 예시는 혼자 사이드 프로젝트를 진행하면서 일급 컬렉션을 사용하여 Service
클래스 코드 양을 줄이고 관련된 로직을 한곳으로 모아두었던 사례이다.
💢 Before
아래의 코드는 게시판 관련 여러 로직들 중 모든 게시판을 조회 하는 게시판 서비스 클래스의 일부분이다. 해당 메서드는 게시판을 모두 조회하여, 게시판에 있는 게시물 중 특정 조건에 만족하는 게시글을 간략하게 보여주기 위해 만든 로직이다.
간단하게 해당 로직의 순서를 설명하면 아래와 같다.
- 모든 게시판 조회 하여, 게시판 아이디와 게시판을 키와 값으로 갖는
Map
컬렉션 생성 - 게시판이 하나도 존재 하지 않으면 빈 리스트 반환
- 게시판이 있으면 게시판에 있는 게시글을 특정 조건에 따라 조회
- 게시판 아이디를 키, 게시글 정보를 갖는 도메인 객체를 값으로 갖는
Map
컬렉션 생성 - 해당
Map
컬렉션에 있는 정보를 바탕으로 응답 객체 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Service
internal class BoardService(
...
) : ... {
override fun readAll(
request: ReadBoardUseCase.ReadAllRequest
): List<ReadAllResponse> {
val boardsById: Map<Long, Board> = readBoardPort.getBoards().associateBy { it.id }
if (boardsById.isEmpty()) {
return ReadBoardUseCase.ReadAllResponses(emptyList())
}
val boardIds: List<Long> = boardsById.keys.toList()
val articles: List<BoardArticle> =
readArticlePort.getArticlesBy(
ids = boardIds,
limit = request.limit,
sort = request.sort,
orderBy = request.orderBy
)
val articlesByBoardId: Map<Long, List<BoardArticle>> =
articles.groupBy { it.boardId }
val responses: List<ReadAllResponse> =
boardsById.map { (boardId: Long, board: Board) ->
ReadAllResponse(
name = board.name,
description = board.description,
visibility = board.visibility.name,
articles = articlesByBoardId[boardId] ?: emptyList()
)
}
return responses
}
}
만약 서비스 클래스 내부에 이런 메서드가 여러개가 있을 경우를 상상 해보면 많이 복잡할것 같다는 느낌을 쉽게 얻을 수
있을것이다. 그럼 어떻게 위 코드를 좀 더 보기 좋게 변경할 수 있을까?
가장 먼저 생각나는 방식은 readAll()
메서드의 로직을 분리하는 것이다. 여기서 발생할 수 있는 문제는 BoardService
클래스에 readAll()
메서드만 있는게 아니라는 점이다. 메서드를 분리 하더라도 결국 서비스 클래스에는 여러 로직들이 들어 있을 텐데 이걸 기능별로 메서드를 나누게 된다면 Service
클래스 하나가 갖는 메서드의 수가 많아지게 되고, 코드의 양이 많아져 결국 유지보수가 어려운 클래스가 되고 만다.
8️⃣0️⃣ After(Extensions)
이제 위 코드를 리팩터링 해보자. 우선은 가장 문제가 되는 변환 로직을 Extensions
함수로 빼보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
internal fun List<BoardArticle>.toDto(
boardsById: Map<Long, Board>
): List<ReadAllResponse> {
val articlesByBoardId: Map<Long, List<BoardArticle>> =
this.groupBy { it.boardId }
val responses: List<ReadAllResponse> =
boardsById.map { (boardId: Long, board: Board) ->
ReadAllResponse(
name = board.name,
description = board.description,
visibility = board.visibility.name,
articles = articlesByBoardId[boardId] ?: emptyList()
)
}
return response
List<BoardArticle>
컬렉션을 응답 객체로 변환하는 부분을 Extensions
함수로 구현한 부분이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Service
internal class BoardService(
...
) : ... {
override fun readAll(
request: ReadBoardUseCase.ReadAllRequest
): List<ReadAllResponse> {
val boardsById: Map<Long, Board> = readBoardPort.getBoards().associateBy { it.id }
if (boardsById.isEmpty()) {
return ReadBoardUseCase.ReadAllResponses(emptyList())
}
val boardIds: List<Long> = boardsById.keys.toList()
val articles: List<BoardArticle> =
readArticlePort.getArticlesBy(
ids = boardIds,
limit = request.limit,
sort = request.sort,
orderBy = request.orderBy
)
return articles.toDto(boardsById)
}
}
List<BoardArticle>
에 추가한 Extensions
함수를 활용하도록 변경된 코드이다. 한눈에 봐도 코드의 양이 많이 줄어든걸 확인할 수 있다. 이제 BoardService
클래스에서는 어떻게 변환이 일어나는지 알 필요 없이 toDto()
라는 Extensions
함수를 호출 하기만 하면 된다. 그렇다면 여기서 끝일까? 만약 Extensions
함수가 계속해서 늘어난다면 어떻게 될까? 결국 Service
클래스에서 문제가 되었던 “하나의 클래스 또는 파일에 코드의 양이 너무 많아진다는” 문제는 결국 다시 발생할 수 밖에 없다.
Extensions
함수만 사용하더라도 충분히 좋은 리팩터링이 될 수 있지만, 일급 컬렉션을 추가한다면 더욱 좋은 리펙터링이 될 수 있다.
1️⃣0️⃣0️⃣ After(Extensions + 일급 컬랙션)
1
2
3
4
5
6
data class ReadAllResponse(
val name: String,
val description: String,
val visibility: String,
val articles: List<BoardArticle>
)
이게 현재 사용중인 응답 전용 객체이다. 여기에 일급 컬렉션을 추가하고 해당 일급 컬렉션이 변환 로직을 갖도록 수정하면 어떻게 될까?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
data class ReadAllResponses(
val readAllResponses: List<ReadAllResponse>
) {
companion object {
fun create(
boardArticles: List<BoardArticle>,
boardsById: Map<Long, Board>
): ReadAllResponses {
val articlesByBoardId: Map<Long, List<BoardArticle>> =
boardArticles.groupBy { it.boardId }
val responses: List<ReadAllResponse> =
boardsById.map { (boardId: Long, board: Board) ->
ReadAllResponse(
name = board.name,
description = board.description,
visibility = board.visibility.name,
articles = articlesByBoardId[boardId] ?: emptyList()
)
}
return ReadAllResponses(responses)
}
fun createEmptyResponse(): ReadAllResponses = ReadAllResponses(emptyList())
}
}
data class ReadAllResponse(
val name: String,
val description: String,
val visibility: String,
val articles: List<BoardArticle>
)
일급 컬렉션을 추가하고, 해당 일급 컬렉션에서 변환 로직을 갖도록 수정했다. 서비스 클래스 와 Extensions
함수의 코드도 살펴보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
internal class BoardService(
...
) : ... {
override fun readAll(
request: ReadBoardUseCase.ReadAllRequest
): ReadBoardUseCase.ReadAllResponses {
val boardsById: Map<Long, Board> = readBoardPort.getBoards().associateBy { it.id }
if (boardsById.isEmpty()) {
return ReadBoardUseCase.ReadAllResponses.createEmptyResponse()
}
val boardIds: List<Long> = boardsById.keys.toList()
val articles: List<BoardArticle> =
readArticlePort.getArticlesBy(
ids = boardIds,
limit = request.limit,
sort = request.sort,
orderBy = request.orderBy
)
return articles.toDto(boardsById)
}
서비스 클래스의 경우는 Extensions
함수를 추가 했을때와 크게 달라진점은 없다.
1
2
3
4
5
6
7
internal fun List<BoardArticle>.toDto(
boardsById: Map<Long, Board>
): ReadBoardUseCase.ReadAllResponses =
ReadBoardUseCase.ReadAllResponses.create(
boardArticles = this,
boardsById = boardsById
)
Extensions
함수의 경우는 코드의 양이 매우 줄어든걸 확인할 수 있다.
이제 여러 Extensions
함수가 추가되어도 문제가 없을듯 하다.
위와 같이 일급 컬렉션을 도입하고, 해당 일급 컬렉션에 변환 로직을 둠으로 써 서비스 클래스와 Extensions
함수에 새로운 기능이 추가되어도 코드의 양이 기하급수적으로 늘어나는걸 방지 할 수 있게 되었다.
위 방식을 적용하게 되면 변환 로직 뿐만 아니라 다른 비즈니스 로직이 추가되어도 서비스 클래스의 코드 양을 적절하게 유지시킬 수 있을것이다.
💡 마무리
오늘은 Extension
함수와 일급 컬렉션
을 조합해 Service 클래스의 복잡도를 낮추는 리팩터링 방법을 정리해봤다.
이 방법이 모든 경우에 정답은 아닐 수 있다. 하지만 “Service 클래스가 점점 커져서 고민”이라면, 꼭 한 번 도입을 고려해볼 만하다.
필자 역시 이 방식을 통해 사이드 프로젝트의 코드 가독성과 유지보수성을 눈에 띄게 높일 수 있었다. 당장 전체를 바꿀 필요는 없지만, 일부 중요한 로직부터 적용해보면서 효과를 체감해보는 것도 좋은 방법이 될 것이다.