[Spring] 웹 어플리케이션 계층 구조

노유성·2023년 7월 8일
0
post-thumbnail

들어가며

회원가입을 하는 웹 사이트의 백엔드 예제를 통해 일반적인 웹 어플리케이션 백엔드의 구조를 알아보자.

계층 구조


웹 어플리케이션의 구조는 위와 같은 구조를 가지고 있다. 화살표는 의존 관계를 나타내며 컨트롤러를 예시로, 컨트롤러는 서비스와 도메인에 의존하고 있음을 알 수 있다.

각 계층에 대해서 간단하게 설명하자면

  1. 컨트롤러: 웹 MVC에서 컨트롤러의 역할을 한다.
  2. 서비스: 비즈니스 로직, 유효성 검사나 할인 정책이 있다면 할인을 직접 계산하는 로직 등이 서비스에 포함된다.
  3. 리포지토리: DB에 접근, domain 객체를 DB에 저장하고 관리한다.
  4. 도메인: 회원정보, 주문정보 등 비즈니스 도메인의 객체이다.(현실을 그대로 구현한 객체)

여기서 어떤 데이터베이스를 사용할 지 정하지 않은 상태에서 개발을 위해 임시 데이터베이스를 선정해서 사용한다는 가정을 하자. DB의 구조 및 DB에서 어떤 데이터를 가져올 지에 대해서는 정의를 해두었고 해당 정의를 interface로 만들어서 이에 대한 구현체를 만들어 repository로 사용한다.

MemberService는 MemberRepository에 맞게 설계를 하고 MemberRepository의 구현체인 MemoryMemberRepository도 인터페이스를 구현한다. 이렇게 설계하면 나중에 구현체를 변경해도 구현체를 변경하는 것 외에는 코드를 작성하는 번거로움을 덜 수 있다.

DI (Dependency Injection)

DI란

Spring에서 DI는 "의존성 주입(Dependency Injection)"의 약어입니다. 의존성 주입은 객체 간의 의존 관계를 외부에서 설정하고 제어하는 디자인 패턴입니다.
Spring 프레임워크는 의존성 주입을 통해 객체 간의 결합도를 낮추고 유연성과 테스트 용이성을 향상시킵니다. 일반적으로 의존성은 클래스 간의 관계를 의미하며, 한 클래스가 다른 클래스에 의존하는 경우 그 의존성을 외부에서 주입받아 사용하도록 만듭니다.
-chatGPT

위에서 살펴보았듯이 컨트롤러는 서비스에, 서비스는 레파지토리에 의존하고 있다. 해당 의존 관계를 설정하는 것을 DI라고 하며 총 3가지의 방법이 있다.

필드 주입 (Field Injection)

@Component
public class SampleController {
    @Autowired
    private SampleService sampleService;
}

Autowired annotation을 바로 지정해주는 것이다.

수정자 주입 (Setter Injection)

@Component
public class SampleController {
    private SampleService sampleService;
 
    @Autowired
    public void setSampleService(SampleService sampleService) {
        this.sampleService = sampleService;
    }
}

setter 함수로 의존 관계를 주입하는 것이다.

생성자 주입 (Constructor Injection)

@Component
public class SampleService {
    private SampleDAO sampleDAO;
 
    @Autowired
    public SampleService(SampleDAO sampleDAO) {
        this.sampleDAO = sampleDAO;
    }
}

@Component
public class SampleController {

	private final SampleService sampleService = new SampleService(new SampleDAO());
    
	...

생성자를 이용해서 주입을 하는 것이다.

위 3가지 방법 중에 다양한 이유로 주로 생성자를 통해 DI를 한다.

예제

회원의 ID, 이름을 저장하고 회원을 등록, 조회하는 경우를 가정해서 백엔드를 개발해보자.

회원 객체 (domain)

public class Member {
    private Long id;
    private String name;

    public void setId(Long id) {
        this.id = id;
    }

    public Long getId() {
        return id;
    }
    public void setName(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

id, 이름을 저장하는 맴버 변수와 변수에 대한 setter, getter 함수이다. 객체에 정의해야할 변수들과 변수를 다루기 위한 아주 기본적인 메소드들로만 정의되어 있다.

여기서 맴버 변수를 private 제어자를 통해 외부에서 접근을 제어하고 setter, getter 함수로만 접근을 가능하게 하는 방식을 java bean class 이라고 한다.

java bean

Java Bean은 Java 언어에서 재사용 가능한 소프트웨어 컴포넌트를 구현하기 위한 표준 방법을 제공하는 클래스입니다. Java Bean은 특정 규칙과 규약을 따라야 하며, 일반적으로 다음과 같은 특성을 가지고 있습니다:

  1. 기본 생성자: Java Bean 클래스는 매개변수가 없는 기본 생성자를 가져야 합니다. 이는 클래스의 인스턴스를 생성하기 위해 필요한 조건입니다.
  2. 속성(private fields): Java Bean 클래스는 속성 또는 멤버 변수를 가집니다. 이러한 속성은 일반적으로 private으로 선언되며, public getter/setter 메서드를 통해 접근됩니다.
  3. Getter/Setter 메서드: Java Bean 클래스는 각 속성에 대한 public getter/setter 메서드를 제공해야 합니다. 이러한 메서드를 통해 속성 값에 접근하고 변경할 수 있습니다.
  4. 직렬화(Serialization) 지원: Java Bean은 Serializable 인터페이스를 구현하여 직렬화 및 역직렬화가 가능하도록 해야 합니다. 이는 객체를 저장하고 전송하는 데 사용됩니다.

회원 repository interface

// 구현체를 위한 interface
// 어떤 database를 사용할 지 결정하지 않았을 때 어떤 database를 사용하더라고 이 interface 대로만 구현하면 작동하 수 있게 하기 위해선
// 필수적인 기능만 최소한으로 만들어야 한다.
public interface MemberRepository {
    Member save(Member member);
    Optional<Member> findById(Long  id);
    Optional<Member> findByName(String name);
    List<Member> findAll();
}

요구 사항에서 "회원을 등록", "회원을 탐색" 하기 위한 기능들만 정의를 해놓은 interface이다. 실질적으로 회원을 등록해도 되는 지에 대한 여부를 검사하는 유효성 검사나 회원을 탐색했는데 null일 경우를 다루는 부분(비즈니스 로직)은 포함되어 있지 않다.

즉, 요구 사항에 따라서 DB와 의사 소통하기 위한 최소한의 기능만 정의해두었다.

Optional은 generic으로 기존에 반환하고 싶어했던 data type에 대해서 명시하고 해당 객체가 null일 경우를 다루는 메소드들을 제공하는 데이터 타입이다.
반환값이 null일 경우에는 해당 경우에 대한 로직을 Service에서 구현해야 하는데 Optional을 이용하면은 자체적으로 null에 대한 처리 메소드를 갖고 있기 때문에 편리하다.

회원 repository 구현체

@Repository
public class MemoryMemberRepository implements MemberRepository {
    private static Map<Long, Member> store  =new HashMap<>(); // DB의 역할을 함.
    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 Optional<Member> findByName(String name) {
        return store.values().stream().filter(member -> member.getName().equals(name)).findAny();
    }

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

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

repository interface를 구현한 메모리 구현체이다. interface에서 정의한 DB와 데이터 입출력을 위한 메소드 4개를 overriding해서 구현하였다.

실질적으로 여기서 DB의 역할을 하는 것이 store 객체인데 예제에서는 HashMap을 이용했다. 그리고 회원들마다 ID를 다르게 주기 위해서 정적 변수인 sequence를 사용했다.

회원 Service

@Service
public class MemberService {
    private final MemberRepository memberRepository;

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

    /**
     * 회원가입
     */
    public Long join(Member member) {
        validateDuplicateMember(member); // 중복 회원 검증

        memberRepository.save(member);
        return member.getId();
    }

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

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

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

실질적인 비즈니스 로직을 구현한 service 객체이다. 우리가 위에서 구현체를 만들어두었지만 중요한 부분은 구현체에 의존하는 것이 아니라 interface에 의존한다는 점이다. 그래서 코드 상단을 보면은 DI의 대상이 interface 임을 알 수 있다.

메소드들에 대해서 간단히 설명하면은, join은 회원가입을 하는 부분이고 validateDuplicateMember은 유효성 검사를 실시하는 부분이다.
findOne, findMembers 또한 회원을 조회하는 메소드이다.

Test

구현이 적절히 되었는 지에 대한 검사를 해야한다. spring에서는 test를 위해서 test 폴더 하위에 테스트 케이스를 작성해서 테스트를 진행한다.

프로젝트의 규모가 커질 수록 test는 불가피하므로 이 부분에 대해서 나중에 깊이 있게 공부를 하자.

MemoryMemberRepositoryTest

class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

    // 테스트가 끝날 때마다 저장소를 다 지우게 하기 위한 코드
    // AfterEach는 한 Test가 끝날 때마다 호출되는 callback method이다.
    @AfterEach
    public void afterEachh() {
        repository.clearStore();
    }

    @Test
    public void save() {
        Member member = new Member();
        member.setName("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);

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

        Member result = repository.findByName("spring1").get();

        assertThat(result).isEqualTo(member1);
    }

    @Test
    public void findnAll() {
        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);
    }
}


위 사진은 프로젝트 폴더를 보여준 것인데, main 폴더와 마찬가지로 테스트 케이스를 작성하는 폴더도 같은 디렉토리 구조를 가지고 작성하면 된다.

먼저 일반적인 클래스를 작성하고 테스트 케이스에 대해서는 각각 Test annotation을 적용한다. annotation는 2개가 더 있는데 하나는 AfterEach 다른 하나는 BeforeEach이다.
각각 test를 진행하기 이전 이후에 작동하는 코드이다.

test 케이스를 class의 범주에서 전부 실행하는 경우 메모리가 누수되는 경우가 발생하므로 AfterEach annotation이 붙는 메소드에 메모리를 정리하는 코드가 구현이 되어있어야 한다.(메모리 청소)

MemberServiceTest

class MemberServiceTest {

    MemberService memberService;
    MemoryMemberRepository repository;

    @BeforeEach
    void beforeEach(){
        repository = new MemoryMemberRepository();
        memberService = new MemberService(repository);
    }

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

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

    @Test
    public void 중복_회원_예외() {
        // given
        Member member1 = new Member();
        member1.setName("hello");

        Member member2 = new Member();
        member2.setName("hello");
        // when
        memberService.join(member1);
        Exception e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
//        try{
//            memberService.join(member2);
//            fail();
//        }catch (Exception e) {
//            assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
//        }


        // then
    }

    @Test
    void findMemberes() {
    }

    @Test
    void findOne() {
    }
}

Test case를 작성하는데에 유용한 도구 중 하나가 Assertions인데

2가지의 종류가 있지만 주로 junit에서 제공하는게 아니라 core에서 제공하는 것을 쓴다.

그리고 static import를 하여 메소드명 만으로 테스트 케이스를 작성한다.(python에서 from package import *)

profile
풀스택개발자가되고싶습니다:)

0개의 댓글