안녕하세요! 최근 우테코 프리코스 과정을 진행하면서 굉장히 많은걸 배우고 있어요.
이번 2주차 자동차 경주 미션을 진행하면서 다른 분들의 코드를 리뷰할 기회가 있었는데, 한 가지 흥미로운 점을 발견했어요. 바로 핵심 도메인 객체인 Car를 설계하는 방식이 크게 두 가지로 나눠지는 점이에요.
data class를 사용해 불변(immutable) 객체로 구현한 방식- 일반
class에서 내부 상태를 직접 변경하는 가변(mutable) 객체로 구현한 방식 (publicvar또는 백킹 프로퍼티 활용)
저는 테스트 용이성과 코드의 안정성을 높이기 위해 data class를 선택했는데요, 이 주제로 동료들과 토론을 나누다 보니 문득 이런 궁금증이 생겼어요.
"그래서, 정말 성능 차이가 얼마나 날까?"
마침 이번 미션의 핵심 목표 중 하나가 '테스트 도구를 사용하는 방법'을 배우는 것이었기에, 이 궁금증을 직접 벤치마크 테스트를 통해 풀어보기로 했습니다.
두 방식은 이렇게 달랐어요
성능을 비교하기 위해, 자동차의 위치(position)를 업데이트하는 두 가지 방식의 Car 객체를 준비했어요.
1. 불변 객체
data class로 Car를 정의하고, move()가 호출될 때마다 position이 1 증가한 새로운 Car 객체를 copy()를 통해 반환하는 방식이에요.
private data class ImmutableCar(val name: String, val position: Int = 0) {
fun move(): ImmutableCar = this.copy(position = position + 1)
}
2. 가변 객체
일반 class로 Car를 정의하고, position을 var 프로퍼티로 가져요. move() 메소드는 내부 position의 값을 직접 1 증가시키는 방식입니다.
private class MutableCar(val name: String, var position: Int = 0) {
fun move() {
this.position += 1
}
}
그래서 직접 테스트 해보자
두 방식의 성능 차이를 명확히 확인하기 위해, 10,000대의 자동차가 10,000번 움직이는 상황을 시뮬레이션했어요. 즉, 총 1억 번의 move 연산이 발생하는 시나리오입니다 🤔
- 불변 객체 테스트: 매 라운드마다
map연산을 통해 새로운 자동차 리스트를 생성 - 가변 객체 테스트:
forEach로 각 자동차 객체의move()메소드를 호출하여 내부 상태를 변경
아래는 벤치마크에 사용된 전체 테스트 코드예요.
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import kotlin.system.measureTimeMillis
class PerformanceTest {
private val carCount = 10000
private val roundCount = 10000
// 1. 불변 객체 (data class)
private data class ImmutableCar(val name: String, val position: Int = 0) {
fun move(): ImmutableCar = this.copy(position = position + 1)
}
// 2. 가변 객체 (class)
private class MutableCar(val name: String, var position: Int = 0) {
fun move() {
this.position += 1
}
}
@Test
@DisplayName("data class와 class의 객체 업데이트 성능 비교")
fun `comparePerformance_ImmutableVsMutable`() {
// 불변 객체 테스트
var immutableCars = (1..carCount).map { ImmutableCar("car$it") }
val immutableTime = measureTimeMillis {
repeat(roundCount) {
immutableCars = immutableCars.map { it.move() }
}
}
println("--- 성능 테스트 결과 ---")
println("Immutable (data class): $immutableTime ms")
// 가변 객체 테스트
val mutableCars = (1..carCount).map { MutableCar("car$it") }
val mutableTime = measureTimeMillis {
repeat(roundCount) {
mutableCars.forEach { it.move() }
}
}
println("Mutable (class): $mutableTime ms")
println("----------------------")
}
}
테스트 결과는?
결과는 예상대로였지만, 수치는 생각보다 더 극적이었어요. 가변 객체를 사용한 방식이 훨씬 빨랐습니다.
- Immutable (
data class): 662 ms - Mutable (
class): 274 ms
내부 상태를 직접 변경하는 방식이 새로운 객체를 계속 생성하는 방식보다 약 2.4배 더 빠른 성능을 보여주었어요. 이 결과는 객체 생성 및 가비지 컬렉션(GC) 오버헤드가 실제로 성능에 유의미한 영향을 미친다는 것을 명확하게 보여줘요.
그렇다면 data class는 항상 나쁜 선택일까요?
저는 그렇지 않다고 생각해요. 이 벤치마크는 성능이라는 극단적 환경에서 검증한 내용이기 때문인데요!
저는 오히려 불변성이 주는 이점에 관점을 맞췄어요.
먼저 객체의 상태가 변하지 않으므로, 여러 곳에서 객체를 참조하더라도 Side Effect 걱정 없이 안전하게 사용할 수 있어요.
상태 변경이 항상 새로운 객체 생성을 통해 이루어지므로, 데이터의 흐름을 추적하기 쉽고 버그가 발생했을 때 원인을 찾기 용이해요.
결국 테스트 시 객체의 내부 상태를 확인할 필요 없이, "입력에 따라 기대하는 출력이 나왔는가"만 검증하면 되므로 테스트 코드가 매우 명확하고 간결해져요.
정답은 없지만, 현명한 선택은 있어요
"섣부른 최적화는 모든 악의 근원이다." - 도널드 커누스
class가 더 빠르다는 결과가 나왔지만, 이번 자동차 경주 미션의 요구사항(테스트 용이성, 안정성)과 규모를 고려했을 때 저는 다시 설계를 하더라도 data class를 사용할거에요.
대부분의 애플리케이션에서는 data class가 제공하는 안정성과 테스트 용이성의 이점이 약간의 성능 비용을 충분히 상쇄하고도 남는다고 생각해요.
반면, 게임 엔진, 대용량 데이터 실시간 처리 등 성능이 극도로 중요한 일부 로직에서는 객체 생성 오버헤드를 줄이기 위해 가변 class를 사용하는 것이 합리적인 선택일 수 있어요.
결국 정답은 없다고 생각해요😅 주어진 문제의 성격과 요구사항, 시스템의 제약 조건을 종합적으로 고려하여 상황에 맞는 최적의 도구를 선택하는 것. 그것이 바로 좋은 설계면서 생각하는 개발자가 되는 능력이라고 생각합니다 👋🏻
'외부 활동 > 우아한테크코스 8기' 카테고리의 다른 글
| 우아한테크코스 2주차 회고 '의미 있는 도전을 해보자' (0) | 2025.10.28 |
|---|