데이터베이스 연동

사공광열·2023년 7월 22일
0

SpringBoot

목록 보기
5/8

ORM

ORM은 Object Relational Mapping의 줄임말로 객체 관계 매핑을 의미합니다.
객체지향 언어에서 의미하는 객체와 RDB(Relational Database) 관계형 데이터베이스의 테이블을 자동으로 매핑하는 방법입니다.

객체지향 언어에서의 객체는 클래스를 의미합니다. 클래스는 데이터베이스의 테이블과 매핑하기 위해 만들어진것이 아니기 때문에 RDB 테이블과 어쩔수 없는 불일치가 존재합니다. 이 둘의 제약사항을 해결하는 역할이 ORM입니다.

ORM을 이용하면 퀴리문이 아닌 코드(메서드)로 데이터를 조작할 수 있습니다.

ORM의 장점

ORM을 사용하면서 데이터베이스 쿼리를 객체지향적으로 조작할 수 있습니다.

  • 쿼리문을 작성하는 양이 현저히 줄어 개발 비용이 줄어듭니다.
  • 객체지향적으로 데이터베이스에 접근할 수 있어 코드의 가독성을 높입니다.

재사용 및 유지보수가 편리합니다.

  • ORM을 통해 매핑된 객체는 모두 독립적으로 작성되어 있어 재사용이 용이합니다.
  • 객체들은 각 클래스로 나뉘어 있어 유지보수가 수월합니다.

데이터베이스에 대한 종속성이 줄어듭니다.

  • ORM을 통해 자동 생성된 SQL문은 객체를 기반으로 데이터베이스 테이블을 관리하기 때문에 데이터베이스에 종속적이지 않습니다.
  • 데이터베이스를 교체하는 상황에서도 비교적 적은 리스클 부담합니다.

ORM의 단점

ORM만으로 온전한 서비스를 구현하기에는 한계가 있습니다.

  • 복잡한 서비스의 경우 직접 쿼리를 구현하지 않고 코드로 구현하기 어렵습니다.
  • 복잡한 쿼리를 정확한 설계없이 ORM만으로 구성하게 되면 속도 저하 등의 성능 문제가 발생할 수 있습니다.

애플리케이션의 객체 관점과 데이터베이스의 관계 관점의 불일치가 발생합니다.

  • 세분성(Granularity): ORM의 자동 설계 방법에 따라 데이터베이스에 있는 테이블의 수와 애플리케이션의 엔티티(Entity) 클래스의 수가 다른 경우가 생깁니다.(클래스가 테이블의 수보다 많아질 수 있습니다.)

  • 상속성(inheritance): RDBMS에는 상속이라는 개념이 없습니다.

  • 식별성(identity): RDBMS는 기본키로 동일성을 정의합니다. 하지만 자바는 두 객체의 값이 같아도 다르다고 판단할 수 있습니다.

  • 연관성(Associations): 객체지향 언어는 객체를 참조함으로써 연관성을 나타내지만 RDBMS에서는 외래키를 사입함으로써 연관성을 표현합니다. 또한 객체지향 언어에서 객체를 참조할 때는 방향성이 존재하지만 RDBMS에서 외래키를 삽입하는 것은 양방향의 관계를 가지기 때문에 방향성이 없습니다.

  • 탐색(Navigation): 자바와 RDBMS는 어떤 값(객체)에 접근하는 방식이 다릅니다. 자바에서는 특정 값에 접근하기 위해 객체 참조 같은 연결 수단을 활용합니다. 이 방식은 객체를 연결하고 또 연결해서 접근하는 그래프 형태의 접근 방식입니다. (예: 어떤 멤버의 회사 주소를 구하기 위해 member.getOrganization().getAddress()와 같이 접근할 수 있습니다.) 반면 RDBMS에서는 쿼리를 최소화하고 조인을 통해 여러 테이블을 로드하고 값을 추출하는 접근 방식을 채택하고 있습니다.

JPA

JPA(Java Persistence API)라는 자바 진영의 ORM 기술 표준으로 채택된 인터페이스의 모음입니다.

ORM이 큰 개념이라면 JPA는 더 구체환된 스펙을 포함합니다. 쉽게 말해 실제로 동작하는것이 아니고 어떻게 동작하는 매커니즘을 정리한 표준 명세서라고 생각하면 됩니다.

JPA 메커니즘을 보면 내부적으로 JDBC를 사용합니다. JDBC를 직접 구현하면 SQL에 의존하게 되는 문제를(개발 효율성이 떨어짐) JPA는 이를 보완해서 적절한 SQL을 생성하고 데이터베이스를 조작해서 객체를 자동 매핑하는 역활을 수행합니다.


하이버네이트, 이클립스 링크, 데이터 뉴클리언스가 있지만 대표적으로 하이버네이트를 자주 사용합니다.

하이버네이트

하이버네이트는 자바의 ORM 프레임워크로, JPA가 정의하는 인터페이스를 구현하고 있는 JPA 구현체 중 하나입니다.(하이버네이트의 기능을 더욱 편하게 사용하기위해 Spring Data JPA 사용)

Spring Data JPA
Spring Data JPA를 편리하게 사용할 수 있도록 지원하는 스프링 하위 프로젝트 중 하나입니다. CRUD처리에 필요한 인터에페이슬 제공하며, 하이버네이트의 엔티티 매니저(Entity Manager)를 직접 다루지 않고 리포지토리를 정의해 사용함으로써 스프링이 적합한 쿼리를 동적으로 생성하는 방식으로 데이터베이스를 조작합니다.(쉽게 하이버네이트에서 자주 사용되는 기능을 사용할 수 있는 라이브러리)

영속성 컨텍스트

영속성 컨텍스트(Persistance Context)는 애플리케이션과 데이터베이스 사이에 엔티티와 레코드의 괴리를 해소하는 기능과 객체를 보관하는 기능을 수행합니다. 쉽게 말해 엔티티 객체가 영속성 컨텍스트에 들어오면 JPA는 엔티티 객체의 매핑 정보를 데이터베이스에 반영합니다.

엔티티 매니저
엔티티 매니저(Entity Manager)는 이름 그대로 엔티티를 관리하는 객체입니다.
엔티티 매니저는 데이터베이스에 접근해서 CRUD 작업을 수행합니다. Spring Data JPA를 사용하면 리포지토리를 사용해서 데이터베이스에 접급하는데, 실제 내부 구현체를 보겠습니다.

public SimpleJpaRepository(JpaEntityInformation<T, ?> entityInformation, EntityManager entityManager){
	Assert.notNull(entityInformation, "JpaEntityInformation must not be null");
	Assert.notNull(entityManager, "EntityManager must not be null");
	
	this.entityInformation = entityInformation;
	this.em = entityManager;
	this.provider = PersistenceProvider.fromEntityManager(entityManager);
}

엔티티 매니저는 엔티티 매니저 팩토리(EntityManagerFactory)가 만듭니다.
그럼 엔티티 매니저 팩토리는 데이터베이스에 대응하는 객체로서 스프링 부트에서는 자동설정이 기능이 있어서 application.properties에서 작성한 최소한의 설정만으로도 동작하지만 JPA의 구현체 중 하나인 하이버네이트에서는 persistence.xml이라는 설정 파일을 구성하고 사용해야 하는객체 입니다. 그럼 다음 코드를 보시면

<?xml version="1.0" encoding="UTF-8" ?>
<persistence xmlns="http://xmlns.jcp.org./xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://xmls.jcp.org/xml/ns/persistence_2_1.xsd" version="2.1">
    
    <persistence-unit name="entity_manager_factory" transaction-type="RESOURCE_LOCAL">
    
        <properties>
            <property name="javax.persistence.jdbc.driver" value="org.mariadb.jdbc.Driver"/>
            <property name="javax.persistence.jdbc.user" value="root"/>
            <property name="javax.persistence.jdbc.password" value="password"/>
            <property name="javax.persistence.jdbc.url" value="jdbc:mariadb://localhost:3306"/>
            <property name="hibernate.dialect" value="org.hibernate.dialect.MariaDB103Dialect"/>
            
            <property name="hibernate.show_sql" value="true"/>
            <property name="hibernate.format_sql" value="true"/>
            
        </properties>
        
    </persistence-unit>
    
</persistence>

위에 코드에 보시면 persistence-unit을 설정하면 해당 유닛의 이름을 가진 엔티티 매니저 팩토리가 생성됩니다. 이는 애플리케이션에서 단 하나만 생성되며, 모든 엔티티가 공유해서 사용합니다.

엔티티 매니저로 생성된 엔티티 매니저는 엔티티를 영속성 컨텍스트에 추가해서 영속 객체로 만든는 작업을 수행하고 따라서 영속성 컨텍스트와 데이터베이스를 비교하여 실제 데이터베이스 대상으로 수행합니다.

엔티티의 생명주기

비영속(New)

  • 영속성 컨텍스트에 추가되지 않은 엔티티 객체의 상태를 의미합니다.

영속(Managed)

  • 영속성 컨텍스트에 의해 엔티티 객체가 관리되는 상태입니다.

준영속(Detached)

  • 영속성 컨텍스트에 의해 관리되던 엔티티 객체가 컨텍스트와 분리된 상태입니다.

삭제(Removed)

  • 데이터베이스에서 레코드를 삭제하기 위해 영속성 컨텍스트에 삭제 요청을 한 상태입니다.

데이터베이스 연동

Spring Data JPA 의존성을 추가한 후에는 별도의 설정이 필요합니다. 즉, 애플리케이션이 정상적으로 실행될 수 있게 연동할 데이터베이스의 정보를 application.properites에 작성해야 합니다.

spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=jdbc:mariadb://localhost:3306/springbootdb
spring.datasource.username=springboot
spring.datasource.password=spring

spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true


  1. 연동하려는 데이터베이스의 드라이버를 정의합니다.

  2. 마리아db경로 즉 데이터베이스의 명입니다.

  3. 사용자 계정 이름

  4. 사용자 계정 비밀번호

  5. 빈칸

  6. 은 데이터베이스를 자동으로 조작하는 옵션입니다.

  • create: 애플리케이션이 가동되고 SessionFactory가 실행될 때 기존 테이블을 지우고 새로 생성합니다.
  • create-drop: create와 동일한 기능을 수행하나 애플리케이션 종료하는 시점에 테이블을 지웁니다.
  • update: SessionFactory가 실행될 때 객체를 검사해서 변경된 스키마를 갱신합니다. 기존에 저장된 데이터는 유지 됩니다.
  • validate: update처럼 객체를 검사하지만 스키마는 건드리지 않습니다. 검사 과정에서 데이터베이스의 정보와 객체의 정보가 다르면 에러가 발생합니다.
  • none: ddl-auto 기능을 사용하지 않습니다.
주의할점 운영 환경에서는 데이터베이스 축전된 데이터를 지워버릴수도 있고 실수로 객체의 정보가 변경됐을 때 운영 환경의 데이터베이스 정보까지 변경 될 수 있어 create, create-drop, update사용하지 않습니다. 운영 환경에서는 대체로 validate나 none을 사용합니다.

반면 개발 환경에서는 create 또는 update를 사용하는 편입니다.

  1. show-sql은 로그에 하이버네이트가 생성한 쿼리문을 출력하는 옵션입니다. 아무설정이 없으면 보기 불편하게 한 줄로 출력됩니다.
  2. format_sql 옵션으로 사람이 보기 좋게 포매팅할 수 있습니다.

엔티티 설계

Spring Data Jpa를 사용하면 데이터베이스 테이블을 생성하기 위해 직접 쿼리 작성할 필요가 없습니다. 엔티티를 이용해 데이터베이스의 테이블을 대응 하는 클래스입니다. 따라서 테이블과 칼럼을 정의합니다. 엔티티에 어노테이션을 사용하면 테이블간의 연관관계를 정의할 수 있습니다.

package com.springboot.jpa.data.entity;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "product")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;

    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;

    private LocalDateTime createdAt;
    private  LocalDateTime updatedAt;
}

엔티티 관련 기본 어노테이션

엔티티 작성할때 어노테이션을 많이 사용합니다. 예시로는

@Entity
해당 클래스가 엔티티임을 명시하기 위한 어노테이션
클래스 자체는 테이블과 일대일 대응
해당 클래스의 인스턴스는 매핑되는 테이블에서 하나의 레코드를 의미

@Table
엔티티 클래스 테이블과 매핑되므로 특별한 경우 아니면 @Table 어노테이션이 필요하지 않습니다.
@Table 어노테이션을 사용할 때는 클래스의 이름과 테이블 이름을 다르게 설정해야합니다.

@Id
엔티티 클래스의 필드는 테이블의 칼럼과 매핑됩니다. @Id 어노테이션이 선어된 필드는 테이블의 기본값 역활로 사용됩니다. 모든 엔티티는 @Id 어노테이션이 필요합니다.

@GeneratedValue
일반적으로 @Id 어노테이션과 함께 사용됩니다. 이 어노테이션은 해당 필드의 값을 어떤 방식으로 자동으로 생성할지 결정할 때 사용합니다. 여러가지 생성 방식

GeneratedValue를 사용하지 않는 방식(직접할당)

  • 애플리케이션에서 자체적으로 고유한 기본값을 생성할 경우 사용하는 방식입니다.
  • 내부에 정해진 규칙에 의해 기본값을 생성하고 식별자로 사용합니다.

AUTO

  • @GeneratedValue의 기본 설정값.
  • 기본값을 사용하는 데이터베이스에 맞게 자동 생성합니다.

IDENTITY

  • 기본값 생성을 데이터베이스에 위임하는 방식입니다..
  • 데이터베이스의 AUTO_INCREMENT를 사용해 기본값을 생성합니다.

SEQUENCE

  • @SequenceGenerator 어노테이션으로 식별자 생성기를 설정하고 이를 통해 값을 자동 주입받습니다.
  • SequenceGenerator를 정의할 때는 name, sequenceName, allocationSize를 활용합니다.
  • @GeneratedValue에 생성기를 설정합니다.

TABLE

  • 어떤 DBMS를 사용하더라도 동일하게 동작하기를 원할 경우 사용합니다.
  • 식별자로 사용할 숫자의 보관 테이블을 별도로 생성해서 엔티티를 생성할 때마다 값을 갱신하며 사용합니다.
  • @TableGenerator 어노테이션으로 테이블 정보를 설정합니다.
여기까지가 @GeneratedValue 값 생성 방식입니다.

@Column
엔티티 클래스의 자동으로 테이블 칼럼으로 매핑됩니다.


많이 사용되는 요소

  • name: 데이터베이스의 칼럼며을 설정하는 속성입니다. 명시하지 않으면 필드명으로 지정됩니다.
  • nullable: 레코드를 생성할 때 칼럼 값에 null 처리가 가능한지를 명시하는 속성입니다.
  • length: 데이터베이스에 저장하는 데이터의 최대 길이를 설정합니다.
  • unique: 해당 칼럼을 유니크로 설정합니다.

@Transient
엔티티 클래스에는 선어돼 있는 필드지만 데이터베이스에 필요 없을 경우 이용하지 않게 하는 어노테이션입니다.

리포지토리 인터페이스 설계

Spring Data JPA는 JpaRepository를 기반으로 더욱 쉽게 데이터베이스를 사용할 수 있는 아키텍처를 제공합니다. 스프링부트로 JpaRepository를 상속하는 인터페이스를 생성하면 기존의 다양한 메서드를 손쉽게 활용할 수 있습니다.

리포지토리 인터페이스 생성
리포지토리는 엔티티가 생성한 데이터베이스에 접근하는 데 사용됩니다.
리포지토리를 생성하기 위해서는 접근하려는 테이블과 매핑되는 엔티티에 대한 인터페이스를 생성합니다.

public interface ProductRepository extends JpaRepository<Product,Long> {
    
}

JpaRepositoroy를 상속받습니다.
상속을 받을 때는 대상 엔티티와 기본값 타입을 지정해야 합니다.


@NoRepositoryBean
public interface JpaRepository<T, ID> extends PagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {
    List<T> findAll();

    List<T> findAll(Sort sort);

    List<T> findAllById(Iterable<ID> ids);

    <S extends T> List<S> saveAll(Iterable<S> entities);

    void flush();

    <S extends T> S saveAndFlush(S entity);

    <S extends T> List<S> saveAllAndFlush(Iterable<S> entities);

    /** @deprecated */
    @Deprecated
    default void deleteInBatch(Iterable<T> entities) {
        this.deleteAllInBatch(entities);
    }

    void deleteAllInBatch(Iterable<T> entities);

    void deleteAllByIdInBatch(Iterable<ID> ids);

    void deleteAllInBatch();

    /** @deprecated */
    @Deprecated
    T getOne(ID id);

    T getById(ID id);

    <S extends T> List<S> findAll(Example<S> example);

    <S extends T> List<S> findAll(Example<S> example, Sort sort);
}

JpaRepository 상속 구조

리포지토리 메서드의 생성 규칙

  • FindBy: SQL문의 where 절 역할 (findByName(String name(엔티티 필드값)))

  • AND, OR: 조건 여러개 설정 (findByNameAndEmail(String name, String email))

  • Like/NotLike: SQL문의 like 와 동일한 기능을 수행하며, 특정 문자를 포함하는지 여부를 조건으로 추가합니다. 비슷한 키워드로 Containing, Contains, isContaining이 있습니다.

  • StartsWith/StartingWith: 특정 키워드로 시작하는 문자열 조건을 설정합니다.

  • EndsWith/EndingWth: 특정 키워드로 끝나는 문자열 조건을 설정합니다.

  • IsNull/IsNotNull: 레코드 값이 Null이거나 Null이 아닌값을 검색합니다.

  • True/False: Boolean 타입의 래코드를 검색할 때 사용합니다.

  • Before/After: 시간을 기준으로 값을 검색합니다.

  • LessThan/GreaterThan: 특정 값(숫자)을 기준으로 대소 비교를 할 때 사용합니다.

  • Between: 두 값(숫자) 사이의 데이터를 조회합니다.

  • OrderBy: SQL 문의 order by와 동일한 기능을 수행합니다.

  • countBy: SQL 문의 count와 동일한 기능을 수행하며, 결괏값의 개수(count)를 추출합니다.

DAO 설계

DAO(Data Access Object)는 데이터베이스에 접근하기 위한 로직을 관리하기 위한 객체입니다. 비즈니스 로직의 동작 과정에서 데이터를 조작하는 기능은 DAO 객체가 수행합니다. 다만 스프링 데이터 JPA에서 DAO의 개념은 리포지토리가 대체하고 있습니다.

DAO vs 리포지토리

DAO와 리포지토리는 역할이 비슷합니다.실제로 리포지토리는 Spring Data JPA에서 제공하는 기능이기 때문에 기존의 스피링 프레임워크나 스프링 MVC의 사용자는 리포지토리라는 개념을 사용하지 않고 DAO 객체로 데이터베이스에 접근했습니다.

DAO 클래스 생성

DAO 클래스는 인터페이스-구현체 구성으로 생성합니다. DAO 클래스는 의존성 결합을 낮추기 위한 디자인 패턴이며, 서비스 레이어에 DAO 객체를 주입받을 때 인터페이스를 선언하는 방식으로 구성할 수 있습니다.

CRUD를 다루기위해 인터페이스 메서드를 정의합니다.

package com.springboot.jpa.data.dao;

import com.springboot.jpa.data.entity.Product;

public interface ProductDAO {
    Product insertProduct(Product product);

    Product selectProduct(Long number);

    Product updateProductName(Long number, String name) throws Exception;

    void deleteProduct(Long number) throws Exception;
}

일반적으로 데이터베이스에 접근하는 메서드는 리턴 값으로 데이터 객체를 전달합니다. 하지만 이때 데이터 객체를 엔티티 객체로 전달할지, DTO 객체로 전달할지에 대해서는 개발자마다 의견이 분분합니다. 일반 적인 설계 원칙에서 엔티티 객체는 데이터베이스에 접근하는 계층에서만 사용하도록 정의합니다. 다른 계층으로 데이터를 전달할 때느 DTO객체를 사용합니다. 그러나 이 부분은 회사나 부서마다 견해 차이가 있으므로 각자 정해진 원칙에 따라 진행하는 것이 좋습니다.

이제 인터페이스 설계가 완료되면 인터페이스의 구현체를 만들어봅니다.

@Component
public class ProductDAOImpl implements ProductDAO {
    private final ProductRepository productRepository;

    @Autowired
    public ProductDAOImpl(ProductRepository productRepository){
        this.productRepository=productRepository;
    }

    @Override
    public Product insertProduct(Product product){
        return null;
    }

    @Override
    public Product selectProduct(Long number){
        return null;
    }

    @Override
    public Product updateProductName(Long number, String name) throws Exception{
        return null;
    }

    @Override
    public void deleteProduct(Long number) throws Exception{

    }
}

ProductDAOImpl 클래스를 스프링이 관리하는 빈으로 등록하려면 @Component 또는 @Service 어노테이션을 지정해야 합니다. 빈으로 등록된 객체는 다른 클래스가 인터페이스를 가지고 의존성을 주입받을 때 이 구현체를 찾아 주입하게 됩니다.

DAO 객체에서도 데이터베이스에 접근하기 위해 리포지토리 인터페이스를 사용해 의존성을 주입을 받아야합니다.

인터페이스에 정의한 메서드를 구현합니다 insertProduct() 메서드를 구현했습니다. 그러 여기에 이제 엔티티를 데이터베이스에 저장하는 기능을 수행합니다.

 @Override
    public Product insertProduct(Product product){
        Product savedProduct = productRepository.save(product);
        
        return savedProduct;
    }

조회 메서드 작성

@Override
    public Product selectProduct(Long number){
        Product selectedProduct = productRepository.getById(number);
        
        return selectedProduct;
    }

리포지토리에서는 단건 조회를 위한 기본 메서드 두 가지를 제공하는데, 바로 getById(), findById() 메서드입니다. 두 메서드는 조회한다는 기능 측면에서는 동일하지만 세부 내용이 다릅니다. 각 메서드의 자세한 설명은 다음과 같습니다.

getById()
내부적으로 EntityManager의 getReference() 메서드를 호출합니다. getReference() 메서드를 호출하면 프락시 객체를 리턴합니다.
실제 쿼리는 프락시 객체를 통해 최초로 데이터에 접근하는 시점에 실행됩니다. 이때 데이터 존재하지 않는 경우에는 EntityNotFoundException이 발생합니다. JpaRepository의 실체 구현체인 SimpleJpaRepository의 getById() 예입니다.

@Override
public T getById(ID id) {
    Assert.notNull(id, ID_MUST_NOT_BE_NULL);
    return em.getReference(getDomainClass(), id);
}

findById()
내부적으로 EntityManger의 find() 메서드를 호출합니다. 이 메서드는 영속성 컨텍스트의 캐시에서 값을 조회한후 영속성 컨텍스트에 값이 존재하지 않는다면 실제 데이터베이스에서 데이터를 조회합니다. 리턴 값으로 Optional 객체를 전달합니다. SimpleJpaRepository의 findById() 예입니다.

@Override
public Optional<T> findById(ID id) {
    Assert.notNull(id, ID_MUST_NOT_BE_NULL);
    
    Class<T> domainType = getDomainClass();
    
    if(metadata == null) {
      return Optional.ofNullable(em.find(domainType, id));
    }
    
    LockModeType type = metadata.getLockModeType();
    
    Map<String, Object> hints = new HashMap<>();
    getQueryHints().withFetchGraphs(em).forEach(hints::put);
    
    return Optional.ofNullable(type == null ? em.find(domainType, id, hints) : em.find(domainType, id, type, hints));
}

조회 기능을 구현하기 위해서는 어떤 메서드를 사용하더라도 무관합니다. 비즈니스 로직을 구현하는 데 적합한 방식을 선정해 활용하면 됩니다.

다음으로 업데이트 메서드를 구현해보겠습니다.

@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;
    }

JPA에서 값을 갱신할 때 update 키워드를 사용하지않고 영속성 컨텍스트를 활용해 값을 갱신하는데, find() 메서드를 통해 데이터베이스에서 값을 가져오면 가져온 객체가 영속성 컨텍스트에 추가됩니다. 영속성 컨텍스트가 유지되는 상황에서 객체의 값을 변경하고 다시 save()를 실행하면 JPA에서는 더티 체크 라고 하는 변경 감지를 수행합니다. SimpleJpaRepository에 구현돼 있는 save() 메서드를 살펴보면

@Transactional
@Override
public <S extends T> S save(S entity) {
    Assert.notNull(entity, "Entity must not be null.");
  
    if(entotyInformation.isNew(entity)) {
        em.persist(entity);
        return entity;
    } else {
      return em.merge(entity);
    }
}

@Transactional 어노테이션이 선언돼 있습니다. 이 어노테이션이 지정돼 있으면 메서드 내 작업을 마칠 경우 자동으로 flush() 메서드를 실행합니다. 이 과정에서 변경이 감지되면 대상 객체에 해당하는 데이터베이스의 레코드를 업데이트하는 쿼리가 실행합니다.

삭제 메서드를 구현합니다.

@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();
        }

    }

데이터베이스의 레코드를 삭제하기 위해서는 삭제하고자 하는 레코드와 매핑된 영속 객체를 영속성 컨텍스트에 가져와야 합니다. deleteProduct() 메서드는 findById() 메서드를 통해 객체를 가져오는 작업을 수행하고 delete() 메서드를 통해 해당 객체를 삭제하게끔 삭제 요청을 합니다. SimpleRepository의 delete() 메서드를 보면

@Override
@Transactional
@SuppressWarnings("unchecked")
public void delete(T entity) {
    
    Assert.notNull(entity, "Entity must not be null!");
    
    if (entityInformation.isNew(entity)) {
        return;
    }
    
    Class<?> type = ProxyUtils.getUserClass(entity);
    
    T existing = (T) em.find(type, entityInformation.getId(entity));
    
    // if the entity to be deleted doesn't exist, delete is a NOOP
    if (existing == null) {
        return;
    }
    
    em.remove(em.contains(entity) ? entity : em.merge(entity));
}

em.remove(em.contains(entity) ? entity : em.merge(entity)); delete() 메서드로 전달받은 엔티티가 영속성 컨텍스트에 있는지 파악하고, 해당 엔티티를 영속성 컨텍스트에 영속화하는 작업을 거쳐 데이터베이스의 레코드와 매핑합니다. 그렇게 매핑된 영속 객체를 대상으로 삭제 요청을 수행하는 메서드를 실행해 작업을 마치고 커밋(Commit) 단계에서 삭제를 진행합니다.

DAO 연동을 위한 컨트롤러와 서비스 설계

설계한 구성들을 클라이언트 요청과 연결할려면 컨틀롤러와 서비스를 생성해야 합니다. 이것을 하기위해 먼저 DAO의 메서드를 호출하고 그 외 비즈니스 로직을 수행하는 서비스 레이어를 생성한 후 컨틀롤러를 생성합니다.

서비스 클래스 만들기
서비스 레이어에서는 도메인 모델(Domain Model)을 사용해 애플리케이션이 제공하는 핵심 기능을 제공합니다.

ProductDTO

package com.springboot.jpa.data.dto;

public class ProductDto {
    
    private String name;
    private int price;
    private int stock;
    
    public ProductDto(String name, int price, int stock){
        this.name=name;
        this.price=price;
        this.stock=stock;
    }
    public String getName(){
        return name;
    }
    public void setName(String name){
        this.name=name;
    }

    public int getPrice() {
        return price;
    }

    public void setPrice(int price) {
        this.price = price;
    }

    public int getStock() {
        return stock;
    }

    public void setStock(int stock) {
        this.stock = stock;
    }
}

ProductResponseDto

package com.springboot.jpa.data.dto;

public class ProductResponseDto {
    private long number;
    private String name;
    private int price;
    private int stock;
    public ProductResponseDto(Long number, String name, int price, int stock){
        this.number=number;
        this.name=name;
        this.price=price;
        this.stock=stock;
    }
    public long getNumber(){
        return number;
    }
    public void setNumber(Long number){
        this.number=number;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getPrice() {
        return price;
    }

    public void setPrice(int price) {
        this.price = price;
    }

    public int getStock() {
        return stock;
    }

    public void setStock(int stock) {
        this.stock = stock;
    }
}

필요에 따라 빌더 메서드와 hashCode/equals 메서드 추가할 수 있습니다.

빌더 메서드
쉽게 말해 데이터 클래스를 사용할때 생성자로 초기화할 경우 모든 필드에 값을 넣거나 null 명시적으로 사용해야합니다. 단점을 보완하기 위해 나온 팬턴이 빌더 패턴입니다. 따라서 이 패턴을 이용하면 필요한 데이터만 설정할 수 있어 유연성을 확보할 수 있습니다.

public class ProductResponseDto {
    
    private Long number;
    private String name;
    private int price;
    private int stock;
    
    public static ProductResponseDtoBuilder builder() {
        return new ProductResponseDtoBuilder();
    }
    
    public static class ProductResponseDtoBuilder {
        private Long number;
        private String name;
        private int price;
        private int stock;
        
        ProductResponseDtoBuilder(){
        }
        
        public ProductResponseDtoBuilder number(Long number) {
            this.number = number;
            return this;
        }
        
        public ProductResponseDtoBuilder name(String name) {
            this.name = name;
            return this;
        }
        
        public ProductResponseDtoBuilder price(int price) {
            this.price = price;
            return this;
        }
        
        public ProductResponseDtoBuilder stock(int stock) {
            this.stock = stock;
            return this;
        }
        
        public ProductResponseDto build() {
            return new ProductResponseDto(number, name, price, stock);
        }
        
        public String toString() {
            return "ProductResponseDto.ProductResponseDtoBuilder(number=" 
            + this.number + ", name=" + this.name + ", price =" + 
            this.price + ", stock=" + this.stock + ")";
        }
    }
}

이제 서비스 인터페이스를 작성합니다. 기본적인 CRUD의 기능을 호출하기 위해 간다하게 메서드를 정리합니다.

public interface ProductService  {
    
    ProductResponseDto getProduct(Long number);
    
    ProductResponseDto saveProduct(ProductDto productDto);
    
    ProductResponseDto changeProductName(Long number, String name) throws Exception;
    
    void deleteProduct(Long number) throws Exception;
}

DAO 구현한 기능을 서비스 인터페이스에 호출에 결괏값을 가져오는 작업을 수행하도록 설계했습니다. 서비스에서 클라이언트 요청한 데이터를 적절하게 가공해서 컨틀로러에게 넘기는 역할입니다. 현재는 CRUD 구현만 하기 때문에 단순해 보이지만 여러 메서드를 사용합니다.

위에 코드를 보면 리턴 타입이 DTO객체인것을 알 수 있습니다.
쉽게 그림으로


보시면 서비스와 DAO의 사이에서 엔티티로 데이터를 전달하는 것으로 표현했지만 회사나 개발 그룹 내 규정에 따라 DTO를 이용하기도 합니다. 큰 데이터를 표현하것이고 작거나 단일 데이텅면 DTO나 엔티티를 사용하지 않기도합니다.

구현체 클래스를 작성합니다.

@Service
public class ProductServiceimpl implements ProductService {
    
    private final ProductDAO productDAO;
    
    @Autowired
    public  ProductServiceimpl(ProductDAO productDAO){
        this.productDAO=productDAO;
    }
    @Override
    public ProductResponseDto getProduct(Long number){
        return null;
    }
    @Override
    public ProductResponseDto saveProduct(ProductDto productDto){
        return null;
    }
    @Override
    public ProductResponseDto changeProductName(Long number, String name) throws Exception{
        return null;
    }
    @Override
    public void deleteProduct(Long number) throws Exception{
        
    }
}

인터페이스 구현체 클래스에서 DAO 인터페이스를 선언하고 @Autowired를 지정한 생성자를 통해 의존성을 주입 받습니다. 그리고 정의한 메서드에서 오버라이딩을 합니다.

이제 오버라이딩 된 메서드를 구현합니다. 조회 메서드

public class ProductServiceimpl implements ProductService {

    private final ProductDAO productDAO;

    @Autowired
    public  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;
    }

현재 서비스 레이어에는 DTO 객체와 엔티티 객체가 공존하도록 설계돼 있어 변환 작업이 필요합니다. @Autowired 부분에서 DTO 객체를 생성하고 값을 넣어 초기화하는 작업을 수행하는데, 이런 부분은 빌더 패턴을 활용하거나 엔티티 객체나 DTO 객체 내부에 변환하는 메서드를 추가해서 간단하게 전환할 수 있습니다.

저장메서드를 구현해보겠습니다.

    @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 saveProduct = productDAO.insertProduct(product);
        
        ProductResponseDto productResponseDto = new ProductResponseDto();
        productResponseDto.setNumber(saveProduct.getNumber());
        productResponseDto.setName(saveProduct.getName());
        productResponseDto.setPrice(saveProduct.getPrice());
        productResponseDto.setStock(saveProduct.getStock());
        
        return productResponseDto;
    }

저장 메서드 로직은 간단합니다. 전달받은 DTO 객체를 통해 엔티티 객체를 생성해서 초기화한 후 DAO 객체로 전달하면 됩니다. 다만 저장 메서드 리턴 타입을 일반적인 저장은 void 작업의 성공 여부는 boolean 으로 지정하는 경우가 많습니다. 따라서 리턴 타입은 해당 비즈니스 성격에따라 결정하는것이 바람직합니다.

saveProduct()는 상품정보를 전달하고 애플리케이션을 거쳐 데이터베이스에 저장하는데 현재 데이터를 조회되는 인데스를 통해 값을 찾아야합니다. void는 인덱스를 알방법이 없습니다. 그래서 DTO에 담아 결괏값으로 알 수 있습니다.

업데이트 메서드 구현합니다.

  @Override
    public ProductResponseDto changeProductName(Long number, String name) throws Exception{
        Product changeProduct=productDAO.updateProductName(number, name);

        ProductResponseDto productResponseDto=new ProductResponseDto();
        productResponseDto.setNumber(changeProduct.getNumber());
        productResponseDto.setName(changeProduct.getName());
        productResponseDto.setPrice(changeProduct.getPrice());
        productResponseDto.setStock(changeProduct.getStock());
        
        return productResponseDto;
    }

changeProductName() 메서드는 상품정보 중 이름을 변경하는 작업을 수행합니다. 이름을 변경하기 위해 클라이언트로부터 대상을 식별할 수 있는 인덱스 값과 변경하려는 이름을 받아옵니다. 좀 더 견고하게 코드를 작성하기 위해 기존 이름도 받아와 식별자로 가져온 상품정보와 일치하는지 검증하는 단계를 추가하기도 합니다.

삭제 메서드 구현

public void deleteProduct(Long number) throws Exception{
        productDAO.deleteProduct(number);
    }

상품정보를 삭제하는 메서드는 리포지토리에서 제공하는 delete() 메서드를 사용할 경우 리턴받는 타입이 지정돼 있지 않기 때문에 void로 지정해 메서드를 구현합니다.

컨트롤러 생성


컨트롤러는 클라이언트로부터 요청을 받고 해당 요청에 대해 서비스 레이어에 구현된 적절한 메서드를 호출해서 결과값을 받습니다. 따라서 컨트롤러는 요청과 응답을 전달하는 역할만 맡는 것이 좋습니다.

컨트롤러 작성해봅니다.

package com.springboot.jpa.controller;

import com.springboot.jpa.data.dto.ChangeProductNameDto;
import com.springboot.jpa.data.dto.ProductDto;
import com.springboot.jpa.data.dto.ProductResponseDto;
import com.springboot.jpa.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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("정상적으로 삭제되었습니다.");
    }

}

지금까지 구현한 코드에는 상품정보를 조회, 저장 삭제할 수 있는 기능을 비롯해 상품정보 중 상품의 이름을 수정하는 기능이 포함돼 있습니다. 각 기능에 대한 요청은 컨틀롤러 - 서비스 - DAO - 리포지토리의 계층으을 따라 이동하고, 그것의 역순으로 응답을 전달하는 구조입니다.

이제 그럼 Swagger API를 통해 애플리케이션의 클라이언트 입장에서 기능을 요청해보고 어떻게 결과가 나타나는지 살펴보겠습니다.

이제 스웨거를 통해 createProduct 에 상품을 입력하면 실행하면 하이베네이트에 로그가 출력됩니다.

이제 최초로 실행한 상태라면 number 칼러의 값은 1로 나오고 Swagger에서 입력한 이름, 가격, 재고수량이 정상적으로 입력되고 ProductService에서 구현했던 saveProduct() 메서드를 통해 created_at과 updated_at 칼럼에 시간이 포함된 데이터가 추가됐습니다.

이제 getProduct()로 값을 가지고 오겠습니다. 그럼 앞에서 만든 조회 메서드는 number의 값을 가지고 데이터를 조회합니다.

Hibernate: 
    select
        product0_.number as number1_0_0_,
        product0_.created_at as created_2_0_0_,
        product0_.name as name3_0_0_,
        product0_.price as price4_0_0_,
        product0_.stock as stock5_0_0_,
        product0_.updated_at as updated_6_0_0_ 
    from
        product product0_ 
    where
        product0_.number=?

하이버네이트 로그에 select 쿼리가 실행된 것을 확인할 수 있습니다.

updateProductName()을 통해 상품에 이름을 수정하겠습니다.


Hibernate: 
    update
        product 
    set
        created_at=?,
        name=?,
        price=?,
        stock=?,
        updated_at=? 
    where
        number=?

update 쿼리가 실행 된것을 확인할 수 있습니다.

이제 상품정보를 삭제 해보겠습니다.

Hibernate: 
    select
        product0_.number as number1_0_0_,
        product0_.created_at as created_2_0_0_,
        product0_.name as name3_0_0_,
        product0_.price as price4_0_0_,
        product0_.stock as stock5_0_0_,
        product0_.updated_at as updated_6_0_0_ 
    from
        product product0_ 
    where
        product0_.number=?
Hibernate: 
    delete 
    from
        product 
    where
        number=?

쿼리를 보면 select 쿼리를 통해 데이터를 영속성 컨텍스트로 가져오고, 해당 객체를 삭제 요청해서 commit 단계에서 정상적으로 삭제하는 동작이 수행됬습니다.

[한걸음더] 반복되는 코드의 작성을 생략하는범 - 롬복

롬복(Lombok)은 데이터(모델) 클래스를 생성할 때 반복적으로 사용하는 getter/setter 같은 메서드를 어노테이션으로 대체하는 기능을 제공하는 라이브러리 입니다.

자바에서 데이터 클래스를 작성하면 대개 많은 멤버 변수를 선언하고, 각 멤버 변수별로 getter/setter 메서드를 만들어 코드가 길어지고 가독성이 낮아집니다. IDE에서(이클립스, 인텔리제이)에서 이러한 메서드를 자동으로 생성하는 기능을 제공하지만 여전히 가독성이 떨어집니다.

롬복을 활용한 장점

  • 어노테이션 기반으로 코드를 자동 생성하므로 생산성이 높아집니다.
  • 반복되는 코드를 생략할 수 있어 가독성이 좋아집니다.
  • 롬복을 안다면 간단하게 코들르 유추할 수 있어 유지보수에 용이합니다.
롬복을 선호하지 않은 가장 큰 이유는 코드를 어노테이션을 통해 자동으로 생성하기 때문에 개발자의 의도대로 정확하게 구현하지 못하는 경우가 발생합니다.

롬복 설치

<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<optional>true</optional>
	</dependency>

업로드중..

롬복 적용

@Entity
@Table(name = "product")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;

    private LocalDateTime createdAt;

    private LocalDateTime updatedAt;

    public Product(Long number, String name, Integer price, Integer stock, LocalDateTime createdAt,
                   LocalDateTime updatedAt){
        this.number = number;
        this.name = name;
        this.price = price;
        this.stock = stock;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
    }
    
    public Product(){
    }
    ... getter/setter 메서드 생략...
}

롬복이 적용전 Product.java

롬복이 적용후

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "product")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;

    private LocalDateTime createdAt;
    private  LocalDateTime updatedAt;
}

delombok all lombok annotation을 하면

   public Product() {
    }

    public Long getNumber() {
        return this.number;
    }

    public String getName() {
        return this.name;
    }

    public Integer getPrice() {
        return this.price;
    }

    public Integer getStock() {
        return this.stock;
    }

    public LocalDateTime getCreatedAt() {
        return this.createdAt;
    }

    public LocalDateTime getUpdatedAt() {
        return this.updatedAt;
    }

    public void setNumber(Long number) {
        this.number = number;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setPrice(Integer price) {
        this.price = price;
    }

    public void setStock(Integer stock) {
        this.stock = stock;
    }

    public void setCreatedAt(LocalDateTime createdAt) {
        this.createdAt = createdAt;
    }

    public void setUpdatedAt(LocalDateTime updatedAt) {
        this.updatedAt = updatedAt;
    }

코드라인수가 다른걸 알수 있습니다.

생성자 자동 생성 어노테이션

데이터 클래스의 초기화를 위한 생성자를 자동으로 만들어주는 어노테이션

  • NoArgsConstructor: 매개변수가 없는 생성자를 자동 생성합니다.
  • AllArgsConstructor: 모든 필드를 매개변수로 갖는 생성자를 자동 생성합니다.
  • RequiredArgsConstructor: 필드 중 final이나 @NotNull이 설정된 변수를 매개변수로 갖는 생성자를 자동 생성합니다.
profile
Interactive Developer

0개의 댓글