11 분 소요

스프링 복습 및 정리 9P

서비스 계층

DI를 통한 서비스 계층 ↔ API 계층 연동

API 계층과 서비스 계층을 연동한다는 의미는 API 계층에서 구현한 Controller 클래스가 서비스 계층의 Service 클래스와 메서드 호출을 통해 상호 작용한다는 것을 의미한다.

Service의 의미

애플리케이션에 있어 Service의 의미는 도메인 업무 영역을 구현하는 비즈니스 로직과 관련이 있다.

애플리케이션의 비즈니스 로직을 처리하기 위한 서비스 계층은 도메인 모델을 포함하고 있다.

도메인 모델은 다시 빈약한 도메인 모델(anemic domain model)풍부한 도메인 모델(rich domain model)로 구분할 수 있는데, 이러한 도메인 모델은 DDD(도메인 주도 설계)와 관련이 깊다.

DDD는 현업에서 클래스 설계 경험이 풍부해야 제대로 사용할 수 있는 영역이기 때문에 백엔드 개발자로 막 입문하는 분들에게 학습을 권장할 만한 영역은 아니다.

DDD 관련 포스트 [더보기]

Service 클래스 작성

밑의 MemberController의 코드를 기준으로 MemberService 클래스를 작성해보겠다.

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("# memberId: " + memberId);
        // No need business logic

        return new ResponseEntity(HttpStatus.NO_CONTENT);
    }
}

Entity, Service 클래스 기본구조 작성

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라는 타입을 사용했다.

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

Member 클래스의 역할?

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

Member 클래스는 API 계층에서 전달받은 요청 데이터를 기반으로 서비스 계층에서 비즈니스 로직을 처리하기 위해 필요한 데이터를 전달받고,

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

Member 클래스 처럼 서비스 계층에서 데이터 액세스 계층과 연동하면서 비즈니스 로직을 처리하기 위해 필요한 데이터를 담는 역할을 하는 클래스를 도메인 엔티티 클래스 라고 부른다.

그리고 @Getter, @Setter, @NoArgsConstructor, @AllArgsConstructor 라는 어노테이션이 보인다.

이 어노테이션은 여기서 참고하길 바란다.

[@Getter, @Setter 더보기]

[@NoArgsConstructor 더보기]

[@AllArgsConstructor 더보기]

MemberService 클래스 구현

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

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

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

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

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

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

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

아직 요청 데이터를 DB에 저장하거나 DB에서 데이터를 조회하는 데이터 액세스 계층 학습 단계가 아니기 때문에

crateMember() 와 updateMember() 메서드의 경우, 단순히 파라미터로 전달받은 Member 객체를 그대로 리턴하고,

findMember() 와 findMembers() 메서드의 경우 Stub 데이터를 넘겨주도록 했다.

DI 없이 비즈니스 계층과 API 계층 연동

MemberController에서 MemberService의 기능을 사용하도록 수정해보겠다.

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

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

    @PostMapping
    public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {

        Member member = new Member();
        member.setEmail(memberDto.getEmail());
        member.setName(memberDto.getName());
        member.setPhone(memberDto.getPhone());

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

        Member member = new Member();
        member.setMemberId(memberPatchDto.getMemberId());
        member.setName(memberPatchDto.getName());
        member.setPhone(memberPatchDto.getPhone());

        Member response = memberService.updateMember(member);

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

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

    @GetMapping
    public ResponseEntity getMembers() {
        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");

        memberService.deleteMember(memberId);

        return new ResponseEntity(HttpStatus.NO_CONTENT);
    }
}

DI를 적용한 비즈니스 계층과 API 계층 연동

DI 없이 비즈니스 계층과 API 계층 연동 부분에서 작성한 코드는 DI 기능을 사용하지 않았기 때문에

MemberController와 MemberService가 강하게 결합되어 있는 상태이다.

Spring의 DI를 사용하면 클래스 간의 결합을 느슨한 결합으로 손쉽게 만들 수 있다.

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

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

이전 코드에서는 MemberController의 생성자 내부에서 new 키워드를 사용하여 MemberService의 객체를 생성했지만

이번 코드에서는 DI를 이용해서 MemberController 생성자 파라미터로 MemberService의 객체를 주입받았다.

Spring이 애플리케이션 로드 시, ApplicationContext에 있는 MemberService 객체를 주입해 준다.

그런데 Spring에서 Di를 통해서 어떤 객체를 주입받기 위해서는 주입을 받는 클래스와 주입 대상 클래스 모두 Spring Bean이어야 한다.

Membercontroller의 경우, @RestController 어노테이션이 추가되어있으므로 Spring Bean이다.

MemberService도 @Service 어노테이션을 사용함으로 Spring Bean이다.

일반적으로 생성자가 하나일 경우에는 @Autowired 어노테이션을 붙이지 않아도 Spring이 알아서 DI를 적용한다.

하지만, 생성자가 하나 이상일 경우, DI를 적용하기 위한 생성자에 반드시 @Autowired 어노테이션을 붙여야 한다.

지금까지 작성한 MemberController의 문제점

지금까지 작성한 MemberController는 두 가지 개선이 필요한 부분이 있다.

첫 번째는 Controller 핸들러 메서드의 책임과 역할에 관한 문제이다.

핸들러 메서드의 역할은 클라이언트로부터 전달받은 요청 데이터를 Service 클래스로 전달하고,

응답 데이터를 클라이언트로 다시 전송해 주는 단순한 역할만을 하는 것이 좋다.

근데 지금의 Membercontroller 에서는 핸들러 메서드가 DTO 클래스를 Entity 객체로 변환하는 작업까지 도맡아서 하고 있다.

두 번째는 Service 계층에서 사용되는 Entity 객체를 클라이언트의 응답으로 전송하고 있다.

DTO 클래스는 API 계층에서만 데이터를 처리하는 역할을 하고,

Entity 클래스는 서비스 계층에서만 데이터를 처리하는 역할을 해야 하는데

Entity 클래스의 객체를 클라이언트의 응답으로 전송함으로써 계층 간의 역할 분리가 이루어지지 않았다.

아래에서 이 문제들을 해결해 보겠다.

Mapper 이용

Mapper를 이용한 DTO class ↔ Entity class 매핑

앞서 말한 두가지 문제를 해결하려면

  1. DTO 클래스를 Entity 클래스로 변환하는 작업을 핸들러 메서드가 하지 않고, 다른 클래스에게 변환해 달라고 요청하면 된다.
  2. 클라이언트의 응답으로 Entity 클래스를 전송하지 말고, 이 Entity 클래스의 객체를 DTO 클래스의 객체로 다시 바꿔주면 된다.

DTO 클래스와 Entity 클래스를 서로 변환해 주는 Mapper가 필요한 상황이다.

MemberResponseDto 생성

MemberResponseDto 클래스는 응답 데이터의 역할을 해주는 DTO 클래스이다.

@Getter
@AllArgsConstructor
public class MemberResponseDto {
    private long memberId;

    private String email;

    private String name;

    private String phone;
}

Mapper 클래스 구현

@Component  // (1)
public class MemberMapper {
    // (2) MemberPostDto를 Member로 변환
    public Member memberPostDtoToMember(MemberPostDto memberPostDto) {
        return new Member(0L,
                memberPostDto.getEmail(), 
                memberPostDto.getName(), 
                memberPostDto.getPhone());
    }

    // (3) MemberPatchDto를 Member로 변환
    public Member memberPatchDtoToMember(MemberPatchDto memberPatchDto) {
        return new Member(memberPatchDto.getMemberId(),
                null, 
                memberPatchDto.getName(), 
                memberPatchDto.getPhone());
    }

    // (4) Member를 MemberResponseDto로 변환
    public MemberResponseDto memberToMemberResponseDto(Member member) {
        return new MemberResponseDto(member.getMemberId(),
                member.getEmail(), 
                member.getName(), 
                member.getPhone());
    }
}

(1) Bean으로 등록하기 위해 @Component 어노테이션을 추가했다. 등록된 Bean은 MemberController에서 사용된다.

(2) MemberPostDto 클래스를 Member 클래스로 변환해 주는 메서드이다.

(3) MemberPatchDto 클래스를 Member 클래스로 변환해 주는 메서드이다.

(4) Member 클래스를 MemberResponseDto 클래스로 변환해 주는 메서드이다.

MemberController에 Mapper 적용

MemberController의 핸들러 메서드에 Mapper 클래스 적용

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

    // (1) MemberMapper DI
    public MemberController(MemberService memberService, MemberMapper mapper) {
        this.memberService = memberService;
        this.mapper = mapper;
    }

    @PostMapping
    public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
        // (2) 매퍼를 이용해서 MemberPostDto를 Member로 변환
        Member member = mapper.memberPostDtoToMember(memberDto);

        Member response = memberService.createMember(member);

        // (3) 매퍼를 이용해서 Member를 MemberResponseDto로 변환
        return new ResponseEntity<>(mapper.memberToMemberResponseDto(response), 
                HttpStatus.CREATED);
    }

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

        // (4) 매퍼를 이용해서 MemberPatchDto를 Member로 변환
        Member response = 
            memberService.updateMember(mapper.memberPatchDtoToMember(memberPatchDto));

        // (5) 매퍼를 이용해서 Member를 MemberResponseDto로 변환
        return new ResponseEntity<>(mapper.memberToMemberResponseDto(response), 
                HttpStatus.OK);
    }

    @GetMapping("/{member-id}")
    public ResponseEntity getMember(
            @PathVariable("member-id") @Positive long memberId) {
        Member response = memberService.findMember(memberId);

        // (6) 매퍼를 이용해서 Member를 MemberResponseDto로 변환
        return new ResponseEntity<>(mapper.memberToMemberResponseDto(response), 
                HttpStatus.OK);
    }

    @GetMapping
    public ResponseEntity getMembers() {
        List<Member> members = memberService.findMembers();

        // (7) 매퍼를 이용해서 List<Member>를 MemberResponseDto로 변환
        List<MemberResponseDto> response =
                members.stream()
                        .map(member -> mapper.memberToMemberResponseDto(member))
                        .collect(Collectors.toList());

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

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

        return new ResponseEntity(HttpStatus.NO_CONTENT);
    }
}

Mapper 클래스를 사용함으로써 앞에서 발생한 문제점이 아래와 같이 해결되었다.

  • MemberMapper에게 DTO 클래스 → Entity 클래스로 변환하는 작업을 위임함으로써 MemberController는 더 이상 두 클래스의 변환 작업을 신경 쓰지 않아도 된다.
  • 역할 분리로 인해 코드가 깔끔해졌다.
  • MemberMapper가 Entity 클래스를 DTO 클래스로 변환해 주기 때문에 서비스 계층에 있는 Entity 클래스를 API 계층에서 직접적으로 사용하는 문제가 해결되었다.

MapStruct를 이용한 Mapper 자동 생성

지금까지는 회원 정보를 처리하는 DTO 클래스에 해당하는 Mapper 클래스를 하나만 만들기 때문에 직접 Mapper 클래스를 만드는 작업이 힘들지 않다.

하지만 어떤 도메인 업무 기능이 늘어날 때마다 개발자가 일일이 수작업으로 Mapper 클래스를 만드는 것은 비효율적이다.

MapStruct가 Mapper 클래스를 자동으로 구현해 줌으로써 개발자의 생산성을 향상해줄 수 있다.

MapStruct는 DTO 클래스처럼 Java Bean 규약을 지키는 객체들 간의 변환 기능을 제공하는 Mapper 구현 클래스를 자동으로 생성해주는 코드 자동 생성기이다.

MapStruct 의존 라이브러리 설정

dependencies {
	implementation 'org.mapstruct:mapstruct:1.4.2.Final'
	annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
}

MapStruct 기반의 Mapper 인터페이스 정의

@Mapper(componentModel = "spring")  // (1)
public interface MemberMapper {
    Member memberPostDtoToMember(MemberPostDto memberPostDto);
    Member memberPatchDtoToMember(MemberPatchDto memberPatchDto);
    MemberResponseDto memberToMemberResponseDto(Member member);
}

위의 코드는 MapStruct 기반으로 정의된 MemberMapper 인터페이스이다.

MemberMapper 인터페이스에 정의한 세 개의 메서드가 이전에 작성한 MemberMapper 클래스를 대체해준다.

@Mapper 어노테이션을 추가함으로써 해당 인터페이스는 MapStruct의 매퍼 인터페이스로 정의가 되는 것이다.

@Mapper 어노테이션의 애트리뷰트로 componentModel = “spring”을 지정해 주면

Spring의 Bean으로 등록이 된다.

자동 생성된 Mapper 인터페이스 구현 클래스 확인

MemberMapper 인터페이스의 구현 클래스는 Gradle의 build task를 실행하면 자동으로 생성된다.

@Component
public class MemberMapperImpl implements MemberMapper {
    public MemberMapperImpl() {
    }

    public Member memberPostDtoToMember(MemberPostDto memberPostDto) {
        if (memberPostDto == null) {
            return null;
        } else {
            Member member = new Member();
            member.setEmail(memberPostDto.getEmail());
            member.setName(memberPostDto.getName());
            member.setPhone(memberPostDto.getPhone());
            return member;
        }
    }

    public Member memberPatchDtoToMember(MemberPatchDto memberPatchDto) {
        if (memberPatchDto == null) {
            return null;
        } else {
            Member member = new Member();
            member.setMemberId(memberPatchDto.getMemberId());
            member.setName(memberPatchDto.getName());
            member.setPhone(memberPatchDto.getPhone());
            return member;
        }
    }

    public MemberResponseDto memberToMemberResponseDto(Member member) {
        if (member == null) {
            return null;
        } else {
            long memberId = 0L;
            String email = null;
            String name = null;
            String phone = null;
            memberId = member.getMemberId();
            email = member.getEmail();
            name = member.getName();
            phone = member.getPhone();
            MemberResponseDto memberResponseDto = new MemberResponseDto(memberId, email, name, phone);
            return memberResponseDto;
        }
    }
}

InteliJ IDE의 오른쪽 상단의 Gradle 탭을 클릭한 후, 프로젝트 명 → Tasks 디렉토리 → build 디렉토리 → build task를

더블 클릭하면 MapStruct로 정의된 인터페이스의 구현 클래스가 생성된다. (혹은 애플리케이션 실행)

그리고 좌측에서 build 디렉토리 → classes 디렉토리 내의 Mapper 인터페이스가 위치한 패키지 안에서 MemberMapperImpl 클래스를 확인할 수 있다.

MapStruct와 ModelMapper

Java에서 Object를 Mapping하는 라이브러리는 생각보다 많이 존재한다.

그중에서 가장 많이 사용되는 라이브러리는 MapStruct와 ModelMapper이다.

ModelMapper가 여전히 많이 사용되고 있지만 ModelMapper는 Runtime시

Java의 리플렉션 API를 이용해서 매핑을 진행하기 때문에 컴파일 타임에

이미 Mapper가 모두 생성되는 MapStruct보다 성능면에서 월등히 떨어진다.

DTO 클래스와 Entity 클래스의 역할 분리가 필요한 이유

DTO와 Entity 클래스를 매핑해서 사용하는 여러 가지 이유가 있지만 그중에서 대표적인 이유는 아래와 같다.

계층별 관심사 분리

우선 서로 사용되는 계층이 다르다. 따라서 기능에 대한 관심사가 다르다.

DTO 클래스는 API 계층에서 요청 데이터를 전달받고, 응답 데이터를 전송하는 것이 주 목적인 반면에

Entity 클래스는 서비스 계층에서 데이터 액세스 계층과 연동하여 비즈니스 로직의 결과로 생성된 데이터를 다루는 것이 주 목적이다.

굳이 Java의 Object Mapping 관점으로 생각하지 않아도

하나의 클래스나 메서드 내에서 여러 개의 기능들을 구현하고 있는 것은 객체 지향 코드 관점에서도 리팩토링 대상이 된다는 사실을 기억하면 좋다.

코드 구성의 단순화

DTO 클래스에서 사용하는 유효성 검사 어노테이션이 Entity 클래스에서 사용이 된다면 JPA에서 사용하는 어노테이션과 뒤섞인 상태가 되어 유지보수하기 상당히 어려운 코드가 된다.

아직 JPA 같은 데이터 액세스 기술은 서술하지 않았기 때문에
JPA 같은 데이터 액세스 기술을 학습하게 되면 이해하기 쉬울 것이다.

REST API 스펙의 독립성 확보

데이터 액세스 계층에서 전달받은 데이터로 채워진 Entity 클래스를 크라이언트의 응답으로 그대로 전달하게 되면 원치 않는 데이터까지 클라이언트에게 전송될 수 있다.

대표적인 예가 회원의 로그인 패스워드이다.

DTO 클래스를 사용하면 회원의 로그인 패스워드 같은 정보를 클라이언트에게 노출하지 않고, 원하는 정보만 제공할 수 있다.

추가 자료

당장은 아니더라도 MapStruct의 사용법을 틈틈이 익혀두면 프로젝트를 진행할 때 도움이 될 것이다.

https://mapstruct.org

https://mapstruct.org/documentation/installation/

https://mapstruct.org/documentation/stable/reference/pdf/mapstruct-reference-guide.pdf

https://github.com/mapstruct/mapstruct-examples

Java의 Object Mapping 라이브러리 성능 비교 자료 [더보기]

Spring Boot 핵심 정리 모음

1P - POJO, IoC, DI, AOP, PSA >

1.1P - DI / 의존성 주입은 왜 필요한가? >

2P - 빈(Bean) 생명주기와 범위 >

3P - 컴포넌트 스캔과 의존성 자동 주입, @Autowired >

4P - AOP (관심 지향 프로그래밍) >

5P - Spring MVC / MVC의 동작 방식과 구성 요소 >

6P - REST, REST API >

7P - HTTP Header, Rest Client >

8P - DTO, DTO 유효성 검증 >

9P - Service / Entity 클래스, Mapper / MapStruct >

10P - 예외 처리 (@ExceptionHandler, @RestControllerAdvice) >

10.1P - 비즈니스 로직에 대한 예외 처리 >

11P - JDBC 데이터 액세스 계층 >

11.1P - DDD, 도메인 엔티티 및 테이블 설계 >

11.2P - 데이터 액세스 계층 구현 (도메인 엔티티 클래스 정의) >

11.3P - 데이터 액세스 계층 구현 (서비스, 리포지토리 구현) >

12P - JPA(Java Persistence API) >

12.1P - JPA 엔티티 매핑 >

12.2P - JPA 엔티티 간의 연관 관계 매핑 >

12.3P - Spring Data JPA 데이터 액세스 계층 구현 >

13P - Spring MVC 트랜잭션 >

13.1P - 선언형 방식의 트랜잭션 적용 >

13.2P - JTA를 이용한 분산 트랜잭션 적용 >

14P - Spring MVC Testing 단위 테스트 >

14.1P - JUnit 단위 테스트 >

14.2P - Hamcrest Assertion >

14.3P - 슬라이스 테스트 (API, 데이터 액세스 계층) >

14.4P - Mockito >

14.5P - TDD (Test Driven Development) >

15P - API 문서화 (Documentation) >

15.1P - API 문서 생성, Asciidoc, Asciidoctor >

16P - 애플리케이션 빌드 / 실행 / 배포 >

17P - Spring Security >

17.1P - Spring Security 2 >

18P - JWT (JSON Web Token) >

댓글남기기