웹 서비스에서 중요한 부분 - 데이터베이스 관리.
데이터베이스에 접근하고 관리하는 것이 중요함.
ORM
웹 어플리케이션에서는 '관계형 데이터베이스'를 사용해 데이터를 관리함. Oracle, MySQL 등을 사용하는데 객체를 관계형 DB에서 관리함. DB는 SQL만을 인식하기에 SQL 코드가 어플리케이션 코드보다 많이 쓰이는 문제가 발생
테이블마다 CRUD를 생성해야함. (Create, Read, Update, Delete)
insert into ~
select * from ~
update ~ set .. where~~~
delete from ~ where ~
1. JPA란?
- 자바 표준명세서로 인터페이스임. 즉, 구현체가 필요한데 Hibernate, Elipse Link등의 구현체가 존재함. 하지만 Spring에서 JPA를 사용할땐, Spring Data JPA 모듈을 사용하는데 Hibernate를 한 번 더 감싼 느낌이다.
JPA <- HIbernate <- Spring Data JPA
이렇ㄱ ㅔ감싼 이유는
1. 구현체 교체의 용이성
내부에서 구현체 매핑을 진행하기 때문에 Hibernate에서 다른 구현체를 사용할때 쉽게 교체할 수 있다.
2. 저장소 교체의 용이성
관계형 데이터베이스 -> MongoDB로 교체할때 dependencies만 교체해주면 된다.
Spring Data의 하위 프로젝트들의 기본적인 CRUD 인터페이스가 동일하기때문인데 save(), findAll(), findOne()과 같은 메소드가 기본적으로 같은 기능으로 구현되어있다는 의미인 듯 하다.
3.2 프로젝트에 Spring Data JPA 적용하기
package com.jojoldu.book.springboot.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;
@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
실제 DB의 테이블과 매칭될 클래스임을 나타낸다.
@Id
이 테이블의 primary key를 나타냄.
@GenratedValue
pk의 생성 규칙을 나타냄. 스프링부트 2.0에서는 GenerationType.IDENTITY를 추가해야 auto_increment가 이루어진다고 하는데 이게 뭐지...
- auto_increment란?
데이터가 삽입될때 1씩 증가해주는 역할
PK는 Long Auto_increment를 추천함.
- 비즈니스상 유니크 키나, 여러키를 복합키로 사용하면 난감한 상황이 발생하기때문!
- Foreign Key를 맺을 때 다른 테이블에서 복합키를 전부 갖고 있거나, 중간에 테이블을 하나 더 만들어야함. 아마 DB시간에 배운 내용과 관련이 있는 듯 하다.
- 인덱스상의 장점이 있다.
- 비즈니스상 유니크의 조건이 변경될 경우 PK전체를 수정해야함.
@Column
테이블의 컬럼임을 나타냄. 굳이 선언하지 않아도 상관없지만 선언시 기본값 외에 추가로 변경할 옵션이 있으면 사용함.
EX : 문자열 VARCHAR(255) --> length = 500, 또는 텍스트로 변경하고 싶을떄 사용 titile, content.
@NoArgsConstructor
- 기본 생성자를 자동으로 추가해줌.
@Builder
해당 클래스의 빌더 패턴 클래스를 생성.
생성자 상단에서 선언 시 생성자에 포함된 빌드만 빌더에 포함?
생성자와 비슷한 역할. 생성시점에 값을 채워줌. 하지만 내가 채울 필드를 명확하게 지정할 수 있다는 점에서 생성자와 차이가 있다.
//생성자를 활용
public Example(String a, String b){
this.a = a;
this.b = b;
}
new Example(b,a)를 실행해도 문제를 찾을 수 없다.
//Builder를 활용
Example.builder()
.a(a)
.b(b)
.build();
Setter가 없다?
- Getter / Setter를 무작정 생성하는 경우가 있는데 이 경우 인스턴스의 값이 언제 어디서 변하는지 코드상으로 구분할 수 가 없다. 즉, 차후 기능 변경시 복잡해짐.
Entity 클래스에서는 절대 Setter를 만들지 않는다. 값 변경이 필요하면 관련 메소드를 추가한다.
//Setter를 사용했을때 주문취소
public class Order{
public void setStatus(boolean status){
this.status = status;
}
}
public void 주문취소_이벤트(){
order.setStatus(false);
}
//Setter 사용하지 않고 명확하게 기능별 메소드를 만든 경우
public class Order{
public void cancelOrder(){
this.status = false;
}
}
public void 주문취소_이벤트(){
order.cancelOrder();
}
Q. 그렇다면 Setter 없이 어떻게 값을 채워 DB에 insert하지?
기본 : 생성자를 통한 값을 채우기 -> DB에 삽입.
변경 : 관련 public 메소드를 호출함.
Posts클래스로 Database에 접근하게 해줄 인터페이스인 JpaRepository를 생성하자
package com.jojoldu.book.springboot.domain.posts;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostsRepository extends JpaRepository<Posts,Long> {
}
이렇게 인터페이스 생성 후 , JpaRepository<클래스 ,PK타입>dmf tjsdjsgkaus 기본적인 CRUD 메소드가 자동으로 생성됨.
Entity 클래스와 기본 Repository는 항상 함께 위치해야함.
3.3 테스트 코드 작성하기
package com.jojoldu.book.springboot.web.domain.posts;
import com.jojoldu.book.springboot.domain.posts.Posts;
import com.jojoldu.book.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.AssertionsForClassTypes.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("jojoldu@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);
}
}
위 테스트는 게시글을 저장하고 해당 내용이 제대로 저장이 되었는지 확인하는 테스트이다.
postsRepository.save
테이블에 insert / update 쿼리를 보내는 메소드.
id에 해당하는 값이 있다면 update, 그렇지 않으면 insert가 수행된다.
@After
테스트가 끝날때마다 실행되는 메소드
해당 어노테이션이 필요한 이유는 테스트 케이스에 사용한 객체가 DB 그대로 남아있어서 데이터 침범을 막기 위해 사용함.
postsRepository.findAll()
테이블에 있는 모든 데이터를 조회하는 메소드
이렇게 테스트를 진행하니 잘 진행되었다.
jpa에서 sql 쿼리를 대신 진행해준다고 하는데 실제 쿼리 형태는 어떨까? 이를 확인하기 위해선 src/main/resources에 application.properties 파일을 생성하고 다음 코드를 추가한다.
spring.jpa.show_sql = true
해당 내용은 H2 쿼리 문법으로 작성되었기에 다음 코드를 작성하고 sql문을 확인하자
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
3.4 등록/수정/조회 API 만들기
API를 만들기 위해 3개의 클래스가 필요하다.
- Request 데이터를 받을 Dto
- API 요청을 받을 Controller
- 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service
Spring web계층은 다음과 같다.
- Web Layer : 컨트롤러, jsp/Freemarker등의 뷰 템플릿 영역. 외부 요청과 응답에 대한 영역을 이야기함
- Service Layer : 서비스에 사용되는 영역으로 비즈니스 로직을 이곳에서 처리하는 것이 아닌 Controller와 Dao의 중간 역역이다. @Transactional이 사용되는 영역
- Repository Layer : Database에 접근하는 영역. (Dao와 유사)
- Dtos : Dto란 계층 간에 데이터 교환을 위한 객체로 이들이 모인 영역을 Dtos라고 한다.
- Donmain Model : 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있게 단순화 한 것. @Entity가 사용된 영역이 도메인 모델이라고 할 수 있지만 반드시 테이블과 관련있는 것은 아님.
VO처럼 값 객체들도 이 영역에 해당됨
VO란?
-value object의 약어. 프로그래밍을 할때 사물을 복합물로 표현하는게 적절할때가 있다. 도메인에서 한 개 또는 그 이상 속성(attribute)를 묶어 특정 값을 나타내는 것을 의미한다. 예로는 2차원 x,y의 좌표는 (실수, 실수), 숫자와 통화로 이루어진 금액, 시작 날짜와 끝 날짜로 이루어진 기간 등이 그 예시이다.
https://tecoble.techcourse.co.kr/post/2020-06-11-value-object/
VO(Value Ojbect)란 무엇일까?
프로그래밍을 하다 보면 VO라는 이야기를 종종 듣게 된다. VO와 함께 언급되는 개념으로는 Entity, DTO등이 있다. 그리고 더 나아가서는 도메인 주도 설계까지도 함께 언급된다. 이 글에서는 우선 다
tecoble.techcourse.co.kr
서비스에서 비즈니스 로직을 처리하지 않으면 어떻게 처리할까?
그것이 바로 'Domain'이다.
서비스 클래스에서 내부 로직을 다 처리하면, 단순 데이터 덩어리 역할만 하게되고 서비스 계층의 의미가 없어진다. 서비스 메소드에선 트랜잭션과 도메인간의 순서만 보장해줘야한다.
// PostsApiController
package com.jojoldu.book.springboot.web;
import com.jojoldu.book.springboot.service.posts.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
package com.jojoldu.book.springboot.service.posts;
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();
}
}
위 코드의 특징을 보면, 컨트롤러, 서비스임에도 Autowired가 없다.
스프링에서 Bean을 주입받는 3가지 방법이 있는데 다음과 같다
- @Autowired
- setter
- 생성자 : @RequiredArgsConstructor 에서 해결해줌.
Bean이란?
Spring에 의하여 생성 및 관리되 자바 객체를 Bean이라고 함. Bean을 등록할때는 어노테이션을 활용한다.
1. @Component Anntation을 이용하는 방법 ( @Controller 등 )
2, 또는 Configuration file에 직접 등록하는 두 가지 방법이 있다.
이렇게 등록된 Bean을 주입 받는 방법이 위의 3가지이다.
package com.jojoldu.book.springboot.web.dto;
import com.jojoldu.book.springboot.domain.posts.Posts;
import javafx.geometry.Pos;
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();
}
}
PostsSaveRequestDto (등록) 는 Entity와 상당히 유사하다. 그러나, Entity 클래스를 절대 Response / Request 클래스로 사용해서는 안된다. 그 이유는 Entity 클래스는 데이터베이스와 가장 맞닿아있는 핵심 클래스로 이를 기준으로 테이블이 생성되고, 스키마가 변경되는데 화면 변경등은 사소한 일이지만 테이블 전체를 바꾸는건 프로젝트 전체의 테이블의 기능과 관련된 부분이 바뀌는 일이라 그렇다.
대부분의 서비스와 비즈니스 로직이 Entity class를 기준으로 동작하기에 여러 영향을 끼치지만 Response / Request는 View ( 화면에 보여주기 위한 ) 클래스이므로 변경이 자주 일어나기 때문이다.
반드시 View Layer와 DB Layer를 분리하자!
수정 / 조회 API 만들기
수정 - PostsUpdateDto
업데이트시에는 제목, 내용만 변경하기에 이 클래스의 builder에는 title,content에 대한 생성자만 존재함.
조회 - PostsResponseDto
Entity의 필드중 일부만 사용하므로 생성자로 Entity의 값을 받아서 사용함.
PostsService 클래스
package com.jojoldu.book.springboot.service.posts;
import com.jojoldu.book.springboot.domain.posts.Posts;
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
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();
}
@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의 영속성 컨텍스트 때문이라고 하는데 이게 뭘까
영속성 컨텍스트란?
Entity를 영구 저장하는 환경을 의미함. Application과 Database 사이에 객체를 보관하는 가상의 DB 역할을 한다. Entity Manager(이하 EM)를 통해 엔티티를 저장하거나 조회하면, EM 영속성 컨텍스트에 보관하고 조회, 수정을 하는 방식으로 관리를 하는 것이다.
컴퓨터 구조때 배운 디스크에서 정보를 가져오고 메인메모리 또는 Cache의 개념과 유사한 듯하다. 따라서, 수정 여부를 확인하기 위한 더티체킹이 필요하다.
또한 이렇게 수정된 정보에 대해 JPA는 트랜잭션이 commit될 때 영속성 컨텍스트에 저장된 새로운 엔티티를 데이터베이스에 반영하는데 이를 'Flush'라고 한다.
참고자
JPA 영속성 컨텍스트란?
영속성 컨텐스트란 엔티티를 영구 저장하는 환경이라는 뜻이다. 엔티티 매니저를 통해 엔티티를 저장하거나 조회하면 엔티티 매니저는 영속성 컨텍스트에 엔티티를 보관하고 관리한다.em.persist
velog.io
더티체킹과 영속성 컨텍스트에 관한 작가님의 보충 설명 글
https://jojoldu.tistory.com/415
더티 체킹 (Dirty Checking)이란?
Spring Data Jpa와 같은 ORM 구현체를 사용하다보면 더티 체킹이란 단어를 종종 듣게 됩니다. 더티 체킹이란 단어를 처음 듣는분들을 몇번 만나게 되어 이번 시간엔 더티 체킹이 무엇인지 알아보겠습
jojoldu.tistory.com
PostsApiController
package com.jojoldu.book.springboot.web;
import com.jojoldu.book.springboot.service.posts.PostsService;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto){
return postsService.save(requestDto);
}
@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 PostsResponseDto findById(@PathVariable Long id){
return postsService.findById(id);
}
}
@PostMapping
특정 객체를 데이터에 저장할때 사용하는 HTTP 메소드
@PutMapping
특정 객체를 수정할때 사용하는 HTTP 메소드
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 com.jojoldu.book.springboot.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.AssertionsForClassTypes.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);
}
@Test
public void Posts_수정된다() 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);
}
}
이때 처음에 exchange 부분에서 오류가 발생했다 자꾸 테스트가 통과 되지 않았는데 알고보니 @PutMapping 관련 컨트롤러를 생성해주지 않아서 생긴 오류였다.
이후 h2 database 콘솔, 웹 브라우저를 통해 조회해보니 결과가 다 올바르게 나왔다.
3.5 JPA Auditing으로 생성/수정 시간 자동화하기'
보통의 엔티티는 생성,수정 시간을 포함하는데 이는 유지보수에 있어서 매우 중요한 정보이다.
따라서, DB에 수정, 삽입을 하기 전에 날짜 데이터를 등록/수정해야한다.
이를 자동화하는게 JPA Auditing이다.
참고 : Java의 Date와 calendar가 아닌 LocalDate, LocalDateTime을 사용하는 이유.
기존, Date,Calendar의 문제점
1. 불변 객체가 아님.
2. Month값 설계가 잘못됨
따라서, 모든 Entity들의 상위 클래스가 되어 Entity들이 생성, 수정 날짜를 자동으로 관리하게 해줘야한다. 이 역할을 하는 BaseTimeEntity의 코드는 다음과 같다.
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
@MappedSuperclass
JPA Entity 클래스들이 이 클래스를 상속할 경우 이 클래스의 필드 (createdDate, modifiedDate)도 칼럼으로 인식하게 하는 어노테이션
@EntityListeners(AuditingEntityListener.class)
BaseTimeEntity 클래스에 Auditing 기능을 포함
@CreatedDate / @LastModifiedDate
Entity들이 생성 및 수정 될때 시간이 자동 저장된다.
반드시 Entity들이 위 클래스를 상속 받게 해야한다.
public class Posts extends BaseTimeEntity{
...
}
또한 JPA Auditing 어노테이션이 활성화 되도록 Application 클래스에 활성화 하여 어노테이션을 추가하자.
@EnableJpaAuditing //
..
..
public class Application{
...
}
'BE > 6기 코테이토 - Spring Study' 카테고리의 다른 글
3회 - Ch.5 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 (0) | 2023.02.07 |
---|---|
3회 - Ch4 머스테치로 화면 구성하기 (0) | 2023.02.03 |
1회 Ch2. 스프링 부트에서 테스트 코드를 작성하자. (0) | 2023.01.24 |
Gradle이란? (0) | 2023.01.23 |
1회 - Ch1. 인텔리제이로 스프링부트 시작하기 (0) | 2023.01.20 |