Home Kotlin 상속 제한과 Spring과의 통합
Post
Cancel

Kotlin 상속 제한과 Spring과의 통합

개요

  • 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 생성 프로세스

    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의 상속 제한 이유

  1. Effective Java 원칙 준수
    • 명시적 설계된 상속만 허용
  2. 컴파일러 수준 안전성 보장
    • 의도하지 않은 오버라이드 방지
  3. 상속 오용 방지
    • Fragile Base Class 문제 해결

Java와의 차이

  • Java
    • 기본 open (상속 가능)
  • Kotlin
    • 기본 final (상속 불가)
    • open 키워드로 명시적 허용

Spring 통합 해결책

  1. all-open 플러그인 (표준, 간단)
  2. Interface 기반 설계 (권장, 명확)
  3. JPA 플러그인 (Entity 전용)
  4. 명시적 open (비권장, 특수 경우)

권장 패턴

  • Controller/Service
    • Interface 기반
  • Entity
    • all-open + no-arg
  • 도메인 타입
    • Sealed class
  • 설계 원칙
    • Composition over Inheritance



Reference

Contents