[spring] 게시글 API 만들기(2) -글 수정,조회

Kaite.Kang·2022년 12월 18일
0
post-thumbnail

* 목표

앞선 포스팅에서 게시판 등록 API를 만들어 보았다. 이번에는 수정, 조회 API를 만들고, 테스트 코드로 기능을 검증해보자.

1. 게시판 수정/조회 기능 만들기

1). PostsApiController: 컨트롤러에 조회, 수정 API 추가

  • src/main/java/com/spring/book/web/PostsApiController.java
...

@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 데이터를 받아 데이터에 반영한다.

A. 컨트롤러에서 @GetMapping @PutMapping 의 의미

B. 어노테이션에서 받아온 {id}는 내부 로직에서 어떻에 사용할수 있을까?

  • @PathVariable
    • 어노테이션 @GetMapping 에서 {id} 로 받아와서 @PathVariable 자료형 id 로 선언하여 사용할 수 있다.
    • 변수명(ex. id)은 정해져 있지 않으며, 개발자가 짓기 나름이다.

2) PostsResponseDto: 조회시 사용할 DTO

앞서서 게시글을 등록할 때 사용할 DTO인 PostsSaveRequestDto을 생성하였다. 게시글을 수정하거나 조회할 때는 PostsSaveRequestDto와 다른 필드를 사용하기 때문에 새로 DTO를 만들어 사용하자.

굳이 모든 필드를 가진 생성자가 필요하진 않으므로 Dto는 Entitiy를 받아 처리하도록 하자.

  • src/main/java/com/spring/book/web/dto/PostsResponseDto.java
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();
    }
}

3) PostsUpdateRequestDto: 수정시 사용할 DTO

  • src/main/java/com/spring/book/web/dto/PostsUpdateRequestDto.java
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를 사용하도록 하자.

4) Posts: Entity에 수정 시 데이터 반영을 위한 update 메소드 생성

게시글 수정시 사용하는 PostsUpdateRequestDto에서는 title과 content 필드를 변경하므로 이를 변경할 메소드를 생성한다.

  • src/main/java/com/spring/book/domain/posts/Posts.java
...

public class Posts {

...

    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }

}

5) PostsService: 서비스

  • src/main/java/com/spring/book/service/posts/PostsService.java
...

@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한다.

A. 더티 체킹(dirty checking)

PostsService의 update 메소드에는 데이터베이스에 쿼리를 날리는 부분이 없이 데이터를 저장한다.

이게 가능한 이유는 JPA의 영속성 컨텍스트 때문이다.

  • 영속성 컨텍스트
    • 엔티티를 영구 저장하는 환경이다. (일종의 논리적 개념이다.)
    • JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다.
    • JPA의 엔티티 매니저가 활성화된 상태(기본값)로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.
      이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시전에 해당 테이블에 변경분을 반영한다.
    • 즉, 엔티티 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다는 것이다.
      이 개념을 더티 체킹이라고 한다.
  • 참고: 블로그- SpringData 더티 체킹 (Dirty Checking)이란?

B. @Transactional 의미

@Transactional은 클래스나 메서드에 붙여줄 경우, 해당 범위 내 메서드가 트랜잭션이 되도록 보장해준다.

선언적 트랜잭션이라고도 하는데, 직접 객체를 만들 필요 없이 선언만으로도 관리를 용이하게 해주기 때문이다.

레거시 코드에서 트랜잭션의 경우, 트랜잭션의 시작과 연산 종료시의 커밋 과정이 필요하므로, 프록시를 생성해 해당 메서드의 앞,뒤에 트랜잭션의 시작과 끝을 추가해줘야 한다.

C. Optional 과 orElseThrow()

아래 구문을 이해해보자.

Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));

A) orElseThrow() 메소드는 Optional 객체에 사용할 수 있는 메소드이다.

Optional 객체에 사용할 수 있는 예외 처리 메소드

  • orElse() : 저장된 값이 존재하면 그 값을 반환하고, 값이 존재하지 않으면 인수로 전달된 값을 반환함.
  • orElseGet() : 저장된 값이 존재하면 그 값을 반환하고, 값이 존재하지 않으면 인수로 전달된 람다 표현식의 결과값을 반환함.
  • orElseThrow() : 저장된 값이 존재하면 그 값을 반환하고, 값이 존재하지 않으면 인수로 전달된 예외를 발생시킴.

만약 orElseThrow()를 안쓴다면 if/else 문을 사용하여 error 예외처리를 해주어야 한다.

  • Optional 객체를 if/else 문으로 예외처리 하는 경우

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);
    }
}
  • Optional 객체를 orElseThrow()로 예외처리 하는 경우
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);

    }
}

B) Optional 객체는 언제 사용될까?

  • Optional이란
    • 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;
         
        ...
      }
  • Optional의 설계 의도
    • 라이브러리 메서드가 반환값이 “없다”는 것을 명백하여 표현할 필요가 있고, null을 반환하면 에러를 유발할 가능성이 높은 상황에서 메서드 반환 타입을 Optional로 사용하기 위해서이다.
    • Optional은 값을 Wrapping하고 다시 풀고, null 일 경우에는 대체하는 함수를 호출하는 등의 오버헤드가 있으므로 잘못 사용하면 시스템 성능이 저하된다.
      그렇기 때문에 메소드의 반환 값이 절대 null이 아니라면 Optional을 사용하지 않는 것이 좋다.
    • 즉, Optional은 메소드의 결과가 null이 될 수 있으며, null에 의해 오류가 발생할 가능성이 매우 높을 때 반환값으로만 사용되어야 한다.

(참고)

C) JpaRepository 를 상속한 postsRepository의 findById()는 Optional 객체를 반환한다.

D) orElseThrow 메소드에 왜 람다를 매개변수로 전달할까?

orElseThrow 는 함수형 인터페이스(Functional Interface)를 매개변수로 전달 받는다.

public <X extends Throwable> T orElseThrow(Supplier<? extends X> exceptionSupplier) throws X

(참고) 컨텐츠- orElseThrow()는 왜 매개변수로 람다식을 전달할까?

D-1. 함수형 인터페이스의 사용 목적

함수형 인터페이스는 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");

정리하자면 함수형 인터페이스는 구현부를 미리 만들지 않아 라이브러리를 사용할 때 상황에 맞는 구현부를 만들어 사용할 수 있다.

(참고) 블로그- 함수형 인터페이스를 사용하는 이유

E) 결론

위의 내용을 정리한 후 아래 구문을 다시 보면

Posts posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
  • postsRepository의 findById()는 Optional 객체를 반환한다.
  • Optional은 NULL을 반환할 가능성이 있는 데이터에 사용된다.
  • Optional 객체를 예외 처리할 때는 orElseThrow()를 사용한다.
  • 만약 findById가 id를 찾지 못하여 NULL을 반환할 경우, orElseThrow() 로 “해당 게시글이 없습니다. id”를 메시지를 출력하도록 예외 처리를 해주었다.
  • 예외가 발생할 때 IllegalArgumentException 의 런타임 에러가 발생한다.
  • 람다식
    • orElseThrow 는 함수형 인터페이스를 매개변수로 받으므로 람다식을 매개 변수로 전달하였다.
  • 함수형 인터페이스는 구현부를 미리 만들지 않아 라이브러리를 사용할 때 상황에 맞는 구현부를 만들어 사용할 수 있다.

3. 테스트 코드로 수정, 조회 API 검증하기

  • src/test/java/com/spring/book/web/PostsApiControllerTest.java
...

@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);
    }
}

1) HttpEntity, ResponseEntity, TestRestTemplate 이해하기

  • HttpEntity

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);
    1. testRestTemplate.getForEntity()
    • 마찬가지로 기본 http 헤더를 사용하며 결과를 ResponseEntity로 반환받는다.

      ResponseEntity<Long> responseEntity = this.testRestTemplate.postForEntity(url, productRegisterRequestDto, Long.class);
    1. testRestTemplate.exchange()
    • update할때 주로 사용된다.

    • 주어진 URI 템플릿에 HTTP 메서드를 실행하고 요청에 주어진 요청 엔티티를 작성하고 응답을 ResponseEntity로 반환합니다. URI 템플릿 변수는 주어진 URI 변수(있는 경우)를 사용하여 확장한다. (→ Http header를 변경할 수 있다.)

    • 형식

      public <T> ResponseEntity<T> exchange(String url,
      																			HttpMethod method,
      																			HttpEntity<?> requestEntity,
      																			Class<T> responseType,
      																			Object... urlVariables)
    • 참고: spring document- Class TestRestTemplate

    • 예시

      // 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;"로 정의함.
  • (1) requestEntity를 이용하여 외부 API를 호출한다. PostsUpdateRequestDto 타입인 requestDto를 함께 전달한다.
  • (2) restTemplate.exchange로 (url로 requestEntity 데이터를 PUT해라)요청 엔터티를 작성하고 응답을 ResponseEntity로 return한다. ResponseEntity는 Http Status, Header, Body 값를 포함하고 있다.

2) 자바 - 리스트와 제네릭스

PostsApiControllerTest 코드를 보고 리스트의 메소드와 제네릭스가 궁금해졌다.

  • 리스트옆에 <타입> 을 명시하는 것을 제네릭스라고 하며, 리스트 안에 <타입>의 자료형만 저장할 수 있다.
  • 아래 코드는 Posts 타입의 객체만 저장할 수 있는 리스트인 List를 선언한 구문이 된다.
List<Posts> all = postsRepository.findAll();

3) h2-console에서 확인하기

  • 로컬 환경에서는 데이터베이스로 H2를 사용하며, 메모리에서 실행된다.
    따라서 직접 접근하려면 웹 콘솔을 사용해야만 한다.
    웹 콘솔을 옵션을 활성화 하려면 application.properties 에서 spring.h2.console.enabled=true 옵션을 추가한다.
    - application.properties
//쿼리 로그 출력
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
  • (1) 웹브라우저에 [http://localhost:8080/h2-console](http://localhost:8080/h2-console) 로 접속한다.
  • (2) JDBC URL에 경로를 수정해준다. → jdbc:h2:mem://localhost/~/testdb
    • 참고

      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’
  • (3) [connect] 버튼을 클릭하면 현재 프로젝트의 H2를 관리할 수 있는 관리페이지로 이동할 수 있다.
  • 조회하기 : SELECT * FROM posts;
  • 데이터 삽입 : insert into posts (author, content, title) values ('author', 'content', 'title');

  • 다시 데이터 조회

A. 트러블슈팅

  • 로그
    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에서 게터가 없으면 응답 내용에 값이 포함되지 않는 문제가 발생

  • 참고: 스프링 테스트 중 406 에러 해결기

* 정리

  • 코드 로직
    • 수정 API는 id로 게시글 데이터를 찾고, 없으면 예외 처리를 하고, 있다면 수정사항을 엔티티에 반영한다.
    • 조회 API는 id로 게시글 데이터를 리포지토리에서 조회한다. 게시글이 없다면 예외 처리하고, 있다면 return한다.
    • DTO는 목적에 따라 여러개 만들 수 있다. DTO마다 필드(또는 엔티티 필드)나 메소드가 다를 수 있으므로 목적에 맞게 생성하여 사용한다.
  • 개념
    • @Transactional은 클래스나 메서드에 붙여줄 경우, 해당 범위 내 메서드가 트랜잭션이 되도록 보장해준다. 선언적 트랜잭션이라고도 하는데, 직접 객체를 만들 필요 없이 선언만으로도 관리를 용이하게 해주기 때문이다.
      • 여기서는 서비스 레벨의 수정 로직에서 @Transational을 메소드에 선언해 주었다.
    • Optional는 null이 올 수 있는 값을 감싸는 Wrapper 클래스로, 참조하더라도 NPE가 발생하지 않도록 도와준다. Optional은 안정성을 위해 사용하지만 오버헤드가 있다. Optional은 메소드의 결과가 null이 될 수 있으며, null에 의해 오류가 발생할 가능성이 매우 높을 때 반환값으로만 사용되어야 한다.
    • 함수형 인터페이스는 1개의 추상메소드를 갖고 있는 인터페이스이다. 함수형 인터페이스는 구현부를 미리 만들지 않아 라이브러리를 사용할 때 상황에 맞는 구현부를 만들어 사용할 수 있다. 매개 변수로 람다식으로 구현부를 만들어 사용한다.
      • orElseThrow 는 함수형 인터페이스(Functional Interface)를 매개변수로 전달 받기 때문에 람다식으로 매개 변수를 전달한다.

* 앞으로 공부할 것

  • 자바 라이브러리들을 공부하고, 자주 사용할만한 것들은 정리해 보아야겠다.

참고

도서 - 스프링 부트와 AWS로 혼자 구현하는 웹 서비스

0개의 댓글