spring-boot-starter-data-jpa 는 내부에 jdbc 관련 라이브러리를 포함한다. 따라서 jdbc는 제거해도 된다
스프링 부트에 JPA 설정 추가
resources/application.properties
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
<- 다음을 추가 ->
spring.datasource.username=sa
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none
스프링부트 2.4부터는 spring.datasource.username=sa 를 꼭 추가해주어야 한다. 그렇지 않으면 오류가 발생한다.
show-sql : JPA가 생성하는 SQL을 출력한다.
ddl-auto : JPA는 테이블을 자동으로 생성하는 기능을 제공하는데 none 를 사용하면 해당 기능을 끈다.
create 를 사용하면 엔티티 정보를 바탕으로 테이블도 직접 생성해준다. 해보자.
JPA 엔티티 매핑
JPA를 사용하려면 entity를 매핑해줘야 한다
JPA는 인터페이스이고, 구현체로 Hibernate, Eclipse Link 등 구현하는 기술들이 여러 개의 벤더들에 있음 (거의 Hibernate만 사용)
JPA를 JAVA의 표준인터페이스로 보고, 구현은 여러 업체가 한다고 보면 됨
JPA는 객체 + ORM 기술
object, relational(rdb), mapping(테이블 매핑)
package hello.hellospring.domain;
// JPA가 관리하는 Entity
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
public class Member {
// @Id : 얘가 primary key임을 알려줌
// @GeneratedValue : DB에서 pk를 생성해주고 있음 (이런거를 identity strategy라고 함)
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
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;
}
}
위 코드에서 import 가 제대로 되지 않는다면 아래 코드로
package hello.hellospring.domain;
// JPA가 관리하는 Entity
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
public class Member {
// @Id : 얘가 primary key임을 알려줌
// @GeneratedValue : DB에서 pk를 생성해주고 있음 (이런거를 identity strategy라고 함)
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
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;
}
}
JPA 회원 리포지토리
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import javax.persistence.EntityManager;
import java.util.List;
import java.util.Optional;
public class JpaMemberRepository implements MemberRepository {
// JPA는 EntityManager 기반으로 모든게 동작함
// build.gradle에 data-jpa 라이브러리를 받음 --> 스프링 부트가 자동으로 EntityManager를 생성하여 DB까지 연결도 하고 프로퍼티도 알아서 연동해줌(DataSource가 em 내부에서 다 처리함)
private final EntityManager em;
// entitiy manager 인젝션
public JpaMemberRepository(EntityManager em) {
this.em = em;
}
public Member save(Member member) {
em.persist(member); // 영구 저장
return member;
}
public Optional<Member> findById(Long id) {
Member member = em.find(Member.class, id);
return Optional.ofNullable(member);
}
public List<Member> findAll() {
// select id, name 이런 걸 적을 필요 없이 m으로 처리해주면 됨
return em.createQuery("select m from Member m", Member.class)
}
public Optional<Member> findByName(String name) {
// select id, name 이런 걸 적을 필요 없이 m으로 처리해주면 됨
List<Member> result = em.createQuery("select m from Member m where m.name = :name", Member.class)
.setParameter("name", name)
.getResultList();
return result.stream().findAny();
}
}
위 코드가 제대로 작동하지 않는다면 아래 코드 사용
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import java.util.List;
import java.util.Optional;
public class JpaMemberRepository implements MemberRepository {
// JPA는 EntityManager 기반으로 모든게 동작함
// build.gradle에 data-jpa 라이브러리를 받음 --> 스프링 부트가 자동으로 EntityManager를 생성하여 DB까지 연결도 하고 프로퍼티도 알아서 연동해줌(DataSource가 em 내부에서 다 처리함)
private final EntityManager em;
// entitiy manager 인젝션
public JpaMemberRepository(EntityManager em) {
this.em = em;
}
public Member save(Member member) {
em.persist(member); // 영구 저장
return member;
}
public Optional<Member> findById(Long id) {
Member member = em.find(Member.class, id);
return Optional.ofNullable(member);
}
public List<Member> findAll() {
// select id, name 이런 걸 적을 필요 없이 m으로 처리해주면 됨
return em.createQuery("select m from Member m", Member.class).getResultList();
}
public Optional<Member> findByName(String name) {
// select id, name 이런 걸 적을 필요 없이 m으로 처리해주면 됨
List<Member> result = em.createQuery("select m from Member m where m.name = :name", Member.class)
.setParameter("name", name)
.getResultList();
return result.stream().findAny();
}
}
커맨드 옵션 n : inline으로 바꿔줌 (int a = 1; return a를 return 1로)
findById의 경우 id가 PK이기 때문에 find로 처리하지만, findByName이나 findAll 이런 것들은 jpql을 작성해줘야 함
JPA를 스프링에 감싸서 제공하는 기술인 스프링 데이터 JPA를 사용하면 jpql을 작성할 필요가 없음
서비스 계층에 트랜잭션 추가
import org.springframework.transaction.annotation.Transactional
@Transactional
public class MemberService {}
JPA를 사용하려면 @Transactional이 있어야 함
스프링은 해당 클래스의 메서드를 실행할 때 트랜잭션을 시작하고, 메서드가 정상 종료되면 트랜잭션을 커밋한다. 만약 런타임 예외가 발생하면 롤백한다.
JPA를 통한 모든 데이터 변경은 트랜잭션 안에서 실행해야 한다.
JPA를 사용하도록 스프링 설정 변경
package hello.hellospring;
import hello.hellospring.repository.*;
import hello.hellospring.service.MemberService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.persistence.EntityManager;
import javax.sql.DataSource;
@Configuration
public class SpringConfig {
// 이제 dataSource를 사용하지 않아도 된다
// private final DataSource dataSource;
private final EntityManager em;
public SpringConfig(EntityManager em) {
// this.dataSource = dataSource;
this.em = em;
}
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
return new JdbcMemberRepository(dataSource);
return new JdbcTemplateMemberRepository(dataSource);
return new JpaMemberRepository(em);
}
}
스프링 데이터 JPA
스프링 부트와 JPA만 사용해도 개발 생산성이 정말 많이 증가하고, 개발해야할 코드도 확연히 줄어듭니다. 여기에 스프링 데이터 JPA를 사용하면, 기존의 한계를 넘어 마치 마법처럼, 리포지토리에 구현 클래스 없이 인터페이스 만으로 개발을 완료할 수 있습니다. 그리고 반복 개발해온 기본 CRUD 기능도 스프링 데이터 JPA가 모두 제공합니다.
스프링 부트와 JPA라는 기반 위에, 스프링 데이터 JPA라는 환상적인 프레임워크를 더하면 개발이 정말 즐거워집니다. 지금까지 조금이라도 단순하고 반복이라 생각했던 개발 코드들이 확연하게 줄어듭니다. 따라서 개발자는 핵심 비즈니스 로직을 개발하는데, 집중할 수 있습니다.
실무에서 관계형 데이터베이스를 사용한다면 스프링 데이터 JPA는 이제 선택이 아니라 필수 입니다.
package hello.hellospring.controller;
public class MemberForm {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
회원 컨트롤러에서 회원을 실제 등록하는 기능
package hello.hellospring.controller;
import hello.hellospring.domain.Member;
import hello.hellospring.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@Controller
public class MemberController {
private final MemberService memberService;
@Autowired
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@GetMapping("/members/new") // HTTP Get방식에 의해 호출됨
public String createForm() {
return "members/createMemberForm"; // 이 템플릿을 찾아 thymeleaf 템플릿 엔진이 렌더링
// form이라는 값을 입력할 수 있는 태그를 통해 input 박스가 생기고, 데이터가 action url의 PostMapping으로 이동
// 보통 등록할 때 Post, 조회할 때 Get을 사용함
}
@PostMapping("/members/new")
public String create(MemberForm form) {
Member member = new Member();
member.setName(form.getName());
memberService.join(member);
return "redirect:/"; // 홈 화면으로 보낸다
}
}
회원 웹 기능 - 조회
회원 컨트롤러에서 조회 기능
@GetMapping(value = "/members")
public String list(Model model) {
List<Member> members = memberService.findMembers();
model.addAttribute("members", members); // model에 멤버리스트 자체를 다 담아서 view 템플릿에 넘김
return "members/memberList";
}
package hello.hellospring.controller;
import hello.hellospring.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
@Controller
public class MemberController {
private final MemberService memberService = new MemberService();
// 위 방식 보단 아래의 방식이 더 권장됨(생성자 주입) : 여러군대에서 memberService를 사용하기 때문
/*
AutoWire를 통해 스프링이 스프링 컨테이너에 등록된 memberService를 가져옴(연결해줌)
autowired가 컨트롤러, 서비스, 리포지토리의 연관관계를 연결해주기 때문
오류가 나는 이유 : memBerService를 스프링이 관리하지 않기 때문(순수한 자바 코드임)
구현체에 @Service를 추가해줘서 Spring container에 멤버 서비스를 등록해줘야 한다
Repository도 구현체에 @Repository를 추가해줘야 한다
위와 같은 방식을 컴포넌트 스캔 방식이라고 함 (@service, @Repository 안에 컴포넌트라는 anotation이 있기 때문에)
@Component 애노테이션이 있으면 스프링 빈으로 자동으로 등록 된다.
hello.hellospring에 있는 모든 패키지를 스캔해서 스프링 빈으로 등록해줌(하위 패키지가 아닌 얘들은 기본적으로 스캔이 안됨, 설정을 따로 해줘야 함)
Service, Repository, Controller는 정형화된 패턴임
컨트롤러를 통해서 외부 요청을 받고, 그 다음에 서비스에서 비즈니스 로직을 만들고, 리포지토리에서 데이터를 저장하는 패턴
스프링 컨테이너에 스프링 빈을 등록할 때 기본적으로 싱글톤으로 등록한다. (설정으로 바꿀 수 있음)
생성자 주입 : 생성자를 통해서 주입이 되는 거
필드 주입 : 별로 안좋음
@Autowired private MemberService memberService;
생성될 때 autowired되고, 중간에 바꿔치기할 방법이 없음
Setter 주입 : 멤버 컨트롤을 호출했을 때 public으로 열려있어야 함
중간에 바꿔치기 할 이유가 없는데도 public으로 노출이 되는 문제가 있음
중간에 잘못 바꾸면 문제가 생김
@Autowired
public void setMemberService(MemberService memberService) {
this.memberService = memberService;
요즘 권장하는 방식은 생성자 주입
멤버 컨트롤러가 생성이 될 때 스프링 빈에 등록되어 있는 멤버 서비스 객체를 가져다가 넣어줌(Dependency Injection)
*/
private final MemberService memberService;
@Autowired
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
}
생성자에 @Autowired 가 있으면 스프링이 연관된 객체를 스프링 컨테이너에서 찾아서 넣어준다. 이렇게 객체 의존관계를 외부에서 넣어주는 것을 DI (Dependency Injection), 의존성 주입이라 한다.
이전 테스트에서는 개발자가 직접 주입했고, 여기서는 @Autowired에 의해 스프링이 주입해준다.
그냥 위 코드를 실행하면, memberService가 스프링 빈으로 등록되어 있지 않아 오류가 발생함
컴포넌트 스캔원리
@Component 애노테이션이 있으면 스프링 빈으로 자동 등록된다.
@Controller 컨트롤러가 스프링 빈으로 자동 등록된 이유도 컴포넌트 스캔 때문이다.
@Service
public class MemberService {
private final MemberRepository memberRepository;
@Autowired
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
}
@Repository
public class MemoryMemberRepository implements MemberRepository {
memberService 와 memberRepository 가 스프링 컨테이너에 스프링 빈으로 등록되었다.
참고) 스프링은 스프링 컨테이너에 스프링 빈을 등록할 때, 기본적으로 싱글톤으로 등록한다(물론 설정으로 바꿀 수 있긴 함). 따라서 같은 스프링 빈이면 모두 같은 인스턴스인 것이다.
스프링 빈을 등록하는 2가지 방법
컴포넌트 스캔과 자동 의존관계 설정 (위에서 했던 방식)
자바 코드로 직접 스프링 빈 등록하기
자바 코드로 직접 스프링 빈 등록하기
회원 서비스와 회원 리포지토리의 @Service, @Repository, @Autowired 애노테이션을 제거하고 진행 한다.
package hello.hellospring.service;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import hello.hellospring.service.MemberService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 직접 스프링 빈에 등록하기
@Configuration
public class SpringConfig {
@Bean spring bean에 등록할거야
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
}
지금 데이터 저장소가 선정되지 않았던 시나리오에 의해 인터페이스를 설계하고 메모리 멤버 리포지토리를 쓰고 있었음 근데 나중에 이거를 다른 리포지토리로, 기준에 운영 중인 코드를 하나도 손대지 않고 바꿔치기 할 수 있는 방법이 있음 이거를 하려면 구현체 바꿔치기를 해야 한다. 상황에 따라 구현 클래스를 변경해야하면 설정을 통해서 스프링 빈으로 등록하는게 좋음 DB에 연결한다면, SpringConfig에서 MemberRepository() 대신 DbMemberRepository()를 리턴하면 됨
참고)
XML로 설정하는 방식도 있지만 최근에는 잘 사용하지 않으므로 생략한다.
DI에는 필드 주입, setter 주입, 생성자 주입 이렇게 3가지 방법이 있다. 의존관계가 실행중에 동적으로 변하는 경우는 거의 없으므로 생성자 주입을 권장한다.
실무에서는 주로 정형화된 컨트롤러, 서비스, 리포지토리 같은 코드는 컴포넌트 스캔을 사용한다. 그리고 정형화 되지 않거나, 상황에 따라 구현 클래스를 변경해야 하면 설정을 통해 스프링 빈으로 등록한다 (위의 데이터 저장소의 경우)
@Autowired 를 통한 DI는 helloController , memberService 등과 같이 스프링이 관리하는 객체에서만 동작한다. 스프링 빈으로 등록하지 않고 내가 직접 생성한 객체에서는 동작하지 않는다.
참조) 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 강좌 (인프런 김영한)
도메인: 비즈니스 도메인 객체, 예) 회원, 주문, 쿠폰 등등 주로 데이터베이스에 저장하고 관리됨
클래스 의존관계
아직 데이터 저장소가 선정되지 않아서, 우선 인터페이스로 구현 클래스를 변경할 수 있도록 설계
데이터 저장소는 RDB, NoSQL 등등 다양한 저장소를 고민중인 상황으로 가정
개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용
회원 도메인과 리포지토리 만들기
회원 객체
package hello.hellospring.domain;
public class Member {
private Long id;
private String name;
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;
}
}
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import java.util.*;
/* 동시성 문제가 고려되어 있지 않음, 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려 */
public class MemoryMemberRepository implements MemberRepository {
private static Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L;
@Override
public Member save(Member member) {
member.setId(++sequence);
store.put(member.getId(), member);
// 이렇게 하는 이유는 id를 시스템에서 정해주기 때문(클래스 변수)
return member;
}
@Override
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id));
// null이 반환될 가능성이 있다면 Optional로 감싸서 반환하면 클라이언트에서 이에 맞춰 처리해준다
}
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
@Override
public Optional<Member> findByName(String name) {
return store.values().stream()
.filter(member -> member.getName().equals(name))
.findAny();
// 찾아서 하나라도 있으면 반환해줌. 끝까지 돌렸는데 없으면 optional에 null이 포함되어 반환된다.
}
public void clearStore() {
store.clear();
}
}
회원 리포지토리 테스트 케이스 작성
메인 메서드를 실행허거나, 웹 application의 컨트롤러를 통해서 해당 기능을 실행하면 준비/실행 시간이 오래걸리고, 반복 실행하기 어렵고, 여러 테스트를 한 번에 실행하기 어렵다는 단점이 존재함
JUnit 프레임워크 사용
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach
public void afterEach() {
repository.clearStore();
}
@Test
public void save() {
//given
Member member = new Member();
member.setName("spring");
//when
repository.save(member);
//then
Member result = repository.findById(member.getId()).get(); // Optional에서 값을 꺼냄(.get())
// Assertions.assertEquals(member, null);--> 기대했던 값과 실제 값이 달라 오류 발생
assertThat(result).isEqualTo(member);
}
@Test
public void findByName() {
//given
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
// 위 세줄을 복붙하고, shift + f6 단축키를 이용하여 rename하자
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
//when
Member result = repository.findByName("spring1").get();
//then
assertThat(result).isEqualTo(member1);
}
@Test
public void findAll() {
//given
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
//when
List<Member> result = repository.findAll();
//then
assertThat(result.size()).isEqualTo(2);
}
}
그냥 코드를 실행시키면 오류가 발생함
테스트의 순서는 보장이 안됨
순서에 의존적으로 설정하면 절대 안된다
이대로 전체 Test를 돌리면 findAll 실행 후 저장되어있던 spring1과 spring2가 findByName에서 이전에 저장했던 객체가 나오게 되는 것
afterEach를 통해 테스트가 한 번 끝날 때 저장소를 지우자
회원 서비스 개발
hello.hellospring에 service 패키지 생성 후 MemberService 클래스 작성
package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import hello.hellospring.repository.MemberRepository;
import java.util.List;
import java.util.Optional;
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
/*** 회원가입 */
public Long join(Member member) {
validateDuplicateMember(member); //중복 회원 검증
memberRepository.save(member);
return member.getId();
}
private void validateDuplicateMember(Member member) {
//find By name의 리턴타입이 optional이라서 아래처럼 사용 가능
memberRepository.findByName(member.getName())
.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
}); }
/**
* 전체 회원 조회 */
public List<Member> findMembers() {
return memberRepository.findAll();
}
public Optional<Member> findOne(Long memberId) {
return memberRepository.findById(memberId);
}
}
command + option + v : 반환 타입 + 변수 = 값 형태로 만들어줌
ex) memberRepository.findByName(member.getName()); 상태에서 저 단축키를 쓰면
Optional byName = memberRepository.findByName(member.getName()); 로 바꿔줌
서비스는 보통 비즈니스에 의존적으로 설계를 한다
리포지토리는 서비스보다 단순히 기계적으로 개발에 가까운 용어들을 선택한다.
회원 서비스 테스트
중복 회원 비지니스 로직이 제대로 작동하는지 테스트 케이스를 작성해보자
클래스를 만들고 커맨드 쉬프트 T --> Create New Test --> Junit5, 원하는 클래스 선택하면 껍데기를 만들어줌
커맨드 옵션 / --> 주석처리
테스트 함수는 한글로 만들어도 됨
given when then 문법
given : 뭔가가 주어짐(데이터)
when : 이거를 실행했을 때
then : 결과가 이게 나와야 해
package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
class MemberServiceTest {
MemberService memberService;
MemoryMemberRepository memberRepository;
@BeforeEach
public void beforeEach() {
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
// 따로 만드는 것보다 이렇게 만드는게 좋다
// 외부에서 메모리 멤버 리포지토리를 넣어주고 있는데, 이런 것을 Dependency Injection이라고 함
}
@AfterEach
public void afterEach() {
memberRepository.clearStore();
}
@Test
public void 회원가입() throws Exception {
//Given
Member member = new Member();
member.setName("hello");
//When
Long saveId = memberService.join(member);
//Then
Member findMember = memberRepository.findById(saveId).get();
assertEquals(member.getName(), findMember.getName());
}
@Test
public void 중복_회원_예외() throws Exception {
//Given
Member member1 = new Member();
member1.setName("spring");
Member member2 = new Member();
member2.setName("spring");
//When
memberService.join(member1);
IllegalStateException e = assertThrows(IllegalStateException.class,
() -> memberService.join(member2));//예외가 발생해야 한다.
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
}
}
BeforeEach : 각 테스트 실행 전에 호출되어 테스트가 서로 영향받지 않도록 새로운 객체를 생성하고, 의존관계도 맺어준다
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
... }
controller가 name=spring!!!!!!을 보고 model의 name에 spring!!!!!!을 담음
${name} : 모델의 key 값이 name인 것에서 value를 꺼냄
@Controller
public class HelloController {
@GetMapping("hello-mvc")
public String helloMvc(@RequestParam("name") String name, Model model) {
model.addAttribute("name", name);
return "hello-template";
}
}
작동방식
웹브라우저에서 localhost 8080에 hello-mvc 넘기면 Spring Boot가 내장 톰켓 서버를 거침
내장 톰캣 서버는 hello-mvc를 스프링한테 전달
스프링은 helloController 메서드에 맵핑이 돼 있는걸 확인하고 이 메서드를 호출
helloController는 hello-template와 key(name):value(spring)을 스프링한테 전달
스프링 viewResolver가 return의 string과 똑같은 파일을 찾아서 Thymeleaf 템플릿 엔진에서 처리해 달라고 전달