Chapter 03. 스프링 부트에서 JPA로 데이터베이스 다뤄보자 (1) - DoDaek/freelec-springboot2-webservice GitHub Wiki
- 프로젝트에 Spring Data Jpa 적용하기
build.gradle
에org.springframework.boot:spring-boot-starter-data-jpa
등록하기- 스프링 부트용 Spring Data Jpa 추상화 라이브러리입니다.
- 스프링 부트 버전에 맞춰 자동으로 JPA관련 라이브러리들의 버전을 관리해줍니다.
build.gradle
에com.h2database:h2
등록하기- 인메모리 관계형 데이터베이스입니다.
- 별도으 설치가 필요 없이 프로젝트 의존성만으로 관리할 수 있습니다.
- 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화된다는 점을 이용하여 데스트 용도로 많이 사용됩니다.
- JPA의 테스트, 로컬 환경에서의 구동에서 사용할 예정입니다.
domain
패키지- 도메인을 담을 패키지입니다.
- 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제 영역이라고 생각하면 됩니다.
용어에 대한 어색함을 해결할 수 있을 것인가...
- 어노테이션 순서
- 정해진 어노테이션 작성 순서는 없습니다.( -> 찾아봐야함. )
- 책에서는 주요 어노테이션을 클래스에 가깝게 둡니다.
- ex.
@Entity
,@NoArgsConstructor
,@Getter
@Entity
는 JPA의 어노테이션이며,@NoArgsConstructor
,@Getter
는 롬복의 어노테이션입니다.- 롬복은 코드를 단순화시켜 주지만 필수 어노테이션은 아닙니다.
- 주요 어노테이션인
@Entity
는 클래스에 가깝게 두고, 롬복 어노테이션은 그 위로 둡니다.
- 코틀린 등의 새 언어 전환으로 롬복이 더 이상 필요없을 경우 쉽게 삭제할 수 있습니다.
- Entity 클래스
- 실제 DB의 테이블과 매칭될 클래스입니다.
- JPA를 사용하시면 DB 데이터에 작업할 경우 실제 쿼리를 날리기보다는, 이 Entity 클래스의 수정을 통해 작업합니다.
- JPA에서 제공하는 어노테이션들이 있습니다.
- Spring Annotation 정리에서 확인해주세요.
- Entity의 PK
- 웬만하면 Entity의
PK
는Long
타입의Auto_increment
를 추천합니다.- MySQL 기준으로
bigint
타입
- MySQL 기준으로
- 주민등록번호와 같이 비즈니스상 유니크 키나, 여러 키를 복합키로
PK
를 잡을 경우 난감한 상황이 종종 발생합니다.FK
를 맺을 때 다른 테이블에서 복합키 전부를 갖고 있거나, 중간 테이블을 하나 더 둬야 하는 상황이 발생합니다.- 인덱스에 좋은 영향을 끼치지 못합니다.
- 유니크한 조건이 변경될 경우
PK
전체를 수정해야 하는 일이 발생합니다.
- 주민등록번호, 복합키 등은 유니크 키로 별도로 추가하시는 것을 추천드립니다.
- 웬만하면 Entity의
- 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(); }
Setter
가 없는 상황에서 어떻게 값을 채워 DB에 삽입해야 할까요?- 기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것이며, 값 변경이 필요한 경우 해당 이벤트에 맞는
public
메소드를 호출하여 변경하는 것을 전제로 합니다. - 생성자 대신에 @Builder를 통해 제공되는 빌더 클래스를 사용합니다.
- 생성자나 빌더나 생성 시점에 값을 채워주는 역할은 똑같습니다. 다만, 생성자의 경우 지금 채워야 할 필드가 무엇인지 명확히 지정할 수가 없습니다.
- 다음과 같은 생성자가 있다면 a와 b의 위치를 변경해도 코드를 실행하기 전까지는 문제를 찾을 수가 없습니다.
public Example(String a, String b) { this.a = a; this.b = b; }
- 하지만 빌더를 사용하게 되면 다음과 같이 어느 필드에 어떤 값을 채워야 할지 명확하게 인지할 수 있습니다.
Example.builder() .a(a) .b(b) .build();
- 기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것이며, 값 변경이 필요한 경우 해당 이벤트에 맞는
- Database로 접근하게 해줄 JpaRepository
- 보통 ibatis나 MyBatis 등에서 Dao라고 불리는 DB Layer 접근자입니다.
- JPA에선 Repository라고 부르며 인터페이스로 생성합니다.
- 단순히 인터페이스를 생성 후,
JpaRepository<Entity 클래스, PK 타입>
를 상속하면 기본적인 CRUD 메소드가 자동으로 생성됩니다. @Repository
를 추가할 필요도 없습니다.- 주의하실 점은
Entity
클래스와 기본Entity
Repository는 함께 위치해야 하는 점입니다. Entity
클래스는 기본 Repository 없이는 제대로 역할을 할 수가 없습니다.- 나중에 프로젝트 규모가 커져 도메인별로 프로젝트를 분리해야 한다면 이때 Entity 클래스와 기본 Repository는 함께 움직여야 하므로 도메인 패키지에서 함께 관리합니다.
- H2 데이터베이스
- 별다른 설정 없이
@SpringBootTest
를 사용할 경우 H2 데이터베이스를 자동으로 실행해 줍니다. - 테스트 역시 실행할 경우 H2가 자동으로 실행됩니다.
- 여기서 한 가지 궁금한 것이 있습니다. "실제로 실행된 쿼리는 어떤 형태일까?"라는 것입니다.
- 실행된 쿼리를 로그로 보는 방법
- 쿼리 로그를 ON/OFF 할 수 있는 설정이 있습니다.
- 설정들은 Java 클래스로 구현할 수 있으나 스프링 부트에서는
application.properties
,application.yml
등의 파일로 한 줄의 코드로 설정할 수 있도록 지원하고 권장하니 이를 사용하겠습니다.
spring.jpa.show_sql=true
create table
쿼리를 보면id bigint generated by default as identity
라는 옵션으로 생성됩니다.- H2의 쿼리 문법이 적용되었기 때문입니다.
- H2는 MySQL의 쿼리를 수행해도 정상적으로 작동하기 때문에 이후 디버깅을 위해서 출력되는 쿼리 로그를 MySQL 버전으로 변경해 보겠습니다.
application.properties
에서 설정이 가능합니다.
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
- 별다른 설정 없이
-
API 만들기
- API를 만들기 위해 총 3개의 클래스가 필요합니다.
- Request 데이터를 받을 Dto
- API 요청을 받을 Controller
- 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service
- 많은 사람들이 오해하고 있는 것이, Service에서 비즈니스 로직을 처리해야 한다는 것입니다.
- Service는 트랜잭션, 도메인 간 순서 보장의 역할만 합니다. -> "그럼 비지니스 로직은 누가 처리하냐?"
- Spring 웹 계층
- Web Layer
- 흔히 사용하는 컨트롤러(
@Controller
)와 JSP/Freemarker 등의 뷰 템플릿 영역입니다. - 이외에도 필터(
@Filter
), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice
) 등 외부 요청과 응답에 대한 전반적인 영역을 이야기합니다.
- 흔히 사용하는 컨트롤러(
- Service Layer
@Service
에 사용되는 서비스 영역입니다.- 일반적으로 Controller와 Dao의 중간 영역에서 사용됩니다.
@Transactional
이 사용되어야 하는 영역이기도 합니다.
- Repository Layer
- Database와 같이 데이터 저장소에 접근하는 영역입니다.
- 기존에 개발하셨던 분들이라면 Dao( Data Access Object ) 영역으로 이해하시면 쉬울 것입니다.
- Dtos
- Dto( Data Transfer Object )는 계층 간에 데이터 교환을 위한 객체를 이야기하며 Dtos는 이들의 영역을 얘기합니다.
- 예를 들어 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기합니다.
- Domain Model
- 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것을 도메인 모델이라고 합니다.
- 이를테면 택시 앱이라고 하면 배차, 탑승, 요금 등이 모두 도메인이 될 수 있습니다.
- @Entity를 사용해보신 분들은 @Entity가 사용된 영역 역시 도메인 모델이라고 이해해주시면 됩니다.
- 다만, 무조건 데이터베이스의 테이블과 관계가 있어야만 하는 것은 아닙니다.
- VO처럼 값 객체들도 이 영역에 해당하기 때문입니다.
- API를 만들기 위해 총 3개의 클래스가 필요합니다.
- 비지니스 로직 처리
- 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) OrdersDto order = ordersDao.selectOrders(orderId); BillingDto billing = billingDao.selectBilling(orderId); DeliveryDto 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); billing.setStatus("CANCEL"); billingDao.update(billing); return order; }
- 모든 로직이 서비스 클래스 내붓에서 처리됩니다.
- 그러다 보니 서비스 계층이 무의미하며, 객체란 단순히 데이터 덩어리 역할만 하게 됩니다.
- 반면 도메인 모델에서 처리할 경우 다음과 같은 코드가 될 수 있습니다.
@Transactional public Order cancelOrder(int orderId) { // 1) OrdersDto order = ordersRepository.findById(orderId); BillingDto billing = billingRepository.findByOrderId(orderId); DeliveryDto delivery = deliveryRepository.findByOrderId(orderId); // 2 - 3) delivery.cancel(); // 4) order.cancel(); billing.cancel(); return order; }
order
,billing
,delivery
가 각자 본인의 취소 이벤트 처리를 하며, 서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해 줍니다.
- Web, Service, Repository, Dto, Domain, 이 5가지 레이어에서 비지니스 처리를 담당해야 할 곳은 어디일까요?
- 스프링에서 Bean을 주입받는 방식들
- Controller와 Service에서
@Autowired
가 없는 것이 어색하게 느껴집니다. - Bean을 주입받는 방식들
@Autowired
- setter
- 생성자
- 이 중 가장 권장하는 방식이 생성자로 주입받는 방식입니다.
@Autowired
는 권장하지 않습니다.
- 생성자로 Bean 객체를 받도록 하면
@Autowired
와 동일한 효과를 볼 수 있다는 것입니다. - 그러면 생성자가 없는 클래스에서는 생성자가 어디있을까요?
- 바로
@RequiredArgsConstructor
에서 해결해 줍니다. final
이 선언된 모든 필드를 인자값으로 하는 생성자를 롬복의@RequiredArgsConstructor
가 대신 생성해 준 것입니다.- 생성자를 직접 안 쓰고 롬복 어노테이션을 사용한 이유
- 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위함입니다.
- Controller와 Service에서
- Dto 클래스
- Entity 클래스와 거의 유사한 형태임에도 Dto 클래스를 추가로 생성했습니다.
- 절대로 Entity 클래스를 Request/Response 클래스로 사용해서는 안 됩니다.
- Entity 클래스는 데이터베이스와 맞닿는 핵심 클래스입니다.
- Entity 클래스를 기준으로 테이블이 생성되고, 스키마가 변경됩니다.
- 화면 변경은 아주 사소한 기능 변경인데, 이를 위해 테이블과 연결된 Entity 클래스를 변경하는 것은 너무 큰 변경입니다.
- 수많은 서비스 클래스나 비즈니스 로직들이 Entity 클래스를 기준으로 동작합니다.
- Entity 클래스가 변경되면 여러 클래스에 영향을 끼치지만, Request와 Response용 Dto는 View를 위한 클래스라 정말 자주 변경이 필요합니다.
- View Layer와 DB Layer의 역할 분리를 철저하게 하는 게 좋습니다.
- 실제로 Controller에서 결과값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하므로 Entity 클래스만으로 표현하기가 어려운 경우가 많습니다.
- 꼭 Entity 클래스와 Controller에서 쓸 Dto는 분리해서 사용해야 합니다.
- Api Controller 테스트
- Api Controller를 테스트하는데 HelloController와 달리
@WebMvcTest
를 사용하지 않았습니다. @WebMvcTest
의 경우 JPA 기능이 작동하지 않기 때문인데, Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화되니 지금 같이 JPA 기능까지 한번에 테스트할 때는@SpringBootTest
와TestRestTemplate
을 사용하면 됩니다.
- Api Controller를 테스트하는데 HelloController와 달리
- Posts 조회 기능
- 조회 기능은 실제로 톰캣을 실행해서 확인해 보겠습니다.
- 로컬 환경에선 데이터베이슬 H2를 사용합니다.
- 메모리에서 실행하기 때문에 직접 접근하려면 웹 콘솔을 사용해야만 합니다.
- 웹 콘솔 옵션을 활성화합니다.
- application.properties에 다음과 같이 옵션을 추가합니다.
spring.h2.console.enabled=true
- 추가한 뒤 Application 클래스의 main 메소드를 실행하니다. -> 정상적으로 실행됐다면 톰캣이 8080 포트로 실행됐습니다.
- 웹 브라우저에
http://localhost:8080/h2-console
로 접속하면 다음과 같이 웹 콘솔이 등장합니다. - 이때 JDBC URL이 화면과 같이
jdbc:h2:mem:testdb
로 되어 있지 않다면 똑같이 작성해주셔야 합니다. - connect 버튼을 누르면 H2를 관리할 수 있는 관리 페이지로 이동합니다.
- posts 테이블에 데이터를 등록합니다.
insert into posts (title, content, author) values ('title', 'content', 'author');
- 브라우저에
http://localhost:8080/api/v1/posts/1
을 입력해 API 조회 기능을 테스트해 봅니다.