앞선 포스팅에서 게시판 등록 API를 만들어 보았다. 이번에는 수정, 조회 API를 만들고, 테스트 코드로 기능을 검증해보자.
...
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsService;
...
@PutMapping("/api/v1/posts/{id}")
public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
return postsService.update(id, requestDto);
}
@GetMapping("/api/v1/posts/{id}")
public PostsResponseDto findById(@PathVariable Long id)
{
return postsService.findById(id);
}
}
조회 요청은 컨트롤러의 findById에서 받아 처리한다. 게시글 id를 받아서 해당 id에 해당하는 게시글 데이터를 조회한다.
수정 요청은 컨트롤러의 update에서 받아 처리한다. 게시글 id와 DTO 데이터를 받아 데이터에 반영한다.
@GetMapping
@PutMapping
의 의미@GetMapping
@PutMapping
@RequestMapping
@RequestMapping(value = "/", method="...")
으로 GET, POST, PUT, DELETE, PATCH 요청을 모두 처리했는데, Spring4.3 버전부터 @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping 로 세분화 되었다.
- @GetMapping - @RequestMapping(메소드 = RequestMethod.GET)의 바로 가기
- @PostMapping - @RequestMapping(메소드 = RequestMethod.POST)의 바로 가기
- @PutMapping - @RequestMapping(메소드 = RequestMethod.PUT)의 바로 가기
- @DeleteMapping - @RequestMapping(메소드 =RequestMethod.DELETE)의 바로 가기
- @PatchMapping - @RequestMapping(메소드 = RequestMethod.PATCH)에 대한 바로 가기
@PathVariable
@PathVariable 자료형 id
로 선언하여 사용할 수 있다.앞서서 게시글을 등록할 때 사용할 DTO인 PostsSaveRequestDto을 생성하였다. 게시글을 수정하거나 조회할 때는 PostsSaveRequestDto와 다른 필드를 사용하기 때문에 새로 DTO를 만들어 사용하자.
굳이 모든 필드를 가진 생성자가 필요하진 않으므로 Dto는 Entitiy를 받아 처리하도록 하자.
package com.spring.book.web.dto;
import com.spring.book.domain.posts.Posts;
import lombok.Getter;
@Getter
public class PostsResponseDto {
private Long id;
private String title;
private String content;
private String author;
public PostsResponseDto(Posts entity) {
this.id = entity.getId();
this.title = entity.getTitle();
this.content = entity.getContent();
this.author = entity.getAuthor();
}
}
package com.spring.book.web.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
private String title;
private String content;
@Builder
public PostsUpdateRequestDto(String title, String content){
this.title = title;
this.content = content;
}
}
필드에 값을 넣어줄때는 Builder를 사용하도록 하자.
게시글 수정시 사용하는 PostsUpdateRequestDto에서는 title과 content 필드를 변경하므로 이를 변경할 메소드를 생성한다.
...
public class Posts {
...
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
...
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
...
@Transactional
public Long update(Long id, PostsUpdateRequestDto requestDto) {
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
posts.update(requestDto.getTitle(), requestDto.getContent());
return id;
}
public PostsResponseDto findById(Long id) {
Posts entity = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id" + id));
return new PostsResponseDto(entity);
}
}
서비스 레벨에서 update 메소드는 수정 로직을 수행한다. id로 게시글 데이터를 찾고, 없으면 예외 처리를 하고, 있다면 수정사항을 엔티티에 반영한다.
findById는 id로 게시글 데이터를 리포지토리에서 조회한다. 게시글이 없다면 예외 처리하고, 있다면 return한다.
PostsService의 update 메소드에는 데이터베이스에 쿼리를 날리는 부분이 없이 데이터를 저장한다.
이게 가능한 이유는 JPA의 영속성 컨텍스트 때문이다.
@Transactional
의미@Transactional은 클래스나 메서드에 붙여줄 경우, 해당 범위 내 메서드가 트랜잭션이 되도록 보장해준다.
선언적 트랜잭션이라고도 하는데, 직접 객체를 만들 필요 없이 선언만으로도 관리를 용이하게 해주기 때문이다.
레거시 코드에서 트랜잭션의 경우, 트랜잭션의 시작과 연산 종료시의 커밋 과정이 필요하므로, 프록시를 생성해 해당 메서드의 앞,뒤에 트랜잭션의 시작과 끝을 추가해줘야 한다.
Optional
과 orElseThrow()아래 구문을 이해해보자.
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
Optional 객체에 사용할 수 있는 예외 처리 메소드
만약 orElseThrow()를 안쓴다면 if/else 문을 사용하여 error 예외처리를 해주어야 한다.
get 메소드에서 param 매개 변수에 값이 없으면 “no hello”를 출력하고, 값이 있으면 “값 + !”를 출력한다.
public class OptElse {
public static String get(String param) throws Exception {
if ("".equals(param)){
throw new Exception("no hello.");
}else{
return param + "!";
}
}
public static void main(String[] args) throws Exception {
String hello = get("hello");
System.out.println(hello);
}
}
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Stack;
public class StTest {
public static String get() throws Exception {
Optional<String> opt = Optional.of("hello");
return opt.orElseThrow(() -> new Exception("ohlcv result set null")); //핵심
}
public static void main(String[] args) throws Exception {
String hello = get();
System.out.println(hello);
}
}
NPE를 방지할 수 있도록 도와준다.
Optional는 null이 올 수 있는 값을 감싸는 Wrapper 클래스로, 참조하더라도 NPE가 발생하지 않도록 도와준다.
Optional 클래스는 아래와 같은 value에 값을 저장하기 때문에 값이 null이더라도 바로 NPE가 발생하지 않으며, 클래스이기 때문에 각종 메소드를 제공해준다.
public final class Optional<T> {
// If non-null, the value; if null, indicates no value is present
private final T value;
...
}
(참고)
orElseThrow
는 함수형 인터페이스(Functional Interface)를 매개변수로 전달 받는다.
public <X extends Throwable> T orElseThrow(Supplier<? extends X> exceptionSupplier) throws X
(참고) 컨텐츠- orElseThrow()는 왜 매개변수로 람다식을 전달할까?
함수형 인터페이스는 1개의 추상메소드를 갖고 있는 인터페이스이다.
함수형 인터페이스를 사용하는 것은 람다식으로 만든 객체에 접근하기 위해서 이다. 아래의 예제처럼 람다식을 사용할 때마다 함수형 인터페이스를 매번 정의하기에는 불편하기 때문에 자바에서 라이브러리로 제공하는 것들이 있다.
예를 들어, (예제1) 코드에서 변수 func는 람다식으로 생성한 객체를 가리키고 있다. doSomething()
에 인자로 문자열을 전달하면 람다식에 정의된 것처럼 로그로 출력을 한다.
(예제1)
public interface FunctionalInterface {
public abstract void doSomething(String text);
}
FunctionalInterface func = text -> System.out.println(text);
func.doSomething("do something");
output : do something
(예제1)은 (예제2)와 같은 흐름으로 동작한다.
(예제2)
FunctionalInterface func = new FunctionalInterface() {
@Override
public void doSomething(String text) {
System.out.println(text);
}
};
func.doSomething("do something");
정리하자면 함수형 인터페이스는 구현부를 미리 만들지 않아 라이브러리를 사용할 때 상황에 맞는 구현부를 만들어 사용할 수 있다.
위의 내용을 정리한 후 아래 구문을 다시 보면
Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
orElseThrow
는 함수형 인터페이스를 매개변수로 받으므로 람다식을 매개 변수로 전달하였다....
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@After
public void tearDown() throws Exception{
postsRepository.deleteAll();
}
...
@Test
public void Posts_수정된다() throws Exception {
//given
Posts savedPosts = postsRepository.save(Posts.builder()
.title("title")
.content("content")
.author("author")
.build());
Long updateId = savedPosts.getId();
String expectedTitle = "title2";
String expectedContent = "content2";
PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
.title(expectedTitle)
.content(expectedContent)
.build();
String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;
HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);
//when
ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class);
//then
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(responseEntity.getBody()).isGreaterThan(0L);
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
}
}
HttpEntity 는 스프링에서 제공하는 클래스로, HTTP 요청 또는 응답에 해당하는 HttpHeader와 HttpBody를 포함하는 클래스다. 즉, 테스트 코드에서는 HttpEntity를 이용하여 외부 API를 호출할 수 있다.
따라서 이 클래스는 다음과 같은 생성자와 필드를 갖는다.
public class HttpEntity<T> {
public static final HttpEntity<?> EMPTY = new HttpEntity();
private final HttpHeaders headers;
private final T body;
...
}
HttpEntity 클래스를 상속받아 구현한 클래스가 RequestEntity, ResponseEntity 클래스이다.
( 참고: 블로그- Springboot- ResponseEntity, HttpEntity란? )
ResponseEntity
ResponseEntity는 사용자의 HttpRequest에 대해 응답하는 데이터를 가진다.
Http Status, Header, Body를 포함한다.
아래와 같이 코드를 작성하고 /hello URL에 접근하면 status값을 확인할 수 있다.
@RestController
public class HelloController {
@GetMapping("/hello")
public ResponseEntity hello() {
return new ResponseEntity(HttpStatus.OK);
}
}
TestRestTemplate
TestRestTemplate은 REST 방식으로 개발한 API의 Test를 최적화 하기 위해 만들어진 클래스이다.
HTTP 요청 후 데이터를 응답 받을 수 있는 템플릿 객체이며 ResponseEntity와 함께 자주 사용된다.
Header와 Content-Type 등을 설정하여 API를 호출 할 수 있다.
TestRestTemplate에서 대표적으로 사용되는 메서드
1. testRestTemplate.getForObject()
- 기본 http 헤더를 사용하며 결과를 객체로 반환받는다.
ProductDetailResponseDto item = this.testRestTemplate.getForObject(url, ProductDetailResponseDto.class);
마찬가지로 기본 http 헤더를 사용하며 결과를 ResponseEntity로 반환받는다.
ResponseEntity<Long> responseEntity = this.testRestTemplate.postForEntity(url, productRegisterRequestDto, Long.class);
update할때 주로 사용된다.
주어진 URI 템플릿에 HTTP 메서드를 실행하고 요청에 주어진 요청 엔티티를 작성하고 응답을 ResponseEntity로 반환합니다. URI 템플릿 변수는 주어진 URI 변수(있는 경우)를 사용하여 확장한다. (→ Http header를 변경할 수 있다.)
형식
public <T> ResponseEntity<T> exchange(String url,
HttpMethod method,
HttpEntity<?> requestEntity,
Class<T> responseType,
Object... urlVariables)
예시
// given
HttpEntity<ProductUpdateRequestDto> requestEntity = new HttpEntity<>(productUpdateRequestDto);
// when
ResponseEntity<Long> responseEntity = this.testRestTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class);
참고:
정리
위 내용을 바탕으로 아래 코드를 이해해보자.
//(1)에서 사용됨.
PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
.title(expectedTitle)
.content(expectedContent)
.build();
//(2)에서 사용됨.
String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;
//(1)
HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);
//(2)
//when
ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class);
//restTemplate = 위에서 "private TestRestTemplate restTemplate;"로 정의함.
PostsApiControllerTest 코드를 보고 리스트의 메소드와 제네릭스가 궁금해졌다.
List<Posts> all = postsRepository.findAll();
spring.h2.console.enabled=true
옵션을 추가한다.//쿼리 로그 출력
spring.jpa.show_sql=true
//MySQL 버전으로 쿼리 로그 출력
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.datasource.hikari.jdbc-url=jdbc:h2:mem://localhost/~/testdb;MODE=MYSQL
//웹 콘솔 접근 활성화
spring.h2.console.enabled=true
[http://localhost:8080/h2-console](http://localhost:8080/h2-console)
로 접속한다.참고
url: jdbc:h2:mem:testdb <-- 이렇게 적으면 메모리에 DB 데이터가 저장된다. 메모리 이므로, spring boot 가 종료되면 모든 데이터는 함께 사라진다.
url: jdbc:h2:file:~/testdb <-- 이렇게 적으면 home 디렉토리(~/) 에 testdb 라는 파일이 생기고 그곳에 DB 데이터가 저장됩니다. 파일에 저장되므로 spring boot가 종료되어도 데이터는 보존됩니다.
[https://blog.naver.com/semtul79/222637095571](https://blog.naver.com/semtul79/222637095571)
해당 경로는 main 메소드 실행 후 로그에서 db 경로 확인 가능
2022-10-07 16:30:52.449 INFO 1422 --- [ main] o.s.b.a.h2.H2ConsoleAutoConfiguration : H2 console available at '/h2-console'. Database available at 'jdbc:h2:mem://localhost/~/testdb’
Hibernate: select posts0_.id as id1_0_0_, posts0_.author as author2_0_0_, posts0_.content as content3_0_0_, posts0_.title as title4_0_0_ from posts posts0_ where posts0_.id=?
2022-10-15 16:26:07.764 WARN 1866 --- [nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpMediaTypeNotAcceptableException: Could not find acceptable representation]
HTTP 406
: 서버가 허용된 타입의 응답을 생성하지 못할 때 발생하는 통신 에러
Dto인 PostsResponseDto에 @Getter
가 빠져있었음.
@Getter
public class PostsResponseDto {
private Long id;
private String title;
private String content;
private String author;
public PostsResponseDto(Posts entity) {
this.id = entity.getId();
this.title = entity.getTitle();
this.content = entity.getContent();
this.author = entity.getAuthor();
}
}
DTO에서 게터가 없으면 응답 내용에 값이 포함되지 않는 문제가 발생
orElseThrow
는 함수형 인터페이스(Functional Interface)를 매개변수로 전달 받기 때문에 람다식으로 매개 변수를 전달한다.