글을 작성하게 된 계기
최근 일급 컬렉션을 잘못 사용한 사례 가 있었고, 일급 컬렉션이 도메인적으로 어떤 의미를 가지는지 에 대한 생각을 하는 과정에서 글을 작성하게 되었습니다.
1. 어떤 실수를 했을까?
다음과 같이 같은 가맹점 정보를 관리하는 Merchants 클래스가 있다고 가정해보겠습니다. 이 클래스는 내부에 가맹점 리스트를 가지고 있으며, 가맹점의 심사 기간을 업데이트하는 메서드가 존재합니다.
1
2
3
4
5
6
7
8
9
10
class Merchants(
private val merchants: List<Merchant>,
) {
fun updateApplyPeriod(
applyStartDate: LocalDate,
applyEndDate: LocalDate,
) {
merchants.forEach { it.updateApplyPeriod(applyStartDate, applyEndDate) }
}
}
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
37
38
39
@Entity
class MerchantJpaEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long,
@Column(name = "apply_start_date")
private var _applyStartDate: LocalDate,
@Column(name = "apply_end_date")
private var _applyEndDate: LocalDate,
@Column(name = "group_id")
val groupId: Long,
) : BaseEntity() {
val applyStartDate: LocalDate
get() = _applyStartDate
val applyEndDate: LocalDate
get() = _applyEndDate
fun updateApplyPeriod(
applyStartDate: LocalDate,
applyEndDate: LocalDate,
) {
if (!isApplyPeriod()) {
throw IllegalStateException("Cannot update apply period during the application period.")
}
this._applyStartDate = applyStartDate
this._applyEndDate = applyEndDate
}
private fun isApplyPeriod(): Boolean {
val today = LocalDate.now()
return today.isAfter(applyStartDate)
&& today.isBefore(applyEndDate)
}
}
일급 컬렉션 내부에 update 메서드를 둔 이유는 서비스 레이어에서 반복문 사용 때문에 코드가 지저분해졌기 때문입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
class MerchantService(
private val merchantRepository: MerchantRepository,
) {
@Transactional
fun updateApplyPeriod(
groupId: Long,
applyStartDate: LocalDate,
applyEndDate: LocalDate,
) {
val merchants: List<MerchantJpaEntity> = merchantRepository.findByGroupId(groupId)
for(merchant in merchants) {
merchant.updateApplyPeriod(applyStartDate, applyEndDate)
}
}
}
위 코드를 일급 컬렉션을 사용해 고치면 코드 한 줄로 간단히 처리할 수 있죠. 그런데 문득 일급 컬렉션의 정의와 특징에 대해 생각하다보니 불변성을 보장하지 못하는 것 같았습니다. Merchants 클래스에서 가맹점 리스트를 변경할 수 있는 메서드가 존재하기 때문에요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
class MerchantService(
private val merchantRepository: MerchantRepository,
) {
@Transactional
fun updateApplyPeriod(
groupId: Long,
applyStartDate: LocalDate,
applyEndDate: LocalDate,
) {
val merchants: Merchants = merchantRepository.findByGroupId(groupId)
merchants.forEach { it.updateApplyPeriod(applyStartDate, applyEndDate) }
}
}
2. 어떻게 코드를 수정해야 할까?
위에서 작성한 코드가 불변성을 어기고 있다고 했는데요, 이를 바르게 고쳐보겠습니다. 사실 정답은 위에서 이미 나왔는데요, 아래와 같이 반복문을 사용하면 올바른 코드가 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
class MerchantService(
private val merchantRepository: MerchantRepository,
) {
@Transactional
fun updateApplyPeriod(
groupId: Long,
applyStartDate: LocalDate,
applyEndDate: LocalDate,
) {
val merchants: List<MerchantJpaEntity> = merchantRepository.findByGroupId(groupId)
for(merchant in merchants) {
merchant.updateApplyPeriod(applyStartDate, applyEndDate)
}
}
}
방어적 복사를 사용하면 다음과 같이 코드를 작성할 수도 있죠. 방법 자체는 간단하지만, 왜 이렇게 작성해야 할까요? 즉, 왜 일급 컬렉션은 불변 이어야 할까요?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Merchants(
private val merchants: List<MerchantJpaEntity>
) {
init {
this.merchants = merchants.toList()
}
fun withUpdatedApplyPeriod(
applyStartDate: LocalDate,
applyEndDate: LocalDate
): Merchants {
val updatedMerchants = merchants.map {
it.copy().apply {
updateApplyPeriod(applyStartDate, applyEndDate)
}
}
return Merchants(updatedMerchants)
}
fun asList(): List<MerchantJpaEntity> = merchants.toList()
}
3. 도메인적으로 어떤 의미를 가질까?
일급 컬렉션이 주는 이점은 다양한데요, 그 중에서도 일급 컬렉션이 도메인에서 어떤 의미를 가지는지 에 대해 한 번 살펴보겠습니다.
- 도메인적인 의미와 역할을 함께 표현하는 객체
- 도메인 규칙 정의
3-1. 도메인적인 의미와 역할을 함께 표현하는 객체
일급 컬렉션은 단순히 여러 개의 값을 하나로 묶는 자료구조의 개념을 넘어, 해당 컬렉션이 갖는 도메인적인 의미와 역할을 함께 표현하는 객체 입니다. 예를 들어, Merchants라는 일급 컬렉션은 단순히 가맹점 여러 개를 리스트로 담고 있는 것이 아니라, 특정 조건을 만족하는 가맹점들의 집합, 즉 같은 그룹에 속해 있고 동일한 심사 기간이 적용되는 가맹점들의 묶음이라는 도메인 개념을 포함한다고 가정해보겠습니다.
1
2
3
class Merchants(
private val merchants: List<MerchantJpaEntity>
) { ...... }
이처럼 어떤 의미를 담은 객체 를 외부에서 임의로 수정할 수 있게 되면 객체가 나타내야 할 의미나 규칙이 무너질 수 있습니다. 즉, Merchants 객체 내부 리스트가 외부에서 직접 접근 가능하고 자유롭게 수정될 수 있다면, 검증되지 않은 가맹점이 리스트에 추가되거나, 의도치 않게 모든 가맹점이 삭제되는 등의 일이 발생할 수 있습니다. 이렇게 되면 심사 대상 가맹점 목록이라는 의미는 깨지고, 이는 단순한 데이터 오염 문제가 아니라 비즈니스 규칙 위반 으로까지 이어질 수 있습니다.
1
2
3
4
5
6
7
8
9
10
class Merchants(
private val merchants: List<Merchant>,
) {
fun updateApplyPeriod(
applyStartDate: LocalDate,
applyEndDate: LocalDate,
) {
merchants.forEach { it.updateApplyPeriod(applyStartDate, applyEndDate) }
}
}
3-2. 도메인 규칙 정의
도메인 모델에서 중요한 것은 객체가 언제나 의미 있는 상태를 유지 해야 한다는 점입니다. 예를 들어, Merchants라는 일급 컬렉션은 단순한 가맹점 리스트가 아닌, 동일한 그룹에 속한 가맹점 목록 이라는 도메인 개념을 표현합니다. 이 안에 엉뚱한 그룹의 가맹점이 섞이면 더 이상 그 객체는 의미를 가지지 못하게 됩니다. 따라서 이 컬렉션은 외부에서 직접 수정할 수 없고, 오직 도메인 규칙을 거친 변경만 허용 되어야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
class MerchantService(
private val merchantRepository: MerchantRepository,
) {
@Transactional
fun updateApplyPeriod(
groupId: Long,
applyStartDate: LocalDate,
applyEndDate: LocalDate,
) {
// 동일 groupId로 조회한 가맹점들 조회
val merchants: Merchants = merchantRepository.findByGroupId(groupId)
merchants.forEach { it.updateApplyPeriod(applyStartDate, applyEndDate) }
}
}
1
2
3
4
5
6
7
8
9
10
class Merchants(
private val merchants: List<Merchant>,
) {
fun add(merchant: Merchant): Merchants {
if (!merchant.isValidForGroup(this)) {
throw InvalidMerchantException()
}
return Merchants(this.merchants + merchant)
}
}
이를 위해 불변성이 필요합니다. 외부에서 내부 컬렉션을 직접 수정할 수 없게 만들고, 모든 변경은 새로운 객체를 생성하는 방식으로 이루어지게 하면, 객체의 생성과 변경을 도메인 메서드나 팩토리 메서드로만 제한할 수 있습니다. 이때, 그 메서드 내부에서 반드시 규칙을 검증하게 하면, 규칙을 위반하는 상태 자체가 만들어지지 않도록 할 수 있습니다.
중요한 점은 단순히 새로운 객체를 만드는 것 자체가 규칙을 지켜준다는 것이 아닌, 어떻게 만들 수 있도록 설계되어 있는가, 즉 생성과 변경의 통로가 도메인 규칙을 통과하도록 구조적으로 제한되어 있어야 의미가 있다는 것입니다.
또한, 모든 변경을 도메인 규칙을 통해 통제 할 수 있습니다. 불변 컬렉션으로 설계하면 외부에서는 컬렉션을 직접 수정할 수 없기 때문에, 모든 변경은 도메인 로직을 거쳐야 만 합니다. 즉, 비즈니스 규칙에 어긋나는 방식으로 상태가 바뀌는 일이 원천적으로 차단되는 것이죠.
- 컬렉션 자체가 도메인 개념을 표현하므로 코드의 의도를 명확히 전달할 수 있습니다.
- 외부에서 직접 컬렉션을 수정할 수 없으므로, 도메인 규칙에 위배되는 상태 변경을 방지할 수 있습니다.
- 모든 변경은 도메인 로직을 통해 통제되므로, 시스템 전반의 일관성과 안정성을 유지할 수 있습니다.
4. 정리
일급 컬렉션을 가변으로 사용했던 실수에서 출발해, 도메인적으로 어떤 의미를 가져야 하는지까지 고민해보게 되었습니다. 단지 코드가 깔끔해진다는 이유로 핵심 개념을 위반했는데, 결국 일급 컬렉션은 단순한 값의 모음이 아닌, 도메인의 의미와 제약을 안전하게 표현하고 유지하기 위한 설계 도구임을 다시금 깨달았습니다.