如何用 pheromone-network 管理智能体上下文
掘金
项目github地址https://github.com/Wrste/pheromone-network 项目相关参考文献地址https://arxiv.org/abs/2606.30669 用 ph
Score: 57.2Confidence: 54%
View offerLoading the catalog…
THE AI OPPORTUNITY INDEX
Find your next AI tool. Explore free access, trials, and credits — all in one place.
掘金
项目github地址https://github.com/Wrste/pheromone-network 项目相关参考文献地址https://arxiv.org/abs/2606.30669 用 ph
Score: 57.2Confidence: 54%
View offervelog
🌊 Delta Lake Beginner's Guide - Practical Edition (Part 1. Local Environment) post-thumbnail Delta Lake Theory - 🌊 Delta Lake Beginner's Guide - Theory Delta Lake Practical Guide Part 1 - 🌊 Delta Lake Beginner's Guide - Practical Edition (Part 1. Local Environment) Delta Lake in Practice Part 2 - 🌊 Delta Lake Beginner's Guide - Practical Edition (Part 2. Utilizing the delta-spark library) INTRO In the previous post , 🌊 Delta Lake Beginner's Guide - Theory, we covered the theoretical aspects of Delta Lake in detail. In this post, we will practice the features of Delta Lake in a Pyspark Docker Container environment. In this practice session, we use PySpark as the tool for handling data, and files of type delta are stored and managed in a local directory. hyunsoolee0506/pyspark-cloud:3.5.1For Docker containers , I recommend using an image I have created separately, considering that you will be integrating with a cloud environment for future practice . However, for this local environment practice, it is fine to proceed using Google Colab. For the practice session, delta_data.zipwe used data from two CSV files located in the file below. 👉 delta_data.zip 1️⃣ Setting up the Practice Environment ▪ 1) Create a Docker Container /workspace/sparkConfigure the directory to be used as a volume on the user's computer to be mapped to the directory inside the container . docker run -d --name pyspark -p 8888:8888 -p 4040:4040 -v [사용자 디렉토리]:/workspace/spark hyunsoolee0506/pyspark-cloud:3.5.1 After executing the command above, 8888you can access the juypter lab development environment by connecting to the port. ▪ 2) Install Library Install the libraries required for the practice. hyunsoolee0506/pyspark-cloud:3.5.1Although they are already installed in the image, for Colab, you must install them by running the code below. pip install pyspark==3.5.1 delta-spark==3.2.0 pyarrow findspark ▪ 3) Pyspark Delta Lake Environment Setup https://docs.delta.io/latest/quick-start.html#set-up-apache-spark-with-delta-lake To use Delta Lake in PySpark, configure the relevant extensions when creating a SparkSession. from delta import * from pyspark.sql import SparkSession import pyspark.sql.functions as F builder = SparkSession.builder.appName("DeltaLakeLocal") .enableHiveSupport() .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") .config("spark.sql.catalog.spark_catalog", "org.apache.spark.sql.delta.catalog.DeltaCatalog") spark = configure_spark_with_delta_pip(builder).getOrCreate() 2️⃣ Create Database and Table ▪ 1) Create Database deltalake_dbCreates a new database named. spark.sql("CREATE DATABASE IF NOT EXISTS deltalake_db") spark.sql("SHOW DATABASES").show() +------------+ | namespace| +------------+ | default| |deltalake_db| +------------+ ▪ 2) Create CSV type table trainer_data.csvtrainerCreates a table to store the file's data . 테이블 이름 : trainer 스키마 : id → INT name → STRING age → INT hometown → STRING prefer_type → STRING badge_count → INT level → STRING query = f""" CREATE TABLE IF NOT EXISTS deltalake_db.trainer ( id INT, name STRING, age INT, hometown STRING, prefer_type STRING, badge_count INT, level STRING ) USING csv OPTIONS ( path '[trainer_data.csv 파일 경로]', header 'true', inferSchema 'true', delimiter ',' ) """ spark.sql(query) 테이블 생성 확인 spark.sql("SHOW TABLES FROM deltalake_db").show() +------------+---------+-----------+ | namespace|tableName|isTemporary| +------------+---------+-----------+ |deltalake_db| trainer| false| +------------+---------+-----------+ 3️⃣ Create delta type table csvSince data from an existing file deltacannot be inserted directly into a table of the same type simultaneously with its creation, deltayou must create the table through two steps as follows. deltaCreate empty table of type deltacsvInsert table data into the table ▪ 1) Create table trainer_deltadeltaCreates a table named . /workspace/spark/deltalake/delta_local/trainer_delta/deltaWe have configured the settings so that table-related data is stored under the corresponding path . query = f""" CREATE TABLE IF NOT EXISTS deltalake_db.trainer_delta ( id INT, name STRING, age INT, hometown STRING, prefer_type STRING, badge_count INT, level STRING ) USING delta LOCATION '/workspace/spark/deltalake/delta_local/trainer_delta/' """ spark.sql(query) ▪ 2) Insert data Insert trainerthe data from the table created above into the table.trainer_delta query = """ INSERT INTO deltalake_db.trainer_delta SELECT * FROM deltalake_db.trainer; """ spark.sql(query) Once data insertion is complete, _delta_log/you can verify that a new folder and parquet file have been created in the delta table directory. 4️⃣ Reading delta type tables In PySpark, you can read tables stored in slightly different ways. ▪ 1) Basic reading in Spark LOCAL_DELTA_PATH = '/workspace/spark/deltalake/delta_local/trainer_delta' df = spark.read.format("delta").load(LOCAL_DELTA_PATH) df.show(5) +---+------+---+--------+-----------+-----------+------------+ | id| name|age|hometown|prefer_type|badge_count| level| +---+------+---+--------+-----------+-----------+------------+ | 1| Brian| 28| Seoul| Electric| 8| Master| | 3| Susan| 18| Gwangju| Rock| 7| Expert| | 6| Vicki| 17| Daejeon| Ice| 4|Intermediate| | 9|Olivia| 45| Incheon| Psychic| 3|Intermediate| | 10| Mark| 16| Gangwon| Fire| 4|Intermediate| +---+------+---+--------+-----------+-----------+------------+ only showing top 5 rows delta.Read as ▪ 2) query = f"SELECT * FROM delta. {LOCAL_DELTA_PATH} " spark.sql(query) ▪ 3) Read from Hive Catalog spark.table('deltalake_db.trainer_delta') 5️⃣ Save after editing the table This time, we will modify the table contents and overwrite the existing directory. This step is intended to prepare for future table change history inquiries and to practice Time Travel queries, a core feature of Delta Lake. ▪ 1) Save after excluding 'Beginner' Beginner 제외한 dataframe 생성 df_1 = df.filter(F.col('level') != 'Beginner') 기존 경로에 덮어쓰기 df_1.write .format('delta') .mode('overwrite') .save(LOCAL_DELTA_PATH) 데이터 확인 df = spark.read.format("delta").load(LOCAL_DELTA_PATH) df.select('level').distinct().show() +------------+ | level| +------------+ | Expert| | Advanced| | Master| |Intermediate| +------------+ ▪ 2) Save after excluding 'Advanced' Advanced 제외한 dataframe 생성 df_2 = df_1.filter(F.col('level') != 'Advanced') 기존 경로에 덮어쓰기 df_2.write .format('delta') .mode('overwrite') .save(LOCAL_DELTA_PATH) 데이터 확인 df = spark.read.format("delta").load(LOCAL_DELTA_PATH) df.select('level').distinct().show() +------------+ | level| +------------+ | Expert| | Master| |Intermediate| +------------+ You can see that as the data is overwritten, a parquet file is added, and _delta_log/metadata (files) are also added within the folder ..json 6️⃣ Change History Lookup and Time Travel Query ▪ 1) View History deltaView the change history for the table. So far, the table has a structure where a total of three write operations have occurred since the initial creation (CREATE). Therefore, the versions also exist as 0, 1, 2, and 3. VERSION 0 → trainer_deltaTable creation status, No data VERSION 1 → trainerInitial state after data is inserted into the table VERSION 2 → Saved state after excluding 'Beginner' rows VERSION 3 → Saved state after excluding 'Advanced' row query = "DESCRIBE HISTORY deltalake_db.trainer_delta" spark.sql(query).show(vertical=True, truncate=False) -RECORD 0-------------------------------------------------------------------------------------------------------------- version | 3 timestamp | 2025-03-21 02:09:39.085 userId | NULL userName | NULL operation | WRITE operationParameters | {mode -> Overwrite, partitionBy -> []} job | NULL notebook | NULL clusterId | NULL readVersion | 2 isolationLevel | Serializable isBlindAppend | false operationMetrics | {numFiles -> 1, numOutputRows -> 42, numOutputBytes -> 3125} userMetadata | NULL engineInfo | Apache-Spark/3.5.1 Delta-Lake/3.2.0 -RECORD 1-------------------------------------------------------------------------------------------------------------- version | 2 timestamp | 2025-03-21 02:08:32.646 userId | NULL userName | NULL operation | WRITE operationParameters | {mode -> Overwrite, partitionBy -> []} job | NULL notebook | NULL clusterId | NULL readVersion | 1 isolationLevel | Serializable isBlindAppend | false operationMetrics | {numFiles -> 1, numOutputRows -> 85, numOutputBytes -> 3868} userMetadata | NULL engineInfo | Apache-Spark/3.5.1 Delta-Lake/3.2.0 -RECORD 2------------------------------------------------------------------------------------------------------------- version | 1 timestamp | 2025-03-21 01:46:21.446 userId | NULL userName | NULL operation | WRITE operationParameters | {mode -> Append, partitionBy -> []} job | NULL notebook | NULL clusterId | NULL readVersion | 0 isolationLevel | Serializable isBlindAppend | true operationMetrics | {numFiles -> 1, numOutputRows -> 90, numOutputBytes -> 3980} userMetadata | NULL engineInfo | Apache-Spark/3.5.1 Delta-Lake/3.2.0 -RECORD 3-------------------------------------------------------------------------------------------------------------- version | 0 timestamp | 2025-03-21 01:43:25.471 userId | NULL userName | NULL operation | CREATE TABLE operationParameters | {partitionBy -> [], clusterBy -> [], description -> NULL, isManaged -> false, properties -> {}} job | NULL notebook | NULL clusterId | NULL readVersion | NULL isolationLevel | Serializable isBlindAppend | true operationMetrics | {} userMetadata | NULL engineInfo | Apache-Spark/3.5.1 Delta-Lake/3.2.0 ▪ 2) Time Travel - Version Whenever the table is modified, the contents of the table corresponding to a specific version are retrieved based on the assigned version number. 👉 Load initial version (version 0) table df_pre = spark.read .format("delta") .option("versionAsof", 0) .load(LOCAL_DELTA_PATH) df_pre.select('level').distinct().show() +-----+ |level| +-----+ +-----+ 👉 Load version 2 table df_pre = spark.read .format("delta") .option("versionA
Score: 54.38Confidence: 49%
View offervelog
중고차 가격 예측: Decision Tree · Random Forest 1. 라이브러리 불러오기 import pandas as pd import seaborn as sns import matplotlib.pyplot as plt from sklearn.model_selection import train_test_split from sklearn.tree import DecisionTreeRegressor from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import root_mean_squared_error 2. 데이터 불러오기 file_path = "/데이터가_있는_폴더/car_data.csv" df = pd.read_csv(file_path) df.head() 3. 데이터 전처리 # 차량 이름은 종류가 많으므로 제거 car_df = df.drop(columns="Car_Name") # 주행거리 이상치 제거 car_df = car_df[car_df["Kms_Driven"] < 100000].copy() # 연식(Year)을 차량 나이(Vehicle_Age)로 변환 car_df["Vehicle_Age"] = 2020 - car_df["Year"] car_df = car_df.drop(columns="Year") # CNG 차량 데이터 제거 car_df = car_df[car_df["Fuel_Type"] != "CNG"].copy() # 범주형 데이터를 숫자형으로 변환 car_df = pd.get_dummies( car_df, columns=["Fuel_Type", "Seller_Type", "Transmission"], drop_first=True, dtype=int ) car_df.head() 4. 입력 데이터와 정답 데이터 분리 X : 가격 예측에 사용할 입력 정보 y : 예측할 정답값인 판매 가격 X = car_df.drop(columns="Selling_Price") y = car_df["Selling_Price"] 5. 학습 데이터와 테스트 데이터 분리 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=2026 ) 6. 결정 트리 모델 tree_model = DecisionTreeRegressor( max_depth=6, min_samples_leaf=5, random_state=2026 ) tree_model.fit(X_train, y_train) tree_pred = tree_model.predict(X_test) tree_rmse = root_mean_squared_error(y_test, tree_pred) print(f"결정 트리 RMSE: {tree_rmse:.3f}") 7. 랜덤 포레스트 모델 rf_model = RandomForestRegressor( n_estimators=200, random_state=2026 ) rf_model.fit(X_train, y_train) rf_pred = rf_model.predict(X_test) rf_rmse = root_mean_squared_error(y_test, rf_pred) print(f"랜덤 포레스트 RMSE: {rf_rmse:.3f}") 8. 실제 가격과 예측 가격 비교 빨간 선에 가까울수록 실제 판매 가격과 예측 가격이 비슷하다는 의미입니다. sns.scatterplot(x=y_test, y=rf_pred) plt.plot( [y_test.min(), y_test.max()], [y_test.min(), y_test.max()], color="red" ) plt.xlabel("실제 판매 가격") plt.ylabel("예측 판매 가격") plt.show() 9. 변수 중요도 확인 랜덤 포레스트가 가격을 예측할 때 어떤 변수를 중요하게 사용했는지 확인합니다. feature_importance = pd.DataFrame({ "feature": X.columns, "importance": rf_model.feature_importances_ }).sort_values("importance", ascending=False) print(feature_importance) sns.barplot( data=feature_importance, x="importance", y="feature" ) plt.title("변수 중요도") plt.show() 결과 해석 RMSE 가 작을수록 예측 오차가 작습니다. 결정 트리는 한 개의 나무로 예측합니다. 랜덤 포레스트는 여러 결정 트리의 예측을 평균 내므로 일반적으로 더 안정적입니다. 트리 기반 모델은 입력 데이터의 스케일링이 필수는 아닙니다.
Score: 54.38Confidence: 49%
View offervelog
Delta Lake Theory - 🌊 Delta Lake Beginner's Guide - Theory Delta Lake Practical Guide Part 1 - 🌊 Delta Lake Beginner's Guide - Practical Edition (Part 1. Local Environment) Delta Lake in Practice Part 2 - 🌊 Delta Lake Beginner's Guide - Practical Edition (Part 2. Utilizing the delta-spark library) 0. INTRO In the previous post , 🌊 Delta Lake Beginner's Guide - Theory, we covered the theoretical aspects of Delta Lake in detail. In this post, we will practice the features of Delta Lake in a Pyspark Docker Container environment. In this practice session, we use PySpark as the tool for handling data, and files of type delta are stored and managed in a local directory. hyunsoolee0506/pyspark-cloud:3.5.1 For Docker containers , I recommend using an image I have created separately, considering that you will be integrating with a cloud environment for future practice . However, for this local environment practice, it is fine to proceed using Google Colab. For the practice session, delta_data.zip we used data from two CSV files located in the file below. 👉 delta_data.zip 1️⃣ Setting up the Practice Environment ▪ 1) Create a Docker Container /workspace/spark Configure the directory to be used as a volume on the user's computer to be mapped to the directory inside the container . docker run -d \ --name pyspark \ -p 8888 :8888 \ -p 4040 :4040 \ -v [ 사용자 디렉토리 ] :/workspace/spark \ hyunsoolee0506/pyspark-cloud:3.5.1 After executing the command above, 8888 you can access the juypter lab development environment by connecting to the port. ▪ 2) Install Library Install the libraries required for the practice. hyunsoolee0506/pyspark-cloud:3.5.1 Although they are already installed in the image, for Colab, you must install them by running the code below. pip install pyspark == 3.5 .1 delta-spark == 3.2 .0 pyarrow findspark ▪ 3) Pyspark Delta Lake Environment Setup https://docs.delta.io/latest/quick-start.html#set-up-apache-spark-with-delta-lake To use Delta Lake in PySpark, configure the relevant extensions when creating a SparkSession. from delta import * from pyspark . sql import SparkSession import pyspark . sql . functions as F builder = SparkSession . builder . appName ( "DeltaLakeLocal" ) . enableHiveSupport ( ) . config ( "spark.sql.extensions" , "io.delta.sql.DeltaSparkSessionExtension" ) . config ( "spark.sql.catalog.spark_catalog" , "org.apache.spark.sql.delta.catalog.DeltaCatalog" ) spark = configure_spark_with_delta_pip ( builder ) . getOrCreate ( ) 2️⃣ Create Database and Table ▪ 1) Create Database deltalake_db Creates a new database named. spark . sql ( "CREATE DATABASE IF NOT EXISTS deltalake_db" ) spark . sql ( "SHOW DATABASES" ) . show ( ) - - - + - - - - - - - - - - - - + | namespace | + - - - - - - - - - - - - + | default | | deltalake_db | + - - - - - - - - - - - - + ▪ 2) Create CSV type table trainer_data.csv trainer Creates a table to store the file's data . - 테이블 이름 : trainer - 스키마 : - id → INT - name → STRING - age → INT - hometown → STRING - prefer_type → STRING - badge_count → INT - level → STRING query = f""" CREATE TABLE IF NOT EXISTS deltalake_db.trainer ( id INT, name STRING, age INT, hometown STRING, prefer_type STRING, badge_count INT, level STRING ) USING csv OPTIONS ( path '[trainer_data.csv 파일 경로]', header 'true', inferSchema 'true', delimiter ',' ) """ spark . sql ( query ) # 테이블 생성 확인 spark . sql ( "SHOW TABLES FROM deltalake_db" ) . show ( ) - - - + - - - - - - - - - - - - + - - - - - - - - - + - - - - - - - - - - - + | namespace | tableName | isTemporary | + - - - - - - - - - - - - + - - - - - - - - - + - - - - - - - - - - - + | deltalake_db | trainer | false | + - - - - - - - - - - - - + - - - - - - - - - + - - - - - - - - - - - + 3️⃣ Create delta type table csv Since data from an existing file delta cannot be inserted directly into a table of the same type simultaneously with its creation, delta you must create the table through two steps as follows. delta Create empty table of type delta csv Insert table data into the table ▪ 1) Create table trainer_delta delta Creates a table named . /workspace/spark/deltalake/delta_local/trainer_delta/ delta We have configured the settings so that table-related data is stored under the corresponding path . query = f""" CREATE TABLE IF NOT EXISTS deltalake_db.trainer_delta ( id INT, name STRING, age INT, hometown STRING, prefer_type STRING, badge_count INT, level STRING ) USING delta LOCATION '/workspace/spark/deltalake/delta_local/trainer_delta/' """ spark . sql ( query ) ▪ 2) Insert data Insert trainer the data from the table created above into the table. trainer_delta query = """ INSERT INTO deltalake_db.trainer_delta SELECT * FROM deltalake_db.trainer; """ spark . sql ( query ) Once data insertion is complete, _delta_log/ you can verify that a new folder and parquet file have been created in the delta table directory. 4️⃣ Reading delta type tables In PySpark, you can read tables stored in slightly different ways. ▪ 1) Basic reading in Spark LOCAL_DELTA_PATH = '/workspace/spark/deltalake/delta_local/trainer_delta' df = spark . read . format ( "delta" ) . load ( LOCAL_DELTA_PATH ) df . show ( 5 ) - - - + - - - + - - - - - - + - - - + - - - - - - - - + - - - - - - - - - - - + - - - - - - - - - - - + - - - - - - - - - - - - + | id | name | age | hometown | prefer_type | badge_count | level | + - - - + - - - - - - + - - - + - - - - - - - - + - - - - - - - - - - - + - - - - - - - - - - - + - - - - - - - - - - - - + | 1 | Brian | 28 | Seoul | Electric | 8 | Master | | 3 | Susan | 18 | Gwangju | Rock | 7 | Expert | | 6 | Vicki | 17 | Daejeon | Ice | 4 | Intermediate | | 9 | Olivia | 45 | Incheon | Psychic | 3 | Intermediate | | 10 | Mark | 16 | Gangwon | Fire | 4 | Intermediate | + - - - + - - - - - - + - - - + - - - - - - - - + - - - - - - - - - - - + - - - - - - - - - - - + - - - - - - - - - - - - + only showing top 5 rows delta. Read as ▪ 2) query = f"SELECT * FROM delta.` { LOCAL_DELTA_PATH } `" spark . sql ( query ) ▪ 3) Read from Hive Catalog spark . table ( 'deltalake_db.trainer_delta' ) 5️⃣ Save after editing the table This time, we will modify the table contents and overwrite the existing directory. This step is intended to prepare for future table change history inquiries and to practice Time Travel queries, a core feature of Delta Lake. ▪ 1) Save after excluding 'Beginner' # Beginner 제외한 dataframe 생성 df_1 = df . filter ( F . col ( 'level' ) != 'Beginner' ) # 기존 경로에 덮어쓰기 df_1 . write . format ( 'delta' ) . mode ( 'overwrite' ) . save ( LOCAL_DELTA_PATH ) # 데이터 확인 df = spark . read . format ( "delta" ) . load ( LOCAL_DELTA_PATH ) df . select ( 'level' ) . distinct ( ) . show ( ) - - - + - - - - - - - - - - - - + | level | + - - - - - - - - - - - - + | Expert | | Advanced | | Master | | Intermediate | + - - - - - - - - - - - - + ▪ 2) Save after excluding 'Advanced' # Advanced 제외한 dataframe 생성 df_2 = df_1 . filter ( F . col ( 'level' ) != 'Advanced' ) # 기존 경로에 덮어쓰기 df_2 . write . format ( 'delta' ) . mode ( 'overwrite' ) . save ( LOCAL_DELTA_PATH ) # 데이터 확인 df = spark . read . format ( "delta" ) . load ( LOCAL_DELTA_PATH ) df . select ( 'level' ) . distinct ( ) . show ( ) - - - + - - - - - - - - - - - - + | level | + - - - - - - - - - - - - + | Expert | | Master | | Intermediate | + - - - - - - - - - - - - + You can see that as the data is overwritten, a parquet file is added, and _delta_log/ metadata (files) are also added within the folder . .json 6️⃣ Change History Lookup and Time Travel Query ▪ 1) View History delta View the change history for the table. So far, the table has a structure where a total of three write operations have occurred since the initial creation (CREATE). Therefore, the versions also exist as 0, 1, 2, and 3. VERSION 0 → trainer_delta Table creation status, No data VERSION 1 → trainer Initial state after data is inserted into the table VERSION 2 → Saved state after excluding 'Beginner' rows VERSION 3 → Saved state after excluding 'Advanced' row query = "DESCRIBE HISTORY deltalake_db.trainer_delta" spark . sql ( query ) . show ( vertical = True , truncate = False ) - - - - RECORD 0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - version | 3 timestamp | 2025 - 03 - 21 02 : 09 : 39.085 userId | NULL userName | NULL operation | WRITE operationParameters | { mode - > Overwrite , partitionBy - > [ ] } job | NULL notebook | NULL clusterId | NULL readVersion | 2 isolationLevel | Serializable isBlindAppend | false operationMetrics | { numFiles - > 1 , numOutputRows - > 42 , numOutputBytes - > 3125 } userMetadata | NULL engineInfo | Apache - Spark / 3.5 .1 Delta - Lake / 3.2 .0 - RECORD 1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - version | 2 timestamp | 2025 - 03 - 21 02 : 08 : 32.646 userId | NULL userName | NULL operation | WRITE operationParameters | { mode - > Overwrite , partitionBy - > [ ] } job | NULL notebook | NULL clusterId | NULL readVersion | 1 isolationLevel | Serializable isBlindAppend | false operationMetrics | { numFiles - > 1 , numOutputRows - > 85 , numOutputBytes - > 3868 } userMetadata | NULL engineInfo | Apache - Spark / 3.5 .1 Delta - Lake / 3.2 .0 - RECORD 2 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - version | 1 timestamp | 2025 - 03 - 21 01 : 46 : 21.446 userId | NULL userName | NULL operation | WRITE operationParameters | { mode - > Append , partitionBy - > [ ] } job | NULL notebook | NULL clusterId | NULL readVersion | 0 isolationLevel |
velog
길었던 추석 연휴가 끝나고 다시 우테코로 돌아왔다. 지난주에는 짧은 3일 동안 스프린트 목표와 제품 가설을 정하느라 바쁘게 움직였지만, 몸살로 인해 생각만큼 프로젝트에 집중하지 못했다. 이번 연휴에는 제대로 쉬고 돌아오고 싶었는데, 추석 동안에도 온전히 휴식을 취하지는 못했던 것 같다. 월요일 아침부터 커피 한 잔에 의지하며 다시 일상으로 돌아왔다. 오랜만에 컴퓨터 앞에 앉으니 손을 움직이는 것마저 조금 어색했고, 팀 프로젝트보다 밀려 있던 안드로이드 미션에 먼저 손이 갔다. 그렇게 조금은 더디게 시작한 한 주였지만, 이번 주에는 레벨 4의 첫 번째 스프린트를 마무리하고 첫 데모데이 를 맞이해야 했다. 수요일에는 준과의 피드백을 통해 프로젝트의 방향을 다시 정리할 수 있었고, 목요일에는 스프린트 요구사항을 제출하기 위해 바쁘게 움직였다. 그리고 금요일 데모데이에서는 생각하지 못했던 질문을 마주했다. 우리 팀의 인프라는 어떻게 구성되어 있을까? 모니터링 도구는 왜 선택했고, 어떤 정보를 얻을 수 있을까? 팀원 누구에게 물어봐도 이 질문에 답할 수 있을까? 같은 서비스를 만들고 있지만, 정작 다른 파트에서 어떤 기술을 사용하고 왜 그런 결정을 내렸는지 충분히 이해하지 못하고 있다는 사실을 깨달았다. 레벨 4 3주차는 단순히 내가 맡은 기능을 잘 만드는 것을 넘어, 우리 팀이 만드는 서비스를 얼마나 이해하고 있는지 돌아보게 된 한 주였다. 1. 연휴가 끝나고 다시 일상으로 월요일 아침은 커피 한 잔과 함께 시작했다. 분명 추석 연휴로 며칠 동안 우테코에 등교하지 않았는데도 피로가 완전히 풀린 느낌은 아니었다. 주말에도 온전히 쉬지 못했던 만큼 다시 프로젝트에 몰입하기까지는 조금 시간이 필요했다. 오랜만에 컴퓨터 앞에 앉으니 어디부터 손을 대야 할지 잠깐 고민하게 되었다. 지난주에는 위치와 날짜를 기반으로 사진을 불러오고, 사용자가 여행 기록을 작성하기 위해 직접 입력해야 하는 정보를 줄이는 방향으로 스프린트 목표를 정했다. 하지만 이번 주 초반의 나는 MapMory보다는 밀려 있던 안드로이드 미션에 조금 더 집중하고 있었다. 해야 할 일이 여러 개 있을 때는 하나를 확실하게 끝내고 싶은 마음이 생긴다. 특히 개인 미션은 내가 해결해야 하는 문제가 상대적으로 명확하다 보니 팀 프로젝트보다 손을 대기 쉬웠던 것 같다. 그렇다고 프로젝트를 계속 미뤄둘 수는 없었다. 이번 주 금요일에는 첫 데모데이가 예정되어 있었고, 그전에 우리가 이번 스프린트에서 무엇을 시도했고 어떤 결과를 얻었는지 정리해야 했다. 이번 주에는 조금 더 빠르게 다시 프로젝트의 흐름을 따라잡아야겠다고 생각했다. 요즘 자주 찾는 새로운 점심 장소 최근에는 새롭게 생긴 정겨운 풍경 이라는 한식뷔페도 자주 방문하고 있다. 여러 크루가 맛있다고 이야기해서 가보게 되었는데, 이제는 꽤 익숙한 점심 장소가 되어버렸다. 프로젝트에 집중하다 보면 점심시간만큼은 든든하게 먹고 잠깐 쉬어가는 시간이 꽤 소중하게 느껴진다. <요즘 크루들 사이에서 인기가 많은 정겨운 풍경의 든든한 한 끼> 2. 미션과 프로젝트, 어디에 더 집중해야 할까? 화요일 오전에는 안드로이드 수업이 있었다. 이번에는 DI(Dependency Injection) 미션과 관련하여 각자 구현한 방식과 고민했던 부분을 이야기하는 시간을 가졌다. 레벨 4에 들어오면서 안드로이드 미션과 팀 프로젝트를 병행하는 것이 생각보다 쉽지 않다는 것을 계속 느끼고 있다. DI 미션을 진행하다 보면 의존성 주입이라는 개념 자체부터 이해해야 할 부분이 많았다. 단순히 객체를 생성하고 전달하는 코드를 작성하는 것이 아니라, 왜 의존성 주입이 필요한지, 객체 생성과 의존성을 어떤 방식으로 관리해야 하는지까지 고민해야 했다. 하나를 공부하다 보면 다른 궁금증이 생기고, 그 부분을 찾아보다 보면 어느새 예상보다 많은 시간이 흘러 있었다. 지난주 중간 점검에서도 여러 크루가 비슷한 고민을 이야기했다. 기술을 어디까지 깊게 공부해야 할까? 프로젝트와 개인 미션의 시간을 어떻게 나눠야 할까? 나 역시 아직 명확한 답을 찾지는 못했다. 새로운 기술을 깊게 학습하는 것도 중요하지만, 그렇다고 팀 프로젝트에 필요한 작업을 계속 미룰 수는 없기 때문이다. 오후에는 피곤함이 몰려와 잠깐 낮잠을 청한 뒤 다시 미션을 진행했다. 그렇게 하루를 돌아보니 이번 주에는 프로젝트에 충분히 힘을 싣지 못하고 있다는 생각이 들었다. 분명 지난주에 스프린트 목표와 제품 가설을 논의했고, 우리가 어떤 경험을 사용자에게 제공하고 싶은지도 이야기했다. 하지만 이번 주에 반드시 확인해야 하는 가설이 무엇인지, 어떤 기능을 먼저 개선해야 하는지 명확하게 드러내고 작업을 이어가지는 못했던 것 같다. 개인 미션을 진행하면서 새로운 기술을 배우는 일과 실제 서비스의 방향을 고민하는 일 사이에서 조금씩 균형을 잃고 있었다. 이번 주에는 데모데이가 예정되어 있었기에 더 이상 미룰 수 없었다. 모든 일을 똑같이 열심히 하는 것보다, 지금 무엇이 중요한지 판단하고 집중하는 것이 필요했다. 3. 이번 스프린트에서 정말 검증하고 싶었던 것 이번 스프린트에서 우리가 해결하려던 문제는 비교적 명확했다. MapMory는 여행 사진을 장소와 함께 지도 위에 기록하고, 여행의 기억을 다시 꺼내볼 수 있도록 돕는 여행 아카이브 서비스다. 여행을 다녀오면 사진은 많이 남지만, 날짜와 장소별로 사진을 정리하고 기록을 작성하는 일은 생각보다 번거롭다. 그래서 여행의 순간들을 다시 돌아보기보다 사진첩에 그대로 쌓아두기 쉽다. 우리는 이런 문제를 해결하기 위해 지도 위에 사진과 기록을 남기는 서비스를 만들었다. 하지만 레벨 3에서 사용자에게 서비스를 보여주면서 또 다른 문제를 발견했다. 여행 기록 하나를 남기기 위해 사용자가 직접 사진을 선택하고, 제목과 날짜, 본문 등을 입력해야 한다는 점이었다. 여행을 기록하고 싶은 마음이 있더라도 그 과정이 번거롭다면 기록을 완성하기 전에 이탈할 수 있었다. 그래서 이번 스프린트에서는 다음과 같은 가설을 세웠다. 사용자가 사진을 직접 선택하고 기록 정보를 입력하는 과정을 줄이면, 기존보다 여행 기록 생성률이 높아질 것이다. 이를 위해 기록 작성 흐름을 사진 중심으로 변경하고자 했다. 먼저 위치와 날짜를 입력하면 해당 조건에 맞는 사진을 불러오고, 사용자는 그중 기록으로 남기고 싶은 사진을 선택한다. 제목과 본문은 반드시 입력해야 하는 정보가 아니라 선택적으로 작성할 수 있도록 변경했다. 기존의 블로그 형식에 가까웠던 기록 조회 화면도 사진을 중심으로 보여주는 방향을 고민했다. 핵심은 새로운 기능을 많이 추가하는 것이 아니었다. 사용자가 여행 기록 하나를 남기기까지 거쳐야 하는 과정을 줄이고, 실제로 더 많은 기록이 완성되는지 확인하는 것 이었다. 이렇게 목표를 정하고 나니 단순히 기능을 구현하는 것에서 끝나서는 안 된다는 사실도 다시 떠올랐다. 우리가 바꾼 흐름이 사용자에게 도움이 되었는지 확인하려면, 사용자의 행동을 관찰할 수 있어야 했다. 4. Firebase Analytics, 데이터를 모으는 것에서 해석하는 것으로 이번 스프린트에서 Android 파트는 크래시와 사용자 행동 분석 이라는 기술 요구사항을 수행했다. 레벨 3에서도 Firebase Analytics를 사용하고 있었지만, 실제로 기록 작성 과정에서 어느 단계까지 진행했고 어디에서 이탈했는지를 명확하게 구분하기는 어려웠다. 이번에는 기록 작성 흐름이 변경된 만큼 사용자 행동을 조금 더 세부적으로 확인할 수 있도록 Analytics 이벤트를 보강했다. 대표적으로 다음과 같은 이벤트를 사용했다. record_flow_step_viewed : 기록 작성 단계 진입 record_editor_field_interacted : 제목, 본문 등 입력 항목과의 상호작용 record_editor_exited : 기록 편집 화면에서의 명시적인 이탈 record_save_started : 기록 저장 시작 record_save_completed : 기록 저장 완료 record_save_failed : 기록 저장 실패 이런 이벤트를 활용하면 사용자가 어떤 단계까지 진행했는지, 어떤 정보를 입력하려고 했는지, 저장은 정상적으로 완료되었는지 등을 이전보다 구체적으로 살펴볼 수 있다. 물론 제목이나 본문에 사용자가 직접 작성한 실제 내용은 이벤트에 포함하지 않았다. 우리가 알고 싶은 것은 사용자가 어떤 내용을 기록했는지가 아니라, 기록 작성 과정에서 어떤 행동을 했는지 이기 때문이다. 그리고 이번에는 기록 저장 시작 이벤트와 완료 이벤트를 비교해볼 수 있었다. 기록 저장 완료 이벤트 비율의 변화 구간 저장 시작 이벤트 저장 완료 이벤트 이벤트 비율 변경 전 (9/3~9/27) 67회 (9명) 40회 (8명) 59.7% 변경 후 (9/28~9/30) 4회 (2명) 3회 (2명) 75.0% 기록 저장 완료 이벤트 수를 저장 시작 이벤트 수로 나누었을 때, 변경 전에는 약 59.7%, 변경 후에는 75.0%로 나타났다. 수치만 보면 15.3%p 상승 한 것이다. 처음에는 기록 작성 흐름을 단순화한 것이 긍정적인 영향을 준 것은 아닐까 기대할 수도 있었다. 하지만 데이터를 조금 더 자세히 살펴보면 그렇게 단정하기는 어려웠다. 변경 전은 25일 동안의 데이터였지만, 변경 후는 3일 동안의 데이터였다. 무엇보다 변경 후에 기록 저장을 시작한 사용자는 단 2명뿐이었다. 또한 이 비율은 저장 시작 이벤트와 완료 이벤트의 발생 횟수를 비교한 값이지, 기록 작성을 시작한 사용자 중 몇 명이 실제로 완료했는지를 나타내는 사용자 기준 전환율은 아니었다. 따라서 이번 결과만으로 변경된 기록 작성 흐름이 생성률을 높였다고 결론 내릴 수는 없었다. 긍정적인 초기 신호는 있었지만, 가설이 검증됐다고 판단하기에는 데이터가 부족했다. 이번에는 수치를 확인했다는 사실보다, 그 수치가 어떤 조건에서 나왔고 어디까지 해석할 수 있는지 구분하는 일이 더 중요하게 느껴졌다. 앞으로는 더 많은 사용자의 데이터가 쌓인 뒤 동일한 조건에서 다시 확인하고, 사용자 기준 퍼널을 통해 실제 기록 작성 완료율을 살펴볼 필요가 있었다. 5. Crashlytics, 오류가 발생한 뒤의 상황까지 생각하기 사용자 행동을 확인하는 것만큼이나 앱의 안정성을 관찰하는 일도 필요했다. 우리가 개발하는 환경에서는 정상적으로 동작하는 기능이라도, 실제 사용자의 기기와 환경에서는 예상하지 못한 오류가 발생할 수 있기 때문이다. 이런 문제를 확인하기 위해 Android 앱에 Firebase Crashlytics를 연동하는 작업도 진행했다. Crashlytics는 앱에서 발생한 크래시와 ANR 등을 수집해 문제를 파악하는 데 도움을 주는 도구다. 이번 작업에서는 Android 앱에 Firebase SDK와 Gradle 플러그인을 연결하고, Debug 빌드에서는 수집을 비활성화하며 Release 빌드에서 수집하도록 구성했다. 내부 테스트 과정에서 발생하는 오류와 실제 배포 환경에서 발생하는 오류를 구분하기 위한 선택이었다. 다만 도구를 연결했다고 해서 곧바로 운영 환경에서 정상적으로 관측되고 있다고 말할 수는 없었다. 이번 스프린트에서는 Crashlytics 연동 PR #288 을 통해 적용 작업을 진행했다. PR은 데모데이 당일 새벽에 병합되었지만, 내부 Release 빌드에서 테스트 크래시를 발생시킨 뒤 Firebase Console에서 실제 보고서 수신까지 확인하는 작업은 별도로 남아 있었다. 이번 작업을 통해 한 가지를 다시 생각했다. 모니터링 도구를 설치했다는 사실과, 실제 문제가 발생했을 때 원인을 확인하고 대응할 수 있다는 것은 다른 이야기였다. 단순히 SDK를 연동하는 데 만족하지 않고, 실제로 문제가 발생했을 때 어떤 정보를 확인할 수 있고 어떻게 대응할 것인지까지 고민해야 했다. 이 생각은 금요일 데모데이에서 받은 피드백과도 자연스럽게 이어졌다. 6. 준과의 피드백, 다시 선명해진 제품의 방향 수요일은 조금 의외의 컨디션으로 시작했다. 전날 늦게 귀가해 잠도 늦게 잤는데, 이상하게 아침에는 생각보다 개운했다. 물론 이날도 커피 한 잔은 빠질 수 없었다. ᄏᄏᄏ 오전에는 안드로이드 미션을 조금씩 진행하고, 오후에 예정된 준과의 피드백 시간을 준비했다. 최근 우리 팀은 어떤 기능을 우선적으로 개선해야 할지, 이번 스프린트에서 무엇을 검증해야 하는지에 대한 고민을 계속 이어가고 있었다. 지난 스프린트에서 사용자의 기록 작성 부담을 줄이기 위한 방향을 정했지만, 서비스를 개발하다 보면 해결하고 싶은 문제와 구현하고 싶은 기능이 계속 늘어난다. 사진을 어떤 기준으로 묶을지, 기록을 어떤 형태로 보여줄지, 사용자가 직접 수정할 수 있는 범위는 어디까지 가져갈지 등 여러 선택지가 있었다. 각각의 아이디어가 의미 있어 보이더라도 모든 것을 한 번에 구현할 수는 없었다. 오후에 준과 이야기를 나누면서 지금 우리에게 필요한 기능의 우선순위와 나아가야 할 방향에 대해 다시 고민할 수 있었다. 그동안 조금 막혀 있던 부분들을 꺼내놓고 이야기하면서, 이번 주에 어떤 목표를 중심으로 스프린트를 정리해야 할지도 이전보다 명확하게 바라볼 수 있었다. 물론 한 번의 피드백으로 모든 고민이 해결된 것은 아니다. 하지만 적어도 지금 우리가 어떤 문제를 해결하려고 하는지, 어떤 기능에 먼저 집중해야 하는지를 다시 생각해볼 수 있었다는 점에서 의미가 있었다. 좋은 아이디어를 많이 가지는 것보다, 지금 해결해야 할 문제를 명확하게 선택하는 일이 더 중요할 때도 있다. 이제 남은 것은 정리한 방향을 바탕으로 스프린트 제출물을 마무리하고, 금요일 데모데이에서 우리가 어떤 판단을 내렸는지 설명하는 일이었다. 7. 레벨 3의 그라운드 룰, 우리는 얼마나 지켰을까? 이번 스프린트에서는 제품과 기술적인 고민뿐 아니라 우리 팀의 협업 방식도 돌아봤다. 레벨 3에서 정했던 그라운드 룰 중에는 데일리 미팅, 자율성 보장, 서로의 의견에 필요하면 반박하기, 혼자 고민하지 않고 공유하기 등 여러 가지가 있었다. 그중에서도 이번에 가장 아쉬웠던 것은 회의를 정해진 시간 안에 마무리하는 것 이었다. 우리 팀은 회의 시간을 1시간, 길어도 최대 2시간 안에 끝내기로 했었다. 하지만 실제로 프로젝트를 진행하면서 이 규칙을 잘 지키지 못했다. 하나의 주제를 이야기하다가 논의가 여러 방향으로 뻗어나가기도 했고, 서로 같은 이야기를 하고 있으면서도 다르게 이해하는 순간도 있었다. 지난주 스프린트 범위를 정하는 회의에서도 비슷한 문제가 있었다. 서로 같은 목표를 바라보고 있었지만 구현하려는 범위가 달랐고, 각자의 의견을 충분히 이야기하는 과정에서 시간이 길어졌다. 그렇다고 대화 자체가 불필요했던 것은 아니다. 각자의 생각을 충분히 듣고 이해하는 과정도 중요했다. 다만 논의가 길어졌을 때 다시 안건으로 돌아오거나, 남은 쟁점을 정리하고 결론을 내리는 방식이 부족했던 것 같다. 그래서 레벨 4에서는 회의 방식을 조금 더 구체적으로 정해보기로 했다. 회의는 회의실에서 진행하고, 50분 단위로 운영한다. 50분이 지나면 10분간 휴식한다. 하나의 안건은 50분 안에 결론을 내는 것을 목표로 한다. 결론이 나지 않으면 각자의 의견을 정리한 뒤 다수결로 결정한다. 회의 전에는 회의록을 읽고 안건에 대한 생각을 미리 정리해온다. 도우너가 회의 진행과 기록, 시간 관리를 맡아 논의의 흐름을 살펴보기로 했고, 스프린트 중간 점검에서 실제로 잘 지켜졌는지도 돌아보기로 했다. 우리 팀의 새로운 그라운드 룰에는 다음과 같은 문구가 있었다. 함께. 오래. 재밌게. 사용자 우선. 단순히 빠르게 결정하기 위한 규칙을 만드는 것이 아니라, 서로의 의견을 충분히 나누면서도 오래 협업할 수 있는 방식을 찾아가고 싶었다. 이번에는 좋은 규칙을 정하는 것에서 끝나지 않고, 실제 회의에서 어떻게 작동하는지까지 확인해보고자 한다. 8. 10월의 시작, 끝나지 않는 스프린트 마감 목요일에는 드디어 10월이 시작되었다. 달력을 보니 시간이 정말 빠르게 흐르고 있다는 것이 실감났다. 우테코 생활이 얼마 남지 않았다고 생각하니 아쉬운 마음도 들었다. 동시에 앞으로 남은 기간에 무엇을 더 얻어가고 싶은지, 우테코가 끝난 뒤에는 어떤 방향으로 나아가야 할지도 자연스럽게 고민하게 되었다. 케이블 하나에도 진심인 크루들 이날 오전에는 디노가 구매한 케이블을 두고 작은 룰렛 이벤트가 있었다. 누군가는 구매 가격보다 저렴하게, 누군가는 더 비싸게 가져가게 되는 방식이었다. 나는 운 좋게 구매 가격보다 저렴하게 케이블을 얻어냈다. 아침부터 소소하게 도파민을 충전한 순간이었다. ᄏᄏᄏ <구매가보다 싸게 가져갈 수 있을까? 디노가 준비한 도파민 룰렛 🎰> <운 좋게 정가보다 저렴하게 얻어낸 100W 접이식 릴 케이블 😎> 하지만 웃고 있을 시간은 많지 않았다. 이날은 지난 2주 동안 진행했던 스프린트 1의 요구사항과 결과를 정리해 제출해야 하는 날 이었다. 우리가 세웠던 제품 가설과 진행 과정, 수집한 데이터, 그라운드 룰과 협업 방식 등을 다시 살펴보며 제출물을 정리했다. 처음에는 개발을 열심히 해왔다면 그 과정을 문서로 정리하는 일은 어렵지 않을 것이라고 생각했다. 하지만 막상 정리하려고 하니 생각보다 많은 질문이 생겼다. 우리가 이번 스프린트에서 정말 확인하려던 것은 무엇이었지? 수집한 데이터만으로 어떤 결론을 내릴 수 있을까? 우리가 내렸던 판단에는 어떤 근거가 있었을까? 특히 기록 저장 완료 이벤트 비율이 높아졌다는 사실만으로 가설을 검증했다고 판단할 수 없다는 점이 기억에 남았다. 수치가 좋아졌다는 사실도 중요하지만, 그 수치가 어떤 환경에서 수집되었고 어떤 한계를 가지고 있는지 설명할 수 있어야 했다. 이번 제출 준비를 하면서 기록을 남기는 일의 중요성을 다시 느꼈다. 그때그때 내렸던 판단과 근거를 충분히 남겨두지 않으면 나중에 그 과정을 설명하는 데에도 많은 시간이 필요하기 때문이다. 9. 바쁜 와중에도 놓칠 수 없었던 사람들 목요일에는 스프린트 제출물을 정리하느라 하루 종일 바쁘게 움직였다. 그 와중에도 달수의 테코톡만큼은 놓칠 수 없었다. 같은 팀원이 준비한 발표이기도 했고, 그동안 준비해온 과정을 알고 있었기에 직접 가서 응원하고 싶었다. 발표를 보고 돌아오자마자 다시 제출물에 집중했다. 오후 5시에는 제이슨이 DI 미션과 관련하여 피드백을 진행해주셨지만, 이날은 팀 프로젝트에서 마무리해야 할 작업이 남아 있어 참석하지 못했다. 개인적으로 아쉬움이 남았다. 이번 미션에서 고민했던 부분들을 다른 크루들과 함께 이야기하고, 제이슨의 관점도 듣고 싶었기 때문이다. 하지만 모든 시간을 다 가져갈 수는 없었다. 이번에는 스프린트 마감이라는 우선순위에 맞춰 프로젝트에 집중하기로 했다. 디노와 안드로이드 크루들의 저녁 하교 시간이 지난 뒤에는 디노가 안드로이드 크루들과 함께 저녁을 먹는 시간을 마련해주셨다. 모든 안드로이드 크루가 모인 것은 아니었지만, 오랜만에 여러 사람이 함께 앉아 이야기를 나눌 수 있었다. <디노와 오랜만에 모인 안드로이드 크루들의 저녁 식사> 각자 진행하고 있는 프로젝트 이야기도 나왔고, 최근에는 이력서와 포트폴리오, 면접을 준비하고 있는 크루들의 이야기도 들을 수 있었다. 같은 우테코에서 비슷한 시간을 보내고 있지만 각자가 바라보는 다음 목표는 조금씩 달랐다. 누군가는 프로젝트 완성도를 높이기 위해 고민하고 있었고, 누군가는 자신이 경험한 내용을 어떻게 이력서에 담아야 할지 고민하고 있었다. 이런 이야기를 듣고 있으면 나 역시 우테코 이후의 시간을 어떻게 준비해야 할지 생각하게 된다. 편맥에서 식사로 이어진 제이슨과의 시간 저녁 식사가 끝난 뒤에는 제이슨이 함께 편맥을 하자고 제안해주셨다. 처음에는 편의점에서 가볍게 이야기할 예정이었지만, 계획이 바뀌면서 모란으로 이동해 식사를 하게 되었다. <제이슨과 함께한 저녁 식사의 토마토 계란 볶음(그렇게 안보이지만 토마토 계란 볶음 맞습니다ᄏᄏᄏ)> <맛있는 음식과 함께 개발과 우테코에 대한 이야기를 나눴던 시간> 이날은 살짝 피곤한 상태여서 평소만큼 적극적으로 대화에 참여하지는 못했다. 그래도 우리가 만들고 있는 MapMory에 대한 이야기도 나눴고, 제이슨은 이전 크루들의 서비스 중에서 참고해볼 만한 사례도 떠올려 제안해주셨다. 이전 기수의 이야기부터 이날 있었던 수업, 앞으로의 우테코에 대한 이야기까지 다양한 주제가 이어졌다. 현재는 캡틴으로 활동하고 계시지만, 여전히 안드로이드 관련 수업을 직접 진행해주시고 크루들과 이야기를 나누는 모습을 보니 안드로이드 파트에 대한 애정도 느껴
velog
알고리즘 Cheat Sheet 시리즈는 코딩 테스트를 풀다가 "이거 자바에선 뭐였지?", "파이썬은 어떻게 했더라?" 싶을 때 바로 펼쳐 보려고 만든 개인 참고용 정리입니다. Java와 Python을 나란히 놓고, 문법 차이 때문에 실수하기 쉬운 부분만 짧게 정리합니다. 이번 주제는 오버로딩 과 오버라이딩 입니다. 이름이 비슷해서 헷갈리지만 전혀 다른 개념이고, 면접 단골 질문이기도 합니다. 코테에서는 직접 만든 클래스를 정렬하거나 HashMap 키로 쓸 때 오버라이딩이 꼭 필요합니다. 1. 한눈에 비교 구분 오버로딩 (Overloading) 오버라이딩 (Overriding) 한 줄 정의 같은 이름, 다른 매개변수 로 메서드를 여러 개 생성 부모의 메서드를 자식이 다시 정의 관계 같은 클래스 안 상속 관계 (부모 ↔ 자식) 메서드 이름 같다 같다 매개변수 달라야 한다 (개수 · 타입 · 순서) 같아야 한다 반환 타입 상관없다 (반환 타입만 다르면 안 됨) 같거나 하위 타입 결정 시점 컴파일할 때 실행할 때 (실제 객체 기준) 자바 ⭕ ⭕ 파이썬 ❌ 지원하지 않는다 ⭕ 오버로딩은 "새로 추가(load)" , 오버라이딩은 "덮어쓰기(ride over)" 로 외우자! 2. 오버로딩 자바: 매개변수가 다르면 같은 이름을 쓸 수 있다 class Calc { int add(int a, int b) { return a + b; } int add(int a, int b, int c) { return a + b + c; } // 개수가 다름 double add(double a, double b) { return a + b; } // 타입이 다름 } Calc c = new Calc(); c.add(1, 2); // 3 → 첫 번째 c.add(1, 2, 3); // 6 → 두 번째 c.add(1.5, 2.5); // 4.0 → 세 번째 우리가 늘 쓰는 Math.max(int, int) , Math.max(long, long) , Math.max(double, double) 과 System.out.println() 도 오버로딩이다. int add(int a, int b) { ... } long add(int a, int b) { ... } // ❌ 반환 타입만 다른 것은 오버로딩이 아니다 (컴파일 에러) 파이썬: 같은 이름으로 다시 정의하면 덮어쓴다 def add(a, b): return a + b def add(a, b, c): # 앞의 add를 덮어쓴다 return a + b + c add(1, 2) # ❌ TypeError: add() missing 1 required positional argument: 'c' 파이썬은 대신 기본값 인자 와 가변 인자 로 같은 효과를 낸다. def add(a, b, c=0): # 기본값 인자 return a + b + c add(1, 2) # 3 add(1, 2, 3) # 6 def add_all(*nums): # 가변 인자 (몇 개든 받는다) return sum(nums) add_all(1, 2, 3, 4) # 10 생성자 오버로딩 상황 자바 파이썬 인자 개수에 따라 다르게 생성자를 여러 개 정의 __init__ 에 기본값 인자 다른 생성자 호출 this(...) - class Point { int x, y; Point() { this(0, 0); } // 다른 생성자를 호출 Point(int x, int y) { this.x = x; this.y = y; } } class Point: def __init__(self, x=0, y=0): self.x = x self.y = y 3. 오버라이딩 상속 문법부터 항목 자바 파이썬 상속 class Dog extends Animal class Dog(Animal): 부모 생성자 호출 super(name); super().__init__(name) 부모 메서드 호출 super.speak(); super().speak() 오버라이딩 표시 @Override 없음 (같은 이름으로 정의하면 끝) 다중 상속 ❌ (인터페이스는 여러 개 가능) ⭕ class Animal { String name; Animal(String name) { this.name = name; } void speak() { System.out.println("..."); } } class Dog extends Animal { Dog(String name) { super(name); } @Override void speak() { // 부모의 speak를 다시 정의 super.speak(); // 부모 것도 부르고 싶을 때 System.out.println("멍멍"); } } Animal a = new Dog("바둑이"); a.speak(); // "..." 다음에 "멍멍" ← 변수 타입(Animal)이 아니라 실제 객체(Dog) 기준 class Animal: def __init__(self, name): self.name = name def speak(self): print("...") class Dog(Animal): def __init__(self, name): super().__init__(name) def speak(self): # 같은 이름으로 정의하면 오버라이딩 super().speak() print("멍멍") Dog("바둑이").speak() # "..." 다음에 "멍멍" 자바 오버라이딩 규칙 메서드 이름과 매개변수가 부모와 똑같아야 한다. 접근 범위를 더 좁힐 수 없다 ( public → private 불가). static , final , private 메서드는 오버라이딩할 수 없다. 4. 코테에서 오버라이딩하는 메서드 직접 만든 클래스를 출력, 비교, 해시, 정렬 에 쓰려면 아래 메서드를 다시 정의해야 한다. 용도 자바 파이썬 안 하면 생기는 일 출력 toString() __repr__ / __str__ Point@1b6d3586 같은 주소가 출력된다 값이 같은지 equals(Object o) __eq__(self, other) 값이 같아도 다른 객체로 본다 해시 ( HashMap · HashSet ) hashCode() __hash__(self) 값이 같은 키를 못 찾는다 정렬 · 우선순위 큐 compareTo() ( Comparable ) __lt__(self, other) 정렬할 때 에러가 난다 class Point implements Comparable<Point> { int x, y; Point(int x, int y) { this.x = x; this.y = y; } @Override public String toString() { return "(" + x + ", " + y + ")"; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Point)) return false; Point p = (Point) o; return x == p.x && y == p.y; } @Override public int hashCode() { return Objects.hash(x, y); } @Override public int compareTo(Point o) { // x 오름차순, 같으면 y 오름차순 if (x != o.x) return Integer.compare(x, o.x); return Integer.compare(y, o.y); } } Set<Point> set = new HashSet<>(); set.add(new Point(1, 2)); set.contains(new Point(1, 2)); // true (equals + hashCode 덕분) PriorityQueue<Point> pq = new PriorityQueue<>(); // compareTo 기준으로 꺼낸다 class Point: def __init__(self, x, y): self.x, self.y = x, y def __repr__(self): return f"({self.x}, {self.y})" def __eq__(self, other): return (self.x, self.y) == (other.x, other.y) def __hash__(self): return hash((self.x, self.y)) def __lt__(self, other): # x 오름차순, 같으면 y 오름차순 return (self.x, self.y) < (other.x, other.y) s = {Point(1, 2)} Point(1, 2) in s # True sorted([Point(2, 1), Point(1, 5)]) # [(1, 5), (2, 1)] 더 짧게 쓰는 방법 (사실 이건 외우기 좀 힘들지도) 방법 코드 자동으로 만들어 주는 것 자바 record (16+) record Point(int x, int y) {} equals , hashCode , toString 파이썬 dataclass @dataclass(frozen=True, order=True) __eq__ , __hash__ , __lt__ , __repr__ 파이썬 튜플 (x, y) 전부 (코테에서는 이게 제일 간단) from dataclasses import dataclass @dataclass(frozen=True, order=True) class Point: x: int y: int 5. 체크포인트 equals(Point p) 는 오버라이딩이 아니라 오버로딩이다 public boolean equals(Point p) { // ❌ 매개변수 타입이 Object가 아니다 return x == p.x && y == p.y; } 부모( Object )의 equals(Object o) 와 매개변수 타입이 달라서 오버라이딩이 아니라 오버로딩 이 된다. HashSet 은 equals(Object) 를 부르기 때문에 이 메서드는 쓰이지 않고, 값이 같아도 못 찾는다. @Override 를 항상 붙이자! 위 코드에 @Override 를 붙이면 컴파일 에러로 바로 알려 준다. 그 밖에 equals 를 재정의하면 hashCode 도 반드시 함께... equals 만 하면 HashSet / HashMap 에서 값이 같은 객체를 못 찾는다. 파이썬에서 __eq__ 만 정의하면 해시가 불가능해진다. TypeError: unhashable type 이 나므로 __hash__ 도 같이 정의한다. 파이썬 리스트를 출력하면 __str__ 이 아니라 __repr__ 을 쓴다. 하나만 정의할 거면 __repr__ 을 정의하자. 자바 static 메서드는 오버라이딩되지 않는다. 자식에 같은 이름으로 만들면 가려질(hiding) 뿐, 변수 타입 기준으로 호출된다. 파이썬 기본값 인자에 리스트를 쓰지 말자. 기본값은 함수 정의 시 한 번만 만들어져서 호출끼리 공유된다. def add(x, lst=[]): # ❌ lst.append(x) return lst add(1) # [1] add(2) # [1, 2] ← 이전 호출의 리스트가 남아 있다 def add(x, lst=None): # ⭕ if lst is None: lst = [] lst.append(x) return lst 💡 이것만 기억하자 오버로딩: 같은 이름, 다른 매개변수 오버라이딩: 부모 메서드를 자식이 재정의 2. 파이썬은 오버로딩이 없다. 같은 이름으로 다시 정의하면 덮어쓴다. 기본값 인자와 *args 로 대신한다. 3. 부모 호출: 자바: super.메서드() 파이썬: super().메서드() 4. 직접 만든 클래스를 HashSet / HashMap 에 넣으려면 equals + hashCode (파이썬은 __eq__ + __hash__ ) 5. 정렬/우선순위 큐에 넣으려면 compareTo (파이썬은 __lt__ ) 6. 자바는 @Override 를 항상 붙이자! equals(Point p) 같은 실수를 컴파일러가 잡아 준다.
velog
1은 이미 사용 중(할당됨), 0은 비어 있음(free) / 4바이트(1워드) paddging (정렬): 맨 앞의 4바이트 여백입니다. prologue (프롤로그): 메모리 관리 영역의 '시작'을 알리는 문지기 역할을 하는 8바이트 크기 블록입니다 (8/1).
Score: 54.37Confidence: 49%
View offervelog
I2C 통신 정리 - 두 개의 선으로 여러 장치와 통신하는 방법 1. I2C란? I2C(Inter-Integrated Circuit) 는 MCU와 센서, EEPROM 등의 주변 장치가 통신하기 위해 사용하는 동기식 직렬 통신 프로토콜 이다. SPI와 마찬가지로 Clock을 이용해 송수신 시점을 맞추지만, I2C는 통신에 필요한 선이 단 두 개라는 특징이 있다. SCL(Serial Clock Line) : Clock 신호 SDA(Serial Data Line) : 데이터 송수신 ┌── 온도 센서 MCU ── SDA ──┼── EEPROM ── SCL ──┼── 가속도 센서 └── 기타 주변 장치 하나의 SDA와 SCL을 여러 장치가 공유하는 Bus 구조 를 사용한다. 2. SPI와 무엇이 다른가? SPI에서는 일반적으로 다음과 같은 신호선을 사용한다. SCLK : Clock MOSI : Master → Slave MISO : Slave → Master CS : Slave 선택 특히 여러 Slave를 연결하면 각각의 장치를 선택하기 위한 CS가 필요하다. ┌── Slave A SCLK ─────────┼── Slave B MOSI ─────────┼── Slave C MISO ─────────┼── │ CS_A ─────────┘ A 선택 CS_B ─────────── B 선택 CS_C ─────────── C 선택 반면 I2C는 별도의 CS가 없다. ┌── Device A SDA ─────────┼── Device B SCL ─────────┼── Device C └── Device D 대신 각각의 장치가 고유한 주소(Address) 를 가지고 있다. 예를 들어, 온도 센서 : 0x48 EEPROM : 0x50 가속도 센서 : 0x68 라고 한다면 Controller가 0x48 이라는 주소를 보내면 해당 주소를 가진 장치가 응답한다. Controller │ │ "0x48!" ▼ ══════════ I2C BUS ══════════ │ │ │ 0x48 0x50 0x68 ↑ "내 주소다!" 즉, 간단하게 비교하면 다음과 같다. SPI는 CS 선으로 통신할 장치를 선택하고, I2C는 주소로 통신할 장치를 선택한다. 3. I2C는 어떻게 데이터를 주고받을까? I2C의 기본적인 통신 흐름은 다음과 같다. START ↓ Address + R/W ↓ ACK ↓ Data ↓ ACK / NACK ↓ STOP 여기서 중요한 개념은 다음과 같다. START Controller가 통신 시작을 알리는 신호이다. Address 어떤 장치와 통신할 것인지를 나타낸다. 일반적으로 많이 사용되는 7-bit Address 방식에서는 주소 뒤에 R/W bit 가 추가된다. [ 7-bit Address ][ R/W ] │ ├─ 0 : Write └─ 1 : Read 따라서 Address와 R/W bit를 통해 누구와 통신할 것인지 + 읽을 것인지 쓸 것인지 를 결정한다. ACK / NACK 데이터나 주소를 받은 장치는 ACK를 통해 정상적으로 수신했음을 알릴 수 있다. ACK : 정상적으로 수신 NACK : 더 이상 데이터를 받지 않거나 응답할 수 없음 등의 의미 STOP 현재 통신이 끝났음을 나타낸다. 4. 왜 SDA 하나로 송신과 수신이 모두 가능한가? SPI는 송신과 수신 선이 분리되어 있다. MOSI : Controller → Peripheral MISO : Controller ← Peripheral 하지만 I2C에서는 SDA 하나를 양방향으로 사용한다. Controller ───── SDA ───── Device → 데이터 ← 데이터 문제는 여러 장치가 하나의 SDA를 공유한다는 것이다. 만약 일반적인 GPIO 출력처럼 각 장치가 HIGH와 LOW를 직접 출력한다면 문제가 발생할 수 있다. 예를 들어, Device A : HIGH 출력 Device B : LOW 출력 이라면 한쪽에서는 선을 VCC에 연결하고 다른 쪽에서는 GND에 연결하려는 상황이 발생할 수 있다. 이를 해결하기 위해 I2C에서는 Open-Drain 구조와 Pull-up 저항 을 사용한다. 5. Open-Drain이란? Open-Drain의 핵심은 간단하다. 장치가 HIGH를 직접 출력하지 않는다. 장치가 할 수 있는 동작은 다음 두 가지이다. 0 출력 → SDA를 GND로 당긴다. 1 출력 → SDA에서 손을 뗀다(Release). 즉, SDA │ ┌──────┴──────┐ │ │ Device A Device B │ │ Switch Switch │ │ GND GND 각 장치 내부에 GND로 연결할 수 있는 스위치가 있다고 생각하면 이해하기 쉽다. 0 을 출력하려면 스위치를 켠다. SDA │ Switch ON │ GND → LOW 반대로 1 을 출력하려면 스위치를 끈다. SDA │ Switch OFF → Release 하지만 이렇게 하면 아무도 SDA를 GND로 당기지 않을 때 SDA의 전압을 HIGH로 만들어 줄 장치가 없다. 이를 해결하는 것이 Pull-up 저항 이다. 6. Pull-up 저항이란? SDA와 SCL을 저항을 통해 VCC에 연결한다. VCC │ Pull-up 저항 │ ├──── SDA │ Devices 아무 장치도 SDA를 GND로 당기지 않으면 Pull-up에 의해 SDA가 HIGH가 된다. VCC │ 저항 │ SDA → HIGH 반대로 하나의 장치라도 SDA를 GND로 당기면, VCC │ 저항 │ SDA ─── Device ─── GND → LOW 가 된다. 따라서 I2C에서는 다음과 같이 생각할 수 있다. 보내려는 값 장치의 동작 실제 SDA 0 GND로 당김 LOW 1 Release HIGH(Pull-up) 즉, 0은 장치가 직접 만들고, 1은 Pull-up 저항이 만들어준다. 이 구조 덕분에 여러 장치가 하나의 선을 안전하게 공유할 수 있다. 7. 여러 장치가 동시에 다른 값을 보내면? 예를 들어 두 장치가 동시에 다음 값을 출력하려 한다고 하자. Device A : 1 Device B : 0 Open-Drain에서는 Device A : Release Device B : GND로 당김 이므로 실제 Bus는 LOW가 된다. A B 실제 SDA 1 1 HIGH 1 0 LOW 0 1 LOW 0 0 LOW 즉, 하나의 장치라도 0을 출력하면 Bus는 0이 된다. 이러한 특성은 여러 Controller가 존재하는 환경에서 Arbitration을 구현하는 데도 사용된다. 8. I2C의 Arbitration 일반적인 I2C 시스템에서는 하나의 Controller가 여러 Peripheral을 제어하는 경우가 많기 때문에 Arbitration이 항상 발생하는 것은 아니다. 하지만 여러 Controller가 같은 Bus를 사용하는 환경에서는 동시에 통신을 시작할 가능성이 있다. Controller A ─┐ ├──── SDA/SCL ─── Devices Controller B ─┘ 두 Controller가 동시에 데이터를 보내고 있다고 가정하자. Controller A : 1 Controller B : 0 Open-Drain 특성 때문에 실제 SDA는 LOW가 된다. 따라서 Controller A는 내가 보낸 값 : 1 실제 SDA : 0 이라는 차이를 확인할 수 있다. 즉, 1을 보내려고 Release ↓ 실제 SDA 확인 ↓ LOW? ↓ "다른 Controller가 0을 보내고 있구나." ↓ Arbitration에서 패배 와 같은 방식으로 Bus의 우선권을 결정할 수 있다. 9. CAN Arbitration과 비슷한 점 CAN을 공부했다면 이 구조가 익숙하게 느껴질 수 있다. CAN에서는 일반적으로 Dominant = 0 Recessive = 1 의 관계를 이용한다. Node A : 1 Node B : 0 Bus : 0 즉 0이 우세하다. I2C 역시 Open-Drain 구조 때문에 1 = Release 0 = LOW로 당김 이므로 여러 장치가 동시에 다른 값을 출력하면 LOW가 우세하게 나타난다. 따라서 I2C와 CAN 모두 공유 Bus에서 실제 Bus 상태를 확인하면서 충돌을 해결할 수 있다는 공통점 이 있다. 다만 두 프로토콜의 Arbitration 목적과 세부 방식은 동일하지 않다. 10. I2C Address와 CAN ID는 같은 개념일까? 둘 다 공유 Bus를 사용하기 때문에 비슷해 보이지만 중요한 차이가 있다. I2C I2C Address는 기본적으로 통신할 장치를 지정하기 위한 주소 이다. 0x48 → "0x48 장치와 통신하겠다." CAN CAN ID는 일반적으로 특정 장치의 주소가 아니라 메시지의 의미와 우선순위 를 나타낸다. 예를 들어 시스템을 다음과 같이 설계할 수 있다. 0x100 → 차량 속도 0x200 → 조향각 0x300 → 브레이크 상태 CAN Bus에 메시지가 전송되면 각 노드는 자신에게 필요한 ID의 메시지를 수신한다. 따라서 다음과 같이 구분할 수 있다. I2C Address → 누구와 통신할 것인가? CAN ID → 어떤 메시지인가? CAN ID를 단순히 "송신자의 주소"라고 이해하면 안 된다. 11. I2C의 장점 1) 필요한 선이 적다 SDA와 SCL 두 개의 신호선만으로 여러 장치를 연결할 수 있다. 2) 여러 장치를 연결하기 쉽다 SPI처럼 Peripheral마다 별도의 CS 선을 추가할 필요가 없다. 3) 주소 기반 통신 각 장치를 Address로 구분할 수 있어 여러 Peripheral을 하나의 Bus에 연결하기 편리하다. 4) ACK/NACK 지원 상대 장치의 응답 여부를 프로토콜 수준에서 확인할 수 있다. 12. I2C의 단점 1) SPI보다 일반적으로 속도가 느리다 주소, ACK/NACK 등의 제어 과정이 존재하고 SDA 하나를 양방향으로 사용하기 때문에 고속 데이터 전송이 중요한 상황에서는 SPI가 더 적합한 경우가 많다. 2) Pull-up 저항이 필요하다 Open-Drain 방식이므로 SDA와 SCL에 적절한 Pull-up 저항이 필요하다. 3) Bus의 전기적 특성을 고려해야 한다 연결된 장치와 배선이 늘어나면 Bus capacitance가 증가하고 신호의 상승 시간이 길어질 수 있다. 따라서 무조건 많은 장치를 연결할 수 있는 것은 아니다. 4) 주소 충돌 가능성이 있다 동일한 I2C Address를 사용하는 장치 여러 개를 같은 Bus에 연결하면 문제가 발생할 수 있다. 일부 센서는 핀 설정 등을 통해 주소를 변경할 수 있지만, 그렇지 않은 경우 I2C Multiplexer 등의 추가적인 방법이 필요할 수 있다. 13. 어떤 상황에서 I2C를 사용할까? I2C는 특히 MCU에 여러 개의 저속 주변 장치를 연결해야 하는 상황 에서 유용하다. 대표적으로 다음과 같은 장치에 많이 사용된다. 온도/습도 센서 IMU RTC EEPROM 각종 환경 센서 설정용 IC 소형 디스플레이 컨트롤러 예를 들어 MCU에 온도 센서, 가속도 센서, EEPROM을 연결한다고 하자. SPI라면 각각의 장치를 선택하기 위한 CS가 필요할 수 있지만 I2C에서는 SDA/SCL Bus를 공유하면서 주소로 장치를 선택할 수 있다. 따라서 GPIO를 아끼면서 여러 저속 Peripheral을 연결해야 할 때 I2C가 좋은 선택지가 될 수 있다. 14. SPI / I2C / CAN 비교 특징 SPI I2C CAN 주요 용도 칩 간 고속 통신 칩 간 주변장치 통신 여러 제어기 간 통신 기본 구조 Controller-Peripheral 공유 Bus 공유 Bus Clock 있음 있음 별도 Clock 선 없음 장치 구분 CS Address Message ID 기반 데이터선 MOSI/MISO SDA CAN-H/CAN-L 여러 장치 연결 CS 증가 Address 사용 Bus 공유 Arbitration 일반적인 SPI에는 없음 Multi-Controller에서 존재 존재 ACK 기본 SPI에는 없음 ACK/NACK ACK 메커니즘 존재 결국 세 통신의 차이를 아주 간단하게 기억하면 다음과 같다. SPI: CS로 "너 나와." I2C: Address로 "0x48 너 나와." CAN: ID로 "차량 속도 메시지 보낸다. 필요한 노드는 받아." 15. 문제 상황과 해결 방법 문제 1. 같은 주소를 가진 장치가 여러 개 존재한다 원인 같은 I2C Address를 가진 Peripheral을 하나의 Bus에 연결하면 Controller가 원하는 장치를 구분하기 어렵다. 해결 장치의 Address 설정 핀 사용 소프트웨어로 주소 변경이 가능한 장치라면 주소 변경 I2C Multiplexer 사용 Bus 분리 문제 2. 통신 속도를 높였더니 신호가 정상적으로 올라오지 않는다 원인 I2C의 HIGH는 장치가 직접 출력하는 것이 아니라 Pull-up 저항을 통해 형성된다. 따라서 Bus capacitance와 Pull-up 저항값에 따라 상승 시간이 영향을 받는다. LOW ────────╮ ╰──── HIGH ↑ 상승 시간 발생 해결 적절한 Pull-up 저항값 선정 배선 길이 감소 연결된 장치 수 및 Bus capacitance 확인 필요하다면 통신 속도 감소 문제 3. Peripheral이 응답하지 않는다 확인할 사항 Slave/Target Address가 올바른가? SDA/SCL 연결이 올바른가? Pull-up 저항이 존재하는가? ACK가 정상적으로 발생하는가? 통신 속도를 해당 장치가 지원하는가? 전원 및 GND 연결이 정상적인가? 16. 면접에서 나올 수 있는 질문 Q. I2C란 무엇인가요? I2C는 SDA와 SCL 두 개의 신호선을 사용하는 동기식 직렬 통신 프로토콜입니다. 여러 장치가 동일한 Bus를 공유하고 Address를 이용해 통신할 장치를 선택할 수 있다는 특징이 있습니다. Q. SPI와 I2C의 가장 큰 차이는 무엇인가요? SPI는 일반적으로 CS를 이용해 Peripheral을 선택하고 MOSI/MISO를 이용해 송수신합니다. 반면 I2C는 SDA와 SCL 두 선을 여러 장치가 공유하며 Address를 통해 통신할 장치를 선택합니다. Q. I2C에서 Pull-up 저항이 필요한 이유는 무엇인가요? I2C의 SDA와 SCL이 Open-Drain 방식이기 때문입니다. 장치는 LOW는 직접 만들 수 있지만 HIGH를 직접 출력하지 않기 때문에, 아무 장치도 LOW로 당기지 않을 때 신호를 HIGH 상태로 만들기 위해 Pull-up 저항이 필요합니다. Q. Open-Drain의 장점은 무엇인가요? 여러 장치가 하나의 Bus를 공유하면서 서로 다른 값을 출력하더라도 HIGH와 LOW를 직접 밀어내는 방식의 충돌을 피할 수 있습니다. 또한 실제 Bus 상태를 확인할 수 있기 때문에 Multi-Controller 환경의 Arbitration 같은 동작도 가능해집니다. Q. I2C와 CAN의 Arbitration은 같은가요? 둘 다 공유 Bus의 상태를 확인하면서 충돌을 해결한다는 공통점이 있지만 목적과 프로토콜은 다릅니다. CAN에서는 메시지 ID를 이용한 Arbitration이 핵심적으로 사용되고, I2C에서는 여러 Controller가 동시에 Bus를 사용하려 할 때 Arbitration이 필요합니다. Q. I2C Address와 CAN ID의 차이는 무엇인가요? I2C Address는 통신할 대상 장치를 선택하는 주소입니다. 반면 CAN ID는 일반적으로 송수신 장치의 주소가 아니라 메시지의 의미와 우선순위를 나타냅니다. 정리 I2C의 핵심 흐름은 다음과 같이 연결해서 이해할 수 있다. 여러 장치를 하나의 Bus에 연결하고 싶다. ↓ SDA / SCL을 여러 장치가 공유한다. ↓ CS 대신 Address로 장치를 구분한다. ↓ 그런데 여러 장치가 같은 선을 사용한다. ↓ HIGH/LOW를 각자 직접 출력하면 충돌할 수 있다. ↓ Open-Drain을 사용한다. ↓ 장치는 LOW만 직접 만들고 HIGH에서는 선을 Release한다. ↓ 그렇다면 HIGH를 만들어 줄 것이 필요하다. ↓ Pull-up 저항을 사용한다. ↓ 한 장치라도 LOW로 당기면 Bus는 LOW가 된다. ↓ 이 특성을 이용하면 공유 Bus에서 Arbitration도 가능하다. 결국 I2C의 핵심은 두 개의 선을 여러 장치가 공유하면서, Address와 Open-Drain 구조를 이용해 안전하게 통신하는 것 이라고 정리할 수 있다.
velog
평소 Python으로 문제를 풀다가 Java로 코딩테스트를 봐야 할 때, 손에서 바로 나와야 하는 것들만 모았다. 알고리즘 이론보다 "Java로 생각을 코드로 옮기는 속도"에 초점을 맞췄다. (Java 17 기준) 1. 기본 템플릿과 입출력 오프라인 IDE 테스트는 채점기가 없으므로 main 에서 직접 함수를 호출해 결과를 출력하는 형태가 기본이다. 입력이 주어지는 경우엔 BufferedReader 가 안전하다. import java.util.*; import java.io.*; import java.util.stream.*; public class Main { public static void main(String[] args) throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); StringTokenizer st = new StringTokenizer(br.readLine()); int n = Integer.parseInt(st.nextToken()); int m = Integer.parseInt(st.nextToken()); int[] arr = Arrays.stream(br.readLine().split(" ")) .mapToInt(Integer::parseInt).toArray(); StringBuilder sb = new StringBuilder(); for (int x : arr) sb.append(x).append('\n'); System.out.print(sb); } } 상황 쓰는 것 비고 입력 적음, 빨리 짜기 Scanner sc = new Scanner(System.in); sc.nextInt() , sc.next() , sc.nextLine() nextInt() 뒤 nextLine() 은 개행 하나 먹으니 한 번 더 호출 입력 10만 줄 이상 BufferedReader + StringTokenizer readLine() 은 String 반환, 파싱 필요 출력 많음 StringBuilder 에 모아 마지막에 한 번 출력 System.out.println 반복은 느림 한 줄 정수 여러 개 Arrays.stream(line.split(" ")).mapToInt(Integer::parseInt).toArray() 문자열 → 숫자 Integer.parseInt(s) , Long.parseLong(s) , Double.parseDouble(s) 숫자 → 문자열 String.valueOf(n) , Integer.toString(n) , "" + n 진법 변환 Integer.toBinaryString(n) , Integer.toString(n, 2) , Integer.parseInt("1011", 2) 포맷 출력 String.format("%.2f", d) , System.out.printf("%d %s%n", a, b) 과제형(플레이리스트 만들기 등)이면 입력 파싱 대신 main 에 테스트 데이터를 직접 만들어 호출하고, 결과를 println 으로 보여주면 된다. 클래스 여러 개는 한 파일에 class Song {} 처럼 public 없이 두면 된다. 2. String / StringBuilder / char String은 불변이라 반복 += 는 O(n2)다. 문자열을 만들거나 바꿀 땐 무조건 StringBuilder . 할 일 코드 길이 s.length() (배열은 arr.length , 리스트는 list.size() ) i번째 문자 s.charAt(i) → char 부분 문자열 s.substring(from, to) (to 미포함), s.substring(from) 문자 배열로 s.toCharArray() / 다시 문자열로 new String(chars) 분리 s.split(" ") , s.split(",") — 정규식이라 \\. , `\ 합치기 String.join(",", list) , String.join(" ", arr) 포함·위치 s.contains("ab") , s.indexOf("ab") (없으면 -1), s.startsWith , s.endsWith 바꾸기 s.replace("a", "b") (전부), s.replaceAll("[0-9]", "") (정규식) 대소문자 s.toUpperCase() , s.toLowerCase() 공백 제거 s.trim() , s.strip() 비교 s.equals(t) (절대 == 금지), s.equalsIgnoreCase(t) , s.compareTo(t) (사전순, 음수/0/양수) 비었는지 s.isEmpty() , s.isBlank() 반복 "ab".repeat(3) 문자 판별 Character.isDigit(c) , isLetter(c) , isUpperCase(c) , isAlphabetic(c) 문자 ↔ 숫자 c - '0' (숫자 문자→int), (char)(n + '0') , c - 'a' (알파벳 인덱스) 문자 변환 Character.toUpperCase(c) , Character.toLowerCase(c) StringBuilder sb = new StringBuilder(); sb.append("abc").append(1).append('x'); sb.insert(0, "z"); // 앞에 삽입 sb.deleteCharAt(sb.length() - 1); sb.setCharAt(0, 'q'); sb.reverse(); // 뒤집기 — 팰린드롬 판별에 바로 쓰임 sb.setLength(0); // 비우기 String result = sb.toString(); 문자열 뒤집기는 new StringBuilder(s).reverse().toString() . 문자 정렬은 char[] c = s.toCharArray(); Arrays.sort(c); new String(c) . 아스키 연산은 char 가 정수로 자동 승격되니 (char)(c + 1) 처럼 캐스팅 필요. 3. 배열과 Arrays 유틸 크기가 고정이면 배열, 늘어나면 ArrayList . int[] 와 Integer[] 는 다른 타입이라 Comparator 정렬은 Integer[] 에만 된다. int[] a = new int[n]; // 0으로 초기화 int[] b = {3, 1, 2}; int[][] grid = new int[r][c]; boolean[][] visited = new boolean[r][c]; Arrays.fill(a, -1); // 전체 채우기 for (int[] row : grid) Arrays.fill(row, Integer.MAX_VALUE); // 2차원은 행별로 Arrays.sort(a); // 오름차순 (기본형은 역순 직접 불가) Integer[] boxed = {3, 1, 2}; Arrays.sort(boxed, Collections.reverseOrder()); Arrays.sort(a, from, to); // 구간 정렬 int[] copy = Arrays.copyOf(a, a.length); int[] part = Arrays.copyOfRange(a, 1, 4); // [1,4) int[] c2 = a.clone(); Arrays.toString(a); // 디버그 출력 "[1, 2, 3]" Arrays.deepToString(grid); // 2차원 Arrays.equals(a, b); Arrays.stream(a).sum(); .max().getAsInt(); .min().getAsInt(); Arrays.asList(boxed); // 고정 크기 List (add 불가) List<Integer> list = new ArrayList<>(Arrays.asList(boxed)); // 수정 가능 List<Integer> li = Arrays.stream(a).boxed().collect(Collectors.toList()); // int[] → List int[] back = li.stream().mapToInt(Integer::intValue).toArray(); // List → int[] int idx = Arrays.binarySearch(a, key); // 정렬된 배열만, 없으면 음수 2차원 배열 정렬은 Arrays.sort(arr, (x, y) -> x[0] - y[0]) (int[][] 은 객체 배열이라 가능). 방향 배열은 int[] dr = {-1, 1, 0, 0}; int[] dc = {0, 0, -1, 1}; 로 두고 범위 검사 if (nr < 0 || nr >= R || nc < 0 || nc >= C) continue; 를 반드시 먼저. 4. 컬렉션 핵심 과제형 문제의 8할은 ArrayList + HashMap 으로 끝난다. 선언은 인터페이스 타입으로, 제네릭은 래퍼 타입( Integer , Long , Character )만 가능. List List<Integer> list = new ArrayList<>(); list.add(x); list.add(0, x); list.get(i); list.set(i, x); list.remove(i); // 인덱스로 삭제 list.remove(Integer.valueOf(x)); // 값으로 삭제 (int면 인덱스로 오해함) list.size(); list.isEmpty(); list.contains(x); list.indexOf(x); Collections.sort(list); list.sort(null); list.sort(Comparator.reverseOrder()); Collections.reverse(list); Collections.max(list); Collections.min(list); Collections.swap(list, i, j); list.subList(from, to); // 뷰(복사 아님) new ArrayList<>(list); // 복사 List.of(1, 2, 3); // 불변 Map Map<String, Integer> map = new HashMap<>(); map.put(k, v); map.get(k); // 없으면 null → int에 넣으면 NPE map.getOrDefault(k, 0); map.put(k, map.getOrDefault(k, 0) + 1); // 카운팅 기본 map.merge(k, 1, Integer::sum); // 카운팅 한 줄 map.computeIfAbsent(k, x -> new ArrayList<>()).add(v); // 그룹핑 한 줄 map.containsKey(k); map.containsValue(v); map.remove(k); map.size(); for (Map.Entry<String, Integer> e : map.entrySet()) { e.getKey(); e.getValue(); } for (String k : map.keySet()) {} for (int v : map.values()) {} TreeMap<Integer, String> tm = new TreeMap<>(); // 키 정렬 유지 tm.firstKey(); tm.lastKey(); tm.floorKey(x); tm.ceilingKey(x); tm.headMap(x); tm.tailMap(x); LinkedHashMap<> // 삽입 순서 유지 Set Set<Integer> set = new HashSet<>(); set.add(x); set.contains(x); set.remove(x); set.size(); new HashSet<>(list).size(); // 중복 제거 개수 TreeSet<Integer> ts = new TreeSet<>(); // 정렬 유지 ts.first(); ts.last(); ts.floor(x); ts.ceiling(x); ts.pollFirst(); Deque (스택·큐 둘 다) Deque<Integer> dq = new ArrayDeque<>(); // 큐: offer / poll / peek (뒤에 넣고 앞에서 뺀다) // 스택: push / pop / peek (앞에 넣고 앞에서 뺀다) dq.offerFirst(x); dq.offerLast(x); dq.pollFirst(); dq.pollLast(); dq.peekFirst(); dq.peekLast(); dq.isEmpty(); // Stack 클래스는 느리고 권장 안 함. ArrayDeque는 null 불가. PriorityQueue PriorityQueue<Integer> pq = new PriorityQueue<>(); // 최소 힙 PriorityQueue<Integer> maxPq = new PriorityQueue<>(Collections.reverseOrder()); PriorityQueue<int[]> pq2 = new PriorityQueue<>((a, b) -> a[1] - b[1]); // 배열 우선순위 pq.offer(x); pq.poll(); pq.peek(); pq.size(); pq.isEmpty(); 자료구조 언제 주요 연산 비용 ArrayList 순서 있는 목록, 인덱스 접근 get O(1), 중간 삽입/삭제 O(n) HashMap / HashSet 카운팅, 중복 체크, 키→값 조회 O(1) TreeMap / TreeSet 정렬 상태 유지, 범위 검색 O(log n) LinkedHashMap 삽입 순서 유지하는 Map (LRU 등) O(1) ArrayDeque 큐, 스택, BFS O(1) PriorityQueue 항상 최소/최대 꺼내기, 다익스트라 O(log n) 5. 정렬과 Comparator 람다 (a, b) -> a - b 는 오름차순, b - a 는 내림차순. 값이 클 수 있으면 뺄셈 대신 Integer.compare(a, b) 를 써야 오버플로우를 피한다. // 기본 Collections.sort(list); list.sort(Comparator.reverseOrder()); Arrays.sort(arr2d, (a, b) -> a[0] - b[0]); // 다중 조건: 점수 내림차순, 같으면 이름 오름차순 list.sort((a, b) -> { if (a.score != b.score) return Integer.compare(b.score, a.score); return a.name.compareTo(b.name); }); // 같은 것을 Comparator 체이닝으로 (면접에서 더 좋아 보임) list.sort(Comparator.comparingInt((Song s) -> s.score).reversed() .thenComparing(s -> s.name)); // 문자열 길이 순, 같으면 사전순 words.sort(Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder())); // Map을 값 기준으로 정렬해 상위 N개 List<Map.Entry<String, Integer>> entries = new ArrayList<>(map.entrySet()); entries.sort((a, b) -> b.getValue() - a.getValue()); 객체가 기본 정렬 기준을 가지게 하려면 Comparable 구현: class Song implements Comparable<Song> { String title; int plays; Song(String title, int plays) { this.title = title; this.plays = plays; } @Override public int compareTo(Song o) { return Integer.compare(o.plays, this.plays); } // 재생수 내림차순 @Override public String toString() { return title + "(" + plays + ")"; } } Collections.sort 와 Arrays.sort(객체) 는 안정 정렬(TimSort)이라 같은 키는 원래 순서 유지. Arrays.sort(int[]) 는 듀얼 피벗 퀵소트로 최악 O(n2)가 있으나 실무 테스트에선 신경 안 써도 됨. 6. Stream 핵심 패턴 과제형에서 "필터링해서 정렬해서 상위 N개" 같은 요구는 Stream 한 줄이 가장 읽기 좋다. 다만 성능 질문이 나오면 "반복문과 동일한 O(n), 가독성 때문에 썼다"고 답하면 된다. import java.util.stream.*; // 필터 + 변환 + 수집 List<String> titles = songs.stream() .filter(s -> s.plays > 100) .map(s -> s.title) .collect(Collectors.toList()); // Java 16+ 면 .toList() // 정렬 + 상위 N List<Song> top3 = songs.stream() .sorted(Comparator.comparingInt((Song s) -> s.plays).reversed()) .limit(3) .collect(Collectors.toList()); // 그룹핑: 아티스트별 곡 목록 / 개수 / 합계 Map<String, List<Song>> byArtist = songs.stream().collect(Collectors.groupingBy(s -> s.artist)); Map<String, Long> countByArtist = songs.stream().collect(Collectors.groupingBy(s -> s.artist, Collectors.counting())); Map<String, Integer> playsByArtist = songs.stream().collect(Collectors.groupingBy(s -> s.artist, Collectors.summingInt(s -> s.plays))); // 집계 int total = songs.stream().mapToInt(s -> s.plays).sum(); Optional<Song> best = songs.stream().max(Comparator.comparingInt(s -> s.plays)); double avg = songs.stream().mapToInt(s -> s.plays).average().orElse(0); boolean any = songs.stream().anyMatch(s -> s.plays == 0); // allMatch, noneMatch long cnt = songs.stream().filter(s -> s.plays > 0).count(); // 중복 제거, 문자열 합치기 List<String> artists = songs.stream().map(s -> s.artist).distinct().collect(Collectors.toList()); String joined = titles.stream().collect(Collectors.joining(", ")); // 범위 반복, 문자열 문자 순회 IntStream.range(0, n).forEach(i -> ...); // 0..n-1 IntStream.rangeClosed(1, n).sum(); s.chars().filter(Character::isDigit).count(); // List → Map (키 중복 시 터짐, 세 번째 인자로 병합 규칙) Map<String, Song> byTitle = songs.stream().collect(Collectors.toMap(s -> s.title, s -> s, (a, b) -> a)); Optional 은 .orElse(기본값) , .orElseThrow() , .isPresent() , .get() 로 꺼낸다. Collectors.toList() 로 만든 리스트는 수정 가능, .toList() (Java 16+)는 불변이라 add 하면 예외. 7. 자주 나오는 알고리즘 패턴 1시간 오프라인 테스트에서 실제로 나오는 건 이 여섯 개가 거의 전부다. 각각 손에서 바로 나와야 한다. 완전탐색 (순열·조합·부분집합) // 조합: n개 중 r개 고르기 static void comb(int[] arr, int start, int r, List<Integer> cur, List<List<Integer>> out) { if (cur.size() == r) { out.add(new A
velog
State 백엔드 공부를 하며 각 서버가 state를 가지는가 ? 상태를 가지는가 ? 에 대한 고민을 해본적이 없었다. 아니 사실은 state가 뭔지도 잘 몰랐다. 스프링 공부를 깊게 하게 되면서 서버는 state를 가지는것이 좋은 서버가 아니라는것을 배우게 되면서 그렇다면 여태까지 내가 빌드한 서버들은 모두 state를 가지는가? 에 대한 물음표가 생기게 되었다. 🍃 스프링의 Singleton 디자인패턴과 비슷한 느낌이다. 상태를 가지는 서버가 여러개있다면, 동시성 제어의 어려움부터 시작해 운영이 복잡해질 것이고 무엇보다 Scale-out 수평적 확장에 무리가 있기 때문에 좋은 설계가 아니다. 그렇다면 state란 무엇인가 ? state는 단어의 뜻 그대로 상태를 말한다. 싱글톤 패턴이 아니라 하나의 클래스에 대하여 여러개의 인스턴스가 존재할때, 각 인스턴스마다 고유한 상태 (변수 , 메타 데이터 등등 ) 을 가질 것이다. 클래스와 인스턴스 범위에서 보게되면 , 인스턴스의 로컬 변수가 state를 말할 수도 있고, 보다 넓은 서버 범위에서 보게되면 세션을 유지하는 경우 state를 가진다 말할 수 있고, Scheduler 정해진 시각에 어떤 기능을 수행하는 경우도 state를 가진다고 말할 수 있을것이다. State : 과거의 상호작용 결과에 따라 미래의 요청 처리 결과가 달라지게 만드는 정보 보통의 stateless 한 서버들은 상호작용의 결과를 서버에서 가지고 있는것이 아니라, 서버들간의 공유된 DB에 저장해두거나, Redis 와 같은 인메모리와 소통할 수도 있을것이다. 다시 두가지의 차이점을 정의해보자면 Stateful Server : 해당 서버가 이전 요청에 대한 상태를 저장해, 장애가 발생했을때 다른 서버가 처리하기 힘든 서버. Stateless Server : 해당 서버가 장애가 발생해도 다른 서버가 그 요청을 처리할 수 있는 서버. Stateful 그렇다면 상태를 유지해야하는 stateful 서버로 구현해야하는 경우는 어떤 경우가 있을까 ? 클라이언트가 세션을 유지해야하는 WebSocket 서버 실시간 통신이 이루어지기 위해서는 클라이언트의 세션이 계속 유지되어야한다. 대용량 파일 업로드 처리 한번의 요청으로 처리하지 못할정도의 대용량 파일의 경우에도 클라이언트와 서버가 대용량 업로드가 끝날때까지 상태를 유지해야하는 경우다. Stream 처리 서버 Kafka Streams, Flink 같은 시스템에서 지난 5분간 사용자별 요청 수, 누적 합계, window aggregation 등을 로컬 state store에 유지하면 Stateful processing이다. Scheduler 기능 정해진 시간에 연산이 수행되는 경우, 모든 서버들이 연산을 수행하게 되면 N번 연산이 수행될 수 있을것이다. 그래서 Redis , DB와 같은 공유 시스템에 상태를 외부화해야한다. 그래서 서버 자체는 stateless 상태가 존재하긴 하지만, 서버에는 상태가 없다. Scale-Out 수평적 확장 인프라를 구성할때에 수평적 확장을 항상 고려해야한다. 확장을 고려하지 않은 설계는 언제 트래픽이 몰려 서버를 확장해야할지 모르는데, 서비스의 성장을 전혀 염두에 두고 있지 않은 나쁜 설계라고 생각한다. 그리고 수평적 확장이라함은 서버 인스턴스 갯수를 늘린다는 것인데, 단순히 서버만 늘린다고 TPS Throughput 이 증가하지는 않을 것이다. 당연히 그에 상응하는 DB도 복제가 되야할 것이다. DB 복제와 샤딩에 대해서는 이번 포스트에서 다루기에는 무거운 주제이니 인스턴스의 수평확장만을 고려해보면 , stateless 서버는 확장에 전혀 어려움이 없어 보인다. 서버들 모두가 상태를 저장하고 있지 않으니, 수평적 확장을 하여서 상태를 전달받거나 전처리를 해야하는 과정이 없다. stateful 서버는 당연하게도 확장에서 신경써야할 부분이 많을 것이다. 그렇다면 무조건 state, stateless 서버는 분리되어야하나 ? 아니다. 모놀리식 서버에서 state , stateless 기능을 모두 다 가지고 있을 수 있다. 확장의 경우에는 서버 앞단의 API Gateway 에서 state, stateless 요청을 다르게 받아서 각 기능들의 요청에 따라 scale-out 을 결정하게 하는 구조로 두면 된다. Thunder Heard , Fan-out Post Service │ │ CommentCreated ▼ Event / Message Layer │ ┌───┼───────────────┐ ▼ ▼ ▼ WS GW 1 WS GW 2 WS GW 3 │ │ │ ├─ User A ├─ User D ├─ User G ├─ User B ├─ User E ├─ User H └─ User C └─ User F └─ User I 각 Gateway 내부에서 Local Fan-out Websocket 기능을 가진 gateway를 통한 MSA 구조를 예를 들어 설명해보자. 어떤 Event가 발생하게 되면 그 이벤트의 구독자인 gateway가 이벤트가 발생한것을 'Heard' 듣게 되면,N개의 gateway가 DB Read연산을 수행해 갑작스러운 QPS 의 기하급수적 상승이 있을 수 있다. (Thunder Heard 문제) 그래서 EDA, Redi Stream을 활용한 Local-Fan-Out구조로 위의 문제를 해결하는 설계이다. Event는 페이지를 새로고침하거나, 사용자에게 줘야하는 최소의 정보를 가진 payload를 담아 발행된다. 그럼 그 Event의 구독자인 WS gateway는 DB를 읽는 것이 아니라, Event Payload를 읽고 WS gateway와 연결된 N개의 클라이언트들에게 정보를 전달한다. 이 구조로 실질적인 DB 연산은 이벤트 발행에 따른 쓰기 연산 한번이 일어난 것이다. 이러한 설계는 DB 부하를 줄이는 것뿐 아니라 Stateful한 WebSocket 서버를 수평 확장할 때도 장점이 있다. 새로운 WebSocket Gateway 인스턴스가 추가되더라도 해당 인스턴스는 새롭게 연결되는 Socket만 관리하면 되고, 이벤트를 전달받은 뒤 자신이 보유한 연결에 대해서만 Fan-out하면 된다. 특정 사용자가 어느 Gateway에 연결되어 있는지를 중앙 DB에서 매번 조회할 필요가 없으며, Gateway 내부의 메모리 접근만으로 실제 전달 대상자를 결정할 수 있다. 마무리 Stateful 서버라고 해서 수평적 확장이 불가능한것은 아니다. 새로 생성된 인스턴스에게 세션을 나누어주고, Sticky Session을 유지시키는 오버헤드가 있어서 그렇지, 완전히 불가능한것은 아니다. 보통의 사이드 프로젝트에서 MSA 를 적용하는일은 거의 없기에, stateless, stateful을 잘 구분하여 수평적 확장에 용이한 설계를 해야겠다. 사실 마구잡이로 확장한다고해서 해결되는것은 아니며 사실 DB sharding , DB 복제 및 동기화 등 더 어려운 문제가 나를 기다린다 .. 🥹
hacker-news-frontpage
124 points · 59 comments · by longhaul
Score: 52.25Confidence: 46%
View offer掘金
BizBuddy 是纯 Java 的企业级 Agent Harness 平台:以 AgentScope 2.0.3 为智能体内核、RuoYi-Vue-Plus 为业务底座,落地入口智能体小Z、专家路由
Score: 57.22Confidence: 54%
View offerScore: 54.38Confidence: 49%
Score: 54.37Confidence: 49%
Score: 54.37Confidence: 49%
Score: 54.37Confidence: 49%
Score: 54.37Confidence: 49%
Score: 54.37Confidence: 49%