컨트롤러와 데이터 액세스 계층(Data Access Layer) 사이에서 비즈니스 로직(Business Logic)을 처리하는 역할을 합니다.
Controller의 핸들러 메서드를 통해서 클라이언트의 요청받았으면, 전달받은 클라이언트의 요청을 직접적으로 처리하는 곳은 서비스 계층입니다.
서비스 계층은 웹 애플리케이션의 비즈니스 요구 사항을 처리하는 핵심 계층으로 비즈니스 요구 사항이 복잡할수록 비즈니스 로직도 복잡도가 증가 할 수 있습니다.
Spring은 개발자가 핵심 비즈니스 로직 개발에 집중할 수 있도록 비즈니스 로직 이외의 작업들을 대신해 줍니다.
Spring에서 DI를 통해서 어떤 객체를 주입받기 위해서는 주입을 받는 클래스와 주입 대상 클래스 모두 Spring Bean이어야 합니다.
@Service를 사용하여 Spring Bean으로 등록합니다.
일반적으로 생성자가 하나일 경우에는 @Autowired 애너테이션을 붙이지 않아도 Spring이 알아서 DI를 적용합니다. 생성자가 하나 이상일 경우, DI를 적용하기 위한 생성자에 반드시 @Autowired 애너테이션을 붙여야 합니다.
package com.codestates.member.service;
import com.codestates.exception.BusinessLogicException;
import com.codestates.exception.ExceptionCode;
import com.codestates.member.entity.Member;
import com.codestates.repository.MemberRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class MemberService {
private MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
public Member createMember(Member member) {
verifyExistsEmail(member.getEmail());
return memberRepository.save(member);
}
public Member updateMember(Member member) { // @Post핸들러메소드의 로직 처리 담당
// 존재하는 회원인지 검증
Member findMember = findVerifiedMember(member.getMemberId());
// 이름 정보와 휴대폰 번호 정보 업데이트
Optional.ofNullable(member.getName())
.ifPresent(name -> findMember.setName(name));
Optional.ofNullable(member.getPhone())
.ifPresent(phone -> findMember.setPhone(phone));
// 회원 정보 업데이트
return memberRepository.save(findMember);
}
public Member findMember(long memberId) { // 회원 1명 조회
return findVerifiedMember(memberId);
}
public List<Member> findMembers() { //회원 전체 조회
return (List<Member>) memberRepository.findAll();
}
public void deleteMember(long memberId) {
Member findMember = findVerifiedMember(memberId);
// 특정 회원 정보 삭제
memberRepository.delete(findMember);
}
// 이미 존재하는 회원인지를 검증
public Member findVerifiedMember(long memberId) {
Optional<Member> optionalMember =
memberRepository.findById(memberId);
Member findMember =
optionalMember.orElseThrow(() ->
new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
return findMember;
}
private void verifyExistsEmail(String email) {
Optional<Member> member = memberRepository.findByEmail(email);
if (member.isPresent())
throw new BusinessLogicException(ExceptionCode.MEMBER_EXISTS);
}
}
서비스 계층에서 데이터 액세스 계층과 연동하면서 비즈니스 로직을 처리하기 위해 필요한 데이터를 담는 역할을 하는 클래스를 도메인 엔티티(Entity) 클래스입니다.
DTO 클래스와 엔티티 클래스의 역할 분리가 필요한 이유
- TO 클래스는 API 계층에서 요청 데이터를 전달받고, 응답 데이터를 전송하는 것이 주 목적인 반면에 Entity 클래스는 서비스 계층에서 데이터 액세스 계층과 연동하여 비즈니스 로직의 결과로 생성된 데이터를 다루는 것이 주목적입니다.
- DTO 클래스에서 사용하는 유효성 검사 애너테이션이 Entity 클래스에서 사용이 된다면 JPA에서 사용하는 애너테이션과 뒤섞인 상태가 되어 유지보수하기 상당히 어려운 코드가 됩니다.
- 데이터 액세스 계층에서 전달받은 데이터로 채워진 Entity 클래스를 클라이언트의 응답으로 그대로 전달하게 되면 원치 않는 데이터까지 클라이언트에게 전송될 수 있습니다.
ex) 회원의 로그인 패스워드를 같이 전달하다보면 클라이언트에 노출될 위험이 증가합니다.
DTO 클래스를 사용하면 회원의 로그인 패스워드 같은 정보를 클라이언트에게 노출하지 않고, 원하는 정보만 제공할 수 있습니다.
package com.codestates.member.entity;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.annotation.Id;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Member {
@Id
private long memberId;
private String email;
private String name;
private String phone;
}
MemberPostDto 클래스와 Member 클래스를 서로 변환해 주는 매퍼(Mapper) 클래스를 구현
우선은 매퍼 클래스를 직접 구현해보겠습니다.
@Component
public class MemberMapper {
// MemberPostDto를 Member로 변환
public Member memberPostDtoToMember(MemberPostDto memberPostDto) {
return new Member(0L,
memberPostDto.getEmail(),
memberPostDto.getName(),
memberPostDto.getPhone());
}
// MemberPatchDto를 Member로 변환
public Member memberPatchDtoToMember(MemberPatchDto memberPatchDto) {
return new Member(memberPatchDto.getMemberId(),
null,
memberPatchDto.getName(),
memberPatchDto.getPhone());
}
// Member를 MemberResponseDto로 변환
public MemberResponseDto memberToMemberResponseDto(Member member) {
return new MemberResponseDto(member.getMemberId(),
member.getEmail(),
member.getName(),
member.getPhone());
}
}
지금은 회원 정보를 처리하는 DTO 클래스에 해당하는 매퍼(Mapper) 클래스를 하나만 만들기 때문에 직접 매퍼(Mapper) 클래스를 만드는 작업이 힘들지 않습니다.
하지만 도메인 업무 기능이 늘어날 때마다 개발자가 일일이 수작업으로 매퍼(Mapper) 클래스를 만드는 것은 비효율적입니다.
MapStruct는 Java Bean 규약을 지키는 객체들 간의 변환 기능을 제공하는 매퍼(Mapper) 구현 클래스를 자동으로 생성해 주는 코드 자동 생성기입니다.
MapStruct 관련 의존 라이브러리를 Gradle의 build.gradle 파일에 아래와 같이 추가해야 합니다.
dependencies {
...
...
implementation 'org.mapstruct:mapstruct:1.4.2.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
}
@Mapper 애너테이션을 추가하고 애트리뷰트로 componentModel = "spring"을 지정해 주면 Spring의 Bean으로 등록이 됩니다.
package com.codestates.member.mapper;
import com.codestates.member.dto.MemberPatchDto;
import com.codestates.member.dto.MemberPostDto;
import com.codestates.member.dto.MemberResponseDto;
import com.codestates.member.entity.Member;
import org.mapstruct.Mapper;
import java.util.List;
@Mapper(componentModel = "spring")
public interface MemberMapper {
Member memberPostDtoToMember(MemberPostDto memberPostDto);
Member memberPatchDtoToMember(MemberPatchDto memberPatchDto);
MemberResponseDto memberToMemberResponseDto(Member member);
List<MemberResponseDto> membersToMemberResponseDtos(List<Member> members);
}
IntelliJ IDE의 오른쪽 상단의 [Gradle] 탭을 클릭한 후, [프로젝트 명 > Tasks 디렉토리 > build 디렉토리 > build task]를 더블 클릭하면 MapStruct로 정의된 인터페이스의 구현 클래스가 생성됩니다.
자동 생성된 Mapper 인터페이스 구현 클래스 확인합니다.
빈약한 도메인 모델(anemic domain model)은 도메인 객체들에 비즈니스 로직(확인, 계산, 비즈니스 규칙 등)이 거의 없거나 아예 없는 소프트웨어 도메인 모델의 이용입니다.
빈약한 도메인 모델은, 도메인 모델에서 상태(state)와 동작(behavior)을 분리하여 상태만을 포함하고 동작은 별도의 서비스 계층(Service Layer)이나 데이터 액세스 계층(Data Access Layer)에 위임하는 방식입니다. 이 방식은 모델 객체가 순수한 데이터 저장소 역할만을 하기 때문에, 비즈니스 로직을 이해하기 어렵고 유지보수하기 어려운 코드를 만들 수 있습니다.
// 빈약한 도메인 모델 예시
public class User {
private Long id;
private String name;
private int age;
private String email;
// getter, setter 생략
}
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public User createUser(User user) {
return userRepository.save(user);
}
public User updateUser(Long id, String name, int age, String email) {
User user = userRepository.findById(id).orElse(null);
if (user == null) {
throw new NotFoundException("User not found");
}
user.setName(name);
user.setAge(age);
user.setEmail(email);
return userRepository.save(user);
}
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
}
풍부한 도메인 모델(Rich Domain Model)은 도메인 모델에서 상태와 동작을 함께 표현하는 방식입니다. 도메인 모델이 객체지향적인 방식으로 설계되어 있기 때문에, 객체의 상태와 동작이 서로 연관되어 있습니다. 따라서, 비즈니스 로직을 객체의 동작으로 표현할 수 있으며, 객체지향의 장점인 캡슐화, 추상화, 다형성 등을 활용할 수 있습니다.
풍부한 도메인 모델은 도메인 로직이 객체 내부에 존재하기 때문에, 객체 자체가 비즈니스 로직을 처리할 수 있습니다. 이를 통해, 객체간의 의존성을 줄이고 코드를 간결하게 유지할 수 있으며, 변경에 유연하게 대처할 수 있습니다. 하지만, 비즈니스 로직이 복잡해질수록 객체 간의 의존성이 복잡해지는 단점이 있습니다.
따라서, 애플리케이션의 복잡도와 요구사항을 고려하여 빈약한 도메인 모델과 풍부한 도메인 모델 중 선택해야 합니다.
// 풍부한 도메인 모델 예시
public class User {
private Long id;
private String name;
private int age;
private String email;
public User(String name, int age, String email) {
this.name = name;
this.age = age;
this.email = email;
}
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public User createUser(String name, int age, String email) {
User user = new User(name, age, email);
return userRepository.save(user);
}
public User updateUser(Long id, String name, int age, String email) {
User user = userRepository.findById(id).orElse(null);
if (user == null) {
throw new NotFoundException("User not found");
}
user.changeName(name);
user.changeAge(age);
user.changeEmail(email);
return userRepository.save(user);
}
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
}
풍부한 도메인 모델의 예시에서는 User 클래스는 상태와 동작을 함께 표현하고 있습니다. UserService 클래스에서는 User 객체의 동작을 호출하여 비즈니스 로직을 처리하고 있습니다. User 객체는 비즈니스 로직을 스스로 처리할 수 있으며, 객체 간의 의존성을 줄이고 코드를 간결하게 유지할 수 있습니다.