Discover us

About us

Projects

Blog

Events

Members

Development Blog

GDGoC CAU 개발자와 디자이너의 작업 과정과
결과물을 공유하는 공간입니다.

어떻게 프로젝트를 시작하게 되었고,
진행하면서 느낀 개발자와 디자이너의
생생한 스토리를 직접 확인해보세요!

Development

3주차 Spring Study

  • #Back-End
  • Seojin Lim
  • 2023. 11. 28.

3주차 Spring Study

JPA소개

notion image
객체지향 프로그래밍에 맞추어 JPA가 관계형 데이터베이스에 맞게 SQL을 대신 생성해서 실행함
 
<장점>
→SQL에 종속적인 개발을 하지 않아도 됨(테이블 마다 CRUD SQL작성할 필요없음, C:create R:read U:update, D:delete) : 패러다임 일치
→JPA ← Hiberante ← Spring Data JPA( 구현체 교체의 용이성, 저장소 교체의 용이성)
<단점>
→실무에선 높은 러닝커브로 잘 안쓰임.
 

요구사항 분석

게시판
0.프로젝트 Spring Data JPA 적용 →테스트 코드
1.등록 API → 테스트 코드
2.수정 API → 테스트 코드
3.조회 API → 테스트 코드
4.JPA Auditing으로 조회시간/생성시간 자동화
 
notion image
 

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

build.gralde에 dependencies 의존성 추가
implementation ('javax.persistence:javax.persistence-api:2.2') implementation('org.springframework.boot:spring-boot-starter-web') implementation('org.springframework.boot:spring-boot-starter-validation') implementation('org.springframework.boot:spring-boot-starter-thymeleaf') implementation('org.projectlombok:lombok') implementation('org.springframework.boot:spring-boot-starter-data-jpa') implementation('com.h2database:h2') implementation ('org.springframework.boot:spring-boot-starter-data-jpa') testImplementation('org.springframework.boot:spring-boot-starter-test')
spring-boot-starter-data-jpa : JPA 관련 라이브러리 관리
h2 : 인메모리 관계형 데이터베이스
 
domain 패키지→ posts 패키지 → Posts 클래스
도메인: 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 혹은 문제영역
실제 DB 테이블과 매칭될 클래스(entity class)
 
package com.hello.book.springboot.domain.posts; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @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 : 테이블과 링크될 클래스임을 나타냄.
  • @Id : 해당 테이블의 PK필드를 나타냄.
  • @GeneratedValue : PK의 생성규칙을 나타냄.
  • @Column : 테이블의 칼럼을 나타내며 굳이 선언하지 않아도 해당 클래스의 필드는 모두 칼럼이 됨. 기본값외에 추가로 변경이 필요한 경우 사용 (ex.사이즈, 타입 변경 등 )
 
domain 패키지→ posts 패키지 → PostsRepository (클래스-인터페이스)
Posts 클래스로 DB접근하게 해줄 JpaRepository
package com.hello.book.springboot.domain.posts; import org.springframework.data.jpa.repository.JpaRepository; public interface PostsRepository extends JpaRepository<Posts, Long>{ }
인터페이스로 생성하고 / 상속하면 기본적인 crud 메소드 자동 생성
 
Spring Data JPA 테스트 코드
 
test 디렉→ com.~springboot 패키지→ domain.posts 패키지 생성 → PostsRepositoryTest 클래스
package com.hello.book.springboot.web.domain.posts; import com.hello.book.springboot.domain.posts.Posts; import com.hello.book.springboot.domain.posts.PostsRepository; 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 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("jojoldu@gmail.com") .build() ); //when List<Posts> postsList = postsRepository.findAll(); //then Posts posts = postsList.get(0); assertThat(posts.getTitle()).isEqualTo(title); assertThat(posts.getContent()).isEqualTo(content); } }
@SpringBootTest 사용할 경우 H2 데이타베이스 자동 실행해준다
application.properties mysql문 버전으로 쿼리로그 확인 가능
spring.jpa.show_sql=true spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect spring.jpa.properties.hibernate.dialect.storage_engine=innodb spring.datasource.hikari.jdbc-url=jdbc:h2:mem:testdb spring.h2.console.enabled=true
 

1. 등록API 생성

PostsApiController
package com.hello.book.springboot.web; import com.hello.book.springboot.service.posts.PostsService; import com.hello.book.springboot.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.hello.book.springboot.service.posts; //import com.hello.book.domain.posts.PostsRepository; //import com.hello.book.dto.PostsSaveRequestDto; import com.hello.book.springboot.domain.posts.PostsRepository; import com.hello.book.springboot.web.dto.PostsSaveRequestDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Service public class PostsService { private final PostsRepository postsRepository; @Transactional public Long save(PostsSaveRequestDto requestDto) { return postsRepository.save(requestDto.toEntity()).getId(); } }
@RequiredArgsConstructor에서 생성자 생성
 
PostsSaveRequestDto
Controller와 Service에서 사용할 DTO클래스 생성
view를 위한 클래스로, entity 클래스(테이블, 스키마 생성)와 분리!(자주 변경)
package com.hello.book.springboot.web.dto; import com.hello.book.springboot.domain.posts.Posts; 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(); } }
 
Test
package com.hello.book.springboot.web; import com.hello.book.springboot.domain.posts.Posts; import com.hello.book.springboot.domain.posts.PostsRepository; import com.hello.book.springboot.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.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.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 PostsRepository postsRepository; @After public void tearDown() { postsRepository.deleteAll(); } @Test public void Posts_register() throws Exception { //given String title = "title"; String content = "content"; String author = "author"; 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> postsList = postsRepository.findAll(); assertThat(postsList.get(0).getTitle()).isEqualTo(title); assertThat(postsList.get(0).getContent()).isEqualTo(content); assertThat(postsList.get(0).getAuthor()).isEqualTo(author); } }
WebMvcTest를 사용하지 않는 이유는 JPA 기능이 작동하지 않기 때문이다.
 
 

2,3. 수정/조회 API 생성

PostApiController
@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); }
PostsResponseDto
package com.hello.book.springboot.web.dto; import com.hello.book.springboot.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(); } }
entity 필드의 일부만 사용하므로, 생성자로 entity를 받아 필드에 값을 넣는다.
 
PostsUpdateRequestDto
package com.hello.book.springboot.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; } }
Posts
public void update(String title, String content) { this.title = title; this.content = content; }
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; } public PostsResponseDto findById(Long id) { Posts entity = postsRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id = " + id)); return new PostsResponseDto(entity); }
PostApiControllerTest
package com.hello.book.springboot.web; import com.hello.book.springboot.domain.posts.Posts; import com.hello.book.springboot.domain.posts.PostsRepository; import com.hello.book.springboot.web.dto.PostsSaveRequestDto; import com.hello.book.springboot.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.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.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.time.LocalDateTime; 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 PostsRepository postsRepository; @After public void tearDown() { postsRepository.deleteAll(); } @Test public void Posts_register() throws Exception { //given String title = "title"; String content = "content"; String author = "author"; 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> postsList = postsRepository.findAll(); assertThat(postsList.get(0).getTitle()).isEqualTo(title); assertThat(postsList.get(0).getContent()).isEqualTo(content); assertThat(postsList.get(0).getAuthor()).isEqualTo(author); } @Test public void Posts_수정된다() throws Exception{ Posts savedPosts = postsRepository.save(Posts.builder() .title("title") .content("content") .author("author") .build()); Long updatedId = 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/"+updatedId; HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto); ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PUT,requestEntity,Long.class); 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); } }
확인
  1. Application의 main 메소드 실행
  1. http://localhost:8080/h2-console 접속
  1. jdbc:h2:mem:testdb
  1. [Connet]
  1. POSTS 테이블 확인 → SELECT * FROM posts;
  1. insert into posts (author, content, title) values (’author’, ‘content’, ‘title’);
  1. http://localhost:8080/api/v1/posts/1 조회
 

4. JPA Auditing으로 생성시간/조회시간 자동화

BaseTimeEntity 생성
도메인 패키지에 생성
package com.hello.book.springboot.domain; 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; }
테스트 코드
@Test public void BaseTimeEntity_등록() { LocalDateTime now = LocalDateTime.of(2023, 11, 7, 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(">>>>>>>>> createdDate = " + posts.getCreatedDate() + ", modifiedDate = " + posts.getModifiedDate()); assertThat(posts.getCreatedDate()).isAfter(now); assertThat(posts.getModifiedDate()).isAfter(now); }