ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring] 서비스(Service) 레이어의 본질적인 역할과 책임
    Spring 2025. 12. 20. 19:57

    백엔드 개발자에게 @Service 어노테이션은 굉장히 많이 사용하는 것 중 하나입니다. 하지만 "서비스의 역할이 정확히 무엇인가?"라는 질문에 명확하게 답하기란 의외로 쉽지 않습니다. 단순히 컨트롤러와 리포지토리 사이를 연결하는 접착제일까요? 아니면 비즈니스 로직 전체를 의미할까요?

    도메인 주도 설계(DDD) 관점에서 바라본 서비스(Application Service)의 역할은 명확합니다. 그것은 바로 '도메인 모델의 협력자'입니다. 구체적으로는 다음 세 가지 흐름을 제어하는 것이 서비스의 핵심 책임입니다.

    • Load: 리포지토리를 통해 도메인 객체를 로드합니다.
    • Delegate: 도메인 객체나 도메인 서비스에 실제 비즈니스 행위를 위임합니다.
    • Save: 로직 수행 후 변경된 상태를 영구 저장소에 반영합니다.

    이번 포스팅에서는 서비스가 왜 이러한 역할을 수행해야 하는지 그 이유를 분석합니다. 더불어 애플리케이션 서비스와 도메인 서비스의 차이, 그리고 스프링 프레임워크가 서비스 컴포넌트에 기대하는 추가적인 역할까지 상세히 알아보겠습니다.

     

    1. 도대체 왜 '서비스(Service)'라고 부를까?

    우리가 사용하는 컴포넌트들의 이름에는 대부분 그 역할이 명확히 드러나 있습니다. 컨트롤러(Controller)는 요청을 제어하는 제어부이고, 리포지토리(Repository)는 데이터를 담아두는 저장소입니다. 영단어를 한글로 번역하는 것만으로도 이 컴포넌트가 시스템에서 어떤 위치를 차지하는지 직관적으로 이해할 수 있습니다.

    하지만 '서비스(Service)'는 다릅니다. 단어 자체가 너무나 포괄적입니다. 그래서 많은 개발자들에게 "서비스가 무엇인가요?"라고 물으면, 이렇게 답하곤 합니다.

    "서비스는... 비즈니스 서비스를 처리하는 곳입니다."

    이는 마치 "서비스는 서비스다"라고 말하는 동어반복에 불과합니다. 우리가 진짜 궁금한 것은 '왜 하필 서비스라는 이름을 붙였는가?', 그리고 '도대체 이 컴포넌트의 본질은 무엇인가?'입니다.

    이 근원적인 질문에 대한 해답 스프링 구현 코드에서 찾을 수 있습니다. 스프링 개발자들이 직접 남겨놓은 @Service 어노테이션의 소스 코드와 주석(Javadoc)을 확인하는 것입니다.

     

    spring-framework/spring-context/src/main/java/org/springframework/stereotype/Service.java at main · spring-projects/spring-fram

    Spring Framework. Contribute to spring-projects/spring-framework development by creating an account on GitHub.

    github.com

     

    스프링 소스 코드가 말하는 '서비스'의 기원

    다음은 스프링 프레임워크 깃허브(Github) 리포지토리에서 발췌한 org.springframework.stereotype.Service의 실제 코드와 주석입니다.

     
    /*
     * Copyright 2002-present the original author or authors.
     *
     * Licensed under the Apache License, Version 2.0 (the "License");
     * you may not use this file except in compliance with the License.
     * You may obtain a copy of the License at
     *
     *      https://www.apache.org/licenses/LICENSE-2.0
     *
     * Unless required by applicable law or agreed to in writing, software
     * distributed under the License is distributed on an "AS IS" BASIS,
     * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     * See the License for the specific language governing permissions and
     * limitations under the License.
     */
    
    package org.springframework.stereotype;
    
    import java.lang.annotation.Documented;
    import java.lang.annotation.ElementType;
    import java.lang.annotation.Retention;
    import java.lang.annotation.RetentionPolicy;
    import java.lang.annotation.Target;
    
    import org.springframework.core.annotation.AliasFor;
    
    /**
     * Indicates that an annotated class is a "Service", originally defined by Domain-Driven
     * Design (Evans, 2003) as "an operation offered as an interface that stands alone in the
     * model, with no encapsulated state."
     *
     * <p>May also indicate that a class is a "Business Service Facade" (in the Core J2EE
     * patterns sense), or something similar. This annotation is a general-purpose stereotype
     * and individual teams may narrow their semantics and use as appropriate.
     *
     * <p>This annotation serves as a specialization of {@link Component @Component},
     * allowing for implementation classes to be autodetected through classpath scanning.
     *
     * @author Juergen Hoeller
     * @since 2.5
     * @see Component
     * @see Repository
     */
    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @Component
    public @interface Service {
    
    	/**
    	 * Alias for {@link Component#value}.
    	 */
    	@AliasFor(annotation = Component.class)
    	String value() default "";
    
    }

    이 짧은 주석 속에 우리가 찾던 모든 해답이 담겨 있습니다. 스프링은 '서비스'라는 개념을 정의하기 위해 에릭 에반스(Eric Evans)의 도메인 주도 설계(Domain-Driven Design, 2003)를 직접적으로 인용하고 있습니다.

    주석에 적힌 정의를 분석해보면 서비스의 본질은 다음 두 가지로 요약됩니다.

    상태를 캡슐화하지 않는 독립된 연산 (No Encapsulated State)

    "an operation offered as an interface that stands alone in the model, with no encapsulated state."

    이 문장이 핵심입니다. 엔티티(Entity)나 값 객체(VO)는 자신의 상태(데이터)를 가지고 있고, 그 상태를 관리하는 메서드를 가집니다. 하지만 서비스는 상태를 가지지 않습니다(Stateless). 서비스는 독자적인 데이터를 품지 않은 채, 도메인 모델 내에서 홀로 서 있는 '연산(Operation)' 그 자체입니다. 즉, 서비스는 '무엇을 가지고 있는 존재'가 아니라 '무엇을 수행하는 행위자'입니다.

    도메인 주도 설계(DDD)의 산물

    스프링의 @Service 주석은 이 애너테이션이 도메인 주도 설계(DDD, Domain-Driven Design)에서 영감을 받아 만들어졌음을 명시하고 있습니다. 그렇다면 서비스의 정체를 파악하기 위해 먼저 DDD가 던지는 핵심 메시지를 이해해야 합니다.

     

    1) 개발자는 코딩만 잘하면 될까? DDD는 말 그대로 '도메인'을 중심에 놓고 소프트웨어를 설계하는 방법론입니다. 여기서 도메인이란 우리가 해결하고자 하는 '비즈니스 문제의 영역'을 의미합니다.

    예를 들어 '배달 시스템'을 만든다고 가정해 봅시다. 이때의 도메인은 '배달' 그 자체입니다. 이 시스템을 개발하는 백엔드 개발자에게 필요한 역량은 무엇일까요? 단순히 자바 문법을 잘 알고, 스프링 프레임워크를 능숙하게 다루는 기술력일까요? DDD의 관점에서는 "그렇지 않다"고 단언합니다.

    아무리 코드를 잘 짜는 개발자라도 '배달 기사', '음식점', '배달료 정산', '주문 파이프라인' 등 배달 도메인 특유의 용어와 흐름을 이해하지 못하면 제대로 된 시스템을 만들 수 없습니다. 우리가 소프트웨어를 만드는 궁극적인 목적은 코딩 자체가 아니라, 도메인에서 발생하는 현실의 문제를 해결하는 것이기 때문입니다. 따라서 DDD 세상에서 "개발자는 개발만 잘하면 된다"라는 말은 더 이상 통하지 않습니다.

     

    2) 도메인 탐색(Domain Exploration)과 전문가와의 협력 물론 현실적으로 개발자가 처음부터 배달 비즈니스의 모든 것을 알 수는 없습니다. 그래서 개발자에게 요구되는 진짜 능력은 이미 알고 있는 지식이 아니라, 모르는 비즈니스 영역을 파고드는 '도메인 탐색 능력'입니다.

    가장 확실한 탐색 방법은 인터넷 검색이 아닙니다. 바로 해당 비즈니스 영역에 종사하며 깊은 이해도를 가진 '도메인 전문가(Domain Expert)'와 직접 소통하는 것입니다. 개발자는 도메인 전문가와 끊임없이 대화하며 서로 다른 용어를 통일하고, 지식의 간극을 좁혀 나가야 합니다. 이 과정에서 '이벤트 스토밍(Event Storming)'과 같은 방법론을 통해 도메인의 핵심 개념을 코드로 추출해 내는 것, 이것이 DDD가 추구하는 설계의 정석입니다.

     

    3) 객체가 아닌 '행동'을 위한 공간 이제 다시 본론인 '서비스'로 돌아오겠습니다. 도메인을 탐색하고 이를 객체지향적으로 모델링하다 보면(예: Rider, Restaurant, Order 등), 필연적으로 딜레마에 빠지는 순간이 옵니다.

    "이 로직은 분명히 중요한 도메인 규칙인데, Order 객체에 넣기도 애매하고 Rider 객체에 넣기도 어색한데...?"

    DDD의 창시자 에릭 에반스(Eric Evans)는 이 문제에 대해 다음과 같이 설명했습니다.

    "자신의 본거지를 엔티티(Entity)나 값 객체(Value Object)에서 찾지 못하는 중요한 도메인 연산이 있다. 이들 중 일부는 본질적으로 사물이 아닌 활동이나 행동(Action)인데, 우리의 모델링 패러다임이 객체이므로 그러한 연산도 객체와 잘 어울리게끔 노력해야 한다."

    이것이 바로 스프링이 말하는 '서비스'의 정체입니다.

    서비스는 도메인 모델 내에서 상태(사물)를 가질 수 없는, 행동 자체를 표현하기 위해 고안된 컴포넌트입니다. 어떤 객체의 소유라고 말하기 힘든 복합적인 로직이나 흐름을 처리하기 위해, 스프링은(그리고 DDD는) '서비스'라는 별도의 공간을 마련해 둔 것입니다.

     

    2. 도메인 vs 도메인 서비스 vs 애플리케이션 서비스

    "서비스는 비즈니스 로직을 처리한다"는 말은 반은 맞고 반은 틀립니다. 정확히 말하면 비즈니스 로직은 도메인에 있어야 하고, 서비스는 이를 조율해야 합니다. 하지만 개발하다보면 이 경계가 모호해지기 쉽습니다.

    객체지향적인 설계를 위해서는 도메인(Domain Object), 도메인 서비스(Domain Service), 그리고 우리가 흔히 만드는 애플리케이션 서비스(Application Service)의 역할을 명확히 구분해야 합니다.

    도메인 객체 (Domain Object)

    • 역할: 비즈니스 로직의 주체
    • 주요 행동: 도메인 본연의 역할 수행, 다른 도메인과의 협력
    • 예시: User, Coupon, Restaurant

     

    도메인 객체(Entity, VO)는 시스템의 주인공입니다. 데이터만 가지고 있는 수동적인 존재가 아니라, 자신의 데이터(상태)를 바탕으로 의사결정을 내리는 자율적이고 능동적인 존재여야 합니다. 예를 들어 Restaurant 객체는 스스로 "영업 중인지" 판단할 수 있어야 하고, User는 스스로 "비밀번호가 일치하는지" 확인할 수 있어야 합니다. 가장 핵심적인 비즈니스 규칙은 바로 이곳에 위치해야 합니다.

     

    여기서는 Restaurant과 Coupon을 예시로 듭니다.

    • Restaurant: "지금 영업 중인가?"를 서비스가 아니라 식당 객체 스스로 판단합니다.
    • Coupon: "이 주문 금액에 사용할 수 있는가?"를 쿠폰 객체 스스로 판단합니다.
     
    // [Domain Object] Restaurant
    @Entity
    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Restaurant {
    
        @Id @GeneratedValue
        private Long id;
        private String name;
        private LocalTime openTime;
        private LocalTime closeTime;
        private boolean isOperating; // 가게 휴무 여부
    
        // 스스로 판단하는 비즈니스 로직 (능동적 존재)
        public void validateOpen(LocalTime currentTime) {
            if (!isOperating) {
                throw new BusinessException("가게 사정으로 휴무 중입니다.");
            }
            if (currentTime.isBefore(openTime) || currentTime.isAfter(closeTime)) {
                throw new BusinessException("지금은 영업 시간이 아닙니다.");
            }
        }
    }
    
    // [Domain Object] Coupon
    @Entity
    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Coupon {
    
        @Id @GeneratedValue
        private Long id;
        private int discountAmount;
        private int minOrderAmount;
        private boolean isUsed;
    
        // 스스로 판단하고 상태를 변경하는 로직
        public void validateApplicable(int totalOrderAmount) {
            if (isUsed) {
                throw new BusinessException("이미 사용된 쿠폰입니다.");
            }
            if (totalOrderAmount < minOrderAmount) {
                throw new BusinessException("최소 주문 금액을 충족하지 못했습니다.");
            }
        }
    
        public void use() {
            this.isUsed = true;
        }
    }

    도메인 서비스 (Domain Service)

    • 역할: 도메인 객체에 넣기 애매한 비즈니스 연산 처리
    • 주요 행동: 도메인 간의 협력 중재, 단일 객체로 표현 불가능한 복합 연산
    • 예시: PriceManager, DiscountCalculator

     

    어떤 로직은 특정 도메인 객체 하나에 넣기엔 애매한 경우가 있습니다. 예를 들어 "여러 개의 쿠폰과 회원 등급, 그리고 식당의 프로모션을 모두 조합하여 최종 할인 금액을 계산한다"는 로직을 생각해 봅시다. 이것을 Coupon 객체에 넣기도, User 객체에 넣기도 어색합니다.

    이처럼 여러 도메인 객체가 협력해야 하거나, 특정 객체에 속하기 힘든 '비즈니스 연산 로직'을 처리하는 곳이 도메인 서비스입니다. 실무에서는 주로 Manager, Calculator 등의 접미사를 사용하여 일반 서비스와 구분하기도 합니다. 여기서 중요한 건, 도메인 서비스 역시 순수한 비즈니스 로직의 영역이라는 점입니다.

     

    DiscountManager를 예시로 듭니다.

    • 주문 총액 계산, 쿠폰 적용, 사용자 등급 할인 등 여러 객체(User, Coupon, OrderItems)가 얽힌 계산 로직을 담당합니다.
    • 이 로직은 상태를 저장하지 않고 계산 결과만 반환합니다.
    // [Domain Service] 할인 및 최종 금액 계산 담당
    @Component // 스프링 빈으로 등록하되, 역할은 순수 도메인 로직임
    public class DiscountManager {
    
        public int calculateFinalPrice(User user, Coupon coupon, List<OrderItem> items) {
            // 1. 기본 총액 계산
            int totalPrice = items.stream()
                                  .mapToInt(item -> item.getPrice() * item.getQuantity())
                                  .sum();
    
            // 2. 쿠폰 할인 적용 가능 여부 확인 (로직은 쿠폰에게 위임)
            if (coupon != null) {
                coupon.validateApplicable(totalPrice);
                totalPrice -= coupon.getDiscountAmount();
            }
    
            // 3. 사용자 등급별 추가 할인 (User 객체와 협력)
            if (user.getGrade() == UserGrade.VIP) {
                totalPrice = (int) (totalPrice * 0.9); // VIP 10% 추가 할인
            }
    
            // 4. 최종 금액이 0원 미만일 수 없음 (도메인 규칙)
            return Math.max(totalPrice, 0);
        }
    }

    애플리케이션 서비스 (Application Service)

    • 역할: 애플리케이션의 유스케이스(Use Case) 흐름 제어
    • 주요 행동: 트랜잭션 관리, 도메인 로드/저장, 도메인(또는 도메인 서비스) 실행
    • 예시: UserService, OrderService (스프링의 @Service)

    우리가 스프링에서 @Service를 붙여 만드는 클래스가 바로 이것입니다. 이곳은 비즈니스 로직을 직접 수행하는 곳이 아닙니다. 철저히 진행자 역할을 맡습니다.

    1. 준비: 저장소(Repository)에서 필요한 도메인을 가져옵니다.
    2. 지시: 도메인 객체나 도메인 서비스에게 "이 일을 하라"고 메시지를 보냅니다.
    3. 마무리: 작업이 끝난 도메인을 저장소에 반영합니다.

    OrderService를 예시로 듭니다.

    • 직접 계산(+, -, if) 하지 않습니다.
    • validateOpen(), calculateFinalPrice() 같은 메서드를 호출하여 "지시"만 합니다.
    // [Application Service] 주문 유스케이스 흐름 제어
    @Service
    @RequiredArgsConstructor
    @Transactional(readOnly = true)
    public class OrderService {
    
        private final RestaurantRepository restaurantRepository;
        private final CouponRepository couponRepository;
        private final UserRepository userRepository;
        private final OrderRepository orderRepository;
        private final DiscountManager discountManager; // 도메인 서비스 주입
    
        @Transactional // 트랜잭션 시작 (쓰기 작업 포함)
        public Long placeOrder(Long userId, Long restaurantId, Long couponId, List<OrderItemRequest> itemRequests) {
            
            // 1. Load: 필요한 도메인 객체들을 불러옵니다.
            User user = userRepository.findById(userId)
                    .orElseThrow(() -> new EntityNotFoundException("사용자가 없습니다."));
            Restaurant restaurant = restaurantRepository.findById(restaurantId)
                    .orElseThrow(() -> new EntityNotFoundException("식당이 없습니다."));
            Coupon coupon = couponRepository.findById(couponId)
                    .orElseThrow(() -> new EntityNotFoundException("쿠폰이 없습니다."));
    
            // 2. Delegate (도메인 객체에게 지시): 식당 영업 확인
            // "지금 영업 중인가요?"라고 식당에게 물어봄 (서비스가 시간 비교 X)
            restaurant.validateOpen(LocalTime.now());
    
            // 2. Delegate (도메인 서비스에게 지시): 최종 결제 금액 계산
            // 복잡한 계산은 전문가(DiscountManager)에게 위임
            List<OrderItem> items = itemRequests.stream().map(OrderItem::from).toList();
            int finalPrice = discountManager.calculateFinalPrice(user, coupon, items);
    
            // 2. Delegate (도메인 객체에게 지시): 쿠폰 사용 처리
            coupon.use();
    
            // 3. Save: 변경 사항 및 생성된 주문 저장
            Order order = Order.createOrder(user, restaurant, items, finalPrice);
            orderRepository.save(order);
    
            return order.getId();
        }
    }

    트랜잭션 스크립트(Transaction Script)

    많은 스프링 개발자들이 범하는 가장 흔한 실수는 애플리케이션 서비스에 모든 비즈니스 로직을 쏟아붓는 것입니다.

    서비스 메소드 하나에 수십 줄의 if-else문과 get/set 호출이 난무한다면, 그것은 객체지향 시스템이 아닙니다. 절차지향적인 '트랜잭션 스크립트'일뿐입니다. 이렇게 되면 도메인 객체는 데이터만 담는 껍데기(Anemic Domain Model)가 되고, 서비스 코드는 비대해져 유지보수가 불가능해집니다.

    절차지향에서 객체지향으로 나아가려면 순서를 바꿔야 합니다. 서비스에 로직을 작성하기 전에, 먼저 풍부한 도메인 객체(Rich Domain Object)를 설계해야 합니다. 복잡한 로직일수록 도메인 객체 안에서 처리해야 합니다.

    결론

    지금까지 우리는 익숙하지만 잘 알지 못했던 '서비스(Service)'의 본질을 파헤쳐 보았습니다. 이제 글의 서두에서 던졌던 질문들에 대해, 이전과는 다른 깊이 있는 대답을 내릴 수 있습니다.

     

    첫째, 서비스는 왜 '서비스'라고 부를까요? 이 이름은 도메인 주도 설계(DDD)의 철학에서 왔습니다. 객체지향 세계에서 대부분의 요소는 상태를 가진 '사물(Object)'로 표현되지만, 때로는 사물에 귀속되지 않는 순수한 '행동(Action)'이 필요합니다. 상태를 가지지 않으면서(Stateless), 도메인의 문제를 해결하기 위한 연산을 제공하는 것입니다.  우리는 이것을 서비스라고 부릅니다.

    둘째, 그래서 서비스란 무엇일까요? 서비스는 비즈니스 로직의 주체가 아니라 '관리자'입니다. 많은 개발자가 서비스에 모든 로직을 몰아넣는 실수를 범하지만, 진정한 의미의 애플리케이션 서비스(Application Service)는 다음의 역할에 충실해야 합니다.

    • 연결하다: 리포지토리(저장소)와 도메인(비즈니스)을 연결합니다.
    • 위임하다: 직접 계산하고 판단하는 대신, 잘 설계된 도메인 객체에게 일을 맡깁니다.
    • 보장하다: 트랜잭션이라는 울타리를 쳐서 작업의 원자성을 보장합니다.

     

Designed by MSJ.