개발자로서 살아남기

챌린지 배틀 회고 근데 이제 OOP와 도메인 주도 설계를 곁들인

코드 살인마 2025. 8. 1. 17:44
728x90

챌린지 배틀 회고 근데 이제 OOP와 도메인 주도 설계를 곁들인

1. 들어가며

작년 하반기에 헥사고날 아키텍처, 클린 아키텍처, TDD 등을 공부하면서 느낀 가장 큰 깨달음은:"이 모든 것이 결국 객체지향 프로그래밍(OOP)에 기반한다" 는 사실이었습니다.

기존에 데이터 중심, 절차지향 프로그래밍으로 프로젝트를 진행해오다가, 최근 챌린지 배틀 기능을 구현하면서 객체지향 관점으로 접근해보았습니다. 그 과정을 통해 느낀 점과 얻은 교훈을 공유하고자 합니다.


2. 절차지향 vs. 객체지향: 이론적 특징

2.1 절차지향 프로그래밍(Procedural)

  • 장점
    • 구현이 단순하고 직관적
    • 빠른 프로토타이핑 및 생산성 확보
    • 로직 흐름이 명확하게 보임
  • 단점
    • 코드 중복이 늘어나고 재사용성이 낮아짐
    • 요구사항 변경 시 유지보수 어려움
    • 복잡해질수록 가독성이 급격히 떨어짐

2.2 객체지향 프로그래밍(OOP)

  • 장점
    • 유지보수와 확장성이 우수
    • 응집도가 높아지고, 도메인 모델과 직관적으로 매핑 가능
    • 캡슐화를 통해 변경에 유연하게 대응 가능
  • 단점
    • 초기 설계와 학습에 상대적으로 많은 시간 투자
    • 설계자의 역량에 따라 품질이 크게 달라질 수 있음
    • 잘못된 객체 분리 시 오히려 복잡도가 증가

3. 왜 우리는 절차지향 프로그래밍을 하게 될까?

아래 예시는 OrderLineItem 엔티티를 JPA로 정의한 코드입니다.

@Entity
public class OrderEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<LineItemEntity> lineItems = new ArrayList<>();

    private double total;
}

@Entity
public class LineItemEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private double price;
    private int quantity;
}

3.1 절차지향 코드 예시

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public double applyDiscountProcedural(Long orderId) {
        // 1. OrderEntity 조회
        OrderEntity orderEntity = orderRepository.findById(orderId)
                .orElseThrow(() -> new IllegalArgumentException("해당 주문이 존재하지 않습니다."));

        // 2. 할인율
        double discountRate = 0.05; // 5% 할인

        // 3. 주문 총액 계산
        double originalTotal = 0.0;
        for (LineItemEntity lineItem : orderEntity.getLineItems()) {
            double itemTotal = lineItem.getPrice() * lineItem.getQuantity();
            originalTotal += itemTotal;
        }

        // 4. 할인가 계산
        double discountAmount = originalTotal * discountRate;
        double finalTotal = originalTotal - discountAmount;

        // 5. 엔티티에 최종 값 set
        orderEntity.setTotal(finalTotal);

        // 6. 저장
        orderRepository.save(orderEntity);

        return finalTotal;
    }
}

이처럼 DB 설계를 먼저 진행하고, 엔티티를 통해 데이터를 꺼내와( get ) 서비스 계층에서 로직을 구현합니다.주요 특징은 다음과 같습니다.

  • 데이터 중심 사고 에 의해 자연스레 절차지향적 흐름이 형성됨
  • 단순하고 빠르게 결과물을 만들 수 있지만, 비즈니스 로직이 서비스 계층에 몰림
  • 변경 및 확장 시(예: 할인정책 변경) 수정 범위가 커지고, 테스트도 어려워짐

3.2 객체지향 코드 예시

// 도메인 모델: Order
public class Order {
    private List<LineItem> lineItems;
    private double total;

    public Order(List<LineItem> lineItems) {
        this.lineItems = lineItems;
    }

    public double calculateFinalPrice(double discountRate) {
        double sum = 0.0;
        for (LineItem item : lineItems) {
            sum += item.calculateItemTotal();
        }
        double discount = sum * discountRate;
        return sum - discount;
    }

    public void updateTotal(double newTotal) {
        this.total = newTotal;
    }

    public double getTotal() {
        return total;
    }
}

// 도메인 모델: LineItem
public class LineItem {
    private double price;
    private int quantity;

    public LineItem(double price, int quantity) {
        this.price = price;
        this.quantity = quantity;
    }

    public double calculateItemTotal() {
        return price * quantity;
    }
}
@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public double applyDiscountUsingDomain(Long orderId) {
        // 1. 엔티티(OrderEntity) 조회
        OrderEntity orderEntity = orderRepository.findById(orderId)
                .orElseThrow(() -> new IllegalArgumentException("해당 주문이 존재하지 않습니다."));

        // 2. 엔티티를 도메인 객체로 변환
        List<LineItem> domainLineItems = orderEntity.getLineItems().stream()
                .map(item -> new LineItem(item.getPrice(), item.getQuantity()))
                .collect(Collectors.toList());

        // 3. 도메인 객체 활용
        Order order = new Order(domainLineItems);
        double discountRate = 0.05; // 5% 할인
        double finalPrice = order.calculateFinalPrice(discountRate);
        order.updateTotal(finalPrice);

        // 4. 결과를 엔티티에 반영 후 저장
        orderEntity.setTotal(order.getTotal());
        orderRepository.save(orderEntity);

        return finalPrice;
    }
}
  • 비즈니스 로직이 도메인(Order, LineItem) 내부로 옮겨짐
  • 객체 스스로 계산하고 상태를 변경
  • 테스트 시 도메인 객체만 테스트할 수 있어 단위 테스트가 훨씬 수월함

4. 챌린지 배틀: 객체지향 프로그래밍 사례

최근 진행한 "챌린지 배틀" 기능을 OOP로 구현하며 느낀 점을 공유합니다.

4.1 객체지향적 개발 프로세스

  1. 기획서 분석 및 도메인 설계
    • 요구사항을 바탕으로 도메인과 그 행위(비즈니스 로직)를 설계
  2. 테스트 코드 작성
    • 도메인의 핵심 로직이 의도대로 동작하는지 먼저 검증
  3. 도메인을 사용하여 구현(조합)
    • 애플리케이션 계층에서 도메인 객체를 조합하여 시나리오 구현
  4. DB 설계
    • 도메인 모델을 확정한 후, 필요한 테이블 및 스키마 설계

이 과정을 따르면 도메인 중심으로 사고하게 되므로,
DB 의존성이 낮아지고, 변경 및 확장에 더욱 유연해집니다.

4.2 챌린지 배틀 시나리오 예시: “스크래치 구매 로직”

  1. 현재 챌린지 게임 상태 확인
    • challengeGame 도메인 객체가 스스로 실행 가능 여부 판단
  2. 유저 정보 검증
    • challengeUser 도메인 객체에서 사용자 자격(뱃지, 재화 등) 체크
  3. 스크래치 사용 가능 여부 검증
    • challengeGameScratch 도메인이 특정 스크래치(실버/프리미엄 등) 사용 가능 여부 판단
  4. 보상 지급 처리
    • 스크래치 보상 계산, 교환 크레딧 잔액 확인, 골드 지급, 아바타 지급 등
  5. 데이터 업데이트 (DB 어댑터)
    • 유저 스크래치 사용 기록, 잔액 업데이트, 통계 반영
  6. 결과 전송 (Protocol)
    • 최종 결과 정보를 응답 패킷으로 전달

이렇게 도메인들이 각자의 책임을 갖고 메시지를 주고받도록 설계하면,
테스트가 용이하고, 새로운 스크래치 타입을 추가하거나 로직을 변경하는 일이 훨씬 간단해집니다.


5. 직접 OOP를 하며 느낀점

  1. 변경에 유연
    • 과거에는 DB 구조 수정이 큰 부담이었지만, 이제는 도메인 객체 변경으로 대응이 가능
  2. 확장성이 뛰어남
    • 예: 실버, 프리미엄 스크래치의 경우, 인터페이스 기반으로 확장 가능
  3. 협업에 용이
    • OOP 방식은 비즈니스 로직이 분리되어 있어, 여러 명이 동시 개발해도 충돌이 적고 이해가 쉬움
  4. 테스트 코드 작성 편의
    • 도메인 로직 자체를 단위 테스트로 검증하므로, 테스트 코드가 늘어나도 가독성과 유지보수가 용이
  5. 가독성 개선
    • 중첩 if-else가 크게 줄어들고, “메시지 전송” 중심으로 코드가 간결해짐
  6. 도메인 중심 사고
    • “너가 할 수 있는 일을 스스로 해결해줘”라고 각 도메인에 메시지를 보내는 방식이 재미있고 유의미했음
    • 다만, 초반 설계가 다소 어렵고 시간이 소요될 수 있음

추가로, 순수 자바로 만들어진 도메인 코드는 프레임워크에 의존되어있지 않기 때문에, 어디서나 사용가능
ex) 프레임워크가 사라진다면? , 특정 모듈이 fade-out 된다면? , 스프링 프레임워크보다 좋은 게 나왔다면?


기타

객체지향을 몸에 익히기 위한 2가지 규칙

이론적으로 객체지향을 이해했어도, 어느 순간 다시 절차지향적 습관으로 되돌아가는 경우가 많습니다.다음 2가지 규칙을 일상적으로 적용하면 자연스럽게 OOP 사고방식을 유지할 수 있습니다.

6.1 규칙 1: get, set을 절대 사용하지 않는다

  • Getter: 객체 내부 데이터를 직접 꺼내 쓰는 행위
  • Setter: 객체 상태를 강제로 바꾸는 행위

이는 객체의 캡슐화를 약화시키고, 로직이 외부로 분산되는 원인이 됩니다.그 대신 “객체에게 메시지를 보내기” 방식을 사용하세요.

// Bad (절차적)
order.setTotal(order.getTotal() - (order.getTotal() * discountRate));

// Good (객체지향적)
order.applyDiscount(discountRate);

6.2 규칙 2: 일급 컬렉션(First-Class Collection)을 적극 활용한다

  • 일급 컬렉션: 컬렉션(List, Set 등)을 감싼 클래스가 해당 컬렉션에 대한 로직을 스스로 책임지는 구조
  • 컬렉션 이외의 멤버 변수가 없고, 로직이 클래스 내부에 집중됨

예: 주문 항목(LineItem) 리스트를 직접 순회하며 로직을 만드는 대신,일급 컬렉션(LineItems)이 스스로 합계 계산 등을 담당합니다.

// 절차적 (Bad)
List<LineItem> lineItems = order.getLineItems();
double total = 0;
for (LineItem item : lineItems) {
    total += item.getPrice() * item.getQuantity();
}

// 일급 컬렉션 (Good)
public class LineItems {
    private List<LineItem> items;

    public LineItems(List<LineItem> items) {
        this.items = items;
    }

    public double calculateTotal() {
        return items.stream()
                    .mapToDouble(LineItem::calculateItemTotal)
                    .sum();
    }
}


감사합니다.