일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- 백준장학금
- jpa n+1 문제
- AVL트리
- 엔티티 그래프
- 멀티프로세서
- 점근적 표기법
- spring
- 스케줄링
- 자료구조
- posix
- 백준 장학금
- 연결리스트
- MSA
- python
- SpringSecurity
- JVM
- 프로세스
- 최소힙
- 연결리스트 종류
- 운영체제
- Kruskal
- 힙트리
- 최대 힙
- JPA
- HTTP
- 강화학습
- 알고리즘
- 완전이진트리
- heapq
- 이분탐색이란
- Today
- Total
KKanging
설계 원칙 , 전지적 2년차 뉴비 시점 본문
유지보수하기 쉽게 코드를 짜라고요?

개발 공부하다 보면 당연히 클린 코드나 SOLID 같은 설계 패턴과 클린 아키텍처, DDD, 헥사고날 아키텍처 같이 아키텍처 패턴 등 멋있는? 이름의 많은 이론과 설계 패턴이 존재한다.
당연히 실력있고 자기 프로젝트에 관심이 많을 수록 해당 이론에 더욱 관심이 많아지고 어떻게 하면 유지보수 쉽고 리팩터링 시 사이드 이펙트가 발생하지 않을까에 대한 고민을 한다.

아직 개발 공부 2년도 안한 뉴비에 가까운 시점에서 아키텍처에 대한 내 생각과 내가 느끼는 설계 원칙과 유지보수 하기 쉬운코드에 대한 설명을 해볼 것이다
설계 원칙
유지보수 좋은 코드 객체지향 설계 를 논하기 전에 SOLID 설계 원칙에 대한 말을 안할 수 없다.
SOLID 같은 설계 원칙에서 강조하는 쟁점이 이후에 다룰 아키텍처와 분리되지 않고 위에서 동작하는 기반과 같다
그래서 내가 느끼는 설계 패턴의 주요 쟁점이 뭔지 다뤄보자
설계 패턴 : SOLID
S - Single Responsibility Principle (단일 책임 원칙)O - Open/Closed Principle (개방-폐쇄 원칙)
L - Liskov Substitution Principle (리스코프 치환 원칙)
I - Interface Segregation Principle (인터페이스 분리 원칙)
D - Dependency Inversion Principle (의존 역전 원칙)
해당 글은 SOLID 를 설명하는 글이 아니므로 SOLID에서 내포하는 주요 쟁점을 설명하겠다.
SOLID 의 주요 쟁점은 소프트웨어를 설계할 때 응집도(Cohesion)은 높고 변화할 이유를 줄이는 것이다.
응집도란?
"하나의 모듈, 클래스 또는 함수가 얼마나 한 가지 책임 또는 목적에 집중하고 있는가"이다.
만약 Board라는 클래스를 다루는 로직에서 서비스 유저 인증 로직이 함께 들어간다고 생각해보자.
class Board {
let title: String
let content: String
func createBoard(userToken: String) {
if AuthService.verifyUser(token: userToken) {
// 게시판 생성 로직
}
}
}
Board 클래스가 사용자 인증까지 직접 다루는 것, 이게 과연 적절할까?
- Board 클래스는 게시판 도메인에만 집중해야 하지,
- 인증 로직의 책임까지 떠맡을 필요는 없다.
✅ 대부분의 경우에서 답은 No다.
이와 같이 하나의 모듈, 클래스 또는 함수는 한 가지 책임만 가지는 것이 좋다.
즉, 응집도를 높이자.
왜 응집도가 높아야 하는가?
책임이 적을수록 변경에 유연해지고, 유지보수가 쉬워진다.
예를 들어 유저 인증 방식이 바뀐다고 해보자
(예: 자체 로그인에서 OAuth를 추가했어요!)
그럼 어떤 일이 생길까?
- Board 클래스의 createBoard()를 수정해야 하고,
- AuthService 로직에 따라 매개변수, 내부 조건, 테스트까지 줄줄이 수정되어야 할 수도 있다.
응집도 높은 코드로 리팩토링하면?
class Board {
let title: String
let content: String
func createBoard() {
// 게시판 생성 로직만 수행
}
}
class BoardService {
func createBoard(token: String, title: String, content: String) {
guard AuthService.verifyUser(token: token) else { return }
let board = Board(title: title, content: content)
board.createBoard()
}
}
- Board: 게시판의 데이터와 행동만 담당
- BoardService: 인증 포함한 서비스 로직 담당
- 책임 분리 → 응집도 상승 → 변경에 강해짐
역할이 하나란 것은 변경될 이유가 하나란 것
내가 해당 내용을 SOLID 의 주요 쟁점이라 생각한 것은 SOLID 에서 강조하는 것이 해당 개념이 전부라 생각한다.
위의 응집도와 어느 정도 중복된다고 생각할 수 있지만 변경 가능성이 줄어든다는 것(SRP)은
리팩터링 후 생기는 사이드 이펙트의 영향을 줄이는 거라 생각한다.
public class NotificationSender {
private final FirebaseService firebaseService;
private final EmailService emailService;
private final SmsService smsService;
public NotificationSender(FirebaseService firebaseService, EmailService emailService, SmsService smsService) {
this.firebaseService = firebaseService;
this.emailService = emailService;
this.smsService = smsService;
}
public void sendNotification(String type, String to, String message) {
switch (type) {
case "push":
firebaseService.sendPush(to, message);
break;
case "email":
emailService.sendEmail(to, message);
break;
case "sms":
smsService.sendSms(to, message);
break;
default:
throw new IllegalArgumentException("Unknown type");
}
}
}
위 코드는 알림을 보내는 역할에 집중하고 있다. 하지만 응집도가 높다고 변경 가능성이 적을까?
만약 email push 서비스를 변경한다면? sms가 아닌 다른 수단을 사용한다면? 이메일을 사용하지 않겠다고 하면?
알림을 보내는 역할에 집중하고 있는 이 NotificationSender 도 같이 변경해야 한다.
이는 의존관계 때문에 발생한다.
의존을 하는 것은 단순히 참조를 하는 것이 아닌 해당 객체의 변경에 의존한다는 의미이기도 한다.
어떻게 해결할까?
SOLID에서 강조하는 변경 가능성을 줄이는 것은 인터페이스로 추상화하여 의존관계를 역전하는 설계를 강조한다. (DIP)
public interface NotificationChannel {
void send(String to, String message);
}
public class PushNotification implements NotificationChannel {
private final FirebaseService firebaseService;
public PushNotification(FirebaseService firebaseService) {
this.firebaseService = firebaseService;
}
@Override
public void send(String to, String message) {
firebaseService.sendPush(to, message);
}
}
public class NotificationSender {
private final Map<String, NotificationChannel> channels;
public NotificationSender(Map<String, NotificationChannel> channels) {
this.channels = channels;
}
public void sendNotification(String type, String to, String message) {
NotificationChannel channel = channels.get(type);
if (channel == null) {
throw new IllegalArgumentException("Unknown type");
}
channel.send(to, message);
}
}
위 처럼 리팩터링을 한다면 NotificationSender 은 알림 채널에 확장에는 열려있고 변경에는 닫힌 구조로 변경 가능성이 줄어든다.
NotificationSender 는 변경할 이유가 알림을 보내는 도메인 로직의 변경만일 것이다.
[참고] 구현에 의존 VS 인터페이스에 의존
public interface Channel{
void send()
}
public class ConcreteChannel implement Channel{
@Override
public void send(){
// 구현 로직
}
}
void programToConcrete(){
ConcreteChannelch1 = new ConcreteChannel();
// ch1 은 ConcreteChannel를 자료형으로 선언함
// 구현에 의존한 사례
// 만약 채널이 변경된다면 ch1 의 자료형도 변경됨
ch1.send();
}
void programToInterface(){
Channel ch1 = new ConcreteChannel();
// ch1 은 ConcreteChannel를 자료형으로 선언하지 않아도 오류가 나지 않음
// 인터페이스에 의존한 사례
ch1.send();
}
위 구조라면 의존하는 객체는 어떻게 삽입하죠?
public class NotificationSender {
private final Map<String, NotificationChannel> channels;
public NotificationSender() {
this.channels.put("channel1,new Sample1NotificationChannel());
this.channels.put("channel2,new Sample2NotificationChannel());
this.channels.put("channel3,new Sample3NotificationChannel());
}
이상적인 예제에서는 위 예제처럼 new 연산자로 객체를 직접 생성해서 주입하지 않는다.
위처럼 안하는 이유는 생성 또한 구현에 의존하는 것이기 때문이다.
그럼 구현에 의존안하고 멤버 변수로 어떻게 가질 수 있나요?

이상적인 구조는 다음과 같다 interface를 의존하여 직접적인 구현에 의존을 숨겨 구현에 변경을 감추는 것이다.

하지만 언어의 특성상 해당 객체를 생성해야하고 멤버 변수로 주입을 해야한다.
이 과정에서 구현에 의존을 불가피 해보인다.
하지만 방법이 없지는 않다. 다음과 같이 해결할 수 있는 방법이 있다
void main{
Map<String, NotificationChannel> channels;
channels.put("channel1,new Sample1NotificationChannel());
channels.put("channel2,new Sample2NotificationChannel());
channels.put("channel3,new Sample3NotificationChannel());
NotificationSender notificationSender = new NotificationSender(channels);
notificationSender.send();
}
더 좋은 방법은 없을까?
class NotificationConfig{
public NotificationSender notificationSender(){
Map<String, NotificationChannel> channels;
channels.put("channel1,new Sample1NotificationChannel());
channels.put("channel2,new Sample2NotificationChannel());
channels.put("channel3,new Sample3NotificationChannel());
return new NotificationSender(channels);
}
}
void main{
NotificationConfig notificationConfig = new NotificationConfig();
NotificationSender notificationSender = notificationConfig.notificationSender();
notificationSender.send();
}
의존관계에 해당하는 멤버변수를 구성하는 역할을 담당하는 Config 클래스를 이용해서 더 유지보수하기 좋은 코드를 만들어봤다.

이제 의존관계를 보자 NotificationSender는 인터페이스를 의존하기 때문에 NotificationChannel 의 세부 구현을 모르므로 NotificationChannel 의 내부 구현의 변경에 영향을 받지 않는다.
만약 NotificationChannel 의 구현체들의 변경과 추가 감소 에 대한 변경이 생기면 어떻게 될까?
그때는 NotificationConfig를 변경하면 된다!
이제 NotificationSender는 NotificationChannel 에서 완전히 분리되었다.
이 예제가 바로 SOLID 에서 강조하는 모든 것이다!
최신 프레임워크의 강력함
최신 프레임워크 Next.js나 Spring 같은 프레임워크는 위 Config 클래스의 역할을 대신해준다.
따라서 Config 클래스를 개발자들이 구현하지 않아도 자동으로 의존되는 구현체를 주입시켜주는 Dependency Injection 일명 DI를 해준다.
클린한 코드를 위한 개인적인 팁
이론들만 학습하면 어떻게 프로젝트에 접목할 지 의문이 드는 경우가 많다.
따라서 글쓴이가 어떻게 위 이론들을 접목 시키는지 개인적인 경험 및 팁을 보여주겠다.
명확한 네이밍
public class SaleService {
private final SaleWriter saleWriter;
private final StoreWriter storeWriter;
private final StoreValidator storeValidator;
public Sale openStore(final UserPassport ownerPassport, final Long storeId) {
final Store previousStore = storeValidator.validateStoreOwner(ownerPassport, storeId);
if (previousStore.getIsOpen()) {
throw new ServiceException(ErrorCode.CONFLICT_OPEN_STORE);
}
final Store opendStore = storeWriter.modifyStoreOpenStatus(previousStore);
final Sale createdSale = saleWriter.createSale(opendStore);
return createdSale;
}
지역 변수든 메게변수든 클래스든 멤버 변수든 이름만 보고 어떤 역할을 하는 클래스 혹은 메서드인지 이해가 되는 것을 선호한다
해당 Sale(판매) 라는 도메인에서 의존하는 컴포넌트만 보더라도 해당 클래스가 무엇을 담당하는지 알 수 있다.
또한 지역적으로 사용하는 지역 변수나 메게 인자의 이름으로만 유추할 수 있다.
정확히 어디에 저장되고 하는지는 몰라도 해당 로직은 Store를 열고 요청 보낸 사람이 가게 주인인지 validation 하고 Open 상태인지 확인하며, Open 상태로 수정한 뒤 Sale이란 판매 기록 데이터를 생성하고 반환한다.

패키지 네이밍과 컴포넌트가 위치할 패키지 또한 중요하게 생각한다. 패키지 네이밍과 컴포넌트의 위치가 개발자에게 나 여깄어요라고 소리쳐야한다.
그리고 패키지의 구조와 해당 패키지는 어느 로직을 담당하는 컴포넌트가 들어갈지 설계하고 규약하는 것이 아키텍처이다.
개발을 해보자
위에서 설명했듯이 인터페이스를 의존하면 유지보수에 용이하다! 라는 식으로 설명했다.

그럼 여기까지 들은 사람이 프로젝트를 시작하면 모든 의존을 인터페이스로 설계할 것이다.
인터페이스로 모든 것을 설계하면 프로젝트는 망하게 될 확률이 높다.
이유는 무엇일까?
조금만 구현하다보면 알다 싶이 추상화를 한다는 것은 귀찮고 시간도 많이 들어간다. 그리고 정돈된 컨벤션과 설계가 없다면 프로젝트의 복잡도와 유지보수 요소는 오히려 높아간다.
어쩌라는거야

난 추상화를 섣부르게 설계하지 않는다.
추상화를 한다는 건 다음과 같은 의미를 내포한다.

위에서 설명했듯이 의존관계가 역전 됨을 의미한다. 즉 NotificationSender는 외부로 격리된다.
격리되면 외부 컴포넌트의 변경에 독립적이다.
난 이러한 의미를 중점으로 외부의 변경과 독립적이고 싶은 부분에 추상화를 적극 사용한다.
그럼 보통 어디에 추상화를 사용하나요?

역할을 분리하는 것에 취중하면서 개발을 하다 보면 보통 위처럼 계층 혹은 컴포넌트를 분리한다.
이 구조로 개발을 하면 어느 계층 주도 개발을 할까?
바로
Data or Persistence Layer
이다.
이유는 배웠듯이 의존관계 방향대로 변경이 가해지면 의존 방향대로 변경은 전파된다.
따라서 의존 방향에 끝단인 Data Layer 주도적인 설계를 할 수 밖에 없다.
하지만 문제는 Domain 은 해당 기능을 잘 나타내는 핵심 기능인데 Data Layer 의 변경에 같이 변경된다면 큰 오류를 부를 수 있고, 혹은 너무 복잡한 나머지 리팩터링에 두려움을 부를 수 있다.

따라서 난 외부 변경에 분리하고 싶은 부분에만 보통 추상화를 한다. 특히 비즈니스 로직 같은
규약과 트레이드 오프
항상 개발을 하다보면 여러가지 기술을 접한다.
그리고 기술을 공부하면 자연스럽게 기술의 본질이 아닌 기술에 매몰되곤 하는거 같다.
모든 기술에는 장점과 단점이 있고 장점과 단점의 저울질 속에서 내가 하는 프로젝트에서 어떤 기술이 적합할까 혹시 과한 기술이지 않을까 혹은 필요한 기술이지만 개발 외에 요소 때문에 기술의 접목을 주춤하지 않는지 계속 고민할 필요가 있다.
'백엔드 > 아키텍처 & 패러다임 & 디자인 패턴' 카테고리의 다른 글
[디자인패턴] 음료시스템 예제로 배우는 Decorator Pattern (0) | 2024.10.24 |
---|---|
[디자인 패턴] 센서 디스플레이 예제로 배우는 Observer Pattern (4) | 2024.10.12 |
[디자인 패턴] 오리 예제로 알아보는 Strategy Pattern (0) | 2024.10.11 |
자바 개발자가 AOP를 공부 해야하는 이유 (1) | 2024.09.26 |