[3. Spring boot] 스프링 부트에서 JPA로 DB를 다뤄보자

박현우·2021년 3월 12일
6

Spring

목록 보기
3/11

오늘날 관계형 DBMS(Oracle, MySQL 등등..)을 안쓰는 곳이 없을 정도로 데이터를 관리하는 부분에서 굉장히 많은 부분을 차지합니다. 그러다 보니 모든 코드가 어플리케이션 코드가 아닌 SQL 중심으로 돌아가고 있습니다. 이것은 관계형 데이터베이스가 SQL만 인식할 수 있기 때문인데 기본적인 SQL문인 CRUD도 테이블마다 매번 생성을 해야합니다.

예를들면, A라는 객체를 만들고, 조회하고, 내용물을 수정해야한다고 생각하면 반드시 SQL을 통해야만 합니다.

학교 프로젝트를 통해서도 관련 테이블만 수십개 정도 존재할 수 있는데 현업에서는 어떨까요? 상상도 하기 싫습니다..

그리고 한가지 문제가 더 존재하는데, 객체지향 프로그래밍 언어는 기능과 속성을 한 곳에서 관리하는 기술이고 관계형 DB는 어떻게 데이터를 저장할지에 초점이 맞춰진 기술이라 패러다임 불일치가 발생합니다. 관계형 DB에서는 객체지향적인 코딩을 하기 힘들다는 의미입니다.


JPA

이러한 패러다임 불일치 문제를 해결하기 위해 나온것이 JPA입니다. JPA는 개발자가 객체지향적 코딩을 하면 SQL에 맞게 변환해주는 역할을 합니다.

JPA는 인터페이스기 때문에 안에 내용물이 없습니다. 그러므로 내용물을 채워야하니 구현체(Hibernate, Eclipse 등)가 필요합니다. 그 중 Spring Data JPA 라는 모듈을 사용하겠습니다.

Spring Data JPA란?

JPA를 편하게 쓸 수 있도록 Spring 측에서 만든 모듈입니다.
repository라는 인터페이스를 가지고 있습니다. 사용자는 이 인터페이스의 규약대로 메소드를 적으면 메소드 이름에 적합한 쿼리를 날립니다. 또한, Hibernate 외에 다른 구현체로 쉽게 교체가 가능하고, 관계형 DB또한 다른 저장소로 쉽게 교체가 가능합니다.


요구사항 분석

책에 적힌대로 3~6장까지 게시판을 만들겠습니다.

  • 게시판 기능

    조회, 등록, 수정, 삭제
  • 회원 기능

    구글/ 네이버 로그인
    로그인한 사용자 글 작성 권한
    본인 작성 글에 대한 권한 관리

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

build.gradle에 다음과 같이 의존성을 등록하겠습니다.

  • 의존성 등록하기 - build.gradle
compile('org.springframework.boot:spring-boot-starter-data-jpa') // 1.
compile('com.h2database:h2:') // 2.
  1. spring-boot-starter-data-jpa
    • 스프링 부트용 Spring Data Jpa 추상화 라이브러리입니다.
  2. h2
    • 인메모리 관계형 DB입니다.
    • 별도의 설치가 필요 없이 프로젝트 의존성만으로 관리할 수 있습니다.
    • 메모리에서 실행되기 때문에 어플을 시작할때마다 초기화된다는 점을 이용해 테스트 용도로 사용됩니다.

  • main/java/domain
    여기는 도메인을 담을 패키지입니다. DAO 패키지를 떠올리시면 되겠습니다.
  • main/java/domain/posts/Posts
    실제 DB 테이블과 매칭될 클래스입니다. 보통 Entity 클래스라고도 합니다. JPA를 사용하시면 DB 데이터에 작업할 경우 실제 쿼리를 날리는 것 보다 이것을 수정해 작업합니다.
package com.qweadzs.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)
    private String title;

    @Column(columnDefinition = "TEXT", nullable = false)// 4.
    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;
    }
}

Tip.
롬복 어노테이션이 아닌 주요 어노테이션을 클래스에 가깝게 두면 나중에 쉽게 삭제할 수 있습니다.

  1. @Entity
    • 테이블과 링크될 클래스임을 나타냅니다.
    • 기본값으로 클래스의 카멜 케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭합니다.
    • ex) SalesManager.java -> sales_manager table
  2. @Id
    • 해당 테이블의 PK 필드를 나타냅니다.
  3. @GeneratedValue
    • PK의 생성 규칙을 나타냅니다.
  4. @Column
    • 테이블의 칼럼을 나타냅니다.
    • 문자열의 기본값 사이즈를 늘리거나 타입을 변경할때 사용합니다.

Tip.
Entity의 PK는 Long타입의 Auto_increment를 써야합니다. 주민번호나 여러키를 조합한 복합키로 PK를 잡으면 다음과 같은 문제가 발생합니다.

  • FK를 맺을 때 달느 테이블에서 복합키 전부를 갖고 있거나, 중간 테이블을 하나 더 둬야 하는 상황이 발생
  • 인덱스에 안좋음
  • 유니크한 조건이 변경될 경우 PK 전체를 수정해야 하는 일이 발생.

때문에 주민번호,복합키 등은 유니크 키로 별도로 추가합시다.


여기서 부터 롬복 어노테이션입니다

  1. NoArgsConstructor
    • 기본 생성자 자동 추가
  2. @Getter
    • 클래스 내 모든 필드의 Getter 메소드 자동생성
  3. @Builder
    • 해당 클래스의 빌더 패턴 클래스를 생성

Tip.
Posts에는 Setter 메소드가 없습니다. Setter를 무작정 생성하면 해당 클래스의 인스턴스 값들이 언제 어디서 변해야 하는지 코드상으로 구분할 수 없습니다.
때문에 생성자 역할을 하며 각 인자가 어떤 의미인지 알기 쉽고 값 검증도 가능한 Builder 패턴을 활용합시다.


main/java/domain/posts/PostsRepository
보통 DAO라고 불리는 DB Layer 접근자입니다. 인터페이스이고 JPA에서는 Repository라고 불립니다. Entity클래스와 반드시 함께 위치해야 합니다. Entity 클래스는 Repository없이는 제대로된 역할을 못하기 때문입니다.

package com.qweadzs.domain.posts;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostsRepository extends JpaRepository<Posts, Long> { // 1.

}
  1. JpaRepository<Entity 클래스, Pk타입>
    • 단순하게 인터페이스 생성후 저렇게 상속을 하면 기본적인 CRUD 메소드가 자동 생성 됩니다.

  • test/domain/posts/PostsRepositoryTest
    JPA를 프로젝트에 적용시켰으니 save, findAll 기능을 테스트하겠습니다.
@After // 1.
    public void cleanup() {
        postsRepository.deleteAll();
    }

    @Test
    public void 게시글저장_불러오기() {
        //given
        String title = "테스트 게시글";
        String content = "테스트 본문";

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

        //when
        List<Posts> postsList = postsRepository.findAll(); // 3.
  1. @After
    • Junit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정합니다.
    • 보통 배포 전 전체 테스트를 수행할 대 테스트간 데이터 침범을 막기위해 사용합니다.
    • 여러 테스트가 동시에 수행되면 테스트용 DB인 H2에 데이터가 그대로 남아있어 다음테스트를 실패할수 있습니다
  2. postsRepository.save
    • 테이블 posts에 insert/update 쿼리를 실행합니다
    • id값이 있으면 update, 없으면 insert 실행
  3. postsRepository.findAll
    • 테이블 posts에 있는 모든 데이터를 조회합니다.

Tip.
실제 실행된 쿼리를 로그를 통해 볼 수 있습니다. 다만 H2 쿼리 문법으로 적용되었기에 이 부분은 고쳐야 합니다.

  • src/main/resources/application.properties
spring.jpa.show_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect

Spring 웹 계층


출처

  1. Web Layer
    • 흔히 사용하는 컨트롤러와 JSP/Freemarker 등의 뷰 템플릿 영역입니다.
    • 웹 어플리케이션의 최상층에 존재하기 때문에 다른 Layer에서 발생한 예외처리도 모두 해야합니다.
    • 사용자의 응답에 대한 처리를 하고 사용자 권한 인증, 인가 처리를 합니다.
  2. Service Layer
    • @Service에 사용되는 서비스 영역입니다.
    • 트랜잭션에 대한 경계 역할을 하고 @Transcational, @Service에 사용되는 영역입니다.
    • 일반적으로 Controller와 DAO의 중간 영역에서 사용됩니다.
  3. Repository Layer
    • 짧게 말해 DAO입니다.
  4. Dtos
    • DTO는 계층 간에 데이터 교환을 위한 객체를 이야기 합니다. Dtos는 이들의 영역입니다.
    • 예를 들어 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기 합니다.
  5. Domain model
    • 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것을 도메인 모델이라고 합니다.
    • 비즈니스 로직을 처리하는 영역입니다.
      이를테면 택시 앱이라고 하면 배차, 탑승, 요금 등이 모두 도메인이 될 수 있습니다.
    • @Entity가 사용된 영역 역시 도메인 모델이라고 이해하면 됩니다.
    • 다만, 무조건 데이터베이스의 테이블과 관계가 있어야 하는 것은 아닙니다. VO처럼 값 객체들도 이 영역에 해당하기 때문입니다.

주문 취소나 회원 가입등의 비즈니스는 Domain에서 처리를 해야합니다.

기존 Service 에서 처리하는 방식은 모든 비즈니스 로직이 서비스 클래스 내부에서 처리되기 때문에 서비스 계층이 무의미 해집니다.


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

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

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

web/PostsApiController
web/dto/PostsSaveRequestDto
service/posts/PostsService

3개의 파일을 만들겠습니다.

web/PostsApiController

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;

    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostsSaveRequestDto requestDto) {
        return postsService.save(requestDto);
    }
}

Controller 와 Service에서 @Autowired가 없는데, 스프링에선 Bean을 주입하는 방식이 3가지 입니다.

  • @Autowired
  • setter
  • 생성자

이때 생성자로 Bean 객체를 받도록 하면 @Autowired와 동일한 효과를 볼 수 있습니다.
@RequiredArgsConstructor에서 final이 선언된 모든 필드를 인자값으로 생성자를 만듭니다.
생성자를 롬복 어노테이션으로 만드는 이유는 해당 클래스의 의존성이 변경될때마다 생성자 코드를 수정해야하는 번거로움을 막아줍니다.


web/dto/PostsSaveRequestDto

@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 클래스와 연관된 DTO 클래스들이 모두 영향을 받을겁니다.

때문에 변경이 자주 일어나는 View나 join 전용 클래스는 Entity와 분리해서 사용해야 합니다!


다음은 테스트 코드로 검증을 해보겠습니다.

test/web/PostsApiControllerTest

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

api Controller를 테스트하는데 HelloController와 달리 @WebMvcTest를 사용하지 않습니다.

@WebMvcTest는 JPA의 기능이 작동하지 않습니다. JPA 기능까지 한번에 테스트 하려면 @SpringBootTest와 TestRestTemplate를 사용해야 합니다.

update 기능은 쿼리를 날리는 부분이 없습니다. JPA의 영속성 컨테스트 때문입니다.

영속성 컨텍스트란, 엔티티를 영구 저장하는 환경입니다. JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈립니다.

JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서 DB의 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태입니다.
이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영합니다. Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없습니다.

마찬가지로 Test 파일에 실험을 해보면 JPA를 사용해 객체지향적인 코딩을 할 수 있음을 직감할 수 있습니다.


실제 톰캣으로 확인하기

실제 톰캣을 실행해 조회기능을 살펴보겠습니다.
application.properties에
spring.h2.console.enabled=true 입력 후 Application의 main을 실행합니다.
그럼 8080port로 톰캣이 실행되는데 http://localhost:8080/h2-console 로 접속하면 다음과 같은 화면이 등장합니다.

여기서 JDBC URL을 jdbc:h2:mem:testdb 로 고쳐주세요.


콘솔을 보시면 POSTS 테이블이 있습니다. 간단한 insert 쿼리 실행 후 API로 조회해 보겠습니다.

브라우저에 http://localhost:8080/api/v1/posts/1 을 입력하면 조회기능이 나옵니다.

등록/수정은 테스트 코드로 보호해 주고 있어 변경 사항이 있어도 안전하게 변경할 수 있습니다.


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

데이터의 만들어진 시간, 수정된 시간은 차후 유지보수에 있어 정말 중요한 정보입니다. 그렇기 때문에 매번 DB에 삽입하기 전 갱신하기 전 코드로 프로그램이 무거워 집니다.
이 문제는 JPA Auditing으로 해결할 수 있습니다.


LocalDate 사용

Date의 문제점을 Java8에서 고친 타입입니다.
domain/BaseTimeEntity 생성

@Getter
@MappedSuperclass // 1.
@EntityListeners(AuditingEntityListener.class) // 2.
public abstract class BaseTimeEntity {

    @CreatedDate // 3.
    private LocalDateTime createdDate;

    @LastModifiedDate // 4.
    private LocalDateTime modifiedDate;

}

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

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

그리고 Posts클래스가 이 클래스를 상속받도록 변경하고 JPA Auditing 어노테이션들을 모두 활성화하도록 Application 클래스에 어노테이션 하나를 추가합니다.

@EnableJpaAuditing 클래스 위에 추가


JPA Auditing 테스트 코드 작성

PostsRepositoryTest 클래스에 테스트 메소드를 하나 더 추가합니다.

@Test
    public void BaseTimeEntity_등록() {
        //given
        LocalDateTime now = LocalDateTime.of(2019, 6, 4, 0, 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);
    }


테스트해보면 다음과 같이 생성 시간과 수정 시간을 확인할 수 있습니다.

앞으로 추가될 Entity들은 BaseTimeEntity만 상속받으면 자동으로 적용됩니다!

0개의 댓글