Gradle 플러그인 세팅
코프링 프로젝트에서 반드시 필요한 Gradle 플러그인이 있다. start.spring.io에서 Kotlin을 선택하면 자동으로 추가되지만, 왜 필요한지 알아야 한다.
// build.gradle.kts
plugins {
kotlin("jvm") version "2.1.0"
kotlin("plugin.spring") version "2.1.0" // all-open
kotlin("plugin.jpa") version "2.1.0" // no-arg
id("org.springframework.boot") version "3.5.0"
id("io.spring.dependency-management") version "1.1.7"
}
kotlin-spring (all-open)
Kotlin 클래스는 기본이 final이다. 그런데 Spring은 프록시를 만들어야 하는 곳이 많다.
@Component,@Service,@Repository→ CGLIB 프록시@Transactional→ AOP 프록시@Configuration→ CGLIB로 빈 메서드 가로채기
매번 open 키워드를 붙이면 귀찮으니까, kotlin-spring 플러그인이 Spring 어노테이션이 붙은 클래스를 자동으로 open으로 만들어준다.
자동으로 open이 되는 어노테이션:
@Component(하위:@Service,@Repository,@Controller)@Configuration@Transactional@Async@Cacheable
kotlin-jpa (no-arg)
JPA 엔티티는 기본 생성자(no-arg constructor)가 필요하다. Kotlin의 클래스는 주 생성자에 파라미터가 있으면 기본 생성자가 없다.
kotlin-jpa 플러그인이 @Entity, @Embeddable, @MappedSuperclass가 붙은 클래스에 기본 생성자를 자동 생성해준다.
이 두 플러그인이 없으면 코프링은 제대로 동작하지 않는다. 왜 필요한지 이해하고 넘어가자.
JPA 엔티티 작성
코프링에서 가장 까다로운 부분이다. 규칙이 좀 있다.
@Entity
class Post(
@Column(nullable = false)
var title: String,
@Column(columnDefinition = "TEXT", nullable = false)
var content: String,
var thumbnail: String? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
var category: Category? = null,
var viewCount: Int = 0,
var isPublished: Boolean = false,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L
) : BaseTimeEntity()
여기서 주의할 점을 하나씩 짚으면 이렇다.
data class 쓰지 않기
data class는 equals(), hashCode()를 모든 프로퍼티로 만든다. JPA에서는 프록시 비교, lazy loading 등에서 문제가 생긴다. 그냥 일반 class를 쓴다.
id를 마지막에 두기
id는 DB가 생성하는 값이니까 기본값 0L을 주고 마지막에 둔다. 객체 생성 시 나머지 필드만 넘기면 된다.
val post = Post(
title = "제목",
content = "<p>내용</p>",
isPublished = true
)
// id는 0L, save 후 DB가 채워줌
var vs val
- 변경될 수 있는 필드:
var(title, content, viewCount 등) - 변경 안 되는 필드:
val(id, createdAt)
nullable은 ? 타입으로
카테고리처럼 null일 수 있는 관계는 Category?로 선언하고 기본값 null을 준다.
BaseTimeEntity
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class BaseTimeEntity {
@CreatedDate
@Column(updatable = false)
var createdAt: LocalDateTime = LocalDateTime.now()
protected set
@LastModifiedDate
var updatedAt: LocalDateTime = LocalDateTime.now()
protected set
}
protected set으로 외부에서 직접 수정하는 걸 막는다. JPA Auditing이 알아서 값을 넣어준다.
의존성 주입
생성자 주입 (추천)
@Service
class PostService(
private val postRepository: PostRepository,
private val categoryRepository: CategoryRepository,
private val tagRepository: TagRepository
)
Kotlin은 주 생성자 문법이 간결하니까 @Autowired 없이 생성자 주입이 자연스럽다. Java에서 Lombok의 @RequiredArgsConstructor가 하던 역할을 언어 차원에서 해결한다.
```kotlin
// 이거 하지 말 것
@Autowired
lateinit var postRepository: PostRepository
```
테스트에서 주입 누락을 잡기 어렵고, 불변성도 깨진다. 생성자 주입을 쓰자.
Controller
@RestController
@RequestMapping("/api/posts")
class PostController(
private val postService: PostService
) {
@GetMapping
fun getPosts(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "10") size: Int,
@RequestParam(required = false) category: Long?
): ResponseEntity<Page<PostListResponse>> {
val posts = postService.getPosts(page, size, category)
return ResponseEntity.ok(posts)
}
@PostMapping
fun createPost(
@Valid @RequestBody request: PostCreateRequest
): ResponseEntity<Map<String, Long>> {
val id = postService.createPost(request)
return ResponseEntity.status(HttpStatus.CREATED)
.body(mapOf("id" to id))
}
}
Java랑 거의 똑같다. 어노테이션도 동일하고, 생성자 주입이 간결해진 것 외에는 큰 차이 없다.
DTO
코프링에서 DTO는 data class의 천국이다.
// 요청
data class PostCreateRequest(
@field:NotBlank(message = "제목은 필수입니다")
val title: String,
@field:NotBlank(message = "내용은 필수입니다")
val content: String,
val thumbnail: String? = null,
val categoryId: Long? = null,
val tagNames: List<String> = emptyList(),
val isPublished: Boolean = true
)
// 응답
data class PostResponse(
val id: Long,
val title: String,
val content: String,
val thumbnail: String?,
val category: CategoryResponse?,
val tags: List<String>,
val viewCount: Int,
val isPublished: Boolean,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime
)
@field: 접두사 필요Kotlin의 주 생성자 파라미터는 생성자 파라미터이자 프로퍼티이자 필드다. @NotBlank만 쓰면 생성자 파라미터에 붙어서 Validation이 안 먹는다. 반드시 @field:NotBlank로 써야 한다. 코프링에서 가장 흔한 실수.
Service
@Service
@Transactional(readOnly = true)
class PostService(
private val postRepository: PostRepository,
private val categoryRepository: CategoryRepository,
private val tagRepository: TagRepository
) {
fun getPosts(page: Int, size: Int, categoryId: Long?): Page<PostListResponse> {
val pageable = PageRequest.of(page, size, Sort.by("createdAt").descending())
val posts = categoryId?.let {
postRepository.findByCategory_IdAndIsPublishedTrue(it, pageable)
} ?: postRepository.findByIsPublishedTrue(pageable)
return posts.map { it.toListResponse() }
}
@Transactional
fun createPost(request: PostCreateRequest): Long {
val category = request.categoryId?.let {
categoryRepository.findByIdOrNull(it)
?: throw BusinessException("카테고리를 찾을 수 없습니다", HttpStatus.NOT_FOUND)
}
val post = Post(
title = request.title,
content = request.content,
thumbnail = request.thumbnail,
category = category,
isPublished = request.isPublished
)
postRepository.save(post)
// 태그 처리
request.tagNames.forEach { tagName ->
val tag = tagRepository.findByName(tagName)
?: tagRepository.save(Tag(name = tagName))
post.addTag(tag)
}
return post.id
}
}
?.let과 ?: 조합으로 null 처리가 깔끔해진다. Java에서 if (categoryId != null) 분기하던 걸 한 줄로 쓸 수 있다.
엔티티 → DTO 변환
확장 함수를 쓰면 깔끔하다.
// Post 엔티티의 확장 함수
fun Post.toListResponse() = PostListResponse(
id = id,
title = title,
thumbnail = thumbnail,
category = category?.toResponse(),
tags = postTags.map { it.tag.name },
viewCount = viewCount,
createdAt = createdAt
)
fun Post.toResponse() = PostResponse(
id = id,
title = title,
content = content,
// ...
)
Java에서 별도 Mapper 클래스를 만들던 걸 확장 함수로 깔끔하게 처리할 수 있다.
Repository
interface PostRepository : JpaRepository<Post, Long> {
fun findByIsPublishedTrue(pageable: Pageable): Page<Post>
fun findByCategory_IdAndIsPublishedTrue(categoryId: Long, pageable: Pageable): Page<Post>
}
Spring Data JPA의 쿼리 메서드는 Java와 완전히 동일하다. 인터페이스 문법만 Kotlin으로 바뀐 것.
findByIdOrNull
Spring Data JPA의 findById()는 Optional<T>을 반환한다. Kotlin에서 Optional은 어울리지 않는다 — Kotlin에 이미 null safety가 있으니까.
// Optional 대신 이걸 쓴다
import org.springframework.data.repository.findByIdOrNull
val post = postRepository.findByIdOrNull(id)
?: throw BusinessException("글을 찾을 수 없습니다", HttpStatus.NOT_FOUND)
findByIdOrNull은 Spring Data가 제공하는 Kotlin 확장 함수다. Post?를 반환한다.
Spring Security Kotlin DSL
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val jwtFilter: JwtFilter
) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
csrf { disable() }
sessionManagement {
sessionCreationPolicy = SessionCreationPolicy.STATELESS
}
authorizeHttpRequests {
authorize(HttpMethod.GET, "/api/posts/**", permitAll)
authorize(HttpMethod.GET, "/api/categories/**", permitAll)
authorize(HttpMethod.GET, "/api/tags/**", permitAll)
authorize("/api/posts/*/comments", permitAll)
authorize(HttpMethod.DELETE, "/api/comments/*", permitAll)
authorize("/api/auth/**", permitAll)
authorize(anyRequest, authenticated)
}
addFilterBefore<UsernamePasswordAuthenticationFilter>(jwtFilter)
}
return http.build()
}
@Bean
fun passwordEncoder() = BCryptPasswordEncoder()
}
Java의 체이닝 방식보다 구조가 눈에 확 들어온다. 이게 Kotlin DSL의 힘이다.
예외 처리
// Sealed class로 비즈니스 예외 정의
class BusinessException(
override val message: String,
val status: HttpStatus = HttpStatus.BAD_REQUEST
) : RuntimeException(message)
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(BusinessException::class)
fun handleBusiness(e: BusinessException): ResponseEntity<Map<String, String>> {
return ResponseEntity
.status(e.status)
.body(mapOf("message" to e.message))
}
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(e: MethodArgumentNotValidException): ResponseEntity<Map<String, String>> {
val message = e.bindingResult.fieldErrors.first().defaultMessage ?: "잘못된 요청입니다"
return ResponseEntity.badRequest()
.body(mapOf("message" to message))
}
}
코프링에서 Lombok은 필요 없다
| Java + Lombok | Kotlin |
|---|---|
@Getter, @Setter | 프로퍼티가 기본 제공 |
@RequiredArgsConstructor | 주 생성자가 기본 |
@Builder | Named argument + 기본값 |
@Data | data class |
@ToString | data class가 자동 생성 |
@Slf4j | companion object에 직접 선언 |
Kotlin을 쓰면 Lombok 의존성을 빼도 된다. build.gradle에서 Lombok 관련 설정을 제거해도 좋다.
로깅
// 방법 1: companion object에 직접
@Service
class PostService {
companion object {
private val log = LoggerFactory.getLogger(PostService::class.java)
}
}
// 방법 2: 확장 함수로 유틸 만들기
inline fun <reified T> T.logger(): Logger =
LoggerFactory.getLogger(T::class.java)
@Service
class PostService {
private val log = logger()
}
방법 2가 더 깔끔하다. 프로젝트 공통 유틸로 하나 만들어두면 모든 클래스에서 logger()만 호출하면 된다.