공부/기술 개념 정리

관심사 분리란? (SoC, separation of concerns)

dev_jiwonpark 2025. 12. 22. 13:11

최근에 사이드 프로젝트를 개발하려고 하는 순간..!

몇달 되지도 않았지만 쌓여있는 레거시 코드들을 보면서 뭐가 이렇게 급해서 모든 기능들이 한 파일안에 전부 들어가 있는지..

가독성도 떨어지고 기능 하나 수정하려고 스크롤을 위 아래로 왔다 갔다 하다가 깨달았다.

이거 분리해야겠다고..

그래서 한 파일에 몰려있던 UI, 로직, API 호출을 쪼개고, Custom Hook으로 분리하고, 유틸 함수로 빼냈다.

나름 뿌듯했다.

그런데 피드백을 받고 깨달았다.

나는 "관심사 분리"를 너무 좁게 생각하고 있었다.

내가 한 건 "함수 단위 분리"였다. 사이드 이펙트 줄이기, 재사용성 높이기, 코드 정리하기. 틀린 건 아니지만, 관심사 분리의 일부분일 뿐이었다.

알고 보니 관심사 분리는 어떤 관점에서 바라보느냐에 따라 완전히 다른 이야기가 된다.


관심사 분리란 ? (SoC, separation of concerns)

https://ko.wikipedia.org/wiki/%EA%B4%80%EC%8B%AC%EC%82%AC_%EB%B6%84%EB%A6%AC

관심사 분리(SoC)는 프로그램을 구별된 부분으로 나누는 설계 원칙이다.
각 부분은 하나의 관심사(Concern)만 담당한다.

 

여기서 관심사란 " 프로그램 코드에 영향을 미치는 정보의 집합 " 을 의미한다.

이게 좀 추상적인데, 쉽게 말하면 아래와 같다. 

UI 렌더링 화면에 뭘 어떻게 보여줄 것인가
데이터 페칭 서버에서 데이터를 어떻게 가져올 것인가
상태 관리 데이터를 어디에 어떻게 저장할 것인가
유효성 검증 입력값이 올바른지 어떻게 판단할 것인가
에러 처리 문제가 생기면 어떻게 대응할 것인가

 

이렇게 각각이 하나의 "관심사"인 것이다. 

 

그리고 관심사 분리는 단순히 코드를 쪼개는 것이 아니다.

"정보를 잘 정의된 인터페이스가 있는 코드 부분 안에 캡슐화시킴으로써 달성한다"

 

즉, 분리된 각 모듈은 

1. 내부 구현을 숨기고 (캡슐화)

2. 외부에는 인터페이스만 노출한다. 

 

"useLogin" 이라는 커스텀 hook을 예로 들자면

내부에서 API를 어떻게 호출하는지, 에러를 어떻게 처리하는지 사용하는 쪽은 몰라도 된다.

const { login, isLoading, error } = useLogin()

내부 구현은 숨기고 useLogin이라는 인터페이스만 노출 시키는 것이다.

 

이를 왜 해야하는가? 

유지보수 하기 좋은 코드란 결국 미래에 어떤 변화가 발생했을 때 쉽게 대응할 수 있는 코드 이다.

그러러면 미리 미래에 발생할 가능성이 있는 일들에 대해 고민해야 한다.

- UI 디자인이 바뀌면?

- API 스펙이 변경되면?

- 새로운 기능이 추가되면?

- 라이브러리를 교체해야 하면?

- 다른 팀원이 이 코드를 수정해야 하면? 한꺼번에 이 모든 걱정을 하면서 코드를 짜야 한다면 상당히 골치 아프다.

 

한번에 한 가지 걱정만 하자

관심사 분리는 프로그램을 관심사 별로 쪼개서 가능하면 한 번에 한 가지 걱정만 하게 만드는 것이다.

- UI를 수정할 때는 UI만 걱정한다.

- API를 수정할 때는 API만 걱정한다.

- 비즈니스 로직을 수정할 때는 로직만 걱정한다.

 

관심사 분리가 잘 이뤄지면?

이점 설명
단순화 각 부분이 작아지고 이해하기 쉬워진다.
유지보수 자유도 한 부분을 수정해도 다른 부분에 영향이 없다.
독립적 개발 팀원들이 서로 다른 모듈을 병렬로 작업 가능
모듈 재사용 분리된 모듈을 다른 곳에서도 사용 가능
점진적 업그레이드 전체를 바꾸지 않아도 일부만 개선 가능

 

"다른 부분의 세세한 사항을 모르더라도, 또 해당 부분들에 상응하는 변경을 취하지 않더라도 하나의 관심사의 코드 부분을 개선하거나 수정할 수 있게 된다"

 

이게 관심사 분리의 궁극적인 목표이다.

 

하지만 관심사 분리에도 비용이 들어간다..ㅠ

위키피디아에서 "인터페이스의 추가는 필수이며 실행에 쓰이는 더 순수한 코드가 있는 것이 일반적이다" 라고 하는데 

관심사 분리를 하게 됐을때

- 파일 수가 늘어나게 되고

- 추상화 레이어가 추가되고

- 코드 흐름을 파악하려면 여러 파일을 봐야하는것

- 과도한 분리는 오히려 복잡도를 높인다는 점 때문에 "적절한 수준"을 찾는 것이 중요하다.

 

관심사 분리의 다양한 관점

처음 리팩토링을 할 때 내 기준은 단순했다.

- 이 함수가 너무 길다 -> 쪼개자

- 이 로직이 반복된다 -> 유틸로 빼자

- API 호출이 컴포넌트에 있다 -> Hook으로 분리하자 

 

틀린건 아니지만 이건 함수 / 모듈 단위의 분리일 뿐이었다.

관심사 분리에 대해서 공부하다보니 관심사 분리는 어떤 기준으로 나누느냐에 따라서 완전히 다른 이야기가 된다.

 

1. 수평적 분리 (레이어 분리)

애플리케이션을 역할/계층에 따라 수평으로 분리하는 방식이다.

Presentation Layer (UI, 화면 표시)
        ↓
Business Layer (비즈니스 로직)
        ↓
Persistence Layer (데이터 접근, API)
        ↓
Database Layer (저장소)

 

각 게층은 자신의 책임만 수행하고, 다른 게층의 내부 구현은 알지 못한다.

핵심 규칙은 의존성의 방향인데 

- 상위 계층은 하위 계층에 의존하며

- 하위 계층은 상위 계층을 모른다.

 

Presentation Layer는 Business Layer를 의존하지만, Business Layer는 Presentation Layer의 존재를 모른다.

그저 전달받은 데이터로 로직을 수행할 뿐이다.

 

이를 프론트엔드에 적용한다면 ??

features/auth/
├── components/       # Presentation Layer
│   └── LoginForm.tsx
├── hooks/            # Business Layer
│   └── useLogin.ts
└── api/              # Persistence Layer
    └── authApi.ts

 

- LoginForm : UI 렌더링만 담당, 비지니스 로직은 모름

- useLogin : 로그인 로직 수행, API 내부 구현 모름

- authApi : 실제 서버 통신. UI가 어떻게 생겼는지 모름

이렇게 분리한다면 디자인이 바뀌어도 API  코드를 건드릴 필요가 없고, API 스펙이 수정되어도 UI 컴포넌트는 영향 받지 않는다.

이것이 Layered Architecture 의 핵심이다.

(Layered Architecture에 대한 자세한 내용은 다음 글에서 다룰 예정이다.)

 

2. 수직적 분리 ( 도메인/기능 분리 )

기능이나 도메인 단위로 수직으로 분리하는 방식이다.

features/
├── auth/       # 인증 도메인
├── payment/    # 결제 도메인
└── campaign/   # 캠페인 도메인

`auth`를 수정할 때 `payment` 폴더를 열 일이 없다. 도메인 간 의존성을 최소화하는 게 핵심이다.

 

정리하자면 수평적 분리는 역할/계층을 기준으로 나뉘고 수직적 분리는 기능/도메인 기준으로 나뉘게 된다.

실제로 이 둘을 적용한다면 다음과 같은 구조를 가질 수 있다.

features/
├── auth/                    # 수직 분리 (도메인)
│   ├── components/          # 수평 분리 (레이어)
│   ├── hooks/
│   ├── api/
│   └── types/
├── payment/                 # 수직 분리 (도메인)
│   ├── components/          # 수평 분리 (레이어)
│   ├── hooks/
│   ├── api/
│   └── types/

수직으로 도메인을 나누고, 각 도메인 내에서 수평으로 레이어를 나눌 수 있게 된다.

 

3. 횡단 관심사 분리 (Cross-cutting Concerns)

여러 모듈에 공통으로 나타나는 관심사를 분리하는 방식이다.

횡단 관심사란 특정 도메인에 속하지 않고 모든 곳에서 필요한 기능을 말한다.

- 로깅

- 에러 핸들링

- 인증/권한 체크

- 성능 모니터링

이런 기능들은 auth, payment 등 어디에도 속하지 않지만 모든 곳에서 필요하다.

 

예를 들어 모든 API 함수에서 로깅, 에러 처리가 반복된다면 

const login = async () => {
  try {
    console.log('API 호출 시작')
    const res = await axios.post('/login')
    console.log('API 호출 성공')
    return res.data
  } catch (e) {
    console.error('API 호출 실패')
    handleError(e)
  }
}

 

공통 로직을 한 곳으로 분리해 관심사 분리를 할 수 있는것이다.

const apiClient = axios.create()

apiClient.interceptors.request.use(config => {
  console.log('API 호출 시작', config.url)
  return config
})

apiClient.interceptors.response.use(
  response => {
    console.log('API 호출 성공')
    return response
  },
  error => {
    console.error('API 호출 실패')
    return handleApiError(error)
  }
)

 

이제 개별 API 함수는 핵심 로직만 담으면 된다.

const login = async (credentials) => {
  const res = await apiClient.post('/login', credentials)
  return res.data
}

 

참고로 이런 접근 방식을 AOP(Aspect-Oriented Programming) 라고 부른다.

 

프론트엔드에서는 어떻게 횡단 관심사 처리를 할 수 있을까?

관심사 분리 방법
API 에러 처리 Axios Interceptor
로딩 상태 React Query, Suspense
인증 체크 Route Guard, HOC
전역 에러 Error Boundary
토스트 알림 Context + Provider

 

4. 모듈화 & 디자인 패턴 

기능을 작은 단위로 나누고, 검증된 패턴을 적용해 관심사를 구조화하는 방식이다.

디자인 패턴은 관심사 분리를 어떻게 구현할 것인가에 대한 답이다.

 

예를 들어 MVC 패턴 을 보면 아래와 같이 각각의 관심사가 명확히 분리되어 있다.

Controller → 사용자 요청 처리, 흐름 제어

Model → 데이터와 비즈니스 로직 

View → 화면 표시

View 가 바뀌어도 Model은 영향받지 않고, Model이 바뀌어도 Controller 로직은 그대로다.

 

대표적인 패턴은 아래와 같다.

패턴 분리 기준 설명
MVC Model / View / Controller  데이터, 화면, 제어 로직 분리
MVVM Model / View / ViewModel 데이터, 화면, 화면 로직 분리
Custom Hook 패턴 상태+로직 / 렌더링 React에서 사용되는 방식

 

React로 프론트엔드 개발할때 흔하게 쓰이는 Custom Hook + 컴포넌트 패턴은 아래와 같다.

// useLogin.ts - 로직 담당
export const useLogin = () => {
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState(null)

  const login = async (credentials: LoginCredentials) => {
    setIsLoading(true)
    try {
      const result = await loginApi(credentials)
      return result
    } catch (e) {
      setError('로그인 실패')
    } finally {
      setIsLoading(false)
    }
  }

  return { login, isLoading, error }
}

// LoginForm.tsx - UI 담당
export const LoginForm = () => {
  const { login, isLoading, error } = useLogin()

  return (
    
      {error && {error}}
      
      
      
        {isLoading ? '로그인 중...' : '로그인'}
      
    
  )
}

 

UI가 어떻게 생겼든 `useLogin`은 동일하게 동작한다.

로직이 어떻게 돌아가든 `LoginForm`은 받은 데이터를 보여주기만 한다.

 


마무리

처음에는 "함수를 쪼개면 관심사 분리"라고 생각했다.

한 파일에 몰려있던 코드를 Custom Hook으로 빼고, 유틸 함수로 분리하고, 나름 뿌듯해했다.

하지만 피드백을 받고 나서야 깨달았다. 내가 한 건 관심사 분리의 일부분일 뿐이었다.

 

관심사 분리는 단순히 코드를 나누는 것이 아니라 

"무엇을 기준으로 나눌 것인가" 를 먼저 정의하는 것이다.

- 레이어로 나눌 것인가 ? ( 수평적 분리 )

- 도메인으로 나눌 것인가 ? ( 수직적 분리 )

- 공통 기능을 어떻게 처리할 것인가 ? ( 횡단 관심사 )

- 어떤 패턴을 적용할 것인가 ? ( 디자인 패턴 )

 

그리고 가장 중요한 건 " 왜 이렇게 나눴는지 " 설명할 수 있어야 한다는 것이다.

다음 글에서는 글에서 언급한 Layered Architecture 에 대해 프론트엔드 관점에서 더 깊에 다뤄보겠다!