Super Kawaii Cute Cat Kaoani
본문 바로가기
⚙️ Back-end/Spring

[Spring Boot] 2주차 스터디 : 3장 스프링 부트에서 JPA로 데이터 베이스를 다뤄보자

by wonee1 2025. 1. 14.
728x90

✏️ 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 책을 보면서 스터디 한 내용을 정리하였습니다 

 

https://www.yes24.com/product/goods/83849117

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - 예스24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

 

 

 

 

 

3장 스프링 부트에서 JPA로 데이터 베이스를 다뤄보자


 

 

💜JPA 소개


  • 자바 표준 ORM
  • 객체를 관계형 데이터베이스에서 관리하는 것 중요
  • 개발자는 객체지향적 프로그래밍을 하고, JPA가 이를 관계형 데이터베이스에 맞게 SQL을 대신 생성해서 실행한다

 

💜Spring Data JPA


  • JPA는 인터페이스로서 자바 표준 명세서이다
  • 인터페이스인 JPA를 사용하기 위해선 구현체가 필요하다

→ 구현체들을 좀 더 쉽게 사용하고자 추상화 시킨 모듈이 Spring Data JPA이다

 

 

💜요구사항 분석


3장에서 6장까지 하나의 게시판 CRUD를 만들어본 후 7장부터 10장까지 AWS에 무중단 배포!

 

 

 

 

💜프로젝트에 Spring Data JPA 적용시키기


  1. build.gradle에 의존성을 등록한다
  • spirng-boot-starter-data-jpa
    • 스프링 부트용 Spring Data JPA 추상화 라이브러리
    • 스프링 부트 버전에 맞춰 자동으로 JPA 관련 라이브러리들의 버전을 관리해준다
  • h2
    • 인메모리 관계형 데이터베이스
    • 별도의 설치 필요 없이 프로젝트 의존성만으로 관리
    • 메모리에서 실행되기 때문에 애플리케이션 재시작할 때마다 초기화됨

    2. domain 패키지 생성

 

 

 

  • 도메인을 담을 패키지
  • 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제 영역

 

  3. posts 패키지와 Posts 클래스 생성 

 

 

 

 

 

    4. Posts 클래스 코드 작성

package com.jojoIdu.book.springboot.domain.posts;

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

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Getter //6 롬복의 어노테이션
@NoArgsConstructor //5 롬복의 어노테이션 
@Entity //1 JPA의 어노테이션
public class Posts {
    @Id //2
    @GeneratedValue(strategy = GenerationType.IDENTITY) //3
    private int id;

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

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

    private String author;

    @Builder //7
    public Posts(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }//실제 DB의 테이블과 매칭된 클래스, Entity 클래스 

}

 

어노테이션 순서를 주요 어노테이션을 클래스에 가깝게 둔다

롬복은 코드를 단순화 시켜주지만 필수 어노테이션은 아니다!

→ 주요 어노테이션인 Entity를 클래스에 가깝게 두고 롬복 어노테이션을 그 위로 둔 것

 

💡Posts 클래스에서 JPA에서 제공하는 어노테이션

  • @Entity
    • 테이블과 링크될 클래스임을 나타낸다
    • 기본값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍으로 테이블 이름을 매칭한다
    • 예 : SalesManager.java -> sales_manager table
  • @Id
    • 해당 테이블의 기본 키(Primary Key, PK) 필드를 나타낸다.
  • @GeneratedValue
    • 기본키의 생성 규칙
    • 스프링 부트 2.0 에서는 GenerationType.IDENTITY 옵션을 추가해야만 auto_increment가 된다.
  • @Column
    • 테이블의 열을 나타내며 굳이 선언하지 않더라도 해당 클래스의 필드는 모두 column이 된다.
    • 기본값 외에 추가로 변경 사항이 있으면 사용한다
  • @NoArgsconstructor
    • 기본 생성자 자동 추가
    • public Posts(){ } 와 같은 효과
  • @Getter
    • 클래스내 모든 필드의 Getter 메소드를 자동 생성
  • @Builder
    • 해당 클래스의 빌더 패턴 클래스를 생성,
    • 생성자 상단에 선언시 생성자에 포함된 필드만 빌더에 포함

 

 

Posts 클래스엔 Setter 메소드가 없다!

→ Entity 클래스에선 절대 Setter 메소드를 만들지 않는다 (무작정 게터 세터가 생성되는 것을 막기 위해서)

❓Setter가 없는 이 상황에서 어떻게 값을 채워 DB에 삽입

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

이 책은 생성자 대신에 @Builder를 통해 제공되는 빌더 클래스 사용!

 

 

 

 5. Posts 클래스로 Database를 접근하게 해줄 JpaRepository 생성

 

 6. 코드 작성

package com.jojoIdu.book.springboot.domain.posts;

import org.springframework.data.jpa.repository.JpaRepository;
public interface PostsRepository extends  JpaRepository<Posts, Long>{
}

  • 인터페이스로 생성
  • **JpaRepository<Entity 클래스. PK 타입>**를 상속하면 기본적인 CRUD 메소드 자동 생성
  • Entity 클래스는 기본 Repository 없이 제대로 역할을 할 수가 없다

 

 

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


  1. PostRepositoryTest 클래스 생성 및 코드 작성

 

 

package com.jojoIdu.book.springboot.web.domain.posts;

import com.jojoIdu.book.springboot.domain.posts.Posts;
import com.jojoIdu.book.springboot.domain.posts.PostsRepository;
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 = "test title";
        String content = "test content";

        postsRepository.save(Posts.builder()
                .title(title)
                .content(content)
                .author("jojoIdu@gmail.com")
                .build());

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

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

  • @After
    • Junit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정
    • 보통은 배포 전 전체 테스트를 수행할 때 테스트간 침범을 막기 위해 사용
    • 여러 테스트가 동시에 수행되면 테스트용 데이터베이스인 H2에 데이터가 그대로 남아 있어 다음 테스트 실행 시 테스트가 실패할 수 있다
  • postsRepository.save
    • 테이블 posts에 insert/update 쿼리를 실행
    • id값이 있다면 update가, 없다면 insert 쿼리가 실행
  • postsRepository.findAll
    • 테이블 posts에 있는 모든 데이터를 조회해오는 메소드

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

 

 

 

 

2. 테스트 실행

 

 

 

3. applicaiton.properties 파일 생성

 

 

 

 

💜등록/수정/조회 API 만들기


API를 만들기 위해 총 3개의 클래스가 필요하다

  • Request 데이터를 받을 Dto
  • API 요청을 받을 Controller
  • 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service

Service에서 비지니스 로직을 처리해야한다

Service는 트랜잭션, 도메인 간 순서 보장의 역할만 한다

 

 

 

  • Web Layer
    • 사용하는 컨트롤러(@Controller)와 JSP/Freemarker 등의 뷰 템플릿 영역
    • 이외에도 필터(@Filter) , 인터셉터, 컨트롤러 어드바이스 (@ControllerAdvice)외부 요청과 응답에 대한 전반적인 영역을 이야기
  • Service Layer
    • @Service에 사용되는 서비스 영역입니다
    • 일반적으로 Controller와 Dao의 중간 영역에서 사용
    • @Transactional이 사용되어야 하는 영역
  • Repository Layer
    • Database와 같이 데이터 저장소에 접근하는 영역
    • 기존에 개발하셨던 분들이라면 Dao (Data Access Object) 영역 이해할 것
  • Dtos
    • Dto(Data Transer Object)는 계층 간에 데이터 교환을 위한 객체를 이야기 하며 Dtos는 이들의 영역을 얘기한다
    • 예를 들어 뷰 템플릿 엔진에서 사용될 객체Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기
  • Domain Model
    • 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것을 도메인 모델
    • @Entity가 사용된 영역 역시 도메인 모델이라고 이해하면 된다
    • VO처럼 값 객체들도 이 영역에 해당

 

비즈니스 처리를 하는 곳은 Domain

이와 관련된 설명이 자세히 나온 블로그 https://rookie-programmer.tistory.com/208

 

Controller, Service, Repository에 Dto 객체 추가해 실습하기

자바 어노테이션 어노테이션은 자바 코드에 추가되는 특별한 형태의 메타데이터로, 클래스, 메서드, 변수 등에 대한 추가적인 정보를 제공 어노테이션이 가능한 기술적 근거는 java의 리플렉션,

rookie-programmer.tistory.com

 

 

 

1. 등록 기능 추가

파일 구조

 

 

PostsApiController

package com.jojoIdu.book.springboot.web;

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

}

 

 

PostsService

package com.jojoIdu.book.springboot.web;

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

}

 

💠스프링에선 Bean을 주입 받는 방식

  • @Autowired
  • setter
  • 생성자

💠가장 권장하는 방식 → 생성자로 주입 받는 방식 (@Autowired는 권장하지 않는다)

생성자로 Bean 객체를 받도록하면 @Autowired와 동일한 효과를 볼 수 있다

 

💠final이 선언된 모든 필드를 인자값으로 하는 생성자를 롬복의 @RequiredArgsConstructor가 대신 생성해준다

 

💠생성자를 안쓰고 롬복 어노테이션을 사용한 이유?

→ 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정해야 하는 번거로움을 해결하기 위해

 

 

PostsSaveRequestDto

package com.jojoIdu.book.springboot.web.dto;

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

}

💠 Controller와 Service에서 사용하는 Dto 클래스

💠 Entity 클래스를 Request/Response 클래스로 사용하면 안된다 → Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스이기 때문에

수많은 서비스 클래스나 비즈니스 로직들이 Entity 클래스를 기준으로 동작한다

💠Entity 클래스가 변경되면 여러 클래스에 영향을 끼치지만, Request와 Response용 Dto는 View를 위한 클래스라 자주 변경이 필요하다

💠View Layer와 DB Layer의 역할 분리 중요

Entity 클래스와 Controller에서 쓸 Dto는 분리해서 사용 할 것 !

 

 

2. 테스트 코드

 

 

 

 

package com.jojoIdu.book.springboot.web;

import com.jojoIdu.book.springboot.domain.posts.Posts;
import com.jojoIdu.book.springboot.domain.posts.PostsRepository;
import com.jojoIdu.book.springboot.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.SpringRunner;

import java.util.List;

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

@RunWith(SpringRunner.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 postUpload() throws Exception{
        //given
        String title = "title";
        String content = "content";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

        String url = "<http://localhost>:"+port +"/api/v1/posts";

        //when
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, 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(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);

    }

}

JPA 기능까지 한번에 테스트할 때는 @SpringBootTest와 TestRestTemplate을 사용하면 된다

 

 

🚨 오류 발생 처리

 

jdk 설정 오류가 발생 → c 드라이브로 다시 깔아서 오류 해결

.java:190)
	at org.gradle.api.internal.tasks.testing.junit.JUnitTestClassExecutor.runTestClass(JUnitTestClassExecutor.java:112)
	at org.gradle.api.internal.tasks.testing.junit.JUnitTestClassExecutor.execute(JUnitTestClassExecutor.java:58)
	at org.gradle.api.internal.tasks.testing.junit.JUnitTestClassExecutor.execute(JUnitTestClassExecutor.java:40)
	at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestClassProcessor.processTestClass(AbstractJUnitTestClassProcessor.java:60)
	at org.gradle.api.internal.tasks.testing.SuiteTestClassProcessor.processTestClass(SuiteTestClassProcessor.java:52)
	at java.base@17.0.10/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base@17.0.10/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
	at java.base@17.0.10/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base@17.0.10/java.lang.reflect.Method.invoke(Method.java:568)
	at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:36)
	at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:24)
	at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
	at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:94)
	at jdk.proxy1/jdk.proxy1.$Proxy2.processTestClass(Unknown Source)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:176)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:129)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:100)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:60)
	at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
	at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:113)
	at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:65)
	at app//worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
	at app//worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "POSTS" not found; SQL statement:
select posts0_.id as id1_0_, posts0_.author as author2_0_, posts0_.content as content3_0_, posts0_.title as title4_0_ from posts posts0_ [42102-200]
	at org.h2.message.DbException.getJdbcSQLException(DbException.java:453)
	at org.h2.message.DbException.getJdbcSQLException(DbException.java:429)
	at org.h2.message.DbException.get(DbException.java:205)
	at org.h2.message.DbException.get(DbException.java:181)
	at org.h2.command.Parser.readTableOrView(Parser.java:7628)
	at org.h2.command.Parser.readTableFilter(Parser.java:1970)
	at org.h2.command.Parser.parseSelectFromPart(Parser.java:2827)
	at org.h2.command.Parser.parseSelect(Parser.java:2959)
	at org.h2.command.Parser.parseQuerySub(Parser.java:2817)
	at org.h2.command.Parser.parseSelectUnion(Parser.java:2649)
	at org.h2.command.Parser.parseQuery(Parser.java:2620)
	at org.h2.command.Parser.parsePrepared(Parser.java:868)
	at org.h2.command.Parser.parse(Parser.java:843)
	at org.h2.command.Parser.parse(Parser.java:815)
	at org.h2.command.Parser.prepareCommand(Parser.java:738)
	at org.h2.engine.Session.prepareLocal(Session.java:657)
	at org.h2.engine.Session.prepareCommand(Session.java:595)
	at org.h2.jdbc.JdbcConnection.prepareCommand(JdbcConnection.java:1235)
	at org.h2.jdbc.JdbcPreparedStatement.(JdbcPreparedStatement.java:76)
	at org.h2.jdbc.JdbcConnection.prepareStatement(JdbcConnection.java:352)
	at com.zaxxer.hikari.pool.ProxyConnection.prepareStatement(ProxyConnection.java:337)
	at com.zaxxer.hikari.pool.HikariProxyConnection.prepareStatement(HikariProxyConnection.java)
	at org.hibernate.engine.jdbc.internal.StatementPreparerImpl$5.doPrepare(StatementPreparerImpl.java:149)
	at org.hibernate.engine.jdbc.internal.StatementPreparerImpl$StatementPreparationTemplate.prepareStatement(StatementPreparerImpl.java:176)
	... 96 more

2025-01-13 16:41:17.597  INFO 6136 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2025-01-13 16:41:17.600  INFO 6136 --- [ionShutdownHook] .SchemaDropperImpl$DelayedDropActionImpl : HHH000477: Starting delayed evictData of schema as part of SessionFactory shut-down'
Hibernate: drop table if exists posts
2025-01-13 16:41:17.617  INFO 6136 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2025-01-13 16:41:17.637  INFO 6136 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.
> Task :test
com.jojoIdu.book.springboot.domain.posts.PostsRepositoryTest > PostsTestLoadSaving FAILED
    org.springframework.dao.InvalidDataAccessResourceUsageException at PostsRepositoryTest.java:34
        Caused by: org.hibernate.exception.SQLGrammarException at PostsRepositoryTest.java:34
            Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException at PostsRepositoryTest.java:34
    org.springframework.dao.InvalidDataAccessResourceUsageException at PostsRepositoryTest.java:25
        Caused by: org.hibernate.exception.SQLGrammarException at PostsRepositoryTest.java:25
            Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException at PostsRepositoryTest.java:25
1 test completed, 1 failed
> Task :test FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':test'.
> There were failing tests. See the report at: file:///D:/SpringBootStudy/springboot2-webservice/build/reports/tests/test/index.html
* Try:
> Run with --scan to get full insights.

Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0.

You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.

For more on this, please refer to <https://docs.gradle.org/8.4/userguide/command_line_interface.html#sec:command_line_warnings> in the Gradle documentation.
BUILD FAILED in 31s
4 actionable tasks: 2 executed, 2 up-to-date

 

 

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

 

 

3. 수정 조회 기능 추가

 

PostApiController

package com.jojoIdu.book.springboot.web;

import com.jojoIdu.book.springboot.domain.posts.PostsRepository;
import com.jojoIdu.book.springboot.web.dto.PostsResponseDto;
import com.jojoIdu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoIdu.book.springboot.service.posts.PostsService;
import com.jojoIdu.book.springboot.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;

    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto){

        return postsService.save(requestDto);
    }

    @PutMapping("/api/v1/posts/{id}")
    public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto){
        return postsService.update(id, requestDto);
    }

    @GetMapping("/api/v1/posts/{id}")
    public PostsResponseDto findById (@PathVariable Long id){
        return postsService.findById(id);
    }

}

 

PostsResponseDto

package com.jojoIdu.book.springboot.web.dto;

import com.jojoIdu.book.springboot.domain.posts.Posts;
import lombok.Getter;

@Getter
public class PostsResponseDto {
    private  Long id;
    private String title;
    private String content;
    private String author;

    public PostsResponseDto(Posts entity){
        this.id = entity.getId();
        this.title= entity.getTitle();
        this.content = entity.getContent();
        this.author= entity.getAuthor();
    }

}

  • PostsResponseDto는 Entity의 필드 중 일부만 사용한다 ⇒ 생성자로 Entity를 받아 필드에 값을 넣는다

PostsUpdateRequestDto

package com.jojoIdu.book.springboot.web.dto;

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

@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
    private  String title;
    private String content;

    @Builder
    public PostsUpdateRequestDto(String title, String content){
        this.title=title;
        this.content = content;

    }

}

 

 

Posts

package com.jojoIdu.book.springboot.domain.posts;

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

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Getter //6
@NoArgsConstructor //5
@Entity //1
public class Posts {
    @Id //2
    @GeneratedValue(strategy = GenerationType.IDENTITY) //3
    private Long id;

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

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

    private String author;

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

}

 

 

PostsService

package com.jojoIdu.book.springboot.service.posts;

import com.jojoIdu.book.springboot.domain.posts.Posts;
import com.jojoIdu.book.springboot.domain.posts.PostsRepository;
import com.jojoIdu.book.springboot.web.dto.PostsResponseDto;
import com.jojoIdu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoIdu.book.springboot.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class PostsService {
    private final PostsRepository postsRepository;
    @Transactional
    public Long save(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }
    @Transactional
    **public Long update(Long id, PostsUpdateRequestDto requestDto) {
        Posts posts = postsRepository.findById(id).orElseThrow(
                ()->new IllegalArgumentException("해당 게시물이 없습니다. id="+id));
        posts.update(requestDto.getTitle(), requestDto.getContent());
        return id;
    }**
    public PostsResponseDto findById(Long id) {
        Posts entity = postsRepository.findById(id).orElseThrow(
                ()-> new IllegalArgumentException("해당 게시글이 없습니다. id="+id));
        return new PostsResponseDto(entity);

    }
}

  • update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없음 → JPA의 영속성 컨텍스트 덕분

🔹영속성 컨세스트란?

  • 엔티티를 영구 저장하는 환경
  • JPA의 핵심 내용은 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다
  • JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스 데이터를 가져오면 데이터는 영속성 컨텍스트가 유지된 상태
    • 이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블 변경분 반영, 즉 Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다 (더티 체킹)

 

 

Update 쿼리를 수행하는 지 확인하기 위한 테스트 코드

package com.jojoIdu.book.springboot.web;

import com.jojoIdu.book.springboot.domain.posts.Posts;
import com.jojoIdu.book.springboot.domain.posts.PostsRepository;
import com.jojoIdu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoIdu.book.springboot.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.SpringRunner;

import java.util.List;

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

@RunWith(SpringRunner.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 postUpload() throws Exception{
        //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";

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

}

JPA를 씀으로 좀 더 객체 지향적으로 코딩할 수 있다

 

 

applicaition 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 데이터베이스를 사용하도록 설정한 것

  • H2 데이터베이스: 가볍고 빠른 임베디드형 DB.
  • mem:testdb: 메모리에 데이터베이스를 생성하며, 이름은 testdb.
  • 특징:
    • 휘발성: 애플리케이션 종료 시 데이터가 삭제됨.
    • 테스트 용도: 테스트나 프로토타이핑에 적합.

 

4. 테스트 실행

  • spring.h2.console.enabled=true 를 추가한 뒤 Application 클래스의 main 메소드를 실행
  • http://localhost:8080/h2-console 로접속하면 다음과 같은 웹 콘솔 화면 등장
  • JDBC URL을 jdbc:h2:mem:testdb로 설정

 

 

  • h2를 관리할 수 있는 관리 페이지로 이동 후 간단한 쿼리 실행 확인

 

 

 

 

💜JPA Auditing으로 생성시간/수정 시간 자동화하기


엔티티에는 해당 데이터의 생성 시간과 수정 시간을 포함한다 그렇기 때문에 언제 만들어졌는지 언제 수정되었는지 등의 정보가 매우 중요하며 매번 DB에 삽입하기 전 갱신 하기전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어간다

→ 이런 단순하고 반복적인 코드가 많아지면 코드가 지저분해진다 따라서 JPA Auditing을 사용한다

 

 

LocalDate 사용

 

Java8 부터 LocalDate와 LocalDateTime이 등장했다

 

 

 

 

BaseTimeEntity

package com.jojoIdu.book.springboot.domain.posts;

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

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

@Getter
@MappedSuperclass //1 
@EntityListeners(AuditingEntityListener.class)//2
public class BaseTimeEntity {
    @CreatedDate//3
    private LocalDateTime createdDate;
    @LastModifiedDate//4
    private LocalDateTime modifiedDate;
}
  • @MappedSupeclass
    • JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들(createdDate, modifiedDate)도 칼럼으로 인식하도록 한다.
  • @EntityListeners(AuditingEntityListener.class)
    • BaseTime Entity 클래스에 Auditing 기능을 포함시킨다
  • @CreatedDate
    • Entity가 생성되어 저장될 때 시간이 자동 저장된다
  • @LastModifiedDate
    • 조회한 Entity가 값을 변경할 때 시간이 자동 저장된다

Posts 클래스가 BaseTimeEntity를 상속받도록 변경한다

public class Posts extends BaseTimeEntity{
    ...

}

jpa auditing 어노테이션들을 모두 활성화 할 수 있도록 Application 클래스에 활성화 어노테이션 하나 추가

package com.jojoIdu.book.springboot;
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 테스트 코드 작성하기


PostsRepositoryTest 수정

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

    }

 

 

✅결과 화면

 

728x90