오늘날 관계형 DBMS(Oracle, MySQL 등등..)을 안쓰는 곳이 없을 정도로 데이터를 관리하는 부분에서 굉장히 많은 부분을 차지합니다. 그러다 보니 모든 코드가 어플리케이션 코드가 아닌 SQL 중심으로 돌아가고 있습니다. 이것은 관계형 데이터베이스가 SQL만 인식할 수 있기 때문인데 기본적인 SQL문인 CRUD도 테이블마다 매번 생성을 해야합니다.
예를들면, A라는 객체를 만들고, 조회하고, 내용물을 수정해야한다고 생각하면 반드시 SQL을 통해야만 합니다.
학교 프로젝트를 통해서도 관련 테이블만 수십개 정도 존재할 수 있는데 현업에서는 어떨까요? 상상도 하기 싫습니다..
그리고 한가지 문제가 더 존재하는데, 객체지향 프로그래밍 언어는 기능과 속성을 한 곳에서 관리하는 기술이고 관계형 DB는 어떻게 데이터를 저장할지에 초점이 맞춰진 기술이라 패러다임 불일치가 발생합니다. 관계형 DB에서는 객체지향적인 코딩을 하기 힘들다는 의미입니다.
이러한 패러다임 불일치 문제를 해결하기 위해 나온것이 JPA입니다. JPA는 개발자가 객체지향적 코딩을 하면 SQL에 맞게 변환해주는 역할을 합니다.
JPA는 인터페이스기 때문에 안에 내용물이 없습니다. 그러므로 내용물을 채워야하니 구현체(Hibernate, Eclipse 등)가 필요합니다. 그 중 Spring Data JPA 라는 모듈을 사용하겠습니다.
Spring Data JPA란?
JPA를 편하게 쓸 수 있도록 Spring 측에서 만든 모듈입니다.
repository라는 인터페이스를 가지고 있습니다. 사용자는 이 인터페이스의 규약대로 메소드를 적으면 메소드 이름에 적합한 쿼리를 날립니다. 또한, Hibernate 외에 다른 구현체로 쉽게 교체가 가능하고, 관계형 DB또한 다른 저장소로 쉽게 교체가 가능합니다.
책에 적힌대로 3~6장까지 게시판을 만들겠습니다.
게시판 기능
조회, 등록, 수정, 삭제
회원 기능
구글/ 네이버 로그인
로그인한 사용자 글 작성 권한
본인 작성 글에 대한 권한 관리
build.gradle에 다음과 같이 의존성을 등록하겠습니다.
compile('org.springframework.boot:spring-boot-starter-data-jpa') // 1.
compile('com.h2database:h2:') // 2.
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.
롬복 어노테이션이 아닌 주요 어노테이션을 클래스에 가깝게 두면 나중에 쉽게 삭제할 수 있습니다.
Tip.
Entity의 PK는 Long타입의 Auto_increment를 써야합니다. 주민번호나 여러키를 조합한 복합키로 PK를 잡으면 다음과 같은 문제가 발생합니다.
- FK를 맺을 때 달느 테이블에서 복합키 전부를 갖고 있거나, 중간 테이블을 하나 더 둬야 하는 상황이 발생
- 인덱스에 안좋음
- 유니크한 조건이 변경될 경우 PK 전체를 수정해야 하는 일이 발생.
때문에 주민번호,복합키 등은 유니크 키로 별도로 추가합시다.
여기서 부터 롬복 어노테이션입니다
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.
}
@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.
Tip.
실제 실행된 쿼리를 로그를 통해 볼 수 있습니다. 다만 H2 쿼리 문법으로 적용되었기에 이 부분은 고쳐야 합니다.
spring.jpa.show_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
주문 취소나 회원 가입등의 비즈니스는 Domain에서 처리를 해야합니다.
기존 Service 에서 처리하는 방식은 모든 비즈니스 로직이 서비스 클래스 내부에서 처리되기 때문에 서비스 계층이 무의미 해집니다.
API를 만들기 위해선 총 3개의 클래스가 필요합니다.
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가지 입니다.
이때 생성자로 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 을 입력하면 조회기능이 나옵니다.
등록/수정은 테스트 코드로 보호해 주고 있어 변경 사항이 있어도 안전하게 변경할 수 있습니다.
데이터의 만들어진 시간, 수정된 시간은 차후 유지보수에 있어 정말 중요한 정보입니다. 그렇기 때문에 매번 DB에 삽입하기 전 갱신하기 전 코드로 프로그램이 무거워 집니다.
이 문제는 JPA Auditing으로 해결할 수 있습니다.
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를 자동으로 관리합니다.
그리고 Posts클래스가 이 클래스를 상속받도록 변경하고 JPA Auditing 어노테이션들을 모두 활성화하도록 Application 클래스에 어노테이션 하나를 추가합니다.
@EnableJpaAuditing 클래스 위에 추가
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만 상속받으면 자동으로 적용됩니다!