2. 스프링 부트에서 JPA로 데이터베이스 다루기
웹 서비스 개발 시엔 자연스레 데이터베이스를 다뤄야 하는 상황을 겪게 된다.
에전에는 iBatis(현재 MyBatis)와 같은 SQL 매퍼를 이용해서 데이터베이스의 쿼리를 작성했다.
그러다 보니 실제로 개발하는 시간보다 SQL을 다루는 시간이 훨씬 더 많았다.
현재 우리는 JPA라는 자바 표준 ORM(Object Relational Mapping) 기술을 통해
관계형 데이터베이스를 이용하는 프로젝트에서 객체지향 프로그래밍을 할 수 있게 되었다.
MyBatis, iBatis는 ORM이 아닌 SQL Mapper이다.
ORM은 객체를 매핑하는 것이고, SQL Mapper은 쿼리를 매핑하는 것이다.
JPA란?
현대의 웹 애플리케이션에서 관계형 데이터베이스 (RDB: Relational Database)는 빠질 수 없는 요소이다.
그러다 보니 객체를 관계형 데이터베이스에서 관리하는 것이 무엇보다 중요하다.
데이터베이스는 SQL만 인식할 수 있기 때문에 현업 프로젝트 대부분이 애플리케이션 코드보다는 SQL로 가득하게 된다.
따라서 각 테이블마다 CRUD(Create, Read, Update, Delete) SQL을 매번 생성해야 한다.
예를 들어 User 객체를 테이블로 관리한다면 다음 코드를 피할 수 없다.
insert into user(id, name, ...) values (...);
select * from uer where ...;
update user set ... where ...;
delete from user where ...;
실제 현업에서는 몇 배의 SQL을 만들고 유지보수해야 하기 때문에 단순 반복 작업의 문제를 겪을 수 있다.
또 하나의 문제로는 패러다임 불일치 문제가 있다.
관계형 데이터베이스는 데이터를 어떻게 저장할지에 초점이 맞춰진 기술이지만,
객체지향 프로그래밍 언어는 메시지를 기반으로 기능과 속성을 한 곳에서 관리하는 기술이다.
객체지향의 추상화, 캡슐화, 정보은닉, 다형성 등 여러 정의를 빗대어 생각해보면 관계형 데이터베이스로 객체지향을 표현하기란 쉽지 않음을 알 수 있다. 결국 이 둘은 사상부터 다른 시작점에서 출발했다는 것이다.
이렇듯 관계형 데이터베이스와 객체지향 프로그래밍 언어의 패러다임이 서로 다른데,
객체를 데이터베이스에 저장하려고 하니 여러 문제가 발생하게 되는 것이다. 이를 패러다임 불일치라고 한다.
JPA는 이렇게 서로 지향하는 바가 다른 2개 영역을 중간에서 패러다임 일치를 시켜주기 위한 기술을 말한다.
즉 개발자는 객체지향적 프로그래밍을 하고, JPA가 이를 관계형 데이터베이스에 맞게 SQL을 대신 생성해서 실행한다.
개발자는 항상 객체지향적으로 코드를 표현할 수 있기 때문에 SQL에 종속적인 개발을 하지 않아도 된다.
Spring Data JPA
JPA는 인터페이스로서 자바 표준 명세서이다.
인터페이스인 JPA를 사용하기 위해서는 구현체가 필요한데 대표적으로 Hiberndate, EclipseLink 등이 있다.
하지만 Spring에서는 구현체들을 좀 더 쉽게 사용하고자 Spring Data JPA라는 모듈을 사용하여 JPA 기술을 다룬다.
이들의 관계는 다음과 같다.
JPA ← Hibernate ← Spring Data JPA
Spring Data JPA가 등장한 이유는 크게 두 가지가 있다.
- 구현체 교체의 용이성: Hibernate 외에 다른 구현체로 쉽게 교체하기 위함
- 저장소 교체의 용이성: 관계형 데이터베이스 외에 다른 저장소로 쉽게 교체하기 위함
Spring Data JPA 적용
먼저 build.gradle에 의존성을 주입한다.
...
dependencies {
implementation('org.springframework.boot:spring-boot-starter-web')
implementation('org.projectlombok:lombok')
// 추가된 의존성
implementation('org.springframework.boot:spring-boot-starter-data-jpa')
implementation('com.h2database:h2')
testImplementation('org.springframework.boot:spring-boot-starter-test')
}
...
- spring-boot-starter-data-jpa
- 스프링 부트용 Spring Data JPA 추상화 라이브러리
- 스프링 부트 버전에 맞춰 자동으로 JPA 관련 라이브러리들의 버전을 관리
- h2
- 인메모리 관계형 데이터베이스
- 별도의 설치 없이 프로젝트 의존성만으로 관리 가능
- 메모리에서 실행되어 앱 시작시마다 초기화된다는 점을 이용하여 테스트 용도로 많이 사용
도메인 패키지 생성
- Posts.class
- 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제 영역
package com.jeonni.webservice.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는 JPA의 어노테이션이며, @Getter와 @NoArgsConstructor는 롬복의 어노테이션이다.
※ 롬복은 코드를 단순화시켜주지만 필수 어노테이션은 아니다.
이렇게 하면 이후 코틀린 등이 새 언어의 전환으로 롬복이 더이상 필요 없을 경우 쉽게 삭제할 수 있다.
여기서 Posts 클래스는 실제 DB 테이블과 매칭될 클래스이며 보통 Entity 클래스라고 부른다.
JPA를 사용하면 DB 데이터에 작업할 경우 실제 쿼리를 날리기 보다는 Entity 클래스의 수정을 통해 작업할 수 있다.
- @Entity
- 테이블과 링크될 클래스임을 나타냄
- 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍으로 테이블 이름을 매칭
- ex) SalseManager.java → sales_manager table
- @Id
- 해당 테이블의 PK 필드를 나타냄
- @GeneratedValue
- PK의 생성 규칙을 나타냄
- 스프링 부트 2.0에서는 GenerationType.IDENTITY 옵션을 추가해야만 auto_increment가 된다.
- @Column
- 테이블의 칼럼을 나타내며 굳이 선언하지 않더라도 해당 클래스의 필드는 모두 칼럼이 된다.
- 이 어노테이션을 사용하는 이유는 기본값 외에 추가로 변경이 필요한 옵션이 있을 경우에 사용
- ex) title의 사이즈를 500으로, content의 타입을 TEXT로 변경
- @NoArgsConstrucor
- 기본 생성자 자동 추가
- public Posts(){}와 같은 효과
- @Getter
- 클래스 내 모든 필드의 Getter 메소드를 자동 생성
- @Builder
- 해당 클래스의 빌더 패턴 클래스를 생성
- 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함됨
getter/setter을 무작정 사용하면 해당 클래스의 인스턴스 값들이 언제 어디서 변해야 하는지 코드 상으로 명확히 구분할 수가 없어서 차후 기능 변경 시 정말 복잡해진다. 그래서 Entity 클래스에서는 절대 Setter 메소드를 만들지 않는다.
Setter가 없는 상황에서 어떻게 값을 채워 DB에 삽입하나?
기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것이며
값 변경이 필요할 경우 해당 이벤트에 맞는 public 메소드를 호출하여 변경하는 것을 전제로 한다.
여기서는 생성자 대신 @Builder 를 통해 제공되는 빌더 클래스를 사용한다.
Posts 클래스로 Dababase를 접근하게 해줄 JpaRepository 생성
- PostsRepository.interface
package com.jeonni.webservice.domain.posts;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostsRepository extends JpaRepository<Posts, Long> {
}
- JpaRepository<Entity클래스, PK 타입>
- 반드시 Entity 클래스와 기본 Entity Repository는 함께 위치해야 함
- Entity 클래스는 기본 Repository 없이는 제대로 역할을 할 수가 없기 때문
Spring Data JPA 데스트 코드 작성
- PostsRepositoryTest.class
package com.jeonni.webservice.domain.posts;
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("jeonniu@naver.com")
.build());
//when
List<Posts> postsList = postsRepository.findAll();
//then
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 쿼리가 실행됨
- @postsRepository.findAll
- 테이블 posts에 있는 모든 데이터를 조회해오는 메소드
실제로 실행된 쿼리는 어떤 형태일까?
먼저 src/main/resources 디렉토리 아래에 application.properties 파일을 생성하여 아래 옵션을 추가
spring.jpa.show_sql=true
여기에 create table 쿼리를 보면 id bigint generated bt default as identity라는 옵션으로 생성된다.
이는 H2 쿼리 문법이 적용되었기 때문이다.
H2는 MySQL 쿼리를 수행해도 정상적으로 작동하기 때문에 이후 디버깅을 위해 출력되는 쿼리 로그를 MySQL 버전으로 변경해보자.
src/main/resources/application.properties
spring.jpa.show_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
등록/수정/조회 API
API를 만들기 위해 총 3개의 클래스가 필요하다.
- Request 데이터를 받을 Dto
- API 요청을 받을 Controller
- 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service
- Web Layer
- 컨트롤러와 JSP/FreeMarker 등의 뷰 템플릿 영역
- 이외에도 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice) 등 외부 요청과 응답에 대한 전반적인 역할
- Sevice Layer
- @Service에 사용되는 서비스 영역
- 일반적으로 Controller와 Dao 사이의 중간 영역에 사용
- @Termination이 사용되어야 하는 영역
- Repository Layer
- 데이터베이스와 같이 테이터 저장소에 접근하는 영역
- Dao(Data Access Object) 영역으로 이해하면 됨
- Dtos
- Dto(Data Transfer Object)는 계층 간에 데이터 교환을 위한 객체를 말하며 Dtos는 이들의 영역을 말함
- 예를 들어 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 뜻함
- Domain Model
- 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화한 것을 도메인 모델이라고 함
- 예를 들어 택시 앱이라고 할 경우 배차, 탑승, 요금 등이 모두 도메인이 될 수 있음 (비즈니스 처리 담당)
- @Entity를 사용한 영역 역시 도메인 모델이라고 하면 됨
- 그러나 무조건 데이터베이스의 테이블과 관계가 있어야만 하는 것은 아니다.
- VO처럼 값 객체들도 이 영역에 해당하기 때문
도메인 모델에서 처리할 경우 각 매소드는 각자 본인의 이벤트 처리를 담당하게 되며,
서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해준다.
게시글 등록, 수정, 삭제 기능을 만들기 위해 각 패키지와 클래스를 생성해보자.
게시글 등록 기능
PostsApiContorller
package com.jeonni.webservice.web;
import com.jeonni.webservice.sevice.posts.PostsService;
import com.jeonni.webservice.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);
}
}
스프링에서 Bean을 주입받는 방식들은 다음과 같다.
- @Autowired
- setter
- 생성자
이 중 가장 권장하는 방식은 생성자로 주입받는 방식이다.
생성자로 Bean 객체를 받도록 하면 @Autowired와 동일한 효과를 볼 수 있다.
위에서 생성자는 바로 @RequiredArgsConstructor 로 해결해 준다.
final이 선언된 모든 필드를 인자값으로 하는 생성자를 롬복의 @RequiredArgsConstructor가 대신 생성해준 것이다.
PostsService
package com.jeonni.webservice.web;
import com.jeonni.webservice.sevice.posts.PostsService;
import com.jeonni.webservice.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);
}
}
PostsSaveRequestDto
package com.jeonni.webservice.web.dto;
import com.jeonni.webservice.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 클래스와 거의 유사한 형태이지만 Dto 클래스를 추가로 생성하였다.
유의할 점으로는 절대로 Entity 클래스를 Request/Response 클래스로 사용해선 안된다는 것이다.
Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스이다.
Entity 클래스를 기준으로 테이블이 생성되고, 스키마가 변경된다. 따라서 해당 클래스가 변경될 시엔 여러 클래스에 영향을 끼칠 수 있다.
반면 Request와 Response 용 Dto는 View를 위한 클래스라 자주 변경이 필요하다. 이처럼 View Layer와 DB Layer의 역할 분리를 철저하게 하는 것은 매우 중요하다. 실제로 Controller에서 결과값으로 여러 테이블을 조인해줘야 하는 경우가 빈번하므로 Entity 클래스만으로 표현하기 어려운 경우가 많다.
그래서 꼭 Entity 클래스와 Controller에서 사용할 Dto는 분리해서 사용해야 하는 것을 잊지 말자.
PostsApiControllerTest
package com.jeonni.webservice.web;
import com.jeonni.webservice.domain.posts.Posts;
import com.jeonni.webservice.domain.posts.PostsRepository;
import com.jeonni.webservice.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);
}
}
Api Controller을 테스트 하는데 HelloController와 달리 @WebMvcTest를 사용하지 않은 이유는 JPA 기능이 작동하지 않기 때문이다. Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화되니 현재 상황처럼 JPA 기능까지 한번에 테스트할 때는 @SpringBootTest와 TestReestTemplate을 사용하면 된다.
WebEnvironment.RANDOM_PORT로 인한 랜덤 포트 실행과 insert 쿼리가 실행된 것을 확인했다.
게시글 수정/조회 기능
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 PostsResponseDto findById(@PathVariable Long id){
return postsService.findById(id);
}
...
PostsResponseDto
package com.jeonni.webservice.web.dto;
import com.jeonni.webservice.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();
}
}
Posts
...
public void update(String title, String content){
this.title = title;
this.content = content;
}
}
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 PostsResponseDto findById(Long id){
Posts entity = postsRepository.findById(id).orElseThrow(()->new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
return new PostsResponseDto((entity));
}
}
update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없는데, 이는 JPA의 영속성 컨텍스트 때문이다.
영속성 컨테스트란, 엔티티를 영구 저장하는 환경이다. JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다.
JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.
이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다.
즉 Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다는 것이다. 이 개념을 더티채킹이라 한다.
해당 코드가 정상적으로 Update 쿼리를 수행하는지 테스트 코드로 확인해보자.
PostsApiControllerTest
...
@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);
}
}
테스트 쿼리 결과를 보면 Update 쿼리가 정상적으로 수행된 것을 볼 수 있다.
이제 실제 톰캣을 실행하여 조회 기능을 확인해보기 위하여 application-properties에 다음 옵션을 추가해보자.
spring.h2.console.enabled=true
등록된 데이터가 없으므로 INSERT 쿼리를 실행한 뒤 API로 조회해보자.
insert into posts(author, content, title) values ('author', 'content', 'title');
스프링 부트와 AWS로 혼자 구현하는 웹 서비스 | 이동욱
'Study' 카테고리의 다른 글
[Spring MVC] 서비스 계층 (0) | 2023.06.24 |
---|---|
[STUDY] 스프링 핵심 원리 - 기본편 (1) (0) | 2023.05.27 |
[STUDY] 스프링부트와 AWS로 혼자 구현하는 웹 서비스 (1) (0) | 2023.04.05 |
[STUDY] 스프링 입문 기록(5) - 코드로 배우는 스프링부트, 웹 MVC, DB 접근 기술 (0) | 2023.03.11 |
[STUDY] 스프링 입문 기록(4) - 코드로 배우는 스프링부트, 웹 MVC, DB 접근 기술 (0) | 2023.03.10 |