-
[우아한 테크코스 7기] 프리코스 2주차 리뷰 언덕에서 배운 것 ✏️회고 2024. 10. 31. 10:27
가고 있다는 사실만으로도 어떤 시간은
반으로 접힌다
펼쳐보면 다른 풍경이 되어 있다
- <여름 언덕에서 배운 것> 중에서내가 좋아하는 안희연 작가의 시가 떠오르는 한 주였다. 과제를 제출하고 나니 뭘 배웠지 하는 허탈함이 몰려왔다.
나름대로 내 최선을 다해봤는데, 내 손에 뭐가 남았는지 잘 모르겠다는 생각을 해 2주차 끝나던 날은 썩 유쾌하지 않은 상태로 잠에 들었다.
그러나 리뷰를 하다 보니 내가 뭘 알게 됐고, 뭘 몰랐고, 어떤 부분에 부정확한 지식을 갖고 있는지 돌아보게 됐다.
이번 주차에는 리뷰를 할 때 최대한 성심 성의껏 리뷰하기 위해 노력했다. 리뷰를 받기 위한 리뷰를 하지 않기 위해 더 노력했다.
이를 위해 작은 부분이라도 한 명 한 명마다 배울 점이 있는지 정리해 쭉 나열했다. 이를 이제는 차근차근 정리해 내 것으로 흡수해보려고 한다.
1. 전략 패턴
저번 주차 내 코드에서 가장 아쉬운 부분을 꼽자면 이 부분이다.
public class Game { public void playNextRound() { for (int index = 0; index < cars.getSize(); index++) { int number = numberPicker.pickNumberInRange(START_INCLUSIVE, END_INCLUSIVE); if (number >= MOVEMENT_CRITERIA) { cars.move(index); } } currentRound++; } }
게임에서 자동차들의 사이즈를 getter 로 가져와서 자동차의 사이즈만큼 게임에서 반복해야 하는 것이 싫었다.
캡슐화를 저해하고, 결합도를 높인다는 생각을 했다. 그러나, 랜덤한 숫자를 뽑는 것은 게임에서 해야 하는 역할이라고 생각해 달리 방법을 찾지 못하고 제출했다. 이 부분에 집중해 다른 이들의 코드를 보려고 노력했다.
전략 패턴은, 객체의 행위를 런타임에 바꾸고 싶은 경우 직접 행위를 수정하지 않고 전략을 바꿔줘서 행위를 유연하게 확장하는 방법이라고 한다. 추상화된 전략을 인터페이스로 선언하고, 이에 대한 구현체들을 생성하면 된다.
즉, 객체가 할 수 있는 행위들(자동차가 움직인다)을 전략으로 선언하고, 동적으로 수정이 필요한 경우 전략을 바꿔 끼우는 것이다.
나의 코드로 예를 들면, 랜덤으로 움직이는 전략 혹은 이진수 중에 선택하는 전략을 동적으로 선택할 수 있도록 하는 것이겠다!
굳이 따지자면 네이밍을 Strategy 가 관련된 것이지, 내가 숫자를 뽑는 행위를 분리하고 이에 대한 구현체를 정의한 것과 거의 유사하다는 생각을 했다.
public interface MovementStrategy { boolean shouldMove(); } public class RandomMovementStrategy implements { public boolean shouldMove(){ return Randoms.pickNumberInRange(0,9) >= 4; } } public class Game { public void playNextRound() { cars.move(); currentRound++; } } public class Cars { public void move(){ for (Car car : cars) { if(randomMovementStrategy.shouldMove()){ car.move(); } } } }
만약 내 코드를 수정하려면 위와 같겠다. 이렇게 훨씬 더 좋은 코드가 될 수 있었을 것 같은데.. 아쉽다!
나의 경우에는, 숫자를 뽑는 것까지만 외부에서 해주는 역할이었다. 그러나 움직여야하는지의 여부까지 직접 판단해서 반환했다면 Cars 내부에 주입하는 것이 이상하게 느껴지지 않았을 것 같다.
사실상, 기존에 구현해오던 방식과 유사하지만 무형의 그 습관에 이름을 붙일 수 있게 되었구나! 싶었다.
2. Handler 를 사용해보자
리뷰 과정에서 한 분께서 Handler 를 사용하신 것을 알 수 있었다. 이는 컨트롤러 - 뷰 사이에 하나 더 레이어가 있는 느낌이었다.
기존에 내가 느꼈던 컨트롤러에서의 애매함은
1. 전체적인 흐름을 관장하는 데에 사용자의 입력값을 검증해야 하는 것이 방해가 된다.
2. 뷰에 필요한 데이터들을 일정한 형태로 변형하는 것이 애매하다.
이에 대한 방법은 딱히 없겠지- 싶어 포기하고 있었다. 그러나 핸들러를 사용하신 분을 보고 무릎을 탁 쳤다!
다음 주차부터는 Input / Ouput 에 Handler 를 사용해봐야겠다.
3. Config 와 팩토리 메서드 패턴
나는 이번 주차에 팩토리 메서드 패턴을 이용해 기능을 구현했다.
public class GameControllerFactory { public static GameController create() { return new GameController(DefaultGameInputValidator.getInstance()); } }
나도 제출하기 이전에 고민도 했던 부분이고, 지적을 받기도 했다. 팩토리 메서드 패턴을 구현해 두고서는 구현체에 의존했기 때문에 결국 유연성을 가져갈 수 없다는 것이었다.. 이에 대해서는 Application.java 에서 객체를 생성해 파라미터로 객체를 넘겨야겠다고 결정을 내리기는 했다. POJO 코드인 이상, 어디선가는 반드시 구현체를 만들기는 해야 하기 때문이다.
팩토리 메서드 패턴 자체에 대한 애매모호함을 갖고 다른 분들의 코드를 리뷰하던 와중, Config 에 대해 알게 됐다.
public class AppConfig { private final GameController gameController; public AppConfig (){ this.gameController = new GameController(DefaultGameInputValidator.getInstance()); } public GameController gameController() { return gameController; } }
만약 나의 코드를 Config 로 변경하게 되면 위와 같을 것이다.
그렇다면 팩토리 메서드 패턴과 Config 의 차이가 뭔지 의문이 들었다. 어차피 둘 다 의존성 주입을 해서 객체를 반환하는데, 뭐가 맞는걸까 싶었다.
우선, 내가 지금 이 둘을 헷갈려 하는 이유는 의존성 주입의 관점에서이다. 그렇기에 이 측면에서 한발짝 떨어져 살펴보자.
팩토리 메서드 패턴의 역할은 두가지이다.
1. 서브 클래스에게 객체 생성을 위임하는 것
2. 객체 생성 로직을 캡슐화하는 것
public interface GameController { } public class RacingGameController implements GameController{ } public interface GameControllerFactory { } // 서브 클래스 public class RacingGameControllerFactory implements GameControllerFactory { }
1번 서브 클래스에게 객체 생성을 위임하기 위해서는 위와 같이 4개의 인터페이스가 존재해야 할 것이다. 그래야 서브 클래스에 객체 생성을 위임할 수 있게 되기 때문이다. 위의 코드를 예시로 들자면, RacingGameControllerFactory 가 서브 클래스이다. 즉, GameControllerFactory 의 책임을 RacingGameControllerFactory 에 위임한 것이다. 이는 내가 구현한 팩토리와도 한참 멀어 보여 의문이 들었다..
그러나 찾아 보다 보니, 내가 구현한 것은 심플 팩토리 패턴에 가깝다는 것을 알게 됐다. 이는 단순히 정말 팩토리, 말 그대로 객체를 생성해 반환하는 것이다. 결국 팩토리 패턴의 궁극적인 목표는 '객체 생성의 캡슐화'에 있다는 것을 깨닫게 됐다.
그렇다면 Config 클래스는 어떨까? 이는 정말 말 그대로 설정 파일이다.
의존성 주입에서 멀어지기로 했으니 다른 예시를 떠올려 보자.
public class AppConfig { // 객체를 생성하는 메서드 public DatabaseConnection createDatabaseConnection() { // 설정 값을 직접 하드코딩하거나 파일에서 읽어올 수 있음 String url = "jdbc:mysql://localhost:3306/mydb"; String username = "root"; String password = "password"; return new DatabaseConnection(url, username, password); } }
Config 클래스에서는 위와 같이 말 그대로의 설정값을 지정하는 역할을 한다고 한다.
즉, 내가 깨달은 바는 Config 클래스와 팩토리 패턴은 동일 선상에 있는 것이 아니라 관점이 다른 것이다. 그러나 이에 대한 결과가 의존성에 대한 주입으로 나타났을 뿐이라고 생각했다. 다시 한번 의존성 주입 관점에서 정리하자면 팩토리 패턴은 어떤 객체들을 주입했는지 캡슐화한 것이고 Config 클래스에서는 어떤 클래스들을 주입한 것인지에 대한 설정 정보를 제공해 반환하는 것인 것이다!
4. Runnable / Predicate / Supplier
@FunctionalInterface public interface Runnable { void run(); } public class TestRunnable { public void example(Runnable test){ test.run(); } } testRunnable.example( () -> 실행하고 싶은 메서드의 내용 );
Runnable 은 작업을 실행하기 위한 인터페이스이다. 이는 내가 직접 바로 실행하고 싶은 메서드를 정의할 수 있다.
public class TestPredicate { public void test(Predicate<Temp> condition){ if(condition.test(temp)){ // do something.. } } } testPredicate.test( temp -> temp 객체의 조건 정의 );
Predicate 는 조건을 확인하는 인터페이스이다. 이는 지네릭을 이용해 타입을 지정할 수 있다.
타입을 지정하게 되면 해당 객체에 대한 조건을 검사하게 된다. 위의 람다식으로 정의한 조건이 참인지 아닌지를 test 를 통해 확인할 수 있고, 이가 참이면 if 문 역시 참이 된다. 즉, test 를 실행하면 람다식에서 정의된 메서드를 확인해 해당 조건에 부합하는지를 확인하는 것이다.
@FunctionalInterface public interface Supplier<T> { T get(); } public class Main { public static void main(String[] args) { // 복잡한 객체를 지연해서 생성하는 Supplier Supplier<Double> randomValueSupplier = () -> Math.random(); // Supplier가 호출될 때마다 새로운 값을 반환 System.out.println(randomValueSupplier.get()); // 출력: 랜덤 값 System.out.println(randomValueSupplier.get()); // 출력: 다른 랜덤 값 } }
Supplier 는 매개변수 없이 값을 제공하는 함수이다. 즉 리턴 값은 있지만 인자는 없는 함수이다.
그렇다면 Runnable 과의 차이는 무엇일까? Runnable 은 작업 단위를 정의하는 반면 Supplier 는 말 그대로 무언가를 제공하고자 동작한다. 또한, Runnable 은 '단순히 실행' 이기에 반환값이 없는 게 적절한 반면 Supplier 는 매개변수는 없으나 반환값은 반드시 있어야 한다.
이들은 기능 구현 상황에서 막막할 때는 도움이 되겠지만, 함수형 인터페이스라서 조금은 경계하며 사용해야겠다는 생각을 했다.
객체지향은 서로 간의 협력과 명령형이 핵심인데, 함수형은 선언형이기 때문이다.
4-1. 언제 쓰면 좋을까?
1. 불필요한 복잡성을 피하고 간결한 표현이 필요한 경우
주로 익명 클래스에서 사용된다.
2. 지연된 실행 또는 조건부 실행이 필요한 경우
객체를 미리 생성하지 않고 필요할 때만 생성하고 싶다면 이를 사용할 수 있다.
3. 행위를 매개변수화 해야 할 때
4. 스트림 처리 혹은 병렬 작업 시에
5. 생성자 체이닝
이번 나의 리뷰에서 주된 요소 중 하나는 '의존성 주입' 이었다. 그런데, 이 방식을 처음 보는 방식으로 해결하신 분이 있어 공부해봤다.
public class Game { private final NumberPicker numberPicker; private final Player player; public Game(Player player, NumberPicker numberPicker) { this.player = player; this.numberPicker = numberPicker; } public Game(Player player) { this(player, new RandomNumberPicker()); } }
다른 분의 코드를 가져오는 것은 실례라고 생각해, 가상의 상황을 예상해 바꿔 적용해봤다. 생성자 체이닝은 위와 같이 구현체를 생성자에서 무조건적으로 생성하는 것이 아니라 기본값 즉 파라미터로 numberPicker 에 대한 구현체가 들어오지 않는 경우에는 RandomNumberPicker 를 사용하게 되는 것이다.무조건적인 기본값이 있는 경우에는 이렇게 구현하는 것도 하나의 방법이라고 생각해 흥미로웠다!
1. 테스트에서 바꿔끼우고 싶을 때는 생성자에 NumberPicker 의 구현체를 넣고 2. 값이 변경되는 모델인 Player 만 주입하고 싶다면 파라미터로 구현체를 넘기지 않으면 된다.
이가 캡슐화나, 결합도의 측면에서는 어떤지 모르겠으나 하나의 새로운 시선이라고 생각했다. 앞으로 그 부분에 대해서는 더 고민해 가야겠다.
6. 열거형과 상수의 차이는 무엇일까
public class ExceptionMessages { public class Car { public static final String LENGTH_OF_NAME_EXCEED = "자동차의 이름은 5자 이하여야 합니다."; } public class Cars { // 생략.. } public class Game { // 생략.. } public class TotalRounds { // 생략.. } }
이번 주차에는 위와 같이 예외 메시지들을 상수화하고, 그룹화하고 싶어 중첩 클래스로 선언했다. 이렇게 되면 외부에서 호출할 때 다음과 같이 된다.
ExceptionMessages.Cars.XXXX_XXX
이는 가독성도 좋고, 분류도 잘 됐다고 생각했다. 그런데 다른 분들이 열거형을 많이 사용하시는 것을 보고 다시 생각해보니 상수 + 카테고라이징 어디서 많이 들어본 역할 아닌가 싶었다. enum 이 떠올랐다.
내가 enum 을 생각없이 사용하지 않은 것은 아니었다. 1. 나는 getter 로 한번 더 접근해 메시지를 가져오는 게 가독성이 떨어진다고 생각했고, 2. 어차피 클래스명이 ExceptionMessages 인데 필드로 또 message 를 정의하는 것이 불필요하다고 판단했다.
이와는 별개로 enum 과 상수의 차이에 대해 명확히 알아야겠다는 생각을 했다.
1. enum 은 추가 기능을 넣을 수 있다. -> 나의 경우에는 단순 메시지이기에 불필요하다.
2. enum 은 고정된 상수들의 집합, 상수는 그룹화할 필요가 없는 경우 적합 -> 중첩 클래스로 나름 그룹화를 잘 했다고 생각한다.
3. 타입 안정성 -> 예외 메시지는 어딘가에 재할당 될 이유가 없기에 고려하지 않아도 된다고 생각했다.
7. 옵저버 패턴
이번 과제에서 모두에게 가장 어려웠던 부분은 게임 결과를 중간 중간 출력해야 하는 부분이라고 생각한다. 나는 이를 위해 스냅샷이라는 모델을 만들어 저장하도록 했다. 만약 이가 프레임워크 였다면, 이벤트 드리븐 패턴을 사용하려고 했을 것이다. 그러나 순수 자바 코드에서는 어떻게 해야 할지 몰라 우회해서 스냅샷을 생각했었다.
그러던 와중, 한 분께서 옵저버 패턴을 쓰신 것을 보고, 이에 대해 궁금해졌다.
주요한 구성 요소는
1. 주제 / 관찰대상 2. 옵저버 이다.
public interface Observer { void update(String message); } public class Car { private List<Observer> observers; public void move() { // do something.. notifyObservers(); } public void notifyObservers() { for (Observer observer : observers) { observer.update(news); } } } class CarStatusObserver implements Observer { @Override public void update(String carStatus) { // 출력 } }
형태는 위와 같이 간단하다. 채팅을 구현할 때의 pub-sub 구조와 유사하다는 생각이 들었다.
8. 서비스를 사용하자
나는 이번 주차에 Controller 와 Game 이라는 객체를 이용해 전반적인 흐름을 구성했다. 사실상 Controller 에서는 흐름 중간에 출력이 필요한 경우에만 중간 중간 Game 의 반환값을 사용하도록 했다. 이렇게 되니, Game 의 역할이 너무 커졌었다.
너무 많은 인스턴스 변수를 갖고 있게 되고, 역할도 너무 많아져 이를 어떻게 해결해야 하는지 고민을 했었다. 그러던 중, 한 분께서 Game 을 서비스로 정의하는 것은 어떻냐는 제안을 주셨다 .다른 분들의 코드를 보면서 서비스는 어떤 기준으로 정의하는 것인지에 대한 궁금증이 생겼었는데, 이런 상황에서 사용하는구나 싶었다. 그래서 다음 주차 과제에서는 서비스 레이어를 도입해야겠다고 생각했다.
8-1. 그렇다면, 서비스란 무엇일까?
서비스 레이어는 비지니스 로직을 캡슐화한 계층이다. 컨트롤러와 DAO 사이에 위치해 핵심 비지니스 로직을 처리한다. 비지니스 로직을 캡슐화 해 한 곳에 모아 응집도를 높이고 유지보수성을 높일 수 있다.
이전에는 비지니스 로직이 컨트롤러에 다 노출이 됐었다. 즉, 컨트롤러는 1. 뷰와 소통해 입출력을 담당하고, 2. 입력값을 정제하고, 3. 비지니스 로직까지 담당해 관심사 분리가 전혀 되지 않았었던 것 같다. 꼭 이번에는 서비스를 도입해 봐야지!
9. 핵사고날 아키텍처의 포트-어댑터 패턴
한 분께서 해당 패턴을 이용해 설계했다고 하셔서 해당 패턴이 궁금해 찾아보게 되었다. 이는 외부 의존성(데이터베이스/API) 로부터 비지니스 로직을 분리해 유연성과 확장성을 높이기 위해 고안된 아키텍처 패턴이라고 한다.
1. 핵심 도메인 (코어)
비지니스 로직을 포함한다. 이는 독립적으로 존재한다.
2. 포트
핵심 도메인은 포트를 통해서만 외부와 소통할 수 있다. 입력 포트와 출력 포트가 존재한다.
입력 포트는 외부에서 시스템으로 들어오는 요청을 처리한다. 주로 애플리케이션 서비스를 통해 구현된다. 출력 포트는 외부로 데이터를 보내거나 통신하기 위해서 사용된다.
3. 어댑터
포트를 구현하는 구체적인 클래스이다. 이는 외부 시스템과 핵심 도메인 간의 연결을 위해 사용된다. 이도 포트와 같이 입/출력으로 나뉘어 존재한다.
+-----------------------------------------------------+ | 어댑터 (외부 의존성) | | (예: 데이터베이스, 외부 API, UI, 메시지 큐) | +-----------------------------------------------------+ ▲ ▲ ▲ | | | +------------------+---------------+------------+--------------------+ | 포트 (Input/Output) 포트 | | (인터페이스, 핵심 로직과 어댑터 간의 연결) | +-------------------+--------------+------------+--------------------+ | | | ▼ ▼ ▼ +-------------------------------------------------+ | 핵심 도메인 (비즈니스 로직) | | (외부 의존성에 영향을 받지 않는 순수 로직) | +-------------------------------------------------+
// 입력 포트 public interface OrderService { void placeOrder(Order order); } // 코어 public class OrderServiceImpl implements OrderService { } // 입력 어댑터 public class OrderController { OrderService 의 placeOrder 호출 } // 출력 포트 public interface OrderRepository { } // 출력 어댑터 public class JpaOrderRepository implements OrderRepository { }
핵사고날 아키텍처를 사용하면 핵심 도메인이 외부 의존성을 알지 못하고, 어댑터가 포트를 통해 상호작용한다. 이를 통해 의존성이 바깥에서 안쪽으로 흐르게 된다. 이를 통해 비지니스 로직을 외부로부터 보호한다. 또한 인터페이스 기반이기에 새로운 어댑터들을 비교적 쉽게 추가할 수 있게 된다.
10. 팩토리의 생성자는 private 으로 막아두자
나는 팩토리 메서드 패턴을 정적 메서드로 구현했기에 이의 생성자는 private 이 되어야 한다.
이렇게 새롭게 알게 된 내용들을 정리하니 좋다! 다음 주차에 여러 사람들에게서 배운 내용들을 정리해보고 내 것으로 더 다져봐야겠다.
이번 주차도 알차게 보내보쟝~
'회고' 카테고리의 다른 글
[우아한테크코스 7기] 백엔드 최종 합격 과정 회고 (2) 2025.01.08 [우아한 테크코스 7기] 프리코스 3주차 회고 (3) 2024.11.04 [우아한 테크코스 7기] 프리코스 2주차 다섯째, 여섯째날 회고 - 유연하게 유연하게.. 🏄🏼🌊 (1) 2024.10.28 [우아한 테크코스 7기] 프리코스 2주차 넷째날 회고 (0) 2024.10.25 [우아한 테크코스 7기] 프리코스 2주차 둘째, 셋째날 회고 (5) 2024.10.24