[Spring] 회원 관리 예제

김민범·2024년 10월 18일

Spring

목록 보기
4/29

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


회원 객체

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

회원 리포지토리 인터페이스

package hello.hellospring.repository;

import hello.hellospring.domain.Member;

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

public interface MemberRepository { // 인터페이스로 추상메소드만 구현해논다.
		Member save(Member member); // 리턴 타입 -> Member
		Optional<Member> findById(Long id); // 리턴타입 -> Optional
		Optional<Member> findByName(String name);
		List<Member> findAll();
}

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

package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import java.util.*;

/**
 * 동시성 문제가 고려되어 있지 않음, 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려
 */
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 List<Member> findAll() {
				return new ArrayList<>(store.values());
    }

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

		public void clearStore() {
        store.clear();
    }
}
  • Optional
    • java8 부터 추가된 기능

    • Optional 클래스는 Java 8에서 소개된 클래스로, null 값의 처리를 보다 효율적으로 할 수 있도록 도와줍니다.

    • Optional로 객체를 감싸서 사용하게 되면 NullPointException(NPE) 방지를 위해 null 체크를 직접 하지 않아도 되며, 명시적으로 해당 변수가 null일 수도 있다는 가능성을 포함하고 있기때문에 불필요한 방어 로직을 줄일 수 있다.

      Optional 생성하기 ( of , ofNullable )

    • .empty()

      • 빈 Optional 객체는 아래와 같이 생성할 수 있다. (Optional 객체 자체는 있지만 내부에서 가리키는 참조가 없는 경우를 말함)
        Optional<String> optional = Optional.empty(); 
        
        System.out.println(optional); //결과 : Optional.empty 
        System.out.println(optional.isPresent()); //결과 : false
    • .of()

      • value가 null인 경우는 NullPointException 발생하기 때문에 값이 반드시 있는 경우에만 of() 메소드를 사용해야 한다.
        Optional<String> optional = Optional.of(value);
    • .ofNullable()
      - value가 null이여도 NullPointException이 발생하지 않고, Optional.empty가 리턴된다.

          ```java
          Optional<String> optional = Optional.ofNullable(value);
          System.out.println(optional);//결과 : Optional.empty
          ```
          

      Optional이 제공하는 메소드

    • filter()

      • filter 메소드의 인자인 람다식이 true이면 Optional 객체를 그대로 통과시키고, false이면 Optional.empty()를 리턴하여 추가로 처리가 되지 않도록 한다.
        String result1 = Optional.of("ABCDE").filter((val) -> val.contains("ABC")).orElse("Does not contain ABC");
        System.out.println(result1); //ABCDE
        
        String result2 = Optional.of("CDEFG").filter((val) -> val.contains("ABC")).orElse("Does not contain ABC");
        System.out.println(result2); //Does not contain ABC
    • map()

      • 입력받은 값을 다른 값으로 변환하는 메서드이다.
        String result3 = Optional.of("abcde").map(String::toUpperCase).orElse("fail");
        System.out.println(result3); //ABCDE
    • isPresent()

      • 값이 있으면 true를 반환하고 그렇지 않으면 false를 반환한다.
    • ifPresent()

      • 람다식을 인자로 받는데, 값이 존재할 때만 람다식이 적용된다. 값이 존재하지 않으면 실행되지 않는다.
        Optional.of("ABCDE").ifPresent(System.out :: println); //결과 : ABCDE
        
        Optional.ofNullable(null).ifPresent(System.out :: println); //결과 : 아무것도 출력되지 않음
        
    • get()

      • Optional 객체가 가지고 있는 value를 가져온다. 만약 객체에 값이 없다면 NoSuchElementException이 발생한다.
    • orElse()

      • Optional 객체가 비어 있다면 orElse() 메소드의 지정된 값으로 리턴된다.
    • orElseGet()

      • Optional 객체가 비어 있다면  기본값으로 제공할 supplier를 지정한다. orElse()의 경우 값이 null이든 아니던 호출 되며, orElseGet()은 null일 때만 호출된다.
    • orElseThrow()

      • 연산을 끝낸 후에도 Optional 객체가 비어 있다면 예외 공급자 함수를 통해 예외를 발생시킨다.
        String result1 = Optional.of("CDEFG").filter((val) -> val.contains("ABC")).orElseThrow(NoSuchElementException::new);
        System.out.println(result1);
        
        //결과
        Exception in thread "main" java.util.NoSuchElementException
        	at java.base/java.util.Optional.orElseThrow(Optional.java:385)

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


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

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

src/test/java 하위 폴더에 생성한다.

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 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() {
				//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(); // get()을 붙여주는 이유?
				// findByName의 return 타입은 Optional이다. 아래 assertThat에서 result가 member1과 같은지 테스트를 하기 때문에 result의 자료형은 Member이어야 한다. 그러므고 Optional에서 get()을 사용하여 그 안에 있는 Member 자료형을 가져오는것!

				//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);
    }
}
  • @AfterEach : 한번에 여러 테스트를 실행하면 메모리 DB에 직전 테스트의 결과가 남을 수 있다. 이렇게 되면 다음 이전 테스트 때문에 다음 테스트가 실패할 가능성이 있다. @AfterEach 를 사용하면 각 테스트가 종료될 때 마다 이 기능을 실행한다. 여기서는 메모리 DB에 저장된 데이터를 삭제한다.
  • 테스트는 각각 독립적으로 실행되어야 한다. 테스트 순서에 의존관계가 있는 것은 좋은 테스트가 아니다.

회원 서비스 개발


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 = new MemoryMemberRepository();
    
		/**
     * 회원가입
     */
		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);
    }
}
  • 중복회원 검증에서 findByName의 결과값이 null일 경우가 있다.
  • 하지만 우리는 이미 findByName의 return값을 Optional로 감쌌기 때문에, ifPresent 문법을 사용하여 편리하게 null을 처리할 수 있다.
  • ifPresent
    • Void 타입
    • Optional 객체가 값을 가지고 있으면 실행 / 값이 없으면 넘어감
  • isPresent()
    • Boolean 타입
    • Optional 객체가 값을 가지고 있으면 true, 없으면 false 리턴

회원 서비스 테스트


기존에는 회원 서비스가 메모리 회원 리포지토리를 직접 생성하게 했다. → 테스트와 직접 서비스를 할때와 같이 각 상황마다 다른 객체의 회원 레포지토리가 생성되는 문제가 발생한다.

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

회원 리포지토리의 코드가 회원 서비스 코드를 DI 가능하게 변경한다.

public class MemberService {

		private final MemberRepository memberRepository;

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

회원 서비스 테스트

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
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(memberRepository);    
		}
    
		@AfterEach
		public void afterEach() {
        memberRepository.clearStore();
    }
    
		@Test
		public void 회원가입() throws Exception { 

				//Given
				Member member = new Member();
        member.setName("hello");

				//When
				Long saveId = memberService.join(member);
				
				//Then
				Member findMember = memberRepository.findById(saveId).get();
				assertEquals(member.getName(), findMember.getName());
    }

    @Test
		public void 중복_회원_예외() throws Exception { // 테스트는 예외플로우가 훨씬 중요!
				//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("이미 존재하는 회원입니다.");
//        try {
//            memberService.join(member2);
//            fail();
//        } catch (IllegalStateException e) {
//            assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
//        }
    }
}
  • @BeforeEach : 각 테스트 실행 전에 호출된다. 테스트가 서로 영향이 없도록 항상 새로운 객체를 생성하고, 의존관계도 새로 맺어준다.
✅ MemberService 에서는 MemberRepository를 사용하고, 테스트에서는 MemoryMemberRepository를 사용한 이유?

→ 생성자로 들어올 인자로서 MemberRepository 인터페이스를 사용한 것이지, MemberRepository만 사용하겠다는 것은 아니다. 왜냐하면 MemoryMemberRepository는 MemberRepository를 implements 하였기 때문에 IS-A 관계가 성립하고, 이 덕분에 MemoryMemberRepository를 생성자의 인자로서 넣어줄 수 있기 때문이다.

또한 추후에 DB를 바꿀 수 도 있기 때문에 구현체인 MemoryMemberRepository를 사용하는 것 보단 인터페이스를 사용하여 생성자를 만들어 둔 것이 코드를 수정할 양이 적어지는 이점도 있다.

0개의 댓글