Programming/Spring & Springboot

스프링부트3 백엔드 개발자 되기#1

오늘도출근하는다람쥐 2023. 12. 9. 22:39

프로젝트 생성 및 build.gradle 설정하기

  1. 프로젝트 생성하기(gradle 프로젝트) java 17버전(springboot 3이상 호환)
  1. 초기 build.gradle 수정

    plugins {

     id 'java'

    }

    group 'com.safejibsa'
    version '1.0-SNAPSHOT'

    repositories {

     mavenCentral()

    }

    dependencies {

     testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
     testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'

    }

    test {

     useJUnitPlatform()

    }

    plugins { //프로젝트에 사용할 플러그인 추가(스프링 부트 플러그인, 스프링 의존성 자동 관리)

     id 'java'
     id 'org.springframework.boot' version '3.0.2'
     id 'io.spring.dependency-management' version '1.1.0'

    }

    // 프로젝트를 설정할 때의 기본값인 그룹 이름과 버전
    group 'com.safejibsa'
    version '1.0-SNAPSHOT'
    sourceCompatibility = '17' //여기서 추가로 자바 소스를 컴파일할 때 사용할 자바 버전을 입력

    repositories { //의존성을 받을 저장소를 지정

     mavenCentral()

    }

    dependencies { //의존성 관리

         //스프링 부트 스타터를 이용해서 프로젝트를 gradle 프로젝트에서 spring 프로젝트로 변경
     implementation 'org.springframework.boot:spring-boot-starter-web' 
     testImplementation 'org.springframework.boot:spring-boot-starter-test'

    }

    test {

     useJUnitPlatform()

    }

  2. main 생성(springbootdeleoper 패키지 생성 후 (SpringBootDeveloperApplication 클래스 생성)

    package com.safejibsa.springbootdeveloper;

    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;

    @SpringBootApplication
    public class SpringBootDeveloperApplication{

     public static void main(String[] args) {
         SpringApplication.run(SpringBootDeveloperApplication.class, args);
     }

    }

  • 기존에 만들어져 있던 Main 클래스는 삭제

여기까지 기본 스프링부트 프로젝트 구성이 완료

H2 / JPA와 Lombok 기능 추가하기

build.gradle에 dependencies 추가

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.0.2'
    id 'io.spring.dependency-management' version '1.1.0'
}

group 'com.safejibsa'
version '1.0-SNAPSHOT'
sourceCompatibility = '17'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.projectlombok:lombok:1.18.22'//스프링 데이터 JPA

    runtimeOnly 'com.h2database:h2' //인메모리 데이터 베이스

    compileOnly 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    annotationProcessor 'org.projectlombok:lombok' //Gradle 5.x 이상 부터는 여기를 추가해주어야 에러가 발생하지 않는다(5.x 미만은 추가해주지 않아도 괜찮다)
}

test {
    useJUnitPlatform()
}

application.yml 파일에 JPA 설정 추가

spring:
    jpa:
        #전송 쿼리 확인
        show-sql: true
  properties:
        hibernate:
            format_sql: true

#위 방식은 콘솔에 System.out.println 방식으로 찍기 때문에 운영에는 적합하지 않다(Logger 권장)

→ 개발(dev)의 경우는 위와 같이 사용해도 괜찮지만 운영(prod)의 경우 다른 방식을 권장(by 인프런-김영한)

#Query Print bind Parameter
logging:
    level:
        org.hibernate.SQL: debug
        org.hibernate.type: trace

#위 방식은 logger를 이용해서 SQL에 대한 내용을 기록

Application 실행 시 data.sql 자동 실행

resources 디렉토리 아래에 data.sql 파일 생성

INSERT INTO MEMBER(ID, NAME) VALUES(1, 'NAME 1');
INSERT INTO MEMBER(ID, NAME) VALUES(2, 'NAME 2');
INSERT INTO MEMBER(ID, NAME) VALUES(3, 'NAME 3');

그런다음 application.yml 파일에 아래 내용 추가

spring:
    jpa:
        #테이블 생성 후에 data.sql 실행
        defer-datasource-initialization: true

⭐ 테스트 코드 개념 익히기

 **💡** GIVEN : 테스트 실행을 준비하는 단계
 **💡** WHEN  : 테스트를 진행하는 단계
 **💡** THEN  : 테스트 결과를 검증하는 단계

예시) given - when - then 패턴의 테스트 코드 예

@DisplayName("새로운 메뉴를 저장한다.")
@Test
public void saveMenuTest(){
        //given : 메뉴를 저장하기 위한 준비 과정
        final String name = "아메리카노";
        final int price = 2000;

        final Menu americano = new Menu(name, price);

        //when : 실제로 메뉴를 저장
        final long savedId = menuService.save(americano);

        //then : 메뉴가 잘 추가되었는지 검증
        final Menu savedMenu = menuService.findById(savedId).get();
        assertThat(savedMenu.getName()).isEqualTo(name);
        assertThat(savedMenu.getPrice()).isEqualTo(price);
}

이름 설명
JUnit 자바 프로그래밍 언어용 단위 테스트 프레임워크
Spring Test & Spring Boot Test 스프링 부트 애플리케이션을 위한 통합 테스트 지원
AssertJ 검증문인 어설션을 작성하는 데 사용되는 라이브러리
Hamcrest 표현식을 이해하기 쉽게 만드는데 사용되는 Matcher 라이브러리
Mockito 테스트에 사용할 가짜 객체인 목 객체를 만들고, 검증하고, 관리할 수 있게 지원하는 테스트 프레임워크
JSONassert JSON용 어설션 라이브러리
JsonPath JSON 데이터에서 특정 데이터를 선택하고 검색하기 위한 라이브러리

⭐ 이 중에서 JUnit과 AssertJ를 가장 많이 사용

  • JUnit 은 자바 언어를 위한 단위 테스트 프레임 워크, @Test 애너테이션으로 메서드를 호출할 때마다 독립 테스트 가능(예상 결과를 검증하는 어설션 메서드 제공, 사용방법이 단순하며 테스트 코드 작성 시간 단축)

    public class JUnitCycleTest {

      @BeforeAll //전체 테스트 시작하기 전에 1회 실행하므로 메서드는 static으로 선언
      static void beforeAll(){
          System.out.println("@BeforeAll");
      }
    
      @BeforeEach //테스트 케이스를 시작하기 전마다 실행
      public void beforeEach(){
          System.out.println("@BeforeEach");
      }
    
      @Test
      public void test1(){
          System.out.println("test1");
      }
    
      @Test
      public void test2(){
          System.out.println("test2");
      }
    
      @Test
      public void test3(){
          System.out.println("test3");
      }
    
      @AfterAll //전체 테스트를 마치고 종료하기 전에 1회 실행하므로 메서드는 static 으로 선언
      static void afterAll(){
          System.out.println("@AfterAll");
      }
    
      @AfterEach //테스트 케이스를 종료하기 전마다 실행
      public void afterEach(){
          System.out.println("@AfterEach");
      }

    }

    이름 설명
    BeforeAll 전체 테스트를 시작하기 전에 처음으로 한 번만 실행 (DB연결, 테스트환경 초기화 등)
    BeforeEach 테스트 케이스를 시작하기 전에 매번 실행 (private 설정시 에러 발생 - public 사용)
    AfterAll 전체 테스트를 마치고 종료하기 전에 한 번만 실행 (DB연결 종료, 자원 해제 등)
    AfterEach 각 테스트 케이스를 종료하기 전 매번 실행 (private 설정시 에러 발생 - public 사용)

BeforeEach, AfterEach의 경우에는 각 인스턴스에 대해 메서드를 호출해야 하므로 메서드는 static이 아니어야 한다

@BeforeAll(클래스 레벨) 
    → @BeforeEach(메서드 레벨) 
            → @Test(테스트 실행) 
    → @AfterEach(메서드 레벨) 
@AfterAll(클래스 레벨) 

JUnit과 사용하기 좋은 AssertJ

AssertJ는 JUnit과 함께 사용하며 검증문의 가독성을 확 높여주는 라이브러리

기존 JUnit 방식(Assertion)

Assertions.assertEquals(sum , a+b);

가독성이 좋은 AssertJ

assertThat(a + b).isEqualTo(sum);

코드를 좀 더 명확하게 이해할 수 있다

AssertJ 자주 사용하는 메서드 정리

메서드 이름 설명
isEqualTo(A) A 값과 같은지 검증
isNotEqualTo(A) A 값과 다른지 검증
contains(A) A 값을 포함하는지 검증
doesNotContain(A) A 값을 포함하지 않는지 검증
startsWith(A) 접두사가 A인지 검증
endsWith(A) 접미사가 A인지 검증
isEmpty() 비어 있는 값인지 검증
isNotEmpty() 비어 있지 않은 값인지 검증
isPositive() 양수인지 검증
isNegative() 음수인지 검증
isGreaterThan(1) 1보다 큰 값인지 검증
isLessThan(1) 1보다 작은 값인지 검증

제대로 테스트 코드 작성해보기

@SpringBootTest //테스트용 애플리케이션 컨텍스트 생성
@AutoConfigureMockMvc // MockMVC 생성 및 자동 구성
class TestControllerTest {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    private WebApplicationContext context;

    @Autowired
    private MemberRepository memberRepository;

    @BeforeEach //테스트 실행 전 실행하는 메서드
    public void mockMvcSetUp(){
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
    }

    @AfterEach //테스트 실행 후 실행하는 메서드
    public void cleanUp(){
        memberRepository.deleteAll();
    }
}

명칭 설명
@SpringBootTest 메인 애플리케이션 클래스(@SpringBootApplication)을 찾고 그 클래스에 포함되어 있는 빈을 찾은 다음 테스트용 애플리케이션 컨텍스트라는 것을 만든다
@AutoConfigureMockMvc MockMvc를 생성하고 자동으로 구성하는 애너테이션(MockMvc는 애플리케이션을 서버에 배포하지 않고도 테스트용 MVC 환경을 만들어 요청 및 전송, 응답 기능을 제공하는 유틸리티 클래스. 즉, 컨트롤러를 테스트할 때 사용되는 클래스)
@BeforeEach 테스트 실행 전 적용 하는 메서드(MockMvcSetUp()메서드를 실행해 MockMvc를 설정)
@AfterEach 테스트 실행 후 적용 하는 메서드(cleanUp()메서드를 실행해 member 테이블에 있는 데이터를 모두 삭제)

@SpringBootTest 
@AutoConfigureMockMvc 
class TestControllerTest {
        ...생략...
        @DisplayName("getAllMembers: 아티클 조회에 성공한다.")
    @Test
    public void getAllMembers() throws Exception{
        //given
        final String url = "/test";
        Member savedMember = memberRepository.save(new Member(1L, "홍길동"));

        //when
        final ResultActions result = mockMvc.perform(get(url).accept(MediaType.APPLICATION_JSON));

        //then
        result.andExpect(status().isOk())
                .andExpect(jsonPath("$[0].id").value(savedMember.getId()))
                .andExpect(jsonPath("$[0].name").value(savedMember.getName()));
    }
}
  • given : 멤버를 저장한다
  • when : 멤버 리스트를 조회하는 API를 호출한다
  • then : 응답 코드가 200 OK이고, 반환받은 값 중에 0번째 요소의 id와 name이 저장된 값과 같은지 확인

💡 perform() 메서드 : 요청을 전송하는 역할을 하는 메서드이다 ( 반환값 : ResultActions 객체 )

 반환값(ResultActions) 객체는 andExpect() 메서드를 제공해준다 .

💡 accpet() : 요청을 보낼 때 무슨 타입으로 응답을 받을지 결정하는 메서드이다.

 JSON, XML 등 다양한 타입이 있지만, 여기에서는 JSON을 받는다고 명시해두자

💡 andExpect() : 응답을 검증한다(TestController에서 만든 API는 응답으로 OK(200)을 반환하므로 isOk를

 사용해 응답코드가 OK(200)인지 확인한다.

💡 jsonPath(”$[0].필드명”) : JSON 응답값의 값을 가져오는 역할을 하는 메서드이다.

테스트 전체 코드

@SpringBootTest //테스트용 애플리케이션 컨텍스트 생성
@AutoConfigureMockMvc // MockMVC 생성 및 자동 구성
class TestControllerTest {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    private WebApplicationContext context;

    @Autowired
    private MemberRepository memberRepository;

    @BeforeEach //테스트 실행 전 실행하는 메서드
    public void mockMvcSetUp(){
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
    }

    @DisplayName("getAllMembers: 아티클 조회에 성공한다.")
    @Test
    public void getAllMembers() throws Exception{
        //given
        final String url = "/test";
        Member savedMember = memberRepository.save(new Member(1L, "홍길동"));

        //when
        final ResultActions result = mockMvc.perform(get(url).accept(MediaType.APPLICATION_JSON));

        //then
        result.andExpect(status().isOk())
                .andExpect(jsonPath("$[0].id").value(savedMember.getId()))
                .andExpect(jsonPath("$[0].name").value(savedMember.getName()));
    }

    @AfterEach //테스트 실행 후 실행하는 메서드
    public void cleanUp(){
        memberRepository.deleteAll();
    }
}

HTTP 주요 응답 코드

코드 매핑메서드 설명
200 OK isOk() HTTP 응답 코드가 200 OK 인지 검증
201 Created isCreated() HTTP 응답 코드가 201 Created 인지 검증
400 Bad Request isBadRequest() HTTP 응답 코드가 400 Bad Request 인지 검증
403 Forbidden isForbbiden() HTTP 응답 코드가 403 Forbidden 인지 검증
404 Not Found isNotFound() HTTP 응답 코드가 404 Not Found 인지 검증
400번대 응답 코드 is4xxClientError() HTTP 응답 코드가 400번대 응답 코드 인지 검증
500 Internal Server Error isInternalServerError() HTTP 응답 코드가 500 Internal Server Error 인지 검증
500번대 응답 코드 is5xxServerError() HTTP 응답 코드가 500번대 응답 코드 인지 검증

데이터베이스 조작이 편리해지는 ORM

ORM(Object-Relational-Mapping)은 자바의 객체와 데이터베이스를 연결하는 프로그래밍 기법

→ SQL을 전혀 몰라도 자바 언어로만 DB 접근 후 원하는 데이터를 받을 수 있다

(즉, 객체와 데이터베이스를 연결해 자바 언어로만 데이터베이스를 다룰 수 있게 하는 도구)

장점 단점
SQL을 직접 작성하지 않고 접근 가능 프로젝트 복잡성이 커질수록 난이도 UP
객체지향적으로 코드 작성할 수 있어 비즈니스 로직에만 집중 복잡하고 무거운 쿼리는 ORM으로 해결이 어려운 경우
데이터베이스 시스템이 추상화 되어 있어 MySQL, Oracle 전환에 드는 추가 작업이 거의 없음(종속성이 줄어든다) -
매핑하는 정보가 명확하기 때문에 ERD에 대한 의존도를 낮출 수 있고 유지보수할 때 유리 -

DBMS에도 여러 종류가 있는 것처럼 ORM에도 여러 종류가 있다. 자바에서는 JPA(Java Persistence API)를 표준으로 사용한다.

JPA는 자바에서 관계형 데이터베이스를 사용하는 방식을 정의한 인터페이스이다.

→ 인터페이스이기 때문에 실제 사용을 위해서는 JPA를 구현하는 ORM 프레임워크를 추가로 선택해야 한다.

(대표적으로 하이버네이트를 많이 사용 - 내부적으로 JDBC API를 사용)

JPA : 자바 객체와 데이터베이스를 연결해 데이터를 관리한다. 객체지향 도메인 모델과 데이터베이스의 다리 역할

하이버네이트 : JPA의 인터페이스를 구현한다. 내부적으로 JDBC API를 사용

엔티티 매니저란?

JPA와 하이버네이트에 대해서 알아보았으니, JPA의 중요한 컨셉 중 하나인 엔티티 매니저와 영속성 컨텍스트를 확인해보자.

⭐엔티티(Entity)

: 데이터베이스의 테이블과 매핑되는 객체(일반 객체와 다르지 않지만, DB 테이블과 직접 연결된다는 아주 특별한 특징이 있어 구분지어 부른다)

⭐엔티티 매니저(Entity Manager)

: 엔티티를 관리해 데이터베이스와 애플리케이션 사이에서 객체를 생성, 수정, 삭제하는 등의 역할을 수행

→ 엔티티 매니저 팩토리(Entity Manager Factory) : 엔티티 매니저를 생성하는 곳

앞서 데이터베이스에 여러 사용자가 접근할 수 있다고 했는데, 예를 들어 회원 2명이 동시에 회원가입을 
하려는 경우 엔티티 매니저는 다음과 같이 업무를 처리한다.

(1) 회원 1의 요청에 대해서 가입 처리를 할 엔티티 매니저를 생성(from 엔티티 매너지 팩토리)
    회원 2도 마찬가지로 수행

(2) 생성된 엔티티 매니저는 필요한 시점에 데이터베이스와 연결한 다음 쿼리를 날린다

스프링 부트도 직접 엔티티 매니저 팩토리를 만들어서 관리하는가? NO

→ 스프링 부트 내부에서 엔티티 매니저 1개만 생성해서 관리 @Persistence Context 또는 @Autowired

 애너테이션을 사용해서 엔티티 매니저를 사용한다.

🔽 스프링 부트가 엔티티 매니저를 사용하는 방법 예

@PersistenceContext
EntityManager em; //프록시 엔티티 매니저. 필요할 때 진짜 엔티티 매니저 호출

스프링 부트는 기본적으로 빈을 하나만 생성해서 공유하므로 동시성 문제가 발생할 수 있다.

그래서 실제로는 엔티티 매니저가 아닌 (가짜)프록시 엔티티 매니저를 사용한다.

→ 필요할 때 실제 엔티티 매니저를 호출

💡 어렵게 설명하였지만, Spring Data JPA에서 엔티티 매니저를 관리하기 때문에 우리가 직접 생성하거나

 관리할 필요는 없다.

⭐ 영속성 컨텍스트란?

엔티티 매니저는 엔티티를 영속성 컨테스트에 저장한다는 특징이 있다. 영속성 컨텍스트는 JPA의 중요한 특징 중 하나로서, 엔티티를 관리하는 가상의 공간이다.

→ 덕분에 데이터베이스에서 효과적으로 데이터를 가져올 수 있고, 엔티티를 편하게 사용할 수 있다.

영속성 컨텍스트 특징(KEYWORD) : 1차 캐시, 쓰기 지연, 변경 감지, 지연 로딩

기존에는 데이터 조작을 위해 쿼리를 쿼리를 직접 작성했지만 스프링 부트에서는 이런 쿼리를 자바 코드로 작성하고 이를 JPA가 알아서 변경해주는 것이 매우 편리하다.

그래서 어떤 사람들은 JPA의 영속성 컨테스트를 몰라도 괜찮다고 하지만, 이를 모르고 지나치면 자신이 의도하지 않은 방향으로 프로그램이 결과를 만들수 있다고 생각한다 ⭐

💡 1차 캐시

: 영속성 컨텍스트는 내부에 1차 캐시를 가지고 있다.

→ 이때 캐시의 키는 엔티티의 @Id 애너테이션이 달린 기본키 역할을 하는 식별자이며 값은 엔티티이다.

→ 엔티티를 조회하면 1차 캐시에서 데이터를 조회하고 값이 있으면 반환한다.

→ 값이 없으면 데이터베이스에 조회해서 1차 캐시에 저장한 다음 반환한다.

📌 캐시된 데이터를 조회할 때에는 데이터베이스를 거치지 않아도 되므로 매우 빠르게 데이터를 조회할 수 있다

💡 쓰기 지연(transactional write-behind)

: 트랜잭션을 커밋하기 전까지 쿼리를 날리지 않고 모았다가 트랜잭션 커밋시 모았던 쿼리를 한번에 실행

→ 예를 들어, 데이터 추가 쿼리가 3개라면 영속성 컨텍스트는 트랜잭션을 커밋하는 시점에 3개의 쿼리를

   한꺼번에 전송한다.

📌 이를 통해 적당한 묶음으로 쿼리를 요청할 수 있어 데이터베이스 시스템의 부담을 줄일 수 있다

💡 변경 감지

: 트랜잭션을 커밋하면 1차 캐시에 저장되어 있는 엔티티의 값과 현재 엔티티의 값을 비교해서 변경 된 값이 있다면 변경사항을 감지해 변경된 값을 데이터베이스에 자동으로 반영한다.

📌 이를 통해 쓰기 지연과 마찬가지로 적당한 묶음으로 쿼리를 요청할 수 있고, 데이터베이스 시스템 부담 감소

💡 지연 로딩

: 지연 로딩(lazy loading)은 쿼리로 요청한 데이터를 애플리케이션에 바로 로딩하는 것이 아니라 필요할 때 쿼리를 날려 데이터를 조회하는 것을 의미한다.

📌 이를 통해 쓰기 지연과 마찬가지로 적당한 묶음으로 쿼리를 요청할 수 있고, 데이터베이스 시스템 부담 감소

엔티티 A를 조회시 관련(Reference)되어 있는 엔티티 B를 한번에 가져오지 않는다. 
프록시를 맵핑하고 실제 B를 조회할때 쿼리가 나간다.
쿼리가 두 번 나간다. A 조회시 한 번, B 조회시 한 번

💡 즉시 로딩

: 반대로 조회할 때 쿼리를 보내 연관된 모든 데이터를 가져오는 즉시 로딩도 있다.

엔티티 A 조회시 관련되어 있는 엔티티 B를 같이 가져온다. 
실제 엔티티를 맵핑한다. Join을 사용하여 한번에 가져온다.
A join B, 쿼리가 한 번만 나간다

→ 즉시로딩 사용 대신 지연 로딩 방식을 권장(by 인프런-김영한) N+1 문제 방지

이 특징들의 공통점은 데이터베이스의 접근을 최소화 하여 성능을 높일 수 있다는 점이다.

💡 캐시를 사용하거나, 자주 쓰지 않게 하거나, 변화를 자동 감지해서 미리 준비하는 등의 방법

[JPA 즉시 로딩과 지연로딩 차이 참고 자료]

[JPA] 즉시 로딩과 지연 로딩(FetchType.LAZY or EAGER)

엔티티의 상태

엔티티는 4가지 상태를 가진다.

  • 분리(detached) 상태
  • 영속성 컨텍스트가 관리하는 관리(managed) 상태
  • 영속성 컨텍스트와 전혀 관계가 없는 비영속(transient) 상태
  • 삭제된(removed) 상태

상태는 특정 메서드를 호출해서 변경할 수 있다(필요에 따라 데이터를 올바르게 유지하고 관리할 수 있다)

public class EntityManagerTest{
        @Autowired
        EntityManager em;

        public void example(){
                //1. 엔티티 매니저가 엔티티를 관리하지 않는 상태(비영속 상태)
                Member member = new Member(1L, "홍길동");

                //2. 엔티티가 관리되는 상태
                em.persist(member);

                //3. 엔티티 객체가 분리된 상태
                em.detach(member);

                //4. 엔티티 객체가 삭제된 상태
                em.remove(member);
        }
}

1) 엔티티를 처음 만들면 비영속 상태가 된다

2) persist() 메서드를 사용해 엔티티를 관리상태로 만들수 있다

(Member 객체는 영속성 컨텍스트에서 상태가 관리된다)

3) 만약 엔티티를 영속성 컨텍스트에서 관리하고 싶으면 detach() 메서드를 사용해 분리 상태로 만들 수 있다

4) 또한 더 이상 객체가 필요 없다면 removed() 메서드를 사용해서 엔티티를 영속성 컨텍스트와

 데이터베이스에서 삭제할 수 있다

 데이터베이스에서 삭제할 수 있다

스프링 데이터와 스프링 데이터 JPA

지금까지는 엔티티의 상태를 직접 관리하고, 필요한 시점에 커밋을 하는 등의 개발자가 신경 써야 할 부분이 많았다. 스프링 데이터(Spring Data)는 비즈니스 로직에 더 집중할 수 있게 데이터베이스 사용 기능을 클래스 레벨에서 추상화하였다.

스프링 데이터에서 제공하는 인터페이스를 통해서 스프링 데이터를 사용할 수 있는데, 이 인터페이스에는 CRUD 포함 여러 메서드가 포함되어 있으며 알아서 쿼리를 만들어 준다. 또한 페이징 처리 기능, 메서드 이름으로 쿼리 빌딩 등 여러 장점이 있다. 추가로 각 데이터베이스의 특성에 맞춰 기능을 확장해 제공하는 기술도 제공한다

ex) 표준 스펙인 JPA는 스프링에서 구현한 스프링 데이터 JPA(Spring Data JPA)를, 몽고디비는 스프링 데이터 몽고디비(Spring Data MongoDB)를 사용한다.

→ 여기서는 스프링 데이터 JPA에 대해서 살펴보자

스프링 데이터 JPA란?

→ 스프링 데이터 JPA는 스프링 데이터의 공통적인 기능에서 JPA의 유용한 기술이 추가된 기술이다. 스프링 데이터 JPA에서는 스프링 데이터의 인터페이스인 PagingAndSortingRepository를 상속받아 JpaRepository 인터페이스를 만들었으며, JPA를 더 편리하게 사용하는 메서드를 제공한다. 지금까지는 다음과 같이 메서드 호출로 엔티티 상태를 바꾸었다.

🔽 메서드 호출로 엔티티 상태 변경 예

@PersistenceContext
EntityManager em;

public void join(){
        //기존에 엔티티 상태를 바꾸는 방법(메서드를 호출해서 상태 변경)
        Member member = new Member(1L, "홍길동");
        em.persist(member);
}

하지만 스프링 데이터 JPA를 사용하면 리포지터리 역할을 하는 인터페이스를 만들어 데이터베이스의 테이블 조회, 수정, 생성, 삭제 같은 작업을 간단히 할 수 있다.

🔽 기존 CRUD 메서드를 사용하기 위한 JpaRepository 상속 예

public interface MemberRepository extends JpaRepository<Member, Long>{

}

🔽 스프링 데이터 JPA에서 제공하는 메서드 사용해보기

@Service
@RequiredArgsConstructor
public class TestService {

    private final MemberRepository memberRepository;

    public void test(){
        //1) 생성(Create)
        memberRepository.save(new Member(1L, "A"));

        //2) 조회(Read)
        Optional<Member> member = memberRepository.findById(1L); //단건 조회
        List<Member> allMembers = memberRepository.findAll(); //전체 조회

        //3) 삭제(Delete)
        memberRepository.deleteById(1L);
    }
}

Member.java(엔티티 클래스)

@NoArgsConstructor(access = AccessLevel.PROTECTED) //1) 기본 생성자
@AllArgsConstructor
@Getter
@Entity //2)엔티티 지정
public class Member {

        @Id //3)id필드를 기본키로 지정
    @GeneratedValue(strategy = GenerationType.IDENTITY) //4)기본키를 자동으로 +1 증가
    @Column(name = "id", updatable = false)
    private Long id; //DB테이블의 'id' 컬럼과 매칭

    @Column(name = "name", nullable = false) //5)name이라는 not null 컬럼과 매핑
    private String name;
}

테이블에 매핑하고 싶은 경우 @Entity(name = “member_list”) 속성을 이용하면 원하는 테이블명으로 매핑할 수 있다.

⭐ 엔티티는 반드시 기본 생성자가 있어야 하고, 접근 제어자는 public 또는 protected여야 한다. public 보다는 protected가 더 안전하므로 접근 제어자가 protected인 기본 생성자를 생성한다.

@Id는 Long 타입의 id 필드를 테이블의 기본키로 지정한다

⭐ GeneratedValue는 기본키의 생성 방식을 결정한다. 여기서는 자동으로 기본키가 증가되도록 지정

명칭 설명
AUTO 선택한 데이터 베이스 방언(dialect)에 따라 방식을 자동으로 선택(기본값)
IDENTITY 기본키 생성을 데이터베이스에 위임(=AUTO_INCREMENT)
SEQUENCE 데이터베이스 시퀀스를 사용해서 기본키를 할당하는 방법. 오라클에서 주로 사용
TABLE 키 생성 테이블 사용

⭐ @Column 애너테이션은 데이터베이스의 컬럼과 필드를 매핑해준다.

🔽 대표적인 @Column 애너테이션의 속성

명칭 설명
name 필드와 매핑할 컬럼 이름. 설정하지 않으면 필드 이름으로 지정한다
nullable 컬럼의 null 허용 여부. 설정하지 않으면 true(nullable)
unique 컬럼의 유일한 값(unique) 여부. 설정하지 않으면 false(non,unique)
columnDefinition 컬럼 정보 설정. default 값을 줄 수 있다.

정리 : ORM은 관계형 데이터베이스와 프로그램 간의 통신 개념, JPA는 자바 애플리케이션에서 관계형 데이터베이스를 사용하는 방식을 정의한 기술 명세, 하이버네이트는 JPA의 구현체, 스프링 데이터 JPA는 JPA를 쓰기 편하게 만들어놓은 모듈

API와 REST API

API는 프로그램 간에 상호작용하기 위한 매개체를 말한다

→ REST API는 웹의 장점을 최대한 활용하는 API

Representational State Transfer를 줄인 표현으로 자원을 이름으로 구분해 자원의 상태를 주고받는 API방식

REST API의 특징

→ 서버/클라이언트 구조, 무상태, 캐시 처리 가능, 계층화, 인터페이스 일관성과 같은 특징이 있다.

REST API 장점과 단점

⭐ 장점

  1. URL만 보고도 무슨 행동을 하는 API인지 명확하게 알 수 있음
  2. 상태가 없다는 특징이 있어서 클라이언트와 서버의 역할이 명확하게 분리
  3. HTTP 표준을 사용하는 모든 플랫폼에서 사용할 수 있다

💥단점

  1. HTTP 메서드, 즉 GET, POST와 같은 방식의 개수에 제한이 있음
  2. 설계를 하기 위한 공식적으로 제공되는 표준 규약이 없음

그럼에도 REST API는 주소와 메서드만 보고 요청의 내용을 파악할 수 있다는 강력한 장점이 있어 많은 개발자가 사용한다

REST API를 사용하는 방법

규칙1. URL에는 동사를 쓰지 말고, 자원을 표시해야 한다

URL은 자원을 표시해야 한다는 말에서 자원은 무엇을 말하는가? 자원은 가져오는 데이터를 말한다.

ex) 학생 중에 id가 1인 학생의 정보를 가져오는 URL은 이렇게 설계할 수 있다.

1. /students/1
2. /get-student?student_id=1

REST API에 더 맞는 표현은 1번이다(왜냐하면 2번은 자원이 아닌 다른 표현을 섞어 사용했기 때문이다)

2번의 경우 개발하닥 추후 개발에 혼란을 줄 수 있는데 예를 들어서 get을 다른 개발자가 show라고 바꿔서 사용하면 URL 구조가 get-student, show-data와 같이 엉망이 될 것이기 때문이다.

그래서 RESTFul API를 설계할 때는 이런 동사를 쓰지 않는다.

규칙2. 동사는 HTTP 메서드로

설명 적합한 HTTP 메서드와 URL
id가 1인 블로그 글을 조회하는 API GET /articles/1
블로그 글을 추가하는 API POST /articles
블로그 글을 수정하는 API PUT /articles/1
블로그 글을 삭제하는 API DELETE /articles/1

이외에도 슬래시는 계층 관계를 나타내는 데 사용하는 등 여러 규칙이 있으니 설계시 참고하자

블로그 개발을 위한 준비

  1. 엔티티 구성하기(Article)

    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @Entity //엔티티로 지정
    public class Article {

     @Id //id 필드를 기본키로 지정
     @GeneratedValue(strategy = GenerationType.IDENTITY)
     @Column(name = "id", updatable = false)
     private Long id;
    
     @Column(name = "title", nullable = false) //'title'이라는 not null 컬럼과 매핑
     private String title;
    
     @Column(name = "content", nullable = false)
     private String content;
    
     @Builder //빌더 패턴으로 객체 생성
     public Article(String title, String content){
         this.title = title;
         this.content = content;
     }

    }

  2. 리포지터리 만들기

    public interface BlogRepository extends JpaRepository<Article, Long> {

    }

  3. 서비스 메서드 코드 작성하기

DTO(AddArticleRequest) 생성 → Blog Service 클래스 생성 → 블로그 글 추가 메서드(save 작성)

→ 컨트롤러 메서드 코드 작성 → 테스트 코드 작성

AddArticleRequest.class(DTO 생성)

@NoArgsConstructor //기본 생성자 추가
@AllArgsConstructor //모든 필드 값을 파라미터로 받는 생성자 추가
@Getter
public class AddArticleRequest {
    private String title;
    private String content;

    public Article toEntity(){ //생성자를 사용해 객체 생성
        return Article.builder()
                .title(title)
                .content(content)
                .build();
    }

}

Blog 서비스 등록 및 글 추가 메서드(save 작성)

@RequiredArgsConstructor //final 이 붙거나 @NotNull이 붙은 필드의 생성자 추가
@Service //빈으로 등록
public class BlogService {
    private final BlogRepository blogRepository;

    // 블로그 글 추가 메서드
    public Article save(AddArticleRequest request){
        return blogRepository.save(request.toEntity());
    }
}

컨트롤러 메서드 코드 작성

@RequiredArgsConstructor
@RestController //HTTP Response Body에 객체 데이터를 JSON 형식으로 반환하는 컨트롤러
public class BlogApiController {

    private final BlogService blogService;

    //HTTP 메서드가 POST일 때 전달받은 URL과 동일하면 메서드로 매핑
    @PostMapping("/api/articles")
    public ResponseEntity<Article> addArticle(@RequestBody AddArticleRequest request){
        Article savedArticle = blogService.save(request);

        //요청한 자원이 성공적으로 생성되었으며 저장된 블로그 글 정보를 응답 객체에 담아 전송
        return ResponseEntity.status(HttpStatus.CREATED).body(savedArticle);
    }
}

⭐ H2 DB 설정

application.yml에 아래와 같이 추가 필요

spring:
    ...생략...
    datasource:
        url: jdbc:h2:mem:testdb

    h2:
        console:
            enabled: true

작성 하면 실행 콘솔에 h2 db가 올라오는 것을 확인 할 수 있다

📌 Database available at ‘jdbc:h2:mem:testdb’

Console-URL : localhost:8080/h2-console

JDBC URL : jdbc:h2:mem:testdb 동일하게 입력해주면 접속 가능

⭐ 반복 작업을 줄여 줄 테스트 코드 작성하기

지금까지 테스트를 하려면 눈으로 직접 확인 하였는데, 이러한 지루하고 반복적인 방식은 이제 그만

테스트코드 LiveTemplate을 활용해서 자동생성하는 방법

[IntelliJ] 코드 템플릿 - Live Template을 이용하여 자주 사용하는 코드 템플릿화 해보기

테스트 자동생성 단축키 : Shift + Cmd + T

🔽 컨트롤러 테스트 코드 템플릿 작성

@SpringBootTest //테스트용 애플리케이션 컨텍스트
@AutoConfigureMockMvc //MockMvc 생성 및 자동 구성
class BlogApiControllerTest {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper; //직력화, 역직렬화를 위한 클래스

    @Autowired
    private WebApplicationContext context;

    @Autowired
    BlogRepository blogRepository;

    @BeforeEach //테스트 실행 전 실행하는 메서드
    public void mockMvcSetUp(){
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .build();
        blogRepository.deleteAll();
    }

}

여기서 사용되는 ObjectMapper는 자바 객체를 JSON 객체로 변환하는 직렬화 또는 반대로 JSON 데이터를 자바에서 사용하기 위해 자바 객체로 변환하는 역직렬화에 사용된다. (Jackson 라이브러리 클래스)

[Java] ObjectMapper를 이용하여 JSON 파싱하기

  • 직렬화 : 자바 시스템 내부에서 사용되는 객체를 외부에서 사용하도록 데이터를 변환하는 작업
    (JAVA Object → JSON 변환 )
  • 역직렬화 : 외부에서 사용하는 데이터를 자바의 객체 형태로 변환하는 작업
    (JSON → JAVA Object 변환)

🔽 테스트 코드 진행방식

Given 블로그 글 추가에 필요한 요청 객체를 만든다
When 블로그 글 추가 API에 요청을 보낸다. 이때 요청 타입은 JSON이며, given 절에서 미리 만들어둔 객체를 요청 본문으로 보낸다
Then 응답 코드가 201 Created 인지 확인한다. Blog를 전체 조회해 크기가 1인지 확인, 실제로 저장된 값과 요청 값을 비교한다

🔽 컨트롤러 테스트 코드 INSERT(POST) 테스트 작성 ⭐

@SpringBootTest
@AutoConfigureMockMvc
public class TestController {
        ...생략...
        @Test
    @DisplayName("addArticle : 블로그 글 추가에 성공한다.")
    void blogApiControllerTest() throws Exception {
        //given
        final String url = "/api/articles";
        final String title = "테스트 제목";
        final String content = "테스트 내용";

        AddArticleRequest addArticleRequest = new AddArticleRequest(title, content);
        String requestBody = objectMapper.writeValueAsString(addArticleRequest);

        //when
        ResultActions perform = mockMvc.perform(post(url)
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody));

        //then
        perform.andExpect(status().isCreated());
        List<Article> articles = blogRepository.findAll();

        assertThat(articles.size()).isEqualTo(1);
        assertThat(articles.get(0).getTitle()).isEqualTo(title);
        assertThat(articles.get(0).getContent()).isEqualTo(content);
    }
}

🔽 테스트 코드 작성 시 import 문 참고용

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

문법 import static
assertThat org.assertj.core.api.Assertions.assertThat;
post(url) org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
status().isCreated() org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
get(url) org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

🔽 기존에 작성했던 Member(SELECT) vs 방금 작성했던 Blog(INSERT) 비교

| MEMBER(SEELECT) | //given

    final String url = "/test";
    Member savedMember = memberRepository.save(new Member(1L, "홍길동"));

    //when
    ResultActions perform = mockMvc.perform(get(url)
            .accept(MediaType.APPLICATION_JSON));

    //then
    perform.andExpect(status().isOk())
            .andExpect(jsonPath("$[0].id").value(savedMember.getId()))
            .andExpect(jsonPath("$[0].name").value(savedMember.getName())); |

| --- | --- |

| BLOG(INSERT) | //given

    final String url = "/api/articles";
    final String title = "테스트 제목";
    final String content = "테스트 내용";

    AddArticleRequest addArticleRequest = new AddArticleRequest(title, content);
    String requestBody = objectMapper.writeValueAsString(addArticleRequest);

    //when
    ResultActions perform = mockMvc.perform(post(url)
            .contentType(MediaType.APPLICATION_JSON)
            .content(requestBody));

    //then
    perform.andExpect(status().isCreated());
    List<Article> articles = blogRepository.findAll();

    assertThat(articles.size()).isEqualTo(1);
    assertThat(articles.get(0).getTitle()).isEqualTo(title);
    assertThat(articles.get(0).getContent()).isEqualTo(content); |

테스트 코드는 여러가지 경우를 작성해보면서 서로의 차이점에 대해서 정리해두면 추후에 도움이 될 것 같다

🔽 자주 사용되는 Assertions.assertThat() 메서드 정리(assertj)

코드 설명
assertThat(articles.size()).isEqualTo(1); 블로그 글 크기가 1이어야 합니다.
assertThat(articles.size()).isGreaterThan(2); 블로그 글 크기가 2보다 커야 합니다.
assertThat(articles.size()).isLessThan(5); 블로그 글 크기가 5보다 작아야 합니다.
assertThat(articles.size()).isZero(); 블로그 글 크기가 0이어야 합니다.
assertThat(articles.title()).isEqualTo(”제목”); 블로그 글의 title 값이 “제목”이어야 합니다.
assertThat(article.title()).isNotEmpty(); 블로그 글의 title값이 비어 있지 않아야 합니다.
assertThat(article.title()).contains(”제”); 블로그 글의 title값이 “제”를 포함해야 합니다.

블로그 글 목록 조회를 위한 API 구현

서비스에 블로그 글 목록 조회 메서드(findAll 작성) → DTO(ArticleResponse) 생성 → 컨트롤러 메서드 코드 작성 → 테스트 코드 작성

🔽 서비스 메서드 코드 작성하기

@RequiredArgsConstructor 
@Service
public class BlogService {
    ...생략...

    //블로그 글 조회 메서드
    public List<Article> findAll(){
        return blogRepository.findAll();
    }
}

🔽 DTP(ArticleResponse) 클래스 생성

@Getter
public class ArticleResponse {

    private final String title;
    private final String content;

    public ArticleResponse(Article article){
        this.title = article.getTitle();
        this.content = article.getContent();
    }    
}

🔽 컨트롤러 클래스 메서드 코드 작성하기

@RequiredArgsConstructor
@RestController //HTTP Response Body에 객체 데이터를 JSON 형식으로 반환하는 컨트롤러
public class BlogApiController {
        ...생략...

        //블로그 목록 조회
    @GetMapping("/api/articles")
    public ResponseEntity<List<ArticleResponse>> findAllArticles(){
        List<ArticleResponse> articles = blogService.findAll()
                .stream()
                .map(ArticleResponse::new)
                .toList();

        return ResponseEntity.ok().body(articles);
    }
}

🔽 테스트 코드 작성하기

@SpringBootTest //테스트용 애플리케이션 컨텍스트
@AutoConfigureMockMvc //MockMvc 생성 및 자동 구성
class BlogApiControllerTest {
        ...생략...

        @Test
        @DisplayName("findAllArticles: 블로그 글 목록 조회에 성공한다.")
        void findAllArticles() throws Exception {
            //given
            final String url = "/api/articles";
            final String title = "title";
            final String content = "content";

            blogRepository.save(Article.builder()
                    .title(title)
                    .content(content)
                    .build());

            //when
            ResultActions perform = mockMvc.perform(get(url).accept(MediaType.APPLICATION_JSON));

            //then
            perform
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$[0].content").value(content))
                    .andExpect(jsonPath("$[0].title").value(title));
        }
}

🔽 기존에 작성했던 AddArticleRequest(입력용) vs 이번에 작성한 ArticleResponse(조회용) 비교

| AddArticleRequest (입력용) | @NoArgsConstructor //기본 생성자 추가

@AllArgsConstructor //모든 필드 값을 파라미터로 받는 생성자 추가

@Getter

public class AddArticleRequest {

private String title;
private String content;

public Article toEntity(){ //생성자를 사용해 객체 생성
    return Article.builder()
            .title(title)
            .content(content)
            .build();
}

} |

| --- | --- |

| ArticleResponse (조회용) | @Getter

public class ArticleResponse {

private final String title;
private final String content;

public ArticleResponse(Article article){
    this.title = article.getTitle();
    this.content = article.getContent();
}

} |

class BlogApiControllerTest {
        ...생략...
        @Test
    @DisplayName("deleteArticle: 블로그 글 삭제에 성공한다.")
    void deleteArticle() throws Exception {
        //given
        final String url = "/api/articles/{id}";
        final String title = "title";
        final String content = "content";

        Article savedArticle = blogRepository.save(Article.builder()
                .title(title)
                .content(content)
                .build());

        //when
        mockMvc.perform(delete(url, savedArticle.getId())).andExpect(status().isOk());

        //then
        List<Article> articles = blogRepository.findAll();
        assertThat(articles).isEmpty();
    }
}

💡 Request DTO의 경우에는 toEntity()를 실제로 Service 단계에서 Repository에 toEntity로 변환해서 저장

public class BlogService {
    private final BlogRepository blogRepository;

    // 블로그 글 추가 메서드
    public Article save(AddArticleRequest request){
        return blogRepository.save(request.toEntity());
    }
}

💡 Response DTO의 경우에는 Controller 단계에서 Entity → DTO로 변환해서 클라이언트에 반환

public class BlogApiController {
        private final BlogService blogService;

        @GetMapping("/api/articles")
    public ResponseEntity<List<ArticleResponse>> findAllArticles(){
        List<ArticleResponse> articles = blogService.findAll()
                .stream()
                .map(ArticleResponse::new)
                .toList();

        return ResponseEntity.ok().body(articles);
    }
}

블로그 글 조회를 위한 API 구현

서비스에 블로그 글 조회 메서드(findById 작성) → 컨트롤러 메서드 코드 작성 → 테스트 코드 작성

⬇️ 서비스 메서드 코드 작성하기

public class BlogService {
    ...생략...
    //블로그 글 조회 메서드
    public Article findById(long id){
        return blogRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("not found : "+id));
    }
}

⬇️ 컨트롤러 메서드 코드 작성하기

public class BlogApiController {
        ...생략...
        @GetMapping("/api/articles/{id}") //URL 경로에서 값 추출
    public ResponseEntity<ArticleResponse> findArticle(@PathVariable long id){
        Article article = blogService.findById(id);
        return ResponseEntity.ok().body(new ArticleResponse(article));
    }
}

⬇️ 테스트 코드 작성하기

public class BlogService {class BlogApiControllerTest {
        ...생략...
        @Test
    @DisplayName("findArticle: 블로그 글 조회에 성공한다.")
    void findArticle() throws Exception {
        //given
        final String url = "/api/articles/{id}";
        final String title = "title";
        final String content = "content";

        Article savedArticle = blogRepository.save(Article.builder()
                .title(title)
                .content(content)
                .build());

        //when
        ResultActions perform = mockMvc.perform(get(url, savedArticle.getId()));

        //then
        perform
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.title").value(title))
                .andExpect(jsonPath("$.content").value(content));
    }
}

블로그 글 삭제 API 구현

서비스에 블로그 글 삭제 메서드(deleteById 작성) → 컨트롤러 메서드 코드 작성 → 테스트 코드 작성

⬇️ 서비스 메서드 코드 작성하기

public class BlogService {
        ...생략...
        public void delete(long id){
        blogRepository.deleteById(id);
    }
}

⬇️ 컨트롤러 메서드 코드 작성하기

public class BlogApiController {
        ...생략...
        @DeleteMapping("/api/articles/{id}")
    public ResponseEntity<Void> deleteArticle(@PathVariable long id){
        blogService.delete(id);

        return ResponseEntity.ok().build();
    }
}

⬇️ 테스트 코드 작성하기

블로그 글 수정 API 구현하기

엔티티에 수정 메서드 추가 → UpdateArticleRequest DTO 클래스 생성 → 서비스에 블로그 글 수정 메서드(update 작성) → 컨트롤러 메서드 코드 작성 → 테스트 코드 작성

⬇️ 엔티티 수정 메서드 코드 작성하기

@Entity
public class Article {
        ...생략...
    public void update(String title, String content){
        this.title = title;
        this.content = content;
    }
}

⬇️ 글 수정 요청을 받을 DTO 작성하기

@NoArgsConstructor
@AllArgsConstructor
@Getter
public class UpdateArticleRequest {
    private String title;
    private String content;
}

⬇️ 서비스 메서드 코드 작성하기

public class BlogService {
        ...생략...
        @Transactional //트랜잭션 메서드
    public Article update(long id, UpdateArticleRequest request){
        Article article = blogRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("not found : "+id));

        article.update(request.getTitle(), request.getContent());

        return article;
    }
}

@Transactional 애너테이션은 매칭한 메서드를 하나의 트랜잭션으로 묶는 역할을 한다. 스프링에서는 트랜잭션을 적용하기 위해 다른 작업을 할 필요 없이 @Transactional 애너테이션만 사용하면 된다. 중간에 에러가 발생해도 제대로 된 값 수정을 보장한다

⭐ 트랜잭션이란?

데이터베이스의 데이터를 바꾸기 위한 작업 단위를 말한다.

예를 들어, 계좌 이체를 할 때 이런 과정을 거친다고 해보자
1. A 계좌에서 출금
2. B 계좌에 입금

그런데 1. A 계좌에서 출금을 성공하고 2. B 계좌에서 입금을 진행하는 도중 실패하면 어떻게 될까? 
고객 입장에서는 출금은 되었지만 입금은 안 된 심각한 상황이 발생한다.
이러한 문제를 해결하기 위해서는 출금과 입금을 하나의 작업 단위로 묶어서, 즉 트랜잭션으로 묶어서 
두 작업을 한 단위로 실행하면 된다. 만약 중간에 실패한다면 트랜잭션의 처음 상태로 모두 되돌리면 된다.

⬇️ 컨트롤러 메서드 코드 작성하기

public class BlogApiController {
    ...생략...
    @PutMapping("/api/articles/{id}")
    public ResponseEntity<Article> updateArticle(@PathVariable long id, @RequestBody UpdateArticleRequest request){
        Article updatedArticle = blogService.update(id, request);
        return ResponseEntity.ok().body(updatedArticle);
    }
}

⬇️ 테스트 코드 작성하기

class BlogApiControllerTest {
        ...생략...

    @Test
    @DisplayName("updateArticle: 블로그 글 수정에 성공한다.")
    void updateArticle() throws Exception {
        //given
        final String url = "/api/articles/{id}";
        final String title = "title";
        final String content = "content";

        Article savedArticle = blogRepository.save(Article.builder()
                .title(title)
                .content(content)
                .build());

        final String newTitle = "new title";
        final String newContent = "new content";

        UpdateArticleRequest request = new UpdateArticleRequest(newTitle, newContent);

        //when
        ResultActions perform = mockMvc.perform(put(url, savedArticle.getId())
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .content(objectMapper.writeValueAsString(request)));

        //then
        perform.andExpect(status().isOk());

        Article article = blogRepository.findById(savedArticle.getId()).get();

        assertThat(article.getTitle()).isEqualTo(newTitle);
        assertThat(article.getContent()).isEqualTo(newContent);
    }
}

이제 테스트 코드 까지 완료가 되었으며 개발자 입장에서는 코드를 추가할 때마다 기존에 작성해둔 코드에 영향은 가지 않을지에 대한 걱정을 하지 않을수 있다. (현재는 Controller쪽만 테스트 코드를 작성하였는데 BlogService에 대해서도 테스트 코드를 작성해보자)


비교 정리

⭐ Article Entity 와 DTO 정리 ⭐

| Article(Entity) | @Getter

@NoArgsConstructor(access = AccessLevel.PROTECTED)

@Entity //엔티티로 지정

public class Article {

@Id //id 필드를 기본키로 지정
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", updatable = false)
private Long id;

@Column(name = "title", nullable = false) //'title'이라는 not null 컬럼과 매핑
private String title;

@Column(name = "content", nullable = false)
private String content;

@Builder //빌더 패턴으로 객체 생성
public Article(String title, String content){
    this.title = title;
    this.content = content;
}

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

} |

| --- | --- |

| AddArticlerequest. (INSERT) | @NoArgsConstructor //기본 생성자 추가

@AllArgsConstructor //모든 필드 값을 파라미터로 받는 생성자 추가

@Getter

public class AddArticleRequest {

private String title;
private String content;

public Article toEntity(){ //생성자를 사용해 객체 생성
    return Article.builder()
            .title(title)
            .content(content)
            .build();
}

} |

| ArticleResponse (SELECT) | @Getter

public class ArticleResponse {

private final String title;
private final String content;

public ArticleResponse(Article article){
    this.title = article.getTitle();
    this.content = article.getContent();
}

} |

| UpdateArticleRequest (UPDATE) | @NoArgsConstructor

@AllArgsConstructor

@Getter

public class UpdateArticleRequest {

private String title;
private String content;

} |

⭐ CRUD 서비스 정리 ⭐

| 글 작성. (INSERT) | public Article save(AddArticleRequest request){

    return blogRepository.save(request.toEntity());
} |

| --- | --- |

| 글 목록 조회(SELECT) | public List

findAll(){

    return blogRepository.findAll();
} |

| 글 조회. (SELECT) | public Article findById(long id){

    return blogRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("not found : "+id));
} |

| 글 삭제. (DELETE) | public void delete(long id){

    blogRepository.deleteById(id);
} |

| 글 수정. (UPDATE) | @Transactional //트랜잭션 메서드

public Article update(long id, UpdateArticleRequest request){

    Article article = blogRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("not found : "+id));
    article.update(request.getTitle(), request.getContent());
    return article;
} |

⭐ CRUD 컨트롤러 정리 ⭐

| 글 작성. (INSERT) | @PostMapping("/api/articles")

public ResponseEntity

addArticle(@RequestBody AddArticleRequest request){

  Article savedArticle = blogService.save(request);


  //요청한 자원이 성공적으로 생성되었으며 저장된 블로그 글 정보를 응답 객체에 담아 전송
    return ResponseEntity.status(HttpStatus.CREATED).body(savedArticle);

} |

| --- | --- |

| 글 목록 조회(SELECT) | @GetMapping("/api/articles")

public ResponseEntity<List> findAllArticles(){

  List<ArticleResponse> articles = blogService.findAll()
            .stream()
            .map(ArticleResponse::new)
            .toList();
  return ResponseEntity.ok().body(articles);

} |

| 글 조회. (SELECT) | @GetMapping("/api/articles/{id}") //URL 경로에서 값 추출

public ResponseEntity findArticle(@PathVariable long id){

  Article article = blogService.findById(id);
  return ResponseEntity.ok().body(new ArticleResponse(article));

} |

| 글 삭제. (DELETE) | @DeleteMapping("/api/articles/{id}")

public ResponseEntity deleteArticle(@PathVariable long id){

 blogService.delete(id);
 return ResponseEntity.ok().build();

} |

| 글 수정. (UPDATE) | @PutMapping("/api/articles/{id}")

public ResponseEntity

updateArticle(@PathVariable long id, @RequestBody UpdateArticleRequest request){

  Article updatedArticle = blogService.update(id, request);
  return ResponseEntity.ok().body(updatedArticle);

} |

⭐ 테스트코드 만드는 방법 정리 ⭐

| 글 작성. (INSERT) | @Test

@DisplayName("addArticle : 블로그 글 추가에 성공한다.")
void blogApiControllerTest() throws Exception {
    //given
    final String url = "/api/articles";
    final String title = "title";
    final String content = "content";
    final AddArticleRequest userRequest = new AddArticleRequest(title, content);

    //객체 JSON으로 직렬화
    final String requestBody = objectMapper.writeValueAsString(userRequest);

    //when
    ResultActions result = mockMvc.perform(post(url)
            .contentType(MediaType.APPLICATION_JSON_VALUE)
            .content(requestBody));

    //then
    result.andExpect(status().isCreated());

    List<Article> articles = blogRepository.findAll();

    assertThat(articles.size()).isEqualTo(1);
    assertThat(articles.get(0).getTitle()).isEqualTo(title);
    assertThat(articles.get(0).getContent()).isEqualTo(content);
} |

| --- | --- |

| 글 목록 조회(SELECT) | @Test

@DisplayName("findAllArticles: 블로그 글 목록 조회에 성공한다.")
void findAllArticles() throws Exception {
    //given
    final String url = "/api/articles";
    final String title = "title";
    final String content = "content";

    blogRepository.save(Article.builder()
            .title(title)
            .content(content)
            .build());

    //when
    ResultActions perform = mockMvc.perform(get(url).accept(MediaType.APPLICATION_JSON));

    //then
    perform
            .andExpect(status().isOk())
            .andExpect(jsonPath("$[0].content").value(content))
            .andExpect(jsonPath("$[0].title").value(title));
} |

| 글 조회. (SELECT) | @Test

@DisplayName("findArticle: 블로그 글 조회에 성공한다.")
void findArticle() throws Exception {
    //given
    final String url = "/api/articles/{id}";
    final String title = "title";
    final String content = "content";

    Article savedArticle = blogRepository.save(Article.builder()
            .title(title)
            .content(content)
            .build());

    //when
    ResultActions perform = mockMvc.perform(get(url, savedArticle.getId()));

    //then
    perform
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.title").value(title))
            .andExpect(jsonPath("$.content").value(content));
} |

| 글 삭제. (DELETE) | @Test

@DisplayName("deleteArticle: 블로그 글 삭제에 성공한다.")
void deleteArticle() throws Exception {
    //given
    final String url = "/api/articles/{id}";
    final String title = "title";
    final String content = "content";

    Article savedArticle = blogRepository.save(Article.builder()
            .title(title)
            .content(content)
            .build());

    //when
    mockMvc.perform(delete(url, savedArticle.getId())).andExpect(status().isOk());

    //then
    List<Article> articles = blogRepository.findAll();
    assertThat(articles).isEmpty();
} |

| 글 수정. (UPDATE) | @Test

@DisplayName("updateArticle: 블로그 글 수정에 성공한다.")
void updateArticle() throws Exception {
    //given
    final String url = "/api/articles/{id}";
    final String title = "title";
    final String content = "content";

    Article savedArticle = blogRepository.save(Article.builder()
            .title(title)
            .content(content)
            .build());

    final String newTitle = "new title";
    final String newContent = "new content";

    UpdateArticleRequest request = new UpdateArticleRequest(newTitle, newContent);

    //when
    ResultActions perform = mockMvc.perform(put(url, savedArticle.getId())
            .contentType(MediaType.APPLICATION_JSON_VALUE)
            .content(objectMapper.writeValueAsString(request)));

    //then
    perform.andExpect(status().isOk());

    Article article = blogRepository.findById(savedArticle.getId()).get();

    assertThat(article.getTitle()).isEqualTo(newTitle);
    assertThat(article.getContent()).isEqualTo(newContent);
} |

블로그 화면 구성하기

⬇️ 타임리프 표현식과 문법

표현식 설명
${…} 변수의 값 표현식
#{…} 속성 파일 값 표현식
@{…} URL 표현식
*{…} 선택한 변수의 표현식. th:object에서 선택한 객체에 접근

⬇️ 타임리프 문법

표현식 설명 예제
th:text 텍스트를 표현할 때 사용 th:text=${person.name}
th:each 컬렉션을 반복할 때 사용 th:each=”person:${persons}”
th:if 조건이 true인 때만 표시 th:if=”${person.age} >= 20”
th:unless 조건이 false인 때만 표시 th:unless=”${person.age}>=20”
th:href 이동 경로 th:href=”@{/persons(id=${person.id}}}”
th:with 변숫값으로 지정 th:with=”name=${person.name}”
th:object 선택한 객체로 지정 th:object=”${person}”

⬇️ 타임리프 사용을 위한 의존성 추가(build.gradle)

dependencies{
    ...생략...
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
}

⬇️ 타임리프 테스트용 컨트롤러

@Controller
public class ExampleController {

    @GetMapping("/thymeleaf/example")
    public String thymeleafExample(Model model){
        Person examplePerson = new Person();
        examplePerson.setId(1L);
        examplePerson.setName("홍길동");
        examplePerson.setAge(11);
        examplePerson.setHobbies(List.of("운동", "독서"));

        model.addAttribute("person", examplePerson);
        model.addAttribute("today", LocalDate.now());
        return "example";
    }

    @Getter
    @Setter
    class Person{
        private Long id;
        private String name;
        private int age;
        private List<String> hobbies;
    }
}

⬇️ 타임리프 테스트용 html 파일

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <p th:text="${#temporals.format(today, 'yyyy-MM-dd')}"></p>
    <div th:object="${person}">
        <p th:text="|이름 : *{name}|"></p>
        <p th:text="|나이 : *{age}|"></p>
        <p>미</p>
        <ul th:each="hobby : *{hobbies}">
            <li th:text="${hobby}"></li>
            <span th:if="${hobby == '운동'}">(대표 취미)</span>
        </ul>
    </div>

    <a th:href="@{/api/articles/{id}(id=${person.id})}">글 보기</a>
</body>
</html>

블로그 글 목록 뷰 구현하기

⬇️ 뷰에게 데이터를 전달하기 위한 객체 생성(ArticleListViewResponse DTO)

@Getter
public class ArticleListViewResponse {
    private final Long id;
    private final String title;
    private final String content;

    public ArticleListViewResponse(Article article){
        this.id = article.getId();
        this.title = article.getTitle();
        this.content = article.getContent();
    }
}

⬇️ VIEW를 위한 별도 컨트롤러 생성(BlogViewController)

@RequiredArgsConstructor
@Controller
public class BlogViewController {
    private final BlogService blogService;

    @GetMapping("/articles")
    public String getArticles(Model model){
        List<ArticleListViewResponse> articles = blogService.findAll().stream()
                .map(ArticleListViewResponse::new)
                .toList();
        model.addAttribute("articles", articles);

        return "articleList";
    }
}

⬇️ 포스트맨 데이터 보내는 방법(POST 선택, RAW 선택, Body 데이터 입력, JSON 타입 선택)

블로그 글 뷰 구현하기

⬇️ 엔티티에 생성 시간과 수정 시간을 추가해 글이 언제 생성되었는지 뷰에서 확인(Article.java 파일 수정)

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity //엔티티로 지정
public class Article {

    ...생략...

    @CreatedDate //엔티티가 생성될 때 생성 시간 저장
    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @LastModifiedDate //엔티티가 수정될 때 수정 시간 저장
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

        ...생략...
}

@CreatedDate 애너테이션을 사용하면 엔티티가 생성될 때 사용 시간을 created_at 컬럼에 저장

@LastModifiedDate 애너테이션을 사용하면 엔티티가 수정될 때 마지막으로 수정된 시간을 updated_at 컬럼에 저장

⬇️ 기존에 작성했던 data.sql파일에 created_at, updated_at 추가

INSERT INTO ARTICLE(TITLE, CONTENT, CREATED_AT, UPDATED_AT) VALUES('제목 1', '내용 1', NOW(), NOW());
INSERT INTO ARTICLE(TITLE, CONTENT, CREATED_AT, UPDATED_AT) VALUES('제목 2', '내용 2', NOW(), NOW());
INSERT INTO ARTICLE(TITLE, CONTENT, CREATED_AT, UPDATED_AT) VALUES('제목 3', '내용 3', NOW(), NOW());

⬇️ SpringBootDeveloperApplication.java 파일 created_at, updated_at 자동 업데이트 애너테이션 추가

@EnableJpaAuditing //created_at, updated_at 자동 업데이트
@SpringBootApplication
public class SpringBootDeveloperApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringBootDeveloperApplication.class, args);

    }
}

⬇️ 상세 보기에 사용할 DTO를 만들어 보자

  • 뷰에서 사용할 DTO를 만들어보자(dto 디렉터리에 ArticleViewResponse.java를 생성한 뒤 클래스 구현)

    @NoArgsConstructor
    @Getter
    public class ArticleViewResponse {

      private Long id;
      private String title;
      private String content;
      private LocalDateTime createdAt;

    }

⬇️ 컨트롤러 메서드 작성하기

@RequiredArgsConstructor
@Controller
public class BlogViewController {
    ...생략...

    @GetMapping("/articles/{id}")
    public String getArticle(@PathVariable Long id, Model model){
        Article article = blogService.findById(id);
        model.addAttribute("article", new ArticleViewResponse(article));

        return "article";
    }
}

⬇️ HTML 뷰 만들기

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>블로그 글</title>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5 text-center</> bg-light">
  <h1 class="mb-3">My Blog</h1>
  <h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
</div>

<div class="container mt-5">
  <div class="row">
    <div class="col-lg-8">
      <article>
        <input type="hidden" id="article-id" th:value="${article.id}">
        <header class="mb-4">
          <h1 class="fw-bolder mb-1" th:text="${article.title}"></h1>
          <div class="text-muted fst-italic mb-2" th:text="|Posted on ${#temporals.format(article.createdAt, 'yyyy-MM-dd HH:mm')}|"></div>
        </header>
        <section class="mb-5">
          <p class="fs-5 mb-4" th:text="${article.content}"></p>
        </section>
        <button type="button" id="modify-btn"
                th:onclick="|location.href='@{/new-article?id={articleId}(articleId=${article.id})}'|"
                class="btn btn-primary btn-sm">수정</button>
        <button type="button" id="delete-btn"
                class="btn btn-secondary btn-sm">삭제</button>
      </article>
    </div>
  </div>
</div>

<script src="/js/article.js"></script>
</body>

⬇️ 기존에 만들었던 articleList.html 파일 수정

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
    <div class="p-5 mb-5 text-center</> bg-light">
      <h1 class="mb-3">My Blog</h1>
      <h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
    </div>

    <div class="container">
        <div class="row-6" th:each="item : ${articles}">
            <div class="card">
              <div class="card-header" th:text="${item.id}"></div>
              <div class="card-body">
                <h5 class="card-title" th:text="${item.title}"></h5>
                <p class="card-text" th:text="${item.content}"></p>
                <!-- 여기를 수정해주세요 -->
                <a th:href="@{/articles/{id}{id=${item.id})}" class="btn btn-primary">보러 가기</a>

              </div>
            </div>
        </div>
    </div>
</body>
</html>

⬇️ article.js 파일 생성 후 아래 내용 추가

//삭제 기능
const deleteButton = document.getElementById('delete-btn');

if(deleteButton){
    deleteButton.addEventListener('click', event => {
        let id = document.getElementById('article-id').value;
        fetch(`/api/articles/{id}`, {
            method: 'DELETE'
        })
        .then(() => {
            alert("삭제가 완료되었습니다.");
            location.replace('/articles');
        });
    });
}

수정/생성 기능 추가하기

⬇️ 수정 화면을 보여주기 위한 컨트롤러 메서드 추가

@GetMapping("/new-article") //id키를 가진 쿼리 파라미터의 값을 id 변수에 매핑(id는 없을수도 있음)
public String newArticle(@RequestParam(required = false) Long id, Model model){
    if(id == null){
        model.addAttribute("article", new ArticleViewResponse());
    }else{
        Article article = blogService.findById(id);
        model.addAttribute("article", new ArticleViewResponse(article));
    }
    return "newArticle";
}

쿼리 파라미터로 넘어온 ID 값은 없을 수도 있으므로, id가 있으면 수정, 없으면 생성이므로 id가 없는 경우 기본 생성자를 이용해 빈 ArticleViewResponse 객체를 만들고, id가 있으면 기존 값을 가져오는 findById()메서드를 호출한다

⬇️ article.js 파일에 아래 내용 추가

// 수정 기능
const modifyButton = document.getElementById('modify-btn');

if (modifyButton) {
    modifyButton.addEventListener('click', event => {
        let params = new URLSearchParams(location.search);
        let id = params.get('id');

        fetch(`/api/articles/${id}`, {
            method: 'PUT',
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                title: document.getElementById('title').value,
                content: document.getElementById('content').value
            })
        })
            .then(() => {
                alert('수정이 완료되었습니다.');
                location.replace(`/articles/${id}`);
            });
    });
}

// 생성 기능
const createButton = document.getElementById('create-btn');

if (createButton) {
    createButton.addEventListener('click', event => {
        fetch('/api/articles', {
            method: 'POST',
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                title: document.getElementById('title').value,
                content: document.getElementById('content').value
            })
        })
            .then(() => {
                alert('등록 완료되었습니다.');
                location.replace('/articles');
            });
    });
}

⬇️ newArticle.html 파일 생성

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>블로그 글</title>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5 text-center</> bg-light">
  <h1 class="mb-3">My Blog</h1>
  <h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
</div>

<div class="container mt-5">
  <div class="row">
    <div class="col-lg-8">
      <article>
        <input type="hidden" id="article-id" th:value="${article.id}">

        <header class="mb-4">
          <input type="text" class="form-control" placeholder="제목" id="title" th:value="${article.title}">
        </header>
        <section class="mb-5">
          <textarea class="form-control h-25" rows="10" placeholder="내용" id="content" th:text="${article.content}"></textarea>
        </section>
        <button th:if="${article.id} != null" type="button" id="modify-btn" class="btn btn-primary btn-sm">수정</button>
        <button th:if="${article.id} == null" type="button" id="create-btn" class="btn btn-primary btn-sm">등록</button>
      </article>
    </div>
  </div>
</div>

<script src="/js/article.js"></script>
</body>

⬇️ articleList.html 파일에 글 생성 버튼 추가

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
    <div class="p-5 mb-5 text-center</> bg-light">
      <h1 class="mb-3">My Blog</h1>
      <h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
    </div>

    <div class="container">
                <!-- 이 부분 추가 -->
        <button type="button" id="create-btn" th:onclick="|location.href=`@{/new-article}`|"
                class="btn btn-secondary btn-sm mb-3">글 등록</button>
        <div class="row-6" th:each="item : ${articles}">
            <div class="card">
              <div class="card-header" th:text="${item.id}"></div>
              <div class="card-body">
                <h5 class="card-title" th:text="${item.title}"></h5>
                <p class="card-text" th:text="${item.content}"></p>
                <!-- 여기를 수정해주세요 -->
                <a th:href="@{/articles/{id}(id=${item.id})}" class="btn btn-primary">보러 가기</a>

              </div>
            </div>
        </div>
    </div>
</body>
</html> 

스프링 시큐리티

인증과 인가 관련 코드를 아무런 도구의 도움 없이 작성하려면 굉장히 많은 시간이 필요한데, 스프링 시큐리티를 적용하면 아주 쉽게 처리할 수 있다.

스프링 시큐리티 : 스프링 기반 애플리케이션의 보안을 담당하는 스프링 하위 프레임워크

CSRF 공격, 세션 고정 공격을 방어해주고, 요청 헤더도 보안 처리를 해주므로 개발자가 보안 관련 개발을 해야하는 부담을 크게 줄일수 있다( ⭐ 쉽게말해 알아두면 로그인 부분을 아주 편하게 구현 할 수 있다)

필터 기반으로 동작하는 스프링 시큐리티