본문 바로가기
대외활동/DRACONIST-백엔드

스프링부트 스터디 1주차. 3단원 JPA 데이터베이스

by 피스타0204 2025. 1. 6.

1. JPA의 필요성/ 소개

 

과거에는 MyBatis와 같은 SQL매퍼를 사용해 데이터베이스의 쿼리를 작성하였습니다. 이렇게 하면 데이터베이스와 연결하는데 SQL을 다루어야 합니다.

하지만 JPA라는 java 표준 ORM(Object Relational Mapping) 기술을 사용하면 객체지향 프로그래밍을 적용할 수 있습니다.

 

 

2019년 기준 SI 회사들에서 spring& MyBatis를 사용하는 경우가 많지만 쿠팡, 우아한 형제들 NHN(naver계열) 등 자사 IT 서비스를 개발하는 곳에서는 SpringBoot & JPA를 표준으로 사용한다고 합니다.

 

현대의 웹 애플리케이션에서는 Oracle, MySQL, MSSQL등 관계형 데이터베이스(RDB, Relational DataBase)를 대부분 사용합니다. 관계형 데이터베이스는 SQL만을 인식하는데 이 때문에 각 테이블마다 CRUD(Create, Read, Update, Delete)를 매번 생성해야 합니다.

 

객체를 관계형 데이터베이스에서 관리하려면 SQL을 사용하는 것이 필수적이지만 반복적으로 SQL을 사용해야 하는 문제와, 객체지향 프로그래밍과의 패러다임 불일치 문제가 있습니다.기능과 속성을 관리하는 데 초점이 맞춰진 객체지향 프로그래밍과 데이터를 어떻게 저장하는 지에 초점이 맞춰진 관계형 데이터 베이스는 미묘하게 어긋나는 부분들이 있습니다. 이를 패러다임 불일치라고 할 수 있는데, 이를 JPA가 자동으로 맞춰줍니다. 개발자는 객체지향적으로 프로그래밍만 하면 됩니다.


JPA

**패러다임 불일치 (Object-Relational Impedance Mismatch)**는 객체지향 프로그래밍 (OOP)과 관계형 데이터베이스 (RDB) 사이의 차이로 인해 발생하는 문제를 말합니다. 이 불일치는 두 기술이 데이터를 처리하는 방식에 차이가 있기 때문에 발생합니다. 객체지향 프로그래밍에서는 객체(실세계의 개념을 표현하는 클래스)를 사용하고, 관계형 데이터베이스는 테이블(행과 열의 형태로 데이터를 관리)을 사용합니다.

 

 

  • 객체지향 프로그래밍에서는 클래스 상속을 통해 부모 클래스의 기능과 속성을 자식 클래스가 물려받습니다.관계형 데이터베이스에서는 상속 관계를 명시적으로 표현하기 어렵습니다. 각 자식 클래스에 대해 별도의 테이블을 만들어야 하거나, 하나의 테이블에 상속 관계를 반영하는 방법을 사용해야 합니다.
  • 객체지향 프로그래밍에서는 객체 간의 연관 관계를 쉽게 표현할 수 있습니다. 예를 들어, 하나의 Order 객체가 여러 개의 Product 객체를 가질 수 있습니다. 이는 객체 간의 일대다 관계입니다. 하지만 관계형 데이터베이스에서는 이런 관계를 표현하려면 외래 키(Foreign Key)와 조인 테이블 등을 사용해야 합니다.

이런 문제를 JPA를 통해 해결할 수 있는데 JPA 실제로 사용하기 위해서는 Hibernate, Eclipse Link와 같은 구현체가 필요합니다. 하지만 Spring에서는 Spring Data JPA라는 모듈을 사용해 Hibernate을 쉽게 사용할 수 있습니다.

 

JPA <- Hibernate <- Spring Data JPA

 

 

Spring Data JPA는 Hibernate가 유행이 지나거나 수명이 끝나도 쉽게 다른 구현체로 교체할 수 있고(구현체 교체의 용이성), 관계형 데이터베이스 외에 다른 저장소로 쉽게 교체할 수 있습니다(저장소 교체의 용이성).

서비스 초기에 관계형 데이터베이스를 사용하다가 트래픽이 많아져 MongoDB로 교체할 필요가 있는 상황이 와도 개발자틑 Spring Data JPA에서 Spring Data MongoDB로 의존성만 교체하면 됩니다.

 

이는 spring data의 하위 프로젝트 Spring Data JPA, Spring Data MongoDB, Spring Data Redis 등이 save(), findAll(), findOne()과 같이 동일한 CRUD 인터페이스를 가지고 있기 때문에 가능합니다. 

 

이와 같은 이유로 Hibernate과 완전히 동일한 기능을 제공함에도 Spring Data JPA의 사용이 권장됩니다.

 

하지만 JPA를 잘 쓰려면 객체지향 프로그래밍과 관계형 데이터베이스를 둘 다 이해해야 하기 때문에 러닝 커브가 가파른 편에 속합니다. 초반에 배우기 어렵다는 뜻이지요.

#가파른 러닝 커브는 기술 습득에 시간이 많이 걸리고 어려움이 많다는 것을 의미합니다.

그럼에도 JPA를 사용하면 CRUD를 일일이 작성할 필요가 없고, 부모자식 관계표현 1-N관계 표현이 가능해지고, 상태와 행위를 한 곳에서 관리할 수 있습니다. 속도 이슈또한 JPA에서 이에 대한 해결책을 이미 내놓은 상태입니다.

 


이제 3~6단원에서 하나의 웹 애플리케이션 게시판을 만들어보겠습니다. (7~10장에서는 AWS에 무중단 배포하는 법을 배울 것임)

이에 속하는 요구사항은 다음과 같습니다.

 

게시판 기능

1) 게시글 조회

2) 게시글 등록

3) 게시글 수정

4) 게시글 삭제

 

회원기능

1) 구글/네이버 로그인

2) 로그인한 사용자 글 작성 권한

3) 본인 작성 글에 대한 권한 관리


2. 프로젝트에 spring data jpa 적용하기

H2 데이터베이스를 메모리 기반으로 실행하고, JPA와 연동하여 데이터베이스 작업을 수행할 수 있습니다. 

 

1) build.gradle이 다음과 같아야 합니다.

buildscript {
    ext {
        springBootVersion = '2.6.3'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'org.example'
version = '1.0-SNAPSHOT'
sourceCompatibility = '11'  // Java 11로 변경
targetCompatibility = '11'  // Java 11로 변경

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'com.h2database:h2'
    implementation 'org.projectlombok:lombok:1.18.30'  // 최신 Lombok 버전 사용
    annotationProcessor 'org.projectlombok:lombok:1.18.30'  // Lombok 어노테이션 처리기 추가
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'junit:junit:4.13.2'
}

 

 

  • org.springframework.boot:spring-boot-starter-data-jpa는 Spring Data JPA를 사용하기 위한 의존성입니다.
  • com.h2database:h2는 H2 데이터베이스를 사용하기 위한 의존성입니다. H2는 임베디드 데이터베이스로, 테스트용이나 간단한 프로젝트에 유용하게 사용됩니다. 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화 됩니다. 이 책에서는 JPA의 테스트,로컬 환경에서의 구동에서 사용할 예정입니다.

2) domain 패키지

 

domain패키지> posts 패키지> posts.java

package com.ulbbang.book.firstproject.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 int 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

테이블과 링크될 클래스임을 나타냅니다. 기본값으로 클래스의 카멜케이스이름을 언더스코어 네이밍으로 테이블 이름을 매칭합니다.(예 : SalesManager.java -> sales_manager table)

 

@Id

해당 테이블의 기본 키(Primary Key, PK) 필드 를 나타냅니다.

Entity의 primary key는 Long type의 Auto_increment(MySQL애서 bigint타입)를 추천합니다.

 

기본키에 대해 더 이야기를 하자면
주민등록번호와 같이 비즈니스상 유니크 키나 여러 키를 조합한 복합 키로 PK를 잡을 경우 문제가 발생할 수 있습니다.

JPA에서 **기본 키(Primary Key, PK)**를 설계할 때 권장사항주의점

더보기

비즈니스 키/복합키를 PK로 사용하면 생기는 문제

(1) FK 문제

  • 설명:
    복합키(PK가 여러 필드로 구성된 경우)를 다른 테이블의 외래 키(FK)로 참조하려면, 참조 대상 테이블에 복합키 전체를 가져와야 합니다.
  • 예시:
    • 이런 복합키를 FK로 설정하려면, 다른 테이블에서 user_id, email 두 필드를 모두 포함해야 함.
    • 불필요한 데이터 중복과 복잡도가 증가함.
  • sql
    코드 복사
    PRIMARY KEY (user_id, email)

(2) 인덱스 성능 저하

  • 설명:
    PK는 자동으로 인덱스가 생성됩니다.
    복합키는 여러 컬럼으로 구성되므로, 인덱스 크기가 커지고 조회 성능이 저하됩니다.

(3) 유니크 조건 변경 시 문제 발생

  • 설명:
    비즈니스 로직에 따라 유니크 키 조건이 변경되면 PK 자체를 수정해야 합니다.
    • 예: user_id와 email을 PK로 설정했지만, 비즈니스 요구사항으로 phone_number가 추가되면 PK를 재설정해야 함.
    • PK를 변경하면 참조 중인 모든 외래 키에도 영향을 미쳐 데이터베이스 전체를 수정해야 할 수 있음.

4. 해결 방법: 비즈니스 키는 유니크 키로 별도 설정

  • 주민등록번호, 이메일 등 비즈니스 로직에서 고유해야 하는 값은 PK 대신 유니크 제약 조건을 추가하는 것이 좋습니다.
    @Column(unique = true) 사용.
  • 예시:
  • java
    코드 복사
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // Primary Key @Column(unique = true, nullable = false) private String email; // Unique Key

 

@GeneratedValue

기본키의 생성 규칙을 나타냅니다. 스프링 부트 2.0 에서는 GenerationType.IDENTITY 옵션을 추가해야만 auto_increment가 됩니다. 

 

@Column

테이블의 열을 나타내며 굳이 선언하지 않더라도 해당 클래스의 필드는 모두 column이 됩니다. 기본값 외에 추가로 변경사항이 있으면 사용합니다. 문자열은 VACHAR(255)가 기본값인데 위 코드에서는 사이즈를 500으로 늘리거난 타입을 TEXT로 변경하는 등의 경우에 사용되었습니다.

 

@NoArgsconstructor

기본 생성자를 자동으로 추가해주는 롬복 라이브러리 어노테이션

기본 생성자(파라미터가 없는 생성자)를 자동으로 생성합니다. JPA는 엔티티 초기화 시 기본 생성자를 필요로 합니다.

 

@Getter

 클래스내 모든 필드의 Getter 메소드를 자동생성해주는 롬복 라이브러리 어노테이션

 

@Builder

해당 클래스의 빌더 패턴 클래스를 생성, 생성자 상단에 선언시 생성자에 포함된 필드만 빌더에 포함 해주는 롬복 라이브러리 어노테이션

 

 


Setter 메소드를 무작정 사용하면 객체의 상태가 언제든지 변경될 수 있기 때문에, 객체가 다른 객체와 함께 동작하는 동안 불확실성을 초래할 수 있습니다. 그래서 Entity클래스에서는 절대 setter메소드를 만들지 않습니다. 그ㅐ신 해당 필드의 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가해야만 합니다.

 

# Entity 클래스는 데이터베이스의 테이블과 매핑되는 객체를 의미합니다. 보통 관계형 데이터베이스에서는 테이블이 데이터를 저장하는 구조로 사용되고, Entity 클래스는 이 테이블을 객체 지향적인 방식으로 표현하는 역할을 합니다.

 

 

setter로 값을 채우지 않고 기본적으로 생성자를 통해 최종값을 채웁니다. 값 변경이 따로 필요한 경우 해당 이벤트에 맞는 public 메소드를 호출하여 변경하는 것을 전제로 합니다.

 

이 책에서는 생성자 대신에 @Builder 로 제공되는 빌더 클래스를 사용하는 데 둘 다 값을 채워주는 것은 똑같지만 빌더의 경우 생성자와 달리 지금 채워야 할 필드가 무엇인지 확실히 지정할 수 있습니다.

//생성자
public Example(String a, String b){
    this.a =a;
    this.b = b;
}

//builder
public Builder)_
	.a(a)
    .b(b)  //b에 b를 넣는다.
    .build();

3) JpaRepositiory

posts클래스로 database에 접근하게 해줄  JpaRepositiory를 작성해봅시다.

단순 인터페이스를 생성한 후,  JpaRepositiory<Entity클래스, PK타입> 을 상속하면 기본적인 CRUD가 자동으로 생성됩니다.

단, Entity 클래스와 기본 Entity Repository는 함께 위치해야 합니다.

프로젝트 규모가 커지면 Entity 클래스와 기본 Entity Repository를 도메인 패키지에서 한번에(함께) 관리합니다.

package com.ulbbang.book.firstproject.domain.posts;

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

public interface PostsRepository extends JpaRepository<Posts, Long> {
    // 추가적인 쿼리 메서드를 작성할 수 있습니다.
}

3. 테스트 코드 작성

package com.ulbbang.book.firstproject.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 poststestLoadSaving() { //게시글 저장 불러오기
        // given
        String title = "title";
        String content = "content";

        postsRepository.save(Posts.builder()
                .title(title)
                .content(content)
                .author("@example@email.com")
                .build());

        // when
        List<Posts> postsList = postsRepository.findAll();

        // then
        Posts posts = postsList.get(0);
        assertThat(posts.getTitle()).isEqualTo(title);
        assertThat(posts.getContent()).isEqualTo(content);
    }
}

 

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

 

@After

Junit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정합니다. 보통은 배포전 전체 테스트를 수행할 때 테스트간 침범을 막기 위해 사용합니다. 테스트가 끝난 후 데이터가 그대로 남아 있으면, 다른 테스트가 영향을 받을 수 있습니다. 이를 방지하기 위해 @After 애너테이션을 활용하여 테스트 후 데이터베이스를 초기화하고, 각 테스트가 독립적으로 실행되도록 합니다.

 

postsRepository.save

테이블 posts에 insert/update 쿼리를 실행합니다. id값이 있다면 update가, 없다면 insert 쿼리가 실행됩니다.

 

postsRepository.findAll

테이블 posts에 있는 모든 데이터를 조회해오는 메소드입니다.

 

저장된 데이터의 유효성을 확인하는 부분입니다.

Posts posts = postsList.get(0); // 데이터베이스에서 가져온 첫 번째 Post 객체
assertThat(posts.getTitle()).isEqualTo(title); // 가져온 Post의 title이 주어진 title 값과 같은지 확인
assertThat(posts.getContent()).isEqualTo(content); // 가져온 Post의 content가 주어진 content 값과 같은지 확인

 

4. 쿼리 로그 확인하기

resources 폴더에 application.properties나 application.yml 파일을 생성하고 파일 안에 아래의 코드를 추가합니다.

spring.jpa.show_sql=true

 

이렇게 terminal에서 쿼리 로그를 확인할 수 있습니다.

 


4. 등록, 수정, 조회 API 만들기

api를 만들기 위해 총 3개의 클래스가 필요합니다. Request 데이터를 받을 Dto, API 요청을 받을 Controller, 트랜잭션/도메인 기능 간의 순서를 보장하는 Service

 

Service에서는 비즈니스 로직을 처리하지 않고 트랜잭션, 도메인 간 순서 보장의 역할만 합니다.

더보기

1. Web Layer (웹 계층)

웹 계층은 사용자의 요청을 받아서 처리하는 계층입니다. 보통 컨트롤러(Controllers), 예외 처리 핸들러(Exception Handlers), 필터(Filters), 뷰 템플릿(View Templates) 등을 포함합니다.

  • 컨트롤러 (Controllers): 클라이언트의 요청을 처리하고, 적절한 응답을 반환하는 역할을 합니다. Spring MVC에서는 @Controller나 @RestController 어노테이션을 사용합니다.
  • 예외 처리 핸들러 (Exception Handlers): 애플리케이션에서 발생할 수 있는 예외를 처리합니다. @ControllerAdvice를 사용하여 전역적으로 예외를 처리할 수 있습니다.
  • 필터 (Filters): 요청과 응답을 가로채어 필요한 전처리 및 후처리를 수행합니다. 예를 들어, 인증, 로깅, 성능 추적 등을 담당합니다.
  • 뷰 템플릿 (View Templates): HTML을 생성하는 템플릿 파일로, 프론트엔드와 데이터를 렌더링합니다. Spring에서는 Thymeleaf, FreeMarker 등의 템플릿 엔진을 사용합니다.

2. DTOs (Data Transfer Objects)

DTO는 데이터를 전송할 때 사용되는 객체입니다. 일반적으로 애플리케이션의 다른 계층 간에 데이터를 전달할 때 사용합니다. DTO는 데이터의 구조만 정의하며, 로직은 포함하지 않습니다.

  • 예시: 클라이언트가 요청하는 데이터를 객체로 묶어서 전송하거나, 서버가 클라이언트에게 응답할 데이터를 전송할 때 사용합니다. 예를 들어, UserDTO와 같은 객체로 사용자 정보를 클라이언트에 전달할 수 있습니다.

3. Service Layer (서비스 계층)

서비스 계층은 애플리케이션의 비즈니스 로직을 처리하는 곳입니다. 이 계층은 **응용 서비스(Application Services)**와 **인프라 서비스(Infrastructure Services)**로 나눌 수 있습니다.

  • 응용 서비스 (Application Services): 사용자의 요청을 처리하는 로직을 포함하며, 비즈니스 흐름을 담당합니다. 예를 들어, 사용자가 로그인 요청을 하면 로그인 서비스가 이 요청을 처리합니다.
  • 인프라 서비스 (Infrastructure Services): 애플리케이션의 인프라를 지원하는 서비스입니다. 예를 들어, 데이터베이스 연결, 이메일 발송, 외부 API 호출 등을 담당합니다.

4. Domain Model (도메인 모델)

도메인 모델은 애플리케이션의 핵심 비즈니스 로직을 담고 있는 계층입니다. 여기에는 도메인 서비스(Domain Services), 엔티티(Entities), **값 객체(Value Objects)**가 포함됩니다.

  • 도메인 서비스 (Domain Services): 비즈니스 로직을 구현하며, 도메인 모델의 중요한 기능을 제공합니다. 예를 들어, 주문 생성, 결제 처리 등의 작업을 처리합니다.
  • 엔티티 (Entities): 데이터베이스에 저장될 수 있는 객체로, 고유한 식별자를 가지고 있으며, 비즈니스 상태와 행동을 정의합니다. 예를 들어, User, Order와 같은 객체가 엔티티가 될 수 있습니다.
  • 값 객체 (Value Objects): 식별자가 없고, 그 자체로 의미 있는 값을 갖는 객체입니다. 예를 들어, Address, Money와 같은 객체가 될 수 있습니다.

5. Repository Layer (레포지토리 계층)

레포지토리 계층은 데이터베이스와의 상호작용을 담당합니다. 이 계층은 **레포지토리 인터페이스(Repository Interfaces)**와 그 구현체(Implementations)를 포함합니다.

  • 레포지토리 인터페이스 (Repository Interfaces): 데이터베이스에 대한 CRUD(Create, Read, Update, Delete) 작업을 정의한 인터페이스입니다. Spring Data JPA에서는 JpaRepository나 CrudRepository와 같은 인터페이스를 확장하여 사용합니다.
  • 레포지토리 구현체 (Repository Implementations): 인터페이스에서 정의한 메서드를 실제로 구현한 클래스입니다. 예를 들어, UserRepository 인터페이스를 구현한 UserRepositoryImpl 클래스가 될 수 있습니다.

Web(controller), Services, Repository, Dto, Domain이 5개 레이어에서 비즈니스 처리를 담당하는 곳은 Domain입니다.

기존에 서비스를 처리하던 방식은 트랜잭션 스크립트라고 하는데 이경우에는 모든 로직이 서비스 클래스 내부에서 처리되기 때문에 객체가 단순히 데이터 덩어리의 역할만 했습니다. 하지만 도메인 방식에서는 각 객체가 스스로의 이벤트 처리를 하고 서비스 메소드는 트랜잭션과 도메인의 순서만 보장합니다. 이 책에서는 이러한 이유때문에 도메인 모델을 다룹니다.

 

 

1) 등록 API 만들기

 

//PostsSaveRequestDto
package com.ulbbang.book.firstproject.web.dto;

import com.ulbbang.book.firstproject.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();
    }
}

 

//PostsService.java
package com.ulbbang.book.firstproject.service.posts;

import com.ulbbang.book.firstproject.domain.posts.PostsRepository;
import com.ulbbang.book.firstproject.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;

@RequiredArgsConstructor
@Service
public class PostsService {
    private final PostsRepository postsRepository;
    @Transactional
    public Long save(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }
}

 

//PostsApiController.java
package com.ulbbang.book.firstproject.web;

import com.ulbbang.book.firstproject.service.posts.PostsService;
import com.ulbbang.book.firstproject.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);
    }
}

 

controller와 service에서 Autowired를 쓰는 것은 권장하지 않고, 생성자로 Bean 객체를 받는 것이 좋습니다. RequiredArgsConstructor에서 final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해줍니다. 롬복 어노 테이션을 쓰면 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속 수정할 필요가 없습니다.

#Bean 객체는 Spring Framework에서 관리하는 객체를 의미합니다.

 

Entity클래스와 controller에서 쓸 Dto클래스는 꼭 분리해서 사용해야 합니다.


테스트 코드)

package com.ulbbang.book.firstproject.web;

import com.ulbbang.book.firstproject.domain.posts.Posts;
import com.ulbbang.book.firstproject.domain.posts.PostsRepository;
import com.ulbbang.book.firstproject.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.SpringJUnit4ClassRunner;

import java.util.List;

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

@RunWith(SpringJUnit4ClassRunner.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 testCreatePost() throws Exception { //Posts등록
        //given
        String title = "Test Title";
        String content = "Test Content";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("author").build();
        String url = "http://localhost:" + port + "/api/v1/posts";  // 수정된 URL

        //when
        ResponseEntity<Long> response = restTemplate.postForEntity(url, requestDto, Long.class);

        //then
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);  // 수정된 변수 이름
        assertThat(response.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를 사용하지 않았습니다. @WebMvcTest의 경우 JPA기능이 작동하지 않기 때문인데 Controller와 controllerAdvice 등 외부 연동과 관련된 부분만 활성화되니 지금 같이 JPA기능까지 한번에 테스트할 때에는 @SpringBootTest와 TestRestTemplate을 사용해야 합니다.

 

WebEnvironment.RANDOM_PORT로 인한 랜덤포트 실행을 확인했습니다.  insert 쿼리가 실행된 것는 모르겠다...


2) 수정 , 조회 api 만들기

Dto는  Entity 필드 중 일부만 사용하고, 생성자로 Entity를 받아 필드에 값을 넣습니다. 모든 필드를 가진 생성자가 필요하지는 않으므로 Dto는 Entity를 받아 처리합니다.

main.zip
0.01MB

 

 

JPA의 영속성 컨텍스트 때문에 update기능에서 데이터 베이스에 쿼리를 날리는 부분이 없습니다.

영속성 컨텍스트란 엔티티를 영구 저장하는 환경입니다. 일종의 논리적 개념으로 JPA 핵심은 엔티티가 영속성 컨텍스트에 포함되어있냐 아니냐로 나뉩니다.

 JPA의 Entity Manager가 활성화된 상태로(Spring Data Jpa를 쓴다면 디폴트 옵션) 트랜잭션 안에서 데이터베이스에서 데이처를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태입니다.

이 상채에서 해당 데이터 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영합니다. 이것을 Dirty checking이라고 합니다.

영속성 컨텍스트의 특징

  • 1차 캐시: 엔티티를 메모리에 캐싱하여, 동일한 엔티티에 대해 반복적인 데이터베이스 조회를 방지합니다.
  • 변경 감지 (Dirty Checking): 엔티티의 상태가 변경되면, 이를 감지하여 자동으로 데이터베이스 업데이트 쿼리를 실행합니다.

테스트 코드)

//PostsApiControllerTest
package com.ulbbang.book.firstproject.web;

import com.ulbbang.book.firstproject.domain.posts.Posts;
import com.ulbbang.book.firstproject.domain.posts.PostsRepository;
import com.ulbbang.book.firstproject.web.dto.PostsSaveRequestDto;
import com.ulbbang.book.firstproject.web.dto.PostsUpdateRequestDto;
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.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import java.util.List;

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

@RunWith(SpringJUnit4ClassRunner.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 testCreatePost() throws Exception { //Posts등록
        //given
        String title = "Test Title";
        String content = "Test Content";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("author").build();
        String url = "http://localhost:" + port + "/api/v1/posts";  // 수정된 URL

        //when
        ResponseEntity<Long> response = restTemplate.postForEntity(url, requestDto, Long.class);

        //then
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);  // 수정된 변수 이름
        assertThat(response.getBody()).isGreaterThan(0L);
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }
    @Test
    public void PostsEdited() throws Exception {
        //given
        Posts savedPosts = postsRepository.save(Posts.builder()
                .title("Test Title")
                .content("Test Content")
                .author("Test author").build());
    Long updatedId = 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/" + updatedId;

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

 

 

application.properties에 다음과 같은 옵션을 추가하여 웹 콘솔을 활성화할 수 있습니다.

spring.jpa.show_sql=true
spring.h2.console.enabled=true

spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

spring.datasource.url=jdbc:h2:mem:testdb

  • 역할: H2 데이터베이스의 연결 URL을 지정합니다.
  • jdbc:h2:mem:testdb는 메모리 기반 데이터베이스를 사용하도록 설정합니다.
    • mem:testdb: 메모리 상에서만 실행되며 애플리케이션 종료 시 데이터가 삭제됩니다

이제 main의 Application.java를 실행하면 http://localhost:8080/h2-console 에서 웹 콘솔에 접속 할 수 있습니다.

이 콘솔로 값을 집어넣고, http://localhost:8080/api/v1/posts/1에서 확인하면 API를 조회할 수 있습니다.

 

자 이제 기본적인 등록/수정/조회 기능은 모두 만들고 테스트했습니다. 등록/수정은 테스트 코드로 보호해주고 있으니 변경사항이 있어도 안전하게 변경할 수 있습니다.

 

5. JPA Auditing으로 생성시간, 수정시간 자동화하기

보통 엔티티에는 해당 데이터의 생성시간, 수정시간이 포함됩니다. 그렇게 하기 위해 DB에 삽입하기 전, 갱신하기 전에 날짜 데이터를 등록, 수정하는 코드가 여기저기 들어갑니다.

 

// 생성일 추가 코드 예제
public void savePosts(){
	...
    posts.setCreateDate(new LocalDate());
    postsRepository.save(posts);
    ...
}

 

이런 코드가 계속 반복되어 포함되는 문제를 해결하기 위해 JPA Auditing을 사용할 것입니다.

Java8부터 LocalDate와 LocalDateTime이 등장했씁니다. JAVA기본 날짜 타입인 Date의 문제점을 고쳤기 때문에 이제 저거 씁니다.

 

package com.ulbbang.book.firstproject.domain.posts;

import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {
    @CreatedDate
    private LocalDateTime createdDate;
    @LastModifiedDate
    private LocalDateTime modifiedDate;
}

 

 

BaseTimeEntity 클래스는 모든 Entity의 상위 클래스가 되어 Entity들의 createdDate, modifiedDate를 자동으로 관리하는 역할을 합니다.

 

MappedSuperclass

JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들(createdDate, modifiedDate)도 칼럼으로 인식하도록 합니다.

 

EntityListeners(AuditingEntityListener.class)

BaseTimeEntity 클래스에 어 Auditing(감사) 기능을 활성화할 수 있습니다. Auditing은 엔티티의 생성 및 수정 시간과 같은 정보를 자동으로 관리하는 데 유용합니다.

 

CreatedDate

Entity가 생성되어 저장될 때 시간이 자동 저장됩니다.

 

LastModifiedDate

조회한 Entity의 값을 변경할 때 시간이 자동 저장됩니다.

 

Posts클래스를 수정해 BaseTimeEntity를 상속받도록 합니다.

package com.ulbbang.book.firstproject.domain.posts;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Getter
@NoArgsConstructor
@Entity
public class Posts extends BaseTimeEntity{

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

    @Column(length = 500, nullable = false)
    private String title;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

    private String author;

    // Fix constructor name to match the class name
    @Builder
    public Posts(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }
    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

 

 

application 파일에도 jPA Auditing 어노테이션을 활성화합시다.

package com.ulbbang.book.firstproject;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing //JPA Auditing 활성화
@SpringBootApplication
public class Application {
    public static void main(String[] args){
        SpringApplication.run(Application.class, args);
    }
}

 

JPA Auditing 테스트 코드)

    @Test
    public void BaseTimeEntity_engagement(){  //등록
        //given
        LocalDateTime now = LocalDateTime.of(2020, 1, 1, 0, 0);
        postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        //when
        List<Posts> postsList = postsRepository.findAll();

        //then
        Posts posts = postsList.get(0);

        System.out.println(">>>>>>>>> createDate="+posts.getCreatedDate()
                +", modifiedDate="+posts.getModifiedDate());

        assertThat(posts.getCreatedDate()).isAfter(now);
        assertThat(posts.getModifiedDate()).isAfter(now);

    }

 

앞으로 추가될 엔티티들은 BaseTimeEntity를 상속받아 등록일, 수정일을 해결할 수 있습니다.

 

다음장에서는 템플릿 엔진을 이용하여 화면을 만들어보겠습니다.