🙇
- 지식재산권을 고려하여 해당 시리즈 게시글이 비공개로 전환될 수 있습니다
🙇 Inflearn : 스프링 입문 - 코드로 배우는 스프링(김영한님)
📄 Github
https://start.spring.io/
스프링부트 기반으로 스프링 프로젝트 만들어주는 사이트
Project
Maven -> Gradle (요즘 추세는 Gradle)
Spring Boot : SNAPSHOT(아직 만들고 있는 버전), M1(등은 정식 릴리즈된 버전 x)
Project Metadata
Group : 기업명 등을 적어줍니다.
Artifact : 빌드되어 나오는 결과물, ≈ 프로젝트 명
Dependencies
어떤 라이브러리를 가져와서 쓸 것인지 지정해줍니다.
웹 프로젝트 만들거니까 Spring Web, Thymeleaf 등..
만들어진 파일 다운 받고 IntelliJ에서 build.gradle 실행
"Gradle은 의존관계가 있는 라이브러리를 함께 다운로드 한다."
gradle이나 maven같은 빌드 툴들은 의존 관계를 직접 관리해줍니다.
어떤 의미이냐면,
'org.springframework.boot:spring-boot-starter-web'같은 것들을 주입해주면
spring starter web에 필요한 것들을 호출해서 의존해줍니다.
필요한 의존관계들을 계속 주입하다보면 스프링 코어까지 파고들게 됩니다.
즉, gradle - Dependencies에서
spring boot starter web, thymeleaf 라이브러리만 선택했더라도
그것에 의존되어 따라오는 라이브러리들을 확인할 수 있습니다.
라이브러리의 의존관계는 깊고 다양하게 형성 되어있기 때문입니다.
이번에 사용할 스프링 부트 라이브러리는 다음과 같이 구성되어있습니다.
spring-boot-starter-web
테스트 라이브러리
스프링은 사실 자바 엔터프라이즈 개발에 대한 전반적인 기능을 다 제공하는데, (2003년 첫 릴리즈, 2017년 대규모 릴리즈)
그만큼 생태계가 어마어마하게 커서 모든 내용을 다 외우는 건 불가능에 가깝습니다.
따라서 필요한 걸 찾는 능력이 정말 !! 중요합니다.
컨트롤러란?
웹 사이트 구축에 대한 Spring의 접근 방식에서 HTTP 요청은 컨트롤러에 의해 처리됩니다.
@Controller
주석으로 컨트롤러를 쉽게 식별할 수 있습니다.
In Spring’s approach to building web sites, HTTP requests are handled by a controller.
You can easily identify the controller by the @Controller
annotation.
@Controller
public class HelloController {
@GetMapping("hello") // /hello가 파라미터로 들어오면 이 메서드를 실행해주는 어노테이션
public String hello(Model model){
model.addAttribute("data", "hello!"); // model view controller의 model
return "hello";
}
}
<body>
<p th:text="'안녕하세요. ' + ${data}" >안녕하세요. 손님</p>
</body>
th: 타임리프 엔진 "http://www.thymeleaf.org"
data의 value가 치환되어서 출력됩니다.
결과
내부적인 과정은 다음과 같습니다.
웹 브라우저에서 http://localhost:8080/hello 를 전달합니다.
스브링 부트의 내장 톰캣 서버에서 /hello를 인식하고 스프링에게 다시 전달합니다.
인식된 hello에 의해 컨트롤러에 있는 GetMapping메서드가 실행됩니다.
실행된 메서드에 의해 model객체에 "hello"라는 값을 전달합니다.
return "hello" ( return값이 resource - templates의 파일명과 같으면 그곳으로 model객체 값을 넘겨주기 때문입니다.)
return 값과 hello.html이 같으므로 실행 & model객체 전달합니다.(AttributeName: "data", Value: "hello!!")
컨트롤러에서 리턴 값으로 문자를 반환하는 것을 viewResolver라고 합니다.
뷰 리졸버가 화면을 찾아서 처리합니다
참고: spring-boot-devtools 라이브러리를 추가하면
html 파일을 컴파일만 해주는 것만으로 서버 재시작 없이 View 파일 변경이 가능합니다.
인텔리J 컴파일 방법: 메뉴 build Recompile
콘솔로 이동
윈도우 사용자를 위한 팁
정리
스프링 부트는 start.spring.io 에서 환경설정을 간편하게 할 수 있습니다.
라이브러리 살펴보기
View환경
빌드, 실행
웹을 개발하는 방법은 크게 3가지가 있습니다.
정적 컨텐츠
MVC와 템플릿 엔진
API
안드로이드와 아이폰 개발자들과도 일 해야 하는 요즈음
과거처럼 html, xml이 아니라 json 데이터 구조 포맷으로 클라이언트에게 데이터를 전달하는 방식입니다.
api로 데이터만 전달해주면 vue나 react로 화면을 클라이언트가 알아서 처리할 수 있습니다.
서버끼리 통신할 때, 불필요한 html의 교류 없이 데이터만 주고 받을 수 있습니다.
웹 브라우저에서 http://localhost:8080/hello-static.html 요청하면
내장 톰캣 서버가 요청 받고 스프링에게 hello-static 정보를 넘깁니다.
스프링은 컨트롤러가 먼저 우선순위를 가지고 일치하는 데이터 조회합니다.
관련 컨트롤러가 없을 경우,
resource로 이동해서 조회 -> resource: static/hello-static.html 일치하므로
hello-static.html을 웹 브라우저에게 반환합니다.
Model View Controller
예전엔 모델1 방식이라는 view에서 모든 것을 해결하는 방법을 사용했었지만,
지금은 관심사의 분리를 위해 각각 역할을 나누어 집중할 수 있도록 바뀌었다고 합니다.
@Controller
public class HelloController {
@GetMapping("hello-mvc")
public String helloMvc(@RequestParam("name") String name, Model model) {
model.addAttribute("name", name);
return "hello-template";
}
}
resources/template/hello-template.html
<html xmlns:th="http://www.thymeleaf.org">
<body>
<p th:text="'hello ' + ${name}">hello! empty</p>
</body>
</html>
에러가 날 때 확인해야 하는 것을 로깅이라고 합니다.
내부과정
?name = value 입력해주면 value가 컨트롤러로 전달됩니다.
모델객체를 통해 value가 생성되고 hello-template으로 반환(뷰 리졸버)되어 전달된 데이터를 통해 hello-template.html을 웹 브라우저에게 전달하게 됩니다.
웹 브라우저 - 내장 톰캣 서버 - 스프링 컨테이너(컨트롤러 -> 뷰 리졸버) -
템플릿 엔진을 통해 html 변환 - 웹 브라우저에게 응답합니다.
정적 컨텐츠는 변환 없이, mvc는 html을 변환해서 응답해주었습니다.
정적 컨텐츠를 제외하면, 두 가지만 기억하면 됩니다.
앞서 뷰를 찾아서 템플릿 엔진을 통해 화면을 렌더링해서 웹 브라우저에게 넘겨주는 방식
API를 사용해서 바로 데이터를 넘겨주는 방식
@GetMapping("hello-string")
@ResponseBody
public String helloString(@RequestParam("name") String name){
return "hello " + name;
}
@ResponseBody
html의 body가 아니라 http 통신 프로토콜 body 부분에 return 데이터를 직접 넣어주겠습니다.
파라미터를 그대로 내려주기 때문에 view가 필요없습니다.
하지만 진짜 장점은 JSON: {key: value}로 이루어진 구조,
(예전에 자주 쓰던 xml 방식에서 요즘은 json방식으로 통일되었고 훨씬 간편해짐)
객체 반환 - @ResponseBody - Json 반환하는 형식이 주로 선호된다고 합니다.
참고 클래스를 선언할 때 가능하면 필드를 private로 선언해서 보호하고 Setter와 Getter를 사용하면 안전하게 사용할 수 있습니다.
Getter and Setter: 자바 빈 표준규약, private은 외부에서 못 꺼내므로 메서드를 통해서 접근해야만 하는 구조
프로퍼티 접근 방식이라고 하기도 합니다.
@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;
}
}
웹 브라우저가 http://localhost:8080/hello-api를 스프링에 전달
톰캣 내장 서버가 hello-api를 스프링 컨테이너에 전달
스프링은 전과 달리 @ResponseBody라는 애노테이션을 전달받습니다.
컨트롤러가 데이터를 바로 넘기려고하는데 객체가 포함되어 있기 때문에
뷰 리졸버 대신 HttpMessageConverter가 작동합니다.
객체일 경우는 JsonConverter, 문자일 경우는 StringConverter가 동작합니다.
@ResponseBody 를 사용
참고: 클라이언트의 HTTP Accept 헤더와 서버의 컨트롤러 반환 타입 정보 둘을 조합해서 HttpMessageConverter 가 선택됩니다.
정리
정적컨텐츠: 파일을 그대로 전달해줍니다.
MVC와 템플릿 엔진: 템플릿 엔진을 모델 뷰 컨트롤러 방식으로 분할해서 뷰를 렌더링하고, 렌더링된 html을 클라이언트에게 전달해줍니다.
API: @ResponseBody를 사용하고 객체를 반환합니다. (객체를 반환하면 HttpMessageConverter를 통해 JSON구조를 변환됩니다),
뷰를 생략하고 HTTP Response에 데이터를 바로 전달해줍니다.
데이터: 회원ID, 이름
기능: 회원 등록, 조회
아직 데이터 저장소가 선정되지 않음(가상의 시나리오)
컨트롤러: 웹 MVC의 컨트롤러 역할
서비스: 핵심 비즈니스 로직 구현
리포지토리: 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리합니다.
도메인: 비즈니스 도메인 객체, 예) 회원, 주문, 쿠폰 등등 주로 데이터베이스에 저장하고 관리됩니다.
개발한 기능을 실행해서 테스트 할 때 자바의 main 메서드를 통해서 실행하거나, 웹 애플리케이션의 컨트롤러를 통해서 해당 기능을 실행합니다.
이러한 방법은 준비하고 실행하는데 오래 걸리고, 반복 실행하기 어렵고 여러 테스트를 한번에 실행하기 어렵다는 단점이 있습니다.
자바는 JUnit이라는 프레임워크로 테스트를 실행해서 이러한 문제를 해결합니다.
테스트 케이스의 큰 장점은, 여러 메서드, 전체 클래스, 전체 디렉토리까지 한 번에 테스트가 가능하다는 것입니다.
테스트는 서로 순서 관계없이, 의존 관계없이 작성되어야 합니다.
그러기 위해선 하나의 테스트가 끝날 때마다 공용 데이터나 저장소를 깔끔하게 지워줘야 합니다.
참고로 테스트를 먼저 만들고 후에 구현 클래스를 만드는 방식의 개발을 TDD, 테스트 주도 개발이라고 합니다.
//구현체 클래스
public class MemoryMemberRepository implements MemberRepository{
private static Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L; // 0,1,2... key값 생성
@Override
public Member save(Member member) {
member.setId(++sequence);
store.put(member.getId(), member);
return member;
}
@Override
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
@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());
}
}
class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach
public void afterEach() { // 테스트가 끝날 때마다 저장소가 지워지도록 clear()가 동작하는 메서드
repository.clearStore();
}
@Test //메서드 테스트 실행이 가능해진다
public void save(){
Member member = new Member();
member.setName("spring");
repository.save(member); // 회원이름 저장
Member result = repository.findById(member.getId()).get(); // 저장된 이름 조회
assertThat(member).isEqualTo(result);
}
@Test
public void findByName() {
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
Member result = repository.findByName("spring1").get();
assertThat(result).isEqualTo(member1);
}
@Test
public void findAll(){
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
List<Member> result = repository.findAll();
assertThat(result.size()).isEqualTo(2);
}
}
회원 저장소와 도메인을 활용해서 핵심 비즈니스 로직을 개발해보겠습니다.
public class MemberService {
private final MemberRepository memberRepository = new MemoryMemberRepository(); // 회원 저장소 객체 생성
/*
* 회원 가입
* */
public Long join(Member member){
validateDuplicateMember(member); // 중복 회원 검증
memberRepository.save(member);
return member.getId();
}
private void validateDuplicateMember(Member member) {
memberRepository.findByName(member.getName())
.ifPresent(m->{ // Optional이 제공하는 ifPresent를 사용해서 null을 확인하는 if 문을 줄일 수 있습니다.
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
}
/*
* 전체 회원 조회
* */
public List<Member> findMembers() {
return memberRepository.findAll();
}
public Optional<Member> findOne(Long memberId){
return memberRepository.findById(memberId);
}
}
클래스 Ctrl Shift T -> Test 클래스 생성
class MemberServiceTest {
MemberService memberService;
MemoryMemberRepository memberRepository;
@BeforeEach
public void beforeEach(){
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository); // 의존성 주입 defendency injection, DI
}
@AfterEach
public void afterEach() { // 테스트가 실행마다 저장소가 지워지도록 clear()가 동작하는 메서드
memberRepository.clearStore();
}
@Test
void 회원가입() {
//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 중복_회원_예외() {
//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("이미 존재하는 회원입니다.");
// try {
// memberService.join(member2);
// fail();
// }catch (IllegalStateException e){
// assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
// }
//then
}
@Test
void findMembers() {
}
@Test
void findOne() {
}
}
컴포넌트 스캔과 자동 의존관계 설정
회원 컨트롤러가 회원서비스와 회원 리포지토리를 사용할 수 있게 의존관계를 준비합니다.
생성자에 @Autowired 가 있으면 스프링이 연관된 객체를 스프링 컨테이너에서 찾아서 넣어줍니다.
이렇게 객체 의존관계를 외부에서 넣어주는 것을 DI (Dependency Injection), 의존성 주입이라 합니다.
이전 테스트에서는 개발자가 직접 주입했고, 여기서는 @Autowired에 의해 스프링이 주입해줍니다.
정리
스프링 빈을 등록하는방법은 2가지입니다.
1. 컴포넌트 스캔과 자동 의존관계 설정
- 컴포넌트 스캔: @Controller, @Service, @Repository...
사실 @Component어노테이션을 의존하는 어노테이션들입니다. 따라서 자동으로 의존적인 관계가 설정됩니다.- @Component애노테이션이 있으면 스프링 빈으로 자동 등록됩니다.
- 따라서 의존관계에 있는 컨트롤러가 스프링 빈으로 자동 등록된 이유도 컴포넌트 스캔 때문입니다.
- 참고: 스프링은 스프링 컨테이너에 스프링 빈을 등록할 때, 기본으로 싱글톤으로 등록합니다.
(유일하게 하나만 등록해서 공유합니다.)- 따라서 같은 스프링 빈이면 모두 같은 인스턴스입니다. 설정으로 싱글톤이 아니게 설정할 수 있지만, 특별한 경우를 제외하면 대부분 싱글톤을 사용합니다.
- 자바 코드로 직접 스프링 빈 등록하기
- 참고: XML로 설정하는 방식도 있지만 최근에는 잘 사용하지 않으므로 생략합니다.
- 참고: DI에는 필드 주입, setter 주입, 생성자 주입 이렇게 3가지 방법이 있습니다.
의존관계가 실행중에 동적으로 변하는 경우는 거의 없으므로 생성자 주입을 권장합니다.- 참고: 실무에서는 주로 정형화된 컨트롤러, 서비스, 리포지토리 같은 코드는 컴포넌트 스캔을 사용합니다.
- 그리고 정형화 되지 않거나, 상황에 따라 구현 클래스를 변경해야 하면 설정을 통해 스프링 빈으로 등록합니다.
주의: @Autowired 를 통한 DI는 helloConroller , memberService 등과 같이 스프링이 관리하는 객체에서만 동작합니다.
스프링 빈으로 등록하지 않고 내가 직접 생성한 객체에서는 동작하지 않습니다.
@Configuration
public class SpringConfig {
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
}
홈 화면 추가
홈 컨트롤러 추가
컨트롤러 - 뷰 리졸버에 의해 템플릿 엔진으로 렌더링된 html 파일이 뿌려짐
html파일이 어떻게 동작하는지 살펴보겠습니다.
form태그, th:each 반복문..
H2 데이터베이스를 사용하겠습니다.
테이블 관리를 위해 프로젝트 루트에 sql/ddl.sql 파일을 생성합니다.
drop table if exists member CASCADE;
create table member
(
id bigint generated by default as identity,
name varchar(255),
primary key (id)
);
H2 데이터베이스가 정상 생성되지 않을 때
다음과 같은 오류 메시지가 나오며 H2 데이터베이스가 정상 생성되지 않는 경우가 있습니다.
해결방안은 다음과 같습니다.
H2 데이터베이스를 종료하고, 다시 시작한다.
웹 브라우저가 자동 실행되면 주소창에 다음과 같이 되어있습니다.(100.1.2.3이 아니라 임의의 숫자가 나옵니다.)
다음과 같이 앞 부분만 100.1.2.3 localhost 로 변경하고 Enter를 입력합니다. 나머지 부분은 절대 변경하면 안된다. (특히 뒤에 세션 부분이 변경되면 안됩니다.)
데이터베이스 파일을 생성하면( jdbc:h2:~/test ), 데이터베이스가 정상 생성됩니다.
환경 설정
build.gradle 파일에 jdbc, h2 데이터베이스 관련 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'
스프링 부트 데이터베이스 연결 설정 추가
resources/application.properties
spring.datasource.url= jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
주의!: 스프링부트 2.4부터는 spring.datasource.username=sa 를 꼭 추가해주어야 합니다.
그렇지 않으면 Wrong user name or password 오류가 발생합니다.
참고로 다음과 같이 마지막에 공백이 들어가면 같은 오류가 발생합니다. spring.datasource.username=sa 공백 주의, 공백은 모두 제거해야 합니다.
참고: 인텔리J 커뮤니티(무료) 버전의 경우 application.properties 파일의 왼쪽이 다음 그림고 같이 회색으로 나옵니다.
엔터프라이즈(유료) 버전에서 제공하는 스프링의 소스 코드를 연결해주는 편의 기능이 빠진 것인데, 실제 동작하는데는 아무런 문제가 없습니다.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
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 JdbcMemberRepository implements MemberRepository {
private final DataSource dataSource;
public JdbcMemberRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public Member save(Member member) {
String sql = "insert into member(name) values(?)";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql,
Statement.RETURN_GENERATED_KEYS);
pstmt.setString(1, member.getName());
pstmt.executeUpdate();
rs = pstmt.getGeneratedKeys();
if (rs.next()) {
member.setId(rs.getLong(1));
} else {
throw new SQLException("id 조회 실패");
}
return member;
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
@Override
public Optional<Member> findById(Long id) {
String sql = "select * from member 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()) {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return Optional.of(member);
} else {
return Optional.empty();
}
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
@Override
public List<Member> findAll() {
String sql = "select * from member";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql);
rs = pstmt.executeQuery();
List<Member> members = new ArrayList<>();
while(rs.next()) {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
members.add(member);
}
return members;
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
@Override
public Optional<Member> findByName(String name) {
String sql = "select * from member 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()) {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return Optional.of(member);
}
return Optional.empty();
} 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);
}
}
다른 코드를 변경하지 않고 구현체를 만들어 인터페이스를 확장합니다.
스프링이 제공하는 configration만 변경합니다.
@Configuration
public class SpringConfig {
private 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 JdbcMemberRepository(dataSource); // 구현체를 만들어 인터페이스를 확장
}
}
인터페이스를 두고 구현체를 교체할 수 있습니다.
또한 이 행위가 굉장히 편리해지도록 스프링 컨테이너는 다형성의 활용을 지원하여 효과를 극대화할 수 있습니다.
Defendency Ingection 덕분에 굉장히 편리해졌다.
과거엔 이런 효과를 누리기 위해선 기존 코드를 변경해야만 했습니다.
그런데 기존의 코드는 전혀 손대지 않고,
오직 애플리케이션을 조립하는 코드(어셈블리라고 합니다)만 관리하면
실제 애플리케이션에 관련된 코드는 하나도 손댈 필요가 없습니다.
멤버서비스는 회원저장소를 의존하고 있습니다.
회원저장소는 구현체로 MemoryMemberRepository, JdbcMemberRepository 가 있습니다.
스프링 컨테이너에서 설정을 어떻게 변경했는지 살펴봐야합니다.
기존엔 memory버전의 MemberRepository를 스프링 빈으로 등록했다면,
MemoryMemberRepository를 제외시키고, Jdbc버전의 MemberRepository 를 등록했습니다.
그리고 나머진 손 댈게 하나도 없습니다. 구현체만 바뀌어서 유연하게 동작합니다.
스프링 컨테이너와 DB까지 연결한 통합 테스트를 진행해보겠습니다.
테스트 코드를 살펴보면 여태까지 스프링없이 순수 자바코드로만 진행해온걸 볼 수 있습니다.
하지만 메인코드에서 스프링과 db를 사용했으니 db에도 연결해서 통합해야 정확한 테스트 진행이 가능합니다.
@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {
@SpringBootTest : 스프링 컨테이너와 테스트를 함께 실행합니다.
@Transactional : 테스트 케이스에 이 어노테이션이 있으면, 테스트 시작 전에 트랜잭션을 시작하고, 테스트 완료 후에 항상 롤백합니다.
이렇게 하면 DB에 데이터가 남지 않으므로 다음 테스트에 영향을 주지 않습니다.
순수 자바 코드, 가장 최소한의 단위로 진행하는 것을 단위 테스트,
스프링이나 db까지 연동하는 등의 형식을 통합 테스트라고 하는데
순수한 단위 테스트가 가장 좋은 테스트일 확률이 높습니다.
우리들은 단위로 쪼개서 스프링 컨테이너 없이 테스트하는 훈련을 할 필요가 있고 그것은 좋습니다. 물론 통합 테스트가 필요할 때도 있습니다.
테스트를 잘 짜는 것은 항상 중요합니다. 아무리 잘 설계하더라도 어이없이 에러를 만나는 경우가 많기 때문입니다.
따라서 프로덕션이 커질 수록 테스트를 잘 짜는 것이 중요해집니다.
작은 버그는 수억, 수십억의 피해로 돌아옵니다.
package hello.hellospring.repository;
import ...
public class JdbcTemplateMemberRepository implements MemberRepository {
private final JdbcTemplate jdbcTemplate;
public JdbcTemplateMemberRepository(DataSource dataSource) {
jdbcTemplate = new JdbcTemplate(dataSource);
}
@Override
public Member save(Member member) {
SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
jdbcInsert.withTableName("member").usingGeneratedKeyColumns("id");
Map<String, Object> parameters = new HashMap<>();
parameters.put("name", member.getName());
Number key = jdbcInsert.executeAndReturnKey(new
MapSqlParameterSource(parameters));
member.setId(key.longValue());
return member;
}
@Override
public Optional<Member> findById(Long id) {
List<Member> result = jdbcTemplate.query("select * from member where id
= ?", memberRowMapper(), id);
return result.stream().findAny();
}
@Override
public List<Member> findAll() {
return jdbcTemplate.query("select * from member", memberRowMapper());
}
@Override
public Optional<Member> findByName(String name) {
List<Member> result = jdbcTemplate.query("select * from member where
name = ?", memberRowMapper(), name);
return result.stream().findAny();
}
private RowMapper<Member> memberRowMapper() {
return (rs, rowNum) -> {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return member;
};
}
}
package hello.hellospring;
import ...
@Configuration
public class SpringConfig {
private final DataSource dataSource;
public SpringConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
// return new MemoryMemberRepository();
// return new JdbcMemberRepository(dataSource);
return new JdbcTemplateMemberRepository(dataSource);
}
}
Jdbc의 반복되는 부분을 JdbcTemplate을 통해 줄여보았습니다.
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' //테스트 라이브러리
}
resource/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
JPA를 사용하려면 엔티티라는 것을 맵핑해야 합니다.
JPA는 인터페이스만 제공, 구현체로는 hibernate를 사용한다고 생각하면 된다고 합니다.
ORM (Object Relational mapping)
관계형 데이터베이스를 어떻게 맵핑하는지 알아보겠습니다.
domain/ member
@Entity // Jpa가 관리하는 Entity가 됨
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) // IDENTITY = db가 id를 자동생성
private Long id;
private String name;
서비스 계층에 트랜잭션 추가
@Transactional // data 저장, 변경 시에 항상 있어야 한다
public class MemberService {
참고: JPA도 스프링 만큼 성숙한 기술이기 때문에 학습해야 할 분량도 방대합니다.
스프링 부트와 JPA만 사용해도 개발 생산성이 정말 많이 증가하고, 개발해야할 코드도 확연히 줄어듭니다.
여기에 스프링 데이터 JPA를 사용하면, 기존의 한계를 넘어 마치 마법처럼, 리포지토리에 구현 클래스 없이 인터페이스 만으로 개발을 완료할 수 있습니다.
그리고 반복 개발해온 기본 CRUD 기능도 스프링 데이터 JPA가 모두 제공합니다.
스프링 부트와 JPA라는 기반 위에, 스프링 데이터 JPA라는 환상적인 프레임워크를 더하면 개발이 정말 즐거워집니다.
지금까지 조금이라도 단순하고 반복이라 생각했던 개발 코드들이 확연하게 줄어듭니다.
따라서 개발자는 핵심 비즈니스 로직을 개발하는데, 집중할 수 있습니다.
실무에서 관계형 데이터베이스를 사용한다면 스프링 데이터 JPA는 이제 선택이 아니라 필수 입니다.
주의: 스프링 데이터 JPA는 JPA를 편리하게 사용하도록 도와주는 기술입니다.
따라서 JPA를 먼저 학습한 후에 스프링 데이터 JPA를 학습해야 합니다. 앞의 JPA 설정을 그대로 사용한다.
public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository {
@Override
Optional<Member> findByName(String name);
}
공통클래스 제공을 사용하지 못하는 경우는? 인터페이스 이름을 지을 때 규칙을 지어 정해줍니다.
그럼 그것을 통해 JPQL이 적절한 쿼리를 찾습니다.
인터페이스를 잘 짓는 것 만으로 개발이 끝날 수 있습니다.
스프링 데이터 JPA 제공 기능
참고: 실무에서는 JPA와 스프링 데이터 JPA를 기본으로 사용하고, 복잡한 동적 쿼리는 Querydsl이라는 라이브러리를 사용하면 됩니다.
Querydsl을 사용하면 쿼리도 자바 코드로 안전하게 작성할 수 있고, 동적 쿼리도 편리하게 작성할 수 있습니다.
이 조합으로 해결하기 어려운 쿼리는 JPA가 제공하는 네이티브 쿼리를 사용하거나, 앞서 학습한 스프링 JdbcTemplate를 사용하면 됩니다.
스프링 DB 접근 기술 정리
- H2 데이터베이스 설치
- 순수 Jdbc 기술 사용(쿼리가 어마어마함)
- 통합 테스트 개발
- 스프링 Jdbc Teplate(반복되는 쿼리 줄이는 게 가능해집니다.)
- JPA(기본적인 CRUD의 쿼리 작성 할 필요가 거의 없어졌으나, jpql 의존이 조금 필요합니다.)
- 스프링 데이터 JPA(아예 구현 클래스 작성할 필요가 없어지고 인터페이스만으로 개발이 가능해졌습니다. 이에 따라 기능 제공 또한 많아졌습니다.)
웹 MVC 기술, DB접근 기술, ORM 기술 모두 중요합니다. 모두 사용법을 알고 사용해야 합니다.
AOP가 필요한 상황
package hello.hellospring.service;
@Transactional
public class MemberService {
/**
* 회원가입
*/
public Long join(Member member) {
long start = System.currentTimeMillis();
try {
validateDuplicateMember(member); //중복 회원 검증
memberRepository.save(member);
return member.getId();
} finally {
long finish = System.currentTimeMillis();
long timeMs = finish - start;
System.out.println("join " + timeMs + "ms");
}
}
/**
* 전체 회원 조회
*/
public List<Member> findMembers() {
long start = System.currentTimeMillis();
try {
return memberRepository.findAll();
} finally {
long finish = System.currentTimeMillis();
long timeMs = finish - start;
System.out.println("findMembers " + timeMs + "ms");
}
}
}
@Aspect // AOP 사용하려면 필요한 애노테이션
@Component
public class TimeTraceAop {
@Around("execution(* hello.hellospring2..*(..))") // 공통 관심사항을 타겟팅 해주는 방법
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
System.out.println("START: " + joinPoint.toString());
try {
return joinPoint.proceed();
} finally {
long finish = System.currentTimeMillis();
long timeMs = finish - start;
System.out.println("END: " + joinPoint.toString() + " " + timeMs + "ms");
}
}
}