기본기 강화 — 객체지향 설계 원칙(SOLID)과 디자인 패턴을 실무 코드에 어떻게 적용했는지 설명할 수 있어야 합니다

위 문장 은 *시니어 개발자 의 *면접관 의 평가 기준. 또는 내가 *내 코드 를 *PR 으로 *제출 할 때 *동료 가 보는 시선.

“SOLID 가 뭔가요?”암기 식 답 이 아니라 — “당신 의 코드 의 *어디 에 *어떻게 적용 했나”증명. 그게 *기본기 의 *진짜 깊이.

이 글은 SOLID 5 원칙주요 디자인 패턴 들 (Strategy / Observer / Command / Template Method / Repository / Specification / Outbox)내 *settlement 시스템 의 *실제 코드 와 함께 *풀어 낸다. 추상 적 설명 이 아니라 내가 *왜 그렇게 짰는지증명.

객체지향 의 *역할 · 책임 · 협력*바로 다음 글. 역할 / 책임 / 협력철학 이면 SOLID + 패턴그 *철학 의 *코드 의 문법.


TL;DR — 한 줄 결론

SOLID 의 5 원칙변경 의 비용 을 *낮추는 *5 가지 의 방향. 디자인 패턴그 방향 의 *반복 적 정답 의 *카탈로그. 둘 다 *외우는 게 아니라 *적용 한 사례 로 *체화 해야 기본기. 내 *settlement 의 *SellerTier · PaymentProcessor · Outbox · Read-only Projection · Chunk-Reader그 *각각 의 살아있는 사례. ArchUnit 으로 *컴파일러 처럼 강제 까지 도 — 기본기 의 *완성 의 신호.


Part 1. SOLID — *5 원칙 의 실무 적용**

1. S — Single Responsibility Principle (SRP)

1.1 원칙

“한 클래스 는 *변경 의 이유 가 *오직 하나”. 책임 의 분리.

1.2 내 settlement 의 *분리**

어제 의 글 에서 본 Settlement / Payout / Ledger 의 분리 :

// === SRP 위반 — *돈 의 모든 일* 을 한 클래스 ===
class MoneyManager {
    fun calculateSettlement() { ... }
    fun processPayout() { ... }
    fun postLedgerEntry() { ... }
    fun reconcilePG() { ... }
    fun generatePdfReport() { ... }
    fun handleChargeback() { ... }
}
// → 6,000 줄 의 *God Class*. *어디 만 *변경 해도 *나머지 영향*

// === SRP 준수 — *책임 별 분리* ===
class Settlement { fun calculate(); fun confirm(); fun cancel() }
class Payout { fun start(); fun markCompleted(); fun markFailed() }
class Ledger { fun post(entry); fun balance() }
class PgReconciliation { fun reconcile() }
class SettlementReport { fun generatePdf() }
class Chargeback { fun open(); fun accept(); fun reject() }

1.3 증명 — *변경 의 영향 범위

“수수료 정책 변경”Settlement.calculate 만 수정. Payout / Ledger / Chargeback 영향 0. 그게 *SRP 의 *진짜 가치.


2. O — Open/Closed Principle (OCP)

2.1 원칙

“확장 에는 열려있고 *변경 에는 닫혀있다”. 새 케이스 가 추가 되어도 *기존 코드 변경 없음.

2.2 내 settlement 의 *셀러 등급 별 수수료**

// === OCP 위반 — if/else 체인 ===
class Settlement {
    fun commissionRate(tier: String): BigDecimal {
        if (tier == "NORMAL") return "0.035".toBigDecimal()
        if (tier == "VIP") return "0.025".toBigDecimal()
        if (tier == "STRATEGIC") return "0.020".toBigDecimal()
        // *새 등급 추가 할 때마다 *이 메서드 수정*
        throw IllegalArgumentException()
    }
}

// === OCP 준수 — Strategy + Sealed Interface ===
sealed interface SellerTier {
    val commissionRate: BigDecimal
    val defaultCycle: Int
    val holdback: HoldbackPolicy
    
    fun discountFor(amount: Money): Money
}

object Normal : SellerTier {
    override val commissionRate = "0.035".toBigDecimal()
    override val defaultCycle = 7
    override val holdback = HoldbackPolicy(rate = 0.30, days = 30)
}

object Vip : SellerTier {
    override val commissionRate = "0.025".toBigDecimal()
    override val defaultCycle = 3
    override val holdback = HoldbackPolicy(rate = 0.10, days = 14)
}

object Strategic : SellerTier {
    override val commissionRate = "0.020".toBigDecimal()
    override val defaultCycle = 1
    override val holdback = HoldbackPolicy(rate = 0.0, days = 0)
}

// 새 등급 *Platinum* 추가 시 — *새 object 만 추가*. *기존 코드 변경 0*

2.3 증명 — *PR 의 *변경 범위**

새 등급 Platinum 추가 PR:

  • Platinum.kt 추가
  • ✅ 등록 의 enum 또는 factory 한 줄
  • Settlement / Payout / Calculator 의 *코드 변경 0

30 줄 PR기능 추가 완성. OCP 의 *체감.


3. L — Liskov Substitution Principle (LSP)

3.1 원칙

“자식 클래스 는 *부모 의 *모든 약속 을 *지켜야 한다”. 부모 의 *약속 보다 *덜 보장 하면 *대체 불가.

3.2 내 settlement 의 *PaymentProcessor**

interface PaymentProcessor {
    /**
     * @return PaymentResult — *반드시 *PaymentId 보장*
     * @throws PaymentDeclinedException — *결제 거절*
     */
    fun process(payment: Payment): PaymentResult
}

// === LSP 준수 ===
class TossPaymentProcessor : PaymentProcessor {
    override fun process(payment: Payment): PaymentResult {
        return tossClient.charge(payment).toResult()  // *약속 지킴*
    }
}

class KakaoPaymentProcessor : PaymentProcessor {
    override fun process(payment: Payment): PaymentResult {
        return kakaoClient.charge(payment).toResult()  // *약속 지킴*
    }
}

// === LSP 위반 — *약속 보다 *덜 보장* ===
class MockPaymentProcessor : PaymentProcessor {
    override fun process(payment: Payment): PaymentResult {
        if (Math.random() < 0.5) return null!!  // ❌ null 반환 — 부모 약속 위반
        if (payment.amount > 100_000) throw RuntimeException()  // ❌ 예외 타입 다름
        return PaymentResult.success()
    }
}

3.3 *증명 — *호출 자 의 *기대 가 *유지 되는가**

Order.pay(processor)TossPaymentProcessor / KakaoPaymentProcessor 어느 것 으로 호출 되어도 동일 한 *오류 처리 / 결과 처리코드. 그게 *LSP 의 *증명.


4. I — Interface Segregation Principle (ISP)

4.1 원칙

“클라이언트 가 *사용 하지 않는 메서드 에 *의존 하지 않게”. 큰 인터페이스 의 분할.

4.2 내 settlement 의 *분할**

// === ISP 위반 — Repository 라는 이름 으로 *모든 것* ===
interface SettlementRepository {
    fun save(s: Settlement)
    fun findById(id: SettlementId): Settlement?
    fun findByPeriod(period: Period): List<Settlement>
    fun deleteAll()                          // *위험*. 사용자 가 *원하지 않음*
    fun generatePdf(id: SettlementId): ByteArray   // *책임 다른 일*
    fun reconcileAll()                       // *책임 다른 일*
}

// === ISP 준수 — *역할 별 분리* ===
interface SettlementWriter {
    fun save(s: Settlement)
}

interface SettlementReader {
    fun findById(id: SettlementId): Settlement?
    fun findByPeriod(period: Period): List<Settlement>
}

interface SettlementPdfGenerator {
    fun generate(s: Settlement): ByteArray
}

interface SettlementReconciler {
    fun reconcile()
}

// 클라이언트 는 *필요 한 것 만* 의존
class SettlementCreateService(
    private val writer: SettlementWriter,
    private val reader: SettlementReader,  // *PdfGenerator / Reconciler 안 봄*
) { ... }

4.3 *증명 — *Mock 의 *간결성**

테스트 시 SettlementCreateService 의 mock — writer + reader 만 mocking. PdfGenerator / Reconciler불필요 한 stub 없음.


5. D — Dependency Inversion Principle (DIP)

5.1 원칙

“상위 모듈 은 *하위 모듈 에 의존 하지 않는다. 둘 다 추상 에 의존. “

5.2 내 settlement 의 *Hexagonal Port**

K8s Watch-Reconcile 글객체지향 글바로 그 *Port-Adapter 구조.

// === DIP 위반 — 도메인 이 *JPA 에 직접 의존* ===
package github.lms.lemuel.settlement.domain

import org.springframework.data.jpa.repository.JpaRepository  // ❌ 도메인 → 인프라

class SettlementService {
    @Autowired lateinit var jpaRepo: JpaSettlementRepository
    fun create(...) { jpaRepo.save(...) }
}

// === DIP 준수 — 도메인 이 *Port 에 의존*, Adapter 가 *Port 구현* ===
// domain/application/port/out/
interface SettlementRepository {
    fun save(s: Settlement)
    fun findById(id: SettlementId): Settlement?
}

// adapter/out/persistence/
@Repository
class JpaSettlementRepositoryAdapter(
    private val jpaRepo: SpringDataSettlementRepository,
) : SettlementRepository {
    override fun save(s: Settlement) {
        jpaRepo.save(s.toEntity())
    }
    override fun findById(id: SettlementId): Settlement? =
        jpaRepo.findById(id.value).orElse(null)?.toDomain()
}

5.3 *증명 — *ArchUnit 으로 *컴파일러 처럼 강제**

settlementArchitectureTest.kt :

@Test
fun `domain  adapter  참조 하면  된다`() {
    noClasses()
        .that().resideInAPackage("..domain..")
        .should().dependOnClassesThat()
        .resideInAPackage("..adapter..")
        .check(classes)
}

@Test
fun `application  JPA  직접 import 하면  된다`() {
    noClasses()
        .that().resideInAPackage("..application..")
        .should().dependOnClassesThat()
        .resideInAPackage("javax.persistence..", "jakarta.persistence..", 
                          "org.springframework.data.jpa..")
        .check(classes)
}

PR 마다 *CI 가 위 룰 검증. DIP 가 *문서 가 아니라 *컴파일러 의 검증. 기본기 의 *완성 의 신호.


Part 2. 디자인 패턴 — *실무 적용**

6. Strategy — *알고리즘 의 교체**

6.1 위 의 *셀러 등급 이 그 자체**

Section 2.2 의 SellerTierStrategy 패턴 의 직접 예. Context (Settlement) 가 *Strategy (SellerTier) 의 선택 으로 런타임 행위 변경.

6.2 추가 사례 — *수수료 정책 변경**

interface CommissionPolicy {
    fun calculate(amount: Money, seller: Seller): Money
}

class FlatRatePolicy(val rate: BigDecimal) : CommissionPolicy { ... }
class TieredPolicy(val tiers: List<Tier>) : CommissionPolicy { ... }
class PromotionalPolicy(val basePolicy: CommissionPolicy, val discount: BigDecimal) : CommissionPolicy {
    override fun calculate(amount: Money, seller: Seller): Money =
        basePolicy.calculate(amount, seller) * (1 - discount)
}

프로모션 적용 = *PromotionalPolicy 로 감싸기. 기존 정책 코드 변경 0. OCP + Decorator 의 시너지.


7. Observer — *이벤트 의 *발행/구독**

7.1 내 settlement 의 *Outbox 가 *그 자체**

Outbox 패턴 글 에서 자세히. Observer 의 *분산 시스템 판.

class Payment {
    fun capture() {
        this.status = CAPTURED
        // *Observer 의 *알림* — 이벤트 발행
        DomainEventPublisher.publish(PaymentCaptured(this.id, this.amount))
    }
}

// Subscribers (Listener) — 자기 책임 만
@Component
class SettlementEventListener {
    @EventListener
    fun on(event: PaymentCaptured) {
        settlementService.createFrom(event)
    }
}

@Component
class RewardEventListener {
    @EventListener
    fun on(event: PaymentCaptured) {
        rewardService.grantPointsFor(event.userId, event.amount)
    }
}

Payment 는 *Settlement / Reward 의 존재 모름. 느슨한 결합. Open/Closed 까지 만족.

7.2 Outbox 의 *Observer 의 확장

같은 패턴분산 버전:

  • 도메인 이벤트 발행 → outbox_events 테이블 INSERT (동일 트랜잭션)
  • Poller 가 *비동기 로 *Kafka 발행
  • 다른 서비스 (settlement-service) 가 Kafka 구독

프로세스 의 경계 를 넘어 도 *Observer. MSA 의 *기본 통신 패턴.


8. Command — *행위 의 *객체화**

8.1 내 settlement 의 *사용**

// === Command 객체 ===
data class CreateSettlementCommand(
    val sellerId: SellerId,
    val period: Period,
    val payments: List<PaymentId>,
    val idempotencyKey: IdempotencyKey,
)

interface CreateSettlementUseCase {
    fun create(command: CreateSettlementCommand): SettlementId
}

// === 이점 ===
// 1. *재시도 가능* — Command 를 *큐* 에 넣었다 *다시 꺼냄*
// 2. *멱등성 보장* — idempotencyKey 가 *Command 의 일부*
// 3. *직렬화 가능* — JSON 변환 → Kafka 발행
// 4. *테스트 용이* — Command 객체 만 만들면 됨

8.2 Outbox 의 *Command 의 직렬화

outbox_events 의 *각 row직렬화 된 Command. poller 가 *deserialize → 재실행. failure 시 *재시도 의 기반.


9. Template Method — *불변 한 흐름 + 가변 한 step**

9.1 내 settlement 의 *Spring Batch**

abstract class SettlementBatchStep<T> {
    fun execute() {
        // *불변 한 흐름*
        val items = read()
        val processed = items.map { process(it) }
        write(processed)
        log()
    }
    
    protected abstract fun read(): List<T>
    protected abstract fun process(item: T): T
    protected abstract fun write(items: List<T>)
}

// === 각 step 이 *가변 부분 만* 구현 ===
class MonthlySettlementStep : SettlementBatchStep<Payment>() {
    override fun read() = paymentRepository.findCapturedByMonth(month)
    override fun process(p: Payment) = settlementCalculator.from(p)
    override fun write(items: List<Settlement>) = settlementRepository.saveAll(items)
}

공통 흐름 의 *중복 제거 + 각 step 의 *변동 부분 만 *명시.


10. Repository — *영속성 의 *추상화**

10.1 DDD 의 *기본 패턴**

[위 Part 1 의 *DIP 의 예시 * 가 *Repository 패턴 의 *Settlement 적용. 도메인 이 *영속성 의 *세부 (JPA / MongoDB / Redis) 를 모름.

10.2 Read-only Projection — *읽기 모델 의 분리**

settlement고유 패턴:

// settlement-service 가 order-service 의 코드 를 *import 하지 않고도*
// payments / orders 테이블 의 데이터 를 *읽음*

@Entity
@Immutable  // *읽기 전용*
@Table(name = "payments")
class SettlementPaymentReadModel {
    @Id val id: Long
    val sellerId: String
    val amount: BigDecimal
    val capturedAt: Instant
    // *order-service 의 *Payment Entity 와 *같은 테이블* 이지만 *독립 클래스*
}

interface SettlementPaymentReadModelRepository : Repository<SettlementPaymentReadModel, Long> {
    fun findBySellerIdAndCapturedAtBetween(sellerId: String, start: Instant, end: Instant): List<SettlementPaymentReadModel>
}

CQRS 의 *간이 형. 읽기 와 쓰기 의 *모델 분리. settlement 가 *order 에 *코드 의존 0.


11. Specification — *조건 의 *조합**

11.1 복잡 한 쿼리 의 *조합**

interface SettlementSpec {
    fun matches(s: Settlement): Boolean
    fun and(other: SettlementSpec) = SettlementSpec { matches(it) && other.matches(it) }
    fun or(other: SettlementSpec) = SettlementSpec { matches(it) || other.matches(it) }
}

class HighValueSpec(val threshold: Money) : SettlementSpec {
    override fun matches(s: Settlement) = s.amount >= threshold
}

class VipTierSpec : SettlementSpec {
    override fun matches(s: Settlement) = s.seller.tier == Vip
}

class DateRangeSpec(val start: LocalDate, val end: LocalDate) : SettlementSpec { ... }

// 조합
val complex = HighValueSpec(Money.of(1_000_000))
    .and(VipTierSpec())
    .and(DateRangeSpec(start, end))

settlements.filter { complex.matches(it) }

조건 의 *재사용 + 합성.


12. Outbox — *현대 MSA 의 핵심 패턴**

12.1 Observer + Command + Repository 의 *합성**

내 *Outbox 패턴 글전체 내용. 핵심:

@Transactional
fun capture(payment: Payment) {
    payment.status = CAPTURED
    paymentRepository.save(payment)
    
    // *동일 트랜잭션* 에서 *outbox 에 *이벤트 저장*
    outboxRepository.save(OutboxEvent(
        eventId = UUID.randomUUID(),  // *멱등 키*
        topic = "payment.captured",
        payload = PaymentCaptured(payment.id, payment.amount).toJson()
    ))
}

// *Poller 가 *비동기 로 *Kafka 발행*
@Scheduled(fixedDelay = 2000)
fun publish() {
    val events = outboxRepository.claimPending(batchSize = 100)  // FOR UPDATE SKIP LOCKED
    events.forEach { kafkaTemplate.send(it.topic, it.payload) }
    outboxRepository.markPublished(events)
}

패턴 의 합성Observer (이벤트) + Command (직렬화) + Repository (저장) + Template Method (poll 흐름). 현대 백엔드 의 *교과서.


Part 3. 적용 의 *깊이 — *증명 의 *5 단계**

13. 기본기 의 *5 단계 의 *체득**

단계 의미 신호
1. 암기 SOLID 의 5 글자 외움 면접 의 짧은 답
2. 식별 코드 보고 위반 여부 판단 코드 리뷰 에서 지적 가능
3. 적용 새 코드 작성 시 원칙 의식 PR 의 구조 가 명확
4. 합성 여러 원칙 + 패턴 의 조합 적 사용 Outbox 같은 *현대 패턴 자연 구사
5. 강제 ArchUnit / lint / CI 로 자동 검증 팀 전체 가 *기본기 의 *바닥 보장

settlement5 단계 까지 도달. ArchUnit 의 *3 가지 룰모든 PR 의 컴파일러 검증.


14. 면접 답변 의 *템플릿**

위 그림 의 평가 기준 에 대한 내 답변 의 구조:

# 1. SOLID 의 *체득 단계*
*5 가지 다 외우 고 있고*, *settlement 에서 *5 가지 모두 적용*.

# 2. 구체 적 예
- SRP — Settlement / Payout / Ledger 의 분리
- OCP — SellerTier 의 sealed interface 로 *새 등급 추가 시 *기존 코드 변경 0*
- LSP — PaymentProcessor 의 *Toss / Kakao 의 *대체 가능*
- ISP — Reader / Writer 의 분리. *Mock 의 *간결성*
- DIP — Hexagonal 의 *Port-Adapter*. *ArchUnit 으로 *컴파일러 처럼 강제*

# 3. 디자인 패턴
- Strategy — SellerTier
- Observer + Command + Repository → *Outbox 의 *합성*
- Template Method — Spring Batch 의 *Chunk-oriented*
- Repository + CQRS Read Model — *settlement ↔ order 의 *코드 의존 0*

# 4. *강제 메커니즘*
ArchUnit 의 *3 가지 룰**팀 전체 의 *바닥 보장*.

# 5. *지속 적 개선*
*매 PR 마다 *기본기 의 *체크리스트*. *나의 *시야 가 *AI 로 일부 위임 되어도 *남는 영역*.

15. 맺음 *— *기본기 의 *진짜 의미**

“SOLID 와 디자인 패턴 을 *실무 코드 에 *어떻게 적용 했는지 설명 할 수 있어야 한다”그림 의 *문장.

“설명 할 수 있어야 한다”진짜 의미 :

  • 암기 가 아니라 *내 코드 의 *구체 적 위치
  • 위반 의 *영향체감
  • 원칙 의 *조합 적 사용
  • 팀 의 *바닥 보장 의 *강제 메커니즘

“SRP 는 단일 책임 원칙 입니다” 의 답 — 암기 단계. “제 Settlement 의 *Payout 과 Ledger 의 분리 가 *SRP 의 예 이고, 수수료 변경 시 *Payout 영향 0 의 의미 이고, 그게 *ArchUnit 의 *…adapter.. → ..application.. 의존 차단 룰 까지* — 5 단계 의 *체득.

차이내 코드 의 *시니어 의 신호. 그리고 *AI 시대 에 *살아남는 *시야 의 *기반.

내일 내 PR 의 *코드 한 줄 한 줄위 5 가지 의 *어느 원칙 의 *어느 단계 의 적용 인가내가 *설명 할 수 있다면, 그게 *기본기.


부록 — 오늘 *3 분 안 에 할 수 있는 *3 가지**

  • 내 *최근 PR코드 가 *SRP 위반 인지 확인 (한 클래스 가 변경 이유몇 개 인가)
  • 내 *if/else 체인Strategy 패턴 으로 *대체 가능 한 곳 식별
  • 내 프로젝트 의 *ArchUnit 룰 이 *존재 하는가. 없다면 *3 가지 룰 부터 시작

3 가지 중 2 가지 가 *NO 면 — 기본기 의 *2~3 단계 에 머문 상태. 5 단계 로 가는 *오늘 의 *3 분.


관련 글