객체지향 프로그래밍과 설계(15)
업데이트:
POCU의 개체지향 프로그래밍 및 설계 강의를 듣고 정리한 내용입니다.
팩토리 메서드
- 사용할 클래스를 정확히 몰라도 객체 생성을 가능하게 해 주는 패턴
- 정적 메서드
createOrNull()
을 통해서만 생성 가능
public final class Cup {
private int sizeMl;
private Cup(int sizeMl) {
this.sizeMl = sizeMl;
}
public static Cup createOrNull(CupSize size) {
switch (size) {
case SMALL :
return new Cup(355);
case MEDIUM:
return new Cup(473);
case LARGE:
return new Cup(651);
default:
assert (false) : "Unhandled CupSize" + size;
return null;
}
}
}
enum CupSize {
SMALL, MEDIUM, LARGE
}
생성자 대신 정적 메서드를 사용하는 것의 장점
null
을 반환 가능- 생성자는 생성이 불가능한 경우 예외를 던질 수 밖에 없음
- 반환형이 없기 때문
다형적인 팩토리 메서드
나라별로 다른 사이즈의 컵을 생성하려면?
createOrNull()
의 매개변수에 나라도 넣어준다createOrNull()
을 다형적으로 만든다.static
메서드를 다형적으로 만들 수 없음- 따라서 자식 클래스를 만들어야 함
public final class Cup {
private int sizeMl;
Cup(int sizeMl) {
this.sizeMl = sizeMl;
}
public int getSize() {
return this.sizeMl;
}
}
public abstract class Menu {
public abstract Cup createCupOrNull(CupSize size);
}
public final class AmericanMenu extends Menu {
@Override
public Cup createCupOrNull(CupSize size) {
switch (size) {
case SMALL :
return new Cup(473);
case MEDIUM:
return new Cup(621);
case LARGE:
return new Cup(887);
default:
assert (false) : "Unhandled CupSize" + size;
return null;
}
}
}
public final class KoreanMenu extends Menu {
@Override
public Cup createCupOrNull(CupSize size) {
switch (size) {
case SMALL :
return new Cup(355);
case MEDIUM:
return new Cup(473);
case LARGE:
return new Cup(651);
default:
assert (false) : "Unhandled CupSize" + size;
return null;
}
}
}
Menu menu = new KoreanMenu();
Cup cup = menu.createCupOrNull(CupSize.LARGE);
System.out.println(cup.getSize()); // 651
Menu menu = new AmericanMenu();
Cup cup = menu.createCupOrNull(CupSize.LARGE);
System.out.println(cup.getSize()); // 887
각 나라마다 사용하는 컵 종류가 다르다면?
public abstract class Cup {
private int sizeMl;
protected Cup(int sizeMl) {
this.sizeMl = sizeMl;
}
public int getSize() {
return this.sizeMl;
}
}
public final class GlassCup extends Cup { // 한국에서 쓰는 컵
GlassCup(int sizeMl) {
super(sizeMl);
}
}
public final class PaperCup extends Cup { // 미국에서 쓰는 컵
private Lid lid;
GlassCup(int sizeMl, Lid lid) {
super(sizeMl);
this.lid = lid
}
}
public final class AmericanMenu extends Menu {
@Override
public Cup createCupOrNull(CupSize size) {
Lid lid = new Lid(size);
switch (size) {
case SMALL :
return new PaperCup(473);
case MEDIUM:
return new PaperCup(621);
case LARGE:
return new PaperCup(887);
default:
assert (false) : "Unhandled CupSize" + size;
return null;
}
}
}
public final class KoreanMenu extends Menu {
@Override
public Cup createCupOrNull(CupSize size) {
switch (size) {
case SMALL :
return new GlassCup(355);
case MEDIUM:
return new GlassCup(473);
case LARGE:
return new GlassCup(651);
default:
assert (false) : "Unhandled CupSize" + size;
return null;
}
}
}
장점
- 클라이언트는 본인에게 익숙한 인자를 통해 객체 생성 가능
- 커피 사이즈는 톨 사이즈로 주세요! -> 355ml
- 생성자에서 오류 상황 감지 시 null 반환 가능
- 다형적으로 객체 생성 가능
- 가상 생성자 패턴이라고도 함
빌더
- 객체의 생성과정을 그 객체의 클래스로부터 분리하는 방법
- 객체의 부분부분을 만들어 나가다 준비되면 그제서 객체를 생성
- ex) 벽돌을 하나씩 쌓아 담장을 만든다.
- 다형성이 없는 빌더는 이미
StringBuilder
에서 봄
StringBuilder
복잡한 문서는 String으로 만들기 힘들다.
- 문자열 붙이기를 계속하는 법 : 성능 문제가 있음
String.foramt()
: 서식 문자열이 매우 복잡해짐
StringBuilder가 알아서 문자열을 합쳐줌
- 오버로딩된
append()
덕분에 String외에 다른 것도 추가하기 쉬움 - 내부에서 알아서 효율적으로 문자열을 합쳐줌
플루언트 인터페이스
builder.append(heading);
builder.append(newLine);
builder.append(heading);
- 아직 2% 부족
- 작성자의 의도 : 제목을 넣고 줄을 바꾸고 싶음
- 코드에서 의도가 명확히 보이지 않음
- 서로 다른 3개를 추가하는 느낌
- 실제 글을 읽듯 읽히지 않는다.
- 빌더 패턴을 구현 시 플루언트 인터페이스도 지원
builder.append(heading)
.append(newLine)
.append(newLine);
append()
메서드가 자기 자신을 반환한다.
빌더 패턴의 잘못 사용한 예
public class Employee {
private String firstName;
private String lastName;
private int id;
private int yearStarted;
private int age;
public Employee(String firstName, String lastName, int id, int yearStarted, int age) {
this.firstName = firstName;
this.lastName = lastName;
this.id = id;
this.yearStarted = yearStarted;
this.age = age;
}
}
- 매개변수의 순서를 잘못 넣어줄 가능성이 있음
Employee robert = new EmployeBuilder(1) // 생성자 매개변수는 한 개
.withAge(31)
.withStartingYear(2020) // 누락 시 유효하지 않은 객체
.withName("Robert", "Lee")
.build();
- 메서드 이름이 명확하니 잘못된 값을 전달할 확률이 적음
- 호출자가 적절한 메서드를 호출하지 않으면
- 객체가 생성될 때 유효한 상태여야 한다는 원칙에 어긋남
자바에서 해결 가능한 방법 : 매개변수 클래스
public class Employee {
private String firstName;
private String lastName;
private int id;
private int yearStarted;
private int age;
public Employee(CreateEmployeeParams params) {
this.firstName = params.firstName;
this.lastName = params.lastName;
this.id = params.id;
this.yearStarted = params.yearStarted;
this.age = params.age;
}
}
CreaateEmployeeParams params = new CreaateEmployeeParams();
params.firstName = "chan";
...
Employee employee = new Employee(parmas);
Employee
생성자 매개변수를 구조체처럼 만들어 전달- 생성자에 인자 순서를 잘못 넣는 경우를 해결
- 여전히 실수로 매개변수를 안 넣는 등의 문제는 존재
코멘트 중 의문점. 나중에 답변 달리는지 찾아볼 것
최종
builder()
메서드에서 객체 상태가 온전한지 체크해주면 되는 것 아닌가?
다형적인 빌더 패턴
CsvReader reader = new CsvReader(csvText);
HtmlTaableBuilder builder = new HtmlTableBuilder();
reader.writeTo(builder);
HtmlDocument html = builder.toHtmlDocument();
CsvReader reader = new CsvReader(csvText);
MarkdownTaableBuilder builder = new MarkdownTableBuilder();
reader.writeTo(builder);
String markdown = builder.toMarkdownText();
- 다형적인 함수 호출이 아님
- 실제 빌더 객체의 레퍼런스를 들고 있기에 가능하다.
래퍼 패턴
- 어떤 클래스의 메서드 시그니처가 맘에 안 들 때 다른 걸로 바꾸는 방법
- 단, 그 클래스의 메서드 시그니처를 직접 변경하지 않음
- 그 클래스의 소스코드가 없을 수도 있음
- 그 클래스에 의존하는 다른 코드가 있을 수도 있음
- 대신 새로운 클래스를 만들어 기존 클래스를 감싼다.
메서드 시그니처를 바꾸려는 이유
- 추후 외부 라이브러리를 바꿀 때 클라이언트 코드를 변경하지 않기 위해
- 그냥 사용 중인 메서드가 코딩 표준에 맞지 않아서
- 기존 클래스에 없는 기능을 추가하기 위해
- 확장된 용도 : 내부 객체를 클라이언트에게 노출시키지 않기 위해
- DTO (Data Transfer Object) 만들기
그래픽 API 예제
clearScreen(float, float, float, float) // OpenGL
clear(int, int, int, int) // DirectX
- 둘 다 화면을 어떤 색상으로 지우는 메서드
- 다른 점
- 메서드 이름
- r, g, b, a 매개변수의 형과 유효한 범위
public final class Graphics {
private OpenGL gl;
...
public void clear(float r, float g, float b, float a) {
this.gl.clearScreen(a, r, g, b);
}
}
클라이언트는 래퍼 클래스만 사용
Graphics graphics;
this.graphics.clear(0.f, 0.f, 0.f, 1.f);
this.graphics.clear(1.f, 0.f, 0.f, 1.f);
// 어떤 graphic을 사용하는지 알 필요 없음
Graphics
메서드들이 directX를 사용하도록 변경
public final class Graphics {
private DirectX dx;
...
public void clear(float r, float g, float b, float a) {
this.dx.clear((int) (r * 255), (int) (g * 255), (int) (b * 255), (int) (a * 255));
}
}
DTO
- DB에 저장된 데이터를 읽어 웹페이지에 보여주는 시스템
PersonEntity
를 웹 브라우저에 반환하면?- 필요 이상의 데이터를 반환
- 민감정보도 포함되어 있음
데이터 전송에만 사용하는 객체를 DTO라 함
PersonEntity
를PersonDto
로 변환하는 메서드만 만들면 됨
public final class PersonEntity {
public UUID id;
public Stirng fullName;
public String email;
public String passwordHash;
public Date createdDateTime;
public PersonDto toDto() {
return new PersonDto(this.fullName, this.email, this.createdDateTime);
}
}
엄밀하게는 래퍼 패턴은 아님
- 궁극적인 목표는 비슷
- DTO는 타 클래스의 데이터를 내 필요에 맞게 바꾸는 것
프록시 패턴
프록시 서버란 실제 웹사이트와 사용자 사이에 위치하는 중간 서버
인터넷상의 캐시 메모리처럼 작동함
- 사용자는 프록시 서버를 통해 원하는 문서를 읽으려 함
- 프록시 서버에 이미 그 문서가 저장되어 있다면 그걸 반환
- 없다면 실제 웹서버에서 문서를 읽어와 프록시 서버에 저장
목적
- 클래스 안에서 어떤 상태를 유지하는 게 여의치 않은 경우
- 데이터가 너무 커서 미리 읽어 두면 메모리 부족
- 객체 생성 시 데이터를 로딩하면 시간이 꽤 걸림
- 객체는 만들었으나 그 속의 데이터를 사용하지 않을 수도 있음
- 이럴 경우 붋필요한 데이터 로딩을 방지
- 객체 생성 시에는 데이터 로딩에 필요한 정보만(ex. 파일 위치) 기억해 둠
- 클라이언트가 실제로 데이터를 요청할 때 메모리에 로딩
예시 : 이미지 데이터
이미지는 용량이 크고, 저장장치에서 읽어와야 한다.
public final class Image {
private ImageData image;
public Image(String filePath) {
this.image = ImageLoader.getInstance().load(filePath);
// 프록시 패턴 사용 X, 즉시 로딩
}
public void draw(Canvas canvas, float x, float y) {
canvas.draw(this.image, x, y);
}
}
문제점
- 생성자에서 무조건 이미지를 읽어 옴
- 메모리를 많이 사용
- 이미지를 읽어오는 데 시간도 걸림
- 모든 image에 대해 draw()가 호출되지 않을 수도 있음
프록시 패턴을 적용
public final class Image {
private String filePath;
private ImageData image;
public Image(String filePath) {
this.filePath = filePath;
}
public void draw(Canvas canvas, float x, float y) {
if (this.image == null) {
this.image = ImageLoader.getInstance().load(this.filePath);
// 지연 로딩
}
canvas.draw(this.image, x, y);
// 이미 메모리에 로딩해 놓았으면 그대로 갖다 쓴다.
}
}
즉시 로딩 vs 지연 로딩
어느 방법에도 장단점이 있다.
즉시 로딩 | 지연 로딩 + 캐시 X | 지연 로딩 + 캐시 (프록시 패턴) |
|
---|---|---|---|
최신 데이터 | X | O | △ |
메모리 사용량 | 최고 | 최소 | 그 중간 어딘가 |
실행 속도 병목점 | 생성 시 | 이미지 사용할 때마다 | 알기 힘듦 |
요즘의 프록시 패턴
- 요즘 컴퓨터에는 메모리를 많이 장착
- 미리 다 로딩해놔도 큰 문제가 아닌 경우도 많음
- 한 번에 그리는 이미지 수가 많지 않다면?
- 필요할 때마다 디스크에서 읽을 수 있음 (충분히 빠름! SSD!)
- 하지만 인터넷에서 그 이미지들을 로딩한다면?
- 예전에 디스크에서 읽을 때보다 시간이 더 오래 걸림
- 그 동안 프로그램이 멈춰 있다면 사용자가 좋아할까?
프록시 패턴 + 캡슐화의 문제
- 클라이언트는 언제 이 클래스가 느려지는지 알 수가 없다
- 세 가지 방법 중 정확히 어떻게 구현되어 있는지 알 수 없기 때문
- 세 구현 방법 모두 Image 클랙스 안에 캡슐화되어 있음
요즘 세상에는 클래스가 남몰래 프록시 패턴을 사용하는 것보다 클라이언트에게 조작 권한을 주는 게 좋을 수도 있다.
public final class Image {
private String filePath;
private ImageData image;
public Image(String filePath) {
this.filePath = filePath;
}
public boolean isLoaded() {
return this.image != null;
}
public void load() {
if (this.image == null) {
this.image = ImageLoader.getInstance().load(this.filePath);
}
}
public void unload() {
this.image = null;
}
public void draw(Canvas canvas, float x, float y) {
canvas.draw(this.image, x, y);
}
}
- 이미지의 로딩 상태를 클라이언트가 명확히 알 수 있게 해 줌
- 클라이언트가 로딩과 언로딩 시점을 직접 제어할 수 있게 해 줌
- 이를 이용해 게임이나 앱에서 봤던 로딩 스크린을 보여줄 수 있음
- 모든 이미지를 다 읽어올 때까지
- 상태 머신(state machine) 을 사용
public class LoadingScreen extends Screen {
ArrayList<Image> requiredImages;
public void update() {
if (this.requiredImages.size() == 0) { // 로딩이 끝
StateManager.getInstance().pop(this); // 상태 머신에서 빼줘
return;
}
Image image = this.requiredImage.get(0);
if (image.isLoaded()) {
this.requiredImages.remove(0);
} else {
image.load();
}
drawScreen();
}
}
책임 연쇄 패턴과 Logger
대표적인 케이스 && 잘못된 예
public abstract class Logger {
private EnumSet<Loglevel> loglevels;
private Logger next;
public Logger(LogLevel[] levels) {
this.logLevels = EnumSet.copyOf()
}
public Logger setNext(Logger next) {
this.next = next;
return this.next; // 클래스 외부에서 반환되는 값을 예측하기 어렵다.
}
public final void message(String msg, LogLevel severity) {
if (logLevels.contans(severity)) {
log(msg);
}
if (this.next != null) {
this.next.message(msg, severity); // 연쇄적으로 로커를 호출
}
}
protected abstract void log(String msg);
}
public class ConsoleLogger extends Logger {
public ConsoleLogger(LogLevel[] levels) {
super(levels);
}
@override
protected void log(String msg) {
System.err.println("Writing to Console: " + msg);
}
}
Logger logger = new ConsoleLogger(LogLevel.all());
logger.setNext(new EmailLogger(new LogLevel[]{LogLevel.FUNTIONAL_MESSSAGE, LogLevel.FUNCTIONAL_ERROR}))
.setNext(new FileLogger(new Loglevel[]{LogLevel.WARNING, LogLevel.ERROR}));
// consoleLogger에서 처리
logger.message("Entering function ProcessOrder().", LogLevel.DEBUG);
// consoleLogger + emailLogger에서 처리
logger.message("Order Dispatched", LogLevel.FUNCTIONAL_MESSAGE);
// consoleLogger + fileLogger에서 처리
logger.message("Customer Address etails missing in Branch DataBase", LogLevel.WARNING);
logger
를 한 번만 호출한다.- 연쇄적으로 로거를 호출한다.
보다 직관적인 방법
public final class LogManager {
private static LogManager instance;
private ArrayList<Logger> loggers = new ArrayList<Logger>();
public static LogManager getInstance() {
if (instance == null) {
instance = new LogManager();
}
return instance;
}
public void addHandler(Logger logger) {
this.loggers.add(logger);
}
public void message(String msg, LogLevel severity) {
for (Logger logger : this.loggers) {
logger.message(msg, severity)
}
}
}
public abstract class Logger {
private EnumSet<LogLevel> logLevels;
public Logger(LogLevel[] levels) {
this.logLevels = EnumSet.copyOf(Arrays.asList(levels));
}
public final void message(String msg, LogLevel severity) {
if (logLevels.contains(severity)) {
log(msg);
}
// 연쇄 호출 필요 없음
}
protected abstract void log(String msg);
}
// 상속하는 로거들은 변경 X
올바른 책임 연쇄 패턴
- 어떤 메세지를 처리할 수 있는 여러 객체가 있음
- 이 객체들은 차례대로 메세지를 처리할 수 있는 기회를 받음
- 만약 그 중 한 객체가 메시지를 처리하면 그것에 대한 책임을 짐
- 즉, 다음 객체는 메시지를 처리할 기회를 받지 못 함
- 이래서 책임 연쇄란 이름이 붙은 것!`
옵저버 / Pub-Sub 패턴
- Pub : publisher
- Sub : subscriber
- 옵저버와 유사하나 엄밀히 말하면 다른 패턴
이전에 봤던 LogManager
가 pub-sub 패턴
- ConsoleLogger, EmailLogger 등을 LogManager에 추가
- 프로그램에서 LogManager에게 로그 메세지를 보냄
- 프로그램이 publisher
- 그 로그 메세지를 처리하게 등록된 구독자들에 전부 메시지가 감
- 여기서 LogManager를 빼면 그게 옵저버 패턴
옵저버 패턴 예 : 크라우드펀딩
- 돈이 들어올 때마다 두 객체를 업데이트하고 싶음
- 장부를 업데이트 (상태는 금액만 필요)
- 모바일 폰에서 노티를 받음 (상태는 이름과 금액이 필요)
public interface IFundingCallback {
void onMoneyRaised(String backer, int amount);
}
public final class BookkeepingApp implements IFundingCallback {
// 멤버 변수와 메서드는 모두 생략
@Override
public void onMoneyRaised(String backer, int amount) {
// 장부에 새 내역 추가
// amount만 사용
}
}
public final class MobileApp implements IFundingCallback {
@Override
public void onMoneyRaised(String backer, int amount) {
// 모바일 앱에 알림을 보여준다.
// backer, amount 모두 사용
}
}
public final class CrowdFundingAccount { // 발행자 하나
private int balance;
private ArrayList<IFundingCallback> subscribers; // 구독자 여러명
public CrowdFundingAccount() {
this.subscribers = new ArrayList<IFundingCallback>();
}
public void subscribe(IFundingCallback sub) {
subscribers.add(sub);
}
public void support(String backer, int amount) {
this.balance += amount;
for (IFundingCallback sub : subscribers) {
sub.onMoneyRaised(backer, amount);
}
}
}
옵저버 패턴은 결국 콜백 함수의 목록이다!
메모리 누수 문제
옵저버 패턴은 매니지드 언어에서 메모리 누수를 만드는 주범
CrowdFundingAccount funding; // 보유한 펀딩 계좌
// 장부 앱에서 구독함
BookkeepingApp book = new BookkeepingApp();
funding.subscribe(book);
// 장부 앱으로 뭔가를 함
// 할 일이 끝남. 장부 앱을 지우자
book = null;
// 시간이 많이 지나도 여전히 book이 사라지지 않음
private ArrayList<IFundingCallback> subscribers;
에서 여전히 참조하고 있기 때문. 따라서 직접 지워줘야 한다.
해결법
public void unsubscribe(IFundingCallback sub) {
subscribers.remove(sub);
}
unsubscribe()
를 호출해야 한다는 것은 쉽게 잊혀질 수 있다.
댓글남기기