일단 씻고 나가자
[스프링 부트 핵심 가이드] 06. 데이터베이스 연동 본문
(본 포스팅은 해당 도서의 정리 및 개인 공부의 목적 포스팅임을 밝힙니다.)
장정우, 『스프링 부트 핵심 가이드 : 스프링 부트를 활용한 애플리케이션 개발 실무』, 위키북스, 2022
06. 데이터베이스 연동
애플리케이션, 특히 엔터프라이즈급에서 꼭 필요한 것이 데이터베이스이다.
데이터베이스는 데이터(리소스)를 주고받으며 논리적인 로직이 정상 수행하도록 돕는다.
본 책에서는 실습을 위해 마리아DB(Maria DB)를 적용한다.
6.1 마리아DB 설치
마리아 DB 설치를 위해 상단의 다운로드 페이지를 접속한다.
책에서 명시하는 버전은 10.6.5 버전이므로, 원활한 실습 진행을 위해 하단의 체크 박스를 체크하고 버전을 찾아 설정한다.
하단의 [Download] 버튼을 누르고, 다운이 완료되면 파일을 실행한다.
별도의 설정 없이 [agree] 및 [next]로 설치를 진행하면 되며,
비밀번호 설정 부에서 자신의 비밀번호를 설정해주고 하단의 [Use UTF-8 as default server's character set]을 체크해준다.
추가로 실무에선 보안상의 이유로 root 계정을 사용하진 않지만, 실습 시에는 편의를 위해 사용한다.
이후 별도의 설정 없이 [Next]를 반복하며 설정을 끝마치고 [Install] 하면 된다.
설치를 마치면 데이터베이스 접속 GUI 서드파티 도구인 HeidiSQL이 함께 설치된다.
편의성을 위해 좌측 하단 [신규] 버튼을 누르고 다음과 같이 설정한다.
세션 이름을 springboot로, 암호를 설치 시 사용했던 암호로 작성한다.
일반적으로 설치했다면 포트는 3306이 맞으며, 필자는 이전 DB 설치로 인해 포트를 설치 시부터 3307로 설정했다.
이후 하단의 [열기] 버튼을 눌러보자.
다음과 같은 화면을 볼 수 있다.
[쿼리] 부분을 눌러 앞으로 실습을 진행할 쿼리문을 작성할 것이며,
쿼리문의 작성 이후 [새로고침] 버튼을 누르면 쿼리문이 반영된다.
6.2 ORM
ORM은 Object Relational Mapping의 줄임말로 관계 매핑을 의미한다.
객체 지향 언어에서 사용하는 객체와 RDB(Relation DataBase) 테이블을 매핑하는 방법으로,
객체의 변수와 동일한 이름의 데이터베이스 애트리뷰트의 각 값을 매핑하여 쿼리문 작성이 아닌 메서드의 이용으로 데이터를 조작할 수 있다.
ORM의 장점
- 객체지향적으로 DB 조작 가능. (개발 비용 하향 및 코드의 가독성 상향)
- 재사용 및 유지보수 편리.
- 데이터베이스의 종속성 하향. (데이터베이스 교체 시에 이점)
ORM의 단점
- ORM만을 이용할 시 한계 발생. (복잡한 쿼리에 대한 한계 및 정확하지 않은 쿼리로 속도 및 성능 저하)
- 애플리케이션의 객체 관점과 데이터베이스의 관계 관점 불일치 발생.
1. 세분성 (Granuality) : 클래스의 개수가 테이블의 개수보다 많아질 수 있음.
2. 상속성 (Inheritance) : RDBMS에는 상속 개념 없음.
3. 식별성 (Identity) : 식별과 동일성의 문제. PK의 값이 같아도 객체의 비교로 다른 결과가 나올 수 있음.
4. 연관성 (Assosiations) : 외래키(foreign)와 객체 참조의 차이 및 방향성 차이. (외래키는 방향성이 없음)
5. 탐색 (Navigation) : 조인(JOIN)과 객체 참조의 연결 수단 차이.
6.3 JPA
JPA은 Java Persistence API의 줄임말로 자바 진영의 ORM 표준 기술으로 채택된 인터페이스의 모음이며, 동작 메커니즘을 정리한 표준 명세이다. 내부적으로 JDBC를 사용하며, 자동으로 객체와 테이블을 매핑하고 간단하게 관련 클래스의 extends만으로 데이터베이스 관련 함수를 사용할 수 있다.
JPA 구현체는 대표적으로 하이버네이트(Hibernate), 이클립스 링크(EclipseLink), 데이터 뉴클리어스(DataNucleus) 세 가지가 있으며 가장 많이 사용하는 구현체는 하이버네이트이다.
6.4 하이버네이트
하이버네이트는 자바 ORM의 프레임워크이다. Sping Data JPA는 하이버네이트의 기능을 더욱 편하게 사용하도록 한 모듈이며 본 책에서는 이를 활용하기 때문에 JPA를 직접 사용할 일은 없다. 단 기능 관련 기초는 숙지해야 한다.
6.4.1 Spring Data JPA
Spring Data JPA는 JPA를 편하게 사용할 수 있도록 지원하는 스프링의 하위 프로젝트이다.
Spring Data JPA는 CRUD 관련 인터페이스를 제공하며, 하이버네이트의 엔티티 매니저(EntityManager)를 직접 다루지 않고 리포지토리를 정의하여 사용함으로써 주요 기능을 쉽게 사용할 수 있다. 사용자는 JpaRepository<Class, PK type> 인터페이스를 extends하고 별도의 코드 작성 없이 주요 함수를 활용하기만 하면 된다.
6.5 영속성 컨텍스트
영속성 컨텍스트(Persistence Context)는 데이터베이스의 데이터와 애플리케이션의 객체 사이에서, 관점의 차이로의 괴리를 해소하고 객체를 보관하는 역할을 담당한다. 엔티티 객체가 영속성 컨텍스트에 들어오면 JPA는 엔티티 객체의 매핑 정보를 데이터베이스에 반영한다.
영속성 컨텍스트는 세션 단위의 생명주기를 갖는데, 이는 데이터베이스 접근을 위한 세션 생성 시에 영속성 컨텍스트가 생성되고 세션 종료 시 영속성 컨텍스트도 종료된다. 이렇게 어떤 엔티티 객체가 JPA의 관리 대상이 되는 시점부터 해당 객체를 영속 객체(Persistence Object)라 칭한다. 엔티티 매니저는 영속성 컨텍스트에 접근하기 위한 수단이다.
6.5.1 엔티티 매니저
엔티티 매니저는 데이터베이스에 접근하여 CRUD를 실행하고, 엔티티를 관리한다. 즉, 엔티티를 영속성 컨텍스트에 추가하여 영속 객체로 만들고 데이터베이스를 대상하여 작업을 수행한다.
엔티티 매니저는 엔티티 매니저 팩토리(EntityManagerFactory)가 만들고, 이는 데이터베이스에 대응하는 객체이다.
엔티티 매니저는 애플리케이션에서 단 하나만 생성되며, 모든 엔티티가 공유하여 사용한다.
스프링 부트에서는 application.properties에서 간단히 설정할 수 있으며, 실제 하이버네이트에서는 persistence.xml 파일을 생성하고 설정 내용을 구성하여 사용해야 한다. 설정 내용은 DB의 종류, 아이디와 패스워드, url 등이 있다.
6.5.2 엔티티의 생명주기
엔티티 객체는 영속성 컨텍스트에서 다음 4가지 상태로 구분된다.
- 비영속(New) : 영속성 컨텍스트에 추가되지 않은 엔티티 객체의 상태.
- 영속(Managed) : 영속성 컨텍스트에 의해 엔티티 객체가 관리되는 상태.
- 준영속(Detached) : 관리되던 객체가 컨텍스트와 분리된 상태.
- 삭제(Removed) : 데이터베이스에서 레코드를 삭제하기 위해 컨텍스트에 삭제 요청을 한 상태.
6.6 데이터베이스 연동
데이터베이스 사용을 위해 스프링 부트 애플리케이션과 연동하는 새로운 프로젝트를 만들고 실습해보자.
6.6.1 프로젝트 생성
start.spring.io 에 접속하여 다음과 같이 설정하고 [Generate], 압축을 해제하고 인텔리제이로 [Open] 하자.[Dependencies]를 유의 깊게 보고 실습에서 사용한 의존성을 모두 추가하자.[Project]를 Maven으로 설정하는 것도 유의해야 한다.
이전 5장에서 진행했던 파일 중 config 패키지의 SwaggerConfiguration 클래스와 resorces의 logback-spring.xml 파일을 복사하여 새로운 프로젝트의 같은 위치에 붙여넣는다. 이때, 우리는 새로운 이름의 프로젝트를 생성했으므로 swagger의 basePackage 경로를 수정해주어야 한다. swagger을 이용하기 위해 pom.xml에서 추가했던 dependency 두 가지 및 스프링 부트 버전과의 충돌을 막기 위한 스프링 부트 버전 설정도 바꿔주자.
이후 데이터베이스 연동 정보를 명시하기 위해, resorces/application.properties에 다음과 같은 코드를 추가한다.
( ★ 6, 8번째 줄에서 spring.jpa 가 spring.jap 로 오타가 났다. 꼭 spring.jpa로 적어주길 바란다. )
윗단에는 연결할 DB 종류 및 드라이버의 명시와, 사용할 계정 정보를 담고 있다. 이때 password에는 mariaDB 최초 설치 시에 설정했던 계정 비밀번호로 입력해야 한다.
아랫단에는 hibernate 관련 설정에 대한 명시를 적는다. ddl-auto 옵션의 종류는 다음과 같다.
- create : 애플리케이션 가동 후 SessionFactory 실행 시 기존 테이블을 삭제하고 새로 생성한다.
- create-drop : create와 동일하며, 애플리케이션 종료 시점에 테이블을 삭제한다.
- update : SessionFactory 실행 시 객체를 검사하여 변경된 스키마를 갱신, 기존 데이터를 유지한다.
- validate : update와 동일하며, 스키마를 갱신하지 않으므로 테이블 정보와 객체 정보가 다르면 에러 발생.
- none : ddl-auto 기능을 사용하지 않는다.
운영 환경에선 주로 validate, none을 활용하고 개발 환경에선 create, update를 활용한다.
이후 설정은 로그에 hibernate가 사용한 쿼리문을 포맷에 맞게 보여주는 기능에 대한 명시이다.
6.7 엔티티 설계
실제 엔티티 클래스를 만들어보는 실습을 진행해보자. Spring Data JPA는 테이블 생성을 위한 쿼리를 사용자가 입력할 필요가 없으며, 단지 데이터베이스 테이블에 대응하는 엔티티 클래스를 만들면 된다.
이런 방식으로 엔티티 클래스를 만들 수 있다.
@Entity 어노테이션을 활용하며, @Table으로 테이블을 직접 매핑할 수 있고 (없다면 자동으로 클래스명과 동일한 테이블을 매핑한다) @Id, @Column으로 각 값에 대한 PK와 이외의 애트리뷰트에 대한 설정이 가능하다.
strategy는 값의 부여 전략으로, DB에게 값의 생성을 자동 값으로 위임하는 것이다. 이는 PK처럼 값이 unique하지만 데이터로서의 기능은 없는 상황에 자주 사용된다.
6.7.1 엔티티 관련 기본 어노테이션
주로 사용되는 핵심적인 어노테이션에 관해 우선 알아보자.
@Entity | 해당 클래스가 엔티티임을 명시하기 위한 기능. 테이블과 일대일 매칭되며, 인스턴스는 테이블의 각 레코드. |
@Table | 특정 테이블과 매핑하기 위한 기능. 엔티티 클래스는 굳이 해당 어노테이션이 필요하진 않으며, 클래스명과 다른 테이블을 매핑할 때 활용된다. |
@Id | 해당 필드가 PK임을 명시하기 위한 기능. 기본값 역할이며, 모든 엔티티 클래스는 하나의 @Id가 필요하다. |
@GeneratedValue | 해당 필드의 값을 자동으로 부여하기 위한 기능. 일반적으로 @Id와 함께 활용된다. |
@Column | 해당 필드와 컬럼을 매핑하기 위한 기능. 없다면 자동으로 필드와 같은 이름의 컬럼과 매핑된다. name/nullable/length/unique 같은 옵션을 활용할 수 있다. |
@Transient | 엔티티 클래스에는 존재하나, 데이터베이스에선 필요하지 않은 필드임을 명시하기 위한 기능. |
추가로 @GeneratedValue의 전략은 다음과 같은 옵션들이 있다.
- 직접 할당(사용하지 않음) : 애플리케이션 내부의 규칙에 의해 기본값을 생성하고 부여.
- AUTO(default) : 데이터베이스에 맞게 기본값을 자동 생성.
- IDENTITY : 기본값을 데이터베이스에 위임. DB에서 AUTO_INCREMENT를 사용한다.
- SEQUENCE : @SequenceGenerator을 통해 식별자 생성기를 설정하고 주입받음.
- TABLE : 식별자로 사용할 숫자 보관 테이블을 별도로 생성하여 엔티티 생성 시마다 값을 갱신하여 사용.
6.8 리포지토리 인터페이스 설계
Spring Data JPA는 JpaRepository를 제공하며, 해당 인터페이스를 상속하는 인터페이스를 만드는 것만으로도 다양한 기능의 메서드가 사용 가능하다.
6.8.1 리포지토리 인터페이스 생성
엔티티를 데이터베이스 테이블과 구조를 생성하는 데 사용했다면, 리포지토리는 엔티티가 생성한 데이터베이스에 접근하는 데에 사용된다. 다음과 같이 JpaRepository 인터페이스를 extends하고, <> 안에 엔티티 대상과 해당 엔티티의 PK 자료형을 적으면 된다.
JpaRepository를 상속 받기만 해도 findAll, findById, SaveAll, deleteInBatch 등의 함수를 사용할 수 있게 된다.
6.8.2 리포지토리 메서드의 생성 규칙
JpaRepository에서 지원하는 메서드 외에 다른 조건의 데이터를 조회하고 싶다면 새로운 메서드를 커스텀하여 만들어야 한다. 이때 메서드의 내용을 구현할 필요 없이, 메서드의 이름을 규칙에 맞게 abstract method로 만드는 것만으로도 JPA는 해당 조건에 맞는 결과를 반환해준다. 메서드 이름 규칙은 자바의 기본 규칙과 같이 첫 글자는 소문자, 이후 단어마다 대문자로 적어 이어 붙이는 규칙을 따르며, 이외 조건에 대한 규칙은 다음 표와 같다. 자세한 규칙은 7장에서 알아본다.
FindBy | SQL의 where처럼, 메서드에 찾고자 하는 데이터의 이름을 붙이고 매개변수로 해당 값을 넣는다. | findByName(String name) |
AND, OR | 복수 조건에 대한 설정. | findByNameAndEmail(String name, String email) |
Like, NotLike | 특정 문자를 포함하는지에 대한 여부. | |
StartsWith, StartingWith |
특정 키워드(문자열)로 시작하는지에 대한 여부. | |
EndsWith, EndingWith |
특정 키워드(문자열)로 끝나는지에 대한 여부. | |
IsNull, IsNotNull |
레코드 값이 null인지 아닌지에 대한 여부. | |
True, False | boolean 타입의 레코드 검색. | |
LessThan, GreaterThan |
특정 숫자를 기준으로 대소 비교. | |
Between | 특정 두 숫자의 사이 데이터에 대한 조회. | |
OrderBy | SQL의 OrderBy처럼, 메서드에 정렬 기준에 대한 이름과 정렬 방법을 붙인다. FindBy와 함께 사용한다. | List<Product> findByNameOrderByPriceAsc(String name) |
countBy | 결과에 대한 개수 조회. |
6.9 DAO 설계
DAO는 Data Access Object의 준말로, 데이터베이스에 접근하기 위한 로직(CRUD)을 관리하기 위한 클래스(객체)를 의미한다. 이는 일반적으로 서비스 계층과 리포지토리의 중간 계층의 역할이며, 원활한 유지보수를 가능케 한다.
Spring Data JPA에서 앞서 살펴보았던 JpaRepository가 담당하는 역할이 DAO와 유사하다.
6.9.1 DAO 클래스 생성
DAO 클래스는 일반적으로 '인터페이스-구현체'의 구조로 생성한다. 이는 의존성 결합을 낮추기 위한 디자인 패턴이며, 서비스 레이어에 DAO 객체를 주입받을 때 인터페이스를 선언하는 방식으로 구성할 수 있다.
우선 data 패키지 내에 dao 패키지를 생성하고, ProductDAO 인터페이스를 생성한다. 또 dao 패키지 내에 impl 패키지를 만들어, ProductDAOImpl 클래스를 만든다.
이후 뼈대가 되어줄 ProductDAO 인터페이스를 구성한다. 구성 내용은 기본적인 CRUD에 기반한다.
데이터베이스에 접근하는 메서드에 대하여, 리턴값을 엔티티 객체로 전달할지 DTO 객체로 전달할지는 의견이 분분하다.
일반적으론 데이터베이스에 접근하는 계층에 대해서만 엔티티 객체를 사용하고 다른 계층으로의 전달에선 DTO를 사용하나, 이는 각 회사나 부서마다의 차이가 있다.
이후 해당 인터페이스를 상속 받는 ProductDAOImpl 클래스를 작성하자.
클래스명에 extends ProductDAO를 입력하고, 클래스 내부 칸에서 오른쪽 마우스 클릭 -> [Generate (Alt+Insert)] -> [Override Methods (Ctrl+O)] -> 아랫쪽에서 ProductDAO에서 입력한 메서드를 모두 선택 (ctrl 누른 상태로 메서드명을 마우스 왼쪽 클릭 하면 다중 선택 가능) -> [OK] 하면 해당 클래스들이 모두 자동으로 작성된다.
인터페이스를 상속했기 때문에 abstract 메서드들을 모두 구현해주어야 한다. 다음과 같이 작성하자.
package com.springboot.jpa.data.dao.impl;
import com.springboot.jpa.data.dao.ProductDAO;
import com.springboot.jpa.data.entity.Product;
import com.springboot.jpa.data.repository.ProductRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.Optional;
@Component // 스프링이 관리하는 Bean으로 등록
public class ProductDAOImpl implements ProductDAO {
private final ProductRepository productRepository;
@Autowired // 생성자와 @Autowired를 통해 repository를 DI
ProductDAOImpl(ProductRepository productRepository){
this.productRepository = productRepository;
}
@Override
public Product insertProduct(Product product) {
Product savedProduct = productRepository.save(product);
return savedProduct;
} // 두 줄을 합쳐 바로 return 할 수도 있지만, 사이에 log등의 작성을 생각하여 분리.
@Override
public Product selectProduct(Long number) {
Product selectedProduct = productRepository.getById(number);
return selectedProduct;
} // 두 줄을 합쳐 바로 return 할 수도 있지만, 사이에 log등의 작성을 생각하여 분리.
@Override
public Product updateProductName(Long number, String name) throws Exception {
Optional<Product> selectedProduct = productRepository.findById(number);
Product updatedProduct;
if(selectedProduct.isPresent()){
Product product = selectedProduct.get();
product.setName(name);
product.setUpdatedAt(LocalDateTime.now());
updatedProduct = productRepository.save(product);
}else{
throw new Exception();
}
return updatedProduct;
}
@Override
public void deleteProduct(Long number) throws Exception {
Optional<Product> selectedProduct = productRepository.findById(number);
if(selectedProduct.isPresent()){
Product product = selectedProduct.get();
productRepository.delete(product);
}else {
throw new Exception();
}
}
}
(+) Exception
selectProduct 메서드를 작성할 때, repository에 getById가 없으니 create 하라는 에러가 나온다.
이는 springboot의 버전 때문으로, swagger 설정을 위해 편의상 springboot의 버전을 낮춘 것 때문에 발생하는 에러이다.
pom.xml의 version을 2.4.0에서 2.5.2로 바꾸어주었더니 해결되었다.
select문에서 getById를 활용하였는데, 조회에서 가장 많이 사용하는 메서드가 getById, findById 두 메서드이다.
함수명 | 사용하는 함수 | 방식 |
getById | EntityManager -> getReference() | 우선 프록시 객체를 반환하고, 이후 프록시가 DB에 접근 시에 해당 데이터가 DB에 없다면 EntityNotFoundException을 발생시킴. |
findById | EntityManager -> find() | 먼저 영속성 컨텍스트 캐시를 확인하고, 없다면 실제 DB에서 해당 데이터를 찾음. |
조회 기능에선 어떤 메서드를 사용하더라도 무관하며, 다만 getById가 실제 DB에 접근하지 않으므로 성능상에서 조금 더 유리하다.
참고 사이트
https://bcp0109.tistory.com/325
JPA는 update 키워드를 사용하지 않고, find로 객체를 영속성 컨텍스트에 담은 뒤 값을 변경 후 save하여 update한다.
이때 save 시에 더티 체크(Dirty Check)를 수행하는데, 더티 체크란 최초 조회 상태의 스냅샷(snapshot)에서 변화가 있는 객체를 검사하여 DB에 반영하는 것을 의미한다. 이 방식은 delete도 유사한데, 영속성 컨텍스트로 delete할 객체를 불러와서 삭제 메서드를 진행하고 commit 과정에서 실제 삭제가 이루어진다.
6.10 DAO 연동을 위한 컨트롤러와 서비스 설계
DAO 메서드를 호출하여 서비스 계층에서 비즈니스 로직을 수행하는 실습 코드를 작성해보자.
6.10.1 서비스 클래스 만들기
서비스 레이어에선 도메인 모델(Domain Model)을 활용해 애플리케이션의 핵심 기능을 제공하게 되는데,
이러한 핵심 기능을 구현하기 위한 여러 세부 기능 또한 서비스 레이어에서 함께 구현되는 아키텍쳐의 한계에 따라
통상 세부 기능을 구현한 비즈니스 로직과, 세부 기능을 합쳐 핵심 기능을 제공하는 서비스 로직으로 분리하기도 한다.
다만 본 책에선 간단한 실습을 목적으로 하므로 서비스 레이어에서 비즈니스 로직을 구현하는 실습을 진행한다.
첫 번째로 service 패키지와 그 하위에 ProductService 인터페이스를 만들고, service 패키지 내에 impl 패키지를 만들어 impl 내부엔 ProductService를 구현한 ProductServiceImpl 클래스를 만든다.
이러한 과정은 느슨한 결합을 위해 구현되며, 인터페이스로 함수의 이름을 강제한다면 이후 해당 인터페이스를 상속 받아 다른 로직을 구현하는 클래스를 생성하여 구축한다 하더라도 같은 함수명을 쓰므로, 다른 부분을 수정할 필요가 없어진다.
이후 이전에 만들어두었던 data 패키지 내에 dto 패키지를 만들고, 하위에 ProductDto, ProductResponseDto 클래스를 생성한다. 각 클래스의 구성은 다음과 같다. 본 책에선 getter/setter와 생성자 등을 수기로 작성했지만, Lombok의 의존을 받고 있기 때문에 어노테이션으로 간단히 처리해주었다. 편의를 위해 @Builder 또한 추가해주었다.
이제 해당 Dto 클래스들을 활용하여, 이전에 만들어두었던 service 패키지 내부의 ProductService 인터페이스를 다음과 같이 구현한다. 작성된 메서드들은 기본적인 CRUD 규칙에 맞게 간단히 정의되었다.
DB의 데이터는 엔티티 객체에 담는 반면 service에서 구현한 메소드들의 반환값은 DTO 객체(ProductResponseDto)이다.
이로서 서비스 계층 내에서 엔티티 <-> DTO의 변환이 일어난다고 유추할 수 있다.
즉, 엔티티는 데이터베이스와 매핑하는 객체이므로 불변해야 하고, DTO는 일회성 데이터로 요청과 반환을 담당하는 수정이 가능한 객체로 보면 되겠다.
참고 사이트
https://wildeveloperetrain.tistory.com/101
https://dahye-jeong.gitbook.io/spring/spring/2020-04-12-layer
이번엔 해당 인터페이스를 구현하는 ProductServiceImpl 클래스를 작성하자. 해당 클래스는 다음과 같이 구현한다.
package com.springboot.jpa.service.impl;
import com.springboot.jpa.data.dao.*;
import com.springboot.jpa.data.dto.*;
import com.springboot.jpa.data.entity.Product;
import com.springboot.jpa.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@Service
public class ProductServiceImpl implements ProductService {
private final ProductDao productDao;
@Autowired
ProductServiceImpl(ProductDao productDao){
this.productDao = productDao;
}
@Override
public ProductResponseDto getProduct(Long number) {
Product product = productDao.selectProduct(number);
ProductResponseDto productResponseDto = new ProductResponseDto();
productResponseDto.setNumber(product.getNumber());
productResponseDto.setName(product.getName());
productResponseDto.setPrice(product.getPrice());
productResponseDto.setStock(product.getStock());
return productResponseDto;
}
@Override
public ProductResponseDto saveProduct(ProductDto productDto) {
Product product = new Product();
product.setName(productDto.getName());
product.setPrice(productDto.getPrice());
product.setStock(productDto.getStock());
product.setCreatedAt(LocalDateTime.now());
product.setUpdatedAt(LocalDateTime.now());
Product savedProduct = productDao.insertProduct(product);
ProductResponseDto productResponseDto = new ProductResponseDto();
productResponseDto.setNumber(product.getNumber());
productResponseDto.setName(product.getName());
productResponseDto.setPrice(product.getPrice());
productResponseDto.setStock(product.getStock());
return productResponseDto;
}
@Override
public ProductResponseDto changeProductName(Long number, String name) throws Exception {
Product changedProduct = productDao.updateProductName(number, name);
ProductResponseDto productResponseDto = new ProductResponseDto();
productResponseDto.setNumber(changedProduct.getNumber());
productResponseDto.setName(changedProduct.getName());
productResponseDto.setPrice(changedProduct.getPrice());
productResponseDto.setStock(changedProduct.getStock());
return productResponseDto;
}
@Override
public void deleteProduct(Long number) throws Exception {
productDao.deleteProduct(number);
}
}
해당 방식은 CRUD 된 객체를 return 하는 방식으로 작성되었으며, 성공 여부를 boolean으로 return하는 방식 중 하나로 선택할 수 있다.
추가로 ProductResponseDto 에 lombok의 @Builder 어노테이션으로 빌더 패턴을 활용할 수 있도록 의존성을 받아두었기 때문에, 다음과 같이 코드 수정이 가능하다. 이러한 빌더 패턴을 통해 더 간단한 객체의 생성과 필드의 초기화가 가능하며,
우선은 책의 실습 코드를 따라가기로 한다.
6.10.2 컨트롤러 생성
이제 비즈니스 로직과 클라이언트를 연결하는 컨트롤러를 생성해보자.
컨트롤러에는 요청을 받고, 요청에 맞는 로직에 대해 서비스에서 알맞은 메서드를 찾아 응답하는 역할만 부여한다.
우선 수정부 구현의 편의성을 위해 data.dto 패키지에 ChangeProductNameDto 클래스를 생성 후 다음과 같이 작성한다.
이후 controller 패키지를 만들고, 해당 패키지 내에 ProductController 클래스를 생성 후 다음과 같이 작성한다.
package com.springboot.jpa.controller;
import com.springboot.jpa.data.dto.*;
import com.springboot.jpa.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/product")
public class ProductController {
private final ProductService productService;
@Autowired
public ProductController(ProductService productService){
this.productService = productService;
}
@GetMapping()
public ResponseEntity<ProductResponseDto> getProduct(Long number){
ProductResponseDto productResponseDto = productService.getProduct(number);
return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
}
@PostMapping()
public ResponseEntity<ProductResponseDto> createProduct(@RequestBody ProductDto productDto){
ProductResponseDto productResponseDto = productService.saveProduct(productDto);
return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
}
@PutMapping()
public ResponseEntity<ProductResponseDto> changeProductName(@RequestBody ChangeProductNameDto changeProductNameDto) throws Exception{
ProductResponseDto productResponseDto = productService.changeProductName(
changeProductNameDto.getNumber(),
changeProductNameDto.getName()
);
return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
}
@DeleteMapping()
public ResponseEntity<String> deleteProduct(Long number) throws Exception {
productService.deleteProduct(number);
return ResponseEntity.status(HttpStatus.OK).body("정상적으로 삭제되었습니다.");
}
}
6.10.3 Swagger API를 통한 동작 확인
이제 여태까지 만들어진 프로젝트를 swagger을 통하여 동작을 확인해보자.
프로젝트의 구조는 '컨트롤러 - 서비스 - DAO - 리포지토리' 순으로 요청이 이동하며, 응답은 역순의 과정으로 이동한다.
앞서 application.properties의 spring.jpa.hibernate.ddl-auto=create 옵션을 통하여 어플리케이션의 실행만으로 테이블은 자동으로 만들어지지만, 잠시 해당 옵션을 꺼두고 데이터베이스의 스키마를 직접 작성해서 정상적으로 연동이 되는지 확인해보자. 이후의 실습은 모두 hibernate의 도움을 받아 자동 생성할 것이다.
우선 일전에 설치해둔 mysql의 UI인 HeidiSQL에 접속한다. 설치 후 'springboot'라는 database까지 만들어두었을 것이다.
우리가 만든 프로젝트와 직접 연동시키기 위해, dto 패키지 내의 Product 클래스와 명세를 맞춘 table을 만든다.
springboot 데이터베이스 우클릭 -> [새로 생성(O)] -> [테이블(T)] -> 테이블 명은 꼭 'product'으로 해주어야 한다.
이후 [옵션]에서 [추가] 버튼을 눌러 열을 추가하고, Product 클래스의 필드들과 동일한 이름의 열을 다음과 같이 생성한다.
이때 number은 primary key이자 AUTO_INCREMENT한 값이다.
number 열을 PK로 설정하는 방법과, 기본값을 AUTO_INCREMENT하게 바꾸는 방법은 다음과 같다.
이때 AUTO_INCREMENT란 primary key의 IDENTITY 전략의 일종이다. (Product 클래스의 number 필드의 상단에 @GeneratedValue(strategy = GenerationType.IDENTITY) 라 명시한 것이 그것이다.)
AUTO_INCREMEMT 방식은 primary key가 데이터로서의 의미는 없고, 단지 다른 데이터들과의 식별자의 역할만 할 때 주로 활용되며, 서버(스프링 부트)에서 개발자가 값을 명시해주지 않고 데이터베이스에 키 생성을 위임하는 방법이다.
데이터베이스는 스스로 해당 열에 스스로 다른 데이터와 구분되는 값을 알아서 넣어주고, 그 방법은 AUTO_INCREMEMT 말 그대로 하나씩 순차적으로 늘어나는 값을 부여한다.
그 외의 다른 열들은 [이름], [데이터 유형], [NULL 허용] 부분만 신경써주자.
우리는 Product 클래스에서 name, price, stock 부분을 (nullable = false)로 선언했기 때문에 [NULL 허용]을 해제해야 한다.
설정을 끝마쳤다면 하단의 [저장]을 눌러 최종적으로 우리의 데이터베이스 설정을 반영해주자.
이후 잘 연동이 되었고 데이터가 잘 전송이 되는지를 테스트한다.
JpaApplication에서 프로젝트를 실행하고 swagger에 접속한다. (http://localhost:8080/swagger-ui.html)
저번 swagger 실습과 마찬가지로, [POST] 부에서 [Try it out]을 누르고 다음과 같이 전송해보자.
[Execute]를 누르면 다음과 같이 200번 코드가 뜨며 전송이 잘 된 것을 볼 수 있다.
이제 데이터베이스에 요청한 결과가 잘 반영되었는지 확인해보자.
HeidiSQL에 접속하여 [쿼리*] -> 쿼리문 작성 -> [SQL 실행 (F9)] 순서로 진행한다.
SQL문은 다음과 같다. 간략하게 Product 테이블(FROM product)에서 전체(*)를 선택(SELECT)하여 보여달라는 의미이다.
실행하면 다음과 같이 결과가 잘 반영된 것을 볼 수 있다.
이후 할당된 number의 값을 이용하여 swagger에서 GET, PUT, DELETE를 실습해볼 수 있다. GET에서는 number값으로 해당 데이터를 가져오고, PUT에서는 number값을 반영하여 해당 데이터의 name을 바꿀 수 있으며, DELETE에서는 number값으로 해당 데이터를 삭제할 수 있다. 해당 내용은 직접 실습해보길 바란다.
추가로, 직접 데이터베이스의 스키마를 명시하여 수기로 만든 테이블과 hibernate의 도움을 받아 생성된 테이블의 차이를 확인해보자. hibernate가 생성해준 테이블은 다음과 같다. 다행스럽게 크게 다른 점이 없었기 때문에 실습이 원활히 잘 진행된 것 같다.
6.11 [한걸음 더] 반복되는 코드의 작성을 생략하는 방법 - 롬복
롬복(lombok)이란 반복적으로 작성되는 getter/setter 등의 코드를 간단히 어노테이션으로 대체하는 라이브러리를 말한다.
롬복은 그 특성으로 코드의 생산성 향상, 가독성 향상, 코드 유추 용이로 인한 유지보수 용이 등의 장점이 존재한다.
우리는 일전의 실습으로 롬복의 작성법, 사용법을 경험해보았을 것이다.
이번장에선 롬복에서 사용 가능한 메서드와 사용 방법 등을 조금 더 자세히 알아보자.
6.11.1 롬복 설치
이미 우린 롬복의 설정을 마쳤다. 다시 한번 롬복의 설치 방법을 따라가보며 숙지해보자.
우선 maven에 의존성을 둔 우리 프로젝트는, pom.xml 파일의 dependency로 lombok을 설정했었다.
하지만 이외에도 롬복을 활용하려면 추가적인 설정이 몇가지 더 필요하다.
우선 lombok 플러그인을 설치한다. 방법은 아래와 같이 진행한다.
필자는 이미 설치되어 있기 때문에 마켓에 롬복 플러그인이 뜨지 않는다. 설치해야 하는 롬복 플러그인은 아래 그림과 같으며, 설치 후엔 아래 사진 처럼 상단의 [Installed]에서 설치된 롬복 플러그을 확인할 수 있다.
이후 같은 [Settings] 화면에서 다음 사진과 같이 들어가서 해당 부분을 체크하면 설정이 끝난다.
6.11.2 롬복 적용
이미 필자는 프로젝트를 진행하며 롬복을 활용하여 코드를 작성해왔고, 글에서도 간단한 설명을 덧붙였었다.
리마인드 하자면, 보안성의 문제로 (자바빈 규약) private으로 설정된 필드(변수)들에 대하여, 외부 클래스에서 해당 필드를 바로 접근할 수 없으므로 해당 변수를 가져오거나(get) 값을 설정하는(set) getter/setter 함수 (@Getter, @Setter),
그리고 매개변수가 없는 생성자와 모든 필드를 매개변수로 가지고 있는 생성자를 만들어주는 함수 (@NoArgsConstructor, @AllArgsConstructor) 등의 작성을 짧은 어노테이션만으로 대체한다.
해당 어노테이션을 선언하면 코드상에는 보이지 않더라도 내부적으로 해당 메서드를 활용할 수 있다.
그렇다면 롬복이 실제로 어느 메서드를 생성하는지 눈으로 볼 수 있는 방법을 활용해보자.
다음과 같은 경로를 따라가서, lombok으로 생성된 메서드를 확인해보자.
해당 방식으로 따라가며 delombok을 선택하면 해당 클래스가 lombok 어노테이션이 해제되면서 getter/setter, 생성자 관련 코드가 수기로 작성된 것을 볼 수 있다.
delombok을 사용하지 않고 확인할 수 있는 다른 방법으로는 인텔리제이 자체에서 제공하는 structer 기능을 이용하여 해당 클래스에 담긴 메서드를 확인하는 방법이다.
6.11.3 롬복의 주요 어노테이션
이번엔 롬복에서 유용하게 활용할 수 있는 주요 어노테이션에 대해 알아보자.
구분 | 어노테이션 | 설명 | 예시 |
필드 | @Getter | 필드를 get (return) 하는 메서드 생성 | public String getName() { return name; } |
@Setter | 필드를 함수의 매개변수 값으로 set 하는 메서드 생성 |
public String setName(String name) { this.name = name; } |
|
생성자 | @NoArgsConstructor | 매개변수가 없는 생성자 생성 | public Product() { } |
@AllArgsConstructor | 모든 필드를 매개변수로 갖는 생성자 생성 | public Product(String name, int age) { this.name = name; this.age = age; } |
|
@RequiredArgsConstructor | final, @NotNull이 설정된 필드를 매개변수로 갖는 생성자 생성 | public Product(String name, int age) { this.name = name; this.age = age; } |
|
객체 | @ToString | 모든 필드 값을 특정 포맷으로 문자열 반환 @ToString(exclude = "필드명")로 특정 필드를 제외할 수 있음. |
public String toString() { return "Product(name=" + this.getName() + ", age=" + this.getAge() + ")"; } |
비교 | @EqualsAndHashCode | 동등성(Equality, 값)를 판별하는 equals와 동일성(Identity, 주소)을 판별하는 hashCode 함수를 생성 |
( 생략 ) |
포괄 | @Data | Getter, Setter, RequiredArgsConstructor, ToString, EqualsAndHashCode 포괄 |
( 생략 ) |
'Backend > Spring' 카테고리의 다른 글
[스프링 부트 핵심 가이드] 08. Spring Data JPA 활용 (0) | 2023.06.08 |
---|---|
[스프링 부트 핵심 가이드] 07. 테스트 코드 작성하기 (0) | 2023.06.08 |
[스프링 부트 핵심 가이드] 05. API를 작성하는 다양한 방법 (0) | 2023.05.25 |
[스프링 부트 핵심 가이드] 04. 스프링 부트 애플리케이션 개발하기 (0) | 2023.05.23 |
[스프링 부트 핵심 가이드] 03. 개발 환경 구성 (0) | 2023.05.19 |