2달간 나태함에 빠져 글을 작성하지 않았는데, 앉은김에 SOLID
법칙에 대해서 잊어버리기전에 리마인드도 할겸 글을 작성하기로 마음을 먹었다. 요즘은 AWS
인프라와 GitOps
구축에 대해서 공부를 하고 있는 중이라 실제 서버 코드를 작성한게 언젠지 기억이 잘 나질 않는다 😹.
그럼 본 포스팅에 들어가기전에 SOLID
에 대해서 짚어보자면 다음과 같다. (면접에도 단골질문이고 수없이 많이 들어본 부분이라 아마 다들 알고 계실꺼 같다.)
SOLID란, 로버트 마틴이라는 개발자가 객체 지향 프로그래밍을 설계 할 때 5가지 원칙을 가지고 설계를 하게 되면 시간이 지나도 유지 보수와 확장이 쉬운 시스템을 만들수 있다고 말하면서 나오게 된 원칙이다.
그럼 SOLID
에서 각각 알파벳들이 뜻하는 바에 대해서 알아보도록 하자.
SRP
(Single Responsibility Principle, 단일 책임 원칙): 한 클래스는 하나의 책임만을 가져야 한다.OCP
(Open/Closed Principle, 개방/폐쇄 원칙): 소프트웨어 요소는 언제나 확장에는 열려있고 변경에는 닫혀있어야 한다.LSP
(Liskov Substitution Principle, 리스코프 치환 원칙): 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.ISP
(Interface Segregation Principle, 인터페이스 분리 원칙): 특정 클라이언트를 위한 인터페이스는 범용성 높은 하나의 인터페이스보다 여러 개의 인터페이스가 낫다.DIP
(Dependency Inversion Principle, 의존관계 역전 원칙): 프로그래머는 추상화에 의존해야지 구체화에 의존하면 안된다.물론, 경험이 많이 없는 나와 같은 주니어 개발자들은 단번에 SOLID
법칙에 준수하면서 코딩을 할 수 없다. 따라서 코드를 작성할 때 많은 고민이 필요하고 꾸준한 리팩토링이 필요한 것 같다.
위에 나온 설명들은 주구장창 많이 들어봤을 것이다. 따라서 개념은 이제 잘 알겠다. 근데 어떻게 적용해야지 SOLID
를 잘 준수한 코드일까? 간단한 예제를 준비해봤다. 하나씩 차근 차근 알아가보도록 하자.
들어가기에 앞서, 작성자 또한 공부하고 있는 개발자로서 100% 완벽할 수 없어 정확하지 않을 수 있습니다. 하지만 의미 전달은 확실하게 될 수 있도록 작성하겠습니다.
❗️ 본 예제는Typescript
로 작성되었습니다.
SRP
(단일 책임 원칙)은 하나의 클래스는 하나의 책임만을 가져야 된다고 나와있다. 그럼 여기서 책임이라는게 뭘까? 우리가 클래스를 생성할 때 이 클래스에서는 이런 기능을 해야 된다고 두루뭉술하게라도 생각을 하면서 만든다. 그 때 생각한 기능 이외에는 가져서는 안된다는 느낌이다.
예제를 보면 다음과 같다.
export class UserService {
login(): string {
// 로그인 처리 로직
return this.createJwt(email);
}
signUp(): string {
// 회원가입 처리 로직
return this.createJwt(email);
}
private createJwt(email: string) {
// jwt 발행 로직
return 'jwt';
}
}
이렇게 UserService
클래스가 있고 로그인과 회원가입 메서드가 있고 로직이 정상적으로 처리가 되면 jwt
가 발행된다고 가졍해보자. 지금 보시다시피 UserService
에 createJwt
메서드가 정의되어있고 jwt
를 발행해주고 있다.
UserService
클래스는 클래스 이름에서도 알 수 있듯이 유저와 관련된 책임만을 가지고 있어야한다. 다르게 생각해보면 UserService
에는 유저가 실제로 서비스를 받는 로직만 처리가 되어있어야 한다. 근데 jwt를 발행해주는 로직이 들어가있다? 어색하다고 할 수 있고 SRP
위반이라고 할 수 있다. (유저는 로그인과 회원가입 서비스를 이용하지 jwt
발행 서비스를 이용하는게 아니기 때문이다.)
두번째로 UserService
클래스에는 유저 서비스에 관련된 부분이 변경되었을때만 수정되어야 한다. 하지만 jwt
발행과 관련된 부분이 수정이 되어도 UserService
클래스가 수정되게된다. 따라서 로그인, 회원가입 이외의 로직이 변경되었을때도 UserService
클래스가 수정될 수 있기 때문에 SRP
위반이라고 할 수 있다.
정리하면 다음과 같다.
UserService
클래스에는 실제 유저가 사용하는 서비스만 들어가있는 것이 바람직하다.UserService
클래스의 로그인, 회원가입 등 유저가 사용하는 서비스의 변경이 있을 때만 수정이 이루어져야 하는데 jwt
로직이 변경되면 해당 클래스가 수정되기 때문에 바람직하지 않다.SRP
에 맞춰 수정하면 다음과 같다.
// user-service.ts
export class UserService {
constructor(private readonly tokenService: TokenService) {}
login(): string {
// 로그인 처리 로직
return this.tokenService.createJwt(email);
}
signUp(): string {
// 회원가입 처리 로직
return this.tokenService.createJwt(email);
}
}
// token-service.ts
export class TokenService {
private createJwt(email: string) {
// jwt 발행 로직
return 'jwt';
}
}
이렇게되면 유저 서비스에 관련해서는 UserService
클래스만 수정하면 되고, jwt 관련해서는 TokenService
클래스만 수정하면 된다.
OCP
(개방/폐쇄 원칙)는 SOLID
의 꽃🌸 중에 하나라고 봐도 무방하다. (김영한님 강의에 나온다.)
그럼 도대체 확장에는 열려있고, 수정에는 닫혀있어야 한다는게 무슨 말인가? 여기서 인터페이스 클래스의 위대함을 맛 볼 수 있을꺼 같다.
예제를 보면 다음과 같다.
import * as mysql from 'mysql2;
export class UserRepository {
save(sql: string, values: string[]) {
await mysql.query(sql, values);
}
find(sql: string, values: string[]) {
return await mysql.query(sql, values);
}
}
현재 UserRepository
클래스는 유저에 대한 정보를 저장하고 조회하는 기능을 가지고 있고, mysql
데이터베이스를 사용한다고 가정해서 mysql
을 import
해서 작성해보았다.
바로 문제점을 지적하자면 데이터베이스가 postgreSQL
이나 mariaDB
로 변경된다고 가정하면 import
문부터 수정해야되고 라이브러리가 변경되었으면 아래 로직들 또한 변경은 불가피하다고 할 수 있다.
지금으로써는 뭐 그냥 수정하면 되지 않나? 라고 생각할 수 있지만 이런 Repository
클래스들이 실제 서비스에는 무수히 많을 수 있다. 그럼 일일히 클래스에 들어가서 장인정신으로 코드를 수정해야 된다. 따라서 확장성이 무지하게 안좋을 코드인데 변경도 거의 모든 코드 라인을 손대야 할 정도로 많은 부분 수정되어야 한다. (확장에는 열려있지 못하고, 변경에는 닫혀있을 수가 없다.)
그리고 UserRespotory
는 현재 유저 정보 저장과 관련된 로직에 대해서만 수정이 이루어져야 하는데 데이터베이스가 변경되면 수정이 되어야하기 때문에 위에서 설명한 SRP
도 위반이라고 할 수 있다.
그럼 OCP
를 지키기 위해서는 어떻게 코드를 작성해야 하는가? 여기서 인터페이스 설계의 위대함을 느낄 수 있다. 코드로 살펴보도록 하자.
// IDatabase-adapter.ts
export interface IDatabaseAdapter {
save(sql: string, values: string[]): unknown[];
find(sql: string, values: string[]): unknown[];
}
// MySQLDatabase-adapter.ts
import * as mysql from 'mysql2';
export class MySQLDatabaseAdapter implements IDatabaseAdapter {
save(sql: string, values: string[]) {
mysql.query(sql, values);
}
find(sql: string, values: string[]) {
return mysql.query(sql, values);
}
}
// user-repository.ts
export class UserRepository {
constructor(private readonly databaseAdapter: IDatabaseAdapter){}
save(sql: string, values: string[]) {
await this.databaseAdapter.query(sql, values);
}
find(sql: string, values: string[]) {
return this.databaseAdapter.query(sql, values);
}
}
이렇게 작성하면 UserRepository
는 IDatabaseAdapter
인터페이스 클래스를 상속 받은 모든 클래스를 사용할 수 있는 상태가 되었다. 따라서 UserRepository
를 생성 할 때 사용하고 싶은 데이터베이스 어댑터를 넣어주면 된다. (여기서 어댑터 패턴이 사용되었다. 해당 포스팅에서 다룰 내용이 아니기 때문에 링크를 눌러서 확인해주세요.)
// mysql 사용하고자 할 때
const mysqlAdapter = new MySQLDatabaseAdapter();
const userRepository = new UserRepository(mysqlAdapter);
// postgreSQL 사용하고자 할 때
// IDatabaseAdapter 클래스를 상속 받아서 구현한 구현체
const postgreAdapter = new PostgreDatabaseAdapter();
const userRepository = new UserRepository(postgreAdapter);
정리하자면 다음과 같다.
UserRepository
클래스에서 mysql
을 import
해서 사용하면 mysql
에 의존성이 생기게 되고, 확장성에는 닫히게 된다. (데이터베이스가 변경되면 많은 코드 수정도 불가피해진다.)UserRepository
클래스에는 필요에 따른 데이터베이스 어댑터 클래스를 외부에서 넣어주기만 하면 되기 떄문에 로직은 변경될 부분이 없다. 이 부분은 코드로 설명할 부분이 없어서 말로만 설명을 하도록 하겠다. LSP
(리스코프 치환 원칙)은 인터페이스 규약을 잘 지켜서 개발을 진행하라는 의미이다. 근데 위에 데이터베이스 인터페이스를 예로 들어보면 save
메서드에서 데이터베이스를 저장하는 로직을 작성해야되는데, SELECT
하는 로직을 작성하게되면 LSP
법칙 위배라고 할 수 있다.
따라서 단순히 컴파일이 성공되었다고 지켜지는 원칙이 아닌 저장하는 메서드면 저장하는 기능으로 잘 구현이 되었는지를 봐야하는 원칙이라고 할 수 있다.
근데 곰곰히 생각해보면 기획자가 요구한 기능대로 개발이 안돼서 LSP
원칙이 깨질수도 있다고 생각은 했지만, 고의적으로 다른 로직을 작성하는 개발자는 없다고 생각한다..ㅎㅎ
진짜 귀찮아서 그러는게 아니고 이 부분 또한 코드로 설명할 부분이 없다. (굳이 하자면 할 순 있지만 글로도 충분히 이해하실 수 있다.) ISP
(인터페이스 분리 원칙)은 하나의 범용적인 인터페이스 클래스보다 여러 개의 인터페이스 클래스가 낫다는 원칙인데, 가장 비유하기 좋은 자동차로 예를 들어보자.
자동차를 만들려고 자동차 인터페이스 하나만 보고 자동차를 만들수도 있다. 그렇게되면 내 생각인데 자동차를 만드는 한 곳에서 운전대, 바퀴, 엔진 등 이 모든것을 다 만들어야 되지 않을까 싶다.
하지만 엔진 인터페이스, 바퀴 인터페이스, 운전대 인터페이스 등 여러개로 인터페이스가 나눠져있다면 엔진 개발 회사에서는 엔진 인터페이스만 보고 만들고, 바퀴 개발 회사는 바퀴 인터페이스만 보고 만들고 최종적으로 조립을 하면 되기 때문에 훨씬 더 좋은 개발과 유지보수가 될 수 있다고 생각한다.
마지막 원칙인 DIP
(의존관계 역전 원칙) 또한 SOLID의 꽃🌸 이라고 할 수 있다. 개발자는 추상화에 의존해야지 구체화에 의존하면 안된다는 법칙인데 이미 DIP
위반이 이루어진 코드를 여러분들은 보았다.
import * as mysql from 'mysql2;
export class UserRepository {
save(sql: string, values: string[]) {
await mysql.query(sql, values);
}
find(sql: string, values: string[]) {
return await mysql.query(sql, values);
}
}
위에서 OCP를 설명할 때 사용한 코드인데 UserRepository
는 구현체에 의존하고 있다. 여기서 구현체란 mysql2
라이브러리이다. 구현체에 의존하게 되면 위에서도 말했듯이 다른 데이터베이스로 변경할 때 코드 수정이 불가피해질뿐더러 바꿀 순 있겠지만 매끄러운 변경이 불가능하다고 할 수 있다.
따라서 DIP
법칙에서 설명했듯이 구현체가 아닌 추상화(인터페이스)에 의존하면 UserRepository
는 어떤 데이터베이스를 사용하는게 중요한게 아닌 인터페이스에 정의된 기능에 의존하게 되면서 좀 더 유연한 클래스가 된다고 할 수 있다.
위의 말이 이해가 잘 안되실 수도 있을꺼 같아서 적절하진 모르겠지만 김영한님 인강에서 본 내용을 공유하자면 다음과 같다.
로미오와 줄리엣 연극이 있는데 로미오에는 장동건, 원빈이 배역을 맡고 줄리엣은 고소영, 이나영이 배역을 맡는다. 장동건은 상대 배우로 고소영과 연습을 하고 호흡을 맞췄고, 원빈은 이나영과 호흡을 맞췄다.
대망의 연극날 장동건과 고소영이 첫날 연극을 진행해야 한다. 하지만 고소용이 몸이 안좋아서 못나오겠다고 해서 급하게 이나영이 왔다. 근데 장동건이 자기는 고소영과 연습했기 때문에 이나영과는 연극을 못한다고 선언을 하게 된다.
지금 글을 보면 장동건은 추상화인 줄리엣에 의존해서 연습을 한게 아니고 구체화된 고소영에 의존해서 연습을 했다고 할 수 있다. 이런 부분이 DIP
법칙을 위반했다고 할 수 있다.
그리고 눈치 빠르신분들은 이미 알아차리셨을지도 모르겠는데 OCP
, DIP
법칙을 지키는 가장 중요한 개념이 객체지향의 다형성이다. 다형성을 잘 활용하면 많은 부분을 해결할 수 있게 된다.
그리고 클린 아키텍처에 나오는 그림인데 해당 그림을 참고해서 개발을 진행하면 더 좋은 개발이 될꺼 같아서 가져와보았다.
그림을 간단하게 설명하면 해당 아키텍처가 마치 양파 껍질과 같다고 해서 붙여진 이름으로 양파의 내부로 갈수록 순수한 객체만 사용되어야 한다는 의미이다.
양파의 가장 바깥쪽은 대표적으로 UI
를 꼽을 수 있는데 실제로 UI
의 경우 수시로 바뀌고 수정된다. 그렇기 때문에 양파의 가장 바깥쪽에 위치하고 있다.
그리고 가장 내부는 Entity
클래스인데 Entity
클래스에는 자바스크립트를 예로 들면 순수 자바스크립트 코드로 작성되어야 한다는 말이다. 만약 순수 자바스크립트 코드 말고 다른 외부 라이브러리가 Entity
클래스에 작성된다면 구조를 잘못 세운 안좋은 아키텍처라고 생각해봐야 할 것이다.
이런 부분들을 완벽하게는 못지켜도 지키려고 노력하면 모두가 엄청난 개발자가 될 수 있지 않을까 생각한다.
오늘 중요하지만 간과하고 넘어갈 수 있는 SOLID
법칙에 대해서 글을 남겨보았는데, 첫 술에 너무 배부르려고 하면 오히려 체할 수 있을꺼 같다. 따라서 해당 원칙에 대해서 많은 고민을 해보고 코드에 최대한 적용해보려고 노력한다면 이쁜 구조를 가진 코드를 하나씩 깃허브에 가지고 있을 수 있지 않을까 생각한다. (나는 아직 없음 ㅎㅎ)
글을 읽으면서 갸우뚱하는 부분도 있을 수 있는데 저도 아직 배우고있는 입장이라 위에서 설명했듯이 완벽할 수 없습니다. 근데 너무 잘못된 부분이 있으면 댓글로 알려주시면 수정하도록 하겠습니다.
긴 글 읽어주셔서 감사하고 이만 글을 마치겠습니다.
총총 🏃