티스토리 뷰

728x90
반응형

 

 

 

지난 글까지 해서 웹 앱의 구조 중 MVC를 주로 사용하는 API계층(=Presentation 계층)을 작성했다.

 

다시 복습하자면 각 계층에 대한 설명은 아래와 같다.

 

  • Presentation Layer - API Layer라고 불리며 클라이언트의 요청을 받아들이는 계층이다. GUI, 웹 화면 등을 포함한다.
  • Application Layer - Service Layer, Business Layer, 혹은 WAS(Web Application Server)라고도 불린다. Presentation Layer에서 수집된 유저의 요청(기능의 사용)이 업무 도메인(범위, 과정)의 요구사항에 맞게 처리되며, 이 과정에서 비즈니스 로직이 사용된다. 또한 Data Access Layer의 데이터를 추가, 삭제, 수정할 수 있다.
  • Data Access Layer - DB Server라고도 불리며 앱의 데이터베이스에 접근하여 데이터를 불러오거나 저장을 담당한다. Application Layer에서 유저의 요청을 처리할 때 이에 대한 작업을 지원한다. 이 단계를 통해 Application Layer의 로직은 데이터베이스에 접근해서 데이터를 회수하고 혹은 저장하는 방식을 최적화할 수 있다.

이번 글에선 스프링의 특징인 DI(Dependency Injection - 의존성 주입)를 이용해

 

작성한 API 계층과 비즈니스 로직을 가진 서비스 계층을 연동시킨다.

 

 

2022.08.12 - [개발/Spring] - [Spring]Spring DI(Dependency Injection)

 

[Spring]Spring DI(Dependency Injection)

DI(Dependency Injection, 의존성 주입)는 스프링 프레임워크의 네 가지 특징 중 하나이다. 이전 글에서 다룬 적이 있지만, 간략하게 요약하면 다음과 같다. 2022.08.09 - [개발/Spring] - [Spring]Spring Framewo..

gnidinger.tistory.com

여기서 연동이란 조금 더 구체적으로 API 계층Controller 클래스서비스 계층 Service 클래스

 

메서드 호출을 통해 상호 작용한다는 것을 의미한다.

 

추가로 스프링 MVC의 작용 영역은 엄밀히 말하면 Controller 클래스까지이며,

 

이 글부터 다루는 내용은 그 경계를 조금 넘는 것이다.

 

본격적으로 들어가기 전에, 지금부터 살펴볼 Service Layer에 대해 다른 각도에서 조금만 더 알아보자.

 

 

비즈니스 로직은 Service Layer에서 처리한다?

 

위 문장은 정확하게 말하자면 틀린 표현이다. 먼저 그림을 하나 보자.

 

위 그림은 글 도입부에서 봤던 3티어 계층과 매치되는 스프링 웹 앱의 기본적인 구조이다. 

 

이중 우리가 주의 깊게 봐야 할 부분은 (당연하게도) Service Layer와 Domain Model인데, 그 이유는 다음과 같다.

 

서비스에서 모든 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라고 한다.

 

그 이유는 하나의 트랜잭션으로 구성된 로직을 단일 함수 또는 단일 스크립트에서 처리하는 구조를 갖기 때문인데,

 

이 패턴의 단점은 프로젝트가 커질수록 하나의 서비스에서 더욱 많은 모델을 읽어 서비스의 복잡도가 매우 높아진다는 것이다.

 

복잡도가 높아진다는 얘기는 테스트와 유지보수가 힘들어진다는 뜻이고, 유연하지 못한 프로그램이 되어버린다는 뜻이다.

 

이를 해결하기 위해 나온 것이 바로 도메인 모델 패턴(Domain Model Pattern)이다.

 

이 방식은 쉽게 말해 서비스에서 처리하던 비즈니스 로직을 위 그림의 도메인 영역에서 각각의 도메인이 처리하도록 하고,

 

서비스는 트랜잭션, 도메인 간 순서 보장의 역할만 하는 것이다.

 

조금 더 구체적으로는 객체지향 설계에 기반해 구현하고자 하는 도메인의 모델을 생성하는 패턴이라고 할 수도 있다.

 

도메인 모델은 비즈니스 영역에서 사용되는 객체를 판별하고 객체가 제공해야 할 목록을 추출하며,

 

각 객체 간의 관계를 정립하는 과정을 거친다.

 

또한 구현하는 비즈니스 로직이 도메인에 가까울수록 서비스 단의 복잡도를 낮추는 효과를 얻을 수 있다.

 

이는 위에서 적었던 트랜잭션 스크립트 패턴의 단점을 정확하게 보완하면서도 객체지향적인 설계를 가능하게 한다.

 

하지만 조금 허탈하게도 이 글에서 사용되는 서비스는 비즈니스 로직을 처리하는 Service 클래스라고 생각해도 무방하다.

 

당연하게도 내가 초보이기 때문이다.

 

그럼 계속해서 서비스 계층을 구현해 보자.

 

 

비즈니스 로직을 처리하는 Service 클래스 작성

 

먼저 지난 글까지 완성한 API 계층의 컨트롤러 클래스를 가져오자.

package com.gnidinger.member;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import javax.validation.constraints.Positive;


@RestController
@RequestMapping("/v1/members")
@Validated
public class MemberController {
    @PostMapping
    public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
        return new ResponseEntity<>(memberDto, HttpStatus.CREATED);
    }

    @PatchMapping("/{member-id}")
    public ResponseEntity patchMember(@PathVariable("member-id") @Positive long memberId,
                                      @Valid @RequestBody MemberPatchDto memberPatchDto) {
        memberPatchDto.setMemberId(memberId);

        // No need Business logic
        return new ResponseEntity<>(memberPatchDto, HttpStatus.OK);
    }

    @GetMapping("/{member-id}")
    public ResponseEntity getMember(@PathVariable("member-id") @Positive long memberId) {
        System.out.println("# memberId: " + memberId);

        // not implementation
        return new ResponseEntity<>(HttpStatus.OK);
    }

    @GetMapping
    public ResponseEntity getMembers() {
        System.out.println("# get Members");

        // not implementation
        return new ResponseEntity<>(HttpStatus.OK);
    }

    @DeleteMapping("/{member-id}")
    public ResponseEntity deleteMember(@PathVariable("member-id") @Positive long memberId) {
        System.out.println("# deleted memberId: " + memberId);
        
        // No need business logic
        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
    }
}

핸들러 메서드의 @PathVariable의 @Min(1)이 @Positive로 교체된 것을 확인할 수 있다.

 

위 클래스에는 총 다섯 개의 핸들러 메서드가 구현되어 있는데, 짧게 요약하면 아래와 같다.

 

  • postMember() - 1명의 회원 등록을 위한 요청을 전달받는다.
  • patchMember() - 1명의 회원 수정을 위한 요청을 전달받는다.
  • getMember() - 1명의 회원 정보 조회를 위한 요청을 전달받는다.
  • getMembers() - N명의 회원 정보 조회를 위한 요청을 전달받는다.
  • deleteMember() - 1명의 회원 정보 삭제를 위한 요청을 전달받는다.

이어서 이 핸들러 메서드가 전달받은 요청을 처리하는 클래스를 작성하자.

 

 

MemberService 클래스와 Member 클래스의 기본 구조 작성

 

위에서 요약한 핸들러 메서드를 기반으로 MemberService 클래스와 Member 클래스의 구조를 먼저 잡는다.

package com.gnidinger.member;

import java.util.List;

public class MemberService {
    public Member createMember(Member member) {
        return null;
    }

    public Member updateMember(Member member) {
        return null;
    }

    public Member findMember(long memberId) {
        return null;
    }

    public List<Member> findMembers() {
        return null;
    }

    public void deleteMember(long memberId) {

    }
}

MemberController 클래스의 핸들러 메서드와 1대 1로 매치되는 것을 확인할 수 있다.

 

또한 createMember() 메서드와 updateMember() 메서드의 파라미터와 리턴 값에 Member 타입을 사용했다.

 

계속해서 Member 타입을 위한 클래스의 틀을 잡는다.

package com.gnidinger.member;

public class Member {
}

기본 구조만 작성했기 때문에 안이 비어있는 것을 볼 수 있다.

 

지금부터 작성할 Member 클래스는 API 영역의 DTO 클래스와 비슷한 역할을 하는데,

 

DTO가 API 계층에서 클라이언트의 요청 데이터를 전달받고 클라이언트에게 되돌려 줄 응답 데이터를 담는 역할을 한다면,

 

Member 클래스는 API 계층에서 전달받은 요청 데이터를 기반으로

 

서비스 계층에서 비즈니스 로직을 처리하기 위해 필요한 데이터를 전달받고,

 

비즈니스 로직을 처리한 후 결과 값을 다시 API 계층으로 리턴해주는 역할을 한다.

 

이와 같이 서비스 계층에서 데이터 액세스 계층과 연동하면서

 

비즈니스 로직 처리를 위해 필요한 데이터를 받는 클래스를 도메인 엔티티(Domain Entity) 클래스라고 부른다.

 

Member 클래스와 MemberService 클래스 구현

 

계속해서 두 클래스를 구현해보자. 이번엔 Member 클래스가 먼저다.

package com.gnidinger.member;

import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Member {
    private long memberId;
    private String email;
    private String name;
    private String phone;
}

API 계층의 DTO 클래스에서 사용했던 멤버 변수들이 모두 포함되어 있는 것을 확인할 수 있다.

 

여기서 사용된 애너테이션은 전부 lombok이라는 라이브러리에서 제공하는 애너테이션인데, 뜻은 아래와 같다.

 

  • @Getter, @Setter -  각 멤버 변수에 해당하는 getter/setter 메서드 자동 생성
  • @AllArgsConstructor - 클래스에 추가된 모든 멤버 변수를 파라미터로 갖는 생성자 자동 생성
  • @NoArgsConstructor - 파라미터가 없는 기본 생성자 자동 생성

계속해서 MemberService 클래스를 구현해 보자.

package com.gnidinger.member;

import java.util.List;

public class MemberService {
    public Member createMember(Member member) {
        // TODO should business logic

        // TODO member 객체는 나중에 DB에 저장 후, 되돌려 받는 것으로 변경 필요.
        Member createdMember = member;
        return createdMember;
    }

    public Member updateMember(Member member) {
        // TODO should business logic

        // TODO member 객체는 나중에 DB에 업데이트 후, 되돌려 받는 것으로 변경 필요.
        Member updatedMember = member;
        return updatedMember;
    }

    public Member findMember(long memberId) {
        // TODO should business logic

        // TODO member 객체는 나중에 DB에서 조회 하는 것으로 변경 필요.
        Member member =
                new Member(memberId, "gni@gmail.com", "gni", "010-1234-5678");
        return member;
    }

    public List<Member> findMembers() {
        // TODO should business logic

        // TODO member 객체는 나중에 DB에서 조회하는 것으로 변경 필요.
        List<Member> members = List.of(
                new Member(1, "gni@gmail.com", "gni", "010-1234-5678"),
                new Member(2, "dinger@gmail.com", "dinger", "010-1111-2222")
        );
        return members;
    }

    public void deleteMember(long memberId) {
        // TODO should business logic
    }
}

아직 DB와 연동하지 않기 때문에 crateMember(), updateMember()는 전달받은 Member 객체를 그대로 리턴하고 있고

 

findMember()와 findMembers() 메서드의 경우 Stub 데이터를 넘겨주는 것을 볼 수 있다.

 

여기서 Stub란 테스트 용도로 하드 코딩한 값을 반환하는 구현체를 의미한다.

 

이어서 API 계층의 MemberController 클래스와 서비스 계층의 MemberService 클래스를 연동시켜 보자.

 

 

DI(Dependency Injection) 없이 API 계층 ↔ 서비스 계층 연동

 

가장 먼저 연동할 방법은 new 키워드를 통해 객체를 생성, 직접 의존성을 만드는 것이다.

 

MemberService의 기능을 사용하도록 MemberController를 수정해 보자.

package com.gnidinger.member;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import javax.validation.constraints.Positive;
import java.util.List;


@RestController
@RequestMapping("/v2/members")
@Validated
public class MemberController {
    private final MemberService memberService;

    public MemberController() {
        this.memberService = new MemberService(); // (1)
    }

    @PostMapping
    public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
       
        // (2)
        Member member = new Member(); 
        member.setEmail(memberDto.getEmail());
        member.setName(memberDto.getName());
        member.setPhone(memberDto.getPhone());

        // (3)
        Member response = memberService.createMember(member);

        return new ResponseEntity<>(response, HttpStatus.CREATED);
    }

    @PatchMapping("/{member-id}")
    public ResponseEntity patchMember(
            @PathVariable("member-id") @Positive long memberId,
            @Valid @RequestBody MemberPatchDto memberPatchDto) {
        memberPatchDto.setMemberId(memberId);
        
        // (4)
        Member member = new Member(); 
        member.setMemberId(memberPatchDto.getMemberId());
        member.setName(memberPatchDto.getName());
        member.setPhone(memberPatchDto.getPhone());

        // (5)
        Member response = memberService.updateMember(member);

        return new ResponseEntity<>(response, HttpStatus.OK);
    }

    @GetMapping("/{member-id}")
    public ResponseEntity getMember(
            @PathVariable("member-id") @Positive long memberId) {
            
        // (6)
        Member response = memberService.findMember(memberId);
        return new ResponseEntity<>(response, HttpStatus.OK);
    }

    @GetMapping
    public ResponseEntity getMembers() {
    
        // (7)
        List<Member> response = memberService.findMembers();
        return new ResponseEntity<>(response, HttpStatus.OK);
    }

    @DeleteMapping("/{member-id}")
    public ResponseEntity deleteMember(
            @PathVariable("member-id") @Positive long memberId) {
        System.out.println("# delete member");

        // (8)
        memberService.deleteMember(memberId);

        return new ResponseEntity(HttpStatus.NO_CONTENT);
    }
}

 

(1) - MemberService 클래스를 사용하기 위해 MemberService 클래스의 객체 생성

(2) - 클라이언트에서 전달받은 DTO 클래스의 정보를 MemberService의 createMember() 메서드의 파라미터로 전달

(3) - 회원 정보 등록을 위해 MemberService 클래스의 createMember() 메서드 호출 ⭐

(4) - 클라이언트에서 전달받은 DTO 클래스의 정보를 MemberService의 updateMember() 메서드의 파라미터로 전달

(5) - 회원 정보 수정을 위해 MemberService 클래스의 updateMember() 메서드 호출 ⭐

(6) - 한 명의 회원 정보 조회를 위해 MemberService 클래스의 findMember() 호출 및 memberId를 파라미터로 전달 ⭐

(7) - 모든 회원의 정보 조회를 위해 MemberService 클래스의 findMembers() 메서드 호출

(8) - 명의 회원 정보 삭제를 위해 MemberService 클래스의 deleteMember() 호출 및 memberId 파라미터로 전달

 

위에서 ⭐이 붙은 부분이 서비스 계층과의 연결 지점이다. URL도 "/v2/members"로 변경 되었다.

 

 

DI(Dependency Injection) 적용한 API 계층 ↔ 서비스 계층 연동

 

위의 코드에서 MemberController와 MemberService는 강하게 결합(Tight Coupling)되어 있는 상태이다.

 

이런 경우 MemberService가 수정될 때마다 MemberController도 많이 수정해야 한다.

 

따라서 클래스 간의 결합을 느슨한 결합(Loose Coupling)으로 바꿀 필요성이 생기는데,

 

Spring에서 지원하는 DI를 사용하면 간단히 이룩할 수 있다.

 

MemberController를 수정해보자.

@RestController
@RequestMapping("/v3/members")
@Validated
public class MemberController {
    private final MemberService memberService;

	// 변경 포인트
    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }
	...
}

Spring DI를 사용해서 MemberController 생성자 파라미터에 MemberService 객체를 주입받는 것을 볼 수 있다.

 

Spring에서 DI를 사용하기 위해서는 주입받는 클래스와 주입 대상 클래스 모두 Spring Bean이어야 하기 때문에

 

MemberService에도 빈 등록 애너테이션을 추가해 준다.

package com.gnidinger.member;

import org.springframework.stereotype.Service;

import java.util.List;

@Service // 변경 포인트
public class MemberService {
	...
}

참고로 MemberController 클래스는 @RestController 애너테이션이 있어 자동 빈 등록이 되며,

 

생성자가 하나일 경우에는 @Autowired의 생략이 가능하다.

 

두 개 이상의 생성자를 사용할 경우에는 반드시 @Autowired를 붙여야 한다.

 

 

개선사항

위 코드는 그대로도 잘 동작하지만 대략 두 가지의 개선사항이 존재한다.

 

  • MemberController 핸들러 메서드의 책임과 역할에 관한 문제

    • 핸들러 메서드는 요청 및 응답 데이터를 Service 클래스 - 클라이언트 사이에서 전송해주는 단순한 역할을 갖는 것이 좋음
    • 현재는 핸들러 메서드가 DTO 클래스를 엔티티(Entity) 객체로 변환하는 작업까지 맡고 있음
  • 서비스 계층에서 사용되는 엔티티(Entity) 객체를 클라이언트에 응답으로 전송하는 문제

    • DTO 클래스는 API 계층에서, 엔티티(Entity) 클래스는 서비스 계층에서만 데이터를 처리하는 역할을 해야
    • 엔티티(Entity) 클래스의 객체를 클라이언트에 응답으로 전송함으로써 계층 간의 역할 분리가 이루어지지 않음

이어지는 글에서 위 두 가지 사항을 개선해보겠다.

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함