[Springboot] 스프링 주요 계층별 잡지식 정리 (2): Service, Dto

winluck·2023년 11월 6일
0

Springboot

목록 보기
12/18

Service 개요

  • Service는 Springboot에서 가장 중추적인 부분이며, 비즈니스 로직을 총괄하는 심장부이다.
  • DB에 가하는 CRUD 작업을 지휘하고, 문제가 생기면 예외를 발생시켜 Springboot에 알린다.
  • @Service 어노테이션을 통해 스프링에 이 객체가 Service 계층임을 설정한다.

의존성 주입

  • Service는 필연적으로 CRUD를 위해 Repository 계층에 의존하게 된다.
    • Controller 역시 필연적으로 Service 계층에 의존한다.
  • 스프링은 이런 의존 관계를 해결해주기 위해 Service에 Repository를 외부에서 주입해줄 수 있다.
  • 이렇게 요구되는 의존 관계를 내부에서 직접 선언하거나 초기화하지 않고, 외부에서 주입받는 일련의 과정을 의존성 주입(Dependency Injection)이라고 한다.
  • 의존성 주입 방법은 크게 필드 주입, 생성자 주입, 수정자 주입으로 나눈다.
  • 필드 주입: @Autowired
@Service
public class UserService {

	@Autowired
    private UserRepository userRepository;

}
  • 생성자 주입: final & @RequiredArgConstructor
@Service
public class UserService {

    private final UserRepository userRepository;

	public UserService(UserRepository userRepository){
			this.userRepository = userRepository;
	}
}

위 코드는 아래와 같다.

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

}
  • 수정자 주입: @Autowired & Setter
@Service
public class UserService {

    private UserRepository userRepository;

		@Autowired
		public void setUserRepository(UserRepository userRepository){
				this.userRepository = userRepository;
		}
}
  • Spring은 공식적으로 생성자 주입을 권장하고 있다.
    • final을 통해 주입된 객체의 불변성을 보장한다.
    • @RequiredArgsConstructor 어노테이션 하나로 쉽고 다양한 의존관계에 대해 간단하게 주입 과정을 표현할 수 있다.

Service 예시 메서드

  • 의존 관계인 UserRepository의 findById() 메서드를 통해 회원번호가 id인 유저의 데이터를 조회하고, 이를 dto로 변환하여 클라이언트에 반환하는 메서드이다.
  • 그런데 보다시피 Optional 관련 오류가 발생하고 있다.
  • 이는 DB에 접근하는 UserRepository에서 반환하는 객체가 User가 아닌, Optional로 감싸진 Optional이기 때문이다.
  • 결과 반환값이 Optional<>로 감싸지는 것의 이점은 다음과 같다.
    • 값이 없으면 null이 아니라, Optional.Empty()를 반환하기에, NullPointerException을 막을 수 있다.
    • 그러므로 값이 존재할 때와 존재하지 않을 때에 대한 로직의 구별이 필요하다면 명시적으로 표현이 가능
Optional<User> user = userRepository.findById(userId);

if (user.isPresent()) {
    User user = userOptional.get();
    // 값이 존재할 때의 로직
} else {
    // 값이 존재하지 않을 때의 로직
}
  • Java 람다식을 사용하여 아래와 같이 if/else문 없는 간결한 표현이 가능해진다.
User user = userRepository.findById(userId)
    .orElseThrow(() -> new EntityNotFoundException("User not found"));

Dto

  • Data Transfer Object
    • 서버 → 클라, 혹은 클라 → 서버로의 데이터 전송을 목적으로 사용되는 객체
    • 비즈니스 로직을 가지지 않고, 데이터만을 저장하고 전송하는 데에만 사용
  • 직렬화: 객체를 Byte Stream 등의 직렬화 가능한 형식으로 변환하는 과정
    • 데이터를 상대방에 전송하기 위해 필수적인 절차이다.
  • 역직렬화: 바이트스트림 등으로 직렬화된 객체를 다시 원래의 객체로 변환하는 과정
  • 왜 객체 그대로 보내지 않고 굳이 Dto로 바꾸는 번거로운 과정을 거쳐야 하나요?
    • 외부에서 객체의 내부를 굳이 불필요하게 드러낼 이유가 없음
    • 클라이언트, 서버가 필요로 하는 데이터만을 포함하는 것이 바람직

RequestDto

  • 클라이언트가 서버로 전송하는 객체이다.
  • RequestDto는 @Getter@NoArgsConstructor가 필수적으로 존재해야 한다.
  • 예를 들어, 내 정보 수정을 위한 Dto는 아래와 같다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UpdateArticleDto {

	private Long userId;
	private Long articleId;
    private String title;
    private String contents;
  
  	...
}
  • 수정을 요청한 유저의 id, 수정할 게시물의 id, 게시물의 수정한 제목, 게시물의 수정한 내용이라는 데이터가 클라이언트로부터 서버로 전송된다.
  • Springboot는 이를 Controller에서 인자로 수신하여 Service 계층에 넘긴다.
  • Service는 적절한 비즈니스 로직을 수행하고 응답을 클라이언트에게 제공한다.

ResponseDto

  • 서버가 클라이언트로 전송하는 객체이다.
  • ResponseDto는 @Getter가 필수적으로 존재해야 한다.
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class ResponseArticleDto {

    private Long articleId;
    private String title;
    private String contents;
    private String authorName;
    private String authorProfileImage;
    private List<String> tags;
    private List<ResponseLikeDto> likes;
    private List<ResponseCommentDto> comments;
    private LocalDateTime createdAt;
    private boolean isLiked;

    public static ResponseArticleDto of(Long articleId, String title, String contents, String authorName, String authorProfileImage, List<String> tags, List<ResponseLikeDto> likes, List<ResponseCommentDto> comments, LocalDateTime createdAt, boolean isLiked) {
        return new ResponseArticleDto(articleId, title, contents, authorName, authorProfileImage, tags, likes, comments, createdAt, isLiked);
    }

    public static ResponseArticleDto from(Article article, boolean isLiked) {
        return ResponseArticleDto.of(
                article.getId(),
                article.getTitle(),
                article.getContents(),
                article.getUser().getName(),
                article.getUser().getProfileImage(),
                article.getArticleTags().stream().map(articleTag -> articleTag.getTag().getTagName()).collect(Collectors.toList()),
                article.getLikes().stream().map(ResponseLikeDto::from).collect(Collectors.toList()),
                article.getComments().stream().map(ResponseCommentDto::from).collect(Collectors.toList()),
                article.getCreatedAt(),
                isLiked
        );
    }
}
  • 클라이언트에게 게시물에 대한 구체적인 정보가 Dto의 형태로 서버에서 전송된다.
  • 변수도 너무 많고, 생성자의 인자도 지나치게 많다.
  • 더 나은 방법이 없을까?

빌더 패턴

  • 우리가 생성 메서드(정적 팩토리 메서드)를 사용하는 이유는 객체의 무결성을 유지하기 위함이다.
  • Dto는 객체의 무결성보다는 데이터 전송을 위한 신속한 구현 및 유연한 수정을 요구한다.
  • 또한 Dto는 그 특성상 다양한 자료형들이 끊임없이 클라이언트의 요구에 따라 얽히고 설킨다.
  • 따라서 생성자나 생성 메서드보다는, 유연성을 가져갈 수 있는 빌더 패턴 전략을 채택할 수 있다.
@Getter
@Builder
public class ResponseSimpleArticleDto {
    private Long articleId;
    private String title;
    private String contents;
    private String authorName;
    private String authorProfileImage;
    private int likesCount;
    private int commentCount;
    private LocalDateTime createdAt;
    private boolean isLiked;
}
  • 빌더 패턴을 채택하면 아래처럼 직관적인 Dto 생성이 가능해진다.
ResponseSimpleArticleDto articleDto = ResponseSimpleArticleDto.builder()
    .articleId(1L)
    .title("title")
    .contents("content")
    .authorName("name")
    .authorProfileImage("profile.jpg")
    .likesCount(10)
    .commentCount(5)
    .createdAt(LocalDateTime.now())
    .isLiked(false)
    .build();
  • 인자가 지나치게 많아지거나 유연성이 필요하다면 언제든지 빌더패턴 도입을 유연하게 검토해보자.

Service 계층의 기본적인 코딩 규칙

  • 협업 팀원 간 주요 계층의 의존성 주입 방식을 공통적으로 합의해야 한다.
    • 생성자 주입, 필드 주입, 수정자 주입 등
    • 보통 생성자 주입(@RequiredArgsConstructor) 전략을 채택하는 경우가 많다.
  • DB에 트랜잭션 연산을 가하는 메서드인 경우 @Transactional 어노테이션을 반드시 추가해야 한다.
    • 조회 기능의 경우 @Transactional(readOnly = true) 를 추가한다.
  • 반복되는 로직은 따로 함수로 분리한다.
    • 특정 조건을 만족하는 Entity의 존재 여부를 판정하거나 존재 자체를 가져와야 할 필요가 많다.
    • 이 경우 orElseThrow() 등 큰 의미 없이 반복되는 코드가 많아진다.
    • 그런 경우 별도의 private 메서드를 선언하여 중복을 제거하는 것이 바람직하다.
private void validateUser(Long userId) {
    if (!userRepository.existsById(userId)) 
				throw new UserException(ResponseCode.USER_NOT_FOUND);
}

private User getUserById(Long userId) {
    return userRepository.findById(userId).orElseThrow(() -> new UserException(ResponseCode.USER_NOT_FOUND));
}
  • 유효성 검증 메서드는 validateXXX으로 네이밍하여 작성하고, 이 메서드를 사용하는 메서드 바로 아래에 위치하도록 한다.
    • 만약 사용하는 메서드가 여러 개라면 맨 아래에 위치하도록 한다.
  • stream() 연산을 적극적으로 활용하자.
// 유저가 구독한 태그 조회
@Transactional(readOnly = true)
public List<ResponseTagDto> getUserTags(Long userId) {
    User user = getUserById(userId);
    List<UserTag> tags = user.getUserTags();
    List<ResponseTagDto> responseTagDtos = new ArrayList<>();
    for(UserTag tag : tags) {
        responseTagDtos.add(ResponseTagDto.from(tag));
    }
    return responseTagDtos;
}

for문을 통해 Entity를 Dto로 변환하기보단, 되도록이면 Java 8의 stream() 연산을 활용하자.

// 유저가 구독한 태그 조회
@Transactional(readOnly = true)
public List<ResponseTagDto> getUserTags(Long userId) {
    User user = getUserById(userId);
    return user.getUserTags().stream()
            .map(ResponseTagDto::from)
            .collect(Collectors.toList());
}

훨씬 간결해졌음을 알 수 있다.
이처럼 Entity → Dto 변환 과정은, for문보다는 람다식과 stream을 활용하여 최대한 짧고 간결하게 작성하자.

profile
Discover Tomorrow

0개의 댓글