이 프로젝트는 Spring Boot 개발자가 Ktor 프레임워크를 학습하기 위한 실전 교육용 프로젝트입니다. Kotlin 기반의 경량 웹 프레임워크인 Ktor를 통해 RESTful API 서버를 구축하는 방법을 단계별로 학습할 수 있도록 설계되었습니다.
물론 제가 볼려고 만들었습니다.
Ktor는 JetBrains에서 개발한 Kotlin 전용 웹 프레임워크로, Spring Boot와 비교했을 때 다음과 같은 특징을 가지고 있습니다:
1. 경량성 (Lightweight)
2. 함수형 & DSL 기반 (Functional & DSL-based)
3. Kotlin First
4. 유연성 (Flexibility)
5. 적합한 사용 사례
✅ Ktor가 좋은 경우:
❌ Spring Boot가 더 나은 경우:
프로젝트는 Clean Architecture 원칙에 따라 계층화되어 있으며, 각 계층은 명확한 책임을 가지고 있습니다. 학습에 필요한것들 위주로 구현했습니다.
src/main/kotlin/com/example/
├── Application.kt # 애플리케이션 진입점 & 설정
├── Routing.kt # 라우팅 설정
├── di/
│ └── KoinModule.kt # Koin 의존성 주입 모듈
├── model/
│ ├── User.kt # User 도메인 모델
│ ├── Product.kt # Product 도메인 모델
│ └── ApiResponse.kt # API 응답 래퍼
├── repository/
│ ├── UserRepository.kt # Repository 인터페이스
│ └── UserRepositoryImpl.kt # In-memory 구현체
├── service/
│ ├── UserService.kt # Service 인터페이스
│ └── UserServiceImpl.kt # 비즈니스 로직 구현
└── route/
└── UserRoutes.kt # User API 라우트 정의
src/test/kotlin/com/example/
├── service/
│ └── UserServiceTest.kt # 서비스 단위 테스트
└── route/
└── UserRoutesTest.kt # 라우트 통합 테스트
| 계층 (Layer) | Spring Boot | Ktor | 책임 (Responsibility) |
|---|---|---|---|
| Route | @RestController | Route extension | HTTP 요청/응답 처리, 파라미터 추출 |
| Service | @Service | Interface + Impl | 비즈니스 로직, 트랜잭션 관리 |
| Repository | @Repository | Interface + Impl | 데이터 접근, CRUD 작업 |
| Model | @Entity / DTO | data class | 도메인 모델, 데이터 전송 객체 |
| DI Config | @Configuration | Koin module | 의존성 설정 및 관리 |
@Service
class UserService @Autowired constructor(
private val userRepository: UserRepository
) {
// ...
}
// KoinModule.kt - 명시적 DI 설정
val appModule = module {
single<UserRepository> { UserRepositoryImpl() }
single<UserService> { UserServiceImpl(get()) }
}
// UserServiceImpl.kt - 생성자 주입
class UserServiceImpl(
private val userRepository: UserRepository // Koin이 자동 주입
) : UserService {
// ...
}
주요 차이점:
@RestController
@RequestMapping("/api/users")
class UserController {
@GetMapping
fun getUsers(): ResponseEntity<List<User>> { ... }
@PostMapping
fun createUser(@RequestBody request: CreateUserRequest): ResponseEntity<User> { ... }
}
fun Route.userRoutes(userService: UserService) {
route("/users") {
get {
val users = userService.getAllUsers()
call.respond(ApiResponse(true, users))
}
post {
val request = call.receive<CreateUserRequest>()
val user = userService.createUser(request)
call.respond(HttpStatusCode.Created, ApiResponse(true, user))
}
}
}
주요 차이점:
@SpringBootApplication
class Application
fun main(args: Array<String>) {
runApplication<Application>(*args)
}
fun main(args: Array<String>) {
io.ktor.server.netty.EngineMain.main(args)
}
fun Application.module() {
configureDI() // 의존성 주입
configurePlugins() // 플러그인
configureRouting() // 라우팅
}
주요 차이점:
이 파일은 애플리케이션의 시작점이자 설정의 중심입니다. Spring Boot의 @SpringBootApplication이 하는 모든 일을 명시적으로 수행합니다.
1) main 함수 - 서버 시작
fun main(args: Array<String>) {
io.ktor.server.netty.EngineMain.main(args)
}
2) module 함수 - 애플리케이션 구성
fun Application.module() {
configureDI() // Koin 의존성 주입 설정
configurePlugins() // ContentNegotiation, CORS 등
configureRouting() // API 라우트 등록
}
3) configureDI - Koin 설정
fun Application.configureDI() {
install(Koin) {
slf4jLogger()
modules(appModule)
}
}
4) configurePlugins - 플러그인 설정
fun Application.configurePlugins() {
// JSON 직렬화/역직렬화
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
})
}
// 전역 예외 처리
install(StatusPages) {
exception<IllegalArgumentException> { call, cause ->
call.respond(HttpStatusCode.BadRequest,
mapOf("error" to (cause.message ?: "Bad request")))
}
}
// 요청/응답 로깅
install(CallLogging) {
level = [org.slf4j.event.Level.INFO](http://org.slf4j.event.Level.INFO)
}
// CORS 설정
install(CORS) {
anyHost()
allowHeader(HttpHeaders.ContentType)
allowMethod(HttpMethod.Get)
allowMethod([HttpMethod.Post](http://HttpMethod.Post))
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
}
}
각 플러그인의 역할:
Koin은 Kotlin DSL 기반의 경량 DI 프레임워크입니다. Spring의 ApplicationContext보다 훨씬 가볍고 빠릅니다.
val appModule = module {
// Repository 레이어
single<UserRepository> { UserRepositoryImpl() }
// Service 레이어
single<UserService> { UserServiceImpl(get()) }
}
1) single { } - 싱글톤
2) factory { } - 프로토타입
3) scoped { } - 스코프 싱글톤
single<UserService> { UserServiceImpl(get()) }
get()은 이미 등록된 의존성을 가져옵니다큰 프로젝트에서는 모듈을 분리할 수 있습니다:
val repositoryModule = module {
single<UserRepository> { UserRepositoryImpl() }
single<ProductRepository> { ProductRepositoryImpl() }
}
val serviceModule = module {
single<UserService> { UserServiceImpl(get()) }
single<ProductService> { ProductServiceImpl(get()) }
}
val networkModule = module {
single { HttpClient() }
}
// Application.kt에서
install(Koin) {
modules(repositoryModule, serviceModule, networkModule)
}
@Serializable
data class User(
val id: Long? = null,
val name: String,
val email: String,
val age: Int
)
@Serializable
data class CreateUserRequest(
val name: String,
val email: String,
val age: Int
)
@Serializable
data class UpdateUserRequest(
val name: String,
val email: String,
val age: Int
)
@Serializable 어노테이션:
DTO 분리:
@Serializable
data class ApiResponse<T>(
val success: Boolean,
val data: T? = null,
val message: String? = null
)
왜 래퍼를 사용하나요?
사용 예:
// 성공 응답
{
"success": true,
"data": { "id": 1, "name": "홍길동" },
"message": null
}
// 실패 응답
{
"success": false,
"data": null,
"message": "User not found with id: 999"
}
interface UserRepository {
fun findAll(): List<User>
fun findById(id: Long): User?
fun save(user: User): User
fun update(id: Long, user: User): User?
fun delete(id: Long): Boolean
fun findByEmail(email: String): User?
}
Spring Boot와의 차이:
장점:
단점:
보일러플레이트 코드 증가
직접 구현 필요
하지만 Ktorm을 쓰면 가능은 하지만 이번에는 단순히 Ktor을 배우기 위한 글이라 사용은 하지않았습니다.
class UserRepositoryImpl : UserRepository {
private val users = ConcurrentHashMap<Long, User>()
private val idCounter = AtomicLong(1)
init {
// 초기 테스트 데이터
save(User(name = "김철수", email = "[kim@example.com](mailto:kim@example.com)", age = 25))
save(User(name = "이영희", email = "[lee@example.com](mailto:lee@example.com)", age = 28))
save(User(name = "박민수", email = "[park@example.com](mailto:park@example.com)", age = 30))
}
override fun findAll(): List<User> {
return users.values.toList()
}
override fun findById(id: Long): User? {
return users[id]
}
override fun save(user: User): User {
val id = [user.id](http://user.id) ?: idCounter.getAndIncrement()
val newUser = user.copy(id = id)
users[id] = newUser
return newUser
}
// ... 기타 메서드
}
In-memory 구현 이유:
실전에서는:
interface UserService {
fun getAllUsers(): List<User>
fun getUserById(id: Long): User
fun createUser(request: CreateUserRequest): User
fun updateUser(id: Long, request: UpdateUserRequest): User
fun deleteUser(id: Long)
fun findUserByEmail(email: String): User?
}
인터페이스를 사용하는 이유:
class UserServiceImpl(
private val userRepository: UserRepository
) : UserService {
override fun createUser(request: CreateUserRequest): User {
// 비즈니스 로직 1: 이메일 중복 체크
val existingUser = userRepository.findByEmail([request.email](http://request.email))
if (existingUser != null) {
throw IllegalArgumentException(
"User with email ${[request.email](http://request.email)} already exists"
)
}
// 비즈니스 로직 2: 나이 유효성 검사
if (request.age < 1 || request.age > 150) {
throw IllegalArgumentException("Invalid age: ${request.age}")
}
val user = User(
name = [request.name](http://request.name),
email = [request.email](http://request.email),
age = request.age
)
return [userRepository.save](http://userRepository.save)(user)
}
// ... 기타 메서드
}
Service 계층의 역할:
@Transactional이 없는 이유:
fun Route.userRoutes(userService: UserService) {
route("/users") {
// GET /api/users - 모든 사용자 조회
get {
try {
val users = userService.getAllUsers()
call.respond(ApiResponse(true, users))
} catch (e: Exception) {
call.respond(
HttpStatusCode.InternalServerError,
ApiResponse<List<User>>(false, message = e.message)
)
}
}
// GET /api/users/{id} - 특정 사용자 조회
get("/{id}") {
try {
val id = call.parameters["id"]?.toLongOrNull()
if (id == null) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse<User>(false, message = "Invalid ID format")
)
return@get
}
val user = userService.getUserById(id)
call.respond(ApiResponse(true, user))
} catch (e: IllegalArgumentException) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse<User>(false, message = e.message)
)
}
}
// POST /api/users - 사용자 생성
post {
try {
val request = call.receive<CreateUserRequest>()
val newUser = userService.createUser(request)
call.respond(HttpStatusCode.Created, ApiResponse(true, newUser))
} catch (e: IllegalArgumentException) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse<User>(false, message = e.message)
)
}
}
// PUT /api/users/{id} - 사용자 업데이트
put("/{id}") {
// 구현 생략...
}
// DELETE /api/users/{id} - 사용자 삭제
delete("/{id}") {
// 구현 생략...
}
// GET /api/users/email/{email} - 이메일로 조회
get("/email/{email}") {
// 구현 생략...
}
}
}
1) Route 확장 함수
fun Route.userRoutes(userService: UserService) {
// ...
}
2) route() 블록 - 경로 그룹화
route("/users") {
get { } // GET /api/users
get("/{id}") { } // GET /api/users/{id}
post { } // POST /api/users
}
3) call 객체 - HTTP 요청/응답 처리
call.parameters["id"]: URL 파라미터 추출 (@PathVariable)call.receive<T>(): 요청 본문 파싱 (@RequestBody)call.respond(data): 응답 반환 (ResponseEntity)call.respond(status, data): 상태 코드와 함께 응답4) 예외 처리 패턴
try {
// 비즈니스 로직
} catch (e: IllegalArgumentException) {
// 400 Bad Request
call.respond(HttpStatusCode.BadRequest, ...)
} catch (e: Exception) {
// 500 Internal Server Error
call.respond(HttpStatusCode.InternalServerError, ...)
}
class UserServiceTest {
private val repository: UserRepository = UserRepositoryImpl()
private val service: UserService = UserServiceImpl(repository)
// 테스트 메서드들...
}
Spring Boot와의 차이:
@Test
fun `getUserById should return user when exists`() {
// Given: 테스트 데이터 준비
val users = service.getAllUsers()
val existingUser = users.first()
// When: 실제 테스트 실행
val user = service.getUserById([existingUser.id](http://existingUser.id)!!)
// Then: 결과 검증
assertNotNull(user)
assertEquals([existingUser.id](http://existingUser.id), [user.id](http://user.id))
assertEquals([existingUser.name](http://existingUser.name), [user.name](http://user.name))
}
@Test
fun `createUser should throw exception when email already exists`() {
// Given
val existingUser = service.getAllUsers().first()
val request = CreateUserRequest(
name = "새 유저",
email = [existingUser.email](http://existingUser.email), // 중복 이메일
age = 30
)
// When/Then
val exception = assertFailsWith<IllegalArgumentException> {
service.createUser(request)
}
assertEquals(
"User with email ${[existingUser.email](http://existingUser.email)} already exists",
exception.message
)
}
어설션 함수:
assertEquals(expected, actual): 값이 같은지 확인assertNotNull(value): null이 아닌지 확인assertNull(value): null인지 확인assertTrue(condition): 조건이 참인지 확인assertFailsWith<T>: 특정 예외가 발생하는지 확인@Test
fun `GET users should return 200 OK with user list`() = testApplication {
// Setup: 애플리케이션 설정
application {
module() // 프로덕션과 동일한 설정
}
// When: HTTP 요청
val response = client.get("/api/users")
// Then: 응답 검증
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("success"))
}
testApplication의 장점:
@Test
fun `POST users should create new user`() = testApplication {
application { module() }
val response = [client.post](http://client.post)("/api/users") {
contentType(ContentType.Application.Json)
setBody("""{
"name":"테스트 유저",
"email":"[test@test.com](mailto:test@test.com)",
"age":25
}""")
}
assertEquals(HttpStatusCode.Created, response.status)
assertTrue(response.bodyAsText().contains("테스트 유저"))
}
| Method | Endpoint | Description | Request Body | Response |
|---|---|---|---|---|
| GET | /api/users | 모든 사용자 조회 | - | List<User> |
| GET | /api/users/{id} | ID로 사용자 조회 | - | User |
| GET | /api/users/email/{email} | 이메일로 조회 | - | User |
| POST | /api/users | 새 사용자 생성 | CreateUserRequest | User |
| PUT | /api/users/{id} | 사용자 업데이트 | UpdateUserRequest | User |
| DELETE | /api/users/{id} | 사용자 삭제 | - | Success message |
1) 모든 사용자 조회
curl http://localhost:8080/api/users
2) 사용자 생성
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{
"name":"홍길동",
"email":"[hong@example.com](mailto:hong@example.com)",
"age":30
}'
3) 사용자 업데이트
curl -X PUT http://localhost:8080/api/users/1 \
-H "Content-Type: application/json" \
-d '{
"name":"홍길동 수정",
"email":"[hong2@example.com](mailto:hong2@example.com)",
"age":31
}'
4) 사용자 삭제
curl -X DELETE http://localhost:8080/api/users/1
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/products | 모든 상품 조회 |
| GET | /api/products/search?q=query&minPrice=1000 | 상품 검색 |
계층 분리:
의존성 방향:
Route → Service → Repository
↓ ↓ ↓
DI DI DI
시작 방식:
의존성 주입:
라우팅:
테스팅:
"마법이 없는 프레임워크"
"필요한 것만 선택"
"Kotlin First"
다음 단계:
# IntelliJ IDEA에서
File → Open → ktorstudy 폴더 선택
방법 1: IntelliJ에서
Application.kt 파일 열기main() 함수 옆의 ▶️ 버튼 클릭방법 2: 터미널에서
./gradlew run
서버가 성공적으로 시작되면:
INFO Application - Application started in 0.303 seconds.
INFO Application - Responding at http://0.0.0.0:8080
브라우저에서 http://localhost:8080 접속 → "Hello Ktor World!" 표시
# 모든 테스트 실행
./gradlew test
# 특정 테스트만 실행
./gradlew test --tests UserServiceTest
./gradlew test --tests UserRoutesTest
핵심 메시지: Ktor는 "마법이 없는" 프레임워크입니다. 모든 것이 명시적이고 이해하기 쉬우며, 빠르고 가볍습니다. 그리고 DSL은 신입니다.
마지막 업데이트: 2025년 10월 10일