2025년 5월 17일
소프트웨어 개발에 활용되는 여러 원칙이 존재한다. 예를 들어, SOLID 원칙이나 DRY 원칙 같은 것들이다. 이와 같은 원칙들은 워낙 널리 알려져있고, 좋은 품질의 소프트웨어를 위한 필요 조건으로 받아들여지고 있다.
하지만, 이 가이드라인을 개발 과정에 정확하게 적용하고 확인하는 것에는 어려움이 있을 때가 많다. 특히, 오늘날에는 빠른 주기로 마이크로서비스를 개발하고 배포하는 것을 지향하고 있기 때문에, 위의 원칙을 엄격하게 적용하는 대신 타협점을 찾아가는 경우가 잦다. 특히, 서비스의 규모가 작게 유지될 수록, 여러 인터페이스 혹은 클래스를 정의하고 유지보수하기 보단, 더 나은 high-level 아키텍처를 설계하는데 드는 노력이 더 중요해진다.
이런 상황에서 단일 추상화 레벨 원칙(singel level of abstraction principle, 이하 SLA)은 굉장히 유용한 도구라고 할 수 있다. 사용하기 쉽고 간편할 뿐더러, 좋은 품질의 코드를 작성하고 유지보수 할 수 있도록 돕는다. 나 역시 코드를 작성할 때, SLA 원칙을 위배하는가에 대해 빠르게 점검해보고 문제가 되지 않는다면 빠르게 넘어가기도 한다.
아래 글을 통해 SLA 원칙에 대해 살펴보도록 하자.
SLA 원칙이란 아래와 같이 정의할 수 있다: 함수 내부의 로직은 같은 추상화 레벨을 가져야 한다.
추상화 레벨이란 코드가 표현하는 개념적 높이를 의미하는데, 저수준(low-level)은 구체적인 구현 세부사항에 가깝고, 고수준(high-level)은 비즈니스 로직이나 도메인 개념에 가깝다. 예를 들어, 데이터베이스에 연결하는 코드는 저수준이고, 사용자 등록 프로세스를 관리하는 코드는 고수준이라 볼 수 있다. 따라서, SLA 원칙은 하나의 함수가 비슷한 개념적 수준의 작업만 수행해야 한다는 의미이다.
나는 이를 확장해서 다음과 같이 적용하고 있다. 개체 내부의 로직은 오직 하나의 추상화를 공유해야 한다. 이는 SOLID 원칙의 단일 책임 원칙 (Single Responsibility Principle, SRP)를 확장한 것이다. 자세한 내용은 아래 예시를 통해 살펴보자.
Abstraction is the only matter in Computer Science.
학부생 시절, 운영체제 수업을 들을 때 교수님께서 위와 같은 말씀을 하셨다. 개발자로 살아오면서 배우고, 일하는데 있어 위의 문장보다 내게 큰 영향을 미친 말은 없다. 그만큼 computer science에서 추상화(abstraction)의 중요도는 절대적이다.
이는 코드 레벨에서도 마찬가지이다. 모든 코드는 은연중에 무언가를 추상화하고 있다. HTTP request의 내부 구현을 request
라는 함수 안에 숨김으로써 저 수준 HTTP 통신을 추상화한다. 기업의 회계 장부에서 오탈자를 찾아내는 로직을 find_error
라는 함수 안에 숨기는 것도 추상화라고 할 수 있다.
그렇다면, 추상화 레벨을 분리하는 것이 왜 중요할까? 다른 추상화 레벨을 이해하는데 필요한 사고 방식이 다르기 때문이다. 따라서 하나의 함수에 여러 추상화 레벨이 섞여 있으면, 우리는 해당 함수를 읽는데 정신적으로 더 큰 노력을 들여야 한다. 따라서 SLA를 위배한 코드는 가독성을 떨어뜨린다.
아래의 예시를 보자.
import requests
from typing import Dict, List, Any
def process_user_data(user_id: int) -> Dict[str, Any]:
response = requests.get(f"https://api.example.com/users/{user_id}")
if response.status_code != 200:
print(f"Error: Failed to fetch user data. Status code: {response.status_code}")
return {}
user_data = response.json()
user_data["name"] = user_data["first_name"] + " " + user_data["last_name"]
if user_data["subscription_tier"] == "premium":
premium_response = requests.get(f"https://api.example.com/premium-benefits/{user_id}")
premium_data = premium_response.json()
user_data["benefits"] = [benefit["name"] for benefit in premium_data["items"]]
calculate_loyalty_points(user_data)
return user_data
def calculate_loyalty_points(user_data: Dict[str, Any]) -> None:
years = user_data["membership_years"]
purchases = user_data["total_purchases"]
user_data["loyalty_points"] = years * 100 + purchases * 10
process_user_data
는 어려운 코드가 아님에도 꽤 읽기 어렵다. 함수 내에 여러 가지 추상화 레벨이 섞여있기 때문이다. HTTP 요청을 보내고 에러 처리, 문자열 처리, 프리미엄 구독을 사용하는 유저와 관련된 높은 추상화 레벨의 로직까지 하나의 함수에 구현되어 있다.
먼저, HTTP 요청을 보내는 부분을 다른 저 수준 함수로 빼내자.
def get_user_data(user_id: int) -> Dict[str, Any]:
response = requests.get(f"https://api.example.com/users/{user_id}")
if response.status_code != 200:
raise Exception(f"Failed to fetch user data. Status code: {response.status_code}")
return response.json()
def get_premium_benefits(user_id: int) -> List[Dict[str, Any]]:
response = requests.get(f"https://api.example.com/premium-benefits/{user_id}")
if response.status_code != 200:
raise Exception(f"Failed to fetch premium benefits. Status code: {response.status_code}")
return response.json()["items"]
그리고, 프리미엄 구독을 사용하는 유저와 관련된 로직을 함수로 구현하자.
def extract_benefit_names(benefits: List[Dict[str, Any]]) -> List[str]:
return [benefit["name"] for benefit in benefits]
def calculate_loyalty_points(years: int, purchases: int) -> int:
return years * 100 + purchases * 10
마지막으로, process_user_data
를 수정하자.
def process_user_data(user_id: int) -> Dict[str, Any]:
try:
user_data = get_user_data(user_id)
user_data["name"] = user_data["first_name"] + " " + user_data["last_name"]
if user_data["subscription_tier"] == "premium":
benefits = get_premium_benefits(user_id)
user_data["benefits"] = extract_benefit_names(benefits)
user_data["loyalty_points"] = calculate_loyalty_points(
user_data["membership_years"],
user_data["total_purchases"]
)
return user_data
except Exception as e:
print(f"Error processing user data: {e}")
return {}
이를 조금 더 확장하면, 조금 더 어려운 상황에도 쉽게 적용할 수 있다. 추상화 단계는 필연적으로 추상화의 대상인 저레벨 수준의 구현에도 영향을 받지만, 동시에 추상화의 목적에도 관련이 있다. 따라서 SLA 단일 책임 원칙(Single Responsibility Principle)은 자연스럽게 연결된다.
아래 예시를 보자. 마찬가지로 간단한 예시이지만, 가독성이 좋지 않다. 또한, 변화나 확장을 하기에도 어려운 구조이다. 아래 코드를 리팩토링 해보자.
import sqlite3
import re
import hashlib
import smtplib
from email.message import EmailMessage
class UserManager:
def __init__(self, db_connection_string):
self.db_connection_string = db_connection_string
def register_user(self, username, email, password):
conn = sqlite3.connect(self.db_connection_string)
cursor = conn.cursor()
if not re.match(r"[^@]+@[^@]+\.[^@]+", email):
return {"success": False, "error": "Invalid email format"}
hashed_password = hashlib.sha256(password.encode()).hexdigest()
cursor.execute("SELECT * FROM users WHERE email = ?", (email,))
if cursor.fetchone():
return {"success": False, "error": "Email already exists"}
cursor.execute(
"INSERT INTO users (username, email, password) VALUES (?, ?, ?)",
(username, email, hashed_password)
)
conn.commit()
msg = EmailMessage()
msg.set_content(f"Welcome, {username}!")
msg["Subject"] = "Welcome to our platform"
msg["From"] = "noreply@example.com"
msg["To"] = email
smtp = smtplib.SMTP("smtp.example.com")
smtp.send_message(msg)
smtp.quit()
return {"success": True, "user_id": cursor.lastrowid}
먼저, 데이터베이스 통신과 관련된 저 레벨 로직을 분리하여 새로운 추상화 계층을 만들자.
class UserRepository:
def __init__(self, db_connection_string: str):
self.conn = sqlite3.connect(db_connection_string)
def get_by_email(self, email: str):
with self.conn.cursor() as cursor:
cursor.execute("SELECT * FROM users WHERE email = ?", (email,))
user = cursor.fetchone()
return user
def save(self, username: str, email: str, password: str) -> int:
with self.conn.cursor() as cursor:
cursor.execute(
"INSERT INTO users (username, email, password) VALUES (?, ?, ?)",
(username, email, password)
)
self.conn.commit()
return cursor.lastrowid
그리고, 패스워드 해싱, 이메일 검증, 웰컴 이메일 전송과 관련된 저 수준의 로직을 분리하자. 이들은 추상화 레벨은 다르지만, 큰 틀에서 유저 관리라는 추상화의 목적에 부합하므로 같은 클래스 안에 구현하자.
class UserManager:
def __init__(self, user_repository: UserRepository):
self.repository = user_repository
def register_user(self, username: str, email: str, password: str):
if not self.is_email_valid(email):
return {"success": False, "error": "Invalid email format"}
if self.repository.get_by_email(email):
return {"success": False, "error": "Email already exists"}
hashed_password = self.hash_password(password)
user_id = self.repository.save(username, email, hashed_password)
self.send_welcome_email(username, email)
return {"success": True, "user_id": user_id}
def hash_password(self, password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def is_email_valid(self, email: str) -> bool:
return bool(re.match(r"[^@]+@[^@]+\.[^@]+", email))
def send_welcome_email(self, username: str, email: str):
msg = EmailMessage()
msg.set_content(f"Welcome, {username}!")
msg["Subject"] = "Welcome to our platform"
msg["From"] = "noreply@example.com"
msg["To"] = email
smtp = smtplib.SMTP("smtp.example.com")
smtp.send_message(msg)
smtp.quit()
완성된 코드와 기존 코드를 비교하면, 가독성 측면에서나 코드의 확장 가능성 측면에서 차이를 느낄 수 있다.
오늘날에는 특히 agile한 개발을 지향함과 동시에 서비스 자체를 가능한 한 작게 유지하려고 한다. 따라서 코드를 작성하는 방법도 이에 알맞게 agile해야 한다.
여기서 알맞는 코드 작성 원칙이 단일 추상화 레벨 원칙(SLA)이다. 코드를 작성할 때, 함수 혹은 개체에 여러 추상화 레벨 혹은 추상화 목적이 혼용되어서 사용되고 있는지를 판별함으로써, 가독성 측면과 기능적으로 우수한 코드를 작성할 수 있다.