spring 웹 애플리케이션 계층 구조

Seungsoo Lee·2022년 11월 13일
0

web

목록 보기
12/13
post-thumbnail

먼저 만들어둔 코드를 설명하기에 앞서 spring에서는 웹 애플리케이션 계층 구조가 어떻게 되는지 알아볼 필요가 있어보였다.

아래는 코드짠 구조인데...


Spring 웹 애플리케이션 계층 구조

아무리 봐도 왜 controller, domain, repository, service라는 패키지를 만들었는지 이해를 할 수 없다. 따라서 더 깊이 파보려고 한다.

이 그림은 스프링의 웹 계층에 대해서 표현해둔것이다.

스프링의 웹 계층은 4가지로 나뉜다.

  • Domain Model
  • Presentation Layer(Controller)
  • Business Layer(Service Layer)
  • Data Access Layer(Reopsitory Layer)


Domain Model

일단 먼저 웹을 설계할때 도메인을 먼저 정하는게 중요하다.

Domain

도메인 모델(객체)은 내가 개발하고자 하는 영역을 분석하고, 그 분석의 결과로 도출된 모델(객체)이라고 할 수 있다.

온라인 쇼핑몰을 예를 든다면,

  • 주문 (핵심 기능)
  • 회원
  • 결제
  • 배송
  • 리뷰

로 도메인을 나눌 수 있다. 이 도메인중 또 하위 도메인으로 나눌 수 있게된다.

우리가 작성한 코드로 보자면 여러 도메인들중의 하위 도메인인, 회원 도메인으로 도메인 모델을 작성했었다.

Entity와 Value

도출한 도메인 모델은 크게 entity와 value로 구분할 수 있다.

Entity

  • 식별자를 가진다.
    • 식별자 이외의 데이터가 변경이 되어도 그 객체가 다른 객체가 되는것이 아니다.
    • 예를 들어서 Member는 member_id라는 식별자를 가진다고 해보자! 그러면 Member에서 닉네임을 변경한다고 해도 Member가 바뀌지 않는다.
    • DB의 entity와는 다른 개념이다. 여기서의 entity는 논리 모델에서 사용된다.

Value

  • 식별자를 가지지 않고 값 그 자체이다.
    • value같은 경우에는 한 데이터가 변경되면 아예 다른 객체가 되어버린다.
    • 따라서 value를 immutable로 구현하는게 좋다.
    • 이를 위해 값을 생성자를 통해서만 받고 setter를 구현하지 않는다.
    • 기존 객체를 변경하고 싶으면 아예 새로운 객체를 만든다.

데이터베이스 연동 방식

지금까지 Domain, Entity, Value에 대해서 알아보았다.

결국 이 데이터 객체들을 db에 저장을 해야한다. 그런데 이 데이터 객체들을 저장하는 방식에 따라서 데이터 객체가 Entity로 불릴 수 도 있고, Value Object로 불릴 수 도 있다.

JPA

  • 데이터 객체: Entity
  • ORM
    • SQL문이 아닌 RDB 객체를 자바 객체로 매핑
    • 객체간 관계, 식별자를 가질 수 있다.
    • 따라서 식별자를 가지고 있는 Entity가 된다.

MyBatis

  • 데이터 객체: VO(ValueObject)
  • SQL-Mapper
    • SQL문으로 RDB에 접근하고 데이터를 객체로 매핑
    • 체간 관계나 식별자는 가질 수 없다.
    • 따라서 식별자를 가지고 있지 않은 VO(Value Object)가 된다.

CODE

hello/hellospring/controller/HelloController.java

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

비즈니스 요구사항을 봤을때 멤버 도메인에는

  • 멤버 id 식별자
  • 멤버 이름

이 들어가야한다.



Repository Layer (Data Access Layer)

우리가 만든 웹은 따로 db를 사용하지 않기 때문에, 아래 내용 전부가 필요하지는 않다. 그러나 나중을 위해서 한번 살펴나 보자.

  • JPA, ORM(Mybatis, Hibernate)를 주로 사용하는 계층이다.
  • DAO 인터페이스와 @Repository 애노테이션을 사용하여 작성된 DAO 구현 클래스가 이 계층에 속한다.
  • Database에 Data를 CRUD(Create, Read, Update, Drop)하는 계층이기도 하다.

    DAO(Data Access Object)

    • DB에 접근하는 객체, DB를 사용해 데이터를 조작하는 기능을 하는 객체 (MyBatis 사용시에 DAO or Mapper 사용)
    • Repository라고도 부름(JPA 사용시 Repository 사용)
    • Service 계층과 DB를 연결하는 고리 역할을 한다.

CODE

hello/hellospring/repository/MemberRepository.java

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

인터페이스는 틀을 미리 짜주는 역할을 하는데(템플릿 같은 느낌), repository 를 짤때 필요한 함수들을 만들었다.

  • 여기서 Optional이라는게 있는데, 무엇인가?
    • NPE(NullPointerException)이란?
      • 개발을 할 때 가장 많이 발생하는 예외 중 하나가 바로 NPE(NullPointerException)이다. NPE를 피하려면 null 여부를 검사해야 하는데, null 검사를 해야하는 변수가 많은 경우 코드가 복잡해지고 번거롭다. 그래서 null 대신 초기값을 사용하길 권장하기도 한다.
      • Optional는 null이 올 수 있는 값을 감싸는 Wrapper 클래스로, 참조하더라도 NPE가 발생하지 않도록 도와준다. Optional 클래스는 아래와 같은 value에 값을 저장하기 때문에 값이 null이더라도 바로 NPE가 발생하지 않으며, 클래스이기 때문에 각종 메소드를 제공해준다.

hello/hellospring/repository/MemoryMemberRepository.java

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.springframework.stereotype.Repository;

import java.util.*;

@Repository
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 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의 구현체이다.

  • @Repository

    • @Component가 포함된 annotation이다.
      • 싱글톤 클래스 빈을 생성하는 어노테이션이다.
      • 이 어노테이션은 선언적(Declarative)인 어노테이션이다. 즉, 패키지 스캔 안에 이 어노테이션은 "이 클래스를 정의했으니 빈(자바 객체(POJO))으로 등록해줘." 라는 뜻이 된다.
  • 우리는 db에 저장을 하지 않고 메모리에 저장을 할껀데, Map을 사용할 것이다.

    • 실무에선 동시성 문제 때문에 공유되는 변수일때는 ConcurrnetHashMap을 사용한다고 한다.
  • public Member save(Member member)

    • 파라미터로 member 객체를 받아서 그 객체에 id를 sequence를 1증가시키고 넣어주고,
    • store라는 Hashmap을 생성해서 실제로 메모리에 저장하는데 id와 객체를 각각넣는다.
      • Hashmap에다 값을 추가해주려면 일단 먼저 new로 생성해주고, put으로 추가해준다. 삭제해주려면, remove(key) 또는 clear()해주면 된다.
    • 멤버 객체를 반환해준다.
  • public Optional<Member> findById(Long id)

    • 그냥 store에서 get을 통해 id를 받아올 수 있지만, 그렇게 되면 null일때 NPE에러가 생길 수 있다. 따라서 아까 Optional을 활용한다.
      • Optional.ofNullable(store.get(id))
      • ofNullable은 null값을 허용한다는 것이다.
      • 장점 : if를 이용한 null값 체크를 대체할 수 있다.
  • public Optional<Member> findByName(String name)

    • stream 이란?

      • 스트림은 '데이터의 흐름’이다. 배열 또는 컬렉션 인스턴스에 함수 여러 개를 조합해서 원하는 결과를 필터링하고 가공된 결과를 얻을 수 있다. 또한 람다를 이용해서 코드의 양을 줄이고 간결하게 표현할 수 있다. 즉, 배열과 컬렉션을 함수형으로 처리할 수 있다.
      • 또 하나의 장점은 간단하게 병렬처리(multi-threading)가 가능하다는 점이다. 하나의 작업을 둘 이상의 작업으로 잘게 나눠서 동시에 진행하는 것을 병렬 처리(parallel processing)라고 한다. 즉 쓰레드를 이용해 많은 요소들을 빠르게 처리할 수 있다.
    • Lambda 란?

      • 람다 함수는 함수형 프로그래밍 언어에서 사용되는 개념으로 익명 함수라고도 한다.
      • 람다식의 표현
        • ( 파라미터 ) -> { 몸체 }
    • filter 란?

      • filter()는 인자로 함수를 받으며, 어떤 조건으로 Stream의 요소들을 필터링한다. 여기서 인자로 전달되는 함수를 구현하여 필터링하는 조건을 설정할 수 있다.
    • 따라서 위 세가지를 다 합쳐버린 식인 hashmap.stream.filter((param)->{})이게 완성이 된다.

      • return store.values().stream()
                        .filter(member -> member.getName().equals(name))
                        .findAny();
      • store의 value를 stream으로 받아서, Fileter로 필터링을 한다. 그런데 그 조건이 member의 이름이 함수 파라미터로 들어온 name과 같은 조건일때를 말한다. 하나가 찾아지면 바로 리턴을 하게된다.

  • public List<Member> findAll()

    • 모든 멤버를 반환해준다.
    • list형태로 반환을 할껀데, new ArrayList<>(store.values());로 store에 저장되어있는 value인 member 객체를 리스트로 바꿔서 반환한다.


Service Layer (Business Layer)

  • 애플리케이션 비즈니스 로직 처리와 비즈니스와 관련된 도메인 모델의 적합성 검증을 한다.

  • 트렌젝션(DB 상태를 변환시키는 하나의 논리적 기능을 수행하기 위한 작업의 단위 또는 한꺼번에 모두 수행되어야 할 일련의 연산들)을 관리한다.

    • 결제 시스템을 예를 들어보면, (1) 판매처에 돈보내기, (2) 판매처에서 돈 받기 이 있다고 하면, (1)은 성공했지만 (2)가 실패를 하게되면, 작업의 실행하기 전 상태로 돌리(rollback)는것을 하는것이 트렌젝션이다.
    • 트랜잭션 ACID
      • Atomicity; 원자성: 트랜잭션 내의 작업들은 모두 성공 또는 모두 실패한다.
      • Consistency; 일관성: 모든 트랜잭션은 일관성 있는 DB 상태를 유지한다. (ex: DB의 무결성 제약 조건 항상 만족)
      • Isolation; 격리성: 동시에 실행되는 트랜잭션들은 서로 영향을 미치지 않는다. (ex: 동시에 같은 데이터 수정 X)
      • Durability; 지속성: 트랜잭션이 성공적으로 끝나면 그 결과는 항상 기록되어야 한다.
  • 프레젠테이션 계층과 데이터 엑세스 계층 사이를 연결하는 역할로서 두 계층이 직접적으로 통신하지 않게 한다.

  • Service 인터페이스와 @Service 어노테이션을 사용하여 작성된 Service 구현 클래스가 이 계층에 속한다.

  • 나중에 ControllerService를 통해 회원가입과 데이터를 가져올 수 있게 된다. (컨트롤러가 서비스를 의존하는 관계)

CODE

hello/hellospring/service/MemberService.java

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

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

@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(m -> {
            throw new IllegalStateException("이미 존재하는 회원입니다.");
            });
    }

    public List<Member> findMember() {
        return memberRepository.findAll();
    }

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

    • @Component가 포함된 annotation이다.
      • 싱글톤 클래스 빈을 생성하는 어노테이션이다.
      • 이 어노테이션은 선언적(Declarative)인 어노테이션이다. 즉, 패키지 스캔 안에 이 어노테이션은 "이 클래스를 정의했으니 빈(자바 객체(POJO))으로 등록해줘." 라는 뜻이 된다.
  • private final MemberRepository memberRepository;

    • 저장할 멤버 공간을 선언한다.
    • final 이란?
      • 상수화를 시켜 수정할 수 없게 만듬.
      • final은 선언만하고 초기화를 나중에 할 수 있다.
      • final은 런타임중에 결정이 된다. (c의 const는 컴파일 중 결정)
  • @Autowired

    • @Autowired 개념을 알기 위해 먼저 DI(Dependency Injection) 개념을 알아야 한다.(따로 포스트로 정리할 예정)
      • 의존대상 B가 변하면, 그것이 A에 영향을 미친다.
      • 객체가 의존하는 또 다른 객체를 외부에서 선언하고 이를 주입받아 사용하는 것이다.
      • 이 코드에서는 Controller 이랑 Service를 연결한다. Controller가 Service를 의존하는 관계(DI)
    • 필요한 의존 객체의 “타입"에 해당하는 빈을 찾아 주입한다.
    • 생성자, setter, 필드 3가지의 경우에 Autowired를 사용할 수 있다.
    • 여기서 사용한 방법은 Constructor Dependency Injection 이다.
      • 장점
      • 필수적으로 사용해야 하는 레퍼런스 없이는 인스턴스를 만들지 못하도록 강제함
      • Spring 4.3 이상부터는 생성자가 하나인 경우 @Autowired를 사용하지 않아도 됨
      • Circular Dependency / 순환 참조2 의존성을 알아 차릴 수 있음
      • 생성자에 점차 많은 의존성이 추가 될 경우 리팩토링 시점을 감지 할 수 있음
      • 의존성 주입 대상 필드를 final로 불편 객체 선언할 수 있음
      • 테스트 코드 작성시 생성자를 통해 의존성 주입이 용이함
      • 단점
      • 어쩔 수 없는 순환 참조는 생성자 주입으로 해결하기 어려움
        • 이러한 경우에는 나머지 주입 방법 중에 하나를 사용
        • 가급적이면 순환 참조가 발생하지 않도록 하는 것이 더 중요
  • public MemberService(MemberRepository memberRepository)

    • test에서 사용되는 repository객체와 같은 객체를 사용하기 위해서, 생성자 단에서 초기화를 시켜준다.
  • public Long join(Member member)

    • 멤버를 회원가입 시키는 함수이다.
    • validateDuplicateMember(member);를 통해 멤버가 존재하는지 확인
    • 문제 없으면 바로 memberRepository의 save함수를 통해 member 객체를 넘겨서 저장함.
    • 멤버 아이디를 반환.
  • validateDuplicateMember(member)

    • 멤버가 존재하는지 확인하고, 존재하면 예외를 던지는 함수이다.
    • memberRepository의 findByName의 멤버함수를 통해 파라미터로 들어온 멤버 객체의 이름을 확인해본다.
    • findByName의 리턴값이 optional 객체로 싸져있기 때문에 ifPresent를 사용할 수 있다.
      • ifPresent는 optional객체가 비어있으면 실행하지 않고 들어있으면, 실행한다.
      • 따라서 만약 멤버가 존재하면 findByName에서 객체를 반환할것이고, ifPresent에선 람다함수를 실행할 것이다. 반대로 없으면 빈 객체를 보낼것이고 람다함수를 실행하지 않을것이다.
    • [RuntimeException] IllegalStateException 메소드가 요구된 처리를 하기에 적합한 상태에 있지 않을때
      • 예외를 강제로 시키려면 throw를 사용
        • throw new 강제시킬예외
  • public List<Member> findMember()

    • 모든 멤버를 리턴해준다.
    • memberRepository의 멤버 findAll의 리턴값이 List이기 때문에 마찬가지로 findMemeber도 같게 해준다.
  • public Optional<Member> findOne(Long memberId)

    • 한 멤버를 멤버 아이디로 찾는다.
    • findById도 마찬가지로 리턴값을 같게 해준다.


Presentation Layer (Controller)

  • Presentation Layer는 브라우저상의 웹 클라이언트의 요청 및 응답을 처리하는 레이어이다.
  • 서비스계층, 데이터 엑세스 계층에서 발생하는 Exception을 처리해준다.
  • @Controller annotation을 사용하여 작성된 Controller 클래스가 이 계층에 속한다.
  • 위 그림은 MVC 패턴으로 컨트롤러를 사용할때를 표시한것이지만, Controller 클래스안에 @RequestBody를 사용해서 REST API로도 만들질 수 있다.

CODE

package hello.hellospring.controller;

import hello.hellospring.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;

@Controller
public class MemberController {


    private final MemberService memberService;

    @Autowired
    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

}
  • @Controller
    • @Component가 포함된 annotation이다.
      • 싱글톤 클래스 빈을 생성하는 어노테이션이다.
      • 이 어노테이션은 선언적(Declarative)인 어노테이션이다. 즉, 패키지 스캔 안에 이 어노테이션은 "이 클래스를 정의했으니 빈(자바 객체(POJO))으로 등록해줘." 라는 뜻이 된다.
  • private final MemberService memberService;
    • 멤버 서비스 객체를 final로 선언.
  • @Autowired
    • 이 코드에서는 Controller 이랑 Service를 연결한다. Controller가 Service를 의존하는 관계(DI)

Domain Model (Domain Object)

  • DB의 테이블과 매칭될 클래스
  • Entity 클래스 또는 가장 Core한 클래스라고 부른다.
  • Domain 로직만을 가지고 있어야하며 Presentation Logic을 가지고 있어서는 안된다.

DTO(Data Tranfer Object)

  • 각 계층간 데이터 교환을 위한 객체 (데이터를 주고 받을 포맷 / 구조체)
  • 일반적인 DTO는 로직을 갖고 있지 않다.
  • 순수한 데이터 객체
  • Domain, VO(Value Object)라고도 부름
  • DB에서 데이터를 얻어 Service, Controller 등으로 보낼 때 사용함
  • 로직을 갖지 않고 순수하게 getter, setter 메소드를 가진다.

Domain 클래스와 DTO 클래스를 분리하는 이유

  • View Layer와 DB Layer의 역할을 철저하게 분리하기 위해서
  • 테이블과 매핑되는 Entity(Domain) 클래스가 변경되면 여러 클래스에 영향을 끼치게 되지만 View와 통신하는 DTO 클래스는 자주 변경되므로 분리해야 한다.
  • 즉 DTO는 Domain Model을 복사한 형태로, 다양한 Presentation Logic을 추가한 정도로 사용하며 Domain Model 객체는 Persistent(영속성(영구적으로 저장))만을 위해서 사용한다.


Dependency Injection(DI) 대해서

DI를 하는 방법이 2가지가 있다.
하나는 Component Scan이 있다. 아까 했던 방법이다. @Controller @Service @Repository annotation을 통해 Spring 빈에 등록을 한다. 여기에 이 3가지가 @Component

you should always put most of the business logic into value objects. Entities in this situation would act as wrappers upon them and represent more high-level functionality.

참고
https://yadon079.github.io/2021/spring/spring-web-layer
https://yeonyeon.tistory.com/223
https://velog.io/@gentledot/ddd-domain-model
https://multifrontgarden.tistory.com/182?category=471239
https://okky.kr/articles/779150
https://mangkyu.tistory.com/70
https://engkimbs.tistory.com/646
https://futurecreator.github.io/2018/08/26/java-8-streams/
https://codechacha.com/ko/stream-filter/

0개의 댓글