본문 바로가기
Spring, Springboot

서비스 ( Service )

by 이상한나라의개발자 2024. 7. 10.

서비스는 도메인 객체나 도메인 서비스라고 불리는 도메인에 일을 위임하는 공간이어야 한다.

  • 리포지터리에서 도메일 객체를 불러온다.
  • 도메일 객체이 일을 시킨다.
  • 리포지터리에 도메일 객체를 저장한다.
  • 컨트롤러에 응답한다.

예를들어, 물건을 파는 사이트를 상상해 봅시다. 이 서비스에는 상품(Product), 쿠폰(Cupon), 마일리지(Mieage) 라는 도메인이 있습니다. 그리고 물건의 가격을 계산하기 위해 다음과 같은 계산식을 사용합니다.

 

가격 = 상품가격 - ( 상품가격 * 쿠폰 최대할일율) - 사용자 마일리지

 

만약 해당 계산 로직이 모두 Service 레이에어 포함되어 있다면 트랙잭션 스크립트 코드로 안티패턴입니다. 그리고 서비스에 있는 비즈니스 로직은 도메인 객체가 처리하게 만들어야 합니다. 

그런데, Product, Cupon, Mileage, User 등의 도메인중에 어떤 도메인에 해당 로직을 넣어 개발하는게 가장 좋을까요?

 

user.calculatePrice(coupons, product) // 유저가 가격을 계산한다.
coupon.calculatePrice(user, product) // 쿠폰이 가격을 계산한다.
product.calculatePrice(user, coupons) // 상품이 가격을 계산한다.

 

각각 언뜻 보면 모두 말이 되는 것 같으면서도 어색합니다. 가격을 계산하는 연산 로직은 모든 도메인 객체가 처리하기 애매합니다. 왜냐하면 이러한 로직을 능동적인 객체에 표현하는 것 자체가 어렵기 때문입니다. ㅇ가격 계산 로직은 그 자체로 "연산"이며 행동입니다. 그래서 객체로 표현되기 어렵고 "계산식"과 같은 형태로 표현되는 것이 오히려 더 자연스럽습니다.

 

그렇다면 이 문제는 어떻게 해결하는 것이 좋을까요? 이러한 로직은 어쩔 수 없이 트랜잭션 스크립트 같은 코드를 유지해야 할까요? 아닙니다. 위 도메인으로 해결하기 애매하다면 새로운 객체를 만들면 됩니다. 

 

@Service
@RequiredArgsConstructor
publi class ProductService {
    private final UserJpaRepository userJpaRepository;
    private final ProductJpaRepository productJpaRepository;
    private final CouponJpaRepository couponJpaRepository;
    
    public int calculatePrice(long userId, long productId) {
        User user = userJpaRepository.getById(userId);
        Product product = productJpaRepository.getById(productId);
        List<Coupon> coupons = couponJpaRepository.getByUserId(userId);

        // manager 객체를 만들어 통합
        PriceManager priceManager = new PriceManager();

        return priceManager.calculate(user, product, coupons);
    }
}

 

PriceManager라는 매니저 클래스를 만들고 비즈니스 로직을 이 클래스 안으로 옮겼습니다. 코드를 매니저라는 클래스로 위임한 것입니다. 그런데, 왜 갑자기 매니저 클래스가 나올까요? 이유는, 이렇게 만들어지는 매니저가 바로 서비스이기 때문입니다. 다시 말해 PriceManager는 PriceService와 같은 이름입니다.

 

PriceManager, UserManager, ProductManager... 이러한 컴포넌트의 이름을 보면 어떤 생각이 드나요? 그리고 어떤 의도로 만든 건가요? 일반적으로 클래스 이름의 접미어에 Manager이 나오면 접두에에 있는 모델을 관리하는 클래스를 뜻합니다.

 

현재 서비스가 두개 있습니다. 스프링 컴포넌트로 만든 ProductService, 가격 계산 로직을 표현하기 위해 만든 PriceManager 입니다. ProductService 가 PriceService(=PriceManager)을 실행 시키고 있습니다. 서비스가 서비스를 실행하고 있다고 볼 수 있습니다.

 

조금더 나아가서, 서비스가 서비스를 실행하는 것은 좋은데, 이 둘을 구분할 필요가 있어 보입니다. 양쪽 모두 어떤 도메인 객체로 표현하기 애매한 연산 로직을 모아둔 클래스인 것은 맞지만 그 성격이 조금씩 다르기 때문입니다. PriceManager는 도메인 시스템을 구축하기 위해 존재합니다. 그리고 가격 계산한다는 점과 가격을 계산하는 비즈니스 업무 규칙을 갖고 있으므로 "도메인"에 가까운 로직입니다.

 

한편 ProductService는 다릅니다. 스프링에서 사용하는 @Service 애너테이션으로 만들어지고 도메인에 필요한 비즈니스 업무 규칙을 갖기 보다는 애플리케이션이 돌아가는 데 필요한 연산을 갖고 있는 서비스 입니다. 그래서 도메인 보다는 "애플리케이션 실행"에 초점을 맞춰 개발된 서비스 입니다.

 

따라서, 목적이 다르므로 둘은 구분되어져야 합니다. ProductService 같은 서비스는 "애플리케이션 서비스, "PriceManager" 같은 서비스를 도메인 서비스라고 부릅니다.

 

분류 역할 주요 행동 예시
도메인 비즈니스 로직 처리 도메인 역할 수행 User, Product, Coupon...
다른 도메인과 협력
도메인 서비스 비즈니스 연산 로직 처리 도메인 협력을 중재한다. PriceManager...
도메인 객체에 기술할 수 없는 연산 로직을 처리한다.
여러 도메인을 가지고 협력을 처리하는 경우
애플리케이션 서비스 애플리케이션 연산 로직을 처리 도메인을 저장소르 불러온다  
도메인 서비스를 실행한다.
도메인을 실행한다.

 

이 셋을 구분하고 각 역할을 이해하는 것이 정말 중요합니다. 정말 많은 스프링 개발자들이 비즈니스 로직이라는 이름하에 서비스 컴포넌트에 모든 코드를 작성합니다. 하지만 알고 보면 그렇게 작성한 대부분의 코드는 사실 도메인 객체가 처리해야 하는 경우가 대부분 입니다.

 

파사드

 

스프링 프로젝트 @Service 주석에 이런 문구가 있습니다 "J2EE의 비즈니스 서비스 파사드 패턴처럼 사용할 수 있다." 스프링 서비스는 도메인과, 도메인 서비스 파사드(facade) 처럼 사용할 수 있는 공간입니다. 왜냐하면 비즈니스 로직을 갖고 있는 곳이 도메인과 도메인 서비스이고 이를 연결해주는게 애플리케이션 서비스 입니다. 달리 말하면 스프링 서비스 ( @Service 애노테이션이 달린 ) 역할이 딱 이 정도여야 한다는 의미이기도 합니다. 그러므로 스프링의 서비스 컴포넌트에 너무 많은 역할을 할당하면 안됩니다.

 

위 내용에서 서비스가 무엇인지에 대해 설명하였습니다. 이제 가격 계산 로직을 다시 한번 되짚어 보겠습니다.

 

앞에서는 가격 계산 로직을 옮길만한 적절한 도메인을 찾지 못해 PirceManager 클래스를 만들었습니다. 하지만 이 로직은 도메인 객체로 옮길 수 있습니다. Cashier(점원)라는 도메인 모델을 만들고 Cashier가 가격을 계산한느 로직을 갖게하면 됩니다.

@Service
@RequiredArgsConstructor
publi class ProductService {
    private final UserJpaRepository userJpaRepository;
    private final ProductJpaRepository productJpaRepository;
    private final CouponJpaRepository couponJpaRepository;
    
    public int calculatePrice(long userId, long productId) {
        User user = userJpaRepository.getById(userId);
        Product product = productJpaRepository.getById(productId);
        List<Coupon> coupons = couponJpaRepository.getByUserId(userId);

        // Cashier 객체를 만들어 통합
        Cashier cashier = new Cashier();

        return cashier.calculate(user, product, coupons);
    }
}

 

그런데 조금 이상합니다. 사실 PriceManager, Cashier은 클래스 이름만 다를뿐 동일한 역할을 하고 있습니다. 내부적인 코드도 같고요. 그런데 평가가 다릅니다. PriceManager 클래스는 도메인 서비스이고, Cashier은 도메인입니다. 고작 이름 하나 바꿨을 뿐인데 평가가 다른것이 뭔가 이상하지 않나요? 하지만 클래스의 이름은 생각보다 많은 것을 결정합니다. 클래스의 역할은 클래스의 이름을 짓는 순간 부터 결정됩니다. PriceManager 라는 이름을 들으면 가격과 관련된 연산로직만 갖고 있을 것처럼 느껴집니다. 그래서 이 클래스로 만들어진 객체는 객체지향이라는 역할극에서 여떤 유의미한 인물로 나오는 것이 아니라 그저 계산식을 여러개 갖고 있는 장치처럼 사용될 것입니다. 따라서 PriceManager은 유의미한 도메인 객체로 확장되기 어렵습니다.

 

그리고 도메인 모델로 발전할 수 있는 출발점에 있으므로 PriceManager는 Cashier로 표현하는 것이 객체지향 관점에서 더 나은 표현이라는 것입니다.

 

이제, 도메인과 도메인 서비스는 이름보다는 둘을 구분 짓는 행동으로 결정됩니다. 다시 말해 접미어에 Service, Manager가 없다고 해서 이 클래스가 더 이상 서비스가 아니게 되는 것은 아닙니다. 마찬가지로 Service 접미어가 온다고 해서 이 클래스가 서비스가 되는 것 또한 아닙니다.

 

이러한 맥락에서 클래스 이름을 지을 때 서비스를 표현하기 위해 컴포넌트 이름에 Service나 Manager를 쓰는 것은 무의미합니다. 왜냐하면 Service라는 이름이 클래스에 붙어 있지 않다고 해서 서비스가 아닌게 아니기 때문입니다. 바로 역할입니다.

 

객체지향으로 보는 서비스

  1. 서비스는 가능한 적게 만들고, 얇게 유지해야 한다. -> 서비스 로직의 길이가 최대한 짧게 유지
  2. 서비스보다 풍부한 도메인 모델을  만들어야 합니다.

서비스를 가능한 적게 만들고, 얇게 유지해야한다는 말은 서비스 로직의 길이가 최대한 짧아야 된다는 의미이며, 이는 로직은 최대한 도메인객체로 옮겨야 한다는 말고 일맥상통합니다. 서비스 코드를 작성할 때는 현재 작성 중인 코드가 "기존 도메인 객체에 들어갈 수는 없는지"와 "새로운 도메인 객체를 만들 수 없는지" 고민해 보는 것이 좋습니다.

 

개발 우선순위

  • 도메인 모델 > 도메인 서비스 > 애플리케이션 서비스

오늘날 흔히 하는 실수는 행위를 적절한 객체로 다듬는 것을 너무나도 쉽게 포기해서 점점 절차지향적 프로그래밍에 빠지는 것이다.

- 에릭 에반스

 

작은 기계

 

첫 번째 객체는 한번 생성하면 여러 번 사용하지만 그 자신은 바꿀 수 없다. 생명 주기도 매운 단순하다. 한번 생성하면 특정 작업을 하는 작은 기계처럼 영원히 실행할 수 있다. 이러한 객체를 서비스라 한다.

- 마티아스 노박

 

위 내용을 정리하면 아래와 같습니다.

  • 서비스는 한번 생성하면 여러 번 사용하지만, 그 자신은 바꿀 수 없다.
  • 서비스는 작은 기계처럼 영원히 실행할 수 있다.

그 자신은 바꿀 수 없다. 라는 말은 곧 불변성을 나타냅니다. 그러면 이와 관련하여 이해할 수 있는 격언이 있습니다. 스프링을 사용하다 보면 "서비스 필드 주입이나 수정자 주입을 사용하지 말고 생성자 주입을 사용하라" 라는 격언이 있습니다. 서비스는 불변해야 한다는 말을 이해한 분이라면 이제 이 격언이 존재하는 이유를 알 수 있습니다.

import org.springframework.stereotype.Service;

@Service
public class MyService {
    private final Dependency dependency;

    public MyService(Dependency dependency) {
        this.dependency = dependency;
    }

    public String performService() {
        return dependency.action();
    }
}


// 의존성 클래스 정의
import org.springframework.stereotype.Component;

@Component
public class Dependency {
    public String action() {
        return "Action performed";
    }
}


// 스프링 설정 클래스
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan(basePackages = "com.example")
public class AppConfig {
}


// 애플리케이션 실행 클래스
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class Application {
    public static void main(String[] args) {
        ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
        MyService myService = context.getBean(MyService.class);

        System.out.println(myService.performService());
    }
}

 

MyService 클래스는 Depency 객체를 생성자를 통해 주입 받습니다. 이로 인해 MyServie 객체가 생성된 후에는 Depency 객체가 변경될 수 없습니다. 

 

생성자 주입을 사용하여 얻을 수 있는 이점 

  1. 생성자 주입을 사용하면 명시적으로 의존성을 표현할 수 있습니다.
  2. 생성자 주입을 사용하면 테스트가 쉬워집니다.
  3. 생성자 주입을 사용하면 순환 의존성을 방지할 수 있습니다.

가장 중요한 것은 바로 "서비스는 불변해야 한다" 라는 원칙입니다. 서비스의 상태가 변경된다면 서비스는 영원히 같은 일을 할 수 없습니다.

 

  1. 서비스는 불변하고 예측 가능한 컴포넌트가 되어야 합니다.
  2. 같은 입력에 항상 같은 결과만 나와야 합니다.
  3. 항상 똑같이 움직이는 작은 기계처럼 작동해야 합니다.
  4. 서비스는 견고해야 합니다.
  5. 서비스는 얇고 작아야 합니다.
  6. 이러한 이유로 서비스는 생성자 주입을 사용해야 합니다.

 

서비스와 관련된 행동 조언 

  1. 서비스 멤버 변수는 모두 final로 만드세요.
  2. 서비스에 세터가 존재한다면 지우세요.
  3. 서비스는 반드시 생성자 주입으로 바꾸세요.
  4. 서비스 비즈니스 로직을 도메인에 양보하세요.
  5. 서비스를 얇게 유지하세요.

이 다섯 가지 조언을 여러분의 프로젝트로 돌아가 적용해 보시기 바랍니다. 장담컨데 여러분의 코드베이스를 더 나은 방향으로 만들어 줄 것입니다.

'Spring, Springboot' 카테고리의 다른 글

자가 호출  (0) 2024.07.16
타입 기반 주입  (0) 2024.07.15
Spring Cloud OpenFeign  (1) 2024.02.07
전역 에러 처리  (0) 2024.01.30
CORS 이해와 설정  (0) 2024.01.29