-
[스프링] 안티 패턴: 스마트 UISpring 2025. 12. 16. 17:00
이번에는 스프링을 개발하면서 만날 수 있는 안티패턴들 중 스마트 UI에 대해서 알아보겠습니다.

1. 스마트 UI(Smart UI)란 무엇인가?
'스마트 UI'라는 용어는 에릭 에반스(Eric Evans)의 저서 『도메인 주도 설계(Domain-Driven Design)』에서 소개되며 널리 알려진 안티패턴입니다.
도메인 주도 설계 | 에릭 에반스 - 교보문고
도메인 주도 설계 | 소프트웨어의 복잡성을 다루는 지혜『도메인 주도 설계』. 이 책은 독자에게 도메인 주도 설계에 대한 체계적인 접근법을 제공하고 폭넓은 우수 설계 실천법과 경험을 토대
product.kyobobook.co.kr
이름만 들으면 UI가 지능적으로 알아서 척척 처리해 주는 좋은 기술처럼 들릴 수 있습니다. 하지만 아키텍처 관점에서 볼 때, UI가 '똑똑하다'는 것은 결코 칭찬이 아닙니다. 이는 UI 계층이 자신의 책임 범위를 넘어, 애플리케이션의 모든 책임을 짊어지고 있다는 뜻이기 때문입니다.
에릭 에반스는 스마트 UI의 특징을 다음과 같이 정의합니다.
스마트 UI의 3가지 핵심 증상
1. 데이터 입출력을 UI 레벨에서 모두 처리한다. 단순히 사용자 입력을 받는 것을 넘어, 데이터의 검증(Validation), 포맷팅, 변환 등 복잡한 전처리 작업을 UI(또는 컨트롤러)가 직접 수행합니다.
2. 비즈니스 로직이 UI 레벨에 포함된다. 가장 치명적인 문제입니다. "상품 가격을 계산한다", "주문 상태를 변경한다"와 같은 핵심 비즈니스 로직이 서비스 계층이나 도메인 객체가 아닌, UI 코드안에 위치합니다.
3. 데이터베이스 통신 코드도 UI 레벨에 있다. UI가 직접 DB 연결을 맺거나 SQL을 실행합니다. 스프링으로 치면 컨트롤러나 JSP/Thymeleaf 같은 뷰 템플릿에서 리포지토리(Repository)를 직접 호출하거나 쿼리를 날리는 형태입니다.
결국 스마트 UI란 시스템의 UI 레벨에서 감당해야 할 업무의 범위를 넘어선 상태를 의미합니다.
2. 백엔드 개발자에게 UI란 무엇인가?
여기까지 읽으신 분들은 아마 고개를 갸웃거리고 계실지도 모릅니다.
"아니, 나는 백엔드 개발자인데? UI(User Interface)는 프론트엔드에서나 신경 쓸 문제 아닌가?" "요즘은 다 REST API 서버로 만드는데, 백엔드가 UI를 가질 일이 어디 있어?"
맞습니다. 백엔드 개발자는 버튼을 깎거나 CSS를 만지는 사람이 아닙니다. 하지만 결론부터 말씀드리면, 백엔드 개발자도 '자신만의 UI'를 가지고 있습니다. 우리가 흔히 생각하는 GUI(Graphic User Interface)가 아닐 뿐입니다.
API와 UI, 본질은 같다
오해를 피하기 위해 용어부터 확실히 짚고 넘어가겠습니다. 사전적인 의미는 분명 다릅니다.
- UI (User Interface): '사용자'를 위해 마련된 인터페이스. (예: 버튼, 입력 폼, 화면)
- API (Application Programming Interface): '애플리케이션 프로그래밍'을 위해 마련된 인터페이스.
일반적으로 UI는 사람이 쓰고, API는 프로그램이 쓴다는 차이가 있죠. 하지만 '누군가에게 우리 시스템을 사용하는 방법을 알려주는 접점(Interface)'이라는 본질적인 목표는 동일합니다.
백엔드의 '사용자'는 누구인가?
이제 백엔드 개발자의 시각으로 '사용자'를 다시 정의해 봅시다. 여러분이 만든 서버(애플리케이션)를 사용하는 진짜 고객은 누구일까요?
최종 사용자가 앱의 버튼을 누르겠지만, 우리 서버에 직접 요청을 보내고 응답을 받아가는 직접적인 사용자는 바로 '프론트엔드 개발자' 혹은 '협력사(타 서비스)의 백엔드 개발자'입니다.
- 고객은 GUI를 통해 시스템과 소통합니다.
- 동료 개발자는 API를 통해 시스템과 소통합니다.
즉, 백엔드 개발자에게 API는 곧 UI입니다. 우리의 동료들이 우리 시스템을 편하게 쓸 수 있도록 제공하는 인터페이스인 것이죠.
스마트 UI = 스마트 컨트롤러
이 논리를 스프링 프레임워크로 가져와 보겠습니다.
백엔드에서 API를 만드는 컴포넌트는 무엇인가요? 바로 컨트롤러(Controller)입니다. 앞선 논리에 따르면 '컨트롤러는 스프링에서 UI를 담당하는 계층'이라고 볼 수 있습니다.
이제 '스마트 UI'의 정의를 다시 대입해 볼까요? 스마트 UI가 "UI 레벨에 너무 많은 업무 로직이 들어가는 것"이라면, 백엔드에서의 스마트 UI는 "컨트롤러(Handler Method)에 지나치게 많은 로직이 들어있는 경우"를 뜻하게 됩니다.
우리는 이것을 '스마트 컨트롤러(Smart Controller)'라고 부를 수 있을 것입니다.
3. 코드 예시: 스마트 컨트롤러
이 코드는 겉보기엔 멀쩡하게 돌아갑니다. 컴파일 에러도 없고, Postman으로 테스트하면 응답도 잘 내려줍니다. 하지만 '스마트 UI' 안티패턴의 전형적인 특징을 모두 가지고 있습니다.
@RestController @RequiredArgsConstructor public class OrderController { // ❌ UI 계층(Controller)이 데이터 접근 계층(Repository)을 직접 알고 있습니다. private final ProductRepository productRepository; private final MemberRepository memberRepository; private final OrderRepository orderRepository; @PostMapping("/orders") public ResponseEntity<OrderDto> placeOrder(@RequestBody OrderRequest request) { // 1. 데이터 검증 로직이 컨트롤러에 있습니다. if (request.getQuantity() <= 0) { throw new IllegalArgumentException("주문 수량은 0보다 커야 합니다."); } // 2. DB 조회 로직이 컨트롤러에 노출되어 있습니다. Product product = productRepository.findById(request.getProductId()) .orElseThrow(() -> new NotFoundException("상품을 찾을 수 없습니다.")); Member member = memberRepository.findById(request.getMemberId()) .orElseThrow(() -> new NotFoundException("사용자를 찾을 수 없습니다.")); // 3. 핵심 비즈니스 로직(재고 확인 및 가격 계산)이 컨트롤러에 섞여 있습니다. if (product.getStock() < request.getQuantity()) { throw new IllegalStateException("재고가 부족합니다."); } // 할인 정책 로직 (VIP라면 10% 할인) -> 도메인 규칙이 UI에 누설됨 long totalPrice = product.getPrice() * request.getQuantity(); if (member.getGrade() == MemberGrade.VIP) { totalPrice = (long) (totalPrice * 0.9); } // 4. 상태 변경(DB 저장)도 컨트롤러가 주도합니다. product.removeStock(request.getQuantity()); // 더티 체킹을 위한 상태 변경 productRepository.save(product); // 명시적 저장 Order order = new Order(member, product, totalPrice); Order savedOrder = orderRepository.save(order); // 5. 응답 생성 return ResponseEntity.ok(new OrderDto(savedOrder)); } }무엇이 문제인가요? (Current Problems)
위 코드를 보면서 "어? 내 코드랑 비슷한데?"라고 생각하셨다면 주의가 필요합니다. 이 '스마트 컨트롤러'는 다음과 같은 심각한 설계 오류를 범하고 있습니다.
- 의존성 문제:
- 컨트롤러가 ProductRepository, MemberRepository, OrderRepository 등 너무 많은 리포지토리에 직접 의존합니다. 컨트롤러의 생성자가 계속 비대해질 것입니다.
- 관심사의 혼합:
- HTTP 요청을 파싱하는 일(Web Layer)과, 할인 정책을 계산하는 일(Business Layer), SQL을 날리는 일(Data Layer)이 한곳에 섞여 있습니다. 코드를 읽을 때 "이게 웹 처리 로직인지, 업무 규칙인지" 한눈에 파악하기 어렵습니다.
- 절차지향 프로그래밍:
- 데이터를 가져와서(Getter), 절차적으로 처리하고, 다시 집어넣습니다(Setter). 객체에게 일을 시키는 것이 아니라, 컨트롤러가 모든 데이터를 가져와서 직접 일을 처리하는 '절차 지향적 프로그래밍'이 되었습니다.
앞으로 생길 문제점
지금 당장은 코드가 돌아가지만, 프로젝트가 커질수록 이 스마트 컨트롤러는 다음과 같은 문제를 일으킬 확률이 큽니다.
1. 재사용성 문제
"관리자 페이지에서도 주문을 생성하는 기능이 필요한데요?"
기획자의 요청이 들어왔습니다. 하지만 OrderController의 로직은 HTTP 요청(@RequestBody)과 강하게 결합되어 있어 다른 곳에서 호출할 수가 없습니다. 결국 관리자용 컨트롤러에 똑같은 코드를 '복사 & 붙여넣기' 해야 합니다. 로직이 파편화되기 시작합니다.
2. 테스트 문제
"VIP 할인 정책이 제대로 적용되는지 테스트하고 싶어요."
고작 '가격 계산' 로직 하나를 테스트하기 위해, HTTP 요청을 Mocking하고 3개의 리포지토리를 모두 Mocking해야 합니다. 테스트 코드가 너무 복잡해져서 결국 테스트 작성을 포기하게 됩니다.
3. 유지보수의 문제
"할인 정책이 변경되었습니다. VIP는 15% 할인, 신규 회원은 5% 할인으로 바꿔주세요."
개발자는 IDE에서 'VIP'라는 단어가 들어간 모든 파일을 뒤져야 합니다. 고객용 앱 컨트롤러, 관리자용 컨트롤러, 배치 프로그램 등 여기저기에 흩어진 로직을 모두 찾아 수정해야 하며, 그중 하나라도 빼먹으면 버그가 발생합니다.
4. 확장성의 문제
"트래픽이 너무 몰려서 주문 처리를 카프카(Kafka)를 이용한 비동기 방식으로 바꿔야겠어요."
시스템이 성장하면 필연적으로 아키텍처를 확장해야 할 시기가 옵니다. 예를 들어, 지금까지는 HTTP 요청으로 들어온 주문을 즉시 처리(Synchronous)했지만, 대량 트래픽 처리를 위해 메시지 큐(Message Queue)를 도입하거나 배치(Batch) 프로그램으로 처리해야 할 수 있습니다.
하지만 비즈니스 로직이 컨트롤러에 갇혀 있다면(Coupled to Web Layer) 어떻게 될까요?
- 웹(HTTP)에 종속됨: 컨트롤러에 작성된 로직은 HttpServletRequest나 웹 전용 파라미터에 의존하고 있어, 웹이 아닌 환경(카프카 컨슈머, 배치 스케줄러, gRPC 등)에서는 전혀 사용할 수 없습니다.
- MSA 전환의 걸림돌: 추후 이 기능을 별도의 마이크로서비스로 분리하려 해도, 로직이 웹 계층과 뒤엉켜 있어 깔끔하게 분리하는 것이 불가능에 가깝습니다. 결국 처음부터 다시 짜야 하는 상황이 발생합니다.
결국 스마트 UI 패턴은 시스템의 확장성을 저하시킵니다.
4. 컨트롤러(Controller)의 역할
웹 애플리케이션 아키텍처(특히 MVC 패턴이나 3-Tier Architecture)에서 컨트롤러(Controller)는 외부 세계(Client)와 내부 시스템(Application)을 연결하는 User Interface 역할을 수행합니다.
컨트롤러를 한 문장으로 정의하자면, "엔드포인트를 정의하여 클라이언트의 요청을 받고, 적절한 비즈니스 로직으로 연결한 뒤, 그 결과를 약속된 포맷으로 응답하는 객체"입니다.
컨트롤러의 핵심 책임
컨트롤러는 다음과 같은 3가지 핵심적인 역할을 수행해야 합니다.
- API 호출 방식(Endpoint) 정의
- 클라이언트가 애플리케이션에 접근할 수 있는 URL(URI)과 HTTP 메서드(GET, POST, PUT, DELETE 등)를 정의합니다.
- 어떤 요청이 들어왔을 때 누가 처리할지를 결정하는 '라우팅(Routing)' 역할을 합니다.
- 비즈니스 로직의 위임 (Delegation)
- 컨트롤러는 직접 일을 처리하지 않습니다. 들어온 요청의 의도를 파악하고, 그 일을 수행할 적절한 서비스(Service) 계층에게 처리를 위임합니다.
- 즉, "어떻게(How)" 할지가 아니라 "누가(Who)" 할지를 결정합니다.
- 응답 포맷(Response Format) 정의
- 서비스 계층에서 처리된 결과를 클라이언트가 이해할 수 있는 형태(JSON, XML 등)로 변환합니다.
- 성공시에는 적절한 데이터와 HTTP 상태 코드(200 OK, 201 Created 등)를, 실패시에는 에러 메시지와 에러 코드(400 Bad Request, 500 Server Error 등)를 반환합니다.
컨트롤러가 하지 말아야 할 것
좋은 컨트롤러를 설계하기 위해서는 '무엇을 하지 않을지' 아는 것이 더 중요합니다.
컨트롤러에 비즈니스 로직이나 데이터베이스 접근 로직이 포함되어서는 안 됩니다.
- NO Business Logic: 데이터를 가공하거나, 계산하거나, 정책을 판단하는 로직은 컨트롤러의 몫이 아닙니다.
- NO Data Access: SQL을 실행하거나 Repository에 직접 접근하여 데이터를 가져오는 행위를 지양해야 합니다.
왜 역할을 분리해야 하는가?
단순히 관습 때문이 아니라, 소프트웨어 공학적 관점에서 명확한 이유가 있습니다.
- 관심사의 분리 (Separation of Concerns): 컨트롤러의 목적은 '웹 요청 처리'이고, 서비스의 목적은 '비즈니스 로직 수행'입니다. 이 둘을 섞으면 코드가 복잡해지고 가독성이 떨어집니다.
- 단일 책임 원칙 (SRP - Single Responsibility Principle): 컨트롤러가 비즈니스 로직까지 떠안게 되면, 책임이 과도해집니다. 비즈니스 정책이 바뀌어도 컨트롤러를 수정해야 하고, API 스펙이 바뀌어도 컨트롤러를 수정해야 하는 상황이 발생합니다.
- 재사용성과 테스트 용이성: 비즈니스 로직이 서비스 계층에 격리되어 있어야 다른 컨트롤러(예: 웹, 모바일, 관리자 페이지)에서도 해당 로직을 재사용할 수 있으며, 컨트롤러와 서비스를 독립적으로 테스트하기 쉬워집니다.
'Spring' 카테고리의 다른 글
[스프링] 안티패턴: 완화된 레이어드 아키텍처 (0) 2025.12.19 [스프링] 안티패턴: 양방향 레이어드 아키텍처 (0) 2025.12.19 [Java/Spring] Entity: 개체 (0) 2025.12.12 [Java/Spring] DTO: 데이터 전송 객체 (0) 2025.12.12 [Java/Spring] VO(Value Object)란 무엇인가? (0) 2025.12.11