이 글은 이동욱 님의 스프링 부트와 AWS로 혼자 구현하는 웹 서비스를 읽고 정리한 글입니다.
웹 서비스를 개발하고 운영하다 보면 항상 다루어야 할 것이 바로 데이터베이스(DB)이다.
과거에는 직접 SQL문을 다루면서 DB를 관리하다 보니 어려움이 많았지만, 요즘은 JPA라는 기술을 사용해서 간단하게 관리할 수 있다.
JPA라는 자바 표준 ORM 기술을 사용해서 객체지향 프로그래밍을 사용하여 관계형 데이터베이스를 관리할 수 있다.
편리성 덕분에
1. JPA 소개
현대의 웹 애플리케이션에서 Oracle, MySQL 등의 관계형 데이터베이스(RDB)는 필수적인 요소이다. 그렇다 보니 객체를 관계형 데이터베이스에서 관리하는 것은 무엇보다 중요하다.
그리고 관계형 데이터베이스가 중심이 되감에 따라 코드 또한 애플리케이션 코드가 아닌 SQL 중심이 되어간다. 이는 관계형 데이터베이스는 SQL만 인식할 수 있기 때문으로, 각 테이블마다 기본적인 CRUD SQL을 매번 생성해야 하기 때문이다.
개발자가 자바 클래스를 아름답게 설계하더라도, SQL만을 통해서만 데이터베이스에 저장하고 조회하는 것이 가능하다.
즉 관계형 데이터베이스를 사용하는 어떤 상황에서도 SQL은 피할 수 없다.
이런 상황은 크게 두 가지의 문제를 가진다.
● 단순 반복 작업
실제 현업에서는 수십, 수백개의 테이블을 사용하는데, 이 테이블의 몇 배의 SQL을 만들고 유지보수를 진행해야 한다.
● 패러다임 불일치
객체지향 프로그래밍 언어는 기능과 속성을 한 곳에서 관리하는 기술이고, 관계형 데이터베이스는 어떻게 데이터를 저장할지에 초첨이 맞춰진 기술이다. 이처럼 둘은 서로 다른 패러다임을 가지고 있고, 객체를 데이터베이스에 저장하려고 하니 여러 문제가 발생하는 것이다.
// 객체지향 프로그래밍 언어
User user = findUser();
Group group = user.getGroup();
// 관계형 데이터베이스 추가
User user = userDao.findUser();
Group group = groupDao.findGroup(user.getGroupId());
위의 객체 지향 언어 기반의 코드를 보면 누구나 명확하게 User - Group의 관계는 부모 - 자식 관계임을 알 수 있다.
하지만 아래의 코드는 User와 Group을 따로 조회하기 때문에 어떤 관계인지 알 수가 없다.
이런 패러다임 불일치 관계를 해결하기 위해 등장한 것이 바로 JPA이다.
서로 지향하는 바가 다른 2개의 영역을 중간에서 조절함으로써 패러다임 일치를 시켜주는 기술이다.
그렇기 때문에 개발자는 객체지향적인 프로그래밍을 진행하는 데에만 집중하고, JPA가 데이터베이스에 맞게 대신 SQL을 생성하여 실행할 수 있다. 더 이상 SQL에 종속적인 개발을 하지 않아도 되는 것이다. 이런 큰 장점 덕분에 JPA는 서비스 표준 기술로 자리 잡고 있다
1) Spring Data JPA
JPA는 인터페이스로서 자바의 표준명세서이다. 인터페이스이기 때문에 JPA를 사용하기 위해선 구현체가 필요한데 대표적으로 Hibernate, Ecilpse Link 등이 존재한다.
하지만 Spring에서 JPA를 사용할 때는 이 구현체를 직접 다루지 않고, 이들을 좀 더 쉽게 사용하기 위해 추상화시킨 Spring Data JPA라는 모듈을 사용한다.
Spring 진영에서 Spring Data JPA를 개발하고 권장하는 이유는 아래의 두 가지 이유이다.
● 구현체 교체의 용이성
Hibernate 외에 다른 구현체로 쉽게 교체하기 위함 => Hibernate가 수명을 다해 다른 JPA 구현체가 대세로 떠오르더라도 내부에서 자체적인 구현체 매핑을 지원하기 때문에 쉽게 교체가 가능하다.
● 저장소 교체의 용이성
관계형 데이터베이스 이외의 다른 저장소로 쉽게 교차하기 위함 => 관계형 데이터베이스를 사용할 때 트래픽이 많아지면 도저히 감당이 안될 때가 있는데, 이때 예를 들어 MongoDB로 교체가 필요하다면 개발자는 Spring Data MongoDB로 의존성만 교체하면 된다. 이는 Spring Data의 하위 프로젝트들은 기본적인 CRUD의 인터페이스가 동일하기 때문이다.
이런 장점들로 인해 Spring 팀에서 Spring Data를 사용하는 것을 적극 권장하고 있다.
2) 실무에서 JPA
실무에서 JPA를 사용하지 못하는 큰 이유는 바로 높은 러닝 커브이다. JPA를 잘 활용하기 위해선 객체지향 프로그래밍과 관계형 데이터베이스를 모두 이해해야 한다.
학습의 난도가 높은 만큼 JPA를 사용할 수 있게 된다면 CRUD 쿼리 작성 불필요, 부모 - 자식 관계 표현, 1:N 관계 표현, 상태와 행위를 한 곳에서 관리 가능 등의 큰 보상을 얻을 수 있다.
속도 이슈도 JPA에서 여러 성능 이슈 해결책을 준비해 놓아서 네이티브 쿼리문만큼 자유롭다.
3) 요구사항 분석
앞으로 진행할 웹 애플리케이션의 요구사항은 다음과 같다.
게시판 기능 | 회원 기능 |
게시글 조회 게시글 등록 게시글 수정 게시글 삭제 |
구글 / 네이버 로그인 로그인한 사용자 글 작성 권한 본인 작성 글에 대한 권한 관리 |
또한 메인 화면, 수정 화면, 등록 화면에서의 여러 가지 기능들도 제공할 것이다.
그럼 이제부터 하나씩 기능을 구현해 보도록 하겠다.
2. 프로젝트에 Spring Data JPA 적용하기
1) 의존성 등록
먼저 build.gradle에 다음과 같이 spring-boot-starter-data-jpa와 h2 의존성을 등록한다.
// build.gradle
...
dependencies { // 프로젝트 개발에 필요한 의존성들을 선언
...
compile('org.springframework.boot:spring-boot-starter-data-jpa') // 스프링 부트용 Spring Data JPA 추상화 라이브러리
compile('com.h2database:h2') // 인메모리형 RDB
...
}
spring-boot-starter-data-jpa는 스프링 부트 버전에 맞춰 자동으로 JPA관련 라이브러리들의 버전을 관리해 준다.
h2는 별도의 설치가 필요 없고, 메모리에서 실행되기 때문에 애플리케이션의 시작 때마다 초기화되기 때문에 테스트 용도로 자주 사용한다.
의존성 등록을 마쳤다면, JPA 기능들을 본격적으로 사용해 보자.
2) Domain 클래스 생성
여기서 도메인이란 게시글, 댓글, 회원 등 소프트웨어적 요구사항 및 문제영역을 의미한다.
com.cotato.study.SpringnAWS 아래에 domain 패키지를 생성하고, domain 패키지 아래에 posts 패키지를 생성하고 Posts라는 Class를 생성했다.
package com.cotato.study.SpringnAWS.domain.posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Getter // 클래스 내의 모든 필드의 Getter 메서드 자동 생성
@NoArgsConstructor // 기본 생성자 자동 추가
@Entity // Table과 링크될 클래스임을 나타내는 어노테이션
public class Posts { // 실제 DB과 매칭될 클래스(엔티티 클래스)
@Id // 테이블의 PK
@GeneratedValue(strategy = GenerationType.IDENTITY) // PK 생성 규칙, auto_increment
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;
}
}
Posts 클래스를 확인해 보면 특이하게 Getter는 존재하지만, Setter는 존재하지 않는다.
Setter가 존재하게 되면 클래스의 인스턴스 값이 언제 어디서 변경되는지 코드상으로 명확하게 확인할 수 없기 때문에 차후 기능 변경 시 복잡해지게 된다.
그렇기 때문에 Entity 클래스에서는 절대 Setter 메서드를 만들어서는 안 된다. 대신, 변경 필요시에는 아래 예시와 같이 명확하게 목적과 의도를 나타내는 이름의 메서드를 추가하도록 한다.
// 잘못된 Setter 메서드 사용 예
public class Order{
public void setStatus(boolean status){
this.status = status;
}
}
public void 주문서비스의_취소이벤트(){
order.setStauts(false);
}
// 올바른 메서드 사용 예
public class Order{
public void cancelOrder(){
this.status = false;
}
}
public void 주문서비스의_취소이벤트(){
order.cancelOrder;
}
그렇다면 이처럼 Setter가 없는 상황에서 우리는 어떻게 DB에 값들을 삽입해야 할까?
기본적으로 우리는 생성자에서 최종값들을 모두 채운 후에 DB에 삽입하는 방식을 사용하고, 값 변경이 필요하다면 이벤트에 맞춘 public 메서드를 호출하는 방식을 사용한다.
이 책에서는 생성자 대신 @Builder를 통해 제공되는 빌더 클래스를 사용한다.
// 생성자를 통한 방법
public Example(String a, String b){
this.a = a;
this.b = b;
}
// 빌더를 사용한 방법
Example.builder().a(a).b(b).build();
위의 예시를 보자. 생성자를 통한 방식에서는 지금 채울 필드가 무엇인지 명확히 지정할 수 없다. 그렇기 때문에 a와 b의 위치가 변경되더라도, 코드가 실행되기 전에는 문제를 발견할 수가 없다.
하지만 빌더를 사용한 방식에서는 어떤 필드에 어떤 값이 채워지는지 명확하게 인지할 수 있다.
3) PostsRepository 생성
Posts 클래스로 DB에 접근할 수 있게 해 줄 JPA Repository를 생성하자. Posts 클래스와 같은 위치에 PostsRepository를 생성하자.
package com.cotato.study.SpringnAWS.domain.posts;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostsRepository extends JpaRepository<Posts, Long> { // JpaRepository를 상속하기만 해도 CRUD 메서드가 생성됨
}
우리가 Mybatis 등에서 Dao로 부르는 DB Layer 접근자이다. JPA에서는 이를 Repository라고 부르며, 인터페이스로 생성한다.
Entity 클래스와 기본 Entity Repository는 Domain 패키지에 함께 위치해야 한다. 둘은 아주 밀접한 관계이고, Entity 클래스는 기본 Entity Repository 없이는 정상적으로 기능할 수 없다.
이제 지금까지 적용한 Spring Data JPA의 기능을 테스트 코드로 검증해 보자.
3. Spring Data JPA 테스트 코드 작성하기
test 디렉터리에 domain.posts 패키지를 생성하고, PostsRepositoryTest라는 이름으로 테스트 클래스를 생성한다.
아래와 같이 save와 findAll 기능을 시험해 볼 것이다.
package com.cotato.study.SpringnAWS.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;
@RunWith(SpringRunner.class) // 테스트 진행 시 JUnit에 내장된 실행자 외에 SpringRunner라는 스프링 실행자를 사용, 즉 스프링 부트 테스트와 JUnit 사이의 연결자
@SpringBootTest // 별다른 설정 없이 사용할 경우 H2 데이터베이스를 자동으로 실행
public class PostsRepositoryTest{
@Autowired
PostsRepository postsRepository;
@After // JUnit에서 단위 테스트가 종료 될 때마다 수행되는 메서드
public void cleanup(){
postsRepository.deleteAll();
}
@Test
public void 게시글저장_불러오기(){
//given
String title = "테스트 게시글";
String content = "테스트 본문";
postsRepository.save(Posts.builder() // 테이블 posts에 Insert와 Update 쿼리를 실행
.title(title)
.content(content)
.author("cotato@gmail.com")
.build());
//when
List<Posts> postsList = postsRepository.findAll(); // posts에 있는 모든 데이터를 조회
//then
Posts posts = postsList.get(0);
Assertions.assertThat(posts.getTitle()).isEqualTo(title);
Assertions.assertThat(posts.getContent()).isEqualTo(content);
}
}
테스트를 실행해 보면 아래와 같이 테스트를 통과함을 확인할 수 있다.
실제로 JPA가 어떤 쿼리를 통해서 이러한 작업을 수행하는지 궁금할 수 있다. 이를 위해 src/main/resources 하단에 application.properties를 추가하고 그 파일에 다음과 같은 옵션을 추가한다.
spring.jpa.show-sql=true
이렇게 설정을 완료하고 다시 테스트를 진행해 보면 콘솔창에서 "Hibernate : 쿼리문"의 형식으로 쿼리 로그 형식을 확인할 수 있다.
또한 H2는 MySQL의 쿼리 형식으로도 실행이 가능하기 때문에, 아래와 같은 코드를 application.properties에 추가해서 SQL 쿼리를 MySQL 버전으로 확인할 수 있다.
spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.MySQL5InnoDBDialect
지금까지 JPA와 H2 DB에 대한 설정을 마쳤으므로 본격적으로 API를 만들어 보겠다.
4. 등록/수정/조회 API 만들기
API를 만들기 위해서 아래와 같이 총 3개의 클래스가 필요하다.
- Request 데이터를 받을 DTO
- API 요청을 받을 Controller
- 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service
흔히 Service에서 비즈니스 로직을 처리한다고 오해하지만, Service에서는 트랜잭션, 도메인 간의 순서만 보장한다.
그렇다면 비즈니스 로직은 누가 처리할까? 이를 알아보기 위해 먼저 스프링 계층에 대해 살펴보자.
각 영역을 간단하게 설명하겠다.
● Web Layer
흔히 사용하는 Controller 등의 뷰 템플릿 영역이다. 외부 요청과 응답에 대한 전반적인 영역을 얘기한다.
● Service Layer
@Service에 사용되는 서비스 영역이다. 일반적으로 Controller와 Dao의 중간 영역으로 사용되며, @Transactional이 사용되어야 하는 영역이다.
● Repository Layer
DB와 같은 데이터 저장소에 접근하는 영역이다. 기존의 Dao(Data Access Object) 영역과 동일한 영역으로 볼 수 있다.
● Dtos
Dto(Data Transfer Object), 즉 계층 간의 데이터 교환을 위한 객체들의 영역이다.
● Domain Model
도메인이라고 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고, 공유할 수 있도록 단순화시킨 모델
그렇다면 위의 5가지의 영역에서 누가 비즈니스 로직을 담당해야 할까? 바로 Domain 영역이다.
아래 코드와 같이 Service 클래스 내부에서 모든 로직이 처리된다면, 서비스 계층이 무의미해지고 단지 객체는 데이터 덩어리 역할만 하게 된다.
@Transactional
public Order cancelOrder(int orderId){
// 1) DB로부터 Order, Billing, Delivery 조회
ordersDto order = orderDao.selectOrders(orderId);
BillingDto billing = billingDao.selectOrders(orderId);
DeliveryDto delivery = deliveryDao.selectDelivery(orderId);
// 2) 배송 취소를 해야하는지 확인
String deliveryStatus = delivery.getStatus();
// 3) 배송 중이라면 배송취소로 변경
if("IN_PROGRESS".equals(deliveryStatus)){
deliveryStatus.setStatus("CANCEL");
deliveryDao.update(delivery);
}
// 4) 각 테이블에 취소 상태 Update
order.setStatus("CANCEL");
ordersDao.update(order);
billing.setStatus("CANCEL");
billingDao.update(billing);
return order
}
하지만 아래 코드처럼 도메인 모델에서 처리할 경우에는 각자 본인의 이벤트 처리를 하며 서비스 메서드는 트랜잭션과 도메인 간의 순서만 보장해 준다.
@Transactional
public Order cancelOrder(int orderId){
// 1)
Orders order = ordersRepository.findById(orderId);
Billing billing = billingRepository.findById(orderId);
Delivery delivery = deliveryRepository.findById(orderId);
// 2 & 3)
delivery.cancel();
// 4)
order.cancel();
billing.cancel();
return order;
}
1) PostsApiController, PostsService 클래스 생성
그럼 이제부터 등록, 삭제, 수정 기능을 만들어 보겠다.
PostsApiController를 web 패키지에, PostsSaveRequestDto를 web.dto 패키지에, PostsService를 service.posts 패키지에 생성한다.
// PostsApiController.java
package com.cotato.study.SpringnAWS.web;
import com.cotato.study.SpringnAWS.service.posts.PostsService;
import com.cotato.study.SpringnAWS.web.dto.PostsSaveRequestDto;
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 // 롬복에서 final이 선언된 모든 필드를 인자값으로 하는 생성자를 생성해줌
@RestController
public class PostsApiController {
private final PostsService postsService;
@PostMapping("api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto){
return PostsService.save(requestDto);
}
}
// PostsService.java
package com.cotato.study.SpringnAWS.service.posts;
import com.cotato.study.SpringnAWS.domain.posts.PostsRepository;
import com.cotato.study.SpringnAWS.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
@RequiredArgsConstructor // 롬복에서 final이 선언된 모든 필드를 인자값으로 하는 생성자를 생성해줌
@Service
public class PostsService {
private final PostsRepository postsRepository;
@Transactional
public Long save(PostsSaveRequestDto requestDto){
return postsRepository.save(requestDto.toEntity()).getId();
}
}
위의 코드들에서 @RequiredArgsConstructor를 사용한 것을 확인할 수 있는데, 자동으로 생성자를 형성해 준다. 그리고 이 생성자를 사용하여 스프링 Bean들을 주입받았다.
생성자 코드를 직접 적지 않고, 롬복 어노테이션을 사용한 이유는 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위해서이다.
2) PostsSaveRequestDto 클래스 생성
Controller와 Service에서 사용할 Dto 클래스를 생성해 보겠다.
package com.cotato.study.SpringnAWS.web.dto;
import com.cotato.study.SpringnAWS.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();
}
}
위의 Dto 클래스를 보면 Entity 클래스와 크게 다른 것이 없어 보임에도 절대로 Entity 클래스를 Request/Response 클래스로 사용해서는 안 된다. Entity 클래스는 테이블과 연결된 핵심 클래스이다. Entity 클래스는 수많은 서비스 클래스와 비즈니스 로직들의 기준이 되는 클래스이다. 하지만 Dto 클래스는 View를 위한 클래스이기 때문에 정말 자주 변경된다. 그렇기 때문에 꼭 Entity와 Dto는 독립해서 사용해야 한다.
3) PostsApiControllerTest 생성
위에서 등록 기능을 위한 코드들을 작성하였으니, 테스트 코드로 검증해 보자. 테스트 패키지 중 web 패키지에 PostsApiControllerTest 클래스를 생성하자.
package com.cotato.study.SpringnAWS.web;
import com.cotato.study.SpringnAWS.domain.posts.Posts;
import com.cotato.study.SpringnAWS.domain.posts.PostsRepository;
import com.cotato.study.SpringnAWS.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) // 랜덤 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
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
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(responseEntity.getBody()).isGreaterThan(0L);
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(title);
assertThat(all.get(0).getContent()).isEqualTo(content);
}
}
@WebMvcTest의 경우 JPA 기능이 작동하지 않기 때문에, 지금처럼 JPA 기능까지 한 번에 테스트를 진행할 때에는 @SpringBootTest와 TestRestTemplate을 사용하면 된다.
테스트를 수행해 보면 아래와 같이 테스트를 통과함을 알 수 있다.
4) 수정/조회 기능 추가
이제 수정/조회 기능도 추가해 보자.
// PostsApiController.java
...
@RequiredArgsConstructor
@RestController
public class PostsApiController {
...
@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 PostsReponseDto findById (@PathVariable Long id){
return postsService.findById(id);
}
}
// PostsResponseDto.java
package com.cotato.study.SpringnAWS.web.dto;
import com.cotato.study.SpringnAWS.domain.posts.Posts;
import lombok.Getter;
@Getter
public class PostsReponseDto {
private Long id;
private String title;
private String content;
private String author;
public PostsReponseDto(Posts entity){ // Entity의 필드 중 일부만 사용하므로 생성자로 Entity를 받아 필드에 값을 넣음
this.id = entity.getId();
this.title = entity.getTitle();
this.content = entity.getContent();
this.author = entity.getAuthor();
}
}
// PostsUpdateRequestDto.java
package com.cotato.study.SpringnAWS.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;
}
}
// Posts.java
...
public class Posts {
...
public void update(String title, String content){
this.title = title;
this.content = content;
}
}
//PostsService.java
...
@RequiredArgsConstructor
@Service
public class PostsService {
...
@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 PostsReponseDto findById(Long id){
Posts entity = postsRepository.findById(id)
.orElseThrow(()-> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id));
return new PostsReponseDto(entity);
}
}
Update 기능을 보면 DB에 쿼리를 날리는 부분이 전혀 존재하지 않는다. 이것이 가능한 이유는 JPA의 영속성 컨텍스트 덕분이다.
JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 DB에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다. 이 상태로 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 자동으로 반영한다. 이를 더티 체킹이라 한다.
4) 수정 기능 테스트
위에서 수정을 위한 코드들을 작성하였으니, 테스트 코드로 검증해 보자.
// PostsApiControllerTest.java
...
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
...
@Test
public void Posts_수정된다() throws Exception {
//given
Posts savedPosts = postsRepository.save(Posts.builder()
.title("title")
.content("content")
.author("author")
.build());
Long updatedId = 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/" + updatedId;
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);
}
}
코드를 실행해 보면 update 쿼리가 실행됨을 확인할 수 있다.
5) 조회 기능 테스트
테스트 코드를 통해 등록과 수정 기능이 잘 구현됨을 확인해 보았다. 이제 직접 톰캣을 실행하여서 h2 데이터베이스를 통해 조회 기능도 잘 구현되었는지 확인해 보자.
먼저 H2 데이터베이스를 웹 콘솔에서 확인할 것이기 때문에 이를 위해 application.properties에 다음과 같이 추가한다.
spring.h2.console.enabled=true
추가 뒤 Application 클래스의 main 메서드를 실행한다. "http://localhost:8080/h2-console/"로 접근하고 JDBC URL을 "jdbc:h2:mem:testdb"로 변경하여 Connect 버튼을 클릭하면 현재 프로젝트의 H2를 관리할 수 있는 관리 페이지로 이동한다.
Posts 테이블에 존재하는 데이터들을 모두 불러오는 간단한 쿼리를 실행해 보겠다.
당연히 어떤 데이터도 집어넣지 않았기 때문에 테이블이 텅 비었음을 확인할 수 있다.
insert 쿼리를 사용해서 Posts 테이블에 데이터를 집어넣었다.
등록된 데이터를 이제 API를 요청하여 확인해 보자.
브라우저에 http://localhost:8080/api/v1/posts/1을 입력해 API 조회 기능을 테스트해 보자.
위와 같이 데이터를 조회하는 API가 제대로 기능함을 확인할 수 있다.
5. JPA Auditing으로 생성/수정시간 자동화하기
보통 Entity에는 해당 데이터의 생성/수정 시점을 포함한다. 차후 유지보수에 있어 매우 중요한 정보이기 때문이다. 그렇다 보니 보통 DB에 데이터를 삽입하기 전, 갱신하기 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 된다.
하지만 이렇게 단순한 코드가 모든 테이블과 서비스 코드에 포함되어야 한다면 코드가 어마어마하게 지저분하게 된다.
이런 문제를 해결하기 위해 JPA Auditing을 사용하겠다.
1) LocalDateTime 사용
자바 8부터 추가된 LocalDateTime 타입을 사용하겠다. 먼저 domain 패키지에 BaseTimeEntity 클래스를 생성한다.
// BaseTimeEntity.java
package com.cotato.study.SpringnAWS.domain.posts;
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 // JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들도 칼럼으로 인식하도록 함
@EntityListeners(AuditingEntityListener.class) // BaseTimeEntity 클래스에 Auditing 기능을 포함
public abstract class BaseTimeEntity {
@CreatedDate // Entity가 생성되어 저장될 때 시간이 자동 저장
private LocalDateTime createdDate;
@LastModifiedDate // 조회된 Entity의 값을 변경할 때 시간이 자동 저장
private LocalDateTime modifiedDate;
}
이제 Posts 클래스가 BaseTimeEntity를 상속받도록 변경한다.
// Posts.java
...
public class Posts extends BaseTimeEntity{
...
}
마지막으로 JPA Auditing 어노테이션들을 모두 활성화할 수 있도록 Application 클래스에 활성화 어노테이션 하나를 추가하겠다.
// Application.java
@EnableJpaAuditing // JPA Auditing 활성화
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
우리가 원하는 대로 기능이 잘 작동하는지 테스트 코드를 통해 확인해 보자.
2) JPA Auditing 테스트 코드 작성하기
PostsRepositoryTest 클래스에 테스트 메서드를 하나 더 추가하겠다.
@Test
public void BaseTimeEntity_등록(){
//given
LocalDateTime now = LocalDateTime.of(2023,01,18,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(">>>>>>> CreatedDate="+posts.getCreatedDate()+", modifiedDate="+posts.getModifiedDate());
assertThat(posts.getCreatedDate()).isAfter(now);
assertThat(posts.getModifiedDate()).isAfter(now);
}
이를 실행하면 아래와 같이 실제 시간이 잘 저장됨을 확인할 수 있다.
앞으로 추가될 다양한 Entity들은 더 이상 등록일/수정일로 고민할 필요가 없다. BaseTimeEntity만 상속받으면 자동으로 해결되기 때문이다.
'개발 > 스프링 부트와 AWS로 혼자 구현하는 웹 서비스' 카테고리의 다른 글
[Spring Boot & AWS] Chpt 6 - AWS 서버 환경을 만들어보자 - AWS EC2 (0) | 2023.01.24 |
---|---|
[Spring Boot & AWS] Chpt 5 - 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 (0) | 2023.01.23 |
[Spring Boot & AWS] Chpt 4 - 머스테치로 화면 구성하기 (0) | 2023.01.18 |
[Spring Boot & AWS] Chpt 2 - 스프링 부트에서 테스트 코드를 작성하자 (0) | 2023.01.11 |
[Spring Boot & AWS] Chpt 1 - 인텔리제이로 스프링 부트 시작하기 (0) | 2023.01.11 |