본문 바로가기

programming/CS

[객체지향] SOLID 원칙 이해하고 적용하기(+ 일급 컬렉션)


최근 소셜 로그인 구현 코드를 리팩토링하는 과정에서 SOLID 원칙을 조사하고 적용해보았습니다. 따라서 이번 포스팅에서는 SOLID 원칙 설명과 리팩토링 과정을 정리하고자 합니다.
 

💡 배경 & 문제 인식

이전에 소셜(카카오) 로그인 API를 서버측에 구현했는데, 문득 서비스에 새로운 소셜 로그인이 추가될 경우 현재 코드 설계는 적합하지 않다고 판단했습니다. 그 근거는 아래와 같습니다.

리팩토링 이전 소셜 로그인 구조

 
위의 UML 클래스 다이어그램은 기존 카카오 소셜 로그인 기능의 설계 구조입니다.
 
우선 소셜 로그인에 사용되는 객체가 모두 구현체로 정의되어 있는 것을 확인할 수 있습니다. 즉, 만약 서비스에 새로운 소셜 로그인이 추가되는 경우에 해당 소셜 로그인에 알맞는 컨트롤러 구현체서비스 구현체를 그때그때 구현해야 한다는 것입니다.
이렇게 소셜 미디어가 추가될 때마다 구현체를 매번 구현해야 한다면, 각 클래스 내에 정의한 메서드 명이 통일되지 않고 제각각이 될 확률도 높아질 것입니다.
 
이는 코드의 통일성도 해칠 뿐만 아니라, 추가되는 소셜 로그인 수만큼 컨트롤러 및 서비스도 추가되기 때문에 코드의 수정도 그만큼 증가합니다. 따라서 유지보수가 어려워질 가능성이 높다고 생각했습니다.
 
SOLID 원칙 중 '기능 확장에는 열려있고 코드 수정에는 닫혀 있어야 한다'는 "개방 폐쇄 원칙"에 위배되는 상황이라 판단하였고, 이를 개선하기 위해 추상화를 활용하기로 했습니다.
소셜 로그인을 추상화 한 후, 알맞는 구현체를 상황에 따라 매핑시킬 수 있도록 한다면 소셜 미디어 확장 시 구현체만 추가하도록 설계할 수 있을 것이라 생각했습니다.
 

📝 SOLID 원칙

SOLID는 객체지향 설계에서 지켜야 할 다섯 가지 원칙을 표현한 용어입니다. 이 설계 원칙은 소프트웨어의 유지보수성(= 확장성, 재사용성 등)을 높이기 위해 등장했습니다. 소프트웨어는 서비스의 지속적인 변화에 유연성 있게 대응할 수 있어야 하기 때문입니다.
 

  • Single Responsibility Principle - 단일 책임 원칙
  • Open Close Principle - 개방 폐쇄 원칙
  • Listov Substitution Principle - 리스코프 치환 원칙
  • Interface Segregation Principle - 인터페이스 분리 원칙
  • Dependency Inversion Principle - 의존성 역전 원칙

 

1️⃣ 단일 책임 원칙

하나의 클래스는 하나의 책임(기능)만 가져야 한다는 원칙입니다.
만약 하나의 클래스가 여러 책임을 담당한다면, 하나의 기능이 변경된다 하더라도 코드의 수정이 불필요하게 증가할 수 있습니다. 가령, 로그인 서비스 클래스에서 사용자 정보를 암호화하는 기능도 존재한다면, 서로 다른 작업 수정(로그인 기능 수정 / 암호화 기능 수정)을 모두 하나의 클래스가 감당하게 될 것입니다. 이를 방지하기 위해 로그인을 진행하는 클래스와 암호화를 진행하는 클래스를 따로 분리하는 것이 단일 책임 원칙입니다. (예시 출처: https://mangkyu.tistory.com/194)
 
 

2️⃣ 개방 폐쇄 원칙

앞서 잠깐 언급한 원칙으로, 기능의 확장에는 열려있고 코드 수정에는 닫혀있어야 한다는 원칙입니다. 즉, 기능이 추가됨에 따라 코드의 수정을 최소화 해야 한다는 것입니다. 이는 추상화를 통해 다형성 및 확장의 이점을 얻어야 한다는 내용으로 볼 수 있습니다.
뒤에서 더 자세히 살펴보겠지만, 이번 리팩토링 과정에서 추상화를 활용했습니다. 
 
 

3️⃣ 리스코프 치환 원칙

자식은 항상 부모로 대체될 수 있어야 한다는 원칙입니다. 이는 OOP 다형성과 관련한 것으로, 자식을 부모 타입으로 정의(다형성)하여 부모 의 메서드로 호출 하더라도 자식에서 의도한대로 동작해야 한다는 의미입니다.
이 원칙 또한 이번 리팩토링 과정에서 적용했습니다. 
 
 

4️⃣ 인터페이스 분리 원칙

단일 책임 원칙을 인터페이스에 적용한 것으로, 인터페이스의 기능을 잘게 쪼개야 한다는 원칙입니다.
가령, A 기능만 필요로 하는 클래스와 A 기능과 B 기능을 모두 필요로 하는 클래스가 존재하는 경우, 하나의 인터페이스에 A 기능과 B 기능을 모두 포함하는 것이 아닌 A 기능과 B 기능의 인터페이스를 따로 분리하자는 것입니다.
여기에 덧붙이자면 인터페이스는 한번 정의하면 웬만해선 변경하지 말아야 합니다.
 
 

5️⃣ 의존성 역전 원칙

구현체에 의존하는 것이 아닌, 추상화에 의존해야 한다는 원칙입니다. 구현체는 변화가 발생할 가능성이 크지만 추상화는 변화에 보수적이기 때문에, 변화할 가능성이 적은 추상화에 의존하는 것입니다.
다만 여기서 주의할 점은 아래와 같이 타입은 인터페이스지만 할당 값이 구현체인 경우에는 의존성 역전 원칙을 지켰다고 볼 수 없습니다. 할당 값이 구현체라면 결국 해당 값은 구현체에 의존하는 것이기 때문입니다. 

public interface A {
    ...
}

public class B implements A {
    ...
}

public class Example {
    A noDIP = new B(); // ❌ 의존성 역전 원칙을 지킨 것이 아니다
}

 
 

✅ 적용하기

앞서 살펴본 SOLID 원칙을 참고하여 리팩토링을 진행했습니다. 
 

소셜 로그인 기능을 추상화 하기

앞에서 언급했다시피 추상화의 필요성을 느꼈기 때문에 소셜 로그인 기능을 모두 포괄할 수 있는 기능을 분류하고자 각 소셜 로그인의 공식 문서를 찾아보게 되었습니다.
공통적으로 소셜 미디어로부터 유저의 코드 값을 전달받고, 이 코드 값을 통해 소셜 미디어 유저 정보를 읽어오는 형식임을 파악할 수 있었습니다.
 
그리고 기존의 코드에서는 카카오 로그인시 서버 API로 리다이렉션 시켜 유저의 코드 값을 전달받도록 했습니다. 하지만 '타 소셜 미디어 API에서는 code 값을 리다이렉션 uri 파라미터 값으로 안 넘겨줄 수도 있지 않을까?' 하는 괜한 마음에 클라이언트 측에서 코드 값을 직접 body로 넘겨주는 걸로 변경했습니다.
즉, 소셜 미디어 API의 리다이렉션 uri를 클라이언트로 등록하고, 클라이언트에서 이 코드 값을 직접 body에 담아 서버에 넘겨주는 것입니다. 
 
(근데 지금 포스팅 작성하면서 공식문서를 자세히 확인해보니 일단 네이버, 구글, 카카오 모두 redirect uri 파라미터로 code 값을 넘겨주고 있네요 .. 그래서 사실 리다이렉션 uri를 서버 uri로 유지하되, 소셜 로그인 컨트롤러에 리다이렉션을 처리해주는 API(= code 값을 파라미터로 전달받는 API)를 추가했어도 됐겠다는 생각이 듭니다.)
 
따라서 위 내용을 종합하자면, 코드 값을 전달 받은 후에 이 코드 값으로 유저 정보를 얻는 기능을 공통 기능으로 파악하였고, 이 내용을 토대로 아래와 같이 OauthProvider 인터페이스를 정의했습니다. (getProvider 메서드는 바로 뒤에서 언급하겠습니다)
 

소셜 로그인 인터페이스

 
또한, 유저가 어느 소셜 로그인을 선택했는지 구분해야 하는데요, 이는 소셜 로그인 요청 API의 end point로 구별하도록 했습니다.
(ex. login/{provider})
 

각 소셜 로그인에 알맞는 구현체 매핑하기

저는 이 부분에서 고민을 조금 많이 했는데요, 
일단 제 머릿속에서는 어느 방식을 떠올려도 각 구현체가 자신이 어떤 provider(= 소셜 미디어)인지 알려줘야 겠더라구요. 그래서 각 구현체가 자신이 무슨 provider 인지 알려줄 수 있도록 OauthProvider 인터페이스에 getProvider 메서드를 추가했습니다.
그리고 provider는 단순 String으로 관리하면 오타에 민감해질 것 같아서, 개발 피로도를 줄이기 위해? 아래와 같이 Enum 으로 따로 분류했습니다.

소셜 로그인 종류

 
 
이제 유저가 요청한 provider에 알맞는 구현체를 매핑해주어야 합니다.
처음에는 약간 하드코딩으로 유저가 입력한 provider 값각 ‘구현체’의 getProvider 리턴값을 비교한 후, 매칭되는 구현체를 리턴해주는 방식을 생각했습니다. 하지만 결국 이 방식은 매핑을 위해 모든 구현체에 의존할 수 밖에 없으므로 좋은 설계가 아닌 것 같다는 생각이 들었습니다. 
 
그래서 몇몇 레퍼런스와 취뽀하신 선배의 조언, 그리고 잊고 있었던 개념 복기를 통해 해결 방법을 떠올릴 수 있었습니다.

Bean으로 등록한 OauthProvider 리스트를 선언하여 이를 연결해주면(@AutoWired / @RequiredArgsConstructor) OauthProvider 인터페이스의 구현체를 모두 참조할 수 있습니다.
즉 스프링의 싱글톤 패턴의존성 주입을 활용한 방식으로 볼 수 있습니다.


따라서 List<OauthProvider> 타입의 리스트 변수를 생성한 후, 해당 리스트를 순회하며 getProvider 메서드를 호출합니다. 이를 유저가 입력한 Provider와 비교하여 매핑되는 구현체를 찾고, 이를 리턴하는 것입니다.
 
List<OauthProvider> 타입 및 매핑 기능을 OauthLoginService 클래스에서 정의할까 했는데, 취뽀 선배의 힌트로 일급 컬렉션에 대해 조사하게 되었고(+ 단일 책임 원칙에도 위배된다고 판단), 최종적으로 OauthProviderFactory 라는 일급 컬렉션으로 따로 분리하여 구현했습니다. 
 

💡 일급 컬렉션

단일 Collection을 wrapping하여 해당 컬렉션의 행위를 한 곳에서 정의하는 것을 의미합니다. 
일급 컬렉션을 사용할 때 얻을 수 있는 이점은 다음과 같습니다.

협업

만약 협업을 하는 과정에서 코드 내에 단순히 컬렉션으로 정의가 되어있다면, 이를 처음 보는 개발자는 이 컬렉션을 어떤식으로 조작해야할지 잘 모를 수 있고 결국 의도치 않은 방향으로 컬렉션 값을 조작할 수 있습니다. 
 

값의 변경을 막을 수 있다.

final을 사용한다고 해서 컬렉션 값의 변경을 막을 수 없습니다. final은 재할당만 금지할 뿐 컬렉션 내에 저장된 값의 수정은 허용하기 때문인데요,
따라서 setter가 존재하지 않는 일급 컬렉션을 사용하게 되면 해당 컬렉션 값의 수정을 막을 수 있습니다.

이에 대한 더 자세한 설명은 포스팅 가장 아래에 첨부한 향로님의 블로그를 참고하면 많은 도움이 될 것 같습니다.
해당 게시글 내용이 잘 정리되어 있어서 일급 컬렉션이 무엇인지, 왜 사용해야 하는지에 대해 이해하기 수월했습니다.

 

👀 결과

리팩토링 이후 변경된 설계는 아래와 같습니다. 

리팩토링 이후 소셜 로그인 구조

 
기존 설계 다이어그램보다 훨씬 복잡해보이긴 하지만 기능 확장에 있어서 코드 수정은 훨씬 줄어든 것을 알 수 있습니다.

소셜 로그인 서비스가 추가될 때 OauthProvider의 구현체 및 Provider Enum 값만 추가하면 되고, 기존 코드의 수정은 발생하지 않습니다.

 
 
 
 


참고 포스팅 목록

- SOLID 원칙
https://mangkyu.tistory.com/194
https://inpa.tistory.com/entry/OOP-💠-객체-지향-설계의-5가지-원칙-SOLID#
- 일급 컬렉션
https://jojoldu.tistory.com/412

'programming > CS' 카테고리의 다른 글

[Spring] 동시성 문제와 해결방법 알아보기  (0) 2024.05.04