TIL - day47

정상화·2023년 4월 27일
0

TIL

목록 보기
36/46
post-thumbnail

알고리즘

프로그래머스-기능개발

문제 접근

예시입력

progressesspeedsreturn
[93, 30, 55][1, 30, 5][2, 1]

각 작업도의 남은 날짜를 담은 큐 remainDays를 선언

남은 날 = (100.0 - progress[i])/speed[i]

remainDays = [9,3,7]

과정

  1. remainDays에서 7을 뺀다
  2. 3이 7보다 작으므로 두번째 작업도 배포해야한다. 3도 뺀다.
  3. 9는 7보다 크므로 첫번째 배포에는 2개작업이 포함된다. 9는 7일이 지나 2가 되었다. remainDays = [2]
  4. remainDays에서 2를 뺀다.
  5. 하나의 작업물을 배포했으므로 결과에 1을 추가

일반화

  1. 큐를 pop, pop된 값은 1번 작업이 완성되는데 걸린 날짜
  2. 큐의 front가 걸린 날짜보다 작을때까지 pop
  3. pop한 개수를 결과값에 push
  4. 경과시간 업데이트
  5. 큐에 값이 존재하는 동안 1~3반복


SQL 매퍼 구현

트랜잭션 처리

구현해야할 것

  • 트랜잭션 시작
  • 롤백
  • 커밋

테스트코드

@Test
    void 트랜잭션_롤백_테스트() {
        simpleDb.startTransaction();
        simpleDb.run("""
                INSERT INTO article
                    SET createdDate = NOW(),
                    modifiedDate = NOW(),
                    title = "dummyArticle",
                    `body` = "dummyContent",
                    isBlind = 1
                """);
        simpleDb.rollback();
        Sql sql = simpleDb.genSql();
        Long count = sql.append("select count(*)")
                .append("from article")
                .selectLong();

        assertThat(count).isEqualTo(6);
    }

    @Test
    void 트랜잭션_중_예외발생() {
        try {
            simpleDb.startTransaction();
            simpleDb.run("""
                    INSERT INTO article
                        SET createdDate = NOW(),
                        modifiedDate = NOW(),
                        title = "dummyArticle1",
                        `body` = "dummyContent",
                        isBlind = 1
                    """);
            simpleDb.run("""
                    STATEMENT_ERROR
                    """);
            simpleDb.run("""
                    INSERT INTO article
                        SET createdDate = NOW(),
                        modifiedDate = NOW(),
                        title = "dummyArticle3",
                        `body` = "dummyContent",
                        isBlind = 1
                    """);
        } catch (RuntimeException e) {
        }

        Sql sql = simpleDb.genSql();
        Long count = sql.append("select count(*)")
                .append("from article")
                .selectLong();
        assertThat(count).isEqualTo(6);
    }

    @Test
    void 트랜잭션_커밋() {
        simpleDb.startTransaction();
        simpleDb.run("""
                INSERT INTO article
                    SET createdDate = NOW(),
                    modifiedDate = NOW(),
                    title = "dummyArticle1",
                    `body` = "dummyContent",
                    isBlind = 1
                """);
        simpleDb.commit();

        Sql sql = simpleDb.genSql();
        Long count = sql.append("select count(*)")
                .append("from article")
                .selectLong();
        assertThat(count).isEqualTo(7);
    }

접근

기존 테스트코드에서는 simpleDb.run()가 트랜잭션 시작을 명시하지 않고도 쿼리를 커밋했다.

새로운 테스트코드에서는 트랜잭션이 시작하고 중간에 예외가 발생하면 롤백이 되어야 한다.

케이스분류

  • 트랜잭션시작을 명시: 자동 커밋이 꺼져야 한다.
  • 트랜잭션시작없이 바로 run: 자동 커밋이 돼야 한다.
class SimpleDb{
//...
public <T> T run(String query, Object... parameter) {
        String queryType = Query.getQueryType(query);
        try {
            if (conn == null) { // 이미 트랜잭션이 시작했는지 확인
                conn = DriverManager.getConnection(url, username, password);
            }
            ps = prepareStatement(queryType, query, conn, parameter);

            logQuery(ps);

            return Query.execute(ps, queryType);
        } catch (SQLException e) {
            rollback();
            throw new RuntimeException(e);
        }finally {
            close(ps);
        }
    }


//...

public void startTransaction() {
        try {
            conn = DriverManager.getConnection(url, username, password);
            conn.setAutoCommit(false); // disable auto commit
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
    //...
}

문제점

run()에서 커넥션을 명시적으로 닫아주지 못한다.

finally구문에서 conn.close()를 해버린다면 트랜잭션 커밋 테스트가 실패하게 된다.
왜냐하면 run()이 하나의 쿼리문을 실행하고 항상 커넥션을 닫아버리기 때문에 이후의 commit()에서 커넥션이 닫혀이있어서 commit이 안된다.

해결

chatGPT에게 해결책을 물어보자 startTransaction을 호출했음을 저장하는 플래그 inTransaction을 사용하라고 했다.

즉 플래그가 true이면 run외부에서 트랜잭션 시작을 명시했으므로 커넥션을 닫으면 안된다. 반대로 플래그가 false이면 트랜잭션 시작 없이 바로 run을 호출했으므로 run이 스스로 커넥션을 닫아야 한다.

public class SimpleDb {
    private String url;
    private final String username;
    private final String password;
    private boolean devMode = true;

    private boolean inTransaction = false;
    
    //..

    public <T> T run(String query, Object... parameter) {
        String queryType = Query.getQueryType(query);
        try {
            if (conn == null) {
                conn = DriverManager.getConnection(url, username, password);
            }
            ps = prepareStatement(queryType, query, conn, parameter);

            logQuery(ps);

            return Query.execute(ps, queryType);
        } catch (SQLException e) {
            rollback();
            throw new RuntimeException(e);
        }finally {
            close(ps);
            
			/* 플래그가 false (외부에서 startTransaction을 호출하지 않고 바로 run())
            이면 커넥션을 직접닫는다.*/
            if (!inTransaction) { 
                close(conn);
            }
        }
    }

    //...

    private void closeConnection() {
        try {
            conn.close();
            conn = null;
        } catch (SQLException e) {
            e.printStackTrace();
        }finally {
            inTransaction = false; // 커넥션close 시 플래그를 false로
        }
    }
    
    //...
}

스레드 안전&커넥션 풀

현재 simpleDb는 멀티스레드를 고려하지 않은 상태. 멀티스레드에서 동작할 수 있게하고 커넥션풀을 이용하도록 변경할 것이다

멀티스레드 테스트코드

@Test
    void 멀티스레드_테스트() throws ExecutionException, InterruptedException {
        // Thread pool 생성
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        // 결과를 담을 List
        List<Future<Article>> futures = new ArrayList<>();

        for (int i = 0; i < 100; i++) {
            // Thread pool에 작업 제출. Callable은 작업 결과를 반환할 수 있습니다.
            int finalI = i;
            futures.add(executorService.submit(() -> {
                // 여기에서 SimpleDb를 이용한 작업을 수행.
                // 예) simpleDb.run(/* query */, /* parameters */);
                simpleDb.genSql()
                        .append("INSERT INTO article")
                        .append("SET createdDate = NOW(),")
                        .append("modifiedDate = NOW(),")
                        .append("title = ?,", "dummyArticle%d".formatted(finalI))
                        .append("`body` = ?,", "dummyContent")
                        .append("isBlind = 1")
                        .insert();

                Article article = simpleDb.genSql()
                        .append("SELECT *")
                        .append("FROM article")
                        .append(" WHERE title = ?", "dummyArticle%d".formatted(finalI))
                        .selectRow(Article.class);
                // 결과가 기대한 값인지 확인하고 반환.
                return article /* 결과 확인 */;
            }));
        }

        List<Article> articles = new ArrayList<>();
        // 모든 작업이 완료될 때까지 대기.
        for (Future<Article> future : futures) {
            // get()은 작업이 완료될 때까지 대기하고, 작업 결과를 반환합니다.
            // 여기서는 Callable에서 반환한 값이 됩니다.
            articles.add(future.get());
        }

        Long count = simpleDb.genSql()
                .append("SELECT COUNT(*)")
                .append("FROM article")
                .selectLong();
        assertThat(count).isEqualTo(106);

        List<Article> articlesInDb = simpleDb.genSql()
                .append("SELECT *")
                .append("FROM article")
                .selectRows(Article.class);
        assertThat(articlesInDb).contains(articles.toArray(Article[]::new));

        // Thread pool 종료.
                        executorService.shutdown();
    }

100개의 스레드에서 article 객체를 생성하고 총개수&생성된 객체의 유무를 검증하는 테스트 코드이다.

+) 멀티스레드관련 테스트는 기존 테스트코드에서 분리하였다.

state

simpleDb는 state로 가득하다. url, username, password는 모든 스레드에서 공통적으로 사용할 거고 후에 변경할 일도 없으므로 고려대상이 아니다.

커넥션은 커넥션풀에 담아놓고 공유할 거기 때문에 이 또한 고려대상이 아니다.

devMode, inTransaction, preparedStatement 은 스레드마다 독립적으로 존재해야하는 필드이다.

자바의 ThreadLocal<T> 은 변수를 스레드마다 독립적인 메모리에 할당해준다.
앞의 세 변수들은 여기에 담으면 될 것 같다.

커넥션풀은 모든 스레드들이 공유해야한다. 한 스레드가 커넥션 풀에 접근하면 다른 스레드는 접근해선 안된다.
자바의 synchrnoized 키워드는 메서드의 lock/unlock기능을 제공해준다.

어떤 스레드A 가 synchronized키워드가 있는 메서드를 실행 중이라면 다른 스레드 B가 이 메서드를 사용하고 싶으면 스레드A가 메서드 실행을 완료할 때까지 기다려야한다.

동시성을 고려한 SimpleDb

public class SimpleDb {
    private final String url;
    private final String username;
    private final String password;
    // 동시성 구현을 위한 필드
    private final Queue<Connection> connectionPool;
    private final ThreadLocal<Connection> currentConnection;
    private final ThreadLocal<PreparedStatement> currentStatement;
    private final ThreadLocal<Boolean> inTransaction;
    private final ThreadLocal<Boolean> devMode;
    private final int MAX_POOL_SIZE = 10;


    public SimpleDb(String host, String username, String password, String dbName) {
        connectionPool = new ConcurrentLinkedDeque<>();
        currentConnection = new ThreadLocal<>();
        currentStatement = new ThreadLocal<>();
        inTransaction = new ThreadLocal<>();
        devMode = new ThreadLocal<>();
        devMode.set(true);

        this.url = "jdbc:mysql://%s:3306/%s?serverTimezone=Asia/Seoul&useSSL=false".formatted(host, dbName);
        this.username = username;
        this.password = password;

        if (devMode.get()) {
            truncate(host, username, password);
        }

        initializeConnectionPool();
    }

    private void initializeConnectionPool() {
        while (!checkIfConnectionPoolIsFull()) {
            connectionPool.add(createNewConnectionForPool());
        }
    }

    private synchronized boolean checkIfConnectionPoolIsFull() {
        if (connectionPool.size() < MAX_POOL_SIZE) {
            return false;
        }
        return true;
    }

    private Connection createNewConnectionForPool() {
        Connection connection;
        try {
            connection = DriverManager.getConnection(url, username, password);
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
        return connection;
    }

    public synchronized Connection getConnectionFromPool() {
        Connection conn = connectionPool.poll();
        currentConnection.set(conn);
        return conn;
    }

    public synchronized void returnConnectionToPool() {
        Connection conn = currentConnection.get();
        if (conn != null) {
            connectionPool.add(conn);
            currentConnection.remove();
        }
    }

    public void setCurrentStatement(PreparedStatement ps) {
        currentStatement.set(ps);
    }

    public void removeCurrentStatement() {
        currentStatement.remove();
    }

    public boolean isInTransaction() {
        Boolean result = inTransaction.get();
        return result != null && result;
    }

    public boolean isDevMode() {
        Boolean result = devMode.get();
        return result != null && result;
    }

    public void setInTransaction(boolean inTransaction) {
        this.inTransaction.set(inTransaction);
    }

    public void removeInTransaction() {
        inTransaction.remove();
    }

    private void truncate(String host, String username, String password) {
        String initConnectionUrl = "jdbc:mysql://%s:3306?serverTimezone=Asia/Seoul&useSSL=false".formatted(host);

        try (Connection connection = DriverManager.getConnection(initConnectionUrl, username, password)) {
            executeSqlScript(connection, "/db/init.sql");
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    private static void executeSqlScript(Connection connection, String filePath) {
        String absolutePath = Paths.get("").toAbsolutePath() + filePath;
        try (BufferedReader reader = new BufferedReader(new FileReader(absolutePath))) {
            String line;
            StringBuilder sqlCommand = new StringBuilder();
            while ((line = reader.readLine()) != null) {
                line = line.trim();
                sqlCommand.append(line);

                if (line.endsWith(";")) {
                    executeUpdate(connection, sqlCommand.toString());
                    sqlCommand.setLength(0);
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private static void executeUpdate(Connection connection, String sqlCommand) {
        try (Statement statement = connection.createStatement()) {
            statement.executeUpdate(sqlCommand);
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    public void setDevMode(boolean devMode) {
        this.devMode.set(devMode);
    }

    public <T> T run(String query, Object... parameter) {
        String queryType = Query.getQueryType(query);
        try {
            if (currentConnection.get() == null) {
                Connection connectionFromPool = getConnectionFromPool();
                currentConnection.set(connectionFromPool);
            }
            setCurrentStatement(prepareStatement(queryType, query, currentConnection.get(), parameter));

            logQuery(currentStatement.get());

            return Query.execute(currentStatement.get(), queryType);
        } catch (SQLException e) {
            e.printStackTrace();
            rollback();
            throw new RuntimeException(e);
        } finally {
            closePreparedStatement();

            if (!isInTransaction()) {
                returnConnectionToPool();
                removeInTransaction();
            }
        }
    }

    private void closePreparedStatement() {
        try {
            currentStatement.get().close();
            removeCurrentStatement();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    private PreparedStatement prepareStatement(String queryType, String query, Connection conn, Object... parameter) throws SQLException {
        PreparedStatement ps;
        if (queryType.equals("INSERT")) {
            ps = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS);
        } else {
            ps = conn.prepareStatement(query);
        }
        bindParameter(ps, parameter);
        return ps;
    }

    private void bindParameter(PreparedStatement ps, Object[] parameters) throws SQLException {
        for (int i = 0; i < parameters.length; i++) {
            ps.setObject(i + 1, parameters[i]);
        }
    }

    private void logQuery(PreparedStatement ps) {
        if (isDevMode()) {
            System.out.println("== rawSql ==");
            System.out.println(ps.toString().split(": ")[1]);
            System.out.println();
        }
    }

    public Sql genSql() {
        return new Sql(this);
    }

    public void startTransaction() {
        try {
            setInTransaction(true);
            currentConnection.set(getConnectionFromPool());
            currentConnection.get().setAutoCommit(false); // disable auto commit
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    public void commit() {
        if (currentConnection.get() != null) {
            try {
                currentConnection.get().commit();
            } catch (SQLException e) {
                throw new RuntimeException(e);
            } finally {
                returnConnectionToPool();
            }
        }
    }

    public void rollback() {
        if (currentConnection.get() != null) {
            try {
                currentConnection.get().rollback();
            } catch (SQLException e) {
                throw new RuntimeException(e);
            } finally {
                returnConnectionToPool();
            }
        }
    }
}

기존의 로직을 그대로 두고 커넥션종료는 커넥션풀로 반환, 멤버변수를 ThreadLocal화하였다.

profile
백엔드 희망

0개의 댓글