스프링 부트 & JPA - 루타블의 개발일기

김주영·2022년 7월 5일
0
post-thumbnail

[본 글은 프로젝트 과정을 기록할 목적으로 작성되었으며 아래 교재에 기반하여 작성됨]

🌱 SQL Mapper ve ORM


📢 SQL Mapper

SQL 쿼리를 매핑하는 데이터 객체화 기술

  • 장점
    1. SQL 응답 결과를 객체로 편리하게 변환할 수 있다.
    2. JDBC 반복 코드를 제거할 수 있다.
    3. 난이도가 낮은 편이다.
  • 단점
    1. 개발자가 SQL을 직접 작성해야 함

ex) MyBatis, Spring JdbcTemplate

📢 ORM(Object Relational Mapping)

객체를 관계형 DB 테이블과 매핑해주는 기술

  • 장점
    1. 동적 SQL 생성 -> SQL에 종속적인 개발을 하지 않아도 된다.
    2. DB마다 다른 SQL 문제 해결
    3. 개발 생산성이 높다.
  • 단점
    1. 난이도가 높은 편이다.

ex) JPA(표준 인터페이스), Hibernate, eclipseLink

🌱 JPA


📢 등장

DB로는 상속, 1:N 등 다양한 객체 모델링을 구현할 수 없다. 그 이유는 서로의 패러다임이 다르기 때문이다. 즉, 서로 다른 목적으로 개발되었다는 것이다. 이것이 문제가 되는 이유는 객체지향보다 DB 모델링에만 집중하게 된다는 것이다. 이런 문제점을 해결하기 위해 관계형 DB에 맞게 SQL을 대신 생성해서 실행하는 JPA가 등장했다.

📢 Spring Data JPA 권장

구현체들을 좀 더 쉽게 사용하고자 추상화시킨 Spring Data JPA 모듈을 이용하여 JPA 기술을 다룬다.

JPA <- Hibernate <- Spring Data JPA

  • 구현체 교체의 용이성 : 내부에서 구현체 매핑을 지원
  • 저장소 교체의 용이성 : 관계형 DB 외에 다른 저장소로 쉽게 교체하기 위함

만약 MongoDB로 교체가 필요하다면 Spring Data JPA에서 Spring MongoDB로 의존성만 교체하면 된다. 이것이 가능한 이유는 Spring Data의 하위 프로젝트들은 기본적인 CRUD의 인터페이스가 같기 때문이다. 따라서, 저장소가 교체되어도 기본적인 기능은 변경할 것이 없다.

🌱 Spring Data JPA 적용


🌿 build.gradle

// Spring Data JPA와 H2 추가
dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.projectlombok:lombok')
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compile('com.h2database:h2')
    testCompile('org.springframework.boot:spring-boot-starter-test')
}

🔧 도메인을 담을 domain 패키지 추가

도메인이란 게시글, 댓글, 회원 등 소프트웨어에 대한 요구사항 혹은 문제 영역을 말한다.

🔧 domain 패키지에 posts 패키지와 Posts 클래스 생성

🌿 Posts

실제 DB의 테이블과 매칭될 클래스이며 보통 Entity 클래스라고도 한다.

package com.bbs.projects.bulletinboard.domain.posts;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Getter
@NoArgsConstructor
@Entity
public class Posts {

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

    @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

테이블과 링크될 클래스임을 나타낸다. 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭한다.

📢 @Id

해당 테이블의 PK 필드

📢 @GeneratedValue

PK의 생성 규칙을 나타낸다. GenerationType.IDENTITY 옵션은 auto_increment를 말한다.

📝 참고

웬만하면 Entity의 PK는 Long 타입의 auto_increment를 추천한다. 주민등록번호와 같이 비즈니스상 유니크 키나, 여러 키를 조합한 복합키로 PK를 잡을 경우 다음과 같은 문제가 있으므로 별도로 유니크 키로 추가하는 것을 추천한다.

  1. FK를 맺을 때 다른 테이블에서 복합키 전부를 갖고 있거나, 중간 테이블을 하나 더 둬야 하는 상황이 발생한다.

  2. 인덱스에 좋은 영향을 끼치지 못한다.

  3. 유니크한 조건이 변경될 경우 PK 전체를 수정해야 하는 일이 발생한다.

📢 @Column

테이블의 칼럼을 나타내며 굳이 선언하지 않더라도 해당 클래스의 필드는 모두 칼럼이 된다. 사용하는 이유는, 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용한다.

📢 @NoArgsConstructor

기본 생성자를 자동으로 추가해 준다.

사실 컴파일러가 기본 생성자를 자동으로 생성해 준다. 하지만 만약, 전체 필드를 가지는 생성자가 필요할 때 사용하는 @AllArgsConstructor를 선언하고 이것을 생략하면 컴파일러는 전체 필드를 가지는 생성자를 우선적으로 생성하기 때문에 기본 생성자 호출이 필요할 경우에도 전체 필드를 가지는 생성자가 호출되어 버린다. 이럴 경우 에러가 나기 때문에 해당 애노테이션을 사용한다.
ref : https://pinokio0702.tistory.com/176

📢 @Builder

점증적 생성자 패턴 -> 자바 빈즈 패턴 -> 빌더 패턴

점증적 생성자 패턴은 일반적으로 사용하는 생성자로 필수 입력 인자와 선택 입력 인자에 대한 유연성이 없어 매개 변수가 늘어날수록 생성자가 많아지고 매개 변수의 정보를 설명하기 힘들다는 단점이 있다.

ex)

A a = new A("a", 20, 255);

그래서 나온 자바 빈즈 패턴은 일단 객체를 생성한 후에 setter를 통해 필요한 값을 넣는 형태다. 가독성은 개선되지만 코드가 길어지고 가장 문제는 객체 일관성이 깨진다는 것이다. 즉, 한번 객체가 생성된 후에 값이 변경될 여지가 있다는 것이다.

ex)

A a = new A();
a.setName("kkk");
a.setAge(20);

둘의 단점을 보완하고자 빌더 패턴이 등장했다. 정보들을 자바 빈즈 패턴처럼 받되, 정보를 다 받은 후에 객체를 생성하는 방식이다.

ex)

A a = A.builder()
	.name("kkk")
    .age(20);
  • 빌더 클래스를 제공하여 생성 시점에 값을 채워준다.
  • 필요한 데이터만 수정할 수 있다.
  • 채워야 할 필드가 무엇인지 명확히 지정할 수 있다.
  • 가독성이 높다.
  • 매개 변수 구성이 변경되어도 코드 수정이 필요 없다.
  • 불변 객체를 만들 수 있다.
  • build() 함수가 null인지 체크하므로 검증이 가능하다.
    ref : https://esoongan.tistory.com/82
  • Entity 클래스에서 setter 메소드 사용 금지

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

🌿 PostsRepository

🔧 Posts 클래스로 DB를 접근하게 해 줄 JpaRepository를 생성(DAO)

package com.bbs.projects.bulletinboard.domain.posts;

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

//DAO + 자동 Bean 등록
public interface PostsRepository extends JpaRepository<Posts, Long> {
}

JpaRepository<Entity 클래스, PK 타입>를 상속하면 기본적인 CURD 메소드가 자동으로 생성되고, 자동 빈 등록이 된다.

@Repository를 추가할 필요도 없다. 주의할 점은 Entity 클래스와 기본 Entity Repository는 함께 위치해야 한다. 둘은 아주 밀접한 관계이고, Entity 클래스는 기본 Repository 없이는 제대로 역할을 할 수가 없다. 도메인 패키지에서 함께 관리한다.

🌱 Spring Data JPA 테스트 코드


🌿 PostsRepositoryTest

package com.bbs.projects.bulletinboard.domain.posts;

import org.assertj.core.api.Assertions;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;

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

@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {

	//필드 주입
    @Autowired
    PostsRepository postsRepository;

	//테스트 메소드 종료마다 DB 정리
    @After
    public void cleanup() {
        postsRepository.deleteAll();
    }

	//DB 저장 테스트
    @Test
    public void save_posts() throws Exception{
        //given
        String title = "테스트 게시글";
        String content = "테스트 본문";

		//빌더를 통해 DB에 각 속성 저장
        postsRepository.save(Posts.builder()
                .title(title)
                .content(content)
                .author("pink@gmail.com")
                .build());

        //when
        //전체 속성 조회
        List<Posts> postsList = postsRepository.findAll();

        //then
        Posts posts = postsList.get(0); //1번째 레코드 추출
        //검증
        assertThat(posts.getTitle()).isEqualTo(title);
        assertThat(posts.getContent()).isEqualTo(content);
    }

}

📢 @After

JUnit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정

save()로 테이블 posts에 insert/update 쿼리를 실행한다. 이것은 id 값이 있다면 update가, 없다면 insert 쿼리가 실행된다. 또한, findAll()을 통해 모든 데이터를 조회한 리스트를 받는다. get(0)는 1번째 레코드를 의미하며, 저장된 레코드가 한 개뿐이기 때문에 저장한 값을 검증할 수 있다.

🌿 application.properties

🔧 콘솔에서 쿼리 로그 확인을 위해 application.properties에 'spring.jpa.show_sql=true'를 추가한다.

이렇게 drop table로 테이블을 깨끗하게 비우는 쿼리나 insert 쿼리 등이 콘솔창에 보이게 됐다!

"id bigint generated by default as identity"

출력되는 로그를 보면 H2 쿼리 문법이 적용되어 있는데, H2는 MySQL 쿼리를 수행해도 정상적으로 작동하기 때문에 이후 디버깅을 위해 출력되는 쿼리 로그를 MySQL 버전으로 변경하겠다.

🔧 application.properties 옵션으로 쿼리 로그를 MySQL 버전으로 변경

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect

"id bigint not null auto_increment"를 보면 MySQL 버전으로 잘 변경되었음을 알 수 있다.

🌱 스프링의 계층


🌿 Spring 웹 계층

📢 Web Layer

@Controller와 뷰 템플릿 영역이다. 이외에도 @Filter, 인터셉터, @ControllerAdvice 등 외부 요청과 응답에 대한 전반적인 영역을 말한다.

📢 Service Layer

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

📢 Repository Layer

DB와 같이 데이터 저장소에 접근하는 영역이다. DAO 영역으로 이해하면 쉬울 것이다. DAO의 예로 Jdbc에서 Connection이나 PreparedStatement 등으로 DB 커넥션이나 세션을 얻는 영역을 말한다.

📢 DTOs

계층 간에 데이터 교환을 위한 객체이다. 이것은 Model이나 Form 객체가 될 수 있고, 혹은 앞서 수행했던 테스트처럼 HelloResponseDto와 같은 전달할 필드를 담을 보관 케이스 역할을 하는 객체로 생각하면 될 것 같다. 또한, Repository Layer에서 결과로 넘겨준 객체도 해당된다.

📢 Domain Model

도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것을 말한다. 또한, @Entity가 사용된 영역 역시 도메인 모델이라고 이해하면 된다. 다만, 무조건 DB의 테이블과 관계가 있어야만 하는 것은 아니다. 왜냐하면 VO처럼 값 객체들도 이 영역에 해당하기 때문이다.

📢 VO

도메인에서 1개 또는 그 이상의 속성들을 묶어서 특정 값을 나타내는 객체
ex) 음식점 이름, 음식 이름, 수량 ➡ Order라는 VO
ref : https://tecoble.techcourse.co.kr/post/2020-06-11-value-object/

📝 스프링 계층

🌿 Service Layer의 역할

Web(Controller), Service, Repository, Dto, Domain 중 비즈니스 처리를 담당해야 할 곳은 Service가 아닌 Domain이다. 기존에 서비스로 처리하던 방식을 트랜잭션 스크립트라고 한다. 일단 다음 두 코드를 비교해보자.

//서비스 계층에서 로직 처리
@Transactional
public Order cancelOrder(int orderId) {
   
   OrdersDto order = ordersDao.selectOrders(orderId);
   BillingDto billing = billingDao.selectBilling(orderId);
   DeliveryDto delivery = deliveryDao.selectDelivery(orderId):
   
   String deliveryStatus = delivery.getStatus();
   
   if("IN_PROGRESS'.equals(deliveryStatus)) {
      delivery.setStatus("CANCEL");
      deliveryDao.update(delivery);
   }
   
   order.setStatus("CANCEL");
   ordersDao.update(order);
   
   billing.setStatus("CANCEL");
   deliveryDao.update(billing);
   
   return order;
}
//도메인 영역에 로직 처리를 맡김
@Transactional
public Order cancelOrder(int orderId) {
   
   OrdersDto order = ordersRepository.findById(orderId);
   BillingDto billing = billingRepository.findByOrderId(orderId);
   DeliveryDto delivery = deliveryRepository.findByOrderId(orderId):
   
   delivery.cancel();
   
   order.cancel();
   billing.cancel();
   
   return order;
}

order, billing, delivery 인스턴스에 주문 번호에 맞는 값을 갖고 와서 취소 상황일 때, 수행되는 로직이다.

보통 서비스 영역은 컨트롤러(웹 영역)에서 가공된 파라미터를 사용하여 비즈니스 로직을 처리하고 결과를 리턴하면 뷰 템플릿을 호출하여 화면 렌더링을 하거나 화면에 값을 그대로 내려준다.

교재 : 서비스 영역은 트랜잭션과 도메인 간의 순서만 보장하고 비즈니스 로직을 처리하지 않는다.

🔎 내 생각은 다음과 같다. 서비스 영역은 컨트롤러의 중복 코드를 없애주기 위한 공통 처리 역할을 하기 때문에 화면에 독립적이어야 한다. 즉, 전자의 소스처럼 객체에 따라 변경될 수 있는 메소드를 사용하고 그 값으로 처리하는 로직까지 수행한다면 서비스 영역은 외부 객체와 화면에 의존적일 수 밖에 없을 것이다. 반면, 후자의 소스처럼 도메인에 처리를 맡기고 CRUD 인터페이스를 활용할 경우, 이러한 의존성을 해결할 수 있기 때문에 교재에서 도메인 모델을 다루는 것 같다.

🌱 등록/수정/조회 API


📢 필요한 클래스 3개

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

🌿 PostsApiController

🔧 web 패키지에 추가

package com.bbs.projects.bulletinboard.web;

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 {

	//생성 시점에 PostsService 의존성을 받음
    private final PostsService postsService;

	//등록 기능용 컨트롤러
    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto) {
        //POST 요청의 각 파라미터를 DTO의 각 필드에 파싱 후 DTO를 저장
        return postsService.save(requestDto);
    }
}

@RequiredArgsConstructor을 통해 생성자로 빈을 주입받을 수 있도록 했다. 이렇게 하면 의존관계 변경으로 인한 생성자 수정에 영향을 받지 않는다. PostsRepository는 JpaRepository를 상속받았으므로 빈으로 등록되어 있다.

해당 URL로 POST 요청을 받으면 PostsSaveRequestDto에 파싱하고, 해당 객체의 인스턴스를 Service에 전달한다.

@RequestBody는 Dto처럼 전달 목적으로 직접 작성한 클래스에 사용하기 적합하다. 그 이유는 HTTP API 환경에서 객체를 전달하기 위해서는 HTTPMessageConverter의 도움을 받아 JSON(객체) 형태를 읽거나 쓸 수 있어야 한다. 이를 위해 @RequestBody는 HTTP 요청의 바디 내용을 통째로 자바 객체로 변환해서 매핑된 메소드 파라미터로 전달해 준다. 결과적으로 내가 생성한 Dto나 클래스의 필드에 HTTP body 내용이 그대로 파싱되는 것이다.
ref : https://cheershennah.tistory.com/179

📝 (참고) @RequestBody vs @RequestParam

@RequestParam은 url 상에서 데이터(파라미터)를 찾고, @RequestBody는 url에 관계 없이 HTTP body에서 객체를 읽어 Dto의 파라미터에 파싱한다. 가장 큰 차이는 url 의존 여부이다. 공통점은 Map<String, String> 으로 결과를 받아올 수 있다는 것이다.
ref : https://ocblog.tistory.com/49

📝 (참고) Parsing(파싱)

많은 데이터 중 어떤 규칙이나 순서에 맞게 데이터를 추출(매핑)하는 행위

🌿 PostsService

🔧 service.posts 패키지 아래 위치시키도록 하겠다.

package com.bbs.projects.bulletinboard.service.posts;

import com.bbs.projects.bulletinboard.domain.posts.PostsRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class PostsService {

	//생성 시점에 PostsRepository 의존성을 받음
    private final PostsRepository postsRepository;
    
    //컨트롤러에서 전달 받은 DTO의 메소드 호출 결과를 저장하고, 저장 ID를 반환
    @Transactional
    public Long save(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }
    
}

@RequiredArgsConstructor을 통해 생성자로 빈을 주입받을 수 있도록 했다. 그리고 PostsApiController에서 받은 Dto를 통해 파싱된 각 필드를 respository에 저장한 후에 Id 값을 반환한다.

🌿 PostsSaveRequestDto

🔧 web.dto 패키지 아래에 위치시키도록 하겠다.

package com.bbs.projects.bulletinboard.web.dto;

import com.bbs.projects.bulletinboard.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;
    }
    
    //각 속성 값에 컨트롤러->서비스로부터 받은 DTO(POST 요청 파라미터)를 매칭
    public Posts toEntity() {
        return Posts.builder()
                .title(title)
                .content(content)
                .author(author)
                .build();
    }
}

Service에서 저장하기 전에 toEntity()를 통해 객체 생성 시점에 title, content, author를 초기화하도록 한다. 이것은 빌더 클래스가 가능하도록 한다. 또한, HTTP Message Body에 태운 객체를 받는 역할도 한다.

해당 클래스가 필요한 이유는 Entity 클래스를 보호하기 위함이다. Entity 클래스는 DB와 맞닿은 핵심 클래스이므로 변경이 잦은 Request/Response 클래스로 사용해서는 안된다. Entity 클래스를 기준으로 DB 스키마와 테이블이 변경되는데 Dto는 View를 위한 클래스라 정말 자주 변경이 필요하다. 또한, Controller에서 결괏값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하므로 Entity 클래스만으로는 표현하기가 어려운 경우가 많다. 따라서, View로 인해 DB에도 변경을 주는 것은 너무 큰 변경이다. 반드시 Entity 클래스와 Controller에서 쓸 Dto는 분리해서 사용하자.

🌿 PostsApiControllerTest - 등록

package com.bbs.projects.bulletinboard.web;

import com.bbs.projects.bulletinboard.domain.posts.Posts;
import com.bbs.projects.bulletinboard.domain.posts.PostsRepository;
import com.bbs.projects.bulletinboard.web.dto.PostsSaveRequestDto;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
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.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;

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

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

    //현재 실행 중인 포트 넘버
    @LocalServerPort
    private int port;

    //필드 주입
    @Autowired
    private TestRestTemplate restTemplate;

    //필드 주입
    @Autowired
    private PostsRepository postsRepository;

    //테스트 메소드 종료마다 DB 정리
    @After
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }

    //게시글 등록 테스트
    @Test
    public void register_posts() throws Exception{
        //given
        String title = "title";
        String content = "content";
        //요청 파라미터들 셋팅
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

        //컨트롤러 테스트를 위한 url 변수 선언
        String url = "http://localhost:" + port + "/api/v1/posts";

        //when
        //해당 url로 파라미터를 요청한 POST 요청
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);

        //then
        //responseEntity를 통한 검증
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);

        //DB 전체 필드 조회를 통한 검증
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }

}

실행 중인 포트 번호와 "/api/v1/posts"로 url을 완성하여 Controller의 @PostMapping과 매칭되도록 한다. 그리고 restTemplate.postForEntity로 ResponseEntity를 얻고, ResponseEntity 객체의 인스턴스를 통해 HTTP 요청 헤더와 바디 값을 가져와 검증을 수행했다. 또한, 빌더를 통해 전달 받은 Dto의 파라미터로 초기화된 변수들도 AssertJ를 통해 검증했다.

📢 WebEnvironment.RANDOM_PORT

랜덤한 포트 번호로 실행한다.

📢 @LocalServerPort

실행 중인 포트 번호를 얻어온다.

📢 TestRestTemplate

@WebMvcTest의 경우, JPA 기능이 작동하지 않고 Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화되어 사용할 수 없다. 하지만 HTTP Api 환경이 필요하기 때문에 @SpringBootTest와 함께 TestRestTemplate을 사용하면 JPA 기능까지 한번에 테스트할 수 있다. 또한, 스프링 부트는 웹 환경이 구성되었다면 이것을 자동 빈 등록한다.

📢 postForEntity(url, 파라미터들, response 타입)

POST 요청을 보내고 그 결과로 ResponseEntity로 받는다.

📢 ResponseEntity

개발자가 직접 결과 데이터와 HTTP 상태 코드를 제어할 수 있는 클래스. HTTP 헤더 및 바디 값 조회도 가능하다.

🌿 PostsApiController 수정

🔧 게시글 수정과 조회 기능을 추가한다.

package com.bbs.projects.bulletinboard.web;

import com.bbs.projects.bulletinboard.service.posts.PostsService;
import com.bbs.projects.bulletinboard.web.dto.PostsSaveRequestDto;
import com.bbs.projects.bulletinboard.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RequiredArgsConstructor
@RestController
public class PostsApiController {

	//생성 시점에 PostsService 의존성을 받음
    private final PostsService postsService;

	//등록 기능용 컨트롤러
    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto) {
        //POST 요청의 각 파라미터를 DTO의 각 필드에 파싱 후 DTO를 저장
        return postsService.save(requestDto);
    }

    //update 기능용 컨트롤러 + 갱신 대상 게시글의 id를 통한 동적 URL 생성
    @PutMapping("/api/v1/posts/{id}")
    public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
        //게시글 갱신 후 서비스에서 해당 id를 반환함
        return postsService.update(id, requestDto);
    }
    
    //조회 기능용 컨트롤러 + 갱신 대상 게시글의 id를 통한 동적 URL 생성
    @GetMapping("/api/v1/posts/{id}")
    public PostsResponseDto findById(@PathVariable Long id) {
        //대상 게시글 id로 서비스의 findById를 호출
        return postsService.findById(id);
    }
    
}

수정을 위한 메소드는 POST도 가능하다. 하지만 전체가 아닌 일부분만 수정하길 원한다면 PUT 메소드를 사용하자. 또한, 수정은 제목(title)과 내용(content)만 할 것이므로 앞서 사용했던 PostsSaveRequestDto가 아닌 새로운 Dto가 필요하다. 즉, 전달을 위한 다른 케이스가 필요하다는 것이다. 마지막으로 Service 영역에 파라미터를 전달한 결과를 반환한다.

URL은 동일하지만 GET 요청일 경우 개별 조회 기능을 수행한다. 이것은 API 설계상 메소드로 구별해놓은 것이다. 조회를 위해서는 id, title, content, author 필드가 필요하기 때문에 새로운 Dto가 필요하다. 이것을 반환 타입으로 하는 Service 영역의 조회 메소드를 호출한다.

📢 @PathVariable

동적 URL을 받고 싶고 싶을 때, {valiable}와 함께 사용하면 된다.

🌿 PostsResponseDto

🔧 web.dto 패키지 아래 추가

package com.bbs.projects.bulletinboard.web.dto;

import com.bbs.projects.bulletinboard.domain.posts.Posts;

@Getter
public class PostsResponseDto {

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

	//조회용 DTO
    public PostsResponseDto(Posts entity) {
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.content = entity.getContent();
        this.author = entity.getAuthor();
    }
}

findById() 메소드(게시글 조회 기능)를 구현하기 위한 DTO(케이스)이다. 모든 파라미터가 필요하고, Entity의 필드 중 일부만 사용하므로 생성자로 Entity를 받아 필드에 값을 넣도록 하겠다. 굳이 모든 필드를 가진 생성자가 필요하진 않으므로 Dto는 Entity를 받아 처리했다.
(현재 필드가 전부가 아니라 뒷 부분에서 필드가 추가되기 때문에 일부라고 표현한 것이다.)

🌿 PostsUpdateRequestDto

🔧 web.dto 패키지 아래 추가

package com.bbs.projects.bulletinboard.web.dto;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {

    private String title;
    private String content;

	//update용 DTO
    @Builder
    public PostsUpdateRequestDto(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

수정을 위해 title과 content를 받는 dto를 생성했다.

🌿 Posts 수정

🔧 update 기능을 추가한다.

package com.bbs.projects.bulletinboard.domain.posts;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Getter
@NoArgsConstructor
@Entity
public class Posts {

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

    @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 갱신을 위한 메소드
    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

수정은 Posts에서 일어나야 한다. 따라서, update(title, content) 메소드를 추가한다.

🌿 PostsService 수정

🔧 update 기능을 추가한다.

package com.bbs.projects.bulletinboard.service.posts;

import com.bbs.projects.bulletinboard.domain.posts.Posts;
import com.bbs.projects.bulletinboard.domain.posts.PostsRepository;
import com.bbs.projects.bulletinboard.web.dto.PostsResponseDto;
import com.bbs.projects.bulletinboard.web.dto.PostsSaveRequestDto;
import com.bbs.projects.bulletinboard.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class PostsService {

	//생성 시점에 PostsRepository 의존성을 받음
    private final PostsRepository postsRepository;

	//컨트롤러에서 전달 받은 DTO의 메소드 호출 결과를 저장하고, 저장 ID를 반환
    @Transactional
    public Long save(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }

    //update 기능
    @Transactional
    public Long update(Long id, PostsUpdateRequestDto requestDto) {
        //findById로 대상 게시글 조회, 존재하지 않을 경우 예외 발생시킴
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));

        //게시글의 title, content 갱신
        posts.update(requestDto.getTitle(), requestDto.getContent());

        return id; //PK 반환
    }

	//조회 기능
    public PostsResponseDto findById(Long id) {
        //findById로 대상 게시글 조회, 존재하지 않을 경우 예외 발생시킴
        Posts entity = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));

		//조회용 DTO에 조회한 객체를 담은 DTO 객체 반환
        return new PostsResponseDto(entity);
    }

}

수정(update)과 게시글 조회(findById)가 추가되었다. 두 메소드 모두 조회 실패 시, 예외를 터트리도록 구현되어 있다. update의 경우 Posts의 update를 호출한 후, id를 반환한다. findById의 경우 조회는 현재 존재하는 4개 파라미터(id, title, content, author)가 모두 필요하므로 PostsResponseDto를 반환하도록 한다.

🌿 JPA의 영속성 컨텍스트

영속성 컨텍스트란, Entity를 영구 저장하는 환경을 말한다. JPA의 Entity Manager가 활성화된 상태로(Spring Data Jpa에서는 기본 옵션) 트랜잭션 안에서 DB에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다. 이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다. 즉, Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다는 것이다. 이 개념을 더티 체킹이라고 한다.

📝 더티 체킹(Dirty Checking)

  • 상태 변경 검사
  • 최초 조회 상태와 비교
  • 영속성 컨텍스트가 관리하는 Entity에만 적용
  • 문제점
    • 기본적으로 모든 필드를 업데이트
    • 부담스러운 Update 쿼리
  • 해결책

📝 @Transactional

DB에 쿼리를 수행하고 나면 commit 또는 rollback이 수행되어야 한다. 그렇지 않으면 DB에 실제로 반영되지 않는다. 그리고 DB에 접근하려면 커넥션을 얻어야 하고 PreparedStatement를 통해 쿼리도 날려야 한다. 끝으로 Connection, PreparedStatement, ResultSet 등은 수행이 끝나면 자원을 반납해야 한다. 하지만 이러한 과정은 DB에 접근할 때마다 거치는 중복 과정이다. 이것을 비즈니스 로직마다 추가하기 힘들고, 무엇보다 비즈니스 로직과 트랜잭션 로직이 섞이는 문제로 유지보수가 어려워진다. 이를 해결하기 위해 @Transactional 애노테이션으로 이러한 중복 과정을 자동화한 것이다.

🌿 PostsApiControllerTest - 수정, 조회

package com.bbs.projects.bulletinboard.web;

import com.bbs.projects.bulletinboard.domain.posts.Posts;
import com.bbs.projects.bulletinboard.domain.posts.PostsRepository;
import com.bbs.projects.bulletinboard.web.dto.PostsSaveRequestDto;
import com.bbs.projects.bulletinboard.web.dto.PostsUpdateRequestDto;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
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.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.junit4.SpringRunner;

import java.util.List;

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

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

    //현재 실행 중인 포트 넘버
    @LocalServerPort
    private int port;

    //필드 주입
    @Autowired
    private TestRestTemplate restTemplate;

    //필드 주입
    @Autowired
    private PostsRepository postsRepository;

    //테스트 메소드 종료마다 DB 정리
    @After
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }

    ...

    //게시글 갱신 테스트
    @Test
    public void update_posts() throws Exception{
        //given
        //각 속성 값 임의 지정
        Posts savedPosts = postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        Long updateId = savedPosts.getId();
        String expectedTitle = "title2"; //테스트용 title
        String expectedContent = "content2"; //테스트용 content

        //갱신용 DTO에 테스트용 title과 content 저장
        PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
                .title(expectedTitle)
                .content(expectedContent)
                .build();

        //컨트롤러 테스트를 위한 url 변수 선언
        String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;

        //갱신용 DTO를 통해 requestEntity를 얻음
        HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);

        //when
        //responseEntity를 얻기 위해 TestRestTemplate의 exchange 호출
        ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class);

        //then
        //responseEntity를 통한 검증
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);

        //DB 전체 필드 조회를 통한 검증
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
        assertThat(all.get(0).getContent()).isEqualTo(expectedContent);

    }

}

먼저, 내가 원하는 title, content, builder를 저장한 savedPosts 객체를 얻었다. expectedTitle과 expectedContent는 수정할 값이고, 이것을 통해 update 기능을 테스트할 것이다. 마찬가지로 수정을 위해 만들어둔 Dto가 있다. 그것의 빌더 메소드를 통해 title과 content값을 수정한다. 등록과 마찬가지로 url을 변수 선언하는데 이번에는 id가 필요하다. 그래서 savedPosts의 id 값을 updateId로 받아둔 것이다. 왜냐하면 게시글을 id로 구분하기 때문이다.

이제 실제 API 요청을 해보아야 한다. 이것은 앞서 설명한대로 TestRestTemplate의 도움을 받는다. 이번에는 수정을 할 것이기 때문에 exchange를 쓰고 메소드도 PUT으로 직접 지정한다. 또한, requestEntity가 인자로 필요하므로 new HttpEntity<>(Dto)를 통해 선언한 것이다. 최종 검증에서 사용할 responseEntity는 HTTP 상태 코드, 바디 내용, 헤더 등에 접근이 가능하므로 이것이 필요하기 때문에 수행한 것이다.

🔎 builder().build()는 DTO 케이스를 두고, 해당 케이스에 맞는 값을 수정하거나 저장할 때도 사용한다.

🔎 JPA를 사용하면 확실히 객체지향적으로 코딩할 수 있음이 느껴진다. CRUD 인터페이스가 있어 그것으로 쿼리를 날리면 되고, 객체 설계도 자유롭다. 그것을 전달하거나 전달 받는 것도 가능하여 DB 테이블이나 스키마에 종속적일 필요가 전혀 없었다.

🌿 H2 DB 테스트

🔧 H2 DB 사용을 위해 application.properties에 spring.h2.console.enabled=true를 추가한다.

🔧 http://localhost:8080/h2-console 로 접속 후 아래와 같은 JDBC URL 입력 후 연결

🔧 접속하면 내가 생성한 Posts 테이블이 보인다. select 쿼리를 날린 결과, 선언했던 속성이 모두 출력되었다.

🔧 insert 쿼리를 실행해보고 이를 API로 조회하겠다.

insert into posts(author, content, title) values('author', 'content', 'title');

🌱 JPA Auditing


보통 Entity에는 해당 데이터의 생성시간과 수정시간을 포함한다. 차후 유지보수에 있어 굉장히 중요한 정보이기 때문이다. 그렇다 보니 매번 DB에 삽입하기 전, 갱신하기 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 된다.

//생성일 추가 코드 예제
public void savePosts() {
   ...
   posts.setCreateDate(new LocalDate());
   postsRepository.save(posts);
   ...
}

이런 단순하고 반복적인 코드가 모든 테이블과 서비스 메소드에 포함되어야 한다고 생각하면 매우 번거럽고 코드가 지저분해진다. 그래서 JPA Auditing이 필요하다.

🌿 BaseTimeEntity

🔧 domain 패키지에 BaseTimeEntity 클래스를 생성한다.

package com.bbs.projects.bulletinboard.domain;

import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

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

    //생성 시간
    @CreatedDate
    private LocalDateTime createdDate;

    //수정 시간
    @LastModifiedDate
    private LocalDateTime modifiedDate;
    
}

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

📢 @MappedSuperClass

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

📢 @EntityListeners(AuditingEntityListener.class)

BaseTimeEntity 클래스에 Auditing 기능을 포함시킨다.

📢 @CreatedDate

Entity가 생성되어 저장될 때 시간이 자동 저장된다.

📢 @LastModifiedDate

조회한 Entity의 값을 변경할 때 시간이 자동 저장된다.

🔧 Posts 클래스가 BaseTimeEntity를 상속받도록 변경한다.

public class Posts extends BaseTimeEntity {

🔧 JPA Auditing 애노테이션을 모두 활성화할 수 있도록 Application 클래스에 활성화 애노테이션 @EnableJpaAuditing을 추가한다.

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

🌿 JPA Auditing 테스트

PostsRepositoryTest

package com.bbs.projects.bulletinboard.domain.posts;

import org.assertj.core.api.Assertions;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.time.LocalDateTime;
import java.util.List;

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

@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {

	//필드 주입
    @Autowired
    PostsRepository postsRepository;

    //테스트 메소드 종료마다 DB 정리
    @After
    public void cleanup() {
        postsRepository.deleteAll();
    }

	...

	//생성 시간, 수정 시간 테스트
    @Test
    public void register_baseTimeEntity() throws Exception{
        //given
        //지정된 시간 저장
        LocalDateTime now = LocalDateTime.of(2022, 7, 6, 0, 0, 0);
        //title, content, author 값을 DB에 저장
        postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        //when
        //전체 속성 값 조회
        List<Posts> postsList = postsRepository.findAll();

        //then
        Posts posts = postsList.get(0); //1번째 레코드 추출

		//생성 시간과 수정 시간 출력 테스트
        System.out.println(">>>>>>>>>> createDate = " + posts.getCreatedDate() +
                ", modifiedDate = " + posts.getModifiedDate());

		//검증
        assertThat(posts.getCreatedDate()).isAfter(now);
        assertThat(posts.getModifiedDate()).isAfter(now);
        
    }

}

임의로 지정한 시간을 넣은 LocalDateTime을 생성한다. builder를 통해 title, content, author 값을 DB에 저장한 후 findAll()을 통해 조회하였다. 마지막으로 저장한 필드 중 생성 시간(createdDate)과 수정 시간(modifiedDate)을 출력하고 잘 저장되었다면 00시00분00초 이후일 것이기 때문에 테스트가 성공하는 것을 볼 수 있다.

📢 isAfter

검증 대상의 시간이 인자로 전달된 시간 이후인지를 검증

0개의 댓글