봄이다. 봄이 온 기념으로 나는 백엔드 공부를 시작해본다.
김영한의 스프링 입문편으로 시작을 할 생각이다. 따라가보자.
프로젝트 설정
버전은 11버전으로 진행하라고 한다. 오라클에 들어가 11.0.25
버전을 설치해줬고, 기본 설정 버전을 변경해주었다.
인줄 알았으나, 그대로 했더니 빌드 오류가 떠서 17버전으로 바꿔주었다.
$ java -version # 현 버전 확인
$ /usr/libexec/java_home -V # 설치된 모든 jdk 버전 확인
$ vim ~/.zshrc # 환경변수를 추기하기 위해 vim 에디터 접속
export JAVA_HOME=$(/usr/libexec/java_home -v 1x.xx.xx) # vim 에디터에 환경변수 등록
source ~/.zshrc # 환경변수 등록
$ java -version # 버전 바뀌었는 지 학인
https://start.spring.io/
에서 파일을 설정하고 다운받으면 cra처럼 개발을 위해 만들어진 폴더를 받을 수 있다.
gradle
java
기반으로 프로젝트를 쉽게 빌드하고 관리할 수 있도록 도와주는 자동화도구이다.
종속성 관리, 빌드 및 배포 자동화, 유연한 설정 및 확장성 제공 등을 수행한다.
Welcome Page
static/index.html
을 작성하면 왤컴페이지를 만들 수 있다.
Controller
MVC의 Controller이고 라우팅과 로직 연결을 담당한다.
@Controller// 기본 어노테이션 설정
public class HelloController {
@GetMapping("hello") // url의 path 매칭(라우팅)
public String hello(Model model) {
model.addAttribute("data", "hello!!"); // k-v
return "hello"; // resources/templates/@ 호출 (view 호출)
}
}
HTML 템플릿
여기에도 html을 띄울 수 있다. php느낌의 thymeleaf
를 사용한다. 자바기반의 php로 이해했다.
컨트롤러에서 데이터처리를 통해 뷰를 호출하면 여기서 값을 받는다.
<!--resources/templates/hello.html-->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!--th: thymeleaf 문법-->
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Document</title>
</head>
<body>
<p th:text="'안녕하세요.' + ${data}">안녕하세요, 손님</p>
</body>
</html>
html의 문법을 사용하고, th라는 thymeleaf
문법을 사용한다.
기본적으로 java파일이 아닌 파일들은 resources
에 위치하고, 라우팅에 의해
호출되는 파일들을 templates
에 위치한다.
자동화
// build.gradle
dependencies {
compileOnly 'org.springframework.boot:spring-boot-devtools'
}
를 추가해주고, 설정-컴파일러-automake, 고급설정-automake 옵션을 켜주면 자동화가 된다고 하나, FE처럼 바로바로 반영되지는 않았다.
빌드하고 실행하기
터미널로 가서 해당 폴더로 이동한 후
./gradlew build
cd build/libs
java -jar @.jar
를 실행하면 된다.
웹 개발 기초
정적컨텐츠
만들어진 html을 그대로 반환한다.
톰캣서버가 받으면 관련 컨트롤러가 있는지 확인하고, 없으니 resources/static/*.html
을 확인하여 반환한다.
MVC와 템플릿엔진
php처럼 동작. 컨트롤러가 확인하여 view에 model을 넘긴다. 이를 템플릿엔진이 처리하여 반환한다.
@GetMapping("hello-mvc")
public String helloMvc(@RequestParam("name") String name, Model model){
model.addAttribute("name", name);
return "hello-template";
}
Controller는 viewReslover를 호출한다.
API
API를 넘겨준다.
@ResponseBody
어노테이션을 사용하면 viewResolver
대신 HttpMessageConverter
가 동작한다.
즉 view를 반환하는게 아니라 데이터를 반환한다.
HTTP의 body에 내용이 반환된다.
// 문자열을 반환
@GetMapping("hello-string")
@ResponseBody
public String helloString(@RequestParam("name") String name){
return "hello " + name;
}
//json을 반환
@GetMapping("hello-api")
@ResponseBody
public Hello helloApi(@RequestParam("name") String name){
Hello hello = new Hello();
hello.setName(setName);
return hello;
}
객체를 반환하면 spring은 디폴트로 json 형식을 반환한다.
일반적인 Web App

컨트롤러: MVC의 컨트롤러 역할
서비스: 핵심 비즈니스 로직 구현
리포지토리: 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
도메인: 비즈니스 도메인 객체. 주로 DB와 연동된다. (DTO는 주로 FE와 통신을 위해 쓰인다)
테스트
Test폴더에서 테스트를 진행할 수 있다.
@Test
public void save(){
Member member = new Member();
member.setName("spring");
repo.save(member);
Member result = repo.findById(member.getId()).get();
Assertions.assertThat(result.getName()).isEqualTo(member.getName());
}
Test 어노테이션을 활용한다.
System.out.println
을 사용할 수도 있고, Assertions.assertThat
을 사용할 수도 있는데 뭐가 더 나을지는 모르겠다.
AfterEach
MemoryMemberRepository memberRepository = new MemoryMemberRepository();
@AfterEach
public void afterEach() {
repo.clearStore();
}
여러 개의 테스트케이스를 돌릴때 독립을 보장하기 위해서는 각 테스트케이스가 끝날때마다 호출되는 콜백메서드를 선언해줘야 한다.
AfterEach
어노테이션을 활용해 설정해줄 수 있다.
DI
위 방식을 사용하면 MemoryMemberRepository
객체를 새롭게 생성한다. 정적으로 선언된 레포지토리라면 문제가 되지 않을 수 있지만 그렇지 않다면 서로다른 인스턴스를 만들어 원치 않는 동작을 야기할 수 있다.
이를 위해 DI를 사용한다. BEAN을 등록시키고, 의존성을 주입한다.
MemberService memberService; MemoryMemberRepository memberRepository;
@BeforeEach // 메서드 시작되기 전
public void beforeEach() {
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
}
@AfterEach // 메서드 시작된 후
public void afterEach() {
memberRepository.clearStore();
}
메서드가 시작되기 전 beforeEach
를 통해 객체를 주입해준다.
빈
스프링이 관리하는 객체이다. 프론트에서 import, export하는 것과 비슷한가 했는데 좀 다르다. 그거는 파일을 export하고 다른 파일에서 import 하는 것.
스프링에서는 Ioc, 빈을 등록해놓으면 스프링이 등록된 빈(객체)를 관리하며 주입이 필요한 곳, AOP가 필요한 빈 등에 권한 등을 부여할 수 있다.
빈을 등록하는 방법은 두 가지다.
1. @Service
, @Repository
, @Autowired
이용하기
// service/MemberService
package spring.study1.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import spring.study1.domain.Member;
import spring.study1.repository.MemberRepository;
import java.util.List;
import java.util.Optional;
// spring이 스프링 컨테이너에 MemberService를 등록해줌
@Service
public class MemberService {
// 저장소
private final MemberRepository memberRepository;
// spring이 MemberRepository를 스프링 컨테이너에 있는 MemberRepository를 가져다 연결시켜줌
@Autowired
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
...
}
빈을 등록하기 위해선 @Component
이 필요한데, 이는 @Service
에 내장되어있다.
컴포넌트 어노테이션이 달려있는 빈은 @ComponentScan
에 의해 읽힌다.
등록을 했으면 @Autowired
를 통해 주입해줄 수 있다.
2. 직접 빈 등록하기
@Configuration
public class SpringConfig {
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
}
@Service
, @Repository
를 사용하지 않고, 패키지 바로 아래에 SpringConfig
클래스를 만들고 @Configuration
, @Bean
을 붙여 등록해준다.
정형회된 방식, @Controller
등에서는 1번의 방법을 사용하지만,
변화무쌍하거나 DB가 정해지지 않은 경우 등에는 2번 방법을 사용해 유동적으로
변경해줄 수 있다.
이 방법을 사용하면 레포지토리를 변경한다고 해도 코드의 대부분을 건드는 게 아니라 이 SpringConfig
부분만 바꿔주면 된다.
회원등록, 조회하기
회원 등록을 위한 MemberController
를 만들어준다.
/members/new
로 GET 요청이 오면 컨트롤러에 의한 페이지를 넘기고, POST 요청이 오면 새로운 멤버객체를 만든다.
이를 위해 회원등록 컨트롤러 MemberForm
를 만들어줘야 한다. POST요청이 오면 이를 MemberForm
형식으로 받아 멤버객체를 만들어 MemberService
에 넣어준다.
또한 /member
로 요청이 오면 멤버조회를 해준다. MemberService
에 등록되어 있는 멤버들을 View
로 넘겨 html로 띄워준다.
...
//MemberController
@Controller // 스프링 컨테이너가 뜰 때 MemberController를 생성하고 관리 해줌
public class MemberController {
private final MemberService memberService;
// MemberController를 생성할 때 호출
@Autowired // spring이 memberService를 스프링 컨테이너에 있는 memberService를 가져다 연결시켜줌(의존관계 주입)
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@GetMapping("/members/new") // 회원등록
public String createForm() {
return "members/createMemberForm";
}
@PostMapping("/members/new") // 회원등록 요청
public String create(MemberForm form) { // form의 name-value 쌍을 이용했기 때문에 이렇게 사용(`@ModelAttribute` 디폴트). 만약 post의 body에 담겨있었다면 `@RequestBody`를 사용해야 한다.
Member member = new Member(); // member 객체 생성
member.setName(form.getName()); // 입력받은 정보가 담긴 form에서 정보를 꺼내 멤버객체에 저장
memberService.join(member); // member 객체로 join(회원가입)
return "redirect:/"; // 홈화면 이동
}
@GetMapping("/members") // 회원조회
public String list(Model model) { // view에 전달하기 위해 model을 사용
List<Member> members = memberService.findMembers(); // 회원 List 가져옴
model.addAttribute("members",members); // 회원 List를 model에 넣음
return "members/memberList"; // view에 전달
}
}
// MemberForm
public class MemberForm {
// createMemberForm의 input의 name 속성 값인 name과 같아야 함
private String name;
// getter
public String getName() {
return name;
}
// setter
public void setName(String name) {
this.name = name;
}
}
DB 연결
지금까지 수행한 방법은 코드를 실행하고 메모리상에서 저장하고 있었다. 하지만 이 방법은 프로그램을 종료하면 날라가기때문에 DB에 연동해서 저장해야 한다. 이를 위해 JDBC를 사용한다.
여기서 OCP, 개방-페쇄 원칙을 활용하여 클래스를 바꿔끼워준다.
h2
데이터베이스로는 h2를 사용하였다. 온라인에서 간단하게 다운로드받을 수 있고 메모리 상에서 동작하는 디비라 테스트 디비로 적합하다.
$ cd h2 # 다운로드한 h2로 이동
$ cd bin
$ chmod 755 h2.sh
$ ./h2.sh
이후 디비명, 사용자명, 비밀번호를 등록하면 웹에서 디비가 열린다.
//SpringConfig
@Bean
public MemberRepository memberRepository() {
// return new MemoryMemberRepository();
// return new JdbcMemberRepository(dataSource);
// return new JdbcTemplateMemberRepository(dataSource);
return new JpaMemberRepository(em);
}
레포지토리를 인터페이스로 만들었기 때문에, DB저장소를 변경할때 코드를 전부 바꿀 게 아니라 SpringConfig
에 있는 멤버레포지토리의 반환값만 변경해준다(즉 변경이 필요한 부품만 갈아준다).
jdbc란
java에서 데이터베이스에 접근하고 조작하기 위한 표준 API
가장 쉬운 방법으로 jdbc template을 사용한다. 순수 jdbc보다 보일러 플레이트가 적지만 sql은 직접 작성해야 한다.
@Configuration
public class SpringConfig {
// db와의 연결을 담당하는 jdbc의 db연결 객체
private final DataSource dataSource;
public SpringConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
...
@Bean
public MemberRepository memberRepository() {
return new JdbcTemplateMemberRepository(dataSource);
}
}
# build/gradle
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'
...
}
# resources/application.properties
spring.datasource.url=jdbc:h2:tcp://localhost/~/db이름
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=사용자이름
spring.datasource.password=비밀번호
//JdbcTemplateMemberRepository
public class JdbcTemplateMemberRepository implements MemberRepository {
private final JdbcTemplate jdbcTemplate;
@Autowired
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 Optional<Member> findByName(String name) {
List<Member> result = jdbcTemplate.query("select * from member where name = ?", memberRowMapper(), name);
return result.stream().findAny();
}
@Override
public List<Member> findAll() {
return jdbcTemplate.query("select * from member", memberRowMapper());
}
private RowMapper<Member> memberRowMapper() {
return (rs, rowNum) -> {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return member;
};
}
}
JPA
보일러플레이트는 물론 sql도 JPA가 작성해준다. sql, 데이터중심 설계에서 객체중심 설계로 변화한다.
# build/gradle
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
...
}
# resources/application.properties
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none
// domain/member
@Entity // JPA가 관리하는 엔티티, 이 어노테이션을 통해 JPA가 클래스를 DB와 매핑해준다.
public class Member {
@Id // pKey로 인식
@GeneratedValue(strategy = GenerationType.IDENTITY) //자동 생성
...
}
// service/memberService
@Transactional // jpa의 변경사항이 커밋되고 실패시 롤백된다
public class MemberService {
...
}
// repository/JpaMemberRepository
public class JpaMemberRepository implements MemberRepository {
// EntityManager
private final EntityManager em;
public JpaMemberRepository(EntityManager em) { // DI
this.em = em;
}
@Override
public Member save(Member member) { // JPA를 사용한 save
em.persist(member); // save기능을 제공한다. 커밋이 될때 sql UPDATE문이 적용됨
return member;
}
@Override
public Optional<Member> findById(Long id) {
Member member = em.find(Member.class, id); // pKey에 대해 사용할 수 있음. 리플렉션을 활용해 Member 클래스를 가져와 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) // pKey가 아닌 값에 대해 쿼리를 만듦
.setParameter("name",name)
.getResultList();
return result.stream().findAny();
}
@Override
public List<Member> findAll() {
return em.createQuery("select m from Member m", Member.class)
.getResultList();
}
}
스프링데이터 JPA
jdbc template의 역할처럼 자주쓰이는 CRUD, findById()등의 기본기능을 제공한다.
AOP
공통관심사항(cross-cutting concern)을 묶는다. 시간측정, 로그조회 등 모든 클래스에서 필요한 관심사항을 묶는다.
AOP를 만들게 되면 프록시가 생겨 이를 거치게 된다.
@Aspect
@Component
public class TraceAop {
@Around("execution(* hello.hello_spring..*(..))") // AOP를 사용할 위치 설정
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");
}
}
}
커스텀 훅과 비슷한가 했더니 다르다. 커스텀 훅은 공통된 로직을 빼놓고 필요한 곳에서 호출해 사용하는 것인데 AOP는 필요한 곳에서 호출하지 않고 자동으로 붙는다.
즉 핵심로직과 분리된 공통적인 관심사항들을 자동으로 결합시킨다.