Super Kawaii Cute Cat Kaoani 스프링 부트와 AWS로 혼자 구현하는 웹 서비스(3)

스프링 부트와 AWS로 혼자 구현하는 웹 서비스(3)

2024. 1. 22. 01:31
728x90
SMALL

📌 JPA

JPA : Java 표준 ORM (Object Relational Mapping)

 

⚡ 패러다임 불일치 문제

  • 관계형 데이터베이스 : 어떻게 데이터를 저장할지에 초점이 맞춰진 기술
  • 객체지향 프로그래밍 : 언어는 메시지를 기반으로 기능과 속성을 한 곳에서 관리하는 기술

✔ 다양한 객체 모델링을 데이터베이스로 구현할 수 없음
✔ 웹 애플리케이션 개발은 점점 데이터베이스 모델링에만 집중!!

 

❗ JPA로 문제 해결
 : 서로 지향하는 바가 다른 2개 영역을 중간에서 패러다임 일치를 시켜주기 위한 기술

 

⚡ Spring Data JPA

  • 인터페이스로서 자바 표준 명세서
  • JPA를 사용하기 위해서는 구현체 필요
  • Hibernate, Eclipse Link 등

✔ 스프링에서는 JPA를 사용할 때는 구현체를 직접 다루지 않고 좀 더 쉽게 사용하고자 추상화시킨 Spring Data JPA라는 모듈을 이용하여 JPA 기술 다룸

 

JPA <- Hibernate <- Spring Data JPA

 

Spring Data JPA가 등장한 이유

  1. 구현체 교체의 용이성
  • Hibernate 외에 다른 구현체로 쉽게 교체하기 위함
  • ex) Hibernate가 언젠간 수명을 다해서 새로운 JPA구현체가 대세로 떠오를 때, Spring Data JPA를 쓰는 중이라면 아주 쉽게 교체할 수 있음
  1. 저장소 교체의 용이성
  • 관계형 데이터베이스 외에 다른 저장소로 쉽게 교체하기 위함
  • Spring Data 하위 프로젝트들은 기본적인 CRUD 인터페이스가 동일하기에 가능함
  • ex) RDB에서 MongoDB로 저장소 교체가 필요하다면 Spring Data JPA에서 Spring Data MongoDB로 의존성만 교체하면 됨

 

📌 게시판 생성

✔ 게시판 기능

  • 게시글 조회
  • 게시글 등록
  • 게시글 수정
  • 게시글 삭제

✔ 회원 기능

  • 구글/네이버 로그인
  • 로그인한 사용자 글 작성 권한
  • 본인 작성 글에 대한 권한 관리

 

 

⚡ Spring Data JPA 적용

🔶 build.gradle의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'

spring-boot-starter-data-jpa

  • 스프링 부트용 Spring Data Jpa 추상화 라이브러리
  • 스프링 부트 버전에 맞춰 자동으로 JPA관련 라이브러리들의 버전을 관리해줌

h2

  • 인메모리 관계형 데이터베이스
  • 별도의 설치가 필요없이 프로젝트 의존성만으로 관리할 수 있음
  • 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화된다는 점을 이용하여 테스트 용도로 많이 사용됨

 

Posts.java

domain 패키지

  • 도메인을 담을 패키지
  • 도메인 : 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제 영역을 의미함

🔶 main/.../springbootwebservice/domain/posts/Posts 

package com.yerin.book.springbootwebservice.domain.posts;

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@Entity
public class Posts {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 500, nullable = false)
    private String title;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

    private String author;

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

}

✔ 주요 어노테이션을 클래스에 가깝게 둠

  • @Getter, @NoArgsConstructor 같은 Lombok은 필수 어노테이션이 아님
  • @EntityJPA 어노테이션으로 클래스에 가깝게 둠

@Entity

  • 테이블과 링크될 클래스임을 나타냄
  • 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭함
    • ex) SalesManager.java -> sales_manager table

@Id

  • 해당 테이블의 PK 필드

@GeneratedValue

  • PK의 생성 규칙을 나타냄
  • 스프링 부트 2.0에서는 CenerationType.IDENTITY 옵션을 추가해야만 auto_increment가 됨

@Colunm

  • 테이블의 칼럼을 나타내며 굳이 선언하지 않더라도 해당 클래스의 필드는 모두 칼럼이 됨
  • 사용하는 이유는 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용
  • 문자열의 경우 VARCHAR(255)가 기본값인데, 사이즈를 500으로 늘리고 싶을 때 사용
  • 타입을 TEXT로 변경하고 싶을 때 사용

@NoArgsConstructor

  • 기본 생성자 자동 추가
  • public Posts(){}와 같은 효과

@Getter

  • 클래스 내 모든 필드의 Getter 메소드를 자동 생성

@Builder

  • 해당 클래스의 빌더 패턴 클래스를 생성
  • 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함

Entity 클래스에는 절대 Setter 메소드를 만들지 않음

    • 무작정 getter/setter를 무작정 생성하는 경우, 해당 클래스의 인스턴스 값들이 언제 어디서 변해야 하는지 코드상으로 명확하게 구분할 수 없어 차후 기능 변경시 매우 복잡해짐
    • Setter 메소드를 만드는 대신, 해당 필드의 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가해야 함

 

 

❗ Setter가 없는 상황에선 어떻게 값을 채워 DB에 삽입하는가? 
    • 생성자를 통해 최종값을 채운 후 DB에 삽입(기본적인 구조)
    • 값 변경이 필요한 경우 해당 이벤트에 맞는 public 메소드를 호출하여 변경하는 것
    • @Builder를 통해 제공되는 빌더 클래스를 사용
    • 생성자와 비슷하지만 생성자는 지금 채워야 할 필드가 무엇인지 명확히 지정 불가

 

 

PostsRepository.java

🔶 main/.../posts 아래에 PostsRepository 이름의 클래스 생성

package com.yerin.book.springbootwebservice.domain.posts;

import org.springframework.data.jpa.repository.JpaRepository;

public interface PostsRepository extends JpaRepository<Posts, Long> {

}
    • interface로 생성
    • JpaRepository< Entity, PK 타입> 을 상속
    • 기본적인 CRUD 메소드가 자동으로 생성됨
    • @Repository를 추가할 필요도 없음
    • Entity 클래스와 기본 Entity Repository는 함께 위치해야 함

 

 

PostsRepositoryTest.java

📎 단축키 command + shift + T -> Test 클래스 생성
🔶 src/test/.../posts/PostsRepositoryTest

package com.yerin.book.springbootwebservice.domain.posts;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import java.util.List;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(SpringExtension.class)
@SpringBootTest
public class PostsRepositoryTest {

    @Autowired
    PostsRepository postsRepository;

    @AfterEach //@After ???
    public void cleanup(){
        postsRepository.deleteAll();;
    }

    @Test
    public void 게시글저장_불러오기(){
        //given
        String title = "테스트 게시글";
        String content = "테스트 본문";

        postsRepository.save(Posts.builder()
                .title(title)
                .content(content)
                .author("nir2y@naver.com")
                .build());

        //when
        List<Posts> postsList = postsRepository.findAll();

        //then
        Posts posts = postsList.get(0);
        assertThat(posts.getTitle()).isEqualTo(title);
        assertThat(posts.getContent()).isEqualTo(content);
    }
}

@SpringBootTest를 사용하면 H2 데이터베이스를 자동으로 실행

@AfterEach

  • JUnit에서 단위테스트가 끝날 때마다 수행되는 메소드 지정
  • 보통은 배포 전 전체 테스트를 수행할 때 테스트 간 데이터 침범을 막기위해 사용
  • 여러 테스트가 동시에 수행되면 테스트용 데이터베이스인 H2에 데이터가 그대로 남아있어 다음 테스트 실행 시 테스트가 실패할 수도 있음

postsRepository.save

  • 테이블 posts에 insert/update 쿼리를 실행함
  • id 값이 있다면 update, 없다면 insert 쿼리가 실행됨

postsRepository.findAll

  • 테이블 pists에 있는 모든 데이털르 조회해오는 메소드

실행된 쿼리 로그 확인

    • resources/application.properties 파일에 다음 옵션을 추가
    • H2 문법의 쿼리 로그를 확인할 수 있음
    • spring.jpa.show_sql=true
    • H2 문법을 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

 

반응형

📌 등록/수정/조회 API 만들기

⚡ API를 만들기 위해 필요한 클래스

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

 

❗Service에서 비지니스 로직을 처리해야 한다는 것은 절대 아님

  •  Service는 트랜잭션, 도메인 간 순서 보장의 역할만 함
  •  비즈니스 로직을 처리하는 건 Domain임

 

 

⚡ Spring 웹 계층

 

Web Layer

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

 

Service Layer

  • @Service에 사용되는 서비스 영역
  • 일반적으로 Controller와 Dao의 중간 영역에서 사용됨
  • @Transaction이 사용되어야 하는 영역

 

Repository Layer

  • Database와 같이 데이터 저장소에 접근하는 영역
  • Dao(Data Access Object)의 영역으로 이해하면 됨

 

Dtos

  • Dto(Data Transfer Object) : 계층 간에 데이터 교환을 위한 객체
  • Dtos : Dto의 영역
  • 뷰 템플릿 엔진에서 사용될 객체, Repository Layer에서 결과로 넘겨준 객체를 의미

 

Domain Model

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

 

⚡ 트랜잭션 스크립트

  • 기존에 서비스로 비지니스로직을 처리하던 방식

🔶 주문 취소 로직

 

✔ 수도 코드

@Transactional
public Order cancelOrder(int orderId){
    1) 데이터베이스로부터 주문정보(Orders), 결제정보(Billing), 배송정보(Delivery) 조회
    2) 배송 취소를 해야 하는지 확인
    3) if (배송 중이라면){
        배송 취소로 변경
    }
    4) 각 테이블에 취소 상태 Update
}

 

✔ 실제 코드

@Transactional
public Order cancelOrder(nt orderId){
    // 1)
    OrdersDto order = ordersDao.selectOrders(orderId);
    BillingDto billing = billingDao.selectBilling(orderId);
    DeliveryDto delivery = deliveryDao.selectDelivery(orderId);

    // 2)
    String deliveryStatus = delivery.getStatus();

    // 3)
    if("IN_PROGRESS".equals(deliveryStatus)){
        delivery.setStatus("CANCEL");
        deliveryDao.update(delivery);
    }

    // 4)
    order.setStatus("CANCEL");
    ordersDao.update(order);

    billing.setStatus("CANCEL");
    deliveryDao.update(billing);

    return order;
}
  • 모든 로직이 서비스 클래스 내부에서 처리됨
  • 서비스 계층이 무의미함
  • 객체란 단순히 데이터 덩어리 역할만 함

 

 

⚡ 도메인 모델에서 처리

🔶 주문 취소 로직

@Transactional
public Order cancelOrder(int orderId){

    // 1)
    Order order = orderRepository.findById(orderId);
    Billing billing = billingRepository.findByOrderId(orderId);
    Delivery delivery = deliveryRepository.findByOrderId(orderId);

    // 2-3)
    delivery.cancel();

    // 4)
    order.cancel();
    billing.cancel();

    return order;

}
  • order, billing, delivery가 각자 본인의 취소 이벤트 처리를 함
  • 서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장

 

 

PostsApiController.java

🔶 main/.../web 아래에 생성

package com.yerin.book.springbootwebservice.web;

import com.yerin.book.springbootwebservice.web.dto.PostsResponseDto;
import com.yerin.book.springbootwebservice.service.posts.PostsService;
import com.yerin.book.springbootwebservice.web.dto.PostsSaveRequestDto;
import com.yerin.book.springbootwebservice.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;

    //저장
    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto){
        return postsService.save(requestDto);
    }

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

@PutMapping("/api/v1/posts/{id}")

  • HTTP PUT 메서드를 사용하여 적힌 경로에 매핑된 API 엔드포인트를 정의함
  • 이 엔드포인트는 수정 작업 수행

@PathVariable

  • 경로에서 추출한 값을 메서드의 파라미터로 매핑해주는 역할
  • {id} 부분은 경로에서 추출한 값을 Long id파라미터로 매핑

@RequestBody

  • HTTP 요청의 본문(body)에 들어있는 데이터를 메서드의 파라미터 객체로 매핑해주는 역할
  • PUT 요청의 본문에 들어있는 JSON 데이터를 PostsUpdateRequestDto 객체로 매핑하여 requestDto파라미터로 사용

PostsUpdateRequestDto

  • 글의 수정을 위한 요청 데이터를 담는 DTO
  • 클라이언트가 수정 요청을 보낼 때 JSON 형태의 데이터를 이 클래스의 객체로 변환하여 전송

@GetMapping("/api/v1/posts/{id}")

  • HTTP GET 메서드를 사용하여 해당 경로에 매핑된 API 엔트포인트를 정의
  • 조회 작업 수행

PostsResponseDto

  • 특정 글의 조회 결과를 클라이언트에게 반환하기 위한 DTO

 

 

PostsService.java

🔶 main/.../springbootwebservive/service/PostService 

package com.yerin.book.springbootwebservice.service.posts;

import com.yerin.book.springbootwebservice.domain.posts.Posts;
import com.yerin.book.springbootwebservice.domain.posts.PostsRepository;
import com.yerin.book.springbootwebservice.web.dto.PostsUpdateRequestDto;
import com.yerin.book.springbootwebservice.web.dto.PostsResponseDto;
import com.yerin.book.springbootwebservice.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class PostsService {

    private final PostsRepository postsRepository;

    @Transactional
    public Long save(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }

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

@RequiredArgsConstructor

  • Lombok 프로젝트에서 제공하는 어노테이션
  • 필드 주입 방식의 생성자 자동 생성
  • final로 선언된 필드들을 가지고 생성자를 만들어주는 역할을 함

@Service

  • Spring 프레임워크에게 해당 클래스가 비즈니스 로직을 처리하는 서비스 클래스임을 알려줌
  • 스프링 컨테이너가 이 어노테이션이 붙은 클래스를 빈으로 등록하며 서비스클래스를 DI하여 사용할 수 있게 해줌

@Transactional

    • 메서드에 부여하여 해당 메서드가 하나의 트랜잭션으로 동작하도록 지정하는 역할
    • 트랜잭션은 일련의 작업을 하나의 단위로 묶어서 처리하는 것을 의미함
    • 메서드 내에서 데이터베이스의 데이터를 수정하거나 저장하는 작업을 수행할 때, 이 작업들이 모두 성공적으로 완료되어야만 트랜잭션이 커밋되고, 그렇지 않으면 롤백됨

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

  • orElseThrow : Java의 Optional 클래스의 메서드 중 하나
    • Optional 객체에 값이 존재할 경우 그 값을 반환하고 없을 경우 지정한 예외 발생

 

스프링에서 Bean을 주입받는 방식

✔ @Autowired

  • 권장하지 않음

✔ setter

생성자

  • 가장 권장하는 방식
  • @RequestArgsConstructor가 해결
    • final이 선언된 모든 필드를 인자값으로 해줌
  • 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움 해결

 

 

PostsSaveRequestDto.java

🔶 main/.../web/dto/PostsSaveRequestDto 
Controller와 Service에서 사용

package com.yerin.book.springbootwebservice.web.dto;

import com.yerin.book.springbootwebservice.domain.posts.Posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class PostsSaveRequestDto {

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

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

    public Posts toEntity() {
        return Posts.builder()
                .title(title)
                .content(content)
                .author(author)
                .build();
    }
}

✔ Entity 클래스와 거의 유사한 형태임에도 Dto 클래스를 추가로 생성함

절대로 Entity 클래스를 Request/Response 클래스로 사용해선 안됨.

  • Entity 클래스
    • 데이터베이스와 맞닿은 핵심 클래스
    • Entity 클래스를 기준으로 테이블이 생성되고, 스키마가 변경
    • Entity 클래스의 변경은 여러 클래스에 영향일 끼침
  • Request와 Response 용 Dto는 View를 위한 클래스라 자주 변경됨
    • View Layer와 DB Layer의 역할 분리를 철저하게 하는 것이 좋음
    • Controller에서 결과값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번함
    • Entity 클래스 만으로 표현하기 어려움
      •  

 

@Builder

  • Lombok의 어노테이션
  • 빌더 패턴을 구현하는 메서드를 자동으로 생성해줌
  • 빌더 패턴 : 객체 생성 과정을 보다 직관적이고 가독성 좋게 만들기 위한 디자인 패턴 중 하나

toEntity()

  • PostsSaveRequestDto 클래스에서 데이터를 Posts 엔티티 객체로 변환하는 역할
  • DTO는 컨트롤러와 서비스 사이에서 데이터를 주고받을 때 사용되며 엔티티는 데이터 베이스와의 상호작용을 담당

 

PostsResponseDto.java

🔶 main/.../web/dto/PostsResponseDto 

package com.yerin.book.springbootwebservice.web.dto;

import com.yerin.book.springbootwebservice.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();
    }
}

PostsResponseDto

 

PostsUpdateRequestDto.java

🔶 main/.../web/dto 아래에 PostsUpdateRequestDto이름의 클래스 생성

package com.yerin.book.springbootwebservice.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;
    }
}

 

PostsApiControllerTest.java

📎 단축키 command + shift + T -> Test 클래스 생성
🔶 src/test/.../web/PostsApiControllerTest

package com.yerin.book.springbootwebservice.web;

import com.yerin.book.springbootwebservice.domain.posts.Posts;
import com.yerin.book.springbootwebservice.domain.posts.PostsRepository;
import com.yerin.book.springbootwebservice.web.dto.PostsSaveRequestDto;
import com.yerin.book.springbootwebservice.web.dto.PostsUpdateRequestDto;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import java.util.List;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

    @AfterEach
    public void tearDown() throws Exception{
        postsRepository.deleteAll();
    }

    @Test
    public void Posts_등록된다() throws Exception{

        //given
        String title="title";
        String content = "content";

        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

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


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

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

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

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

 

@WebMvcTest를 사용하지 않음

  • 이 애노테이션은 JPA 기능이 작동하지 않기 때문에, Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화되니 지금 같이 JPA 기능까지 한번에 테스트할 때는 @SpringBootTest와 TestRestTemplate을 사용하면 됨

WebEnvironment.RANDOM_PORT로 인한 랜덤 포트 실행과 insert쿼리가 실행된 것을 모두 확인

 

HttpEntity<PostsUpdateRequestDto> requestEntity

  • HttpEntity : HTTP 요청과 응답의 헤더와 본문을 포함하는 클래스
  • PUT 요청을 보낼 때 수정하려는 데이터가 본문에 포함되어야 하기 때문

ResponseEntity<Long> responseEntity

  • 스프링 프레임워크의 클래스로서, HTTP 응답을 나타내는 객체
  • 제네릭 타입을 통해 응답 본문의 데이터 타입을 지정 가능

restTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class)

  • 다양한 HTTP 메서드로 요청을 보내고 그에 대한 응답을 받을 수 있는 메서드
  • PUT 메서드로 요청을 보내고
  • url : 요청을 보낼 URL
  • requestEntity : 요청 본문에 담길 데이터를 포함한 HttpEntity 객체
  • 응답으로 ResponseEntity<Long>을 받기 때문에 Long 타입의 데이터 받음

assertThat(responseEntity.getBody()).isGreaterThan(0L);

    • 응답 본문에 담긴 데이터가 0보다 큰지 검증
    • 0보다 크다면 성공적

 

update 기능에서 쿼리를 날리는 부분이 없음
-> JPA 영속성 컨텍스트 때문

  • 영속성 컨텍스트 : 엔티티를 영구 저장하는 환경
    • JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태
    • 이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영
    • 즉, Entity 객체의 값만 변경하면 별도 Update 쿼리를 날릴 필요가 없음(더티체킹)

 

 

 

⚡ 톰캣을 실행해서 조회기능 확인

✔ 데이터베이스 : H2 사용
-> 메모리에서 실행하기 때문에 직접 접근하려면 웹 콘솔 사용해야 함

    • application.properties에 코드 추가
      spring.h2.console.enabled=true
    • http://localhost:8081/h2-console 접속
    • JDBC URL을 jdbc:h2:mem:testdb로 설정
    • Connect
    • SELECT * FROM posts;
    • insert into posts (author, content, title) values ('author', 'content', 'title');

 

http://localhost:8080/api/v1/posts/1 검색

 

 

 

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

    • 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함함
    • 매번 DB에 삽입하기 전, 갱신하기 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가면 코드가 지저분해지기도 하고 귀찮아짐
    • JPA Auditing 사용

 

⚡ LocalDate 사용

Java8부터 LocalDate와 LocalDateTime 등장

  • domain 패키지에 BaseTimeEntity 클래스 생성
package com.yerin.book.springbootwebservice.domain;

import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {

    @CreatedDate
    private LocalDateTime createDate;

    @LastModifiedDate
    private LocalDateTime modifiedDate;
}

@MappedSuperclass

  • JPA Entity 클래스들이 BaseTimeEntity을 상속할 경우 필드들(createDate, modifiedDate)도 칼럼으로 인식하도록 함

@EntityListeners(AuditingEntityListener.class)

  • BaseTimeEntity 클래스에 Auditing 기능 포함시킴

@CreatedDate

  • Entity가 생성되어 저장될 때 시간이 자동 저장됨

@LastModifiedDate

  • 조회한 Entity의 값을 변경할 때 시간이 자동으로 저장됨

Posts 클래스에 BaseTimeEntity 상속시킴
❗ JPA Auditing 어노테이션들이 활성화할 수 있도록 Application에 활성화 어노테이션 추가

  • @EnableJpaAuditing

 

JPA Auditing 테스트코드

🔶 PostsRepositoryTest.java에 메소드 추가

    @Test
    public void BaseTimeEntity_등록(){

        //given
        LocalDateTime now = LocalDateTime.of(2023,8,19,0,0,0);
        postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());
        //when
        List<Posts>postsList = postsRepository.findAll();

        //then
        Posts posts = postsList.get(0);

        System.out.println(">>>>>>>>> createDate=" + posts.getCreateDate()+", modifiedDate="+posts.getModifiedDate());

        assertThat(posts.getCreateDate()).isAfter(now);
        assertThat(posts.getModifiedDate()).isAfter(now);
    }

>>>>>>>>> createDate=2023-08-20T15:22:19.238646, modifiedDate=2023-08-20T15:22:19.238646
-> 실제 시간 잘 저장됨

 

 

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스_이동욱 지음 책을 읽고 정리하는 글입니다

 

 

 

 


PREV

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스(2)

📌 Test Code ✔ TDD : 테스트가 주도하는 개발 ✔ 단위 테스트 : 기능 단위의 테스트 코드를 작성하는 것 ⚡ 단위 테스트 단위 테스트 장점 ✔ 빠른 피드백 - 코드를 수정할 때마다 톰캣을 내렸다가

nyeroni.tistory.com

NEXT

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스(4)

📌 머스테치로 화면 구성하기 ⚡ 서버 템플릿 엔진과 머스테치 소개 ✔ 템플릿 엔진 : 지정된 템플릿 양식과 데이터가 합쳐서 HTML 문서를 출력하는 소프트웨어 클라이언트 템플릿 엔진 : 리액트

nyeroni.tistory.com

 

728x90
LIST

BELATED ARTICLES

more