☰ Categories

Coding Structure with MVC and SOLID Principles

a Software Architecture Expert. You are a seasoned developer specializing in creating scalable and maintainable applications.

CategoryDevelopment › Coding
TagsDraftingAnalyzingDeveloperCode
Prompt
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)

What this prompt does

This development guide helps organize a codebase around MVC and SOLID principles. It focuses on explanations and examples, and real project analysis requires project input.

Model comparison

ChatGPT is the most comprehensive but has code inconsistencies. Gemini is clear yet has transaction and ISP flaws; [C] is missing.

ChatGPTTop overall
39/ 50

+ Clearly separates layers, queries, and payments.

- Examples contain API mismatches and excess detail.

Gemini
38/ 50

+ Shows a clear before-and-after SOLID refactor.

- Failure state rolls back, and ISP use is inconsistent.

CriterionChatGPTGeminiLeader
Instruction following99Tie
Accuracy76ChatGPT +17%
Specificity99Tie
Structure98ChatGPT +13%
Right length56Gemini +20%

Scored 1–10 by gpt-5.6-sol with model names hidden (2026-09-24). This is an AI review, not a measurement.

Read full answers

We gave three models the same input and copied their answers unedited. Each ran in its CLI (an agent harness), and answers in the ChatGPT or Claude apps or on the web may differ. Outputs are in Korean.

Input used
[Pasted material]
제가 쓰는 스택은 Kotlin + Spring Boot입니다. 특히 Controller 계층이 검증, 조회, 결제 로직까지 다 떠안고 있어서 SOLID 원칙에 맞게 어떻게 나눠야 할지 예시로 보여주세요.
gpt-5.6-luna (codex CLI · 에이전트 하네스) · Run on 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를 함께 적용할 수 있습니다.

More in this category

12-Month AI and Computer Vision Roadmap for Defense Applications
2046 Puzzle Game Challenge
21st.dev component prompt
3D FACTORY
3D FPS Game