들어가며
Flutter 프로젝트가 커질수록 "이 로직을 어디에 둬야 하지?"라는 고민을 하게 됩니다. 오늘은 실제 MTS(Mobile Trading System) 프로젝트를 통해 Clean Architecture와 DTO 패턴, 그리고 Dart Extension을 활용한 깔끔한 레이어 분리 방법을 소개하겠습니다.
왜 DTO와 Entity를 분리해야 할까?
문제 상황
Dart
// 서버에서 받은 JSON
{
"cash": 100000.0,
"lastUpdated": 1701234567890 // 숫자(timestamp)로 전달
}
// 앱에서 사용하고 싶은 형태
Account(
cash: 100000.0,
lastUpdated: DateTime(2024, 1, 15) // DateTime 객체로 사용하고 싶다
)
서버는 단순한 타입(int, String)을 선호하지만, 앱에서는 풍부한 타입(DateTime, enum)을 사용하고 싶습니다. 이 간극을 어떻게 해결할까요?
해결책: DTO 레이어
[외부 세계] ← DTO → [내부 도메인]
JSON ↔ Entity
DTO(Data Transfer Object)는 외부 세계와 통신하기 위한 "외출복"이고, Entity는 앱 내부에서 편하게 쓰는 "집에서 입는 옷"이라고 생각하면 쉽습니다.
JSON ↔ Entity
DTO(Data Transfer Object)는 외부 세계와 통신하기 위한 "외출복"이고, Entity는 앱 내부에서 편하게 쓰는 "집에서 입는 옷"이라고 생각하면 쉽습니다.
Freezed + Extension 조합의 마법
1. Freezed로 불변 데이터 클래스 정의
Dart
@freezed
class AccountDto with _$AccountDto {
const factory AccountDto({
required double cash,
required List<PositionDto> positions,
required int lastUpdated, // timestamp는 int로
}) = _AccountDto;
factory AccountDto.fromJson(Map<String, dynamic> json) =>
_$AccountDtoFromJson(json);
}
2. Extension으로 변환 로직 추가
여기서 핵심이 등장합니다! Freezed는 const factory만 허용하므로 일반 메서드를 추가할 수 없습니다. 이때 Extension이 구원자가 됩니다.
Dart
// DTO → Entity 변환
extension AccountDtoExtension on AccountDto {
Account toEntity() {
return Account(
cash: cash,
positions: positions.map((dto) => dto.toEntity()).toList(),
lastUpdated: DateTime.fromMillisecondsSinceEpoch(lastUpdated),
// int를 DateTime으로 변환
);
}
}
// Entity → DTO 변환
extension AccountEntityExtension on Account {
AccountDto toDto() {
return AccountDto(
cash: cash,
positions: positions.map((entity) => entity.toDto()).toList(),
lastUpdated: lastUpdated.millisecondsSinceEpoch,
// DateTime을 int로 변환
);
}
}
Extension이 뭐길래?
Extension은 "기존 클래스에 새 메서드를 추가하는 Dart의 마법"입니다.
Dart
extension 확장이름 on 확장할클래스 {
// 새로운 메서드들
}
간단한 예시
Dart
// String 클래스 확장
extension StringExtension on String {
String 첫글자대문자() {
return this[0].toUpperCase() + substring(1);
}
}
// 사용
String name = "flutter";
print(name.첫글자대문자()); // "Flutter"
왜 Extension을 사용해야 할까?
방법 비교
방법 1: Helper 클래스 (구식)
Dart
class AccountConverter {
static Account dtoToEntity(AccountDto dto) { ... }
static AccountDto entityToDto(Account entity) { ... }
}
// 사용 시 - 길고 불편
final entity = AccountConverter.dtoToEntity(dto);
final newDto = AccountConverter.entityToDto(entity);
방법 2: Extension (현대적)
Dart
// 사용 시 - 직관적이고 체이닝 가능
final result = dto
.toEntity()
.copyWith(cash: 50000)
.toDto()
.toJson();
Clean Architecture 전체 구조
text
```
┌─────────────────────────────────────────┐
│ Presentation Layer │
│ (UI + Riverpod) │
├─────────────────────────────────────────┤
│ Domain Layer │
│ (Entities + Repository Interface) │
├─────────────────────────────────────────┤
│ Data Layer │
│ (Repository Impl + DTO + DataSource) │
└─────────────────────────────────────────┘
```
실제 데이터 흐름
Dart
// 1. 외부에서 JSON 수신
final json = await api.getAccount();
// 2. JSON → DTO
final dto = AccountDto.fromJson(json);
// 3. DTO → Entity (Extension 활용)
final account = dto.toEntity();
// 4. 비즈니스 로직 수행
final updated = account.copyWith(cash: account.cash + 10000);
// 5. Entity → DTO → JSON (저장/전송)
final saveData = updated.toDto().toJson();
실제 프로젝트 적용 예시
Repository Implementation
Dart
class AccountRepositoryImpl implements AccountRepository {
final ApiClient _api;
@override
Future<Account> getAccount() async {
// API 호출 → DTO → Entity
final json = await _api.get('/account');
final dto = AccountDto.fromJson(json);
return dto.toEntity(); // Extension 메서드
}
@override
Future<void> updateAccount(Account account) async {
// Entity → DTO → API
final dto = account.toDto(); // Extension 메서드
await _api.post('/account', dto.toJson());
}
}
Extension 패턴의 장점
- Freezed 제약 우회: const factory 제한을 우아하게 해결
- 관심사 분리: 데이터 구조와 변환 로직을 명확히 분리
- 가독성: 메서드 체이닝으로 자연스러운 흐름
- IDE 지원: 자동완성과 타입 안정성
- 테스트 용이: 각 레이어를 독립적으로 테스트 가능
- 관심사 분리: 데이터 구조와 변환 로직을 명확히 분리
- 가독성: 메서드 체이닝으로 자연스러운 흐름
- IDE 지원: 자동완성과 타입 안정성
- 테스트 용이: 각 레이어를 독립적으로 테스트 가능
마치며
DTO와 Extension을 활용한 레이어 분리는 처음엔 번거로워 보일 수 있습니다. 하지만 프로젝트가 커질수록 이런 구조의 장점이 빛을 발합니다:
- API 변경에 유연하게 대응 가능
- 도메인 로직이 외부 의존성에서 자유로움
- 테스트와 유지보수가 쉬워짐
"좋은 아키텍처는 변경을 환영한다"는 말처럼, Extension과 DTO 패턴은 Flutter 앱을 변경에 강한 구조로 만들어줍니다.
- API 변경에 유연하게 대응 가능
- 도메인 로직이 외부 의존성에서 자유로움
- 테스트와 유지보수가 쉬워짐
"좋은 아키텍처는 변경을 환영한다"는 말처럼, Extension과 DTO 패턴은 Flutter 앱을 변경에 강한 구조로 만들어줍니다.
전체 코드 예시
Dart
// 1. DTO 정의 (Freezed)
@freezed
class AccountDto with _$AccountDto {
const factory AccountDto({
required double cash,
required int lastUpdated,
}) = _AccountDto;
factory AccountDto.fromJson(Map<String, dynamic> json) =>
_$AccountDtoFromJson(json);
}
// 2. Entity 정의 (Freezed)
@freezed
class Account with _$Account {
const factory Account({
required double cash,
required DateTime lastUpdated,
}) = _Account;
}
// 3. 변환 Extension
extension AccountDtoExtension on AccountDto {
Account toEntity() => Account(
cash: cash,
lastUpdated: DateTime.fromMillisecondsSinceEpoch(lastUpdated),
);
}
extension AccountEntityExtension on Account {
AccountDto toDto() => AccountDto(
cash: cash,
lastUpdated: lastUpdated.millisecondsSinceEpoch,
);
}
// 4. 사용
void main() {
final dto = AccountDto(cash: 100000, lastUpdated: 1234567890);
final entity = dto.toEntity(); // So clean
}