
객체 지향의 4대 요소가 Dart에서 어떻게 구현되어 있는지 알아보겠다.
[객체 지향의 4대 요소]
- Encapsulation
- Inheritance
- Polymorphism
- Abstraction
추가로 SOLID라는 원칙이 있다고 한다. 객체지향 수업을 들으면 배운다는데, 나는 이번에 처음 들었다. 그러고 보니 나는 객체지향개발방법론 전공선택 과목을 수강하지 않았다. 내가 수강한 전공선택 과목은 시스템엔지니어링이나 소프트웨어공학 쪽에 치우쳐져 있다. 응용프로그램 같은 경우엔 애초에 우리 학교가 시스템 쪽에 치중되어 있는 컴퓨터공학과와 응용프로그램 쪽에 치중되어 있는 소프트웨어공학과로 나뉘어 있었기에 컴공에서는 거의 건드릴 일도 없었고. 아무튼 이런 부분은 이제라도 살펴볼 필요가 있을 것 같다.
[SOLID 5대 원칙 요약]
- S (단일 책임 원칙, Single Responsibility Principle): 하나의 클래스는 하나의 책임(바뀌는 이유도 단 하나)만 가져야 합니다.
- O (개방-폐쇄 원칙, Open-Closed Principle): 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 합니다. 기존 코드를 수정하지 않고 기능을 추가할 수 있어야 합니다.
- L (리스코프 치환 원칙, Liskov Substitution Principle): 자식 클래스는 언제나 부모 클래스의 역할을 대체할 수 있어야 합니다. 다형성의 성질을 올바르게 지키는 원칙입니다.
- I (인터페이스 분리 원칙, Interface Segregation Principle): 자신이 사용하지 않는 메서드에 의존하지 않아야 합니다. 큰 인터페이스 하나보다 작고 구체적인 인터페이스 여러 개가 좋습니다.
- D (의존역전 원칙, Dependency Inversion Principle): 구현체(변하는 것)가 아니라 추상화(인터페이스, 변하지 않는 것)에 의존해야 합니다. 고수준 모듈이 저수준 모듈을 직접 참조해서는 안 됩니다.
원칙 핵심 목적 위반 시 발생하는 대표적 문제 SRP (Single Responsibility) 코드 응집도 향상 하나의 기능 수정 시 전혀 상관없는 코드까지 오작동함 OCP (Open-Closed) 결합도 낮추기 새로운 기능을 추가할 때마다 기존 소스코드를 모두 고쳐야 함 LSP (Liskov Substitution) 다형성의 안정성 보장 부모 인스턴스 대신 자식 인스턴스를 넣었을 때 프로그램이 터짐 ISP (Interface Segregation) 클라이언트 맞춤 설계 불필요한 빈 메서드를 억지로 구현해야 하는 상황이 발생함 DIP (Dependency Inversion) 코드 변화에 유연한 대처 하위 모듈(ex. 데이터베이스 종류)을 바꿀 때 상위 로직을 뜯어고쳐야 함
지난 시간에 살펴 보았던 것. class의 형태로 property와 method를 하나로 묶고 _ 를 붙여 private으로 만들어 외부 접근을 제한하여 데이터를 보호하는 것이 Encapsulation에 해당한다. getter와 setter도 이와 관련된 개념이고.
Encapsulation에 대한 건 길게 말하지 않겠다.
흔히 상속이라고 불리는 것. "부모의 특성과 기능을 자식에게 전달해주는 것"으로 정의된다.
extends 키워드를 통해 상속이 이루어진다. 어떤 class를 extends 한 class를 또 extends 하고... 이런 식으로 depth를 늘려가는 것은 가능하지만 둘 이상의 class를 동시에 extends 하지는 못한다. 바로 위의 부모가 가진 것을 온전히 물려받아 자식에서 추가적인 구현이 이루어진다.
class A extends B 일 때, B 는 A 의 부모이며 superclass라고 부른다. 반대로 A 는 B 의 subclass다. 최상위가 아니라 바로 위의 class를 superclass라고 부른다는 걸 유의하자. Dart에서는 class 자체와 바로 위의 superclass만 따진다. 어차피 A 의 superclass B 의 superclass C 가 있다고 할 때, C 에 대한 건 B 에 다 구현되어 있다.
B 에 정의되어 있는 foo 라는 property가 있을 때, B 를 extends 한 A 에서는 그것에 super.foo 로 접근할 수 있다. bar() 라는 method에 대해서도 마찬가지로 super.bar() 할 수 있다.
B 의 constructor가 B({required this.foo}) 로 property의 초기화를 요구한다면 이를 extends 한 A 에서는 A({required super.foo}) 와 같은 방식으로 superclass의 property를 초기화하는 constructor를 작성해야 한다.
흔히 다형성이라고 불리는 것. overloading과 overriding이 있는데 Dart에서는 overriding만 사용한다. @override 키워드를 사용하여 superclass에 정의된 method를 subclass에서 더 구체적인 방식으로 재정의할 수 있다.
이 때, superclass에 정의된 내용을 완전히 대체하는 게 아니라 supclass의 기능을 그대로 사용하며 subclass만의 기능을 추가하는 게 좋다. B 에 정의되어 있는 bar() 라는 method를 overriding 할 때, 보통 다음의 세 가지 방식 중 하나를 사용하는 게 일반적이다.
class A extends B { void bar() { super.bar(); // 부모의 기능을 먼저 수행하고 TASK; // 독자적인 기능을 수행한다. } }
class A extends B { void bar() { TASK; // 독자적인 기능을 먼저 수행하고 super.bar(); // 부모의 기능을 수행한다. } }
class A extends B { void bar() { TASK1; // 독자적인 기능을 먼저 수행하다 super.bar(); // 부모의 기능을 수행한 후 TASK2; // 독자적인 기능을 마저 수행한다. } }
물론 경우에 따라 overriding된 bar() 에서 super.bar() 를 호출하지 않고 독자적인 기능만 사용할 수도 있긴 하다.
흔히 추상화라고 불리는 것. Dart에서는 abstract 키워드를 class에 붙여 사용하는 게 대표적이다. abstract class는 추상화를 목적으로 한 class로, abstract member를 포함한다. 직접 instance를 생성할 수 없으며, 이를 extends 하고 abstract member를 overriding 하여 concrete class를 작성해야 비로소 instance를 생성할 수 있는 형태가 된다.
[abstract members]
- body 부분을 정의하지 않고 선언만 한 abstract method
- body 부분을 정의하지 않고 선언만 한 getter/setter
- abstract class 내부의 초기화되지 않은 property → 암묵적으로 추상 getter/setter로 취급
abstract class는 class가 구현해야 할 공통된 contract를 정의하는 interface 역할을 한다. (interface 키워드를 사용하는 class가 Dart 3.0에서 추가되었는데 약간 개념이 다르니 유의하자.)
이 class를 상속(extends)하여 기능을 확장할 수도 있고, 완전히 껍데기만 빌려와 새롭게 구현(implements)할 수도 있다. extends 는 superclass에 구현되어 있는 property와 method를 가져다 쓰며 subclass만의 기능을 추가하는 반면, implements 는 interface에 구현되어 있는 property와 method의 껍데기만 사용하고 모든 method를 overriding한다. 단 하나의 class만 extends 할 수 있는 것과 달리 implements 는 여러 개를 할 수 있다.
interface는 구현체의 가이드 역할을 하기도 하고, 종속성을 낮추고 확장성을 확보하는 데에도 사용된다. "이 interface만 충족시키면 어떤 class든 상관없다!" 라는 느낌? concrete class가 아닌 interface를 사용하여 작성된 것은 그것을 extends 혹은 implements 한 class를 모두 사용할 수 있는 거다. SOLID의 Open-Closed Principle과 유관한 부분인 것 같다.
[
abstract와interface]
abstractinterface기능 확장 및 일부 공통 코드 재사용 동일한 규격 정의 및 구현 강제 어디에서나 extends가능같은 파일에서만 extends가능어디에서나 implements가능어디에서나 implements가능instance 생성 불가 instance 생성 가능 body가 없는 abstract method는 반드시 재정의하여 사용 방식에 따라 다름 공통으로 사용될 로직이나 상태(필드 변수)가 많아서
자식 클래스들의 중복 코드를 줄여주고 싶을 때 주로 사용이 클래스를 직접 가져다 써도 좋은데,
만약 이 기능을 커스텀하고 싶다면 상속받아 뜯어고치지 말고
설계도대로 직접 새로implements해서 바꿔 끼워라
interface를implements할 경우: 정의된 모든 method는 예시일 뿐 전부 재정의하여 사용
abstract도implements할 경우에는 abstract/concrete 여부와 무관하게 전부 재정의 필요interface를extends할 경우: 정의된 것을 그대로 사용할 수도 있고 선택적으로 재정의하여 사용
Dart에서는 선언된 위치에 따라 scope가 결정된다. function을 호출하는 위치가 어디든 그 function은 그것을 작성한 위치에 scope를 갖는다는 거다. 이런 걸 lexical scope라고 부른다. 흔히 '정적 스코프'라고 번역되는 모양이다.
main() 밖에서 선언된 녀석들은 global scope를 갖는데, Dart에서는 일반적으로 main() 안에 대부분의 코드를 작성하므로 사용 빈도가 낮다. function을 비롯한 코드 블록 내부에서 선언된 녀석들은 local scope를 갖는데, 해당 코드 블록을 벗어나면 더 이상 사용할 수 없게 된다.
상위 scope에 선언된 값에 접근할 수 있지만 그러한 코드는 의도치 않은 side effect를 야기할 수도 있으니 조심해서 써야 한다. state를 상위 scope에 두지 말고 local scope를 사용하여 동일 입력에 대해 동일 출력을 하는 pure function으로 작성하는 게 좋다. 상위 scope에 있는 값을 받아다가 잘 활용하는 대표적인 방식으로는 closure가 있다. closure는 상위 임의의 scope에 선언된 값을 사용하는 게 아니라 closure를 생성하는 function에 선언된 값을 사용하기 때문에 global scope를 접근하는 등의 다른 방식보다 비교적 안전하다. closure가 참조하고 있는 값은 Garbage Collector가 처리하지 못하니 memory leak에 유의하여 사용해야 한다.
Dart에서는 function도 object다. Function 타입을 갖는다. parameter와 return type을 명시해 Bool Function(int, int) 와 같이 구체적으로 표현할 수도 있다. Bool Function(int, int) 와 같은 type에 대해 typedef 를 통해 별칭을 설정하면 코드 가독성을 높일 수 있다.
typedef CompareNumbers = Bool Function(int, int);
function도 object이기에 어떤 함수에서 parameter로 받을 수 있다. parameter로 전달되어야 하는 function과 parameter와 return type만 맞으면 일반적인 function이든 annonymous function이든 뭐든 다 argument로 전달할 수 있다. 이렇게 전달되는 function을 callback function이라고 한다. callback function으로 전달되는 녀석이 parameter를 갖는다면 그것도 함께 전달하는 경우가 많다.
void checkCondition(CompareNumbers condition, int numberA, int numberB) { print(condition(numberA, numberB)); } checkCondition(isCoprime, 13, 7);
주말에는 이력서 초안과 포트폴리오 초안 작성을 시작했다. 자기소개서는 일단 파편화된 조각들만 조금 만들어졌다. 그래도 봄에 노원청년카페 3월 진로테라피 참여할 때보다는 훨씬... 뭐라도 진행되는 느낌이다. 보완해야 할 부분이야 많겠지만. 일단은 Flutter 앱 개발 기획이 좀 더 우선순위 위로 올라오고 있다. 그렇다고 이력서/자기소개서/포트폴리오를 마냥 미룰 수도 없다. 바쁘다 바빠.
[현재 1순위 아이디어에 대한 Gemini와의 후속 대화]
Gemini야 재밌는 소리 하네?
flutter_rust_bridgeFFI라고???
어 근데 이거 하면 Rust 지식을 그냥 주장하는 게 아니라 포트폴리오에 담을 수 있는 거잖아...? 재밌겠ㄷ...?
FastAPI에 Py03/Maturin 붙이거나 gRPC 쓸 때부터 드러나 있던 사실이지만 나 좀 이런 거 좋아한다.
ReceiptMemo... 기각. WeMeet... 기각. AppMon... 기각. Drag2Time... 아니 이건 기능이 약간 추가되긴 했지만 바이브코딩 실습했던 Drag2Plan 재탕이잖아. 기각. 그러고 있다. Gemini 녀석 이름은 꽤나 그럴듯하게 짓는군. 하여간 계속 고민만 하는 것보다는 중간에 더 좋은 아이디어가 떠오르면 갈아치우더라도 뭐라도 시작해 보는 게 좋지 않을까 싶기도 하더라. 일단 부딛혀 보는 게 생각만 하는 것보다 의미있다고 생각한다. 무지성으로 저지르는 게 아니라 고민하며 해나가는 거라면 말이다.
일단 오늘은 내가 객체지향개발방법론을 안 배웠기 때문에 공부해야 할 영역이 꽤 있었다. 아래는 진행한 실습의 일부다.
lib/classes_intermediate/practice09.dartclass Employee { final String name; final double baseSalary; Employee(this.name, this.baseSalary); double getSalary() => baseSalary; } class Manager extends Employee { int teamSize; Manager(super.name, super.baseSalary, this.teamSize); double getSalary() => baseSalary + 100 * teamSize; } class Director extends Manager { double stockOption; Director(super.name, super.baseSalary, super.teamSize, this.stockOption); double getSalary() => super.getSalary() + stockOption; } void answer() { final e = Employee('철수', 3000); final m = Manager('영희', 4000, 5); final d = Director('길동', 5000, 10, 2000); print(e.getSalary()); print(m.getSalary()); print(d.getSalary()); }
lib/classes_intermediate/practice10.dartclass Notifier { void notify(String msg) => print('Sending: $msg'); } class SlackNotifier extends Notifier { String channel; SlackNotifier(this.channel); void notify(String msg) => print('Slack[$channel]: $msg'); } class BufferedNotifier extends Notifier { final List<String> _msgList = []; void notify(String msg) => _msgList.add(msg); void flush() { for (String msg in _msgList) { print(msg); } _msgList.clear(); } } void answer() { final s = SlackNotifier('general'); s.notify('Hello!'); final b = BufferedNotifier(); b.notify('msg1'); b.notify('msg2'); b.flush(); print('---flushed---'); b.notify('msg3'); b.notify('msg4'); b.flush(); }
bin/dart_practice.dartimport 'package:dart_practice/classes_intermediate/practice09.dart' as classes_intermediate_practice09; import 'package:dart_practice/classes_intermediate/practice10.dart' as classes_intermediate_practice10; void main(List<String> arguments) { print('[>>> practice 09]'); classes_intermediate_practice09.answer(); print('[>>> practice 10]'); classes_intermediate_practice10.answer(); }
[>>> practice 09] 3000.0 4500.0 8000.0 [>>> practice 10] Slack[general]: Hello! msg1 msg2 ---flushed--- msg3 msg4
extends 라던가 @override 같은 걸 사용하는 건 문제 없는데 이론적인 background가 부족한 것 같다. 당장 개발 실력 향상에 직접적인 도움이 되는 건 아니지만 알아두면 유의미한 기반 지식이 되는 것들. 수업이랑 별개로 학습해 볼 필요가 있겠다. Rust / FastAPI(Python) / Svelte(TypeScript) 에서 알 수 있듯이 컴퓨터공학을 전공하는 동안 주류 개발보다는 내가 생각하는 이상적인 것들을 주로 건드려 왔으니 이제 주류 개발을 따라잡아야지.