Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Spring Summer Coding 2024 Week 3 #1

Open
tmdcheol opened this issue Jul 13, 2024 · 0 comments
Open

Spring Summer Coding 2024 Week 3 #1

tmdcheol opened this issue Jul 13, 2024 · 0 comments
Assignees
Labels
documentation Improvements or additions to documentation

Comments

@tmdcheol
Copy link
Owner

tmdcheol commented Jul 13, 2024

Spring Summer Coding 2024 Week 3 : 스프링 입문


  • 본 자료는 김영한 강사님의 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술
    을 기반으로 작성하였습니다.

간단한 웹 애플리케이션 개발

  • 스프링 프로젝트 생성
  • 스프링 부트로 웹 서버 실행
  • 회원 도메인 개발
  • 웹 MVC 개발
  • DB 연동 - JDBC, JPA, 스프링 데이터 JPA
  • 테스트 케이스 작성

코드로 웹 어플리케이션을 만들어보면서 어떤 기술들이 어떻게 사용되는지 큰 그림을 잡는 것이 목표

프로젝트 환경설정

  • 프로젝트 생성
  • 라이브러리 살펴보기
  • View 환경설정
  • 빌드하고 실행하기

스프링 부트 스타터 사이트로 이동해서 스프링 프로젝트 생성
https://start.spring.io

프로젝트 생성

  • 프로젝트 선택
    • Project: Gradle - Groovy Project
      • Gradle이란, 필요한 라이브러리를 끌어오고 그에 대한 의존성 관리 및 빌드 라이프사이클을 관리해주는 툴이다.
    • Spring Boot: 3.3.1
    • Language: Java
    • Packaging: Jar -> 빌드 시 .jar 파일 생성
      • .jar 파일은 .class 파일을 포함하여 어플리케이션을 실행할 때 필요한 모든 파일이 포함된다.
      • 배포 시 해당 파일만 가지고 있으면 Spring Boot 어플리케이션을 어디서든 배포가능하다.
    • Java: 17
    • Project Metadata
      • groupId: landvibe -> 회사명
      • artifactId: springintro -> 프로젝트 명
    • Dependencies: Spring Web, Thymeleaf -> 추가할 라이브러리 선택

프로젝트 폴더 구조

  • .idea
    • Intellij가 사용하는 설정파일
  • gradle
    • gradle이 사용하는 디렉토리
  • src
    • 소스코드를 작성하는 곳
    • test 디렉토리가 기본 생성 되어있다. 그만큼 테스트 코드 작성이 중요하다는 것이다.
  • resources
    • 자바코드를 제외한 것
    • 설정파일
    • html, css, yml ..
  • build.gradle
    • gradle 설정파일
    • 버전 설정
    • 어디서, 어떤 라이브러리를 가져올지 설정
  • .gitignore
    • 필요한 소스코드만 올라가야되고, 빌드파일은 올라가면 안된다. 이런 것을 막아주는 파일
  • gradlew
    • mac에서 빌드할 때 사용하는 실행파일
  • gradle.bat
    • window에서 빌드할 때 필요

Gradle 전체 설정
build.gradle

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

group = 'landvibe'
version = '0.0.1-SNAPSHOT'

java {
 toolchain {
  languageVersion = JavaLanguageVersion.of(17)
 }
}

repositories {
 mavenCentral()
}

dependencies {
 implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
 implementation 'org.springframework.boot:spring-boot-starter-web'
 testImplementation 'org.springframework.boot:spring-boot-starter-test'
 testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
 useJUnitPlatform()
}

  • 동작 확인
    • 기본 메인 클래스 실행
    • 스프링 부트 메인 실행 후 에러페이지로 간단하게 동작 확인(http://localhost:8080)

라이브러리 살펴보기

Gradle은 의존관계가 있는 라이브러리를 함께 다운로드 한다.

우리는 Spring Web, Thymeleaf 만 추가했지만, 그 외에도 수 많은 라이브러리들이 다운받아진 것을 볼 수 있다.

Spring Web, Thymeleaf가 필요로 하는(의존하는) 추가 라이브러리들이 있기 때문에 수 많은 라이브러리들이 딸려오는 것이고, 그런 라이브러리들을 모두 가져온다.
라이브러리 간 의존관계를 관리하고 가져오는 작업을 Gradle이 수행해준다.

스프링 부트 라이브러리

  • spring-boot-starter-web
    • spring-boot-starter-tomcat: 톰캣 (웹서버)
    • spring-webmvc: 스프링 웹 MVC
    • spring-boot-starter-thymeleaf: 타임리프 템플릿 엔진(View)
  • spring-boot-starter(공통): 스프링 부트 + 스프링 코어 + 로깅
    • spring-boot
      • spring-core
    • spring-boot-starter-logging
      • logback, slf4j

테스트 라이브러리

  • spring-boot-starter-test
    • junit: 테스트 프레임워크
    • mockito: 목 라이브러리
    • assertj: 테스트 코드를 좀 더 편하게 작성하게 도와주는 라이브러리
    • spring-test: 스프링 통합 테스트 지원

View 환경설정

Welcome Page 만들기

resources/static/index.html

<!DOCTYPE HTML>
<html>
<head>
    <title>Hello</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
Hello
<a href="/hello">hello</a>
</body>
</html>
  • 스프링 부트가 제공하는 Welcome Page 기능

thymeleaf 템플릿 엔진

@Controller
public class HelloController {
    @GetMapping("hello")
    public String hello(Model model) {
        model.addAttribute("data", "LandVibe");
        return "hello";
    }
}

resources/templates/hello.html

  
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Hello</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
<p th:text="'안녕 ' + ${data}">땅울림 썸머코딩입니다. </p>
</body>
</html>

thymeleaf 템플릿엔진 동작 확인
실행: http://localhost:8080/hello

동작 환경 그림

  • spring boot는 tomcat이라는 내장 서버를 보유하고 있다.
  • 웹 브라우저에서 localhost:8080/hello 요청을 보낸다.
  • tomcat이 웹 브라우저의 요청을 받아서 spring 에게 던진다.
  • spring은 url을 분석해서 HelloController의 메소드 중 하나를 찾게 되고 해당 메소드를 실행시킨다.
  • 메서드는 MVC(model view controller) 패턴을 활용하고 있는데, model이라는 객체에 값을 셋팅해서 지정한 view에게 전달한다. -> 동적 웹(WAS)
  • 컨트롤러에서 리턴 값으로 문자를 반환하면 뷰 리졸버(ViewResolver)가 화면을 찾아서 처리한다.
    • 스프링 부트 템플릿엔진 기본 viewName 매핑
    • resources:templates/ +{ViewName}+ .html

빌드하고 실행하기

콘솔로 이동

  1. ./gradlew build
  2. cd build/libs
  3. java -jar springintro-0.0.1-SNAPSHOT.jar
  4. 실행 확인

윈도우 사용자를 위한 팁

  • 콘솔로 이동 명령 프롬프트(cmd)로 이동
  • ./gradlew gradlew.bat를 실행하면 됩니다.
  • 명령 프롬프트에서 gradlew.bat를 실행하려면 gradlew하고 엔터를 치면 됩니다.
  • gradlew build

스프링 웹 개발 기초

  • 정적 컨텐츠
  • MVC와 템플릿 엔진
  • API

정적 컨텐츠 -> 파일을 웹브라우저에게 그대로 전달하는 것
MVC와 템플릿 엔진 -> 어플리케이션에서 템플릿 엔진을 이용해서 html을 동적으로 만들어서 전달해주는 것
API -> 안드로이드, 아이폰, vue.js, react 와 같은 클라이언트와 개발할 때 json이라는 데이터 전송 format으로 통신하는 방식. 데이터를 주면 화면은 클라이언트가 그린다. 서버끼리 통신할 때도 API 방식이라고 한다.

정적 컨텐츠

resources/static/hello/static-file.html

<!DOCTYPE HTML>
<html>
<head>
    <title>static content</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
땅울림 썸머코딩 2024
</body>
</html>

실행

  • 웹 브라우저에서 localhost:8080/static-file.html 요청을 보내면, spring boot는 tomcat이라는 내장 서버를 보유하고 있다.
  • tomcat이 웹 브라우저의 요청을 받아서 spring 에게 던진다.
  • spring은 url을 분석해서 Controller 쪽에서 해당 url을 처리할 수 있는 메소드가 있는지 확인한다. 즉, Controller 쪽이 우선순위가 있다는 것이다.
  • 없다면, resources:static 경로의 정적 html 파일을 찾아 뱉는 동작을 수행한다.

MVC와 템플릿 엔진

  • MVC: Model, View, Controller
  • View는 화면을 그리는데 집중, Controller는 비즈니스 로직에 집중한다. Controller에서 비즈니스 로직에 대한 결과를 만들어내면 Model 안에 데이터를 담아 View에게 전달한다.

과거에는 View와 Controller가 분리되어 있지 않았다.

Controller

@GetMapping("hello-was")
@ResponseBody
public String helloBadEx(@RequestParam("name") String name) {
    return "<html>\n" +
            "<body>\n" +
            "<p>hello " + name + "</p>\n" +
            "</body>\n" +
            "</html>";
}

물론 이와 같이 개발하지는 않는다. Controller와 View를 분리하지 않았을 때의 불편함을 보여주는 인위적인 예시이다.

  • MVC 패턴을 사용한다면?

Controller

@GetMapping("hello-mvc")
public String helloMvc(
        @RequestParam(value = "name", required = false, defaultValue = "landVibe") String name,
        Model model
) {
    model.addAttribute("name", name);
    return "hello-template";
}

View
resources/templates/hello-template.html

  
<html xmlns:th="http://www.thymeleaf.org">
<body>
<p th:text="'hello ' + ${name}">default message</p>
</body>
</html> 

실행

  • spring boot는 tomcat이라는 내장 서버를 보유하고 있다.
  • 웹 브라우저에서 localhost:8080/hello-mvc?name={query-parameter} 요청을 보낸다.
  • tomcat이 웹 브라우저의 요청을 받아서 spring 에게 던진다.
  • spring은 url을 분석해서 HelloController의 메소드 중 하나를 찾게 되고 해당 메소드를 실행시킨다.
  • 메서드는 MVC(model view controller) 패턴을 활용하고 있는데, model이라는 객체에 값을 셋팅해서 지정한 view에게 전달한다. -> 동적 웹(WAS)
  • 컨트롤러에서 리턴 값으로 문자를 반환하면 뷰 리졸버(ViewResolver)가 view를 찾고 template engine을 연동하여 처리한다.

참고: 사실 결국 서버에서 보내주는 값은 html vs json 두 가지 방법만 있는 것이다.

html -> html을 보내주는 방식에 정적 html을 보내는 방식(Web Server)과 동적으로 html을 만들어서 보내주는 방식(Web Application Server)이 있는 것이다.
그리고 동적으로 html을 만들 때 편리한 개발을 위해서 나온 패턴이 MVC 패턴인 것이다.
Spring boot는 정적웹 기능과 동적웹 기능 모두를 수행가능하다.

json -> json 데이터를 보내는 방식이 이제 배울 API 방식이다.

API

@responsebody 문자 반환

  • 우리의 서버는 결국 HTTP protocol에 맞추어서 데이터를 클라이언트에 반환한다.
  • HTTP protocol에는 header와 body 부분이 있다.
  • @responsebody를 사용하면 body 부분에 view가 아니라 응답 데이터를 직접 넣는다는 의미이다.
    -> ViewResolver 대신 MessageConverter가 동작
@Controller 
  public class HelloController {

      @GetMapping("hello-string")
      @ResponseBody
      public String helloString(@RequestParam("name") String name) {
          return "hello " + name;
      }
} 
  • @responsebody를 사용하면 뷰 리졸버(viewResolver)를 사용하지 않음
  • 대신에 HTTP의 BODY에 문자 내용을 직접 반환(HTML BODY TAG를 말하는 것이 아님)

실행

@responsebody 객체 반환 -> 실질적인 API 방식

@GetMapping("hello-api")
@ResponseBody
public Hello helloApi(@RequestParam("name") String name) {
    Hello hello = new Hello();
    hello.setName(name);
    return hello;
}

static class Hello {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
  • @responsebody를 사용하고, 객체를 반환하면 객체가 JSON으로 변환됨
  • 객체를 JSON으로 변환하는데 이 때 Jackson 라이브러리가 사용된다.

실행
http://localhost:8080/hello-api?name=landVibe

@responsebody 사용 원리

  • @responsebody를 사용하면?
    • HTTP의 BODY에 문자 내용을 직접 반환
    • viewResolver 대신에 HttpMessageConverter가 동작
    • 기본 문자처리: StringHttpMessageConverter
    • 기본 객체처리: MappingJackson2HttpMessageConverter
    • byte 처리 등등 기타 여러 HttpMessageConverter가 기본으로 등록되어 있음

참고: 클라이언트의 HTTP Accept 해더와 서버의 컨트롤러 반환 타입 정보 둘을 조합해서
HttpMessageConverter가 선택된다.

라이브러리 vs 프레임워크
지금까지 Spring Boot로 간단하게 개발한 방식을 보면 어떤 느낌인가? 코드의 흐름을 여러분이 직접 제어하고 있는 느낌인가?

1,2 주차에서 자바로 프로그램을 구현할 때, 모든 코드의 흐름은 public static void main에서 시작했고, 그 곳에서 원하는 흐름대로 코드 작성을 해왔다. 그런데 Spring Boot 에서는 @controller 어노테이션을 붙힌 클래스 내에 메소드를 만들어 놓기만 하면 해당 메소드를 무언가가 찾아서 실행을 해준다. 여러분은 단지 메소드만 구현한 것인데 어떻게 해당 프로그램이 실행되고 있는가?

이미 Spring Boot가 이미 정해놓은 동작 흐름이 있고, 그 흐름 위에서 일정 부분만 구현하고 있다는 느낌을 받지 않는가?

main 문을 실행시켜서 서버를 킬 수 있었고, 해당 실행으로 인해서 Spring Boot Framework는 자신들이 갖고있는 온갖 클래스 파일들(StringHttpMessageConverter, MappingJackson2HttpMessageConverter 등등 들이 여기에 포함되겠지?)을 메모리에 올리게 된다. Framework는 이미 정해져 있는 코드 흐름이 존재하게 된다.

그리고 여러분은 그 흐름에 맞춰 조그만한 코드조각을 잘 올린 것이며, 때문에 여러분이 만든 메소드가 동작하게 되는 것이다.

따라서 지금까지 해왔던 코드작성과는 다르게, 프레임워크 기반 개발을 할 때는 프레임워크의 동작원리를 확실하게 이해하고 있어야 프로젝트를 잘 만들어갈 수 있다. 프로젝트를 구현하며 에러가 발생했을 때도 어디서, 어떤 에러가, 왜 발생했는지 추론하고 수정하기 위해선 동작원리에 대한 이해가 꼭 필요하다.

질문: 그렇다면 위에서 배운 MappingJackson2HttpMessageConverter는 라이브러리일까 프레임워크일까?


상품 관리 예제 - 백엔드 개발

  • 비즈니스 요구사항 정리
  • 상품 도메인과 리포지토리 만들기
  • 상품 리포지토리 테스트 케이스 작성
  • 상품 서비스 개발
  • 상품 서비스 테스트

스프링 생태계가 어떻게 동작하는지 이해해보는 것이 목표이기 때문에 정말 간단한 프로젝트를 만들어서 예시를 보여줄 것이다. 그리고 과제로 여러분이 직접 기능들을 추가 해볼 것이다.


우선 이 홈페이지에서 상품기능만 추가

비즈니스 요구사항 정리

  • 데이터: 회원ID, 상품이름, 가격, 수량
  • 기능: 상품 등록, 조회
  • 상황부여 -> 아직 데이터 저장소가 선정되지 않음(가상의 시나리오)

일반적인 웹 애플리케이션 계층 구조 -> layered architecture

  • 컨트롤러: 웹 MVC의 컨트롤러 역할, 첫 진입점
  • 서비스: 핵심 비즈니스 로직 구현
  • 리포지토리: 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
  • 도메인: 비즈니스 도메인 객체, 예) 회원, 주문, 쿠폰 등등 주로 데이터베이스에 저장하고 관리됨

클래스 의존관계

  • 아직 데이터 저장소가 선정되지 않아서, 우선 인터페이스(ItemRepository)로 구현 클래스를 변경할 수 있도록 설계
  • 데이터 저장소는 RDB, NoSQL 등등 다양한 저장소를 고민중인 상황으로 가정
  • 인터페이스를 만들어 놓으면, 향후에 구현체를 바꿔서 바꿔 끼울 수 있다.
  • 개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 메모리 기반(자료구조)의 데이터 저장소 사용

상품 도메인과 리포지토리 만들기

상품 객체

package landvibe.springintro.item.domain;

public class Item {
    private Long id;
    private String name;
    private Integer price;
    private Integer count;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getPrice() {
        return price;
    }

    public void setPrice(Integer price) {
        this.price = price;
    }

    public Integer getCount() {
        return count;
    }

    public void setCount(Integer count) {
        this.count = count;
    }
}

상품 리포지토리 인터페이스

package landvibe.springintro.item.repository;

import landvibe.springintro.item.domain.Item;

import java.util.List;
import java.util.Optional;

public interface ItemRepository {
    
	Item save(Item item);

    Optional<Item> findById(Long id);

    Optional<Item> findByName(String name);

    List<Item> findAll();

}
  • optional 은 java 8에 들어간 기능으로 null 위에 한번 감싸는 역할을 한다고 생각하면 된다.
  • null 일 때 동작가능한 여러가지 메소드들을 제공한다.

상품 리포지토리 메모리 구현체

package landvibe.springintro.item.repository;

import landvibe.springintro.item.domain.Item;

import java.util.*;

public class MemoryItemRepository implements ItemRepository {

    private static Map<Long, Item> store = new HashMap<>();
    private static long sequence = 0L;


    @Override
    public Item save(Item item) {
        item.setId(++sequence);
        store.put(item.getId(), item);
        return item;
    }

    @Override
    public Optional<Item> findById(Long id) {
        Item item = store.get(id);
        return Optional.ofNullable(item);
    }

    @Override
    public Optional<Item> findByName(String name) {
        return store.values().stream()
                .filter(item -> item.getName().equals(name))
                .findAny();
    }

    @Override
    public List<Item> findAll() {
        return new ArrayList<>(store.values());
    }

    public void clearStore() {
        store.clear();
    }
}
  • 동시성 문제가 고려되어 있지 않음, 실제는 ConcurrentHashMap, AtomicLong 사용 고려
  • tomcat은 멀티스레드 서버 -> 동시에 여러 명의 사용자가 접속가능하도록

회원 리포지토리 테스트 케이스 작성

  • code를 code로 검증하자

프로그램을 동작시키기 위해서는 자바의 main 메서드 혹은, 웹 애플리케이션의 컨트롤러를 통해서 실행한다. 이러한 방법은 준비하고 실행하는데 오래 걸리고, 반복 실행하기 어렵고 여러 테스트를 한번에 실행하기 어렵다는 단점이 있다. 자바는 JUnit이라는 프레임워크로 테스트를 실행해서 이러한 문제를 해결한다.

회원 리포지토리 메모리 구현체 테스트

src/test/java 하위 폴더에 main과 동일한 패키지를 만들어 생성한다.
테스트 생성 단축키 -> mac 기준 cmd + shift + t

package landvibe.springintro.item.repository;

import landvibe.springintro.item.domain.Item;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import java.util.List;

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

class MemoryItemRepositoryTest {

    MemoryItemRepository repository = new MemoryItemRepository();

    @AfterEach
    void afterEach() {
        repository.clearStore();
    }

    @Test
    void save() {
        // given
        Item item = createItem("과자", 10, 1000);

        // when
        repository.save(item);

        // then
        Item foundItem = repository.findById(item.getId()).get();
        assertThat(foundItem).isEqualTo(item);
    }

    @Test
    void findByName() {
        // given
        Item 눈을감자 = createItem("눈을감자", 10, 1000);
        Item 프링글스 = createItem("프링글스", 10, 1000);
        repository.save(눈을감자);
        repository.save(프링글스);

        // when
        Item foundItem = repository.findByName("눈을감자").get();

        // then
        assertThat(foundItem).isEqualTo(눈을감자);
    }

    @Test
    void findAll() {
        // given
        Item 눈을감자 = createItem("눈을감자", 10, 1000);
        Item 프링글스 = createItem("프링글스", 10, 1000);
        repository.save(눈을감자);
        repository.save(프링글스);

        // when
        List<Item> foundItems = repository.findAll();

        // then
        assertThat(foundItems.size()).isEqualTo(2);
        assertThat(foundItems).contains(눈을감자, 프링글스);
    }

    private static Item createItem(String name, int count, int price) {
        Item item = new Item();
        item.setName(name);
        item.setCount(count);
        item.setPrice(price);
        return item;
    }

}
  • 위의 코드에서 isEqualTo()는 동일성 vs 동등성?
  • 메모리에 있기 때문에 주소값이 같아서 equal이 성공한다.
  • @AfterEach: 한번에 여러 테스트를 실행하면 메모리 DB에 직전 테스트의 결과가 남을 수 있다. 이렇게 되면 다음 이전 테스트 때문에 다음 테스트가 실패할 가능성이 있다. @AfterEach를 사용하면 각 테스트가 종료될 때 마다 이 기능을 실행한다. 여기서는 메모리 DB에 저장된 데이터를 삭제한다.
  • 테스트는 각각 독립적으로 실행되어야 한다. 테스트 순서에 의존관계가 있는 것은 좋은 테스트가 아니다.

상품 서비스 개발

  • 비즈니스 로직을 작성해보자
package landvibe.springintro.item.service;

import landvibe.springintro.item.domain.Item;
import landvibe.springintro.item.repository.ItemRepository;
import landvibe.springintro.item.repository.MemoryItemRepository;

import java.util.List;

public class ItemService {

    private final ItemRepository repository = new MemoryItemRepository();

    /**
     * 상품 등록
     */
    public Long create(Item item) {
        validateDuplicateItem(item.getName());
        repository.save(item);
        return item.getId();
    }

    /**
     * 상품 전체조회
     */
    public List<Item> findItems() {
        return repository.findAll();
    }

    private void validateDuplicateItem(String itemName) {
        repository.findByName(itemName)
                .ifPresent(i -> {
                    throw new IllegalArgumentException("이미 존재하는 상품입니다.");
                });
    }
}
  • service는 비즈니스를 처리하는게 역할이기 때문에 비즈니스적으로 메소드명을 보통 네이밍한다.

기존에는 상품 서비스가 메모리 상품 리포지토리를 직접 생성하게 했다. 그리고 test 코드에서는 직접 상품서비스와 MemoryItemRepository를 생성하는 구조이다. 이는 Repository가 중복생성되기 때문에 되게 애매한 상황이다. 물론 static으로 map을 생성했기 때문에 class level에 붙어서 다른 인스턴스여도 같은 static map을 써서 문제는 되지 않지만 MemoryItemRepository들은 다른 인스턴스여서 혹시 내용물이 달라질 수 있는 것이다. 아무튼 실제로 동작하는 repository 인스턴스가 아닌 다른 인스턴스로 테스트코드가 실행되고 있다는 것이 문제이다.

-> 여기서 DI(Dependency Injection) 이라는 개념이 등장

상품 서비스 코드
기존에는 상품 서비스가 메모리 상품 리포지토리를 직접 생성했다.

public class ItemService {
    private final ItemRepository repository = new MemoryItemRepository();

...
}

상품 서비스 코드를 DI 가능하게 변경한다.

public class ItemService {

    private final ItemRepository repository;

    public ItemService(ItemRepository repository) {
        this.repository = repository;
    }

...
}

상품 서비스 테스트

package landvibe.springintro.item.service;

import landvibe.springintro.item.domain.Item;
import landvibe.springintro.item.repository.ItemRepository;
import landvibe.springintro.item.repository.MemoryItemRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.util.Optional;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

class ItemServiceTest {

    ItemService service;
    MemoryItemRepository repository;

    @BeforeEach
    void setUp() {
        repository = new MemoryItemRepository();
        service = new ItemService(repository);
    }

    @AfterEach
    void tearDown() {
        repository.clearStore();
    }

    @Test
    public void 상품생성() throws Exception {
        // given
        Item item = createItem("눈을감자", 10, 1000);

        // when
        Long id = service.create(item);

        // then
        Item foundItem = repository.findById(id).get();
        assertThat(foundItem).isEqualTo(item);
    }

    @Test
    void 중복이름_상품예외() {
        // given
        Item item = createItem("눈을감자", 10, 1000);
        Item duplicatedItem = createItem("눈을감자", 10, 1000);
        service.create(item);

        // when & then
        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
                () -> service.create(duplicatedItem));// 예외 발생

        assertThat(ex.getMessage()).isEqualTo("이미 존재하는 상품입니다.");
    }

    private static Item createItem(String name, int count, int price) {
        Item item = new Item();
        item.setName(name);
        item.setCount(count);
        item.setPrice(price);
        return item;
    }
}

  • 테스트 코드는 과감하게 한글로 메소드이름을 지어도 된다
  • given, when, then 패턴으로 하면 코드를 파악하기 쉽다
  • 테스트는 예외 flow가 훨씬 중요하다
  • @beforeeach: 각 테스트 실행 전에 호출된다. 테스트가 서로 영향이 없도록 항상 새로운 객체를 생성하고, 의존관계도 새로 맺어준다. 여기서는 인스턴스 생성 및 의존관계 주입을 해준다.

스프링 빈과 의존관계

  • 컴포넌트 스캔과 자동 의존관계 설정
  • 자바 코드로 직접 스프링 빈 등록하기

layered architecture는 일반적으로 controller -> service -> repository 방식으로 코드의 흐름이 진행된다. 우리는 service, repository를 구현하고 테스트까지 진행했다.

이제 controller와 view를 만들어서 화면을 추가하는 작업을 진행할텐데 이를 컴포넌트 스캔과 의존관계 설정을 하면서 spring 방식으로 개발을 할 예정이다.

컴포넌트 스캔과 자동 의존관계 설정

상품 컨트롤러가 상품 서비스를 사용할 수 있게 의존관계를 준비하자.

상품 컨트롤러에 의존관계 추가

@controller 어노테이션을 추가하면, spring container라는 통이 생기는데 그 통 안에 해당 클래스를 자동으로 추가해서 관리해준다고 생각하면 된다. 이를 spring container에서 Bean이 관리된다고 생각하면 된다.

@Controller
public class ItemController {

    private final ItemService itemService = new ItemService();
}

이런식으로 코드를 작성하면 어떤 문제가 생길까? ItemService는 Spring Container에 의해 관리되지 않고, Controller가 직접 생성해서 사용하고 있다. ItemService를 만약 다른 컨트롤러들이 필요로 한다면 각 컨트롤러들은 스스로 새로운 ItemService 인스턴스를 생성해서 쓸 것이다. ItemService는 하나만 생성해서 그들이 공유해서 사용하도록 하는 것이 좋을 것이다.

따라서 ItemService를 Spring Container에 등록해서 관리하게 해보자. Spring Container에 등록해서 관리하게 하면 딱 한 개의 인스턴스만 생성되며, 이를 Spring이 주입하도록 하면 된다.

@Controller
public class ItemController {
    
    private final ItemService itemService;

	@Autowired
    public ItemController(ItemService itemService) {
        this.itemService = itemService;
    }
}
  • 생성자에 @Autowired가 있으면 스프링이 연관된 객체를 스프링 컨테이너에서 찾아서 넣어준다. 이렇게 객체 의존관계를 외부에서 넣어주는 것을 DI (Dependency Injection), 의존성 주입이라 한다.
  • 이전 테스트에서는 @beforeeach 메소드 내에서 개발자가 직접 주입했고, 여기서는 @Autowired에 의해 스프링이 주입해준다.

오류 발생
Could not autowire. No beans of 'ItemService' type found.

ItemService가 스프링 빈으로 등록되어 있지 않다.

스프링 빈을 등록하는 2가지 방법

  • 컴포넌트 스캔과 자동 의존관계 설정
  • 자바 코드로 직접 스프링 빈 등록하기

컴포넌트 스캔 원리

  • @component 애노테이션이 있으면 스프링 빈으로 자동 등록된다.

  • @controller 컨트롤러가 스프링 빈으로 자동 등록된 이유도 컴포넌트 스캔 때문이다.

  • @component를 포함하는 다음 애노테이션도 스프링 빈으로 자동 등록된다.

-> command + 클릭해서 들어가보면 @component가 포함되어 있는 것을 볼 수 있음

상품 서비스 스프링 빈 등록

@Service
public class ItemService {
    
/*    private final ItemRepository repository = new MemoryItemRepository();*/

    private final ItemRepository repository;

    public ItemService(ItemRepository repository) {
        this.repository = repository;
    }

참고: 생성자에 @Autowired를 사용하면 객체 생성 시점에 스프링 컨테이너에서 해당 스프링 빈을 찾아서 주입한다. 생성자가 1개만 있으면 @Autowired는 생략할 수 있다.

상품 리포지토리 스프링 빈 등록

@Repository
public class MemoryItemRepository implements ItemRepository {

...
}

스프링 빈 등록 이미지

ItemService와 ItemRepository가 스프링 컨테이너에 스프링 빈으로 등록되었다.

참고: 스프링은 스프링 컨테이너에 스프링 빈을 등록할 때, 기본으로 싱글톤으로 등록한다(유일하게 하나만 등록해서 공유한다) 따라서 같은 스프링 빈이면 모두 같은 인스턴스다. 설정으로 싱글톤이 아니게 설정할 수 있지만, 특별한 경우를 제외하면 대부분 싱글톤을 사용한다.

참고: ComponentScan은 어느 범위의 @component 까지 스캔해줄까?
main 실행문에서 @SpringBootApplication 어노테이션을 까고 들어가보면
@componentscan이라는 어노테이션이 있는 것을 볼 수 있다. 해당 어노테이션이 있어서 실제 컴포넌트 스캔이 수행되는 것인데, default는 @SpringBootApplication 어노테이션이 있는 클래스 위치의 패키지를 포함한 하위 패키지들의 @component들을 모두 스캔해서 Spring Container에 올려준다.

자바 코드로 직접 스프링 빈 등록하기

상품 서비스와 상품 리포지토리의 @service, @repository, @Autowired 애노테이션을 제거하고
진행한다.

package landvibe.springintro.item.config;

import landvibe.springintro.item.repository.ItemRepository;
import landvibe.springintro.item.repository.MemoryItemRepository;
import landvibe.springintro.item.service.ItemService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ItemConfig {
    
    @Bean
    public ItemService itemService() {
        return new ItemService(itemRepository());
    }

    @Bean
    public ItemRepository itemRepository() {
        return new MemoryItemRepository();
    }
}

여기서는 향후 메모리 리포지토리를 다른 리포지토리로 변경할 예정이므로, 컴포넌트 스캔 방식 대신에
자바 코드로 스프링 빈을 설정하겠다.

초반에 우리는 데이터 저장 방식이 아직 정해지지 않았다는 상황 가정을 했었다. 그리고 해당 이유 때문에 interface로 ItemRepository를 만들어 놓았던 것이다.

메모리 저장방식 대신, interface를 상속한 실제 데이터베이스 저장방식을 수행하는 구현체를 만들고, ItemConfig 파일의 ItemRepository의 구현체를 등록하는 함수만 바꿔주면 어떻게 될까?

Java 설정파일을 제외하고 기존코드의 수정은 전혀 일어나지 않으면서 데이터의 저장방식이 바뀌는 진귀한 경험을 할 수 있다.

참고: XML로 설정하는 방식도 있지만 최근에는 잘 사용하지 않으므로 생략
레거시 프로젝트들은 XML 방식 아직도 사용한다.

Java 코드로도 설정파일을 작성할 수 있고, XML 방식으로도 설정파일을 작성할 수 있다.
이것이 어떻게 가능한 것일까? -> Spring이 객체지향의 특성 중 다형성을 이용한 것

설정파일 등록해주는 역할을 정의해놓은 interface가 존재하고, 그 interface에 맞게 XML 등록 방식 구체클래스와 Java 등록방식 구체클래스를 Spring이 만들어 놓는다. 그리고 스프링은 우리가 Java 등록 방식을 사용했기 때문에 해당 방식을 구현한 구현체를 택해서 수행해주는 것이다.

참고: DI에는 필드 주입, setter 주입, 생성자 주입 이렇게 3가지 방법이 있다. 의존관계가 실행중에 동적으로 변하는 경우는 거의 없으므로 생성자 주입을 권장한다.

참고: 주로 정형화된 컨트롤러, 서비스, 리포지토리 같은 코드는 컴포넌트 스캔을 사용한다. 그리고 정형화 되지 않거나, 상황에 따라 구현 클래스를 변경해야 하면 설정을 통해 스프링 빈으로 등록한다.

주의: @Autowired를 통한 DI는 스프링이 관리하는 객체에서만 동작한다. 스프링 빈으로 등록하지 않고 내가 직접 생성한 객체에서는 동작하지 않는다.

상품 관리 예제 - 웹 MVC 개발

  • 홈 화면 추가
  • 상품 웹 기능 - 등록
  • 상품 웹 기능 - 조회

홈 화면 추가

홈 컨트롤러 추가

package landvibe.springintro.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HomeController {
    @GetMapping("/")
    public String home() {
        return "home";
    }
}

홈 html

  • static 폴더에 css, js 파일 추가 -> BootStrap
  • template 폴더안에 ragments 폴더 추가 -> footer, header, bodyHeader 추가
  • template 폴더에 home.html 추가
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header">
    <title>LANDVIBE</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>

<body>

<div class="container">

    <div th:replace="fragments/bodyHeader :: bodyHeader"/>

    <div class="jumbotron">
        <h1>LANDVIBE SHOP</h1>
        <p class="lead">회원 기능</p>
        <p>
            <a class="btn btn-lg btn-secondary" href="/members/new">회원 가입</a>
            <a class="btn btn-lg btn-secondary" href="/members">회원 목록</a>
        </p>
        <p class="lead">상품 기능</p>
        <p>
            <a class="btn btn-lg btn-dark" href="/items/new">상품 등록</a>
            <a class="btn btn-lg btn-dark" href="/items">상품 목록</a>
        </p>
        <p class="lead">주문 기능</p>
        <p>
            <a class="btn btn-lg btn-info" href="/order">상품 주문</a>
            <a class="btn btn-lg btn-info" href="/orders">주문 내역</a>
        </p>
    </div>

    <div th:replace="fragments/footer :: footer"/>

</div> <!-- /container -->

</body>
</html>

참고: Controller가 정적 파일보다 우선순위가 높다
localhost:8080 을 호출하면 , 어떤 일이 일어날까? spring은 먼저 해당 요청을 받을 수 있는 Controller가 있는지 부터 확인하고 없다면 static 페이지를 확인한다고 했다. 따라서 위처럼 localhost:8080을 받을 수 있는 HomeController 메소드를 선언했기 때문에 기존의 welcome page 였던 index.html은 우선순위가 밀려 무시되게 된다.

상품 웹 기능 - 등록

상품 등록 폼 개발

상품 등록 폼 컨트롤러

@Controller
public class ItemController {

    private final ItemService itemService;

    @Autowired
    public ItemController(ItemService itemService) {
        this.itemService = itemService;
    }

    @GetMapping("/items/new")
    public String createForm(){
        return "items/createForm";
    }
}

상품 등록 폼 HTML

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header"/>
<body>

<div class="container">
    <div th:replace="fragments/bodyHeader :: bodyHeader"/>
    <form action="/items/new" method="post">
        <div class="form-group">
            <label for="name">상품명</label>
            <input type="text" id="name" name="name" class="form-control" placeholder="이름을 입력하세요">
        </div>
        <div class="form-group">
            <label for="price">가격</label>
            <input type="number" id="price" name="price" class="form-control" placeholder="가격을 입력하세요">
        </div>
        <div class="form-group">
            <label for="count">수량</label>
            <input type="number" id="count" name="count" class="form-control" placeholder="수량을 입력하세요">
        </div>
        <button type="submit" class="btn btn-primary">Submit</button>
    </form>
    <br/>
    <div th:replace="fragments/footer :: footer"/>

</div> <!-- /container -->

</body>
</html>

웹 등록 화면에서 데이터를 전달 받을 폼 객체

package landvibe.springintro.item.controller;

public class ItemCreateForm {
    private String name;
    private Integer price;
    private Integer count;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getPrice() {
        return price;
    }

    public void setPrice(Integer price) {
        this.price = price;
    }

    public Integer getCount() {
        return count;
    }

    public void setCount(Integer count) {
        this.count = count;
    }
}

상품 컨트롤러에서 상품을 실제 등록하는 기능

@PostMapping("/items/new")
public String create(@ModelAttribute ItemCreateForm form) {
    Item item = new Item();
    item.setName(form.getName());
    item.setPrice(form.getPrice());
    item.setCount(form.getCount());
    service.create(item);

    return "redirect:/";
}

상품 웹 기능 - 조회

상품 컨트롤러에서 조회 기능

@GetMapping("/items")
public String list(Model model){
    List<Item> items = service.findItems();
    model.addAttribute("items", items);
    return "items/itemList";
}

상품 리스트 HTML

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>

<div class="container">
    <div th:replace="fragments/bodyHeader :: bodyHeader"/>

    <div>
        <table class="table table-striped">
            <thead>
            <tr>
                <th>#</th>
                <th>상품명</th>
                <th>가격</th>
                <th>재고수량</th>
                <th></th>
            </tr>
            </thead>
            <tbody>
            <tr th:each="item : ${items}">
                <td th:text="${item.id}"></td>
                <td th:text="${item.name}"></td>
                <td th:text="${item.price}"></td>
                <td th:text="${item.count}"></td>
                <td>
                    <a href="#" th:href="@{/items/{id}/edit (id=${item.id})}" class="btn btn-primary" role="button">수정</a>
                </td>
            </tr>
            </tbody>
        </table>
    </div>

    <div th:replace="fragments/footer :: footer"/>

</div> <!-- /container -->

</body>
</html>

스프링 DB 접근 기술

지금까지는 메모리에만 데이터를 저장했다. 실제 어플리케이션에서 메모리에 데이터를 관리하면 어떻게 될까? 서버가 강제종료되었을 때 데이터들이 모두 사라져 버릴 것이다. 데이터를 영구저장하기 위해서 우리는 데이터베이스를 사용한다.

스프링 데이터 엑세스

  • H2 데이터베이스 설치
  • 순수 Jdbc
  • 스프링 통합 테스트
  • 스프링 JdbcTemplate
  • JPA
  • 스프링 데이터 JPA

H2 데이터베이스 설치

개발이나 테스트 용도로 가볍고 편리한 DB, 웹 화면 제공

  • https://www.h2database.com/html/main.html
  • 다운로드 및 설치
  • h2 데이터베이스 버전은 스프링 부트 버전에 맞춘다.
  • 권한 주기: chmod 755 h2.sh(윈도우 사용자는 x)
  • 실행: ./h2.sh (윈도우 사용자는 h2.bat)
  • 데이터베이스 파일 생성 방법
    • jdbc:h2:~/test (최초 한번)
    • ~/test.mv.db 파일 생성 확인
    • 이후부터는 jdbc:h2:tcp://localhost/~/test 이렇게 접속

테이블 생성하기

테이블 관리를 위해 프로젝트 루트에 sql/ddl.sql 파일을 생성

drop table if exists item CASCADE;
create table item
(
    id   bigint generated by default as identity,
    name varchar(255),
	price integer not null,
 	count integer not null,
    primary key (id)
);

H2 데이터베이스에 접근해서 item 테이블 생성

순수 Jdbc

환경 설정

build.gradle 파일에 jdbc, h2 데이터베이스 관련 라이브러리 추가

implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'

스프링 부트 데이터베이스 연결 설정 추가
resources/application.yml

spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/test
    driver-class-name: org.h2.Driver
    username: sa

Jdbc 리포지토리 구현

순수 JDBC API로 코딩하는 것은 많은 코드가 반복되고 복잡해진다. 실제로 이렇게 개발은 하지 않지만 모든 Java에서의 RDB 접근은 결국 JDBC 를 사용하기 때문에 한번쯤은 알아두는 것이 좋다.

Jdbc 회원 리포지토리

package landvibe.springintro.item.repository;

import landvibe.springintro.item.domain.Item;
import org.springframework.jdbc.datasource.DataSourceUtils;

import javax.sql.DataSource;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class JdbcItemRepository implements ItemRepository {
    private final DataSource dataSource;

    public JdbcItemRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public Item save(Item item) {
        String sql = "insert into item(name, price, count) values(?, ?, ?)";
        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        try {
            conn = getConnection();
            pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
            pstmt.setString(1, item.getName());
            pstmt.setInt(2, item.getPrice());
            pstmt.setInt(3, item.getCount());
            pstmt.executeUpdate();
            rs = pstmt.getGeneratedKeys();
            if (rs.next()) {
                item.setId(rs.getLong(1));
            } else {
                throw new SQLException("id 조회 실패");
            }
            return item;
        } catch (Exception e) {
            throw new IllegalStateException(e);
        } finally {
            close(conn, pstmt, rs);
        }
    }

    @Override
    public Optional<Item> findById(Long id) {
        String sql = "select * from item where id = ?";
        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        try {
            conn = getConnection();
            pstmt = conn.prepareStatement(sql);
            pstmt.setLong(1, id);
            rs = pstmt.executeQuery();
            if (rs.next()) {
                Item item = new Item();
                item.setId(rs.getLong("id"));
                item.setName(rs.getString("name"));
                item.setCount(rs.getInt("count"));
                item.setPrice(rs.getInt("price"));
                return Optional.of(item);
            } else {
                return Optional.empty();
            }
        } catch (Exception e) {
            throw new IllegalStateException(e);
        } finally {
            close(conn, pstmt, rs);
        }
    }

    @Override
    Optional<Item> findByName(String name) {
        String sql = "select * from item where name = ?";
        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        try {
            conn = getConnection();
            pstmt = conn.prepareStatement(sql);
            pstmt.setString(1, name);
            rs = pstmt.executeQuery();
            if (rs.next()) {
                Item item = new Item();
                item.setId(rs.getLong("id"));
                item.setName(rs.getString("name"));
                item.setCount(rs.getInt("count"));
                item.setPrice(rs.getInt("price"));
                return Optional.of(item);
            }
            return Optional.empty();
        } catch (Exception e) {
            throw new IllegalStateException(e);
        } finally {
            close(conn, pstmt, rs);
        }
    }

    @Override
    public List<Item> findAll() {
        String sql = "select * from item";
        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        try {
            conn = getConnection();
            pstmt = conn.prepareStatement(sql);
            rs = pstmt.executeQuery();
            List<Item> items = new ArrayList<>();
            while (rs.next()) {
                Item item = new Item();
                item.setId(rs.getLong("id"));
                item.setName(rs.getString("name"));
                item.setCount(rs.getInt("count"));
                item.setPrice(rs.getInt("price"));
                items.add(item);
            }
            return items;
        } catch (Exception e) {
            throw new IllegalStateException(e);
        } finally {
            close(conn, pstmt, rs);
        }
    }

    private Connection getConnection() {
        return DataSourceUtils.getConnection(dataSource);
    }

    private void close(Connection conn, PreparedStatement pstmt, ResultSet rs) {
        try {
            if (rs != null) {
                rs.close();
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
        try {
            if (pstmt != null) {
                pstmt.close();
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
        try {
            if (conn != null) {
                close(conn);
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    private void close(Connection conn) throws SQLException {
        DataSourceUtils.releaseConnection(conn, dataSource);
    }
}

스프링 설정 변경

package landvibe.springintro.item.config;

import landvibe.springintro.item.repository.ItemRepository;
import landvibe.springintro.item.repository.JdbcItemRepository;
import landvibe.springintro.item.service.ItemService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@Configuration
public class ItemConfig {

    private final DataSource dataSource;

    @Autowired
    public ItemConfig(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Bean
    public ItemService itemService() {
        return new ItemService(itemRepository());
    }

    @Bean
    public ItemRepository itemRepository() {
        // return new MemoryItemRepository();
        return new JdbcItemRepository(dataSource);
    }
}
  • DataSource는 데이터베이스 커넥션을 획득할 때 사용하는 객체다. 스프링 부트는 데이터베이스 커넥션 정보를 바탕으로 DataSource를 생성하고 스프링 빈으로 만들어둔다. 그래서 DI를 받을 수 있다.

구현 클래스 추가 이미지

스프링 설정 이미지

  • 개방-폐쇄 원칙(OCP, Open-Closed Principle)
    • 확장에는 열려있고, 수정, 변경에는 닫혀있다.
  • 스프링의 DI (Dependencies Injection)을 사용하면 기존 코드를 전혀 손대지 않고, 설정만으로 구현클래스를 변경할 수 있다.
  • ==사실 이게 스프링을 사용하는 이유이다==
  • 상품을 등록하고 DB에 결과가 잘 입력되는지 확인하자.
  • 데이터를 DB에 저장하므로 스프링 서버를 다시 실행해도 데이터가 안전하게 저장된다.

참고: 데이터베이스도 서버
우리는 h2 라는 간단한 데이터베이스를 통해서 로컬 네트워크(localhost)를 타는 데이터베이스를 사용하고 있는 것이다.
데이터베이스 서버와 Spring Boot 서버는 클라이언트와 서버가 통신하는 것처럼 결국에는 tcp를 통해서 데이터를 주고 저장하고 리턴 값을 받는 작업을 수행한다.

실제 개발을 해보면, application.yml에서 설정한 데이터베이스 url에 실제 데이터베이스의 IP를 적어주고 통신을 진행하게 된다.

기존 url: jdbc:h2:tcp://localhost/~/test
예시 url: jdbc:mysql://123.1.2.3:3306/{databaseName}

이제 실제로 다시 실행을 해보면, 데이터베이스에 회원 데이터가 영구저장되기 때문에 서버를 껐다가 켜도 회원데이터들이 조회되는 것을 확인할 수 있다.

스프링 통합 테스트

  • 스프링 컨테이너와 DB까지 연결한 통합 테스트를 진행해보자.

이전에 했던 테스트들은 스프링과 관련없이 순수한 자바코드를 테스트한 단위 테스트이다.
이제 스프링부트를 결합한 통합테스트를 해보자.

상품 서비스 스프링 통합 테스트

package landvibe.springintro.item.service;

import landvibe.springintro.item.domain.Item;
import landvibe.springintro.item.repository.ItemRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;

@SpringBootTest
@Transactional
class ItemServiceIntegrationTest {

    @Autowired
    ItemService service;
    @Autowired
    ItemRepository repository;

    @Test
    public void 상품생성() throws Exception {
        // given
        Item item = createItem("눈을감자", 10, 1000);

        // when
        Long id = service.create(item);

        // then
        Item foundItem = repository.findById(id).get();
        assertThat(foundItem.getName()).isEqualTo(item.getName());
        assertThat(foundItem.getPrice()).isEqualTo(item.getPrice());
        assertThat(foundItem.getCount()).isEqualTo(item.getCount());
    }

    @Test
    void 중복이름_상품예외() {
        // given
        Item item = createItem("눈을감자", 10, 1000);
        Item duplicatedItem = createItem("눈을감자", 10, 1000);
        service.create(item);

        // when & then
        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
                () -> service.create(duplicatedItem));// 예외 발생

        assertThat(ex.getMessage()).isEqualTo("이미 존재하는 상품입니다.");
    }

    private static Item createItem(String name, int count, int price) {
        Item item = new Item();
        item.setName(name);
        item.setCount(count);
        item.setPrice(price);
        return item;
    }
}
  • @SpringBootTest: 스프링 컨테이너와 테스트를 함께 실행한다.
  • @transactional: 테스트 케이스에 이 애노테이션이 있으면, 테스트 시작 전에 트랜잭션을 시작하고,테스트 완료 후에 항상 롤백한다. 이렇게 하면 DB에 데이터가 남지 않으므로 다음 테스트에 영향을 주지 않는다.
    • @transaction을 주석처리하고 테스트 코드를 반복해서 실행하면, 에러가 나는 것을 확인할 수 있다. 테스트 코드는 독립적으로 반복해서 실행 가능 해야하기 때문에 롤백이 꼭 필요하다.

질문: 동일성 vs 동등성
상품생성 테스트에서 값을 꺼내 비교하는 것이 아닌 isEqualTo()를 사용하면 테스트가 실패한다. 왜?

참고: 순수한 단위 테스트가 좋은 테스트일 확률이 높다.
통합테스트는 스프링부트를 실제로 띄우고 그 위에서 동작하는 테스트이며, 때문에 테스트 시간도 매우 오래 걸린다. 순수한 자바코드로만 진행하는 단위테스트로 작성할 수 있는 연습이 평소에 필요하다.

스프링 JdbcTemplate

  • 순수 Jdbc와 동일한 환경설정을 하면 된다.
  • 스프링 JdbcTemplate과 MyBatis 같은 라이브러리는 JDBC API에서 본 반복 코드를 대부분 제거해준다. 하지만 SQL은 직접 작성해야 한다.
  • 참고로 왜 JdbcTemplate이나면 템플릿 메서드 패턴이라는 디자인 패턴을 이용해서 코드를 많이 줄여주었기 때문에 JdbcTemplate이라고 부른다.

스프링 JdbcTemplate 상품 리포지토리

package landvibe.springintro.item.repository;

import landvibe.springintro.item.domain.Item;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

public class JdbcTemplateItemRepository implements ItemRepository {
    private final JdbcTemplate jdbcTemplate;

    public JdbcTemplateItemRepository(DataSource dataSource) {
        jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Override
    public Item save(Item item) {
        SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
        jdbcInsert.withTableName("item").usingGeneratedKeyColumns("id");
        Map<String, Object> parameters = new HashMap<>();
        parameters.put("name", item.getName());
        parameters.put("count", item.getCount());
        parameters.put("price", item.getPrice());
        Number key = jdbcInsert.executeAndReturnKey(new MapSqlParameterSource(parameters));
        item.setId(key.longValue());
        return item;
    }

    @Override
    public Optional<Item> findById(Long id) {
        List<Item> result = jdbcTemplate.query("select * from item where id = ? ", rowMapper(), id);
        return result.stream().findAny();
    }

    @Override
    public Optional<Item> findByName(String name) {
        List<Item> result = jdbcTemplate.query("select * from item where name = ? ", rowMapper(), name);
        return result.stream().findAny();
    }

    @Override
    public List<Item> findAll() {
        return jdbcTemplate.query("select * from item", rowMapper());
    }

    private RowMapper<Item> rowMapper() {
        return (rs, rowNum) -> {
            Item item = new Item();
            item.setId(rs.getLong("id"));
            item.setName(rs.getString("name"));
            item.setPrice(rs.getInt("price"));
            item.setCount(rs.getInt("count"));
            return item;
        };
    }
}

JdbcTemplate을 사용하도록 스프링 설정 변경

package landvibe.springintro.item.config;

import landvibe.springintro.item.repository.ItemRepository;
import landvibe.springintro.item.repository.JdbcTemplateItemRepository;
import landvibe.springintro.item.service.ItemService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@Configuration
public class ItemConfig {

    private final DataSource dataSource;

    @Autowired
    public ItemConfig(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Bean
    public ItemService itemService() {
        return new ItemService(itemRepository());
    }

    @Bean
    public ItemRepository itemRepository() {
        // return new MemoryItemRepository();
        // return new JdbcItemRepository(dataSource);
        return new JdbcTemplateItemRepository(dataSource);
    }
}

JPA

  • JPA는 기존의 반복 코드는 물론이고, 기본적인 SQL도 JPA가 직접 만들어서 실행해준다.
  • JPA를 사용하면, SQL과 데이터 중심의 설계에서 객체 중심의 설계로 패러다임을 전환을 할 수 있다.
  • JPA를 사용하면 개발 생산성을 크게 높일 수 있다.

build.gradle 파일에 JPA, h2 데이터베이스 관련 라이브러리 수정

// implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

spring-boot-starter-data-jpa는 내부에 jdbc 관련 라이브러리를 포함한다. 따라서 jdbc는 제거해도
된다.

스프링 부트에 JPA 설정 추가
resources/application.yml

spring:
  jpa:
    show-sql: true
    hibernate:
      ddl-auto: none
  • show-sql: JPA가 생성하는 SQL을 출력한다. (System.out.println, log X)
  • ddl-auto: JPA는 테이블을 자동으로 생성하는 기능을 제공하는데 none를 사용하면 해당 기능을 끈다.

JPA 엔티티 매핑

@Entity
public class Item {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
...
}

JPA 회원 리포지토리

package landvibe.springintro.item.repository;

import jakarta.persistence.EntityManager;
import landvibe.springintro.item.domain.Item;

import java.util.List;
import java.util.Optional;

public class JpaItemRepository implements ItemRepository {

    private final EntityManager em;

    public JpaItemRepository(EntityManager em) {
        this.em = em;
    }

    @Override
    public Item save(Item item) {
        em.persist(item);
        return item;
    }

    @Override
    public Optional<Item> findById(Long id) {
        Item item = em.find(Item.class, id);
        return Optional.ofNullable(item);
    }

    @Override
    public Optional<Item> findByName(String name) {
        return em.createQuery("select i from Item i where i.name = :name", Item.class)
                .setParameter("name", name)
                .getResultStream()
                .findAny();
    }

    @Override
    public List<Item> findAll() {
        return em.createQuery("select i from Item i", Item.class)
                .getResultList();
    }
}

서비스 계층에 트랜잭션 추가

@Transactional
public class ItemService {
...
}
  • 스프링은 @transactional을 설정한 해당 클래스의 메서드를 실행할 때 트랜잭션을 시작하고, 메서드가 정상 종료되면 트랜잭션을 커밋한다. 만약 런타임 예외가 발생하면 롤백한다.
  • JPA를 통한 모든 데이터 변경은 트랜잭션 안에서 실행해야 한다.

JPA를 사용하도록 스프링 설정 변경

package landvibe.springintro.item.config;

import jakarta.persistence.EntityManager;
import landvibe.springintro.item.repository.ItemRepository;
import landvibe.springintro.item.repository.JdbcTemplateItemRepository;
import landvibe.springintro.item.repository.JpaItemRepository;
import landvibe.springintro.item.service.ItemService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

import javax.sql.DataSource;

@Configuration
public class ItemConfig {

    private final DataSource dataSource;
    private final EntityManager em;

    @Autowired
    public ItemConfig(DataSource dataSource, EntityManager em) {
        this.dataSource = dataSource;
        this.em = em;
    }

    @Bean
    public ItemService itemService() {
        return new ItemService(itemRepository());
    }

    @Bean
    public ItemRepository itemRepository() {
        // return new MemoryItemRepository();
        // return new JdbcItemRepository(dataSource);
        // return new JdbcTemplateItemRepository(dataSource);
        return new JpaItemRepository(em);
    }
}

스프링 데이터 JPA

스프링 부트와 JPA만 사용해도 개발 생산성이 정말 많이 증가하고, 개발해야할 코드도 확연히 줄어듭니다.
여기에 스프링 데이터 JPA를 사용하면, 기존의 한계를 넘어 마치 마법처럼, 리포지토리에 구현 클래스 없이
인터페이스 만으로 개발을 완료할 수 있습니다. 그리고 반복 개발해온 기본 CRUD 기능도 스프링 데이터
JPA가 모두 제공합니다.
스프링 부트와 JPA라는 기반 위에, 스프링 데이터 JPA라는 환상적인 프레임워크를 더하면 개발이 정말
즐거워집니다. 지금까지 조금이라도 단순하고 반복이라 생각했던 개발 코드들이 확연하게 줄어듭니다.
따라서 개발자는 핵심 비즈니스 로직을 개발하는데, 집중할 수 있습니다.

주의: 스프링 데이터 JPA는 JPA를 편리하게 사용하도록 도와주는 기술입니다. 따라서 JPA를 먼저 학습한
후에 스프링 데이터 JPA를 학습해야 합니다.

  • 앞의 JPA 설정을 그대로 사용한다.

스프링 데이터 JPA 상품 리포지토리

package landvibe.springintro.item.repository;

import landvibe.springintro.item.domain.Item;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface SpringDataJpaItemRepository extends JpaRepository<Item, Long>, ItemRepository {
    @Override
    Optional<Item> findByName(String name);
}

스프링 데이터 JPA 상품 리포지토리를 사용하도록 스프링 설정 변경

package landvibe.springintro.item.config;

import landvibe.springintro.item.repository.ItemRepository;
import landvibe.springintro.item.service.ItemService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ItemConfig {

/*    private final DataSource dataSource;
    private final EntityManager em;

    @Autowired
    public ItemConfig(DataSource dataSource, EntityManager em) {
        this.dataSource = dataSource;
        this.em = em;
    }*/

    private final ItemRepository itemRepository;

    public ItemConfig(ItemRepository itemRepository) {
        this.itemRepository = itemRepository;
    }

    @Bean
    public ItemService itemService() {
        return new ItemService(itemRepository);
    }

/*    @Bean
    public ItemRepository itemRepository() {
        // return new MemoryItemRepository();
        // return new JdbcItemRepository(dataSource);
        // return new JdbcTemplateItemRepository(dataSource);
        return new JpaItemRepository(em);
    }*/
}
  • 스프링 데이터 JPA가 SpringDataJpaMemberRepository를 스프링 빈으로 자동 등록해준다.

스프링 데이터 JPA 제공 클래스

스프링 데이터 JPA 제공 기능

  • 인터페이스를 통한 기본적인 CRUD
  • findByName(), findByEmail() 처럼 메서드 이름 만으로 조회 기능 제공
  • 페이징 기능 자동 제공

참고: 실무에서는 JPA와 스프링 데이터 JPA를 기본으로 사용하고, 복잡한 동적 쿼리는 Querydsl이라는
라이브러리를 사용하면 된다. Querydsl을 사용하면 쿼리도 자바 코드로 안전하게 작성할 수 있고, 동적
쿼리도 편리하게 작성할 수 있다. 이 조합으로 해결하기 어려운 쿼리는 JPA가 제공하는 네이티브 쿼리를
사용하거나, 앞서 학습한 스프링 JdbcTemplate, MyBatis 등을 사용하면 된다.

AOP

AOP가 필요한 상황

  • 모든 메소드의 호출 시간을 측정하고 싶다면?
  • 공통 관심 사항(cross-cutting concern) vs 핵심 관심 사항(core concern)
  • 회원 가입 시간, 회원 조회 시간을 측정하고 싶다면?

만약 회사에 들어가서 사수분이 모든 메소드가 실행되는데 얼마나 걸리는지를 측정하라고 했다.
다 해놨는데, 갑자기 초단위에서 밀리세컨드 단위로 바꿔달라고 한다. 어떻게 해야할까?
그 수많은 메소드들을 일일히 수정해야할까?


ItemService 시간 측정 로직 추가

/**
 * 상품 등록
 */
public Long create(Item item) {
    long start = System.currentTimeMillis();
    try {
        validateDuplicateItem(item.getName());
        repository.save(item);
        return item.getId();
    } finally {
        long end = System.currentTimeMillis();
        long timeMs = end - start;
        System.out.println("create::timeMs = " + timeMs);
    }
}

문제

  • 상품생성에서 시간을 측정하는 기능은 핵심 관심 사항이 아니다.
  • 시간을 측정하는 로직은 공통 관심 사항이다.
  • 시간을 측정하는 로직과 핵심 비즈니스의 로직이 섞여서 유지보수가 어렵다.
  • 시간을 측정하는 로직을 별도의 공통 로직으로 만들기 매우 어렵다.
  • 시간을 측정하는 로직을 변경할 때 모든 로직을 찾아가면서 변경해야 한다.

AOP 적용

  • AOP: Aspect Oriented Programming (관점 지향 프로그래밍)
  • 공통 관심 사항(cross-cutting concern) vs 핵심 관심 사항(core concern) 분리

  • 공통관심사항(시간 측정 로직)을 분리해서, 내가 원하는 곳에만 적용한다.

시간 측정 AOP 등록

package landvibe.springintro.aop;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Component
@Aspect
public class TimeTraceAop {

    @Around("execution(* landvibe.springintro..*(..))")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        System.out.println("START : " + joinPoint.toString()); // 어떤 메소드를 call 했는지

        try {
            return joinPoint.proceed(); // 다음 메소드가 진행
        } finally {
            long finish = System.currentTimeMillis();
            long timeMs = finish - start;
            System.out.println("END : " + joinPoint.toString() + " " + timeMs + "ms"); // 어떤 메소드를 call 했는지
        }
    }
}

joinPoint를 보면 제공해주는 메소드가 매우 많은 것을 볼 수 있다. 지정한 메소드가 실행될 때마다 intercept 하여 실행하게 되는 것인데 코드의 흐름도 제어할 수 있다. 더 자세한 내용은 추후에 공부해보자.

해결

  • 핵심 관심사항과 시간을 측정하는 공통 관심 사항을 분리한다.
  • 시간을 측정하는 로직을 별도의 공통 로직으로 만들었다.
  • 핵심 관심 사항을 깔끔하게 유지할 수 있다.
  • 변경이 필요하면 이 로직만 변경하면 된다.
  • 원하는 적용 대상을 선택할 수 있다.

스프링의 AOP 동작 방식 설명

AOP 적용 전 의존관계

AOP 적용 후 의존관계

AOP 적용 전 전체 그림

AOP 적용 후 전체 그림

  • 실제 Proxy가 주입되는지 콘솔에 출력해서 확인하기
    -> MemberService 생성자 getClass()로 찍어보기

참고: AOP가 왜 가능할까?
Spring Container를 통해서 DI가 가능하도록 설계되었기 때문이다.
ItemController는 ItemService를 의존한다. ItemController는 ItemService가 뭔진 모르겠고, 그냥 호출만 한다. 프록시가 들어오든 실제 ItemService가 들어오든 상관없다. 그냥 호출만 해주는 것.
프록시는 실제 객체를 상속하여 만든 클래스이기 때문에 주입이 가능하다.

마무리

지금까지 스프링으로 웹 애플리케이션을 개발하는 방법에 대해서 얇고 넓게 학습했다. 이제부터는 각각의
기술들을 깊이있게 이해해야 한다.
거대한 스프링의 모든 것을 세세하게 알 필요는 없다. 우리는 스프링을 만드는 개발자가 아니다. 스프링을
활용해서 실무에서 발생하는 문제들을 잘 해결하는 것이 훨씬 중요하다. 따라서 핵심 원리를 이해하고,
문제가 발생했을 때, 대략 어디쯤 부터 찾아들어가면 될지, 필요한 부분을 찾아서 사용할 수 있는 능력이 더
중요하다.

summer coding 계획
오늘 수업을 통해 넓은 범위를 얕게 배웠으니 하나씩 깊게 파고 들어가볼 예정입니다.

  • 스프링 Core
  • 스프링 웹 MVC
  • 스프링 DB 접근 기술

각 부분들에 대한 동작원리 파악 및 깊은 이해를 위한 수업을 해보려고 합니다.

이번 방학 때 공부할 때 스스로 이렇게 생각해보셨으면 합니다.

  1. 해당 기술이 왜 나왔는지
  2. 핵심 원리와 가치는 뭔지
  3. 직접 구현해볼 수 있다면 해보고
  4. Spring이 이 문제를 어떻게 해결하는지

과제

  • 목표: Spring 개발과 친숙해지기

  • fork 하고, 오늘 배운 상품 기능을 이해하며 따라쳐보기

  • 회원 기능 추가하기

    • html 파일 제공
  • 만약 본인이 Spring에 익숙하지 않다. 오늘 배운 내용이 이해가 가지 않는 부분이 많다.

    • Inflearn 무료강좌 시청 -> 해당 강의를 기반으로 준비하였으며, 정말 수업을 잘하시니 이해가 안가시는 분들은 듣는 것을 추천합니다.
  • 자유롭게 구현하시면 됩니다.

  • 꼭 실제 서비스라고 생각하고 구현하기 -> 생각할 부분이 정말 많아집니다

    • 예시1_회원가입, 로그인 기능? 로그인하면 로그인한 상태는 어떻게 남겨야하지?
    • 예시2_로그인하지 않은 회원은 상품등록 페이지로 이동 못하도록 막아야할 것 같은데?
    • 예시3_상품을 등록할 때 잘못된 값을 넘기면 검증은 어떻게 하지?
  • 실패해도 됩니다. 본인이 직접 생각해보고 시도해야 실력이 향상됩니다

@tmdcheol tmdcheol self-assigned this Jul 13, 2024
@tmdcheol tmdcheol added the documentation Improvements or additions to documentation label Jul 13, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
documentation Improvements or additions to documentation
Projects
None yet
Development

No branches or pull requests

1 participant