반응형
2023-10-26-TIL
터미널로 빌드하는 방법(맥 기준): ./gradlew build
cd build/libs
ls -arlth
java -jar hello-spring-0.0.1-SNAPSHOT.jar
→ 서버 배포할 땐 이 jar 파일만 넣어서 실행시키면 서버에서 동작하게 된다.
build 파일을 clean하는 방법: ./gradlew clean
→ build 폴더가 사라진 것을 볼 수 있음
기존 build를 지우고 다시 build하는 방법: ./gradlew clean build
스프링 웹 개발 기초
정적 컨텐츠: 서버가 가진 파일을 그대로 웹 페이지로 뿌리는 것. 즉, 정적 파일이 그대로 사이트로 반환된다.
템플릿 엔진: 내부적인 엔진에 의해 HTML 코드가 동적으로 바뀌는 것. 이를 위해 MVC 패턴을 함께 사용한다.
기본적으로 Spring Boot는 클래스 경로의 /static(또는 /public 또는 /resources 또는 /META-INF/resources)이라는 이름의 디렉토리 또는 ServletContext의 루트에서 정적 콘텐츠를 제공한다(By default, Spring Boot serves static content from a directory called /static (or /public or /resources or /META-INF/resources) in the classpath or from the root of the ServletContext).
<!DOCTYPE HTML>
<html>
<head>
<title>static content</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
정적 컨텐츠 입니다.
</body>
</html>
main/resources/static/hello-static.html
스프링은 정적 페이지가 Controller쪽에 있는지를 먼저 살핀다(즉, 컨트롤러가 더 높은 우선순위를 갖는다). 컨트롤러에 매핑되는 게 없으면 정적 페이지를 찾아서 반환한다.
MVC: Model, View, Controller
예전에는 Controller와 View를 함께 작업했다.
View: 화면을 그려내는데에 모든 역량이 집중됨. 즉, 화면을 그려내는 역할과 책임을 가진다.
Controller: 비즈니스 로직 관련. 내부적인 처리에 집중해야 할 역할과 책임을 갖는다.
정적 페이지에 대해서, 스프링은 가장 먼저 컨트롤러로 접근한다. 만약 컨트롤러에 클라이언트 요청 페이지에 대한 처리가 있다면, 정적 페이지보다 더 높은 우선순위를 갖기 때문에 컨트롤러의 내용이 적용된다.
package hello.hellospring.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class HelloController {
...
@GetMapping("hello-mvc")
public String helloMvc(@RequestParam("name") String name, Model model) {
model.addAttribute("name", name);
return "hello-template";
}
}
위 코드에서는 hello-mvc로 접근했을 때 RequestParam 어노테이션을 통해 파라미터도 함께 입력받게 하고, 이 파라미터의 이름을 name으로, model에 attribute로 이 name을 등록하고, hello-template을 반환한다.
RequestParam의 required는 디폴트로 true다. 즉, 웹 페이지 요청 시 파라미터를 반드시 입력해야 한다.
<html xmlns:th="http://www.thymeleaf.org">
<body>
<p th:text="'hello ' + ${name}">hello! empty</p>
</body>
hello-template.html
컨트롤러에서 반환하는 대상은 hello-template이다. 즉, resources/template에서 찾아낸 위 파일을 반환한다. 이때, 템플릿 엔진인 Thymeleaf에 의해 변환된 HTML을 반환한다.
위 코드에서 'hello ' + ${name}"에 내용이 있으면 hello! empty 대신에 'hello ' + ${name}"이 들어간다.
${name}은 model로부터 key값 중 name을 꺼내어 그 value를 담는다.
package hello.hellospring.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class HelloController {
...
@GetMapping("hello-string")
@ResponseBody
public String helloString(@RequestParam("name") String name, Model model) {
return "Hello" + name;
}
}
return "Hello" + name;을 하면, 문자를 반환하는 것이기 때문에 정적 페이지를 찾지 않는다. 그냥 저 코드 자체를 리턴하기 때문에, 템플릿 엔진을 사용하지 않는 것이다. 따라서 아래와 같은 결과가 나온다.
ResponseBody 어노테이션은 HTTP 프로토콜의 응답 패킷의 body 부분에 return 이하의 내용을 바로 채우라는 의미다.
API 방식
package hello.hellospring.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class HelloController {
...
static class Hello {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
@GetMapping("hello-api")
@ResponseBody
public Hello helloApi(@RequestParam("name") String name) {
Hello hello = new Hello();
hello.setName(name);
return hello; // 문자가 아닌, 객체를 넘기는 방식
}
}
결과가 Json 포맷으로 나온다. 즉, 스프링은 ResponseBody에서 객체를 Json 포맷으로 변환하는 것이 디폴트다. Json이 아닌 다른 포맷으로의 변환도 가능한데, 실무에서도 그냥 Json 포맷을 그대로 쓴다.
ResponseBody 사용 원리
localhost:8080/hello-api → 스프링: “컨트롤러부터 보자.” → “컨트롤러에 처리 로직이 있는데, ResponseBody가 붙어있다? HttpMessageConverter에게 응답을 그대로 넘겨야겠다. 포맷은? JsonCoverter와 StringConverter가 가능한데, 응답으로 넘기는 게 문자면 StringConverter를, 객체면 JsonCoverter를 이용해서 넘기겠다.”
만약, 컨트롤러에 처리 로직이 없다면? ViewResolver에게 응답을 넘긴다. → 정적 페이지 처리 쪽으로 넘어감
일반적인 웹 애플리케이션 계층 구조
서비스: 비즈니스 로직(회원은 중복가입이 안 된다 등). 비즈니스 도메인 객체로 핵심 비즈니스 로직이 돌아가도록 구현한다.
도메인: 회원, 주문, 쿠폰과 같이 DB에 저장되고 관리되는 비즈니스 도메인 객체
리포지토리: DB 접근, DB에 도메인 객체를 저장하고 관리함
인메모리 데이터베이스 웹 애플리케이션 클래스 구조도
MembserService: 비즈니스 로직
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;
}
}
domain/Member.java
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import java.util.List;
import java.util.Optional;
public interface MemberRepository {
Member save(Member member);
Optional<Member> findMyId(Long id); // null일 수 있음. null을 Optional로 한 번 감싸서 반환받는 방법이 많이 사용된다.
Optional<Member> findByName(String name);
List<Member> findAll();
}
repository/MemberRepository.java [interface]
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import java.util.*;
public class MemoryMemberRepository implements MemberRepository{
private static Map<Long, Member> store = new HashMap<>();
// 공유로 인한 동시성 문제를 해결하기 위해 concurrentHashMap을 사용할 것을 권장함
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> findMyId(Long id) {
// return store.get(id); // null일 수 있는 경우를 처리해야 한다.
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(); // 1개라도 찾아낸다. 디폴트로 Optional로 감싸져서 나온다.
// findAny의 결과로 아무것도 없으면 Null이라서, 이를 Optional로 감싸서 반환한다.
}
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
}
repository/MemoryMemberRepository.java
작성한 위의 코드들이 실제로 동작하는지는 테스트 코드를 통해 알 수 있다.
실제로 동작하는지 여부는 main 메서드를 통해서도 알 수 있지만, 이런 방식의 경우에는 main 메서드 작성 등의 준비 시간이 오래 걸리고 여러 테스트를 동시에 진행할 수 없다.
테스트에 많이 쓰이는 것은 JUnit이고, JUnit은 프레임워크다.
테스트 코드 파일 네임 컨벤션: 테스트하고자 하는 클래스명+Test (MemoryMemberRepositoryTest)
package repository;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.Test;
class MemoryMemberRepositoryTest { // 외부에서 이 클래스로 접근하지 않기 때문에 접근 제어자가 public일 필요는 없다.
MemberRepository repository = new MemoryMemberRepository();
@Test
public void save() { // MemoryMemberRepository 객체가 가진 save 메서드를 테스트하기 위함
...
}
}
test/repository/MemoryMemberRepositoryTest.java
IDE에서 해당 파일을 그냥 실행시키면, 테스트가 된다.
테스트 결과가 성공적이면, 아래와 같이 왼쪽에 초록색의 체크 표시가 뜬다.
만약 테스트를 통과하지 못하면 아래와 같이 왼쪽에 노란색의 경고 아이콘이 뜬다.
class MemoryMemberRepositoryTest {
...
}
외부에서 테스트 클래스로 접근하지 않기 때문에 접근 제어자가 public일 필요는 없다.
import org.junit.jupiter.api.Test;
class MemoryMemberRepositoryTest {
@Test
public void save() {
...
}
@Test
public void findByName(String name) {
...
}
}
테스트를 위해선 Test 어노테이션을 붙여야 한다.
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.Test;
class MemoryMemberRepositoryTest {
MemberRepository repository = new MemoryMemberRepository();
@Test // 테스트를 위해선 Test 어노테이션을 붙여야 한다.
public void save() { // MemoryMemberRepository 객체가 가진 save 메서드를 테스트하기 위함
Member member = new Member();
member.setName("TEDD");
repository.save(member);
Member result = repository.findMyId(member.getId()).get();
// findMyId에 의해 반환되는 것은 Optional이기 때문에 get() 메서드를 사용한다.
// get() 메서드로 꺼내는 게 좋은 방법은 아니다.
// 다만, 테스트 코드에서는 어느 정도 괜찮다.
System.out.println("(result == member) = " + (result == member));
//
}
}
테스트 결과를 확인하는 방법1. sout을 이용한다.
하지만, 보통 sout으로 매번 확인하지는 않는다. 대신 assert 기능을 사용한다.
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class MemoryMemberRepositoryTest {
MemberRepository repository = new MemoryMemberRepository();
@Test
public void save() {
Member member = new Member();
member.setName("TEDD");
repository.save(member);
Member result = repository.findMyId(member.getId()).get();
Assertions.assertEquals(member, result);
}
}
Assertions.assertEquals(expected: 기대한 값, actual: 실제로 들어온 값);
테스트 결과를 확인하는 방법2. JUnit의 Assertions
Assertions의 종류엔 두 가지가 있다; JUnit의 Assertions와 assertj의 Assertions
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
class MemoryMemberRepositoryTest {
MemberRepository repository = new MemoryMemberRepository();
@Test
public void save() {
Member member = new Member();
member.setName("TEDD");
repository.save(member);
Member result = repository.findMyId(member.getId()).get();
Assertions.assertEquals(member, 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 spring1 = repository.findByName("Spring1").get();
Assertions.assertThat(spring1).isEqualTo(member1);
}
}
테스트 결과를 확인하는 방법2. assertj의 Assertions
클래스 자체를 run하면 모든 테스트가 수행된다.
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
class MemoryMemberRepositoryTest {
MemberRepository repository = new MemoryMemberRepository();
@Test
public void save() {
Member member = new Member();
member.setName("TEDD");
repository.save(member);
Member result = repository.findMyId(member.getId()).get();
Assertions.assertEquals(member, 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 spring1 = repository.findByName("Spring1").get();
Assertions.assertThat(spring1).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> all = repository.findAll();
Assertions.assertThat(all.size()).isEqualTo(2);
}
}
이 코드는 findAll() 또는 findByName()을 각각 테스트하면 성공하지만, MemoryMemberRepositoryTest 클래스를 run(즉, 전체 테스트를 동시에 테스트)하면, findByName() 에서 에러가 뜬다.
실행 순서: 테스트 메서드의 실행 순서는 일반적으로 보장되지 않는다. 위의 케이스에서는 findAll → findByName 이다. 모든 테스트는 순서와 무관하게 모두 따로 동작하도록 설계해야 한다. 즉, 테스트 케이스끼리 서로 의존관계가 없어야만 한다.
현재 코드에서는 repository를 서로 공유하기 때문에 발생하는 문제다. 한 메서드에서 사용한 데이터를 clear 해줘야 한다.
import hello.hellospring.domain.Member;
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);
store.put(member.getId(), member);
return member;
}
@Override
public Optional<Member> findMyId(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());
}
public void clearStore() {
store.clear();
}
}
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
class MemoryMemberRepositoryTest { // 외부에서 이 클래스로 접근하지 않기 때문에 접근 제어자가 public일 필요는 없다.
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach
public void afterEach() {
repository.clearStore();
}
@Test
public void save() {
Member member = new Member();
member.setName("TEDD");
repository.save(member);
Member result = repository.findMyId(member.getId()).get();
Assertions.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 spring1 = repository.findByName("Spring1").get();
Assertions.assertThat(spring1).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> all = repository.findAll();
Assertions.assertThat(all.size()).isEqualTo(2);
}
}
AfterEach 어노테이션: 특정 메서드가 끝날 때마다 실행되는 콜백 메서드. 즉, 위 코드에서는 세 개의 메서드가 각각 끝날 때마다 호출되는 콜백 메서드다.
테스트 클래스(검증 틀)를 먼저, 관련 클래스(검증 대상이 되는 것)들을 나중에 작성 → TDD
반응형
'TIL' 카테고리의 다른 글
2023-10-31-TIL (1) | 2023.11.01 |
---|---|
2023-10-30-TIL (1) | 2023.10.31 |
2023-10-28-TIL (1) | 2023.10.31 |
2023-10-27-TIL (1) | 2023.10.27 |
2023-10-24-TIL (0) | 2023.10.24 |