7 min read

<Spring> 스프링으로 만드는 회원 관리 예제 - 백엔드 개발

해당 글은 김영한님의 <스프링 입문> 강좌를 기반으로 작성되었습니다.

https://github.com/hcs4125/starting_spring에서 모든 코드를 확인가능합니다

비즈니스 요구 사항 정리

  • 데이터:회원ID, 이름
  • 기능:회원 등록, 조회
  • DB는 선정되지 않는 상황이라고 가정
  • 컨트롤러 : 웹 mvc의 컨트롤러 역할
  • 서비스 : 핵심 비지니스 로직 구현(ex-회원간 이름 중복 불가 등)
  • 레포지토리 : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리 -> 단순히 저장된 객체에 대한 접근하는 방식으로 구현
  • 위 그림의 클래스 의존관계 형태로 구현한다.
  • 데이터 저장소가 선정되지 않았기 때문에, 나중에 변경하기 쉽게 interface로 레포지토리 구현
  • 개발을 진행하기위해서 초기 개발 단계에는 메모리 기반의 저장소를 사용한다.

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

회원객체생성

  • 도메인 패키지 생성 후, 아래의 Member 클래스 작성
package hello.hellospring.domain;

public class Member {
    private Long id;//system이 정해주는 순번
    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;
    }
}

회원 레포지토리 인터페이스 구현

  • repository 패키지 생성 후 MemberRepository interface를 생성합니다.
  • Optional로 리턴값을 wrapping하는 이유는 null값을 편하게 다루기 위해서 사용합니다.
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();
}

회원 레포지토리 메모리 구현체

  • repository 패키지 내에 MemoryMemberRepository 클래스를 생성합니다.
  • 도메인 객체를 저장하는 저장소는 HashMap으로 구현하고, id 값에 할당시킬 sequence 변수도 초기화 합니다.
  • save() 메소드는 도메인 객체를 저장소에 저장하는 역할을 수행합니다.
  • Id와 Name 값으로 회원을 저장소에서 꺼내오는 메소드도 구현합니다.
  • 저장소를 초기화해주는 메소드도 구현합니다(test시 레포지토리 중복 문제 해결)
package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import java.util.*;

public class MemoryMemberRepository implements MemberRepository{

    private 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());
    }

    public void clearStore(){
        store.clear();
    }
    public void clearSequence(){
        sequence = 0L;
    }
}

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

  • 기존까지 해오던 방법의 테스트는 콘솔창에 값을 직접 출력하거나, 자바의 main 메서드를 직접 실행하여 테스트 해보았는데 이는 동시에 여려가지의 테스트 케이스를 진행해보지 못할 뿐더러 시간도 오래걸리기 때문에 현업에서는 JUnit5를 사용한 테스트 케이스를 구현후 실행하는 방식을 사용합니다.

회원 레포지토리 메모리 구현체 테스트

  • src/test/java 하위에 repository 패키지를 생성한다.
  • 이후 MemoryMemberRepositoryTest클래스를 생성합니다.
  • 각각의 메소드위에 @Test 어노테이션으로 테스트 메소드라는 것을 명시합니다.
  • @AfterEach의 경우 다양한 테스트를 진행할 경우 레포지토리가 초기화 되지 않으면 중복되는 요소 때문에 테스트가 실패할 경우를 방지해주는 어노테이션입니다.즉 각각의 테스트 케이스가 끝날때마다 afterEach()를 호출해서 레포지토리를 초기화 시켜 줍니다.여기서는 메모리 DB에 저장된 데이터를 삭제합니다.
  • 테스트는 순서에 구애 받지않고 각각 실행되어야 합니다.
package hello.hellospring.repository;

import hello.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.*;

//	만약 해당 테스트 클래스를 먼저 만들고 MemoryMemberRepository를 구현하면 TDD 방식이다.
class MemoryMemberRepositoryTest {
    MemoryMemberRepository memberRepository = new MemoryMemberRepository();

    @AfterEach//메서드 호출이 끝날 때 마다 호출되는 메서드
    public void afterEach(){
        memberRepository.clearStore();
        memberRepository.clearSequence();
    }

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

        memberRepository.save(member);

        Member result = memberRepository.findById(member.getId()).get();

        assertThat(member).isEqualTo(result);
    }

    @Test
    public void findById(){
        Member member1 = new Member();
        memberRepository.save(member1);

        Member member2 = new Member();
        memberRepository.save(member2);

        Member result = memberRepository.findById(1L).get();
        assertThat(result).isEqualTo(member1);
    }

    @Test
    public void findByName(){
        Member member1 = new Member();
        member1.setName("spring1");
        memberRepository.save(member1);

        Member member2 = new Member();
        member2.setName("spring2");
        memberRepository.save(member2);

        Member result = memberRepository.findByName("spring1").get();
        assertThat(result).isEqualTo(member1);
    }

    @Test
    public void findAll(){

        Member member1 = new Member();
        member1.setName("spring1");
        memberRepository.save(member1);

        Member member2 = new Member();
        member2.setName("spring2");
        memberRepository.save(member2);

        List<Member> result = memberRepository.findAll();
        assertThat(result.size()).isEqualTo(2);
    }
}

회원 서비스 개발

  • 서비스의 경우 레포지토리의 접근하는 메서드들 보다는 복잡한 행동을 수행하는 비지니스 로직를 구현합니다.
  • 마찬가지로 service 패키지 생성후, 아래에 MemberService 클래스를 생성합니다.
  • 해당 클래스에서 바로 private final MemberRepository memberRepository = new MemoryMemberRepository();로 초기화 하지 않고 생성자를 통해서 외부에서 객체를 생성하도록 하는 행위을 DI(Dependency Injection)이라고 한다.
package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import java.util.List;
import java.util.Optional;

public class MemberService {
    private final MemberRepository memberRepository;

    //DI(Dependency Injection) MemberRepository를 외부(MemberServiceTest)에서 주입해준다.
    public MemberService(MemberRepository memberRepository) {
        this.memberRepository  = memberRepository;
    }

    //회원가입
    public Long join(Member member) {
        validateDuplicateMember(member);
        memberRepository.save(member);
        return member.getId();
    }

    public List<Member> findMembers(){
        return memberRepository.findAll();
    }

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

    private void validateDuplicateMember(Member member) {
        //같은 이름 방지
        memberRepository.findByName(member.getName())
        .ifPresent(m -> {
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        });
    }
}

회원 서비스 테스트

  • 레포지토리와 마찬가지로 직접 패키지를 생성 후 클래스를 만들어도 되고 단축키 (command+shift+T)를 통해서 생성해도 된다.
  • @BeforeEach : 각 테스트 실행전에 호출된다.테스트가 서로 영향이 없도록 항상 새로운 객체를 생성하고 의존관계도 새로 맺게합니다.(DI)
  • 테스트 케이스의 메소드 이름은 직관적으로 한글로 작성해도 상관없습니다.
package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;

class MemberServiceTest {

    MemberService memberService;
    MemoryMemberRepository memberRepository;

    @BeforeEach
    void beforeEach() {
        memberRepository = new MemoryMemberRepository();
        memberService = new MemberService(memberRepository);//memberRepository를 memberService에 주입해준다
    }

    @AfterEach
    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();
        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);
        IllegalStateException exception = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
        assertThat(exception.getMessage()).isEqualTo("이미 존재하는 회원입니다.");

        /*try {
            memberService.join(member2);
            fail();
        } catch (IllegalStateException e) {
            assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
        }*/
        //then
    }

    @Test
    void findMembers() {
    }

    @Test
    void findOne() {
    }
}

전체 디렉토리 구조 이미지