과부하된 컨트롤러에서 간결한 서비스 계층으로 백엔드 로직 간소화
Emily Parker
Product Engineer · Leapcell

소개
빠르게 발전하는 백엔드 개발 환경에서 깔끔하고 확장 가능하며 유지보수 가능한 코드베이스를 유지하는 것은 매우 중요합니다. 개발자들은 종종 컨트롤러가 요청 라우팅과 대부분의 비즈니스 로직을 모두 처리하는 것처럼 보이는 간단한 디자인으로 시작합니다. 이 접근 방식은 소규모 프로젝트에는 편리해 보일 수 있지만, 애플리케이션이 복잡해짐에 따라 '과부하된 컨트롤러'로 빠르게 이어집니다. 이러한 컨트롤러는 관리, 테스트 및 진화하기 어려워지며, 다른 곳에 속하는 책임을 축적하게 됩니다. 이 글에서는 이러한 번거로운 컨트롤러에서 간결한 서비스 계층을 중심으로 하는 보다 세련되고 강력한 디자인으로의 중요한 아키텍처 진화를 탐구할 것입니다. 이러한 전환은 코드 품질을 향상시킬 뿐만 아니라 팀 협업 및 장기적인 프로젝트 생존 가능성도 크게 향상시킵니다.
핵심 개념 및 원칙
구조 재조정 프로세스를 자세히 살펴보기 전에, 관련된 핵심 구성 요소와 잘 설계된 백엔드 애플리케이션에서 의도된 역할에 대한 명확한 이해를 확립해 봅시다.
컨트롤러
컨트롤러(종종 '프레젠테이션 계층' 또는 'API 계층'의 일부)는 주로 들어오는 HTTP 요청을 처리하고, 입력을 검증하고, 적절한 비즈니스 로직을 호출하고, HTTP 응답을 반환하는 책임을 집니다. 주요 임무는 진입점 역할을 하여 웹과 애플리케이션의 핵심 로직 간의 상호 작용을 조정하는 것입니다. 컨트롤러의 핵심 원칙은 '얇게' 유지하는 것입니다. 즉, 최소한의 비즈니스 로직만 포함해야 합니다.
서비스 (또는 서비스 계층)
서비스 계층은 애플리케이션의 비즈니스 로직을 캡슐화합니다. 이것이 애플리케이션 작업의 '무엇'과 '어떻게'가 resides하는 곳입니다. 서비스는 다른 도메인 엔터티 간의 상호 작용을 조정하고, 계산을 수행하고, 비즈니스 규칙을 시행하고, 데이터 액세스 계층과 상호 작용합니다. 서비스는 재사용 가능하고 웹 프레임워크와 독립적으로 테스트 가능하며 특정 비즈니스 기능에 초점을 맞추도록 설계되었습니다.
데이터 액세스 계층 (DAL) / 리포지토리
데이터 액세스 계층(종종 리포지토리 또는 DAO를 통해 구현됨)은 기본 데이터베이스 또는 데이터 소스를 추상화하는 책임을 집니다. 유일한 목적은 데이터에 대한 CRUD(Create, Read, Update, Delete) 작업을 수행하는 메서드를 제공하여 서비스가 데이터베이스별 세부 정보로부터 보호되도록 하는 것입니다.
과부하된 컨트롤러의 문제점
비즈니스 로직, 데이터 액세스 호출 및 요청 처리가 모두 단일 컨트롤러 메서드에 포함될 때 여러 문제가 발생합니다.
- 낮은 응집력: 메서드가 여러 개의 관련 없는 작업을 수행합니다.
- 높은 결합: 컨트롤러가 특정 데이터 액세스 구현 및 비즈니스 규칙과 단단히 결합됩니다.
- 테스트하기 어려움: 단일 메서드가 전체 웹 컨텍스트 및 데이터베이스 설정이 필요할 수 있으므로 단위 테스트가 어려워집니다.
- 재사용성 감소: 비즈니스 로직은 애플리케이션의 다른 부분(예: 예약된 작업, 메시지 큐)에서 쉽게 재사용할 수 없습니다.
- 나쁜 유지보수성: 비즈니스 규칙이나 데이터 액세스 패턴의 변경은 여러 계층에 영향을 미쳐 버그 위험을 증가시킵니다.
간결한 서비스 계층으로 구조 재조정
과부하된 컨트롤러에 대한 해결책은 단일 책임 원칙을 준수하고, 책임을 분리하며, 전용 서비스 계층을 도입하는 데 있습니다.
원칙: 책임 분리
애플리케이션의 각 계층은 고유한 책임을 가져야 합니다. 컨트롤러는 HTTP 관련 사항을 처리하고, 서비스는 비즈니스 로직을 처리하며, 데이터 액세스 계층은 지속성 관련 사항을 처리합니다.
구현 예제
간단한 사용자 계정 관리 API를 실제 예제로 살펴보겠습니다.
과부하된 컨트롤러 (리팩토링 전):
// Spring Boot 애플리케이션의 예제 @RestController @RequestMapping("/api/users") public class UserController { @Autowired private UserRepository userRepository; // 리포지토리에 직접 접근 @PostMapping public ResponseEntity<User> createUser(@RequestBody UserCreateRequest request) { // 1. 입력 유효성 검사 (유효성 검사 프레임워크에서 처리해야 함) if (request.getUsername() == null || request.getPassword() == null) { return ResponseEntity.badRequest().build(); } // 2. 비즈니스 로직: 사용자 존재 여부 확인 if (userRepository.findByUsername(request.getUsername()).isPresent()) { return ResponseEntity.status(HttpStatus.CONFLICT).build(); } // 3. 비즈니스 로직: 비밀번호 암호화 String hashedPassword = MyPasswordEncoder.encode(request.getPassword()); // 4. 데이터 액세스: 사용자 엔티티 생성 User newUser = new User(); newUser.setUsername(request.getUsername()); newUser.setPassword(hashedPassword); User savedUser = userRepository.save(newUser); // 5. 응답 처리 return ResponseEntity.status(HttpStatus.CREATED).body(savedUser); } }
이 예제에서 UserController는 너무 많은 일을 합니다. 입력을 검증하고, 기존 사용자를 확인하고, 비밀번호를 해싱하고, UserRepository와 직접 상호 작용하고, 응답 생성을 처리합니다.
서비스 계층으로 리팩토링 후:
먼저 서비스 인터페이스와 그 구현을 정의합니다:
// UserService.java (인터페이스) public interface UserService { User createUser(String username, String password); Optional<User> findByUsername(String username); // ... 기타 사용자 관련 비즈니스 작업 } // UserServiceImpl.java (구현) @Service public class UserServiceImpl implements UserService { @Autowired private UserRepository userRepository; // 주입된 리포지토리 @Override @Transactional // 작업의 원자성 보장 public User createUser(String username, String password) { // 1. 비즈니스 규칙: 기존 사용자 확인 if (userRepository.findByUsername(username).isPresent()) { throw new DuplicateUsernameException("Username already taken."); } // 2. 비즈니스 로직: 비밀번호 암호화 String hashedPassword = MyPasswordEncoder.encode(password); // 3. 데이터 액세스: 사용자 생성 및 저장 (리포지토리에 위임) User newUser = new User(); newUser.setUsername(username); newUser.setPassword(hashedPassword); return userRepository.save(newUser); } @Override public Optional<User> findByUsername(String username) { return userRepository.findByUsername(username); } }
이제 UserController가 훨씬 간결해집니다:
// UserController.java (리팩토링됨) @RestController @RequestMapping("/api/users") public class UserController { @Autowired private UserService userService; // 주입된 서비스 @PostMapping public ResponseEntity<User> createUser(@Valid @RequestBody UserCreateRequest request) { try { // 1. 입력 유효성 검사는 프레임워크 주석(@Valid)으로 처리됨 // 2. 모든 비즈니스 로직을 서비스 계층에 위임 User createdUser = userService.createUser(request.getUsername(), request.getPassword()); return ResponseEntity.status(HttpStatus.CREATED).body(createdUser); } catch (DuplicateUsernameException e) { return ResponseEntity.status(HttpStatus.CONFLICT).body(e.getMessage()); } catch (Exception e) { // 일반 오류 처리 return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("An error occurred."); } } }
이 리팩토링된 디자인에서:
UserController는 주로 요청을 수신하고, 적절한 서비스 메서드를 호출하고, 응답을 형식화하는 데 중점을 둡니다. HTTP 관련 사항을 처리합니다.UserService는 중복 확인 및 비밀번호 해싱을 포함한 모든 사용자 관련 비즈니스 로직을 보유합니다. 기본 데이터베이스 기술을 알지 못하고 데이터 영속성을 위해UserRepository를 사용합니다.- 입력 유효성 검사는 외부화되어 일반적으로
@Valid와 같은 프레임워크별 주석으로 처리되며, 이는 유효성 검사 라이브러리(예: Hibernate Validator)에 위임합니다. - 오류 처리는 컨트롤러에서 더 집중되어 애플리케이션별 예외를 HTTP 상태 코드로 매핑합니다.
서비스 계층의 이점
이러한 아키텍처 전환은 수많은 이점을 제공합니다:
- 향상된 테스트 용이성:
UserService는 HTTP 요청이나 데이터베이스 연결을 모의할 필요 없이 독립적으로 단위 테스트할 수 있어 테스트 노력을 크게 간소화합니다. - 향상된 유지보수성: 비즈니스 규칙의 변경은 컨트롤러 또는 데이터 액세스 계층이 아닌 서비스 계층에만 영향을 미칩니다.
- 재사용성 증가:
UserService메서드는 웹 계층을 거치지 않고 애플리케이션의 다른 부분(예: 배치 처리, 메시지 소비자)에서 호출될 수 있습니다. - 더 나은 구성: 책임의 명확한 분리는 코드베이스를 탐색하고 이해하기 쉽게 만듭니다.
- 확장성: 서비스는 독립적으로 확장하거나 프리젠테이션 관련 사항에 직접적인 영향을 주지 않고 진화할 수 있습니다.
- 데이터 액세스 추상화: 서비스 계층은 추상 리포지토리와 상호 작용하여 지속성 기술을 쉽게 전환할 수 있습니다.
결론
과부하된 컨트롤러에서 간결한 서비스 계층으로의 전환은 견고하고 유지보수 가능하며 확장 가능한 백엔드 애플리케이션을 구축하기 위한 기본적인 단계입니다. 책임을 엄격하게 분리하고, 컨트롤러와 서비스에 명확한 책임을 할당하며, 종속성 주입을 활용함으로써 개발자는 작업하기 즐겁고 변화에 탄력적인 코드베이스를 만들 수 있습니다. 서비스 계층을 채택하여 이해, 테스트 및 진화하기 쉬운 백엔드 시스템을 구축하십시오.