요구사항 분석
* 게시판 기능
- 게시글 조회, 등록, 수정, 삭제
* 회원 기능
- 구글/네이버 로그인
- 로그인한 사용자 글 작성 권한
- 본인 작성 글에 대한 권한 관리
1. 의존성 등록
* build.gradle
// spring data JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// h2 데이터베이스
runtimeOnly 'com.h2database:h2'
- spring-boot-starter-data-jpa
- 스프링 부트용 Spring Data Jpa 추상화 라이브러리
- 스프링 부트 버전에 맞춰 자동으로 JPA 관련 라이브러리들의 버전 관리해줌
- h2
- 인메모리 관계형 데이터베이스
- 별도의 설치 X, 프로젝트 의존성만으로 관리
- 메모리에서 실행 -> 애플리케이션을 재시작할 때마다 초기화 -> 테스트용으로 많이 사용함
2. 도메인
* Posts
package com.example.studyspringbootwebservice.domain.posts;
import jakarta.persistence.*;
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;
}
}
(1) JPA 어노테이션
- @Entity
- 테이블과 링크될 클래스
- 클래스의 카멜케이스 이름 -> 언더스코어 이름으로 테이블 매칭
- @Id
- PK 필드
- PK 필드
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- PK 생성 규칙 - 데이터베이스에서 생성해줌 (auto-increment)
- 웬만하면 Auto-increment 추천
- @Column
- 선언하지 않아도 해당 클래스의 필드를 테이블의 컬럼과 매핑해주지만, 추가로 변경이 필요한 옵션이 있을 때 사용
- ex) 문자열의 기본 길이 VARCHAR(255)를 500으로 늘리거나,
타입을 TEXT로 변경하고 싶을 때 등등
(2) 롬복 어노테이션
- @NoArgsConstructor
- 기본 생성자 자동 추가 -> JPA는 기본 생성자 필수 !
- 기본 생성자 자동 추가 -> JPA는 기본 생성자 필수 !
- @Getter
- @Builder
- 해당 클래스의 빌더 패턴 클래스 생성
- 생성자 상단에 선언 시 생성자에 포함된 필드만 빌더에 포함
- 생성자 대신 @Builder를 사용한 이유
: 생성자는 new Posts(a, b, c)에서 b, c, a 등으로 위치를 변경해도 문제를 찾을 수 X
-> 빌더는 어느 필드에 어떤 값을 채워야 할지 명확하게 인지 O
🚨 Entity 클래스에서는 절대 @Setter 메소드를 만들지 않음 !!!
* setter 사용시
- 그냥 아무때나 set으로 값 변경 -> 언제 어디서 왜 setting 하는지 명확하게 알 수 X
public class Order {
public void setStatus(boolean status) {
this.status = status;
}
}
public void 주문서비스의_취소이벤트() {
order.setStatus(false);
}
* 올바른 사용
- 값 변경의 목적과 의도가 명확함
- 생성자 / 빌더 사용해서 값 채움
public class Order {
public void cancelOrder() {
this.status = false;
}
}
public void 주문서비스의_취소이벤트() {
order.cancelOrder();
}
3. 리포지토리 - DB 접근
package com.example.studyspringbootwebservice.domain.posts;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostsRepository extends JpaRepository<Posts, Long> {
}
- 인터페이스 !!
- @Repository 필요 X
- 💡 Entity 클래스와 기본 Entity Repository는 함께 위치해야 함
- 도메인 별로 프로젝트를 분리해야 할 때, Entity와 Repository가 함께 움직여야 하므로 도메인 패키지에서 함께 관리
4. 테스트
package com.example.studyspringbootwebservice.domain.posts;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
@ExtendWith(SpringExtension.class)
@SpringBootTest
public class PostsRepositoryTest {
@Autowired
PostsRepository postsRepository;
@AfterEach
public void cleanup() {
postsRepository.deleteAll();
}
@Test
public void 게시글저장_불러오기() {
//given
String title = "테스트 게시글";
String content = "테스트 본문";
postsRepository.save(Posts.builder()
.title(title)
.content(content)
.author("wnnns4@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);
}
}
- @ExtendWith(SpringExtension.class)
- @Autowired, @MockBean에 해당하는 것들만 application context를 로딩
- @Autowired, @MockBean에 해당하는 것들만 application context를 로딩
- @SpringBootTest
- H2 데이터베이스 자동으로 실행
- H2 데이터베이스 자동으로 실행
- @AfterEach
- @After (Junit4) -> @AfterEach (Junit5)
- 단위 테스트가 끝날때마다 수행
* 쿼리 로그 확인 application.properties
spring.jpa.show-sql=true
5. 등록/수정/조회 API 만들기
💡 Spring 웹 계층
- Web Layer
컨트롤러(@Controller)와 JSP 등의 뷰 템플릿 영역
필터(@Filter), 인터셉터, 컨트롤러 어드바이스(@Controller Advice) 등 외부 요청과 응답에 대한 전반적인 영역
- Service Layer
@Service에 사용되는 서비스 영역
일반적으로 Controller와 Dao의 중간 영역에서 사용
@Transactional이 사용되어야 하는 영역
- Repository Layer
Database와 같이 데이터 저장소에 접근하는 영역
Dao 영역
- Dto
계층 간에 데이터 교환을 위한 객체들의 영역
- Domain
도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화시킨 것
@Entity가 사용된 영역
VO처럼 값 객체들도 해당
* Domain : 비즈니스 처리 담당 !
* Service : 트랜잭션, 도메인 간 순서 보장 ! (비즈니스 처리 X)
@Transactional public Order cancelOrder(int orderId) { // 1) DB에서 주문정보, 결제정보, 배송정보 조회 Orders order = ordersRepository.findById(orderId); Billing billing = billingRepository.findByOrderId(orderId); Delivery delivery = deliveryRepository.findByOrderId(orderId); // 2) 배송 취소를 해야하는지 확인 // 3) if (배송중) -> 배송취소로 변경 delivery.cancel(); // 4) 각 테이블에 취소상태 update order.cancle(); billing.cancle(); return order; }
💡 스프링에서 Bean 주입받는 방식
1. @Autowired (권장 X)
2. setter
3. 생성자 (권장) : @ReuiredArgsConstructor + private final ~~
-> 생성자를 직접 쓰지 않고 롬복 사용 : 의존성 관계가 변경될 때마다 생성자 코드를 수정하지 않아도 됨
1) 등록
(1) 컨트롤러
package com.example.studyspringbootwebservice.web;
import com.example.studyspringbootwebservice.service.posts.PostsService;
import com.example.studyspringbootwebservice.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);
}
}
- @RequiredArgsConstructor + final
- PostsService를 의존성 주입받음
- PostsService를 의존성 주입받음
- @RestController
- 리턴값에 자동으로 @ResponseBody가 붙음
- http 응답 body에 자바 객체가 매핑되어 전달
- @Controller + @ResponseBody
- -> REST API 적용 (@RestController + @PostMapping (HTTP 메소드) + @RequestBody, @ResponseBody (JSON 데이터 처리 -> Long 타입의 ID를 반환) )
- @RequestBody
- 일반적인 GET, POST 말고, xml, json 기반의 데이터를 요청 body에 담아서 서버로 보냄
- http 요청의 body 전부를 PostsSaveRequestDto 자바객체로 변환해서 매핑된 메소드 파라미터로 전달, 객체에 저장
- @ResponseBody
- 자바 객체를 json 형태로 반환하여 http 응답 body에 담아 클라이언트로 전송
- 자바 객체만 리턴하도록 짜면 됨
- @RestController를 사용하면 생략
(2) 서비스
package com.example.studyspringbootwebservice.service.posts;
import com.example.studyspringbootwebservice.domain.posts.PostsRepository;
import com.example.studyspringbootwebservice.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();
}
}
(3) Dto
package com.example.studyspringbootwebservice.web.dto;
import com.example.studyspringbootwebservice.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();
}
}
(4) 테스트
package com.example.studyspringbootwebservice.web;
import com.example.studyspringbootwebservice.domain.posts.Posts;
import com.example.studyspringbootwebservice.domain.posts.PostsRepository;
import com.example.studyspringbootwebservice.web.dto.PostsSaveRequestDto;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PostsApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@AfterEach
public void tearDown() {
postsRepository.deleteAll();
}
@Test
public void Posts_등록() {
//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
// post 요청 - postForEntity(url, post 요청 바디로 보낼 객체, 서버 응답을 변환할 타입)
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);
}
}
- @SpringBootTest
- @WebMvcTest -> JPA 기능 작동 X
- JPA 기능까지 한번에 테스트할 때는 @SpringBootTest, TestRestTemplate 사용
- @LocalServerPort
- 스프링 부트 테스트에서 사용
- 테스트 중인 웹 서버의 포트 번호 주입받을 때 사용 (임의의 포트 번호로 웹 서버 테스트 실행)
- TestRestTemplate
- 스프링 프레임워크의 테스트 환경에서 사용하는 HTTP 클라이언트 라이브러리
- 실제 서버를 실행하지 않고, 컨트롤러 엔드포인트 호출, 응답
2) 수정
(1) 컨트롤러
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsService;
// 수정
@PutMapping("/api/v1/posts/{id}")
public Long update(@PathVariable Long id, @RequestBody PostsUpdateRequestDto requestDto) {
return postsService.update(id, requestDto);
}
(2) 서비스
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
// 수정
@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;
}
- postsRepository의 save(), findById() 와 달리 update는 쿼리를 날리는 부분이 없음 !! -> JPA의 영속성 컨텍스트 때문
- 영속성 컨텍스트 : 엔티티를 영구 저장하는 환경
- 트랜잭션이 적용되고 있는 상태에서 엔티티 값 변경 -> 트랜잭션 커밋/롤백 시점에 자동으로 DB에 변경내용 반영
- -> 엔티티 객체의 값만 변경하면 -> 별도로 update 쿼리 날릴 필요 X (더티 체킹)
* Posts 객체에 update() 메소드 추가 - 엔티티 객체의 값 변경
@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;
}
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
(3) 수정 Dto
package com.example.studyspringbootwebservice.web.dto;
import com.example.studyspringbootwebservice.domain.posts.Posts;
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;
}
}
(4) 테스트
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PostsApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@AfterEach
public void tearDown() {
postsRepository.deleteAll();
}
@Test
public void Posts_수정() {
//given
// Posts 생성해서 DB에 저장
Posts savedPosts = postsRepository.save(Posts.builder()
.title("title")
.content("content")
.author("author")
.build());
Long updateId = savedPosts.getId();
// 변경할 내용
String expectedTitle = "title2";
String expectedContent = "content2";
// 변경할 내용을 담은 PostsUpdateRequestDto 생성
PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
.title(expectedTitle)
.content(expectedContent)
.build();
// 테스트할 API의 엔드포인트
String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;
// http 요청을 위한 httpEntity 객체 생성 (http 요청 바디에 requestDto 담아서 보냄)
HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);
//when
// exchange - put보다 유연함 (HttpEntity 포함)
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);
}
}
- HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto)
- HttpEntity : HTTP 요청이나 응답 메시지를 나타내는 클래스
- HttpEntity 객체를 생성하는데, PostsUpdateRequestDto 타입의 requestDto를 HTTP 요청의 바디에 담아서 보냄
- 요청의 헤더와 바디를 커스터마이징할 수 있음
- 바디에 다양한 데이터 타입을 담을 수 있음 (JSON, XML, 문자열 등)
- TestRestTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class)
- put() 도 있지만, HttpEntity를 포함하는 등 더 유연하게 설정하기 위해 exchange() 사용
- HttpMethod.PUT : HTTP 메소드를 명시적으로 지정
- requestEntity : HttpEntity 객체를 사용해서 요청의 헤더와 바디를 설정
- Long.class : 응답 데이터 타입 지정
3) 조회
(1) 컨트롤러
@RequiredArgsConstructor
@RestController
public class PostsApiController {
private final PostsService postsService;
// 조회
@GetMapping("/api/v1/posts/{id}")
public PostsResponseDto findById(@PathVariable Long id) {
return postsService.findById(id);
}
** 응답객체 PostsResponseDto 반환 !!
(2) 서비스
@RequiredArgsConstructor
@Service
public class PostsService {
private final PostsRepository postsRepository;
// 조회
public PostsResponseDto findById(Long id) {
Posts entity = postsRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당 게시물이 없습니다. id=" + id));
return new PostsResponseDto(entity);
}
}
- DB에서 id에 해당하는 Posts 객체를 찾아서, PostsResponseDto에 그 값을 넣어서 반환
(3) 응답객체 Dto
package com.example.studyspringbootwebservice.web.dto;
import com.example.studyspringbootwebservice.domain.posts.Posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
public class PostsResponseDto {
private Long id;
private String title;
private String content;
private String author;
@Builder
public PostsResponseDto(Posts entity) {
this.id = entity.getId();
this.title = entity.getTitle();
this.content = entity.getContent();
this.author = entity.getAuthor();
}
}
(4) 테스트
h2 데이터베이스와 톰캣 사용해서 실행
① application.properties
# h2 console 활성화
spring.h2.console.enabled=true
# 원격접속 허용
spring.h2.console.settings.web-allow-others=true
# h2 console 경로
spring.h2.console.path=/h2-console
# h2 url 경로
spring.datasource.url=jdbc:h2:mem:testdb
# class 명칭
spring.datasource.driverClassName=org.h2.Driver
② localhost:8080/h2-console 접속
JDBC URL: jdbc:h2:mem:testdb
③ 쿼리 실행
insert into posts(author, content, title) values('author', 'content', 'title')
④ api 요청
==> 객체지향적인 코드 !!!
6. JPA Auditing으로 생성시간/수정시간 자동화
엔티티들이 공통적으로 꼭 가져야 하는 필드 -> 생성시간, 수정시간
(공통 -> 중복이라는 뜻 ! )
원래는 이런식으로 각 객체마다 다 일일이 생성/수정 시간 세팅해줬음
posts.setCreatedDate(new LocalDate());
posts.setUpdatedDate(new LocalDate());
-> JPA Auditing 사용
💡 LocalDate / LocalDateTime
- Java8 부터 등장
- 원래 쓰던 Date, Calendar 의 문제점 : 불변객체가 아님 + Calendar는 월이 안맞음 (10월을 '9'로 표시)
1) BaseTimeEntity 추상클래스
package com.example.studyspringbootwebservice.domain;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Getter
@MappedSuperclass // JPA Entity 클래스들이 상속할 경우 이 필드들도 테이블 컬럼으로 인식하도록 함
@EntityListeners(AuditingEntityListener.class) // BaseTimeEntity 클래스에 Auditing 기능 포함
public abstract class BaseTimeEntity {
@CreatedDate // Entity가 생성되어 저장될 때 시간 자동 저장
private LocalDateTime createdDate;
@LastModifiedDate // 조회한 Entity의 값을 변경할 때 시간 자동 저장
private LocalDateTime modifiedDate;
}
- 모든 엔티티의 상위 클래스가 되어 엔티티들의 createdDate, modifiedDate를 자동으로 관리 !
2) Posts 객체 - BaseTimeEntity 상속받도록 변경
@Getter
@NoArgsConstructor
@Entity
public class Posts extends BaseTimeEntity { ... }
3) Application에서 JPA Auditing 기능 활성화
@EnableJpaAuditing // JPA Auditing 활성화
@SpringBootApplication
public class Application { ... }
4) 테스트
@ExtendWith(SpringExtension.class)
@SpringBootTest
public class PostsRepositoryTest {
@Autowired
PostsRepository postsRepository;
@AfterEach
public void cleanup() {
postsRepository.deleteAll();
}
@Test
public void BaseTimeEntity_등록() {
//given
// 시간 설정
// LocalDateTime now = LocalDateTime.of(2023, 8, 11, 0, 0, 0);
LocalDateTime now = LocalDateTime.now();
// Posts 객체 생성, 저장
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);
}
}
- LocalDateTime.now() 로 하면 현재 날짜와 시간 설정됨
=> BaseTimeEntity 상속 + JPA Auditing 활성화 -> 등록/수정 시간 자동화 !!
'Spring' 카테고리의 다른 글
[스프링 부트와 AWS로 혼자 구현하는 웹 서비스] 5. 스프링 시큐리티, OAuth2.0, Thymeleaf로 소셜로그인 기능 구현하기 (0) | 2023.08.21 |
---|---|
[스프링 부트와 AWS로 혼자 구현하는 웹 서비스] 4. 타임리프로 화면 구성하기 (0) | 2023.08.18 |
[스프링 부트와 AWS로 혼자 구현하는 웹 서비스] 2. 테스트코드 작성하기 (0) | 2023.08.01 |
[스프링 부트와 AWS로 혼자 구현하는 웹 서비스] 1. 인텔리제이로 스프링 부트 시작하기 (0) | 2023.07.31 |
[인프런/스프링DB 2편] 6. 스프링 트랜잭션 전파 (2) (0) | 2023.07.28 |