https://github.com/Code-Jihwan/hello-spring.git
<진행 순서>
스프링 프로잭트 생성
스프링 부트로 웹 서버 실행
회원 도메인 개발
웹 MVC 개발
DB 연동 - JDBC, JPA, 스프링 데이터 JPA
테스트 케이스 작성
openjdk version "17.0.13"
IntelliJ 사용
스프링 프로젝트 생성
https://start.spring.io
프로젝트 선택
Project: Gradle - Groovy Project
Spring Boot: 3.4.1
Language: Java
Packaging: Jar
Java: 17
Project Metadata
groupId: hello
artifactId: hello-spring
Dependencies: Spring Web, Thymeleaf
(Thymeleaf : html 템플릿 엔진)
이렇게 설정하고 spring initializr에서 GENERATE 해주면, 스프링 부트 기반으로 스프링 프로잭트를 알아서 생성해준다.
이후 IntelliJ에서 파일 다운 받아서 작업 시작!
실행 후 localhost:8080 접속해서 서버 정상 동작 확인
프로잭트 환경 설정 완료.
main 메소드 실행하면 스프링 부트 애플리케이션 실행 됨.
내장된 톰캣 웹서버를 자체적으로 띄우면서 올라옴.
gradle 빌드 툴은 의존관계 관리를 해줌
spring-boot-starter-web 라이브러리만 받아오면 그외 필요한 것들을 다 당겨온다.
계속 파고들면서 의존관계에 있는 필요한 것들을 가져온다.
의존관계에 의해 서로 필요한 것들을 가져온다.
스프링 부트 라이브러리
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
테스트 라이브러리
(도메인 누르고 들어오는 첫화면)
src/main/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>
static/index.html
을 올려두면 Welcome page 기능을 제공한다.welcome 페이지 어떻게 하지? -> 문서 찾기
https://docs.spring.io/spring-boot/index.html
spring.io -> https://spring.io/projects/spring-boot#learn
https://docs.spring.io/spring-boot/reference/web/servlet.html#web.servlet.spring-mvc.welcome-page
thymeleaf 공식 사이트: https://www.thymeleaf.org/
스프링 공식 튜토리얼: https://spring.io/guides/gs/serving-web-content/
스프링부트 메뉴얼: https://docs.spring.io/spring-boot/reference/web/reactive.html#web.reactive.webflux.template-engines
thymeleaf 템플릿 장점 : html을 작성하고 그 파일을 서버 없이 바로 열어봐도 껍데기를 볼 수 있다.
콘솔 실행
작업 폴더로 이동
.gradlew builld
cd build/libs
java -jar hello-spring-0.0.1-SNAPSHOT.jar
실행 확인
나중에 서버 배포 할 때는 -> hello-spring-0.0.1-SNAPSHOT.jar 파일만 서버에 복사해서 넣고, java -jar ~~~ 해서 실행 하면 된다.
src/main/java/controller/HelloController
@Controller
public class HelloController {
// 웹 애플리케이션에서 /hello 라고 들어오면 이 메소드 호출한다.
@GetMapping("hello")
public String hello(Model model) {
model.addAttribute("data", "hello!!");
return "hello";
}
@GetMapping("hello-mvc")
// 외부에서 파라미타를 받음
// 옵션 넣을 떄 단축키 -> command + p
public String helloMvc(@RequestParam("name") String name, Model model) {
model.addAttribute("name", name); // 키 - name
return "hello-template";
}
@GetMapping("hello-string")
@ResponseBody
// http 응답 body 부분에 "hello " + name 이 데이터를 직접 넣어 주겠다는 뜻
public String helloString(@RequestParam("name") String name) {
return "hello " + name; // 문자적은 것이 그대로 내려간다.
}
// json 방식으로 결과 나옴 -> 요즘은 다 JSON 반환으로 함
@GetMapping("hello-api")
@ResponseBody
public Hello helloApi(@RequestParam("name") String name) {
// command + shift + enter 해주면 자동 완성
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;
}
}
}
src/main/resources/templates/hello.html
<html xmlns:th="http://www.thymeleaf.org">
<body>
<p th:text="'hello ' + ${name}">hello! empty</p>
</body>
</html>
<!-- thymeㅣeaf 템플릿 장점은 html을 작성하고 그 파일을 서버 없이 바로 열어봐도 껍데기를 볼 수 있다. -->
MVC와 템플릿 엔진
이후 localhost:8080/hello-mvc?name=spring 실행
API
@ResponseBody
객체 반환
이후 localhost:8080/hello-api?name=spring 실행
@ResponseBody
를 사용하고, 객체를 반환하면 객체가 JSON으로 변환됨
@ResponseBody
객체 반환 사용 원리
@ResponseBody
를 사용
viewResolver
대신에 HttpMessageConverter
가 동작StringHttpMessageConverter
MappingJackson2HttpMessageConverter
콘솔 실행
작업 폴더로 이동
.gradlew builld
cd build/libs
java -jar hello-spring-0.0.1-SNAPSHOT.jar
실행 확인
나중에 서버 배포 할 때는 -> hello-spring-0.0.1-SNAPSHOT.jar 파일만 서버에 복사해서 넣고, java -jar ~~~ 해서 실행 하면 된다.
스프링에 내장된 JUnit 프레임워크로 테스트 실행한다.
src/test/java 하위 폴더에 생성한다.
테스트 케이스 작성은 매우 중요
테스트는 순서대로 실행되는 것이 아닌 순서 상관없이 실행된다.
한번에 여러 테스트를 실행하면 메모리 DB에 직전 테스트의 결과가 남을 수 있다. 이렇게
되면 다음 이전 테스트 때문에 다음 테스트가 실패할 가능성이 있다.
따라서
@AfterEach를 사용하여 각 테스트가 종료 될 때 마다 이 기능을 실행한다.
여기서는 메모리 DB에 저장된 데이터를 삭제한다.
@AfterEach
public void afterEach() {
repository.clearStore(); // 저장소 데이터 클리어 시키기
}
클래스 의존관계
회원 객체
domain/Member
package hello.hello_spring.domain;
import jakarta.persistence.*;
@Entity
public class Member {
// 데이터 구분하기 위해서 시스템에 저장하는 id값
@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;
}
}
회원리포지토리 인터페이스
repository/MemberRepository
package hello.hello_spring.repository;
import hello.hello_spring.domain.Member;
import java.util.List;
import java.util.Optional;
public interface MemberRepository {
// 회원을 저장하면 저장된 회원이 반환됨 (회원이 저장소에 저장)
Member save(Member member);
// id, name 으로 회원을 찾는 것
Optional<Member> findById(Long id);
Optional<Member> findByName(String name);
// 지금까지 저장된 모든 회원리스트를 다 반환함
List<Member> findAll();
}
회원 리포지토리 메모리 구현체
repository/MemoryMemberRepository
package hello.hello_spring.repository;
import hello.hello_spring.domain.Member;
import org.springframework.stereotype.Repository;
import java.util.*;
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); // id 세팅
store.put(member.getId(), member); // store에 저장
return member;
}
@Override
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id));
// null 반환될 가능성이 있다면 Optional 로 감싼다.
}
@Override
public Optional<Member> findByName(String name) {
return store.values().stream()
.filter(member -> member.getName().equals(name))
.findAny();
}
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
public void clearStore() {
store.clear();
}
}
package hello.hello_spring.service;
import hello.hello_spring.domain.Member;
import hello.hello_spring.repository.MemberRepository;
import hello.hello_spring.repository.MemoryMemberRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Transactional // 데이터 저장 혹은 변경시에 반드시 필요
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
/**
* 회원가입
*/
public Long join(Member member) {
// 같은 이름이 있는 중복 회원 X
validateDuplicateMember(member); // 중복 회원 검증
memberRepository.save(member);
return member.getId(); // 아이디 반환
}
private void validateDuplicateMember(Member member) {
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);
}
}
package hello.hello_spring.service;
import hello.hello_spring.domain.Member;
import hello.hello_spring.repository.MemberRepository;
import hello.hello_spring.repository.MemoryMemberRepository;
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 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);
}
@AfterEach
public void afterEach() {
memberRepository.clearStore(); // 돌 떄마다 다 끝나고 나면 DB의 값을 다 날려준다.
}
// 테스트 코드에서는 한글로 작성해도 된다. 빌드 될 때 테스트 코드는 실제 코드에 포함 되지 않는다.
@Test
void 회원가입() {
// 추천 문법 : given (뭔가가 주어졌다.) - when (이거를 실행 했을 때) - then (결과가 이것이 나와야 해)
// given (이 데이터를 기반으로 하는 것이구나)
Member member = new Member();
member.setName("hello");
// when (이거를 검증하는구나)
Long saveId = memberService.join(member);
// then (여기가 검증부)
Member findMember = memberService.findOne(saveId).get();
assertThat(member.getName()).isEqualTo(findMember.getName());
}
@Test
public void 중복_회원_예외() {
// give
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));
// () -> memberService.join(member2) 이 로직을 실행하면, IllegalStateException 아 예외가 발생해야 함
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
// 메세지도 같은지 검증
// then
}
@Test
void findMembers() {
}
@Test
void findOne() {
}
}
추천 문법 : given (뭔가가 주어졌다.) - when (이거를 실행 했을 때) - then (결과가 이것이 나와야 해)
given (이 데이터를 기반으로 하는 것이구나)
when (이거를 검증하는구나)
then (여기가 검증부)
이런 방식으로 테스트 케이스 작성하면 코드가 길어질 때 도움이 됨.
해당 클래스에서 command + shift + t -> 테스트 파일 껍데기를 자동으로 만들어주어서 시간 단축.
홈 컨트롤러 추가
package hello.hello_spring.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HomeController {
@GetMapping("/")
public String home() {
return "home";
}
}
회원 관리용 홈화면
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="container">
<div>
<h1>Hello Spring</h1>
<p>회원 기능</p>
<p>
<a href="/members/new">회원 가입</a>
<a href="/members">회원 목록</a>
</p>
</div>
</div> <!-- /container -->
</body>
</html>
회원 등록 폼 컨트롤러
package hello.hello_spring.controller;
import org.springframework.ui.Model;
import hello.hello_spring.domain.Member;
import hello.hello_spring.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;
import java.util.List;
@Controller
public class MemberController {
private final MemberService memberService;
@Autowired
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@GetMapping("/members/new")
public String createForm() {
return "members/createMemberForm";
}
@PostMapping("/members/new")
public String create(MemberForm form) {
Member member = new Member();
member.setName(form.getName());
memberService.join(member);
return "redirect:/"; // 회원가입이 끝나면 홈으로 돌려보냄
}
@GetMapping("/members")
public String list(Model model) {
List<Member> members = memberService.findMembers();
model.addAttribute("members", members);
return "members/memberList";
}
}
회원 등록 폼
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="container">
<form action="/members/new" method="post">
<div class="form-group">
<label for="name">이름</label>
<input type="text" id="name" name="name" placeholder="이름을 입력하세요">
</div>
<button type="submit">등록</button>
</form>
</div> <!-- /container -->
</body>
</html>
회원 등록 컨트롤러
package hello.hello_spring.controller;
public class MemberForm {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
회원 리스트
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="container">
<div>
<table>
<thead>
<tr>
<th>#</th>
<th>이름</th>
</tr>
</thead>
<tbody>
<tr th:each="member : ${members}">
<td th:text="${member.id}"></td>
<td th:text="${member.name}"></td>
</tr>
</tbody>
</table>
</div>
</div> <!-- /container -->
</body>
</html>
build.gradle 파일에 JPA, h2 데이터베이스 관련 라이브러리 추가
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
// implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
JPA 엔티티 매핑
package hello.hello_spring.domain;
import jakarta.persistence.*;
@Entity
public class Member {
// 데이터 구분하기 위해서 시스템에 저장하는 id값
@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.hello_spring.repository;
import hello.hello_spring.domain.Member;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityManager;
import java.util.List;
import java.util.Optional;
public class JpaMemberRepository implements MemberRepository {
private final EntityManager em; // JPA 는 EntityManager 로 모든 것이 동작함
public JpaMemberRepository(EntityManager em) {
this.em = em;
}
@Override
public Member save(Member member) {
em.persist(member);
return member;
}
@Override
public Optional<Member> findById(Long id) {
Member member = em.find(Member.class, id);
return Optional.ofNullable(member);
}
@Override
public Optional<Member> findByName(String name) {
List<Member> result = em.createQuery("select m from Member m where m.name = :name", Member.class)
.setParameter("name", name)
.getResultList();
return result.stream().findAny();
}
@Override
public List<Member> findAll() {
List<Member> result = em.createQuery("select m from Member m", Member.class)
.getResultList();
return result;
}
}
JPA를 사용하도록 스프링 설정 변경
package hello.hello_spring.service;
import hello.hello_spring.aop.TimeTraceAop;
import hello.hello_spring.repository.JdbcTemplateMemberRepository;
import hello.hello_spring.repository.JpaMemberRepository;
import hello.hello_spring.repository.MemberRepository;
import hello.hello_spring.repository.MemoryMemberRepository;
import jakarta.persistence.EntityManager;
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 SpringConfig {
private final MemberRepository memberRepository;
@Autowired
public SpringConfig(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
// private EntityManager em;
//
// @Autowired
// public SpringConfig(EntityManager em) {
// this.em = em;
// }
// private final DataSource dataSource;
// @Autowired
// public SpringConfig(DataSource dataSource) {
// this.dataSource = dataSource;
// }
@Bean
public MemberService memberService() {
return new MemberService(memberRepository);
}
// @Bean
// public MemberRepository memberRepository() {
// return new MemoryMemberRepository();
// return new JdbcTemplateMemberRepository(dataSource);
// return new JpaMemberRepository(em);
// }
// @Bean
// public TimeTraceAop timeTraceAop() {
// return new TimeTraceAop();
// }
}
스프링 데이터 JPA 회원 리포지토리
package hello.hello_spring.repository;
import hello.hello_spring.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository {
@Override
Optional<Member> findByName(String name);
}
스프링 데이터 JPA 회원 리포지토리를 사용하도록 스프링 설정 변경
package hello.hello_spring.service;
import hello.hello_spring.aop.TimeTraceAop;
import hello.hello_spring.repository.JdbcTemplateMemberRepository;
import hello.hello_spring.repository.JpaMemberRepository;
import hello.hello_spring.repository.MemberRepository;
import hello.hello_spring.repository.MemoryMemberRepository;
import jakarta.persistence.EntityManager;
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 SpringConfig {
private final MemberRepository memberRepository;
@Autowired
public SpringConfig(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Bean
public MemberService memberService() {
return new MemberService(memberRepository);
}
}
package hello.hello_spring.service;
import hello.hello_spring.domain.Member;
import hello.hello_spring.repository.MemberRepository;
import hello.hello_spring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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 MemberServiceIntegrationTest {
@Autowired MemberService memberService;
@Autowired MemberRepository memberRepository;
// 테스트 코드에서는 한글로 작성해도 된다. 빌드 될 때 테스트 코드는 실제 코드에 포함 되지 않는다.
@Test
void 회원가입() {
// 추천 문법 : given (뭔가가 주어졌다.) - when (이거를 실행 했을 때) - then (결과가 이것이 나와야 해)
// given (이 데이터를 기반으로 하는 것이구나)
Member member = new Member();
member.setName("spring");
// when (이거를 검증하는구나)
Long saveId = memberService.join(member);
// then (여기가 검증부)
Member findMember = memberService.findOne(saveId).get();
assertThat(member.getName()).isEqualTo(findMember.getName());
}
@Test
public void 중복_회원_예외() {
// give
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));
// () -> memberService.join(member2) 이 로직을 실행하면, IllegalStateException 아 예외가 발생해야 함
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
// 메세지도 같은지 검증
// then
}
}
데이터베이스 파일 생성 방법
이후부터는 jdbc:h2:tcp://localhost/~/test 이렇게 접속
테이블 생성
create table member
(
id bigint generated by default as identity,
name varchar(255),
primary key (id)
);
홈화면
회원 가입 화면
회원 목록 화면
정상적인 동작이 되는 것을 알 수 있다.