이 글은 이동욱 님의 '스프링 부트와 AWS로 혼자 구현하는 웹 서비스' 책 내용을 정리한 것입니다.
현대의 웹 어플리케이션에서 데이터 베이스는 빠질 수 없는 요소
➡️ 객체를 관계형 데이터베이스에서 관리하는 것이 중요!
하지만 관계형 데이터베이스는 SQL만 인식할 수 있기 때문에 개발자가 아무리 자바 클래스를 아름답게 설계해도, SQL을 통해야만 데이터베이스에 저장하고 조회할 수 있다.
즉, 결국은 관계형 데이터베이스를 사용해야만 하는 상황에서 SQL은 피할 수 없다.
문제는 다음에 있다.
실제 현업에서는 수십, 수백 개의 테이블이 있는데 이 테이블의 몇배의 SQL을 만들고 유지보수 해야만 한다.
이와 같이 JPA를 사용하지 않으면 단순 반복 작업을 해야만 하는 것이다.
✏️ 관계형 데이터베이스 : 어떻게 데이터를 저장할지
✏️ 객체 지향 프로그래밍 언어 : 기능과 속성을 한 곳에서 관리하는 기술
둘은 이미 사상부터 다른 시작점에서 출발했다.
관계형 데이터베이스와 객체지향 프로그래밍 언어의 패러다임이 서로 다른데,
"객체" 를 "데이터베이스" 에 저장하려고 하니 여러 문제가 발생하는 것이다.
✅ JPA는 이 둘을 중간에서 패러다임 일치를 시켜주기 위한 기술이다.
즉, 개발자는 객체지향적으로 프로그래밍을 하고,
JPA가 이를 관계형 데이터베이스에 맞게 SQL을 대신 생성해서 실행한다.
개발자는 항상 객체지향적으로 코드를 표현할 수 있으니 더는 SQL에 종속적인 개발을 하지 않아도 된다
JPA (Java Persistence API) 에 대해 더 자세히 살펴보자.
JPA는 자바 진영에서 ORM(Object-Relational Mapping) 기술 표준으로 사용되는 인터페이스의 모음이다.
➡️ 즉, 인터페이스이므로 구현체가 필요하다!
( ex. Hibernate, Eclipse Link ... )
구현체들을 좀 더 쉽게 사용하고자 추상화시킨 Spring Data JPA라는 모듈을 이용해 JPA기술을 다룰 것임
(Spring에서 JPA를 사용할 때 구현체를 직접 다루지 X)
이들의 관계는 다음과 같다.
JPA ← Hibernate ← Spring Data JPA
Spring Data JPA 등장 이유
📌 HOW?
Spring Data의 하위 프로젝트들은 기본적은 CRUD의 인터페이스가 같음
즉, 저장소가 교체되어도 기본적인 기능은 변경할 것이 없다.
왜 실무에서 JPA를 사용하지 못하는가
높은 러닝 커브!
📌 러닝 커브
Learning Curve
특정 기술 또는 지식을 실제 필요한 업무와 같은 환경에서 효율적으로 사용하기 위해 드는 학습 비용
(위키백과)
JPA를 잘 쓰려면 객체지향 프로그래밍과 관계형 데이터베이스를 둘 다 이해해야함
하지만, 그만큼 JPA를 사용해서 얻는 보상은 크다!
CRUD 직접 작성할 필요 X
부모-자식관계 표현, 1:N 관계 표현, 생태와 행위를 한 곳에서 관리하는 등 객체지향 프로그래밍을 쉽게 할 수 있음
뿐만 아니라 JPA에서는 여러 성능 이슈 해결책들을 이미 준비해 놓은 상태이기 떄문에,
이를 잘 활용하면 네이티브 쿼리 만큼의 퍼포먼스를 낼 수 있음!
Chap3~ Chap6까지 하나의 게시판을 만들 것임
build.gradle에 다음과 같이 의존성을 등록한다.
나는 compile 대신 implementaion을 사용했다.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'com.h2database:h2'
코드 설명
✅ spring-boot-starter-data-jpa
스프링 부트용 Spring Data JPA 추상화 라이브러리
스프링 부트 버전에 맞춰 자동으로 JPA관련 라이브러리들의 버전을 관리해줌
✅ h2
인메모리 관리형 데이터베이스
변도의 설치없이 프로젝트 의존성만으로 관리할 수 있음
메모리에서 실행 → 어플리케이션을 재시작할 따마다 초기화 → 테스트용도 多 사용
다음과 같이 domain 패키지를 만듦
이 패키지에는 게시글, 댓글, 회원, 정산 등 소프트웨어에 대한 요구사항 혹은 문제 영역인 도메인을 담을 것이다.
그리고 domain 패키지에 post 패키지와 Posts 클래스를 만듦
@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.author = author;
this.content = content;
}
}
@Entity : 테이블과 링크될 클래스임을 나타냄. 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭 ( ex. SalesManager.java -> sales_manager table )
@Id : 해당 테이블의 PK필드
@GeneratedValue : PK의 생성규칙. 스프링 부트 2.0에서는 GenerationType.IDENTITY옵션을 추가해야만 auto_increment가 된다.
@Column : 테이블의 칼럼을 나타내며 굳이 선언하지 않아도 해당 클래스의 필드는 모두 칼럼이 된다. 기본값외에 추가로 변경이 필요한 경우 사용한다. (ex.사이즈 늘리기, 타입 변경 등 )
@Builder : 해당 클래스의 빌더 패턴 클래스를 생성, 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함
Entity클래스에서는 절대 Setter메소드를 만들지 않음
setter를 무작정 생성하다보면 해당 클래스의 인스턴스 값들이 언제 어디서 변해야하는지 코드상으로 명확하게 구분할 수 없기 때문!
대신, 해당 필드의 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가해야만 함.
➡️ 그렇다면 Setter가 없는 상황에서 어떻게 값을 채워 DB에 삽입 할까?
기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것이며, 값 변경이 필요한 경우 해당 이벤트에 맞는 public 메소드를 호출 하여 변경하는 것을 전제로 한다.
(여기선 @Builder를 통해 제공되는 빌더클래스를 사용한다. )
package com.jojoldu.book.springboot.domain.posts;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostsRepository extends JpaRepository<Posts, Long> {
}
//save,findAll 기능을 테스트
@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest extends TestCase {
@Autowired
PostsRepository postsRepository;
@After
public void cleanup(){
postsRepository.deleteAll();
}
@Test
public void 게시글저장_불러오기(){
String title = "테스트 게시글";
String content = "테스트 본문";
postsRepository.save(Posts.builder()
.title(title)
.content(content)
.author("jojoldu@gmail.com")
.build());
List<Posts> postsList = postsRepository.findAll();
Posts posts = postsList.get(0);
assertThat(posts.getTitle()).isEqualTo(title);
assertThat(posts.getContent()).isEqualTo(content);
}
}
@After : Junit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정. 보통은 배포 전 전체 테스트를 수행할 때 테스트간 데이터 침범 막기 위해 사용. 여러 테스트가 동시 수행시 테스트용 데이터베이스인 H2에 데이터가 그대로 남아 있어 다음 테스트 실행 시 테스트 실패 가능
postsRepository.save : 테이블 posts에 insert/update쿼리를 실행. id값이 있다면 update가 없다면 insert 쿼리가 실행
postsRepostiroy.findAll : 테이블 posts에 있는 모든 데이터를 조회하는 메소드
실제 실행 쿼리 로그 보기
application.properties 혹은 application.yml 등의 파일에 한줄의 코드로 설정 가능!
src/main/resources 아래에 application.properties파일 생성 후 spring.jpa.show_sql=true 작성
출력되는 쿼리 로그를 MySQL 버전으로 변경
application.properties에 아래 코드 추가
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
API를 만들기 위해 총 3개의 클래스가 필요힘
Web Layer
컨트롤러와 JSP/Freemarker 등의 뷰 템플릿 영역. 이외에도 필터, 인터셉터, 컨트롤러 어드바이스 등 외부 요청과 응답에 대한 전반적인 영역
Service Layer
@Service에 사용되는 서비스 영역으로 일반적으로 Controller와 Dao의 중간 영역에서 사용. @Transactional이 사용되어야 하는 영역임
Repostiroy Layer
Database와 같이 데이터 저장소에 접근하는 영역(Dao영역)
Dtos
Dto = 계층 간에 데이터 교환을 위한 객체
Dtos = Dto들의 영역
Domain Model
도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화 시킨 것
@RestController
@RequiredArgsConstructor
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);
}
}
@Service
@RequiredArgsConstructor
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 posts = postsRepository.findById(id).orElseThrow(()->new IllegalArgumentException("해당 게시글이 없습니다. id = " + id));
PostsResponseDto responseDto = new PostsResponseDto(posts);
return responseDto;
}
}
✏️ update기능에서 데이터베이스에 쿼리를 날리는 부분 없음
JPA의 영속성 컨텍스트 때문!
✅ 영속성 컨텍스트
엔티티를 영구 저장하는 환경
JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다.
JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.
이 상태에서 해당 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다.
즉 Entity 객체의 값만 변경하면 별도로 update쿼리를 날릴 필요가 없다는 것이다.
이 개념을 더티 체킹 이라고 한다.
@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.author = author;
this.content = content;
}
public Posts toEntity(){
return Posts.builder()
.author(this.author)
.title(this.title)
.content(this.content)
.build();
}
}
dto클래스는 Entity클래스와 거의 유사한 형태
✅ 하지만, 절대로 Entity클래스를 Request/Response 클래스로 사용해서는 안됨
Entity클래스는 데이터베이스와 맞닿은 핵심 클래스이기 때문
Entity 클래스를 기준으로 테이블이 생성되고, 스키마가 변경됨.
Entity 클래스가 변경되면 여러 클래스에 영향을 끼치지만,
Request와 Respose용 Dto는 View를 위한 클래스라 정말 자주 변경이 필요하다.
✅ View Layer와 DB Layer의 역할 분리를 철저하게 하는 것이 좋음.
실제로 Controller에서 결과값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하므로 Entity클래스만으로 표현하기가 어려운 경우가 많다.
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest extends TestCase {
@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{
String title = "title";
String content = "content";
PostsSaveRequestDto postsSaveRequestDto = new PostsSaveRequestDto().builder()
.title(title)
.content(content)
.author("author")
.build();
String url = "http://localhost:" + port + "/api/v1/posts";
ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url,postsSaveRequestDto,Long.class);
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{
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);
ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT,requestEntity,Long.class);
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);
}
HelloController와 달리 @WebMvcTest를 사용하지 X.
➡️ @webMvcTest의 경우 JPA 기능이 작동하지 않기 때문
Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화되니
지금 같이 JPA 기능 까지 한번에 테스트 할 때는 @SpringBootTest와 TestRestTemplate을 사용하면 됨
@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();
}
}
@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
private String title;
private String content;
@Builder
public PostsUpdateRequestDto(String title,String content){
this.title = title;
this.content = content;
}
}
보통 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함한다.
언제 만들어졌는지, 언제 수정되었는지 등은 차후 유지보수에 있어 굉장히 중요한 정보이기 때문이다.
그렇다 보니 매번 DB에 삽입하기전 갱신하기전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 된다.
이런 단순하고 반복적인 코드가 모든 테이블과 서비스 메소드에 포함되어야 한다면 귀찮고 코드가 지저분해질 수 있다. 이런 문제를 해결하고자 JAP Auditing을 사용한다.
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {
@CreatedDate
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
@MappedSuperclass : JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들도 칼럼으로 인식하도록 함
@EntityListeners(AuditingEntityListener.class) : BaseTimeEntity클래스에 Auditing기능을 포함시킴
@CreatedDate : Entity가 생성되어 저장될때 시간이 자동 저장
@LastModifiedDate : 조회한 Entity값을 변경할 떄 시간이 자동 저장
이후에 Posts클래스가 BaseTimeEntity를 상속받도록 변경
마지막으로 JPA Auditing 어노테이션들을 모두 활성화 할 수 있도록 Application클래스에 활성화 어노테이션 @EnableJpaAuditing을 추가한다.
@Test
public void BaseTimeEntity_등록(){
LocalDateTime now = LocalDateTime.of(2019,6,4,0,0,0);
postsRepository.save(Posts.builder()
.title("title")
.content("content")
.author("author")
.build());
List<Posts> postsList = postsRepository.findAll();
Posts posts = postsList.get(0);
System.out.println(">>>>>>>>>>>>>>> createDate = " + posts.getCreatedDate() + ", modifiedDate = " + posts.getModifiedDate());
assertThat(posts.getCreatedDate()).isAfter(now);
assertThat(posts.getModifiedDate()).isAfter(now);
}