🛠️Backend/Spring

[Spring Boot] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - 06

뉴발자 2022. 12. 1. 23:19
728x90

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Spring의 웹 계층

 

 


Spring의 웹 계층

 

Web Layer

  • 흔히 사용하는 Controller와 JSP/Freamwork 등의 뷰 템플릿 영역이다.
  • 이외에도 Filter, Interceptor, ControllerAdvice 등 외부 요청과 응답에 대한 전반적인 영역을 이야기한다.

 

 

Service Layer

  • @Service에 사용되는 서비스 영역이다.
  • 일반적으로 Controller와 DAO의 중간 영역에서 사용된다.
  • @Transactional이 사용되어야 하는 영역이기도 하다.

 

 

Repository Layer

  • Database와 같이 저장소에 접근하는 영역이다.
  • 기존에 개발 경험이 있다면 DAO(Data Access Object)와 같은 영역이라고 생각하면 된다.

 

 

DTOs

  • DTO(Data Transfer Object)는 계층 간에 데이터 교환을 위한 객체를 이야기하며 DTOs는 이들의 영역을 이야기한다.
  • 예를 들면, Vue 템플릿 엔진에서 사용될 객체나 Repository Layer에서 결과로 넘겨준 객체 등을 이야기한다.

 

 

Domain Model

  • 도메인이라 불리는 개발 대상을 모든 사람이 동일한 관점에서 이해할 수 있고 공유할 수 있도록 단순화 한 것
  • Entity가 사용된 영역 역시 도메인 모델이라고 할 수 있다.
  • 다만, 무조건 Database의 테이블과 관계가 있어야만 하는 것은 아니고 VO처럼 값 객체들도 이 영역에 해당한다.
  • 5가지 레이어중 비지니스 처리를 담당하는 곳이다.

 

기존에 서비스로 처리하던 방식트랜잭션 스크립트라고 한다.

 

이 방식으로 주문 취소 로직을 작성한다면 아래와 같다.

 

서비스로 처리하는 방식

@Transactional
public Order cancelOrder(int orderId) {

	//1) 데이터 베이스로부터 주문 정보(Orders), 결제 정보(Billing), 배송 정보(Delivery) 조회
    	OrdersDto order = ordersDao.selectOrders(orderId);
        BillingDto billing = billingDao.selectBilling(orderId);
        DeliveryDto delivery = deliveryDao.selectDelivery(orderId);
    
    	//2) 배송 취소를 해야 하는지 확인
        String deliveryStatus = delivery.getStatus();
    
    	//3) if(배송중이라면) { 배송 취소로 변경 }
        If("IN_PROGRESS".equals(deliveryStatus)) {
        	delivery.setStatus("CANCEL");
            deliveryDao.update(delivery);
        }
        
    	//4) 각 테이블에 취소 상태 Update
        order.setStatus("CANCEL");
        orderDao.update(order);
        
        billing.setStatus("CANCEL");
        billingDao.update(billing);
        
        return order;
    
}

 

이처럼 모든 로직이 서비스 클래스 내부에서 처리된다.

 

그러다 보니 서비스 계층이 무의미하며, 객체는 단순하게 데이터 덩어리 역할만 하게 된다.

 

반면 도메인 모델에서 처리할 경우 아래와 같은 코드가 작성된다.

 

도메인으로 처리하는 방식

@Transactional
public Order cancelOrder(int orderId) {

	//1) 데이터 베이스로부터 주문 정보(Orders), 결제 정보(Billing), 배송 정보(Delivery) 조회
    	OrdersDto order = ordersRepository.findById(orderId);
        BillingDto billing = billingRepository.findByOrderId(orderId);
        DeliveryDto delivery = deliveryRepository.findByOrderId(orderId);
    
    	//2) 배송 취소를 해야 하는지 확인 ~ 3) if(배송중이라면) { 배송 취소로 변경 }
        delivery.cancel();
        
    	//4) 각 테이블에 취소 상태 Update
      	order.cancel();
        billing.cancel();
        
        return order;
    
}

 

order, billing, delivery가 각자 본인의 취소 이벤트를 처리하며,

 

서비스 메소드는 트랜잭션과 도메인 간의 순서만 보장해준다.

 

 

 

 

등록, 수정, 삭제 기능 생성

API를 만들기 위해서는 총 3개의 클래스가 필요하다.


  • Request 데이터를 받을 DTO
  • API 요청을 받을 Controller
  • 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service

 

반드시 Service에서 비즈니스 로직을 처리해야만 하는 것은 아니다.

 

여기서 Service는 트랜잭션, 도메인의 순서 보장의 역할만 한다.


 

 

1.  Class 생성

우선 기능을 작성할 Class를 생성해준다.

 

web 패키지에 PostsApiController, web.dto 패키지에 PostsSaveRequestDto,

 

service.posts 패키지에 PostsService를 생성한다.

PostsService, PostsSaveRequestDto, PostsApiController Class 신규 생성

 

 

2) 등록 기능 Class  작성

2-1) PostsApiContoller

package com.tistory.tlseoqja.web;

import com.tistory.tlseoqja.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;
import service.posts.PostsService;

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;

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

}

 

 

2-2) PostsService

package service.posts;

import com.tistory.tlseoqja.domain.posts.PostsRepository;
import com.tistory.tlseoqja.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;

@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, 생성자 ] 가 있다.

 

 

이 중 권장하는 방식은 생성자로 주입받는 방식이다.

 

즉, 생성자로 Bean객체를 받도록 하면 @Autowired와 동일한 효과를 볼 수 있다.

 

 

생성자는 @RequiredArgsConstructor에서 해결해 준다.

 

final이 선언된 모든 필드를 인자값으로 하는 생성자를 롬복의 @RequiredArgsConstructor 어노테이션이

 

대신 생성해준다.

 

 

생성자를 직접 작성하지않고 어노테이션을 사용하는 이유는

 

해당 클래스의 의존성 관계가 변경될 때 마다 생성자 코드를 계속해서 수정하는 번거로움을 해결하기 위함이다.

 

 

2-3) PostsSaveRequestDto

package com.tistory.tlseoqja.web.dto;

import com.tistory.tlseoqja.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();
    }

}

 

여기서 Entity Class와 거의 유사한 형태임에도 DTO Class를 추가로 생성했다.

 

하지만, 절대로 Entity Class를 Request/Response Class로 사용해서는 안된다.

 

Entity Class는 데이터베이스와 맞닿은 핵심 클래스이다.

 

Entity Class를 기준으로 테이블이 생성되고, 스키마가 변경된다.

 

화면 변경은 아주 사소한 기능 변경인데,

 

이를 위해 테이블과 연결된 Entity Class를 변경하는 것은 너무 큰 변경이다.

 

 

수많은 서비스 클래스나 비즈니스 로직들이 Entity Class를 기준으로 동작한다.

 

Entity Class가 변경되면 여러 Class에 영향을 끼치지만,

 

Request와 Response용 DTO는 View를 위한 클래스라 자주 변경을 필요로한다.

 

 

View Layer와 DB Layer의 역할 분리를 철저하게 하는게 좋다.

 

실제로 Controller에서 결과값으로 여러 테이블을 조인해서 줘야 할 경우가 빈번하므로

 

Entity Class만으로 표현하기 어려운 경우가 많다.

 

 

그래서 Entity Class와 Controller에서 쓸 DTO는 분리해서 사용해야 한다.

 

이제 등록 기능의 코드의 작성이 끝났고, 테스트 코드를 작성해서 실행시켜보겠다.

 

테스트 패키지 중 web 패키지에 PostsApiControllerTest를 생성한다.

 

 

3) Test Class 작성

test 폴더의 web 패키지 안에 PostsApiControllerTest Class를 생성한다

 

 

PostsApiControllerTest

package com.tistory.tlseoqja.web;

import com.tistory.tlseoqja.domain.posts.Posts;
import com.tistory.tlseoqja.domain.posts.PostsRepository;
import com.tistory.tlseoqja.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.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() 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);

    }

}

* assertThat은 직접 import를 작성해서 사용하면 된다.

 

ApiController를 테스트하는데 HelloController와 다르게 @WebMvcTest를 사용하지 않았다.

 

이유는 @WebMvcTest 어노테이션의 경우 JPA 기능이 작동하지 않기 때문이다.

 

 

Controller와 ControllerAdvice 등 외부 연동과 관련된 부분만 활성화되니

 

지금 같이 JPA 기능까지 한번에 테스트할 때는 @SpringBootTest 어노테이션과 TestTemplate를 사용하면 된다.

 

테스트를 수행해보면 콘솔창에 아래와 같이 성공 화면이 나온다.

PostsApiControllerTest 실행 후 콘솔 화면

 

WebEnvironment.RANDOM_PORT로 인해 랜덤 포트로 테스트가 실행되고 insert 쿼리도 정상적으로 실행됐다.

 

등록 기능이 완성됐으니 이어서 수정/조회 기능도 추가하겠다.

 

 

4) 수정/조회 기능 Class 작성

4-1) PostsApiContoller

public Class PostsController{

	...

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

 

 

4-2) PostsResponseDto

package com.tistory.tlseoqja.web.dto;

import com.tistory.tlseoqja.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();
    }

}

* web.dto 패키지안에 클래스를 새로 생성후 작성해주면 된다.

 

PostsResponseDto는 Entity의 필드 중 일부만 사용하므로 생성자로 Entity를 받아 필드에 값을 넣는다.

 

굳이 모든 필드를 가진 생성자가 필요하진 않으므로 Dto는 Entity를 받아서 처리한다.

 

 

4-3) PostsUpdateRequestDto

package com.tistory.tlseoqja.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;
    }

}

* web.dto 패키지안에 클래스를 새로 생성후 작성해주면 된다.

 

 

4-4) Posts

public class Posts {

	...

	public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }
    
}

 

 

4-5) PostsService

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

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

        return new PostsResponseDto(entity);
    }

}

 

여기서 신기한 점은 update 기능에서 데이터베이스에 쿼리를 날리는 부분이 없다는 것이다.

 

이게 가능한 이유는 JPA의 영속성 컨텍스트 때문이다.

 


영속성 컨텍스트

 

엔티티를 영구적으로 저장하는 환경이다.

 

일종의 논리적 개념이라고 보면 된다.

 

 

JPA의 핵심 내용은 엔티티가 영속성 컨텍스트에 포함되어 있냐 아니냐로 갈린다.

 

JPA의 Entity Manager가 활성화된 상태로(Spring Data Jpa를 쓴다면 기본 옵션) 트랜잭션 안에서

 

데이터베이스에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.

 

 

이 상태에서 해당 데이터의 값을 변경하면 트랜잭션이 끝나는 시점에 해당 테이블에 변경 내용을 반영한다.

 

즉, Entity 객체의 값만 변경하면 별도로 Update 쿼리를 날릴 필요가 없다.

 

이 개념을 더티 체킹(Dirty Checking)이라고 한다.


 

이제 작성한 코드가 정상적으로 동작되는지 확인하기 위해서 테스트 코드를 작성한다.

 

테스트 코드는 등록 기능과 마찬가지로 PostsApiControllerTest Class에 추가로 작성하겠다.

 

 

4-6) PostsApiControllerTest

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

    }

}

 

테스트를 실행시키고 콘솔창을 확인하면 아래와 같이 테스트가 성공한 것을 확인할 수 있다.

 

Posts Update Api 테스트 결과 화면

 

이제 모든 기능의 작성이 끝났다. 조회 기능은 실제로 톰캣을 실행해서 확인하겠다.

 

로컬 환경에선 데이터베이스로 H2를 사용한다. 메모리에서 실행하기 때문에 직접 접근하려면 웹 콘솔을 사용해야만 한다.

 

먼저 웹 콘솔 옵션을 활성화해준다. application.properties에 아래의 옵션을 추가해준다.

 

spring.h2.console.enabled=true

 

위 코드를 추가한 뒤 Application 클래스의 main 메소드를 실행해준다.

 

톰캣이 정상적으로 실행됐다면 8080 포트로 실행될 것이다.

 

여기서 웹 브라우저에 http://localhost:8080/h2-console로 접속하면 아래와 같은 웹 콘솔 화면이 표시된다.

 

http://localhost:8080/h2-console로 접속시 나오는 화면

 

이때 JDBC URL이 위의 화면과 같이 jdbc:h2:mem:testdb로 되어 있지 않다면 똑같이 작성해야한다.

 

아래 [Connect] 버튼을 클릭하면 현재 프로젝트의 H2를 관리할 수 있는 관리 페이지로 이동한다.

 

POSTS 이블을 클릭하면 SELECT문이 자동으로 작성되고 

 

[RUN] 버튼을 눌렀을 때 POSTS 테이블이 정상적으로 노출되어야만 한다.

 

h2-console 관리 화면 및 select 쿼리 실행 화면

 

현재는 등록된 데이터가 없어서 빈 테이블이 나오게 된다.

 

간단한 insert 쿼리를 실행해보고 이를 API로 조회해보겠다.

 

INSERT INTO posts (author, content, title) VALUES ('author', 'content', 'title');

 

쿼리를 입력한 후 [RUN]버튼을 누르면 아래와 같이 테이블에 업데이트가 된 것을 확인할 수 있다.

 

 

 

이제 등록된 데이터를 확인한 후 API를 요청해보도록 하겠다.

 

브라우저에 http://localhost:8080/api/v1/posts/1을 입력해 API 조회 기능을 테스트한다.

 

 

브라우저로 API 조회

 

위와 같이 테이블에 insert한 값이 정상적으로 조회되어 출력되는 것을 확인할 수 있다.

 

 

 

 

+ 추가

JPA Auditing으로 생성시간 / 수정시간 자동화 하기

일반적으로 사용되는 Entity에는 데이터의 생성시간과 수정시간을 포함하는 경우가 많다.

 

차후 데이터를 유지보수하는데 있어서 중요한 부분이기 때문이다.

 

그렇다 보니 새로운 Entity를 만들게 되면 매번 새로운 데이터를 만들어 삽입하게 된다.

 

이런 단순하고 반복적인 코드가 모든 테이블과 메소드에 포함된다면 중복된 코드의 사용으로 코드가 지저분해질 것이다.

 

그래서 이 문제를 해결하고자 사용하는 것이 바로 JPA Auditing이다.

 

 

1. LocalDate 사용

LocalDate는 LocalDateTime과 함께 Java8부터 등장한 기능이다.

 

기본 날짜 타입인 Date의 문제점을 보완한 기능이라 Java8에서부터는 무조건 사용해야 한다.

 


Date와 Calendar의 문제점

  • 불변의 객체가 아니다.
  •  + 멀티스레드 환경에서 언제든지 문제가 발생할 수 있다.
  • Calendar는 월(Month)의 설계가 잘못되어있다.
  •  + 10월을 나타내는 Calendar.OCTOBER의 실제 숫자 값은 '9' 이다.
  •  + 당연히 '10'으로 알고 사용했던 개발자들에게 큰 혼란을 주었다.

 

JodaTime이라는 오픈소스를 사용해서 문제점들을 피했었고,Java8에서 나온 LocalDate를 통해서 해결됐다.


 

LocalDate와 LocalDateTime이 데이터베이스에 제대로 매핑되지 않는 이슈가 Hibernate 5.2.10 버전에서 해결됐다.

 

Spring boot 1.x 버전을 쓴다면 별도로 Hibernate의 버전 설정이 필요하지만,

 

Spring Boot 2.x 버전 이상을 사용하면 별다른 설정없이 사용하면 된다.

 

 

1-1) BaseTimeEntity Class 생성

domain package에 Class를 생성하고 아래의 코드를 입력해준다.

BaseTimeEntity Class 생성

 

package com.jojoIdu.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 class BaseTimeEntity {

    @CreatedDate
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime modifiedDate;

}

 

BaseTimeEntity Class는 모든 Entity의 상위 클래스가 되어 Entity들의

 

createDate, modifiedDate를 자동으로 관리하는 역할이다.

 


@MappedSuperclass

  • JPA Entity Class들이 BaseTimeEntity를 상속할 경우 필드들(createDate, modifiedDate)도 컬럼으로 인식하도록 한다.

 

 

@EntityListeners(AuditingEntityListeners.class)

  • BaseTimeEntity Class에 Auditing 기능을 포함시켜준다.

 

 

@CreatedDate

  • Entity가 생성되어 저장될 때 시간이 자동으로 저장된다.

 

 

@LastModifiedDate

  • 조회한 Entity의 값을 변경할 때 시간이 자동으로 저장된다.

 


 

그리고 Posts Class가 BaseTimeEntity Class를 상속받도록 변경한다.

 

package com.jojoIdu.book.springboot.domain.posts;

...

public class Posts extends BaseTimeEntity {

...

}

 

마지막으로 JPA Auditing 어노테이션들을 모두 활성화할 수 있는 Application Class에

 

@EnableJpaAuditing 어노테이션을 추가해준다.

 

package com.jojoIdu.book.springboot;

...

@EnableJpaAuditing
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
    
}

 

 

1-2) JPA Auditing 테스트 코드 작성

PostsRepositoryTest Class에 테스트 메소드를 하나 더 추가해준다.

package com.jojoIdu.book.springboot.domain.posts;

...

public class 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(">>>>>>>>>> createdDate=" + posts.getCreatedDate()
                +", modifiedDate="+ posts.getModifiedDate());

        assertThat(posts.getCreatedDate()).isAfter(now);
        assertThat(posts.getModifiedDate()).isAfter(now);

    }

}

 

테스트 코드를 실행시키면 아래와 같이 실제 시간이 저장된 것을 확인할 수 있다.

 

BaseTimeEntity_등록 테스트 코드 실행 결과

 

 

이번 시간엔 게시판 등록/수정/조회 및 JPA Auditing의 사용법에 대해서 알아봤다.

 

 

 

다음번에는 Mustach로 화면을 구성하는 방법을 포스팅하겠다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

728x90