Rev Notebook

[Spring Boot] 2. 회원 관리 예제 구현하기

by Rev_

목표

우리는 먼저 회원ID(id)이름(name)을 지니는 Member 클래스를 통해 여러가지 기능을 구현해보고자 한다.

기능에는 회원 등록, 회원 조회 기능을 구현해 볼 것이다.

진행 순서는 다음과 같다.

  1. 회원 도메인&리포지토리 개발
  2. 회원 리포지토리 테스트
  3. 회원 서비스 개발
  4. 회원 서비스 테스트

대략적으로 하나의 기능을 만들고 -> 테스트 하는 방식으로 흘러간다.

 

구성

MemberService ----> MemberRepository(interface) <---- MemoryMemberRepository

클래스 의존 관계는 위와같다.

아직 데이터 저장소가 선정되지 않았다고 가정하였기 때문에, 우선 인터페이스로 구현 클래스를 변경할 수 있도록 설계하였다. 또한 개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소를 사용한다.

 

또한 MemoryMemberRepositoryMemberService에는 각각 테스트 케이스를 작성한다.

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

 

Member

package hello.hellospring.domain;
// 회원ID와 이름 필요
// 회원 도메인 Member 클래스
public class Member {
    private Long id;  // 고객이 정하는 id가 아니라 시스템이 정하는 id
    private String name;

    // id와 name이 private이기 때문에 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;
    }
}

Member.java

idname 변수를 각각 private로 생성해주었다.

우리는 아이디와 이름을 가진 멤버 객체를 만들 것이다.

또한 private에 접근하기 위해서 getter setter를 생성해주었다.

 

MemberRepository

package hello.hellospring.repository;
// repository 패키지 -> 회원 정보를 저장하는 저장소
import hello.hellospring.domain.Member; // Member.java
import java.util.List;
import java.util.Optional;

public interface MemberRepository {
    // 1. save()를 통해 회원 저장
    Member save(Member member);  // 회원 저장
    // 2. ID로 저장된 회원 찾을 수 있음
    Optional<Member> findById(Long id);  // ID
    // 3. 이름으로 저장된 회원 찾을 수 있음
    Optional<Member> findByName(String name);  // 이름
    // 4. 저장된 모든 회원 리스트를 불러올 수 있음
    List<Member> findAll();  // 모든 회원 list
}

MemberRepository.java

회원을 저장하고, ID나 이름으로 회원을 찾고, 모든 회원 리스트를 불러오기 위한 인터페이스를 구현하였다.

여기서 Optional이란 무엇일까?

findById 또는 findByName으로 가져올 때 만일 null 값일 때 처리하는 방법 중 하나이다. 즉, NPE(NullPointException)을 방지하기 위해 반환값을 Optional로 감싸서 가져온다.

옆에 붙어있는 <>은 무엇일까?

<>은 제네릭(Generics)이라고 하며, 객체의 타입을 지정해준다. 제네릭의 장점은 타입의 안정성을 지니며, 불필요한 형변환을 줄여 코드의 간결함을 나타낸다.

 

interface에 대한 자세한 설명은 따로 글을 작성해두었다.

https://hirev.tistory.com/79

 

[Java] 인터페이스(Interface)

인터페이스란? 자바에는 클래스 외에 인터페이스가 있다. 이는 자바에서 클래스들이 구현 해야하는 동작을 지정하는 용도로 사용되는 추상 자료형이다. class 대신에 interface라는 키워드를 이용

hirev.tistory.com

 

MemoryMemberRepository

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import java.util.*;
// implements 를 통해 interface 사용
// Alt + Enter 단축키를 통해 interface 메소드 모두 가져오기 가능
public class MemoryMemberRepository implements MemberRepository {

    // HashMap에 <key, value> 를 <Long, Member> 타입으로 저장
    // 데이터를 저장하기 위한 store 이라는 데이터 저장 HashMap 생성
    private static Map<Long, Member> store = new HashMap<>();
    // key 값을 생성해주는 역할
    private static long sequence = 0L;

    // 오버라이드를 통해 interface에 있는 메소드 가져옴
    // 기능 구현
    @Override
    public Member save(Member member) {
        // sequence: id값, 멤버를 저장할 때마다 순차적으로 값을 증가하면서 저장
        member.setId(++sequence);
        store.put(member.getId(), member); // HashMap 삽입
        return member;
    }
	
    // Optional: NPE 방지
    // store 라는 저장 객체에서 id 값을 얻음
    // key 값으로 찾기
    @Override
    public Optional<Member> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    // name 데이터 찾기
    @Override
    public Optional<Member> findByName(String name) {
        return store.values().stream()
                .filter(member -> member.getName().equals(name))
                .findAny();
    }

    // store에 있는 모든 값 찾기
    // store에 있는 value들(member) 반환
    @Override
    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }

    // Test 할 때마다 store를 비워주기 위함
    public void clearStore() {
        store.clear();
    }
}

MemoryMemberRepository.java

멤버 저장소를 구현하는 코드이다. 이 클래스는 MemberRepository 인터페이스를 참조하기 때문에 인터페이스에 있는 모든 메소드를 불러와 해당 클래스에서 구현할 수 있다.

메소드를 하나씩 살펴보도록 하자.

private static Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L;

제일 먼저 멤버들을 저장할 저장소가 필요하다. HashMap을 사용하였는데, 이는 <key : value>의 형태를 지닌다. 여기서는 id를 key로 사용해주고, value로 멤버 객체를 저장하기 위해 Member 타입을 지정해주었다.

 

사실 앞서 Member.java에서는 두 개의 변수 Long 타입 idString 타입 name이 저장되어 있었는데, 왜 이를 저장할 저장소를 만들어 줄 때는 <Long, Member> 형태인지 의아했었다.

해당 강의 Q&A에서 찾아본 결과, id인 key를 토대로 value인 Member 객체의 조회를 쉽게 하기 위함이었다.

이 궁금증에 대한 설명은 이 답변이 아주 이해가 잘 되게 설명 해주셨다!

https://www.inflearn.com/questions/228133

 

map<Long,Member>를 넣은 이유가 궁금합니다! - 인프런 | 질문 & 답변

Member class에는 long id, String name을 가지고 있어서 id값에 Long을 주는건 이해가 되는데 name값을  넣어주려면 Member가 아니라 String이 들어가야되는게 아닌가요?? 잘 이해가 안되서 설명 부탁드리겠습

www.inflearn.com

 

@Override
public Member save(Member member) {
    // sequence: id값, 멤버를 저장할 때마다 순차적으로 값을 증가하면서 저장
    member.setId(++sequence);
    store.put(member.getId(), member); // HashMap 삽입
    return member;
}

먼저 save() 메소드이다.

HashMap에 저장된 id와 member 객체를 넣고 member를 반환해준다. 단순히 HashMap인 store에 멤버를 save 해주는 용도이다.

 

@Override
public Optional<Member> findById(Long id) {
    return Optional.ofNullable(store.get(id));
}

id로 멤버를 조회하는 메소드이다. get() key를 통해 value를 찾을 수 있다.

 

@Override
public Optional<Member> findByName(String name) {
    return store.values().stream()
            .filter(member -> member.getName().equals(name))
            .findAny();
}

name으로 멤버를 조회하는 메소드이다.

파라미터로 넘어온 name과 member 객체에 있는 같은 이름을 찾는다.

여기서 findAny()filter 조건에 일치하는 element 1개를 Optional로 리턴한다.

비슷한 것으로 findFirst()도 있는데, 기능은 findAny()와 동일하나 이는 여러 요소가 조건에 부합해도 가장 Stream의 순서에서 앞에 있는 요소를 리턴한다. 반면 findAny()순서와 관계없이 가장 먼저 찾은 요소를 리턴한다. 만일 찾은 값이 없다면 null 값을 반환한다.

 

@Override
public List<Member> findAll() {
    return new ArrayList<>(store.values());
}

store에 있는 모든 값을 찾는 메소드이다.

이는 value로 member 객체가 저장되어 있기 때문에 value만 불러주면 되서 간단하다.

 

MemoryMemberRepositoryTest

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

public class MemoryMemberRepositoryTest {
    // 테스트를 위하여 MemoryMemberRepository 객체 생성
    MemoryMemberRepository repository = new MemoryMemberRepository();

    // 테스트 하나 돌릴 때마다 초기화를 위하여 clearStore() 사용
    // 보통 Test 메소드가 사용되고 난 후 종료되어야 할 리소스를 처리할 때 사용
    @AfterEach
    public void afterEach() {
        repository.clearStore();
    }

    // Test 어노테이션은 테스트를 만드는 모듈 역할을 한다.
    // @Test가 붙어있는 메소드는 main 메소드처럼 IDE로 직접 실행할 수 있는 메소드
    @Test
    public void save() {
        Member member = new Member(); // 멤버 객체 생성
        member.setName("spring"); // 이름에 spring 입력

        repository.save(member);
        Member result = repository.findById(member.getId()).get();
        assertThat(member).isEqualTo(result);
        // => 테스트 성공적: 데이터를 잘 저장하고 잘 받아오고 있음
    }

    @Test
    public void findByName() {
        // 객체 생성
        Member member1 = new Member();
        member1.setName("spring1");
        repository.save(member1);  // ID와 멤버 저장

        // 객체 생성
        Member member2 = new Member();
        member2.setName("spring2");
        repository.save(member2);  // ID와 멤버 저장

        Member result = repository.findByName("spring1").get();  // "spring1"을 찾아서 가져옴

        // 두 값이 일치하는지 확인
        assertThat(result).isEqualTo(member1);
    }
    @Test
    public void findAll() {
        // 객체 생성
        Member member1 = new Member();
        member1.setName("spring1");
        repository.save(member1);

        // 객체 생성
        Member member2 = new Member();
        member2.setName("spring1");
        repository.save(member2);

        // 저장되어 있는 데이터들 확인
        List<Member> result = repository.findAll();
        // 저장된 데이터의 수가 2가 맞는지 확인
        assertThat(result.size()).isEqualTo(2);
    }
}

MemoryMemberRepositoryTest.java

테스트를 위한 코드이다.

MemoryMemberRepository 객체를 생성하여 테스트하도록 한다.

이 테스트 코드에서 실제로 Member 객체를 생성하여 값이 올바르게 저장되고 찾을 수 있는지 테스트를 해보면 된다.

 

@Test
public void save() {
    Member member = new Member(); // 멤버 객체 생성
    member.setName("spring"); // 이름에 spring 입력

    repository.save(member);
    // Optional에서 값을 꺼낼 때 get()을 통하여 바로 꺼낼 수 있음
    Member result = repository.findById(member.getId()).get();
    assertThat(member).isEqualTo(result);
}

먼저 save()에 대한 테스트 코드이다.

객체를 생성하여 이름을 "spring"으로 하고 저장소에 저장해주었다.

findById()를 통해 id값으로 조회하여 value인 member 객체를 result 변수에 저장해주었다.

테스트를 할 때 JUnit의 assertThat 구문을 활용하였는데, 두 값이 일치하는지 확인하여 준다.

 

@Test
public void findByName() {
    // 객체 생성
    Member member1 = new Member();
    member1.setName("spring1");
    repository.save(member1);  // ID와 멤버 저장

    // 객체 생성
    Member member2 = new Member();
    member2.setName("spring2");
    repository.save(member2);  // ID와 멤버 저장

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

findByName()에 대한 테스트 코드이다.

member1과 member2 2개의 객체를 생성해보았다. 

name으로 멤버 객체를 찾는 역할을 한다.

 

@Test
public void findAll() {
    // 객체 생성
    Member member1 = new Member();
    member1.setName("spring1");
    repository.save(member1);

    // 객체 생성
    Member member2 = new Member();
    member2.setName("spring1");
    repository.save(member2);

    // 저장되어 있는 데이터들 확인
    List<Member> result = repository.findAll();
    // 저장된 데이터의 수가 2가 맞는지 확인
    assertThat(result.size()).isEqualTo(2);
}

findAll()에 대한 테스트코드이다.

객체 2개를 생성하고 저장된 데이터가 수가 2가 맞는지 확인하였다.

 

여기서 주의할 점은 각각의 테스트 케이스에서 만일 같은 이름의 객체를 생성하였다면, 정보가 중복으로 저장될 수 있음에 주의해야한다. 따라서 테스트가 끝날 때마다 비워주는 코드를 작성하는 것이 좋다.

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

@AfterEach는 테스트가 끝난 후 실행하라는 것인데, 보통 Test 메소드가 사용되고 난 후 종료되어야 할 리소스를 처리할 때 사용한다.

clearStore() 함수는 MemoryMemberRepository.java에 구현해놓았다.

 

MemberService

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;

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

// Test 파일 만들 때 단축키: 클래스 안에서 Ctrl + Shift + T
public class MemberService {
    private final MemberRepository memberRepository = new MemoryMemberRepository();

    // 회원 가입
    // 만일 같은 이름의 회원 가입을 불가능하다고 가정하였을 때(중복 member X)
    public Long join(Member member) {
        validateDuplicateMember(member); // 중복 회원 검증
        // result.get() : 그냥 단순하게 값을 꺼내고 싶다면 get() 사용 but 권장하지는 않음
        memberRepository.save(member); // 멤버 이름 저장 (ID는 자동 생성)
        return member.getId();
    }

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

    // 회원 조회 메소드
    public Optional<Member> findOne(Long memberId) {
        return memberRepository.findById(memberId);
    }

    // 단축키를 이용해 특정 부분 메소드 만들기: Ctrl + Alt + Shift
    // 중복 회원 검증 메소드
    private void validateDuplicateMember(Member member) {
        memberRepository.findByName(member.getName())
                .ifPresent(m -> { // ifPresent(): 만일 값이 있다면 동작
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });
    }
}

서비스에는 회원 가입, 전체 회원 조회, ID로 회원 조회 기능을 제공한다.

public Long join(Member member) {
    validateDuplicateMember(member); // 중복 회원 검증
    memberRepository.save(member); // 멤버 이름 저장 (ID는 자동 생성)
    return member.getId();
}

private void validateDuplicateMember(Member member) {
        memberRepository.findByName(member.getName())
                .ifPresent(m -> { // ifPresent(): 만일 값이 있다면 동작
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });

첫 번째로 회원 가입 기능이다.

여기서 우리는 같은 이름은 회원 가입할 수 없다는 가정을 했으므로, 따로 validateDuplicateMember() 메소드를 구현하였다. 이 메소드는 중복 회원을 검증하는 역할을 한다.

ifPresent()를 사용하여 name 값이 있을 경우 지정된 상세 메세지를 가지는 IllegalStateException을 구축한다. 여기서 throw고의로 예외를 발생시키고자 할 때 사용한다.

그리고 중복을 검증한 후 member를 저장하도록 한다.

 

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

findMembers()는 전체 회원을 조회한다.

 

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

findOne()은 해당 id에 맞는 member를 찾는다.

 

MemberServiceTest

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.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

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

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

    @Test
    void 회원가입() {
        // given
        Member member = new Member();
        member.setName("hello"); // member 객체에 이름 hello 저장

        // when
        Long saveId = memberService.join(member);

        // then
        Member findMember = memberService.findOne(saveId).get();
        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);
        try {
            memberService.join(member2);
            fail();
        } catch (IllegalStateException e) {
            assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
        }

        // then
    }

    @Test
    void findMembers() {
    }

    @Test
    void findOne() {
    }
}

MemberServiceTest.java

MemberService에 대한 테스트 코드이다.

실제 main 코드에서 한글로 된 메소드명을 쓰면 문제가 생길 수 있지만 Test 코드에서는 괜찮다.

 

[References]

김영한님의 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%9E%85%EB%AC%B8-%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8#

 

블로그의 정보

Hi Rev

Rev_

활동하기