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

김나윤·2024년 9월 30일
0

Spring

목록 보기
5/9

03. 스프링 부트에서 JPA로 데이터베이스 다루기

1) 버전 차이로 인한 오류 잡기

JPA에 대해 공부하기 전,
<스프링 부트와 AWS로 혼자 구현하는 웹 서비스> 저자분의 블로그를 켜두고 프로젝트를 진행하였다.
책에서 사용한 버전과 최신 버전의 차이가 있기 때문에 버전 차이에서 오는 오류가 몇몇 있어서 블로그를 참고해 내가 사용하는 버전과 호환되도록 코드를 변경하며 진행하였다.


2) JPA?

3) 프로젝트에 Spring Data JPA 적용하기

프로젝트에 Spring Data JPA를 적용하기 위해 build.gradle에 의존성을 주입해준다.

  • implementation('org.springframework.boot:spring-boot-starter-data-jpa')
  • implementation('com.h2database:h2')

의존성 등록 후, gradle을 refresh 해주었다.

의존성이 등록되었다면, 본격적으로 JPA 기능을 사용할 수 있다.

java 폴더 내에 domain 패키지를 만들어 준다.
이 domain 패키지는 도메인을 담을 패키지로, 여기서 도메인은 게시글, 댓글, 회원, 정산, 결제 등 소프트웨어에 대한 요구사항 or 문제 영역이라고 생각하면 된다.

기존에 MyBatis와 같은 SQL Mapper를 사용할 때는 DAO 패키지를 사용했지만, DAO 패키지와 조금 결이 다르다.
그간 xml에 쿼리를 담고 클래스는 오로지 쿼리의 결과만 담던 일을 모두 이 domain 이라는 클래스에서 해결하도록 한다.

domain 패키지에 posts 패키지와 Posts 클래스를 만든다.

Posts 클래스 코드는 다음과 같다.

이렇게 Posts 클래스 생성이 끝났다면, Posts 클래스로 DB에 접근하게 해줄 JpaRepository를 생성한다.

여기까지 코드를 작성하고 간단하게 테스트 코드로 기능을 검증해보았다.


4) Spring Data JPA 테스트 코드 작성하기

test 폴더 하위에 domain.posts 패키지를 만들고 PostsRepositoryTest 파일을 생성한다.

package com.springboot.project.springboot_webservice_project.domain.posts;

import org.aspectj.lang.annotation.After;
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.assertThat;

@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("kny99306ny@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데이터베이스를 자동으로 실행해준다. 즉, 이 테스트 역시 H2가 자동으로 실행된다.

성공적으로 테스트가 실행되는 것을 확인할 수 있다.

여기서 실제로 실행된 쿼리가 어떤 형태를 띄고 있는지 추가로 확인해 보았다.
쿼리 로그를 ON/OFF 할 수 있는 설정이 있고 이를 Java 클래스로 구현할 수 있지만, 스프링 부트에서는 application.properties, application.yml 등의 파일로 한 줄의 코드로 설정할 수 있도록 지원하고 권장하고 있으니 이를 이용해보았다.
그 방법은 간단하다.
src > main > resources > application.properties 파일에서

spring.jpa.show_sql=true

코드 하나만 추가해주면 된다.

이렇게 옵션을 추가했다면 다시 테스트를 수행해본다.

그럼 이렇게 콘솔에서 쿼리 로그를 확인할 수 있다.
이때, creat table의 쿼리를 보면 id bigint generated by default as identity라는 옵션으로 생성되는데, 이는 H2 쿼리 문법이 적용되었기 때문이다.

H2는 MySQL의 쿼리를 수행해도 정상적으로 작동하기 때문에 이후 디버깅을 위해 출력되는 쿼리 로그를 MySQL 버전으로 변경해보았다.
이 옵션 역시 application.properties에서 설정이 가능하다.

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
이 소스코드를 추가해 테스트 코드를 재실행 해보았더니...

?왜 또 이러세요...
혹시 버전이 달라져서 그런가 싶어서 저자분 블로그를 다시 찾아보았다.

404 그만 보고 싶다.....

404 에러는 접근하려 하는 페이지 또는 API를 찾지 못했다라는 것을 의미하는 HTTP 응답 상태코드라고 한다.

뭐가 문젠지 모르겠어서 디버깅 해보니 java.lang.IllegalStateException 오류가 떴다.

(원인1) perties 설정에 h2-console 사용 여부를 설정하지 않았다?

spring.h2.console.enabled=true

application.properties에 설정을 추가했지만 문제가 해결되지 않았다.

(원인2) @WebAppConfiguration 어노테이션이 없어서?

https://codingwanee.tistory.com/entry/%EB%94%94%EB%B2%84%EA%B7%B8-Spring-jUnit-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%A0%9C%EC%9D%B4%EC%9C%A0%EB%8B%9B-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-javalangIllegalStateException-Failed-to-load-ApplicationContext

이 블로그를 참고해 어노테이션을 추가해주었지만 여전히 해결 불가..

(원인3) index.html 파일이 없어서 mapping이 안 되어서?

main > resources > static > index.html 을 만들어 줬지만 여전히 해결되지 않는다...

기타 등등 구글에 있는 웬만한 방법은 다 시도해봤지만 해결은 되지 않고
3일 동안 이 문제 때문에 진도를 못 나갔다ㅠ
뛰어넘기에는 너무 찝찝해서 천천히 오류 메시지를 다시 읽어보기로 했다.
그러다 문득 application.properties 설정에 문제가 있을 것 같다는 생각이 들어 관련 내용을 찾아보다 드디어!!!
해결방법을 찾았다.

https://cyeongy.tistory.com/entry/springjpapropertieshibernatedialect-orghibernatedialectmysql5innodbdialect-%EC%82%AC%EC%9A%A9-%EB%B6%88%EA%B0%80-Deprecated-%EB%AC%B8%EC%A0%9C

해당 블로그에서 소개한 방법으로 해결되진 않았지만 이 블로그에서 걸어준 깃 링크에서 해결책을 찾았다.

이 properties 추가해주니 정상적으로 테스트가 완료되었다.

저자분이 소개해주신 해결방법은 이거였는데, 이것 또한 시간이 지나면서 설정이 변한 것 같다.
해결 과정까지는 너무 힘들었지만 덕분에 각각의 properties가 무엇을 뜻하는지 잊어버리지 않을 것 같다.

옵션이 잘 적용된 것까지 확인하고 여기서 JPA와 H2에 대한 기본적인 설정 과정을 마무리 했다.


5) 등록/수정/조회 API 만들기

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

  • Request 데이터를 받을 DTO
  • API 요청을 받을 Controller
  • 트랜잭션, 도메인 기능 간의 순서를 보장하는 Service
    (※ Service는 비즈니스 로직을 처리하지 않는다.
    트랜잭션, 도메인 간 순서 보장 역할만!)

▲ PostsApiController

▲ PostsService

▲ PostsSaveRequestDto

package com.springboot.project.springboot_webservice_project.web;

import com.springboot.project.springboot_webservice_project.domain.posts.Posts;
import com.springboot.project.springboot_webservice_project.domain.posts.PostsRepository;
import com.springboot.project.springboot_webservice_project.web.dto.PostsSaveRequestDto;
import org.checkerframework.checker.units.qual.A;
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 org.springframework.test.context.junit4.SpringRunner;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

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

▲ PostsApiControllerTest

WebEnvironment.RANDOM_PORT로 인한 랜덤 포트 실행과 insert 쿼리가 모두 성공적으로 실행된 것을 확인했다.
이렇게 등록 기능을 완성했으니 수정/조회 기능도 만들어 보자.

......ㅋㅋㅋㅋㅋ큐ㅠㅠㅠㅠㅠㅠㅠㅠㅠ
세상 모든 오류 다 만나는 것 같다...신난다..

@PutMapping -> @PostMapping

여전히 같은 오류가 발생했다.

(후보1) 생성자가 없어서?

https://codenamejy.tistory.com/49

그렇지만 @NoArgsConstructor를 적어놓고 시작했기에 생성자 문제는 아닌 것 같았다.

구글링해서 나온 방법들을 다 시도해보고 ChatGPT도 활용해봤지만 여전히 해결되지 않고 제자리 걸음...

천천히 에러 문구 하나씩 다시 살펴 보기로 했다.
크게 3가지 에러로 나누어봤는데,

  • org.springframework.web.client.RestClientException: Error while extracting response for type [class java.lang.Long] and content type [application/json]
  • Caused by: org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type java.lang.Long from Object value (token JsonToken.START_OBJECT)
  • Caused by: com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize value of type java.lang.Long from Object value (token JsonToken.START_OBJECT)

계속 고민하다 나흘이 지나고..
GitHub에 들어가서 다른 해결하신 분들의 코드를 참고해보면 어떨까 싶었다.
그 중, 질문을 하시고 스스로 해결하신 것 같은 분이 계셔서 그 분의 코드를 들여다 보았다.

(참고)
https://github.com/SehyeonKang/webservice-practice/blob/master/src/test/java/com/jojoldu/book/springboot/web/PostsApiControllerTest.java

이 분의 깃헙에 들어가 ApiControllerTest.java를 위주로 내 코드와 다른 점이 무엇인지 찾아보았다.

확인해보니 이 부분이 나와 달랐다.

코드를 수정해주고,

필요한 설정도 추가해주었다.

그러고 나서 실행 해봤더니,
java.lang.AssertionError: Stauts expected:<200> but was:<405> 오류가 생겼다.
오류가 하도 많이 떠서 살짝 헷갈리지만 아마 이 부분을 수정하면서 해결했던 것 같다.

이 분은 post가 아닌 put을 쓰셨는데 나는

PostMapping을 사용해줬기에 put > post 로 바꿔 Run 해보았다.

그렇게 나온 새로운 오류! ^.ㅠ

jakarta.servlet.servletexception: request processing failed: java.lang.illegalargumentexception: name for argument of type [java.lang.long] not specified, and parameter name information not available via reflection. ensure that the compiler uses the '-parameters' flag.

구글링 해보니 답을 얻을 수 있었다.

(참고)
https://velog.io/@ghwns9991/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B6%80%ED%8A%B8-3.2-%EB%A7%A4%EA%B0%9C%EB%B3%80%EC%88%98-%EC%9D%B4%EB%A6%84-%EC%9D%B8%EC%8B%9D-%EB%AC%B8%EC%A0%9C

build.gradle에
compileJava { options.compilerArgs << '-parameters'} 를 추가하는 방법으로는 문제가 해결되지 않았다.

나는 세 번째 방법을 사용해서 해결했는데 Build and run using 설정을
IntelliJ IDEA > Gradle 로 변경하면서 해결되었다.

프로젝트를 시작하기 전, 설정 단계에서
Build, Execution, Deployment -> Build Tools -> Gradle에서
Build and run using를 IntelliJ IDEA로 선택했던 기억이 나, 모두 Gradle로 설정을 해주고 다시 Run 해보니 정상적으로 실행되었다.

얼마만에 보는 테스트 통과야ㅠㅠㅠ 감격ㅠㅠㅠㅠㅠ

테스트가 정상적으로 종료된 걸 확인하고 눈에 걸리는 빨간 문장도 해결해보기로 했다.
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended

다시 시작된 구글링..

(참고)
https://velog.io/@s0nnyday/6.-TestingSpring-Security-for-REST-API-with-Spring-Boot-3

이건 JDK 1.8 이후에 추가된 기능으로 클래스 로딩 시, Class Data Sharing(CDS) 기능을 사용하면 발생하는 것이라고 한다.
오류가 아니고 경고 메시지로 무시해도 된다고도 하는데 빨간 문장 하나라도 줄이고 싶다..

위 블로그를 참고해 세 가지 방법을 다 도전해봤지만 여전히 해결되지 않았다..
이건 다음에 다시 고쳐봐야지..


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

먼저 웹 콘솔 옵션을 활성화해야 하는데 application.properties에
spring.h2.console.enabled=true 를 추가해준다.
오류 수정하려고 이것저것 건드리다 보니 이거 추가해뒀넹 힣

추가한 뒤, Application 클래스의 main 메소드를 실행해서 톰갯 8080 포트에 정상적으로 연결된 것까지 확인해준다.

http://localhost:8080/h2-console 로 접속하면 다음과 같은 웹 콘솔 화면이 등장한다.

이때 JDBC URL이 jdbc:h2:mem:testdb 로 되어있지 않다면 똑같이 작성해주어야 한다.
그리고 아래 Connect 버튼을 클릭하면 현재 프로젝트의 H2를 관리할 수 있는 관리 페이지로 이동한다.

JDBC URL이 달랐기 때문에 제대로 입력해주고 Connect 버튼을 눌렀다.

ㅋㅋㅋㅋㅋㅋㅋ큐ㅠㅠㅠㅠㅠ
왜!!! 왜!!!!! 왜애!!!!!!!!!

구글이 없었으면 어떻게 공부했을까..
구글 최고

(참고)
https://stackoverflow.com/questions/61865206/springboot-2-3-0-while-connecting-to-h2-database

이 글을 읽어보니 내 JDBC URL을 확인할 수 있다는 거 알게 되었고, 그 URL을 입력했더니 성공적으로 관리 페이지로 이동할 수 있었다.

아하, 경로가 이거구나.

다시 한 번 JDBC URL을 수정해주고 Connect를 눌렀더니,

성공~!!

이렇게 정상적으로 POSTS 테이블이 노출되었고 간단한 쿼리문을 실행보았다.

현재 따로 등록된 데이터가 없기 때문에 간단하게 insert 쿼리를 실행해보고 이를 API로 조회해보자.
insert into posts (author, content, title) values ('이동욱', '스프링 부트로 혼자 웹 서비스를 구현해보도록 합니다! 파이팅!', '스프링 부트와 AWS로 혼자 구현하는 웹 서비스');

등록된 데이터를 확인한 후, http://localhost:8080/api/v1/posts/1 을 입력해 API 조회 기능을 테스트 해보았다.

헤헤 잘 되니까 재밌당

추가로 정렬된 JSON 형태를 보기 위해 Chrome에서 JSON Viewer라는 플러그인을 설치해보았다.

(참고)
https://son1004007.tistory.com/11

설치 후 다시 해당 url에 들어가보니!

우아아ㅏㅏ!! 예쁘다아!!!
이렇게 예쁘게 잘 정리된 형태의 JSON을 확인할 수 있었다.


여기까지 기본적인 등록/수정/조회 기능을 모두 만들고 테스트 해보았다.
특히 등록/수정은 테스트 코드로 보호해 주고 있으니, 이후 변경 사항이 있어도 안전하게 변경할 수 있다.


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

보통 Entity에는 해당 데이터의 생성시간과 수정시간을 포함한다. 언제 만들어졌는지, 언제 수정되었는지 등은 차후 유지보수에 있어 굉장히 중요한 정보이기 때문이라고 한다. 그렇다 보니 매번 DB에 insert하기 전, update하기 전에 날짜 데이터를 등록/수정하는 코드가 여기저기 들어간다고 한다.

//생성일 추가 코드 예제
public void savePosts(){
...
posts.setCreateDate(new LocalDate());
postsRepository.save(posts);
...

이런 단순하고 반복적인 코드가 모든 테이블과 서비스 메소드에 포함되면 코드가 지저분해진다.
따라서 이 문제를 해결하기 위해 JPA Auditing을 사용한다.

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

profile
Hello, world!

0개의 댓글