*김영한 | 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 강의를 참조한 글입니다.
오늘은 단순한 회원 관리 예제를 다뤄볼겁니다ㅎㅎ
드디어 뭔가 프로젝트다운 걸 하게되어 기뻐요 - (매우 간단한 거지만요)
![](https://t1.daumcdn.net/keditor/emoticon/niniz/large/008.gif)
그럼 바로 기릿
1. 일반적인 웹 애플리케이션 계층 구조
- 컨트롤러 : 웹 MVC의 컨트롤러 역할 *지난 글에서 다룸
- 서비스 : 핵심 비지니스 로직 구현
- DB(데이터베이스)
- 리포지토리(저장소) : DB(데이터베이스)에 접근, 도메인 객체를 DB에 저장하고 관리
- 도메인 : 비지니스 도메인 객체
ex) 회원, 주문, 쿠폰 등과 같은 비지니스 도메인 객체를 주로 DB에 저장하여 관리함
2. 백엔드 개발 순서
2-1. 비지니스 요구사항 정리 및 설계
- 데이터 : 회원 ID, 이름
- 기능 : 회원 등록, 조회
- 아직 데이터 저장소가 선정되지 않음
: 일반적으로 쓰이는 관계형 데이터베이스를 할지, 어떠한 성능이 필요할지.. 그러한 것들이 아직 정의되지 않은 경우
=> Then How?
- 아직 데이터 저장소가 선정되지 않아서, 우선 인터페이스로 구현 클래스를 변경할 수 있도록 설계
- 데이터 저장소는 RDB, NoSQL 등등 다양한 저장소를 고민중인 상황으로 가정
- 개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용
2-2. 회원 도메인과 리포지토리 만들기
2-2번에 해당하는 파일 구성은 다음과 같다.
2-2-1. 'src - main - java - hellospring.hellospring'패키지에 domain 패키지를 생성하고,
domain 패키지 하위에 Member 클래스를 생성한다.
Member 클래스에 다음의 코드를 작성한다.
package hellospring.hellospring.domain;
public class Member {
private Long id;
private String name;
// alt + insert -> getter setter
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;
}
}
*window기준 단축키 alt + insert -> getter setter를 통해 코드를 빠르게 불러올 수 있다.
<코드 설명>
Member에 대해 아이디, 이름을 만들고 각각에 대한 getter setter를 설정함
id : 고객이 정하는 것이 아니라, 데이터 구분을 위해 시스템이 임의로 정하는 값
name : 고객 등록하는 이름
2-2-2. 'src - main - java - hellospring.hellospring'패키지에 repository 패키지를 생성하고,
그 하위에 MemberRepository 인터페이스를 생성한다.
MemberRepository 인터페이스에 다음의 코드를 작성한다.
package hellospring.hellospring.repository;
import hellospring.hellospring.domain.Member;
import java.util.List;
import java.util.Optional;
public interface MemberRepository {
Member save(Member member); // 회원을 저장
Optional<Member> findById(Long id); // id로 회원을 찾기
Optional<Member> findByName(String name); // name으로 회원을 찾기
List<Member> findAll();// 지금까지 저장된 모든 회원을 불러오기
}
*Optional
- findById 나 findByName으로 회원을 찾는데, 없으면 null로 반환되는데 이걸 Optional로 감싸서 반환하는 방식
- 래퍼 클래스(Wrapper class)
- NPE(Null Pointer Exception)를 간단히 회피할 수 있다.
<코드설명>
리포지토리의 네 가지 기능 만들기
2-2-3. 'src - main - java - hellospring.hellospring - repository'패키지에 MemoryMemberRepository 클래스를 생성한다.
MemoryMemberRepository 클래스에 다음의 코드를 입력한다.
MemberRepository 우클릭 - show context actions 클릭 - implement methods 클릭 - 메서드 툴을 전부 가져온다.
그 후, 다음의 코드를 작성한다.
package hellospring.hellospring.repository;
import hellospring.hellospring.domain.Member;
import java.util.*;
public class MemoryMemberRepository implements MemberRepository {
private static Map<Long, Member> store = new HashMap<>(); //Map은 우클릭 - import class 해준다.
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> 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());
}
public void clearStore () {
store.clear();
}
}
<코드 설명>
private static Map<Long, Member> store = new HashMap<>();
// Member를 저장(save)해주기 위해 Map사용. <key, value> = <Long(id값이 return), Member>
// 실무에서는 동시성 문제로 인해 ConcurrentHashMap을 사용하나, 여기선 그냥 단순히 HashMap 씀
private static long sequence = 0L;
// sequence는 0, 1, 2... 의 key값을 생성하는 용도.
//실무에서는 동시성 문제로 인해 AtomicLong 등등을 사용.
@Override
public Member save(Member member) {
member.setId(++sequence); // member에 저장할 때 sequence 값을 하나 올려줌
store.put(member.getId(), member); // member의 id값 세팅한 후! store에 member를 넣어준다.
// id : 고객이 정하는 것이 아니라, 데이터 구분을 위해 시스템이 임의로 정하는 값
// name : 고객 등록하는 이름이기에, name은 이미 넘어온 상태.
return member;
}
@Override
public Optional<Member> findById(Long id) {
// 어떤 id를 찾았는데 없는 경우(null)를 대비해 Optinal로 감쌈
return Optional.ofNullable(store.get(id));
// store.get(id)를 사용해 store에서 값을 꺼내 반환
}
@Override
public Optional<Member> findByName(String name) {
return store.values().stream()
.filter(member -> member.getName().equals(name))
.findAny();
// 찾는 name에 대해 루프를 돌리다가 Map에서 하나라도 찾으면 반환(=findAny),
// 못 찾으면 Optinal(null)로 반환
}
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values());
// Map을 썼지만, List로 반환함. List는 루프를 돌리기 용이하여 자주 쓰임.
// store.values()는 store에 있는 values(Mamber)를 전부 반환함
}
public void clearStore () {
store.clear();
} //테스트시 각자의 테스트가 서로에게 영향을 끼치지 않도록 메모리를 클리어해줌
* 2-3번에서 테스트 코드를 작성할 것인데, 모든 테스트는 순서가 보장이 안 되므로 순서에 의존하지 않게 설계되어야 한다. 또한, 여러 테스트를 함께 돌리면 이전 테스트에서 저장된 것이 안 없어져 오류가 난다. 그래서 테스트가 끝나면 데이터를 클리어해주어야 하고, 이것에 대한 코드를 작성한 것이다.(clearStore)
2-3. 회원 리포지토리 테스트 케이스 작성
개발한 기능을 실행해서 테스트 할 때 자바의 main 메서드를 통해서 실행하거나, 웹 애플리케이션의 컨트롤러를 통해서 해당 기능을 실행한다. 이러한 방법은 준비하고 실행하는데 오래 걸리고, 반복 실행하기 어렵고 여러 테스트를 한번에 실행하기 어렵다는 단점이 있다. 자바는 JUnit이라는 프레임워크로 테스트를 실행해서 이러한 문제를 해결한다.
'src - test - java - hellospring.hellospring' 패키지에 repository 패키지를 생성하고,
그 하위에 MemberMemoryRepositoryTest 클래스를 생성하고 다음의 코드를 작성한다.
(패키지는 똑같은 이름으로, 클래스는 'Test' 붙여서 만드는 것이 편리하다.)
package hellospring.hellospring.repository;
import hellospring.hellospring.domain.Member;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach
public void afterEach() {
repository.clearStore();
}
@Test
public void save() {
//given
Member member = new Member();
member.setName("spring");
//when
repository.save(member);
//then
Member result = repository.findById(member.getId()).get();
assertThat(result).isEqualTo(member);
}
@Test
public void findByName() {
//given
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
//when
Member result = repository.findByName("spring1").get();
//then
assertThat(result).isEqualTo(member1);
}
@Test
public void findAll() {
//given
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
//when
List<Member> result = repository.findAll();
//then
assertThat(result.size()).isEqualTo(2);
}
}
*우린 구현 클래스를 먼저 만들고, 테스트를 했지만
TDD - 테스트 코드를 먼저 만들고 구현을 하는 것 - 도 있다.
*given/when/then 패턴
- given : 이 상황이 주어졌을 때
- when : 이러한 코드를 실행하면
- then : 이러한 결과가 나와야 함
<코드 설명>
class MemoryMemberRepositoryTest {
// 다른 곳에서 가져다 쓸 것이 아니므로 굳이 public으로 작성하지 않아도 됨
MemoryMemberRepository repository = new MemoryMemberRepository();
// 테스트를 하기 위해 생성
@AfterEach // 각 테스트 메서드가 끝날 때마다 할 동작을 정의
public void afterEach() {
repository.clearStore(); // 테스트가 끝나면 repository를 클리어해줘~
}
@Test // test 실행 가능
public void save() {
//given
Member member = new Member(); // Member 객체 생성
member.setName("spring"); // name에 "Spring" 저장
//when
repository.save(member); // repository에 member 저장
//then
Member result = repository.findById(member.getId()).get();
// 저장한 것이 잘 들어갔는지 확인. 참고로, 저장할 때 id가 세팅됨.
// 반환이 Optional로 되는데, Optional에서는 get()을 통해 값을 꺼낼 수 있음. 그다지 좋은 방법은 아니나 테스트 코드니 ㄱㅊ
assertThat(result).isEqualTo(member); // 검증단계. result값과 member값이 같으면 True(녹색체크표시)
}
@Test
public void findByName() {
//given
// 정교한 테스트를 위해 2개의 경우를 작성함
Member member1 = new Member();
member1.setName("spring1"); // name을 "spring1"이라 지정
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2"); // name을 "spring2"이라 지정
repository.save(member2);
//when
Member result = repository.findByName("spring1").get();
// get()을 사용해 값을 꺼냄
//then
assertThat(result).isEqualTo(member1);
}
@Test
public void findAll() {
//given
// 마찬가지로, 정교한 테스트를 위해 2가지의 경우 작성; name = spring1, 2
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
//when
List<Member> result = repository.findAll();
//then
// List에 저장된 객체의 개수와 실제 db의 개수를 비교함. 2가지 경우 작성했으니 여기서는 '2'
assertThat(result.size()).isEqualTo(2);
}
2-4. 회원 서비스 개발
이제 repository와 domain을 활용한 실제 비지니스 로직을 만들어보자.
'src - test - java - hellospring.hellospring' 패키지에 service 패키지를 생성하고,
그 하위에 MemberService 클래스를 생성하고 다음의 코드를 작성한다.
package hellospring.hellospring.service;
import hellospring.hellospring.domain.Member;
import hellospring.hellospring.repository.MemberRepository;
import java.util.List;
import java.util.Optional;
@Service
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository){
this.memberRepository = memberRepository;
}
public Long join(Member member) { // Member는 우클릭 - import class 해줌
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);
}
}
<코드 설명>
@Service // 비지니스 로직을 작성하는 부분
public class MemberService {
private final MemberRepository memberRepository;
// 회원 서비스를 만들기 위한 재료로 memberRepository가 필요함
// final은 처음 초기화 할 때와 생성자에서만 값을 할당하는 것이 가능하고, 그 외에는 값 수정 불가
public MemberService(MemberRepository memberRepository){
this.memberRepository = memberRepository;
}
this.memberRepository = memberRepository; 대신
private final MemberRepository memberRepository = new MemoryMemberRepository(); 를 쓸 수도있지만
그럼 DI를 하지 못하므로 해당 코드로 작성한다.
*DI란?
2. spring이란 / IoC 컨테이너, DI 컨테이너 / spring bean / Java Config
*강경미 | 웹 백엔드 강의를 참조한 글입니다. 다른 강의에서도 스프링 프레임워크에 대해 다루긴 했으나, 예제를 통해 중간중간 개념을 배우는 형식이었기에 정보들이 분산되어 정리되어있었
8w8u8.tistory.com
public Long join(Member member) { // 회원가입
validateDuplicateMember(member); // 같은 이름이 있는 중복 회원 불가
memberRepository.save(member); // 중복 회원 없을 시 저장
return member.getId();
}
private void validateDuplicateMember(Member member){
memberRepository.findByName(member.getName()) // memberRepository에서 name 찾기
.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
}); // 값이 있으면, "이미 존재하는 회원입니다."
// ifPresent는 Optinal 타입으로 감싸서 값을 return했기에 사용 가능한 메서드
}
public List<Member> findMembers(){ // 전체 회원 조회
return memberRepository.findAll();
}
public Optional<Member> findOne(Long memberId) { // 특정 회원 아이디로 조회
return memberRepository.findById(memberId);
}
2-5. 회원 서비스 테스트
2-4번에서 만든 회원 서비스 클래스가 잘 작동하는지 테스트해보자.
2-3번에서는 패키지 생성 후 클래스를 생성해줬지만, 아주 편리한 단축키가 있다.
단축키 : ctrl + shift + t - create new test
ok 가보자고
자동으로 test 패키지와 클래스가 완성되었다. 굿~
이제 다음의 코드를 작성한다.
package hellospring.hellospring.service;
import hellospring.hellospring.domain.Member;
import hellospring.hellospring.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.junit.jupiter.api.Assertions.assertThrows;
class MemberServiceTest {
MemberService memberService;
MemoryMemberRepository memberRepository;
@BeforeEach
public void beforeEach(){
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
}
@AfterEach
public void afterEach(){
memberRepository.clearStore();
}
@Test
void 회원가입(){ // 테스트라서 한국어 써도 ㄱㅊ
// given
Member member = new Member();
member.setName("hello");
// when
Long saveId = memberService.join(member);
// then
Member findMember = memberService.findOne(saveId).get();
Assertions.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);
assertThrows(IllegalStateException.class, () -> memberService.join(member2));
// then
}
@Test
void findMembers(){
}
@Test
void findOne(){
}
}
<코드 설명>
MemberService memberService;
MemoryMemberRepository memberRepository; // 밑에서 클리어해주기 위해 가져옴
@BeforeEach
public void beforeEach(){ // 각 테스트 실행할 때마다 독립적으로 생성
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
}
@AfterEach
public void afterEach(){
memberRepository.clearStore(); // 테스트 끝나면 클리어
}
@Test
void 회원가입(){
// given
Member member = new Member(); // 새로운 member 객체 생성
member.setName("hello"); // member 객체의 이름은 hello
// 이때, 이름을 spring으로 변경하면 테스트에서 fail할 것이다.
// 왜냐면 뒤의 중복회원예외 부분에서 spring으로 저장된 이름이 있기 때문에!
// when
Long saveId = memberService.join(member); // join 메서드를 활용해 회원가입
// then
Member findMember = memberService.findOne(saveId).get(); // get으로 가져와서
Assertions.assertThat(member.getName()).isEqualTo(findMember.getName());
// member name과 찾은 값이 같은지 확인
}
@Test
public void 중복_회원_예외(){
// given
// 이름이 spring으로 동일한 2개의 member 생성
Member member1 = new Member();
member1.setName("spring");
Member member2 = new Member();
member2.setName("spring"); // 중복 회원 발생!!!!!!
// when
memberService.join(member1);
assertThrows(IllegalStateException.class, () -> memberService.join(member2));
// asserThrows를 사용한 로직 실행시 예외가 발생하는 것을 확인
// then
}
*이처럼 테스트에서는 예외가 잘 처리되는지 확인하는 것도 중요하다.