Spring

[Spring] 스프링 입문 (3) - 회원 관리 예제 (백엔드 개발)

Sue 2022. 2. 27. 17:06

비즈니스 요구사항 정리

  • 데이터 : 회원ID, 이름
  • 기능 : 회원 등록, 조회
  • 아직 데이터 저장소가 선정되지 않음 (가상의 시나리오)

인프런 김영한님 스프링 입문 강의자료

  • 컨트롤러 : 웹 MVC의 컨트롤러 역할
  • 서비스 : 핵심 비즈니스 로직 구현 (e.g. 회원은 중복 가입이 되지 않음)
  • 도메인 : 비즈니스 도메인 객체 (e.g. 회원, 주문, 쿠폰 등 주로 데이터베이스에 저장하고 관리됨)
  • 리포지토리 : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리

 

인프런 김영한님 스프링 입문 강의자료

  • 데이터 저장소는 RDB, NoSQL 등 다양한 저장소를 고민중인 상황으로 가정
  • 아직 데이터 저장소가 선정되지 않았기 때문에 회원을 저장하는 MemberRepository는 인터페이스로 설계
  • 개발을 진행하기 위해 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용

 

 

회원 도메인과 리포지토리 만들기

Member : 회원 도메인
package hello.hellospring.domain;

public class Member {

    private Long id;    // 데이터를 구분하기 위해 시스템이 저장하는 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;
    }
}

 

MemberRepository : 리포지토리 인터페이스
  • Null일 가능성이 있는 반환값의 경우 Optional로 감싸주면 Null Pointer Exception(NPE)를 방지할 수 있다.
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> findById(Long id);
    Optional<Member> findByName(String name);
    List<Member> findAll();
}

 

MemoryMemberRepository : 구현체
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<>();
    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());
    }
}

 

회원 리포지토리 테스트 케이스 작성

개발한 기능이 잘 작동하는지 실행하여 테스트를 진행할 때 자바의 main 메서드를 통해 실행하거나 웹 어플리케이션의 컨트롤러를 통해 실행한다. 이러한 방법은 준비하고 실행하는데 시간이 오래걸리고 반복 실행하기 어렵고, 여러 테스트를 한번에 테스트하기 힘들다는 단점이 있다. 자바의 JUnit이라는 프레임워크를 사용해서 테스트 코드를 만들고 실행하여 이러한 문제들을 해결할 수 있다.

 

src>test>hello.hellospring>repository
  • 테스트 하고자 하는 클래스가 있는 폴더와 대칭적으로 test폴더에 같은 이름으로 생성한다.
  • 테스트 클래스의 이름은 테스트를 하고자하는 클래스이름 뒤에 Test를 붙인다.
  • 테스트 메서드는 @Test를 사용한다.
  • Junit 또는 Assertj의 프레임워크를 사용하면 콘솔에 찍는 것보다 편리하게 테스트할 수 있다. (Assortions)
class MemoryMemberRepositoryTest { 

    MemoryMemberRepository repository = new MemoryMemberRepository();

    @Test
    public void save() {
        Member member = new Member();
        member.setName("spring");

        repository.save(member);

       Member result = repository.findById(member.getId()).get();
       Assertions.assertThat(result).isEqualTo(member); // assertJ Assertions
    }
    
    @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);
    }

}

 

findAll() 테스트 코드 추가

findAll() 테스트 코드를 추가하고 전체 테스트를 실행시켜보면 테스트가 제대로 수행되지 않는다. findByName()과 findAll()에서 사용된 변수 이름이 같기 때문에 오류가 발생한다. 

@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);
}

 

테스트가 끝날 때마다 데이터를 clear해주는 메서드 추가
  • MemoryMemberRepository
public void clearStore() {
    store.clear();
}
  • 테스트 코드
@AfterEach
public void afterEach() {   // 테스트 끝나고 실행될때마다 호출됨
    repository.clearStore();
}

위와 같은 상황을 방지하기 위해 테스트가 끝날때마다 데이터를 비워주는 작업이 필요하다. 구현 코드에 데이터를 clear하는 메서드를 정의하고 테스트 코드에서 @AfterEach를 사용해 메서드를 만들어준다. 테스트는 각각 독립적으로 실행되어야 한다. 테스트 순서에 의존관계가 있는 것은 좋은 테스트가 아니다.

 

지금까지 한 것은 먼저 개발을 하고 그를 검증하기 위한 테스트 케이스를 작성했다. 이와 반대로 테스트 코드를 작성하고 그에 따라 개발을 하는 것테스트 주도 개발 (Test Driven Development : TDD)이라고 한다.

 

회원 서비스 개발

회원 서비스를 개발하는 단계에서는 회원 리포지토리와 도메인을 이용해서 비즈니스 로직을 구현한다.

MemberService의 메서드
  • 회원 가입 기능과 회원 조회 기능을 개발
  • 회원 가입을 할때는 같은 이름이 있는 경우 가입이 되지 않도록 한다.
  • 서비스 클래스의 메서드 이름은 비즈니스 로직과 가깝다. (join, findMembers, ...)
  • 반면 리포지토리 클래스의 메서드 이름은 단순한 로직 위주로 명명한다. (save, findById, ...)
  • 반환값으로 Optional을 가지는 경우, Optional 클래스에서 제공하는 메서드를 사용할 수 있다. (ifPresent)
    /**
     * 회원 가입
     */
    public Long join(Member member) {
        // 같은 이름이 있는 중복 회원X
        validateDuplicateMember(member); // 중복회원 검증
        memberRepository.save(member);
        return member.getId();
    }

    private void validateDuplicateMember(Member member) {
        memberRepository.findByName(member.getName())
                .ifPresent(m -> { // Optional이기 때문에 사용 가능
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });
    }

    /**
     * 전체 회원 조회
     */
    public List<Member> findMembers() {
        return memberRepository.findAll();
    }

    public Optional<Member> findOne(Long memberId) {
        return memberRepository.findById(memberId);
    }

 

회원 서비스 테스트

MemberServiceTest
  • 테스트 코드의 경우 빌드되지 않기 때문에 메서드 이름을 한글로 해도 괜찮다.
  • 테스트 코드 작성시 given-when-then구조 (주어진 상황-실행했을 때-결과)로 작성하면 로직을 쉽게 이해할 수 있다. 특히 코드가 길어질 때 편함
@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
void 중복회원예외() {
    //given
    Member member1 = new Member();
    member1.setName("spring");

    Member member2 = new Member();
    member2.setName("spring");

    //when
    memberService.join(member1);
/*
    try {
        memberService.join(member2);
        fail();
    } catch (IllegalStateException e) {
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
    }
*/
    IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));

    assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");

    //then

}

 

MemberService
public class MemberService {

	private final MemberRepository memberRepository;
 	
    public MemberService(MemberRepository memberRepository) {
 		this.memberRepository = memberRepository;
 	}
 	...
}

여러 테스트를 동시에 실행할 경우 역시 @AfterEach를 사용해 데이터를 clear 해주어야 한다. 이때 MemoryMemberRepository를 new 키워드로 생성해서 사용하면 MemberService와 각각 다른 인스턴스를 사용하기 때문에 테스트가 제대로 수행되지 않을 수 있다. 이를 방지하기 위해 외부에서 MemberReposiory를 받는 MemberService의 생성자를 만들어준다. 이렇게 외부에서 선언된 객체를 받아 사용하는 것을 의존성 주입 (Dependency Injection : DI)이라고 한다.

 

@BeforeEach 메서드
@BeforeEach
void beforeEach() {
    memoryMemberRepository = new MemoryMemberRepository();
    memberService = new MemberService(memoryMemberRepository);
}

각 테스트가 실행되기 전에 호출된다. 테스트가 서로 영향이 없도록 항상 새로운 객체를 생성하고 의존관계를 새로 맺어준다.


강의 링크

 

[무료] 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 - 인프런 | 강의

스프링 입문자가 예제를 만들어가면서 스프링 웹 애플리케이션 개발 전반을 빠르게 학습할 수 있습니다., - 강의 소개 | 인프런...

www.inflearn.com