ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Boot]서비스 코드를 Interface와 Class로 나누는 이유
    Spring 2025. 4. 15. 13:44

    Spring Boot로 백엔드 애플리케이션을 개발하다 보면, 서비스 계층(Service Layer)을 interfaceclass로 나누어 설계하는 구조를 자주 보게 됩니다. 초보 개발자라면 왜 굳이 두 개의 파일로 나누는지, interface 없이 바로 @Service 클래스만으로 구현해도 되는 것 아닌지 궁금할 수 있습니다.

    이 글에서는 서비스 계층을 interface와 class로 나누는 이유, 어떤 상황에서 사용하는 것이 좋은지, 실제 코드 예시와 함께 구조적으로 설명합니다.


    1. 서비스 계층이란?

    서비스 계층(Service Layer)은 비즈니스 로직을 담당하는 계층입니다. 컨트롤러(@RestController)가 클라이언트 요청을 받으면, 그 처리를 서비스 계층에게 위임하고, 서비스 계층은 DB 접근이 필요하면 레포지토리에게 요청합니다.

    Client → Controller → Service → Repository → DB

    2. Interface와 Class의 역할 구분

    Interface: "무엇을 할 것인가"만 정의

    인터페이스는 서비스가 제공해야 할 기능의 형태(계약)만 정의합니다. 어떤 메서드를 가져야 하는지를 명시할 뿐, 구현 내용은 포함하지 않습니다.

    public interface UserService {
        UserDto getUserById(Long userId);
        void createUser(UserDto userDto);
    }
    
    • 이 코드는 “UserService는 반드시 getUserById와 createUser 메서드를 가져야 한다”는 계약만 정의합니다.
    • 내부에서 어떤 방식으로 DB를 조회하거나 객체를 변환하는지는 정하지 않습니다.

    Class: "어떻게 할 것인가"를 구현

    클래스는 interface에서 정의한 메서드를 구체적으로 구현합니다. Spring에서는 이 구현 클래스에 @Service를 붙여서 Bean으로 등록합니다.

    @Service
    public class UserServiceImpl implements UserService {
    
        private final UserRepository userRepository;
        private final ModelMapper modelMapper;
    
        public UserServiceImpl(UserRepository userRepository, ModelMapper modelMapper) {
            this.userRepository = userRepository;
            this.modelMapper = modelMapper;
        }
    
        @Override
        public UserDto getUserById(Long userId) {
            User user = userRepository.findById(userId)
                .orElseThrow(() -> new UserNotFoundException(userId));
            return modelMapper.map(user, UserDto.class);
        }
    
        @Override
        public void createUser(UserDto userDto) {
            User user = modelMapper.map(userDto, User.class);
            userRepository.save(user);
        }
    }
    

    3. Interface + 구현체 구조의 장점

    3.1 테스트 코드 작성이 쉬워진다 (Mocking 용이)

    인터페이스 기반 구조라면 테스트에서 가짜(Mock) 객체를 사용해 실제 DB나 의존 객체 없이도 테스트할 수 있습니다.

    // 예시: Mockito를 활용한 테스트 코드
    UserService mockUserService = mock(UserService.class);
    
    when(mockUserService.getUserById(1L))
        .thenReturn(new UserDto("test@example.com", "홍길동"));
    
    • DB와 연결하지 않고도 테스트 가능 (비용, 속도 절감)
    • 서비스 외부 동작에만 집중한 테스트 작성 가능
    • 의존성 주입(DI)을 활용하여 테스트 대상만 유연하게 교체 가능

    결론: 단위 테스트의 독립성과 생산성이 높아집니다.


    3.2 유연한 확장 및 유지보수

    서비스의 역할은 같지만, 구현 방식이 다른 경우 다양한 구현체가 필요할 수 있습니다.

    • UserServiceImpl: 일반 사용자 로직
    • AdminUserServiceImpl: 관리자 전용 로직
    • LegacyUserServiceImpl: 구형 시스템 연동용
    // 예시: 상황에 따라 구현체를 바꿔서 주입
    UserService userService;
    
    if (isAdmin) {
        userService = new AdminUserServiceImpl(...);
    } else {
        userService = new UserServiceImpl(...);
    }
    

    인터페이스를 사용하면 OCP(Open-Closed Principle)을 만족시킬 수 있습니다.
    OCP란? "소프트웨어 요소는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다"

    결론: 인터페이스는 기능 확장을 유연하게 만들고, 기존 코드를 수정하지 않아도 새로운 기능을 추가할 수 있게 합니다.


    3.3 역할과 책임이 명확해진다

    • Interface: 무엇을 해야 하는지를 정의 → 계약
    • Class: 어떻게 해야 하는지를 정의 → 구현

    이러한 구조는 다음과 같은 상황에서 특히 유용합니다.

    • 팀 협업 시 작업 분담이 쉬움 (예: A는 인터페이스, B는 구현)
    • 요구사항 변경 시 인터페이스는 그대로 유지하며 구현만 변경
    • 계층형 아키텍처 / 클린 아키텍처 설계에 적합
    // 역할 분리 예시
    public interface NotificationService {
        void send(String target, String message);
    }
    
    @Service
    public class EmailNotificationService implements NotificationService {
        public void send(String email, String message) {
            // 이메일 전송 로직
        }
    }
    
    @Service
    public class SmsNotificationService implements NotificationService {
        public void send(String phone, String message) {
            // 문자 전송 로직
        }
    }
    

    결론: 역할 분리가 명확해지면 협업과 유지보수에 강한 구조가 만들어집니다.


    4. Interface 없이 바로 구현해도 되는 경우

    서비스 계층을 항상 interface + 구현체로 설계할 필요는 없습니다. 아래와 같은 경우에는 @Service 클래스 하나로 충분합니다.

    @Service
    public class UserService {
    
        private final UserRepository userRepository;
    
        public UserService(UserRepository userRepository) {
            this.userRepository = userRepository;
        }
    
        public UserDto getUserById(Long userId) {
            User user = userRepository.findById(userId)
                .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
            return new UserDto(user.getId(), user.getEmail(), user.getName());
        }
    
        public void createUser(UserDto userDto) {
            User user = new User(userDto.getEmail(), userDto.getName());
            userRepository.save(user);
        }
    }
    

    interface 생략이 가능한 대표적인 경우

    1. 단순 CRUD 서비스
      복잡한 비즈니스 로직 없이 데이터 조회/저장만 수행
    2. 개인 프로젝트 / 토이 프로젝트
      빠른 결과 확인이 목적
    3. 확장 가능성이 거의 없음
      변경 가능성 낮고 구현체는 하나로 고정
    4. 테스트보다 빠른 구현이 우선
      프로토타입 단계, 단기간 구현이 목표

    주의: 다음과 같은 경우에는 interface 도입을 고려해야 합니다

    조건 interface 도입 권장 여부
    팀원이 늘어나고 협업이 시작됨 고려해야 함
    테스트 코드 작성이 늘어남 필요해질 수 있음
    여러 구현체가 필요해질 가능성 있음 구조 변경 필요
    도메인이 복잡해지기 시작함 구조 리팩토링 필요

    결론: interface를 반드시 써야 한다는 강박보다는,
    현재 프로젝트의 규모, 목적, 팀 구조, 향후 확장 가능성 등을 고려해서 유연하게 판단하는 것이 가장 좋습니다.


    5. 실제 프로젝트 디렉토리 구조 예시

    src/main/java/com/example/userservice
    ├── controller
    │   └── UserController.java
    ├── service
    │   ├── UserService.java          // interface
    │   └── UserServiceImpl.java      // 구현체 class
    ├── dto
    │   └── UserDto.java
    ├── entity
    │   └── User.java
    └── repository
        └── UserRepository.java
    

    6. interface 분리 구조가 적절한 상황 정리

    상황 interface 분리 필요 여부
    단순 CRUD 로직만 존재 생략 가능
    클린 아키텍처를 따르는 경우 필요
    테스트 코드 작성 예정 필요
    여러 구현체를 둘 예정 필요
    유지보수와 확장성을 고려 추천
    개인 실습/과제 수준 생략 가능

    7. 마무리 정리

    Spring Boot에서 서비스 계층을 interface와 class로 나누는 것은 초기에는 코드가 많아지는 것처럼 보일 수 있지만, 테스트, 확장성, 협업, 유지보수성 측면에서 매우 유리한 설계 방식입니다.

    작고 단순한 프로젝트라면 interface 없이 작성해도 충분합니다. 하지만 프로젝트 규모가 커지거나 팀 작업이 많아지면 interface + 구현체 구조를 사용하는 것이 권장됩니다.


Designed by MSJ.