좋은 아키텍처란 무엇일까요?
예전에는 이 질문에 대한 대답을 굉장히 추상적으로 할 수 밖에 없었습니다. "좋은 아키텍처란 무엇인가?"에 대해서 깊게 고민해보지 않았기 때문에 질문의 본질적인 의미를 이해하기 어려웠고 결국에는 유명한 아키텍처를 적절하게 차용한 그럴싸한 폴더 구조를 찾아 설명하는 것이 최선이었습니다.
개인적으로 '좋은 아키텍처'를 진심으로 고민하게 된 계기는 불분명한 기준으로 설계된 프로젝트가 커지면서 예기치 못한 문제가 발생하는 때였습니다. 간단한 수정임에도 불구하고 여러 서비스에서 사이드이펙트를 염려해야하거나, 작은 기능 하나 추가하려고 수 많은 파일을 수정해야 할 때가 되어서야 비로소 "더 좋은 아키텍처로 설계했더라면 어땠을까?"라는 후회가 들었습니다.
이러한 어려움을 겪어본 사람이라면 자연스럽게 “좋은 아키텍처란 무엇인가?”라는 질문에 고민이 깊어질 것입니다. 저 역시 이런 문제들을 해결할 수 있을 것이라는 기대를 가지고 로버트 C. 마틴의 클린 아키텍처를 읽었습니다. 처음에는 혼자 책을 읽고 “클린 아키텍처란 무엇인가?“에 대해 고민했지만, 책에서 설명하는 개념과 컨셉을 온전히 이해하기는 쉽지 않았습니다.
그러던 와중 스터디에 참여해 함께 책을 읽고 의견을 나눌 기회가 생겼습니다. 스터디 멤버들도 저와 비슷한 필요성을 느끼고 책을 읽었지만, 결론적으로는 지금의 프런트엔드에 클린 아키텍처에서 제시하는 구조를 그대로 적용하기에는 어려움이 있다는 공감대가 형성됐습니다. 저 역시 클린 아키텍처가 모든 문제를 해결하는 해답은 아니다라는 생각에 도달하게 되었습니다.
그렇다면 이상적인 아키텍처란 과연 존재할까요? 최근 주목 받는 FSD(Feature-Sliced Design)같은 방식이 해답이 되어줄 수 있을까요?
저는 이상적인 아키텍처란 존재하지 않는다고 생각합니다.
출처: 제로스애니리그
단, 스키야키에는 명확한 법칙은 존재하지 않는다. 관동풍, 관서풍의 차이 뿐만 아니라 재료의 종류, 배치, 순서까지... 가정의 개수만큼 스키야키의 조리법이 존재한다. 이건 어디까지나 내가 최고로 생각하는 방법. 나에게 있어서 이상적인 스키야키다.
제가 좋아하는 영상 '이상적인 스키야키'의 한 대목입니다.
아키텍처를 공부하면서 깨달은 것은, 스키야키 조리법만큼이나 아키텍처도 프로젝트 규모, 도메인, 조직 문화 등에 따라 “이상적”이라는 개념이 달라질 수밖에 없다는 점입니다. 결국, 상황마다 ‘가장 적합한 아키텍처’를 찾아 적용해야 하고, 그 과정에서 통용될 수 있는 ‘이상적인 아키텍처’는 존재하지 않습니다.
그럼에도 불구하고 널리 알려진 아키텍처를 공부하는 것은 가치가 있습니다. 그 안에 녹아 있는 공통적인 원칙과 이유를 이해하면, 이를 우리의 프로젝트에 맞게 응용할 수 있기 때문입니다.
이 글에서는 클린 아키텍처를 통해 바라본 ‘이상적인 아키텍처’가 가지는 특징에 대한 제 생각을 정리해 보고자 합니다.
소프트웨어를 계층으로 분리하고 의존성 규칙을 준수한다면 본질적으로 테스트하기 쉬운 시스템을 만들게 될 것이며, 그에 다른 이점을 누릴 수 있다. - 로버트 C. 마틴, 『클린 아키텍처』 중에서
이 문장은 클린 아키텍처의 핵심을 명확히 보여줍니다. “계층 분리”와 “의존성 규칙 준수”는 클린 아키텍처의 근간이며, 이 두 가지를 통해 궁극적으로 예측 가능하고 유지보수하기 쉬운 시스템을 만들 수 있습니다.
소프트웨어 아키텍처의 첫 번째 목표는 역할(Role)과 책임(Responsibility)을 분리해, 코드가 논리적으로 정리되도록 만드는 것입니다. 이를 통해 각 계층의 변경이 다른 계층에 영향을 주지 않도록 설계함으로써, 유지보수와 확장이 용이한 시스템을 구축할 수 있습니다.
소프트웨어 아키텍처는 선을 긋는 기술이며, 나는 이러한 선을 경계(boundary)라고 부른다. - 로버트 C. 마틴, 『클린 아키텍처』 중에서
클린 아키텍처에서 말하는 “계층 분리”를 이해하려면 먼저 경계(boundary)라는 개념을 알아야 합니다. 책에서도 “소프트웨어 아키텍처는 선을 긋는 기술”이라고 소개하면서, 이 선을 ‘경계’라고 부릅니다.
// RegisterDate.tsx
import moment from 'moment';
const RegisterDate = (registerDate) => {
return <Text>{moment(registerDate).format('YYYY-MM-DD')}</Text>
}
<RegisterDate/>
컴퍼넌트는 가입일을 YYYY-MM-DD
형식으로 표시해주는 단순한 컴퍼넌트입니다. 읽기에도 어렵지 않고, 그 의미가 충분히 잘 전달된다고 생각이 들 수 있습니다. 프로젝트가 점차 커지면 날짜 포맷 처리 관련 로직을 비슷하게 중복 사용하게 되고, 프로젝트가 커지면 이 로직을 사용하는 컴퍼넌트가 수 없이 많아지게 됩니다.
이때 팀의 규칙 변화로 “moment 대신 dayjs로 교체하자”와 같은 결정이 내려지면, 모든 호출부를 한꺼번에 수정해야 하는 문제가 생길 수 있습니다.
이러한 문제의 원인은, 라이브러리 의존성을 “경계 없이” 컴포넌트 내부에 직접 두었기 때문입니다.
// lib/date
import moment from 'moment';
export function formatDate(date, { format }) {
return moment(date).format(format);
}
// RegisterDate.tsx
import formatDate from '@lib/date';
const RegisterDate = (date) => {
return <Text>{formatDate(date,{format:'YYYY-MM-DD'})}</Text>
}
만약 moment
를 formatDate
함수에 래핑하여 경계를 두었다면,moment
대신 dayjs
나 date-fns
같은 라이브러리로 교체한다고 해도 formatDate
함수 내부만 수정하면 되므로, 수정 범위가 매우 줄어듭니다. 또한, formatDate
를 가져와서 사용하는 컴퍼넌트는 내부적으로 어떤 라이브러리를 사용하는지 알 필요가 없어집니다. 즉, <RegisterDate/>
는 formatDate
에만 의존할 뿐 외부 라이브러리에 직접 의존하지 않게 됩니다.
경계를 세웠다면, 이제 책임 또한 명확히 분리해야 합니다.
아키텍처에서 말하는 책임은 단일 책임 원칙(Single Responsibility Principle)과 밀접한 관련이 있습니다. 클린 아키텍처에서는 단일 책임 원칙을 다음과 같이 설명합니다.
헷갈리지 말라. 단 하나의 일만 해야 한다는 원칙은 사실 따로 있다. 그것은 바로 함수는 반드시 하나의 일만 해야한다는 원칙이다. 이 원칙은 커다란 함수를 작은 함수들로 리팩터링하는 더 저수준에서 사용된다. - 로버트 C. 마틴, 『클린 아키텍처』 중에서
역사적으로 SRP는 아래와 같이 기술되어 왔다.
단일 모듈은 변경의 이유가 하나, 오직 하나뿐이어야 한다. - 로버트 C. 마틴, 『클린 아키텍처』 중에서
하나의 모듈은 하나의, 오직 하나의 사용자 또는 이해관계자에 대해서만 책임져야 한다. - 로버트 C. 마틴, 『클린 아키텍처』 중에서
하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다. - 로버트 C. 마틴, 『클린 아키텍처』 중에서
즉, “하나의 모듈은 변경 이유가 오직 하나여야 한다”는 것입니다. 여러 목적과 책임이 한곳에 섞여 있으면 변경 요인이 많아지고, 결국 유지보수가 어려워집니다.
좋은 아키텍처가 되기 위해서는 아키텍처를 구성하는 모듈을 각각 하나의 책임을 가지도록 설계하는 것이 중요합니다. 여러 책임을 가지고 있는 모듈로 이루어진 아키텍처라면 그만큼 변경될 이유가 많기 때문에 좋은 아키텍처라고 할 수 없습니다. 하나의 레이어에서 하나의 역할을 담당하기 위해서는 모듈이나 코드도 이러한 취지에 맞게 나눠지는 것이 중요할 것 입니다.
// apis.ts
import { toast } from 'react-toastify';
import axios from 'axios';
axios.interceptors.response.use(
response => response,
error => {
const { message } = error;
toast.error(message);
return Promise.reject(error.originalError);
}
);
여기서는 네트워크 요청 코드(axios)와 UI 표시 코드(toast)가 한 파일에 섞여 있습니다. 만약 토스트 대신 모달이나 다른 방식으로 에러를 표시하고 싶다면, 이 파일도 수정해야 합니다. 즉, “네트워크 요청”이라는 책임과 “UI 표시”라는 책임이 함께 뒤섞여 있어, 변경이 발생했을 때 서로 얽히게 됩니다.
// apis.ts
import axios from 'axios';
import { handleGlobalError } from './handleGlobalError';
axios.interceptors.response.use(
response => response,
error => {
handleGlobalError(error);
}
);
// handleGlobalError.ts
import { toast } from 'react-toastify';
import type { AxiosError } from 'axios';
export const handleGlobalError = (error: AxiosError) => {
const { message } = error;
toast.error(message);
return Promise.reject(error.originalError);
};
에러 처리 함수가 분리되어 책임 분리에 한 걸음 가까워졌지만, 여기서는 여전히 handleGlobalError
가 UI 표시(토스트)에 직접 의존합니다. 특정 기능에서 에러 표시를 모달로 표시해달라는 요청이 들어오거나, UI로 표시가 아닌 처리방식으로 요청이 들어왔을 때 이 로직이 복잡해질 우려가 있습니다.
더 나은 접근 방법은 “에러를 전파하는 역할”과 “UI에 표시하는 역할”을 나누는 것입니다. 전역 에러 핸들러는 “에러 객체를 넘겨받아 전역에서 처리할 수 있도록 만드는 책임”까지만 맡고, 실제 UI 표시 로직은 가장 끝단(예: ErrorBoundary, 최상위 컴포넌트 등)에서 수행하도록 분리할 수 있습니다.
// apis.ts
import { toast } from 'react-toastify';
import axios from 'axios';
const handleGlobalError = (error: Error) => {
// ... UI와 관련 없는 에러 로직 처리
Promise.reject(error);
};
axios.interceptors.response.use(
response => response,
error => handleGlobalError(error)
);
// ProductDetail.tsx
const ProductDetail = ({ id }: { id: string }) => {
const { data } = useSuspenseQuery({
queryKey: ['product', id],
queryFn: () => axios.get(`/product/${id}`),
});
return <div>{data.name}</div>;
};
// ParentComponent.tsx
const handleLocalError = (error: Error) => {
// 여기서만 Toast 등 UI 표시 로직 처리
toast.error(error.message);
};
const ParentComponent = ({ id }: { id: string }) => {
return (
<ErrorBoundary
fallback={<div>Something went wrong</div>}
onError={handleLocalError}
>
<Suspense fallback={<div>Loading...</div>}>
<ProductDetail id={id} />
</Suspense>
</ErrorBoundary>
);
};
이 방식이면, 상위 컴포넌트 혹은 ErrorBoundary
쪽에서 “에러를 어떤 방식으로 표현할지” 결정하므로, apis.ts
같은 네트워크 계층을 굳이 수정할 필요가 없어집니다. 책임이 명확히 분리되면 이런 유연함을 얻을 수 있습니다.
프로젝트가 커질수록, 코드가 흩어지며 유지보수가 어려워집니다. 그래서 ‘비슷한 역할끼리 모으는 계층 구조’가 중요해집니다. 역할(Role)을 기준으로 코드를 나누는 것이 계층 분리의 핵심입니다. 역할을 명확히 정의하면 코드가 어디에 위치해야 하는지 명확해지고, 변경사항에 내성이 생깁니다.
프로젝트 규모가 커질수록, “역할이 뒤섞인” 코드가 유지보수를 어렵게 만듭니다. 여기서는 UI/도메인/인프라로 단순화한 예시를 들어 보겠습니다. 앞서 다뤘던 에러 처리 코드를 이 레이어 구조에 대입해보면, 각 계층이 어떤 역할을 맡아야 하는지 보다 쉽게 이해할 수 있을 것 입니다.
주의: 로버트 C. 마틴이 책에서 말하는 4단계(Entities, Use Cases, Interface Adapters, Frameworks & Drivers)와는 용어와 범위가 조금 다를 수 있습니다. 이 글에서는 UI/도메인/인프라로 나눠서 사용해봤을 때 어떠한 이점을 얻었는지 예시를 보여주기 위함입니다.
1. UI 레이어 (User Interface Layer)
2. 도메인 레이어 (Domain/Application Layer)
3. 인프라 레이어 (Infrastructure Layer)
에러 처리 코드를 레이어에 맞게 재구성해보면 아래와 같이 나눌 수 있습니다.
1. 인프라 레이어
어떤 HTTP 에러가 발생했는지 도메인 계층으로 넘겨주는 역할을 합니다.
// infra/apiClient.ts
import axios from 'axios';
import { classifyError } from '@domain/errorClassifier';
const apiClient = axios.create({
baseURL: '/api',
});
apiClient.interceptors.response.use(
response => response,
error => {
// 서버에서 받은 에러를 도메인 계층이 이해할 수 있는 형태로 변환
const domainError = classifyError(error.response?.data || error);
// 분류된 에러를 UI에서 처리할 수 있도록 throw
return Promise.reject(domainError);
}
);
export default apiClient;
2. 도메인 레이어
// domain/errorClassifier.ts
export type DomainErrorType = 'AUTH_ERROR' | 'PERMISSION_ERROR' | 'SERVER_ERROR' | 'UNKNOWN_ERROR';
export interface DomainError {
type: DomainErrorType;
message: string;
}
export function classifyError(errorData: any): DomainError {
if (errorData?.status === 401) {
return {
type: 'AUTH_ERROR',
message: '로그인이 필요합니다.',
};
}
if (errorData?.status === 403) {
return {
type: 'PERMISSION_ERROR',
message: '권한이 없습니다.',
};
}
if (errorData?.status >= 500) {
return {
type: 'SERVER_ERROR',
message: '서버 오류가 발생했습니다.',
};
}
return {
type: 'UNKNOWN_ERROR',
message: errorData?.message || '알 수 없는 오류가 발생했습니다.',
};
}
3. UI 레이어
// ui/ErrorBoundary.tsx
import { ErrorBoundary } from 'react-error-boundary';
import { toast } from 'react-toastify';
import type { DomainError } from '@domain/errorClassifier';
function handleDomainError(error: DomainError) {
switch (error.type) {
case 'AUTH_ERROR':
// 이미 로그인 화면으로 이동하는 로직이 UI 책임으로 분류되었다고 가정
navigate('/login');
break;
case 'PERMISSION_ERROR':
toast.error('권한이 없습니다.');
break;
case 'SERVER_ERROR':
toast.error(error.message);
break;
default:
toast.error(error.message);
}
}
export function MyErrorBoundary({ children }: { children: React.ReactNode }) {
return (
<ErrorBoundary
fallback={<div>Something went wrong</div>}
onError={(error: DomainError) => {
handleDomainError(error);
}}
>
{children}
</ErrorBoundary>
);
}
이처럼 에러 처리 코드를 역할에 따라 분리하고 레이어 구조에 대입하면 다음과 같은 장점이 있습니다.
1. UI와 로직 분리
이렇듯 “계층 분리”와 “책임 분리”가 만나면, 코드 구조가 훨씬 명확해지고 유지보수가 쉬워집니다.
클린 아키텍처에서 말하는 의존성 규칙은, 의존성이 항상 “안쪽(도메인)” 계층으로만 흐르도록 설계해야 한다는 원칙입니다. 즉, UI나 인프라 레이어가 도메인 레이어를 참조할 수 있지만, 도메인 레이어가 UI나 인프라를 역으로 참조해서는 안 됩니다.
이 규칙을 지키지 않으면 코드가 잘못된 방식으로 결합되어 유지보수와 확장이 어려워질 위험이 있습니다. 앞서 살펴본 경계 설정 예시를 통해 의존성 규칙의 필요성과 효과를 구체적으로 알아보겠습니다.
// apis.ts
import { toast } from 'react-toastify';
import axios from 'axios';
axios.interceptors.response.use(
response => response,
error => {
const { message } = error;
toast.error(message);
return Promise.reject(error);
}
);
위 코드에서는 인프라 레이어(axios 설정)가 UI 라이브러리인 toast에 직접 의존합니다. 만약 에러 표시 방식을 토스트에서 모달로 바꾸거나, react-toastify 대신 다른 라이브러리를 쓰기로 하면 이 코드 역시 수정해야 합니다. 이는 인프라 로직과 UI가 강하게 결합되어 있다는 신호이며, 수정 범위를 넓히고 테스트 복잡도를 높입니다.
의존성 규칙을 준수하려면, 도메인 계층에서는 에러 처리 결과만 반환하고, 실제 UI 표시 로직은 UI 레이어에서 처리하도록 분리해야 합니다.
1. 인프라 레이어
외부 연동(HTTP 호출)만 책임지며, 에러가 발생하면 도메인 형태로 변환해 throw합니다.
// infra/apiClient.ts
import axios from 'axios';
import { classifyError } from '@domain/errorClassifier';
const apiClient = axios.create({ baseURL: '/api' });
apiClient.interceptors.response.use(
response => response,
error => {
const domainError = classifyError(error.response?.data || error);
return Promise.reject(domainError);
}
);
export default apiClient;
네트워크 요청 중 발생한 에러를 포착해 도메인 계층으로 전달합니다.
2. 도메인 레이어
에러를 어떻게 해석(분류)할지만 결정하고, UI나 인프라에 직접 의존하지 않습니다.
// domain/errorClassifier.ts
export function classifyError(errorData): DomainError {
// ...
}
3. UI 레이어
도메인 에러를 구체적으로 어떻게 표시할지 담당합니다. (Toast, 모달, 라우팅 등)
// ui/ErrorBoundary.tsx
const handleLocalError = (error: CustomError) => {
if (error.action === 'notify') {
toast.error(error.message); // UI 레이어에서 에러 표시
} else if (error.action === 'redirect') {
navigate(error.path);
}
};
<ErrorBoundary
fallback={<div>Something went wrong</div>}
onError={handleLocalError}
>
<YourComponent />
</ErrorBoundary>;
의존성 규칙을 조금 더 명확히 표현하면, 아래와 같은 흐름을 가진다고 볼 수 있습니다.
즉, 의존성이 한쪽 방향(안쪽 계층)으로만 흐르도록 하면 UI가 바뀌어도 도메인 레이어는 건드릴 필요가 없고, 도메인 로직이 바뀌어도(예: 에러 처리 로직 변경) UI에는 최소한의 영향만 미치도록 설계할 수 있습니다. 이처럼 “어느 한쪽이 변경되더라도 다른 한쪽에 대한 영향이 최소화되는 ‘내성’”을 확보할 수 있다는 점이 가장 큰 장점입니다.
결과적으로, 첫 번째 특징인 “계층 분리”와 두 번째 특징인 “의존성 규칙 준수”가 결합되면, 유지보수성과 확장성이 뛰어난 시스템을 구축할 수 있습니다.
좋은 아키텍처는 테스트 코드 작성이 쉽고, 코드 수정 후에도 비교적 쉽게 리팩터링할 수 있다는 특징을 가지고 있습니다. 왜냐하면, “테스트하기 어려운 구조”는 그 자체로 응집도가 낮거나 결합도가 높다는 신호일 수 있기 때문입니다.
아래 코드를 다시 보겠습니다.
// lib/date
import moment from 'moment';
export function formatDate(date, format) {
return moment(date).format(format);
}
// Date.tsx
const Date = (date) => {
return <Text>{formatDate(date,'YYYY-MM-DD')}</Text>
}
it("should format a general date string to the given format", () => {
const inputDate = "01/02/2025";
const format = "YYYY-MM-DD";
const expectedOutput = "2025-01-02";
const result = formatDate(inputDate, format);
expect(result).toBe(expectedOutput);
});
경계에서 언급했던 예시에 테스트코드를 추가해 가져왔습니다. 여기서 moment
대신 dayjs
등 다른 라이브러리로 교체하여 세부구현을 바꾸더라도, 입출력(파라미터와 반환값)이 동일하게 유지되면 테스트가 그대로 통과합니다.
즉, “경계를 둔 유틸 함수”에 대한 단위 테스트만 통과하면, 컴포넌트를 전부 바꾸지 않아도 됩니다. 이것이 “경계를 두는 것”과 “테스트 코드”가 결합했을 때 얻을 수 있는 가장 큰 이점입니다.
반면, 한 컴포넌트에 지나치게 많은 의존성이 몰려 있으면 테스트가 매우 까다로워집니다. 예를 들어, 아래처럼 하나의 컴포넌트가 Redux 상태, 네트워크 로직, UI 로직, 도메인 로직 등 다양한 책임을 동시에 안고 있다고 해봅시다.
// ComplexComponent.tsx
export function ComplexComponent() {
// 1. 리덕스 상태와 UI 상태를 혼합
const [localState, setLocalState] = useState(false);
const reduxValue = useSelector((state) => state.value);
// 2. 네트워크 요청 포함
useEffect(() => {
axios.get('/api/something').then((res) => {
// 네트워크 응답 처리
});
}, []);
// 3. 여러 레이어 섞임: 도메인 로직 + UI 로직 + 네트워크 로직 등
const handleClick = async () => {
await axios.post('/api/action', { value: reduxValue });
setLocalState(true);
};
return (
<div onClick={handleClick}>
{localState ? 'Clicked' : 'Not clicked yet'}
</div>
);
}
위 컴포넌트의 테스트 코드를 작성하려고 하면, 아래와 같은 문제가 발생합니다:
store
, QueryClientProvider
, BrowserRouter
등 여러 의존성이 모두 필요하고, 어느 한 군데만 바뀌어도 테스트가 깨질 수 있습니다.이는 이미 “해당 컴포넌트가 너무 많은 책임을 안고 있다”는 신호입니다. 즉, 아키텍처적 관점에서 봤을 때 결합도가 높은 구조가 숨어 있을 가능성이 높습니다.
결국, 테스트가 용이한 구조란 의존성 방향을 잘 지키고, 각 계층이 독립적으로 동작하도록 만들어진 구조를 의미합니다. 특정 컴포넌트나 모듈에 외부 의존성이 몰려 있다면 테스트가 힘들어지며, 이 또한 좋은 아키텍처와 거리가 멀어지는 지름길입니다.
정리하자면, 클린 아키텍처가 말하는 계층 분리와 의존성 규칙을 제대로 지키면, 결국 테스트하기 편한 구조가 만들어지고 예측 가능한 아키텍처를 쟁취할 수 있습니다.
클린 아키텍처에서 제시하는 레이어를 학습한 후 그대로 적용해야 한다는 의미는 아닙니다. 이 레이어들이 어떤 기준으로 구분되었는지 이해하고, 자신의 프로젝트와 팀 문화에 맞춰 재설정하는 과정이 필요합니다. 작은 규모의 프로젝트라면 이러한 레이어링이 오버엔지니어링이 될 수도 있으므로, 늘 균형감 있게 적용해야 합니다.
여전히 아키텍처 고민을 하다 보면, “이상적인 스키야키” 영상을 떠올리곤 합니다. 서로 다른 재료와 조리법이 있듯, 우리도 서로 다른 조직, 기술 스택, 프로젝트 규모가 있습니다. 스키야키 영상 속에선 가족들이 각자 다른 방식을 고집하다가, 결국 서로의 문화를 받아들이고 이해하며 ‘나름의 스키야키’를 완성해 갑니다.
마치 아키텍처 설계도 그렇게 여러 방식을 시도해보고 때로는 충돌도 일으키며, 결국엔 우리 팀에 맞는 ‘또다른 스키야키’를 만들어가는 과정이 아닐까 싶습니다.
영상에서 스키야키를 즐기는 서로의 방식을 이해하고 받아들이는 모습처럼, 우리 역시 “이상적인 아키텍처”라는 하나의 정답이 아니라 서로 다른 방식을 열어두고, 함께 발전시키며 결국엔 우리만의 스키야키, 우리만의 아키텍처를 완성나가는 것이 아닐까 생각해 봅니다.
긴 글 읽어주셔서 감사합니다.
좋은글 감사합니다:) 정말 깊은 내공이 느껴지는 글이네요!