웹 서비스를 개발하고 운영하다 보면 피할 수 없는 문제가 데이터베이스를 다루는 일 입니다. 이동욱님이 스프링을 배울 때는 MyBatis와 같은 SQL 매퍼를 이용해서 데이터베이스의 쿼리를 작성했다고 합니다. 그러다 보니 실제로 개발하는 시간보다 SQL을 다루는 시간이 더 많았습니다. 그러던 중 RDBMS를 이용하는 프로젝트에서 객체지향 프로그래밍을 할 수 있는 JPA라는 자바 표준 ORM기술을 만나게 되었다고 합니다.
JPA 소개
현대의 웹 애플리케이션에서 RDB는 빠질 수 없는 요소입니다. 그러다 보니 객체를 관계형 데이터베이스에서 관리하는 것이 무엇보다중요합니다. 관계형 데이터베이스가 SQL만 인식할 수 있기 때문에 현업 프로젝트 대부분이 애플리케이션 코드보다 SQL로 가득하게 됬습니다. SQL로만 가능하다 보니 각 테이블마다 기본적인 CRUD(Create, Read, Update, Delete) SQL을 매번 생성해야 합니다.
이러한 반복적인 SQL 코드는 실제 현업에서 수십, 수백 개의 테이블이 존재하고, 이런 테이블의 몇 배의 SQL을 만들고 유지보수해야합니다. 이러한 단순 반복 작업 문제 외에도 문제가 한 가지 더 있습니다. 바로 패러다임 불일치 문제입니다. RDB는 어떻게 데이터를 저장할지에 초점이 맞춰진 기술입니다.
객체지향 프로그래밍의 특징인 추상화, 캡슐화, 다형성, 상속화 등이 있습니다. RDB로 객체지향을 표현할 수 있을까요?? 결론부터 말하자면 쉽지 않습니다. 이유는 사상부터 다른 시작점에서 출발했기 때문입니다. RDB와 객체지향 프로그래밍 언어의 패러다임이 서로 다른데, 객체를 데이터베이스에 저장하려고 하니 여러 문제가 발생하는 것입니다. 이를 패러다임 불일치라고 합니다.
예를 들어 봅시다. 객체지향 프로그래밍이 부모가 되는 객체를 가져오려면 어떻게 해야될까요?
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는 이런 문제점을 해결하기 위해 등장하게 됩니다.
서로 지향하는 바가 다른 2개 영역을 중간에서 패러다임 일치를 시켜주기 위한 기술입니다. 즉, 개발자는 객체지향적으로 프로그래밍하고, JPA가 이를 RDB에 맞게 SQL을 대신 생성해서 실행합니다. 개발자는 항상 객체지향적으로 코드를 표현할 수 있으니 더는 SQL에 종속적인 개발을 하지 않아도 됩니다.
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를 쓰는 중이라면 쉽게 교체할 수 있습니다. 실제로 자바의 Redis 클라이언트가 Jedis에서 Lettuce로 대세가 넘어갈 때 Spring Data Redis를 사용한 사람들은 아주 쉽게 교체를 했다고 합니다.
다음으로 저장소 교체의 용이성이란 RDB 외에 다른 저장소로 쉽게 교체하기 위함입니다. 서비스 초기에는 RDB로 모든 기능을 처리했지만, 점점 트래픽이 많아져 RDB로는 감당이 안 될 때가 올 수도 있습니다. 이때 MongoDB로 교체가 필요하다면 개발자는 Spring Data JPA에서 Spring Data MongoDB로 의존성만 교체하면 됩니다.
이는 Spring Data 하위 프로젝트들은 기본적인 CRUD의 인터페이스가 같기 때문입니다. 즉, Spring Data 하위 프로젝트들은 save(), findAll(), findOne() 등을 인터페이스로 갖고 있습니다. 그러다보니 저장소가 교체되어도 기본적인 기능은 변결할 것이 없습니다. 이러한 장점들로 인해 Hibernate를 직접 쓰기보다는 Spring 팀에서 계속해서 Spring Data 프로젝트를 권하고 있습니다.
실무에서 JPA
실무에서 JPA를 사용하지 못하는 가장 큰 이유로 높은 러닝 커브를 이야기합니다. 이점은 이동욱님도 동의한다고 합니다. JPA를 잘 쓰려면 객체지향 프로그래밍과 관계형 데이터베이스를 둘다 이해해야 합니다.
하지만 그만큼 JPA를 사용해서 얻는 보상은 큽니다. 가장 먼저 CRUD 쿼리를 직접 작성할 필요가 없습니다. 또한, 부모-자식 관계 표현, 1:N관계 표현, 상태와 행위를 한 곳에서 관리하는 등 객체지향 프로그래밍을 쉽게 할 수 있습니다.
속도 이슈에는 없을까 하는 걱정이 있을거라 생각합니다. 이동욱님은 포털 서비스와 이커머스에서 모두 JPA 기술들을 사용해보면서 높은 트래픽과 대용량 데이터 처리를 경험해보았지만 JPA에서는 여러 성능 이슈 해결 책들을 이미 준비해놓은 상태이기 때문에 이를 잘 활용하면 네이티브 쿼리만큼의 퍼포먼스를 낼 수 있다고 합니다.
요구사항 분석
게시판의 요구사항은 아래와 같습니다.
- 게시판 기능
- 게시글 조회
- 게시글 등록
- 게시글 수정
- 게시글 삭제
- 회원 기능
- 구글 / 네이버로 로그인
- 로그인한 사용자 글 작성 권한
- 본인 작성 글에 대한 권한 관리
어떤 웹 애플리케이션을 만들더라도 기반이 될 수 있게 보편적이지만 필수 기능들은 모두 구현하게 됩니다.
프로젝트에 Spring Data JPA 적용하기
먼저 build.gradle에 아래와 같이 의존성들을 등록합니다.
// jpa
implementation('org.springframework.boot:spring-boot-starter-data-jpa')
implementation('com.h2database:h2')
- spring-boot-starter-data-jpa
- 스프링 부트용 Spring Data JPA 추상화 라이브러리입니다.
- 스프링 부트 버전에 맞춰 자동으로 JPA관련 라이브러리들의 버전을 관리해줍니다.
- h2
- 인메모리 관계형 데이터베이스입니다.
- 별도의 설치가 필요없이 프로젝트 의존성만으로 관리할 수 있습니다.
- 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화된다는 점을 이용하여 테스트 용도로 많이 사용됩니다.
- JPA의 테스트, 로컬 환경에서의 구동에서 사용할 예정입니다.
의존성이 등록되었다면, JPA 기능을 사용해봅시다. main패키지 하위 패키지로 domain이란 패키지를 만듭시다. 해당 패키지는 도메인을 담을 패키지입니다. 여기서 도메인이란 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제 영역이라고 생각하시면 됩니다.
기존에 MyBatis와 같은 쿼리 매퍼를 사용했다면 dao 패키지를 떠올리겠지만, dao 패키지와는 조금 결이 다르다고 생각하면 됩니다. 도메인이란 용어가 조금 어색하겠지만, 과정이 진행될 때마다 어떤 이야기인지 몸으로 느낄 수 있으니 조금만 참아달라고 합니다. (도메인에 대하여 공부하고 싶다면 최범균님이 집필하신 "DDD Start"를 추천합니다.)
domain 패키지에 posts 패키지와 Posts클래스를 만듭니다. Posts 클래스 코드는 아래와 같습니다.
@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가 됩니다.
- 스프링 부트 2.0과 1.5 버전의 차이는 https://jojoldu.tistory.com/295에 정리되어있으니 참고하시길 바랍니다.
- @Column
- 테이블의 칼럼을 나타내며 굳이 선언하지 않더라도 해당 클래스의 필드는 모두 칼럼이됩니다.
- 사용하는 이유는 기본값 외에 추가로 변경이 필요한 옵션이 있으면 사용합니다.
Ex) 문자열의 경우 VARCHAR(255)가 기본값인데, 사이즈를 500으로 늘리고 싶거나, 타입을 TEXT로 변경하고싶거나 등의 경우에 사용됩니다.
- @NoArgsConstructor
- 롬복의 기능으로 기본 생성자를 자동 추가해주는 어노테이션입니다.
- 위의 코드에서는 public Posts() {}가 자동생성된다고 보면됩니다.
- @Builder
- 롬복의 기능으로 해당 클래스의 빌더 패턴 클래스를 생성합니다.
- 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함됩니다.
Posts 클래스는 실제 DB의 테이블과 매칭될 클래스이며 보통 Entity 클래스라고 불리기도 합니다. JPA를 사용하시면 DB 데이터에 작업할 경우 실제 쿼리를 날리기보다는, 이 Entity 클래스의 수정을 통해 작업합니다. 서비스 초기 구축단계 에선 테이블 설계(여기선 Entity 설계)가 빈번하게 변경되는데, 이 때 롬복의 어노테이션들은 코드 변경량을 최소화시켜 주기 때문에 적극적으로 사용합니다.
@Setter를 두지 않는 이유는 해당 클래스의 인스턴스 값들이 언제 어디서 변해야하는지 코드상으로 명확하게 구분할 수 없기 때문입니다. 따라서, 필드 값 변경이 필요한 경우 파라미터로 set을 해주는 것이 아닌 목적과 의도를 나타낼 수 있는 메소드를 추가하여 사용합니다.
예를들어, 취소 메소드를 만든다고 가정해봅시다.
잘못된 코드
public class Order {
public void setStatus(boolean status){
this.status = status
}
}
public void 주문서비스의_취소이벤트() {
order.setStatus(false);
}
올바른 코드
public class Order {
public void setStatus(){
this.status = false;
}
}
public void 주문서비스의_취소이벤트() {
order.setStatus();
}
그렇다면 의문점이 생깁니다. Setter가 없는 상황에서 어떻게 값을 채워 DB에 삽입해야 할까요?
기본적인 구조는 생성자를 통해 최종값을 채운 후 DB에 삽입하는 것이며, 값 변경이 필요한 경우 해당 이벤트에 맞는 public 메소드를 호출하여 변경하는 것을 전제로합니다.
이 책에서는 생성자 대신에 @Builder를 통해 제공되는 빌더 클래스를 사용합니다. 생성자나 빌더나 생성 시점에 값을 채워주는 역할은 똑같습니다. 다만, 생성자의 경우 지금 채워야 할 필드가 무엇인지 명확히 지정할 수가 없습니다.
예를들어, new Example(a, b)를 new Example(b, a)처럼 a와 b의 위치를 변경해도 코드를 싱행하기 전까지는 문제를 찾을 수가 없습니다. 하지만, 빌더를 사용하게 되면 아래와 같이 어느 필드에 어떤 값을 채워야 할지 명확하게 인지할 수 있습니다.
Example.builder()
.a(a)
.b(b)
.build();
Posts 클래스 생성이 끝났다면, Posts 클래스로 Database를 접근하게 해줄 JpaRepository를 생성합니다. 패키지는 Posts 클래스와 같습니다.
public interface PostsRepository extends JpaRepository<Posts, Long> {
@Query("SELECT p FROM Posts p ORDER BY p.id DESC")
List<Posts> findAllDesc();
}
보통 Dao라고 불리는 DB Layer 접근자 입니다. JPA에선 Repository라고 부르며 인터페이스로 생성합니다. 단순히 인터페이스를 생성 후, JpaRepository<Entity 클래스, PK 타입>을 상속하면 기본적인 CURD 메소드가 자동으로 생성됩니다.
@Repository를 추가할 필요도 없습니다. 여기서 주의할 점은 Entity 클래스와 기본 Entity Repository는 함께 위치해야 하는 점 입니다. 둘은 아주 밀접한 관계이고, Entity 클래스는 기본 Repository 없이는 제대로 역할을 할 수가 없습니다.
모두 작성되었다면 간단하게 테스트 코드로 기능을 검증해 보겠습니다.
Spring Data JPA 테스트 코드 작성하기
test디렉토리에 domain.posts 패키지를 생성하고, 테스트 클래스는 PostsRepositoryTest란 이름으로 생성합니다. 해당 클래스에서 save, findAll과 같은 기능을 테스트합니다. 코드는 아래와 같습니다.
@RunWith(SpringRunner.class)
@SpringBootTest
public class PostsRepositoryTest {
@Autowired
PostsRepository postsRepository;
@After
public void cleanup() {
postsRepository.deleteAll();
}
@Test
public void 게시글저장_불러오기() {
//given
String title = "테스트 게시글";
String content = "테스트 본문";
postsRepository.save(Posts.builder()
.title(title)
.content(content)
.author("qazyj@naver.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
- Junit에서 단위 테스트가 끝날 때마다 수행되는 메소드를 지정
- 보통은 배포 전 전체 테스트를 수행할 때 테스트 간 침범을 막기위해 사용합니다.
- 여러 테스트가 동시에 수행되면 테스트용 데이터베이스인 H2에 데이터가 그대로 남아있어 다음 테스트 실행 시 테스트가 실패할 수 있습니다.
- postsRepository.save
- 테이블 posts에 insert/update 쿼리를 실행합니다.
- id 값이 있다면 update, 없다면 Insert쿼리가 실행됩니다.
- postsRepository.findAll
- 테이블에 posts에 있는 모든 데이터를 조회해오는 메소드입니다.
별다른 설정 없이 @SpringBootTest를 사용할 경우 H2 데이터베이스를 자동으로 실행해 줍니다. 이 테스트 역시 실행할 경우 H2가 자동으로 실행됩니다. 테스트를 실행해보면 정상적으로 돌아가는 것을 확인할 수 있습니다.
여기서 한 가지 궁금한 것이 있습니다. 실제로 실행된 쿼리는 어떤 형태일까?입니다.
application.properties에 아래 한줄의 코드를 적으면 확인할 수 있습니다.
spring.jpa.show_sql = true
저는 application.properties가 생성되어있지 않아서 생성해주었습니다. 위치는 main의 resources 폴더 안에 file로 만들면됩니다.
그 후 위의 코드를 추가해준 뒤 실행시켜보았습니다.
Hibernate: drop table posts if exists
Hibernate: create table posts (id bigint generated by default as identity, author varchar(255), content TEXT not null, title varchar(500) not null, primary key (id))
Hibernate: insert into posts (id, author, content, title) values (null, ?, ?, ?)
Hibernate: select posts0_.id as id1_0_, posts0_.author as author2_0_, posts0_.content as content3_0_, posts0_.title as title4_0_ from posts posts0_
Hibernate: select posts0_.id as id1_0_, posts0_.author as author2_0_, posts0_.content as content3_0_, posts0_.title as title4_0_ from posts posts0_
Hibernate: delete from posts where id=?
그 후 출력된 것을 자세히보면 위처럼 쿼리 로그가 출력이 되어 나옵니다.
출력되는 로그를 MySQL 버전으로 바꿀 수도 있습니다. application.properties에 아래의 코드를 추가하면 됩니다.
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
그 후 출력하면 아래와 같이 나옵니다.
Hibernate: create table posts (id bigint not null auto_increment, author varchar(255), content TEXT not null, title varchar(500) not null, primary key (id)) engine=InnoDB
등록/수정/조회 API 만들기
API를 만들기 위해 총 3개의 클래스가 필요합니다.
- Request 데이터를 받을 Dto
- API 요청을 받을 Controller
- 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service
Service에서는 비즈니스 로직을 처리하는 것이 아닌 트랜잭션, 도메인 간 순서 보장의 역할만 합니다. 그렇다면 비즈니스 로직은 누가 처리할까요?? 그림을 봅시다.
간단하게 각 영역을 소개하자면 아래와 같습니다.
- Web Layer
- 흔히 사용하는 컨트롤러(@Controller), JSP/Freemarker 등의 뷰 템플릿 영역입니다.
- 이외에도 필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@ControllerAdvice) 등 외부 요청과 응답에 대한 전반적인 영역을 이야기합니다.
- Service Layer
- @Service에 사용되는 서비스 영역입니다.
- 일반적으로 Controller와 Dao의 중간 영역에서 사용됩니다.
- @Transactional이 사용되어야하는 영역이기도 합니다.
- Repository Layer
- Database와 같이 데이터 저장소에 접근하는 영역입니다.
- 기존에 개발하셨던 분들이라면 Dao(Database Access Object) 영역으로 이해하시면 쉬울 것입니다.
- Dtos
- Dto(Data tranfer Ojbect)는 계층 간에 데이터 교환을 위한 객체를 이야기하며 Dtos는 이들의 영역을 얘기합니다.
- 예를들어, 뷰 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등이 이들을 이야기합니다.
- Domain Model
- 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것을 도메인 모델이라고 합니다.
- 이를테면 택시 앱이라고 하면 배차 ,탑승, 요금 등이 모두 도메인이 될 수 있습니다.
- @Entity를 사용해보신 분들은 @Entity가 사용된 영역 역시 도메인 모델이라고 이해해주시면 됩니다.
- 다만, 무조건 데이터베이스의 테이블과 관계가 있어야만 하는 것은 아닙니다.
- VO처럼 값 객체들도 이 영역에 해당하기 때문입니다.
Web, Service, Repository, Dto, Domain 이 5가지 레이어에서 비지니스 처리를 담당해야 할 곳은 어디일까요? 바로 Domain입니다. 기존에 서비스로 처리하던 방식을 트랜잭션 스크립트라고 합니다. 주문 취소 로직을 작성한다면 아래와 같습니다.
@Transactional
public Order cancelOrder(int orderId){
1) 데이터베이스로부터 주문정보 (Orders), 결제정보(Biling), 배송정보(Delivery) 조회
2) 배송 취소를 해야하는지 확인
3) if(배송중이라면) {배송 취소로 변경}
4) 각 테이블에 취소 상태 Update
}
모든 로직이 서비스 클래스 내부에서 처리됩니다. 그러다 보니 서비스 계층이 무의미하며, 객체란 단순히 데이터 덩어리 역할만 하게 됩니다.
order, biling, delivery가 각자 본인의 취소 이벤트 처리를 하며, 서비스 메소드는 트랜잭션과 모데인 간의 순서만 보장해 줍니다. 이 책에서는 계속 이렇게 도메인 모델을 다루고 코드를 작성합니다.
그렇다면 등록, 수정, 삭제 기능을 만들어 보겠습니다. PostsApiController를 web 패키지에, PostsSaveRequestDto를 web.dto 패키지에, PostsService를 service.posts 패키지에 생성합니다. 코드는 아래와 같습니다.
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsService;
@PostMapping("/api/v1/posts")
public Long save(@RequestBody PostsSaveRequestDto requestDto) {
return postsService.save(requestDto);
}
}
@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
- 생성자
이 중 가장 권장하는 방식이 생성자로 주입받는 방식입니다.(@Autowired는 권장하지 않습니다.) 즉, 생성자로 Bean 객체를 받도록 하면 @Autowired와 동일한 효과를 볼 수 있다는 것 입니다. 위의 코드에서 생성자는 @RequiredArgsConstructor에서 해줍니다.
생성자를 직접 안쓰고 롬복 어노테이션을 사용한 이유는 간단합니다. 해당 클래스의 의존성 관계가 변경될 때마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위함입니다.
이제 Controller와 Service에서 사용할 Dto 클래스를 생성합니다.
@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를 생성합니다. 코드는 아래와 같습니다.
@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 기능이 작동하지 않기 때문인데, Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화 되니 지금 같이 JPA 기능까지 한번에 테스트할 때는 @SpringBootTest와 TestRestTemplate를 사용하면 됩니다.
수정/조회 기능도 빠르게 만들어 보겠습니다.
@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);
}
}
@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();
}
}
@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {
private String title;
private String content;
@Builder
public PostsUpdateRequestDto(String title, String content) {
this.title = title;
this.content = content;
}
}
@Getter
@NoArgsConstructor
@Entity
public class Posts {
...
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
@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;
}
@Transactional(readOnly = true)
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 쿼리를 날릴 필요가 없는 것입니다. 이 개념을 더티 체킹이라고 합니다. (더티 체킹에 대한 자세한 내용 - https://jojoldu.tistory.com/415)
그럼 테스트 코드를 작성하여 업데이트한 코드가 정상작동 하는지 테스트해보겠습니다.
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
...
@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);
}
}
돌려보면 정상 작동하는 것을 확인할수 있습니다.
JPA와 테스트 코드에 대해 진행해보았으니, 조회 기능은 실제로 톰캣을 실행해서 확인해보겠습니다.
직접 접근하려면 웹 콘솔을 사용해야만 합니다. application.properties에 아래의 코드를 추가해줍시다.
spring.h2.console.enabled=true
추가 한 뒤 Application 클래스의 main 메소드를 실행합니다. 정상적으로 실행됐다면 톰캣이 8080 포트로 실행됐습니다. http://localhost:8080/h2-console로 접속하면 아래와 같이 웹 콘솔 화면이 등장합니다.
여기서 JDBC URL의 값이 jdbc:h2:mem:testdb로 되어있지 않다면 작성해줍니다. 그 후 connect 버튼을 클릭하면 현재 프로젝트의 H2를 관리할 수 있는 관리 페이지로 이동합니다. POSTS 테이블이 있어야 정상적으로 동작한 것 입니다.
간단한 쿼리를 실행해봅시다.
SELECT * FROM POSTS
위의 POSTS 테이블을 조회하면 아래와 같이 빈 테이블이 뜹니다.
테이블에 값을 추가한 뒤, 실행해 봅시다.
insert into posts (author, content, title) values ('author', 'content', 'title');
정상적으로 값이 잘 들어가는 것을 볼 수 있습니다.
등록된 데이터를 확인 후 API를 요청해 보겠습니다. http://localhosts:8080/api/v1/posts/1 을 입력해 API 조회 기능을 테스트해봅니다.
방금 입력한 데이터가 정상적으로 보이는 것을 볼 수 있습니다. 기본적인 등록/수정/조회 기능을 모두 만들고 테스트해 보았습니다.
JPA Auditing으로 생성시간/수정시간 자동화하기
보통 엔티티에는 해당 데이터의 생성시간과 수정시간을 포함합니다. 언제 만들어졌는지, 언제 수정되었는지 등은 차후 유지보수에 있어서 굉장히 중요한 정보이기 때문입니다. 그렇다 보니 매번 DB에 삽입하기 전, 갱신하기 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어가게 됩니다.
이런 단순하고 반복적인 코드가 모든 테이블과 서비스 메소드에 포함되어야 한다고 생각하면 어마어마하게 귀찮고 코드가 지저분해집니다. 그래서 이 문제를 해결하고자 JPA Auditing을 사용하겠습니다.
LocalDate 사용
Java8 부터 LocalDate와 LocalDateTime이 등장했습니다. 그간 Java의 기본 날짜 타입인 Date의 문제점을 제대로 고친 타입이라 Java8일 경우 무조건 써야 한다고 생각하면 됩니다.
domain 패키지에 BaseTimeEntity를 생성합니다. 코드는 아래와 같습니다.
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
}
- @MappedSuperclass
- JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들도 칼럼으로 인식하도록 합니다.
- @EntityListeners(AuditingEntityListener.class)
- BaseTimeEntity 클래스에 Auditing 기능을 포함시킵니다.
- @CreatedDate
- Entity가 생성되어 저장될 때 시간이 자동 저장됩니다.
- @LastModifiedDate
- 조회한 Entity의 값을 변경할 때 시간이 자동 저장됩니다.
BaseTimeEntity 클래스는 모든 Entity의 상위 클래스가 되어 Entity들의 createdDate, modifiedDate를 자동으로 관리하는 역할입니다.
그다음 Posts 클래스가 BaseTimeEntity를 상속받도록 변경합니다.
public class Posts extends BaseTimeEntity {
...
}
마지막으로 JPA Auditing 어노테이션들을 모두 활성화할 수 있도록 Application 클래스에 활성화 어노테이션 하나를 추가해줍니다.
@EnableJpaAuditing // JPA Auditing 활성화
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
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);
}
테스트 코드를 수행해보면 실제 시간이 잘 저장된 것을 확인할 수 있습니다.
출처
'spring > 스프링 부트와 AWS로 혼자 구현하는 웹 서비스' 카테고리의 다른 글
[Spring] 6. AWS 서버 환경 구축 - AWS EC2 (0) | 2021.12.30 |
---|---|
[Spring] 5. 스프링시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 (0) | 2021.12.29 |
[Spring] 4. 머스테치로 화면 구성하기 (0) | 2021.12.28 |
[Spring] 2. 테스트 코드 (0) | 2021.12.28 |
[Spring] 1. 프로젝트 생성 및 gradle 설정 (0) | 2021.12.28 |