글 작성자: 자바니또

이번 포스팅은 '스프링 부트와 AWS로 혼자 구현하는 웹 서비스'라는 책을 읽고 책의 내용을 정리한 것입니다.
책의 장점은 저자가 경험한 것과 생각을 글로 나마 빠르게 경험할 수 있다는 것입니다.
깊이 이해 하지 못하더라도 그러한 것이 있다라는 것을 안다는 것만으로도 충분히 가치있다고 생각합니다.
더 나아가서 직접 해본다면 더더욱 이득이겠지요.

개요

웹 서비스를 개발하고 운영하다 보면 피할 수 없는 문제가 데이터베이스를 다루는 일이다. Spring Boot를 처음 배울 때 데이터베이스를 다루기 위해 SQL매퍼를 사용하여 쿼리를 작성한다. 그러다보면 한 가지 의문이 든다. 객체지향 프로그래밍 언어인 Java를 사용함에도 객체지향 프로그래밍을 못한다는 것이다. 객체지향설계를 해야하는 것을 알고 있음에도 관계형 테이블들의 관계에만 집중하고 객체를 단순히 테이블에 맞추어 데이터를 전달 하는 역할만 하게된다.

우리의 선배 개발자들도 같은 고민을 하였고, 문제의 해결책으로 등장한 것이 JPA라는 자바 표준 ORM(Obejct Relational Mapping)이다. 이번 포스팅에서는 JPA를 프로젝트에 적용하기 위해서 해야할 작업과 덧 붙여 JPA를 사용하면서 객체지향 프로그래밍을 하기 위해서 각 Layer의 역할을 어떻게 주어야 하는지 알아보도록 한다.

목차

  • JPA 소개
  • Spring Data JPA
  • 프로젝트에 Spring Data Jpa 적용하기
  • 실행된 쿼리 로그 콘솔에 출력하기
  • 스프링 웹 계층(Layer)
  • Spring Data JPA의 테스트 방법
  • JPA 영속성 컨텍스트
  • H2 웹 콘솔 옵션 활성화 하기
  • JPA Auditing으로 생성/수정시간 자동화하기

JPA 소개

현대의 웹 애플리케이션에서 관계형 데이터베이스는 빠질 수 없는 요소이다. 관계형 데이터베이스(RDB)는 어떻게 데이터를 중복없이 효율적으로 저장할지에 초점이 맞춰져 있고, 객체지향 프로그래밍(OOP)은 메시징을 기반으로 기능과 속성을 객체로 캡슐화하는 것에 초점이 맞춰져 있다. 이 둘은 이미 사상부터 다른 시작점에서 출발했다. 즉 서로 다른 패러다임에 의하여 발생하는 문제를 패러다임 불일치라고 한다.

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

위의 코드는 객체지향적으로 짠 코드이다. 코드에서 User와 Group은 부모-자식 관계임을 알 수 있다. 하지만 여기서 데이터베이스가 추가되면 다음과 같이 변경된다.

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

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

JPA는 중간에서 패러다임 일치를 시켜주기 위한 기술이다. 즉, 개발자는 객체지향적으로 프로그래밍을 하고, JPA가 이를 관계형 데이터 베이스에 맞게 SQL을 대신 생성해서 실행한다. 또한, 객체 중심으로 개발을 하게 되니 생산성 향상은 물론 유지 보수하기가 정말 편하다.

Spring Data JPA

JPA는 인터페이스로서 자바 표준명세서이고, 구현체로는 대표적으로 Hibernate, Eclipse Link 등이 있다. 스프링에서는 JPA를 사용할 때는 이 구현체들을 직접 다루지 않고 한 번더 추상화 시킨 Spring Data JPA라는 모듈을 이용한다.

구현체를 직접 사용하지 않고 Spring Data Jpa를 사용하면 의존성은 느슨해지고 따라서 구현체를 교체할 때 코드를 변경하지 않고 쉽게 교체가 가능하다. 또, 쉽게 관계형 데이터베이스 외에 다른 저장소로 교체할 수 있다. 예를 들면, 트래픽이 많아져 관계형 데이터베이스로는 도저히 감당히 되지 않을 때 MongoDB로 교체가 필요하다면 개발자는 Spring Data JPA에서 Spring Data MongoDB로 의존성 교체만 하면 된다. Spring Data의 하위프로젝트들은 기본적인 CRUD의 인터페이스가 같기 때문이다.

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

프로젝트에 Spring Data JPA를 적용하기 위해서는 다음의 단계를 거친다.

  1. 의존성 등록
  2. Entity클래스 생성
  3. JpaRepository 생성
  4. Spring Data JPA 테스트 코드 작성

1. 의존성 등록

dependencies {
    //...
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compile('com.h2database:h2')
}

여기서 h2database가 거슬린다. 이것은 내장형 데이터베이스 중 하나로서 다음과 같은 특징을 지닌다.

  • 인메모리 관계형 데이터베이스이다.
  • 별도의 설치 필요없이 프로젝트 의존성만으로 관리할 수 있기 때문에 초기 개발시 사용하기 좋다.
  • 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화된다는 점을 이용하여 테스트 용도로 많이 사용된다.

2. Entity 클래스 생성

@Getter
@NoArgsConstructor  
@Entity  
public class Posts {  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  

    @Column  
    private String title;  

    @Column  
    private String content;  

    @Builder  
    public Posts(String title, String content) {  
        this.title = title;  
        this.content = content;  
    }  
}
  • @Entity
    : 엔터티 클래스라는 표시로서, 기본 값으로 클래스의 카멜케이스 이름을 언더스코어 네이밍(_)으로 테이블 이름을 매칭한다.
  • @Id
    : 해당 테이블의 PK필드를 나타낸다. Entity클래스에는 반드시 존재해야 한다.
  • @GeneratedValue
    : PK의 생성 규칙을 나타낸다. 스프링 부트 2.0에서는 GenerationType.IDENTITY 옵션을 추가해야만 auto_increment가 된다.

@Getter와 @NoArgsConstructor는 lombok의 어노테이션이고, 중요한 것은 @Entity이다. 이것은 JPA의 어노테이션으로서 해당 클래스가 실제 DB의 테이블과 매칭될 클래스, 즉 Entity클래스라는 표시이다. JPA를 사용하면 DB데이터에 작업할 경우 실제 쿼리를 날리기보다는, 이 Entity클래스의 수정을 통해 작업을 한다.

Entity클래스를 만들 때 몇 가지 주의 할 것이 있다.

  1. Entity클래스에는 public 또는 protected 접근 제한자로 인자가 없는 생성자가 반드시 있어야 한다. JPA Provider가 도메인 개체를 동적으로 생성하는 경우가 있기 때문이다.
  2. 웬만하면 Entity의 PK는 Long타입의 Auto_increment를 추천한다. 비즈니스상 유니크 키나, 여러키를 조합한 복합키로 PK를 잡을 경우 인덱스에 좋지 않고 유니크 조건이 변경될 경우 PK전체를 수정하는 등 난감한 상황이 종종 발생하기 때문이다.
  3. 차후 유지보수의 복잡성을 줄이기 위해 Entity 클래스에서는 절대 Setter메서드를 만들지 않는다. 필드의 값 변경이 필요하다면 명확히 그 목적과 의도를 나타낼 수 있는 메서드를 추가한다.
  4. Entity클래스를 Dto클래스로 사용해서는 안 된다. Dto는 View를 위한 클래스이기 때문에 자주 변경이 필요하기 때문이다. View Layer와 DB Layer의 역할 분리를 철저하게 하는 게 좋다.

3번을 보면 한 가지 의문이 들 수 있다. Setter가 없는 이 상황에서 어떻게 값을 채워 DB에 삽입 할까? 기본적인 구조는 생성자 또는 빌더를 통해 최종값을 채운 후 DB에 삽입하는 것이다. 값 변경이 필요한 경우 해당 이벤트에 맞는 public 메서드를 호출하여 변경하는 것을 전제로 한다.

3. JpaRepository 생성

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

보통 SQL Mapper에서는 DB Layer에 접근하는 객체를 Dao라고 부른다. JPA에선 Repository라고 부르며 인터페이스로 생성한다. @Repository를 붙일 필요없이 단순히 인터페이스를 생성 후, JpaRepository<Entity클래스, PK타입>를 상속하면 기본적인 CRUD 메서드가 자동으로 생성된다. 이때 조회 메서드는 단순히 모든 레코드 조회나 id로 조회만 제공된다.

실제로 사용하는 대부분의 조회의 경우 직접 만들어야 하는데, 실제로 규모가 있는 프로젝트에서의 데이터 조회는 FK의 조인, 복잡한 조건 등으로 인해 Entity클래스 만으로 처리하기 어려워 조회용 프레임워크를 추가로 사용한다. 대표적으로 querydsl, jooq, MyBatis등이 있다. 그 중 querydsl을 추천하는데, 이유는 다음과 같다.

  1. 타입 안전성이 보장된다. 단순한 문자열이 아니라, 메소드를 기반으로 쿼리를 생성하기 때문에 오타나 존재하지 않는 컬럼명을 명시할 경우 IDE에서 자동으로 검출된다. 이 장점은 Jooq에서도 지원하는 장점이지만, MyBatis에서는 지원하지 않는다.
  2. 국내 많은 회사에서 사용중이다. 그렇다보니 레퍼런스가 많다. 어떤 문제가 발생했을때 여러 커뮤니티로부터 정보를 얻을 수 있다.

이 때 주의할 것Entity클래스와 기본 Entity Repository는 같은 패키지에 위치해야 한다. Entity클래스는 기본Repository 없이는 제대로 역할을 할 수가 없는 아주 밀접한 관계이기 때문이다. 따라서 둘은 도메인 패키지에서 함께 관리한다.

SpringDataJpa에서 제공하지 않는 조회 메서드는 다음과 같이 쿼리로 작성해도 된다.

public interface PostsRepository extends JpaRepository<Posts, Long> {
    @Query("SELECT p FROM Posts p ORDER BY p.id DESC")
    List<Posts> findAllDesc();
}

이 코드는 JPQL(객체 쿼리)를 사용한 코드로서, Posts엔터티의 모든 레코드를 내림차순으로 조회한다. nativeQuery속성을 true로 하면 value속성 값으로 db에서 사용하는 쿼리를 그대로 사용할 수도 있다.

4. Service 코드 생성

Repository에 데이터만 보내면 되는 생성/수정/삭제와 다르게 조회의 경우 Repository로 부터 데이터를 받아야한다. 이때 JpaRepository는 Entity의 타입으로 리턴하는데, Service는 JpaRepository로 부터 받은 Entity 리스트를 그대로 리턴하면 안되고, Dto리스트로 변환하여 리턴하여야 한다.

// PostsService 클래스의 메서드

@Transactional(readOnly = true)
public List<PostsListResponseDto> findAllDesc() {
    return postsRepository.findAllDesc().stream()
            .map(PostsListResponseDto::new)
            .collect(Collectors.toList());
}

콜백 메서드를 람다식으로 짧게 사용한 구조이다. 많이 사용하게 되는 구조이니 익혀두는게 좋다. 과정을 간략히 설명하면 postsRepository 로부터 받은 Posts의 리스트의 Stream을 map을 통해 하나하나 new PostsListsResponseDto()의 인자로 넣어서 생성한다. 그 후 결과들을 리스트로 모아 반환한다.

5. Spring Data JPA 테스트 코드 작성

@RunWith(SpringRunner.class)  
@DataJpaTest  
public class PostsRepositoryTest {  
    @Autowired  
    PostsRepository postsRepository;  

    @After  
    public void cleanup() {  
        postsRepository.deleteAll();  
    }  

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

        //when  
        List<Posts> postsLists = postsRepository.findAll();  
        Posts posts = postsLists.get(0);  
        assertThat(posts.getTitle()).isEqualTo(title);  
        assertThat(posts.getContent()).isEqualTo(content);  
    }  
}

별다른 설정 없이 **@SpringBootTest를 사용할 경우 H2 인메모리 데이터베이스를 사용한다.

실행된 쿼리 로그 콘솔에 출력하기

Spring Data JPA에 의해 실제로 실행된 쿼리가 어떤 형태인지 궁금할 수있다. 그 땐 src/main/resources 아래에 application.properties파일을 생성한다. 이 파일의 이름과 위치는 Spring Boot 프로젝트에서 기본설정파일로 가리키고 있으므로 똑같이 해야한다.

파일을 생성하였다면 다음 옵션을 추가한다.

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

첫번째 줄은 콘솔에 로그를 찍히게 하는 옵션이고, 두번째 줄은 출력되는 쿼리 로그 문법을 MySQL버전으로 변경하는 옵션이다.

스프링 웹 계층(Layer)

기본적인 CRUD API를 만들기 위해서는 총 3개의 클래스가 필요하다.

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

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

그렇다면 비즈니스 로직은 누가 처리할까?

스프링 웹 계층

  • Web Layer
    • 흔히 사용하는 컨트롤러와 JSP/Freemarker등의 뷰 템플릿 영역이다.
    • 이외에도 필터, 인터셉터, 컨트롤러 어드바이스등 외부 요청과 응답에 대한 전반적인 영역을 나타낸다.
  • Service Layer
    • @Service에 사용되는 서비스 영역이다.
    • 일반적으로 Controller와 Dao의 중간 영역에서 사용된다.
    • @Transactional이 사용되어야 하는 영역이기도 하다.
  • Repository Layer
    • Database와 같이 데이터 저장소에 접근하는 영역이다. Dao 영역으로 이해해도 좋다.
  • Dtos
    • Dto(Data Transfer Object)는 계층 간에 데이터 교환을 위한 객체를 이야기하며 Dtos는 이들의 영역을 나타낸다.
    • 예를들어 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기한다.
  • Domain Model
    • 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화 시킨 것을 도메인 모델이라고 한다.
    • 예를들어 택시 앱이라고 하면 배차, 탑승, 요금 등이 모두 도메인이 될 수 있다.
    • @Entity가 사용된 영역 역시 도메인 모델이라고 이해해도 된다.
    • 다만, 무조건 데이터베이스의 테이블과 관계가 있어야만 하는 것은 아니다.
    • VO처럼 값 객체들도 이 영역에서 해당하기 때문이다.

그렇다면 본론으로 다시 돌아와서 Web, Service, Repository, Dto, Domain 이 5가지 레이어에서 비지니스 처리를 담당해야 할 곳은 어디일 까? 정답은 Domain이다.

기존에 서비스에서 비즈니스로직을 두어 처리하던 방식을 트랜잭션 스크립트라고 한다. 모든 로직이 서비스 클래스 내부에서 처리된다. 즉, Dao를 통해 Dto들을 받고 getter를 사용하여 데이터를 가져와 비즈니스 로직을 거쳐 알맞은 Dto에 setter로 삽입한다. 그러다 보니 서비스 계층이 무의미하며, 객체란 단순히 데이터 덩어리 역할만 하게 된다. 이는 객체지향적 프로그래밍과는 거리가 멀다.

반면 도메인 모델에서 처리할 경우, Service는 Repository를 통해 Domain객체를 받고 해당 도메인 객체에게 메시징을 통해 비즈니스 로직 수행을 명령하기만 하면 된다. 즉, 각 객체가 각자 본인의 이벤트를 처리하며, 서비스는 트랜잭션과 도메인 간의 순서만 보장해 준다.

Spring Data JPA의 테스트 방법

Spring Data JPA를 이용하여 구현한 CRUD API를 테스트하는 코드라면 @WebMvcTest가 아니라 @SpringBootTest와 TestRestTemplate을 사용해야한다. @WebMvcTest의 경우 Controller와 ControllerAdvice등 외부 연동과 관련된 부분만 활성화되기 때문에 JPA 기능이 작동하지 않는다.

JPA 영속성 컨텍스트

@Transactional  
public Long update(Long id, PostsUpdateRequestDto requestDto) {  
    Posts saved = postsRepository.findById(id)  
            .orElseThrow(IllegalArgumentException::new);  
    saved.update(requestDto.getTitle(), requestDto.getContent());  

    return saved.getId();  
}

PostsService 메서드 중 수정을 담당하는 메서드이다. 코드를 보면 의문이 들 것이다. Repository 에서 Entity를 가져오고 수정을 하였다. 그렇다면 다시 repository에 넣는 코드가 있어야 하는데 없다. 하지만 코드는 정상적으로 동작한다. 어떻게 된 것일까? 이게 가능한 이유는 JPA의 영속성 컨텍스트 때문이다.

영속성 컨텍스트란, 엔터티를 영구 저장하는 환경을 말한다. 일종의 논리적 개념이라고 보면되며, JPA의 핵심 내용은 엔터티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다. JPA의 엔터티 매니저가 활성화된 상태(Spring Data Jpa를 쓸다면 기본 옵션)로 트랜잭션 안에서 데이터베이스에서 엔터티를 가져오면 이 엔터티는 엔터티 컨텍스트가 관리하는 데이터이다. 이것을 영속성 유지 상태라 한다. 이 상태에서 데이터 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경분을 반영한다. 이렇게 상태의 변화(dirty)를 감지(checking)하는 것을 dirty checking이라 한다.

H2 웹 콘솔 옵션 활성화 하기

H2는 인메모리 데이터베이스이기 때문에 직접 접근하려면 웹 콘솔을 사용해야만 한다. application.propoerties에 다음 옵션을 추가함으로써 사용할 수 있다.

spring.h2.console.enabled=true

웹 브라우저에서 http://localhost:8080/h2-console로 접속하면 다음과 같이 웹 콘솔 화면이 등장한다.

H2 웹 콘솔화면

JDBC URL을 jdbc:h2:mem:testdb로 하고 Connect버튼을 클릭하면 현재 H2를 관리할 수 있는 관리 페이지로 이동한다. Chrom에 JSON Viewer라는 플러그인을 설치하면 JSON데이터가 정렬되어 보여진다.

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

보통 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함한다. 차후 유지보수에 있어서 굉장히 중요한 정보이기 때문이다. 하지만 이러한 작업을 직접하게 되면 반복적인 코드가 모든 테이블과 서비스 메서드에 포함 되어야 하고 굉장히 지저분해진다. Spring에서는 JPA Auditing을 사용하여 이 문제를 해결하였다.

JPA Auditing을 사용하기 위해서는 다음의 작업이 필요하다.

  • 생성/수정시간을 필드로 갖는 추상클래스 생성.
  • @EnableJpaAuditing을 Configuation클래스에 추가하기.

생성/수정시간을 필드로 갖는 추상클래스 생성.

@Getter
@MappedSuperclass
@EntityListener(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
    @CreateDate
    private LocalDateTime createDate;

    @LastModifiedDate
    private LocalDateTime modifiedDate;
}
  • @MappedSuperclass
    : JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 BaseTimeEntity의 필드들도 컬럼으로 인식하여 테이블을 생성한다.
  • @EntityListenenr(AuditingEntityListener.class)
    : BaseTimeEntity 클래스에 Auditing 기능을 포함시킨다.
  • @CreateDate
    : Entity가 생성되어 저장될 때 시간이 자동 저장된다.
  • @LastModifiedDate
    : 조회한 Entity의 값을 변경할 때 시간이 자동 저장된다.

@EnableJpaAuditing을 Configuation클래스에 추가하기.

@SpringBootApplication은 @Configuration을 포함하고 있기 때문에 다음과 같이 적용하여도 문제없다.

@EnableJpaAuditing
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

앞으로 추가될 엔터티들은 BaseTimeEntity를 상속하기만 하면 더이상 생성/수정시간으로 고민할 필요없이 자동으로 해결된다.

참고

'Spring Data' 카테고리의 다른 글

DTO vs Entity  (0) 2021.08.23
JPA 란 무엇인가?  (0) 2021.08.20