+ 계층별 책임과 조회·결제 분리를 구체화했다.
- 예시 간 인터페이스 불일치와 과도한 분량이 있다.
언어, 프레임워크, 관심 컴포넌트를 넣으면 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]는 답이 없다.
+ 계층별 책임과 조회·결제 분리를 구체화했다.
- 예시 간 인터페이스 불일치와 과도한 분량이 있다.
+ 리팩터링 전후와 SOLID 적용이 선명하다.
- 결제 실패 상태가 롤백되며 ISP 적용도 모순된다.
| 기준 | ChatGPT | Gemini | 앞선 모델 |
|---|---|---|---|
| 지시 준수 | 9 | 9 | 동점 |
| 정확성 | 7 | 6 | ChatGPT +17% |
| 구체성 | 9 | 9 | 동점 |
| 구성·가독성 | 9 | 8 | ChatGPT +13% |
| 분량 적정성 | 5 | 6 | Gemini +20% |
gpt-5.6-sol가 모델 이름을 가린 채 1~10점으로 채점했습니다 (2026-09-24). 측정값이 아니라 AI 심사 결과입니다.
같은 입력을 세 모델에 넣어 받은 답을 그대로 옮겼습니다. 각 CLI(에이전트 하네스)에서 실행했습니다. ChatGPT·Claude 앱이나 웹에서는 답이 다를 수 있습니다.
[붙여 넣은 자료] 제가 쓰는 스택은 Kotlin + Spring Boot입니다. 특히 Controller 계층이 검증, 조회, 결제 로직까지 다 떠안고 있어서 SOLID 원칙에 맞게 어떻게 나눠야 할지 예시로 보여주세요.
Kotlin + Spring Boot에서 Controller가 검증, 조회, 결제까지 모두 담당한다면 다음처럼 역할을 분리하는 것이 좋습니다.
Controller는 “무엇을 할지” 결정하기보다, 요청을 받아 적절한 애플리케이션 서비스를 호출하는 진입점이어야 합니다.
HTTP 요청
↓
Controller
↓
Application Service
↓
Domain Model ── Repository Port
↓ ↓
결과 DB Adapter
Application Service ── Payment Port
↓
외부 결제 Adapter
@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)
}
}
이 코드는 다음 책임이 한곳에 섞여 있습니다.
order/
├── presentation/
│ ├── OrderController.kt
│ ├── CreateOrderRequest.kt
│ └── OrderResponse.kt
├── application/
│ └── CreateOrderService.kt
├── domain/
│ ├── Order.kt
│ └── OrderRepository.kt
└── infrastructure/
├── JpaOrderRepository.kt
└── TossPaymentAdapter.kt
@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가 담당할 일은 다음 정도가 적절합니다.
다음 로직은 Controller에 두지 않는 것이 좋습니다.
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("잘못된 요청입니다."))
}
}
“상품이 존재하는가”, “재고가 충분한가”, “결제 가능한 주문인가”는 단순한 입력 검증이 아니라 비즈니스 규칙입니다.
@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)
}
}
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 내부에 모이고, 잘못된 상태 전이를 방지할 수 있습니다.
외부 결제사에 직접 의존하지 않고 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
)
결제사를 바꾸거나 테스트용 결제 구현체를 주입하기 쉬워집니다.
클래스 하나는 하나의 변경 이유만 가져야 합니다.
class OrderController // HTTP 처리
class CreateOrderService // 주문 생성 유스케이스
class TossPaymentAdapter // Toss API 연동
class Order // 주문 도메인 규칙
class OrderResponse // 응답 표현
OrderController가 결제 API까지 호출하면 HTTP 변경과 결제 변경이 서로 영향을 줍니다.
새로운 결제 수단을 추가할 때 기존 주문 생성 로직을 수정하지 않도록 합니다.
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)
}
인터페이스의 구현체는 기대되는 계약을 깨뜨리면 안 됩니다.
interface PaymentProcessor {
fun pay(command: PaymentCommand): PaymentResult
}
모든 구현체는 다음을 보장해야 합니다.
사용하지 않는 기능까지 포함한 거대한 인터페이스를 만들지 않습니다.
interface ProductReader {
fun getById(id: Long): Product
}
interface ProductStockManager {
fun decreaseStock(productId: Long, quantity: Int)
}
조회만 필요한 서비스가 저장과 삭제 기능까지 의존하지 않게 됩니다.
class ProductQueryService(
private val productReader: ProductReader
)
상위 정책이 구체적인 기술 구현에 직접 의존하지 않도록 합니다.
CreateOrderService
↓
PaymentProcessor
↑
TossPaymentAdapter
Application Service는 TossClient, JpaRepository 같은 기술에 직접 의존하지 않고 추상화된 Port에 의존합니다.
쓰기 유스케이스와 조회 유스케이스의 요구사항은 다릅니다.
@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에는 노출하지 않습니다.
다음 질문에 “예”라고 답할 수 있으면 구조가 건강한 편입니다.
Order 내부에 있는가?핵심적으로는 다음 구조를 목표로 하면 됩니다.
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 게임 개발 프롬프트 |