GoF 밖에서 아키텍처를 떠받치는 패턴들: MVC·MVP·MVVM·Repository·DI
MVC·MVP·MVVM으로 프레젠테이션 책임을 나누고 Repository·DI로 도메인과 인프라를 분리하는 법, 스파게티 코드·갓 오브젝트 대응까지 정리한다
2026-08-13 · 최초 발행 2025-10-14
디자인 패턴이라고 하면 GoF의 23개 패턴부터 떠올리기 쉽지만, 실무에서 애플리케이션 구조를 실제로 좌우하는 것은 MVC·MVP·MVVM 같은 UI 아키텍처 패턴과 Repository·Dependency Injection 같은 구조화 기법인 경우가 많다. 이들은 프레임워크와 밀접하게 결합되는 설계 관례에 가깝고, 관심사 분리와 테스트 용이성, 유지보수성을 목적으로 계층·역할·데이터 흐름을 표준화한다.
패턴과 안티패턴을 가르는 기준
Non-GoF 패턴이 반복되는 문제에 대한 검증된 해법이라면, 안티패턴(Anti-Pattern)은 반복 출현하는 비효율적 해법의 전형이다. 단기적으로는 편의성을 주지만 장기적으로는 비용을 늘리며, 스멜을 감지하고 리팩터링한 뒤 회귀 테스트로 검증하는 절차를 지속해야 제거할 수 있다.
MVC·MVP·MVVM은 프레젠테이션 책임을 어떻게 나누는가
MVC(Model–View–Controller)는 요청 처리 책임을 Controller, 데이터·비즈니스 규칙을 Model, 렌더링을 View로 분리하는 구조로 웹·서버 사이드 렌더링에 적합하다. 라우팅, 폼 검증, 세션 관리 등 요청-응답 파이프라인에 강점이 있고 View-Model 결합도 관리가 핵심이다.
MVP(Model–View–Presenter)는 View를 수동적으로 두고 Presenter가 UI 로직 중심을 담당해 테스트 가능한 UI 로직 분리에 유리하다. 안드로이드·데스크톱 레거시 UI에서 콜백 기반 상호작용을 제어하는 데 적합하고, Presenter-View 인터페이스 설계가 관건이다.
MVVM(Model–View–ViewModel)은 양방향 또는 단방향 바인딩으로 상태와 UI를 동기화해 상태 관리 일관성을 강화한다. iOS(SwiftUI), Android(Jetpack), 프런트엔드(Vue·React+Hooks) 같은 선언적 UI 환경에서 효과적이지만 바인딩 오버헤드와 상태 폭증 관리가 필요하다.
Repository와 DI로 도메인과 인프라를 떼어놓기
Repository 패턴은 도메인과 데이터 접근 계층을 분리해 저장소 인터페이스를 통해 영속성과 무관한 도메인 모델을 유지한다. 트랜잭션 경계와 단위 작업(Unit of Work) 설계가 쉬워지고, 데이터 소스 교체나 다중 소스(읽기·쓰기 분리) 대응에 강점을 갖는다.
Dependency Injection(DI)은 객체 생성과 의존성 해결을 외부 컨테이너로 이관해 구성(Composition)과 생명주기(스코프) 관리를 효율화한다. 테스트 격리, 모듈화, 횡단 관심사(AOP) 적용이 쉬워지지만 리플렉션 기반 컨테이너의 초기화 비용·복잡도라는 트레이드오프가 따른다.
웹 백엔드·모바일·레거시 UI에서 실제로 조합하는 법
웹 백엔드에서는 MVC + Repository + DI를 조합한다. HTTP 요청이 들어오면 Controller가 DTO를 바인딩·검증하고, Service가 트랜잭션을 시작해 Repository를 호출한 뒤 도메인 로직을 수행하고, 응답 DTO를 매핑해 표준 에러 포맷으로 반환한다. 트랜잭션 경계는 Service 레이어에서 설정해 성공 시 커밋, 예외 시 롤백하며, 읽기-쓰기를 분리한 경우 쓰기 트랜잭션 완료 이후 읽기 복제 지연에 따른 일시적 비일관성을 고려해야 한다.
모바일·프런트엔드에서는 MVVM이 주로 쓰인다. ViewModel의 상태(State) 변경이 UI에 자동 반영되고, 폼 검증이나 비동기 로딩 상태를 일원화한다. 단방향 데이터 흐름(UDF)을 도입하면 디버깅과 타임트래블 용이성이 향상된다.
레거시 UI·임베디드 환경에서는 MVP가 적합하다. 이벤트 루프 기반 환경에서 View 의존을 최소화하고 Presenter 단위로 테스트하기 쉬우며, View-Adapter를 분리해두면 UI 프레임워크 교체 비용도 줄어든다.
운영 관점에서는 Controller를 얇게 유지하고 도메인 규칙은 Service에 집중시키며, Repository는 조회·저장에만 전념시키는 게 모범사례다. DI는 생성자 주입을 기본으로 하고 스코프(Singleton/Request/Prototype)를 명시적으로 관리한다. MVVM은 상태 슬라이싱과 불변 모델을 활용해 바인딩을 최소화하는 원칙을 적용한다. 다만 DI 컨테이너 도입 초기에는 학습·부트 비용이 늘고, MVVM의 바인딩·옵저버를 남용하면 성능이 저하될 수 있으며, Repository를 남용하면 도메인 서비스가 빈 껍데기가 될 위험이 있어 복잡한 질의는 Query Object나 Specification으로 분리하는 게 낫다.
흔한 안티패턴과 대응: 스파게티 코드·갓 오브젝트·매직 넘버
Spaghetti Code는 순환 의존, 전역 상태, 제어 흐름 난독화가 특징이다. 프레젠테이션·도메인·인프라로 계층화하고 모듈 경계를 설정해 사이클을 제거(컴포넌트 의존 방향 단일화)하며, 테스트 주도로 분리하는 게 대응책이다.
God Object는 하나의 클래스에 과도한 책임이 집중돼 거대한 메서드·필드를 갖는 상태다. SRP 기반으로 책임을 분해하고 도메인 서비스나 밸류 오브젝트로 이전하며, 이벤트 발행으로 결합도를 완화한다.
Magic Number는 맥락 없는 상수 값을 하드코딩한 경우다. 의미 있는 상수·열거형·설정값으로 치환하고, 시간·크기 단위는 표준 타입(Duration/Size)으로 모델링하며, 정적 분석 규칙을 활성화해 재발을 막는다.
요청이 흐르는 경로를 도식으로 보면
성능·확장성·일관성 지표로 비교하면
| 패턴 | 성능 | 확장성 | 일관성 | 안정성 | 운영 편의 |
|---|---|---|---|---|---|
| MVC | 낮은 오버헤드, 서버사이드 적합 | 수평 확장 용이(무상태 설계) | 요청-응답 단위 일관성 우수 | 프레임워크 성숙도 높음 | 라우팅/미들웨어 생태계 풍부 |
| MVP | UI 로직 분리로 예측 가능 | 복잡 UI에서 유지보수 확장 용이 | Presenter 규약으로 흐름 일관 | 단위 테스트로 회귀 위험 감소 | View 교체 용이, 보일러플레이트 증가 |
| MVVM | 바인딩 오버헤드 주의 | 상태 관리 패턴으로 대규모 UI 확장 | 상태 단일원천(Single Source) | 선언적 UI와 궁합 높음 | 데이터 바인딩/스토어 운영 편의 높음 |
| Repository | 데이터 접근 최적화 별도 관리 | 데이터 소스 교체/샤딩 대응 | 트랜잭션 경계로 강한 일관 | 실패 격리/재시도 전략 용이 | 테스트 대역(Mock) 구성 용이 |
| DI | 미미한 런타임 비용(컨테이너별 상이) | 모듈 단위 확장/교체 용이 | 구성 일관성(프로파일/스코프) | 순환 의존 방지로 안정성 향상 | 설정/프로비저닝 자동화 용이 |
실행 코드: Repository·DI·MVC 결합
전제조건은 Java 17, Spring Boot 3.x, JPA이며 Gradle·Maven 표준 프로젝트를 가정한다.
// 도메인
data class User(val id: Long? = null, val email: String, val name: String)
// Repository 인터페이스
interface UserRepository {
fun findByEmail(email: String): User?
fun save(user: User): User
}
// JPA 구현체
@Repository
class JpaUserRepository(private val em: jakarta.persistence.EntityManager) : UserRepository {
override fun findByEmail(email: String): User? =
em.createQuery("select u from UserEntity u where u.email = :email", UserEntity::class.java)
.setParameter("email", email)
.resultList.firstOrNull()
?.toModel()
override fun save(user: User): User {
val entity = UserEntity.fromModel(user)
if (entity.id == null) em.persist(entity) else em.merge(entity)
return entity.toModel()
}
}
// Service 레이어 (트랜잭션 경계)
@Service
class UserService(private val repo: UserRepository) {
@Transactional
fun register(email: String, name: String): User {
require(email.isNotBlank()) { "email required" }
if (repo.findByEmail(email) != null) throw IllegalArgumentException("duplicate email")
return repo.save(User(email = email, name = name))
}
}
// MVC 컨트롤러
@RestController
@RequestMapping("/users")
class UserController(private val userService: UserService) {
data class CreateUserReq(val email: String, val name: String)
data class UserRes(val id: Long, val email: String, val name: String)
@PostMapping
fun create(@RequestBody req: CreateUserReq): ResponseEntity<UserRes> =
try {
val u = userService.register(req.email, req.name)
ResponseEntity.status(HttpStatus.CREATED).body(UserRes(u.id!!, u.email, u.name))
} catch (e: IllegalArgumentException) {
ResponseEntity.status(HttpStatus.BAD_REQUEST).build()
}
}
생성자 주입을 적용하면 순환 의존을 방지하고 테스트 격리가 쉬워진다. Service에서 트랜잭션을 시작하고 Repository I/O를 수행한 뒤 예외에 따라 롤백하며, Controller는 얇게 유지하고 검증·바인딩·표준 응답만 담당하게 한다.
팀에 자리 잡았을 때 달라지는 지표
이 패턴군이 조직에 자리 잡으면 얻는 효과는 유사 규모 팀(38명)이 36개월 도입했을 때를 기준으로 한 가이드라인 수치로 제시된다. 책임 분리와 테스트 용이성이 향상되면서 결함 밀도가 1530% 감소하고, DI·Repository 기반 재사용으로 신규 기능 리드타임이 1025% 단축되며, 서비스·프레젠테이션 단위 테스트가 확충되면서 회귀 버그 재발률이 20~35% 줄어드는 것으로 보고된다.
정성적으로는 아키텍처 일관성이 확보돼 온보딩이 쉬워지고, 프레임워크를 교체하거나 확장할 때의 리스크가 줄어든다. 코드 리뷰 품질도 올라가고 변경 영향 범위를 예측하기 쉬워진다. 다만 이 수치는 조직·코드베이스 규모에 따라 달라질 수 있는 가이드라인이라는 점을 감안해야 한다.