ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [스프링] 안티패턴: 양방향 레이어드 아키텍처
    Spring 2025. 12. 19. 01:20

    백엔드 개발자, 특히 Spring 프레임워크를 사용하는 개발자들에게 가장 익숙한 아키텍처 패턴을 꼽으라면 단연 '레이어드 아키텍처(Layered Architecture)'일 것입니다. 관심사를 분리하고 유지보수성을 높이기 위해 우리는 습관처럼 Controller, Service, Repository로 계층을 나누곤 합니다.

    하지만 프로젝트 규모가 커지고 비즈니스 로직이 복잡해지다 보면, 처음에 의도했던 깔끔한 단방향 흐름이 조금씩 어긋나는 순간이 찾아옵니다.

    이번 글에서 다룰 주제는 바로 '양방향 레이어드 아키텍처(Bidirectional Layered Architecture)'입니다.

    이는 정식 아키텍처 패턴이 아닌, 레이어드 아키텍처를 지향하는 프로젝트에서 빈번하게 발생하는 대표적인 안티패턴(Anti-pattern)입니다. 분명 레이어드 아키텍처의 구조를 띠고 있지만, 실제로는 레이어 간의 의존 관계가 단방향으로 흐르지 않고 서로를 참조하는 '양방향 의존'이 발생한 상태를 의미합니다.

    겉보기엔 멀쩡해 보이지만 코드의 복잡도를 높이고 순환 참조의 늪에 빠지게 만드는 이 안티패턴이 정확히 무엇인지, 그리고 왜 피해야 하는지 지금부터 차근차근 알아보겠습니다.

     

    1. 레이어드 아키텍처란?

    레이어드 아키텍처(Layered Architecture)는 소프트웨어 시스템을 수평적인 '레이어(Layer)'로 구분하여 구성하는 가장 보편적인 설계 방식입니다.

    이 아키텍처를 따르는 애플리케이션은 개발을 시작하기 전, 시스템을 구성할 레이어를 먼저 정의합니다. Spring 프레임워크를 사용하는 백엔드 개발 환경에서는 통상적으로 역할과 책임에 따라 다음 세 가지 레이어로 구분합니다.

    1) 프레젠테이션 레이어 (Presentation Layer)

    사용자와의 상호작용(Interface)을 담당하는 계층입니다. 클라이언트의 요청을 받고, 적절한 비즈니스 로직을 수행한 뒤 최종 결과를 응답하는 역할을 수행합니다.

    • 주요 역할: 요청 매핑, 입력값 검증(Validation), 응답 포맷팅
    • 대표 컴포넌트: Spring의 Controller가 이곳에 위치합니다. 즉, 외부와의 통신을 담당하는 웹 계층 컴포넌트들의 집합이라고 볼 수 있습니다.

    2) 비즈니스 레이어 (Business Layer)

    애플리케이션의 핵심 업무 규칙을 처리하는 계층입니다. 프레젠테이션 레이어로부터 요청을 전달받아 실제 비즈니스 요구사항을 수행합니다.

    • 주요 역할: 데이터 가공, 트랜잭션 관리, 도메인 로직 실행, 비즈니스 규칙 적용
    • 대표 컴포넌트: 주로 Service 컴포넌트가 이곳에 배치되어 비즈니스 흐름을 제어합니다.

    3) 인프라스트럭처 레이어 (Infrastructure Layer)

    데이터베이스나 외부 API 등 외부 시스템과의 통신을 담당하는 계층입니다. 흔히 영속성 레이어(Persistence Layer)라고도 불리지만, 이는 데이터를 저장하고 조회하는 DB 접근 기술(JDBC, JPA/Hibernate 등)에 초점을 맞춘 좁은 의미의 명칭입니다.

    • 확장된 의미: 실제 현업에서는 DB뿐만 아니라 외부 결제 모듈이나 타 서비스 API와 통신해야 하는 경우도 빈번합니다. (예: WebClient 사용)
    • 대표 컴포넌트: Repository, DAO, 외부 API 클라이언트 등
    • 따라서 영속성 관련 컴포넌트뿐만 아니라 외부 시스템과의 통신 전반을 포함한다는 의미에서 '인프라스트럭처 레이어'라고 부르는 것이 더 적합합니다.

    핵심 규칙과 장점

    레이어드 아키텍처는 단순히 코드를 나누는 것에 그치지 않고, '상위 레이어가 하위 레이어를 사용한다'는 명확한 방향성을 가집니다. 즉, 요청의 흐름은 위에서 아래로(Top-down) 흐르게 됩니다.

    이러한 구조가 주는 가장 큰 장점은 '단순함'과 '직관성'입니다. 새로운 기능을 개발하거나 기존 코드를 수정할 때, 개발자는 "이 코드를 어디에 둬야 하지?"라는 고민을 깊게 할 필요가 없습니다. 컴포넌트의 역할만 떠올리면 그 위치와 접근해야 할 대상이 명확해지기 때문입니다. 덕분에 초기 진입 장벽이 낮고 빠른 기능 개발이 가능하다는 강력한 이점을 가집니다.

     

    2. 레이어드 아키텍처 코드 예시 (회원가입)

    회원가입 기능은 사용자 요청(ID, PW) -> 중복 검사 및 저장 -> DB 적재의 순서로 진행됩니다. 각 레이어가 자신의 역할에 충실하며 하위 레이어만 호출하는 모습을 주목해 주세요.

    1) 인프라스트럭처 레이어 (Infrastructure Layer)

    가장 먼저 데이터베이스와 소통하는 영역입니다. Entity와 Repository가 여기에 해당합니다.

    // [Entity] 데이터베이스 테이블과 매핑되는 객체
    @Entity
    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Member {
    
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String email;
        private String password;
    
        public Member(String email, String password) {
            this.email = email;
            this.password = password;
        }
    }
    
    // [Repository] DB 접근을 담당하는 인터페이스 (JPA 예시)
    public interface MemberRepository extends JpaRepository<Member, Long> {
        boolean existsByEmail(String email);
    }
    

    2) 비즈니스 레이어 (Business Layer)

    핵심 로직을 담당합니다. 인프라스트럭처 레이어(Repository)에 의존하여 데이터를 조회하거나 저장합니다. 웹 계층(Controller)에 대해서는 전혀 알지 못합니다.

     
    @Service
    @RequiredArgsConstructor
    public class MemberService {
    
        private final MemberRepository memberRepository; // 하위 레이어 의존
    
        // 회원가입 핵심 로직
        public void register(String email, String password) {
            // 1. 비즈니스 규칙 검사: 중복 회원 확인
            if (memberRepository.existsByEmail(email)) {
                throw new IllegalStateException("이미 존재하는 이메일입니다.");
            }
    
            // 2. 데이터 가공 및 저장 요청
            // (실무에선 비밀번호 암호화 로직 등이 이곳에 포함됨)
            Member newMember = new Member(email, password);
            memberRepository.save(newMember);
        }
    }
    

    3) 프레젠테이션 레이어 (Presentation Layer)

    사용자의 입력을 받고 응답을 줍니다. 비즈니스 레이어(Service)에 의존하여 작업을 위임합니다.

     
    // [DTO] 계층 간 데이터 전송을 위한 객체 (요청용)
    @Getter
    @NoArgsConstructor
    public class SignupRequest {
        private String email;
        private String password;
    }
    
    // [Controller] 외부 요청의 진입점
    @RestController
    @RequestMapping("/api/members")
    @RequiredArgsConstructor
    public class MemberController {
    
        private final MemberService memberService; // 하위 레이어 의존
    
        @PostMapping("/signup")
        public ResponseEntity<String> signup(@RequestBody SignupRequest request) {
            // 사용자의 요청(DTO)을 비즈니스 로직에 필요한 데이터로 변환하여 전달
            memberService.register(request.getEmail(), request.getPassword());
            
            return ResponseEntity.ok("회원가입 성공");
        }
    }

     

    3. 양방향 레이어드 아키텍처란?

    양방향 레이어드 아키텍처(Bidirectional Layered Architecture)는 사실 공식적인 아키텍처 패턴의 명칭이라기보다, 레이어드 아키텍처를 지향하면서도 가장 기초적인 제약을 위반했을 때 나타나는 현상을 지칭하는 말입니다.

    여기서 말하는 '가장 기초적인 제약'이란 바로 '모든 레이어 간의 의존 방향은 반드시 단방향(위에서 아래)으로 유지되어야 한다'는 원칙입니다.

    • 상위 레이어(Controller)는 하위 레이어(Service)를 알 수 있습니다.
    • 하지만, 하위 레이어는 상위 레이어를 절대 알아서는 안 됩니다.

    하지만 실무에서는 '편의성'이라는 유혹 때문에 이 원칙이 깨지곤 합니다. 데이터를 하나하나 풀어서 전달하기 귀찮다는 이유로, 혹은 빠르게 개발해야 한다는 핑계로 하위 레이어(Service)가 상위 레이어(Presentation)의 객체를 파라미터로 받거나 반환 타입으로 사용하는 경우가 발생합니다.

    이 순간, 서비스 레이어는 웹 계층의 객체에 의존하게 되며, 의존성의 흐름이 역류하는 '양방향 의존'이 만들어집니다.

    잘못된 코드 예시: 서비스가 웹 DTO를 알 때

    앞서 작성했던 회원가입 코드를 '양방향 레이어드 아키텍처' 형태로 망가뜨려 보겠습니다. 서비스 계층이 프레젠테이션 계층의 SignupRequest DTO를 직접 사용하는 상황입니다.

     
    // [Service] 비즈니스 레이어
    package com.example.blog.service;
    
    // 치명적인 문제: 서비스가 웹 계층의 DTO(Presentation Layer)를 import 하고 있음
    import com.example.blog.controller.dto.SignupRequest; 
    import com.example.blog.repository.MemberRepository;
    import com.example.blog.domain.Member;
    import org.springframework.stereotype.Service;
    
    @Service
    @RequiredArgsConstructor
    public class MemberService {
    
        private final MemberRepository memberRepository;
    
        /**
         * 안티패턴 발생 지점!
         * 서비스 메서드가 상위 레이어의 객체인 'SignupRequest'를 파라미터로 받고 있다.
         */
        public void register(SignupRequest request) {
            
            // 1. DTO에서 데이터를 꺼내 비즈니스 로직 수행
            if (memberRepository.existsByEmail(request.getEmail())) {
                throw new IllegalStateException("이미 존재하는 이메일입니다.");
            }
    
            // 2. DTO를 엔티티로 변환
            Member newMember = new Member(request.getEmail(), request.getPassword());
            memberRepository.save(newMember);
        }
    }

    무엇이 문제일까요?

    위 코드를 보면 MemberService는 순수한 비즈니스 로직만 담고 있어야 함에도 불구하고, 웹 계층의 객체인 SignupRequest에 의존하고 있습니다.

    이로 인해 다음과 같은 의존성 사이클이 생깁니다.

    1. Controller → Service: 컨트롤러는 서비스를 호출하므로 서비스에 의존합니다. (정상)
    2. Service → DTO (in Controller package): 서비스가 파라미터를 쓰기 위해 웹 패키지를 참조합니다. (비정상)

    결국 Controller와 Service가 서로에 대한 의존이 강한 형태가 되었습니다. 이것이 바로 양방향 레이어드 아키텍처입니다.

     

    이처럼 하위 레이어가 상위 레이어를 참조하는 코드가 하나둘 늘어나면 어떤 일이 벌어질까요? 우리가 애써 정의했던 레이어의 역할과 경계는 의미가 없어집니다. 다시 말해, 계층(Layer) 자체가 무너져 내립니다.

    엄밀히 말하면, 이는 '양방향 의존'이라는 그럴싸한 말로 포장할 수 있는 문제가 아닙니다. 실상은 '아키텍처 수준의 순환 참조(Circular Reference)'가 발생한 것입니다.

    컴포넌트 간에 순환 참조(A ⇄ B)가 발생했다는 것은 무엇을 의미할까요? 두 컴포넌트가 서로 없으면 동작하지 못한다는 뜻이며, 이는 사실상 "우리는 두 개가 아니라 하나의 컴포넌트입니다"라고 선언하는 것과 다를 바 없습니다.

    레이어도 마찬가지입니다. 프레젠테이션 레이어와 비즈니스 레이어가 서로를 의존하고 있다면, 물리적으로는 패키지가 나뉘어 있을지 몰라도 논리적으로는 이미 하나의 레이어로 통합된 것이나 다름없습니다. 가장 중요하게 여겨야 할 계층 간의 위계질서와 격벽이 완전히 사라진, 모호한 상태가 되어버린 것입니다.

    따라서 양방향 레이어드 아키텍처에서 '레이어'는 더 이상 레이어라고 부를 자격이 없습니다. 그저 비슷한 파일들을 모아놓고 컴포넌트를 구분하는 역할만 할 뿐입니다. 그러니 차라리 'Directory'라고 부르는 편이 훨씬 정확할 것입니다.

    더 나아가, 우리는 이것을 아키텍처라고 부를 수도 없습니다. 단순히 파일을 Directory 별로 분류해서 넣어두는 행위를 두고 '아키텍처를 설계했다'고 말하지 않기 때문입니다. 결국 양방향 레이어드 아키텍처는 아키텍처 패턴이 아니라, 안티패턴일 뿐입니다.

     

    4. 해결법: 레이어별 모델 구성

    가장 근본적인 해결책은 '하위 레이어가 상위 레이어의 객체를 모르게 하는 것'입니다. 이를 위해 서비스 레이어의 메서드 시그니처를 수정해야 합니다. 상위 레이어의 객체인 SignupRequest 대신 서비스가 필요로 하는 데이터만 명확하게 정의하는 것입니다.

    1) Service 코드 수정 

    먼저 서비스 코드를 수정합니다. SignupRequest 객체 대신 비즈니스 로직 수행에 꼭 필요한 데이터(email, password)만 인자로 받도록 변경합니다.

     
    package com.example.blog.service;
    
    import com.example.blog.repository.MemberRepository;
    import com.example.blog.domain.Member;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    import lombok.RequiredArgsConstructor;
    
    // import 문에서 controller 패키지가 완전히 사라졌습니다.
    @Service
    @RequiredArgsConstructor
    public class MemberService {
    
        private final MemberRepository memberRepository;
    
        /**
         * [Refactoring]
         * 상위 레이어의 객체(SignupRequest) 대신,
         * 자바의 기본 타입(String)이나 비즈니스 레이어 전용 DTO를 파라미터로 받습니다.
         */
        @Transactional
        public void register(String email, String password) {
            
            // 1. 순수 데이터로 비즈니스 로직 수행
            if (memberRepository.existsByEmail(email)) {
                throw new IllegalStateException("이미 존재하는 이메일입니다.");
            }
    
            // 2. 엔티티 생성 및 저장
            Member newMember = new Member(email, password);
            memberRepository.save(newMember);
        }
    }

    이제 MemberService는 웹 계층에 누가 있는지, 어떤 DTO가 들어오는지 전혀 알지 못합니다. 오직 자신에게 필요한 데이터가 무엇인지만 정의하고 있습니다. 

    2) Controller 코드 수정 

    서비스가 수정했지만 상위 레이어인 컨트롤러 또한 수정을 해야합니다. 컨트롤러는 자신이 받은 SignupRequest에서 데이터를 꺼내 서비스가 원하는 형태에 맞춰 전달해야 합니다.

     
    package com.example.blog.controller;
    
    import com.example.blog.controller.dto.SignupRequest;
    import com.example.blog.service.MemberService;
    import org.springframework.web.bind.annotation.*;
    import lombok.RequiredArgsConstructor;
    
    @RestController
    @RequestMapping("/api/members")
    @RequiredArgsConstructor
    public class MemberController {
    
        private final MemberService memberService;
    
        @PostMapping("/signup")
        public ResponseEntity<String> signup(@RequestBody SignupRequest request) {
            
            // [Mapping]
            // 웹 계층의 DTO를 해체하여 서비스 계층이 원하는 형태로 전달합니다.
            // 이 과정이 바로 '단방향 의존'을 지키기 위한 노력입니다.
            memberService.register(request.getEmail(), request.getPassword());
            
            return ResponseEntity.ok("회원가입 성공");
        }
    }
    

    무엇이 달라졌나요?

    코드 자체는 드라마틱하게 변하지 않은 것 같지만, 의존 관계는 완전히 달라졌습니다.

    1. 의존성 방향의 정상화:
      • Service는 더 이상 Controller 패키지를 import 하지 않습니다.
      • Controller → Service 방향의 단방향 흐름이 완벽하게 복구되었습니다.
    2. 변경의 격리:
      • 만약 웹 API 스펙이 변경되어 SignupRequest의 필드명이 바뀌더라도, Service 코드는 단 한 줄도 수정할 필요가 없습니다. 컨트롤러에서 매핑하는 코드만 고치면 됩니다.
    3. 테스트 용이성:
      • 서비스 레이어를 테스트할 때, 굳이 웹 계층의 객체인 SignupRequest를 만들 필요가 없습니다. 단순히 문자열(String)만 넣어주면 되므로 단위 테스트가 훨씬 간결해집니다.

    물론, 전달해야 할 파라미터가 5개, 10개로 늘어난다면 어떻게 해야 할까요? 그때는 email, password 등을 묶은 '서비스 전용 DTO(Service Spec)'를 별도로 만들어서 사용하면 됩니다. 핵심은 "그 객체의 소유권이 누구에게 있느냐"입니다.

Designed by MSJ.