본문 바로가기
  • 개발공부 및 일상적인 내용을 작성하는 블로그 입니다.
Spring basic

스프링 입문 : 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 - 회원 서비스 개발

by 방구석 대학생 2021. 11. 3.

"인프런의 스프링 입문 - 코드로 배우는 스프링 부트, 웹 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

 

[무료] 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 - 인프런 | 강의

스프링 입문자가 예제를 만들어가면서 스프링 웹 애플리케이션 개발 전반을 빠르게 학습할 수 있습니다., 스프링 학습 첫 길잡이! 개발 공부의 길을 잃지 않도록 도와드립니다. 📣 확인해주세

www.inflearn.com

 

MemberService - 회원 Repository 와 도메인을 활용하여 실제 비즈니스 로직을 작성하는 파트

다음과 같이 코드를 작성해보자.

- MemberService

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

join 메소드를 보면 validateDuplicateMember() 메소드를 호출하여 회원 가입시 같은 이름을 가진 회원, 즉 중복된 이름을 가진 회원이 존재하는지를 검증하고 있다.

여기서 validateDuplicateMember 메소드를 보면 Optional<T> 클래스 타입의 데이터를 반환하는 findByName 메소드의 반환값에 ifPresent 메소드와 람다식을 붙여서 반환값이 있는지 없는지를 검증하고 있는것을 알 수 있다.

최종적으로 반환값이 존재할 경우 throw 키워드를 통해 IllegalStateException 을 발생시키면서 중복된 회원 이름이 존재함을 알려준다.

 

과거에는 if 조건문을 이용해서 반환값이 null 값인지 아닌지를 판단했지만 요즘은 반환값이 null 일 가능성이 있으면 Optional<T> 클래스 타입으로 한번 감싸서 반환해준다고 한다.

위와 같은 과정없이 그냥 꺼낸다음 if 조건문으로 검사하고 싶으면 그냥 Optional<T> 타입 반환값에 get() 메소드를 사용해서 값을 꺼내도 무방하다. 추가적으로 orElseGet() 메소드로 꽤 많이 사용된다고 한다.

(한창 pro.gg 프로젝트를 진행할때 반환값이 null 값인지 아닌지를 판별해야 하는 경우가 꽤 있었던것 같은데 진작에 Optional<T> 클래스 타입에 대해 알았더라면 작업이 좀 더 쉬워졌을 거란 아쉬움이 생기는듯 하다.)

 

이쯤에서 알 수 있는 사실?

Repositrory 의 경우 getId, findByName 등 메소드들의 이름이 데이터베이스 접근적인 느낌이 강한 반면

Service 의 경우 join, validateDuplicateMember 등 메소드의 네이밍이 비즈니스 로직에 가까운 느낌이 강하다.

Service 클래스의 경우 네이밍을 비즈니스 로직을 가깝게 작성해주는 것이 좋다.

다른 개발자 들과 협업을 하면서 사람이 바뀌는 경우가 생길 수 있는데, 그런 경우라도 메소드의 이름을 잘 정해두면 이제 막 프로젝트에 투입되어서 프로젝트에 대한 숙련도가 부족한 개발자라고 해도 쉽게 본인이 찾고자 하는 메소드를 찾아낼 수 있게 된다.

* Service 는 비즈니스 로직에 의존적이게 설계하고, Repository 는 좀 더 기계적인 느낌(데이터베이스 접근) 으로 설계하는 것이 좋다.(데이터 검색, 추출 등)

 

검증 메소드도 작성되었겠다, 회원 이름 중복 검증 메소드에 작성된 코드대로 IllegalStateException 이 잘 발생하는지 확인해보자.

* 테스트 코드 메소드의 경우 굳이 영어가 아니라 한글로 해도 무방하다, 테스트의 경우 영어권 사람들과 협업을 하는 것이 아니라면 실제 사용되는 코드와 달리 한글로도 많이 작성하는 편이다. 테스트 코드는 빌드 될 때 실제 코드에 포함되지 않는다.

- MemberServiceTest.java

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
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 = new MemberService();
    MemoryMemberRepository memberRepository = new MemoryMemberRepository();
    
    @Test
    void 회원가입(){
    	// 테스트 코드를 작성할 때 추천하는 양식 - given, when, then
        // 테스트 코드를 작성할 때 내용이 길어질 경우 각 역할을 하는 코드들을 위와 같은 주석들을 통해 분단하여
        // 각 역할을 하는 코드들을 좀 더 직관적으로 명확하게 확인할 수 있다.
        
        // given
        Member member = new Member();
        member.setName("spring");
        
        //when
        Long saveId = memberService.join(member);
        
        //then
        Member findMember = memberService.findOne(saveId).get();
        // JUnit 의 Assertion 이 아닌 assertj 의 Assertion 을 활용한다.
        assertThat(member.getName()).isEqualTo(findMember.getName());
    }
}

위와 같이 코드를 작성한 후 테스트를 수행하면 잘 동작하는 것을 확인할 수 있다.

그러나 위의 코드의 경우 너무 단순하다는 문제점이 있다.

테스트는 정상 동작을 테스트 하는것도 중요하지만 예외 처리에 대한 테스트를 하는 것도 굉장히 중요하다.

위의 join 메소드 테스트는 동일한 이름의 회원가입에 대한 예외처리가 되어 있지 않으므로 반쪽짜리 테스트라고 할 수 있다.

 

그렇다면 중복된 이름의 회원이 등록되려 할 경우 익셉션이 정상적으로 잘 발생하는지 확인하는 테스트 코드를 작성해보자.

- MemberServiceTest.java

@Test
public void 중복_회원_예외(){
	
    //given
    Member member1 = new Member();
    member1.setName("spring");
    
    Member member2 = new Member();
    member2.setName("spring"); // 동일한 이름 중복처리 예외 테스트
    
    // when
    memberService.join("member1");
    
    // then
    try {
    	memberService.join(member2);
        // 동일한 이름의 회원이 가입하려고 하므로
        // validateDuplicateMember 메소드를 통해 익셉션이 발생한다.
        fail(); 
        // 익셉션이 발생하지 않고 코드가 정상적으로 동작하게 될 경우를 위해 
        // fail() 메소드를 사용한다.
    } catch(IllegalStateException e){
    	// 예외가 발생하면 정상적으로 수행되는 코드 구간
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
        // 오류 메세지가 validateDuplicateMember 메소드에 적혀있는
        // 전달 메세지와 같은지 확인한다.
        // 메세지 내용이 다를 경우 테스트가 실패한다.
    }
}

위와 같이 익셉션이 발생할 경우 처리를 해주기 위해 try - catch 문을 사용해 줄 수도 있다.

그러나 try - catch 문을 사용하지 않고도 예외 처리 테스트를 할 수 있도록 문법을 제공해주는 것이 있다.

 

위의 소스 코드를 아래와 같이 고쳐보자.

- MemberServiceTest.java

@Test
public void 중복_회원_예외(){
	
    //given
    Member member1 = new Member();
    member1.setName("spring");
    
    Member member2 = new Member();
    member2.setName("spring"); // 동일한 이름 중복처리 예외 테스트
    
    // when
    memberService.join("member1");
    
    // then
    IlleagalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
    assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
}

위의 소스 코드는 람다식을 통해 실행되는 join 메소드에서 IllegalStateException 이 throw 되는지 확인한다.
오류 전달 메세지 또한 확인이 가능하다.

 

 

DI(Dependency Injection) 을 구현해보자.

기존의 회원 서비스를 보면 MemberService 가 MemberRepository 를 직접 생성하게 했었다.

* MemberService 에서의 MemberRepository 생성

    - private final MemberRepository memberRepository = new MemoryMemberRepository();

    - 회원 서비스가 회원 레포지토리를 직접 생성하는 코드

그런데 여기서 MemberServiceTest 에서 생성한 MemberRepository 를 비교해보자.

* MemberServiceTest 에서의 MemberRepository 생성 : 

    - MemberService memberService = new MemberService();

    - MemoryMemberRepository = memberRepository = new MemoryMemberRepository();

 

이렇게 되면 MemberService 에서 선언해준 memberRepository 값과 현재 테스트 파일에서 선언해준 memberRepository 는 인스턴스 값이 서로 다르게 된다.

정확하게는 MemberService 클래스 타입으로 선언한 memberService 객체를 통해 활용되는 memberRepository 변수 값(MemberService.java 에서 선언되고 사용되어 지는 memberRepository)과 테스트 파일에서 선언된 memberRepository 의 값이 서로 다르게 된다.

 

생성자를 통해 객체가 만들어질 때 똑같이 MemoryMemberRepository 클래스로 객체를 만들었다고 해도 MemberService 의 경우 memberRepository 변수의 타입이 MemberRepository 클래스이고, 테스트 파일의 경우 변수 타입이 MemoryMemberRepository 이다.

* 객체, 인스턴스에 대한 참고자료(링크의 내용을 보면 인스턴스가 다르다는 말은 객체의 클래스 타입이 다르다고 이해하는 것이 맞을 것이다.)

https://gmlwjd9405.github.io/2018/09/17/class-object-instance.html

 

[Java] 클래스, 객체, 인스턴스의 차이 - Heee's Development Blog

Step by step goes a long way.

gmlwjd9405.github.io

물론 지금이야 MemoryMemberRepository 클래스에서 메모리로 활용되는 store 변수가 static(Map) 으로 선언되어 있기 때문에 문제가 될 것이 없다.

왜냐하면 static 으로 선언된 변수는 어플리케이션 실행전 컴파일 단계에서 store 변수가 메모리에 미리 적재됨과 동시에 같은 이름을 가지고 같은 객체 생성자로 만들어진 객체 변수가 인스턴스가 다르더라도(MemberRepository, MemoryMemberRepositroy) 인스턴스 끼리 같은 주소를 공유하게 되기 때문이다. (메모리로 활용하는 store 변수가 이미 메모리에 적재되어 있으므로, 해당 변수를 메모리로서 같은 주소를 공유하게 된다.)

즉, 선언된 클래스 타입이 달라도 static 키워드 덕분에 같은 메모리 주소를 공유하게 되는 것이다.

 

그러나 static 이 아니게 되는 경우 서로 다른 데이터베이스(메모리) 로 인식이 되면서 결국 문제가 발생하게 된다.

같은 메모리 주소를 공유하지 않게 되므로 서로 다른 데이터베이스(메모리) 로 인식을 해버리기 때문이다.

 

같은 Repository 를 가지고 테스트를 해야 하는데 현재는 static 키워드 덕분에 동일한 메모리 주소를 공유하고 있을지언정 인스턴스가 서로 다르기 때문에 서로 다른 Repository 를 가지고 테스트를 하고 있다고 봐도 무방하다.

여기서 두 변수를 같은 인스턴스로 활용하는 것으로 바꾸려면 테스트 파일 상단에서 memberRepository 변수 선언 코드와 MemberService 에서 memberRepository 변수 선언 코드를 아래와 같이 변경한다.

- MemberServiceTest.java

class MemberServiceTest{
	
    // MemberService memberService = new MemberService();
    // MemoryMemberRepository memberRepository = new MemoryMemberRepository();
    
    MemberService memberService;
    MemoryMemberRepository memberRepository;
    
    // @BeforeEach 어노테이션이 붙어있는 메소드는 각 테스트 실행 전에 호출된다.
    // 테스트가 서로 영향이 없도록 항상 새로운 객체를 생성하고, 의존관계도 맺어준다.
    @BeforeEach  
    public void beforeEach(){
    	memberRepository = new MemoryMemberRepository();
        memberService = new MemberService(memberRepository);
    }
    
    @AfterEach 
    public void afterEach(){
        memberRepository.clearStore();
    }
    
    // 이하 테스트 코드 동일
}

- MemberService.java

public class MemberService{
	// private final MemberRepository memberRepository = new MemoryMemberRepository();
    
    private final MemberRepository memberRepository;
    public MemberService(MemberRepository memberRepository){
    	this.memberRepository = memberRepository;
    }
    
    // 이하 코드 동일
}

 

우선 MemberService 파일에서의 변경점을 보면 테스트 파일에서 서로 다른 인스턴스를 사용하는 두 변수 memberRepository 가 같은 인스턴스를 사용하게끔 하기 위해 생성자를 만들어서 memberRepository 변수의 데이터를 외부에서 받아온 후 MemberService 객체를 만들도록 작성하였다.

 

이후 MemberServiceTest 파일에서의 변경점을 보면 일단 변수값 초기화 없이 MemberService 객체와 MemoryMemberRepository 객체를 선언해 준 다음 @BeforeEach 어노테이션을 활용하여 beforeEach() 메소드의 내용을 작성했는데, 그 내용을 보면 MemoryMemberRepository 객체 값에 생성자를 통해 객체 생성 및 메모리 주소 할당을 해준 다음, 객체로서 생성되어 메모리 주소를 할당받은 memberRepository 변수를 MemberService 파일에서 작성했던 객체 생성자대로 MemberService 객체가 외부에서 MemberRepository 객체 데이터 값을 받아와서 객체를 생성할 수 있게 생성자의 파라미터 값으로서 넘겨주고, 그 결과 memberService 객체가 정상적으로 객체 생성 및 메모리 주소 할당을 받을 수 있게되었다.

즉, MemberServiceTest 에서 생성된 memberRepository 변수와 MemberService 에서 생성된 memberRepository 변수가 서로 다른 변수가 아닌 동일한 한 가지의 값이 됨으로서 같은 메모리 주소를 참조하게 되는 것이다.

 

간단하게 요약하자면 다음과 같다.

MemberService 객체 내부에서 memberRepository 객체 변수를 따로 생성하는 것이 아니라, 생성자를 통해 외부에서 데이터를 받아와서 해당 변수를 활용하도록 하여 static 키워드 없이 같은 메모리 주소를 활용하도록 하였다.

테스트 파일에서 생성해준 memberRepository 객체 변수를 그대로 가져와서 활용하므로 서로 다른 변수 2개가 있는것이 아니라, 객체 변수를 하나만 선언해서 활용하는 것이다.

애초에 사용하는 변수가 하나이기 때문에 서로 다른 변수가 되어 메모리 주소까지 서로 다른 공간을 쓰게 되는 불상사를 막을 수 있게된다.

 

이와 같은 방식의 코딩을 DI(Dependency Injection) 라고 한다.

(아마 pro.gg 프로젝트를 하면서 @AutoWired 어노테이션을 통해 클래스간 의존성을 주입해준걸 이런 방식으로 코딩해 줄 수 있는것 같다.)