ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [3장] 스프링 부트에서 JPA로 데이터베이스 다뤄보자
    Spring/스프링 부트와 AWS로 혼자 구현하는 웹 서비스 2022. 11. 17. 13:16

    JPA: 자바 표준 ORM

     

    • ORM: 객체를 매핑
    • SQL Mapper: 쿼리를 매핑

    패러다임 불일치

    • 관계형 데이터베이스: 어떻게 데이터를 저장하는지에 초점
    • 객체지향 프로그래밍: 기능과 속성을 한 곳에서 관리하는 기술
      • => 서로의 패러다임이 다른데 객체를 DB에 저장하려니 문제 발생

    👉 JPA: 객체지향 프로그래밍 언어와 관계형 데이터베이스 중간에서 패러다임 일치를 시켜주는 기술

    • SQL에 종속적인 개발을 하지 않을 수 있게 됨
    • 객체 중심으로 개발 => 생산성 향상 & 유지 보수 용이해짐

    Spring Data JPA

    JPA는 인터페이스이기 때문에 구현체가 필요함

    • Hibernate
    • Eclipse Link

    하지만, Spring에서 JPA를 사용할 때는 이 구현체들을 직접 다루지는 않음

    구현체들을 좀 더 쉽게 사용할 수 있도록 추상화시킨 Spring Data JPA라는 모듈을 이용해서 JPA 기술을 다룸

     

    • JPA ← Hibernate ← Spring Data JPA

     

    Hibernate와 큰 차이가 없지만 Spring Data JPA를 만들어 사용을 권장하는 이유

    • 구현체 교체의 용이성 : Hibernate 외에 다른 구현체로 쉽게 교체하기 위함
    • 저장소 교체의 용이성 : 관계형 데이터베이스 외에 다른 저장소로 쉽게 교체하기 위함

     


     

    게시판 만들기 

     

    Spring Data JPA 적용

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

    build.gradle에 JPA와 h2 데이터베이스 의존성 추가

     

    • spring-boot-starter-data-jpa
      • 스프링부트용 Spring Data Jpa 추상화 라이브러리
      • 스프링부트 버전에 맞춰서 자동으로 JPA관련 라이브러리들의 버전을 관리해줌
    • h2
      • 인메모리 관계형 데이터베이스
      • 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화된다는 점을 이용하여 테스트 용도로 많이 사용됨

     

    Posts 클래스

    package com.example.techeerteama1.springboot.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 Long 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
      • JPA의 어노테이션
      • 테이블과 링크될 클래스임을 나타냄
      • 카멜케이스 => 언더스코어 네이밍 (SalesManager => sales_manager) 기본적으로 해줌
    • @Getter
      • 롬복의 어노테이션
      • 클래스 내 모든 필드의 Getter 메소드를 자동생성
    • @NoArgsConstructor
      • 롬복의 어노테이션
      • 기본 생성자 자동 추가
    • @Id
      • 해당 테이블의 PK 나타냄
    • @GenerationValue
      • PK의 생성 규칙을 나타냄
      • GenerationType.AUTO 가 기본값
      • identity와 auto의 차이점
        • JPA는 영속성 컨텍스트에서 객체를 관리하기 위해서는 무조건 PK값이 있어야 함
        • (KEY값으로 해당 객체의 값을 넣기 때문)
        • auto의 경우, 애플리케이션에서는 그 값이 얼마인지 알지 못하기 때문에 DB에 insert문을 날려야만 id(PK)값을 알 수 있음
        • 하지만, identity의 경우, 기본키 생성을 DB에 위임하기 때문에 id가 null일 경우 해당 객체의 Id를 DB의 auto_increment를 가져와서 할당함
    • @Column
      • 테이블의 칼럼
      • 선언하지 않아도 해당 클래스의 필드는 모두 칼럼이 됨
      • 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용하기 위함
    • @Builder
      • 해당 클래스의 빌더 패턴 클래스를 생성
      • 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함

     

    📎 @Setter는 하지 않는 이유

    • 해당 클래스의 인스턴스 값들이 언제 어디서 변해야 하는지 코드상으로 명확하게 구분할 수 없어, 차후 변경 시 매우 복잡해짐
    • 따라서, Entity 클래스에서는 절대 Setter 메소드를 만들지 않음

     

    PostsRepository 인터페이스

    package com.example.techeerteama1.springboot.domain.posts;
    
    import org.springframework.data.jpa.repository.JpaRepository;
    
    public interface PostsRepository extends JpaRepository<Posts, Long> {
    }
    • JpaRepository: Posts 클래스로 Database를 접근하게 해줌
    • 인터페이스 생성 + JpaRepository<Entity 클래스, PK타입>을 상속하면 기본적인 CRUD 메소드가 자동 생성됨
    • Entity 클래스와 기본 Repository 인터페이스는 함께 위치해야 함!

     


     

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

     

    PostsRepositoryTests 클래스

    package com.example.techeerteama1.springboot.domain.posts;
    
    
    
    import static org.assertj.core.api.Assertions.assertThat;
    import org.junit.jupiter.api.AfterEach;
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.test.context.junit.jupiter.SpringExtension;
    
    import java.util.List;
    
    @ExtendWith(SpringExtension.class)
    @SpringBootTest
    class PostsRepositoryTest {
    
        @Autowired
        PostsRepository postsRepository;
    
        @AfterEach
        public void cleanup() {
            postsRepository.deleteAll();
        }
    
        @Test
        public void 게시글저장_불러오기() {
            //given
            String title = "테스트 게시글";
            String content = "테스트 본문";
    
            postsRepository.save(Posts.builder()
                            .title(title)
                            .content(content)
                            .author("yeonjy@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);
        }
    
    }

     

    책에는 JUnit4를 써서인지 @After으로 나와있는데 JUnit5에서는 @AfterEach라고 한다.

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

    테스트 성공

     

     

    📎 실제로 실행된 쿼리의 형태는?

    • 쿼리 로그 ON/OFF 가능 
      • Java 클래스로 구현 가능하지만, 스프링 부트에서는 아래의 방법 권장
    • application.properties 등의 파일로 한 줄의 코드로 설정

    resources 디렉토리에 해당 파일 생성 후 아래의 코드 입력

    spring.jpa.show_sql=true

     

    이후 다시 테스트를 실행하면 위와 같은 쿼리 로그 확인 가능

    id bigint generated by default as identity라는 옵션으로 create table된다.

    이는 H2의 쿼리 문법이 적용되었기 때문!

    => H2는 MySQL의 쿼리를 수행해도 정상적으로 작동하기 떄문에 이후 디버깅을 위해서 출력되는 쿼리 로그를 MySQL버전으로 변경 => 아래의 코드 추가

    spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect

     

     

    🧨위의 코드를 추가하고 다시 테스트를 실행하니 아래와 같은 오류가 발생했다.

    org.springframework.dao.InvalidDataAccessResourceUsageException: could not prepare statement;

    Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "POSTS" not found (this database is empty);

     

    https://github.com/jojoldu/freelec-springboot2-webservice/issues/67#issuecomment-832692299

     

    P.100 properties 추가 시 작동 오류 · Issue #67 · jojoldu/freelec-springboot2-webservice

    안녕하세요~ 조졸두님 책으로 공부하고 있는 베니라고 합니다! 100 페이지에서 application.properties에 MySQL5InnoDBDialect 추가 시 밑에 이미지와 같이 나오고 있습니다! 그리고 해당 클래스를 보면 /** A D

    github.com

    위의 코드를 그대로 입력했더니 auto_increment 로그가 뜨면서 테스트도 통과했다.

     

     

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

    API를 만들기 위해 필요한 클래스 3개

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

    📎Service에서 비지니스 로직 처리 X

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

    비지니스 로직을 처리하는 곳은 Domain!!

     

     

    PostsApiController

    package com.example.techeerteama1.springboot.web;
    
    import com.example.techeerteama1.springboot.service.posts.PostsService;
    import com.example.techeerteama1.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.example.techeerteama1.springboot.service.posts;
    
    import com.example.techeerteama1.springboot.domain.posts.PostsRepository;
    import com.example.techeerteama1.springboot.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();
        }
    }
    

     

    📎 Controller와 Service에서 @Autowired를 안 쓴 이유

     

    • 스프링에서 Bean을 주입받는 방식
      • @Autowired
      • setter
      • 생성자

        👉 가장 권장하는 방식은 생성자로 주입받는 방식!!

                => 생성자는 어디있나?!

    @RequiredArgsConstructor가 final이 선언된 모든 필드의 인자값으로 하는 생성자 생성해줌!

     

     

    PostsSaveRequestDto

    package com.example.techeerteama1.springboot.web.dto;
    
    import com.example.techeerteama1.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();
        }
    }
    

     

    📎 Entity와 거의 유사한 형태임에도 Dto 클래스를 생성한 이유

    • Entity 클래스는 절대 Request/Response 클래스로 사용해서는 안 됨
    • Entity 클래스는 데이터베이스와 맞닿은 핵심 클래스
      • 화면 변경은 아주 사소한 기능인데, 이를 위해 테이블과 연관된 Entity 클래스를 변경하는 것은 너무 큰 변경
      • Entity 클래스는 많은 클래스와 연관되어있음 => Request/Response용 DTO는 View를 위한 클래스이기 때문에 자주 변경될 것
      • 따라서, View Layer와 DB Layer의 역할 분리를 철저히 하는 것 권장

     

    HelloApiControllerTests

    package com.example.techeerteama1.springboot.web;
    
    import com.example.techeerteama1.springboot.domain.posts.Posts;
    import com.example.techeerteama1.springboot.domain.posts.PostsRepository;
    import com.example.techeerteama1.springboot.web.dto.PostsSaveRequestDto;
    import static org.assertj.core.api.Assertions.assertThat;
    import org.junit.jupiter.api.AfterEach;
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    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.test.web.server.LocalServerPort;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.ResponseEntity;
    import org.springframework.test.context.junit.jupiter.SpringExtension;
    
    import java.util.List;
    
    @ExtendWith(SpringExtension.class)
    @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
    public class HelloApiControllerTest {
    
        @LocalServerPort
        private int port;
    
        @Autowired
        private TestRestTemplate restTemplate;
    
        @Autowired
        private PostsRepository postsRepository;
    
        @AfterEach
        public void tearDown() throws Exception {
            postsRepository.deleteAll();
        }
    
        @Test
        public void Posts_등록된다() 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);
        }
    }
    

    HelloControllerTest와 달리, @WebMvcTest를 사용하지 않은 이유는 @WebMvcTest가 JPA 기능이 작동하지 않기 때문

    => JPA 기능까지 한번에 테스트할 때는 @SpringBootTest를 사용

     

    테스트 통과

     


    수정/조회 기능 구현

     

    PostsApiController

    package com.example.techeerteama1.springboot.web;
    
    import com.example.techeerteama1.springboot.service.posts.PostsService;
    import com.example.techeerteama1.springboot.web.dto.PostsSaveRequestDto;
    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);
        }
        
        @PostMapping("/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.example.techeerteama1.springboot.web.dto;
    
    import com.example.techeerteama1.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를 받아 필드에 값을 넣음

    굳이 모든 필드를 가진 생성자가 필요하진 않으므로 Dto는 Entity를 받아 처리함

     

     

    PostsUpdateRequestDto

    package com.example.techeerteama1.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 클래스에 추가

    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }

     

    PostsService 클래스에 추가

    @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의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태임
        • 이 상태에서 해당 데이터를 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영
      • = dirty checking 더티 체킹

     

    PostsApiController에 추가

    @Test
    public void Posts_수정된다() throws Exception {
        //given
        Posts savedPosts = postsRepository.save(Posts.builder()
                        .title("title")
                        .content("content")
                        .author("author")
                        .build());
    
        Long updateId = 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/" + updateId;
    
        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).getContent()).isEqualTo(expectedContent);
                            
    }

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

     

Designed by Tistory.