좋은 소프트웨어 시스템은 깔끔한 코드(clean code)로부터 시작한다.
좋은 벽돌을 사용하지 않으면 빌딩의 아키텍처가 좋고 나쁨은
그리 큰 의미가 없는 것과 같다.
반대로 좋은 벽돌을 사용하더라도 빌딩의 아키텍처를 엉망으로 만들 수 있다.
그래서 좋은 벽돌로 좋은 아키텍처를 정의하는 원칙이 필요한데,
그게 바로 SOLID다. SOLID 원칙은 함수와 데이터 구조를 클래스로 배치하는 방법,
그리고 이들 클래스를 서로 결합하는 방법을 설명해준다.
'클래스'라는 단어를 사용했다고 해서 SOLID 원칙이 객체 지향 소프트웨어에만
적용된다는 뜻은 아니다. 여기에서 클래스는 단순히 함수와 데이터를
결합한 집합을 가리킨다. 소프트웨어 시스템은 모두이러한 집합을 포함하며,
이러한 집합이 클래스라고 불릴 수도 있고 아닐 수도 있다.
- 클린 아키텍처, 62p
훌륭한 설계에 대한 최초의 이론은 1970년대가 돼서야
비로소 세상에 모습을 드러냈다.대부분의 설계 원칙과 개념
역시 이론에서 출발해 실무에 스며들었다기보다는
실무에서 반복적으로 적용되던 기법들을 이론화한 것들이 대부분이다.
실무에서는 다양한 규모의 소프트웨어를 성공적으로 유지보수하고 있지만
소프트웨어 유지보수와 관련된 효과적인 이론이 발표된 적은 거의 없다.
심지어 이론은 소프트웨어 유지보수에 전혀 관심이 없는 것처럼 보이기까지 한다.
소프트웨어 생명주기 동안 유지보수가 차지하는 비중을 감안해 볼 때
현재의 상황은 매우 실망스러운 수준이라고 할 수 있다.
결론적으로 소프트웨어 설계와 유지보수에 중점을 두려면 이론이 아닌
실무에 초점을 맞추는 것이 효과적이다.
- 조영호 <오브젝트>, 7~8p
이번 회차는 여러 번 복습하며 늦게 정리하게 되었는데 그만큼 중요하고
이번 회차를 통해 내 코드의 방향성을 바꿔줄 굉장히 엄청나게 중요한
회차라고 생각한다. 위에 두글을 읽으며 내가 느낀 건 SOLID를 배우며 이론에서
그 치치 말고 내 프로젝트에 직접 도입하여 내가 SOLID를 통해 무엇이 변화했는지
느끼지 못한다면 무용지물이라는 생각이 들어 바로 코드를 통해 도입해보고 있다.
https://zoon-bloom.tistory.com/147
SOLID 원칙
SRP: 단일 책임 원칙 (Single Responsibility Principle) !중요
- 콘웨이 법칙에 따른 따름정리: 소프트웨어 시스템이 가질 수 있는
최적의 구조는 시스템을 만드는 조직의 사회적 구조에 커다란
영향을 받는다. 따라서
각 소프트웨어 모듈은 변경의 이유가 하나,단 하나여야만 한다.
하나의 모듈은 하나의 사용자 또는 이해관계자에 대해서만 책임져야 한다. - “하나의 함수가 하나의 동작만 하도록 설계하라”는 의미로 번역되기도
하지만 문장 그대로 받아들인다면 원래 맥락의 뜻이 많이 손상되는
요약이라서 ‘하나의 요소가 하나의 변경의 이유를 갖게 하라’ 는
정의로 바꿔서 생각하는게 좋다. - 애플리케이션에서 유지보수성(maintainability)은 재사용성보다 중요하다.
애플리케이션에서 코드가 반드시 변경되어야 한다면, 이러한 변경이
여러 컴포넌트 도처에 분산되어 발생하기보다는,
차라리 변경 모두가 단일 컴포넌트에서 발생하는 편이 낫다.
만약 변경을 단일 컴포넌트로 제한할 수 있다면,
해당 컴포넌트만 재배포하면 된다. 변경된 컴포넌트에 의존하지 않는
다른 컴포넌트는 다시 검증하거나 배포할필요가 없다. - 수정, 변경의 이유를 만드는 건 주로 해당 컴포넌트에 대한
이해관계자(개발자)이다. 여기서 포인트는
‘그렇다면 이해관계자가 무엇을 원하는지 어떻게 알 수 있는가?’ 이다.
당연하게도 그 답은 ‘소통’ 이다!.
"시스템은 그것을 설계한 조직의 커뮤니케이션 구조를 반영한다."
- 콘웨이의 법칙
설계 단계(구현 이후가 아니라 초기 단계)에서 부터 컴포넌트를
어떻게 관리할지에 대한 논의가 반드시 필요한 이유이다. - '응집된(cohesive)'이라는 단어가 SRP를 암시한다. 단일 액터를 책임지는
코드를 함께 묶어주는 힘이 바로 응집성(cohesion)이다.
-클린 아키텍처67p
이 개념도 SRP와 긴밀한 연관성이 있는 개념이다.
SRP는 단순히 어떤 로직에 대한 이야기가 아닐 수 있으며,
강의에서는 실무에서 사용되었던 상황을 예시로 보여주었다.
- 특정 유저군의 서비스 접근을 3회에 거쳐 단계적으로 종료한다.
- 일자 별로 접근 불가능해져야 하는 페이지가 추가된다.
- 일자 별로 노출되지 말아야 하거나 작동하면 안되는 기능이 추가된다.
- QA 담당자가 각 일자를 시뮬레이션 할 수 있도록
쿼리스트링으로 feature flag를 제공한다.

대충 이론은 이해했지만 코드를 보니 잘 모르겠다;;
소프트웨어 설계와 유지보수에 중점을 두려면 이론이
아닌 실무에 초점을 맞추는 것이 효과적이다 라는 말이 여기서 와닿았다.
OCP: 개방-폐쇄 원칙 (Open-Closed Principle)
- 1980년대에 버트란트 마이어에 의해 유명해진 원칙이며
기존 코드를 수정하기보다는 반드시 새로운 코드를 추가하는 방식으로
시스템의 행위를 변경할 수 있도록 설계 해야만 소프트웨어 시스템을 쉽게
변경할 수 있다는 것이 이 원칙의 요지다. - OCP는 “확장에는 열려 있고, 수정에는 닫혀 있도록 한다”는
문장으로 정리될 수 있다.
기존 코드를 수정하기 보다는 새로운 코드를 추가하는 방식으로
시스템의 행위를 변경하기 쉽도록 설계하라인데.
이 원칙에 컴파운드 컴포넌트 패턴(CCP)이 해당한다.
관련 로직들은 내부에서 구현되어 title과 content를 prop으로 받고 있다.
<Card title="foo" content="bar" />
컴포넌트 내부에 댓글 관련 기능이 추가된다고 가정하고.
comments라는 prop을 추가하고 관련 로직들을 추가 구현한다.
<Card title="foo" content="bar" comments={comments} />
유저 관련 정보 유무에 따라 해당 플래그를 판단할 수 있도록 정보를 전달한다.
<Card title="foo" content="bar" comments={comments} user={user} />
매번 코드가 추가되거나 수정될 때마다 Card 컴포넌트 전체에 변경이 발생하고
전파된다. 하지만 같은 요구사항을 CCP로 구현한다면?
<Card>
<Card.Title>foo</Card.Title>
<Card.Content>bar</Card.Content>
{user
? <Card.CommentsWithAuth comments={comments} />
: <Card.CommentsWithAnonymous comments={comments} />
}
</Card>
- SRP: Card 이하 각각의 서브 컴포넌트들이 책임을 가지도록 분리되어,
수정의 범위와 시점이 이전에 비해 구체적일 수 있도록 개선되었습니다. - OCP: Card 에 기능을 추가하거나 변경하고 싶을 때 기존 코드를 전혀
건드리지 않고도 새로운 컴포넌트를 추가함으로써 기능을 수정하고
확장할 수 있는 설계가 되었습니다.
LSP: 리스코프 치환 원칙 (Liskov Substitution Principle)
- 1988년 바바라 리스코프가 정의한, 하위 타입(subtype)에 관한 유명한 원칙이다.
요약하면, 상호 대체 가능한 구성요소를 이용해 소프트웨어 시스템을 만들 수 있으려면,
이들 구성요소는 반드시 서로 치환 가능해야 한다는 계약을 반드시 지켜야 한다. - LSP는 하위 클래스가 상위 클래스를 대체할 수 있어야 한다는 원칙인데
상속(Inheritance)보다는 합성(Composition)을 권장하는 React로 개발하는
입장에서 직관적으로 받아들이기는 쉽지 않을 수 있다.
- 대체 가능성: 하위 클래스의 인스턴스는 프로그램의 정확성을 해치지 않으면서
상위 클래스의 인스턴스를 대체할 수 있어야 합니다. - 계약에 의한 설계: 하위 클래스는 상위 클래스가 정의한 계약
(ex. 메소드 사양)을 준수해야 합니다. - 행동 호환성: 하위 클래스는 상위 클래스의 행동
(ex. 메소드가 반환하는 값의 범위, 예외 처리)을 보존해야 합니다.
(기대되는 훅의 인터페이스)을대체할 수 있어야 합니다.
즉, 내부 구현이 변경되더라도 외부 인터페이스와 행동은변하지 않아야 한다. - 대체 가능성: 하위 클래스의 인스턴스는 프로그램의 정확성을 해치지 않으면서
밑에 코드는 input 폼을 관리해 주는 라이브러리 fomik애서 react-hook-form으로
라이브러리를 바꿨을 때의 예시이다.
react-hook-form으로 변경하면서 이제 라이브러리가 바뀌어도
useFormik, useForm 부분의 코드만 교체함으로써
form을 관리할 수 있게 되었고 이 코드로 인해
내부 구현이 변경되더라도 외부 행동은 변경되지 않는다.
* 변경 전
import { useFormik } from 'formik';
const useCustomFormWithFormik = (initialValues, onSubmit) => {
const formik = useFormik({
initialValues: initialValues,
onSubmit: onSubmit,
});
return {
values: formik.values,
handleChange: formik.handleChange,
handleSubmit: formik.handleSubmit,
};
};
export default useCustomFormWithFormik;
*변경 후
import { useForm } from 'react-hook-form';
const useCustomFormWithReactHookForm = (initialValues, onSubmit) => {
const { register, handleSubmit, setValue } = useForm({
defaultValues: initialValues
});
const handleChange = (event) => {
setValue(event.target.name, event.target.value);
};
return {
values: register,
handleChange: handleChange,
handleSubmit: () => handleSubmit(data => onSubmit(data)),
};
};
export default useCustomFormWithReactHookForm;
ISP: 인터페이스 분리 원칙 (Interface Segregation Principle)
- 이 원칙에 따르면 소프트웨어 설계자는 사용하지 않은 것에
의존하지 않아야 한다.(= 꼭 필요한 것에만 의존하도록 만들어야 한다) - 컴포넌트에 필요한 props만 전달할 것!
DIP: 의존성 역전 원칙 (Dependency Inversion Principle)
- 고수준 정책을 구현하는 코드는 저수준 세부사항을 구현하는 코드에
절대로의존하지말고 .대신 세부사항이 정책에 의존해야 한다.
필요한 정보를 내부에서 구체적으로 정의하지 말고,외부에서 추상의
형태로 주입받아 쓰라는 뜻 - 콜백 함수를 생각하면 된다? 콜백 함수가 인자로 들어가 실행되면
콜백함수를인자로 받는 함수는콜백함수에게 제어권을 넘기게되므로
DIP가 원칙을 따른다? - 의존성 역전 원칙에서 말하는 ‘유연성이 극대화된 시스템’이란
소스 코드 의존성이 ‘추상(abstraction)’에 의존하며 ‘구체(concretion)’에는
의존하지 않는 시스템이다. 구체적이며 변동성이 크다면
절대로 그 이름을 언급하지 말라. - 클린 아키텍처, 92~94p
React 측면에서의 DIP 원칙은 컴포넌트에 props를 넘겨주거나
Context API로 값을 주입해주는 것, children으로 합성하는 것
모두 DIP의 일종으로 볼 수 있다. 다만 이런 방법들은 DIP를
구현할 수 있는 수단이지 항상 DIP를 온전하게 구현하지 못한다고 함!
왜냐하면 일반적으로 외부에서 컴포넌트 내부로 값을 전달할 때 매우
구체적인 값들을 전달하는 경우가 빈번하기 때문이며 중요한 것은
‘의존성’이고, 의존성이 추상적일 수록 컴포넌트는 자유를 얻고
더 다양한 상황에유연하게 대응할 수 있는 능력을 가지게 된다.
아래에 코드는 다음과 같은 문제가 예상된다.
- DB를 MySQL에서 PostgreSQL이나 NoSQL 등으로 교체할 수도 있습니다
- JWT 외의 다른 인증 방식을 사용할 수도 있습니다
class UserService {
async localLogin(email: string, password: string) {
const user = await mysql.query(`SELECT * FROM user WHERE email = ?`, [
email,
]);
if (jwt.compare(password, user.password)) {
throw new Error("login fail");
}
return jwt.create(user.uuid);
}
}
UserService가 아래와 같이 userRepository와 tokenService를 외부에서
주입받도록 변경하면 UserService에서 구체적인 로직에 대한 언급을 피하면서
로직의 유연성을 높일 수 있다.
class UserService {
constructor(
private userRepository: UserRepository,
private tokenService: TokenService
) {}
async localLogin(email: string, password: string) {
const user = await userRepository.getUserByEmail(email);
if (!tokenService.compare(password, user.password)) {
throw new Error("login fail");
}
return tokenService.create(user.uuid);
}
}
위와 같이 UserService 에서 DB와 토큰 인증 방식에 대하여 특정 기술에
의존하는구체적인 변수명이 사라졌고, 외부에서 해당 인터페이스를
만족하는 무언가가 들어오면 그냥 실행하도록 변경되었다.
interface UserRepository {
getUserByEmail(email: string): Promise<User>;
}
class MysqlUserAdaptor implements UserRepository {
getUserByEmail(email: string) {
return mysql.query(`SELECT * FROM user WHERE email = ?`, [email]);
}
}
interface TokenService {
compare(value: string, encrypted: string): Boolean;
create(value: string): Token;
}
class JWTTokenAdaptor implements TokenService {
compare(value, encrypted) {
return jwt.compare(value, encrypted);
}
create(value) {
return jwt.create(value);
}
}
UserRepository, TokenService라는 인터페이스를 규약으로 추가하고,
해당 규약을 만족하는 class를 추가하고 있습니다. 만약 새로운 기술을 추가할
필요가 생겼다면 규약을 만족하는 새로운 클래스를 추가로 생성한다
이 부분은 OCP와 닮았다고도 볼 수 있다.
// 사용자 인스턴스 생성을 위한 의존성 주입
const userService = new UserService(
// new MysqlUserAdaptor(),
new PostgreUserAdaptor()
new JWTTokenAdaptor()
);
// 로컬 로그인 함수 사용 예
async function loginUser(email, password) {
try {
const token = await userService.localLogin(email, password);
console.log('User logged in with token:', token);
// 로그인 성공 시, 토큰을 사용하는 로직
} catch (error) {
console.error(error.message);
// 에러 처리 로직
}
}
// 사용자 로그인 시도
loginUser('user@example.com', 'password123');
Class 형태의 코드가 들어가서 좀 해석하기 어렵지만 DIP 원칙에 대해
쉽게 이해할 수 있었던 것 같다!
추가로 인증 로직 예시까지 더 보여주였다.(정말 개이득이다)
// 1. 인증 서비스 인터페이스
interface IAuthService {
login(credentials: any): Promise<User>;
logout(): Promise<void>;
getCurrentUser(): User | null;
}
// 2. 인증 컨텍스트 및 프로바이더 구현
const AuthContext = createContext<IAuthService | null>(null);
const AuthProvider = ({ authService, children }) => {
return (
<AuthContext.Provider value={authService}>
{children}
</AuthContext.Provider>
);
};
// 3. 구체적인 인증 서비스 구현
// 추후 다른 인증 방법 추가 시 IAuthService를 기반으로 새로운 클래스 생성
class BasicAuthService implements IAuthService {
// BasicAuthService의 구체적인 구현
login(credentials: any): Promise<User> {
// 로그인 로직
}
logout(): Promise<void> {
// 로그아웃 로직
}
getCurrentUser(): User | null {
// 현재 사용자 정보 반환
}
}
// 4. 컨텍스트를 사용하는 로그인 컴포넌트
const Login = () => {
const authService = useContext(AuthContext);
const handleLogin = async () => {
try {
const user = await authService.login({ username: 'user', password: 'pass' });
console.log('Logged in user:', user);
} catch (error) {
console.error('Login failed:', error);
}
};
return <button onClick={handleLogin}>Login</button>;
};
// 5. 최종 구현
const App = () => {
const authService = new BasicAuthService();
return (
<AuthProvider authService={authService}>
<Login />
</AuthProvider>
);
};
- 인터페이스 기반 설계: IAuthService 인터페이스는 다양한 인증 메커니즘을 추상화합니다.
이를 통해 실제 인증 로직의 구현은 구체적인 클래스(BasicAuthService)에 위임됩니다. - 컨텍스트를 통한 의존성 주입: AuthProvider 컴포넌트는 authService를
자식 컴포넌트에게 주입합니다. 이를 통해 자식 컴포넌트는 인증 로직의
구체적인 구현에 의존하지 않고 인터페이스를 통해 작동합니다. - 유연성 및 확장성: 이 접근 방식을 사용하면, 나중에 다른 인증 메커니즘
(예: OAuth, SSO)을 쉽게 도입할 수 있습니다.
새 인증 서비스 클래스를 IAuthService에 맞게 구현하고,
AuthProvider에 주입하기만 하면 됩니다.
추가로 더봐야할 것!
https://fe-developers.kakaoent.com/2023/230330-frontend-solid/
'프리온보딩 챌린지' 카테고리의 다른 글
프리온보딩 FE 챌린지, 비지니스 로직(2) (0) | 2023.12.13 |
---|---|
프리온보딩 FE 챌린지, 비지니스로직(1) (1) | 2023.12.08 |
프리온보딩 FE 챌린지, 클린코드(4) (0) | 2023.11.19 |
프리온보딩 FE 챌린지, 클린코드(3) (1) | 2023.11.13 |
프리온보딩 FE챌린지, 클린코드(2) (1) | 2023.11.09 |