[Spring&AWS][3-3] 등록/수정/조회 API 만들기

mmy789·2022년 3월 15일
0

Spring-AWS

목록 보기
4/13
post-thumbnail

이 글은 책 「스프링 부트와 AWS로 혼자 구현하는 웹 서비스」를 공부하고 정리한 글입니다.

이번 시간에는 본격적으로 게시판 API을 만들어보도록 하겠다.


[ 등록/수정/조회 API 만들기 ]

API를 만들기 위해서는 총 3개의 클래스가 필요하다.

  • Request 데이터를 받을 Dto
  • API 요청을 받을 Controller
  • 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service

0. Spring 웹 계층 살펴보기

지금까지 비지니스 로직은 Service 계층에서 처리해야 한다고 알았는데,
사실 꼭 Service 계층에서 비지니스 로직을 처리하지 않아도 된다고 한다.

Spring 웹 계층을 하나씩 다시 살펴보자.

1. Web Layer

  • 흔히 사용하는 컨트롤러(@Controller)와 JSP/Freemarker 등의 뷰 템플릿 영역이다.
  • 이외에도 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice) 등
    외부 요청과 응답에 대한 전반적인 영역을 이야기한다.

2. Service Layer

  • @Service에 사용되는 서비스 영역이다.
  • 일반적으로 Controller와 Dao의 중간 영역에서 사용된다.
  • @Transactional이 사용되어야 하는 영역이기도 하다.

3. Repository Layer

  • Database와 같이 데이터 저장소에 접근하는 영역이다.
  • Dao(Data Access Obejct) 영역으로 이해하면 된다.

4. Dtos

  • Dto(Data Transfer Object)계층 간에 데이터 교환을 위한 객체를 말하며,
    Dtos는 이들의 영역을 뜻한다.
  • 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 해당된다.

5. Domain Model

  • 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것도메인 모델이라고 한다.
  • 택시 앱을 예시로 들었을 때, 배차, 탑승, 요금 등이 모두 도메인이 될 수 있다.
  • @Entity가 사용된 영역 역시 도메인 모델이라고 이해하면 된다.
  • 다만, VO처럼 값 객체들도 이 영역에 해당되므로 무조건 데이터베이스의 테이블과 관계가 있어야만 하는 것은 아니다.

💡 기존에 서비스로 처리하던 방식은 트랜잭션 스크립트라고 한다. 최근에는 도메인 중심의 개발을 하면서 핵심 비지니스 로직이 도메인 객체 또는 계층에 존재하며, 서비스 계층은 위임의 역할을 하거나 다른 도메인과 함께 처리되어야 하는 비지니스 로직이 많이 작성되기도 한다.


1. 등록 API

✅ PostApiController

@RequiredArgsConstructor
@RestController
public class PostApiController {

    private final PostService postService;

    @PostMapping("/api/v1/post")
    public Long save(@RequestBody PostSaveRequestDto requestDto) {
        return postService.save(requestDto);
    }
}

✅ PostService

@RequiredArgsConstructor
@Service
public class PostService {

    private final PostRepository postRepository;

    @Transactional
    public Long save(PostSaveRequestDto requestDto) {
        return postRepository.save(requestDto.toEntity()).getId();
    }
}

스프링 Bean 등록 시 생성자 주입 방식을 위해 @RequiredArgsConstructor 어노테이션을 사용하였다. (final이 선언된 모든 필드를 인자값으로 하는 생성자를 롬복의 이 어노테이션이 대신 생성해준다.)

생성자를 직접 사용하지 않고 롬복 어노테이션을 사용하는 이유해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위해서이다.

롬복 어노테이션이 있으면 해당 컨트롤러에 새로운 서비스를 추가하거나, 기존 컴포넌트를 제거하는 등의 상황이 발생해도 생성자 코드는 전혀 손대지 않아도 된다.

📌 스프링에서 Bean 주입 방식

  • @Autowired → 권장하지 않는다.
  • setter
  • 생성자 → 가장 권장하는 방식!

✅ PostSaveRequestDto

@Getter
@NoArgsConstructor
public class PostSaveRequestDto {

    private String title;
    private String content;
    private String author;

    @Builder
    public PostSaveRequestDto(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }
    
    public Post toEntity() {
        return Post.builder()
                .title(title)
                .content(content)
                .author(author)
                .build();
    }
}

🙅🏻‍♀️ Entity 클래스를 Request/Response 클래스로 사용하면 안 된다!

  • 여기서 Entity 클래스와 거의 유사한 형태임에도 Dto 클래스를 추가로 생성했다.
  • Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스이기 때문에 이를 기준으로 테이블이 생성되고 스키마가 변경된다.
  • 화면 변경은 아주 사소한 기능 변경인데 이를 위해 테이블과 연관된 Entity 클래스를 변경하는 것은 너무 큰 변경이다.
  • Entity 클래스가 변경되면 여러 클래스에 영향을 끼치지만,
    Request와 Response용 Dto는 View를 위한 클래스라 정말 자주 변경이 필요하다.

그러므로 Entity 클래스와 Controller에서 쓸 Dto는 분리해서 사용해야 한다.


✅ 테스트 - PostApiControllerTest

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostRepository postRepository;

    @After
    public void tearDown() throws Exception {
        postRepository.deleteAll();
    }

    @Test
    public void Post_등록() throws Exception {
        //given
        String title = "title";
        String content = "content";
        PostSaveRequestDto requestDto = PostSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

        String url = "http://localhost:" + port + "/api/v1/post";

        //when
        ResponseEntity<Long> responseEntity= restTemplate.postForEntity(url, requestDto, Long.class);

        //then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);

        List<Post> all = postRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }
}

ApiController를 테스트하는데 HelloController와 달리 @WebMvcTest를 사용하지 않는다.

  • @WebMvcTest의 경우 JPA 기능이 작동하지 않기 때문인데,
    Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화된다.
  • 이렇게 JPA 기능까지 한 번에 테스트 할 때는 @SpringBootTestTestRestTemplate를 사용하면 된다.

WebEnvironment.RANDOM_PORT로 인한 랜덤 포트 실행과 insert 쿼리가 실행된 것까지 모두 확인했다!


2. 수정/조회 API

✅ PostApiController

@RequiredArgsConstructor
@RestController
public class PostApiController {
    ...
    @PutMapping("/api/v1/post/{id}")
    public Long update(@PathVariable Long id, @RequestBody PostUpdateRequestDto requestDto) {
        return postService.update(id, requestDto);
    }

    @GetMapping("/api/v1/post/{id}")
    public PostResponseDto findById(@PathVariable Long id) {
        return postService.findById(id);
    }
}

✅ PostResponseDto

@Getter
public class PostResponseDto {

    private Long id;
    private String title;
    private String content;
    private String author;

    public PostResponseDto(Post entity) {
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.content = entity.getContent();
        this.author = entity.getAuthor();
    }
}

✅ PostUpdateRequestDto

@Getter
@NoArgsConstructor
public class PostUpdateRequestDto {

    private String title;
    private String content;

    @Builder
    public PostUpdateRequestDto(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

✅ Post

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

✅ PostService

@RequiredArgsConstructor
@Service
public class PostService {
    ...
    @Transactional
    public Long update(Long id, PostUpdateRequestDto requestDto) {
        Post post = postRepository.findById(id).
                orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id));

        post.update(requestDto.getTitle(), requestDto.getContent());

        return id;
    }

    public PostResponseDto findById(Long id) {
        Post entity = postRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id));

        return new PostResponseDto(entity);
    }

}

✅ 테스트 - PostApiControllerTest

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostApiControllerTest {
    ...
    @Test
    public void Post_수정() throws Exception {
        //given
        Post savePost = postRepository.save(Post.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        Long updateId = savePost.getId();
        String expectedTitle = "title2";
        String expectedContent = "content2";

        PostUpdateRequestDto requestDto = PostUpdateRequestDto.builder()
                .title(expectedTitle)
                .content(expectedContent)
                .build();

        String url = "http://localhost:" + port + "/api/v1/post/" + updateId;
        HttpEntity<PostUpdateRequestDto> 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<Post> all = postRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
        assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
    }
} 


✅ 조회 기능 동작 확인하기


3. JPA Auditing으로 생성시간/수정시간 자동화하기

보통 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함한다. 언제 만들어졌는지, 언제 수정되었는지 등은 차후 유지보수에 중요한 정보이기 때문이다.

생성시간/수정시간 정보를 매번 DB에 삽입하기 전, 갱신하기 전에 코드로 직접 등록/수정할 수도 있지만, JPA Auditing를 이용하면 이러한 생성시간/수정시간을 자동화할 수 있다.


✅ LocalDate 사용

  • domain 패키지에 BaseTimeEntity 클래스 생성
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    @CreatedDate
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime modifiedDate;
}

BaseTimeEntity 클래스는 모든 Entity의 상위 클래스가 되어 Entity들의 createdDate, modifiedDate를 자동으로 관리하는 역할을 한다.

코드 설명

  • @MappedSuperclass
    • JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드를 칼럼으로 인식하도록 한다.
  • @EntityListeners(AuditingEntityListener.class)
    • BaseTimeEntity 클래스에 Auditing 기능을 포함시킨다.
  • @CreatedDate
    • Entity가 생성되어 저장될 때 시간이 자동으로 저장된다.
  • @LastModifiedDate
    • 조회한 Entity의 값을 변경할 때 시간이 자동으로 저장된다.

  • Application에 JPA Auditing 활성화 어노테이션 추가
@EnableJpaAuditing	//JPA Auditing 활성화
@SpringBootApplication
public class BookApplication { ... }

✅ 테스트 - PostRepositoryTest

public class PostRepositoryTest {
    @Test
    public void BaseTimeEntity_등록() {
        //given
        LocalDateTime now = LocalDateTime.of(2022, 03, 13, 0, 0, 0);
        postRepository.save(Post.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        //when
        List<Post> postList = postRepository.findAll();

        //then
        Post post = postList.get(0);

        System.out.println(">>>>>>>>>> createdDate = " + post.getCreatedDate() + ", modifiedDate = " + post.getModifiedDate());
        assertThat(post.getCreatedDate()).isAfter(now);
        assertThat(post.getModifiedDate()).isAfter(now);
    }
} 


다음 시간에는 Mustache를 이용하여 화면 영역을 개발해보도록 하겠다.


[ 참고자료 ]

https://mangkyu.tistory.com/156

profile
Backend Developer

0개의 댓글