Spring Boot 프로젝트에 데이터베이스 지원 추가하기

Spring Boot 프로젝트에 데이터베이스 지원 추가하기 (Add database support for Spring Boot project)

이 튜토리얼 파트에서는 Java Database Connectivity(JDBC)를 사용해 프로젝트에 데이터베이스를 추가하고 구성해요. JVM 애플리케이션에서는 JDBC로 데이터베이스와 상호작용합니다. 편의를 위해 Spring Framework는 JDBC 사용을 단순화하고 흔한 실수를 피하도록 돕는 JdbcTemplate 클래스를 제공해요.

출처: Add database support for Spring Boot project

본문

이번 파트에서는 Java Database Connectivity(JDBC)를 사용해 프로젝트에 데이터베이스를 추가하고 구성할 거예요. JVM 애플리케이션에서는 JDBC를 통해 데이터베이스와 상호작용하는데, 편의를 위해 Spring Framework가 제공하는 JdbcTemplate 클래스를 쓰면 JDBC 사용이 단순해지고 흔한 실수도 피할 수 있습니다.

데이터베이스 지원 추가하기 (Add database support)

Spring Framework 기반 애플리케이션의 일반적인 관례는 소위 서비스 계층(service layer) 안에서 데이터베이스 접근 로직을 구현하는 것입니다. 여기가 비즈니스 로직이 사는 자리죠. Spring에서는 클래스가 애플리케이션의 서비스 계층에 속함을 나타내려고 @Service 애너테이션으로 표시해야 해요. 이 애플리케이션에서는 MessageService 클래스를 만들어 이 목적을 달성합니다.

같은 패키지에 MessageService.kt 파일과 MessageService 클래스를 다음과 같이 만들어요.

// MessageService.kt
package com.example.demo

import org.springframework.stereotype.Service
import org.springframework.jdbc.core.JdbcTemplate

@Service
class MessageService(private val db: JdbcTemplate) {
 fun findMessages(): List<Message> = db.query("select * from messages") { response, _ ->
 Message(response.getString("id"), response.getString("text"))
 }

 fun save(message: Message): Message {
 db.update(
 "insert into messages values ( ?, ? )",
 message.id, message.text
 )
 return message
 }
}

생성자 인자와 의존성 주입 – (private val db: JdbcTemplate)

Kotlin의 클래스는 primary 생성자를 가집니다. 클래스는 하나 이상의 secondary 생성자도 가질 수 있어요. primary 생성자는 클래스 헤더의 일부로, 클래스 이름과 선택적 타입 파라미터 뒤에 옵니다. 여기서 생성자는 (val db: JdbcTemplate)이에요.

val db: JdbcTemplate는 생성자의 인자입니다.

 @Service
 class MessageService(private val db: JdbcTemplate)

후행 람다와 SAM 변환 (Trailing lambda and SAM conversion)

findMessages() 함수는 JdbcTemplate 클래스의 query() 함수를 호출합니다. query() 함수는 두 인자를 받아요. String 인스턴스인 SQL 쿼리와, 행마다 객체 하나를 매핑할 콜백이죠.

 db.query("...", RowMapper { ... } )

RowMapper 인터페이스는 메서드가 하나뿐이라 인터페이스 이름을 생략하고 람다 표현식으로 구현할 수 있어요. 함수 호출의 파라미터로 사용되기 때문에, Kotlin 컴파일러는 람다 표현식이 어떤 인터페이스로 변환돼야 하는지 알고 있습니다. 이를 Kotlin의 SAM 변환이라고 해요.

 db.query("...", { ... } )

SAM 변환 후 query 함수는 인자 두 개로 정리됩니다. 첫 번째 자리에 String, 마지막 자리에 람다 표현식이 오죠. Kotlin 관례에 따르면 함수의 마지막 파라미터가 함수라면, 해당 인자로 전달되는 람다 표현식을 괄호 밖에 둘 수 있어요. 이런 문법을 후행 람다(trailing lambda)라고 부릅니다.

 db.query("...") { ... }

사용하지 않는 람다 인자의 밑줄 (Underscore for unused lambda argument)

파라미터가 여러 개인 람다에서는 밑줄 _ 문자로 사용하지 않는 파라미터의 이름을 대신할 수 있어요.

따라서 query 함수 호출의 최종 문법은 이렇게 생겼습니다.

 db.query("select * from messages") { response, _ ->
 Message(response.getString("id"), response.getString("text"))
 }

MessageController 클래스 업데이트하기

MessageService 클래스를 사용하도록 MessageController.kt를 업데이트해요.

// MessageController.kt
package com.example.demo

import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.net.URI

@RestController
@RequestMapping("/")
class MessageController(private val service: MessageService) {
 @GetMapping
 fun listMessages() = service.findMessages()

 @PostMapping
 fun post(@RequestBody message: Message): ResponseEntity<Message> {
 val savedMessage = service.save(message)
 return ResponseEntity.created(URI("/${savedMessage.id}")).body(savedMessage)
 }
}

@PostMapping 애너테이션

HTTP POST 요청을 처리하는 메서드는 @PostMapping 애너테이션으로 표시해야 해요. HTTP Body 콘텐츠로 전송된 JSON을 객체로 변환하려면 메서드 인자에 @RequestBody 애너테이션을 사용해야 합니다. 애플리케이션 클래스패스에 Jackson 라이브러리가 있기 때문에 변환은 자동으로 일어나요.

ResponseEntity

ResponseEntity는 전체 HTTP 응답, 즉 상태 코드, 헤더, 본문을 나타냅니다.

created() 메서드를 사용하면 응답 상태 코드(201)를 구성하고, 생성된 리소스의 컨텍스트 경로를 나타내는 location 헤더를 설정해요.

MessageService 클래스 업데이트하기

Message 클래스의 id는 널 가능 String으로 선언되었어요.

data class Message(val id: String?, val text: String)

하지만 데이터베이스에 id 값으로 null을 저장하는 것은 올바르지 않아요. 이 상황을 자연스럽게 처리해야 합니다.

MessageService.kt 파일의 코드를 업데이트해, 메시지를 데이터베이스에 저장할 때 idnull이면 새 값을 생성하도록 해봅시다.

// MessageService.kt
package com.example.demo

import org.springframework.stereotype.Service
import org.springframework.jdbc.core.JdbcTemplate
import java.util.UUID

@Service
class MessageService(private val db: JdbcTemplate) {
 fun findMessages(): List<Message> = db.query("select * from messages") { response, _ ->
 Message(response.getString("id"), response.getString("text"))
 }

 fun save(message: Message): Message {
 val id = message.id ?: UUID.randomUUID().toString() // Generate new id if it is null
 db.update(
 "insert into messages values ( ?, ? )",
 id, message.text
 )
 return message.copy(id = id) // Return a copy of the message with the new id
 }
}

엘비스 연산자 – ?:

message.id ?: UUID.randomUUID().toString() 코드는 엘비스 연산자(if-not-null-else 축약형) ?:를 사용해요. ?: 왼쪽의 표현식이 null이 아니라면 엘비스 연산자는 그 값을 반환하고, 그렇지 않으면 오른쪽 표현식을 반환합니다. 오른쪽 표현식은 왼쪽이 null일 때만 평가된다는 점에 주의하세요.

이제 애플리케이션 코드는 데이터베이스와 함께 동작할 준비가 됐어요. 이제 데이터 소스를 구성할 차례입니다.

데이터베이스 구성하기 (Configure the database)

애플리케이션에서 데이터베이스를 구성해요.

  1. src/main/resources 디렉터리에 schema.sql 파일을 만들어요. 여기에 데이터베이스 객체 정의가 저장됩니다.
  2. src/main/resources/schema.sql 파일을 다음 코드로 업데이트해요.
-- schema.sql
CREATE TABLE IF NOT EXISTS messages (
id VARCHAR(60) PRIMARY KEY,
text VARCHAR NOT NULL
);

이 코드는 idtext 두 컬럼을 가진 messages 테이블을 만들어요. 테이블 구조는 Message 클래스의 구조와 일치합니다.

  1. src/main/resources 폴더에 있는 application.properties 파일을 열고 다음 애플리케이션 속성을 추가해요.
spring.application.name=demo
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:file:./data/testdb
spring.datasource.username=name
spring.datasource.password=password
spring.sql.init.schema-locations=classpath:schema.sql
spring.sql.init.mode=always

이 설정들이 Spring Boot 애플리케이션에서 데이터베이스를 활성화해요. 흔한 애플리케이션 속성 전체 목록은 Spring 문서를 참고하세요.

HTTP 요청으로 데이터베이스에 메시지 추가하기

앞서 만든 엔드포인트를 사용하려면 HTTP 클라이언트를 써야 해요. IntelliJ IDEA에서는 내장 HTTP 클라이언트를 사용합니다.

  1. 애플리케이션을 실행해요. 애플리케이션이 실행 중이 되면 POST 요청을 보내 메시지를 데이터베이스에 저장할 수 있어요.
  2. 프로젝트 루트 폴더에 requests.http 파일을 만들고 다음 HTTP 요청을 추가해요.
### Post "Hello!"
POST http://localhost:8080/
Content-Type: application/json

{
 "text": "Hello!"
}

### Post "Bonjour!"

POST http://localhost:8080/
Content-Type: application/json

{
 "text": "Bonjour!"
}

### Post "Privet!"

POST http://localhost:8080/
Content-Type: application/json

{
 "text": "Privet!"
}

### Get all the messages
GET http://localhost:8080/
  1. 모든 POST 요청을 실행해요. 요청 선언 옆 거터의 초록색 Run 아이콘을 사용하면 됩니다. 이 요청들이 텍스트 메시지를 데이터베이스에 씁니다.
  2. GET 요청을 실행하고 Run 도구 창에서 결과를 확인해요.

요청 실행의 대체 방법 (Alternative way to execute requests)

다른 HTTP 클라이언트나 cURL 커맨드라인 도구를 사용해도 됩니다. 예를 들어 터미널에서 다음 명령을 실행하면 같은 결과를 얻을 수 있어요.

curl -X POST --location "http://localhost:8080" -H "Content-Type: application/json" -d "{ \"text\": \"Hello!\" }"

curl -X POST --location "http://localhost:8080" -H "Content-Type: application/json" -d "{ \"text\": \"Bonjour!\" }"

curl -X POST --location "http://localhost:8080" -H "Content-Type: application/json" -d "{ \"text\": \"Privet!\" }"

curl -X GET --location "http://localhost:8080"

id로 메시지 검색하기 (Retrieve messages by id)

개별 메시지를 id로 검색하도록 애플리케이션 기능을 확장해 봅시다.

  1. MessageService 클래스에 개별 메시지를 id로 검색하는 새 함수 findMessageById(id: String)을 추가해요.
// MessageService.kt
package com.example.demo

import org.springframework.stereotype.Service
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.query
import java.util.*

@Service
class MessageService(private val db: JdbcTemplate) {
 fun findMessages(): List<Message> = db.query("select * from messages") { response, _ ->
 Message(response.getString("id"), response.getString("text"))
 }

 fun findMessageById(id: String): Message? = db.query("select * from messages where id = ?", id) { response, _ ->
 Message(response.getString("id"), response.getString("text"))
 }.singleOrNull()

 fun save(message: Message): Message {
 val id = message.id ?: UUID.randomUUID().toString() // Generate new id if it is null
 db.update(
 "insert into messages values ( ?, ? )",
 id, message.text
 )
 return message.copy(id = id) // Return a copy of the message with the new id
 }
}

파라미터 목록에서 vararg 인자 위치 (vararg argument position in the parameter list)

query() 함수는 세 개의 인자를 받아요.

  • 실행에 파라미터가 필요한 SQL 쿼리 문자열
  • String 타입 파라미터인 id
  • 람다 표현식으로 구현된 RowMapper 인스턴스

query() 함수의 두 번째 파라미터는 *가변 인자(vararg)*로 선언됩니다. Kotlin에서는 가변 인자 파라미터가 반드시 파라미터 목록의 마지막일 필요는 없어요.

singleOrNull() 함수

singleOrNull() 함수는 단일 요소를 반환하거나, 배열이 비어 있거나 같은 값을 가진 요소가 둘 이상이면 null을 반환해요.

id로 메시지를 가져오는 데 쓰는 .query() 함수는 Spring Framework가 제공하는 Kotlin 확장 함수예요. 위 코드처럼 별도의 import import org.springframework.jdbc.core.query가 필요합니다.

  1. MessageController 클래스에 id 파라미터를 가진 새 index(...) 함수를 추가해요.
// MessageController.kt
package com.example.demo

import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.net.URI

@RestController
@RequestMapping("/")
class MessageController(private val service: MessageService) {
 @GetMapping
 fun listMessages() = ResponseEntity.ok(service.findMessages())

 @PostMapping
 fun post(@RequestBody message: Message): ResponseEntity<Message> {
 val savedMessage = service.save(message)
 return ResponseEntity.created(URI("/${savedMessage.id}")).body(savedMessage)
 }

 @GetMapping("/{id}")
 fun getMessage(@PathVariable id: String): ResponseEntity<Message> =
 service.findMessageById(id).toResponseEntity()

 private fun Message?.toResponseEntity(): ResponseEntity<Message> =
 // If the message is null (not found), set response code to 404
 this?.let { ResponseEntity.ok(it) } ?: ResponseEntity.notFound().build() 
}

컨텍스트 경로에서 값 가져오기 (Retrieving a value from the context path)

새 함수를 @GetMapping("/{id}")으로 애너테이션했기 때문에 메시지 id는 Spring Framework가 컨텍스트 경로에서 가져와요. 함수 인자를 @PathVariable로 애너테이션하면 프레임워크가 가져온 값을 함수 인자로 쓰라고 지시하는 셈입니다. 새 함수는 MessageService를 호출해서 개별 메시지를 id로 검색합니다.

널 가능 수신자를 가진 확장 함수 (Extension function with nullable receiver)

확장은 널 가능 수신자 타입으로 정의할 수 있어요. 수신자가 null이면 thisnull이 됩니다. 따라서 널 가능 수신자 타입으로 확장을 정의할 때는 함수 본문 안에서 this == null 검사를 수행하는 것이 권장됩니다.

toResponseEntity() 함수처럼 null 안전 호출 연산자(?.)를 사용해 null 검사를 수행할 수도 있어요.

 this?.let { ResponseEntity.ok(it) }

ResponseEntity

ResponseEntity는 상태 코드, 헤더, 본문을 포함한 HTTP 응답을 나타냅니다. 콘텐츠에 대해 더 많은 제어를 하면서 커스터마이즈된 HTTP 응답을 클라이언트로 보낼 수 있는 제네릭 래퍼죠.

다음은 애플리케이션의 전체 코드입니다.

// DemoApplication.kt
package com.example.demo

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class DemoApplication

fun main(args: Array<String>) {
 runApplication<DemoApplication>(*args)
}
// Message.kt
package com.example.demo

data class Message(val id: String?, val text: String)
// MessageService.kt
package com.example.demo

import org.springframework.stereotype.Service
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.query
import java.util.*

@Service
class MessageService(private val db: JdbcTemplate) {
 fun findMessages(): List<Message> = db.query("select * from messages") { response, _ ->
 Message(response.getString("id"), response.getString("text"))
 }

 fun findMessageById(id: String): Message? = db.query("select * from messages where id = ?", id) { response, _ ->
 Message(response.getString("id"), response.getString("text"))
 }.singleOrNull()

 fun save(message: Message): Message {
 val id = message.id ?: UUID.randomUUID().toString()
 db.update(
 "insert into messages values ( ?, ? )",
 id, message.text
 )
 return message.copy(id = id)
 }
}
// MessageController.kt
package com.example.demo

import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.net.URI

@RestController
@RequestMapping("/")
class MessageController(private val service: MessageService) {
 @GetMapping
 fun listMessages() = ResponseEntity.ok(service.findMessages())

 @PostMapping
 fun post(@RequestBody message: Message): ResponseEntity<Message> {
 val savedMessage = service.save(message)
 return ResponseEntity.created(URI("/${savedMessage.id}")).body(savedMessage)
 }

 @GetMapping("/{id}")
 fun getMessage(@PathVariable id: String): ResponseEntity<Message> =
 service.findMessageById(id).toResponseEntity()

 private fun Message?.toResponseEntity(): ResponseEntity<Message> =
 this?.let { ResponseEntity.ok(it) } ?: ResponseEntity.notFound().build()
}

애플리케이션 실행하기 (Run the application)

Spring 애플리케이션은 실행할 준비가 됐어요.

  1. 애플리케이션을 다시 실행해요.
  2. requests.http 파일을 열고 새 GET 요청을 추가해요.
### Get the message by its id
GET http://localhost:8080/id
  1. GET 요청을 실행해서 데이터베이스에서 모든 메시지를 검색해요.
  2. Run 도구 창에서 id 중 하나를 복사해 요청에 추가해요. 예를 들어:
### Get the message by its id
GET http://localhost:8080/f910aa7e-11ee-4215-93ed-1aeeac822707

위에 언급된 id 대신 자신의 메시지 id를 넣으세요.

  1. GET 요청을 실행하고 Run 도구 창에서 결과를 확인해요.

다음 단계 (Next step)

마지막 단계에서는 Spring Data를 사용한 더 널리 쓰이는 데이터베이스 연결 방법을 보여 줍니다.

더 알아보기 (Learn more)