개요
- Kotlin은 기본적으로 모든 클래스와 메서드를
final로 설정하여 상속을 제한함 - 이는 Joshua Bloch의 “Effective Java” 원칙을 언어 수준에서 강제한 설계 철학임
- Spring Framework는 CGLIB 프록시를 사용하므로 Kotlin의
final클래스와 충돌이 발생함 - all-open 플러그인과 Interface 기반 설계로 이 문제를 해결할 수 있음
Kotlin의 상속 제한 철학
Effective Java 원칙 채택
- Kotlin은 Joshua Bloch의 “Design and document for inheritance or else prohibit it” 원칙을 기반으로 설계됨
-
이 원칙은 상속이 다음 두 경우 중 하나여야 함을 강조함
- 명시적으로 설계된 상속
- 클래스 작성자가 상속을 고려하여 문서화
- 모든 부작용을 검토하고 테스트
- 상속 금지
- 상속을 고려하지 않은 클래스는 기본적으로 차단
- 의도하지 않은 오버라이드 방지
컴파일러 수준 안전성 보장
- 기본
final설정으로 컴파일러는 다음을 보장함- 특정 메서드 호출이 예상된 구현만 실행됨
- Reflection을 통한 런타임 변경이 차단됨
- 다형성 계약 위반을 사전에 방지함
상속 오용 방지
| 문제점 | 설명 |
|---|---|
| 취약한 기반 클래스 | 기본 클래스 변경이 모든 자식 클래스에 영향을 미침 |
| 깊은 계층 구조 | 3단계 이상 상속은 유지보수가 어려움 |
| 리스코프 치환 원칙 위반 | 자식 클래스가 부모 계약을 위반할 가능성 |
| 의도하지 않은 오버라이드 | 내부 메서드를 실수로 재정의하는 문제 |
Java와의 차이
기본 동작 비교
| 항목 | Java | Kotlin |
|---|---|---|
| 클래스 기본 | open (상속 가능) | final (상속 불가) |
| 메서드 기본 | open | final |
| 상속 허용 | (기본값) | open 키워드 필요 |
| 오버라이드 | (기본값) | open + override |
코드 비교
-
Java 방식
1 2 3 4 5 6 7 8 9 10 11 12
class Animal { public void sound() { System.out.println("Generic sound"); } } class Dog extends Animal { @Override public void sound() { System.out.println("Bark"); } }
-
Kotlin 방식 (잘못된 예)
1 2 3 4 5 6 7 8 9 10 11 12
// 컴파일 에러 발생 class Animal { fun sound() { println("Generic sound") } } class Dog : Animal() { // Error override fun sound() { println("Bark") } }
-
Kotlin 방식 (올바른 예)
1 2 3 4 5 6 7 8 9 10 11
open class Animal { open fun sound() { println("Generic sound") } } class Dog : Animal() { override fun sound() { println("Bark") } }
메서드 오버라이드 제어
- Java
- 오버라이드된 메서드는 자동으로 재오버라이드 가능
1 2 3 4 5 6 7 8 9 10 11 12 13
class Parent { public void method() { } } class Child extends Parent { @Override public void method() { } } class GrandChild extends Child { @Override public void method() { } // 가능 }
- Kotlin
- 명시적 제어 가능
1 2 3 4 5 6 7 8 9 10 11 12
open class Parent { open fun method() { } } open class Child : Parent() { override fun method() { } // 기본적으로 open } // 추가 오버라이드 차단 open class SecureChild : Parent() { final override fun method() { } // 더 이상 재정의 불가 }
Kotlin 상속 규칙 체계
기본 규칙
| 대상 | 기본 상태 | 상속/오버라이드 | 명시 필요 |
|---|---|---|---|
| 일반 클래스 | final | 불가 | open |
| 추상 클래스 | abstract | 필수 | - |
| 인터페이스 | abstract | 필수 | - |
| 메서드 | final | 불가 | open |
| 프로퍼티 | final | 불가 | open |
| Sealed 클래스 | abstract + 제한 | 같은 파일/패키지만 | - |
Sealed Classes 활용
1
2
3
4
5
6
7
8
9
10
11
12
13
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val exception: Exception) : Result()
object Loading : Result()
}
// when 표현식에서 모든 경우 처리 강제
fun handleResult(result: Result) = when (result) {
is Result.Success -> println(result.data)
is Result.Error -> println(result.exception.message)
is Result.Loading -> println("Loading...")
// else 불필요 - 모든 케이스를 컴파일러가 확인
}
Spring과의 통합
문제점
- Spring Boot 2.x 이후 기본 프록시 방식은 CGLIB임
-
CGLIB는 클래스를 상속하여 프록시를 생성하므로
final클래스에서는 작동하지 않음 -
Spring Bean 생성 프로세스

실패 시나리오
1
2
3
4
5
@Service
@Transactional
class UserService(val repository: UserRepository) {
fun saveUser(user: User) = repository.save(user)
}
- 에러 발생
- Cannot proxy final class com.example.UserService
-
JPA Lazy Loading 실패
1 2 3 4 5 6
@Entity class Order( val id: Long, @OneToMany(fetch = FetchType.LAZY) val items: List<OrderItem> )
- 결과
- Lazy loading 무시
- Eager loading 강제 실행
- N+1 쿼리 문제 발생
- 결과
all-open 플러그인
-
build.gradle.kts
1 2 3 4
plugins { kotlin("plugin.spring") version "1.9.x" kotlin("plugin.jpa") version "1.9.x" }
-
동작 원리
1 2 3 4 5 6 7 8 9 10 11 12 13
// 작성 코드 @Service @Transactional class UserService { fun saveUser(user: User) { } } // 컴파일 후 자동 변환 @Service @Transactional open class UserService { open fun saveUser(user: User) { } }
- 장점
- 코드 변경 불필요
- Spring Initializr 기본 설정
- JPA와 자동 통합
- 단점
- 암시적 동작
- 모든 메서드가
open - 의도하지 않은 오버라이드 가능
Interface 기반 설계
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
40
41
42
43
44
45
46
47
48
// 서비스 인터페이스 정의
interface UserService {
fun getUser(id: Long): UserDTO
fun createUser(req: CreateUserRequest): UserDTO
fun updateUser(id: Long, req: UpdateUserRequest): UserDTO
fun deleteUser(id: Long)
}
// 구현체
@Service
class UserServiceImpl(
val repository: UserRepository,
val mapper: UserMapper
) : UserService {
override fun getUser(id: Long): UserDTO =
repository.findById(id)
?.let { mapper.toDTO(it) }
?: throw NotFoundException("User not found: $id")
override fun createUser(req: CreateUserRequest): UserDTO {
val user = User(name = req.name, email = req.email)
return repository.save(user).let { mapper.toDTO(it) }
}
override fun updateUser(id: Long, req: UpdateUserRequest): UserDTO {
val user = repository.findById(id)
?: throw NotFoundException("User not found: $id")
user.apply {
name = req.name
email = req.email
}
return repository.save(user).let { mapper.toDTO(it) }
}
override fun deleteUser(id: Long) {
repository.deleteById(id)
}
}
// Controller
@RestController
@RequestMapping("/api/users")
class UserController(
private val userService: UserService // 인터페이스 의존
) {
@GetMapping("/{id}")
fun getUser(@PathVariable id: Long) = userService.getUser(id)
}
-
비교 분석
측면 all-open Interface 암시성 높음 명확함 확장성 제한적 우수 테스트 클래스 mock 인터페이스 mock 문서성 낮음 높음 결합도 강함 약함 성능 CGLIB JDK 프록시
JPA 최적 설정
-
build.gradle.kts
1 2 3 4 5 6 7 8 9 10 11 12 13
plugins { kotlin("plugin.spring") version "1.9.x" kotlin("plugin.jpa") version "1.9.x" } allOpen { annotation("org.springframework.stereotype.Service") annotation("org.springframework.stereotype.Component") } noArg { annotation("jakarta.persistence.Entity") }
-
Entity 작성
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
// 권장: nullable 필드 @Entity @Table(name = "users") data class User( @Id @GeneratedValue val id: Long? = null, val name: String, val email: String, @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) val orders: List<Order> = emptyList() ) { // Lazy loading 필드는 toString에서 제외 override fun toString() = "User(id=$id, name='$name', email='$email')" } // 대안: non-data class @Entity @Table(name = "users") class User( @Id @GeneratedValue val id: Long? = null, val name: String, val email: String, @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) var orders: List<Order> = emptyList() )
명시적 open
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 특수한 경우만 사용
@Service
@Transactional
open class LegacyUserService(
val repository: UserRepository
) {
open fun getUser(id: Long): User? =
repository.findById(id).orElse(null)
open fun saveUser(user: User) {
repository.save(user)
}
// private 메서드는 open 불필요
private fun validateUser(user: User) {
require(user.email.isNotEmpty()) { "Email is required" }
}
}
권장 설계 패턴
우선순위 기반 선택
- Interface 기반 설계 (최우선)
- 계약 명확
- 테스트 용이
- 확장 유연
- all-open 플러그인 (표준)
- Java 마이그레이션
- 소규모 서비스
- 간단한 설정
- Interface + all-open 조합
- Entity/DTO는 all-open
- 서비스는 Interface
- 각 영역의 장점 결합
- Sealed classes (선택적)
- 제한된 상속 계층
- 도메인 타입 모델링
- 명시적 open (마지막 수단)
- Legacy 호환성
- 특수한 경우만
계층별 권장 방식
| 계층 | 권장 방식 | 이유 |
|---|---|---|
| Controller | Interface | 엔드포인트 계약 명확 |
| Service | Interface | DI 유연성, 테스트 용이 |
| Repository | Interface | Spring Data 자동 구현 |
| Entity | all-open + no-arg | JPA/Hibernate 요구 |
| DTO/Request | 일반 클래스 | 프록시 불필요 |
| 도메인 타입 | Sealed class | Sum type 표현 |
Composition over Inheritance
1
2
3
4
5
6
7
8
9
10
11
12
13
// 상속 대신 위임 사용
interface Logger {
fun log(msg: String)
}
class ConsoleLogger : Logger {
override fun log(msg: String) = println(msg)
}
// Kotlin의 by 키워드 활용
class Application(
logger: Logger = ConsoleLogger()
) : Logger by logger
- 위임의 장점
- 런타임 구현체 변경 가능
- 다중 행동 조합 가능
- Fragile Base Class 문제 해소
- 테스트 시 mock 주입 용이
적용 가이드
-
build.gradle.kts
1 2 3 4
plugins { kotlin("plugin.spring") // 필수 kotlin("plugin.jpa") // JPA 사용 시 }
- 서비스 계층
- 모든 서비스는 interface 정의
- 구현체는
@Service(명시적open금지) @Transactional은 구현체에만
- Entity 계층
- nullable 필드 활용
- data class는 신중하게 사용
toString()exclude Lazy fields
- 테스트
- Mock은 interface 기반
@WebMvcTest선호@SpringBootTest는 필요시만
정리
Kotlin의 상속 제한 이유
- Effective Java 원칙 준수
- 명시적 설계된 상속만 허용
- 컴파일러 수준 안전성 보장
- 의도하지 않은 오버라이드 방지
- 상속 오용 방지
- Fragile Base Class 문제 해결
Java와의 차이
- Java
- 기본 open (상속 가능)
- Kotlin
- 기본 final (상속 불가)
open키워드로 명시적 허용
Spring 통합 해결책
- all-open 플러그인 (표준, 간단)
- Interface 기반 설계 (권장, 명확)
- JPA 플러그인 (Entity 전용)
- 명시적 open (비권장, 특수 경우)
권장 패턴
- Controller/Service
- Interface 기반
- Entity
- all-open + no-arg
- 도메인 타입
- Sealed class
- 설계 원칙
- Composition over Inheritance