[Spring Boot] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - 2

xyzw·2023년 3월 25일
0

Spring

목록 보기
2/22

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

JPA 소개

JPA의 필요성

관계형 데이터베이스와 객체지향 프로그래밍 언어 간의 문제를 해결하기 위해 등장

  • 단순 반복 작업의 문제
    객체를 관계형 데이터베이스에서 관리하는 것이 중요한데, 관계형 데이터베이스를 사용해야만 하는 상황에서 SQL을 반복적으로 많이 만들고 유지보수해야 한다.
  • 패러다임 불일치 문제
    관계형 데이터베이스와 객체지향 프로그래밍 언어의 패러다임이 서로 다른데, 객체를 데이터베이스에 저장하려고 하니 여러 문제가 발생한다.

JPA의 장점

더이상 SQL에 종속적인 개발을 하지 않아도 된다.

  • 개발자는 객체지향적으로 프로그래밍을 하고, JPA가 이를 관계형 데이터베이스에 맞게 SQL을 대신 생성해서 실행

Spring Data JPA

JPA는 인터페이스로서 자바 표준명세서이다.
인터페이스인 JPA를 사용하기 위해서 구현체가 필요한데, 구현체들을 좀 더 쉽게 사용하고자 추상화시킨 Spring Data JPA라는 모듈을 이용하여 JPA를 다룬다.

JPA <- Hibernate <- Spring Data JPA

  • 구현체 교체의 용이성
    Hibernate 외에 다른 구현체로 쉽게 교체하기 위함
    Hibernate가 언젠가 수명을 다해서 새로운 JPA 구현체가 대세로 떠오를 때, Spring Data JPA를 사용 중이라면 내부에서 구현체 매핑을 지원해주기 때문에 아주 쉽게 교체할 수 있다.

  • 저장소 교체의 용이성
    관계형 데이터베이스 외에 다른 저장소로 쉽게 교체하기 위함
    트래픽이 많아져 관계형 데이터베이스로는 도저히 감당이 안 될 때가 올 수 있는데, 이때 MongoDB로 교체가 필요하다면 개발자는 Spring Data JPA에서 Spring Data MongoDB로 의존성만 교체하면 된다.

Spring Data의 하위 프로젝트들은 기본적인 CRUD의 인터페이스가 같다. 따라서 저장소가 교체되어도 기본적인 기능은 변경할 것이 없다.

실무에서 JPA

JPA를 잘 쓰려면 객체지향 프로그래밍과 관계형 데이터베이스를 둘 다 이해해야 한다.
장점

  • CRUE 쿼리를 직접 작성할 필요가 없다.
  • 부모-자식 관계 표현
  • 1:N 관계 표현
  • 상태와 행위를 한 곳에서 관리

3장~6장: 하나의 게시판(웹 어플리케이션) 만들기
7장~10장: AWS에 무중단 배포

요구사항 분석

이 게시판의 요구사항

  • 게시판 기능
    • 게시글 조회
    • 게시글 등록
    • 게시글 수정
    • 게시글 삭제
  • 회원 기능
    • 구글 / 네이버 로그인
    • 로그인한 사용자 글 작성 권한
    • 본인 작성 글에 대한 권한 관리

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

1. build.gradle에 다음 코드를 추가한다.

dependencies {
	...
    // 스프링 부트용 Spring Data Jpa 추상화 라이브러리
    // 스프링 부트 버전에 맞춰 자동으로 JPA 관련 라이브러리들이 버전을 관리
    implementation('org.springframework.boot:spring-boot-starter-data-jpa')
    // 인메모리 관계형 데이터베이스
    // 메모리에서 실행되기 대문에 어플리케이션을 재시작할 때마다 초기화된다는 점을 이용하여 테스트 용도로 많이 사용
    implementation('com.h2database:h2')
    ...
}

2. src/main/java/com.jojoldu.book.springboot 패키지에 domain 패키지 생성

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

3. 이 패키지에 post 패키지와 Posts 클래스 생성

실제 DB의 테이블과 매칭될 클래스이며 보통 Entity 클래스라고도 한다.
JPA를 사용하면 DB 데이터에 작업할 경우 실제 쿼리를 날리기보다는, 이 Entity 클래스의 수정을 통해 작업한다.

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

Entity 클래스에서는 절대 Setter 메소드를 만들지 않는다.

대신, 해당 필드의 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가해야만 한다.

DB에 값을 삽입하는 방법

생성자를 통해 최종값을 채운 후 DB에 삽입한다.
값 변경이 필요한 경우 해당 이벤트에 맞는 public 메소드를 호출하여 변경한다.

@Builder를 통해 빌더 클래스 사용

생성자를 사용하면 지금 채워야 할 필드가 무엇인지 명확히 지정할 수 없으므로, 생성자 대신 빌더 클래스를 사용한다.

4. 이 패키지에 PostsRepository 인터페이스 생성

Posts 클래스로 Database를 접근하게 해줄 JpaRepository이다.
인터페이스를 생성한 후, JpaRepository<Entity 클래스, PK 타입>를 상속하면 기본적인 CRUD 메소드가 자동으로 생성된다.

Entity 클래스와 기본 Entity Repository는 함께 위치해야 한다.


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

1. test 디렉토리에 domain.post 패키지 생성

2. 이 패키지에 PostsRepositoryTest 테스트 클래스 생성

save, findAll 기능을 테스트한다.

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

import org.aspectj.lang.annotation.After;
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;

import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(SpringExtension.class)
@SpringBootTest
public 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("jojoldu@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: @AfterEach로 변경되었다. Junit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정한다. 보통은 배포 전 전체 테스트를 수행할 때 테스트 간 데이터 침범을 막기 위해 사용한다.

postRepository.save

  • 테이블 posts에 insert/update 쿼리를 실행한다.
  • id 값이 있다면 update가, 없다면 insert 쿼리가 실행된다.

postRepository.findAll

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

5. 테스트 메소드 실행

실행된 쿼리를 로그로 확인하는 방법

  1. src/main/resources 디렉토리 아래에 application.properties 파일을 생성한다.
spring.jpa.show_sql=true
  1. 테스트 수행

  2. 콘솔에서 쿼리 로그 확인
    현재 H2의 쿼리 문법이 적용되어있다. MySQL 버전으로 바꿔보자.

  3. application.properties에 다음 코드를 추가한다.
    책과는 상이한 버전으로 진행 중이어서 오류가 발생하였는데, 아래 링크를 참고하여 해결하였다.
    https://github.com/jojoldu/freelec-springboot2-webservice/issues/612

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.datasource.hikari.jdbc-url=jdbc:h2:mem://localhost/~/testdb;MODE=MYSQL
spring.h2.console.enabled=true
  1. 다시 테스트 코드 수행

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

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

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

Spring 웹 계층

Web Layer

  • 흔히 사용하는 컨트롤러(@Controller)와 JSP/Freemarker 등의 뷰 템플릿 영역
  • 이외에도 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice) 등 외부 유청과 응답에 대한 전반적인 영역

Service Layer

  • @Service에 사용되는 서비스 영역
  • 일반적으로 Controller와 Dao의 중간 영역에서 사용
  • @Transactional이 사용되어야 하는 영역

Repository Layer

  • Database와 같이 데이터 저장소에 접근하는 영역

Dtos

  • Dto(Data Transfer Object)는 계층 간에 데이터 교환을 위한 객체, Dtos는 이들의 영역
  • ex) 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등

Domain Model

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

비즈니스 처리를 담당해야 할 곳: Domain


등록

1. PostsApiController를 web 패키지에 생성

package com.jojoldu.book.springboot.web;


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

스프링에서 생성자로 Bean 객체를 받도록 하면 @Autowired와 동일한 효과를 볼 수 있다. 생성자는 @RequiredArgsConstructor에서 생성해준다.

2. PostsSaveRequestDto를 web.dto 패키지에 생성

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

import com.jojoldu.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();
    }
}

3. PostsService를 service.posts 패키지에 생성

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

import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.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();
    }
}

Entity 클래스와 거의 유사한 형태임에도 Dto 클래스를 추가로 생성한 이유는 둘을 꼭 분리해서 사용해야 하기 때문이다.

  • Entity 클래스는 절대로 Request/Response 클래스로 사용해서는 안 된다. 이는 데이터베이스와 맞닿은 핵심 클래스로, Entity 클래스 기준으로 테이블이 생성되고, 스키마가 변경된다.
  • Request/Response용 Dto는 View를 위한 클래스로, 자주 변경이 필요하다. Entity 클래스는 변경되면 여러 클래스에 영향을 미친다. Controller에서 결과값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하므로 View Layer와 DB Layer의 역할 분리를 철저하게 하는 게 좋다.

4. test 패키지 중 web 패키지에 PostApiControllerTest 클래스 생성

package com.jojoldu.book.springboot.web;

import com.jojoldu.book.springboot.domain.posts.Posts;
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import org.aspectj.lang.annotation.After;
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;

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

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostApiControllerTest {

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

HelloController오 달리 @WebMvcTest를 사용하지 않았다. @WebMvcTest의 경우 JPA 기능이 작동하지 않기 때문인데, Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화되니 지금 같이 JPA 기능까지 한번에 테스트할 때는 @SpringBootTest와 TestRestTemplate을 사용하면 된다.

5. 테스트 수행


수정

1. PostsApiController에 다음 코드 추가

@RequiredArgsConstructor
@RestController
public class PostsApiController {
    ...
    
    @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);
    }
}

2. PostsResponseDto를 web.dto 패키지에 생성

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

import com.jojoldu.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();
    }
}

이 클래스는 Entity의 필드 중 일부만 사용하므로 생성자로 Entity를 받아 필드에 값을 넣는다.

3. PostsUpdateRequestDto를 위 패키지에 생성

package com.jojoldu.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;
    }
}

4. Posts 클래스에 다음 코드 추가

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

5. PostsService에 다음 코드 추가

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

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

import javax.transaction.Transactional;

@RequiredArgsConstructor
@Service
public class 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의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다.
JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.
이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다. 즉, Entity 객체의 값만 변경하면 별도로 update 쿼리를 날릴 필요가 없다. 이 개념을 더티 체킹이라고 한다.

6. PostsApiControllerTest에 다음 코드 추가

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostApiControllerTest {
	...
    
    @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).getTitle()).isEqualTo(expectedTitle);
        assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
    }
}

7. 테스트 코드 수행


조회

톰캣을 실행해서 확인

1. 웹 콘솔 옵션 활성화

application.properties에 다음 옵션 추가

spring.h2.console.enabled=true

2. Application 클래스의 main 메소드 실행

0개의 댓글