
지난시간 까지는 UML, 클래스 다이어그램등을 배웠다. 그렇다면 이제는 본격적인 설계를 해볼 차례이다.
전통적인 설계는 개발 과정에서 요구사항 분석, 설계, 구현, 테스트, 유지보수등 과정을 단계별로 체계적으로 진행하는 고전적인 접근 방법을 의미한다. 전통적인 설계원칙에는 크게 3가지가 있다.
- 추상화
- 캡슐화
- 모듈화
복잡한 시스템이나 객체에서 핵심적인 내용만 남겨서 단순화 한다.
: 불필요한 내용은 감추고, 필요한 부분만 노출시킨다.
장점 : 복잡성을 줄이고 시스템 전체 구조를 보다 쉽게 파악할 수 있다.
내부 정보를 외부에서 접근할 수 없게 만드는 보호 기법이다.
이런식으로 설계에서 +,- 를 이용해 보호 수준을 정하는 걸 의미한다.
시스템의 전체 기능을 여러 개의 독립적인 모듈로 분할하여 설계하는 원칙이다.
장점 : 각각의 모듈을 독립적으로 수정할 수 있다. 재사용 가능하다.
단점 : 너무 세분화 하면 모듈간 상호작용을 이해하기 어려워진다. (복잡성 증가)
Q. 어떤 것이 적절한 모듈일까?
A. 결합도는 낮추고, 응집도는 높이는 모듈
모듈간의 서로 의존하는 정도.
결합도가 높다는 의미 : 모듈간 상호작용이 많다 -> 이해하기 어려워짐 -> 버그 수정하기 어려움, 예상하지 못한곳에서 버그 발생 가능.

그렇다면 어떤 요소들이 결합도에 영향을 미치게 될까?
다른 모듈이 내용에 직접적인 의존성이 있는 경우
-> 모듈을 사용하기 위해 그 모듈의 내용을 알고 있어야 하는 경우
해결법: 단순한 getter/setter보다는 논리적으로 적절한 메서드를 고려한다.
public class User {
public int age;
}
…
user.age = user.age + 1;
…
이렇게 나이를 하나 더하는 클래스가 있다면,
public class User {
private int age;
public void getOld() {age += 1;}
}
user.getOld();
이런식으로 필요한 부분을 메서드로 논리적으로 고려한다. (단순 getter, setter말고)
모듈간 전역 변수를 공유하는 경우.
public class Item {
public static int PRICE = 20;
}
이런식으로 static을 하면 문제가
Item a = new Item();
a.PRICE = 100;
Item b = new Item();
System.out.println(b.PRICE);
객체별 변수인줄 알고 a를 수정해버리면 이 클래스를 이용한 객체들 즉, b까지 값이 바뀌게 된다.
해결법 : 전역 변수 혹은 static사용을 피한다.
해결법2 : 혹은 싱글톤 객체로 만든다. (싱글톤 = 하나만 존재하는 객체)
class Config {
public int PRICE;
};
Config globalConfig = new Config();
매개변수 등으로 다른 모듈의 제어 흐름 경로를 바꿀 수 있는 경우
public class Store {
public void onPurchase(boolean isPromotion) {
if (isPromotion) {
// 할인된 가격
} else {
// 정가
}
}
}
위 코드는 bool 값을 받아서 흐름을 제어하고 있는 모습이다.
하지만 단일성의 원칙을 위반한 것이다. 왜냐하면 한 메서드는 한가지의 기능을 하는 것이 바람직 하기 때문이다.
따라서 그냥 bool값을 넘겨주지 말고 처음부터 다른 기능을 하는 메서드나 클래스를 만들어 필요한 순간에 다른 기능을 호출하는게 더 효율적이다.
모듈간 자료구조 전체를 인터페이스로 전달하여 상호작용하는 결합 형태를 의미한다.
// 스탬프 결합 예시: Record라는 객체 전체를 전달
public static void calculateFee(Record record) {
int defaultMoney = 1000;
record.setFee(defaultMoney * record.useTime);
}
이런식으로 Recore라는 클래스나 자료구조를 전달받아 사용하는 형태를 스탬프 결합이라고 생각하면 된다.
사실 괜찮은 형태이긴 하지만 추후에 발생할 수 있는 문제는 구조를 다 사용하지 않는데 전체를 넘기기 때문에 자원 낭비가 된다는 것이다.
해결법: 당연하겠지만 사용하는 부분만 인수로 넘기거나 활용하는 식으로 하면 된다. 하지만 개인적으로 그냥 전체를 넘기는게 뭔가 깔끔....하다.ㅎㅎ
모듈 간에 오직 필요한 데이터(기본 타입 변수, 값 등)만을 파라미터로 전달하는 결합 방식입니다.
// 데이터 결합 예시
public static void calculateFee(int useTime) {
int defaultMoney = 1000;
int fee = defaultMoney * useTime;
}
위에서 말했던 스탬프 해결책이 데이터 결합이다. 근데! 만약에 넘길 인자가 많아진다면 스탬프로 넘기는게 이득이다. 따라서 적절하게 스탬프-데이터 결합을 사용하는 것이 중요하다.
하나의 모듈 안에서 수행되는 작업들이 서로 관련된 정도를 의미한다.
우연적 응집 (coicidental) : 아무 의미 없이 그냥 우연히 모아진 것.
논리적 응집 (logical): 큰 범주에서 볼 때 비슷한 기능들이라 묶은 경우
시간적 응집 (temporal) : 내부 요소들이 비슷한 시기에 호출될 수 있어서 묶어둔 경우
절차적 응집 (procedural) : 내부 요소들의 수행 순서에 따라 묶은 경우.
교환적 응집 (communicational) : 같은 데이터 타입을 다루기 때문에 묶은 경우.
기능적 응집 (functional) : 모듈이 한 기능에 모두 기여하는 것들을 묶음
아래로 내려갈 수록 높은 응집성을 보여서 추구해야하는 방향에 가깝다.
결합도는 모듈 간 의존성에 영향을 미치게 된다.
응집도는 한 모듈이 얼마나 연관된 기능을 담고 있는지에 대한 내용이므로 높은 응집도를 갖게 만들면 불필요한 모듈 분리에 따른 의존성을 없앨 수 있다.
-> 응집도가 높을 수록 의존도는 낮아짐
모듈 간 의존성은 빌드에 영향을 미친다.
-> B가 A에 의존적이면 A에 변경이 생기면 B도 처리를 해야한다.
-> 일정 규모 이상이 되면 빌드 시간이 10~60분 정도 된다. 따라서 빌드 시간을 줄이는 것은 생산성을 높이는 것이다.
아키텍쳐를 먼저 정하는 설계이다.
- 소프트웨어가 커지면 커질수록 복잡도가 무조건 높아진다.
- 그래서 처음부터 전체 시스템이 어떻게 생겼는지, 어떤 컴포넌트들이 있고 서로 어떻게 소통하는지 먼저 결정한다.
| 관점 | 설명 | 예시 |
|---|---|---|
| 모듈 관점 | 코드 기준으로 기능을 어떻게 나눌지 | 로그인 모듈, 결제 모듈 등 |
| 컴포넌트 관점 | 실행되는 컴포넌트들의 연결 구조 | 클라이언트-서버, pub-sub |
| 할당 관점 | 물리적 자원에 어떻게 배치할지 | API 서버는 AWS EC2, DB는 RDS 등 |
품질 제약의 우선순위에 따라 설계가 달라질 수 있다.
예를들어 보안이 가장 중요하면 보안 알고리즘등을 우선적으로 설계해야하고
속도가 가장 중요하다고 하면 캐시 서버나 데이터베이스 성능 위주로 개발하게 된다.

따라서 요구의 품질을 구체적으로 명시하고 합의한 후 개발을 시작해야 한다.
효율성
단순성
Design Principles and Design Patterns이라는 책에서 나온 객체지향 설계를 위한 5가지 원칙을 의미한다.
클래스, 모듈, 함수는 오직 하나의 책임(기능) 만 가져야 한다.

이런식으로 하나의 클래스에는 거기에 맞는 기능만 넣는 것이다. 또 필요한 기능은 외부에서 상속하거나 가져다가 쓴다.
+) 참고로 내가 게임개발할때 단일성의 원칙을 지키지않아서 Player코드에서 몬스터 소환까지 했다... 절대 귀찮다고 한번에 넣지 말자. 나중에 수많은 버그를 뱉어낸다....
확장에 대해서는 개발적이고, 수정에 대해서는 폐쇠적이여야 한다.
-> 확장은 쉽게, 확장에 따른 변셩은 최소화
-> 기존코드를 수정하지 않고 확장해야함 이라는 뜻.
void makeNoise() {
if (type.equals("dog")) {
System.out.println("멍멍");
} else if (type.equals("cat")) {
System.out.println("야옹");
}
}
이런 코드가 있다면, 새로운 동물이 추가 될 때마다 이걸 추가해야 한다.
따라서 이런식으로 분리를 시켜두면, 새로운 동물이 추가될 때마다 동물만 추가하면 된다. 이렇게 확장은 쉽고 기존 코드는 최대한 건드리지 않는 것이 개발 폐쇠 원칙이다.
참고로 이는 객체지향의 "다형성" 과 연관이 있다는 사실을 알 수 있다.
"자식클래스는 언제나 부모 클래스를 대신할 수 있어야 한다."라는 의미이다.
Parent p = new Child();
이런식으로 부모 클래스 위치에 자식이 올 수 있어야 한다는 의미이다.
class Bird {
void fly() {
System.out.println("I can fly!");
}
}
class Ostrich extends Bird {
void fly() {
throw new UnsupportedOperationException("I can't fly!");
}
}
이런식으로 자식 클래스가 부모클래스를 대신하지 못한다면 리스코프 교체 원칙을 위반한 것이다.
또다른 대표적인 위반 사례는 타원과 원이 있다.
class Ellipse {
protected double radiusX;
protected double radiusY;
public void setRadiusX(double r) { radiusX = r; }
public void setRadiusY(double r) { radiusY = r; }
}
class Circle extends Ellipse {
@Override
public void setRadiusX(double r) {
radiusX = r;
radiusY = r; // 원은 항상 두 반지름이 같아야 하니까!
}
@Override
public void setRadiusY(double r) {
radiusX = r;
radiusY = r;
}
}
Ellipse e = new Circle();
e.setRadiusX(10);
e.setRadiusY(5); // 타원에서는 가능한 행동
System.out.println(e.radiusX); // 5? 10? 뭔가 이상함
이런식으로 하면 원의 기능이 정상적으로 작동하지 않고 문제가 생긴다. 이것이 자식이 부모를 완전히 대체하지 못하기때문에 발생하는 문제이다.
클라이언트는 자신이 쓰지도 않을 인터페이스를 받으면 안된다.
= 범용성 있는 인터페이스를 만드는게 아니라 한 기능당 한 인터페이스를 만들어야함.
클래스를 참조할 때는 추상 클래스/인터페이스를 사용해야 한다.
public class Client {
public void doSomething(Service service) {
service.f();
service.g();
}
}
public class Service {
public void f() {
// 이 코드가 약 1천 줄 된다고 합시다.
}
public void g() {
// 이 코드가 약 1천 줄 된다고 합시다.
}
}
만약 이렇게 코드가 존재한다고 해보자.
만약 h()라는 새로운 코드를 추가한다고 하면, client.java, service.java둘다 새로 빌드를 해야한다.
public interface Service {
public void f();
public void g();
}
public class ServiceImpl implements Service {
public void f() {
// 이 코드가 약 1천 줄 된다고 합시다.
}
public void g() {
// 이 코드가 약 1천 줄 된다고 합시다.
}
}
,,,
Service s = new ServiceImpI();
doSomething(s);
,,,