🦁멋쟁이사자처럼 백엔드 부트캠프 13기 🦁
TIL 회고 - [20]일차
🚀20일차에는 객체지향설계원칙 중 DIP를 배우고, 디자인 패턴을 본격적으로 학습할 수 있었다.
디자인 패턴을 사용하는 이유와 디자인패턴의 종류를 실습을 통해서 익힐 수 있어서 이해에 도움이되었다.
디자인 패턴 개념을 단순히 공부할때는 왜 사용해야하고 어떤 코드로 동작할까를 짐작할 수 없었는데 디자인 패턴에 대해서 더 자세히 알 수 있게 되었다.
물론 어댑터 패턴과 옵저버 패턴을 좀 더 공부해야겠다는 생각도 들었다.
DIP
- 객체지향 설계원칙 중 DIP (Dependency Inversion Principle) = 의존성 역전 원칙
- 객체에서 어떤 Class를 참조해서 사용해야하는 상황이 생긴다면 직접 참조하지 않고
대상의 상위 요소(추상 클래스 or 인터페이스)로 참조하라는 원칙 - 객체 간의 의존 관계를 외부에서 주입하는 설계 패턴
- 객체가 필요한 의존성을 직접 생성하거나 관리하는 대신, 외부에서 제공받음
➡️객체 간의 결합도를 줄이고 코드의 유연성과 재사용성을 높일 수 있음 - 의존성(Dependency): 클래스가 정상적으로 작동하기 위해 필요한 객체
(ex. Car 클래스가 Engine 클래스에 의존할때, (Engine = 의존성)) - 주입(Injection): 의존성을 클래스 내부에서 생성하지 않고, 외부에서 전달받음
▶️예시 - DIP를 보여주는 단적인 예시
ArrayList<T> dip = new ArrayList<>(); // 일반적인 경우
List<T> dip = new ArrayList<>(); // DIP 원칙
- DIP의 핵심은 구체적인 구현 클래스에 의존하지 않고, 추상화된 인터페이스나 상위 계층에 의존하도록 설계
- 위 코드는 ArrayList라는 구체적인 구현 클래스에 의존하기때문에
ArrayList가 바뀐다거나 다른 구현체로 교체하기위해서는 코드를 수정해야 한다 ➡️DIP 위반 - 아래 코드의 경우 List라는 인터페이스(추상화)에 의존하기때문에
ArrayList를 다른 List 구현체(ex. LinkedList, CopyOnWriteArrayList)로 변경해도
코드를 수정할 필요없이 대체 가능으로 "구현이 아닌 추상화에 의존"하도록 설계 ➡️DIP 원칙 적용 - 다만 선언부만으로는 DIP 원칙을 보여준다고 생각할 순 없고 "인터페이스를 사용하는 것 이상의 설계를 포함"하는 DIP 특징으로 인터페이스를 직접 주입하거나, 구현체를 변경하기 쉽게 만드는 코드가 작성되어야한다.
▶️실습 - DIP 적용 전
class EmailService {
public void sendEmail(String msg){
System.out.println("Email : " + msg);
}
}
class MessageSender{
private final EmailService emailService;
public MessageSender() {
this.emailService = new EmailService();
}
public void send(String msg){
emailService.sendEmail(msg);
}
}
public class DIPDemo{
public static void main(String[] args) {
MessageSender sender = new MessageSender();
sender.send("DIP TEST");
}
}
- 직접적으로 연관되어 결합도가 높아져있는 상태
- 이메일로 보내는 방식 외로 SNS로 보내는 방식을 추가하고자함
class SmsService{
public void sendSms(String msg){
System.out.println("SMS : " + msg);
}
}
class MessageSender{
private final EmailService emailService;
private final SmsService smsService;
public MessageSender() {
this.emailService = new EmailService();
this.smsService = new SmsService();
}
public void send(String msg){
emailService.sendEmail(msg);
smsService.sendSms(msg);
}
}
- 기존에는 SmsService 클래스를 추가, MessageSender에서 SmsService클래스 선언
MessageSender클래스에서 생성자로 새로운 인스턴스를 생성하도록 해야한다. - 또한 send()메소드에서 smsService를 참조하여 sendSms()메소드를 호출해야한다.
➡️결합도와 의존도가 높은 것을 확인할 수 있다. - ➡️ 인터페이스를 추가하여 그 인터페이스를 참조하는 방식 사용하여 의존하는 방식을 바꿈
▶️실습 - DIP 적용 후
interface MessageService{
public void sendMessage(String msg);
}
- 인터페이스를 구현하고 공통적으로 사용될 sendMessage() 추상메소드 선언
// DIP 적용 전 코드
class EmailService {
public void sendEmail(String msg){
System.out.println("Email : " + msg);
}
}
// DIP 적용 후 코드
class EmailService implements MessageService{
@Override
public void sendMessage(String msg) {
System.out.println("Email : " + msg);
}
}
- MessageService 인터페이스를 구현하여 sendMessage()를 오버라이딩하여 사용 가능
class MessageSender{ // DIP 적용 후 코드
private final MessageService messageService;
public MessageSender(MessageService messageService) {
this.messageService = messageService;
}
public void send(String msg){
messageService.sendMessage(msg);
}
}
- MessageSender클래스 또한 인터페이스 객체를 생성하고 인터페이스를 생성하게 된다.
public static void main(String[] args) {
MessageSender emailSender = new MessageSender(new EmailService());
emailSender.send("[Email] DIP TEST");
}
- main에서도 인터페이스를 선언하고 그 인스턴스의 생성자 안에 EmailService()클래스의 생성자를 넣는다.
- 만약 SMS클래스를 추가하더라도 코드의 큰 변경없이 SMS클래스 코드만 추가해주면 될 것

▶️실습 - 파일 저장 시스템 (DIP)
interface SaveService{
public void save(String data);
}
class FileStorage implements SaveService{
@Override
public void save(String data) {
System.out.println("파일에 저장합니다.");
}
}
class DataManager{
private final SaveService saveService;
public DataManager(SaveService saveService){
this.saveService = saveService;
}
public void saveData(String data){
saveService.save(data);
}
}
// main메소드
DataManager fileStorage = new DataManager(new FileStorage());
fileStorage.saveData("[파일] 실습 데이터 저장");
- 인터페이스는 인스턴스화할 수 없으므로 SaveService 인터페이스 생성자를 호출할 수 없다.
- SaveService 인터페이스를 구현한 FileStorage / DatabaseStorage를 사용하여 인스턴스를 생성해야한다.
- DataManager는 SaveService를 주입받아 사용 ➡️DIP 을 준수하는 설계 방법
- ➡️DIP의 목표인 "결합도는 낮게, 응집도는 높게" 설계 가능
🚀실습 - 의존성 직접 생성 (DI 적용 전)
class Engine {
// ... 기능 ...
}
class Car {
private Engine engine;
public Car() {
// 의존성을 직접 생성
this.engine = new Engine();
}
// ... 기능...
}
- 이처럼 Car클래스가 Engine클래스의 구체적인 구현에 "의존"하고 있다.
- Engine을 교체하기위해서는 Car코드를 수정해야한다. (ex. GasEngine)
🚀실습 - 의존성 외부에서 주입 (DI 적용 후)
// Engine 인터페이스
interface Engine {
void start();
}
// GasEngine 구현체
class GasEngine implements Engine {
@Override
public void start() {
System.out.println("Gas engine started.");
}
}
- 이처럼 Engine을 인터페이스로 선언 후 교체될 다양한 Engine구현체를 class로 만들고 인터페이스를 구현
// Car 클래스
class Car {
private Engine engine;
// 생성자를 통해 의존성 주입
public Car(Engine engine) {
this.engine = engine;
}
public void drive() {
engine.start();
System.out.println("Car is driving.");
}
}
- Car클래스는 Engine 인터페이스에만 "의존"하도록 하여, 구체적인 구현체인 GasEngine에 대해서는 알 필요가 없다.
- Engine의 구현체를 쉽게 변경할 수 있게된다.
❓DI가 자주 쓰이는 상황
➡️대규모 애플리케이션에서 의존성 관리가 복잡한 경우
ex.Spring Framework에서 DI 컨테이너를 사용해 애플리케이션의 모든 의존성을 관리
➡️플러그 가능 아키텍처(Pluggable Architecture)
소프트웨어 시스템이 서로 다른 구성 요소(컴포넌트)를 쉽게 교체하거나 확장할 수 있도록 설계된 아키텍처
즉 구현체를 쉽게 교체할 수 있는 구조
ex. Logger 인터페이스를 통해 콘솔 로그와 파일 로그를 전환, 전기플러그
OCP와 Enum
- OCP를 지키기 위해서는 추상화와 인터페이스 활용 이를 활용하여 확장 포인트를 설계하는 것이 중요
➡️새로운 기능이 등장해도 기존 코드를 최소 수정으로 대응할 수 있다.
▶️실습 - OCP 위배 (Enum 사용)
- Enum의 장점 : 오타가 줄어들고 가독성이 좋아질 것
- Enum의 메소드
- ordinal() : 상수가 들어있고, enum의 인덱스처럼 작동하여 0부터 시작
- values() : ENUM타입의 값들을 모두 출력 ➡️ForEach문 활용
public enum PaymentType {
CREDIT_CARD, KAKAO_PAY, NAVER_PAY, GOOGLE,PAY;
}
static class OCPWrongPaymentProcessor{
public void processPayment(PaymentType type, double amount){
if(type == PaymentType.KAKAO_PAY){
System.out.println("[카카오페이] 결제 : " + amount + "원");
}else if(type == PaymentType.NAVER_PAY){
System.out.println("[네이버페이] 결제 : " + amount + "원");
}
}
}
//main
OCPWrongPaymentProcessor processor = new OCPWrongPaymentProcessor();
processor.processPayment(PaymentType.KAKAO_PAY, 10000.0);
- 타입을 직접 접근하여 조건문으로 출력
▶️실습 - OCP 준수 (인터페이스 사용)
interface PaymentMethod{
void pay(double amount);
}
static class KakaoPay implements PaymentMethod{
@Override
public void pay(double amount) {
System.out.println("[OCP 카카오페이] 결제 : " + amount + "원");
}
}
static class OCPRightPaymentProcessor{
public void processPayment(PaymentMethod method, double amount){
method.pay(amount);
}
}
// main메소드
OCPRightPaymentProcessor processor = new OCPRightPaymentProcessor();
processor.processPayment(new KakaoPay(), 10000.0);
- PaymentMethod 인터페이스를 구현하는 여러개의 클래스들 중 하나를 new 생성자로 만든다.
- 만약 결제방법을 추가해주어도 PaymentMethod 인터페이스만 구현해주면 되기때문에
➡️수정에는 닫혀있고 확장에는 용이
POJO
- Plain Old Java Object
- 프레임워크나 라이브러리에 “의존하지 않는” 순수 자바 객체
- 특별한 규칙 (특정 상속, 애노테이션, 인터페이스 등) 없이 자바 기본문법만으로 작성
- SRP 단일 책임 원칙 구현에 용이
public class PojoNormal {
class Student{
private String name;
private int grade;
public Student(String name, int grade) {
this.name = name;
this.grade = grade;
}
public String getName() {
return name;
}
}
class PojoMain{
public void main(String[] args) {
Student student = new Student("Barnes", 3);
}
}
}
- 어떠한 프레임워크에도 의존하지 않는 순수자바객체를 보여줌
JUnit
- 단위 테스트를 의미
- assertTrue(), assertFalse() 와 같은 메소드를 제공 ➡️자체적으로 테스트 수행 후 결과 반환 가능
- 장점 : JUnit을 통해 프로젝트 완료 전 미리 테스트하여 프로젝트 완료 후에 테스트하는 비용보다 적은 비용이 발생
디자인패턴 Design Pattern
- 소프트웨어 설계 시 자주 등장하는 문제 상황을 해결하기 위해 “검증된 설계 기법 (=템플릿)”을 “정형화”한 것
- 객체지향원리의 극대화 (낮은 결합도, 높은 응집도, 캡슐화, 추상화등을 실천)
- 디자인 패턴의 종류는 GoF가 정리한 23가지 패턴이 대표적
- 생성패턴 (Creational), 구조패턴(Structural), 행위패턴(Behavioral)
- 단점 :
- 패턴 오남용 : 단순한 문제에서도 불필요하게 패턴을 사용하여 코드 복잡도 증가
- 23가지 패턴을 모두 익히려면 시간이 필요
- 케이스 별 다른 적용 : 패턴들이 정답이 아니므로 상황에 따라 변형, 조합할 줄 알아야 함
❓디자인패턴을 알아야하는 이유
➡️ 동일한 일을 두번 다시 하지 않고 해결할 수 있도록 검증된 템플릿을 제공
구체적인 설명 없이 구조화된 패턴에 대한 사전 지식으로 개발자 간에 원활한 커뮤니케이션을 도와줌
Singleton 패턴
- 아파트 관리인같이, 여러 세대가 호출해도 동일한 인스턴스가 응답하도록 “보장”
- 가장 단순하면서 가장 많이 사용되는 패턴 중 하나
// 하나만 생성되어야하는 객체
class Singleton{
// 1. 외부에서 접근할 수 있도록 자기 자신을 가리키는 객체 선언 (클래스 내부에 자신의 유일한 인스턴스를 저장)
private static Singleton singleton;
// 2. 외부에서는 생성할 수 없도록 private한 생성자 선언
private Singleton(){
System.out.println("Singleton 생성");
}
// 3. 자신을 리턴하는 static한 메소드 (getInstance()메소드 이름은 정하기 나름)
public static Singleton getInstance(){
if(singleton == null){ // 인스턴스가 없는 경우 (한 번도 생성되지 않았음)
singleton = new Singleton(); // 객체 생성
}
return singleton; // 인스턴스가 있는 경우 자신을 바로 리턴
}
}
- 싱글톤 패턴을 정의하고 외부에서는 접근할 수 없는 자기 자신의 객체를 선언
(=생성자를 public이 아닌 private 지정자 사용) - getInstance()메소드에서 자기 자신을 리턴시킬 수 있도록 정의
// Singleton 패턴 만든 후의 방법
Singleton singleton1 = Singleton.getInstance();
Singleton singleton2 = Singleton.getInstance();
if(singleton1 == singleton2){
System.out.println("같은 객체입니다.");
}else System.out.println("다른 객체입니다.");
- getInstance() 를 이용하여 하나의 객체를 가져옴 ➡️싱글톤은 여러개가 만들어지는 경우를 방지
- 각기 다른 인스턴스이 여러 개 생기는 문제를 해결할 수 있다.
▶️실습 - Logger
class Logger{
private static Logger instance;
// 생성자를 public이 아닌 private 지정자 사용
private Logger(){
System.out.println("Logger 인스턴스 생성");
}
public static synchronized Logger getInstance(){
if(instance == null){
instance = new Logger();
}
return instance;
}
public void log(String message){
System.out.println("[LOG] : " + message);
}
}
class LoggerUserA{
public void loggerTesterA(){
Logger logger = Logger.getInstance();
logger.log("LoggerUserA가 Logger를 이용해서 Log를 남깁니다.");
}
}
class LoggerUserB{
public void loggerTesterB(){
Logger logger = Logger.getInstance();
logger.log("LoggerUserB가 Logger를 이용해서 Log를 남깁니다.");
}
}
- Logger는 하나만 만들어져도 각기 다른 유저(=클래스)가 Logger객체를 생성해서 사용 가능
public static void main(String[] args) {
Logger logger1 = Logger.getInstance();
logger1.log("첫번째 로그 메시지");
Logger logger2 = Logger.getInstance();
logger2.log("두번째 로그 메시지");
System.out.println("logger1 == logger2 : " + (logger1 == logger2));
new LoggerUserA().loggerTesterA();
new LoggerUserB().loggerTesterB();
}
- new LoggerUserA().loggerTesterA() :
LoggerUserA클래스 인스턴스를 생성하여 그 클래스의 loggerTesterA()메소드를 호출

싱글톤 패턴 종류
- EagerSingleton - 미리 만들어놓고 쓰기 (프로그램 시작 시점에 인스턴스 생성), (ex.스프링)
- LazySingleton - 필요할때 만들어짐 (실제 필요해질때까지 인스턴스 생성을 지연)
- DoubleCheckedLockingSingleton - 생성이 여러번 되지 않게하는 syncronized 키워드 사용
1. 메소드 안에서 특정 기능의 부분에만 syncronized 블록처리
2. 메소드 전체를 syncronized로 선언
DoubleCheckedLockingSingleton 패턴은 멀티스레드 동시 접근시에도 안전하게 하나만 생성하게 함 - StaticInnerClassSingleton - 정적 내부클래스 로딩시점에서 인스턴스를 호출
EagerSingleton
public class EagerSingleton {
// static으로 메모리 상에 미리 만들어짐
private static final EagerSingleton INSTANCE = new EagerSingleton();
private EagerSingleton(){}
public static EagerSingleton getInstance(){
return INSTANCE;
}
}
- 동기화하지 않아도 스레드가 안전하며 간단
- 미리 인스턴스가 생성되어 사용하지 않을때 메모리 낭비될 수도 있음
LazySingleton
public class LazySingleton {
private static LazySingleton instance;
private LazySingleton(){}
public static synchronized LazySingleton getInstance(){
if(instance == null){
instance = new LazySingleton();
}
return instance;
}
}
- syncronized 키워드를 사용하여 getInstance()메소드를 호출할때가 되어서야 Singleton인스턴스가 생성
- 호출마다 동기화되기때문에 “성능의 저하”가 발생할 수 있음
Double-Checked Locking Singleton
- 초기화후에는 synchronized 키워드 사용이 필요 없으므로 성능이 개선됨
- DCL 싱글톤이라고도 불리는 패턴으로 "코드가 조금 복잡하다"
- volatile : 하나의 프로세스 안에 있는 각각의 스레드는 스택 공간, 스레드 외 영역의 프로세스는 힙 공간
1. 각각의 스레드 영역에서 관리하지 않고 전체 프로세스 영역에서 관리하게끔 함
2. 가시성 보장 : 모든 스레드가 메모리에서 최신 값을 읽도록 강제
🚀실습 - volatile 키워드의 역할
public class Singleton {
// volatile 키워드로 선언
private static volatile Singleton instance;
// private 생성자
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 첫 번째 체크
synchronized (Singleton.class) { // syncronized
if (instance == null) { // 두 번째 체크
instance = new Singleton(); // 객체 초기화
}
}
}
return instance;
}
}
- 첫번째 체크에서 null이 아니면 동기화 블록(syncronized)으로 진입한다.
❓메소드 전체에 선언하지 않은 블록에서 동기화 사용한 이유 ➡️ 비용절감을 위해 동기화를 최소화
매번 메소드 호출마다 동기화되지않고 synchronized 블록 부분만 호출이 되기때문에 성능개선가능 - 두번째 체크에서 동기화 블록 내 다시 체크하여 "여러 스레드가 동시에 객체를 생성하지 못하도록 방지"
- volatile : 객체 초기화가 완료되기 전에 "다른 스레드가 instance를 참조하지 못하게 보장"
StaticInnerClassSingleton
public class StaticInnerClassSingleton {
private StaticInnerClassSingleton(){}
private static class Holder{
private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
}
public static StaticInnerClassSingleton getInstance(){
return Holder.INSTANCE;
}
}
- 인스턴스 생성을 지연시켜 스레드를 안전하게 함
- 해당 클래스를 호출하는 시점에서 로딩이 일어나고(지연 로딩) 그때 내부 인스턴스가 static으로 초기화
- 싱글톤 객체 생성 시 별도의 동기화 처리를 하지 않아도 클래스 로딩되는 과정 자체가
JVM에서 동기화 환경을 제공하여 멀티 스레드 환경에서도 안전한 것
➡️ sychronized 키워드를 사용하지 않아도 스레드가 안전
- 단점 : 내부 클래스의 로딩시점을 이해해야함
❓싱글톤 패턴 장단점
➡️장점 : 메모리 절약, 접근성, 데이터 일관성 보장
➡️단점 : 테스트의 어려움, 멀티스레드 환경에 문제발생가능, 객체 지향적이지 않음
❓싱글톤 패턴은 SRP원칙(=단일책임원칙)을 지키는 것일까
➡️ 싱글톤 패턴은 “주요 기능 수행”과 “하나의 인스턴스만 생성” 이라는 두 가지 책임이 있기 때문에
SRP를 위반할 수 있고, 의존 관계상 클라이언트가 구현체에 의존하여 DIP, OCP 또한 위반할 가능성이 높다.
두가지 책임이란
- Primary Function(주요 기능): 클래스가 싱글톤으로 설계되지 않았더라도 클래스가 맡아야할 책임
ex. Logger 클래스의 "로그를 기록하는 기능"
- 인스턴스 제어 : 인스턴스가 하나만 생성되도록 강제하는 역할 ➡️객체 생성 관리라는 "책임"
❓싱글톤 패턴이 OCP, DIP를 위반하는 이유
➡️ OCP : 클래스가 확장에는 열려 있어야 하지만, 변경에는 닫혀 있어야하지만
싱글톤 클래스에서 새로운 기능을 추가하려면 기존 클래스를 수정해야 하므로 OCP를 위반
➡️DIP : 싱글톤 클래스는 종종 직접적으로 인스턴스에 의존하기 때문에 DIP를 위반
Adapter 어댑터 패턴
- 구조적 디자인 패턴
- 서로 다른 형태의 “인터페이스”를 연결
- 기존 코드(Adaptee)와 새 코드 (클라이언트) 간 인터페이스가 다르면 중간에 Adapter를 두어
“서로 인터페이스를 맞춰주는” 방식 - Wrapper라고도 함 - 기존 객체를 감싸서 새로운 인터페이스로 보이게 만들기때문
▶️실습 - 드라이기 어댑터 변환기
// Adaptee -- 기존 코드 (집에서 가져간 드라이기)
class Power220v{
public void connect(){
System.out.println("[드라이기 연결완료] 머리 말리는 중...");
}
}
// target -- 클라이언트가 기대하는 인터페이스 110v
interface Power110v{
void supply();
}
// Adapter -- Adaptee의 인터페이스를 Target의 인터페이스로 변환해주는 클래스
class PowerAdapter implements Power110v{
private Power220v power220v; // 기존 코드
public PowerAdapter(Power220v power220v){
this.power220v = power220v;
}
@Override
public void supply() {
System.out.println("어댑터 변환 완료 : [220v] -> [110v]");
power220v.connect();
}
}
- Adapter를 두어 변환하고자 하는 인터페이스(Power110v)를 구현
- 기존코드(Power220v)를 필드로 선언 후 생성자에서 갱신
- 변환하고자 하는 인터페이스(Power110v)의 메소드인 supply()메소드를 오버라이딩하고
기존코드(Power220v)의 메소드인 connect()를 사용할 수 있도록 한다.
//main
// 1. 집에서 가져간 드라이기
Power220v power220v = new Power220v();
// 2. 어댑터에 끼워서 110v로 변환 후 사용
Power110v adapter = new PowerAdapter(power220v);
adapter.supply();
- Power110v 인터페이스 타입으로 선언한 PowerAdapter 인스턴스의 생성자로 power220v를 인자로 전달

❓어댑터 패턴 활용사례
➡️레거시 코드 (=사용하고있는코드, 오래된코드)에 추가된 새로운 라이브러리나 코드 등이 함께 사용되어야할때
레거시 코드를 수정하지 않고도 어댑터를 두어 새로운 시스템에서도 잘 작동할 수 있도록 도와주는 것이 어댑터 패턴
Decorator 데코레이터 패턴
- 구조적 패턴
- 객체들 간 관계를 통해 기존 객체에 “새로운 기능”을 유연하게 덧붙이는 패턴 (장식 패턴)
- 기존객체(기능)에 새로운 책임(기능)을 추가하되 기존코드는 크게 수정하지 않도록함
- 무한히 상속하여 늘리기보단 장식을 얹듯이 기능을 계층적으로 붙임
➡️여러 데코레이터를 체인 형태로 연결 (ex. 생크림 케이크 객체에 “바닐라 소스 데코레이터 → 딸기 데코레이터”)
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
- 이처럼 자바 IO에서도 데코레이터 패턴이 사용
- Wrapper패턴이라고도 불림 ➡️어댑터의 Wrapper와는 다른 의미
어댑터 패턴의 Wrapper : 변환해주는 역할 / 데코레이터 패턴의 Wrapper : 기능을 확장해주는 역할 - 장점 :
- 상속 없이 동적으로 기능을 조합해 기존 코드를 수정하지 않고 확장 가능
- 데코레이터 들은 각각 SRP, OCP원칙을 지킴
- GUI 프레임워크에서 버튼, 스크롤 등을 사용할때 활용 가능 - 단점 :
- 클래스가 많아져서 코드 복잡성 증가
- 중첩되는 데코레이터로 인해 디버깅의 어려움
▶️실습 - 커피 주문
- 커피 레시피
- 에스프레소 + 물 = 아메리카노
- 에스프레소 + 우유 = 라떼 등
- 에스프레소 + 우유 + 시럽 + 생크림 = ? 처럼 추가 가능
// 모든 커피의 기본이 되는 공통 인터페이스 (컴포넌트 인터페이스)
interface Coffee{
String getDescription(); // 커피 설명
int getCost(); // 가격
}
- 에스프레소 : 모든 커피의 기본이되는 공통 인터페이스 = “컴포넌트 인터페이스”
// 가장 기본적인 커피 (에스프레소) 구현 - (기본 컴포넌트)
class Espresso implements Coffee{
@Override
public String getDescription() {
return "에스프레소";
}
@Override
public int getCost() {
return 3000;
}
}
- 에스프레소만 구현한 클래스 = “기본 컴포넌트”
// 장식클래스 즉, 표준적인 데코레이터를 추상클래스
abstract class CoffeeDecorator implements Coffee{
protected Coffee decoratorCoffee; // 상속관계에서는 사용할 수 있도록 protected선언
public CoffeeDecorator(Coffee coffee){ // 생성자
this.decoratorCoffee = coffee;
}
// 실체가 들어오면 실체가 가진 메소드 (커피설명)를 가져오게끔 함
@Override
public String getDescription() {
return decoratorCoffee.getDescription();
}
// 실체가 들어오면 실체가 가진 메소드 (가격)을 가져오게끔 함
@Override
public int getCost() {
return decoratorCoffee.getCost();
}
}
- 장식클래스들의 표준적인 형태를 정의한 추상클래스 = “Decorator 추상 클래스”
- Decorator 추상 클래스 : 다른 구체적 장식 클래스에서 상속을 받고 그 안에서 구체적으로 장식하는 기능 정의
- 생성자에서 CoffeeDecorator(Coffee coffee)의 경우 데코레이터(장식)은 혼자 생성될 수 없으므로
매개변수로 꾸며줄 객체(주인공)를 받고 있다.
// 구체적인 장식이 되는 클래스
class MilkDecorator extends CoffeeDecorator{
// 부모클래스인 추상클래스 CoffeeDecorator에 super(coffee)로 보냄
public MilkDecorator(Coffee coffee) {
super(coffee);
}
@Override
public String getDescription() {
return super.getDescription() + " (+우유) ";
}
// 추가되면 가격 추가
@Override
public int getCost() {
return super.getCost() + 500;
}
}
- 구체적인 장식 클래스 = “ConcreteDecorator”
- 기존 커피의 기능을 확장할 옵션을 추가
// 기본 커피
Coffee espresso = new Espresso();
// 에스프레소 + 우유
// 커피를 생성자로 받아야하므로 Coffee타입의 espresso가 들어갈 수 있음
Coffee latte = new MilkDecorator(espresso);
- 바깥 데코레이터 + 안의 (꾸며줄 객체 에스프레소)
▶️실습 - 커피에 우유와 시럽을 둘다 추가 (장식)
// 에스프레소 + 시럽
Coffee espressoSyrup = new SyrupDecorator(espresso);
// 에스프레소 + 우유 + 시럽
Coffee espressoMS = new MilkDecorator(new SyrupDecorator(espresso));
- MilkDecorator안의 SyrupDecorator, 그 안에 espresso
➡️순서대로 espresso → SyrupDecorator → MilkDecorator로 출력

Observer 옵저버 패턴
- 행위 패턴 중 하나
- 객체의 상태가 변하면 연결된 Observer들에게 자동으로 알리는 디자인 패턴 (ex.유튜브구독)
- 주제 (Subject) : 중요한 소식이나 이벤트를 발생시키는 객체
- 옵저버 (Observer) : 주제의 상태가 바뀌면 등록된 옵저버 들에게 즉시 알림 변화를 알려줌
- 장점 : 느슨한 연결을 유지 ➡️주제는 옵저버가 무슨일을 하는지 내부구현을 알 필요가 없음
- 발행-구독 패턴이라고도 함 (발행(Publish) : 주제가 변화를 알림, 구독(Subscribe) : 옵저버가 그 알림을 수신)
ex. 날씨 정보 시스템 (기상청 데이터(주제)가 업데이트 ↔ 여러화면/앱(옵저버)가 즉시 반영)
▶️실습 - 유튜브 채널 구독 시스템
// 1. 옵저버 인터페이스 (Observer)
interface Observer{
void update(String message);
}
// 2. 주제 인터페이스 (Subject)
interface Subject {
// 해야되는 일들을 등록
// 1) 구독 등록 (관찰자 등록)
void registerObserver(Observer observer);
// 2) 구독 제거 (관찰자 제거)
void removeObserver(Observer observer);
// 3) 구독 알림 (관찰자 알림)
void notifyObserver();
}
// 3. 주제 인터페이스를 구현하는 구체적인 클래스 (Channel)
class YoutubeChannel implements Subject {
private String channelName; // 유튜브 채널 이름
private List<Observer> observers; // 구독자들을 저장하는 리스트
// 채널이 생성될때 채널 이름을 받도록 함
public YoutubeChannel(String channelName) {
this.channelName = channelName;
this.observers = new ArrayList<>();
}
// ... 오버라이딩 메소드들...
// 영상 업로드 메소드
public void uploadVideo(){
notifyObserver();
}
}
// 4. 옵저버 인터페이스를 구현하는 구체적인 클래스 (Subscriber)
class Subscriber implements Observer{
private String name; // 구독자 이름
public Subscriber(String name) {
this.name = name;
}
@Override
public void update(String message) {
System.out.println(name + "님, 알림 도착! - " + message);
}
}
YoutubeChannel channel = new YoutubeChannel("LEFT Coding"); // 유튜브 채널 생성
Subscriber subscriber1 = new Subscriber("Kolalov");
Subscriber subscriber2 = new Subscriber("Lautaro");
Subscriber subscriber3 = new Subscriber("Desaily");
channel.registerObserver(subscriber1);
channel.registerObserver(subscriber2);
channel.registerObserver(subscriber3);
channel.removeObserver(subscriber3);
channel.uploadVideo();
- 옵저버와 주제 인터페이스를 만들고, 각각의 인터페이스를 구현하는 클래스를 만든다.
- 주제 인터페이스를 구현하는 클래스 (채널 클래스)는 "영상 업로드 메소드 uploadVideo()"를 통해 notifyObserver()를 호출하고
- notifyObserver() 메소드를 오버라이딩한 채널 클래스에서 update()메소드를 호출하도록 하여 알림을 보내게된다.

💡각 디자인 패턴들이 OCP, DIP 등의 원칙을 잘 지키는 경우도 있고 안지켜지는 경우도 있었다.
회고를 작성하면서 디자인 패턴들에서 이해가 조금 어려웠던 부분들은 따로 검색하고 강의를 찾아보기도 하였다.
특히나 Double-Checked Locking Singleton패턴은 왜 사용하는지 부터 volatile 키워드 사용까지 이해하기가 쉽지 않았다.
아직 각 패턴별로 하지 못한 실습들을 시간 날 때 마무리 해봐야겠다고 생각했다.🚀
'Recording > 멋쟁이사자처럼 BE 13기' 카테고리의 다른 글
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_22일차_"HTML과 CSS" (3) | 2025.01.02 |
---|---|
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_21일차_''프론트엔드 시작" (2) | 2024.12.31 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_19일차_''객체지향원칙 OOP" (4) | 2024.12.27 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_18일차_''스레드 Thread" (0) | 2024.12.26 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_17일차_''Java IO" (1) | 2024.12.24 |
🦁멋쟁이사자처럼 백엔드 부트캠프 13기 🦁
TIL 회고 - [20]일차
🚀20일차에는 객체지향설계원칙 중 DIP를 배우고, 디자인 패턴을 본격적으로 학습할 수 있었다.
디자인 패턴을 사용하는 이유와 디자인패턴의 종류를 실습을 통해서 익힐 수 있어서 이해에 도움이되었다.
디자인 패턴 개념을 단순히 공부할때는 왜 사용해야하고 어떤 코드로 동작할까를 짐작할 수 없었는데 디자인 패턴에 대해서 더 자세히 알 수 있게 되었다.
물론 어댑터 패턴과 옵저버 패턴을 좀 더 공부해야겠다는 생각도 들었다.
DIP
- 객체지향 설계원칙 중 DIP (Dependency Inversion Principle) = 의존성 역전 원칙
- 객체에서 어떤 Class를 참조해서 사용해야하는 상황이 생긴다면 직접 참조하지 않고
대상의 상위 요소(추상 클래스 or 인터페이스)로 참조하라는 원칙 - 객체 간의 의존 관계를 외부에서 주입하는 설계 패턴
- 객체가 필요한 의존성을 직접 생성하거나 관리하는 대신, 외부에서 제공받음
➡️객체 간의 결합도를 줄이고 코드의 유연성과 재사용성을 높일 수 있음 - 의존성(Dependency): 클래스가 정상적으로 작동하기 위해 필요한 객체
(ex. Car 클래스가 Engine 클래스에 의존할때, (Engine = 의존성)) - 주입(Injection): 의존성을 클래스 내부에서 생성하지 않고, 외부에서 전달받음
▶️예시 - DIP를 보여주는 단적인 예시
ArrayList<T> dip = new ArrayList<>(); // 일반적인 경우
List<T> dip = new ArrayList<>(); // DIP 원칙
- DIP의 핵심은 구체적인 구현 클래스에 의존하지 않고, 추상화된 인터페이스나 상위 계층에 의존하도록 설계
- 위 코드는 ArrayList라는 구체적인 구현 클래스에 의존하기때문에
ArrayList가 바뀐다거나 다른 구현체로 교체하기위해서는 코드를 수정해야 한다 ➡️DIP 위반 - 아래 코드의 경우 List라는 인터페이스(추상화)에 의존하기때문에
ArrayList를 다른 List 구현체(ex. LinkedList, CopyOnWriteArrayList)로 변경해도
코드를 수정할 필요없이 대체 가능으로 "구현이 아닌 추상화에 의존"하도록 설계 ➡️DIP 원칙 적용 - 다만 선언부만으로는 DIP 원칙을 보여준다고 생각할 순 없고 "인터페이스를 사용하는 것 이상의 설계를 포함"하는 DIP 특징으로 인터페이스를 직접 주입하거나, 구현체를 변경하기 쉽게 만드는 코드가 작성되어야한다.
▶️실습 - DIP 적용 전
class EmailService {
public void sendEmail(String msg){
System.out.println("Email : " + msg);
}
}
class MessageSender{
private final EmailService emailService;
public MessageSender() {
this.emailService = new EmailService();
}
public void send(String msg){
emailService.sendEmail(msg);
}
}
public class DIPDemo{
public static void main(String[] args) {
MessageSender sender = new MessageSender();
sender.send("DIP TEST");
}
}
- 직접적으로 연관되어 결합도가 높아져있는 상태
- 이메일로 보내는 방식 외로 SNS로 보내는 방식을 추가하고자함
class SmsService{
public void sendSms(String msg){
System.out.println("SMS : " + msg);
}
}
class MessageSender{
private final EmailService emailService;
private final SmsService smsService;
public MessageSender() {
this.emailService = new EmailService();
this.smsService = new SmsService();
}
public void send(String msg){
emailService.sendEmail(msg);
smsService.sendSms(msg);
}
}
- 기존에는 SmsService 클래스를 추가, MessageSender에서 SmsService클래스 선언
MessageSender클래스에서 생성자로 새로운 인스턴스를 생성하도록 해야한다. - 또한 send()메소드에서 smsService를 참조하여 sendSms()메소드를 호출해야한다.
➡️결합도와 의존도가 높은 것을 확인할 수 있다. - ➡️ 인터페이스를 추가하여 그 인터페이스를 참조하는 방식 사용하여 의존하는 방식을 바꿈
▶️실습 - DIP 적용 후
interface MessageService{
public void sendMessage(String msg);
}
- 인터페이스를 구현하고 공통적으로 사용될 sendMessage() 추상메소드 선언
// DIP 적용 전 코드
class EmailService {
public void sendEmail(String msg){
System.out.println("Email : " + msg);
}
}
// DIP 적용 후 코드
class EmailService implements MessageService{
@Override
public void sendMessage(String msg) {
System.out.println("Email : " + msg);
}
}
- MessageService 인터페이스를 구현하여 sendMessage()를 오버라이딩하여 사용 가능
class MessageSender{ // DIP 적용 후 코드
private final MessageService messageService;
public MessageSender(MessageService messageService) {
this.messageService = messageService;
}
public void send(String msg){
messageService.sendMessage(msg);
}
}
- MessageSender클래스 또한 인터페이스 객체를 생성하고 인터페이스를 생성하게 된다.
public static void main(String[] args) {
MessageSender emailSender = new MessageSender(new EmailService());
emailSender.send("[Email] DIP TEST");
}
- main에서도 인터페이스를 선언하고 그 인스턴스의 생성자 안에 EmailService()클래스의 생성자를 넣는다.
- 만약 SMS클래스를 추가하더라도 코드의 큰 변경없이 SMS클래스 코드만 추가해주면 될 것

▶️실습 - 파일 저장 시스템 (DIP)
interface SaveService{
public void save(String data);
}
class FileStorage implements SaveService{
@Override
public void save(String data) {
System.out.println("파일에 저장합니다.");
}
}
class DataManager{
private final SaveService saveService;
public DataManager(SaveService saveService){
this.saveService = saveService;
}
public void saveData(String data){
saveService.save(data);
}
}
// main메소드
DataManager fileStorage = new DataManager(new FileStorage());
fileStorage.saveData("[파일] 실습 데이터 저장");
- 인터페이스는 인스턴스화할 수 없으므로 SaveService 인터페이스 생성자를 호출할 수 없다.
- SaveService 인터페이스를 구현한 FileStorage / DatabaseStorage를 사용하여 인스턴스를 생성해야한다.
- DataManager는 SaveService를 주입받아 사용 ➡️DIP 을 준수하는 설계 방법
- ➡️DIP의 목표인 "결합도는 낮게, 응집도는 높게" 설계 가능
🚀실습 - 의존성 직접 생성 (DI 적용 전)
class Engine {
// ... 기능 ...
}
class Car {
private Engine engine;
public Car() {
// 의존성을 직접 생성
this.engine = new Engine();
}
// ... 기능...
}
- 이처럼 Car클래스가 Engine클래스의 구체적인 구현에 "의존"하고 있다.
- Engine을 교체하기위해서는 Car코드를 수정해야한다. (ex. GasEngine)
🚀실습 - 의존성 외부에서 주입 (DI 적용 후)
// Engine 인터페이스
interface Engine {
void start();
}
// GasEngine 구현체
class GasEngine implements Engine {
@Override
public void start() {
System.out.println("Gas engine started.");
}
}
- 이처럼 Engine을 인터페이스로 선언 후 교체될 다양한 Engine구현체를 class로 만들고 인터페이스를 구현
// Car 클래스
class Car {
private Engine engine;
// 생성자를 통해 의존성 주입
public Car(Engine engine) {
this.engine = engine;
}
public void drive() {
engine.start();
System.out.println("Car is driving.");
}
}
- Car클래스는 Engine 인터페이스에만 "의존"하도록 하여, 구체적인 구현체인 GasEngine에 대해서는 알 필요가 없다.
- Engine의 구현체를 쉽게 변경할 수 있게된다.
❓DI가 자주 쓰이는 상황
➡️대규모 애플리케이션에서 의존성 관리가 복잡한 경우
ex.Spring Framework에서 DI 컨테이너를 사용해 애플리케이션의 모든 의존성을 관리
➡️플러그 가능 아키텍처(Pluggable Architecture)
소프트웨어 시스템이 서로 다른 구성 요소(컴포넌트)를 쉽게 교체하거나 확장할 수 있도록 설계된 아키텍처
즉 구현체를 쉽게 교체할 수 있는 구조
ex. Logger 인터페이스를 통해 콘솔 로그와 파일 로그를 전환, 전기플러그
OCP와 Enum
- OCP를 지키기 위해서는 추상화와 인터페이스 활용 이를 활용하여 확장 포인트를 설계하는 것이 중요
➡️새로운 기능이 등장해도 기존 코드를 최소 수정으로 대응할 수 있다.
▶️실습 - OCP 위배 (Enum 사용)
- Enum의 장점 : 오타가 줄어들고 가독성이 좋아질 것
- Enum의 메소드
- ordinal() : 상수가 들어있고, enum의 인덱스처럼 작동하여 0부터 시작
- values() : ENUM타입의 값들을 모두 출력 ➡️ForEach문 활용
public enum PaymentType {
CREDIT_CARD, KAKAO_PAY, NAVER_PAY, GOOGLE,PAY;
}
static class OCPWrongPaymentProcessor{
public void processPayment(PaymentType type, double amount){
if(type == PaymentType.KAKAO_PAY){
System.out.println("[카카오페이] 결제 : " + amount + "원");
}else if(type == PaymentType.NAVER_PAY){
System.out.println("[네이버페이] 결제 : " + amount + "원");
}
}
}
//main
OCPWrongPaymentProcessor processor = new OCPWrongPaymentProcessor();
processor.processPayment(PaymentType.KAKAO_PAY, 10000.0);
- 타입을 직접 접근하여 조건문으로 출력
▶️실습 - OCP 준수 (인터페이스 사용)
interface PaymentMethod{
void pay(double amount);
}
static class KakaoPay implements PaymentMethod{
@Override
public void pay(double amount) {
System.out.println("[OCP 카카오페이] 결제 : " + amount + "원");
}
}
static class OCPRightPaymentProcessor{
public void processPayment(PaymentMethod method, double amount){
method.pay(amount);
}
}
// main메소드
OCPRightPaymentProcessor processor = new OCPRightPaymentProcessor();
processor.processPayment(new KakaoPay(), 10000.0);
- PaymentMethod 인터페이스를 구현하는 여러개의 클래스들 중 하나를 new 생성자로 만든다.
- 만약 결제방법을 추가해주어도 PaymentMethod 인터페이스만 구현해주면 되기때문에
➡️수정에는 닫혀있고 확장에는 용이
POJO
- Plain Old Java Object
- 프레임워크나 라이브러리에 “의존하지 않는” 순수 자바 객체
- 특별한 규칙 (특정 상속, 애노테이션, 인터페이스 등) 없이 자바 기본문법만으로 작성
- SRP 단일 책임 원칙 구현에 용이
public class PojoNormal {
class Student{
private String name;
private int grade;
public Student(String name, int grade) {
this.name = name;
this.grade = grade;
}
public String getName() {
return name;
}
}
class PojoMain{
public void main(String[] args) {
Student student = new Student("Barnes", 3);
}
}
}
- 어떠한 프레임워크에도 의존하지 않는 순수자바객체를 보여줌
JUnit
- 단위 테스트를 의미
- assertTrue(), assertFalse() 와 같은 메소드를 제공 ➡️자체적으로 테스트 수행 후 결과 반환 가능
- 장점 : JUnit을 통해 프로젝트 완료 전 미리 테스트하여 프로젝트 완료 후에 테스트하는 비용보다 적은 비용이 발생
디자인패턴 Design Pattern
- 소프트웨어 설계 시 자주 등장하는 문제 상황을 해결하기 위해 “검증된 설계 기법 (=템플릿)”을 “정형화”한 것
- 객체지향원리의 극대화 (낮은 결합도, 높은 응집도, 캡슐화, 추상화등을 실천)
- 디자인 패턴의 종류는 GoF가 정리한 23가지 패턴이 대표적
- 생성패턴 (Creational), 구조패턴(Structural), 행위패턴(Behavioral)
- 단점 :
- 패턴 오남용 : 단순한 문제에서도 불필요하게 패턴을 사용하여 코드 복잡도 증가
- 23가지 패턴을 모두 익히려면 시간이 필요
- 케이스 별 다른 적용 : 패턴들이 정답이 아니므로 상황에 따라 변형, 조합할 줄 알아야 함
❓디자인패턴을 알아야하는 이유
➡️ 동일한 일을 두번 다시 하지 않고 해결할 수 있도록 검증된 템플릿을 제공
구체적인 설명 없이 구조화된 패턴에 대한 사전 지식으로 개발자 간에 원활한 커뮤니케이션을 도와줌
Singleton 패턴
- 아파트 관리인같이, 여러 세대가 호출해도 동일한 인스턴스가 응답하도록 “보장”
- 가장 단순하면서 가장 많이 사용되는 패턴 중 하나
// 하나만 생성되어야하는 객체
class Singleton{
// 1. 외부에서 접근할 수 있도록 자기 자신을 가리키는 객체 선언 (클래스 내부에 자신의 유일한 인스턴스를 저장)
private static Singleton singleton;
// 2. 외부에서는 생성할 수 없도록 private한 생성자 선언
private Singleton(){
System.out.println("Singleton 생성");
}
// 3. 자신을 리턴하는 static한 메소드 (getInstance()메소드 이름은 정하기 나름)
public static Singleton getInstance(){
if(singleton == null){ // 인스턴스가 없는 경우 (한 번도 생성되지 않았음)
singleton = new Singleton(); // 객체 생성
}
return singleton; // 인스턴스가 있는 경우 자신을 바로 리턴
}
}
- 싱글톤 패턴을 정의하고 외부에서는 접근할 수 없는 자기 자신의 객체를 선언
(=생성자를 public이 아닌 private 지정자 사용) - getInstance()메소드에서 자기 자신을 리턴시킬 수 있도록 정의
// Singleton 패턴 만든 후의 방법
Singleton singleton1 = Singleton.getInstance();
Singleton singleton2 = Singleton.getInstance();
if(singleton1 == singleton2){
System.out.println("같은 객체입니다.");
}else System.out.println("다른 객체입니다.");
- getInstance() 를 이용하여 하나의 객체를 가져옴 ➡️싱글톤은 여러개가 만들어지는 경우를 방지
- 각기 다른 인스턴스이 여러 개 생기는 문제를 해결할 수 있다.
▶️실습 - Logger
class Logger{
private static Logger instance;
// 생성자를 public이 아닌 private 지정자 사용
private Logger(){
System.out.println("Logger 인스턴스 생성");
}
public static synchronized Logger getInstance(){
if(instance == null){
instance = new Logger();
}
return instance;
}
public void log(String message){
System.out.println("[LOG] : " + message);
}
}
class LoggerUserA{
public void loggerTesterA(){
Logger logger = Logger.getInstance();
logger.log("LoggerUserA가 Logger를 이용해서 Log를 남깁니다.");
}
}
class LoggerUserB{
public void loggerTesterB(){
Logger logger = Logger.getInstance();
logger.log("LoggerUserB가 Logger를 이용해서 Log를 남깁니다.");
}
}
- Logger는 하나만 만들어져도 각기 다른 유저(=클래스)가 Logger객체를 생성해서 사용 가능
public static void main(String[] args) {
Logger logger1 = Logger.getInstance();
logger1.log("첫번째 로그 메시지");
Logger logger2 = Logger.getInstance();
logger2.log("두번째 로그 메시지");
System.out.println("logger1 == logger2 : " + (logger1 == logger2));
new LoggerUserA().loggerTesterA();
new LoggerUserB().loggerTesterB();
}
- new LoggerUserA().loggerTesterA() :
LoggerUserA클래스 인스턴스를 생성하여 그 클래스의 loggerTesterA()메소드를 호출

싱글톤 패턴 종류
- EagerSingleton - 미리 만들어놓고 쓰기 (프로그램 시작 시점에 인스턴스 생성), (ex.스프링)
- LazySingleton - 필요할때 만들어짐 (실제 필요해질때까지 인스턴스 생성을 지연)
- DoubleCheckedLockingSingleton - 생성이 여러번 되지 않게하는 syncronized 키워드 사용
1. 메소드 안에서 특정 기능의 부분에만 syncronized 블록처리
2. 메소드 전체를 syncronized로 선언
DoubleCheckedLockingSingleton 패턴은 멀티스레드 동시 접근시에도 안전하게 하나만 생성하게 함 - StaticInnerClassSingleton - 정적 내부클래스 로딩시점에서 인스턴스를 호출
EagerSingleton
public class EagerSingleton {
// static으로 메모리 상에 미리 만들어짐
private static final EagerSingleton INSTANCE = new EagerSingleton();
private EagerSingleton(){}
public static EagerSingleton getInstance(){
return INSTANCE;
}
}
- 동기화하지 않아도 스레드가 안전하며 간단
- 미리 인스턴스가 생성되어 사용하지 않을때 메모리 낭비될 수도 있음
LazySingleton
public class LazySingleton {
private static LazySingleton instance;
private LazySingleton(){}
public static synchronized LazySingleton getInstance(){
if(instance == null){
instance = new LazySingleton();
}
return instance;
}
}
- syncronized 키워드를 사용하여 getInstance()메소드를 호출할때가 되어서야 Singleton인스턴스가 생성
- 호출마다 동기화되기때문에 “성능의 저하”가 발생할 수 있음
Double-Checked Locking Singleton
- 초기화후에는 synchronized 키워드 사용이 필요 없으므로 성능이 개선됨
- DCL 싱글톤이라고도 불리는 패턴으로 "코드가 조금 복잡하다"
- volatile : 하나의 프로세스 안에 있는 각각의 스레드는 스택 공간, 스레드 외 영역의 프로세스는 힙 공간
1. 각각의 스레드 영역에서 관리하지 않고 전체 프로세스 영역에서 관리하게끔 함
2. 가시성 보장 : 모든 스레드가 메모리에서 최신 값을 읽도록 강제
🚀실습 - volatile 키워드의 역할
public class Singleton {
// volatile 키워드로 선언
private static volatile Singleton instance;
// private 생성자
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 첫 번째 체크
synchronized (Singleton.class) { // syncronized
if (instance == null) { // 두 번째 체크
instance = new Singleton(); // 객체 초기화
}
}
}
return instance;
}
}
- 첫번째 체크에서 null이 아니면 동기화 블록(syncronized)으로 진입한다.
❓메소드 전체에 선언하지 않은 블록에서 동기화 사용한 이유 ➡️ 비용절감을 위해 동기화를 최소화
매번 메소드 호출마다 동기화되지않고 synchronized 블록 부분만 호출이 되기때문에 성능개선가능 - 두번째 체크에서 동기화 블록 내 다시 체크하여 "여러 스레드가 동시에 객체를 생성하지 못하도록 방지"
- volatile : 객체 초기화가 완료되기 전에 "다른 스레드가 instance를 참조하지 못하게 보장"
StaticInnerClassSingleton
public class StaticInnerClassSingleton {
private StaticInnerClassSingleton(){}
private static class Holder{
private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
}
public static StaticInnerClassSingleton getInstance(){
return Holder.INSTANCE;
}
}
- 인스턴스 생성을 지연시켜 스레드를 안전하게 함
- 해당 클래스를 호출하는 시점에서 로딩이 일어나고(지연 로딩) 그때 내부 인스턴스가 static으로 초기화
- 싱글톤 객체 생성 시 별도의 동기화 처리를 하지 않아도 클래스 로딩되는 과정 자체가
JVM에서 동기화 환경을 제공하여 멀티 스레드 환경에서도 안전한 것
➡️ sychronized 키워드를 사용하지 않아도 스레드가 안전
- 단점 : 내부 클래스의 로딩시점을 이해해야함
❓싱글톤 패턴 장단점
➡️장점 : 메모리 절약, 접근성, 데이터 일관성 보장
➡️단점 : 테스트의 어려움, 멀티스레드 환경에 문제발생가능, 객체 지향적이지 않음
❓싱글톤 패턴은 SRP원칙(=단일책임원칙)을 지키는 것일까
➡️ 싱글톤 패턴은 “주요 기능 수행”과 “하나의 인스턴스만 생성” 이라는 두 가지 책임이 있기 때문에
SRP를 위반할 수 있고, 의존 관계상 클라이언트가 구현체에 의존하여 DIP, OCP 또한 위반할 가능성이 높다.
두가지 책임이란
- Primary Function(주요 기능): 클래스가 싱글톤으로 설계되지 않았더라도 클래스가 맡아야할 책임
ex. Logger 클래스의 "로그를 기록하는 기능"
- 인스턴스 제어 : 인스턴스가 하나만 생성되도록 강제하는 역할 ➡️객체 생성 관리라는 "책임"
❓싱글톤 패턴이 OCP, DIP를 위반하는 이유
➡️ OCP : 클래스가 확장에는 열려 있어야 하지만, 변경에는 닫혀 있어야하지만
싱글톤 클래스에서 새로운 기능을 추가하려면 기존 클래스를 수정해야 하므로 OCP를 위반
➡️DIP : 싱글톤 클래스는 종종 직접적으로 인스턴스에 의존하기 때문에 DIP를 위반
Adapter 어댑터 패턴
- 구조적 디자인 패턴
- 서로 다른 형태의 “인터페이스”를 연결
- 기존 코드(Adaptee)와 새 코드 (클라이언트) 간 인터페이스가 다르면 중간에 Adapter를 두어
“서로 인터페이스를 맞춰주는” 방식 - Wrapper라고도 함 - 기존 객체를 감싸서 새로운 인터페이스로 보이게 만들기때문
▶️실습 - 드라이기 어댑터 변환기
// Adaptee -- 기존 코드 (집에서 가져간 드라이기)
class Power220v{
public void connect(){
System.out.println("[드라이기 연결완료] 머리 말리는 중...");
}
}
// target -- 클라이언트가 기대하는 인터페이스 110v
interface Power110v{
void supply();
}
// Adapter -- Adaptee의 인터페이스를 Target의 인터페이스로 변환해주는 클래스
class PowerAdapter implements Power110v{
private Power220v power220v; // 기존 코드
public PowerAdapter(Power220v power220v){
this.power220v = power220v;
}
@Override
public void supply() {
System.out.println("어댑터 변환 완료 : [220v] -> [110v]");
power220v.connect();
}
}
- Adapter를 두어 변환하고자 하는 인터페이스(Power110v)를 구현
- 기존코드(Power220v)를 필드로 선언 후 생성자에서 갱신
- 변환하고자 하는 인터페이스(Power110v)의 메소드인 supply()메소드를 오버라이딩하고
기존코드(Power220v)의 메소드인 connect()를 사용할 수 있도록 한다.
//main
// 1. 집에서 가져간 드라이기
Power220v power220v = new Power220v();
// 2. 어댑터에 끼워서 110v로 변환 후 사용
Power110v adapter = new PowerAdapter(power220v);
adapter.supply();
- Power110v 인터페이스 타입으로 선언한 PowerAdapter 인스턴스의 생성자로 power220v를 인자로 전달

❓어댑터 패턴 활용사례
➡️레거시 코드 (=사용하고있는코드, 오래된코드)에 추가된 새로운 라이브러리나 코드 등이 함께 사용되어야할때
레거시 코드를 수정하지 않고도 어댑터를 두어 새로운 시스템에서도 잘 작동할 수 있도록 도와주는 것이 어댑터 패턴
Decorator 데코레이터 패턴
- 구조적 패턴
- 객체들 간 관계를 통해 기존 객체에 “새로운 기능”을 유연하게 덧붙이는 패턴 (장식 패턴)
- 기존객체(기능)에 새로운 책임(기능)을 추가하되 기존코드는 크게 수정하지 않도록함
- 무한히 상속하여 늘리기보단 장식을 얹듯이 기능을 계층적으로 붙임
➡️여러 데코레이터를 체인 형태로 연결 (ex. 생크림 케이크 객체에 “바닐라 소스 데코레이터 → 딸기 데코레이터”)
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
- 이처럼 자바 IO에서도 데코레이터 패턴이 사용
- Wrapper패턴이라고도 불림 ➡️어댑터의 Wrapper와는 다른 의미
어댑터 패턴의 Wrapper : 변환해주는 역할 / 데코레이터 패턴의 Wrapper : 기능을 확장해주는 역할 - 장점 :
- 상속 없이 동적으로 기능을 조합해 기존 코드를 수정하지 않고 확장 가능
- 데코레이터 들은 각각 SRP, OCP원칙을 지킴
- GUI 프레임워크에서 버튼, 스크롤 등을 사용할때 활용 가능 - 단점 :
- 클래스가 많아져서 코드 복잡성 증가
- 중첩되는 데코레이터로 인해 디버깅의 어려움
▶️실습 - 커피 주문
- 커피 레시피
- 에스프레소 + 물 = 아메리카노
- 에스프레소 + 우유 = 라떼 등
- 에스프레소 + 우유 + 시럽 + 생크림 = ? 처럼 추가 가능
// 모든 커피의 기본이 되는 공통 인터페이스 (컴포넌트 인터페이스)
interface Coffee{
String getDescription(); // 커피 설명
int getCost(); // 가격
}
- 에스프레소 : 모든 커피의 기본이되는 공통 인터페이스 = “컴포넌트 인터페이스”
// 가장 기본적인 커피 (에스프레소) 구현 - (기본 컴포넌트)
class Espresso implements Coffee{
@Override
public String getDescription() {
return "에스프레소";
}
@Override
public int getCost() {
return 3000;
}
}
- 에스프레소만 구현한 클래스 = “기본 컴포넌트”
// 장식클래스 즉, 표준적인 데코레이터를 추상클래스
abstract class CoffeeDecorator implements Coffee{
protected Coffee decoratorCoffee; // 상속관계에서는 사용할 수 있도록 protected선언
public CoffeeDecorator(Coffee coffee){ // 생성자
this.decoratorCoffee = coffee;
}
// 실체가 들어오면 실체가 가진 메소드 (커피설명)를 가져오게끔 함
@Override
public String getDescription() {
return decoratorCoffee.getDescription();
}
// 실체가 들어오면 실체가 가진 메소드 (가격)을 가져오게끔 함
@Override
public int getCost() {
return decoratorCoffee.getCost();
}
}
- 장식클래스들의 표준적인 형태를 정의한 추상클래스 = “Decorator 추상 클래스”
- Decorator 추상 클래스 : 다른 구체적 장식 클래스에서 상속을 받고 그 안에서 구체적으로 장식하는 기능 정의
- 생성자에서 CoffeeDecorator(Coffee coffee)의 경우 데코레이터(장식)은 혼자 생성될 수 없으므로
매개변수로 꾸며줄 객체(주인공)를 받고 있다.
// 구체적인 장식이 되는 클래스
class MilkDecorator extends CoffeeDecorator{
// 부모클래스인 추상클래스 CoffeeDecorator에 super(coffee)로 보냄
public MilkDecorator(Coffee coffee) {
super(coffee);
}
@Override
public String getDescription() {
return super.getDescription() + " (+우유) ";
}
// 추가되면 가격 추가
@Override
public int getCost() {
return super.getCost() + 500;
}
}
- 구체적인 장식 클래스 = “ConcreteDecorator”
- 기존 커피의 기능을 확장할 옵션을 추가
// 기본 커피
Coffee espresso = new Espresso();
// 에스프레소 + 우유
// 커피를 생성자로 받아야하므로 Coffee타입의 espresso가 들어갈 수 있음
Coffee latte = new MilkDecorator(espresso);
- 바깥 데코레이터 + 안의 (꾸며줄 객체 에스프레소)
▶️실습 - 커피에 우유와 시럽을 둘다 추가 (장식)
// 에스프레소 + 시럽
Coffee espressoSyrup = new SyrupDecorator(espresso);
// 에스프레소 + 우유 + 시럽
Coffee espressoMS = new MilkDecorator(new SyrupDecorator(espresso));
- MilkDecorator안의 SyrupDecorator, 그 안에 espresso
➡️순서대로 espresso → SyrupDecorator → MilkDecorator로 출력

Observer 옵저버 패턴
- 행위 패턴 중 하나
- 객체의 상태가 변하면 연결된 Observer들에게 자동으로 알리는 디자인 패턴 (ex.유튜브구독)
- 주제 (Subject) : 중요한 소식이나 이벤트를 발생시키는 객체
- 옵저버 (Observer) : 주제의 상태가 바뀌면 등록된 옵저버 들에게 즉시 알림 변화를 알려줌
- 장점 : 느슨한 연결을 유지 ➡️주제는 옵저버가 무슨일을 하는지 내부구현을 알 필요가 없음
- 발행-구독 패턴이라고도 함 (발행(Publish) : 주제가 변화를 알림, 구독(Subscribe) : 옵저버가 그 알림을 수신)
ex. 날씨 정보 시스템 (기상청 데이터(주제)가 업데이트 ↔ 여러화면/앱(옵저버)가 즉시 반영)
▶️실습 - 유튜브 채널 구독 시스템
// 1. 옵저버 인터페이스 (Observer)
interface Observer{
void update(String message);
}
// 2. 주제 인터페이스 (Subject)
interface Subject {
// 해야되는 일들을 등록
// 1) 구독 등록 (관찰자 등록)
void registerObserver(Observer observer);
// 2) 구독 제거 (관찰자 제거)
void removeObserver(Observer observer);
// 3) 구독 알림 (관찰자 알림)
void notifyObserver();
}
// 3. 주제 인터페이스를 구현하는 구체적인 클래스 (Channel)
class YoutubeChannel implements Subject {
private String channelName; // 유튜브 채널 이름
private List<Observer> observers; // 구독자들을 저장하는 리스트
// 채널이 생성될때 채널 이름을 받도록 함
public YoutubeChannel(String channelName) {
this.channelName = channelName;
this.observers = new ArrayList<>();
}
// ... 오버라이딩 메소드들...
// 영상 업로드 메소드
public void uploadVideo(){
notifyObserver();
}
}
// 4. 옵저버 인터페이스를 구현하는 구체적인 클래스 (Subscriber)
class Subscriber implements Observer{
private String name; // 구독자 이름
public Subscriber(String name) {
this.name = name;
}
@Override
public void update(String message) {
System.out.println(name + "님, 알림 도착! - " + message);
}
}
YoutubeChannel channel = new YoutubeChannel("LEFT Coding"); // 유튜브 채널 생성
Subscriber subscriber1 = new Subscriber("Kolalov");
Subscriber subscriber2 = new Subscriber("Lautaro");
Subscriber subscriber3 = new Subscriber("Desaily");
channel.registerObserver(subscriber1);
channel.registerObserver(subscriber2);
channel.registerObserver(subscriber3);
channel.removeObserver(subscriber3);
channel.uploadVideo();
- 옵저버와 주제 인터페이스를 만들고, 각각의 인터페이스를 구현하는 클래스를 만든다.
- 주제 인터페이스를 구현하는 클래스 (채널 클래스)는 "영상 업로드 메소드 uploadVideo()"를 통해 notifyObserver()를 호출하고
- notifyObserver() 메소드를 오버라이딩한 채널 클래스에서 update()메소드를 호출하도록 하여 알림을 보내게된다.

💡각 디자인 패턴들이 OCP, DIP 등의 원칙을 잘 지키는 경우도 있고 안지켜지는 경우도 있었다.
회고를 작성하면서 디자인 패턴들에서 이해가 조금 어려웠던 부분들은 따로 검색하고 강의를 찾아보기도 하였다.
특히나 Double-Checked Locking Singleton패턴은 왜 사용하는지 부터 volatile 키워드 사용까지 이해하기가 쉽지 않았다.
아직 각 패턴별로 하지 못한 실습들을 시간 날 때 마무리 해봐야겠다고 생각했다.🚀
'Recording > 멋쟁이사자처럼 BE 13기' 카테고리의 다른 글
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_22일차_"HTML과 CSS" (3) | 2025.01.02 |
---|---|
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_21일차_''프론트엔드 시작" (2) | 2024.12.31 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_19일차_''객체지향원칙 OOP" (4) | 2024.12.27 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_18일차_''스레드 Thread" (0) | 2024.12.26 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_17일차_''Java IO" (1) | 2024.12.24 |