Develope Me!

[SpringBoot] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - API 구현 본문

Java/SpringBoot

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

코잘알지망생 2022. 2. 23. 15:29

JPA가 러닝커브가 높다는 말이 맞는 거 같다...어렵다..ㅎㅎ...

그치만 이렇게 계속 익히다보면 어느순간 머릿 속으로 정리가 될 거라고 믿는다.

 

이번에는 스프링 웹 계층 정리했던 것에 이어서 등록/수정/조회 API를 구현해보고자 한다!

 

 

등록/수정/삭제 

 

PostsApiController

@RequiredArgsConstructor
@RestController
public class PostsApiController {

    private final PostsService postsService;

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

 

PostsService

@RequiredArgsConstructor 
@Service
public class PostsService {

    private final PostsRepository postsRepository;

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

해당 Controller와 Service에서 @Autowired가 없다. 

스프링에선 의존성을 주입할 때 1. @Autowired(필드 주입) 2. setter (수정자 주입) 3. 생성자 주입 이렇게 3가지 방식으로 Bean을 주입가능하다.

그 중 가장 권장하는 방식은 생성자로 Bean 객체를 받는 방식이다.

여기선 final이 선언된 모든 필드를 인자값으로 하는 생성자를 롬복의 @RequiredArgsConstructor 가 대신 생성해준다. 

 

근데 이쯤에서 궁금증이 생긴다.

왜 Autowired는 권장하지 않고 생성자로 Bean 객체를 받는 방식을 권장하는 이유는 무엇일까?

그리고 생성자를 직접 쓰지 않고 왜 롬복 어노테이션을 사용할까?

 

 

1. @Autowired 사용을 권장하지 않는 이유

 

이유를 설명하기 전에 먼저 @Autowired 의 개념부터 알아보자.

@Autowired는 스프링 DI(Dependency Injection, 의존성 주입)에서 사용되는 어노테이션이다. 

스프링에서 빈 인스턴스가 생성된 이후에 @Autowired를 설정한 메서드가 자동으로 호출되며 인스턴스가 자동으로 주입된다. 

같은 타입의 빈이 있을 땐 @Qualifier나 @Resource를 통해 빈의 이름을 가져와서 주입할 수 있다는 특징을 가졌고

final 선언이 가능한 생성자 주입과는 달리 @Autowired는 필드를 final로 선언할 수 없다. 

 

Field injection is not recommended

인텔리제이에서는 필드를 통해 빈을 주입하게 되면 경고 메시지를 띄운다. 책에 나온 것처럼 생성자 주입을 권고해준다.

필드 주입이 아닌 생성자 주입을 권고하는 이유를 찾아보니 다음과 같다.

 

(1) 순환 참조 방지

순환 참조는 서로 다른 빈들이 서로 물고 늘어져 계속 연결되어 있음을 의미한다.

필드 주입이나 수정자 주입은 객체가 생성된 후 값을 주입하는 방식이기 때문에 실제로 프로그램을 실행하기 전까지는

순환 참조가 일어나는 지 아닌 지를 확인할 수 없다. 

생성자 주입의 경우에는 서버 자체가 구동되지 않아 순환 참조가 일어나고 있음을 확인할 수 있고 이를 방지할 수 있다. 

 

(2) final 선언 가능

필드 주입/ 수정자 주입과 생성자 주입의 차이점 중에 하나가 필드를 final로 선언 가능한 지의 여부이다.

생성자 주입은 필드를 final로 선언 가능하여 런타임에 객체 불변성을 보장한다. 

 

(3) 테스트 코드 작성 용이

스프링 컨테이너 도움없이 편리하게 테스트 코드를 작성 가능하다. 

테스트 코드를 작성할 때 원하는 객체를 생성하고 생성자를 넣어주기만 하면 된다. 

 

 

2. 생성자를 직접 쓰지 않고 롬복 어노테이션 사용 이유

 

해당하는 클래스의 의존성 관계가 변경되었을 때 생성자 코드를 계속 변경해야하는 수고로움을 덜어주기 때문이다.

새로운 서비스를 추가한다던가 기존 컴포넌트를 제거하는 상황이 발생해도 생성자 코드를 손대지 않아도 된다. 

 

 

롬복 어노테이션을 사용해서 생성자를 통해 bean을 주입해주었다면 이제 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 클래스는 DB와 맞닿은 핵심 클래스이며 해당 클래스를 기준으로 테이블이 생성되고 스키마가 변경된다.

수많은 서비스 클래스와 비즈니스 로직들이 Entity 클래스를 기준으로 동작하기 때문에 해당 클래스가 변경되면 여러 클래스에 영향을 미친다.

따라서 View를 위한 클래스(Request/Response 용도)인 DTO를 생성해준다. 

View와 DB Layer의 역할을 철저해준다.

 

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 = "제목테스트";
        String content = "내용테스트";
        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                                                            .title(title)
                                                            .content(content)
                                                            .author("글쓴이테스트")
                                                            .build();
        String url = "http://localhost:" + port + "/api/v1/posts";

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

        //then
        Assertions.assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        Assertions.assertThat(responseEntity.getBody()).isGreaterThan(0L);
        List<Posts> all = postsRepository.findAll();
        Assertions.assertThat(all.get(0).getTitle()).isEqualTo(title);
        Assertions.assertThat(all.get(0).getContent()).isEqualTo(content);
		}
    }

 

Api Controller를 테스트 할 때 JPA 기능이 작동하지 않는 @WebMvcTest를 사용하지 않고 JPA 기능까지 한 번에 테스트 할 수 있는 @SpringBootTest와 TestRestTemplate을 사용하여 테스트를 수행했다. 랜덤 포트 실행과 insert 쿼리가 실행된 것을 확인했다. 

 

다음으로는 수정/조회 기능을 만들 것이다. 

 

PostsApiController

    @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

 

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

Dto는 Entity 필드 중 일부만 사용하여 생성자로 Entity를 받아 필드 값을 넣어줬다. 

 

PostsUpdateRequestDto

@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

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

JPA의 영속성 컨텍스트라는 특성으로 update 기능에서 DB 쿼리를 날리는 부분이 없다.

영속성 컨텍스트란 Entity를 영구 저장하는 환경으로 JPA의 엔티티 매니저가 활성화된 상태로 트랜잭션 안에서  DB에서 데이터를 가져오면 이 데이터는 영속성 컨텍스트가 유지된 상태이다.

유지된 상태에서 데이터 값을 변경하면 트랜잭션이 끝나는 지점에서 해당 테이블에 변경 사항을 반영한다.

즉, Entity 객체 값만 변경하면 별도로  update 쿼리를 날릴 필요가 없다.(이 개념을 '더티 체킹'이라 함)

 

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
        Assertions.assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        Assertions.assertThat(responseEntity.getBody()).isGreaterThan(0L);

        List<Posts> all = postsRepository.findAll();
        Assertions.assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
        Assertions.assertThat(all.get(0).getContent()).isEqualTo(expectedContent);

    }
}

테스트 결과 update 쿼리가 수행되었음을 확인했다. 

 

조회 기능은 톰캣을 실행해서 확인해보았다.

로컬 환경에서 H2 DB를 사용해서 확인했다. H2는 메모리에서 실행되기에 직접 접근하려면 웹 콘솔을 사용해야 한다. 

따라서 application.properties 파일에 해당 옵션을 추가해준 뒤 Application 클래스의 main 메소드를 실행했다. 

 

spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.generate-unique-name = false
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect

http://localhost:8082/h2-console로 접속하면 해당 웹 콘솔 화면이 뜬다. (8080서버를 이미 사용하고 있어서 다른 서버 사용)

JDBC URL을 작성해주고 Connet 버튼을 눌러 H2를 관리할 수 있는 관리 페이지로 이동했다.

 

POSTS 테이블이 생성됐음을 확인했고 inset 쿼리를 실행하여 이를 API로 조회해보았다.등록된 데이터를 확인한 후 http://localhost:8082/api/v1/posts/1을 입력하여 API 조회 기능을 테스트 했다.

 

기존에 JSON Viewer 플러그인을 설치해둬서 인지 정렬된 JSON 형태로 데이터를 조회할 수 있었다. 

 

 

JPA Auditing

 

보통 Entity에는 해당 데이터의 생성/수정 시간을 포함한다.

차후 유지보수에 있어서 중요한 정보이기 때문이다.

DB에 삽입 하기 전, 갱신 하기 전에 날짜 데이터를 등록/수정하는 코드를  여기저기 추가하기 보다 깔끔하게 JPA Auditing을 사용해보려고 한다.

 

domain 패키지에 BaseTimeEntity 클래스를 생성한다.

 

BaseTimeEntity

@Getter
@MappedSuperclass //JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우 필드들로 컬럼으로 인식하도록 함
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    @CreatedDate //Entity가 생성되어 저장될 때 시간이 자동 저장됨
    private LocalDateTime createdDate;

    @LastModifiedDate //조회한 Entity 값을 변경할 때 시간이 자동 저장
    private LocalDateTime modifiedDate;
}

BaseTimeEntity가 모든 Entity 클래스의 상위 클래스가 되어 Entity들의 생성/수정 시간을 자동 관리하는 역할을 한다. 

 

Posts

public class Posts extends BaseTimeEntity{
	....
}

Posts 클래스가 BaseTimeEntity 클래스를 상속 받도록 변경해줬다. 

 

Application

@EnableJpaAuditing //JPA Auditing 활성화
@SpringBootApplication 
public class Application {
    public static void main(String[] args){
        SpringApplication.run(Application.class, args); 
    }
}

 

PostsRepositoryTest

@Test
    public void BaseTimeEntity_등록(){
        //given
        LocalDateTime now = LocalDateTime.of(2022,02,20,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());

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


    }
}

테스트 코드 수행 결과 실제 시간이 잘 저장되었음을 확인했다.

BaseTimeEntity 클래스를 상속받아주기만 하면 등록/수정일을 자동으로 해결할 수 있음을 알게 됐다.

 

 

다음 포스팅은 템플릿 엔진을 사용해서 화면을 만들어보도록 하겠다. 

Comments