[2] 스프링 부트와 JPA

hyyyynjn·2021년 2월 26일
0
post-thumbnail

API 만들기

  • API를 만들기 위해 필요한 3개의 클래스
    1. Request 데이터를 받을 Dto 클래스
    2. API 요청을 받을 Controller
    3. 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service

Spring의 Web 계층

  • web layer

    • 컨트롤러와 뷰 템플릿 영역이다
    • 외부 요청과 응답에 대한 전반적인 영역이다.
  • service layer (서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해준다.)

    • @Service에 사용되는 서비스 영역
    • 일반적으로 Controller와 Dao의 중간 영역에서 사용된다.
    • @Transactional이 사용되어야 할 영역이다.
  • repository layer

    • DB와 같이 데이터 저장소에 접근하는 영역이다.
    • Dao 영역으로 이해하면 쉽다.
  • Dtos

    • Data Transfer Object : 계층간에 데이터 교환을 위한 객체이다.
    • Dtos : Dto들의 영역
    • 뷰 탬플릿 엔진에서 사용될 객체, Repository layer에서 결과 넘겨준 객체를 말한다.
  • domain model (비지니스 처리를 담당해야 할 곳이다)

    • "개발 대상" (=도메인)을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화 시킨 것을 도메인 모델이라고 한다.
    • 택시 앱에서 '배차', '탑승', '요금' 등이 도메인이다.
    • @Entity가 사용될 영역도 도메인 모델이라고 할 수 있다.
    • 하지만 무조건 데이터베이스 테이블과 관련 있어야 하는 것은 아니다.

바람직하지 않은 코드

@Transational
public Order cancelOrder(int orderId) {
	
    OrdersDto order = orderDao.selectOrders(orderId);
    BillingDto order = billingDao.selectBilling(orderId);
    DeliveryDto order = deliveryDao.selectDelivery(orderId);
    
    String deliveryStatus = delivery.getStatus();
    
    if("IN_PROGRESS".equals(deliveryStatus)) {
    	delivery.setStatus("CANCEL");
        deliveryDao.update(delivery);
    }
    
    order.setStatus("CANCEL");
    ordersDao.update(order);
    
    billing.setStatus("CANCEL");
    deliveryDao.update(billing);
    
    return order;
}
  • 이처럼 모든 로직이 서비스 클래스 내부에서 처리된다면, 서비스 계층이 무의미해진다.

바람직한 코드

@Transational
public Order cancelOrder(int orderId) {
	
    Orders order = ordersRepository.findById(orderId);
    Billing billing = billingRepository.findByOrderId(orderId);
    Delivery delivery = deliveryRepository.findByOrderId(orderId);
    
    delivery.cancel();
    
    order.cancel();
    billing.cancel();
    
    return order;
}
  • 도메인 모델에서 로직이 처리되는 경우

api controller, dto, service

  • 등록, 수정, 삭제 기능에 관한 PostsApiController, PostsSaveRequestDto, PostsService를 생성한다.
@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;

    @PostMapping("/api/v1/posts")
    public Long save(@RequestBody PostSaveRequestDto requestDto) {
        return postsService.save(requestDto);
    }

    @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);
    }
}
@RequiredArgsConstructor
@Service
public class PostsService {

    private final PostsRepository postsRepository;

    @Transactional
    public Long save(PostSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }


    // update 부분에서 DB에 쿼리를 날리는 부분이 없다. (JPA의 영속성 컨텍스트 때문이다)
    // 영속성 컨텍스트 : 엔티티를 영구 저장하는 환경. 일종의 논리적 개념
    // JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있는지 아닌지에 갈린다.
    // JPA의 엔티티 메니저가 활성화된 상태로 트랜잭션 안에서 데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태
    // 이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경된 내용을 반영한다.
    // 더티 체킹 : 엔티티 객체의 값만 변경하면 별도로 update 쿼리를 날릴 필요가 없다.
    @Transactional
    public Long update(Long id, PostsUpdateRequestDto requestDto) {
        Posts posts = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id="+id));

        // 더티 체킹 : 엔티티 객체의 값만 변경하면 별도로 update 쿼리를 날릴 필요가 없다.
        posts.update(requestDto.getTitle(), requestDto.getContent());

        return id;
    }

    @Transactional
    public PostsResponseDto findById(Long id) {
        Posts entity = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id="+id));

        return new PostsResponseDto(entity);
    }
}
  • controller와 service 에서 Autowired가 없이 Bean을 주입하는 방법에는 3가지가 있다.

    1. @Autowired
    2. setter
    3. 생성자 (가장 권장하는 방식)
  • @RequiredArgsConstructor

    • final이 선언된 모든 필드를 인자값으로 하는 생성자를 생성하는 롬복 어노테이션이다.
  • update 부분에서 DB에 쿼리를 날리는 부분이 없다. 그 이유는 JPA의 영속성 컨텍스트 덕분이다.

    • 영속성 컨텍스트
      • 엔티티를 영구 저장하는 환경이다.
      • 일종의 논리적 개념이다.
  • JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있는지 아닌지에 갈린다.

    • 영속성 컨텍스트가 유직된 상태
      • JPA의 EntityManager가 활성화된 상태로, 트랜잭션 안에서 데이터베이스에서 데이터를 가져올 경우
      • 이 데이터의 상태가 영속성 컨텍스트가 유지된 상태이다.
        • 이 상태(영속성 컨텍스트가 유지된 상태)에서 해당 데이터의 값을 변경하면 트랜젝션이 끝나는 시점에 해당 테이블에 변경된 내용을 반영한다.
        • 다시말해, 엔티티 객체의 값만 변경한다면 별도로 쿼리를 날릴필요가 없다는 것이다. (더티 체킹 개념)
@Getter
@NoArgsConstructor
public class PostSaveRequestDto {

    private String title;
    private String content;
    private String author;

    @Builder
    public PostSaveRequestDto(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();
    }
}
  • controller와 service에서 사용할 dto 클래스이다.
  • dto 클래스
    • request/ response 클래스로 사용된다.
      • dto 클래스(view layer)는 entity 클래스(db layer)와 유사하다.
        • 하지만 entity 클래스를 request/ response 클래스로 사용하면 안된다.
        • 데이터베이스와 맞닿은 핵심 클래스이기 떄문이다.
          • 따라서, Entity 클래스 따로 Controller에서 사용할 Dto 클래스 따로 분리하여 사용해야한다.
@Getter
public class PostsResponseDto {

    // final 키워드는 엔티티를 한 번만 할당한다. 즉, 두 번 이상 할당하려 할 때 컴파일 오류가 발생하여 확인이 가능
    private final Long id;
    private final String title;
    private final String content;
    private final String author;


    public PostsResponseDto(Posts entity) {
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.content = entity.getContent();
        this.author = entity.getAuthor();
    }
}
  • PostsResponseDto는 entity의 일부분만 사용하기 떄문에 생성자로 entity를 받아 필드에 갚을 넣는다.
    • 모든 필드를 갖는 생성자가 필요하지 않기 떄문에 entity를 받아 처리한다.

test code

@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 {
        String title = "title";
        String content = "content";
        PostSaveRequestDto requestDto = PostSaveRequestDto.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts";

        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);

        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);

    }

    @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);
    }
}
  • @WebMvcTest를 사용하지 않는다

    • 해당 어노테이션을 사용하면 JPA 기능이 작동하지 않기 때문이다.
  • Controller와 ControllerAdvice 과 같이 외부 연동과 관련된 부분만 활성화하면 되므로 @SpringBootTest와 TestRestTemplate만 사용한다.

  • WebEnvironment.RANDOM_PORT : 테스트시 사용될 포트번호를 랜덤으로 생성하는 옵션이다.

  • 로컬환경에서는 H2 데이터베이스를 사용한다.

    • H2 데이터베이스는 메모리에서 실행되므로 웹 콘솔을 통해 접근할 수 있다.
      • application.yaml 파일에서 spring.h2.console.enabled=true 옵션을 추가한다.
      • http://localhost:8080/h2-console 로 접속하면 웹 콘솔 화면이 등장한다.
      • JDBC URL 란에 jdbc:h2:mem:testdb 를 작성하고 Connect 버튼을 눌러 웹 콘솔에 접근한다.

JPA Auditing

  • JPA Auditing으로 생성시간과 수정시간을 자동화할 수 있다.

  • 먼저 domain 패키지에 BaseTimeEntity abstract 클래스를 생성한다.

    • BaseTimeEntity 클래스는 모든 Entity 클래스의 상위 클래스가 되어 createdDate, modifiedDate를 자동으로 관리하는 역할을 한다.
    • public class Posts extends BaseTimeEntity {...} 처럼 엔티티 클래스에 상속한다.
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)

// 모든 Entity 클래스에 상속시킨다.
public abstract class BaseTimeEntity {

    @CreatedDate
    private LocalDateTime createdData;

    @LastModifiedDate
    private LocalDateTime modifiedData;
}
  • @MappedSuperclass
    • JPA 엔티티 클래스들이 BaseTimeEntity을 상속할 경우 필드들(createdData,modifiedData)도 칼럼으로 인식하도록 한다.
  • @EntityListeners
    • BaseTimeEntity 클래스에 Auditing 기능을 포함시킨다.
  • @CreatedDate
    • 엔티티가 생성되어 저장될 떄의 시간이 자동 저장된다.
  • @LastModifiedDate
    • 조회한 엔티티의 값을 변경할 떄의 시간이 자동 저장된다.
// @EnableJpaAuditing : JPA Auditing 활성화
@EnableJpaAuditing
// @SpringBootApplication : 스프링 부투의 자동설정, 스프링 Bean 읽기와 생성을 모두 자동을 설정해준다. (항상 프로젝트 상단에 위치해야함)
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        // SpringApplication.run 함수 : 내장 WAS(웹 어플리케이션 서버)를 실행한다. 외부의 별도 WAS(Tomcat 등)가 필요 없게된다
        // 스프링 부트로 만들어진 Jar 파일(실행가능한 Java 패키징 파일)로 실행하면 된다.
        // 내장 WAS는 언제 어디서나 같은 환경에서 스프링 부트를 배포할 수 있다.
        SpringApplication.run(Application.class, args);
    }
}
  • @EnableJpaAuditing 어노테이션으로 JPA Auditing 활성화한다.

0개의 댓글