웹 애플리케이션에서 RDB는 빠질 수 없는 요소
하지만, RDB로 인해 2가지 문제점이 있음
SQL 사용에 따른 단순 반복 작업이 늘어남
패러다임의 불일치
RDB(어떻게 데이터를 저장할 것인가) VS OOP(기능과 속성을 한 곳에서 관리)
예시 코드(User와 Group이 부모-자식 관계임을 보여줌)
User user = findUser();
Group group = user.getGroup();
DB를 추가(User와 Group의 관계와 상관없이 따로 조회)
User user = userDao.findUser();
Group group = groupDao.findGroup(user.getGroupId());
JPA는 OOP와 RDB를 중간에서 패러다임 일치를 시켜주기 위한 기술
implementation('org.springframework.boot:spring-boot-starter-data-jpa') // Spring-boot용 Spring Data JPA 추상화 라이브러리
// 인메모리 관계형 DB로 별도의 설치 없이 프로젝트 의존성만으로 관리 가능
// 메모리에서 실행돼 앱을 재시작할 때마다 초기화되고, 이를 이용해 테스트 용도로 많이 사용
implementation('com.h2database:h2')
package com.jojoldu.book.springboot.domain.posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
// 주요 어노테이션을 클래스에 가깝게
// Kotlin 등 새 언어 전환 시 불필요한 어노테이션(e.g. lombok의 어노테이션) 삭제에 용이
@Getter // 클래스내 모든 필드에 Getter 자동 생성
@NoArgsConstructor // 기본 생성자 추가
@Entity // 테이블과 링크될 클래스임을 나타냄
public class Posts
{
@Id // 해당 테이블의 PK 필드
@GeneratedValue(strategy = GenerationType.IDENTITY) // PK의 생성 규칙
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 클래스는 절대 Setter 메소드를 만들지 않음
클래스의 인스턴스 값들이 언제 어디서 변해야 하는지 코드상으로 명확한 구분이 어려워지기 때문
잘못된 예시
public class Order
{
public void setStatus(boolean status)
{
this.status = status;
}
}
public void 주문서비스의_취소이벤트()
{
order.setStatus(false);
}
올바른 예시
public class Order
{
public void cancelOrder()
{
this.status = false;
}
}
public void 주문서비스의_취소이벤트()
{
order.cancelOrder();
}
기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것
값 변경은 해당 이벤트에 맞는 public 메소드를 호출
여기서는 @Builder를 통해 제공되는 빌더 클래스를 사용
지금 채워야 할 필드를 명확히 지정 가능
생성자 예시
// 아래의 경우 new Example(b, a)처럼 a와 b의 위치를 변경해도 코드를 실행하기 전에는 문제를 찾을 수 없음
public Example(String a, String b)
{
this.a = a;
this.b = b;
}
빌더 패턴 예시
Example.builder()
.a(a)
.b(b)
.build();
PostsRepository.java
// Entity 클래스와 기본 Entity Repository는 항상 함께 위치
package com.jojoldu.book.springboot.domain.posts;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostsRepository extends JpaRepository<Posts, Long>
{
}
PostsRepositoryTest.java
package com.jojoldu.book.springboot.domain.posts;
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.assertThat;
@ExtendWith(SpringExtension.class)
@SpringBootTest
public class PostsRepositoryTest
{
@Autowired
PostsRepository postsRepository;
// 단위 테스트가 끝날 때마다 수행되는 메소드를 지정
// 배포 전 전체 테스트를 수행할 때 테스트간 데이터 침범을 막기 위해 사용
@AfterEach
public void cleanup()
{
postsRepository.deleteAll();
}
@Test
public void savePost()
{
// given
String title = "테스트 게시글";
String content = "테스트 본문";
postsRepository.save(Posts.builder() // 테이블 posts에 insert/update 쿼리 실행(id 값이 있으면 update, 없으면 insert)
.title(title)
.content(content)
.author("jojoldu@gmail.com")
.build());
// when
List<Posts> postsList = postsRepository.findAll(); // 테이블 posts에 있는 모든 데이터 조회
// then
Posts posts = postsList.get(0);
assertThat(posts.getTitle()).isEqualTo(title);
assertThat(posts.getContent()).isEqualTo(content);
}
}
application.properties(쿼리 로그를 보기 위해)
spring.jpa.properties.hibernate.show_sql = true
API를 만들기 위해 총 3개의 클래스가 필요
Service에서 비지니스 로직을 처리할 필요 없음
Web Layer
Service Layer
Repository Layer
Dtos
Domain Model
비지니스 로직 처리는 Domain에서 담당
트랜잭션 스크립트
@Transactional
public Order cancelOrder(int orderId)
{
OrderDto order = orderDao.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");
orderDao.update(order);
billing.setStatus("CANCEL");
deliveryDao.update(billing);
return order;
}
도메인 모델
@Transactional
public Order cancelOrder(int orderId)
{
Orders order = orderRepository.findById(orderId);
Billing billing = billingRepository.findByOrderId(orderId);
Delivery delivery = deliveryRepository.findByOrderId(orderId);
delivery.cancel();
order.cancel();
billing.cancel();
return order;
}
두 방식의 차이점
PostsApiController.java
package com.jojoldu.book.springboot.web;
import com.jojoldu.book.springboot.service.PostsService;
import com.jojoldu.book.springboot.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
@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.jojoldu.book.springboot.service;
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.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();
}
}
PostsSaveRequestDto
package com.jojoldu.book.springboot.web.dto;
import com.jojoldu.book.springboot.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();
}
}
PostsApiControllerTest
package com.jojoldu.book.springboot.web;
import com.jojoldu.book.springboot.domain.posts.Posts;
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
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.web.server.LocalServerPort;
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.Assertions.assertThat;
@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 PostsRegister() 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);
}
}
PostsApiController.java
// 기존 import
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
@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.jojoldu.book.springboot.web.dto;
import com.jojoldu.book.springboot.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();
}
}
PostsUpdateRequestDto.java
package com.jojoldu.book.springboot.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
// 기존 import
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import com.jojoldu.book.springboot.domain.posts.Posts;
@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;
}
@Transactional(readOnly = true)
public PostsResponseDto findById(Long id)
{
Posts entity = postsRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다. id=" + id));
return new PostsResponseDto(entity);
}
}
PublicApiControllerTest.java
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest
{
// 기존 코드
@Test
public void PostsUpdate() 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);
}
}
application.properties
// 다음과 같이 수정
spring.jpa.show-sql=true
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
spring.h2.console.enabled=true
SQL로 데이터 조회, 추가
http://localhost:8080/api/v1/posts/1으로 접속해 API 조회 기능 테스트
보통 엔티티에는 데이터의 생성시간과 수정시간을 포함함
이 두 정보는 차후 유지보수에서 중요한 정보이기 때문
그렇다 보니 매번 DB에 삽입/갱신하기 전에 날짜 데이터를 등록/수정하는 코드가 사용됨
// 생성일 추가 코드 예시
public void savePosts()
{
// ...
posts.setCreateDate(new LocalDate());
postsRepository.save(posts);
}
위의 과정을 JPA Auditing으로 자동화 가능
Java8부터 등장한 LocalDate와 LocalDateTime 사용
BaseTimeEntity.java
package com.jojoldu.book.springboot.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 // JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우, 필드들도 칼럼으로 인식되게 함
@EntityListeners(AuditingEntityListener.class) // BaseTimeEntity 클래스에 Auditing 기능 포함
public class BaseTimeEntity
{
@CreatedDate // Entity가 생성되어 저장될 때 시간이 자동으로 저장
private LocalDateTime createdDate;
@LastModifiedDate // 조회한 Entity의 값을 변경할 때 시간이 자동 저장
private LocalDateTime modifiedDate;
}
Posts.java
public class Posts extends BaseTimeEntity {}
Application.java
@EnableJpaAuditing // 어노테이션 추가하여 JPA Auditing 어노테이션들을 모두 활성화
@SpringBootApplication
public class Application
{
public static void main(String[] args)
{
SpringApplication.run(Application.class, args);
}
}
PostsRepositoryTest.java
public class PostsRepositoryTest
{
// 기존 코드
@Test
public void registerBaseTimeEntity()
{
// given
LocalDateTime now = LocalDateTime.of(2021, 11, 16, 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(">>>>>>>>> create Date="+posts.getCreatedDate()+", modifiedDate="+posts.getModifiedDate());
assertThat(posts.getCreatedDate()).isAfter(now);
assertThat(posts.getModifiedDate()).isAfter(now);
}
}