ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 스프링 부트와 AWS 로 구현하는 웹서비스 3장
    책/스프링부트와 AWS로 혼자 구현하는 웹서비스 2022. 5. 1. 23:43

    JPA 라는 자바 표준 ORM 기술을 사용하면 객체지향 프로그래밍을 할 수 있습니다.

    MyBatis, iBatis 는 ORM 이 안닌 SQL Mapper 입니다.

    ORM 은 객체를 맵핑하는 것이고

    SQL MAPPLER 는 쿼리를 매핑합니다.

    3.1 JPA 소개

    현대 웹 어플리케이션에서 RDB(Relational Database, 관계형 데이터베이스) 는 필수적인 요소가 되었습니다. 관계형 데이터베이스는 SQL 만 인식할 수 있기 때문에 SQL 은 필수적입니다. 하지만 객체지향 프로그래밍과 순수 SQL 이 만나면 패러다임 불일치 가 일어납니다.

    RDB는 어떻게 데이터를 저장할지에 초점이 맞춰진 기술입니다.

    객체지향 프로그래밍은 메시지를 기반으로 기능과 속성을 한곳에서 관리 하는 기술 입니다.

    아래 코드를 살펴보겠습니다.

    User user = findUser();
    Group group = user.getGroup();

    코드는 객체지향 프로그래밍에서 부모 객체(User) 가 객체가 되는 객체(Group)를 가져오는 코드입니다. 누구나 명확하게 User 가 본인이 속한 Group 을 가져오는 코드라는 걸 알 수 있습니다. 하지만 DB가 추가되면 다음과 같이 변경 됩니다.

    User user = userDao.findUser();
    Group group = groupDao.findGroup(user.getGroupId());

    User 따로, Group 따로 조회하게 됩니다. 즉, UserGroup 이 어떤 관계를 맺고 있는지 알기 힘듭니다. 상속, 1:N 등 다양한 객체 모델링을 DB로는 구현할 수 없습니다.

    이 패러다임 불일치 해결하기 위해(즉 DB가 추가되어도 객체 지향적인 프로그래밍이 가능할 수 있게) JPA 가 등장합니다.

    JPA 를 사용함으로써 개발자는 객체지향적으로 프로그래밍을 하고, JPA 가 이를 데이터베이스에 맞게 SQL을 대신 생성해서 실행합니다.

    Spring Data JPA

    JPA 는 인터페이스로서 자바 표준명세서입니다. 대표적인 구현체로는 Hibernate, Eclipse Link 등이 있습니다. 하지만 Spring 에서 JPA를 사용할 때 이 구현체를 사용하지 않고 Spring Data JPA 라는 구현체들을 더 쉽게 사용할 수 있게 추상화 시킨 모듈을 사용하여 JPA 를 다룹니다.

    JPA <- Hibernate <- Spring Data JPA 형태로 생각하시면 됩니다.

    Hibernate와 Spring Data JPA 의 사용법은 크게 차이가 없습니다. 하지만 스프링 진영에선 다음과 같은 이유로 Spring Data JPA 사용을 권장하고 있습니다.

    1. 구현체 교체의 용이성
      • Hibernate 외에 다른 구현체로도 쉽게 교체하기 위함입니다.
    2. 저장소 교체의 용이성
      • RDB 외에 다른 저장소로 쉽게 교체하기 위함입니다.
      • 추가적인 설명을 하자면 만약 트래픽이 너무 많아져 RDB 로 감당이 안되면 MongoDB와 같은 DB로 쉽게 교체할 수 있습니다.(Spring Data JPA 에서 Spring Data MongoDB 로 의존성만 교체하면 됩니다.)
      • Spring Data 하위 프로젝트(Spring Data JPA, Spring Data Redis, Spring Data MongoDB 등) 는 save(), findAll(), findOne() 등을 인터페이스로 갖고 있습니다. 그렇기 때문에 저장소가 변경되어도 기본적인 기능은 변경할 것이 없습니다.

    실무에서 JPA

    JPA 를 사용하면 부모-자식 관계 표현, 1:N 관계표현, 상태와 행위를 한곳에서 관리하는 등 객체지향형 프로그래밍을 할 수 있습니다.

    속도 이슈에 대해 걱정은 하지 않아도 됩니다. 잘 활용하면 Native Query 만큼의 속도를 낼 수 있습니다.

    요구사항 분석

    • 게시판 기능
      • 게시글 등록
      • 게시글 조회
      • 게시글 수정
      • 게시글 삭제
    • 회원 기능
      • 구글 / 네이버 로그인
      • 로그인한 사용자 글 작성 권한
      • 본인 작성 글에 대한 권한 관리

    3,2 프로젝트에 Spring Data JPA 적용하기

    Spring Data JPA 를 적용하기 위해 책에서는 h2 데이터 베이스를 사용하였습니다. 하지만 저는 MySQL 를 사용하도록 하겠습니다. h2 데이터베이스의 특징은 아래와 같습니다.

    • In Memory RDB입니다.
    • 메모리에서 실행되기 때문에 애플리케이션이 새로 올라갈 때 마다 초기화 됩니다.

    이제 본격적으로 Spring Data JPA를 사용하기 위해 Domain 이란 패키지를 만들어 안에 posts 라는 폴더를 만들고 그 안에 Posts라는 클래스를 만들도록 하겠습니다.

    도메인이란 소프트웨어에 대한 요구사항 혹은 문제영역(게시글, 댓글, 회원, 정산 등)으로 생각하면 됩니다.

    package com.purple.purplebook.domain.posts;
    ​
    import javax.persistence.Column;
    import javax.persistence.Entity;
    import javax.persistence.GeneratedValue;
    import javax.persistence.GenerationType;
    import javax.persistence.Id;
    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;
        }
    ​
    }
    • 어노테이션 순서는 위에서 부터 덜 중요한 어노테이션 순으로 둡니다.
      • @Entity는 JPA를 사용하기 위한 필수 어노테이션 입니다.
      • @Getter, @NoArgsConstuctor 는 코드를 단순화 시켜주지만 필수 어노테이션은 아닙니다.
      • 이렇게 순서를 위치시킴으로써 이후 코틀린 등의 새 언어로 전환으로 롬복이 더이상 필요없을 경우 쉽게 삭제 할 수 있습니다.
    • @Entity
      • 테이블과 링크될 클래스임을 나타냅니다.
      • 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_) 으로 테이블 이름을 매칭합니다.
      • Ex) UserSubscription.java -> user_subscription table
    • @GeneratedValue
      • PK 의 생성 규칙을 나타냅니다.
      • Spring Boot 2.0에서는 GenerationType.IDENTITY 옵션을 추가해야하면 auto_increment 가 됩니다.
      • 자세한 내용은 이곳 을 참조하면 됩니다.
    • @Column
      • 테이블의 칼럼을 나타내며 선언하지 않아도 해당 클래스에 있는 모든 필드는 칼럼이 됩니다.
      • 사용하는 이유는 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용합니다.
      • 문자열의 경우 VARCHAR(255) 가 기본값인데, 사이즈를 500으로 늘리고 싶거나 타입을 TEXT 로 변경하고 싶은 경우에 사용합니다.
    • @Builder
      • 해달 클래스의 빌더 패턴 클래스 생성
      • 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함

    이 클래스에서보면 @Setter 가 없는것을 알 수 있습니다. 어떤 클래스에선 @Setter 를 넣는게 적합하지만 인스턴스 값들이 언제 어디서 변경하는지 코드상으로 명확히 알 수 없어, 차후 변경시 복잡해집니다.

    따라서 Entity 클래스에서는 절대 Setter를 메소드로 만들지 않습니다.

    아래와 같은 코드를 살펴보겠습니다.

    public class Order {
      public void setStatus(boolean status) {
        this.status = status
      }
      
      public void 주문서비스의_취소이벤트() {
        order.setStatus(false);
      }
    }

    위 코드는 코드상으로 주문을 취소한것인지, 아니면 잘못된 주문을 나타낸것인지 코드에서 명확히 알기 어렵습니다. 따라서 다음과 같이 리팩토링을 한다면 코드레벨에서 잘 이해할 수 있습니다.

    public class Order {
      public void cancleOrder() {
        this.status = false;
      }
      
      public void 주문서비스의_취소이벤트() {
        order.cancelOrder();
      }
    }

    이제 Posts 클래스로 DB를 접근하게 해줄 JpaRepository 를 생성하겠습니다.

    package com.purple.purplebook.domain.posts;
    ​
    import org.springframework.data.jpa.repository.JpaRepository;
    ​
    public interface PostsRepository extends JpaRepository<Posts, Long> {
    }
    ​

    보통 ibatis 나 MyBatis 등에서 Dao 라고 불리는 DB Layer 접근자입니다. JPA 에선 Repository 라고 불립니다.

    @Repository 라고 추가할 필요가 없습니다.

    Entity와 Entity Repository 는 함께 위치해야하는게 나중에 프로젝트 분리할 때도 유지보수 면에서 편합니다.

    이제 이와 관련된 테스트를 만들어 보겠습니다.

    package com.purple.purplebook.domain.posts;
    ​
    import static org.assertj.core.api.Assertions.*;
    import static org.junit.jupiter.api.Assertions.*;
    ​
    import java.util.List;
    import org.junit.jupiter.api.AfterEach;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
    ​
    @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
    class PostsRepositoryTest {
    ​
        @Autowired
        PostsRepository postsRepository;
    ​
        @AfterEach
        public void cleanUp() {
            postsRepository.deleteAll();
        }
    ​
        @Test
        void 게시글저장_불러오기() throws Exception {
            //given
            String title = "테스트 게시글";
            String content = "테스트 본문";
            String author = "fightnyy@gmail.com";
    ​
            Posts posts = Posts.builder()
                              .title(title)
                              .content(content)
                              .author(author)
                              .build();
            postsRepository.save(posts);
            //when
            List<Posts> postsList = postsRepository.findAll();
            //then
            Posts foundPosts = postsList.get(0);
                assertThat(foundPosts.getTitle()).isEqualTo(title);
                assertThat(foundPosts.getContent()).isEqualTo(content);
                assertThat(foundPosts.getAuthor()).isEqualTo(author);
        }
    }

    @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)

    • @SpringBootTest 는 default 로 WebEnvironment.Mock 을 사용한다.
    • 이 경우 내장 톰캣을 띄우지 않고 mock web environment를 제공해준다.

    @AfterEach

    • Junit 에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정한 형태입니다.

    테스트가 잘 돌아가는것을 확인할 수 있습니다. 하지만 쿼리가 어떻게 돌아가는지는 알 수 없었습니다. 쿼리가 어떻게 돌아가는지 확인하고 싶으면 application.yml 에 다음과 같은 코드를 추가하면 됩니다.

    spring:
      jpa:
        show-sql: true

    SQL 를 확인해보니 id bigint generated by default as identity 라고 나와있는걸 알 수 있습니다. 이는 h2 DB 의 문법을 따랐기 때문입니다. 이를 MySQL 문법으로 바꾸려면 해당 코드를 application.yml 에 추가하면 됩니다.

    spring:
      jpa:
        properties:
          hibernate:
            dialect: org.hibernate.dialect.MySQL57Dialect
      datasource:
        hikari:
          jdbc-url: jdbc:h2:mem://localhost/~/testdb;MODE=MYSQL

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

    많은 사람들은 Service 에서 비즈니스 로직을 처리해야 한다 고 생각합니다. 하지만 이는 전혀 사실이 아닙니다. Service 는 트랜잭션, 도메인 간 순서 보장의 역할을 합니다. 그렇다면 비즈니스 로직은 누가 처리할까요?

    다음 그림은 Spring 웹 계층을 나타낸 그림입니다. 각 영역을 소개하자면 다음과 같습니다.

    • Web Layer
      • 흔히 사용하는 컨트롤러(@Controller) 와 JSP/Freemarker 등의 뷰 템플릿 영역입니다.
      • 이외에도 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice) 등 외부 요청과 응답에 대한 전반적인 영역을 이야기합니다.
    • Service Layer
      • @Service 에 서용되는 영역입니다.
      • 일반적으로 Controller 와 Dao 의 중간영역에서 사용됩니다.
      • @Transactional 이 사용되어야 하는 영역이기도 합니다.
    • Repository Layer
      • DB와 같이 데이터 저장소에 접근하는 영역입니다.
      • Dao 영역으로 이해할 수도 있습니다.
    • DTOs
      • Dto(Data Transfer Object) 는 계층 간에 데이터 교환을 위한 객체 를 이야기하며 Dtos 는 이들의 영역을 얘기합니다.
      • 예를들어 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer 에서 결과로 넘겨준 객체 등이 이들을 얘기합니다.
    • Domain Model
      • 도메인이라 불리는 개발 대상을 모두 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화 시킨 것을 도메인 모델이라고 합니다.
      • @Entity가 사용된 영역이 도메인 모델이라고 이해하면 됩니다.
      • 단, 무조건 DB Table 과 관계가 있어야 하는 것은 아닙니다.
      • VO 처럼 값 객체들도 이 영역에 해당 될 수 있기 때문입니다.

    그렇다면 비즈니스 로직은 위 5 영역중 어디서 해결하면 될까요? 바로 도메인 영역입니다.

    기존에 서비스로 비즈니스 로직을 처리하던 방식을 트랜잭션 스크립트 라고 합니다.

    예를 들어 취소로직을 구성해보겠습니다.

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

    이를 트랜잭션 스크립트 방식으로 처리해보겠습니다.

    @Transactional
    public Order cancleOrder(int orderId) {
      
      // 1)
      OrderDto order = ordersDao.selectOrders(orderId);
      BillingDto billing = billingDao.selectBilling(orderId);
      DeliveryDto delivery = deliveryDao.selectDelivery(orderId);
      
      // 2)
      String deliveryStatus = delivery.getStatus();
      
      // 3)
      if ("IN_PROGRSS".equals(deliveryStatus)) {
        delivery.setStatus("CANCEL");
        deliveryDao.update("delivery");
      }
      
      // 4)
      order.setStatus("CANCLE");
      orderDao.update(order);
      
      // 5)
      billing.setStatus("CANCLE");
      billingDao.update(billing);
      
      return order;
    }

    이 경우 모든 로직이 서비스 레이어에서 수행되고 있음을 알 수 있습니다. 반면 도메인 모델에서 처리할 경우 다음과 같은 코드가 됩니다.

    @Transactional
    public Order cancelOrder(int orderId) {
      
      // 1)
      Orders order = ordersRepository.findOrderById(orderId);
      Billings billing = billingRepository.findBillingByOrderId(orderId);
      Deliverys delivery = deliveryRepository.findDeliveryByOrderId(orderId);
      
      // 2-3)
      delivery.cancel();
      
      // 4)
      order.cancel();
      billing.cancle();
      
      return order;
    }

    Order, billing, delivery 가 각자 본인의 취소 이벤트 처리를 하며, 서비스 메소드는 트랜잭션과 도메인간의 순서만 보장 해줍니다. 이후 코드를 구성할 땐 도메인에서 서비스로직을 처리하는 식의 코드를 작성하겠습니다.

    이제 포스팅을 등록, 수정, 삭제 하는 기능을 만들어 보겠습니다.

    Controller

    package com.purple.purplebook.web;
    ​
    import com.purple.purplebook.dto.PostsSaveRequestDto;
    import com.purple.purplebook.service.PostsService;
    import lombok.RequiredArgsConstructor;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RestController;
    ​
    @RequiredArgsConstructor
    @RestController
    public class PostsApiController {
    ​
        private final PostsService postsApiService;
    ​
        @PostMapping("/api/v1/posts")
        public Long save(@RequestBody PostsSaveRequestDto requestDto) {
            return postsApiService.save(requestDto);
        }
    }
    ​

    Service

    package com.purple.purplebook.service;
    ​
    import com.purple.purplebook.domain.posts.PostsRepository;
    import com.purple.purplebook.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();
        }
    }
    ​

    DTO

    package com.purple.purplebook.dto;
    ​
    import com.purple.purplebook.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 클래스를 기준으로 테이블이 생성되고 스키마가 변경됩니다. 예를들어 Client 에서 어떤 화면 변경이 있어 요청 받은 응답의 형태가 변경되어야 한다면 DTO를 사용할 경우 어떤 기능을 추가하거나 삭제하면 되는것에 비해 Entity 를 그대로 사용한다면 데이터베이스를 변경해야하는 엄청난 일이 발생할 수 있습니다.

    이제 Test 코드를 작성하여 검증해보도록 하겠습니다.

    package com.purple.purplebook.web;
    ​
    import static org.assertj.core.api.Assertions.assertThat;
    ​
    import com.purple.purplebook.domain.posts.Posts;
    import com.purple.purplebook.domain.posts.PostsRepository;
    import com.purple.purplebook.dto.PostsSaveRequestDto;
    import java.util.List;
    import org.junit.jupiter.api.AfterEach;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
    import org.springframework.boot.test.web.client.TestRestTemplate;
    import org.springframework.boot.web.server.LocalServerPort;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.ResponseEntity;
    ​
    @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
    class PostsApiControllerTest {
    ​
        @LocalServerPort
        private int port;
    ​
        @Autowired
        private TestRestTemplate restTemplate;
    ​
        @Autowired
        private PostsRepository postsRepository;
    ​
        @AfterEach
        public void tearDown() {
            postsRepository.deleteAll();
        }
    ​
        @Test
        void Posts_등록된다() throws Exception {
            //given
            String title = "title";
            String content = "content";
            String author = "author";
            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
            assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
            assertThat(responseEntity.getBody()).isGreaterThan(0L);
    ​
            List<Posts> postsList = postsRepository.findAll();
            assertThat(postsList.get(0).getTitle()).isEqualTo(title);
            assertThat(postsList.get(0).getContent()).isEqualTo(content);
            assertThat(postsList.get(0).getAuthor()).isEqualTo(author);
        }
    ​
    }

    테스트 코드를 살펴보면 @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)TestRestTemplate 을 사용한것을 알 수 있습니다.

    이전 Controller 테스트를 할때 @WebMvcTest 를 썻던것과 다르게 @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)를 사용하면 모든 빈들을 만들어 사용할 수 있습니다. @WebMvcTest 를 사용했을땐 @Controller@ControllerAdvice에만 빈을 주입했던 것과 다르게 @Serivce, @Repository, @Component 등 모든 빈을 생성합니다. 또한 RANDOM_PORT 를 사용하기 때문에 @SpringBootTest 의 기본값인 Mock 웹서버를 띄우는 것과 다르게 실제로 Tomcat 을 띄우고 포트를 랜덤으로 생성해 띄웁니다. Tomcat 은 기본값으로 8080 으로 띄워지지만 실제로 배포할 땐 8080이 다른 애플리케이션에서 사용할 수 도 있고 악의적인 의도를 가진 사용자가 8080 번으로 Spring Boot 가 띄워진것을 알고 공격을 할 수 있기 때문에 8080으로 띄우지 않은 경우가 있습니다.

    따라서 최대한 배포 환경과 비슷하게 배포하기 위해 RANDOM_PORT 를 사용하였습니다.

    TestRestTemplateRestTemplateWrapper 입니다. TestRestTemplate을 사용하면 헤더에 인증과 관련된 요소를 함께 넣을 수도 있고 여러 편리한점을 제공합니다. TestRestTemplateJavaDoc 을 보면 아래와 같이 나와 있습니다 . 더 자세한 설명은 이 글을 참조하시면 됩니다.

    /**
     * Convenient subclass of {@link RestTemplate} that is suitable for integration tests.
     * They are fault tolerant, and optionally can carry Basic authentication headers. If
     * Apache Http Client 4.3.2 or better is available (recommended) it will be used as the
     * client, and by default configured to ignore cookies and redirects.
     *
     * @author Dave Syer
     * @author Phillip Webb
     */

    이제 등록기능을 완성했으니 수정/조회 기능도 빠르게 만들어 보겠습니다.

        @PutMapping("/api/v1/posts/{id}")
        public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
            return postsApiService.update(id, requestDto);
        }
    ​
        @GetMapping("/api/v1/posts/{id}")
        public PostsResponseDto findById (@PathVariable Long id) {
            return postsApiService.findById(id);
        }

    여기서 주의해서 봐야할 점은 @PutMapping 입니다. HTTP 스펙에도 설명되어 있듯이 어떤 데이터를 추가하거나 등록할 때는 POST 를, 수정할 때는 PUT 을 사용합니다. 하지만 그 외에도 멱등성 이란 차이가 있습니다.

    멱등성이란?

    동일한 요청을 한 번 보내는 것과 여러번 연속으로 보내는 것이 같은 효과를 지니고, 서버의 상태도 동일하게 남을 때 , 해당 HTTP 메서드는 멱등성을 가졌다고 말합니다. 즉, 멱등성 메서드에는 통계 기록 등을 제외하면 어떠한 부수 효과(Side Effect) 도 존재해서는 안됩니다.

    올바르게 구현한 경우 GET, HEAD, PUT, DELETE 메서드는 멱등성을 가지며, POST 메서드는 그렇지 않습니다.

    update 를 요청할 때 사용할 PostsUpdateRequestDto 와 Posts를 조회할 때 응답으로 줄 PostsResponseDto 를 살펴보겠습니다.

    @Getter
    @NoArgsConstructor
    public class PostsUpdateRequestDto {
    ​
        private String title;
        private String content;
    ​
        @Builder
        public PostsUpdateRequestDto(String title, String content) {
            this.title = title;
            this.content = content;
        }
    ​
    }
    @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();
        }
    ​
    }

    변경에 사용할 Posts 객체의 update 코드도 추가해줍니다.

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

    PostsService 코드도 업데이트 합니다.

    @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 posts = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id));
            return new PostsResponseDto(posts);
    ​
        }
    }

    여기서 서비스 코드를 살펴보면 update 코드에서 DB에 쿼리를 날리는 부분이 없습니다. 바로 JPA 영속성 컨텍스트 때문입니다. 영속성 컨텍스트란 엔티티를 영구 저장하는 환경 입니다. JPA 의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐 로 갈립니다. JPA 의 EntityManager 가 활성화된 상태로(Spring Data Jpa 를 사용한다면 기본옵션 입니다.) 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태입니다.이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영 합니다. 즉 Entity 객체의 갑만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다는 얘기 입니다. 이를 더티 체킹 이라고 합니다.

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

    보통의 Entity 들은 생성시간과 수정시간이 포함됩니다. 하지만 이를 등록하거나 수정할 때 하나하나 넣는것은 매우 번거로우면 가독성 측면에서도 좋지 않습니다. 이문제를 해결하고자 우리는 JPA Auditing 을 사용해보도록 하겠습니다.

    LocalDate 사용

    Java8 부터 LocalDate 와 LocalDateTime이 등장했습니다. Java8 이전의 Date와 Calendar 클래스는 다음과 같은 문제점이 있습니다.

    1. 불변 객체가 아닙니다.
      • 멀티스레드 환경에서 언제든 문제가 발생할 수 있습니다.
    1. Calendar는 월(Month) 설계가 잘못되었습니다.
      • 10월을 나타내는 Calendar.OCTOBER 의 숫자값은 9 입니다.

    이를 해결하고자 JodaTime 이란 오픈소스를 사용했지만 Java 8에서 이를 해결했기 때문에 이를 사용합니다.

    @Getter
    @MappedSuperclass
    @EntityListeners(AuditingEntityListener.class)
    public abstract class BaseTimeEntity {
    ​
        @CreatedDate
        private LocalDateTime createdDate;
    ​
        @LastModifiedDate
        private LocalDateTime modifiedDate;
    }
    ​
    • @MappedSuperclass
      • JPA Entity 클래스들이 해당 클래스를 상속할 경우 해당 클래스의 필드들도 칼럼으로 인식하게 됩니다.
    • @EntityListeners(AuditingEntityListener.class)
      • BaseTimeEntity 클래스에 Auditing 기능을 포함시킵니다.
      • 참고로 Audit 이란 감사(Inspect) 란 뜻이 있습니다.
    • @CreatedDate
      • Entity 가 생성되어 저장될 때 시간이 자동으로 저장됩니다.
    • @LastModifiedDate
      • Entity 의 값을 변경할 때 시간이 자동으로 저장됩니다.

    또한 JPA Auditing 어노테이션들을 모두 활성화 할 수 있도록 Application 클래스에 활성화 어노테이션을 하나 추가해줘야합니다.

    @EnableJpaAuditing // JPA Auditing 활성화
    @SpringBootApplication
    public class PurpleBookApplication {
    ​
        public static void main(String[] args) {
            SpringApplication.run(PurpleBookApplication.class, args);
        }
    }

    JPA Auditing 테스트 코드 작성하기

        @Test
        void BaseTimeEntity_등록() throws Exception {
            //given
            LocalDateTime now = LocalDateTime.of(2022, 5, 1, 0, 0, 0);
            Posts posts1 = Posts.builder()
                               .title("title")
                               .content("content")
                               .author("author")
                               .build();
            postsRepository.save(posts1);
    ​
            //when
            List<Posts> postsList = postsRepository.findAll();
    ​
            //then
            Posts posts = postsList.get(0);
            System.out.println(">>>>>>>>> createdDate = " + posts.getCreatedDate() + ", modifiedDate = " + posts.getModifiedDate());
            assertThat(posts.getCreatedDate()).isAfter(now);
            assertThat(posts.getModifiedDate()).isAfter(now);
    ​
        }

    Auditing 이 잘 되는지 확인 해보겠습니다. 아래와 같이 잘 되는것을 확인할 수 있습니다.

     

    댓글