스프링 부트 시작(개발부터 빌드까지) - 4

PM/iOS Developer KimKU·2022년 8월 18일
1
post-thumbnail

4. 스프링 부트에서 JPA로 데이터베이스

웹 서비스를 개발하고 운영하다 보면 피할 수 없는 문제가 데이터베이스를 다루는 일이다. 그러다 보니 실제로 개발하는 시간보다 SQL을 다루는 시간이 더 많아진다. 이것이 이상하게 느껴졌다. 분명 "객체지향 프로그래밍을 배웠는데 왜 객체지향 프로그래밍을 못하지?" 라는 생각을 계속했다. 객체 모델링보다는 테이블 모델링에만 집중하고, 객체를 단순히 테이블에 맞추어 데이터 전달 역할만 하는 개발은 분명 기형적인 형태였다.

어떻게 하면 관계형 데이터베이스를 이용하는 프로젝트에서 객체지향 프로그래밍을 할 수 있을까 고민했다. 그러던 중, 이미 기존의 Java 개발자들도 역시 이 고민을 하고 있었음을 알게 되었다. 문제의 해결책으로 JPA 라는 자바 표준 ORM 기술을 만나게 된다. 이번에는 JPA를 프로젝트에 적용해 보겠다.

아직 SI 환경에서는 Spring & MyBatis를 많이 사용하지만, 쿠팡, 우아한형제들, NHN 등 자사 서비스를 개발하는 곳에서는 SpringBoot & JPA 를 전사 표준으로 사용하고 있다. 그 외 나머지 서비스 기업들 역시 기존 프로젝트 환경을 개편하시는 분들은 JPA 를 선택하고 있다. 자사 서비스를 운영하는 회사에선 점점 더 많이 사용되고 있으므로 이런 곳으로 가고자 하는 사람들은 이번 기회에 꼭 시작해 보았으면 한다.

4.1 JPA 소개

현대의 웹 애플리게이션에서 관계형 데이터베이스는 빠질 수 없는 요소이다. Oracl, MySQL, MSSQL 등을 쓰지 않는 웹 애플리케이션은 거의 없다. 그러다 보니 객체를 관계형 데이터베이스에서 관리 하는 것이 무것보다 중요하다.

관계형 데이터베이스가 계속해서 웹 서비스의 중심이 되면서 모든 코드는 SQL 중심이 되어간다.

현업 프로젝트 대부분이 애플리케이션 코드보다 SQL로 가득하게 된 것이다.

이는 관계형 데이터베이스가 SQL 만 인식할 수 있기 때문인데, SQL로만 가능하니 각 테이블마다 기본적인 CRUD SQL 을 매번 생성해야 한다. 예를 들어 User 객체를 테이블로 관리한다면 아래의 코드를 피할 수 없다.

insert into user(id, name, ...) value (...);
select * fromuser where ...;
update user set ... wherer ...;
delete from user where ...;

개발자가 아무리 자바 클래스를 아름답게 설계해도, SQL을 통해야만 데이터베이스에 저장하고 조회할 수 있다. 결국, 관계형 데이터베이스를 사용해야만 하는 상황에서 SQL은 피할 수 없다.

이 반복적인 SQL을 얼마나 많이 만들어야 할까? 실제 현업에서는 수십, 수백 개의 테이블이 있는데, 이 테이블의 몇 배의 SQL 을 만들고 유지 보수 해야만 한다. 단순 반복 작업을 수백 번 해야 하는 것만큼 스트레스 쌓이는 일은 없다.

이런 단순 박복 작업의 문제 외에도 한 가지 문제가 더 있다. 그건 바로 패러다임 불일치 문제이다. 관계형 데이터베이스는 어떻게 데이터를 저장할지에 초점이 맞춰진 기술이다. 반대로 객체지향 프로그래밍 언어는 메시지를 기반으로 기능과 속성을 한 곳에 관리하는 기술이다.

C++ 혹은 자바라는 언어를 배운 사람들은 추상화, 캡슐화, 정보은닉, 다형성 등 여러 정의를 배운 것을 떠올려보면 된다.

관계형 데이터베이스와 객체지향 프로그래밍 언어의 패러다임이 서로 다른데, 객체를 데이터베이스에 저장하려고 하니 여러 문제가 발생한다. 이를 패러다임 불일치라고 한다. 객체지향 프로그래밍에서 부과 되는 객체를 가져오려면 어떻게 해야 할까?

User user = findUser();
Group group = user.getGroup();

누구나 명확하게 User 와 Group은 부모-자식 관계임을 알 수 있다. User 가 본인이 속한 Group 을 가져온 코드이기 때문이다. 하지만 여기에 데이터베이스가 추가되면 다음과 같이 변경된다.

User user = userDto.findUser();
Group group = groupDao.findGroup(user.getGroupId());

User 따로, Group 따로 조회하게 된다. User 와 Group 이 어떤 관계인지 알 수 있을까? 상속, 1:N 등 다양한 객체 모델링을 데이터베이스로는 구현할 수 없다. 그러다 보니 웹 애플리케이션 개발은 점점 데이터베이스 모델링에만 집중하게 된다. JPA 는 이런 문제점을 해결하기 위해 베이스 모델링에만 집중하게 된다. JPA 는 이런 문제점을 해결하기 위해 등장하게 된다.

서로 지향하는 바가 다른 2개 영역을 중간에서 패러다임 일치를 시켜주기 위한 기술이다. 즉, 개발자는 객체지향적으로 프로그래밍을 하고, JPA 가 이를 관계형 데이터베이스에 맞게 SQL 을 대신 생성해서 실행한다. 개발자는 항상 객체지향적으로 코드를 표현할 수 있으니 더는 SQL 에 종속적인 개발을 하지 않아도 된다.

객체 중심으로 개발을 하게 되니 생산성 향상은 물론 유지 보수하기가 정말 편하다. 이런 점 때문에 규모가 크고 365일 24시간, 대규모 트래픽과 데이터를 가진 서비스에서 JPA 는 점점 표준 기술로 자리 잡고 있다.

4.2 Spring Data JPA

JPA 는 인터페이스로서 자바 표준명세서이다. 인터페이스인 JPA를 시용하기 위해서는 구현체가 필요하다. 대표적으로 Hibernate, Eclipse Link 등이 있다. 하지만 Spring 에서 JPA를 사용할 때는 이 구현체들을 직접 다루진 않는다.

구현체들을 좀 더 쉽게 사용하고자 추상화시킨 Spring Data JPA라는 모듈을 이용하여 JPA 기술을 다룬다. 이들의 관계를 보면 다음과 같다.

  • JPA <- Hibernate <- Spring Data JPA

Hibernate 를 쓰는 것과 Spring Data JPA 를 쓰는 것 사이에는 큰 사이가 없다. 그럼에도 스프링 진영에서는 Spring Data JPA 를 개발했고, 이를 권장하고 있다.

이렇게 한 다녜 더 감싸놓은 Spring Data JPA 가 등장한 이유는 크게 두 가지가 있다.

  • 구현체 교체의 용이성
  • 저장소 고체의 용이성

먼저 '구현체 교체의 용이성' 이란 Hibernate 외에 다른 구현체로 쉽게 교체하기 위함이다.

Hibernate 가 언젠가 수명을 다해서 새로운 JPA 구현체가 대세로 떠오를 때, Spring Data JPA 를 쓰는 중이라면 아주 쉽게 교체할 수 있다. Spring Data JPA 내부에서 구현체 매핑을 지원해주기 때문이다. 실제로 자바의 Redis 클라이언트가 Jedis 에서 Lettucc 로 대세가 넘어갈 때 Spring Data Redis 를 사용한 개발자들은 아주 쉽게 교체를 했다.

다음으로 '저장소 교체의 용이성' 이란 관계형 데이터베이스 외에 다른 저장소로 쉽게 교체하기 위함이다. 서비스 초기에는 관계형 데이터베이스로 모든 기능을 처리했지만, 점점 트래픽이 많아져 관계형 데이터베이스로는 도저히 감당이 안 될 때가 있을 수 있다. 이때 MongoDB 로 교체가 필요하다면 개발자는 Spring Data JAP 에서 Spring Data MongoDB 로 의존성만 교체하면 된다.

이는 Spring Data 의 하위 프로젝트들은 기본적인 CRUD 의 인터페이스 같기 때문이다. 즉, Spring Data JPA, Spring Data Redis, Spring Data MongoDB 등 Spring Data 의 하위 프로젝트들은 save( ), findAll, findOne( ) 등을 인터페이스로 갖고 있다. 그러다 보니 저장소가 교체되어도 기본적인 기능은 변경할 것이 없다. 이런 장점들로 인해 Hibernate 를 직접 쓰기보다는 Spring 팀에서 계속해서 Spring Data 프로젝트를 권장하고 있다.

4.3 실무에서 JPA

실무에서 JPA 를 사용하지 못하는 가장 큰 이유로 높은 러닝 커브를 이야기한다. 이점은 나도 동의한다. JPA 를 잘 쓰려면 객체지향 프로그래밍과 관계형 데이터베이스를 둘 다 이해해야 한다.

하지만 그만큼 JPA 를 사용해서 얻는 보상은 크다. 가장 먼저 CRUD 쿼리를 직접 작성할 필요가 없다. 또한, 부모-자식 관계 표현, 1:N 관계 표현, 상태와 행위를 한 곳에서 관리하는 등 객체지향 프로그래밍을 쉽게 할 수 있다.

속도 이수는 없을까 하는 걱정이 있을 거라 생각한다. JPA에서는 여러 성능 이슈 해결책들을 이미 준비해놓은 상태이기 때문에 이를 잘 활용하면 네이티브 쿼리만큼의 퍼포먼스를 낼 수 있다.

4.4 요구사항 분석

앞으로 하나의 게시판을 만들어보고 후에는 이 서비스를 AWS에 무중단 배포 하는것까지 진행한다.

이 게시판의 요구사항은 다음과 같다.

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

어떤 웹 애플리케이션을 만들더라도 기반이 될 수 있게 보편적이지만 필수 기능들은 모두 구현하게 된다.

4.5 프로젝트에 Spring Data JPA 적용하기

먼저 build.gradle 에 다음과 같이 'org.springframework.boot:spring-boot-starter-data-jpa' 와 'com.h2database:h2' 의존성을 등록하자.

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

코드 설명하겠다.

spring-boot-starter-data-jpa

  • 스프링 부트용 Spring Data Jpa 추상화 라이브러리이다.
  • 스프링 부트 버전에 맞춰 자동으로 JPA 관련 라이브러리들의 버전을 관리해 준다.

h2

  • 인메모리 관계형 데이터베이스이다.
  • 별도의 설치가 필요 없이 프로젝트 의존성만으로 관리할 수 있다.
  • 메모리에서 실행되기 때문에 애플리게이션을 재시작할 때마다 초기화된다는 점을 이용하여 테스트 용도로 많이 사용한다.
  • 이 책에서는 JPA 의 테스트, 로컬 환경에서의 구동에서 사용할 예정이다.

의존성이 등록되었다면, 본격적으로 JPA 기능을 사용해 보겠다. 다음과 같이 패키지를 만든다.
d
이 domain 패키지는 도메인을 담을 패키지이다. 여기서 도메인이란 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제 영역이라고 생각하면 된다.

기존에 MyBatis 외 같은 쿼리 매퍼를 사용했다면 dao 패키지를 떠올리겠지만, dao 패키지와는 조금 결이 다르다고 생각하면 된다. 그간 xml에 쿼리를 담고, 클래스는 오로지 쿼리의 결과만 담던 일들이 모두 도메인 클래스라고 불리는 곳에서 해결된다.

도메인이란 용어가 조금 어색할 수 있다. 과정이 하나씩 진행될 때마다 어떤 이야기인지 몸으로 느낄 수 있다.

domain 패키지에 posts 패키지와 posts 클래스를 만든다.

Posts 클래스의 코드는 다음과 같다.

package com.example.springbootwebsite.domain;

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 필드를 나타낸다.

@GeneratedValue

  • PK의 생성 규칙을 나타낸다.
  • 스프링 부트 2.0 에서는 GenerationType.IDENTITY 옵션을 추가해야만 auto_increment 가 된다.

@Column

  • 테이블의 칼럼을 나타내며 굳이 선언하지 않더라도 해당 클래스의 필드는 모두 칼럼이 된다.
  • 사용하는 이유는, 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용한다.
  • 문자열의 경우 VARCHAR(255) 가 기본값인데, 사이즈를 500으로 늘리고 싶거나, 타입을 TEXT로 변경하고 싶거나 등의 경우에 사용된다.

@NoArgsConstructor

  • 기본 생성자 자동 추가
  • public Posts( ) { } 와 같은 효과

@Getter

  • 클래스 내 모든 필드의 Getter 메소드를 자동생성

@Builder

  • 해당 클래스의 빌더 패턴 클래스 생성
  • 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함

나는 어노테이션 순서를 주요 어노테이션을 클래스에 가깝게 둔다. 이렇게 어노테이션을 정렬하는 기준은 다음과 같다.

@Entity 는 JPA의 어노테이션이며, @Getter 와 @NoArgsConstructor 는 롬복의 어노테이션이다. 롬복은 코드를 단순화시켜 주지만 필수 어노테이션은 아니다. 그러다 보니 주요 어노테이션인 @Entity 를 클래스에 가깝게 두고, 롬복 어노테이션을 그 위로 두었다. 이렇게 하면 이후에 코틀린 등의 새 언어 전환으로 롬복이 더이상 필요 없을 경우 쉽게 삭제할 수 있다.

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

서비스 초기 구축 단계에선 테이블 설계가 빈번하게 변경되는데, 이때 롬복의 어노테이션들은 코드 변경량을 최소화시켜 주기 때문에 적극적으로 사용한다.

이 Posts 클래스에는 한 가지 특이점이 있다. 바로 Setter 메소드가 없다는 점이다.

자바빈 규약을 생각하면서 getter/setter 무작정 생성하는 경우가 있다. 이렇게 되면 해당 클래스의 인스턴스 값들이 언제 어디서나 변해야 하는지 코드상으로 명확하게 구분할 수가 없어, 차후 기능 변경 시 정말 복잡해진다. 그래서 Entity 클래스에서는 절대 Setter 메소드를 만들지 않는다. 대신, 해당 필스듸 값 변뎡이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가해야만 한다.

그럼 여기서 한 가지 의문이 남는다. Setter가 없는 이 상황에서 어떻게 값을 채워 DB에 삽입해야 할까?

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

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

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

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

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

앞으로 모든 예제는 이렇게 빌더 패턴을 적극적으로 사용하니, 잘 익혀두면 좋다.

Posts 클래스 생성이 끝났다면, Post 클래스로 Database를 접근하게 해줄 JpaRepository를 생성한다.
d

package com.example.springbootwebsite.domain.posts;

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

public interface PostRepository extends JpaRepository<Posts, Long> {
}

보통 ibatis 나 MyBatis 등에서 Dao 라고 불리는 DB Layer 접근자이다. JPA 에선 Repository 라고 부르며 인터페이스로 생성한다. 단순히 인터페이스를 생성 후, JpaRepository<Entity 클래스, PK 타입>를 상속하면 기본적인 CRUD 메소드가 자동으로 생성된다.

@Repository 를 추가할 필요도 없다. 여기서 주의할 점은 Entity 클래스와 기본 Entity Repository는 함께 위치해야 하는 점이다. 둘은 아주 밀접한 관계이고, Entity 클래스는 기본 Repository 없이는 제대로 역할을 할 수가 없다.

나중에 프로젝트 규모가 커져 도메인별로 프로젝트를 분리해야 한다면 이때 Entity 클래스와 기본 Repository 는 함께 움직여야 하므로 도메인 패키지에서 함께 관리한다. 모두 작성되었다면 간단하게 티스트 코드로 기능을 검증해 보겠다.

4.6 Spring Data JPA 테스트 코드 작성

test 디렉토리에 domain.posts 패키지를 생성하고, 태스트 클래스는 PostsRepositoryTest 란 이름으로 생성한다.
d

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

package com.example.springbootwebsite.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 PostRepositoryTest {
    
    @Autowired
    PostRepository postRepository;
    
    @After
    public void cleanup(){
        postRepository.deleteAll();
    }
    
    @Test
    public void 게시글저장_불러오기(){
        //given
        String title = "테스트게시글";
        String content = "테스트본문";
        
        postRepository.save(Posts.builder()
                .title(title)
                .content(content)
                .author("kgu8825@naver.com")
                .build());
        
        //when
        List<Posts> postsList = postRepository.findAll();
        
        //then
        Posts posts = postsList.get(0);
        assertThat(posts.getTitle()).isEqualTo(title);
        assertThat(posts.getContent()).isEqualTo(content);
    }
}

코드 설명하겠다.

@After

  • Junit 에서 단위 테스트가 끝날 때마다 수행되는 메소드를 저장
  • 보통은 배포 전 전체 테스트를 주행할 떄 테스트간 데이터 침범을 막기 위해 사용
  • 여러 테스트가 동시에 수행되면 테스트용 데이터베이스인 H2에 데이터가 그대로 남아 있어 다음 테스트 실행 시 테스트가 실패할 수 있다.

postRepository.save

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

postRepository.findAll()

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

별다른 설정 없이 @SpringBootTest 를 사용할 경우 H2 데이터베이스를 자동으로 실행해 준다. 이 테스트 역시 실행할 경우 H2가 자동으로 실행된다. 자 그럼 이 테스트 코드를 한번 실행해보겠다.

ㅇ

여기서 한 가지 궁금한 것이 있다. "실제로 실행된 쿼리는 어떤 형태일까?" 라는 것이다.

실행된 쿼리를 로그로 볼 수 는 없나? 물로 누커리 로그를 ON/OFF 할 수 있는 설정이 있다. 다만, 이런 설정들을 Java 클래스로 구현할 수 있으나 스프링 부트에서는 application.properties 파일을 생성한다.

d

옵션은 다음과 같다. 옵션이 추가가 되었다면 다시 테스트를 수행해보자.

spring.jpa.show_sql=true

f
다음과 같이 콘솔에서 쿼리 로그를 확인할 수 있다.

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

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

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

여기서 많은 사람들이 오해하는 것이 Service 에서 비즈니스 로직을 처리해야 하한다는 것이다. 하지만, 전혀 그렇지 않다. Service 는 트랜잭션, 도메인 간 순서 보장의 역할만 한다.

그럼 등록, 수정, 삭제 기능을 만들어 보겠다. PostsApiController 를 web 패키지에, PostsSaveRequestDto 를 web.dto 패키지에, PostsService 를 service.posts 패키지에 생성한다.
d

  • PostsApiController
package com.example.springbootwebsite.web;

import com.example.springbootwebsite.service.posts.PostsService;
import com.example.springbootwebsite.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.springbootwebsite.service.posts;

import com.example.springbootwebsite.domain.posts.PostRepository;
import com.example.springbootwebsite.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class PostsService {
    private final PostRepository postRepository;

    @Transactional
    public Long save(PostsSaveRequestDto requestDto){
        return postRepository.save(requestDto.toEntity()).getId();
    }
}

스프링을 어느 정도 써본 사람들이라면 Controller 와 Service 에서 @Autowired 가 없는 것이 어색하게 느껴진다. 스프링에선 Bean 을 주입받는 방식들이 다음과 같다.

  • @Autowired
  • setter
  • 생성자

이 중 가장 권장하는 방식이 생성자로 주입받는 방식이다.(@Autowired 는 권장하지 않는다). 즉 생성자로 Bean 객체를 받도록 하면 @Autowired 와 동일한 효과를 볼 수 있다는 것이다. 그러면 앞에서 생성자는 어디 있을까?

바로 @RequiredArgsConstructor 에서 해결해 준다. final이 선언된 모둔 필드를 인자값으로 하는 생성자를 롬복의 @RequiredArgsContstructor 가 대신 생성해 준 것이다.

생성자를 직접 안 쓰고 롬복 어노테이션을 사용한 이유는 간단하다. 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위함이다.

이제는 Controller 와 Service 에서 사용할 Dto 클래스를 생성하겠다.

package com.example.springbootwebsite.web.dto;

import com.example.springbootwebsite.domain.posts.Posts;
import com.example.springbootwebsite.web.PostsApiController;
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 클래스를 변경하는 것은 너무 큰 변경이다.

수많은 서비스 클래스나 비즈니스 로직들이 Entity 클래스를 기준으로 동작한다. Entity 클래스가 변경되면 여러 클래스에 영향을 끼치지만, request 와 Response 용 Dto는 view 를 위한 클래스라 정말 자주 변경이 필요하다.

View Layer 와 DB. Layer 의 역할 분리를 철저하게 하는 것이 좋다. 실제로 Controller 에서 결괏값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하므로 Entity 클래스만으로 표현하기가 어려운 경우가 많다.

꼭 Entity 클래스와 Controller 에서 쓸 Dto 는 분리해서 사용해야 한다. 등록 기능의 코드가 완성되었으니, 테스트 코드로 검증해 보겠다. 테스트 패키지 중 web 패키지에 PostsApiControllerTest 를 생성한다`
d

package com.example.springbootwebsite;

import com.example.springbootwebsite.domain.posts.PostRepository;
import com.example.springbootwebsite.domain.posts.Posts;
import com.example.springbootwebsite.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.test.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 PostRepository postRepository;

    @After
    public void tearDown() throws Exception{
        postRepository.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 = postRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }
}

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

ㄴ

WebEnvironment.RANDOM_PORT 로 인해 랜덤 포트 실행과 insert 쿼리가 실행된 것 모두 확인했다. 등록 기능을 완성했으니 수정/조회 기능도 빠르게 만들어보겠다.

  • PostsResponseDto
package com.example.springbootwebsite.web.dto;

import com.example.springbootwebsite.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 를 받아 처리한다.

  • PostUpdateRequestDto
package com.example.springbootwebsite.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;
    }
}
  • PostsApiController
package com.example.springbootwebsite.web;

import com.example.springbootwebsite.service.posts.PostsService;
import com.example.springbootwebsite.web.dto.PostsResponseDto;
import com.example.springbootwebsite.web.dto.PostsSaveRequestDto;
import com.example.springbootwebsite.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 findByIf(@PathVariable Long id){
        return postsService.findByID(id);
    }
}
  • Posts
package com.example.springbootwebsite.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;
    }

    public void update(String title, String content){
        this.title = title;
        this.content = content;
    }
}
  • PostsService
package com.example.springbootwebsite.service.posts;

import com.example.springbootwebsite.domain.posts.PostRepository;
import com.example.springbootwebsite.domain.posts.Posts;
import com.example.springbootwebsite.web.dto.PostsResponseDto;
import com.example.springbootwebsite.web.dto.PostsSaveRequestDto;
import com.example.springbootwebsite.web.dto.PostsUpdateRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class PostsService {
    private final PostRepository postRepository;

    @Transactional
    public Long save(PostsSaveRequestDto requestDto){
        return postRepository.save(requestDto.toEntity()).getId();
    }

    @Transactional
    public Long update(Long id, PostsUpdateRequestDto requestDto){
        Posts posts = postRepository.findById(id).orElseThrow(()->new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));

        posts.update(requestDto.getTitle(), requestDto.getContent());

        return id;
    }

    public PostsResponseDto findByID (Long id){
        Posts entity = postRepository.findById(id).orElseThrow(()-> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));

        return new PostsResponseDto(entity);
    }
}

여기서 신기한 것이 있다. update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없다. 이게 가능한 이유는 JPA의 영속성 컨텍스트 때문이다.

영속성 컨테스트란, 엔티티를 영구 저장하는 환경이다, 일종의 논리적 개념이라고 보면 되고, JPA 의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다.

JPA 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.

이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에서 해당 테이블에 변경분을 반영한다. 즉, Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다는 것이다. 이 개념을 더티 체킹(dirty checking) 이라고 한다.

자 그럼 실제로 이 코드가 정상적으로 Update 쿼리를 수행하는지 테스트 코드로 확인해 보겠다.

수정 기능의 테스트 코드는 등록 기능과 마찬가지로 PostsApiController-Test 에 추가하겠다.

  • PostsApiControllerTest
package com.example.springbootwebsite;

import com.example.springbootwebsite.domain.posts.PostRepository;
import com.example.springbootwebsite.domain.posts.Posts;
import com.example.springbootwebsite.web.dto.PostsSaveRequestDto;
import com.example.springbootwebsite.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.test.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 PostRepository postRepository;

    @After
    public void tearDown() throws Exception{
        postRepository.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 = postRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }

    @Test
    public void Posts_수정된다() throws Exception{
        //given
        Posts savedPosts = postRepository.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 = postRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
        assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
    }
}

테스트 결과만 보면 update 쿼리가 수행되는 것을 확인할 수 있다.
ㅇ

예전 MyBatis 를 쓰던것과 달리 JPA를 씀으로 좀 더 객체지향적으로 코딩할 수 있다. JPA와 테스트 코드에 대해 진행해 보았으니, 조회 기능은 실제로 톰캣을 실행해서 확인해 보겠다.

앞서 안급한 대로 로컬 환경에선 데이터베이스로 H2를 사용한다. 메모리에서 실행하기 때문에 직접 접근하려면 웹 콘솔을 사용해야만 한다. 먼저 웹 콘솔 옵션을 화성화한다. application.properties 에 다음과 같이 옵션을 추가한다.

spring.h2.console.enabled=true

혹시 Application 클래스의 main 메서드를 실행한 후 에러가 날 수도 있다.(본인이 그랬다.) 아래 그림과 같은 에러가 난 사람은 application.properties에서 server.port=8082 로 바꾸어주자.
ㅇ

추가한 뒤 Application 클래스의 main 메서드를 실행한다. 정상적으로 실행됐다면 톰캣이 8082 포트로 생성됐다. 여기서 웹 브라우저에 http://localhost:8082/h2-console 로 접속하면 아래와 같이 웹 콘솔 화면이 등장한다.
d
이때 JDBC URL 이 위 화면과 같이 jdbc:h2:mem:testde 로 되어 있지 않다면 똑같이 작성해야한다. 아래 [Connect] 버튼을 클릭하면 현재 프로젝트의 H2를 관리할 수 있는 관리 페이지로 이동한다.

이 과정에서 오류가 날 수 있다. 나는 Database "mem:testdb" not found, either pre-create it or allow remote database creation (not recommended in secure environments) 라는 오류 메시지가 났다.

내용을 살펴보면 mem:testdb 라는 데이터베이스를 찾을 수 없다고 나온다. 혹은 데이터베이스를 미리 생성(pre-create) 할 수도 없다고 나온다. 혹은 원격 데이터 생성도 할 수 없다고 나온다. 데이터베이스를 보안상의 이유로 생성을 못하게 h2 기본 설정 자체에서 막아놓을 것으로 보인다.

조금 찾아본 결과 h2가 1.4.1971.4.198 버전 사이에 대규모 업데이트가 되면서 데이터베이스를 미리 생성하는 것을 방지하도록 설정되었다.

해결책은 h2의 버전을 1.4.198 보다 낮은 버전으로 설정하면 된다. build.gradle 에 아래 코드로 바꾼다.

runtimeOnly'com.h2database:h2:1.4.197'

다음과 같이 POSTS 테이블이 정상적으로 노출되어야만 한다.

현재 Posts 테이블이 보이지 않는 오류가 나고 있다. 오류를 해결하기 위해서 알아보는 중이고, 해결이 된다면 여기는 다시 연재하겠다.
다음 과정으로 넘어가도 괜찮다.

4.8 JPA Auditing 으로 생성시간/수정시간 자동화

보통 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함한다. 언제 만들어졌는지, 언제 수정되었는지 등은 차후 유지보수에 있어 굉장히 중요한 정보이기 때문이다. 그렇다 보니 매번 DB 에 삽입하기 전, 갱신하기 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 된다.

이런 단순하고 반복적인 코드가 모든 테이블과 서비스 메소드에 포함되어야 한다고 생각하면 어마어마하게 귀찮고 코드가 지저분해진다. 그래서 이 문제를 해결하고자 JPA Auditing 를 사용하겠다.

  • LocalDate 사용

여기서부터는 날짜 타입을 사용한다. Java8 부터 LocalDate 와 LocalDateTime이 등장했다. 그간 Java의 기본 날짜 타입인 Date의 문제점을 제대로 고친 타입이라 Java8일 경우 무조건 써야 한다고 생각하면 된다.

domain 패키지에 BaseTimeEntity 클래스를 생성한다.
ㅇ

package com.example.springbootwebsite.domain.posts;

import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
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
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    @CreatedDate
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime modifiedDate;
}

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

코드를 설명하겠다.

@MappedSuperclass

  • JPA Entity 클래스들이 BaseTimeEntity을 상속할 경우 필드들도 칼럼으로 인식하도록 한다.

@EntityListeners(AuditingEntityListener.class)

  • BaseTimeEntity 클래스에 Auditing 기능을 포함시킨다.

@CreatedDate

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

@LastModifiedDate

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

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

...
public class Posts extends BaseTimeEntity {
	...
    ...
}

마지막으로 JPA Auditing 어노테이션들을 모두 활성화할 수 있도록 Application 클래스에 활성화 어노테이션 하나를 추가하겠다.

package com.example.springbootwebsite;

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

@EnableJpaAuditing
@SpringBootApplication
public class SpringbootWebsiteApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringbootWebsiteApplication.class, args);
    }
}

그러면 실제 코드는 완성이되었다. 기능이 잘 작동하는지 테스트 코드를 작성해 보겠다.

PostRepositoryTest 클래스에 테스트 메소드를 하나 더 추가하겠다.

@Test
    public void BaseTimeEntity_등록(){
        //given
        LocalDateTime now = LocalDateTime.of(2022,8,18,0,0,0);
        postRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        //when
        List<Posts> postsList = postRepository.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만 상속받으면 자동으로 문제가 해결되기 때문이다.

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

profile
With passion and honesty

0개의 댓글