본 예제는 Spring 5.x & Spring boot 2.3.0 버전을 사용합니다.
또한 Kotlin (v1.4.20)로 예제를 작성하며 IntelliJ CE를 사용합니다.
앞서 스프링 MVC를 이용한 웹 어플리케이션 개발에 이어 DB에 접근하는 방법에 대해서 얘기해 봅니다.
스프링에서 JDBC를 지원하기 위해서 기본적으로 JdbcTemplate를 사용합니다.
물론, JdbcTemplate을 사용하지 않고, dataSource.getConnection()부터 시작해서 DB에 접근할 수 있지만 JdbcTemplate를 사용함으로써 불필요한 코드들을 줄여 줍니다.
또한 JPA를 이용하면 JdbcTemplate보다 더 간소화하여 DB에 접근이 가능합니다.
이번 포스팅에서는 JdbcTemplate에 대해서 설명하고 다음 포스팅에 JPA를 사용하여 DB에 접근하는 방법을 설명합니다.
JdbcTemplate 환경설정
JdbcTemplate를 사용하기 위해서 아래 dependency를 pom.xml 파일에 추가합니다.
<dependencies>
...
<!-- JDBC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
...
</dependencies>
간단하게 내장 database를 사용할 것이므로 h2 db의 dependency도 같이 추가해 줍니다.
<dependencies>
...
<!-- H2-->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
...
</dependencies>
추가적으로 h2를 사용하기 위한 설정을 application.properties에 추가합니다.
spring.h2.console.enabled=true
spring.h2.console.path=/h2
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
이제 h2를 메모리 db로 사용하며 localhost:8080/h2 를 통하여 접속할 수 있습니다.
id는 sa이고, password는 지정하지 않았습니다.
Database sheme 설정
사용할 DB의 sheme를 정합니다.
제가 좋아하는 dept 테이블과 employee 테이블 두 개를 아래와 같이 만들어 보겠습니다.
create table if not exists employee (
id identity primary key auto_increment,
name varchar(25) not null,
address varchar(50) not null,
phoneNumber varchar(11) not null,
deptId bigint not null,
crDate timestamp not null
);
create table if not exists dept (
id identity primary key auto_increment,
deptName varchar(50) not null,
crDate timestamp not null
);
이 파일 이름은 scheme.sql로 설정합니다.
또한 테이블 생성 후 기본 데이터를 넣기 위해 아래와 같이 data.sql 이란 이름으로 파일을 만들어 아래 내용을 넣습니다.
insert into dept (deptname, crdate) values('infra', CURRENT_DATE());
insert into employee (name, address, phonenumber, deptid, crdate)
values ('admin', 'office','01011112222', '1', CURRENT_DATE());
이 두 개의 파일 위치는 /resource 아래에 위치합니다.
이렇게 /resource 밑에 scheme.sql과 data.sql 파일을 위치시켜 놓으면 application이 시작될 때 자동으로 로드되어 DB가 생성됩니다.
기본적인 설정은 마쳤습니다.
이제 이 테이블에 접근하기 위한 Model을 생성해 보겠습니다.
Model class 작성
테이블을 생성했으니 정보를 담을 Model class를 만들어 봅니다.
Model 클래스는 각각의 테이블의 정보를 담기 때문에 data class로 생성해 줍니다.
/**
* IMPL_NOTE: 값이 변경되기 때문에 var로 속성값을 넣는다.
*/
data class Employee(
var id: Long = -1,
var name: String? = null,
var address: String? = null,
var phoneNumber: String? = null,
var deptId: Long = -1,
var crDate: Date = Date())
/**
* IMPL_NOTE: 값이 변경되기 때문에 var로 속성값을 넣는다.
*/
data class Dept(
var id: Long = -1,
var deptName: String? = null,
var crDate: Date = Date()
)
data class를 사용하였기 때문에 getter와 setter를 따로 만들 필요가 없습니다.
또한 각 속성에 모두 기본값을 지정해 두었기 때문에 param이 없이도 객체를 생성할 수 있습니다.
ex) Employee() / Dept()로 param 없이 생성 가능.
Repository 생성
이제 각 테이블의 operation을 담당할 Repository를 만들어 봅니다.
먼저 각각 사용할 기본 함수를 갖는 interface를 만듭니다.
interface IEmployeeRepository {
fun findAll() : List<Employee>
fun findById(id: Long): Employee?
fun save(employee: Employee) : Employee
fun saveWithSimpleJdbcInsert(dept: Employee): Employee
}
interface IDeptRepository {
fun findAll(): List<Dept>
fun findById(id: Long): Dept?
fun save(dept: Dept): Dept
fun saveWithSimpleJdbcInsert(dept: Dept): Dept
}
함수명은 동일하며, 전체 검색, 아이디로 검색 및 저장을 위한 함수를 지정했습니다.
네 번째에 정의된 saveWithSimpleJdbcInsert는 save와 동일하나 SimpleJdbcInsert를 사용해서 좀 더 간단하게 입력하는 함수입니다.
interface 정의가 다 되었으니 이제 이를 상속받아 구현해 보겠습니다.
@Repository
class JdbcEmployeeRepository(@Autowired val jdbcTemplate: JdbcTemplate) : IEmployeeRepository {
...
override fun findAll(): List<Employee> {
...
}
override fun findById(id: Long): Employee? {
...
}
@Throws
override fun save(employee: Employee): Employee {
...
}
override fun saveWithSimpleJdbcInsert(employee: Employee): Employee {
...
}
}
먼저 Employee repository를 생성해 봅니다.
spring application context에 등록하기 위해 (연결하기 위해) @Repository annotation을 붙여 줍니다.
이렇게 해 놓으면 실제 이 repository를 사용하는 곳에서 간단히 injection 받아 사용이 가능해집니다.
그리고 class 생서자에서 JdbcTemplate를 injection 받아 사용합니다.
@Autowired annotation을 이용하여 자동으로 주입되도록 유도합니다.
@Repository
class JdbcEmployeeRepository(@Autowired val jdbcTemplate: JdbcTemplate) : IEmployeeRepository {
private val log = LoggerFactory.getLogger("JdbcEmployeeRepository")
val mapper = RowMapper<Employee> { resultSet, rowId ->
Employee(
resultSet.getLong("id"),
resultSet.getString("name"),
resultSet.getString("address"),
resultSet.getString("phoneNumber"),
resultSet.getLong("deptId"),
resultSet.getDate("crDate")
)
}
override fun findAll(): List<Employee> {
val sql = "select id, name, address, phoneNumber, deptId, crDate from employee"
return jdbcTemplate.query(sql, mapper)
}
...
}
findAll() 함수를 구현해 봅니다.
jdbcTemplate.query()를 이용하여 db의 데이터에 접근이 가능합니다.
이때 query는 기본적으로 두 개의 param을 받습니다.
첫 번째는 sql을 넣으면 되나 두 번째 param으로는 RowMapper 클래스를 전달받습니다.
따라서 읽어온 값을 Model로 반환할 수 있도록 RowMapper<Employee> 함수를 mapper란 변수에 담에 전달합니다.
해당 함수를 따라가 보면 아래와 같이 정의되어 있습니다.
//JdbcTempate.java
public <T> List<T> query(String sql, RowMapper<T> rowMapper) throws DataAccessException {
...
}
//RowMapper.java
@FunctionalInterface
public interface RowMapper<T> {
@Nullable
T mapRow(ResultSet var1, int var2) throws SQLException;
}
RowMapper는 functionalInterface로 query의 결과를 RessultSet에 담아 반환해 줍니다. (두번째 인자는 rowId 값입니다.)
findAll()은 반환 값이 여러 개일 수 있으므로 List 형태로 반환됩니다.
이제 param이 있는 findById 함수를 구현합니다.
override fun findById(id: Long): Employee? {
return jdbcTemplate.queryForObject(
"select id, name, address, phoneNumber, deptId, crDate " +
"from employee where id = ?", mapper, id
)
}
queryForObject()는 결괏값이 하나인 경우 호출하여 사용합니다.
고유 id값으로 조회하는 것이니 반환되는 객체가 하나겠죠?
이때 queryForObject() param은 (sql, mapper, binding 변수 1, binding 변수 2,...) 형태를 가집니다.
물론 위에서 언급한 다수의 결과를 반환하는 query() 함수 역시 binding 되어야 하는 값들이 있는 경우 param에 계속 넣어주면 됩니다.
save를 하기 위해서는 update() 함수를 사용합니다.
이는 저장, 변경 등 데이터를 변경하는데 공통으로 사용합니다.
@Throws
override fun save(employee: Employee): Employee {
//날짜값 새로 입력
employee.crDate = Date()
jdbcTemplate.update(
"insert into employee (name, address, phoneNumber, deptId, crDate) values(?,?,?,?,?)",
employee.name,
employee.address,
employee.phoneNumber,
employee.deptId,
employee.crDate
)
return employee
}
update() 함수 역시 첫 번째 인자는 sql, 그 뒤에는 필요한 binding 개수만큼 인자가 추가됩니다.
여기서 이 save 함수는 하나의 문제점을 안고 있습니다.
param으로 전달받은 employee 객체를 db에 write 한 후에 그대로 객체를 return 해 줍니다.
하지만 자세히 보면 insert의 column으로 id값은 빠져있는 걸 알 수 있습니다.
이는 sheme.sql로 table을 create 할 때 id는 PK + auto increment로 지정했기 때문입니다.
따라서 insert가 되면서 자동으로 값을 할당받았을 텐데 update() 함수로는 id에 무슨 값이 설정되었는지 알 수가 없습니다.
(그렇다고 다시 한번 query로 조회해 보는 건 하지 말아야겠죠?)
return 되는 employee객체의 id속성에는 여전히 기본값인 -1이 설정되어 있습니다.
이를 보완하기 위해 SimpleInsert를 이용하여 save 하는 방법을 알아봅니다.
override fun saveWithSimpleJdbcInsert(employee: Employee): Employee {
val simpleJdbcOrderInsert = SimpleJdbcInsert(jdbcTemplate).withTableName("employee")
.usingGeneratedKeyColumns("id")
//날짜값 새로 입력
employee.crDate = Date()
val values = mapOf(
"name" to employee.name,
"address" to employee.address,
"phoneNumber" to employee.phoneNumber,
"deptId" to employee.deptId,
"crDate" to employee.crDate,
)
val newId = simpleJdbcOrderInsert.executeAndReturnKey(values).toLong()
employee.id = newId
log.info("saveWithSimpleJdbcInsert() - employee:$employee")
return employee
}
먼저 SimpleJdbcInsert 객체를 생성합니다.
이때 생성자에는 jdbcTemplate이 들어가면 각각의 인자에는 아래 값이 설정됩니다.
- withTableName() -> 접근할 table 지정
- usingGeneratedKeyColumns() -> 자동 생성되는 key column을 지정
넣어야 할 데이터는 Map형태로 (칼럼명, 값) 형태로 생성하여 executeAndReturnKey()의 param으로 넘겨줍니다.
executeAndRetuenKey()는 생성된 이후 지정된 id값을 반환해 줍니다.
(만약 return값이 필요 없다면 execute(Map)을 호출해도 됩니다.)
전체 코드는 아래와 같습니다.
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.RowMapper
import org.springframework.jdbc.core.simple.SimpleJdbcInsert
import org.springframework.stereotype.Repository
import java.util.*
import kotlin.jvm.Throws
@Repository
class JdbcEmployeeRepository(@Autowired val jdbcTemplate: JdbcTemplate) : IEmployeeRepository {
private val log = LoggerFactory.getLogger("JdbcEmployeeRepository")
val mapper = RowMapper<Employee> { resultSet, rowId ->
Employee(
resultSet.getLong("id"),
resultSet.getString("name"),
resultSet.getString("address"),
resultSet.getString("phoneNumber"),
resultSet.getLong("deptId"),
resultSet.getDate("crDate")
)
}
override fun findAll(): List<Employee> {
val sql = "select id, name, address, phoneNumber, deptId, crDate from employee"
return jdbcTemplate.query(sql, mapper)
}
override fun findById(id: Long): Employee? {
return jdbcTemplate.queryForObject(
"select id, name, address, phoneNumber, deptId, crDate " +
"from employee where id = ?", mapper, id
)
}
@Throws
override fun save(employee: Employee): Employee {
//날짜값 새로 입력
employee.crDate = Date()
jdbcTemplate.update(
"insert into employee (name, address, phoneNumber, deptId, crDate) values(?,?,?,?,?)",
employee.name,
employee.address,
employee.phoneNumber,
employee.deptId,
employee.crDate
)
return employee
}
override fun saveWithSimpleJdbcInsert(employee: Employee): Employee {
val simpleJdbcOrderInsert = SimpleJdbcInsert(jdbcTemplate).withTableName("employee")
.usingGeneratedKeyColumns("id")
//날짜값 새로 입력
employee.crDate = Date()
val values = mapOf(
"name" to employee.name,
"address" to employee.address,
"phoneNumber" to employee.phoneNumber,
"deptId" to employee.deptId,
"crDate" to employee.crDate,
)
val newId = simpleJdbcOrderInsert.executeAndReturnKey(values).toLong()
employee.id = newId
log.info("saveWithSimpleJdbcInsert() - employee:$employee")
return employee
}
이제 Dept부분을 처리해 봅니다.
기본적으로 Employee repository와 동일합니다.
다만save() 함수의 부분만 다르니 이 부분만 따로 떼내서 보도록 합니다.
@Repository
class JdbcDeptRepository(@Autowired val jdbcTemplate: JdbcTemplate) : IDeptRepository {
private val log = LoggerFactory.getLogger("AddressController")
val mapper = RowMapper<Dept> { resultSet, rowId ->
Dept(
resultSet.getLong("id"),
resultSet.getString("deptName"),
resultSet.getDate("crDate")
)
}
override fun findAll(): List<Dept> {
return jdbcTemplate.query("select id, deptName, crDate from dept", mapper)
}
override fun findById(id: Long): Dept? {
val sql = "select id, deptName, crDate from dept where id = ?"
return jdbcTemplate.queryForObject(sql, mapper, id)
}
@Throws
override fun save(dept: Dept): Dept {
...
}
override fun saveWithSimpleJdbcInsert(dept: Dept): Dept {
val simpleJdbcOrderInsert = SimpleJdbcInsert(jdbcTemplate).withTableName("dept")
.usingGeneratedKeyColumns("id")
//날짜값 새로 입력
dept.crDate = Date()
val values = mapOf("deptName" to dept.deptName, "crDate" to dept.crDate)
val newId = simpleJdbcOrderInsert.executeAndReturnKey(values).toLong()
dept.id = newId
log.info("saveWithSimpleJdbcInsert() - dept:$dept")
return dept
}
}
employee table에는 dept id를 foreignKey로 가지고 있습니다.
따라서 정보 입력 시 dept 정보를 먼저 write 한 후에 생성된 id를 employee에 넘겨줘야 합니다.
그래야 employee 테이블에 정보를 넣을 때 deptId를 넣을 수 있습니다.
이는 위에서 언급한 SimpleJdbcInsert를 이용하면 되며 saveWithSimpleJdbcInsert() 함수에 구현되어 있습니다.
하지만 위 save() 함수에서는 SimpleJdbcInsert를 사용하지 않고 PreparedStatementCreatorFactory를 사용하는 방법을 써 봅니다.
미리 말해두지만 이게 더 코드량이 많습니다.
하지만 기존 코드들에서 이 방법이 사용되었을 수도 있으니 알아는 둬야 하겠죠?
@Throws
override fun save(dept: Dept): Dept {
...
val sql = "insert into dept (deptName, crDate) values(?,?)"
val param = listOf(dept.deptName, dept.crDate)
// 반환값으로 생성된 key를 전달 받는다.
val preparedStmt = PreparedStatementCreatorFactory(sql, Types.VARCHAR, Types.TIMESTAMP).apply {
setReturnGeneratedKeys(true)
setGeneratedKeysColumnNames("id")
}.newPreparedStatementCreator(listOf(dept.deptName, dept.crDate))
val keyHolder = GeneratedKeyHolder()
jdbcTemplate.update(preparedStmt, keyHolder)
// 생성된 key 설정
dept.id = keyHolder.key?.toLong() ?: -1
log.info("save() - $dept")
return dept
}
update() 함수에 sql과 param을 넣지 않고 아래와 같은 signature 정의된 upate()를 사용합니다.
//JdbcTemplate.java
public int update(PreparedStatementCreator psc, KeyHolder generatedKeyHolder)
throws DataAccessException {
...
}
첫 번째 인자인 PreparedStatementCreator를 만들기 위해서 PreparedStatementCreatorFactory를 사용합니다.
이때 첫 번째 인자로는 sql, 그리고 update 할 값들의 type을 하나씩 param에 추가해 줍니다.
여기서는 deptName과 crDate 두 개의 값만 넣을 것이므로 Types.VARCHAR, Types.TIMESTAMP를 지정했습니다.
그리고 key값인 id를 return 받기 위해 PreparedStatementCreatorFactory의 함수인 setReturnGeneratedKeys(), setGeneratedKeysColumnNames()을 설정합니다.
마지막으로 newPreparedStatementCreator로 PreparedStatementCreator를 생성하면서 실제 업데이트할 값을 list형태로 넘겨줍니다.
이렇게 생성한 PreparedStatementCreator값과 keyHolder를 update의 인자로 넘겨주면 keyHolder에 새로 추가된 key값이 넣어져 반환 됩니다.
설명이 길긴 하지만 사실 복잡하지는 않습니다.
다만 SimpleJdbcInsert보다는 코드량이 좀 있습니다.
Controller의 처리
db operation을 할 준비가 끝났으므로 이제 이를 처리할 화면과 controller를 만들어 봅니다.
먼저 입력을 처리할 html form을 만들겠습니다.
Employee와 Dept 객체에 저장할 위와 같은 정보를 입력받습니다.
Html을 아래와 같이 구성하여 /resource/templates/employee_dept_input.html에 위치시킵니다.
...
<body>
<h1>Register Employee & Dept</h1>
<form method="POST" th:action="@{/employee_regi}" th:object="${employee}">
<label for="name">Username: </label>
<input type="text" th:field="*{name}"/><br/>
<label for="address">Address: </label>
<input type="text" th:field="*{address}"/><br/>
<label for="phoneNumber">PhoneNumber: </label>
<input type="text" th:field="*{phoneNumber}"/><br/>
<label for="deptName">Dept. Name: </label>
<input type="text" th:field="${dept.deptName}"/><br/>
<input type="submit" value="Register"/>
</form>
</body>
...
form을 보면 "employee" 객체를 넘겨받아 입력받은 정보를 해당 객체에 채웁니다.
Dept. Name 항목에서 전달받은 정보는 "dept" 란 이름의 객체에 저장합니다.
따라서 controlloer에서 두 개의 객체를 modelAttribute로 넘겨줘야겠죠?
@Slf4j
@Controller
@RequestMapping("/employee_regi")
@SessionAttributes("employee", "dept")
class EmployeeController(
@Autowired val employeeRepository: IEmployeeRepository,
@Autowired val deptRepository: IDeptRepository) {
private val log = LoggerFactory.getLogger("EmployeeController")
@GetMapping
fun showAddressView(): String {
return "employee_dept_input"
}
@ModelAttribute(name = "employee")
fun getEmployee() = Employee()
@ModelAttribute(name = "dept")
fun getDept() = Dept()
@PostMapping
fun processForm(
...
}
}
Controller에서는 생성자로 만들어 놓은 두 개의 repository를 전달받습니다.
두 개의 repository에 @Repository annotation을 붙였기 때문에 spring에서 알아서 생성자를 채워 줍니다.
@GetMapping 함수를 이용하여 localhost:8080/employee_regi 경로로 접근했을 때 "employee_dept_input.html"이 보이도록 showAddressView() 함수를 설정합니다.
그리고 html내부에서 넣은 데이터를 등록받기 위해서 @ModelAttribute를 이용해서 Employee() 객체와 Dept() 객체를 등록시킵니다.
이 두 개의 객체는 데이터 처리 이후에도 다른 경로에서 등록 결과를 보여주기 위해 session에 저장합니다.
session에 저장하기 위해서 class 선언 상단에 @SessionArrtibutes를 이용하여 두 개의 객체를 등록해 줍니다.
따라서 이 페이지를 벗어나더라도 다른 곳에서 session에서 이 객체들을 꺼내서 쓸 수 있습니다.
마지막으로 form으로 전달받은 데이터를 처리하는 @PostMapping 부분은 아래와 같습니다.
@PostMapping
fun processForm(
@ModelAttribute(name = "employee") employee: Employee,
@ModelAttribute(name = "dept") dept: Dept
): String {
log.info("dept: $dept | employee: $employee")
// 부서를 먼저 넣는다.
// val insertedDept = deptRepository.save(dept)
val insertedDept = deptRepository.saveWithSimpleJdbcInsert(dept)
// 부서 생성으로 받아온 id를 넣는다.
employee.deptId = insertedDept.id
//employeeRepository.save(employee)
val insertedEmp = employeeRepository.saveWithSimpleJdbcInsert(employee)
employee.id = insertedEmp.id
return "redirect:/regi_complete
}
먼저 dept 객체를 저장하여 dept id를 생성합니다.
그리고 생성된 id를 employee.deptId에 넣은 이후에 employee객체를 저장합니다.
마지막으로 저장된 결과를 표시하기 위해서 아래와 같이 html을 작성합니다.
<!-- employee_regi_complete.html-->
<body>
<h1>Register Employee & Dept Completed</h1>
<br>
<ul>
<li>Employee ID:<div th:text="${session.employee.id}">사번</div></li>
<li>Name:<div th:text="${session.employee.name}">이름</div></li>
<li>Address:<div th:text="${session.employee.address}">주소</div></li>
<li>PhoneNumber:<div th:text="${session.employee.phoneNumber}">전화번호</div></li>
<li>Dept ID:<div th:text="${session.employee.deptId}">부서 ID</div></li>
<li>DeptName:<div th:text="${session.dept.deptName}">부서명</div></li>
</ul>
</body>
전달받은 정보를 표시할 때 session을 통해서 접근합니다.
이 controller는 딱히 하는 일이 없으니 webConfigure에 간단히 등록해서 controller를 대신하도록 하겠습니다.
@Configuration
class WebConfigure : WebMvcConfigurer {
override fun addViewControllers(registry: ViewControllerRegistry) {
...
registry.addViewController("/regi_complete").setViewName("employee_regi_complete")
}
}
동작 테스트
아래와 같이 정보를 넣어 봅니다.
Register를 누르면 아래 화면이 나타납니다.
일단 페이지 이동이 잘 되는 것과 session에 객체가 잘 담겨 있는 걸 확인했으니 DB도 확인해 보겠습니다.
포스팅 초반에 H2 db를 설정하면서 경로를 /h2로 설정했습니다.
아래와 같이 주소를 넣으면 h2 db에 접근할 수 있습니다.
application.properties에 입력해 놓은 값들이 잘 들어가 있네요.
Connect를 눌러 H2 db에 접근합니다.
select query를 날려보면 두 개의 테이블이 잘 생성되어 있으며, 기본값으로 지정해 놓은 데이터도 잘 들어가 있고, form에서 추가한 Captain America와 부서도 잘 들어가 있는 걸 확인할 수 있습니다.
JdbcTemplate을 이용하여 원하는 형태의 sql을 명시적으로 custom 하게 만드는 장점이 있습니다.
JPA를 이용하면 훨씬 더 간단하게 DB에 접근할 수 있으나, 코드가 간략해지는 만큼 자동으로 생성되고 처리되는 규칙에 따라야 한다는 단점도 있습니다.
다음 포스팅에서 JPA에 대한 사용법을 확인해 보도록 하겠습니다.
'개발이야기 > Spring & Ktor Framework' 카테고리의 다른 글
[Spring] 스프링 인증 (Security) - with kotlin #6 (0) | 2020.12.30 |
---|---|
[Spring] 스프링 DB 접근 및 활용 - JPA (2/2) with kotlin #5 (0) | 2020.12.29 |
[Spring] lombok 오류 - java: variable type not initialized in the default constructor (0) | 2020.12.23 |
[Spring] 스프링 - Form 유효성 체크 with kotlin #3 (2) | 2020.05.28 |
[Spring] 스프링 - Web application 구현 with kotlin #2 (0) | 2020.05.27 |