ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Java] 절차 지향적인 자바 코드를 객체 지향적이게 바꾸기
    Java 2025. 12. 11. 01:49

    저번 글에서는 절차지향과 절차지향적으로 작성된 자바 코드를 알아보았습니다. 이번 글에서는 절차지향적인 자바 코드를 어떻게 객체지향적으로 바꾸는지 알아보겠습니다. 이 글에서 가장 중요한 키워드는  역할, 책임, 협력입니다.

     

    [Java] 절차지향적인 자바 코드

    Java, Kotlin 등과 같이 흔히 객체지향 프로그래밍 언어를 사용하더라도 무조건 객체지향적인 코드가 나오는 것은 아닙니다. 코드 작성자가 절차지향(procedure oriented)적인 사고를 가지고 있다면 절

    msj9965.tistory.com

     

     

    1. 책임의 이동: 함수(Function)에서 객체(Object)로

    지난 포스팅에서 우리는 서비스 레이어(OrderService) 안에 있는 함수들에 비즈니스 로직의 책임들이 집중되어 있고 OrderService가 모든 일을 도맡아 처리하는 '절차지향적 코드' 방식을 알아보았습니다. 이제 이 코드를 객체지향적으로 바꾸기 위해 가장 먼저 해야 할 일은 문법을 바꾸는 것이 아니라, '사고방식'을 바꾸는 것입니다.

    바로 책임(Responsibility)의 주체를 옮기는 것입니다.

    1) 절차지향의: 책임은 함수(메서드)에 있다

    절차지향 프로그래밍에서는 전체 기능을 프로시저(함수) 단위로 나누고, 각 책임을 프로시저에 할당합니다. 즉, 책임은 일련의 절차(Procedure) 그 자체입니다."

    • 주체: OrderService의 createOrder() 메서드
    • 관점: "주문을 완료하기 위해 내가 무엇을 순서대로 해야 하지?"
    • 동작 방식:
      1. 데이터를 가져온다. (Get)
      2. 데이터를 검사하고 조작한다. (Process)
      3. 데이터를 다시 저장한다. (Set)
    public void createOrder(Long memberId, Long productId) {
            System.out.println("\n============== [주문 프로세스 시작] ==============");
    
            // 1. [데이터 조회]
            System.out.println("[1. 데이터 조회] DB에서 회원과 상품 정보를 가져옵니다.");
            Member member = orderRepository.findMemberById(memberId);
            Product product = orderRepository.findProductById(productId);
    
            System.out.println("   -> 조회된 회원: " + member.getName() + " (잔액: " + member.getMoney() + ")");
            System.out.println("   -> 조회된 상품: " + product.getName() + " (가격: " + product.getPrice() + ", 재고: " + product.getStock() + ")");
    
            // 2. [검증 로직]
            System.out.println("[2. 유효성 검증] 재고와 잔액을 확인합니다.");
            if (product.getStock() <= 0) {
                throw new RuntimeException("!! 예외 발생: 재고가 부족합니다.");
            }
    
            if (member.getMoney() < product.getPrice()) {
                throw new RuntimeException("!! 예외 발생: 잔액이 부족합니다.");
            }
            System.out.println("   -> 검증 통과: 구매 가능한 상태입니다.");
    
            // 3. [비즈니스 로직 수행 및 상태 변경]
            System.out.println("[3. 데이터 조작] 서비스가 직접 객체의 값을 계산하고 변경(Setter)합니다.");
    
            // 재고 감소 처리
            int currentStock = product.getStock();
            int nextStock = currentStock - 1;
            product.setStock(nextStock); // 서비스가 주입
            System.out.println("   -> 상품 재고 변경: " + currentStock + "개 => " + nextStock + "개");
    
            // 잔액 차감 처리
            int currentMoney = member.getMoney();
            int nextMoney = currentMoney - product.getPrice();
            member.setMoney(nextMoney); // 서비스가 주입
            System.out.println("   -> 회원 잔액 변경: " + currentMoney + "원 => " + nextMoney + "원");
    
            // 4. [주문 생성 및 저장]
            System.out.println("[4. 결과 저장] 주문 정보를 생성하고 DB에 반영합니다.");
            Long orderId = orderRepository.generateOrderId();
            Order order = new Order(orderId, member.getId(), product.getId(), "COMPLETED");
    
            orderRepository.save(order);
    
            System.out.println("============== [주문 프로세스 종료] ==============\n");
            System.out.println("최종 주문 결과: Order ID=" + orderId + ", Status=" + order.getStatus());
        }
    • 문제: 데이터(Product, Member)는 그저 값을 담아두기 위한 수동적인 객체일 뿐, 비즈니스 로직 처리에 대한 아무런 책임도 없습니다. 모든 책임은 이 절차의 핵심인 함수(Service Method)가 가집니다.

    2) 객체지향의: 책임은 객체에 있다

    객체지향 프로그래밍에서 '책임'은 협력하는 객체(Object)들의 능력입니다.

    • 주체: Product, Member, Order 객체들
    • 관점: "이 일을 하기 위해 누구에게 부탁해야 하지?"
    • 동작 방식:
      1. 서비스는 전체적인 흐름만 조율합니다.
      2. 구체적인 작업에 대한 책임은 데이터를 가지고 있는 객체에게 위임합니다.
    • 변화:
      • "재고를 가져와서 0보다 작은지 검사해" (X) -> "재고가 충분한지 확인해줘" (O)
      • "돈을 가져와서 가격만큼 뺀 다음 다시 저장해" (X) -> "결제해줘" (O)

    3) TDA (Tell, Don't Ask) 원칙 

    책임을 함수에서 객체로 옮길 때 가장 유용한 원칙이 바로 "Tell, Don't Ask"입니다.

    • Ask (절차지향): 객체의 상태를 묻고(get), 결정은 내가 한다.
      // Service가 묻고 판단함
      if (member.getMoney() < price) { ... } 
      
    • Tell (객체지향): 객체에게 원하는 작업을 하도록 시킨다(method call). 판단은 객체가 한다.
      // Service는 명령만 내림 (어떻게 하는지는 Member가 알아서 함)
      member.pay(price); 
      

    4) 책임 할당의 기준: 정보 전문가 패턴 (Information Expert)

    그렇다면 어떤 책임을 누구에게 줘야 할까요? 이를 결정하는 가장 쉬운 기준은 '정보 전문가 패턴(Information Expert)'입니다.

    "해당 기능을 수행하는 데 필요한 정보를 가장 많이 가지고 있는 객체에게 책임을 할당하라."

    • 재고 감소의 책임: 재고 데이터(stock)를 가진 Product에게 할당.
    • 잔액 차감의 책임: 잔액 데이터(money)를 가진 Member에게 할당.

    이렇게 책임을 이동시키면, 데이터가 있는 곳에 로직이 함께 존재하게 됩니다. 즉, 이를 통해 높은 응집도(High Cohesion)를 가지는 코드가 됩니다.

     

    2. 객체지향적인 코드로 리팩토링(캡슐화)

    우리는 지금부터 OrderService에 집중되어 있던 거대한 로직들을 각 도메인 객체(Member, Product)로 분산시켜 보겠습니다. 이 변화가 코드에 어떤 의미를 주는지 3가지 핵심 관점에서 정리해 보겠습니다.

    Product.java (상품 객체) & Member.java (회원 객체)

    가장 큰 변화는 데이터 담는 역할만 하던 객체들이 비즈니스 로직을 처리할 수 있는 능동적인 객체가 되었다는 점입니다.

    • [책임] 스스로 자신의 상태를 관리한다
      • 이전: 재고가 부족한지 검사하고, 수량을 줄이는 책임이 Service에 있었습니다.
      • 이후: 이제 Product가 "재고 감소(decreaseStock)"라는 책임을, Member가 "결제(pay)"라는 책임을 직접 집니다. 데이터가 있는 곳에 로직이 함께 위치하게 되어 응집도(Cohesion)가 높아졌습니다.
    • [캡슐화] Setter를 없애고 데이터를 보호한다
      • 이전: setStock(), setMoney()가 열려 있어 외부 어디서든 값을 바꿀 수 있었습니다. 이는 데이터가 불안정하다는 뜻입니다.
      • 이후: Setter를 제거하고, decreaseStock(), pay()와 같이 의도가 명확한 메서드만 외부에 노출했습니다. 이제 외부에서는 객체 내부의 데이터가 어떻게 조작되는지 알 필요도 없고, 알 수도 없습니다. 이것이 바로 완벽한 캡슐화(Encapsulation)입니다.
    import lombok.Getter;
    import lombok.AllArgsConstructor;
    
    @Getter
    @AllArgsConstructor
    public class Product {
        private Long id;
        private String name;
        private int price;
        private int stock;
    
        // [핵심] Setter 제거! -> 외부에서 마음대로 값을 바꿀 수 없음
    
        // [책임 할당] 재고 감소 로직
        public void decreaseStock(int quantity) {
            if (stock < quantity) {
                throw new IllegalArgumentException("재고가 부족합니다.");
            }
            this.stock -= quantity;
            System.out.println("[Product] 재고 차감 완료: " + (stock + quantity) + " -> " + stock);
        }
    }
     
    import lombok.Getter;
    import lombok.AllArgsConstructor;
    
    @Getter
    @AllArgsConstructor
    public class Member {
        private Long id;
        private String name;
        private int money;
    
        // [핵심] Setter 제거!
    
        // [책임 할당] 결제 로직
        public void pay(int amount) {
            if (money < amount) {
                throw new IllegalArgumentException("잔액이 부족합니다.");
            }
            this.money -= amount;
            System.out.println("[Member] 결제 완료: " + (money + amount) + " -> " + money);
        }
    }
    

    Order.java (주문 객체)

    변화: 정적 팩토리 메서드를 사용하여 생성의 의도를 명확히 합니다.


     
    import lombok.Getter;
    import lombok.AllArgsConstructor;
    
    @Getter
    @AllArgsConstructor
    public class Order {
        private Long id;
        private Long memberId;
        private Long productId;
        private String status;
    
        // 생성 로직을 캡슐화 (정적 팩토리 메서드)
        public static Order createOrder(Long orderId, Member member, Product product) {
            return new Order(orderId, member.getId(), product.getId(), "COMPLETED");
        }
    }
    

    OrderRepository.java (리포지토리)

    데이터 저장소 역할은 동일하므로 큰 변화는 없습니다. (편의상 코드는 유지합니다.)


     
    import org.springframework.stereotype.Repository;
    import java.util.HashMap;
    import java.util.Map;
    
    @Repository
    public class OrderRepository {
        private Map<Long, Member> members = new HashMap<>();
        private Map<Long, Product> products = new HashMap<>();
        private Map<Long, Order> orders = new HashMap<>();
        private long orderIdSequence = 1L;
    
        public OrderRepository() {
            members.put(1L, new Member(1L, "홍길동", 50000));
            products.put(1L, new Product(1L, "기계식키보드", 35000, 10));
        }
    
        public Member findMemberById(Long id) { return members.get(id); }
        public Product findProductById(Long id) { return products.get(id); }
        public void save(Order order) { orders.put(order.getId(), order); }
        public Long generateOrderId() { return orderIdSequence++; }
    }
    

    OrderService.java (서비스 - 핵심 변화)

    서비스 레이어는 더 이상 비즈니스 로직 관련 모든 책임을 혼자 가지는 요소가 아닙니다. 전체적인 비즈니스 흐름을 조율하고 적절한 객체에게 책임을 할당하는 역할을 합니다.

    • [책임] 로직 수행이 아닌, 흐름 제어
      • 이전: 데이터를 가져와서(Get), 비교하고(If), 계산하고(Calc), 넣는(Set) 구체적인 작업까지 모두 책임졌습니다.
      • 이후: 이제 서비스의 책임은 "어떤 순서로 객체들을 부를 것인가"에만 집중됩니다. 비즈니스 로직의 복잡성이 객체로 분산되었기 때문에, 서비스 코드는 매우 간결해졌습니다.
     
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    import lombok.RequiredArgsConstructor;
    
    @Service
    @RequiredArgsConstructor
    public class OrderService {
    
        private final OrderRepository orderRepository;
    
        @Transactional
        public void createOrder(Long memberId, Long productId) {
            // 1. 조회
            Member member = orderRepository.findMemberById(memberId);
            Product product = orderRepository.findProductById(productId);
    
            System.out.println("\n============== [객체지향적 주문 프로세스] ==============");
    
            // 2. [위임] 상품에게 재고 감소 요청 (Tell)
            // 서비스는 "재고가 몇 개인지, 어떻게 줄이는지" 알 필요가 없음. 그냥 줄이라고 시킴.
            product.decreaseStock(1);
    
            // 3. [위임] 회원에게 결제 요청 (Tell)
            // 서비스는 "잔액이 얼마인지, 뺄셈을 어떻게 하는지" 알 필요가 없음. 그냥 결제하라고 시킴.
            member.pay(product.getPrice());
    
            // 4. 주문 생성 및 저장
            Long orderId = orderRepository.generateOrderId();
            Order order = Order.createOrder(orderId, member, product);
            
            orderRepository.save(order);
            System.out.println("============== [주문 완료] Order ID: " + orderId + " ==============\n");
        }
    }
    

    AppRunner.java (실행)

    실행 코드는 외부에서 보기에 동일합니다. (이것이 캡슐화의 장점이기도 합니다.)


     
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.CommandLineRunner;
    import org.springframework.stereotype.Component;
    
    @Component
    public class AppRunner implements CommandLineRunner {
    
        @Autowired
        private OrderService orderService;
    
        @Override
        public void run(String... args) throws Exception {
            try {
                orderService.createOrder(1L, 1L);
            } catch (Exception e) {
                System.out.println("주문 실패: " + e.getMessage());
            }
        }
    }
    

     

    3. 역할과 구현의 분리, 그리고 다형성(Polymorphism)

    우리가 앞서 만든 객체지향 코드(Product, Member)는 훌륭하지만, 아직 한 가지 아쉬운 점이 있습니다. 바로 갈아 끼우기(Replaceable)가 어렵다는 점입니다. 이를 해결하기 위해 필요한 것이 역할과 구현의 분리, 그리고 이를 가능하게 하는 다형성입니다.

    1) 개념 정의: 역할(Role)과 구현(Implementation)

    세상에 있는 모든 사물을 '역할'과 '구현'으로 나누어 생각해 봅시다. 가장 쉬운 비유는 '연극(Drama)'입니다.

    • 역할 (Role): 대본에 적힌 배역(예: 로미오). 배우가 누구든 상관없이 수행해야 할 책임의 집합입니다.
    • 구현 (Implementation): 그 역할을 연기하는 실제 배우(예: 디카프리오, 무명의 연극배우). 역할을 실제로 어떻게 수행할지 결정하는 구체적인 존재입니다.

    이것을 자바 언어에 대입하면 다음과 같습니다.

    개념 자바 요소 설명
    역할 (Role) Interface (인터페이스) "무엇을(What) 해야 하는가"만 정의합니다. 구체적인 동작 코드는 없고 책임(메서드 껍데기)만 있습니다.
    구현 (Implementation) Class (클래스) "어떻게(How) 할 것인가"를 정의합니다. 인터페이스를 implements 하여 실제 로직(기능)을 채워 넣습니다.

    2) 다형성(Polymorphism)

    여기서 다형성이라는 핵심 개념이 등장합니다. 다형성이란 "하나의 역할(Interface)이 상황에 따라 다양한 구현체(Class)를 통해 실행되는 능력"을 말합니다.

    • 유연함: 클라이언트(요청자)는 "로미오 역할"하고만 대화하면 됩니다. 실제 그 안에 "디카프리오"가 있든 "조승우"가 있든, 클라이언트는 신경 쓸 필요가 없습니다.
    • 오버라이딩(Overriding): 자바에서는 부모 타입(Role)으로 변수를 선언하고, 자식 타입(Implementation)의 인스턴스를 넣어서 실행 시점에 실제 자식의 메서드가 호출되게 합니다.

    3) 책임은 '구현'이 아닌 '역할'에 할당하라

    진정한 객체지향 설계를 위해서는 책임을 구체적인 클래스가 아닌, 인터페이스(역할)에 할당해야 합니다.

    • 나쁜 예 (구현에 의존): "나는 고정 할인 정책만 쓸 거야."
      • OrderService가 FixDiscountPolicy라는 구체적인 클래스를 직접 알고 있으면, 나중에 '10% 할인'으로 정책이 바뀔 때 코드를 뜯어고쳐야 합니다. (다형성 활용 불가)
    • 좋은 예 (역할에 의존): "나는 '할인 정책'이라면 뭐든 상관없어. 할인만 해주면 돼."
      • OrderService는 DiscountPolicy라는 역할(인터페이스)에게 "할인해줘(책임)"라고 요청합니다.
      • 실제로 Fix가 동작할지 Rate가 동작할지는 실행 시점에 결정됩니다. 이것이 다형성의 이점입니다.

    4) 시나리오 예시: 할인 정책의 유연한 변경

    기존 코드에 '할인' 기능을 추가하되, 언제든지 정책을 바꿀 수 있도록 설계해 봅시다.

    1. 역할 정의 (Interface): DiscountPolicy
      • 책임: discount(Member member, int price) -> "할인된 금액을 계산해라"
    2. 구현 정의 (Polymorphism):
      • FixDiscountPolicy implements DiscountPolicy -> "1000원을 깎아준다"
      • RateDiscountPolicy implements DiscountPolicy -> "10%를 깎아준다"

    다형성의 이점

    "역할(Interface)에 책임을 할당하고 다형성을 활용하면, 우리는 기존 코드(OrderService)를 단 한 줄도 수정하지 않고도 프로그램의 동작(DiscountPolicy)을 완전히 바꿀 수 있습니다.

    이것이 바로 객체지향 설계가 추구하는 '유연하고 변경에 강한 소프트웨어'의 비밀입니다."

     

    4. 역할과 구현을 분리한 코드 예시

    1) 역할(Role) 정의: 인터페이스

    가장 먼저 할 일은 "할인을 해준다"라는 역할을 정의하고 책임을 할당하는 것입니다. 구체적인 방법은 나중에 생각하고, 역할(Interface)만 만듭니다.

     
    // [역할] 할인 정책 인터페이스
    // 책임: 회원의 등급이나 상품 가격을 보고 '할인할 금액'을 계산해서 알려준다.
    public interface DiscountPolicy {
        /**
         * @param member 회원 (등급 확인용 등)
         * @param price 상품 가격
         * @return 할인 대상 금액
         */
        int discount(Member member, int price);
    }
    

    2) 구현(Implementation) 정의: 구체적인 클래스들

    이제 위 인터페이스(DiscountPolicy)라는 역할을 수행할 구현체들을 만듭니다. 우리는 두개의 구현체를 준비합니다.

     

    고정 할인 정책 (1000원 깎아줌)

    public class FixDiscountPolicy implements DiscountPolicy {
        
        private int discountAmount = 1000; // 1000원 할인
    
        @Override
        public int discount(Member member, int price) {
            // 가격이 할인액보다 적으면 전액 할인 (0원으로 만듦)
            if (price < discountAmount) {
                return price;
            }
            return discountAmount;
        }
    }
    

     

    정률 할인 정책 (10% 깎아줌)

    public class RateDiscountPolicy implements DiscountPolicy {
    
        private int discountPercent = 10; // 10% 할인
    
        @Override
        public int discount(Member member, int price) {
            return price * discountPercent / 100;
        }
    }
    

    3) 클라이언트(Client): 역할에 의존하는 서비스

    이 부분이 가장 중요합니다. OrderService는 구체적인 클래스(Fix... or Rate...)를 전혀 모릅니다. 오직 DiscountPolicy라는 인터페이스(역할)만 바라봅니다.

    import org.springframework.stereotype.Service;
    import lombok.RequiredArgsConstructor;
    
    @Service
    @RequiredArgsConstructor
    public class OrderService {
    
        private final OrderRepository orderRepository;
        
        // [핵심] 구체적인 클래스(FixDiscountPolicy)가 아니라 인터페이스(DiscountPolicy)에 의존합니다.
        // 이것이 바로 'DIP(의존관계 역전 원칙)'를 지킨 것입니다.
        private final DiscountPolicy discountPolicy; 
    
        public void createOrder(Long memberId, Long productId) {
            Member member = orderRepository.findMemberById(memberId);
            Product product = orderRepository.findProductById(productId);
    
            // [다형성 활용]
            // discountPolicy에 무엇이 들어있든(Fix든 Rate든) 상관없이
            // "할인 금액 계산해줘"라는 메시지만 보내면 알아서 동작합니다.
            int discountPrice = discountPolicy.discount(member, product.getPrice());
            
            // 최종 결제 금액 계산
            int finalPrice = product.getPrice() - discountPrice;
    
            // 결제 (객체에게 위임)
            member.pay(finalPrice); 
            
            // ... (주문 생성 및 저장 로직 생략)
        }
    }
    

    4) 조립(Assembly): 설정 파일

    그렇다면 OrderService는 실제로 어떤 할인 정책을 쓸까요? 그것은 코드가 실행될 때 외부(스프링 설정 등)에서 주입(Injection)해줍니다.

     

     
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    public class AppConfig {
    
        // 여기서 어떤 할인 정책을 쓸지 결정합니다.
        // 나중에 정책을 바꾸고 싶으면 이 코드만 'RateDiscountPolicy'로 바꾸면 됩니다.
        // OrderService 코드는 단 한 줄도 고칠 필요가 없습니다! (OCP 준수)
        
        @Bean
        public DiscountPolicy discountPolicy() {
            // return new FixDiscountPolicy(); // 1000원 할인 정책 사용 시
            return new RateDiscountPolicy();   // 10% 할인 정책으로 변경 시
        }
    }
    

     

Designed by MSJ.