느낀점

시작은 수월했으나 좌절을 여러 번 느끼게 했던 미션이었다.

주드와 호흡이 잘 맞아서 구현을 막힘 없이 진행할 수 있었고 2번째로 pr을 빠르게 제출하였다.

실제 도메인과 비슷하게 구현하려고 칸을 만들고 이를 이중 리스트로 만들어 보드를 구현하였다.

칸에는 말이 존재하고, 말은 위치를 모른다.

정보전문가 패턴에 따라서 책임을 부여하였고 책임 분리는 실제로 잘 되었다.

그러나 먼저 실제 도메인을 흉내 내고 책임을 부여하다 보니 객체들의 협력이 느슨하거나 부자연스러운 객체들의 협력이 완성되었다.

이러한 부자연스러운 객체들의 협력은 코드를 이해하기 어렵게 만들었고, 캡슐화가 깨지는 부분도 생겼다.

결국 모든 기능을 구현해 놓고 객체의 협력을 다시 설계하여 리팩토링을 진행했다.

대공사였기 때문에 하기 싫었지만 테스트를 배제하고 리팩터링 하여 생각보다 빠르게 변경할 수 있었다.

리팩토링을 완성하고 기도를 하면서 애플리케이션을 돌렸는데 잘 돌아가서 한 숨 놓을 수 있었다.

이 방법은 고민이 필요할 것 같다. 이번에는 잘 돌아갔으나 여기저기서 터졌다면 매우 힘들었을 것이다....

레벨 1이 끝났음에도 객체를 설계하는 능력은 아직 많이 부족한 것 같다.

앞으로도 객체 지향 공부를 놓지 말자.


배운 점

배치 쿼리

쿼리를 반복적으로 수행해야 한다면 어떻게 해결할 수 있을까?

다음 코드를 보자.

@Override  
public void saveGame(ChessGame chessGame) {  

    final Board board = chessGame.board();  

    for (File file : File.values()) {  
        for (Rank rank : Rank.values()) {  

            final var query = "INSERT INTO chessGame VALUES(?, ?, ?, ?, ?)";  

            Position position = new Position(file, rank);  
            Square square = board.getSquare(position);  
            Piece piece = square.getPiece();  
            Color color = piece.getColor();  
            Kind kind = piece.getKind();  
            Class<?> pieceClass = piece.getClass();  

            try (final var connection = Loader.getConnection();  
                    final var preparedStatement = connection.prepareStatement(query)) {  
                preparedStatement.setString(1, file.name());  
                preparedStatement.setString(2, rank.name());  
                preparedStatement.setString(3, color.name());  
                preparedStatement.setString(4, kind.name());  
                preparedStatement.setString(5, pieceClass.getName());

                preparedStatement.executeUpdate();  
            } catch (final SQLException e) {  
                throw new RuntimeException(e);  
            }  
        }  
    }  
}

위 코드는 체스 보드의 File과 Rank의 모든 Piece를 저장하는 메서드이다.
따라서 8 x 8 = 64번의 insert 쿼리를 실행하게 된다.
쿼리 실행은 네트워크를 타고 서버까지 갔다 와야 하기 때문에 시간이 매우 오래 걸린다.
이를 64번 수행하면 성능이 매우 안 좋을 것이다.
이를 Batch를 통해 해결할 수 있다.

@Override  
public void saveGame(ChessGame chessGame) {  

    final var query = "INSERT INTO chessGame VALUES(?, ?, ?, ?, ?)";  

    try (final var connection = Loader.getConnection();  
         final var preparedStatement = connection.prepareStatement(query)) {  

        final Board board = chessGame.board();  

        for (File file : File.values()) {  
            for (Rank rank : Rank.values()) {  

                Position position = new Position(file, rank);  
                Square square = board.getSquare(position);  
                Piece piece = square.getPiece();  
                Color color = piece.getColor();  
                Kind kind = piece.getKind();  
                Class<?> pieceClass = piece.getClass();  

                preparedStatement.setString(1, file.name());  
                preparedStatement.setString(2, rank.name());  
                preparedStatement.setString(3, color.name());  
                preparedStatement.setString(4, kind.name());  
                preparedStatement.setString(5, pieceClass.getName());  

                preparedStatement.addBatch();  
            }  
        }  

        preparedStatement.executeBatch();  
    } catch (final SQLException e) {  
        throw new RuntimeException(e);  
    }  
}

preparedStatement.addBatch() 를 통해 한 번의 쿼리를 배치에 저장하고, 모든 배치를 다 저장하면 preparedStatement.executeBatch() 로 배치를 실행한다. 이렇게 하면 네트워크를 한 번만 이동할 수 있다.

취약한 기반 클래스

이번 미션에서 자식클래스가 구현된 부모 클래스의 메서드를 오버라이딩 하여 발생했다.

부모클래스의 변경이 자식클래스에 영향을 미치면 안 된다.

Optional 활용

Entity는 테이블의 열과 직접적으로 매핑되는 클래스이다.
다음 클래스를 보자.

public class ChessGameEntity {  

    private final Long gameId;  
    private final String userName;  
    private final String gameTurn;

    private static ChessGameEntity of(String userName, String gameTurn) {
        this.gameId = null;
        ...
    }
}

userName과 gameTurn에 해당하는 데이터를 테이블에 삽입할 때 쿼리문은 다음과 같을 것이다.

INSERT INTO chess_game('user_name', 'game_turn') VALUES(?, ?)

이때, gameId는 필요하지 않다.
따라서 gameId를 null로 초기화해 주거나, 생성자에서 기본 값으로 초기화해주거나, final 키워드를 제거하여 생성 시 초기화를 하지 않아도 되도록 하는 방법 중에 선택해야 한다.

기본값(-1과 같은)을 사용하는 것과 null을 사용하는 것, final 키워드를 제거하기

  • 기본값 : 처리하는 쪽에서 해당 값에 대해서 알아야 처리할 수 있어 복잡해진다. 잘못된 값임에도 DB에는 정상적인 값으로 인식되어 잘못된 데이터가 저장되고, 문제가 바로 발견되지 않아 나중에 더 큰 문제로 돌아올 가능성이 있음.
  • null : null을 반환하면 사용자가 null에 대한 검사를 반드시 해야 하고, 이는 프로그램을 불안정하게 만들 수 있다.
  • final 제거 : setter와 같은 재할당을 허용하여 불변을 보장할 수 없다.

Optional을 반환하도록 한다면 null 검사를 강제할 수 있다.

public Optional<Long> getGameId() {  
    return Optional.ofNullable(gameId);  
}

Optional을 필드에도 사용해 보고, 매개변수로 사용해 본 적이 있다. IDE에서 경고를 띄워주기도 하고 가독성도 떨어진다고 생각하여 Optional의 필요성을 느끼지 못했다. 이번 경험을 통해 null일 수 있는 값을 반환할 때 Optional로 래핑 해서 반환하여 사용한다는 것을 배웠다.

추가로 다음과 같은 피드백도 받았다.

어플리케이션의 관점에서 null을 사용하지 않는 개념은 좋은 방향이라고 생각해요. 하지만 외부 시스템과의 연계를 생각하게 된다면, null이라는 개념이 필요한 케이스도 있습니다.
예를들면, jpa를 사용하기 위해 null을 사용하지 않는 코틀린에서 null을 허용하는 케이스도 있어요

null을 무조건 사용하고 싶지 않았으나 무조건 안 쓰기보다는 상황에 따라 필요한 경우도 있기 때문에 상황을 잘 판단하도록 하자.

var의 장점

movablePathSet<Position> 에서 MovablePositions 일급 컬렉션으로 래핑 하는 수정이 있었습니다.
이때 var를 사용했기 때문에 변경할 필요가 없었습니다.

즉, 타입에 종속되지 않아서 리팩토링을 수행하더라도 로직이 변경되지 않는 한 변경이 적다는 장점이 있었습니다.

타입의 이름이 길더라도 이를 줄여주는 장점도 있는 것 같습니다.

DIP 위반과 Service의 재사용

체스게임의 컨트롤러 로직을 Command 패턴을 통해 구현했다.

그중에서 각 커맨드에서 필요한 서비스를 직접 생성해서 사용했다.

public class StartCommand extends ActionCommand {
    @Override

public void execute(final String input) {
    final var loadService = new LoadService();
    ...
}

이렇게 되면 다른 Command에서 LoadService가 필요하면 새로운 LoadService를 인스턴스화해서 사용할 것이다. 즉, Service 를 재사용하지 않고 필요할 때마다 새로 생성한다는 것이 문제다.
또한, LoadService의 구체적인 구현에 의존하고 있기 때문에 DIP를 위반하고 있다. 이는 변경에도 취약하고 테스트도 어렵게 만든다. LoadService 를 추상화하여 Loadable 같은 인터페이스를 주입받도록 한다면 결합도를 낮춰 Service가 바뀌더라도 코드 변경은 없거나 매우 적을 것이다. 또한, Loadable 을 구현하는 fake를 만들어 테스트하거나 스텁을 통해 테스트를 하기 훨씬 수월할 것이다.

Gradle의 종속성 선언

  • api : 종속된 라이브러리까지 가져온다. 종속된 라이브러리를 모두 빌드하기 때문에 크기가 커지고 시간이 오래 걸릴 수 있다. 사용하려면 plugins의 id를 java-library로 선언해야 한다.
  • implementation : 선언한 라이브러리만 빌드한다.
  • compile : 의존성을 컴파일클래스패스와 런타임클래스패스에 선언하기 때문에 충돌 등의 문제로 gradle 5.0부터 deprecated 됨
  • compileOnly : 컴파일타임에만 사용하는 의존성을 선언 ex) lombok
  • runtimeOnly : 런타임에만 사용하는 의존성을 선언 ex) JDBC

라이브러리를 만드는 것이 아니라면 api 보다는 implementation을 사용한다

DTO Assembler

처음에는 DAO에서 데이터를 꺼내고, 도메인 객체로 조립하여 바로 컨트롤러로 전달하도록 했다. 이는 DAO의 책임이 너무 크다고 생각해 처음으로 Entitiy, Repository, Service의 개념을 도입하여 책임을 분리하였다.
DB에서 Board 정보를 꺼내올 때 구조는 다음과 같다.

DAO가 DB의 정보를 테이블과 직접적으로 매핑되는 Entity에 담아서 Repository로 전달한다.
Repository는 매핑 클래스의 도움을 받아 Entitiy를 도메인 객체인 Board 로 매핑하고, 다시 DTO 매핑 클래스인 DTOAssembler 를 통해 DTO로 매핑한다. 이 DTO를 Service로 전달하고, 다른 컨트롤러나 이를 사용하는 Service에서 DTO를 다시 도메인 객체인 Board 로 조립하여 사용한다. 이렇게 하면 중간에 Board의 상태를 변경시키지 못하고 데이터를 안전하게 전송할 수 있다.

자연키와 대체키

board 테이블은 체스판에서 위치와 위치에 해당하는 piece의 정보를 담고 있다.

하나의 체스판에서 같은 위치두 개 이상의 piece가 존재할 수 없기 때문에 다음과 같이 복합키를 사용했다.

PRIMARY KEY (game_id,piece_file,piece_rank)

이에 대해서 다음과 같은 피드백을 받았다.

복합키를 기본키로 쓰는 것과, auto increment와 같은 것을 기본키로 쓸 때의 장단점을 알아볼까요?

복합키를 기본키로 쓰게 됐을 때 단점은 다음과 같습니다. (자연키)

  • 변경에 취약하다
    의미 있는 값으로 기본키를 설정했기 때문에 나중에 요구사항이 변경되면 테이블 설계를 다시 해야 한다.
  • 자동으로 인덱스를 생성해주지 않아 전체 테이블을 검색해야 하므로 성능이 떨어진다. 인덱스를 작성해 주더라도 열의 수가 많아지면 인덱스를 관리하는데 비용이 커진다.

auto increment와 같은 것 (대체키)

  • 요구사항이 변경되어도 변경될 일이 적다.

그렇다면 대체키를 사용하고 필요하다면 유니크 인덱스를 걸어주는 게 나을 것 같습니다.
하지만 서비스 환경에 따라 성능이 중요하다면 굳이 DB 쪽에서 중복을 처리해 주기보다 애플리케이션을 믿고 처리를 해주지 않는 것도 괜찮다는 생각이 드네요.

'회고 > 우아한테크코스' 카테고리의 다른 글

2023.04.12 일일 회고  (0) 2023.04.13
2023.04.11 일일 회고  (0) 2023.04.12
[레벨 1] 블랙잭 미션 회고  (0) 2023.03.30
[레벨 1] 레벨 로그  (0) 2023.03.28
2023.03.27 일일 회고  (0) 2023.03.28

+ Recent posts