Design Pattern
일종의 설계 기법이며, 설계 방법이다.
- 목적
SW 재사용성, 호환성, 유지 보수성을 보장.
- 특징
디자인 패턴은 아이디어임, 특정한 구현이 아님.
프로젝트에 항상 적용해야 하는 것은 아니지만, 추후 재사용/호환/유지 보수 시 발생하는 문제를 예방하기 위해 패턴을 만들어 둔 것임.
- 원칙
SOLID(객체지향 설계 원칙)
1. Single Responsibility Principle(SRP, 단일 책임 원칙) 하나의 클래스는 하나의 역할만 해야 함.
2. Open - Close Principle(OCP, 개방-폐쇄 원칙) 확장(상속)에는 열려있고, 수정에는 닫혀 있어야 함.
3. Liskov Substitution Principle(LSP, 리스코프 치환 원칙) 자식이 부모의 자리에 항상 교체될 수 있어야 함.
4. Interface Segregation Principle(ISP, 인터페이스 분리 원칙) 인터페이스가 잘 분리되어서, 클래스가 꼭 필요한 인터페이스만 구현되도록 해야함.
5. Dependency Inversion Property(DIP, 의존 역전 원칙) 상위 모듈이 하위 모듈에 의존하면 안됨. 둘 다 추상화에 의존하며, 추상화는 세부 사항에 의존하면 안됨.
- 분류
1. 생성 패턴 (Creational) : 객체의 생성 방식 결정
Class-creational patterns, Object-creational patterns
예 ) DBConnection을 관리하는 Instance를 하나만 만들 수 있도록 제한하여, 불필요한 연결을 막음
2. 구조 패턴 (Structural) : 객체간의 관계를 조직
예 ) 2개의 인터페이스가 서로 호환이 되지 않을 때, 둘을 연결해주기 위해서 새로운 클래스를 만들어 연결시킬 수 있도록 함.
3. 행위 패턴 (Behavioral) : 객체의 행위를 조직, 관리, 연합
예 ) 하위 클래스에서 구현해야 하는 함수 및 알고리즘을 미리 선언하여, 상속시 이를 필수로 구현하도록 함.
싱글톤 패턴(Singleton pattern)
애플리케이션이 시작될 때, 어떤 클래스가 최초 한 번만 메모리를 할당(static)하고 해당 메모리에 인스턴스를 만들어 사용하는 패턴
즉, 싱글톤 패턴은 하나의 클래스에 오직 '하나'의 인스턴스만 가지는 패턴이다.
인스턴스가 필요할 때, 똑같은 인스턴스를 만들지 않고 기존의 인스턴스를 활용하는 것
public class Singleton {
// static 변수로 싱글톤 인스턴스를 저장
private static Singleton instance;
// private 생성자로 외부에서 인스턴스 생성하지 못하도록 함
private Singleton() {
// private constructor
}
// 인스턴스를 반환하는 public 메소드
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
public static void main(String[] args) {
Singleton a = Singleton.getInstance();
Singleton b = Singleton.getInstance();
System.out.println(a == b); // true
}
}
생성자가 여러번 호출되도, 실제로 생성되는 객체는 하나이며 최초로 생성된 이후에 호출된 생성자는 이미 생성한 객체를 반환시키도록 만드는 것이다.
왜 쓰나요?
객체를 생성할 때마다 메모리 영역을 할당받아야 한다. 하지만 한 번의 new를 통해 객체를 생성한다면 메모리 낭비를 방지할 수 있다.
또한, 싱글톤으로 구현한 인스턴스는 '전역' 이므로, 다른 클래스들의 인스턴스들이 데이터를 공유하는 것이 가능한 장점이 있다.
많이 사용하는 경우가 언제인가요?
주로 공통된 객체를 여러개 생성해서 사용해야 하는 상황
데이터베이스에서 커넥션풀, 스레드풀, 캐시, 로그 기록 객체 등
안드로이드 앱에선 각 액티비티 들이나 클래스마다 주요 클래스들을 하나하나 전달하는 게 번거롭기 때문에 싱글톤 클래스를 만들어 어디서든 접근하도록 설계
또한, 인스턴스가 절대적으로 한 개만 존재하는 것을 보증하고 싶을 때 사용함
//MYSQL의 싱글톤 패턴
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class DatabaseConnection {
// 싱글톤 인스턴스를 저장할 private static 변수
private static DatabaseConnection instance;
// 데이터베이스 연결을 저장할 private 변수
private Connection connection;
// 데이터베이스 연결 정보
private String url = "jdbc:mysql://localhost:3306/mydatabase";
private String username = "root";
private String password = "password";
// 생성자는 private으로 설정하여 외부에서 인스턴스를 생성하지 못하도록 함
private DatabaseConnection() {
try {
// 드라이버를 로드
Class.forName("com.mysql.cj.jdbc.Driver");
// 연결 생성
this.connection = DriverManager.getConnection(url, username, password);
} catch (ClassNotFoundException | SQLException e) {
e.printStackTrace();
}
}
// 싱글톤 인스턴스를 반환하는 public static 메소드
public static DatabaseConnection getInstance() {
if (instance == null) {
synchronized (DatabaseConnection.class) {
if (instance == null) {
instance = new DatabaseConnection();
}
}
}
return instance;
}
// 데이터베이스 연결을 반환하는 public 메소드
public Connection getConnection() {
return connection;
}
}
단점도 있나요?
객체 지향 설계 원칙 중에 '개방-폐쇄 원칙(OCP)' 이란 것이 존재한다.
만약 싱글톤 클래스가 너무 많은 책임을 지고 있다면, 이를 변경하지 않고 새로운 기능을 추가하기가 어렵다. 또한 싱글톤 인스턴스가 많은 데이터를 공유하게 되면 결합도가 증가해 한 부분의 변경이 다른 부분에 영향을 미치게 된다.
결합도가 증가하면, 유지보수가 힘들고 TDD(Test Driven Development)를 할 때 힘들다. TDD를 할 때 단위 테스트를 주로 하는데, 단위 테스트는 서로 독립적이어야 하며 테스트를 어떤 순서로든 실행할 수 있어야 하기 때문이다.
결합을 낮추는 방법이 있나요?
의존성(종속성)이란 클래스 간 관계를 의미한다. 의존성 주입(DI, Dependency Injection)을 통해 모듈 간의 결합을 느슨하게 만들어 해결할 수 있다.
의존성 주입을 사용하지 않는 경우 메인 모듈(main module)이 '직접' 다른 하위 모듈에 대한 의존성을 줘야 한다.
/*의존성 주입을 하지 않은 경우*/
// MessageService 클래스
class MessageService {
private EmailService emailService;
public MessageService() {
this.emailService = new EmailService();
}
public void sendMessage(String message) {
emailService.sendEmail(message);
}
}
// EmailService 클래스
class EmailService {
public void sendEmail(String message) {
System.out.println("Email sent with message: " + message);
}
}
// Main 클래스
public class Main {
public static void main(String[] args) {
MessageService messageService = new MessageService();
messageService.sendMessage("Hello, World!");
}
}
이 때, 의존성 주입을 사용하면 중간에 의존성 주입자(dependency injector)가 이 부분을 가로채 메인 모듈이 '간접' 적으로 의존성을 주입한다. 이를 통해 상위 모듈은 하위 모듈에 대한 의존성이 떨어지게 된다.(디커플링 된다.)
의존성 주입자는 의존성을 주입하는 역할을 하는 객체 또는 프레임워크이다. 실제 개발에서는 DI 프레임워크를 사용하기도 하는데, 대표적인 프레임워크로는 Spring Framework가 있다. Spring을 사용하면 의존성 주입자가 프레임워크 자체가 되며, 설정 파일이나 어노테이션을 통해 의존성을 주입한다.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Controller;
import org.springframework.stereotype.Service;
/* @Service나 @Controller... 를 사용하게 되면 해당 클래스가 스프링 컨테이너에 빈(Bean)으로 등록된다.
스프링 프레임워크는 @Autowired 어노테이션을 통해 자동으로 이러한 빈들을 주입할 수 있다.
*/
// EmailService 클래스
@Service
class EmailService {
public void sendEmail(String message) {
System.out.println("Email sent with message: " + message);
}
}
// MessageController 클래스
@Controller
class MessageController {
private EmailService emailService;
// @Autowired 어노테이션을 사용하여 의존성 주입
@Autowired
public MessageController(EmailService emailService) {
this.emailService = emailService;
}
public void processMessage(String message) {
emailService.sendEmail(message);
}
}
팩토리 패턴(Factory pattern)
객체 생성 부분을 분리해 추상화한 패턴이자(단일 팩토리 클래스가 담당), 상속 관계에 있는 두 클래스에서 상위 클래스가 중요한 뼈대를 결정하고 하위 클래스에서 객체 생성에 관한 구체적인 내용을 결정하는 패턴
CoffeeType(Enum)
Coffee (추상 클래스)
ㄴ Latte
ㄴ Espresso
CoffeeFactory
즉 Coffee(Latte, Espresso)라는 클래스를 CoffeeFactory에서 생성함.
enum CoffeeType {
LATTE,
ESPRESSO
}
abstract class Coffee {
protected String name;
public String getName() {
return name;
}
}
class Latte extends Coffee {
public Latte() {
name = "latte";
}
}
class Espresso extends Coffee {
public Espresso() {
name = "espresso";
}
}
class CoffeeFactory {
public static Coffee createCoffee(CoffeeType type) {
switch (type) {
case LATTE:
return new Latte();
case ESPRESSO:
return new Espresso();
default:
throw new IllegalArgumentException("Invalid coffee type: " + type);
}
}
}
public class Main {
public static void main(String[] args) {
Coffee coffee = CoffeeFactory.createCoffee(CoffeeType.LATTE);
System.out.println(coffee.getName()); // latte
}
}
팩토리 메서드 패턴(Factory Method Pattern)
팩토리 패턴을 확장한 것으로, 객체 생성을 위한 인터페이스를 정의하고 실제 객체 생성은 이를 구현한 하위 클래스에서 수행한다. 각 하위 클래스는 객체 생성 방법을 구체적으로 정의한다.
// Coffee 추상 클래스
abstract class Coffee {
protected String name;
public String getName() {
return name;
}
}
// Latte 클래스
class Latte extends Coffee {
public Latte() {
name = "latte";
}
}
// Espresso 클래스
class Espresso extends Coffee {
public Espresso() {
name = "espresso";
}
}
// CoffeeFactory 추상 클래스 (팩토리 메서드) -> 인터페이스
abstract class CoffeeFactory {
public abstract Coffee createCoffee();
}
// LatteFactory 클래스
class LatteFactory extends CoffeeFactory {
@Override
public Coffee createCoffee() {
return new Latte();
}
}
// EspressoFactory 클래스
class EspressoFactory extends CoffeeFactory {
@Override
public Coffee createCoffee() {
return new Espresso();
}
}
// Main 클래스
public class Main {
public static void main(String[] args) {
CoffeeFactory latteFactory = new LatteFactory();
Coffee latte = latteFactory.createCoffee();
System.out.println(latte.getName()); // latte
CoffeeFactory espressoFactory = new EspressoFactory();
Coffee espresso = espressoFactory.createCoffee();
System.out.println(espresso.getName()); // espresso
}
}
단순 팩토리 패턴에선 하나의 팩토리 클래스가 모든 객체 생성 책임을 지지만, 팩토리 메서드 패턴에선 객체 생성의 책임이 구체적인 하위 팩토리 클래스에 있다.
새로운 종류의 객체를 추가하려면 팩토리 클래스의 코드를 추가해야 하는 단순 팩토리 패턴과 달리, 팩토리 메소드 패턴에선 새로운 하위 팩토리 클래스를 작성하면 된다. 기존 클래스는 수정할 필요가 없다.
전략 패턴(Strategy Pattern)
객체의 행위를 바꾸고 싶은 경우 '직접' 수정하지 않고 전략이라고 부르는 '캡슐화한 알고리즘'을 컨텍스트 안에서 바꿔주면서 상호 교체가 가능하게 만드는 패턴
새로운 로직을 추가하거나 변경할 때, 한번에 효율적으로 변경이 가능하다.
[ 결제 시스템을 설계하시오. ]
유닛 종류 : KAKAOCard, LUNACard
유닛들은 결제할 수 있다.
KAKAOCard는 카드 정보를 사용하여 결제한다.
LUNACard는 이메일과 비밀번호를 사용하여 결제한다.
아이템들은 쇼핑 카트에 추가될 수 있다.
쇼핑 카트는 아이템을 추가하고 제거할 수 있으며, 총액을 계산할 수 있다.
쇼핑 카트는 결제 방식을 사용하여 결제를 처리할 수 있다.
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
interface PaymentStrategy {
public void pay(int amount);
}
class KAKAOCardStrategy implements PaymentStrategy {
private String name;
private String cardNumber;
private String cvv;
private String dateOfExpiry;
public KAKAOCardStrategy(String nm, String ccNum, String cvv, String expiryDate) {
this.name = nm;
this.cardNumber = ccNum;
this.cvv = cvv;
this.dateOfExpiry = expiryDate;
}
@Override
public void pay(int amount) {
System.out.println(amount + " paid using KAKAOCard.");
}
}
class LUNACardStrategy implements PaymentStrategy {
private String emailId;
private String password;
public LUNACardStrategy(String email, String pwd) {
this.emailId = email;
this.password = pwd;
}
@Override
public void pay(int amount) {
System.out.println(amount + " paid using LUNACard.");
}
}
class Item {
private String name;
private int price;
public Item(String name, int cost) {
this.name = name;
this.price = cost;
}
public String getName() {
return name;
}
public int getPrice() {
return price;
}
}
class ShoppingCart {
List<Item> items;
public ShoppingCart() {
this.items = new ArrayList<Item>();
}
public void addItem(Item item) {
this.items.add(item);
}
public void removeItem(Item item) {
this.items.remove(item);
}
public int calculateTotal() {
int sum = 0;
for (Item item : items) {
sum += item.getPrice();
}
return sum;
}
public void pay(PaymentStrategy paymentMethod) {
int amount = calculateTotal();
paymentMethod.pay(amount);
}
}
public class HelloWorld {
public static void main(String[] args) {
ShoppingCart cart = new ShoppingCart();
Item A = new Item("kundolA", 100);
Item B = new Item("kundolB", 300);
cart.addItem(A);
cart.addItem(B);
// pay by LUNACard
cart.pay(new LUNACardStrategy("kundol@example.com","pukubababo"));
// pay by KAKAOCard
cart.pay(new KAKAOCardStrategy("Ju hongchul", "123456789","123","12/01"));
}
}
/*
400 paid using LUNACard.
400 paid using KAKAOCard.
*/
이처럼 Strategy Pattern을 활용하면 로직을 독립적으로 관리하는 것이 편해진다. 객체에 들어가는 '행위' 를 클래스로 선언하고, 인터페이스와 연결하는 방식으로 구성하는 것
옵저버 패턴(Observer pattern)
어떤 객체(subject)의 상태 변화를 관찰하는 주체 객체 & 상태 변화에 따라 전달되는 메서드 등을 기반으로 '추가 변경 사항' 이 생기는 관찰 객체
(1 대 1 or 1 대 N 관계)
정보의 단위가 클수록, 객체 규모가 클수록 복잡성이 증가하는데 이 때 가이드라인을 제시해줄 수 있는 것이 '옵저버 패턴'
- Subject 인터페이스
Observer들을 관리하는 메소드를 가지고 있음
옵저버 등록(register), 제외(unregister), 옵저버들에게 정보를 알려줌(notifyObservers)
interface Subject {
public void register(Observer obj);
public void unregister(Observer obj);
public void notifyObservers();
public Object getUpdate(Observer obj);
}
- Observer 인터페이스
정보를 업데이트(update)
interface Observer {
public void update();
}
- Topic 클래스
주체이자 객체
Subject를 구현한 클래스로, 정보를 제공해주는 주체가 됨
class Topic implements Subject {
private List<Observer> observers;
private String message;
public Topic() {
this.observers = new ArrayList<>();
this.message = "";
}
@Override
public void register(Observer obj) {
if (!observers.contains(obj)) observers.add(obj);
}
@Override
public void unregister(Observer obj) {
observers.remove(obj);
}
@Override
public void notifyObservers() {
this.observers.forEach(Observer::update);
}
@Override
public Object getUpdate(Observer obj) {
return this.message;
}
public void postMessage(String msg) {
System.out.println("Message sended to Topic: " + msg);
this.message = msg;
notifyObservers();
}
}
- TopicSubscriber 클래스
Observer를 구현한 클래스로, notifyObserververs()를 호출하면서 알려줄 때마다 update가 호출됨
class TopicSubscriber implements Observer {
private String name;
private Subject topic;
public TopicSubscriber(String name, Subject topic) {
this.name = name;
this.topic = topic;
}
@Override
public void update() {
String msg = (String) topic.getUpdate(this);
System.out.println(name + ":: got message >> " + msg);
}
}
Java에는 옵저버 패턴을 적용한 것들을 기본적으로 제공해줌(Observer 인터페이스, Observable 클래스)
하지만 Observable은 클래스로 구현되어 있기 때문에 사용하려면 상속을 해야 함. 따라서 다른 상속을 함께 이용할 수 없는 단점 존재
프록시 패턴과 프록시 서버
프록시 패턴(Proxy pattern)은 대상 객체에 접근하기 전 그 접근에 대한 흐름을 가로채 해당 접근을 필터링하거나 수정하는 등의 역할을 하는 계층이 있는 디자인 패턴
객체의 속성, 변환 등을 보완하며 보안, 데이터 검증, 캐싱, 로깅에 사용한다.
프록시 서버에서의 캐싱이란 프록시 서버 캐시에 정보를 담아두고, 캐시 안에 있는 정보를 요구하는 요청에 원격 서버에 요청을 보내는 대신 캐시 안에 있는 데이터를 활용하는 것
프록시 서버
프록시 서버(Proxy server)는 서버와 클라이언트 사이에서 클라이언트가 자신을 통해 다른 네트워크 서비스에 간접적으로 접속할 수 있게 해주는 컴퓨터 시스템이나 응용 프로그램
nginx
CloudFlare
ClodFlare는 전 세계적으로 분산된 서버가 있는 CDN 서비스이다.
각 사용자가 인터넷에 접속하는 곳과 가까운 곳에서 콘텐츠를 캐싱 또는 배포하는 서버 네트워크
웹 서버 앞단에 프록시 서버로 두어 DDOS 공격 방어나 HTTPS 구축에 쓰인다.
CloudFlare는 의심스러운 트래픽, 특히 시스템을 통해 오는 트래픽을 자동으로 차단해서 DDOS 공격으로부터 보호한다.
또한, HTTPS를 별도의 인증서 없이 구축할 수 있도록 한다.
CORS와 프론트엔드의 프록시 서버
CORS(Cross-Origin Resource Sharing)는 서버가 웹 브라우저에서 리소스를 로드할 때 다른 오리진을 통해 로드하지 못하게 하는 HTTP 헤더 기반 메커니즘이다.
오리진 = 프로토콜 + 호스트 이름(도메인) + 포트
프론트엔드에선 테스트를 위해 127.0.0.1:3000을 사용하는데, 백엔드 서버는 127.0.0.1:12010이라면 포트 번호가 달라 CORS 에러가 뜨게 된다. 이 때 프록시 서버를 둬서 프론트엔드에서 요청되는 오리진을 127.0.0.1:12010으로 바꾸는 것이다.
MVC 패턴
모델(Model), 뷰(View), 컨트롤러(Controller)로 이루어진 디자인 패턴
재사용성과 확장성이 용이하지만, 애플리케이션이 복잡해질수록 모델과 뷰의 관계가 복잡해진다.
- 모델
애플리케이션의 데이터인 데이터베이스, 상수, 변수 등
뷰에서 데이터를 생성하거나 수정하면 컨트롤러를 통해 모델을 생성하거나 갱신한다.
- 뷰
inputbox, checkbox, textarea 등 사용자 인터페이스 요소
즉, 모델을 기반으로 사용자가 볼 수 있는 화면이며 모델이 가지고 있는 정보를 따로 저장해선 안된다. 변경이 일어나면 컨트롤러에 전달한다.
- 컨트롤러
하나 이상의 모델과 하나 이상의 뷰를 잇는 다리 역할
이벤트 등 메인 로직을 담당하고 모델과 뷰의 생명주기도 관리하며, 모델이나 뷰의 변경 통지를 받으면 이를 해석하여 각 구성 요소에 해당 내용에 대해 알려준다.
spring
MVC 패턴을 이용한 대표적 프레임워크로는 자바 플랫폼을 위한 오픈 소스 애플리케이션 프레임워크인 스프링(Spring)이 있다.
스프링의 WEB MVC는 웹 서비스를 구축하는 데 편리한 기능들을 제공한다. 예를 들어 @RequestParam, @RequestHeader, @Pathvariable 등의 어노테이션을 기반으로 사용자의 요청 값을 쉽게 분석할 수 있다.
프로그래밍 패러다임
프로그래머에게 프로그래밍의 관점을 갖게 해주는 역할을 하는 개발 방법론
프로그래밍 패러다임
ㄴ 선언형
ㄴ 함수형
ㄴ 명령형
ㄴ 객체지향형
ㄴ 절차지향형
- 선언형과 함수형 프로그래밍
선언형 프로그래밍(declarative programming)은 '무엇을' 풀어내는가에 집중하는, "프로그램은 함수로 이루어지는 것이다." 라는 명제가 담겨 있는 패러다임이다.
함수형 프로그래밍(functional programming)은 선언형 패러다임의 일종으로, '순수 함수'들을 블록처럼 쌓아 로직을 구현하고 '고차 함수'를 통해 재사용성을 높인 프로그래밍 패러다임이다.
순수 함수는 출력이 입력, 즉 매개변수에만 영향을 받는 것을 의미한다. 전역 변수가 출력에 영향을 주면 순수 함수가 아니다. 고차 함수는 함수가 함수를 값처럼 매개변수로 받아 로직을 생성할 수 있는 것을 말한다.
- 객체지향 프로그래밍
객체지향 프로그래밍(OOP, Object-Oriented Programming)은 객체들의 집합으로 프로그래밍의 상호 작용을 표현하며 데이터를 객체로 취급한다. 객체 내부에 선언된 메소드를 활용하는 방식이다.
설계에 많은 시간이 소요되며 처리 시간이 다른 프로그래밍 패러다임에 비해 상대적으로 느리다.
객체지향 프로그래밍의 특징은 추상화, 캡슐화, 상속성, 다형성이이 있다.
1. 추상화(Abstraction)
복잡한 시스템으로부터 핵심적인 개념 또는 기능을 간추려내는 것
2. 캡슐화(Encapsulation)
객체의 속성과 메서드를 하나로 묶고 일부를 외부에 감추어 은닉하는 것
3. 상속성(Inheritance)
상위 클래스의 특징을 하위 클래스가 이어받아서 재사용하거나 추가, 확장하는 것
4. 다형성(Polymorphism)
하나의 메서드나 클래스가 다양한 방법들로 동작하는 것, 대표적으로 오버로딩/오버라이딩이 있음
//오버로딩
class Person {
public void eat(String a) {
System.out.println("I eat " + a);
}
public void eat(String a, String b) {
System.out.println("I eat " + a + " and " + b);
}
}
오버로딩은 같은 이름을 가진 메서드를 메서드 타입, 매개변수 유형, 개수 등으로 여러 개 둘 수 있으며 컴파일 중에 발생하는 '정적' 다형성이다.
//오버라이딩
class Animal {
public void bark() {
System.out.println("왁왁!!!");
}
}
class Dog extends Animal {
@Override
public void bark() {
System.out.println("멍멍!!");
}
}
상위 클래스로부터 상속받은 메서드를 하위 클래스가 재정의하는 것으로, 런타임 중에 발생하는 '동적' 다형성이다.