관심사 분리
고친 사람 github-actions[bot]
관심사 분리는 코드를 하는 일의 종류별로 갈라 놓습니다. 하는 일이 다른 코드끼리 섞지 않습니다. 그러면 한 가지를 고칠 때 그 한 곳만 열어 보면 됩니다.
쉽고 빠른 이해
하는 일이 다른 코드끼리 섞이지 않게 나눠 둡니다. 회원 가입이라면 요청 읽기, 가입 규칙 따지기, 데이터베이스에 저장하기를 각각 다른 클래스에 둡니다.
섞여 있으면 저장 방식 하나만 바꿔도 가입 규칙이 든 메서드까지 열어야 합니다. 규칙 하나만 시험하려 해도 데이터베이스를 띄워야 합니다.
어떻게 나누나:
- 코드가 바뀌는 이유를 셉니다. 이유가 다르면 다른 일입니다
- 일마다 클래스를 따로 둡니다
- 서로는 정해진 메서드로만 부릅니다
대가도 있습니다. 파일이 늘어나서 요청 하나의 흐름을 따라가려면 여러 파일을 건너야 합니다.
그래서 늘 함께 바뀌는 코드나 한 번 쓰고 버릴 스크립트는 나누지 않습니다.
상세
이 절은 관심사가 무엇인지, 섞이면 무엇이 곤란한지, 어떻게 나누는지를 봅니다. 회원 가입 요청을 처리하는 백엔드 코드 한 벌을 줄곧 예로 씁니다. 섞인 코드를 먼저 봅니다. 그다음 같은 코드를 나눈 모습과 견줍니다.
식당을 떠올리면 가깝습니다. 주문 받는 사람, 요리하는 사람, 재료 창고를 맡는 사람이 따로 있습니다. 메뉴가 바뀌면 주방 일만 달라집니다. 창고를 다른 건물로 옮겨도 창고 일만 달라집니다.
관심사라는 말
관심사는 프로그램이 신경 써야 하는 한 가지 일입니다. 영어 concern 을 옮긴 말이고 「걱정거리」에 가깝습니다. 회원 가입이라면 요청을 어떻게 받나, 가입 규칙이 무엇인가, 데이터를 어디에 두나가 각각 하나의 관심사입니다.
관심사를 가르는 기준은 대개 「바뀌는 이유」입니다. 가입 규칙은 기획이 바뀔 때 바뀝니다. 저장 코드는 데이터베이스를 바꿀 때 바뀝니다. 바뀌는 이유가 다르면 다른 관심사로 봅니다.
코드를 나눈 한 덩이를 모듈이라고 부릅니다. 함수 하나일 수도 있고 클래스나 패키지, 서버 하나일 수도 있습니다. 관심사 분리는 모듈 하나가 관심사 하나를 맡도록 경계를 긋는 일입니다.
한 메서드에 섞인 세 관심사
먼저 섞인 모습을 봅니다. 아래는 가입 요청을 받아 이메일을 확인하고 저장하는 Java 메서드 하나입니다.
void register(Request req) {
String email = req.param("email");
if (!email.contains("@")) {
throw new BadRequest();
}
db.insert("users", email);
}
이 메서드 하나에 관심사 셋이 들어 있습니다. 첫 줄은 요청에서 값을 꺼냅니다. 가운데 if 는 가입 규칙입니다. 마지막 줄은 저장입니다.
셋은 바뀌는 때가 다릅니다. 아래 표는 관심사마다 이 메서드를 고치게 되는 때를 적었습니다.
| 관심사 | 이 메서드를 고치게 되는 때 |
|---|---|
| 요청 받기 | 웹 요청 대신 메시지로 가입을 받게 될 때 |
| 가입 규칙 | 이메일 말고 휴대폰 번호로도 가입을 받게 될 때 |
| 저장 | 데이터베이스를 다른 제품으로 옮길 때 |
세 경우 모두 같은 메서드를 엽니다. 저장 방식만 바꾸려 해도 규칙 코드 바로 옆을 고치게 됩니다. 이 메서드 하나를 여러 사람이 동시에 고치면 서로의 수정이 부딪힙니다.
시험하기도 어렵습니다. 이메일 규칙 하나를 확인하려 해도 db.insert 가 불리므로 데이터베이스가 떠 있어야 합니다.
관심사마다 나눈 코드
같은 일을 세 클래스로 나눕니다. 요청을 받는 컨트롤러, 규칙을 따지는 서비스, 저장을 맡는 리포지토리입니다. 백엔드에서 흔히 쓰는 이름입니다.
// 요청 받기
class SignupController {
SignupService service;
void register(Request req) {
service.register(req.param("email"));
}
}
// 가입 규칙
class SignupService {
UserRepository repo;
SignupService(UserRepository repo) {
this.repo = repo;
}
void register(String email) {
if (!email.contains("@")) {
throw new InvalidEmail();
}
repo.save(new User(email));
}
}
// 저장
class UserRepository {
Database db;
void save(User user) {
db.insert("users", user.email());
}
}
이제 클래스마다 아는 것이 하나뿐입니다. 컨트롤러는 요청 모양만 압니다. 리포지토리는 데이터베이스만 압니다.
서비스는 규칙만 압니다. 요청이 웹으로 왔는지는 모릅니다. 그래서 웹 요청이 잘못됐다는 뜻의 BadRequest 대신, 가입 규칙을 어겼다는 뜻의 InvalidEmail 을 던집니다.
부르는 방향은 한쪽입니다. 요청이 위에서 들어와 아래로 내려갑니다.
flowchart TD
R["가입 요청"] --> C["SignupController · 요청 받기"]
C --> S["SignupService · 가입 규칙"]
S --> P["UserRepository · 저장"]
P --> D["데이터베이스"]
표의 세 경우를 다시 봅니다. 메시지로 가입을 받게 되면 컨트롤러만 새로 만듭니다. 데이터베이스를 옮기면 리포지토리만 고칩니다. 가입 규칙이 바뀌면 서비스만 엽니다.
떼어서 시험하기
나누면 규칙 하나를 데이터베이스 없이 시험할 수 있습니다. 서비스는 쓸 리포지토리를 생성자로 받습니다. 시험할 때는 그 리포지토리를 가짜로 바꿔 끼웁니다.
바꿔 끼우려면 진짜와 가짜가 같은 약속을 따라야 합니다. 서비스가 리포지토리에 기대는 것은 save 메서드 하나뿐입니다. 그래서 앞에서 클래스로 적은 UserRepository 를 save 하나만 적은 인터페이스로 바꿉니다. 데이터베이스에 넣던 코드는 이 인터페이스를 따르는 다른 클래스로 옮깁니다.
interface UserRepository {
void save(User user);
}
class FakeUserRepository implements UserRepository {
List<User> saved = new ArrayList<>();
public void save(User user) {
saved.add(user);
}
int count() {
return saved.size();
}
}
가짜 리포지토리는 저장하는 척만 합니다. 받은 것은 목록에 담아 둡니다. 아래 시험은 이 가짜를 서비스에 넣고 가입을 두 번 시킵니다.
var repo = new FakeUserRepository();
var service = new SignupService(repo);
service.register("[email protected]");
repo.count(); // 1
service.register("kim"); // InvalidEmail
첫 가입은 저장되어 개수가 1이 됩니다. 형식이 틀린 kim 은 저장 전에 예외로 막힙니다. 이 시험에는 웹 서버도 데이터베이스도 없습니다. 이렇게 한 덩이만 떼어 확인하는 시험을 단위 테스트라고 부릅니다.
결합도와 응집도로 본 효과
관심사 분리가 무엇을 바꾸는지는 두 척도로 말합니다. 하나는 모듈 사이를 봅니다. 다른 하나는 모듈 안을 봅니다.
결합도는 두 모듈이 서로에게 기대는 정도입니다. 한쪽을 고칠 때 다른 쪽까지 따라 고쳐야 하면 결합도가 높습니다. 나눈 코드에서 서비스는 리포지토리의 save 하나만 압니다. 저장 방식이 바뀌어도 서비스는 고치지 않으니 결합도가 낮습니다.
응집도는 한 모듈 안의 코드가 한 가지 일에 모여 있는 정도입니다. 섞인 메서드에는 세 가지 일이 들어 있었습니다. 나눈 뒤에는 클래스마다 한 가지 일만 남아 응집도가 높습니다.
관심사로 나누면 결합도는 내려갑니다. 그리고 응집도는 올라갑니다. 한 가지 일을 한곳에 모으면 모듈 안의 코드가 그 일로 모입니다. 그만큼 모듈끼리 서로에게 기대는 정도가 줄어듭니다.
크기마다 다른 경계
관심사 분리는 크기를 가리지 않습니다. 작게는 함수 하나 안에서, 크게는 서버 여럿 사이에서 씁니다. 이 소절은 크기마다 무엇을 무엇과 나누는지 봅니다. 끝에 표로 모읍니다.
가장 작은 단위는 함수입니다. 값을 셈하는 코드와 그 값을 화면에 찍는 코드를 한 함수에 두지 않습니다. 셈하는 함수를 따로 두면 찍지 않고도 셈 결과를 확인할 수 있습니다.
앞에서 나눈 컨트롤러·서비스·리포지토리처럼 코드를 층으로 쌓고 위층이 아래층만 부르게 하는 구조를 레이어드 아키텍처라고 부릅니다. 층 하나가 관심사 하나를 맡습니다.
화면이 있는 앱에서는 MVC(Model-View-Controller, 모델-뷰-컨트롤러)로 나누곤 합니다. 앞의 세 클래스는 요청·규칙·저장을 갈랐습니다. MVC 는 데이터·화면·입력을 가릅니다.
모델은 데이터를 담습니다. 뷰는 그 데이터를 화면에 그립니다. 화면에 보이는 모양을 바꿀 때는 뷰만 고칩니다.
컨트롤러는 사용자 입력을 받아 모델과 뷰를 움직입니다. 입력을 받는 쪽이라 앞의 SignupController 와 하는 일이 같습니다.
웹 페이지는 세 언어로 나뉩니다. HTML(HyperText Markup Language)은 제목과 문단 같은 문서 구조를 적습니다. CSS(Cascading Style Sheets)는 글자 크기와 색 같은 모양을 적습니다. 동작은 JavaScript가 맡습니다. 그래서 색을 바꿀 때 문서 구조를 건드리지 않습니다.
서버 여럿으로 가면 업무마다 서버를 나눕니다. 주문 서버, 결제 서버, 배송 서버를 따로 둡니다. 서로는 네트워크로 부릅니다. 이렇게 짓는 방식을 마이크로서비스라고 부릅니다.
아래 표는 지금까지 본 다섯 크기를 모은 것입니다.
| 크기 | 나누는 관심사 | 흔히 부르는 이름 |
|---|---|---|
| 함수 | 값을 셈하는 코드와 결과를 찍는 코드 | 계산과 출력의 분리 |
| 클래스 묶음 | 요청 받기 · 규칙 · 저장 | 레이어드 아키텍처 |
| 화면이 있는 앱 | 데이터 · 화면 · 입력 처리 | MVC |
| 웹 페이지 | 문서 구조 · 모양 · 동작 | HTML · CSS · JavaScript |
| 서버 여럿 | 주문 · 결제 · 배송 같은 업무 | 마이크로서비스 |
여러 곳에 걸치는 관심사
어떤 관심사는 한 모듈에 모이지 않습니다. 로그 남기기, 권한 확인, 트랜잭션 열고 닫기가 그렇습니다. 가입에도 주문에도 결제에도 똑같이 필요합니다.
이렇게 여러 모듈을 가로지르는 관심사를 횡단 관심사라고 부릅니다. 그냥 두면 같은 로그 코드가 서비스 메서드마다 한두 줄씩 흩어집니다. 가입 규칙을 읽으려는 사람은 그 줄들을 건너뛰며 읽어야 합니다.
횡단 관심사는 따로 떼어 한곳에 적습니다. 웹 서버에서는 필터나 미들웨어가 그 한곳입니다. 둘 다 요청이 컨트롤러에 닿기 전에 반드시 거치는 코드입니다. 프레임워크마다 부르는 이름만 다릅니다.
모든 가입·주문 요청이 이 코드를 지납니다. 그래서 로그 코드를 여기에 한 번만 적으면 모든 요청에 로그가 남습니다. 서비스 메서드에는 로그 줄을 적지 않아도 됩니다.
메서드마다 끼워 넣는 방법도 있습니다. 관점 지향 프로그래밍(AOP, Aspect-Oriented Programming)은 메서드 앞뒤에 끼워 넣을 코드를 따로 적어 둡니다. 그 코드를 쓸 메서드에는 표시만 해 둡니다. 예를 들어 메서드 위에 어노테이션 한 줄을 붙여 표시합니다.
생각을 나누는 데서 온 이름
이 이름은 처음에 코드를 나누는 법보다 생각을 나누는 법을 가리켰습니다. 한 번에 한 측면만 따져 본다는 뜻이었습니다. 프로그램이 맞게 도는지 따질 때는 빠르게 도는지를 잠시 치워 두는 식입니다.
코드를 나누는 일은 이 생각을 코드의 모양으로 옮긴 것입니다. 모듈 하나를 열었을 때 한 가지만 생각해도 되도록 경계를 긋습니다.
같은 생각을 클래스 하나로 좁힌 것이 단일 책임 원칙입니다. 한 클래스가 바뀌는 이유는 하나여야 한다는 원칙입니다. 관심사를 「바뀌는 이유」로 가르는 앞의 기준과 같은 말입니다.
나누는 대가
무엇을 치르는지와 언제 덜 나누는지를 차례로 봅니다.
첫째는 흐름을 따라가기 어려워진다는 점입니다. 섞인 코드는 메서드 하나만 읽으면 가입이 어떻게 도는지 보였습니다. 나눈 코드는 컨트롤러에서 서비스로, 서비스에서 리포지토리로 파일을 세 번 건너야 보입니다.
둘째는 한 가지 변경이 여러 층을 지나갈 때입니다. 가입 때 닉네임을 하나 더 받게 되면 컨트롤러가 값을 꺼냅니다. 서비스가 그 값을 넘깁니다. 리포지토리가 저장합니다. 요청 받기·규칙·저장이 모두 닿는 변경이라 세 층이 다 움직입니다.
셋째는 너무 잘게 나눴을 때입니다. 받은 값을 아래로 넘기기만 하는 클래스가 생깁니다. 그런 클래스는 파일 수만 늘립니다. 떼어 놓아서 얻는 것은 없습니다.
그래서 나눌지 말지는 앞의 기준으로 정합니다. 두 코드가 늘 함께 바뀐다면 나눠도 매번 둘 다 고칩니다. 그럴 때는 한 모듈에 둡니다. 한 번 돌리고 버릴 짧은 스크립트도 대개 나누지 않습니다.
관련 항목
관심사 분리를 재는 척도
결합도 · 응집도 · 모듈 · 의존성 그래프 · 순환 의존성
관심사 분리와 같은 생각을 적은 설계 원칙
단일 책임 원칙 · 캡슐화 · 정보 은닉 · 추상화 · SOLID · 의존성 역전 원칙 · 인터페이스 분리 원칙 · 디미터 법칙
관심사 분리를 계층으로 옮긴 구조
레이어드 아키텍처 · MVC · MVVM · 클린 아키텍처 · 헥사고날 아키텍처 · 클라이언트-서버 · 마이크로서비스 · 소프트웨어 아키텍처
관심사마다 코드를 담는 단위
함수 · 컨트롤러 · 서비스 계층 · 리포지토리 패턴 · 인터페이스 · 패키지
여러 모듈에 걸치는 관심사와 그 처리 수단
횡단 관심사 · 관점 지향 프로그래밍 · 필터 · 미들웨어 · 로깅 · 인증 · 트랜잭션
나눈 모듈을 잇고 시험하는 장치
의존성 주입 · 제어의 역전 · 단위 테스트 · 테스트 더블 · 목 객체
웹 페이지를 관심사로 나눈 세 언어
HTML · CSS · JavaScript · DOM
관심사가 섞였을 때 드러나는 문제
다른 이름: separation of concerns · 관심의 분리 · 관심사의 분리