Robert C. Martin(Bob 삼촌)이 소개한 SOLID 원칙은 좋은 소프트웨어 디자인의 기초를 형성합니다. 이러한 원칙은 개발자가 유지 관리 가능하고 확장 가능하며 이해하기 쉬운 시스템을 만드는 데 도움이 됩니다. 이 블로그에서는 각 SOLID 원칙에 대해 자세히 알아보고 이를 React Native 및 MERN 스택(MongoDB, Express.js, React, Node.js)의 맥락에서 적용할 수 있는 방법을 살펴보겠습니다.
정의: 클래스를 변경해야 하는 이유는 단 하나여야 합니다. 즉, 클래스에는 하나의 작업이나 책임만 있어야 합니다.
설명:
SRP(단일 책임 원칙)는 클래스나 모듈이 소프트웨어 기능의 한 측면에 집중되도록 보장합니다. 클래스에 둘 이상의 책임이 있는 경우 한 책임과 관련된 변경 사항이 의도하지 않게 다른 책임에 영향을 미쳐 버그가 발생하고 유지 관리 비용이 높아질 수 있습니다.
React 네이티브 예:
React Native 애플리케이션의 UserProfile 구성 요소를 고려해보세요. 처음에 이 구성 요소는 사용자 인터페이스 렌더링과 사용자 데이터 업데이트를 위한 API 요청 처리를 모두 담당할 수 있습니다. 이는 구성 요소가 UI 관리와 비즈니스 로직 관리라는 두 가지 다른 작업을 수행하기 때문에 SRP를 위반합니다.
위반:
const UserProfile = ({ userId }) => { const [userData, setUserData] = useState(null); useEffect(() => { fetch(`/api/users/${userId}`) .then(response => response.json()) .then(data => setUserData(data)); }, [userId]); return ( <View> <Text>{userData?.name}</Text> <Text>{userData?.email}</Text> </View> ); };
이 예에서 UserProfile 구성 요소는 데이터 가져오기와 UI 렌더링을 모두 담당합니다. 데이터를 가져오는 방법을 변경해야 하는 경우(예: 다른 API 엔드포인트를 사용하거나 캐싱을 도입하여) 구성 요소를 수정해야 하며, 이로 인해 UI에 의도하지 않은 부작용이 발생할 수 있습니다.
리팩터링:
SRP를 준수하려면 데이터 가져오기 로직을 UI 렌더링 로직과 분리하세요.
// Custom hook for fetching user data const useUserData = (userId) => { const [userData, setUserData] = useState(null); useEffect(() => { const fetchUserData = async () => { const response = await fetch(`/api/users/${userId}`); const data = await response.json(); setUserData(data); }; fetchUserData(); }, [userId]); return userData; }; // UserProfile component focuses only on rendering the UI const UserProfile = ({ userId }) => { const userData = useUserData(userId); return ( <View> <Text>{userData?.name}</Text> <Text>{userData?.email}</Text> </View> ); };
이 리팩터링에서 useUserData 후크는 데이터 가져오기를 처리하여 UserProfile 구성 요소가 UI 렌더링에만 집중할 수 있도록 합니다. 이제 데이터 가져오기 로직을 수정해야 하는 경우 UI 코드에 영향을 주지 않고 수정할 수 있습니다.
Node.js 예:
Node.js 애플리케이션에서 데이터베이스 쿼리와 비즈니스 로직을 모두 처리하는 UserController를 상상해 보세요. 컨트롤러가 여러 가지 책임을 갖고 있으므로 이는 SRP를 위반합니다.
위반:
class UserController { async getUserProfile(req, res) { const user = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]); res.json(user); } async updateUserProfile(req, res) { const result = await db.query('UPDATE users SET name = ? WHERE id = ?', [req.body.name, req.params.id]); res.json(result); } }
여기서 UserController는 데이터베이스와의 상호작용과 HTTP 요청 처리를 모두 담당합니다. 데이터베이스 상호 작용 논리를 변경하려면 컨트롤러를 수정해야 하므로 버그 위험이 높아집니다.
리팩터링:
데이터베이스 상호 작용 논리를 저장소 클래스로 분리하여 컨트롤러가 HTTP 요청 처리에 집중할 수 있도록 합니다.
// UserRepository class handles data access logic class UserRepository { async getUserById(id) { return db.query('SELECT * FROM users WHERE id = ?', [id]); } async updateUser(id, name) { return db.query('UPDATE users SET name = ? WHERE id = ?', [name, id]); } } // UserController focuses on business logic and HTTP request handling class UserController { constructor(userRepository) { this.userRepository = userRepository; } async getUserProfile(req, res) { const user = await this.userRepository.getUserById(req.params.id); res.json(user); } async updateUserProfile(req, res) { const result = await this.userRepository.updateUser(req.params.id, req.body.name); res.json(result); } }
이제 UserController는 HTTP 요청 및 비즈니스 로직을 처리하는 데 중점을 두고 있고 UserRepository는 데이터베이스 상호 작용을 담당합니다. 이렇게 분리하면 코드를 더 쉽게 유지 관리하고 확장할 수 있습니다.
정의: 소프트웨어 엔터티는 확장을 위해 열려 있어야 하고 수정을 위해 닫혀 있어야 합니다.
설명:
OCP(개방/폐쇄 원칙)는 클래스, 모듈 및 함수가 기존 코드를 수정하지 않고도 쉽게 확장 가능해야 함을 강조합니다. 이는 추상화 및 인터페이스의 사용을 촉진하여 개발자가 기존 코드에 버그가 발생할 위험을 최소화하면서 새로운 기능을 도입할 수 있게 해줍니다.
React 네이티브 예:
처음에 고정된 스타일을 가진 버튼 구성요소를 상상해 보세요. 애플리케이션이 성장함에 따라 다양한 사용 사례에 맞게 다양한 스타일로 버튼을 확장해야 합니다. 새로운 스타일이 필요할 때마다 기존 구성요소를 계속 수정한다면 결국 OCP를 위반하게 됩니다.
위반:
const Button = ({ onPress, type, children }) => { let style = {}; if (type === 'primary') { style = { backgroundColor: 'blue', color: 'white' }; } else if (type === 'secondary') { style = { backgroundColor: 'gray', color: 'black' }; } return ( <TouchableOpacity onPress={onPress} style={style}> <Text>{children}</Text> </TouchableOpacity> ); };
이 예에서는 새 버튼 유형이 추가될 때마다 Button 구성 요소를 수정해야 합니다. 이는 확장성이 없으며 버그 위험이 높아집니다.
리팩터링:
스타일을 소품으로 전달할 수 있도록 하여 확장이 가능하도록 Button 구성 요소를 리팩터링합니다.
const Button = ({ onPress, style, children }) => { const defaultStyle = { padding: 10, borderRadius: 5, }; return ( <TouchableOpacity onPress={onPress} style={[defaultStyle, style]}> <Text>{children}</Text> </TouchableOpacity> ); }; // Now, you can extend the button's style without modifying the component itself <Button style={{ backgroundColor: 'blue', color: 'white' }} onPress={handlePress}> Primary Button </Button> <Button style={{ backgroundColor: 'gray', color: 'black' }} onPress={handlePress}> Secondary Button </Button>
리팩토링을 통해 Button 구성 요소는 수정을 위해 닫혀 있지만 확장을 위해 열려 구성 요소의 내부 로직을 변경하지 않고도 새 버튼 스타일을 추가할 수 있습니다.
Node.js 예:
여러 결제 방법을 지원하는 Node.js 애플리케이션의 결제 처리 시스템을 생각해 보세요. 처음에는 단일 클래스 내에서 각 결제 방법을 처리하고 싶을 수도 있습니다.
위반:
class PaymentProcessor { processPayment(amount, method) { if (method === 'paypal') { console.log(`Paid ${amount} using PayPal`); } else if (method === 'stripe') { console.log(`Paid ${amount} using Stripe`); } else if (method === 'creditcard') { console.log(`Paid ${amount} using Credit Card`); } } }
이 예에서 새 결제 수단을 추가하려면 OCP를 위반하는 PaymentProcessor 클래스를 수정해야 합니다.
Refactor:
Introduce a strategy pattern to encapsulate each payment method in its own class, making the PaymentProcessor open for extension but closed for modification.
class PaymentProcessor { constructor(paymentMethod) { this.paymentMethod = paymentMethod; } processPayment(amount) { return this.paymentMethod.pay(amount); } } // Payment methods encapsulated in their own classes class PayPalPayment { pay(amount) { console.log(`Paid ${amount} using PayPal`); } } class StripePayment { pay(amount) { console.log(`Paid ${amount} using Stripe`); } } class CreditCardPayment { pay(amount) { console.log(`Paid ${amount} using Credit Card`); } } // Usage const paypalProcessor = new PaymentProcessor(new PayPalPayment()); paypalProcessor.processPayment(100); const stripeProcessor = new PaymentProcessor(new StripePayment()); stripeProcessor.processPayment(200);
Now, to add a new payment method, you simply create a new class without modifying the existing PaymentProcessor. This adheres to OCP and makes the system more scalable and maintainable.
Definition: Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
Explanation:
The Liskov Substitution Principle (LSP) ensures that subclasses can stand in for their parent classes without causing errors or altering the
expected behavior. This principle helps maintain the integrity of a system's design and ensures that inheritance is used appropriately.
React Native Example:
Imagine a base Shape class with a method draw. You might have several subclasses like Circle and Square, each with its own implementation of the draw method. These subclasses should be able to replace Shape without causing issues.
Correct Implementation:
class Shape { draw() { // Default drawing logic } } class Circle extends Shape { draw() { super.draw(); // Circle-specific drawing logic } } class Square extends Shape { draw() { super.draw(); // Square-specific drawing logic } } function renderShape(shape) { shape.draw(); } // Both Circle and Square can replace Shape without issues const circle = new Circle(); renderShape(circle); const square = new Square(); renderShape(square);
In this example, both Circle and Square classes can replace the Shape class without causing any problems, adhering to LSP.
Node.js Example:
Consider a base Bird class with a method fly. You might have a subclass Sparrow that extends Bird and provides its own implementation of fly. However, if you introduce a subclass like Penguin that cannot fly, it violates LSP.
Violation:
class Bird { fly() { console.log('Flying'); } } class Sparrow extends Bird { fly() { super.fly(); console.log('Sparrow flying'); } } class Penguin extends Bird { fly() { throw new Error("Penguins can't fly"); } }
In this example, substituting a Penguin for a Bird will cause errors, violating LSP.
Refactor:
Instead of extending Bird, you can create a different hierarchy or use composition to avoid violating LSP.
class Bird { layEggs() { console.log('Laying eggs'); } } class FlyingBird extends Bird { fly() { console.log('Flying'); } } class Penguin extends Bird { swim() { console.log('Swimming'); } } // Now, Penguin does not extend Bird in a way that violates LSP
In this refactor, Penguin no longer extends Bird in a way that requires it to support flying, adhering to LSP.
Definition: A client should not be forced to implement interfaces it doesn't use.
Explanation:
The Interface Segregation Principle (ISP) suggests that instead of having large, monolithic interfaces, it's better to have smaller, more specific interfaces. This way, classes implementing the interfaces are only required to implement the methods they actually use, making the system more flexible and easier to maintain.
React Native Example:
Suppose you have a UserActions interface that includes methods for both regular users and admins. This forces regular users to implement admin-specific methods, which they don't need, violating ISP.
Violation:
interface UserActions { viewProfile(): void; deleteUser(): void; } class RegularUser implements UserActions { viewProfile() { console.log('Viewing profile'); } deleteUser() { throw new Error("Regular users can't delete users"); } }
In this example, the RegularUser class is forced to implement a method (deleteUser) it doesn't need, violating ISP.
Refactor:
Split the UserActions interface into more specific interfaces for regular users and admins.
interface RegularUserActions { viewProfile(): void; } interface AdminUserActions extends RegularUserActions { deleteUser(): void; } class RegularUser implements RegularUserActions { viewProfile() { console.log('Viewing profile'); } } class AdminUser implements AdminUserActions { viewProfile() { console.log('Viewing profile'); } deleteUser() { console.log('User deleted'); } }
Now, RegularUser only implements the methods it needs, adhering to ISP. Admin users implement the AdminUserActions interface, which extends RegularUserActions, ensuring that they have access to both sets of methods.
Node.js Example:
Consider a logger interface that forces implementing methods for various log levels, even if they are not required.
Violation:
class Logger { logError(message) { console.error(message); } logInfo(message) { console.log(message); } logDebug(message) { console.debug(message); } } class ErrorLogger extends Logger { logError(message) { console.error(message); } logInfo(message) { // Not needed, but must be implemented } logDebug(message) { // Not needed, but must be implemented } }
In this example, ErrorLogger is forced to implement methods (logInfo, logDebug) it doesn't need, violating ISP.
Refactor:
Create smaller, more specific interfaces to allow classes to implement only what they need.
class ErrorLogger { logError(message) { console.error(message); } } class InfoLogger { logInfo(message) { console.log(message); } } class DebugLogger { logDebug(message) { console.debug(message); } }
Now, classes can implement only the logging methods they need, adhering to ISP and making the system more modular.
Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions.
Explanation:
The Dependency Inversion Principle (DIP) emphasizes that high-level modules (business logic) should not be directly dependent on low-level modules (e.g., database access, external services). Instead, both should depend on abstractions, such as interfaces. This makes the system more flexible and easier to modify or extend.
React Native Example:
In a React Native application, you might have a UserProfile component that directly fetches data from an API service. This creates a tight coupling between the component and the specific API implementation, violating DIP.
Violation:
const UserProfile = ({ userId }) => { const [userData, setUserData] = useState(null); useEffect(() => { fetch(`/api/users/${userId}`) .then(response => response.json()) .then(data => setUserData(data)); }, [userId]); return ( <View> <Text>{userData?.name}</Text> <Text>{userData?.email}</Text> </View> ); };
In this example, the UserProfile component is tightly coupled with a specific API implementation. If the API changes, the component must be modified, violating DIP.
Refactor:
Introduce an abstraction layer (such as a service) that handles data fetching. The UserProfile component will depend on this abstraction, not the concrete implementation.
// Define an abstraction (interface) const useUserData = (userId, apiService) => { const [userData, setUserData] = useState(null); useEffect(() => { apiService.getUserById(userId).then(setUserData); }, [userId]); return userData; }; // UserProfile depends on an abstraction, not a specific API implementation const UserProfile = ({ userId, apiService }) => { const userData = useUserData(userId, apiService); return ( <View> <Text>{userData?.name}</Text> <Text>{userData?.email}</Text> </View> ); };
Now, UserProfile can work with any service that conforms to the apiService interface, adhering to DIP and making the code more flexible.
Node.js Example:
In a Node.js application, you might have a service that directly uses a specific database implementation. This creates a tight coupling between the service and the database, violating DIP.
Violation:
class UserService { getUserById(id) { return db.query('SELECT * FROM users WHERE id = ?', [id]); } }
In this example, UserService is tightly coupled with the specific database implementation (db.query). If you want to switch databases, you must modify UserService, violating DIP.
Refactor:
Introduce an abstraction (interface) for database access, and have UserService depend on this abstraction instead of the concrete implementation.
// Define an abstraction (interface) class UserRepository { constructor(database) { this.database = database; } getUserById(id) { return this.database.findById(id); } } // Now, UserService depends on an abstraction, not a specific database implementation class UserService { constructor(userRepository) { this.userRepository = userRepository; } async getUserById(id) { return this.userRepository.getUserById(id); } } // You can easily switch database implementations without modifying UserService const mongoDatabase = new MongoDatabase(); const userRepository = new UserRepository(mongoDatabase); const userService = new UserService(userRepository);
By depending on an abstraction (UserRepository), UserService is no longer tied to a specific database implementation. This adheres to DIP, making the system more flexible and easier to maintain.
The SOLID principles are powerful guidelines that help developers create more maintainable, scalable, and robust software systems. By applying these principles in your React
Native and MERN stack projects, you can write cleaner code that's easier to understand, extend, and modify.
Understanding and implementing SOLID principles might require a bit of effort initially, but the long-term benefits—such as reduced technical debt, easier code maintenance, and more flexible systems—are well worth it. Start applying these principles in your projects today, and you'll soon see the difference they can make!
위 내용은 React Native 및 MERN 스택의 SOLID 원칙 마스터하기의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!