스프링부트에서 DB를 연결하려면 크게 ORM 방식과 SQL Mapper가 있다.
ORM(Object Relational Mapping)
SQL Mapper
수업에서는 SQL Mapper부터 먼저 사용해봤다.
그중에서 MyBatis를 사용해보자.
방명록 CRUD 하는 예제를 풀어보면서 MyBatis를 사용해보자.
build.gradle에서 사용할 DB(MySQL)를 디펜던시에 작성해준다.
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.1.2'
runtimeOnly 'com.mysql:mysql-connector-j'
공식문서를 참고해 MyBatis 버전과 맞는 스프링부트 맞춰주어야 한다. 현재는 3.0이나 3.1 버전이어야 MyBatis가 사용 가능하다.
resource/application.properties
는 스프링부트 애플리케이션의 속성을 설정하는 파일이다. 키-값을 한 쌍으로 내용을 작성한다.
ex) DB 연결, 로깅, 서버 포트 변경
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/{DB이름}?
useUnicode=yes&characterEncoding=UTF-8&allowMultiQueries=true&serverTimezone=Asia/Seoul
spring.datasource.username=root
spring.datasource.password={MySQL 비밀번호}
# 패키지 별칭
mybatis.type-aliases-package=com.spring.boot.mapper
# xml 파일 위치
mybatis.mapper-locations=mybatis-mapper/*.xml
datasource는 자바에서 connection pool을 지원해주는 인터페이스다.
JDBC 사용 시 데이터베이스에 접근할 때마다 connection의 연결되는데, connection pool에 connection을 미리 저장해두어 DB 접근 시의 connection 작업을 가볍게 만든다.
xml 경로는 mybatis-mapper 패키지 안의 모든 xml 파일로 설정해줬다.
cf. application.yml
properties와 마찬가지로 속성 설정을 할 수 있는데 구조가 좀더 단순하다. 하지만 application.properties가 더 높은 우선순위라서 병행 시 설정을 덮어쓰진 않는지 확인해야 한다.spring: datasource: driver-class-name: {사용할 DBMS} url: {로컬 호스트} username: root
방명록에 들어갈 정보로 제목, 내용, 작성자, 작성일이고 수정과 삭제를 위한 조건으로 id를 PK 및 auto_increment로 해주었다.
CREATE DATABASE sesac DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
use sesac;
create table board(
id int primary key auto_increment not null,
title varchar(20) not null,
content varchar(100) not null,
writer varchar(10) not null,
registered DATETIME DEFAULT NOW()
);
설정은 여기서 끝났고, 이제 본격적으로 코드를 작성할 때다. 전체적인 흐름 파악을 위해 스프링부트의 MVC 패턴부터 살펴보자.
Brower <-> Controller <-> Service <-> Mapper(DAO*) <-> DB
오른쪽 방향으로는 호출(요청)하고, DB에 접속한 것을 기점으로 데이터 반환이 이루어진다. 자세하게 설명하자면 이렇다.
Controller가 브라우저의 요청을 받고, 이 요청에 대한 비즈니스 로직을 처리하기 위해 Service를 호출.
Service는 하나 이상의 Mapper를 호출하여 DB와 상호작용한다. 즉 DAO의 결과값을 받는다.
Mapper는 SQL 쿼리를 실행하고 그 결과를 Domain 객체로 매핑, 이후 Service에 반환한다.
Controller가 최종적으로 이 과정의 결과를 브라우저에 반환한다.
개인적으로 여기서 Domain과 DTO의 역할이 아리송했다. 둘다 객체인 건 동일해서 하나로 통일해도 될 것 같은데, 각각 만든다는 건 둘의 쓰임이 다르단 거다. 뭐가 다른 걸까?
Domain 객체
비즈니스 로직을 담고 있는 객체로 SQL 쿼리 결과를 매핑한다. Mapper가 SQL문을 실행하는 DAO*니까 이 내용은 Mapper에 해당된다.
ex.
@Mapper
public interface BoardMapper {
...
void insertBoard(Board board);
...
또, Service 단에서 전달받은 DTO 값으로 Domain 객체를 생성하고, 필요한 데이터를 설정한다.
ex.
public void insertBoard(BoardDTO boardDTO){ // DTO 받아옴
Board board = new Board(); // 도메인 객체 생성
board.setTitle(boardDTO.getTitle()); // 전달 받은 DTO로 도메인 객체 set
...
}
즉 비즈니스 로직이 담긴 객체이자 로직에 따라 데이터를 가공하는 객체라고 볼 수 있다.
DTO(Data Transfer Object)
계층 간 데이터를 전달하는 순수한 데이터 객체(Java Beans)이다. 계층 내 데이터 전달이 목적이라서 필요한 데이터만을 포함해서 가볍게 설계하는 게 좋다. 그래야 불필요한 정보 교환이 줄고 가독성을 높아진다.
ex.
// DTO
@Getter
@Setter
public class BoardDTO {
private int id;
private String title, content, writer, registered;
private int no;
}
DAO(Data Access Object)
데이터베이스에 접근해서 SQL 쿼리 실행하고, CRUD하는 메서드를 제공한다.
이렇게 비즈니스 로직과 데이터베이스 상호작용 로직을 분리할 수 있어 모듈화와 유지보수에 도움이 된다.
기본 개념을 숙지한 채로 웹 브라우저의 요청을 수행할 컨트롤러부터 작성했다. 예제로 제공받은 View 파일엔 방명록 CRUD, 그리고 검색 기능까지 있어서 총 5개의 컨트롤러가 필요하다. 하지만 포스팅에서는 검색 기능은 생략하고 CRUD에 집중하고자 한다.
@Controller
어노테이션으로 정의한 BoardController 클래스는 Service를 호출해야 한다. 이를 @Autowired
로 간단히 불러와주고, 메서드를 매핑해준다.
@Controller
@RequestMapping("/board")
public class BoardController {
@Autowired
BoardService boardService;
...
생성과 수정은 필드가 여러개라서 하나 이상의 변수가 필요하므로 Service 단에 DTO를 넘겨준다. Restful하게 작성하고자 하므로 각각 Post와 Patch 매핑을 이용해준다. 그리고 동적 폼전송이므로 url 쿼리 스트링이 아니라 Body에 데이터가 담긴다.
// Create
@PostMapping("")
@ResponseBody
public void insertBoard (@RequestBody BoardDTO boardDTO){
boardService.insertBoard(boardDTO);
}
// Update
@PatchMapping("")
@ResponseBody
public void patchBoard(@RequestBody BoardDTO boardDTO){
boardService.patchBoard(boardDTO);
}
POST 메서드는 url에 변화를 주지 않으므로 @ResponseBody
와 @RequestBody
을 활용해야 한다.
@RequestBody
HTTP 요청 시 클라이언트가 서버로 전송하는 데이터. 종류는 두 가지다. 먼저 폼 데이터는 application/x-www-form-urlencoded나 multipart/form-data 형식으로 데이터를 담는다.
JSON 데이터(axios, fetch)는 application/json 형식으로 담긴다.
application/json 형식인데 왜 @RequestBody를?
클라이언트가 보내는 데이터 형식이 다양할 수 있기 때문에(위에서 보듯 폼 데이터로 전달할 수도 있음) 일관성 유지를 위해 동적 폼 전송 시에도 @RequestBody로 받는다. 물론 폼 데이터도 같은 어노테이션 사용 가능.
@ResponseBody
서버가 클라이언트에 전송하는 데이터. 스프링부트의 MVC 컨트롤러 메서드는 반환값을 뷰 이름으로 해석한다. 그래서 View를 통해 데이터를 전달해야 했다. Model의 addAttribute를 사용하는 식으로 말이다. 하지만 이 어노테이션을 사용함으로써 클라이언트로 직접 데이터를 전송할 수 있다.
삭제는 위 내용과 거의 같지만 Domain 객체가 아닌 변수 하나만 오간다. 클라이언트에서 axios 요청을 Query String으로 보내기 때문이다.
// Delete
@DeleteMapping("")
@ResponseBody
public void deleteBoard(@RequestParam int id){
boardService.deleteBoard(id);
}
DB에 변경 사항을 저장(C, U, D)해야 할 땐 컨트롤러에서 DTO를 서비스단에 넘기고, 서비스에서 도메인을 DTO 내용으로 set한 후에 Mapper로 전달한다. 하지만 Read는 거꾸로다. 도메인과 Mapper로 DB에 접근해 데이터를 받아온 후, 이를 DTO에 담아 View에 전달한다.
// Read
@GetMapping("")
public String getBoard(Model model){
List<BoardDTO> result = boardService.boards();
model.addAttribute("boards", result);
return "board";
}
컨트롤러에서 서비스 단으로 열심히 보내고 있는 객체 파일을 살펴보자.
// 도메인
@Getter
@Setter
public class Board {
private int id;
private String title;
private String content;
private String writer;
private String registered;
}
DTO 파일은 위의 예시로 작성해서 생략했다.
이제 Service, Mapper, 그리고 SQL문이 담긴 xml 파일을 만들어보자.
서비스에서는 각 기능에 필요한 객체 값을 변경해주고, 이를 Mapper에 전달한다. 즉 컨트롤러에서 넘겨준 DTO로 값에 접근하여 이를 도메인 객체에 업데이트(set) 해준다.
// service
@Service
public class BoardService {
@Autowired
BoardMapper boardMapper;
...
// Create
public void insertBoard(BoardDTO boardDTO){
Board board = new Board();
board.setTitle(boardDTO.getTitle());
...
boardMapper.insertBoard(board);
}
// Update
public void patchBoard(BoardDTO boardDTO){
Board board = new Board();
board.setId(boardDTO.getId());
board.setTitle(boardDTO.getTitle());
...
boardMapper.patchBoard(board);
}
// Delete
public void deleteBoard(int id){
boardMapper.deleteBoard(id);
}
}
Read는 앞서 언급했듯이 도메인을 set하는 게 아니고, 도메인에 담긴 값을 역으로 DTO에 업데이트 해주어야 하기에 객체를 순회하며 값을 바꾸어 주었다. 그래서 Mapper를 호출하는 게 아니라 단순히 값을 리턴해준다.
// Read
public List<BoardDTO> boards(){
List<Board> result = boardMapper.boards();
List<BoardDTO> boards = new ArrayList<>();
for (Board board:result) {
BoardDTO boardDTO = new BoardDTO();
boardDTO.setId(board.getId());
...
boardDTO.setNo(100 + board.getId());
boards.add(boardDTO);
}
return boards;
}
참고로 setNo
에 100을 더해준 건 id와 다르게 숫자를 구분해 주었을 뿐 기술적인 이유가 따로 있는 건 아니다.
서비스단에서 호출한 Mapper를 보면 이렇다.
// Mapper
@Mapper
public interface BoardMapper {
List<Board> boards(); // Read
void insertBoard(Board board); // Create
void patchBoard(Board board); // Update
void deleteBoard(int id); // Delete
}
도메인 객체로 DB와 상호작용하기 때문에 호출만 해준다. 호출하는 내용, 즉 SQL문은 Mapper 내에서 어노테이션을 사용하는 대신 xml 파일에 작성했다. MyBatis는 Java 코드와 SQL문 분리해서 작성할 수 있다는 장점이 있어서 이 특징을 잘 활용하고 싶었다.
xml 파일은 최상단에 mybatis 관련 설정을 꼭 해주어야 한다. 반드시 최상단이다. 그리고 <mapper>
태그 안에 <select>
등 작성하고 싶은 SQL문을 형식에 맞게 적어주면 된다.
<mapper>
에는 namespace가, SQL문은 id 속성이 필수다. 그리고 어떤 SQL문이냐에 따라 resultType이나 parameterType을 명시해야 한다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sesac.sesacspring.mybatis.mapper.BoardMapper">
<select id="boards"
resultType="com.sesac.sesacspring.mybatis.domain.Board">
SELECT * FROM board
</select>
<insert id="insertBoard" parameterType="com.sesac.sesacspring.mybatis.domain.Board">
INSERT INTO board(id, title, content, writer, registered)
VALUES(#{id}, #{title}, #{content}, #{writer}, NOW())
</insert>
<delete id="deleteBoard" parameterType="Integer">
DELETE FROM board WHERE id=#{id}
</delete>
...
반환 타입이 객체일 땐 도메인 객체 경로를 적어주었는데, map
으로 적으면 프레임워크가 알아서 찾아준다. 하지만 전자가 정확하긴 하다.
delete문은 객체가 아니기 때문에 id 변수의 타입인 정수형으로 작성해줬다.
이렇게 기나긴.. 예제 풀이 정리가 끝났다. 이해하는 과정에서 숱한 트러블을 만나 끝으로 이 내용을 후술하려고 한다.
create table boardUser(
id int primary key auto_increment not null,
title varchar(20) not null,
content varchar(100) not null,
writer varchar(10) not null,
registered DATETIME DEFAULT NOW()
);
// DTO
package com.sesac.sesacspring.dto;
import lombok.Getter;
import lombok.Setter;
import java.sql.Timestamp;
@Getter
@Setter
public class BoardDTO {
private int id;
private String title;
private String content;
private String writer;
private Timestamp registered;
}
원인
작성일은 input value로 값을 받는 다른 필드와 다르게 DB에 레코드 생성 시 자동으로 채워지는 필드이다. 따라서 변수#{registered}
가 아닌 현재 날짜/시간을 담는 함수를 실행시켜주어야 한다.
해결
NOW() 함수 실행.
// Mapper.java
@Insert("INSERT INTO board(id, title, content, writer, registered)
VALUES(#{id}, #{title}, #{content}, #{writer}, NOW())")
void createBoard(BoardDTO boardDTO);
[[${}]]
<tr th:each="board:${boards}">
...
<th><button type="button" onclick="deleteBoard([[${board.id}]])">삭제</button></th>
</tr>
(삽질) 매개변수로 전달하는 변수 형태가 문제인가 하고, board.id
부분을 어떻게 고칠지만 고심했다.
uncaught Syntaxerror: unexpected token '{'
board.id
) :uncaught syntaxerror: missing ) after argument list
에러메시지 중 문법 오류라는 것에 힌트가 있었다.. 타임리프 문법임을 나타내주는 th:
접두사를 붙이지 않아서 생긴 에러였다.
th:onclick
으로 작성 <tr th:each="board:${boards}">
<th th:text="${board.id}"></th>
<th>익명</th>
<th th:text="${board.title}"></th>
<th th:text="${board.writer}"></th>
<th th:text="${board.registered}"></th>
<th><button>수정</button></th>
<th><button type="button" th:onclick="deleteBoard([[${board.id}]])">삭제</button></th>
</tr>
1️⃣
상황
400 에러 발생.
원인
클라이언트에서 쿼리 스트링에 담아서 요청했는데 서버 측은 @RequestBody로 잘못 기입.
해결
public void deleteBoard(@RequestParam int id){
boardService.deleteBoard(id);
}
2️⃣
상황
콘솔에는 아무 에러가 없는데 클라이언트, DB 모두 값이 업데이트 되지 않음.
원인
서비스에서의 로직을 잘못 이해했다. id는 자동으로 1씩 증가하는 PK인데 왜 setter를 사용해서 값을 기입하는지 의문이었는데, DTO에서 받은 id 값을 Board 객체에 설정해주어 DB 레코드를 식별하는 것이었다.
해결
도메인 객체의 ID 업데이트 부분 추가.
public void patchBoard(BoardDTO boardDTO){
Board board = new Board();
board.setId(boardDTO.getId()); // <-- 여기
...
}
상황
405 에러 발생.
원인
@PatchMapping("/")로 작성해서 @RequestMapping("/board") 뒤에 슬래시 추가된 URL /board/
로 잘못 매핑하여 HTTP 메서드가 일치하지 않는다고 알려준 것.
해결
클라이언트에서 요청하는 URL과 동일하게 작성
@PatchMapping("")
...