본문 바로가기
3.1 SpringBoot/SpringBoot 강의정리

1-3. 회원 관리 예제

by Dohi._. 2023. 5. 14.
728x90

목차 

1. 비지니스 요구사항 정리

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

3. 회원 리포지토리 테스트 케이스 작성
4. 회원 서비스 개발
5. 회원 서비스 테스트

 

 

1. 비지니스 요구사항 정리

  요구사항

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

 

 

일반적인 웹 애플리케이션 계층 구조 


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


클래스 의존관계

merberService -> (interface) MemberRepository  <---- memoryMemberRepository

아직 데이터 저장소가 선정되지 않아서 우선 인터페이스로 구현 클래스를변할수있도록 설계

데이터 저장소는 RDB ,NoSql등등 다양한 저장소를 고민중인 상황으로 가정
개발을 진행하기위해서 초기개발단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용

 

 


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

  • 회원 객체
  • 회원 리포지토리 인터페이스
  • 회원 리포지토리 메모리 구현체

회원 객체(class Member)

domain이라는 package만든후 안에 생성

id(여기서는 고객이 만든 아이디가 아니라 임의의 값)

Getter Setter방식으로 주고 받을수 있게 메소드를 선언한다 (윈도우 기준 alt +Insert하면 편하게 가능)

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);
 Optional<Member> findById(Long id);
 Optional<Member> findByName(String name);
 List<Member> findAll();
}

class말고 Interface이다.

Optinal이란?

Optional은 Java 8에서 추가된 클래스로, null 값의 처리를 간편하게 할 수 있도록 도와주는 기능을 제공합니다.
Java에서 null 값은 오류를 발생시키는 원인 중 하나입니다. null 값이 반환될 수 있는 메소드를 호출하거나 null 값이 저장된 변수를 사용하면 NullPointerException과 같은 예외가 발생합니다.
Optional 클래스는 이러한 문제를 해결하기 위해 null 값 처리를 안전하게 수행할 수 있도록 도와줍니다. Optional 객체는 값이 존재할 수도 있고, 존재하지 않을 수도 있습니다. 값이 존재하는 경우에는 get() 메소드를 사용하여 값을 가져올 수 있고, 값이 존재하지 않는 경우에는 get() 메소드를 호출하면 예외가 발생하지 않고 빈 Optional 객체를 반환합니다.

public Optional<User> getUserById(Long id) {
}   // id에 해당하는 사용자 정보를 조회하여 반환하는 로직

Optional<User> user = getUserById(123L);

if (user.isPresent()) {
    User foundUser = user.get();
    // 사용자 정보가 존재하는 경우, 해당 정보를 사용하여 로직 수행
} else {
    // 사용자 정보가 존재하지 않는 경우, 로직 수행
}

위의 코드에서 getUserById 메소드는 id에 해당하는 사용자 정보를 조회하여 Optional<User> 객체를 반환합니다.
이후에는 Optional의 isPresent() 메소드를 사용하여 Optional 객체가 비어있지 않은지 확인하고,
get() 메소드를 사용하여 사용자 정보를 가져옵니다.
이를 통해 null 값 처리를 안전하게 수행할 수 있습니다

 

 

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

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

 

 

 

현재 HashMap을 사용하고 있다.  그리고 sequence를 Long타입으로 정하였다. 여기서는 단순한 예제이기 때문에 동시성 문제를 고려하지 않고, HashMap, Long을 사용하고 있다.

동시성 문제가 있을 수 있어서 공유되는 변수일 때는 ConcurrentHashMap와  AtomicLong을 고려하자.

 


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

개발한 기능을 테스트 -> main으로 하거나 웹 어플리케이션의 컨트롤러를 통해서 해당 기능을 실행.

하지만 준비하는데 오래걸리고 반복실행하기 어렵기때문에 여러번 테스트를 한번에 하기 어렵다는 단점이 있음
자바는 JUnit이라는 프레임워크로 테스트를 실행해서 이러한 문제를 해결한다.

 

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

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

main이 아닌 test에서 진행한다.

 

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

코드에 사용된 간단한 메소드/애노테이션

 

@Test

JUnit과 같은 자바 단위 테스트 프레임워크에서 사용되는 애노테이션입니다.

@Test는 테스트 메소드를 지정하는 데 사용됩니다.

@Test 애노테이션을 붙인 메소드는 JUnit 테스트 도구에서 실행되며,

해당 메소드의 실행 결과에 따라 테스트가 성공적으로 완료되었는지 여부를 판단합니다.


Assertions

JUnit 프레임워크는 assert를 포함한 다양한 Assertions 메소드를 제공합니다.

이를 사용하여 테스트 코드에서 예상한 결과가 실제 결과와 일치하는지를 검사할 수 있습니다.

JUnit에서 제공하는 대표적인 Assertions 메소드는 다음과 같습니다.

 

  • assertEquals(expected, actual) : expected와 actual 값이 동일한지를 비교합니다.
  • assertNotEquals(expected, actual) : expected와 actual 값이 서로 다른지를 비교합니다.
  • assertTrue(condition) : condition이 true인지 검사합니다.
  • assertFalse(condition) : condition이 false인지 검사합니다.
  • assertNull(object) : object가 null인지 검사합니다.
  • assertNotNull(object) : object가 null이 아닌지 검사합니다.
  • assertSame(expected, actual) : expected와 actual이 같은 객체를 참조하는지 검사합니다.
  • assertNotSame(expected, actual) : expected와 actual이 서로 다른 객체를 참조하는지 검사합니다.

assertThat()

AssertJ 라이브러리에서 제공하는 메소드 중 하나입니다.

assertThat() 메소드는 특정 값을 비교할 때 사용합니다.

일반적으로는 JUnit의 assertEquals()와 유사한 역할을 합니다.

그러나 assertThat()은 가독성이 높고, 테스트 코드 작성 시 자연어와 비슷한 문법을 사용하여 코드를 더 명확하고 이해하기 쉽게 만들어줍니다.

예를 들어, 다음과 같은 코드가 있다고 가정해봅시다.

int num = 5;
assertEquals(5, num);
 

위의 코드는 JUnit의 assertEquals()를 사용하여 num의 값이 5와 같은지를 검사합니다. 이를 AssertJ의 assertThat()을 사용하여 작성하면 다음과 같습니다.

int num = 5;
assertThat(num).isEqualTo(5);
 

위의 코드는 num의 값이 5와 같은지를 검사하는데, 이를 자연어처럼 표현하여 가독성을 높였습니다. 이외에도 assertThat() 메소드를 사용하여 다양한 비교 연산을 수행할 수 있습니다.

 


@AfterEach

각 테스트가 종료될 때마다 이 기능을 실행한다(각 메서드가 호출될 때마다 호출되는 콜백 메서드라고 생각하면 된다). 여기서는 메모리 DB에 저장된 데이터를 삭제한다.

 

@BeforeEach 

각 테스트 실행 전에 호출된다.

테스트가 서로 영향이 없도록 항상 새로운 객체를 생성하고, 의존관계도 새로 맺어준다

 

 

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


4. 회원 서비스 개발 

memberservice

package hello.hellospring.service;

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

validateDuplicateMember()

회원가입 하기 전에 중복 회원이 있는지 확인을 하고 중복 이름이 있다다면

ifPresent()메소드(Optional에서 ifPresent사용)를 사용하여 m 객체가 값이 있는 경우(Optional 객체가 비어있지 않은 경우)

IllegalStateException 예외를 던진다.

 

일단 Optional로 한 번 감싸면 Optional 안에 Member가 있기에

findByName의 리턴값이 Optional이기 때문에 가능한 것이다.

 

이러한 방식으로 Optional을 사용하면,

null체크나 if문을 사용하여 예외처리를 하는 등의 코드를 줄일 수 있으며, 가독성과 안정성을 높일 수 있다.

 

 

null일 가능성이 있을 경우에는 Optional로 감싸서 반환을 해주고, ifPresent를 사용한다. 

 

 


5.회원서비스 텍스트

테스트 케이스 메서드명은 한글로 작성하는 경우도 종종 있다. 직관적이고 빌드에 포함되지도 않기 때문이다.

 

테스트를 작성할 때 given, when, then으로 나누어 작성하는 것이 좋다. 

//Given   테스트에서 구체화하고자 하는 행동을 시작하기 전에 테스트 상태를 설명하는 부분

//When   구체화하고자 하는 그 행동

//Then    어떤 특정한 행동 때문에 발생할거라고 예상되는 변화에 대한 설명

 

다음과 같이 주석으로 given, when, then으로 적어두고 해당하는 코드를 작성하면 직관적이다.

상황에 따라 이에 맞지 않을 때는 변형하여 작성하면 된다.

 

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

public class MemberService {
  private final MemberRepository memberRepository = new MemoryMemberRepository();
}
public class MemberServiceTest {
  MemberService memberService = new MemberService;
  MemberRepository memberRepository = new MemoryMemberRepository();
  //.. 
}


회원 리포지토리의 코드가

회원 서비스 코드를 DI 가능하게 변경한다.

public class MemberService {

  private final MemberRepository memberRepository;

  public MemberService(MemberRepository memberRepository) {
    this.memberRepository = memberRepository;
  }
  ..
}
public class MemberServiceTest {
  MemberService memberService;
  MemberRepository memberRepository;
  
  @BeforeEach
  public void beforeEach() {
    memberRepository = new MemoryMemberRepository();
    memberService = new MemberService(memberRepository);
  }
}


지금 문제는 MemberService에서 생성한 memberRepository와 테스트 케이스에서 만든 memberRepository가

서로 다른 Instance라는 것이다.

지금은 memberRepository의 store 객체가 static이기 때문에 문제가 되지 않지만

만약 static이 아니라면 문제가 생길 것이다. 


그래서 같은 인스턴스를 사용하도록 바꿔야 한다. 

MemberRepository를 MemberService에서 직접 생성안하고 생성자 파라미터를 통해 외부로부터 주입받도록 바꿔준다.

이러한 것을 DI(의존성 주입,Dependency Injection)이라고 한다.

의존성 주입을 사용하면 MemberService가 MemberRepository를 직접 생성하지 않기 때문에,

MemberRepository가 변경되더라도 MemberService를 수정할 필요가 없습니다.

이는 유지보수성을 높이고, 코드의 결합도를 낮추는 효과가 있습니다.

 

Instance(인스턴스)

 객체 지향 프로그래밍에서 클래스로부터 생성된 구체적인 실체(객체)를 의미합니다.

클래스는 일종의 틀이며, 이를 이용해 여러 개의 인스턴스를 생성할 수 있습니다.

이러한 인스턴스는 서로 다른 상태를 가질 수 있으며, 메소드를 호출하여 특정 작업을 수행할 수 있습니다.


회원 서비스 테스트

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

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

class MemberServiceTest {

  MemberService memberService;
  MemoryMemberRepository memberRepository;

  @AfterEach
  public void afterEach() {
    memberRepository = new MemoryMemberRepository();
    memberService = new MemberService(memberRepository);
    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
  public 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("이미 존재하는 회원입니다.");
        /*
        try {
            memberService.join(member2);
            // Exception이 발생되지 않고 넘어가면 실패한 것이기 때문에 fail()
            fail();
        } catch (IllegalStateException e) {
            assertThat(e.getMessage().isEqualTo("이미 존재하는 회원입니다."));
        }
         */
  }

  @Test
  void findMembers() {

  }

  @Test
  void findOne() {
  }
}

+)

테스트 클래스 자동 생성 단축키 Ctrl + Shift + T
이전 실행한 것 다시 실행 Shift + F10

728x90

댓글