☰ 분류

MVC와 SOLID 구조화 프롬프트

언어, 프레임워크, 관심 컴포넌트를 넣으면 MVC 구성 방식과 SOLID 원칙 적용법을 예시와 함께 설명합니다.

분류개발 › 코딩
태그초안작성분석개발자코드
프롬프트 (영어 본문 · 답은 한국어로 옵니다)
Act as a Software Architecture Expert. You are a seasoned developer specializing in creating scalable and maintainable applications.

Your task is to guide developers in structuring their codebase using the Model-View-Controller (MVC) architecture and adhering to SOLID principles.

You will:
- Explain the fundamentals of the MVC pattern and its benefits for software design.
- Illustrate how to implement each component (Model, View, Controller) effectively.
- Provide guidelines for applying SOLID principles (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) in code.
- Share best practices for clean coding and refactoring.

Rules:
- Use clear, concise examples to demonstrate each principle.
- Encourage modularity and separation of concerns.
- Ensure code is readable and maintainable.

Variables:
- ${language:Java} - Programming language to use for examples
- ${framework:Spring} - Framework to consider for implementation
- ${component:Controller} - Specific component focus (Model, View, Controller)

어떤 프롬프트인가

코드베이스 구조를 MVC와 SOLID 관점에서 정리할 때 쓰는 개발 가이드 역할이다. 개념 설명과 예시 중심이며, 실제 프로젝트 분석은 입력이 있어야 가능하다.

모델 비교

ChatGPT가 가장 폭넓고 실무적이나 일부 코드가 불일치한다. Gemini는 명료하지만 트랜잭션·ISP 문제가 크며, [C]는 답이 없다.

ChatGPT종합 1위
39/ 50

+ 계층별 책임과 조회·결제 분리를 구체화했다.

- 예시 간 인터페이스 불일치와 과도한 분량이 있다.

Gemini
38/ 50

+ 리팩터링 전후와 SOLID 적용이 선명하다.

- 결제 실패 상태가 롤백되며 ISP 적용도 모순된다.

기준ChatGPTGemini앞선 모델
지시 준수99동점
정확성76ChatGPT +17%
구체성99동점
구성·가독성98ChatGPT +13%
분량 적정성56Gemini +20%

gpt-5.6-sol가 모델 이름을 가린 채 1~10점으로 채점했습니다 (2026-09-24). 측정값이 아니라 AI 심사 결과입니다.

답 전문 보기

같은 입력을 세 모델에 넣어 받은 답을 그대로 옮겼습니다. 각 CLI(에이전트 하네스)에서 실행했습니다. ChatGPT·Claude 앱이나 웹에서는 답이 다를 수 있습니다.

넣은 입력
[붙여 넣은 자료]
제가 쓰는 스택은 Kotlin + Spring Boot입니다. 특히 Controller 계층이 검증, 조회, 결제 로직까지 다 떠안고 있어서 SOLID 원칙에 맞게 어떻게 나눠야 할지 예시로 보여주세요.
gpt-5.6-luna (codex CLI · 에이전트 하네스) · 실행일 2026-09-24

Kotlin + Spring Boot에서 Controller가 검증, 조회, 결제까지 모두 담당한다면 다음처럼 역할을 분리하는 것이 좋습니다.

1. MVC의 역할

  • Model: 비즈니스 상태와 규칙
  • View: 사용자에게 반환할 표현 형식. REST API에서는 보통 Response DTO와 JSON
  • Controller: HTTP 요청을 애플리케이션 유스케이스로 변환하고 결과를 응답으로 변환

Controller는 “무엇을 할지” 결정하기보다, 요청을 받아 적절한 애플리케이션 서비스를 호출하는 진입점이어야 합니다.

HTTP 요청
   ↓
Controller
   ↓
Application Service
   ↓
Domain Model ── Repository Port
   ↓                 ↓
결과              DB Adapter

Application Service ── Payment Port
                         ↓
                  외부 결제 Adapter

2. 나쁜 예: 모든 책임이 Controller에 있음

@RestController
class OrderController(
    private val orderRepository: OrderRepository,
    private val paymentClient: PaymentClient
) {
    @PostMapping("/orders")
    fun create(@RequestBody request: CreateOrderRequest): ResponseEntity<Any> {
        if (request.items.isEmpty()) {
            return ResponseEntity.badRequest().body("상품이 없습니다.")
        }

        val product = productRepository.findById(request.productId)
            ?: return ResponseEntity.notFound().build()

        if (product.stock < request.quantity) {
            return ResponseEntity.badRequest().body("재고가 부족합니다.")
        }

        val order = Order(
            productId = product.id,
            quantity = request.quantity,
            amount = product.price * request.quantity
        )

        orderRepository.save(order)

        val paymentResult = paymentClient.pay(
            orderId = order.id,
            amount = order.amount
        )

        if (!paymentResult.success) {
            order.cancel()
            orderRepository.save(order)
        }

        return ResponseEntity.ok(order)
    }
}

이 코드는 다음 책임이 한곳에 섞여 있습니다.

  • HTTP 요청 검증
  • 상품 조회
  • 재고 및 주문 규칙
  • 주문 저장
  • 결제 API 호출
  • 결제 실패 처리
  • 응답 변환

3. 개선된 구조

order/
├── presentation/
│   ├── OrderController.kt
│   ├── CreateOrderRequest.kt
│   └── OrderResponse.kt
├── application/
│   └── CreateOrderService.kt
├── domain/
│   ├── Order.kt
│   └── OrderRepository.kt
└── infrastructure/
    ├── JpaOrderRepository.kt
    └── TossPaymentAdapter.kt

Controller: HTTP 변환만 담당

@RestController
@RequestMapping("/orders")
class OrderController(
    private val createOrderService: CreateOrderService
) {
    @PostMapping
    fun create(
        @Valid @RequestBody request: CreateOrderRequest
    ): ResponseEntity<OrderResponse> {
        val command = CreateOrderCommand(
            memberId = request.memberId,
            productId = request.productId,
            quantity = request.quantity,
            paymentMethod = request.paymentMethod
        )

        val result = createOrderService.create(command)

        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(OrderResponse.from(result))
    }
}

Controller가 담당할 일은 다음 정도가 적절합니다.

  • 경로 변수와 요청 DTO 수신
  • Bean Validation 실행
  • Command 변환
  • 애플리케이션 서비스 호출
  • Response DTO 변환
  • HTTP 상태 코드 결정

다음 로직은 Controller에 두지 않는 것이 좋습니다.

  • 데이터베이스 조회
  • 결제 API 호출
  • 재고 차감
  • 주문 상태 변경
  • 트랜잭션 제어
  • 복잡한 조건문과 비즈니스 규칙

4. 요청 검증 분리

형식 검증은 DTO에서 처리

data class CreateOrderRequest(
    @field:NotNull
    val memberId: Long?,

    @field:NotNull
    val productId: Long?,

    @field:Min(1)
    val quantity: Int,

    @field:NotBlank
    val paymentMethod: String
)

null, 빈 문자열, 최소 수량처럼 단순한 입력 형식 검증은 DTO와 Bean Validation에 맡깁니다.

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidation(
        exception: MethodArgumentNotValidException
    ): ResponseEntity<ErrorResponse> {
        return ResponseEntity.badRequest()
            .body(ErrorResponse("잘못된 요청입니다."))
    }
}

비즈니스 검증은 Application Service와 Domain에서 처리

“상품이 존재하는가”, “재고가 충분한가”, “결제 가능한 주문인가”는 단순한 입력 검증이 아니라 비즈니스 규칙입니다.

@Service
@Transactional
class CreateOrderService(
    private val productReader: ProductReader,
    private val orderRepository: OrderRepository,
    private val paymentProcessor: PaymentProcessor
) {
    fun create(command: CreateOrderCommand): OrderResult {
        val product = productReader.getById(command.productId)

        val order = Order.create(
            memberId = command.memberId,
            productId = product.id,
            quantity = command.quantity,
            unitPrice = product.price
        )

        product.decreaseStock(command.quantity)
        orderRepository.save(order)

        val payment = paymentProcessor.pay(
            PaymentCommand(
                orderId = order.id,
                amount = order.totalAmount,
                method = command.paymentMethod
            )
        )

        if (!payment.isSuccessful) {
            order.failPayment(payment.failureReason)
        } else {
            order.completePayment(payment.paymentId)
        }

        return OrderResult.from(order)
    }
}

5. Domain Model: 상태와 규칙의 주인

class Order private constructor(
    val id: Long,
    val memberId: Long,
    val productId: Long,
    val quantity: Int,
    val totalAmount: Money
) {
    var status: OrderStatus = OrderStatus.CREATED
        private set

    fun completePayment(paymentId: String) {
        check(status == OrderStatus.CREATED) {
            "결제 가능한 주문 상태가 아닙니다."
        }

        status = OrderStatus.PAID
    }

    fun failPayment(reason: String?) {
        check(status == OrderStatus.CREATED)

        status = OrderStatus.PAYMENT_FAILED
    }

    companion object {
        fun create(
            memberId: Long,
            productId: Long,
            quantity: Int,
            unitPrice: Money
        ): Order {
            require(quantity > 0) { "수량은 1 이상이어야 합니다." }

            return Order(
                id = 0L,
                memberId = memberId,
                productId = productId,
                quantity = quantity,
                totalAmount = unitPrice * quantity
            )
        }
    }
}

Controller나 Service가 직접 다음처럼 상태를 조작하지 않도록 합니다.

order.status = OrderStatus.PAID // 지양

대신 도메인 메서드를 사용합니다.

order.completePayment(paymentId)

그러면 상태 변경 규칙이 Order 내부에 모이고, 잘못된 상태 전이를 방지할 수 있습니다.

6. 결제 연동은 인터페이스로 분리

외부 결제사에 직접 의존하지 않고 Port를 정의합니다.

interface PaymentProcessor {
    fun pay(command: PaymentCommand): PaymentResult
}

결제 구현체는 Infrastructure 계층에 둡니다.

@Component
class TossPaymentAdapter(
    private val tossClient: TossClient
) : PaymentProcessor {

    override fun pay(command: PaymentCommand): PaymentResult {
        val response = tossClient.pay(
            orderId = command.orderId.toString(),
            amount = command.amount.value,
            method = command.method
        )

        return PaymentResult(
            isSuccessful = response.success,
            paymentId = response.paymentKey,
            failureReason = response.failureReason
        )
    }
}

Application Service는 Toss인지, 다른 결제사인지 알 필요가 없습니다.

class CreateOrderService(
    private val paymentProcessor: PaymentProcessor
)

결제사를 바꾸거나 테스트용 결제 구현체를 주입하기 쉬워집니다.

7. SOLID 적용

S — 단일 책임 원칙

클래스 하나는 하나의 변경 이유만 가져야 합니다.

class OrderController       // HTTP 처리
class CreateOrderService    // 주문 생성 유스케이스
class TossPaymentAdapter    // Toss API 연동
class Order                 // 주문 도메인 규칙
class OrderResponse         // 응답 표현

OrderController가 결제 API까지 호출하면 HTTP 변경과 결제 변경이 서로 영향을 줍니다.

O — 개방/폐쇄 원칙

새로운 결제 수단을 추가할 때 기존 주문 생성 로직을 수정하지 않도록 합니다.

interface PaymentProcessor {
    fun pay(command: PaymentCommand): PaymentResult
}

@Component
class CardPaymentProcessor : PaymentProcessor {
    override fun pay(command: PaymentCommand): PaymentResult {
        // 카드 결제
    }
}

@Component
class KakaoPayProcessor : PaymentProcessor {
    override fun pay(command: PaymentCommand): PaymentResult {
        // 카카오페이 결제
    }
}

여러 구현체를 선택해야 한다면 명시적인 Factory를 둘 수 있습니다.

@Component
class PaymentProcessorFactory(
    processors: List<PaymentProcessor>
) {
    private val processorMap = processors.associateBy { it.method() }

    fun get(method: PaymentMethod): PaymentProcessor =
        processorMap[method]
            ?: throw UnsupportedPaymentMethodException(method)
}

L — 리스코프 치환 원칙

인터페이스의 구현체는 기대되는 계약을 깨뜨리면 안 됩니다.

interface PaymentProcessor {
    fun pay(command: PaymentCommand): PaymentResult
}

모든 구현체는 다음을 보장해야 합니다.

  • 성공 시 성공 결과 반환
  • 실패 시 예외나 실패 결과를 일관된 방식으로 반환
  • 금액을 임의로 변경하지 않음
  • 호출자가 구현체별 특수 동작을 알 필요가 없음

I — 인터페이스 분리 원칙

사용하지 않는 기능까지 포함한 거대한 인터페이스를 만들지 않습니다.

interface ProductReader {
    fun getById(id: Long): Product
}

interface ProductStockManager {
    fun decreaseStock(productId: Long, quantity: Int)
}

조회만 필요한 서비스가 저장과 삭제 기능까지 의존하지 않게 됩니다.

class ProductQueryService(
    private val productReader: ProductReader
)

D — 의존성 역전 원칙

상위 정책이 구체적인 기술 구현에 직접 의존하지 않도록 합니다.

CreateOrderService
        ↓
PaymentProcessor
        ↑
TossPaymentAdapter

Application Service는 TossClient, JpaRepository 같은 기술에 직접 의존하지 않고 추상화된 Port에 의존합니다.

8. 조회 로직은 별도 Query Service로 분리

쓰기 유스케이스와 조회 유스케이스의 요구사항은 다릅니다.

@Service
@Transactional(readOnly = true)
class OrderQueryService(
    private val orderReader: OrderReader
) {
    fun getOrder(orderId: Long): OrderDetailResult {
        return OrderDetailResult.from(orderReader.getById(orderId))
    }
}
@RestController
@RequestMapping("/orders")
class OrderQueryController(
    private val orderQueryService: OrderQueryService
) {
    @GetMapping("/{orderId}")
    fun get(@PathVariable orderId: Long): OrderDetailResponse {
        return OrderDetailResponse.from(
            orderQueryService.getOrder(orderId)
        )
    }
}

조회 성능을 위해 QueryDSL, 전용 조회 DTO, 읽기 전용 Repository를 사용하더라도 Controller에는 노출하지 않습니다.

9. 실무에서 권장하는 기준

다음 질문에 “예”라고 답할 수 있으면 구조가 건강한 편입니다.

  • Controller를 단위 테스트할 때 결제 서버가 필요 없는가?
  • 결제사를 교체해도 Application Service를 거의 수정하지 않는가?
  • 주문 상태 변경 규칙이 Order 내부에 있는가?
  • 입력 형식 검증과 비즈니스 검증이 구분되어 있는가?
  • 조회와 변경 로직이 분리되어 있는가?
  • 외부 API, DB 구현체가 Domain에 침투하지 않는가?
  • 실패 응답 형식이 전역적으로 일관적인가?

핵심적으로는 다음 구조를 목표로 하면 됩니다.

Controller
  = HTTP 입출력 조정

Application Service
  = 유스케이스 실행과 트랜잭션 경계

Domain
  = 핵심 비즈니스 규칙과 상태 변경

Repository / Payment Port
  = 외부 의존성의 추상화

Infrastructure Adapter
  = DB, 결제사, 메시지 브로커의 실제 구현

Controller는 얇게 유지하고, 유스케이스 흐름은 Application Service에, 상태와 불변식은 Domain Model에, 외부 기술 연동은 Adapter에 배치하면 MVC와 SOLID를 함께 적용할 수 있습니다.

같은 분류의 프롬프트

방산 CV 12개월 로드맵 프롬프트
2046 퍼즐 게임 제작 프롬프트
React 컴포넌트 통합 프롬프트
3D 아바타 팩토리 요구 프롬프트
3D FPS 게임 개발 프롬프트