BACKEND/Spring

[Spring Boot/JPA] JPA 의존성 추가, 도메인 개발

도라프 2023. 2. 15. 21:44

이 글은 책 스프링부트와 AWS로 혼자 구현하는 웹 서비스를 참고하여 작성되었습니다.

 

프로젝트에 Spring Data Jpa 적용하기

build.gradle 파일 dependencies  다음 코드를 추가한 후 빌드 해준다. (망치모양 누르기)

compileOnly 'org.springframework.boot:spring-boot-starter-data-jpa'
compileOnly 'com.h2database:h2'

그리고 지난 번에 web 패키지를 만들었던 패키지 아래 domain 패키지를 만든다.

여기서 도메인이란 게시글, 댓글, 회원, ... 등 소프트웨어에 대한 요구사항 혹은 문제 영역이라고 해석하면 된다.

기존에 MyBatis같은 쿼리 매퍼를 사용했다면 dao 패키지를 떠올리겠지만, 그것과는 결이 다르다.

xml에 쿼리를 담고, 클래스는 오로지 쿼리의 결과만 담던 일들이 모두 도메인 클래스 안에서 해결된다./

 

이제 도메인 패키지에 엔티티를 설계할 것이다.

 

domain 패키지 아래 oneMeal 패키지를 생성하고 OneMeal 클래스를 만든 다음 아래 코드를 작성했다.

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;

import java.time.LocalDateTime;

@Getter
@Entity
public class oneMeal {

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

    @Column
    @CreatedDate
    private LocalDateTime mealTime;

    @Column
    private int meal_Kcal;

    @Column
    private int meal_Carbon;

    @Column
    private int meal_Fat;

    @Column
    private int meal_Protein;

    @Builder
    public oneMeal(LocalDateTime mealTime, int meal_Kcal, int meal_Carbon, int meal_Fat, int meal_Protein){
        this.mealTime = mealTime;
        this.meal_Kcal = meal_Kcal;
        this.meal_Fat = meal_Fat;
        this.meal_Carbon = meal_Carbon;
        this.meal_Protein = meal_Protein;
    }
}

 

이렇게 식사량을 파악한 도메인을 설계해주었다. 

이 oneMeal 클래스를 보면 Setter 메소드가 없는 것을 알 수 있다. 엔티티 클래스에는 절대 Setter 메소드를 만들지 않든다. 대신 해당 필드의 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가해야한다. 

 

그렇다면 Setter가 없는 상황에서 어떻게 값을 채워 DB에 삽입해야 할까?

 

기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것이며, 값 변경이 필요한 경우 해당 이벤트에 맞는 public 메소드를 호출하여 변경하는 것을 전제로 한다.

 

이 글에서는 생성자 대신에 @Builder를 통해 제공되는 빌더 클래스를 사용한다. 생성자나 빌더나 생성 시점에 값을 채워주는 역할은 똑같다. 다만, 생성자의 경우 지금 채워야 할 필드가 무엇인지 명확히 지정할 수 없다.

 

예를 들어 다음과 같은 생성자가 있다면 개발자가 new Example(b,a) 처럼 a와 b의 위치를 변경해도 코드를 실행하기 전까지는 문제를 찾을 수 없다.

public Example(String a, String b){	
	this.a = a;
    	this.b = b;
}

하지만 빌더를 사용하게 되면 다음과 같이 어느 필드에 어느 값을 채워야 할지 명확하게 인지할 수 있다.

Example.builder()
	.a(a)
	.b(b)
    	.build();

 

OneMeal 클래스 생성이 끝났다면 oneMeal 패키지 아래 OneMeal 클래스로 Database를 접근하게 해줄 JpaRepository를 생성해보자.

 

import org.springframework.data.jpa.repository.JpaRepository;

public interface OneMealRepository extends JpaRepository<OneMeal, Long> {
}

보통 ibatis나 MyBatis 등에서 Dao라고 불리는 DB Layer 접근자이다.

 

JPA에서는 Repository라고 부르며 인터페이스로 생성한다. 단순히 인터페이스 생성 후 JpaRepository<Entity 클래스, PK타입>을 상속하면 기본적인 CRUD 메소드가 자동으로 생성된다.

 

@Repository를 추가할 필요도 없다.

 

❗️주의할 점은 Entity 클래스의 기본 Entity Repository는 함께 위치해야 하는 점이다. 둘은 아주 밀접한 관계이고 Entity 클래스는  기본 Repository 없이는 제대로 역할을 할 수가 없다.

 

프로젝트 규모가 커져 도메인별로 프로젝트를 분리해야 한다면 도메인 패키지에서 함께 관리해야한다.

 

Spring Data JPA 테스트 코드 작성하기

test 디렉토리에 domain.oneMeal 패키지를 생성하고, 테스트 클래스는 OneMealRepositoryTest란 이름으로 생성했다.

OneMealRepositoryTest에서는 다음과 같이 save, findAll 기능을 테스트한다.

 

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.time.LocalDateTime;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest
public class OneMealRepositoryTest {
    @Autowired
    OneMealRepository oneMealRepository;

    @After //Junit단위 테스트가 끝날 때 마다 수행되는 메소드를 여러 테스트가 동시에 수행되면 테스트용 데이터베이스인 H2에 데이터가 그대로 남아있을 수 있음
    public void cleanup() {
        oneMealRepository.deleteAll();
    }

    @Test
    public void 한끼저장_불러오기() {
        LocalDateTime mealTime = LocalDateTime.now();
        int kcal = 170;
        int carbon = 13;
        int fat = 20;
        int protein = 10;

        oneMealRepository.save(OneMeal.builder()
                .mealTime(mealTime)
                .meal_Kcal(kcal)
                .meal_Carbon(carbon)
                .meal_Fat(fat)
                .meal_Protein(protein)
                .build());

        //when
        List<OneMeal> oneMealList = oneMealRepository.findAll();

        //then
        OneMeal oneMeal = oneMealList.get(0);
        assertThat(oneMeal.getMeal_Carbon()).isEqualTo(carbon);
        assertThat(oneMeal.getMealTime()).isEqualTo(mealTime);
        assertThat(oneMeal.getMeal_Fat()).isEqualTo(fat);
        assertThat(oneMeal.getMeal_Protein()).isEqualTo(protein);
        assertThat(oneMeal.getMeal_Kcal()).isEqualTo(kcal);
    }
}

 

별다른 설정 없이 @SpringBootTest를 사용할 경우 H2 데이터베이스를 자동으로 실행해준다.

이 테스트 역시 실행할 경우 H2가 자동으로 실행된다.

다음과 같이 테스트가 통과했다.(휴..)

 

여기서 한 가지 궁금증이 생긴다. "실제고 실행된 쿼리는 어떤 형태일까?", 그렇다면 실행된 쿼리의 로그를 볼 수 없을까? src/main/resources디렉토리 안에 application.properties 파일에 다음 코드를 추가해준다.

spring.jpa.show_sql = true

그리고 테스트를 다시 실행시키면

이렇게 쿼리 로그를 확인할 수 있다.

 

그런데 이 create 쿼리를 보면

다음과 같이 id bigint generated by default as identity라는 옵션으로 생성된다. 이는 H2의 쿼리 문법이 적용되었기 때문이다.

 

등록/수정/조회 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 영역으로 이해하면 쉽다.

 

Dtos
Dto는 계층 간에 데이터 교환을 위한 객체를 이야기하며 Dtos는 이들의 영역을 얘기한다.
예를 들어 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기한다.

 

Domain Model
도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화 시킨 것을 도메인 모델이라고 한다.
이를테면 택시 앱이라고 하면 배차, 탑승, 요금 등이 모두 도메인이 될 수 있다.
@Entity를 사용해봤다면, @Entity가 사용된 영역 역시 도메인 모델이라고 이해하면 된다.
다만, 무조건 데이터베이스의 테이블과 관계가 있어야 하는 것은 아니다. VO처럼 값 객체들도 이 영역에 해당하기 때문이다.

이 5가지 레이어에서 비지니스 처리를 담당해야하는 곳은 바로 Domain이다.

 

프로젝트 구조를 수정하였다.

 

수정된 구조를 기반의 내용은 다음 글에서 설명할 예정이다.