웹 애플리케이션 개발이 점점 복잡해지면서, 초기의 단일 구조(Monolithic) 애플리케이션은 여러 문제점을 드러냈습니다. 이런 문제를 해결하기 위해 등장한 것이 3계층 구조(3-Tier Architecture) 입니다. IBM, Oracle, Microsoft 등의 기업들은 엔터프라이즈 애플리케이션 개발 가이드에서 단일 구조의 한계를 극복하기 위한 다계층 아키텍처의 중요성을 강조해왔습니다.
모든 것이 한 곳에 섞임:
- 한 파일에 HTML, CSS, JavaScript, 데이터베이스 쿼리가 모두 섞여 있어 변경이 어려웠습니다.
- 화면 디자인만 바꾸고 싶어도 데이터 처리 코드까지 건드려야 했습니다.
- TV 채널만 바꾸려고 할 때마다 전체 설정을 들어가서 바꿔야 하는 느낌.
코드 재활용이 어려움:
- 모듈화가 되지 않으니 같은 기능이 필요할 때마다 처음부터 다시 코드를 작성해야 했습니다.
테스트가 복잡:
- 특정 기능만 테스트하고 싶어도 전체를 실행해야 했습니다.
이러한 문제를 해결하기 위해 애플리케이션을 기능별로 분리하는 계층화된 접근 방식이 필요!
3계층 구조는 애플리케이션을 논리적/물리적으로 세 개의 독립된 계층으로 나누어 구성하는 아키텍처 패턴입니다. 각 계층은 특정 책임을 담당하며, 서로 간의 의존성을 최소화합니다.
- 역할: 사용자와 직접 상호작용하는 인터페이스 담당
- 포함 요소: UI 컴포넌트, 사용자 입력 처리, 데이터 표시
- 프론트엔드 영역: 사용자가 직접 마주하는 부분
- 역할: 비즈니스 로직과 규칙 처리
- 포함 요소: 데이터 검증, 비즈니스 규칙 적용, UseCase 구현
- 미들웨어 영역: 프레젠테이션과 데이터 계층 사이의 중개자
- 역할: 데이터 저장 및 접근
- 포함 요소: 데이터베이스, 데이터 접근 로직
- 백엔드 영역: 실제 데이터 관리를 담당
실제 진행중인 프로젝트 코드를 통해 각 계층의 구현을 살펴보겠습니다.
// 데이터 계층: UserRepositoryImpl.ts
export class UserRepositoryImpl implements UserRepository {
async getUser(): Promise<User> {
const response = await fetch('https://XXXXXX.co.kr/product/user', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('XXXX-accessToken')}`,
},
});
if (!response.ok) {
throw new Error('존재하지 않는 id입니다. 관리자에게 문의해주세요.');
}
const data = await response.json();
return {
userId: data.userId,
name: data.name,
// 기타 필드...
};
}
}
데이터 계층은
UserRepositoryImpl
클래스로 구현되며, API 호출을 통해 실제 데이터를 가져오는 역할을 합니다.
// 비즈니스 계층: GetUserUseCase.ts
export class GetUserUseCase {
constructor(private repository: UserRepository) {}
async execute(): Promise<User> {
return this.repository.getUser();
}
}
비즈니스 계층은
GetUserUseCase
클래스로 구현되며, 데이터 계층에 의존하지만 구체적인 구현체가 아닌 인터페이스에 의존합니다. 개인적으로 이 부분이 아키텍처의 핵심이라고 생각합니다. 이유는 DIP와 SOLID와 연관되어있는데 밑에서 더 자세히 설명드리겠습니다.
// 프레젠테이션 계층: actions.ts
export const getUserAction = createAsyncThunk<User, void, { rejectValue: string }>(
'user/getUser',
async (_, { rejectWithValue }) => {
const useCase = new GetUserUseCase(repository);
try {
const user = await useCase.execute();
return user;
} catch (error) {
return rejectWithValue((error as Error).message);
}
},
);
이 예시에서는 Redux를 사용하여 프레젠테이션 계층을 구현했습니다. 이것은 3계층 구조에서 필수적인 기술이 아니라, 제가 진행중인 프로젝트의 요구사항에 맞게 선택된 구현 방식입니다.
- 사용자 요청 → 프레젠테이션 계층 접근
- 사용자가 프로필 페이지를 열거나 "정보 가져오기" 버튼 클릭
- Redux 액션 → 비즈니스 계층 호출
getUserAction
이GetUserUseCase
호출
- UseCase → 데이터 계층 인터페이스 접근
GetUserUseCase
가UserRepository
인터페이스의 메서드 호출
- Repository 구현체 → 외부 API 요청
UserRepositoryImpl
이 실제 서버 API에 HTTP 요청
- API 응답 → 역순으로 데이터 전달 → 화면 표시
- 서버 응답 → Repository → UseCase → Redux → UI 컴포넌트
3계층 구조에서 핵심은 각 계층 간의 인터페이스입니다. 3계층 구조와 SOLID/DIP는 필수적인 상관관계를 갖는 것은 아니지만, 함께 사용될 때 코드 품질과 유지보수성을 크게 향상시킵니다.
// 인터페이스 (추상화)
interface UserRepository {
getUser(): Promise<User>;
}
// 비즈니스 계층: 인터페이스에 의존
class GetUserUseCase {
constructor(private repository: UserRepository) {}
async execute(): Promise<User> {
return this.repository.getUser();
}
}
// 비즈니스 계층: 구체적인 구현체에 직접 의존
class GetUserUseCase {
private repository = new UserRepository(); // 직접 의존!
async execute(): Promise<User> {
return this.repository.getUser();
}
}
- 인터페이스 사용
- DIP 적용: 인터페이스를 통한 추상화로 유연성 확보
- DIP 미적용: 구체적인 구현체에 직접 의존으로 유연성 감소
- 의존성 주입
- DIP 적용: 생성자를 통해 외부에서 의존성 주입
- DIP 미적용: 내부에서 직접 의존성 생성
- 결합도
- DIP 적용: 느슨한 결합으로 변경에 유연하게 대응
- DIP 미적용: 강한 결합으로 변경 시 여러 부분 수정 필요
- 테스트 용이성
- DIP 적용: Mock 객체로 쉽게 대체 가능
- DIP 미적용: 테스트 시 실제 API 호출 필요
- 관심사의 분리
- 각 계층은 명확한 책임(이것이 3계층의 핵심)을 가져 코드 이해도가 향상됩니다.
- 재사용성 증가
- 비즈니스 로직과 데이터 접근 로직을 여러 상황에서 재사용할 수 있습니다.
- 테스트 용이성
- 각 계층을 독립적으로 테스트할 수 있어 품질 보증이 쉬워집니다.
- 유지보수성 향상
- 특정 계층의 변경이 다른 계층에 미치는 영향을 최소화합니다.
- 확장성 개선
- 각 계층을 독립적으로 확장할 수 있어 성장하는 애플리케이션에 적합합니다.
3계층 구조는 복잡한 애플리케이션의 유지보수성, 확장성, 테스트 용이성을 높이는 효과적인 아키텍처 패턴입니다. 모든 상황에 적합한 것은 아니지만, 프로젝트가 성장함에 따라 그 가치가 더욱 분명해질 것 같은 느낌입니다.
핵심은 각 계층 간의 인터페이스를 통한 의존성 관리입니다. 이는 SOLID 원칙 중 DIP(의존성 역전 원칙)와 직접적으로 연관되며, 코드의 유연성과 테스트 용이성을 크게 향상시킵니다.