@Mapper interface와 mapper XML — 명세와 SQL의 분리selectKey로 시퀀스 값 받아오기@PathVariable, @RequestHeader로 값 받기SEQUENCE 추가toBuilder()@Transactional(readOnly = true)HTTP는 이전 요청의 상태를 기억하지 않는다. 클라이언트의 이전 상태 정보를 유지하기 위한 메커니즘으로 과거에는 Session을 사용했다.
Session -> JWT
웹이 SPA로 바뀌면서 front에는 Session의 개념이 없어졌다. 화면 전환이 서버 요청이 아니라 브라우저 안에서 일어나기 때문이다. 그 자리를 Token이 대신한다.
Token
- header
- payload == data
- signature
payload가 곧 데이터다. signature는 이 토큰이 위조되지 않았음을 확인하는 자리다.
토큰은 요청 헤더에 실려 온다. 아래 read 요청에서 Authorization 헤더를 받는 부분이 그 자리다.
@Mapper가 붙은 interface에는 메서드 선언만 있고 구현이 없다.
@Mapper
public interface BlogMapper {
public List<BlogResponseDTO> findByAll();
public int save(BlogRequestDTO request);
public Optional<BlogResponseDTO> findById(Integer id);
}
@Mapper
public interface CommentMapper {
public List<CommentResponseDTO> findByBlogId(Integer blogId);
public int save(CommentRequestDTO request);
}
interface는 "무엇을 호출할 수 있는가"만 정하고, 실제 SQL은 여기에 없다. service 쪽에서는 이 명세만 보고 호출한다.
반환 타입이 계층 사이의 약속이 된다. findById()는 없을 수 있으므로 Optional<BlogResponseDTO>, findByBlogId()는 없으면 빈 List, save()는 처리 건수인 int다.
SQL은 XML에 따로 쓴다. 둘을 잇는 것은 이름이다.
<mapper namespace="com.example.inspire_mybatis.features.blogs.repository.BlogMapper">
<!-- findByAll -->
<select id="findByAll"
resultType="com.example.inspire_mybatis.features.blogs.domain.dto.BlogResponseDTO">
SELECT
ID, TITLE, CONTENT, CATEGORY, EMAIL
FROM
spring_blog_tbl
</select>
| XML | 대응 |
|---|---|
namespace | interface의 전체 경로 |
id | interface의 메서드 이름 |
parameterType | 파라미터 타입 |
resultType | 한 행을 담을 DTO |
resultType에 DTO를 적어두면 조회 결과가 그 타입으로 만들어져 나온다. 컬럼 이름과 필드 이름이 맞으면 되고, 객체를 채우는 일은 framework이 한다.
<!-- framework이 내부적으로 builder 패턴으로 할당함 -->
findByAll()의 반환 타입은 List인데 XML의 resultType은 BlogResponseDTO 하나다. resultType은 한 행에 대한 타입이고, 여러 행이면 알아서 List로 모인다.
입력은 #{}로 파라미터 객체의 필드를 꺼내 쓴다. ID는 시퀀스에서 채운다.
<!-- save -->
<insert id="save"
parameterType="com.example.inspire_mybatis.features.blogs.domain.dto.BlogRequestDTO">
INSERT INTO spring_blog_tbl(ID, TITLE, CONTENT, CATEGORY, EMAIL)
VALUES(NEXTVAL(seq_blog), #{title}, #{content}, #{category}, #{email})
</insert>
controller에 @RequestMapping으로 공통 경로를 두고 각 메서드에 세부 경로를 붙였다.
@RestController
@RequestMapping("/blogs")
@RequiredArgsConstructor
public class BlogController {
private final BlogService blogService;
// endPoint : http:// ip : port / blogs / index
@GetMapping("/index")
public ResponseEntity<?> index() {
json-server를 쓰던 때와 경로가 달라졌으므로 front의 요청 주소도 함께 고쳤다.
await api.get(`/blogs/index`)
await api.post('/blogs/insert', {
title,
content,
category,
email : user
})
await api.post('/comments/insert', {blogId : Number(blogId), comment, email}, {
headers : {Authorization : at ? at : ""}
})
api.post의 세 번째 인자가 설정 객체다. 두 번째 인자인 body와 자리가 다르다.
목록은 조회 결과를 그대로 body에 담는다.
@GetMapping("/index")
public ResponseEntity<?> index() {
System.out.println("debug >>>> blog controller index");
List<BlogResponseDTO> list = blogService.list();
// return list.isEmpty()
// ? ResponseEntity.status(HttpStatus.NO_CONTENT).build()
// : ResponseEntity.status(HttpStatus.OK).body(list);
return ResponseEntity.status(HttpStatus.OK).body(list);
}
비어 있을 때 NO_CONTENT를 돌려주는 분기는 주석으로 남겨두고, 현재는 항상 OK를 반환한다.
입력은 @RequestBody로 JSON을 DTO에 바인딩하고, 처리 건수로 상태 코드를 가른다.
@PostMapping("/insert")
public ResponseEntity<?> insert(@RequestBody BlogRequestDTO request) {
System.out.println("debug >>>> blog controller insert");
System.out.println("debug >>>> blog controller insert request : " + request);
int insertFlag = blogService.insert(request);
return insertFlag != 0
? ResponseEntity.status(HttpStatus.CREATED).build()
: ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
service는 mapper로 그대로 넘긴다.
public List<BlogResponseDTO> list() {
System.out.println("debug >>>> blog service list ");
return blogMapper.findByAll();
}
public int insert(BlogRequestDTO request) {
System.out.println("debug >>>> blog service insert : " + request);
return blogMapper.save(request);
}
경로에 포함된 값은 @PathVariable로, 헤더는 @RequestHeader로 받는다.
@GetMapping("/read/{id}")
public ResponseEntity read( @PathVariable("id") Integer id,
@RequestHeader("Authorization") String at ) {
System.out.println("debug >>>> blog controller read");
// token은 원래 앞단에서 처리되어 controller에서 다룰 일은 없지만 시범용
System.out.println("debug >>>> blog controller read access token : " + at);
System.out.println("debug >>>> blog controller read params : " + id);
BlogResponseDTO response = blogService.read(id);
System.out.println("debug >>>> blog controller read result : " + response);
// status code : NOT_FOUND(404), OK(200)
return response != null
? ResponseEntity.status(HttpStatus.OK).body(response)
: ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
@GetMapping("/read/{id}")의 중괄호 이름과 @PathVariable("id")의 이름이 같아야 한다.
조회 SQL은 키 하나로 거른다.
<!-- findById -->
<select id="findById"
parameterType="java.lang.Integer"
resultType="com.example.inspire_mybatis.features.blogs.domain.dto.BlogResponseDTO">
SELECT
ID, TITLE, CONTENT, CATEGORY, EMAIL
FROM
spring_blog_tbl
WHERE
ID = #{id}
</select>
XML 쪽은 한 행짜리 BlogResponseDTO를 지정하고 있지만 interface의 반환 타입은 Optional<BlogResponseDTO>다. 결과가 없을 수 있다는 사실을 java 쪽 타입으로 표시한 것이다.
토큰 검증은 원래 controller에 도달하기 전 단계에서 처리되고, 여기서는 헤더가 어떻게 넘어오는지 확인하기 위해 출력만 했다.
댓글을 저장할 테이블을 추가했다.
CREATE SEQUENCE seq_comment;
CREATE TABLE spring_comment_tbl (
ID INT PRIMARY KEY,
COMMENT VARCHAR(200) NOT NULL,
EMAIL VARCHAR(50) REFERENCES spring_user_tbl(EMAIL),
BLOGID INT REFERENCES spring_blog_tbl(ID)
);
ID에 기본값이 없으므로 insert 시점에 NEXTVAL(seq_comment)로 값을 채운다. EMAIL은 사용자 테이블을, BLOGID는 블로그 테이블을 참조한다. BLOGID가 blog(1) : comment(N) 관계를 만드는 컬럼이다.
BlogResponseDTO에 댓글 목록 필드를 추가했다.
@Builder(toBuilder = true)
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class BlogResponseDTO {
private Integer id;
private String title, content, category, email;
//////////////// blog(1) : comment(N)
private List<CommentResponseDTO> comments;
}
service는 mapper 두 개를 주입받아 조회를 두 번 한다. blog를 한 건 찾고, 그 id로 댓글 목록을 찾아 붙인다.
@Service
@RequiredArgsConstructor
public class BlogService {
private final BlogMapper blogMapper;
private final CommentMapper commentMapper;
@Transactional(readOnly = true)
public BlogResponseDTO read(Integer id) {
// blog {} select - comment [] select
System.out.println("debug >>>> blog service read");
System.out.println("debug >>>> blog service read params : " + id);
BlogResponseDTO blog = blogMapper
.findById(id)
.orElseThrow(() -> new RuntimeException(id + "BLOG NOT FOUND"));
// case 01
return blog.toBuilder()
.comments(commentMapper.findByBlogId(blog.getId()))
.build();
댓글 목록은 BLOGID로 걸러 가져온다. 조인이 아니라 별도의 select다.
<!-- findByBlogId -->
<select id="findByBlogId"
parameterType="java.lang.Integer"
resultType="com.example.inspire_mybatis.features.comments.domain.dto.CommentResponseDTO">
SELECT
ID, COMMENT, EMAIL, BLOGID
FROM
spring_comment_tbl
WHERE
BLOGID = #{blogId}
ORDER BY ID DESC
</select>
findById()가 Optional을 반환하므로 orElseThrow()로 풀었다. 값이 없으면 예외가 되어 null이 위 계층으로 나가지 않는다.
toBuilder()는 기존 객체의 값을 채운 빌더를 만들어 준다. 여기에 comments만 얹어 새 객체로 빌드한다. @Builder(toBuilder = true)를 붙여야 생성되는 메서드다.
@Setter가 있어서 직접 넣는 것도 가능하지만 쓰지 않았다.
// 직접 set은 가능하지만 부적절
// blog.setComments(commentMapper.findByBlogId(blog.getId()));
필드를 하나씩 옮겨 담는 방법도 비교했다. 같은 결과지만 필드가 늘어날 때마다 이 부분을 고쳐야 한다.
// case 02
// BlogResponseDTO blog = blogMapper
// .findById(id)
// .map(dto -> BlogResponseDTO.builder()
// .id(dto.getId())
// .title(dto.getTitle())
// .content(dto.getContent())
// .comments(dto.getComments())
// .build())
// .orElseThrow(() -> new RuntimeException(id + "BLOG NOT FOUND"));
read()에는 @Transactional(readOnly = true)가 붙어 있다. select 두 번이 하나의 조회로 묶이는 자리다.
댓글은 blog와 같은 구조를 별도 패키지(features.comments)로 갖는다. request와 response DTO의 필드 구성은 같다.
public class CommentRequestDTO {
private Integer id;
private String comment, email;
private Integer blogId;
}
service는 저장한 뒤 id를 반환한다.
public int insert(CommentRequestDTO request) {
System.out.println("debug >>>> comment service insert : " + request);
commentMapper.save(request);
return request.getId();
}
save()의 반환값(처리 건수)이 아니라 request에서 id를 다시 꺼낸다. 이것이 가능한 이유는 XML의 selectKey에 있다.
<!-- save -->
<insert id="save"
parameterType="com.example.inspire_mybatis.features.comments.domain.dto.CommentRequestDTO">
<selectKey keyProperty="id"
resultType="int"
order="BEFORE">
SELECT NEXTVAL(seq_comment)
</selectKey>
INSERT INTO spring_comment_tbl(ID, COMMENT, EMAIL, BLOGID)
VALUES(#{id}, #{comment}, #{email}, #{blogId})
</insert>
order="BEFORE"이므로 insert 전에 selectKey가 먼저 실행된다. 가져온 값은 keyProperty="id"에 따라 파라미터 객체인 CommentRequestDTO의 id에 담기고, 그 다음 insert의 #{id}가 그 값을 쓴다. service가 save() 이후에 request.getId()로 꺼낼 수 있는 것도 같은 객체에 값이 채워져 있기 때문이다.
blog 쪽 save()는 VALUES(NEXTVAL(seq_blog), ...)로 SQL 안에서 바로 채우기 때문에 java 쪽에서 id를 알 수 없다. 그래서 blog 입력은 CREATED만 반환하고 body가 없다.
controller는 그 id를 응답 body에 담는다. front가 목록을 다시 받아오지 않고 방금 쓴 댓글을 배열에 붙이려면 id가 필요하기 때문이다.
@PostMapping("/insert")
public ResponseEntity<?> insert(@RequestBody CommentRequestDTO request) {
System.out.println("debug >>>> comment controller insert");
System.out.println("debug >>>> comment controller insert request : " + request);
int id = commentService.insert(request);
System.out.println("debug >>>> comment controller insert request id : " + id);
return id != 0
? ResponseEntity
.status(HttpStatus.CREATED)
.body(CommentResponseDTO.builder()
.blogId(request.getBlogId())
.email(request.getEmail())
.comment(request.getComment())
.id(id)
.build())
: ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
받은 CommentRequestDTO의 값과 id를 합쳐 CommentResponseDTO로 만들어 돌려준다. 들어올 때와 나갈 때의 타입이 나뉘어 있다.
콘솔 로그의 출력 순서
StackTrace 아래에 System.out 로그가 찍혀 있어서 등록 성공 후의 로그로 착각했다. 예외는 처리 시점에 한꺼번에 출력되고 System.out은 버퍼링되므로 실제 실행 순서와 다르게 보인다. 스프링 부트는 콘솔을 지우지 않으므로 타임스탬프를 먼저 확인해야 한다.
ID가 1이 아니라 2인 것
실패한 INSERT가 시퀀스를 소비하고 롤백되면서 1번이 건너뛰어졌다. 롤백해도 시퀀스 값은 되돌아가지 않는다.