1) REST
HTTP URL로 서버의 자원(resource)
을 명시하고, HTTP 메서드(POST, GET, PATCH/PUT, DELETE)
로 해당 자원에 대해 CRUD하는 것을 말함.
2) API
클라이언트가 서버의 자원을 요청할 수 있도록 서버에서 제공하는 인터페이스.
- FirstApiController -
@RestController // REST API용 controller
public class FirstApiController {
@GetMapping("/api/hello")
public String hello(){
return "hello world!";
}
}
http://localhost:8081/api/hello 로 접속하여 확인.
postman으로도 확인해보면 정상적으로 출력됨을 확인할 수 있다.
@RestController와 @Controller의 차이
@Controller
어노테이션은 resources.templates
패키지 아래에서 return된 문자열의 이름을 가진(확장자는 따지지 않음) view파일을 찾아 화면에 출력한다.
@RestController
어노테이션은 JSON이나 텍스트 같은 데이터를 return한다.
- ArticleApiController -
@RestController
public class ArticleApiController {
@Autowired
private ArticleRepository articleRepository;
// GET
@GetMapping("/api/articles")
public List<Article> index(){
return articleRepository.findAll();
}
// POST
@PostMapping("/api/articles")
public Article create(@RequestBody ArticleForm dto) {
Article article = dto.toEntity();
return articleRepository.save(article);
}
// PATCH
@PatchMapping("/api/articles/{id}")
public ResponseEntity<Article> update(@PathVariable Long id,
@RequestBody ArticleForm dto) {
// 1. DTO -> Entity 변환
Article target = dto.toEntity();
log.info("id: {}, article: {},", id, target.toString());
// 2. 타깃 조회하기
Optional<Article> article = articleRepository.findById(id);
// 3. 잘못된 요청 처리하기
if (article.isEmpty() || !id.equals(target.getId())){
// article객체가 비었거나 매개변수로 받아온 id와 article객체의 id가 동일하지 않을 경우
log.info("잘못된 요청! id: {}, article: {}", id, article.toString());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
}
// 4. 업데이트 및 정상 응답(200)하기
Article update = articleRepository.save(target);
return ResponseEntity.status(HttpStatus.OK).body(update);
}
// DELETE
}
REST API에서 데이터를 생성할때는 JSON 데이터를 받아와야 하기 때문으로, 이때는 @RequestBody
어노테이션을 추가하면 본문(BODY)에서 실어 보내는 데이터를 create() 메서드의 매개변수로 받아올 수 있다.
아래는 POST 통신으로 새 게시글을 추가한 결과.
1) 수정용 Entity 생성
2) DB에 대상 Entity가 있는지 조회
3) 대상 Entity가 없거나 수정하려는 id가 잘못되었을 경우에 대한 처리
4) 대상 Entity가 있으면 수정 내용으로 업데이트 후 정상 응답(200) 보내기
매개변수로 받아온 id값으로 DB를 조회해 해당 데이터가 존재하는지 확인 후 update는 수정용 Entity로 해야함에 주의.
- ArticleApiController -
// PATCH
@PatchMapping("/api/articles/{id}")
public ResponseEntity<Article> update(@PathVariable Long id,
@RequestBody ArticleForm dto) {
// 1. DTO -> Entity 변환
Article target = dto.toEntity();
log.info("id: {}, article: {},", id, target.toString());
// 2. 타깃 조회하기
Optional<Article> article = articleRepository.findById(id);
// 3. 잘못된 요청 처리하기
if (article.isEmpty() || !id.equals(target.getId())){
// article객체가 비었거나 매개변수로 받아온 id와 article객체의 id가 동일하지 않을 경우
log.info("잘못된 요청! id: {}, article: {}", id, article.toString());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
}
// 4. 업데이트 및 정상 응답(200)하기
// 4-1. 클라이언트로부터 전달받지 못한 속성은 DB에서 조회해온 기존의 데이터로 대체
target.patch(article.get());
Article update = articleRepository.save(target);
return ResponseEntity.status(HttpStatus.OK).body(update);
}
- Article -
public void patch(Article article) {
// 수정할 내용이 없는 경우 null대신 기존의 Article객체가 가진 값으로 대체함
if (article.title !=null){
this.title = article.title;
}
if (article.content !=null){
this.content = article.content;
}
}
아래는 PATCH 통신을 값을 일부 수정한 결과
HttpStatus
클래스를 이용해 처리에 대한 HTTP 응답코드(200, 500 등)를 설정하여 반환한다.
- ArticleApiController -
// DELETE
@DeleteMapping("/api/articles/{id}")
public ResponseEntity<Article> delete(@PathVariable Long id) {
// 1. DB에서 대상 Entity가 있는지 조회
Optional<Article> article = articleRepository.findById(id);
// 2. 대상 Entity가 없는 경우 처리
if (article.isEmpty()){
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
}
// 3. Entity가 있을 경우 삭제 후 정상 응답(200) 반환
articleRepository.delete(article.get());
return ResponseEntity.status(HttpStatus.OK).build();
}
@RequestBody / @ResponseBody 어노테이션 이란?
비동기 통신을 위해서는 서버와 클라이언트가 요청 및 응답을 보낼 때 본문에 데이터를 실어보내야 한다.
클라이언트가 서버에 데이터를 보낼때는 @RequestBody
, 서버가 클라이언트에게 응답을 보낼때는 @ResponseBody
어노테이션을 사용한다.
REST API의 응답을 위해 사용하는 클래스.
REST API요청을 받아 응답할 때 HTTP 상태코드, 헤더, 본문을 실어보낼 수 있다.
HTTP상태코드를 관리하는 클래스.
각 트랜잭션에 대한 처리를 service에서 처리할 수 있도록 변경.
controller에서는 service에 요청을 보내고, repository에 직접 sql요청을 보내는 작업은 service에서 담당하도록 역할을 분리한다.
트랜잭션 (Transaction) : 더 이상 쪼갤 수 없는 하나의 기능단위.
(예시 - 은행의 이체의 경우 내 계좌에서 돈이 빠지는 동작과 상대 계좌로 돈이 들어가는 동작의 두 동작이 더 이상 쪼갤 수 없는 하나의 기능단위가 됨.)
- ArticleApiController -
@Slf4j
@RestController
public class ArticleApiController {
@Autowired
private ArticleService articleService;
// GET
@GetMapping("/api/articles")
public List<Article> index() {
return articleService.index();
}
@GetMapping("/api/articles/{id}")
public Article show(@PathVariable Long id) {
return articleService.show(id);
}
// POST
@PostMapping("/api/articles")
public ResponseEntity<Article> create(@RequestBody ArticleForm dto) {
Article created = articleService.create(dto);
return (created != null) ?
ResponseEntity.status(HttpStatus.OK).body(created) :
ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
// PATCH
@PatchMapping("/api/articles/{id}")
public ResponseEntity<Article> update(@PathVariable Long id,
@RequestBody ArticleForm dto) {
Article updated = articleService.update(id, dto);
return (updated != null) ?
ResponseEntity.status(HttpStatus.OK).body(updated) :
ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
// DELETE
@DeleteMapping("/api/articles/{id}")
public ResponseEntity<Article> delete(@PathVariable Long id) {
Article deleted = articleService.delete(id);
return (deleted != null) ?
ResponseEntity.status(HttpStatus.NO_CONTENT).build() :
ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
}
- ArticleService -
@Slf4j
@Service
public class ArticleService {
@Autowired
private ArticleRepository articleRepository;
public List<Article> index() {
return articleRepository.findAll();
}
public Article show(Long id) {
return articleRepository.findById(id).orElse(null);
}
public Article create(ArticleForm dto) {
Article article = dto.toEntity();
if (article.getId() != null) {
return null;
}
return articleRepository.save(article);
}
public Article update(Long id, ArticleForm dto) {
// 1. DTO -> Entity 변환
Article target = dto.toEntity();
log.info("id: {}, article: {},", id, target.toString());
// 2. 타깃 조회하기
Optional<Article> article = articleRepository.findById(id);
// 3. 잘못된 요청 처리하기
if (article.isEmpty() || !id.equals(target.getId())) {
// article객체가 비었거나 매개변수로 받아온 id와 article객체의 id가 동일하지 않을 경우
log.info("잘못된 요청! id: {}, article: {}", id, article.toString());
return null;
}
// 4. 업데이트 및 정상 응답(200)하기
// 4-1. 클라이언트로부터 전달받지 못한 속성은 DB에서 조회해온 기존의 데이터로 대체
article.get().patch(target);
log.info("article id: {}, article: {}", id, article.toString());
Article updated = articleRepository.save(article.get());
return updated;
}
public Article delete(Long id) {
// 1. DB에서 대상 Entity가 있는지 조회
Optional<Article> target = articleRepository.findById(id);
// 2. 대상 Entity가 없는 경우 처리
if (target.isEmpty()) {
return null;
}
// 3. Entity가 있을 경우 삭제 후 정상 응답(200) 반환
articleRepository.delete(target.get());
return target.get();
}
}
conrtoller에서 repository에 요청을 직접 보내던것을 수정하여 controller는 service에, service는 repository에 요청을 넣도록 변경.
Optional을 사용했기 때문에 책의 코드와는 약간 다르나, 기능은 같음.
아래는 Transaction을 적용하기 전 일반적인 실행에서의 예외상황.
- ArticleApiController -
// Transaction 테스트
@PostMapping("/api/transaction-test")
public ResponseEntity<List<Article>> transactionTest(@RequestBody List<ArticleForm> dtos){
List<Article> createList = articleService.createArticles(dtos);
return (createList != null) ?
ResponseEntity.status(HttpStatus.OK).body(createList) :
ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
- ArticleService -
@Transactional
public List<Article> createArticles(List<ArticleForm> dtos) {
// 1. dto 묶음을 Entity 묶음으로 변환
List<Article> articleList = dtos.stream()
.map(dto -> dto.toEntity())
.collect(Collectors.toList());
// 2. Entity 묶음을 DB에 저장
articleList.stream()
.forEach(article -> articleRepository.save(article));
// 3. 강제 예외 발생시키기
articleRepository.findById(-1L) // id가 -1인 데이터 검색
.orElseThrow(() -> new IllegalArgumentException("결제 실패!")); // 찾는 데이터가 없으면 예외 발생
// 4. 결과 값 반환하기
return articleList;
}
의도적으로 예외 상황을 발생시켰으나 예외가 발생하기 전 DB에 insert작업이 진행되었음.
Transaction을 사용하면 요청일 실패했을때 실행자체를 롤백시킬 수 있다.
이때 Transaction은 보통 service단에서 사용한다.
- ArticleService -
@Transactional
public List<Article> createArticles(List<ArticleForm> dtos) {
// 생략
}
수정 후 postman을 통해 다시 똑같은 요청을 보내보면 이번에는 요청이 롤백되어 DB에 insert작업이 진행되지 않는다.
테스트 코드를 이용하면 다양한 문제를 미리 예방하고 코드 변경으로 인해 발생하는 문제역시 조기에 발견할 수 있다.
요즘은 테스트 도구를 통해 반복적인 검증절차를 자동화할 수 있다.
테스트 코드의 3단계
1. 예상 데이터 작성하기
2. 실제 데이터 획득하기
3. 예상 데이터와 실제 데이터 비교해 검증하기
CRUD 중 Read작업을 제외한 나머지 작업을 테스트 할 경우 테스트를 마치고 데이터가 롤백되어야 하므로 @Test
와 함께 @Transactional
어노테이션도 추가한다.
ArticleService에서 우클릭 > Generate > Test > Junit5 확인 후 원하는 메서드 체크 후 test클래스 생성
test패키지의 service패키지 아래에 생성됨을 확인.
- ArticleServiceTest -
@SpringBootTest
class ArticleServiceTest {
@Autowired
ArticleService articleService;
@Test
void show() {
// 1. 예상 데이터 작성하기
Long id = 1L;
Article expected = new Article(id, "첫번쨰 게시글", "첫 게시글의 내용입니다.");
// 2. 실제 데이터 획득하기
Article article = articleService.show(id);
// 3. 예상 데이터와 실제 데이터 비교해 검증하기
assertEquals(expected.toString(), article.toString());
}
@Test
void show_fail() {
Long id = -1L; // 존재하지 않는 데이터
Article expected = null;
Article article = articleService.show(id);
assertEquals(expected.toString(), article.toString());
}
@Test
@Transactional
void create() {
String title = "가나다라";
String content = "마바사아자차카타파하";
ArticleForm dto = new ArticleForm(null, title, content);
Article expected = new Article(42L, title, content);
// 실제 데이터
Article article = articleService.create(dto);
// 비교 및 검증
assertEquals(expected.toString(), article.toString());
}
}
show()
, show_fail()
실행결과 show()
메서드는 성공, show_fail()
메서드는 실패로 나온다.
create()
실행결과 성공.
위는 @Transactional
어노테이션을 붙이기 전 실행결과이므로 어노테이션 적용 시 실제로 데이터가 들어가지 않게 됨.
update delete test는 직접 해볼 것.
게시글과 댓글은 1:N 의 관계를 가짐.
- Comment -
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Getter
@Entity
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // 기본키
@ManyToOne
@JoinColumn(name = "article_id") // 외래키 생성, Article Entity의 기본키(id)와 매핑
private Article article; // 해당 댓글의 게시글
@Column
private String nickname; // 댓글 작성자
@Column
private String body; // 댓글 본문
}
- CommentrRepository -
public interface CommentRepository extends JpaRepository<Comment, Long> {
// 특정 게시글의 모든 댓글 조회
@Query(value = "SELECT * FROM comment WHERE article_id = :articleId", nativeQuery = true)
List<Comment> findByArticleId(Long articleId);
@Query(value = "SELECT count(*) FROM comment WHERE article_id = :articleId", nativeQuery = true)
Integer findByArticleIdCnt(Long articleId);
// 특정 닉네임의 모든 댓글 조회
List<Comment> findByNickname(String nickname);
}
이번에는 CrudRepository
대신 JpaRepository
를 상속받음.
단순 CRUD작업만 한다면 CrudRepository
를 상속받으면 되고,
페이징과 정렬작업까지 필요하다면 JpaRepository
를 상속받아 사용하면 된다.
CREATE TABLE hongongspringboot.`comment` (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
body VARCHAR(255),
nickname VARCHAR(255),
article_id BIGINT NOT NULL,
FOREIGN KEY(article_id) REFERENCES article(id)
)
INSERT INTO comment(article_id, nickname, body)
VALUES ('1', 'Park', '가나다라');
INSERT INTO comment(article_id, nickname, body)
VALUES ('1', 'Kim', '마바사아');
INSERT INTO comment(article_id, nickname, body)
VALUES ('1', 'Park', '자차카타');
INSERT INTO comment(article_id, nickname, body)
VALUES ('1', 'Lee', '파하');
repository에서 쿼리 입력 시 주의.
매개변수랑 쿼리문 내 변수이름을 다르게 적은걸 못찾아서 한참동안 오류랑 씨름했음.
=> 이거 안되는데 왜 안되는지 모르겠음.
나중에 다시 해볼것.
- Controller -
@RestController
public class CommentApiController {
@Autowired
private CommentService commentService;
@GetMapping("/api/articles/{articleId}/comments")
public ResponseEntity<List<CommentDto>> comments(@PathVariable Long articleId) {
List<CommentDto> dtos = commentService.comments(articleId);
return ResponseEntity.status(HttpStatus.OK).body(dtos);
}
@PostMapping("/api/articles/{articleId}/comments")
public ResponseEntity<CommentDto> create(@PathVariable Long articleId,
@RequestBody CommentDto dto) {
CommentDto createDto = commentService.create(articleId, dto);
return ResponseEntity.status(HttpStatus.OK).body(createDto);
}
@PatchMapping("/api/comments/{id}")
public ResponseEntity<CommentDto> update(@PathVariable Long id,
@RequestBody CommentDto dto) {
CommentDto updateDto = commentService.update(id, dto);
return ResponseEntity.status(HttpStatus.OK).body(updateDto);
}
@DeleteMapping("/api/comments/{id}")
public ResponseEntity<CommentDto> delete(@PathVariable Long id) {
CommentDto deleteDto = commentService.delete(id);
return ResponseEntity.status(HttpStatus.OK).body(deleteDto);
}
}
- CommentService -
@Service
public class CommentService {
@Autowired
private CommentRepository commentRepository;
@Autowired
private ArticleRepository articleRepository;
public List<CommentDto> comments(Long articleId) {
return commentRepository.findByArticleId(articleId)
.stream() // 댓글 Entity목록을 stream으로 변환
.map(comment -> CommentDto.createCommentDto(comment)) // Entity를 dto로 매핑
.collect(Collectors.toList()); // stream을 list로 변환
}
@Transactional
public CommentDto create(Long articleId, CommentDto dto) {
// 1. 댓글 조회 및 예외 발생
Article article = articleRepository.findById(articleId)
.orElseThrow(() -> new IllegalArgumentException("댓글 생성 실패! " + "대상 게시글이 없습니다."));
// 2. 댓글 Entity 생성
Comment commnet = Comment.createComment(dto, article);
// 3. 댓글 Entity를 DB에 저장
Comment created = commentRepository.save(commnet);
// 4. DTO로 변환해 반환
return CommentDto.createCommentDto(created);
}
@Transactional
public CommentDto update(Long id, CommentDto dto) {
Comment target = commentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("댓글 수정 실패! " + "대상 댓글이 없습니다."));
target.patch(dto);
Comment updated = commentRepository.save(target);
return CommentDto.createCommentDto(updated);
}
@Transactional
public CommentDto delete(Long id) {
Comment target = commentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("댓글 삭제 실패! " + "대상 댓글이 없습니다."));
commentRepository.delete(target);
return CommentDto.createCommentDto(target);
}
}
- CommentDto -
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Getter
public class CommentDto {
private Long id;
private Long article_id;
private String nickname;
private String body;
public static CommentDto createCommentDto(Comment comment) {
return new CommentDto(
comment.getId(),
comment.getArticle().getId(),
comment.getNickname(),
comment.getBody()
);
}
}
- Comment -
메서드 추가
public static Comment createComment(CommentDto dto, Article article) {
// 예외 발생
if (dto.getId() != null) {
throw new IllegalArgumentException("댓글 생성 실패! 댓글의 id가 없어야 합니다.");
}
if (!Objects.equals(dto.getArticle_id(), article.getId())) {
throw new IllegalArgumentException("댓글 생성 실패! 게시글의 id가 잘못되었습니다.");
}
// Entity생성 및 반환
return new Comment(
dto.getId(),
article,
dto.getNickname(),
dto.getBody()
);
}
public void patch(CommentDto dto) {
// 예외 발생
if (!Objects.equals(this.id, dto.getId())) {
throw new IllegalArgumentException("댓글 수정 실패! 잘못된 id가 입력되었습니다.");
}
// 객체 갱신
if (dto.getNickname() != null) {
this.nickname = dto.getNickname();
}
if (dto.getBody() != null) {
this.body = dto.getBody();
}
}
http://localhost:8081/api/articles/1/comments
DB에서 1번 게시글에 대한 댓글인 4개가 모두 정상조회됨을 확인할 수 있음.
http://localhost:8081/api/articles/1/comments
1번 게시글에 대한 덧글을 추가.
수정 테스트.
수정한 댓글의 데이터를 반환한다.
http://localhost:8081/api/comments/4
삭제 테스트.
댓글을 삭제한 후 삭제한 댓글의 데이터를 반환한다.
coffee 데이터를 REST API로 구현하라.
CREATE TABLE hongongspringboot.coffee(
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
NAME VARCHAR(255) NOT NULL,
price INT NOT NULL
)
INSERT INTO coffee(NAME, price)
VALUES ('아메리카노', 2000);
INSERT INTO coffee(NAME, price)
VALUES ('카페라떼', 3000);
- CoffeeController -
@RestController
public class CoffeeController {
@Autowired
CoffeeService coffeeService;
@GetMapping("/api/coffees")
public List<Coffee> list() {
return coffeeService.list();
}
}
- CoffeeService -
@Service
public class CoffeeService {
@Autowired
CoffeeRepository coffeeRepository;
public List<Coffee> list() {
return coffeeRepository.findAll();
}
}
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Getter
@Entity
public class Coffee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String name;
@Column
private int price;
}
@AllArgsConstructor
@Getter
public class CoffeeDto {
private Long id;
private String name;
private int price;
private static CoffeeDto create(Coffee coffee){
return new CoffeeDto(coffee.getId(), coffee.getName(), coffee.getPrice());
}
}
ArticleServiceTest에 update, delete 테스트 케이스 코드를 추가하라.
@Test
@Transactional
void update() {
String title = "수정test";
ArticleForm dto = new ArticleForm(2L, title, (String) null);
Article expected = new Article(2L, title, "testtest");
// 실제 데이터
Article article = articleService.update(2L, dto);
// 비교 및 검증
assertEquals(expected.toString(), article.toString());
}
@Test
@Transactional
void delete() {
Article expected = new Article(2L, "test", "testtest");
Article article = articleService.delete(2L);
assertEquals(expected.toString(), article.toString());
}
- CoffeeController -
api메서드 수정.
page는 배열처럼 0페이지부터 시작이므로 URI로 입력받은 페이지에 -1을 한 페이지를 검색해야 함.
@GetMapping("/api/coffees/{pageNum}")
public Page<Coffee> list(@PathVariable int pageNum) {
PageRequest pageRequest = PageRequest.of(pageNum+1, 5);
return coffeeService.list(pageRequest);
}
- CoffeeService -
@Service
public Page<Coffee> list(PageRequest pageRequest) {
return coffeeRepository.findAll(pageRequest);
}
http://localhost:8081/api/coffees/2
위 경로로 접근 시 6번부터 10번에 해당하는 coffee데이터가 출력됨.
이걸 어떻게 화면에 출력할지, 페이지네이션은 어떻게 추가할지는 책 끝까지 진행 후 좀 더 생각해보기.