java 기반 프레임워크 Spring을 이용하여 옷의 세탁 방법에 대한 정보를 관리하는 백엔드 시스템을 구현해보자.
백엔드 중심의 프로젝트이며, 프론트엔드는 기초적인 정도만 사용한다.
목차
- 요구사항 정리
- 시스템 구성의 이해
- 개발 환경 준비
- 스프링 부트를 이용한 프로젝트 생성
- 도메인 만들기
- 리포지토리 만들기
- 리포지토리 테스트
- 서비스 만들기
- 서비스 테스트
- SpringConfig 파일을 이용한 스프링 빈 등록
- 컨트롤러 및 html 구현
- 최종 테스트
요구사항 정리
먼저 시스템의 요구사항을 정리하자.
우리는 무엇을 만드는가
우리가 만들 것은, 사용자가 자신이 가지고 있는 옷의 세탁 정보를 저장하고, 조회하고, 삭제할 수 있는 웹사이트이다.
옷 정보 객체는 어떻게 생겼는가
각 옷 정보 객체는 다음의 정보를 담고 있는 것으로 정의하자.
- 옷 이름 - String
- 옷의 종류 - String
- 옷의 재질 - String
- 세탁시 적정 물 온도 - int
- 세제 종류 - String
어떤 페이지가 필요한가
필요한 페이지는 다음과 같다.
- 메인화면 (모든 옷 정보 목록을 확인할 수 있는 리스트를 표시)
- 새 옷 정보를 등록하는 화면
- 각 옷의 세부화면 (삭제 버튼이 있다)
시스템 구성의 이해
일반적 웹 애플리케이션의 구성
일반적인 웹 애플리케이션은 다음과 같이 구성되어있다.
화살표는 의존관계를 나타낸다. A->B 이면 A가 B에게 의존적이라는 뜻이다.
각 구성요소를 쉽게 설명하자면 다음과 같다.
도메인은 데이터가 어떻게 생겼는지 그 구성을 정의하는 역할을 한다.
리포지토리는 DB에 직접 접촉하는 놈이다. 데이터를 넣고 빼고 수정하는 등 데이터와 관련된 기본적인 기능을 구현해놓은 것을 리포지토리라 한다.
서비스는 리포지토리 기능들을 가져다가 짜고 붙이고 조합하여 사용자가 진짜 쓸 법한 제대로 된 기능을 구현해놓은 것을 가리킨다. 예를 들면 이렇다. 리포지토리에다가 '회원 정보를 DB에 저장하기', '회원 정보를 DB에서 삭제하기', '회원 정보를 DB에서 조회하기' 등의 기능들을 구현한다. 그 후 이들을 이용하여 서비스에다가 '회원가입', '로그인', '회원 탈퇴' 등의 기능들을 완성시킨다. 이처럼 서비스가 리포지토리의 기능들을 가져다 쓰므로, 서비스는 리포지토리에 의존적이다.
컨트롤러는 말 그대로, 웹사이트의 각 페이지마다 알맞는 기능을 작동시키기 위한 각각의 실행 버튼들을 모아놓은 리모컨이라고 보면 된다. 어떤 url에 어떤 기능이 연결되는지 이 컨트롤러 코드에서 매핑을 한다. 실제로 스프링 프로젝트를 실행시켜서 특정 url에 접속을 하면, 스프링의 내장 톰캣 서버는 그 url에 들어갔을 때 해야 하는 행동을 정의해둔 컨트롤러가 있는지부터 샅샅이 뒤진다. 맞는 컨트롤러를 찾아내면 그 컨트롤러가 시키는 대로 행동을 하는 것이고, 찾지 못한다면 플랜 B (url과 동일한 이름을 가진 html 파일이 있는지 뒤져보는 등) 를 수행한다. 실행 시 가장 우선적으로 찾는 것은 바로 컨트롤러라는 것이다! 이렇게 시스템의 모든 기능으로 들어가는 첫 관문의 역할을 하므로 컨트롤러는 시스템의 다른 구성요소들에 의존적이다.
인터페이스를 사용하는 이유
우리는 리포지토리를 인터페이스로 구현해놓고,
그 인터페이스를 implement한 구현체들을 만들어 사용할 것이다.
이는 저장소를 자유자재로 변경할 수 있도록 하기 위해서다.
이 프로젝트에서는 편리하고 빠른 테스트를 위하여 그냥 개인 PC의 메모리 공간을 이용해 데이터를 저장할 것이다. 이렇게 하면 편하긴 하지만, 프로그램을 껐다 켤 때마다 데이터가 날아가 초기화된다.
그래서, 언제든 제대로 된 데이터베이스로 편리하게 갈아끼울 수 있도록 준비해놓는 것이 좋다.
아래의 그림을 보자.
우리가 설계할 서비스와 리포지토리의 모습이다.
리포지토리 인터페이스가 있고,
서비스는 그 인터페이스에 의존한다.
인터페이스의 알맹이를 구현한, 즉 인터페이스를 implement한 구현체 두 개가 있다.
만약 인터페이스를 안 쓰면 어떻게 될까?
즉, ClothingService가 아래처럼 직빵으로 실제 클래스에 의존한다면 어떻게 될까?
리포지토리를 갈아끼우기 위해서는 ClothingService의 코드도 함께 수정해야 하는 상황이 발생한다.
그래서 인터페이스를 쓰는 것이다.
인터페이스가 있으면, ClothingRepository를 implement한 구현체들끼리 우리 마음대로 막 갈아끼울 수 있게 된다.
ClothingService가 ClothingRepository에만 의존하기 때문에, 실제 구현체를 몇 번을 갈아끼우든 ClothingService가 알 바가 아니다. 따라서 새 리포지토리 코드에 맞게 ClothingService의 코드까지 죄다 수정해야 한다는 걱정을 할 필요가 없어진다!
개발 환경 준비
이 프로젝트를 개발할 때에 내가 사용한 프로그램 및 환경은 다음과 같다.
사용 IDE: IntelliJ
다운로드 링크: https://www.jetbrains.com/idea/download/
Java version: 17
다운로드 링크: https://www.oracle.com/java/technologies/downloads/#java17
템플릿 엔진: Thymeleaf
운영체제: Windows
스프링 부트를 이용한 프로젝트 생성
스프링은 정말 좋은 프레임워크이지만, 단점이 있다. 프로젝트를 처음 시작할 때 준비해야 하는 사항이 너무 많다는 것이다.
하지만 걱정할 필요 없다! 고마운 선배 개발자들이 '스프링 부트'라는 것을 만들어, 스프링 프로젝트 준비를 클릭 몇 번만으로 매우 쉽게 끝낼 수 있게 해놓았다.
스프링 부트를 쓰기 위해서는 다음의 사이트로 들어가야 한다.
바로 이곳이다.
다음과 같이 설정해주자.
Project
Gradle - Groovy
Language
Java
Spring Boot
뒤에 괄호가 붙지 않은 버전 중 가장 최근의 것 (위의 캡처에서는 3.0.0)을 선택한다. 뒤에 괄호가 붙은 것들은 아직 개발 중인 버전 또는 정식 배포가 되지 않은 버전이다.
Project Metadata
Group은 자신의 기업이 사용하는 도메인 등을 적는 곳이다. 우리는 그런 게 없으니 아무 단어나 편하게 적어주면 된다.
Artifact와 Name은 프로젝트 이름과 유사한 것이라고 보면 된다. 역시 원하는 대로 적어준다.
나머지 metadata는 건드리지 않는다.
다 적은 뒤의 모습은 다음과 같다.
이제 Dependencies를 추가한다.
오른쪽 위의 ADD DEPENDENCIES...를 클릭한다.
여기서 Spring Web 과 Thymeleaf 를 검색하여 선택해준다.
아래는 선택을 마친 모습이다.
여기까지 해주었으면 끝이다. 하단의 GENERATE를 눌러 우리 프로젝트의 초석이 될 코드 덩어리를 다운받아준다. zip파일이 다운로드될 것이다.
받은 zip파일의 압축을 풀어 본인 PC의 원하는 위치에 넣어준다.
그리고 그 폴더를 IntelliJ에서 열어준다.
폴더를 열고 나면, 위의 캡처들과 같이 IntelliJ가 뭔가 바쁘게 움직이기 시작한다.
프로젝트 시작을 위한 준비를 자동으로 해주고 있는 것이므로, 모든 준비가 끝나 잠잠해질 때까지 기다려주자. 몇 분 정도 걸린다.
이 ClothingDemoApplication.java 파일이, 모든 실행의 출발점이 되는 코드이다.
이 코드의 클래스 시작부분(7번째 줄), main 메소드 시작부분(9번째 줄), 오른쪽 위의 벌레 모양 아이콘 옆에 있는 재생 버튼들 중 하나를 눌러 이 코드를 실행할 수 있다. 그렇게 이 코드를 실행하면 우리가 만든 스프링 프로젝트가 실행되는 것이다.
만약 아무리 살펴봐도 초록색 재생 버튼이 없고, 창 상단에 이런 Project JDK is not defined라는 경고문이 떠있다면, 오른쪽의 Setup SDK를 누른 뒤 표시되는 것을 선택하여주면 된다.
초록 재생 버튼을 한 번 눌러보자.
아래에 Run 창이 뜬다. Run 창의 내용을 읽어보면, 실행이 잘 되었고, 8080 포트를 통해 실행되고 있음을 알 수 있다.
우리가 아직 아무 코딩도 하지 않았으므로 실행만 될 뿐 아무 일도 일어나지 않는다.
잘 실행되는 걸 확인했으니 빨간 네모 버튼을 눌러 정지해주자.
이제 코딩을 시작할 준비가 끝났다.
도메인 만들기
이제 도메인을 만들어보자.
src>main>java>hello.clothingdemo 아래에 domain이라는 패키지를 만들고, 그 안에 Clothing이라는 java 클래스를 만든다.
일단 내용을 아래와 같이 채워보자.
package hello.clothingdemo.domain;
public class Clothing {
private Long id; //각 객체 구분을 위한 고유 ID값
private String name; //이름
private String category; //분류
private String material; //재질
private int temp; //세탁시 적정 물 온도
private String det; //세제 종류
}
위의 요구사항 정리 부분에서 옷 객체를 어떻게 설계했는지 기억 나는가?
- 옷 이름 - String
- 옷의 분류 - String
- 옷의 재질 - String
- 세탁시 적정 물 온도 - int
- 세제 종류 - String
이것을 변수로 옮겨 적은 것이다.
이제 이 변수들에 접근하게 해주는 메소드들을 만들자.
이 클래스의 객체를 만드는 생성자를 만들어야 하고,
변수의 값을 정하는 setter 함수와 변수의 값을 읽어오는 getter 함수를 각 변수 하나하나마다 만들어줘야 한다. 변수가 6개니까 getter와 setter를 합하면 총 12개이다.
딱 봐도 귀찮아보이는 노가다 작업인데, 이 때 쓸 만한 IntelliJ의 개꿀 단축키가 있다.
바로 Generate 단축키인 Alt + Insert이다.
클래스 내의 아무 데에나 커서를 두고 Alt + Insert를 누르면 아래와 같이 자주 쓰는 구성요소들의 목록이 뜬다.
생성자부터 만들어주자. Constructor부터 누른다.
위와 같은 창이 뜰 것이다.
클래스 내에 선언된 변수들의 목록이 쫙 뜨는데, 이 중 객체 생성 시에 받아올 매개변수들을 선택해준다.
여러 개를 선택하려면 Ctrl을 누르고 클릭해주면 된다.
나는 새 옷 정보를 저장할 때에 사용자가 무조건 모든 값을 다 입력하도록 할 것이기 때문에,
사용자가 적지 않는 값인 id를 제외한 나머지 모든 변수를 선택한다.
이렇게 선택하고 OK를 클릭하면 자동으로 생성자가 만들어진다.
12개의 getter와 setter도 똑같이 클릭 몇 번 만에 만들어보자.
Getter and Setter를 누른다.
이번엔 모든 변수를 선택해준다.
모두 선택했으면 OK를 누른다.
그럼 또 마법처럼 20개의 getter와 setter 함수가 한순간에 생겨난다.
완성된 ClothingDomain 코드는 다음과 같다.
package hello.clothingdemo.domain;
public class Clothing {
private Long id; //각 객체 구분을 위한 고유 ID값
private String name; //이름
private String category; //종류
private String material; //재질
private int temp; //세탁시 적정 물 온도
private String det; //세제 종류
public Clothing() {//변수 값을 모를 때 미리 선언만 해두고 나중에 값을 넣을 수 있도록, 매개변수 없는 생성자도 준비해둠
}
public Clothing(String name, String category, String material, int temp, String det) {
this.name = name;
this.category = category;
this.material = material;
this.temp = temp;
this.det = det;
}
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;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getMaterial() {
return material;
}
public void setMaterial(String material) {
this.material = material;
}
public int getTemp() {
return temp;
}
public void setTemp(int temp) {
this.temp = temp;
}
public String getDet() {
return det;
}
public void setDet(String det) {
this.det = det;
}
}
리포지토리 만들기
리포지토리를 만들 차례이다.
인터페이스 만들기
시스템 구성의 이해 부분에서 말했듯이, 우리는 냅다 리포지토리 클래스부터 만드는 것이 아니라
인터페이스부터 만든 후, 그 인터페이스를 구현한 구현체 클래스를 만들 것이다.
그럼 가장 먼저 인터페이스부터 만들자.
src>main>java>hello.clothingdemo 아래에 repository라는 패키지를 만들고 그 안에 ClothingRepository라는 인터페이스를 만든다.
우리에게 필요한 리포지토리 기능은
- 새로운 옷 객체를 DB에 저장하고, 어떤 객체를 저장했는지 확인할 수 있도록 해당 Clothing 객체를 리턴하는 기능
- 저장된 옷 객체를 ID를 기준으로 조회하여 Clothing 객체를 리턴하는 기능
- 저장된 옷 객체를 이름을 기준으로 조회하여 Clothing 객체를 리턴하는 기능
- 모든 옷 객체의 리스트를 리턴하는 기능
- 특정 ID의 옷 객체를 DB에서 삭제하는 기능
이렇게 다섯 가지이다.
그러므로 다음과 같이 인터페이스를 만들어준다.
package hello.clothingdemo.repository;
import hello.clothingdemo.domain.Clothing;
import java.util.List;
import java.util.Optional;
public interface ClothingRepository {
Clothing save(Clothing clothing);
Optional<Clothing> findById(Long id);
Optional<Clothing> findByName(String name);
List<Clothing> findAll();
void delete(Long id);
}
Optional<> 은 리턴값이 null일 수도 있는 함수의 리턴값을 감싸주는 Wrapper 클래스이다.
사용자가 특정 이름을 가진 옷을 검색하였는데, 그런 이름의 옷이 DB에 존재하지 않을 수도 있다. 그럼 null이 반환된다. 그럼 오류가 발생할 수 있다.
이런 경우에 오류의 부담을 줄여주는 것이 Optional<>이다.
구현체 만들기
인터페이스만 가지고는 프로그램이 작동할 수 없다. 인터페이스를 구체화한 구현체 클래스를 만들어야 한다.
인터페이스와 동일한
src>main>java>hello.clothingdemo>repository 안에 MemoryClothingRepository라는 java 클래스를 만든다.
저장소의 역할을 할 HashMap과
각 객체에 순서대로 ID를 부여할 때에 사용될 변수 sequence를 선언한다.
package hello.clothingdemo.repository;
import hello.clothingdemo.domain.Clothing;
import java.util.HashMap;
public class MemoryClothingRepository {
private HashMap<Long, Clothing> store = new HashMap<>();
private long sequence = 0L;
}
MemoryClothingRepository 뒤에 implements ClothingRepository를 붙여준다.
함수 구현은 다음과 같이 해준다.
package hello.clothingdemo.repository;
import hello.clothingdemo.domain.Clothing;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
public class MemoryClothingRepository implements ClothingRepository{
private HashMap<Long, Clothing> store = new HashMap<>();
private long sequence = 0L;
@Override
public Clothing save(Clothing clothing) {
clothing.setId(++sequence); //Clothing 객체에 ID값 부여
store.put(clothing.getId(), clothing); //해시맵에 저장
return clothing; //무슨 객체를 저장했는지 리턴
}
@Override
public Optional<Clothing> findById(Long id) {
return Optional.ofNullable(store.get(id)); //id를 기준으로 해시맵에서 객체를 찾아와 리턴
}
@Override
public Optional<Clothing> findByName(String name) {
return store.values().stream() //해시맵에 있는 모든 값(Clothing 객체)들 중에서
.filter(clothing -> clothing.getName().equals(name)) //이름이 name과 일치하는 것이
.findAny(); //하나라도 존재하면 그 Clothing 객체를 리턴
}
@Override
public List<Clothing> findAll() {
return new ArrayList<>(store.values()); //해시맵에 있는 모든 Clothing 객체들의 ArrayList를 리턴
}
@Override
public void delete(Long id) {
store.remove(id); //해시맵에 있는 객체들 중 특정 id 값을 가진 데이터를 삭제
}
//리포지토리 테스트 시에, 매 테스트마다 데이터베이스를 초기화하기 위해 사용할 함수
public void clearStore() {
store.clear(); //데이터베이스를 싹 비워줌
}
}
내 PC의 저장공간을 사용하는 리포지토리가 완성되었다.
리포지토리 테스트
만든 리포지토리가 잘 작동하는지 테스트해보자.
여기서 테스트의 대상은 ClothingRepository 인터페이스가 아니라 MemoryClothingRepository 클래스이다.
프로젝트를 만들 때에는 이렇게 각 요소를 제때 테스트하고 다음 개발로 넘어가야 한다.
지금 버그를 잡지 못하고 다음 개발로 넘어가면 후에 더욱 힘들고 번거롭게 수정을 해야 한다.
테스트 준비
src>test>java>hello.clothingdemo 아래에 repository라는 패키지를 만들고 MemoryClothingRepositoryTest라는 java 클래스를 만든다.
만들었으면 아래와 같이 테스트의 대상이 될 객체를 만들어둔다.
package hello.clothingdemo.repository;
public class MemoryClothingRepositoryTest {
MemoryClothingRepository repo = new MemoryClothingRepository();
}
테스트 설계
테스트 설계 시 꼭 지켜야 하는 틀 같은 것은 없다. 테스트 대상의 모든 주요 기능이 잘 작동하는지 확인할 수 있도록 설계하기만 하면 된다.
나는 다음과 같이 테스트를 해보겠다.
테스트케이스 1
Clothing 객체 A를 DB에 저장한다. (save()를 사용)
A의 ID 값을 기준으로 DB에서 데이터를 조회해온다. (findById()를 사용)
A의 데이터 값들과 findById() 를 통해 가져온 Clothing 객체의 데이터 값들이 서로 같은지 확인한다.
→ 이 테스트케이스에서는 findById(), save() 가 잘 작동하는지 확인할 수 있다.
테스트케이스 2
Clothing 객체 A를 DB에 저장한다. (save()를 사용)
DB에서 A와 같은 이름을 가진 객체를 찾아온다. (findByName()을 사용)
A의 데이터 값들과 findByName() 을 통해 가져온 Clothing 객체의 데이터 값들이 서로 같은지 확인한다.
→ 이 테스트케이스에서는 findByName(), save() 가 잘 작동하는지 확인할 수 있다.
테스트케이스 3
2개의 Clothing 객체를 DB에 저장한다. (save()를 사용)
DB에 저장된 데이터들을 모두 가져온다. (findAll()을 사용)
가져온 데이터의 갯수가 2개가 맞는지 확인한다.
→ 이 테스트케이스에서는 findAll(), save() 가 잘 작동하는지 확인할 수 있다.
테스트케이스 4
Clothing 객체 A를 DB에 저장한다 (save()를 사용)
A와 같은 ID를 가진 Clothing 객체를 DB에서 삭제한다. (delete()를 사용)
DB에서 A와 같은 ID를 가진 Clothing 객체를 찾아보았을 때 그 결과값이 비어있는지 확인한다. (findById()를 사용)
→ 이 테스트케이스에서는 save(), delete(), findById() 가 잘 작동하는지 확인할 수 있다.
테스트 코드 작성
특정 작업을 수행한 뒤 그 결과값을 콘솔에 문자열로 프린트해서 올바른 결과값이 도출되었는지 내 눈으로 확인하는 것도 테스트라고 할 수 있다.
그러나 우리는 출력문을 사용하지 않을 것이다.
그 대신, 내가 요청한 행동을 올바르게 수행했는지 아닌지를 Yes or No로 알려주는, 편리한 테스트 전용 라이브러리를 사용할 것이다.
바로 Assertions이다.
junit에서 제공하는 Assertions가 있고 assertj에서 제공하는 Assertions가 있다.
둘이 이름은 같지만 사용법이 조금 다르므로 헷갈리지 말자.
둘 중 어느 라이브러리를 사용할지는 취향 문제이다. 지금 하는 리포지토리 테스트에서는 assertj에서 제공하는 Assertions를 사용하겠다.
junit의 Assertions를 편리하게 사용하기 위해 다음과 같은 import문을 잊지 말고 추가해주자.
import static org.junit.jupiter.api.Assertions.*;
이제 설계해준 대로 다음과 같이 테스트 코드를 작성한다.
package hello.clothingdemo.repository;
import hello.clothingdemo.domain.Clothing;
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.*;
public class MemoryClothingRepositoryTest {
MemoryClothingRepository repo = new MemoryClothingRepository();
@AfterEach
public void afterEach() {
repo.clearStore();
}
@Test
public void test1() {
Clothing a = new Clothing(
"A", "셔츠", "면", 30, "중성세제"
);
repo.save(a); //a를 DB에 저장
Clothing found = repo.findById(a.getId()).get(); //조회
assertThat(found).isEqualTo(a); //found의 데이터 값이 a와 동일한지 확인
}
@Test
public void test2() {
Clothing a = new Clothing(
"A", "셔츠", "면", 30, "중성세제"
);
repo.save(a); //a를 DB에 저장
Clothing found = repo.findByName(a.getName()).get(); //조회
assertThat(found).isEqualTo(a); //found의 데이터 값이 a와 동일한지 확인
}
@Test
public void test3() {
Clothing a = new Clothing(
"A", "셔츠", "면", 30, "중성세제"
);
Clothing b = new Clothing(
"B", "바지", "데님", 40, "중성세제"
);
repo.save(a); //a와 b를 DB에 저장
repo.save(b);
List<Clothing> all = repo.findAll(); //DB에 들어있는 모든 데이터 조회
assertThat(all.size()).isEqualTo(2); //모든 데이터의 개수가 2개가 맞는지 확인
}
@Test
public void test4() {
Clothing a = new Clothing(
"A", "셔츠", "면", 30, "중성세제"
);
repo.save(a); //a를 DB에 저장
repo.delete(a.getId()); //a와 같은 ID를 가진 객체를 DB에서 삭제
Optional<Clothing> found = repo.findById(a.getId()); //조회
assertThat(found).isEqualTo(Optional.empty()); //조회 결과값이 비어있는지 확인
}
}
위쪽의 afterEach() 함수는, 각각의 테스트 함수 하나하나가 끝날 때마다 수행될 작업을 명시한다.
함수 머리에 @AfterEach 만 붙여주면, 스프링이 알아서 매 테스트 함수가 끝날 때마다 afterEach()를 자동으로 반복 실행시켜준다.
afterEach() 함수의 내용을 보면, 데이터베이스의 내용물을 싹 지워버리는 clearStore()을 실행시키고 있다.
따라서 이 테스트 코드에서는
test1()을 끝내고 DB 초기화
test2()를 끝내고 DB 초기화
...
이런 식으로 모든 각각의 테스트 함수가 끝날 때마다 매번 DB 초기화가 일어난다.
테스트 실행
아래 캡처와 같이 파일명에 대고 마우스 오른쪽 버튼을 클릭한 후 Run 'MemoryClothingRepositoryTest'를 눌러 이 테스트 코드 파일 안에 있는 4개의 테스트를 한꺼번에 돌릴 수 있다.
실행을 시키면 하단에 다음과 같은 결과창이 표시된다.
좌측을 보면 4개의 테스트에 모두 초록 체크가 떴으며
우측 콘솔에는 아무런 경고 메시지가 없다.
이러면 테스트가 모두 잘 통과된 것이다. 우리의 리포지토리에는 문제가 없다.
서비스 만들기
사용자가 진짜로 쓰는 기능을 구현한 코드인, 서비스를 만들 차례이다.
src>main>java>hello.clothingdemo 아래에 service라는 패키지를 만들고 그 안에 ClothingService 라는 java 클래스를 만든다.
서비스 설계
서비스를 설계할 때에는, 사용자가 우리의 웹사이트에서 어떤 행동을 하는지를 생각해보고 그것을 설계하면 된다.
우리에게 필요한 서비스 기능은 다음과 같다.
- 모든 옷 정보를 확인
- 새 옷 정보를 추가
- 특정 옷 정보를 삭제
- ID를 기준으로 특정 옷 정보 조회 (옷 별 상세페이지로 넘어갈 때 필요)
서비스 코드 작성
다음과 같이 세 개의 서비스 메소드가 포함된 서비스 코드를 작성한다.
package hello.clothingdemo.service;
import hello.clothingdemo.domain.Clothing;
import hello.clothingdemo.repository.ClothingRepository;
import java.util.List;
import java.util.Optional;
public class ClothingService {
private final ClothingRepository repo;
public ClothingService(ClothingRepository repo) {
this.repo = repo;
}
/*
모든 옷 정보를 확인
*/
public List<Clothing> showAll() {
return repo.findAll();
}
/*
새 옷 정보를 추가
*/
public Long add(Clothing clothing) {
//이름 중복 검사
Optional<Clothing> search = repo.findByName(clothing.getName());
if(!search.isEmpty())
throw new IllegalStateException("이미 존재하는 이름입니다.");
repo.save(clothing); //중복 검사를 통과했을 경우 저장
return clothing.getId();
}
/*
특정 옷 정보를 삭제
*/
public Long remove(Long id) {
//해당 ID를 가진 객체가 DB에 존재하는지 검사
if(repo.findById(id).equals(Optional.empty()))
throw new IllegalStateException("존재하지 않는 옷이므로 삭제할 수 없습니다.");
repo.delete(id); //존재함이 확인되었을 경우 삭제
return id;
}
/*
ID를 기준으로 특정 옷 정보 조회
*/
public Optional<Clothing> findOne(Long id) {
return repo.findById(id);
}
}
서비스 테스트
서비스를 테스트해보자.
src>test>java>hello.clothingdemo 아래에 service라는 패키지를 만들고 그 안에 ClothingServiceTest라는 java 클래스를 만든다.
테스트 설계
테스트를 할 때에는, 예외의 경우에 코드가 잘 작동하는지도 잊지 말고 살펴야 한다.
따라서 다음과 같이 4개의 테스트가 필요하다.
- 새 옷 정보가 정상적으로 추가되는 경우를 테스트
- 새 옷 정보를 추가하려 시도하였으나 이미 겹치는 이름의 옷 정보가 있는 경우, 예외가 잘 throw되는지 테스트
- 특정 옷을 정상적으로 삭제하는 경우를 테스트
- 특정 옷을 삭제하려 하였으나 그런 옷은 DB에 없는 경우, 예외가 잘 throw되는지 테스트
모든 옷의 정보를 가져오는 서비스인 showAll()에는 findAll() 메소드를 호출하는 것 외에 다른 코드가 전혀 없고, findAll() 메소드는 리포지토리 테스트 때 이미 테스트를 마쳤으므로 showAll()의 테스트는 굳이 할 필요 없다.
findOne()도 동일한 이유로 테스트를 생략한다.
테스트 코드 작성
이번에는 junit의 Assertions와 assertj의 Assertions를 모두 사용할 것이므로 다음과 같이 두 줄의 import문을 적어준다.
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
테스트 코드는 다음과 같이 작성해준다.
package hello.clothingdemo.service;
import hello.clothingdemo.domain.Clothing;
import hello.clothingdemo.repository.ClothingRepository;
import hello.clothingdemo.repository.MemoryClothingRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
public class ClothingServiceTest {
ClothingRepository repo; //service 객체를 선언하는 데 필요한 repository 객체
ClothingService service; //이번 테스트 대상인 service 객체
/*
각각의 테스트 메소드 실행 전에 repository와 service를 모두 새롭게 선언
*/
@BeforeEach
public void beforeEach() {
repo = new MemoryClothingRepository();
service = new ClothingService(repo);
}
/*
정상적으로 옷 정보 추가가 잘 되는지 + findOne()이 잘 작동하는지 테스트
*/
@Test
void add() {
Clothing a = new Clothing(
"A", "셔츠", "면", 30, "중성세제"
);
service.add(a); //a를 데이터베이스에 추가
Clothing found = service.findOne(a.getId()).get(); //a의 ID를 기준으로 찾아오기
assertThat(found).isEqualTo(a); //찾아온 결과물과 a의 데이터 값이 같은지 확인
}
/*
이름 중복으로 인해 옷 정보 추가에 실패한 경우, 예외 throw가 잘 되는지 확인
*/
@Test
void addEx() {
//name 값이 동일한 두 개의 Clothing 객체 선언
Clothing a = new Clothing(
"A", "셔츠", "면", 30, "중성세제"
);
Clothing b = new Clothing(
"A", "바지", "데님", 50, "중성세제"
);
service.add(a); //두 객체 중 하나를 데이터베이스에 추가
//나머지 하나를 추가하려 할 때 IllegalStateException이 throw되는지 확인
IllegalStateException e = assertThrows(IllegalStateException.class, () -> service.add(b));
//그리고 그 Exception의 메시지가 "이미 존재하는 이름입니다."가 맞는지 확인
assertThat(e.getMessage()).isEqualTo("이미 존재하는 이름입니다.");
}
/*
정상적으로 옷 정보 삭제
*/
@Test
void delete() {
Clothing a = new Clothing(
"A", "셔츠", "면", 30, "중성세제"
);
service.add(a); //a를 DB에 저장
service.remove(a.getId()); //a와 같은 ID를 가진 객체를 DB에서 삭제
Optional<Clothing> found = service.findOne(a.getId()); //조회
assertThat(found).isEqualTo(Optional.empty()); //조회 결과값이 비어있는지 확인
}
/*
없는 옷 정보를 삭제하려 하여 옷 정보 삭제에 실패한 경우, 예외 throw가 잘 되는지 확인
*/
@Test
void deleteEx() {
//데이터베이스가 비어있는 상황에서 삭제를 시도할 때 IllegalStateException이 throw되는지 확인
IllegalStateException e = assertThrows(IllegalStateException.class, () -> service.remove(1L));
//그리고 그 Exception의 메시지가 "존재하지 않는 옷이므로 삭제할 수 없습니다."가 맞는지 확인
assertThat(e.getMessage()).isEqualTo("존재하지 않는 옷이므로 삭제할 수 없습니다.");
}
}
실행해보면 아래와 같이 4개의 테스트케이스 모두 성공한다.
SpringConfig 파일을 이용한 스프링 빈 등록
도메인, 리포지토리, 서비스를 모두 완성했다.
우리는 이 코드들 중 어떤 놈이 어떤 놈이 서비스이고, 어떤 놈이 리포지토리이고, 어떤 놈이 도메인인지 알고 있다. 그리고 그들 사이의 연결관계도 알고 있다.
그러나 스프링은 알지 못한다.
따라서 스프링에게 서비스와 리포지토리를 직접 알려주는 과정이 필요하다.
그 방법에는 여러 가지가 있는데, 이 프로젝트에서는 SpringConfig 파일을 이용하는 방법을 사용하겠다.
src>main>java>hello.clothingdemo 안에 SpringConfig라는 java 클래스를 만든다.
파일의 내용은 아래와 같이 채운다.
package hello.clothingdemo;
import hello.clothingdemo.repository.ClothingRepository;
import hello.clothingdemo.repository.MemoryClothingRepository;
import hello.clothingdemo.service.ClothingService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SpringConfig {
@Bean
public ClothingService clothingService() {
return new ClothingService(clothingRepository());
}
@Bean
public ClothingRepository clothingRepository() {
return new MemoryClothingRepository();
}
}
SpringConfig 클래스의 머리에는 @Configuration 을 붙여, 스프링이 이 코드가 Configuration을 위한 코드임을 알도록 한다.
SpringConfig 클래스 내부의 메소드들에는 @Bean 이라는 어노테이션을 붙인다.
스프링을 실행시켰을 때 불려나가 일을 하는 각 구성요소들을 통틀어 bean이라고 부른다. @Bean은 이 메소드들이 리턴하는 객체들이 바로 그 bean에 해당한다는 사실을 스프링에게 알려주는 역할을 한다.
컨트롤러 및 html 구현
이제 마지막 핵심 부분인 컨트롤러를 만들 차례이다.
각 화면별로 만들어볼 것이다.
메인화면용 컨트롤러 및 html 구현
사용자가 올린 모든 옷 정보의 목록을 출력하는 메인화면부터 구현해보겠다.
src>main>java>hello.clothingdemo 아래에 controller라는 패키지를 만들고 그 안에 MainController라는 java 클래스를 만든다.
내용은 아래와 같이 적는다.
package hello.clothingdemo.controller;
import hello.clothingdemo.domain.Clothing;
import hello.clothingdemo.service.ClothingService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.List;
@Controller
public class MainController {
private final ClothingService clothingService;
@Autowired
public MainController(ClothingService clothingService) {
this.clothingService = clothingService;
}
@GetMapping("/")
public String main(Model model) {
List<Clothing> list = clothingService.showAll();
model.addAttribute("list", list);
return "main";
}
}
@Controller는 이것이 컨트롤러라는 사실을 스프링에게 알려주는 역할을 한다.
@Autowired는 MainController와 ClothingService 객체 간의 관계가 MainController 생성자에 서술되어 있음을 있음을 스프링에게 알려주는 역할을 한다.
@GetMapping()은 "/"이라는 url에 GET 방식으로 접속하였을 때 main() 메소드가 실행됨을 나타내는 역할을 한다.
main() 메소드의 내용을 살펴보자.
Model을 매개변수로 받아온 후에 그 Model에 addAttribute를 하고 있다.
Model객체.addAttribute("A", B) 를 한다는 것은, B라는 데이터를 html의 입장에서 "A"라는 이름으로 가져다 쓸 수 있게 해주는 것이다.
그리고 문자열 "main"을 리턴하고 있다.
이렇게 @GetMapping 또는 @PostMapping이 붙은 메소드가 특정 문자열을 리턴하면,
스프링은 알아서 templates 폴더 안에 main.html 이라는 이름을 가진 html 파일이 있는지 샅샅이 뒤진다.
그러한 파일이 있을 경우 스프링은 그 파일의 내용을 화면에 출력한다.
따라서 main() 메소드는
데이터베이스에 저장된 모든 옷 정보를 서비스의 showAll() 메소드를 사용해 가져와서 list에 담은 다음,
그 정보를 main.html 파일의 입장에서 "list"라는 이름으로 가져다 쓸 수 있도록 한 뒤에
main.html 파일을 화면에 출력한다.
이제 main.html 파일을 만들어주자.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Main</title>
</head>
<body>
<div>
<h1>Welcome!</h1>
<table>
<thead>
<tr>
<th>번호</th>
<th>이름</th>
<th>상세보기</th>
</tr>
</thead>
<tbody>
<tr th:each="clothing : ${list}">
<td th:text="${clothing.id}"></td>
<td th:text="${clothing.name}"></td>
<td ><a th:href="@{/details(id=${clothing.id})}">클릭</a></td>
</tr>
</tbody>
</table>
<a href="/add">추가하기</a>
</div>
</body>
</html>
페이지 제목은 Main이며, 상단에는 Welcome! 이라는 문구가 표시된다.
테이블 형태로 데이터를 표시하는데, thymeleaf를 활용한 반복문을 적용하였다.
하단에는 옷 정보를 새로 추가하는 화면으로 이동하는 "추가하기" 링크가 있다.
한번 실행해보자.
스프링 어플리케이션을 실행할 때에는 이 Applicaton 파일을 실행하면 된다.
실행하면 하단에 Run 창이 뜰 것이다.
Run 창에 올라온 마지막 두 줄의 내용을 보니,
내 어플리케이션이 잘 실행되었고 포트 번호는 8080이다.
그럼 브라우저를 열고 localhost:8080으로 접속하면 내가 만든 웹사이트에 들어갈 수 있다.
메인화면이 잘 보인다.
현재 데이터베이스에 들어가있는 데이터가 없어, 표에는 아무런 내용도 표시되지 않는다.
옷 정보 추가 화면용 컨트롤러 및 html 구현
다음으로 사용자가 옷 정보를 새로 추가하는 화면을 구현해보겠다.
이 화면에서는 사용자로부터 데이터를 받아와야 하기 때문에 할 일이 많다.
일단 컨트롤러 파일부터 만들어준다.
src>main>java>hello.clothingdemo>controller 안에 AddController이라는 java 클래스를 만든다.
아직 html 파일 안의 form을 만들지 않았으니, @PostMapping은 미뤄두고 @GetMapping만 해주자.
package hello.clothingdemo.controller;
import hello.clothingdemo.service.ClothingService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class AddController {
private ClothingService clothingService;
@Autowired
public AddController(ClothingService clothingService) {
this.clothingService = clothingService;
}
@GetMapping("/add")
public String addGet() {
return "add";
}
}
메인 화면 때와 비슷하게 코드를 적어주고, @Controller와 @Autowirded, @GetMapping을 붙여준다.
localhost:8080/add 라는 url에 GET 방식으로 접속할 경우 addGet() 메소드가 실행되도록 되어있고,
addGet() 메소드는 화면에 add.html 을 출력한다.
다음으로는 add.html을 만들어주자.
src>main>resources>templates 안에 add.html을 만든다.
내용은 다음과 같이 채운다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Add</title>
</head>
<body>
<form action="/add" method="post">
<label for="name">이름</label>
<input type="text" id="name" name="name" required /><br/>
<label for="category">종류</label>
<input type="text" id="category" name="category" required /><br/>
<label for="material">재질</label>
<input type="text" id="material" name="material" required /><br/>
<label for="temp">적정 물 온도</label>
<input type="number" id="temp" name="temp" required /><br/>
<label for="det">세제 종류</label>
<input type="text" id="det" name="det" required /><br/><br/>
<input type="submit" value="완료" />
</form>
<button onclick="history.back()">취소</button>
</body>
</html>
필요한 모든 값을 사용자에게서 받는 input 들이 있고,
하단에는 완료와 취소 버튼이 있다.
취소 버튼을 누르면 이전 화면으로 돌아간다.
실행해서 localhost:8080/add 에 들어가보면 다음과 같이 잘 표시된다.
이제 이 html 파일의 form에서 post 방식으로 데이터를 넘겼을 때 받아줄 @PostMapping 메소드를 추가해야 하는데...
그러기 전에,
src>main>java>hello.clothingdemo>controller 안에 ControlForm이라는 java 클래스를 만든다.
이 java 클래스는 컨트롤러가 아니다.
옷 정보 추가 화면에서 받아온 데이터를 잠시 담을 그릇의 역할을 하는 객체가 필요한데,
그 객체를 정의하는 클래스이다.
다음과 같이 옷 정보에 포함되는 필드들과 그 필드들의 getter, setter만 넣어주면 된다.
package hello.clothingdemo.controller;
public class ClothingForm {
private Long id; //각 객체 구분을 위한 고유 ID값
private String name; //이름
private String category; //종류
private String material; //재질
private int temp; //세탁시 적정 물 온도
private String det; //세제 종류
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;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getMaterial() {
return material;
}
public void setMaterial(String material) {
this.material = material;
}
public int getTemp() {
return temp;
}
public void setTemp(int temp) {
this.temp = temp;
}
public String getDet() {
return det;
}
public void setDet(String det) {
this.det = det;
}
}
이제 아까 만들어둔 AddController 클래스의 @GetMapping 메소드 아래쪽에 @PostMapping 메소드도 적어준다.
package hello.clothingdemo.controller;
import hello.clothingdemo.domain.Clothing;
import hello.clothingdemo.service.ClothingService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@Controller
public class AddController {
private ClothingService clothingService;
@Autowired
public AddController(ClothingService clothingService) {
this.clothingService = clothingService;
}
@GetMapping("/add")
public String addGet() {
return "add";
}
@PostMapping("/add")
public String addPost(ClothingForm clothingForm, Model model) {
Clothing clothing = new Clothing();
//ClothingForm 객체에 들어있는 값들을 Clothing 객체에 복붙
clothing.setName(clothingForm.getName());
clothing.setCategory(clothingForm.getCategory());
clothing.setMaterial(clothingForm.getMaterial());
clothing.setTemp(clothingForm.getTemp());
clothing.setDet(clothingForm.getDet());
//데이터베이스에 Clothing 객체를 저장
try{
clothingService.add(clothing);
} catch(IllegalStateException e) { //이름 중복으로 예외가 발생한다면
model.addAttribute("message", e.getMessage()); //미리 준비해둔 에러메시지를 가지고
return "errorPage"; //에러 페이지로 이동
}
return "redirect:/"; //저장에 성공했다면 메인화면으로 이동
}
}
예외가 발생했을 때에는 errorPage.html을 출력하고, 저장에 성공하였을 때는 메인화면으로 이동하도록 하였다.
errorPage.html을 만들어주자.
src>main>resources>templates 안에 errorPage.html을 만든다.
내용은 다음과 같이 채운다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Error</title>
</head>
<body>
<h1>Error!</h1>
<p><span th:text="${message}"></span><br/><a href="/">여기</a>를 눌러 메인화면으로 돌아가세요.</p>
</body>
</html>
message라는 문자열 변수를 받아와 화면에 출력하고
사용자가 메인화면으로 이동할 수 있도록 안내하는 페이지이다.
상세정보 화면용 컨트롤러 및 html 구현
상세정보 화면을 구현해보자.
src>main>java>hello.clothingdemo>controller 안에 DetailsController 라는 java 클래스를 만든다.
내용은 다음과 같이 채워준다.
package hello.clothingdemo.controller;
import hello.clothingdemo.domain.Clothing;
import hello.clothingdemo.service.ClothingService;
import org.springframework.beans.factory.annotation.Autowired;
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 java.util.Optional;
@Controller
public class DetailsController {
private ClothingService clothingService;
@Autowired
public DetailsController(ClothingService clothingService) {
this.clothingService = clothingService;
}
@GetMapping("/details")
public String details(@RequestParam("id") Long id, Model model) {
Optional<Clothing> clothing = clothingService.findOne(id);
if (clothing.isEmpty()) {
model.addAttribute("message", "존재하지 않는 옷입니다.");
return "errorPage";
}
model.addAttribute("clothing", clothing.get());
return "details";
}
}
존재하지 않는 데이터를 탐색하였을 때에는 errorPage.html을, 올바른 데이터를 탐색하였을 때는 details.html을 출력하도록 하였다.
src>main>resources>templates 안에 details.html을 만들어준다.
내용은 다음과 같이 채운다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Details</title>
</head>
<body>
<p>ID: <span th:text="${clothing.id}"></span></p>
<p>이름: <span th:text="${clothing.name}"></span></p>
<p>종류: <span th:text="${clothing.category}"></span></p>
<p>재질: <span th:text="${clothing.material}"></span></p>
<p>적정 물 온도: <span th:text="${clothing.temp}"></span></p>
<p>세제 종류: <span th:text="${clothing.det}"></span></p>
<br/>
<a th:href="@{/delete(id=${clothing.id})}">삭제하기<a/>
<button onclick="location.href = '/'">목록으로</button>
</body>
</html>
옷 정보가 모두 나열되고
하단에는 삭제하기 버튼과 목록으로 돌아가는 버튼이 있다.
삭제 기능 구현
마지막으로 삭제 기능을 구현하자.
src>main>java>hello.clothingdemo>controller 안에 DeleteController라는 java 클래스를 만든다.
내용은 아래와 같이 채운다.
package hello.clothingdemo.controller;
import hello.clothingdemo.service.ClothingService;
import org.springframework.beans.factory.annotation.Autowired;
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 DeleteController {
private ClothingService clothingService;
@Autowired
public DeleteController(ClothingService clothingService) {
this.clothingService = clothingService;
}
@GetMapping("/delete")
public String delete(@RequestParam("id") Long id, Model model) {
try{
clothingService.remove(id);
} catch (IllegalStateException e) { //에러가 발생하면
model.addAttribute("message", e.getMessage()); //미리 지정해둔 에러메시지를 가지고
return "errorPage"; //만들어둔 에러페이지로 이동
}
return "redirect:/"; //잘 삭제했다면 메인화면으로 이동
}
}
최종 테스트
모든 구성요소를 다 만들었다.
이제 실행하여 잘 돌아가는지 확인해보자.
여기서 삭제하기 버튼을 클릭하면,
1번 옷 정보가 삭제되었음을 볼 수 있다.
에러도 유도해보자.
ID가 2인 옷 정보의 name 값이 "옷옷옷"이므로, name 값이 똑같이 "옷옷옷"인 객체를 하나 더 저장하려 시도해보자.
에러 페이지가 잘 뜨고, 문구도 알맞게 출력된다.
다음은 존재하지 않는 옷 정보의 상세페이지로 들어가보자.
ID 값이 10인 옷 정보를 보여달라고 요청했다.
에러 페이지가 잘 뜨고 메시지도 올바르게 출력된다.
마지막으로, 존재하지 않는 옷 정보를 삭제하려 시도해보자.
역시 잘 뜬다.
'스프링 공부 > 기타' 카테고리의 다른 글
MySQL&스프링 부트 - DB에 생성날짜가 null로 들어갈 때 (0) | 2023.05.27 |
---|---|
EFUB 7주차 과제 Postman 테스트 캡처 (0) | 2023.05.14 |
EFUB 5주차 과제 Postman 테스트 캡처 (0) | 2023.04.14 |
EFUB 4월 11일까지의 세션 코드 Postman 테스트 캡처 (0) | 2023.04.12 |
EFUB 4주차 과제 Postman 테스트 캡처 (0) | 2023.04.11 |