SOLID 원칙이 뭔데?
SOLID 원칙이란 소프트웨어 설계에서 유지보수성과 확장성을 높이기 위한 다섯 가지 객체 지향 설계 원칙이다.
말 그대로, 좋은 코드를 만들기 위한 원칙!
이 원칙들은 잘 구조화된 소프트웨어를 작성하는 데 중점을 두며, 코드의 가독성, 유연성, 테스트 가능성 등을 향상시키는 데 기여한다!
객체 지향 설계 원칙...?
자바스크립트는 완전한 객체 지향 언어가 아닌데?
그렇다.
JavaScript는 전통적인 객체 지향 언어인 Java, C++과는 다르게 프로토타입 기반 객체 지향 언어이다.
그럼 프로토타입 기반 OOP와 클래스 기반 OOP는 뭐가 다를까?
특징 | 클래스 기반 OOP | 프로토타입 기반 OOP |
객체 생성 방식 | 클래스를 정의하고, 클래스를 통해 객체 생성 |
기존 객체를 복사하거나, object.create()로 생성 |
상속 방식 | 클래스 상속을 통해 상속 관계 형성 |
프로토타입 체인을 통해 객체가 다른 객체를 상속 |
동적 속성 추가 | 객체 생성 후 속성이나 메소드 추가 어려움 |
생성 후에도 객체에 속성 및 메소드를 동적으로 추가 가능 |
설계 시점 | 컴파일 시점에 클래스 구조가 고정됨 |
런타임 시점에 객체가 동적으로 수정 가능 |
유연성 | 구조가 고정적이며 명확함 | 유연하고 동적으로 변할 수 있음 |
클래스 기반 OOP는 객체가 클래스를 기반으로 생성되고, 상속은 클래스 간에 이루어지고, 명확한 상속 구조가 특징이다.
프로토타입 기반 OOP는 객체가 다른 객체로부터 상속되며, 객체를 동적으로 수정할 수 있는 유연함이 특징이다.
심지어 자바스크립트는 객체 지향뿐만 아니라 함수형 프로그래밍 스타일도 지원하는 다중 패러다임 언어이다.
ES6 이후로는 함수형 프로그래밍 패턴이 더욱 강조되었다고...
함수형 프로그래밍과 객체 지향 프로그래밍은 뭐가 다를까?
특징 | 함수형 프로그래밍 (FP) | 객체 지향 프로그래밍 (OOP) |
중심 개념 | 함수, 순수 함수, 불변성 | 객체, 클래스, 상속, 상태 |
상태 관리 | 상태가 없으며, 불변성 유지 | 객체 상태가 변경될 수 있음 |
데이터와 행동 | 데이터와 행동을 분리, 데이터는 불변 |
데이터와 행동(메소드)을 객체 내부에 함께 정의 |
코드 구조 | 함수의 조합을 통해 문제 해결 | 객체 간의 상호작용을 통해 문제 해결 |
부수 효과 (Side effects) | 가능한 부수 효과를 피함 | 부수 효과 발생 가능 |
함수형 프로그래밍은 함수와 불변성을 강조하고, 객체 지향 프로그래밍은 객체와 상태를 강조한다고 생각하면 될 것 같다.
자바스크립트는 동적이며 프로토타입 기반 언어이지만,
SOLID 원칙을 적용하여 더 유연하고 유지보수하기 쉬운 코드를 작성할 수 있다.
SOLID 원칙은 주로 객체 지향 프로그래밍(OOP)에 적용되지만,
자바스크립트와 같은 언어에서도 클래스, 함수, 모듈 단위로 이를 적용할 수 있다.
각 원칙을 자바스크립트에서 어떻게 적용할 수 있는지 살펴보자.
1. 단일 책임 원칙 (Single Responsibility Principle, SRP)
하나의 함수는 단 하나의 책임을 가져야 한다!
함수는 하나의 목적만 가져야 한다!
만일 함수가 여러가지 기능을 수행하고 있다면, 해당 기능들을 각각의 함수로 분리하는 것이 좋다.
// SRP 위반 - 여러 역할을 하는 함수
function handleUser(user) {
// 사용자 인증
authenticateUser(user);
// 사용자 데이터 저장
saveUserData(user);
}
// SRP 준수 - 각 기능을 별도로 분리
function authenticateUser(user) {
// 사용자 인증 로직
}
function saveUserData(user) {
// 사용자 데이터 저장 로직
}
2. 개방-폐쇄 원칙 (Open/Closed Principle, OCP)
함수는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다.
새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있어야 한다.
즉, 새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있어야 한다.
// OCP 위반 - 기능을 확장하기 위해 기존 코드를 수정해야 함
// type이 달라지면 getDiscount()를 수정해야 함!
function calculateDiscount(type, price) {
if (type === 'regular') return price * 0.1;
if (type === 'vip') return price * 0.2;
}
// OCP 준수 - 확장 가능한 구조로 변경
const discountStrategies = {
regular: price => price * 0.1,
vip: price => price * 0.2,
};
function calculateDiscount(type, price) {
return discountStrategies[type](price);
}
새로운 할인 전략을 추가할 때 기존 함수 calculateDiscount를 수정하지 않고, discountStrategies 객체에 추가할 수 있다.
3. 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)
함수가 서로 교체 가능해야 하며, 교체된 함수가 기존 함수처럼 동일하게 동작해야 한다.
// LSP 위반: birdFly 와 penguinFly 는 서로 대체 불가능하다.
function birdFly() {
console.log("Flying");
}
function penguinFly() {
throw new Error("Penguins can't fly");
}
// LSP 준수: penguinMove와 birdMove는 서로 다르게 동작하지만, 호출 방식은 동일하다.
function birdMove() {
console.log("Flying");
}
function penguinMove() {
console.log("Swimming");
}
function makeMove(animalMove) {
animalMove();
}
makeMove(birdMove); // "Flying"
makeMove(penguinMove); // "Swimming"
4. 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)
클라이언트는 자신이 사용하지 않는 함수에 의존하지 않아야 한다.
큰 함수보다는 작은 함수로 나누어 자신이 사용하지 않는 메서드에 의존하지 않도록 해야 한다!
// ISP 위반: 하나의 함수에 너무 많은 책임이 있음
function operate(vehicle) {
vehicle.drive();
vehicle.fly();
}
// ISP 준수: 각각의 기능을 분리된 함수로 만듦
function drive(vehicle) {
vehicle.drive();
}
function fly(vehicle) {
if (vehicle.fly) vehicle.fly();
}
const car = { drive: () => console.log("Driving") };
const plane = { drive: () => console.log("Taxiing"), fly: () => console.log("Flying") };
drive(car); // "Driving"
drive(plane); // "Taxiing"
fly(plane); // "Flying"
drive와 fly를 분리함으로써, 자동차와 비행기를 각각 처리하는 함수가 필요로 하는 기능만 사용하도록 분리했다.
얼핏 보면 단일 책임 원칙이랑 인터페이스 분리 원칙이랑 비슷해 보이지만,
SRP는 개별 클래스나 함수의 역할을 제한하는 데 초점을 맞추며, 하나의 책임을 가지도록 설계한다.
ISP는 인터페이스를 작게 분리하여 클라이언트가 필요하지 않은 메서드에 의존하지 않게 설계하는 데 중점을 둔다.
이러한 차이점이 있다.
5. 의존 역전 원칙 (Dependency Inversion Principle, DIP)
함수는 구체적인 구현이 아닌, 추상화에 의존해야 한다.
// DIP 위반: switchOperate 함수가 구체적인 lightBulbOn 함수에 직접 의존.
function lightBulbOn() {
console.log("Light is on");
}
function switchOperate(bulb) {
bulb();
}
switchOperate(lightBulbOn); // "Light is on"
// DIP 준수: Switch가 추상 클래스 Device에 의존하여 유연성을 확보.
function operateDevice(device) {
device();
}
function lightBulbOn() {
console.log("Light is on");
}
operateDevice(lightBulbOn); // "Light is on"
SOLID 원칙을 따르면 다음과 같은 장점이 있다.
- 유지보수성: 각 함수가 명확한 책임을 가지기 때문에 유지보수가 용이하다.
- 확장성: 새로운 기능을 추가하거나 기존 기능을 확장할 때 기존 코드를 수정할 필요가 없다.
- 테스트 용이성: 각 기능이 독립적이므로 테스트 작성이 더 쉬워진다.
- 재사용성: 모듈들이 독립적으로 작동하므로, 재사용성이 높아진다.
따라서 코드를 작성할 때 SOLID 원칙을 생각하며 작성하면 좋을 것 같다.
솔직히 내용이 너무 어려워서... 잘 적은 건지 모르겠다. ㅠㅠ
그러면 이번에는, 이전에 적은 블로그인
2024.09.11 - [개발 공부] - [JavaScript] 허접한 몬스터 때려잡기 게임 만들기
해당 글의 코드를 SOLID 원칙 중 단일 책임 원에 따라 고쳐보도록 하겠다!
const container = document.getElementById("container");
// 몬스터 모양 만들기
const monster = document.createElement("div");
monster.textContent = "*_*";
monster.style.width = "50px";
monster.style.height = "50px";
monster.style.backgroundColor = "rgb(255, 158, 158)";
monster.style.display = "flex";
monster.style.justifyContent = "center";
monster.style.alignItems = "center";
monster.style.position = "absolute";
monster.style.pointerEvents = "all"; // div 전체가 클릭 가능하게 만듦
container.appendChild(monster);
// 화면 크기
const screenWidth = container.offsetWidth;
const screenHeight = container.offsetHeight;
// 몬스터의 이동 방향 및 속도
let xSpeed = Math.random() * 6 + 1; // X축 속도
let ySpeed = Math.random() * 6 + 1; // Y축 속도
let xPosition = 0; // 초기 X 위치
let yPosition = 0; // 초기 Y 위치
function moveMonster() {
xPosition += xSpeed;
yPosition += ySpeed;
// X축 경계에 도달했을 때 반대 방향으로 전환
if (xPosition <= 0 || xPosition >= screenWidth - 50) {
xSpeed = -xSpeed; // 속도 반전
}
// Y축 경계에 도달했을 때 반대 방향으로 전환
if (yPosition <= 0 || yPosition >= screenHeight - 50) {
ySpeed = -ySpeed; // 속도 반전
}
// 몬스터의 위치 업데이트
monster.style.left = xPosition + "px";
monster.style.top = yPosition + "px";
}
// 50ms마다 몬스터 이동
setInterval(moveMonster, 40);
// 입력 받기
let monsterHP = 100;
let attackDamage = parseInt(prompt("1회 공격시 데미지는? (양의 정수)"));
let attackCount = 0;
// 이벤트 리스너 함수 설정
const attackFunction = () => {
attackCount++;
monsterHP -= attackDamage;
console.log(monsterHP, attackCount);
const attackText = document.createElement("p");
attackText.textContent = `Attack! : ${-attackDamage}`;
container.appendChild(attackText);
attackText.style.color = "red";
if (monsterHP <= 0) {
container.removeChild(attackText);
container.removeChild(monster);
const h2 = document.createElement("h2");
h2.textContent = `몬스터 잡기 완료! 수고하셨습니다.`;
h2.style.color = "green";
container.appendChild(h2)
} else {
setTimeout(() => {
container.removeChild(attackText);
}, 300); // 0.3초 뒤 삭제
}
}
if (attackDamage > 0) {
monster.addEventListener('click', attackFunction);
} else {
alert("잘못 입력하셨습니다. 게임을 취소합니다.")
container.removeChild(monster);
container.style.border = "none";
}
우선 가장 먼저 눈에 보이는 것은, moveMonster() 이다.
몬스터의 위치를 바꾸는 것 뿐만 아니라, 경계에 부딪힌 경우까지 정의하고 있다.
// 몬스터의 이동 방향 및 속도
let xSpeed = Math.random() * 6 + 1; // X축 속도
let ySpeed = Math.random() * 6 + 1; // Y축 속도
let xPosition = 0; // 초기 X 위치
let yPosition = 0; // 초기 Y 위치
function moveMonster() {
xPosition += xSpeed;
yPosition += ySpeed;
// X축 경계에 도달했을 때 반대 방향으로 전환
if (xPosition <= 0 || xPosition >= screenWidth - 50) {
xSpeed = -xSpeed; // 속도 반전
}
// Y축 경계에 도달했을 때 반대 방향으로 전환
if (yPosition <= 0 || yPosition >= screenHeight - 50) {
ySpeed = -ySpeed; // 속도 반전
}
// 몬스터의 위치 업데이트
monster.style.left = xPosition + "px";
monster.style.top = yPosition + "px";
}
// 50ms마다 몬스터 이동
setInterval(moveMonster, 40);
이걸 먼저 단일 책임 원칙에 따라 바꿔보도록 하자.
// 몬스터의 이동 방향 및 속도
let xSpeed = Math.random() * 6 + 1; // X축 속도
let ySpeed = Math.random() * 6 + 1; // Y축 속도
let xPosition = 0; // 초기 X 위치
let yPosition = 0; // 초기 Y 위치
// 경계에 부딪힐 경우
function monsterXBoundary(xPosition) {
// X축 경계에 도달했을 때 반대 방향으로 전환
if (xPosition <= 0 || xPosition >= screenWidth - 50) {
xSpeed = -xSpeed; // 속도 반전
}
return xSpeed;
}
function monsterYBoundary(yPosition) {
// Y축 경계에 도달했을 때 반대 방향으로 전환
if (yPosition <= 0 || yPosition >= screenHeight - 50) {
ySpeed = -ySpeed; // 속도 반전
}
return ySpeed;
}
function monsterBoundary (xPosition, yPosition) { // x, y축 경계 확인 함수
monsterXBoundary(xPosition);
monsterYBoundary(yPosition);
}
// 몬스터 위치 업데이트 함수
function monsterPositionUpdate() {
xPosition += xSpeed;
yPosition += ySpeed;
// 몬스터의 위치 업데이트
monster.style.left = xPosition + "px";
monster.style.top = yPosition + "px";
}
function moveMonster() {
monsterPositionUpdate();
monsterBoundary(xPosition, yPosition);
}
// 50ms마다 몬스터 이동
setInterval(moveMonster, 40);
monsterBoundary 함수는 경계 확인을 하고, monsterPositionUpdate 함수는 경계에 부딪혔을 때를 체크하고,
마지막으로 moveMonster 함수는 두 함수를 호출하는 것으로 수정하였다.
두번째로 수정할 것은... 이벤트 리스너 함수!
이것도 몬스터 공격하는 거랑 텍스트 띄우는 거랑 죽었을 때... 이렇게 여러가지 경우에서의 기능들을 다 정의하고 있다.
// 입력 받기
let monsterHP = 100;
let attackDamage = parseInt(prompt("1회 공격시 데미지는? (양의 정수)"));
// 이벤트 리스너 함수 설정
const attackFunction = () => {
monsterHP -= attackDamage;
console.log(monsterHP, attackCount);
const attackText = document.createElement("p");
attackText.textContent = `Attack! : ${-attackDamage}`;
container.appendChild(attackText);
attackText.style.color = "red";
if (monsterHP <= 0) {
container.removeChild(attackText);
container.removeChild(monster);
const h2 = document.createElement("h2");
h2.textContent = `몬스터 잡기 완료! 수고하셨습니다.`;
h2.style.color = "green";
container.appendChild(h2)
} else {
setTimeout(() => {
container.removeChild(attackText);
}, 300); // 0.3초 뒤 삭제
}
}
이것 역시 단일 책임 원칙에 따라 분리해보도록 하겠다.
// 입력 받기
let monsterHP = 100;
let attackDamage = parseInt(prompt("1회 공격시 데미지는? (양의 정수)"));
// monster 피 깎기
function monsterAttack(monsterHP) {
monsterHP -= attackDamage;
return monsterHP;
}
// attack 메시지 띄우기
function attackMessage() {
const attackText = document.createElement("p");
attackText.textContent = `Attack! : ${-attackDamage}`;
container.appendChild(attackText);
attackText.style.color = "red";
return attackText;
}
// Clear 메시지 띄우기
function clearMessage() {
const h2 = document.createElement("h2");
h2.textContent = `몬스터 잡기 완료! 수고하셨습니다.`;
h2.style.color = "green";
container.appendChild(h2)
return h2;
}
// 몬스터 죽었을 때 처리
function handleMonsterDeath(monsterHP, attackText, monster) {
if (monsterHP <= 0) {
container.removeChild(attackText);
container.removeChild(monster);
clearMessage();
} else {
setTimeout(() => {
container.removeChild(attackText);
}, 300); // 0.3초 뒤 삭제
}
}
// 이벤트 리스너 함수 설정
const attackFunction = () => {
monsterHP = monsterAttack(monsterHP);
const attackText = attackMessage();
handleMonsterDeath(monsterHP, attackText, monster); // clearText() 안에 포함
}
- monsterAttack() 는 체력을 깎는 역할.
- attackMessage() 는 공격 메시지를 생성.
- clearMessage() 는 몬스터가 죽었을 때 완료 메시지를 출력.
- handleMonsterDeath() 는 몬스터가 죽었는지 확인하고, 그에 따라 메시지를 처리.
그리고 이벤트 리스너 함수에서 한 번에 처리해 주었다.
수정하다가 느낀 건데... 값 업데이트와 매개변수를 신경쓰도록 하자. ㅠㅠ
채찍피티한테 단일 책임 원칙 만족하냐고 물어봤는데,
handleMonsterDeath() 에서 몬스터 죽었는지 확인하는 거랑 메시지 처리하는 거랑 두가지 역할 한다고 고쳐주랬다.
(와 빡세네... 생각 못했다.)
// 몬스터 죽음 확인
function checkMonsterDeath (monsterHP) {
return monsterHP <= 0;
}
// 몬스터 죽었을 때 처리
function handleMonsterDeath(monsterHP, attackText, monster) {
if (checkMonsterDeath(monsterHP)) {
container.removeChild(attackText);
container.removeChild(monster);
clearMessage();
} else {
setTimeout(() => {
container.removeChild(attackText);
}, 300); // 0.3초 뒤 삭제
}
}
그래서 이렇게 고쳐주었다.
그랬는데 handleMonsterDeath() 에서 몬스터 죽은 거 확인이랑 처리까지 다 해주고 있다고...
아니 이렇게까지 쪼개야 하는 거구나...
그래서
1. 몬스터 죽었을 때 처리 (attackText, monster 삭제)
2. 몬스터 처치 시 attackText 0.3초 뒤 삭제 처리
3. 몬스터 상태 관리
이렇게 3가지로 또 나누어 주었다.
용어도 어떻게 써야할지 모르겠다... 하도 쪼개니 헷갈림.
// 몬스터 죽음 처리 (attackText, monster 삭제)
function handleMonsterDeath (attackText, monster) {
container.removeChild(attackText);
container.removeChild(monster);
clearMessage();
}
// 몬스터 처치 시 attackText 삭제
function removeAttackText (attackText) {
setTimeout(() => {
container.removeChild(attackText);
}, 300); // 0.3초 뒤 삭제
}
// 몬스터 상태 관리
function handleMonsterState(monsterHP, attackText, monster) {
if (checkMonsterDeath(monsterHP)) {
handleMonsterDeath(attackText, monster);
} else {
removeAttackText(attackText);
}
}
으하하... 이렇게 단일 책임 원칙에 따라 코드를 수정해 보았다.
최종 코드!! 함수가 아주 많아졌다.
앞으로는 SOLID 원칙을 생각하며 좋은 코드를 작성하길 바라며,,, 이 글을 마친다.
const container = document.getElementById("container");
// 몬스터 모양 만들기
const monster = document.createElement("div");
monster.textContent = "*_*";
monster.style.width = "50px";
monster.style.height = "50px";
monster.style.backgroundColor = "rgb(255, 158, 158)";
monster.style.display = "flex";
monster.style.justifyContent = "center";
monster.style.alignItems = "center";
monster.style.position = "absolute";
monster.style.pointerEvents = "all"; // div 전체가 클릭 가능하게 만듦
container.appendChild(monster);
// 화면 크기
const screenWidth = container.offsetWidth;
const screenHeight = container.offsetHeight;
// 몬스터의 이동 방향 및 속도
let xSpeed = Math.random() * 6 + 1; // X축 속도
let ySpeed = Math.random() * 6 + 1; // Y축 속도
let xPosition = 0; // 초기 X 위치
let yPosition = 0; // 초기 Y 위치
// 경계에 부딪힐 경우
function monsterXBoundary(xPosition) {
// X축 경계에 도달했을 때 반대 방향으로 전환
if (xPosition <= 0 || xPosition >= screenWidth - 50) {
xSpeed = -xSpeed; // 속도 반전
}
return xSpeed;
}
function monsterYBoundary(yPosition) {
// Y축 경계에 도달했을 때 반대 방향으로 전환
if (yPosition <= 0 || yPosition >= screenHeight - 50) {
ySpeed = -ySpeed; // 속도 반전
}
return ySpeed;
}
function monsterBoundary(xPosition, yPosition) { // x, y축 경계 확인 함수
monsterXBoundary(xPosition);
monsterYBoundary(yPosition);
}
// 몬스터 위치 업데이트 함수
function monsterPositionUpdate() {
xPosition += xSpeed;
yPosition += ySpeed;
// 몬스터의 위치 업데이트
monster.style.left = xPosition + "px";
monster.style.top = yPosition + "px";
}
function moveMonster() {
monsterPositionUpdate();
monsterBoundary(xPosition, yPosition);
}
// 50ms마다 몬스터 이동
setInterval(moveMonster, 40);
// 입력 받기
let monsterHP = 100;
let attackDamage = parseInt(prompt("1회 공격시 데미지는? (양의 정수)"));
// monster 피 깎기
function monsterAttack(monsterHP) {
monsterHP -= attackDamage;
return monsterHP;
}
// attack 메시지 띄우기
function attackMessage() {
const attackText = document.createElement("p");
attackText.textContent = `Attack! : ${-attackDamage}`;
container.appendChild(attackText);
attackText.style.color = "red";
return attackText;
}
// Clear 메시지 띄우기
function clearMessage() {
const h2 = document.createElement("h2");
h2.textContent = `몬스터 잡기 완료! 수고하셨습니다.`;
h2.style.color = "green";
container.appendChild(h2)
return h2;
}
// 몬스터 죽음 확인
function checkMonsterDeath (monsterHP) {
return monsterHP <= 0;
}
// 몬스터 죽음 처리
function handleMonsterDeath (attackText, monster) {
container.removeChild(attackText);
container.removeChild(monster);
clearMessage();
}
// 몬스터 처치 시 attackText 삭제
function removeAttackText (attackText) {
setTimeout(() => {
container.removeChild(attackText);
}, 300); // 0.3초 뒤 삭제
}
// 몬스터 처리
function handleMonsterState(monsterHP, attackText, monster) {
if (checkMonsterDeath(monsterHP)) {
handleMonsterDeath(attackText, monster);
} else {
removeAttackText(attackText);
}
}
// 이벤트 리스너 함수 설정
const attackFunction = () => {
monsterHP = monsterAttack(monsterHP);
const attackText = attackMessage();
handleMonsterState(monsterHP, attackText, monster); // clearText() 안에 포함
}
if (attackDamage > 0) {
monster.addEventListener('click', attackFunction);
} else {
alert("잘못 입력하셨습니다. 게임을 취소합니다.")
container.removeChild(monster);
container.style.border = "none";
}
참고 자료
- H43RO, "SOLID 원칙, 어렵지 않다!", https://velog.io/@haero_kim/SOLID-%EC%9B%90%EC%B9%99-%EC%96%B4%EB%A0%B5%EC%A7%80-%EC%95%8A%EB%8B%A4
- teo.v, "Javascript에서도 SOLID 원칙이 통할까?", https://velog.io/@teo/Javascript%EC%97%90%EC%84%9C%EB%8F%84-SOLID-%EC%9B%90%EC%B9%99%EC%9D%B4-%ED%86%B5%ED%95%A0%EA%B9%8C
'개발 > JavaScript' 카테고리의 다른 글
[JavaScript] JavaScript로 Todo List 만들기 (2) | 2024.10.18 |
---|---|
[JavaScript] 계산기를 만들어 보았습니다. (6) | 2024.10.11 |
[JavaScript] 허접한 몬스터 때려잡기 게임 만들기 (0) | 2024.09.11 |
[JavaScript] 웹 개발에서 JavaScript가 중요한 이유 (2) | 2024.09.10 |
[JavaScript] var, let, const의 차이점 (2) | 2024.09.05 |