| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- 마이크로서비스아키텍처
- MSA
- ci/cd
- CS
- 아키텍처
- GCP
- Flyway
- GitHub Packages
- 도커
- 공통모듈
- 자바
- PostgreSQL
- 트러블슈팅
- Database
- 백엔드
- Java 8
- 마이그레이션
- dockercompose
- SpringCloud
- github actions
- 백엔드면접준비
- java
- springboot
- 컨테이너
- 인프라
- gradle
- 멀티모듈
- 분산시스템
- docker
- 마이크로서비스
- Today
- Total
NYO_O
응집도와 결합도, 좋은 코드가 지켜야 할 두 가지 원칙 (feat. Java) 본문
소프트웨어를 처음 만들 때는 동작만 하면 충분해 보입니다. 하지만 서비스가 성장하고, 팀원이 늘고, 요구사항이 바뀌기 시작하는 순간 코드의 구조가 얼마나 중요한지를 몸으로 느끼게 됩니다. 기능 하나를 수정했는데 전혀 관계없어 보이는 곳에서 버그가 터지고, 새 기능을 추가하려 했더니 손대야 할 파일이 열 개가 넘는 상황, 한 번쯤은 마주치게 되는 문제입니다.
이런 상황의 원인을 거슬러 올라가면 대부분 두 가지 개념과 맞닿아 있습니다. 바로 응집도(Cohesion) 와 결합도(Coupling) 입니다.
이 두 개념은 1979년 래리 콘스탄틴(Larry Constantine)과 에드워드 요든(Edward Yourdon)이 저서 Structured Design 에서 체계화한 이후, 수십 년이 지난 지금도 소프트웨어 설계의 핵심 척도로 쓰이고 있습니다. 오늘은 이 두 개념이 각각 무엇인지, 그리고 Java 코드에서 어떻게 구현할 수 있는지 정리해 보겠습니다.
응집도란 무엇인가
응집도는 하나의 모듈(클래스, 메서드, 패키지 등) 안에 있는 요소들이 얼마나 밀접하게 관련되어 있는지를 나타내는 척도입니다. 쉽게 말해, "이 클래스 안에 있는 것들이 모두 같은 목적을 향하고 있는가?"를 묻는 질문입니다.
응집도가 높다는 것은 클래스 내의 필드와 메서드들이 하나의 명확한 책임을 중심으로 뭉쳐 있다는 의미입니다. 반대로 응집도가 낮다는 것은 서로 관련 없는 기능들이 하나의 클래스에 뒤섞여 있다는 신호입니다.
핵심 기준: 클래스의 이름만 보고도 그 안에 어떤 내용이 있는지 예측할 수 있다면, 응집도가 높을 가능성이 큽니다.
응집도의 7단계 — 낮은 것부터 높은 것까지
콘스탄틴과 요든은 응집도를 7단계로 분류했습니다. 낮은 단계일수록 나쁜 설계에 가깝고, 높은 단계일수록 좋은 설계입니다.
1단계: 우연적 응집도 (Coincidental Cohesion) — 최악
모듈 안의 요소들 사이에 아무런 관계가 없습니다. 단순히 여러 기능을 하나의 클래스에 몰아넣은 상태입니다. Util, Helper, Common 같은 이름을 가진 클래스에서 자주 발견됩니다.
// 나쁜 예 — 아무 관련 없는 메서드들이 한 클래스에 모여 있음
public class MiscUtils {
public String formatDate(LocalDate date) { ... }
public double calculateTax(double price) { ... }
public void sendEmail(String to, String subject) { ... }
public List<String> parseCsv(String filePath) { ... }
}
2단계: 논리적 응집도 (Logical Cohesion)
비슷한 종류의 기능들을 묶어 놓았지만, 실제로 호출 시 어떤 기능을 실행할지 외부에서 파라미터로 결정합니다. 조건문으로 분기하는 구조가 특징입니다.
// 나쁜 예 — type 파라미터로 동작이 달라짐
public class Notifier {
public void notify(String message, String type) {
if (type.equals("email")) {
// 이메일 전송 로직
} else if (type.equals("sms")) {
// SMS 전송 로직
} else if (type.equals("push")) {
// 푸시 알림 로직
}
}
}
3단계: 시간적 응집도 (Temporal Cohesion)
특정 시점(초기화, 종료 등)에 함께 실행되어야 하기 때문에 묶인 경우입니다. 실행 시점이 같을 뿐, 기능들 간의 논리적 연관성은 낮습니다.
// 예 — 애플리케이션 시작 시점에 함께 호출될 뿐, 논리적 관계는 없음
public class AppInitializer {
public void initialize() {
loadConfig();
connectDatabase();
startScheduler();
warmUpCache();
}
}
4단계: 절차적 응집도 (Procedural Cohesion)
정해진 순서대로 실행되어야 하는 절차들을 묶은 경우입니다. 순서 의존성은 있지만, 데이터를 공유하지는 않습니다.
5단계: 통신적 응집도 (Communicational Cohesion)
같은 데이터를 사용하거나 같은 결과물을 만들어 내는 기능들이 묶인 경우입니다. 이 수준부터는 어느 정도 납득할 수 있는 구조가 됩니다.
// 같은 Order 데이터를 다루는 기능들이 모여 있음
public class OrderProcessor {
public void validateOrder(Order order) { ... }
public double calculateTotal(Order order) { ... }
public void applyDiscount(Order order) { ... }
}
6단계: 순차적 응집도 (Sequential Cohesion)
한 기능의 출력이 다음 기능의 입력으로 이어지는 형태입니다. 파이프라인 구조와 유사하며, 꽤 좋은 수준의 응집도입니다.
// 앞 단계의 결과가 다음 단계의 입력으로 이어짐
public class OrderFulfillmentService {
public Order processOrder(OrderRequest request) {
Order order = createOrder(request); // 1단계
order = applyPricing(order); // 2단계: 1단계 결과 사용
order = reserveInventory(order); // 3단계: 2단계 결과 사용
return confirmOrder(order); // 4단계: 3단계 결과 사용
}
}
7단계: 기능적 응집도 (Functional Cohesion) — 최선
모듈 내의 모든 요소가 단 하나의 잘 정의된 기능을 수행하기 위해 기여합니다. 단일 책임 원칙(SRP)을 충실히 따르는 클래스가 여기에 해당합니다.
// 좋은 예 — 오직 비밀번호 암호화라는 단 하나의 책임만 가짐
public class PasswordEncoder {
private static final int BCRYPT_STRENGTH = 12;
public String encode(String rawPassword) {
return BCrypt.hashpw(rawPassword, BCrypt.gensalt(BCRYPT_STRENGTH));
}
public boolean matches(String rawPassword, String encodedPassword) {
return BCrypt.checkpw(rawPassword, encodedPassword);
}
}
결합도란 무엇인가
결합도는 서로 다른 모듈들이 얼마나 강하게 서로에게 의존하고 있는지를 나타내는 척도입니다. "모듈 A를 수정했을 때, 모듈 B도 함께 수정해야 하는가?"를 묻는 질문입니다.
결합도가 낮다는 것은 각 모듈이 서로에 대해 최소한의 정보만 알고 있다는 의미입니다. 한 모듈이 변경되어도 다른 모듈이 영향을 거의 받지 않습니다. 반대로 결합도가 높다면, 시스템의 한 부분을 건드릴 때마다 예상치 못한 곳에서 문제가 생기는 경험을 하게 됩니다.
핵심 기준: 클래스 A의 내부 구현을 바꿨을 때 클래스 B의 코드도 수정해야 한다면, 결합도가 높은 상태입니다.
결합도의 6단계 — 강한 것부터 약한 것까지
결합도는 강한 것이 나쁘고 약한 것이 좋습니다. 단계가 낮을수록 강한(나쁜) 결합입니다.
1단계: 내용 결합도 (Content Coupling) — 최악
한 모듈이 다른 모듈의 내부 데이터나 구현에 직접 접근하는 경우입니다. Java에서는 리플렉션을 남용하거나, 패키지 전용 필드에 같은 패키지 내에서 직접 접근하는 형태로 나타납니다.
// 나쁜 예 — B가 A의 내부 필드에 직접 접근
public class OrderService {
double totalAmount; // package-private 필드
}
public class DiscountService {
public void applyDiscount(OrderService orderService) {
// OrderService의 내부 상태를 직접 조작
orderService.totalAmount = orderService.totalAmount * 0.9;
}
}
2단계: 공통 결합도 (Common Coupling)
여러 모듈이 같은 전역 데이터를 공유하는 경우입니다. 정적(static) 변수나 싱글턴 상태를 여러 클래스가 함께 읽고 쓰는 패턴이 대표적입니다.
// 나쁜 예 — 전역 상태를 여러 클래스가 공유
public class GlobalConfig {
public static String currentUser;
public static boolean isDebugMode;
}
public class PaymentService {
public void process() {
if (GlobalConfig.isDebugMode) { // 전역 상태 읽기
// ...
}
}
}
3단계: 제어 결합도 (Control Coupling)
한 모듈이 다른 모듈의 내부 흐름을 제어하는 플래그나 제어 변수를 전달하는 경우입니다. 앞서 논리적 응집도의 예시와도 맞닿아 있습니다.
// 나쁜 예 — boolean 플래그로 내부 동작을 외부에서 제어
public class ReportGenerator {
public void generate(Report report, boolean includeDetails) {
// includeDetails 값에 따라 내부 흐름이 달라짐
if (includeDetails) {
generateDetailedReport(report);
} else {
generateSummaryReport(report);
}
}
}
4단계: 스탬프 결합도 (Stamp Coupling)
필요한 데이터 일부만 사용하면 되는데 전체 객체를 넘기는 경우입니다. 불필요한 의존성이 생기고, 해당 클래스의 변경이 수신 측에도 영향을 미칠 수 있습니다.
// 개선 전 — User 전체를 넘기지만 실제로는 email만 사용
public class EmailSender {
public void send(User user, String content) {
String email = user.getEmail(); // email만 필요
// 전송 로직
}
}
// 개선 후 — 필요한 데이터만 받음
public class EmailSender {
public void send(String email, String content) {
// 전송 로직
}
}
5단계: 데이터 결합도 (Data Coupling)
모듈 간에 필요한 데이터만 파라미터로 주고받는 경우입니다. 가장 현실적으로 달성하기 좋은 수준이며, 실무에서 권장되는 형태입니다.
// 좋은 예 — 필요한 값만 전달
public class TaxCalculator {
public double calculate(double price, double taxRate) {
return price * taxRate;
}
}
6단계: 메시지 결합도 (Message Coupling) — 최선
파라미터조차 없이 메시지(메서드 호출) 만으로 소통하는 경우입니다. 각 모듈이 완전히 독립적이며, 이벤트 기반 아키텍처나 옵저버 패턴에서 자주 등장합니다.
// 좋은 예 — 이벤트 발행을 통한 느슨한 연결
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
public void placeOrder(Order order) {
// 주문 처리 로직
eventPublisher.publishEvent(new OrderPlacedEvent(order.getId()));
// OrderPlacedEvent를 구독하는 쪽이 누구인지 OrderService는 알 필요가 없음
}
}
Java 코드로 보는 나쁜 설계와 좋은 설계
지금까지의 개념을 하나의 시나리오로 묶어 보겠습니다. 간단한 사용자 등록 기능을 만든다고 가정합니다.
나쁜 설계 — 낮은 응집도 + 높은 결합도
// UserService가 너무 많은 것을 알고 있음
public class UserService {
private final MySQLDatabase database; // 구체 클래스에 직접 의존
private final SmtpEmailClient emailClient; // 구체 클래스에 직접 의존
public UserService() {
this.database = new MySQLDatabase("jdbc:mysql://..."); // 직접 생성
this.emailClient = new SmtpEmailClient("smtp.gmail.com"); // 직접 생성
}
public void registerUser(String name, String email, String password) {
// 1. 유효성 검사
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("Invalid email");
}
if (password.length() < 8) {
throw new IllegalArgumentException("Password too short");
}
// 2. 비밀번호 암호화
String hashedPassword = BCrypt.hashpw(password, BCrypt.gensalt(12));
// 3. DB 저장
String sql = "INSERT INTO users (name, email, password) VALUES (?, ?, ?)";
database.execute(sql, name, email, hashedPassword);
// 4. 이메일 발송
String subject = "Welcome!";
String body = "Hello " + name + ", welcome to our service.";
emailClient.send(email, subject, body);
// 5. 로그
System.out.println("User registered: " + email);
}
}
이 코드의 문제점을 살펴보면, 우선 UserService 하나가 유효성 검사, 암호화, DB 저장, 이메일 발송, 로깅까지 전부 담당하고 있습니다. 응집도가 극히 낮은 상태입니다. 또한 MySQLDatabase와 SmtpEmailClient라는 구체 클래스를 직접 생성하기 때문에, DB를 PostgreSQL로 바꾸거나 이메일 전송 방식을 변경하려면 UserService 코드를 직접 수정해야 합니다.
좋은 설계 — 높은 응집도 + 낮은 결합도
// 각 클래스가 단 하나의 책임만 가짐
// 유효성 검사 책임
public class UserValidator {
public void validate(String email, String password) {
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("유효하지 않은 이메일입니다.");
}
if (password == null || password.length() < 8) {
throw new IllegalArgumentException("비밀번호는 8자 이상이어야 합니다.");
}
}
}
// 암호화 책임
public class PasswordEncoder {
private static final int STRENGTH = 12;
public String encode(String rawPassword) {
return BCrypt.hashpw(rawPassword, BCrypt.gensalt(STRENGTH));
}
}
// 저장소 추상화 — 구체 구현에 의존하지 않도록 인터페이스로 분리
public interface UserRepository {
void save(User user);
}
// MySQL 구현체 (나중에 PostgreSQL로 교체해도 UserService는 변경 불필요)
public class MySQLUserRepository implements UserRepository {
@Override
public void save(User user) {
// MySQL INSERT 로직
}
}
// 알림 추상화
public interface NotificationService {
void sendWelcomeNotification(String email, String name);
}
// 이메일 구현체 (SMS로 교체해도 UserService는 변경 불필요)
public class EmailNotificationService implements NotificationService {
@Override
public void sendWelcomeNotification(String email, String name) {
// 이메일 전송 로직
}
}
// UserService는 이제 '등록 흐름 조율'이라는 단 하나의 책임만 가짐
public class UserService {
private final UserValidator validator;
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
private final NotificationService notificationService;
// 의존성 주입 — 구체 클래스가 아닌 추상에 의존
public UserService(
UserValidator validator,
PasswordEncoder passwordEncoder,
UserRepository userRepository,
NotificationService notificationService
) {
this.validator = validator;
this.passwordEncoder = passwordEncoder;
this.userRepository = userRepository;
this.notificationService = notificationService;
}
public void registerUser(String name, String email, String password) {
validator.validate(email, password);
String encodedPassword = passwordEncoder.encode(password);
User user = new User(name, email, encodedPassword);
userRepository.save(user);
notificationService.sendWelcomeNotification(email, name);
}
}
이제 UserService는 등록 흐름을 조율하는 역할만 합니다. DB를 교체하고 싶다면 MySQLUserRepository 대신 다른 구현체를 주입하면 되고, UserService는 건드릴 필요가 없습니다. 이메일을 SMS로 바꿔도 마찬가지입니다. 각 클래스가 독립적으로 테스트 가능해지고, 변경의 파급 효과가 최소화됩니다.
응집도와 결합도는 왜 함께 이야기해야 하는가
두 개념은 별개처럼 보이지만, 사실 깊이 연결되어 있습니다.
응집도를 높이면 클래스가 작고 명확해집니다. 작고 명확한 클래스는 자연스럽게 인터페이스로 추상화하기 쉬워지고, 이는 결합도를 낮추는 데 직접적으로 기여합니다. 반대로 응집도가 낮아 하나의 클래스가 너무 많은 것을 알고 있으면, 그 클래스에 의존하는 쪽도 많아지고 결합도는 올라가게 됩니다.
두 목표를 동시에 달성하기 위한 실용적인 접근 방법은 아래와 같습니다.
클래스 설계 시 "이 클래스를 한 문장으로 설명할 수 있는가?"를 먼저 자문해 보는 것이 좋습니다. 설명에 "그리고"가 많이 들어간다면, 클래스를 분리할 신호입니다. 의존성을 설계할 때는 구체 클래스 대신 인터페이스를 참조하도록 하고, 의존성 주입(DI)을 통해 런타임에 구현체를 연결하는 방식을 권장합니다. 패키지 구조 역시 단순히 계층(controller, service, repository)으로만 나누기보다, 도메인(user, order, payment) 단위로 패키지를 구성하면 응집도를 높이는 데 도움이 됩니다.
실무에서 체감하는 설계 원칙들과의 연결
응집도와 결합도는 독립적인 개념이 아니라, 우리가 이미 알고 있는 여러 원칙들의 뿌리에 해당합니다.
| 원칙 | 응집도/결합도와의 연관 |
| SRP (단일 책임 원칙) | 기능적 응집도를 직접 구현하는 원칙 |
| OCP (개방-폐쇄 원칙) | 결합도를 낮춰야 확장에는 열리고 수정에는 닫힌 구조가 됨 |
| DIP (의존성 역전 원칙) | 추상에 의존 → 결합도를 데이터/메시지 수준으로 낮춤 |
| ISP (인터페이스 분리 원칙) | 불필요한 의존성 제거 → 스탬프 결합도를 줄임 |
| 패키지 응집성 원칙(REP, CRP, CCP) | 패키지 수준의 응집도를 다루는 원칙 |
SOLID 원칙들이 사실은 응집도를 높이고 결합도를 낮추기 위한 구체적인 지침들의 집합임을 알 수 있습니다. 이 두 개념을 이해하고 나면, SOLID를 비롯한 다양한 설계 원칙들이 왜 그런 방향을 가리키는지 더 자연스럽게 납득이 됩니다.
마무리
오늘은 응집도와 결합도가 무엇인지, 각각 어떤 단계로 나뉘는지, 그리고 Java 코드에서 어떻게 구현하면 좋은지를 살펴보았습니다.
두 개념의 핵심을 한 줄로 정리하면, 응집도는 "한 클래스는 하나의 이유로만 존재해야 한다"는 것이고, 결합도는 "클래스들은 서로에 대해 최대한 모르는 것이 좋다"는 것입니다. 이 두 가지를 코드에서 꾸준히 실천하다 보면, 변경에 강하고 테스트하기 쉬운 구조가 자연스럽게 만들어집니다.
'Architecture' 카테고리의 다른 글
| SOLID 원칙, 좋은 객체지향 설계를 위한 다섯 가지 기준 (feat. Java) (0) | 2026.05.27 |
|---|---|
| 객체지향 프로그래밍(OOP)의 4대 원칙, (feat. Java) (0) | 2026.05.27 |
| JPA ddl-auto는 이제 그만! 실무에서 쓰는 DB 마이그레이션 툴 비교 (Flyway vs Liquibase) (0) | 2026.05.20 |