개발 새발/Spring

[Spring] 스프링 회원 관리 예제

recordari 2024. 8. 7. 14:41

*인프런 김영한의 스프링 입문 강의를 기반으로 작성되었습니다.

 

비즈니스 요구사항 정리

  • 데이터: 회원ID, 이름
  • 기능: 회원 등록, 조회
  • 아직 데이터 저장소가 정해지지 않았다는 가상의 시나리오 부여

 

일반적인 웹 애플리케이션의 계층구조는 아래와 같다

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

 

클래스 의존관계는 아래와 같다

  • 아직 데이터 저장소가 선정되지 않아서, 우선 인터페이스로 구현 클래스를 변경할 수 있도록 설계
  • 데이터 저장소는 RDB, NoSQL 등등 다양한 저장소를 고민중인 상황으로 가정
  • 개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소를 사용

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

(1) 회원 객체 생성

회원, 주문, 쿠폰과 같이 비즈니스 도메인 객체들이 저장되는 도메인 패키지를 스프링 프로젝트 내 src/main/java/hello.hello_spring 위치에 생성한다. 

Member.java 파일을 만들어 클래스를 생성하여 다음과 같이 회원 객체를 만들어 준다.

회원ID와 이름을 정의해 주었다.(회원ID는 사용자가 입력하는 아이디가 아니라 시스템에서 임의로 지정해 저장하는 아이디를 말한다)

package hello.hello_spring.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;
	}
}

 

(2) 회원 리포지토리 생성

어떤 데이터베이스를 사용할지 정해지지 않았다는 가상의 시나리오를 부여했기 때문에 인터페이스로 정의하여 클래스를 변경할 수 있도록 한다.

src/main/java/hello.hello_spring 위치에 repository 패키지를 생성하고 MemberRepository.java 파일을 만든다.

그 다음 아래와 같은 코드로 인터페이스를 생성해준다.

package hello.hello_spring.repository;

import hello.hello_spring.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();
}

findById() findByName() 메소드를 호출하고 반환 값이 null일 경유 NullPointerException이 발생할 수 있기 때문에 Optional로 감싸서 방지해준다.

 

(3) 회원 리포지토리 메모리 구현체 작성

아직 데이터베이스가 없는 상태에서 프로젝트가 실행되었을때 메모리를 이용해 임시로 저장하는 데이터베이스를 MemoryMemberRepository라는 구현체를 통해 만들 것이다.

repository 패키지 안에 MemoryMemberRepository.java 파일을 만들어 주고 MemberRepository를 implements하여 다음과 같이 작성해 준다.

package hello.hello_spring.repository;

import hello.hello_spring.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());
    }

    public void clearStore(){
        store.clear();
    }
}

(동시성 문제가 고려되어 있지 않음, 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려)

HashMap<>으로 선언된 store가 임시 데이터베이스라고 보면 된다. 시스템이 임의로 아이디를 지정하여 저장하기 위해서 sequence 변수를 사용하고, findById() 메소드에서 store.get(id)의 값이 null인 경우 Optional.empty()가 반환된다.

findByName() 메소드에서는 store.values()를 통해 store에 저장된 value 값을 가져와서 stream().filter()로 파라미터로 받아온 name과 일치하는 멤버를 찾아 반환한다.

 

회원 리포지토리 테스트

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

 

테스트 케이스 작성

test/java/hello.hello_spring 위치에 repository 패키지를 생성하고 MemoryMemberRepository.java 파일을 만들어 아래와 같은 코드를 작성해준다.

package hello.hello_spring.repository;

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

class MemoryMemberRepositoryTest {
    MemoryMemberRepository repository = new MemoryMemberRepository();

    @AfterEach
    public void afterEach(){
        repository.clearStore();
    }

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

        repository.save(member);

        Member result = repository.findById(member.getId()).get();
        assertThat(result).isEqualTo(member);
    }

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

    @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);
    }
}
  • @AfterEach: 한번에 여러 테스트를 실행하면 메모리 DB에 직전 테스트의 결과가 남을 수 있다. 이렇게 되면 다음 이전 테스트 때문에 다음 테스트가 실패할 가능성이 있다. @AfterEach를 사용하면 각 테스트가 종료 될 때 마다 이 기능을 실행하여 메모리 DB에 저장된 데이터를 삭제한다.

  • 테스트는 각각 독립적으로 실행되어야 한다. 테스트 순서에 의존관계가 있는 것은 좋은 테스트가 아니다.

  • save() 메소드를 통하여 생성한 회원객체를 통해 "spring" 이라는 이름을 가진 회원을 repository 에 저장한다. findById() 로 저장된 회원객체를 가져와, 저장된 회원과 불러온 회원이 일치한지 확인한다.
  • findByName()와 findAll() 메소드도 save() 메소드와 비슷한 방식으로 확인을 진행한다.
  • AssertJ의 Assertions 기능은 스프링으로 테스트 코드를 쓸 때 많이 사용된다.
  • assertThat()은 특정 조건을 검증하기 위해 사용되며 예상되는 결과와 실제 결과를 비교하여 테스트를 수행한다. 주로 단위나 통합 테스트에서 사용된다.
  • assertThat(테스트 타겟).메소드1().메소드2().메소드3();

 


 

회원 서비스 개발

package hello.hello_spring.service;

import hello.hello_spring.domain.Member;
import hello.hello_spring.repository.MemberRepository;
import hello.hello_spring.repository.MemoryMemberRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;


public class MemberService {
    private final MemberRepository memberRepository;

    public MemberService(MemoryMemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    //회원 가입
    public Long join(Member member) {
        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);
    }
}

join() 메소드는 validateDuplicateMember()를 사용하여 동일한 이름을 가지 회원이 있는지 검사하고 검사를 통과하면 memberRepository에 전달받은 회원을 저장한다.

 

기존에는 회원 서비스가 메모리 회원 리포지토리를 직접 생성하게 했다.

public class MemberService {
	private final MemberRepository memberRepository = new MemoryMemberRepository();
}

회원 리포지토리 코드가 회원 서비스 코드를 의존성 주입 가능하게 변경한 코드는 아래와 같다.

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

회원 리포지토리 메모리 구현체를 사용하기 위해 memberRepository 객체를 생성한 코드를 위와 같이 변경하면 MemberService 객체를 생성할 때 외부에서 선언된 memberRepository 객체를 주입받아 사용할 수 있게 된다.

처럼 외부에서 두 객체 간의 관계를 결정해주는 디자인 패턴 같은 의존성 주입(Dependency Injection)이라고 한다.

 

회원 서비스 테스트

이번 테스트 코드는 given/when/then 문법으로 코드를 작성할 것이다.

  • given: 이런 상황이 주어져서
  • when: 실행했을 때
  • then: 이런 결과가 나와야 한다

 

테스트 케이스 작성

test/java/hello.hello_spring 위치에 service라는 패키지를 생성하고 MemberServiceTest.java 라는 파일을 만들어 아래와 같이 작성해준다.

package hello.hello_spring.service;

import hello.hello_spring.domain.Member;
import hello.hello_spring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
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.*;

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("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);
        IllegalStateException e = assertThrows(IllegalStateException.class,
                () -> memberService.join(member2)); //예외가 발생해야 한다.
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
    }
}
  • @BeforeEach 어노테이션을 이용하여 각 테스트 전에 beforeEach() 메소드를 실행시켜서 테스트가 서로 영향이 없도록 항상 새로운 객체를 생성하고 새로 의존성도 주입해준다.
  • @AfterEach 어노테이션을 이용하여 각각의 테스트가 끝날 때마다 메모리 데이터베이스에 저장된 데이터를 삭제한다.
  • @BeforeEach로 의존성을 새로 부여해주었는데도 @AfterEach를 사용하는 이유는 MemoryMemberRepository 안의 store가 static 이기 때문에 인스턴스를 새롭게 생성해도 데이터가 남아 있어서 이 데이터를 삭제하기 위함이다.
  • 중복_회원_예외() 메소드는 member1, member2의 이름을 같게 하여 회원가입을 했을 때 예외가 발행하는지 확인하는 테스트이다. Try-Catch 문을 사용하는 방식으로도 작성 가능하다. 코드는 아래와 같다.
memberService.join(member1);
	try{
    	memberService.join(member2);
        fail();
    } catch (IllegalStateException e) {
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
    }

 

참고 블로그

https://velog.io/@gerrymandering/Spring-%ED%9A%8C%EC%9B%90%EA%B4%80%EB%A6%AC-%EC%98%88%EC%A0%9C-2%ED%8E%B8

 

[Spring] 회원관리 예제 - 2편

회원 서비스를 만들고 단위 테스트를 돌려보자

velog.io