JPA는 여러 ORM 전문가가 참여한 EJB 3.0 스펙 작업에서 기존 EJB ORM이던 Entity Bean을 JPA라고 바꾸고 JavaSE, JavaEE를 위한 영속성(persistence) 관리와 ORM을 위한 표준 기술이다. JPA는 ORM 표준 기술로 Hibernate, OpenJPA, EclipseLink, TopLink Essentials과 같은 구현체가 있고 이에 표준 인터페이스가 바로 JPA이다.
ORM(Object Relational Mapping)이란 RDB 테이블을 객체지향적으로 사용하기 위한 기술이다. RDB 테이블은 객체지향적 특징(상속, 다형성, 레퍼런스, 오브젝트 등)이 없고 자바와 같은 언어로 접근하기 쉽지 않다. 때문에 ORM을 사용해 오브젝트와 RDB 사이에 존재하는 개념과 접근을 객체지향적으로 다루기 위한 기술이다.
두 기둥 위에 있는 기술
이다.- MyBatis, iBatis는 ORM이 아니다. SQL Mapper입니다.
- ORM은 객체를 매핑하는 것이고, SQL Mapper는 쿼리를 매핑하는 것이다.
jpa 기능을 사용하여 게시판과 회원 기능을 구현한다.
post 기능
- post 조회
- post 등록
- post 수정
- post 삭제
member 기능
- 구글/ 네이버 로그인
- 로그인한 사용자 글 작성 권한
- 본인 작성 글에 대한 권한 관리
먼저 build.gradle에 다음과 같이 org.springframework.boot:spring-boot-stater-data-jpa와 com.h2database:h2 의존성들을 등록한다.
dependencies {
compile('org.springframework.boot:spring-boot-starter-web')
compile('org.projectlombok:lombok')
compile('org.springframework.boot:spring-boot-stater-data-jpa')
compile('com.h2database:h2')
testCompile('org.springframework.boot:spring-boot-starter-test')
}
Posts.class
package com.swchoi.webservice.springboot.domain.posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@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;
}
}
이 Posts 클래스에는 한 가지 특이점이 있습니다. setter 메소드가 없다는 점입니다.
자바빈 규약을 생각하면서 getter/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(); }
Post 클래스 생성이 끝났다면, Post 클래스로 Database를 접근하게 해 줄 JpaRepository를 생성한다.
PostsRepository
package com.swchoi.webservice.springboot.domain.posts; import org.springframework.data.jpa.repository.JpaRepository; public interface PostsRepository extends JpaRepository<Posts, Long> { }
보통 ibatis나 MyBatis 등에서 Dao라고 불리는 DB Layer 접근자입니다.
JPA에선 Repository라고 부르며 인터페이스로 생성합니다. 인터페이스 생성 후 JpaRepository<Entity 클래스, PK 타입>을 상속하면 기본적인 CRUD 메소드가 자동으로 생성된다.
package com.swchoi.webservice.springboot.domai.posts;
import com.swchoi.webservice.springboot.domain.posts.Posts;
import com.swchoi.webservice.springboot.domain.posts.PostsRepository;
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.assertThat;
@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {
@Autowired
PostsRepository postsRepository;
@After
public void cleanup() {
postsRepository.deleteAll();
}
@Test
public void 게시글저장_블러오기() {
//given
String title = "테스트 게시글";
String content = "테스트 본문";
postsRepository.save(Posts.builder()
.title(title)
.content(content)
.author("b088081@gmail.com")
.build());
//when
List<Posts> postsList = postsRepository.findAll();
//then
Posts posts = postsList.get(0);
assertThat(posts.getTitle()).isEqualTo(title);
assertThat(posts.getContent()).isEqualTo(content);
}
}
application.properties
spring.jpa.show-sql=true
쿼리 로그 확인
- create table 쿼리를 보면 id bigint generated by default as identity라는 옵션으로 생성된다.
- 이는 H2 쿼리 문법으로 적용되었기 때문이다.
- 출력되는 쿼리 로그를 MySql 버전으로 변경해보자
application.propertiesspring.jpa.show-sql=true spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
API를 만들기 위해 총 3개의 클래스가 필요합니다.
Service는트랜잭션, 도메인 간 순서 보장
의 역할만 합니다.
Spring 웹 계층
Web,Service,Repository,Dto,Domain
이 5가지 레이어에서 비지니스 처리를 담당해야 할 곳은 어디일까요? 바로 Domain이다.
기존에 서비스로 처리하던 방식을 트랜잭션 스크립트라고 합니다. 주문 취소 로직을 작성한다면 다음과 같습니다.
슈도코드
@Transactional public Order cancelOrder(int orderId) { 1) 데이터베이스로부터 주문정보 (Orders), 결제정보(Billing) , 배송정보(Delivery) 조회 2) 배송 취소를 해야 하는지 확인 3) if(배송 중이라면) { 배송 취소로 변경 } 4) 각 테이블에 취소 상태 Update }
실제 코드
@Transactional public Order cancelOrder(int orderId) { //1) OrderDto order = ordersDao.selectOrders(orderId); BillingDto billing = billingDao.selectBilling(orderId); DeliverDto 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); //5) billing.setStatus("CANCEL"); billingDao.update(billing); return order; }
모든 로직이
서비스 클래스 내부에서 처리됩니다.
그러다 보니서비스 계층이 무의미하며, 객체란 단순히 데이터 덩어리
역할만 하게 됩니다.
반면 도메인 모델에서 처리할 경우 다음과 같은 코드가 될 수 있습니다.
@Transactional public Order cancelOrder(int orderId) { //1) Order order = ordersRepository.findById(orderId); Billing billing = billingRepository.findById(orderId); Deliver delivery = deliveryRepository.findById(orderId); //2-3) delivery.cancel(); //4) order.cancel(); billing.cancel(); return order; }
order, billing, delivery가 각자 본인의 취소 이벤트 처리를 하면,
서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장
해 줍니다.
이러한 방식으로 등록, 수정, 삭제 기능을 만들어 보겠습니다.
PostsApiController
package com.swchoi.webservice.springboot.web; import com.swchoi.webservice.springboot.service.posts.PostService; import com.swchoi.webservice.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 PostService postService; @PostMapping("/api/v1/posts") public Long save(@RequestBody PostsSaveRequestDto requestDto) { return postService.save(requestDto); } }
PostService
package com.swchoi.webservice.springboot.service.posts; import com.swchoi.webservice.springboot.domain.posts.PostsRepository; import com.swchoi.webservice.springboot.web.dto.PostsSaveRequestDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Service public class PostService { private final PostsRepository postsRepository; @Transactional public Long save(PostsSaveRequestDto requestDto) { return postsRepository.save(requestDto.toEntity()).getId(); } }
스프링을 써보셨던 분들은 Controller 와 Service에서 @Autowired가 없는 것이 어색하게 느껴집니다. 스프링에선 Bean을 주입받는 방식들이 다음과 같습니다.
- @Autowired
- setter
- 생성자
이 중 가장 권장하는 방식이 생성자로 주입받는 방식입니다. 즉 생성자로 Bean 객체를 받도록 하면 @Autowired와 동일한 효과를 볼 수 있다.
PostsSaveRequestDto
package com.swchoi.webservice.springboot.web.dto; import com.swchoi.webservice.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(); } }
여기서 Entity 클래스와 거의 유사한 형태임에도 Dto클래스를 추가로 생성했습니다. 하지만, 절대로 Entity 클래스를 Request/Response 클래스로 사용해서는 안된다.
Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스입니다.
PostsApiControllerTest
package com.swchoi.webservice.springboot.web; import com.swchoi.webservice.springboot.domain.posts.Posts; import com.swchoi.webservice.springboot.domain.posts.PostsRepository; import com.swchoi.webservice.springboot.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.assertThat; @RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_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); } }
PostsApiController
@RequiredArgsConstructor @RestController public class PostsApiController { ... @PutMapping("/api/v1/posts/{id}") public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) { return postService.update(id, requestDto); } @GetMapping("/api/v1/posts/{id}") public PostsResponseDto findById (@PathVariable Long id) { return postService.findById(id); } }
PostsResponseDto
package com.swchoi.webservice.springboot.web.dto; import com.swchoi.webservice.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(); } }
PostsResponseDto
package com.swchoi.webservice.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; } }
Post
@Getter @NoArgsConstructor @Entity public class Posts { ... public void update(String title, String content) { this.title = title; this.content = content; } }
PostService
@RequiredArgsConstructor @Service public class PostService { ... @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); } }
여기서 신기한 것이 있습니다. update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없습니다. 이게 가능한 이유가 JPA의 영속성 컨텍스트 때문입니다.
영속성 컨텍스트란, 엔티티를 영구 저장하는 환경입니다. 일종의 놀리적 개념이라고 보시면 되며, JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈립니다.
JPA의 엔티티 매니저(EntityManager)가 활성화된 상태로(Spring Data Jpa를 쓴다면 기본옵션) 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태입니다.
이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영합니다. 즉 Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다는 것이죠, 이 개념을 더티 체킹이라고 합니다.
PostsApiControllerTest
@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class PostsApiControllerTest { ... @Test public void Posts_수정된다() throws Exception { //given Posts savePosts = postsRepository.save(Posts.builder() .title("title") .content("content") .author("author") .build()); Long updateId = savePosts.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); } }
테스트 결과를 보면 update 쿼리가 수행된는 것을 확인 할 수 있습니다.
Posts 수정 API 테스트 결과
application.properties 추가
spring.h2.console.enabled=true
톰캣 실행 후 http://localhost:8080/h2-console 접속
SELECT * FROM posts; 쿼리 실행
insert into posts(author, content, title) values ('author', 'content', 'title'); 쿼리실행
브라우저로 Posts API 조회
Java8부터 LocalDate와 LocalDateTime이 등장했습니다.
그긴 Java의 기본 날짜 타입인 Date의 문제점을 제대로 고친 타입이라 Java8 이상일 경우 무조건 사용해야한다.
참고
Naver D2 - Java의 날짜와 시간 API
LocalDate와 LocalDateTime이 데이터베이스에 제대로 매핑되지 않는 이슈가 Hibernate 5.2.10 버전에서 해결되었습니다.
스프링 부트 1.x를 쓴다면 별도로 Hibernate 5.2.10 이상을 사용하도록 설정이 필요하지만, 스프링 부트 2.x 버전을 사용하면 기본적으로 해당 버전을 사용 중이라 별다른 설정 없이 바로 적용 가능하다.
domain 패기키지에 BaseTimeEntity 클래스 생성
BaseTimeEntity
package com.swchoi.webservice.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 @EntityListeners(AuditingEntityListener.class) public class BaseTimeEntity { @CreatedDate private LocalDateTime createDate; @LastModifiedDate private LocalDateTime modifiedDate; }
...
public class Posts extends BaseTimeEntity {
...
}
@EnableJpaAuditing // JPA Auditing 활성화
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
PostsRepositryTest BaseTimeEntity_등록 추가
@Test public void BaseTimeEntity_등록() { //given LocalDateTime now = LocalDateTime.of(2020,03,17,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() +", modeifeidDate="+posts.getModifiedDate()); assertThat(posts.getCreateDate()).isAfter(now); assertThat(posts.getModifiedDate()).isAfter(now); }
테스트 결과
오타있습니다 r이 빠져있습니다.
org.springframework.boot:spring-boot-stater-data-jpa -> org.springframework.boot:spring-boot-starter-data-jpa
글은 잘읽고 있습니다.
안녕하세요?
궁금한 게 있는데요.
모든 로직이 서비스 클래스 내부에서 처리됩니다. 라는 내용이 있는데 그 위의 코드를 보면
딱히 아무 내용 없어보이는데 모든 로직이 서비스 클래스에서 처리된다는 게 무슨 의미일까요?
그 부분이나 밑에 cancel() 로 구현된 부분을 봤을 때
dao 를 호출하냐 repository 를 호출하냐의 차이 밖에 없는 것 같은데 이게 큰 차이가 있나요?