CS: 응집도와 결합도

hyeppy·2025년 9월 21일

CS

목록 보기
5/11
post-thumbnail

[10분 테코톡] 캉골의 좋은 코드를 작성하기 위한 기준


좋은 코드를 위해서는 높은 응집도와 낮은 결합도 방식을 추구해야 한다.


1. 응집도

1.1 응집도의 개념과 중요성

응집도(Cohesion)란?
하나의 모듈(클래스, 패키지, 메서드) 내부의 구성 요소들이 얼마나 밀접하게 관련되어 있는지를 나타내는 척도로, “함께 변경되어야 하는 것들이 실제로 함께 있는지?”를 측정하는 지표라고 할 수 있다.

높은 응집도의 예시: 피스톤, 실린더, 크랭크샤프트 등은 모두 자동차 엔진이라는 하나의 목적을 위한 부품

낮은 응집도의 예시: 다용도실은 청소용품, 운동기구, 계절용품이 뒤섞여 있어 특정 목적으로 사용하기 어려움

  • 소프트웨어에서 응집도가 중요한 이유
    • 변경의 국소화: 기능 변경 시 수정해야 할 코드가 한 곳에 모여 있어 찾기 쉬움
    • 오류 전파 방지: 한 부분의 수정이 예상치 못한 곳에 영향을 주지 않음
    • 이해도 향상: 관련 코드가 함께 있어 전체적인 흐름을 파악하기 쉬움
    • 재사용성 증대: 목적이 명확한 모듈은 다른 곳에서도 활용하기 쉬움

1.2 응집도의 7가지 수준 (낮은 순서부터) - 우논시절통순기

  1. 우연적 응집도: 아무런 관련성 없는 요소들이 한 모듈에 있는 상태
  2. 논리적 응집도: 비슷한 성격의 기능들을 모아둔 상태 (예: 모든 입출력 처리를 한 클래스에)
  3. 시간적 응집도: 같은 시점에 실행되는 기능들을 모아둔 상태 (예: 초기화 작업들)
  4. 절차적 응집도: 순차적으로 실행되는 기능들을 모아둔 상태
  5. 통신적 응집도: 같은 데이터를 사용하는 기능들을 모아둔 상태
  6. 순차적 응집도: 한 기능의 출력이 다른 기능의 입력이 되는 관계
  7. 기능적 응집도: 하나의 잘 정의된 기능을 수행하는 요소들로 구성 (가장 이상적)

1.3 계층형 vs 도메인형 아키텍처 비교

  • 계층형 구조의 한계
    com.example.ecommerce
    ├── controller
    │   ├── UserController
    │   ├── OrderController
    │   └── ProductController
    ├── service
    │   ├── UserService
    │   ├── OrderService
    │   └── ProductService
    ├── repository
    │   ├── UserRepository
    │   ├── OrderRepository
    │   └── ProductRepository
    └── entity
        ├── User
        ├── Order
        └── Product
    문제점:
    • 기능 추가 시 여러 계층을 수직으로 관통하며 파일들을 수정해야 함
    • 비즈니스 로직이 여러 계층에 흩어져 파악하기 어려움
    • 도메인 전문가와 개발자 간 의사소통에서 불일치 발생
  • 도메인형 구조의 장점
    com.example.ecommerce
    ├── domain
    │   ├── user
    │   │   ├── controller
    │   │   ├── service
    │   │   ├── repository
    │   │   ├── dto
    │   │   └── entity
    │   ├── order
    │   │   ├── controller
    │   │   ├── service
    │   │   ├── repository
    │   │   ├── dto
    │   │   └── entity
    │   └── product
    │       ├── controller
    │       ├── service
    │       ├── repository
    │       ├── dto
    │       └── entity
    └── shared
        ├── config
        ├── exception
        └── utils
    장점:
    • 의존 관계 파악 용이: 각 도메인의 경계가 명확하여 코드 간 의존성을 쉽게 파악할 수 있음
    • 기능 단위 수정: 사용자 관련 기능 변경 시 user 패키지만 확인하면 됨
    • 모듈 단위 삭제: 특정 기능이 불필요해지면 해당 도메인 패키지를 통째로 제거 가능
    • 팀 단위 개발: 각 도메인별로 팀을 나누어 독립적으로 개발할 수 있음
    • 비즈니스 도메인과 코드 구조 일치: 도메인 전문가와의 소통 원활

1.4 SOLID 원칙과 응집도의 관계

  • 단일 책임 원칙(SRP)
    • 클래스는 오직 하나의 책임만 가져야 한다.

    • 클래스가 변경되어야 하는 이유는 오직 하나여야 한다.

    • 한 클래스는 한 가지 일만 잘해야 한다.

      // 나쁜 예: 여러 책임이 혼재 (우연적 응집도)
      @Service
      public class UserService {
          public void createUser(UserDto userDto) { ... }
          public void sendEmail(String email) { ... }        // 이메일 발송 책임
          public void generateReport() { ... }               // 리포트 생성 책임
          public void validateUserData(UserDto userDto) { ... } // 검증 책임
          public void calculateTax() { ... }                 // 세금 계산 책임
      }
      
      // 좋은 예: 단일 책임으로 분리 (기능적 응집도)
      @Service
      public class UserService {
          private final EmailService emailService;
          private final ReportService reportService;
          private final UserValidator userValidator;
          
          public UserService(EmailService emailService, 
                            ReportService reportService,
                            UserValidator userValidator) {
              this.emailService = emailService;
              this.reportService = reportService;
              this.userValidator = userValidator;
          }
          
          public void createUser(UserDto userDto) {
              userValidator.validate(userDto);
              // 사용자 생성 로직만 담당
              User user = User.from(userDto);
              userRepository.save(user);
              emailService.sendWelcomeEmail(userDto.getEmail());
          }
      }
  • 메서드의 기능적 응집도
    // 나쁜 예: 하나의 메서드가 여러 일을 수행
    public void processUser(UserDto userDto) {
        // 검증
        if (userDto.getEmail() == null) throw new IllegalArgumentException();
        if (userDto.getName().length() < 2) throw new IllegalArgumentException();
        
        // 사용자 생성
        User user = new User();
        user.setName(userDto.getName());
        user.setEmail(userDto.getEmail());
        userRepository.save(user);
        
        // 이메일 발송
        String emailBody = "Welcome " + user.getName();
        emailService.send(user.getEmail(), "Welcome", emailBody);
        
        // 로깅
        logger.info("User created: " + user.getId());
        
        // 통계 업데이트
        statisticsService.incrementUserCount();
    }
    
    // 좋은 예: 각 메서드가 하나의 명확한 기능만 수행
    public void createUser(UserDto userDto) {
        validateUser(userDto);
        User user = buildUser(userDto);
        User savedUser = saveUser(user);
        sendWelcomeEmail(savedUser);
        logUserCreation(savedUser);
        updateStatistics();
    }
    
    private void validateUser(UserDto userDto) {
        if (userDto.getEmail() == null) throw new IllegalArgumentException("이메일은 필수입니다");
        if (userDto.getName().length() < 2) throw new IllegalArgumentException("이름은 2자 이상이어야 합니다");
    }
    
    private User buildUser(UserDto userDto) {
        return User.builder()
            .name(userDto.getName())
            .email(userDto.getEmail())
            .build();
    }
    
    private User saveUser(User user) {
        return userRepository.save(user);
    }
    
    private void sendWelcomeEmail(User user) {
        emailService.sendWelcomeEmail(user.getEmail());
    }
    
    private void logUserCreation(User user) {
        logger.info("User created: {}", user.getId());
    }
    
    private void updateStatistics() {
        statisticsService.incrementUserCount();
    }

1.5 검증 로직의 응집도 관리

검증의 계층별 분리
:
백엔드에서 검증은 목적에 따라 여러 계층으로 나누어 관리해야 한다. 입력 검증은 Presentation Layer에서 형식적 유효성을 검사하고, 비즈니스 검증은 Business Layer에서 도메인 규칙을 확인하며, 데이터 검증은 Persistence Layer에서 데이터 무결성을 보장한다. 각 계층이 자신의 책임에 맞는 검증만 담당하여 응집도를 높일 수 있다.

필드 단위 vs 객체 단위 검증
: 필드 단위 검증은 이메일 형식, 전화번호 패턴과 같은 개별 필드의 유효성을 검사하며 재사용성이 높다. 반면 객체 단위 검증은 “미성년자는 마케팅 동의 불가”, “VIP 고객은 특별 혜택 필수 선택” 같은 복합적인 비즈니스 규칙을 처리한다. 단순한 형식 검증과 복잡한 비즈니스 규칙을 분리하여 각각의 응집도를 높이는 것이 핵심이다.

Bean Validation의 선언적 접근
: Spring의 Bean Validation을 활용하면 검증 로직을 선언적으로 표현할 수 있어 코드의 가독성과 유지보수성이 향상된다. DTO 클래스에 @NotBlank, @Email, @Size 같은 어노테이션을 사용하여 검증 규칙을 데이터와 함께 응집시키면, 관련 정보가 한 곳에 모여 이해하기 쉬워진다.

도메인 모델 내부 검증
: 가장 중요한 것은 핵심 비즈니스 규칙을 도메인 모델 내부에 위치시키는 것이다. 예를 들어 주문 엔티티 내부에 “배송 완료된 주문은 취소 불가”, “주문 후 1시간 경과 시 취소 불가” 같은 규칙을 배치하여 데이터와 검증 로직의 응집도를 최대화한다. 이렇게 하면 비즈니스 규칙이 여러 곳에 흩어지지 않고 해당 도메인 객체와 함께 관리된다.


2. 결합도

2.1 결합도의 개념과 중요성

결합도(Coupling)란?
결합도는 서로 다른 모듈들 간에 얼마나 강하게 연결되어 있는지를 나타내는 척도로, “한 모듈의 변경이 다른 모듈에 얼마나 큰 영향을 미치는지”를 측정하는 지표로, 낮은 결합도를 유지하는 것이 좋은 소프트웨어 설계의 핵심이다.

높은 결합도의 예시: 일체형 데스크톱 컴퓨터의 경우 모니터가 고장나면 본체까지 교체해야 함

낮은 결합도의 예시: 조립식 컴퓨터의 경우 모니터, 키보드, 마우스를 독립적으로 교체 가능함

  • 소프트웨어에서 결합도가 중요한 이유
    • 변경 용이성: 한 모듈 변경 시 다른 모듈에 미치는 영향을 최소화
    • 테스트 용이성: 각 모듈을 독립적으로 테스트할 수 있음
    • 재사용성: 다른 모듈에 의존하지 않아 다른 프로젝트에서 재사용 가능
    • 병렬 개발: 서로 다른 팀이 독립적으로 개발할 수 있음

2.2 결합도의 6가지 수준 (높은 순서부터) - 내공외제스자

  1. 내용적 결합도: 한 모듈이 다른 모듈의 내부 구현(지역 변수, 내부 코드)을 직접 참조하거나 수정
  2. 공통 결합도: 전역 변수(Global variable)를 여러 모듈이 공유
  3. 외부 결합도: 외부에서 정의된 데이터 포맷, 통신 프로토콜, 인터페이스를 공유
  4. 제어 결합도: 한 모듈이 다른 모듈의 실행 흐름을 제어(플래그나 제어 변수를 넘김)
  5. 스탬프 결합도: 모듈 간에 자료 구조(객체, 배열, 구조체)를 전달하지만, 필요한 일부 필드만 사용
  6. 자료 결합도: 모듈 간에 필요한 데이터만 매개변수로 주고받음 → 가장 바람직

2.3 의존성 주입을 통한 결합도 관리

  • 의존성 역전 원칙 (Dependency Inversion Principle)
    • 고수준 모듈은 저수준 모듈에 의존해서는 안 된다.

    • 둘 다 추상화에 의존해야 한다.

    • 추상화는 구체적인 것에 의존해서는 안 된다.

      // 나쁜 예: 구체 클래스에 직접 의존
      @Service
      public class OrderService {
          private MySqlOrderRepository repository; // 구체 클래스에 의존
          private SmtpEmailService emailService;   // 구체 클래스에 의존
          
          public OrderService() {
              this.repository = new MySqlOrderRepository(); // 직접 생성
              this.emailService = new SmtpEmailService();   // 직접 생성
          }
      }
      
      // 좋은 예: 인터페이스에 의존
      @Service
      public class OrderService {
          private final OrderRepository repository; // 인터페이스에 의존
          private final EmailService emailService;  // 인터페이스에 의존
          
          public OrderService(OrderRepository repository, EmailService emailService) {
              this.repository = repository;
              this.emailService = emailService;
          }
      }
  • 의존성 주입의 세 가지 방식
    • 생성자 주입 (권장)
      @Service
      public class OrderService {
          private final PaymentService paymentService;
          private final EmailService emailService;
          
          // 불변성 보장, 필수 의존성 명시
          public OrderService(PaymentService paymentService, EmailService emailService) {
              this.paymentService = paymentService;
              this.emailService = emailService;
          }
      }
    • Setter 주입
      @Service
      public class UserService {
          private EmailService emailService;
          private AuditService auditService; // 선택적 의존성
          
          @Autowired
          public void setEmailService(EmailService emailService) {
              this.emailService = emailService;
          }
          
          @Autowired(required = false) // 선택적 의존성
          public void setAuditService(AuditService auditService) {
              this.auditService = auditService;
          }
      }
    • 필드 주입 (비권장)
      @Service
      public class ProductService {
          @Autowired
          private ProductRepository repository; // 테스트하기 어려움
          
          @Autowired
          private CategoryService categoryService; // 불변성 보장 안됨
      }

2.4 인터페이스를 통한 추상화

  • 인터페이스 분리 원칙
    // 나쁜 예: 큰 인터페이스 (인터페이스 오염)
    public interface PaymentService {
        PaymentResult processPayment(PaymentRequest request);
        void refundPayment(String paymentId);
        PaymentHistory getPaymentHistory(Long userId);
        void generatePaymentReport(LocalDate from, LocalDate to);
        void sendPaymentNotification(String email);
        void updatePaymentSettings(PaymentSettings settings);
    }
    
    // 좋은 예: 인터페이스 분리
    public interface PaymentProcessor {
        PaymentResult processPayment(PaymentRequest request);
        void refundPayment(String paymentId);
    }
    
    public interface PaymentHistoryService {
        PaymentHistory getPaymentHistory(Long userId);
    }
    
    public interface PaymentReportService {
        void generatePaymentReport(LocalDate from, LocalDate to);
    }
  • 전략 패턴을 통한 결합도 관리
    // 결제 전략 인터페이스
    public interface PaymentStrategy {
        PaymentResult processPayment(PaymentRequest request);
        boolean supports(PaymentMethod method);
    }
    
    // 구체적인 전략들
    @Component
    public class CreditCardPaymentStrategy implements PaymentStrategy {
        @Override
        public PaymentResult processPayment(PaymentRequest request) {
            // 신용카드 결제 로직
            return PaymentResult.success("신용카드 결제 완료");
        }
        
        @Override
        public boolean supports(PaymentMethod method) {
            return PaymentMethod.CREDIT_CARD.equals(method);
        }
    }
    
    // 컨텍스트 클래스
    @Service
    public class PaymentService {
        private final List<PaymentStrategy> paymentStrategies;
        
        public PaymentService(List<PaymentStrategy> paymentStrategies) {
            this.paymentStrategies = paymentStrategies;
        }
        
        public PaymentResult processPayment(PaymentRequest request) {
            PaymentStrategy strategy = paymentStrategies.stream()
                .filter(s -> s.supports(request.getPaymentMethod()))
                .findFirst()
                .orElseThrow(() -> new UnsupportedPaymentException("지원하지 않는 결제 방식"));
                
            return strategy.processPayment(request);
        }
    }

2.5 계층 간 결합도 관리

  • DTO를 활용한 계층 간 분리
    // 나쁜 예: Entity 직접 반환으로 높은 결합도
    @RestController
    public class UserController {
        @GetMapping("/users/{id}")
        public User getUser(@PathVariable Long id) {
            return userService.findById(id); // Entity 직접 반환
            // 문제: DB 스키마 변경이 API 응답에 직접 영향
        }
    }
    
    // 좋은 예: DTO를 통한 계층 분리
    @RestController  
    public class UserController {
        @GetMapping("/users/{id}")
        public UserResponse getUser(@PathVariable Long id) {
            User user = userService.findById(id);
            return UserResponse.from(user); // DTO 변환
            // 장점: DB 구조 변경이 API에 미치는 영향 최소화
        }
    }
    
    public class UserResponse {
        private Long id;
        private String name;
        private String email;
        private LocalDateTime createdAt;
        // password, 내부 시스템 필드 등은 제외
        
        public static UserResponse from(User user) {
            return UserResponse.builder()
                .id(user.getId())
                .name(user.getName())
                .email(user.getEmail())
                .createdAt(user.getCreatedAt())
                .build();
        }
    }

3. 좋은 코드를 위한 습관

구조적 개선은 모듈 간의 관계와 시스템 설계에 초점을 맞춘다면, 습관적 개선은 개별 코드 라인과 표현 방식에 집중한다. 둘 다 중요하지만, 습관적 개선은 매일의 코딩 과정에서 자연스럽게 적용할 수 있어 더욱 실용적이다.

다음 아티클은 좋은 코드를 위한 실용적인 습관에 대한 내용이다:

코드를 빛나게 하는 개발자의 작은 습관 7가지

1. 상수 대신 열거형(Enum) 사용

핵심: 의미를 명시적으로 표현하라

  • 숫자 상수(1, 2, 3) 대신 DiscountTier.GOLD처럼 의미가 명확한 열거형 사용
  • 컴파일러의 타입 안정성 보장과 코드 가독성을 동시에 확보

2. 설명 변수 활용

핵심: 함수 호출 결과를 바로 사용하지 말고 의미 있는 변수에 할당

  • logPurchase(getUserId(), getItemId())userId = getUserId(); logPurchase(userId, itemId)
  • 미래의 유지보수자(자신 포함)를 위한 배려

3. 구체적이고 자세한 에러 메시지

핵심: 스스로를 설명하는 에러는 절반은 해결된 것

  • "List index out of bounds" → "menuItemModifiers 리스트에서 인덱스 43을 조회했지만 42개 항목만 존재"
  • 디버깅 시간을 대폭 단축시키는 투자

4. 풍부한 로그 기록

핵심: 로그는 값싸지만 인사이트는 소중하다

  • '과하다' 싶을 정도로 상세하게 기록
  • 로그 레벨을 적극 활용하여 충분한 컨텍스트 정보 제공

5. 열었으면 반드시 닫기

핵심: 리소스 생성 즉시 해제 코드도 함께 작성

  • try-with-resources, try-finally 블록 활용
  • 메모리 누수와 리소스 고갈 방지

6. 계산 가능한 값은 저장하지 않기

핵심: 가공된 데이터는 시간이 지날수록 신뢰성을 잃는다

  • 데이터베이스에는 원시 데이터만 저장하고 총액, 합계 등은 실시간 계산
  • 데이터 불일치 문제를 원천 차단

7. 변경 가능성 최소화 (Immutable)

핵심: 불변 객체를 적극 활용

  • 예상치 못한 상태 변경으로 인한 버그 방지
  • final 키워드, readonly 속성 등을 통한 불변성 보장

응집도와 결합도 같은 구조적 개선도 중요하지만, 매일의 코드 작성 과정에서 이런 좋은 습관을 체계적으로 만들어가는 것이 더욱 현실적이고 효과적인 접근법이다. 작은 습관들이 쌓여 결국 탁월한 코드를 만들어낸다.


profile
Backend

0개의 댓글