3. 회원 관리 예제 - 백엔드 개발

주세환·2025년 9월 9일
0

spring boot

목록 보기
1/1
post-thumbnail

비즈니스 요구사항 정리

  • 데이터 : 회원ID, 이름

  • 기능 : 회원 등록, 조회

  • 아직 데이터 저장소가 선정되지 않음(가상의 시나리오)

  • 컨트롤러 : 웹 MVC의 컨트롤러 역할

  • 서비스 : 핵심 비즈니스 로직 구현

  • 리포지토리 : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리

  • 도메인 : 비즈니스 도메인 객체, 예) 회원, 주문, 쿠폰 등등 주로 데이터베이스에 저장하고 관리됨

    클래스 의존관계

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

  • 데이터 저장소는 RDB, NoSQL 등등 다양한 저장소를 고민중인 상황으로 가정

  • 개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용


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

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

domain이라는 Package를 만들고, Member라는 Class를 생성한다.

package study.hello_spring.Repository;

import study.hello_spring.domain.Member;

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

public interface MemberRepository {
    Member save(Member member); // 저장
    Optional<Member> findById(Long id); // id로 찾기
    Optional<Member> findByName(String name); // 이름으로 찾기
    List<Member> findAll(); // 저장된 모든 회원 리스트를 반환
}

Repository Package를 생성하고 MemberRepository Interface를 생성한다.

package study.hello_spring.Repository;

import study.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;
	// 동시성 문제가 고려되어 있지 않음, 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려

    @Override
    public Member save(Member member) {
        member.setId(++sequence); // sequence 값 증가
        store.put(member.getId(), member);
        return member;
    }

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

    @Override
    public Optional<Member> findByName(String name) {
        return store.values().stream() //컬렉션(List, Set 등)에서 호출하는 메서드
                .filter(member -> member.getName().equals(name)) // 스트림 안의 요소들을 조건에 맞는 것만 걸러내기
                .findAny(); // 스트림에서 조건에 맞는 요소 중 아무거나 하나를 꺼냄
    }

    @Override
    public List<Member> findAll() {
        return new ArrayList<>(store.values()); // ArrayList : 자바스크립트 배열처럼 가변적인 리스트
    }
}

설명은 코드에 주석으로 작성

MemoryMemberRepository 클래스를 생성하여 리포지토리를 만들었다.

이제 테스트를 할 차례이다.


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

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

test에 main과 같이 repository 패키지를 생성하고 MemoryMemberRepositoryTest 클래스를 생성한다.

package study.hello_spring.repository;

import org.junit.jupiter.api.Test;

class MemoryMemberRepositoryTest {

    MemberRepository repository = new MemoryMemberRepository();

    @Test
    public void save(){

    }
}

그 후에 좌측 실행 버튼을 클릭하면

위 사진과 같이 Run 탭이 실행된다면 성공이다.


save

@Test
public void save(){
    Member member = new Member();   // member 생성
    member.setName("spring");   // 'spring' 이라는 이름을 설정 

    repository.save(member);    // repository에 member 저장

    Member result = repository.findById(member.getId()).get();  // member.getId()로 Id를 불러오고, findbyId로 repository에서 member를 찾은 후 get()으로 가져옴
    System.out.println("reuslt = " + (result == member));
}

true가 출력된다.

Assertions.assertEquals(member, result);	// 기대값과 실제값을 비교

System.out.print 대신, 값을 비교하는 매서드인 Assertion.assertEquals를 사용해보자.

완료 마크가 보인다.

member와 result가 같다는 뜻이다.

그럼 result 대신 null을 넣으면 어떻게 될까?

위 사진과 같이 오류가 발생한다.

Assertions.assertThat(member).isEqualTo(result);

또한 비교하는 매서드이다.

Assertions에 커서를 두고 option + enter를 누르면 static import를 할 수 있다.

import static이 추가되고, Assertions. 없이 assertThat을 바로 사용할 수 있다.


findByName

@Test
public void findByName(){
    Member member1 = new Member();  // member1 생성
    member1.setName("spring1");     // 'spring1' 지명
    repository.save(member1);       // member1에 저장

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

    Member result = repository.findByName("spring1").get(); // result에 "spring1" 으로 findByName

    assertThat(result).isEqualTo(member1);
}

이번엔 findByName 테스트해보자.

member1에 setName("spring1")을 했으니 성공한 모습.

repository.findByName("spring2").get(); 으로 수정하고 실행하면

예상대로 오류가 발생한다.


테스트를 동시에 실행하려면 위 사진과 같이 class에서 실행하면 해당 클래스에 있는 모든 테스트를 실행할 수 있다.

  • 단축키

shift + F6을 누르면 위 사진과 같이 rename이 가능하다.


findAll

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

이번에도 member1, member2를 만든 후 테스트해보자.

result.size가 2이므로 정상적으로 작동한다.

isEqualTo(3)으로 수정하면

예상대로 오류가 발생한다.


AfterEach

정상적으로 작동하도록 코드를 수정하고 class단에서 테스트를 모두 실행했더니 위 사진처럼 오류가 발생한다.

같은 저장소를 사용하고 있어 테스트 간 간섭이 발생하여 오류가 발생하는 것이다.

class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

    @AfterEach
    public void afterEach(){
        repository.clearStore(); // 저장소 클리어
    }
    
    ...
    

MemoryRepository를 MemoryMemberRepository로 변경 후

MemoryMemberRepository.java 파일에

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

위 코드를 추가해준다.

@AfterEach는 각 테스트가 끝난 후 실행된다는 뜻이다.

각 테스트 후 clearStore()를 실행해주면서 테스트 간 간섭이 일어나지 않는다.

  • 같은 저장소를 사용하고 있어 테스트 간 간섭이 발생한다.
  • 테스트 순서는 보장할 수 없다.
  • 테스트 하나가 끝나고 나면 데이터를 클리어해줘야 한다.

회원 사이트 개발

repository 아래에 service 패키지를 만들고 MemberService 클래스를 생성한다.

package study.hello_spring.repository.service;

import study.hello_spring.domain.Member;
import study.hello_spring.repository.MemberRepository;
import study.hello_spring.repository.MemoryMemberRepository;

import java.util.Optional;

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

    /*
     * 회원 가입
     */
    public Long join(Member member) {
        // 같은 이름이 있는 중복 회원 x
        Optional<Member> result = memberRepository.findByName(member.getName());    // Optional : 값이 있을 수도 있고 없을 수도 있는 상황을 표현하는 "상자" 같은 객체
        result.ifPresent(m -> { // ifPresent : 값이 있으면
            throw new IllegalStateException("이미 존재하는 회원입니다.");  // throw : 예외(에러)를 던짐, IllegalStateException : 자바 런타임 예외 클래스
        });
		// result.orElseGet 도 있음
        memberRepository.save(member);
        return member.getId();
    }
}

이름이 중복되지 않게 설계했으니
이미 중복된 이름이면 throw, 중복되지 않은 이름이면 save 후 getId()

Optional<Member> result = memberRepository.findByName(member.getName()); 
        result.ifPresent(m -> { 
            throw new IllegalStateException("이미 존재하는 회원입니다.");  
        });

부분을

memberRepository.findByName(member.getName())
                .ifPresent(m -> {
                throw new IllegalStateException("이미 존재하는 회원입니다.");
            });

이렇게 표현할 수 있다.

위 코드를 선택하고 control + t 를 누르면

위 사진과 같이 refactor 할 수 있다.

Extract Method를 사용하면

위 사진과 같이 메서드가 생성된다.


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

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

회원 조회하는 메서드도 작성하고 테스트해보자.


회원 서비스 테스트

지난 테스트에서는 test에 패키지를 만들고 직접 작성했는데 간단한 방법이 있다.

클래스 내부에 커서를 잡고 command + shift + t 단축키를 입력하면 위 사진처럼 Create 할 수 있다.

  • Testing library는 JUnit5로 두고
  • Class name을 입력한 후
  • Test 하고 싶은 메서드를 선택
  • OK 클릭

간단하게 Test용 껍데기 파일이 생성된다.

Build 될 때 Test 코드는 실제 코드에 포함되지 않는다.

그래서 직관적으로 알아볼 수 있도록 과감하게 한글로 바꿔도 무방하다.


회원가입

MemberService memberService = new MemberService();

memberService를 새로 생성하고

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

given, when, then 주법으로 테스트하는 걸 추천한다

member를 생성하여 setName("hello")
memberService에 방금 생성한 member를 join
결과 확인

정상적으로 테스트 성공

하지만 테스트는 성공보단 예외(실패)가 더 중요하다.

중복 가입을 테스트해보자.


중복가입

@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("이미 존재하는 회원입니다. 123"); // 실패 메세지와 작성한 메세지가 같은지 확인
    }

    // then
}

member1, member2에 둘 다 "spring" 이라고 setName을 한 후
memberService에 join을 해보았다.

실패 메세지는 "이미 존재하는 회원입니다." 이지만 뒤에 123을 붙여서 두 메세지가 같지 않으니 위 사진처럼 오류가 발생한다.

근데 이렇게 try catch를 사용하려니 뭔가 애매하다.

assertThrows를 사용해보자.

// when
memberService.join(member1);
assertThrows(IllegalStateException.class, () -> memberService.join(member2));

assertThrows(예외클래스, 실행할코드);

  • 예외클래스 → 어떤 예외가 발생해야 하는지 지정
  • 실행할코드 → 예외가 발생할 것으로 기대하는 코드(람다로 작성)

assertThrows는 "이 코드 실행했을 때 정말로 IllegalStateException이 던져지는지 확인"하는 역할을 한다.

  • memberService.join(member2) 실행 도중 IllegalStateException이 발생하면
    → 예상한 대로 동작했으므로 테스트 성공
  • 만약 예외가 발생하지 않거나, 다른 종류의 예외가 발생하면
    → 예상과 다르므로 테스트 실패

IllegalStateException이 발생하므로 테스트 결과는 통과인 것이다.


만약 회원 가입의 setName에 "hello" 가 아닌 "spring"을 넣고 전체 실행하면 오류가 발생한다.

왜? 테스트하면서 "spring"이라는 member는 이미 memberRepository에 저장되어있기 때문이다.

그럴 땐 아까 배운

MemoryMemberRepository memberRepository = new MemoryMemberRepository();

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

를 추가해주면 clear되기 때문에 정상적으로 작동한다.


테스트 할 때

MemoryMemberRepository memberRepository = new MemoryMemberRepository();

를 작성해서 하였다.

하지만 new로 새로 생성하기 때문에 memberService에서 사용하는 repository와 테스트 케이스에서 사용하는 repository는 서로 다르다.

이런 경우 테스트하면서 문제가 발생할 수 있다.

그럴 땐

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

외부에서 넣어주도록 바꿔준다.

테스트 케이스에서도

class MemberServiceTest {

    MemberService memberService;
    MemoryMemberRepository memberRepository;
    
    @BeforeEach	// 각 테스트 실행 전
    public void beforeEach(){
        memberRepository = new MemoryMemberRepository();
        memberService = new MemberService(memberRepository);
    }

위처럼 수정한다.

new MemberRepository로 생성 후 memberRepository에 넣고,
MemberService에 넣어준다.

이러면 같은 repository를 사용하게 된다.

위와 같은 구조를 DI는 Dependency Injection (의존성 주입) 라고 한다.

0개의 댓글