Repository: yourssu/ssufid
Pull request #17: [feat] cache RwLock으로 감싸기 및 캐시 관련 메서드 구현
Author: login: cometj03
Labels: name: enhancement
description: New feature or request
Created: 2025-03-19T06:08:58Z | Merged: 2025-03-22T02:00:12Z

## #️⃣연관된 이슈

resolves #12 

## 📝작업 내용

> 이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지 첨부 가능)

- SsufidCore의 `cache`의 타입을 `Arc<RwLock<HashMap>>`으로 변경
  - `run`에서 read lock, write lock 얻는 부분 나눠둠
- `read_cache` 구현
  - 특정 id에 해당하는 파일을 읽어서 `Vec<SsufidPost>`로 변환하여 반환
- `save_cache` 구현
  - `cache`의 모든 데이터를 파일로 쓰기

## 💬리뷰 요구사항(선택)

> 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요
>
> ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요?

- 반환 타입의 에러 타입을 어떻게 해야하는지 아직 감이 안 와서 일단 일일이 SsufidError 반환하도록 했는데, 조언이 필요합니다
- 테스트는 별도의 모듈에서 진행하는 게 나을까요?
  - 테스트를 위한 TestPlugin을 만드는 것도 좋을 것 같아요

Linked issue #12: 병렬 수행을 위해 `cache`를 RwLock으로 감싸기
### 📄 설명

#### 배경 설명

- 기존에는 SsufidCore에 HashMap 타입의 캐시를 두었음
  - 포스트 내용 변경 여부를 확인하기 위해 예전 데이터를 참조해야 함
  - 실행할 때마다 매번 file i/o하지 않기 위해 캐시를 두었음
  - 캐시의 키는 플러그인의 IDENTIFIER
- 그런데 HashMap에 데이터를 추가할 수 있어야 하므로 SsufidCore의 mutable reference가 필요함
  - 러스트에서 mutable reference는 공유될 수 없으므로 병렬처리가 불가능해짐
- 이것을 해결하기 위해 캐시를 RwLock으로 감싸자는 의견이 나옴 (참고: #11)
- ~~그런데 어차피 각 플러그인은 각자의 IDENTIFIER에 해당하는 키에만 관여하고 다른 키에는 관여하지 않기 때문에 굳이 lock을 걸어야 할 필요가 없을 것으로 보임. 적어도 더 좋은 방법이 있을 것 같음~~

#### 결론

- 캐시를 RwLock으로 감싸기. 결과 캐시의 타입: `Arc<RwLock<HashMap<String, Vec<SsufidPost>>>>`
- 캐시를 읽는 read_cache 메서드 구현
  - `read_cache(&self, id: &str) -> Result<Vec<SsufidPost>, Err>`

### ✅ 작업할 내용

- SsufidCore의 cache 필드 삭제
- file i/o 로직 추가

### 🙋🏻 참고 자료

_No response_
Linked issue comments: user: login: cometj03
created_at: 2025-03-18T06:14:55Z
body: > 그런데 어차피 각 플러그인은 각자의 IDENTIFIER에 해당하는 키에만 관여하고 다른 키에는 관여하지 않기 때문에 굳이 lock을 걸어야 할 필요가 없을 것으로 보임. 적어도 더 좋은 방법이 있을 것 같음

라고 적긴 했는데, 다시 생각해보니 RwLock으로 감싸면 HashMap에 쓸 때만(insert 할 때만) lock 걸리는 거잖아요. file i/o 자체를 병렬적으로 못하는 건 아니니까 성능상 큰 영향이 없지 않을까요? 그래서 지금은 생각이 다시 캐시를 RwLock으로 감싸는 게 낫겠다는 쪽으로 기울었습니다. 캐시라는 것이 주는 이점이 더 큰 것 같아서요(로직이 더 단순해지고 여러 번 file i/o를 하지 않아도 됨).
이것에 대해 어떻게 생각하시나요?
@EATSTEAK @2wndrhs 
author_association: MEMBER
user: login: EATSTEAK
created_at: 2025-03-18T06:29:12Z
body: 저도 사실 캐시를 없애자는 제안은 아니였는데, 댓글 보다 보니까 그냥 `HashMap<String, RwLock<Vec<SsufidPost>>>` 하면 모든 문제 해결일듯요??
author_association: COLLABORATOR
user: login: cometj03
created_at: 2025-03-18T06:36:21Z
body: 오 그렇게도 할 수 있겠네요 ㅋㅋㅋ 실제로 한번 해보고 가능한지 말씀드릴게요!
author_association: MEMBER
user: login: 2wndrhs
created_at: 2025-03-18T07:56:59Z
body: 동시 접근에 최적화된 [dashmap](https://github.com/xacrimon/dashmap)이라는 크레이트도 있네요.
author_association: MEMBER
user: login: EATSTEAK
created_at: 2025-03-18T08:42:08Z
body: > 동시 접근에 최적화된 [dashmap](https://github.com/xacrimon/dashmap?rgh-link-date=2025-03-18T07%3A56%3A59.000Z)이라는 크레이트도 있네요.

ㄹㅈㄷ;; 신이 여깄었네
author_association: COLLABORATOR

Diff:
diff --git a/src/core.rs b/src/core.rs
index 800381e..21b2903 100644
--- a/src/core.rs
+++ b/src/core.rs
@@ -1,10 +1,12 @@
-use std::collections::HashMap;
+use std::{collections::HashMap, sync::Arc, vec};
 
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 use time;
+use tokio::io::AsyncWriteExt;
+use tokio::sync::RwLock;
 
-#[derive(Serialize, Deserialize, Debug)]
+#[derive(Serialize, Deserialize, Debug, PartialEq)]
 pub struct SsufidPost {
     id: String,
     title: String,
@@ -15,7 +17,7 @@ pub struct SsufidPost {
     content: String,
 }
 
-#[derive(Serialize, Deserialize, Debug)]
+#[derive(Serialize, Deserialize, Debug, PartialEq)]
 pub struct SsufidSiteData {
     title: String,
     source: String,
@@ -31,47 +33,131 @@ impl SsufidSiteData {
 }
 
 pub struct SsufidCore {
-    cache: HashMap<String, Vec<SsufidPost>>,
-    #[allow(dead_code)]
+    cache: Arc<RwLock<HashMap<String, Vec<SsufidPost>>>>,
     cache_dir: String,
 }
 
 impl SsufidCore {
     pub fn new(cache_dir: &str) -> Self {
         Self {
-            cache: HashMap::new(),
+            cache: Arc::new(RwLock::new(HashMap::new())),
             cache_dir: cache_dir.to_string(),
         }
     }
 
-    pub async fn run<T: SsufidPlugin>(&mut self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
+    pub async fn run<T: SsufidPlugin>(&self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
         let new_entries = plugin.crawl().await?;
-        #[allow(unused_variables)]
-        let old_entries = match self.cache.get(T::IDENTIFIER) {
-            Some(entries) => entries,
-            None => todo!("retrieve cache from file"),
-        };
-        self.cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        let cache = Arc::clone(&self.cache);
+        let updated_entries = {
+            // read lock scope
+            let cache = cache.read().await;
+            #[allow(unused_variables)]
+            let old_entries = match cache.get(T::IDENTIFIER) {
+                Some(entries) => entries,
+                None => &self.read_cache(T::IDENTIFIER).await?,
+            };
 
-        // Compare with new and old: `updated_at` 설정
-        // and return the result
-        todo!()
+            // Compare with new and old: `updated_at` 설정
+            // and return the result
+            vec![]
+        };
+        {
+            // write lock scope
+            let mut cache = cache.write().await;
+            cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        }
+        Ok(SsufidSiteData {
+            title: "TODO".to_string(), // T::TITLE
+            source: T::IDENTIFIER.to_string(),
+            description: "TODO".to_string(), // T::DESC
+            items: updated_entries,
+        })
     }
 
-    pub async fn save_cache(&self) -> Result<(), std::io::Error> {
+    pub async fn save_cache(&self) -> Result<(), SsufidError> {
         // Save all caches into files
-        todo!()
+        let cache = Arc::clone(&self.cache);
+        let cache = cache.read().await;
+        let dir = std::path::Path::new(&self.cache_dir);
+        tokio::fs::create_dir_all(dir).await.unwrap();
+
+        for (id, posts) in &*cache {
+            let json = serde_json::to_string_pretty(&posts).unwrap();
+            let mut file = tokio::fs::File::create(dir.join(format!("{id}.json"))).await?;
+            file.write_all(json.as_bytes()).await.unwrap();
+        }
+        Ok(())
+    }
+
+    async fn read_cache(&self, id: &str) -> Result<Vec<SsufidPost>, SsufidError> {
+        let path = std::path::Path::new(&self.cache_dir).join(format!("{id}.json"));
+        let content = tokio::fs::read_to_string(&path).await?;
+        let items: Vec<SsufidPost> = serde_json::from_str(&content)?;
+        Ok(items)
     }
 }
 
 pub trait SsufidPlugin {
     const IDENTIFIER: &'static str;
-    fn crawl(&self) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
+    fn crawl(
+        &self,
+    ) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
 }
 
 #[derive(Debug, Error)]
 pub enum SsufidError {
-    #[error("crawl error")]
-    CrawlError,
-    // TODO: 다양한 에러 타입 정의
+    #[error("Crawl error: {0}")]
+    CrawlError(String),
+
+    #[error("File I/O error: {0}")]
+    FileIOError(#[from] std::io::Error),
+    
+    #[error("Serialization error: {0}")]
+    SerializationError(#[from] serde_json::Error),
+}
+
+// 임시 테스트
+#[cfg(test)]
+mod tests {
+    use tokio::io::AsyncWriteExt;
+
+    use super::{SsufidCore, SsufidPost};
+
+    #[tokio::test]
+    async fn core_read_cache_test() {
+        let mock = vec![
+            SsufidPost {
+                id: "asdf".to_string(),
+                title: "asdf".to_string(),
+                category: "asdf".to_string(),
+                url: "asdf".to_string(),
+                created_at: time::OffsetDateTime::now_utc(),
+                updated_at: None,
+                content: "asdf".to_string(),
+            },
+            SsufidPost {
+                id: "asdf".to_string(),
+                title: "asdf".to_string(),
+                category: "asdf".to_string(),
+                url: "asdf".to_string(),
+                created_at: time::OffsetDateTime::now_utc(),
+                updated_at: Some(time::OffsetDateTime::now_utc()),
+                content: "asdf".to_string(),
+            },
+        ];
+
+        // write mock data
+        let test_data_str = serde_json::to_string_pretty(&mock).unwrap();
+        let dir = std::path::Path::new("./.ssufid/cache_test");
+        tokio::fs::create_dir_all(dir).await.unwrap();
+        let mut test_file = tokio::fs::File::create(dir.join("test.json"))
+            .await
+            .unwrap();
+        test_file.write_all(test_data_str.as_bytes()).await.unwrap();
+
+        // read data and compare
+        let core = SsufidCore::new("./.ssufid/cache_test");
+        let read_data = core.read_cache("test").await.unwrap();
+        assert_eq!(mock, read_data);
+    }
 }
diff --git a/src/lib.rs b/src/lib.rs
index c284380..bc0d33a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,2 +1,2 @@
 pub mod core;
-pub mod plugins;
\ No newline at end of file
+pub mod plugins;
diff --git a/src/main.rs b/src/main.rs
index 9108bb2..f317cad 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5,7 +5,7 @@ use tokio::io::AsyncWriteExt;
 
 #[tokio::main]
 async fn main() -> eyre::Result<()> {
-    let mut core = SsufidCore::new("./.ssufid/cache");
+    let core = SsufidCore::new("./.ssufid/cache");
 
     let site = core.run(ExamplePlugin).await?;
 
@@ -18,5 +18,7 @@ async fn main() -> eyre::Result<()> {
     // let example_rss = site.to_rss()?;
     // TODO: Write rss structure to xml
     example_file.write_all(example_json.as_bytes()).await?;
+
+    core.save_cache().await?;
     Ok(())
 }


Reviews:
user: login: 2wndrhs
body: 수고하셨습니다 👍
테스트 모듈 분리는 추후 테스트 케이스가 늘어났을 때 하면 좋을 것 같아요!
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T09:43:36Z
user: login: cometj03
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T09:47:25Z
user: login: EATSTEAK
body: 단순 궁금) HashMap<String, RwLock<Vec<SsufidPost>>> 를 사용할 때 어떤 어려움이 있었을까요?
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T09:52:18Z
user: login: 2wndrhs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T09:53:41Z
user: login: 2wndrhs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:00:55Z
user: login: cometj03
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T13:32:06Z
user: login: cometj03
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T13:35:39Z
user: login: cometj03
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T13:36:33Z
user: login: cometj03
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T13:37:01Z
user: login: EATSTEAK
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-21T14:55:27Z
user: login: 2wndrhs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T16:38:14Z
user: login: 2wndrhs
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-21T16:39:10Z
user: login: cometj03
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-22T01:55:09Z

Review comments:
diff_hunk: @@ -31,47 +33,144 @@ impl SsufidSiteData {
 }
 
 pub struct SsufidCore {
-    cache: HashMap<String, Vec<SsufidPost>>,
+    cache: Arc<RwLock<HashMap<String, Vec<SsufidPost>>>>,
     #[allow(dead_code)]
     cache_dir: String,
 }
 
 impl SsufidCore {
     pub fn new(cache_dir: &str) -> Self {
         Self {
-            cache: HashMap::new(),
+            cache: Arc::new(RwLock::new(HashMap::new())),
             cache_dir: cache_dir.to_string(),
         }
     }
 
-    pub async fn run<T: SsufidPlugin>(&mut self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
+    pub async fn run<T: SsufidPlugin>(&self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
         let new_entries = plugin.crawl().await?;
+        let cache = Arc::clone(&self.cache);
         #[allow(unused_variables)]
-        let old_entries = match self.cache.get(T::IDENTIFIER) {
-            Some(entries) => entries,
-            None => todo!("retrieve cache from file"),
-        };
-        self.cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        let updated_entries = {
+            // read lock scope
+            let cache = cache.read().await;
+            #[allow(unused_variables)]
+            let old_entries = match cache.get(T::IDENTIFIER) {
+                Some(entries) => entries,
+                None => &self.read_cache(T::IDENTIFIER).await?,
+            };
 
-        // Compare with new and old: `updated_at` 설정
-        // and return the result
-        todo!()
+            // Compare with new and old: `updated_at` 설정
+            // and return the result
+            vec![]
+        };
+        {
+            // write lock scope
+            let mut cache = cache.write().await;
+            cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        }
+        Ok(SsufidSiteData {
+            title: "TODO".to_string(), // T::TITLE
+            source: T::IDENTIFIER.to_string(),
+            description: "TODO".to_string(), // T::DESC
+            items: updated_entries,
+        })
     }
 
-    pub async fn save_cache(&self) -> Result<(), std::io::Error> {
+    pub async fn save_cache(&self) -> Result<(), SsufidError> {
         // Save all caches into files
-        todo!()
+        let cache = Arc::clone(&self.cache);
+        let cache = cache.read().await;
+        let dir = std::path::Path::new(&self.cache_dir);
+        tokio::fs::create_dir_all(dir).await.unwrap();
+
+        for (id, posts) in &*cache {
+            let json = serde_json::to_string_pretty(&posts).unwrap();
+            // TODO replace to `?`
+            let mut file = match tokio::fs::File::create(dir.join(format!("{}.json", id))).await {
+                Ok(f) => f,
+                Err(_) => return Err(SsufidError::FileIOError),
+            };
+            file.write_all(json.as_bytes()).await.unwrap();
+        }
+        Ok(())
+    }
+
+    async fn read_cache(&self, id: &str) -> Result<Vec<SsufidPost>, SsufidError> {
+        let path = format!("{}/{}.json", self.cache_dir, id);
+        // TODO replace to `?`
+        let content = match tokio::fs::read_to_string(&path).await {
+            Ok(items) => items,
+            Err(_) => return Err(SsufidError::FileIOError),
+        };
+        // TODO
+        let items: Vec<SsufidPost> = match serde_json::from_str(&content) {
+            Ok(items) => items,
+            Err(_) => return Err(SsufidError::FileIOError),
+        };
+        Ok(items)
     }
 }
 
 pub trait SsufidPlugin {
     const IDENTIFIER: &'static str;
-    fn crawl(&self) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
+    fn crawl(
+        &self,
+    ) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
 }
 
 #[derive(Debug, Error)]
 pub enum SsufidError {
     #[error("crawl error")]
     CrawlError,
+
+    // 임시
+    #[error("file error")]
+    FileIOError,
path: src/core.rs
user: login: 2wndrhs
body: 
```suggestion
    #[error("Crawl error: {0}")]
    CrawlError(String),

    #[error("File I/O error: {0}")]
    FileIOError(#[from] std::io::Error),

    #[error("Serialization error: {0}")]
    SerializationError(#[from] serde_json::Error),
```
요런식으로 에러를 정의하면 `unwrap()` 부분과 match로 에러를 반환하는 부분을 제거할 수 있을 것 같아요!
created_at: 2025-03-19T07:51:19Z
author_association: MEMBER
diff_hunk: @@ -1,10 +1,12 @@
-use std::collections::HashMap;
+use std::{collections::HashMap, sync::Arc, vec};
 
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 use time;
+use tokio::io::AsyncWriteExt;
+use tokio::sync::RwLock;
 
-#[derive(Serialize, Deserialize, Debug)]
+#[derive(Serialize, Deserialize, Debug, PartialEq)]
 pub struct SsufidPost {
path: src/core.rs
user: login: 2wndrhs
body: `SsufidPost` 인스턴스를 생성할 때 월요일 스터디에서 배운 빌더 패턴을 사용해도 좋을 것 같네요
created_at: 2025-03-19T08:00:24Z
author_association: MEMBER
diff_hunk: @@ -31,47 +33,144 @@ impl SsufidSiteData {
 }
 
 pub struct SsufidCore {
-    cache: HashMap<String, Vec<SsufidPost>>,
+    cache: Arc<RwLock<HashMap<String, Vec<SsufidPost>>>>,
     #[allow(dead_code)]
     cache_dir: String,
 }
 
 impl SsufidCore {
     pub fn new(cache_dir: &str) -> Self {
         Self {
-            cache: HashMap::new(),
+            cache: Arc::new(RwLock::new(HashMap::new())),
             cache_dir: cache_dir.to_string(),
         }
     }
 
-    pub async fn run<T: SsufidPlugin>(&mut self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
+    pub async fn run<T: SsufidPlugin>(&self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
         let new_entries = plugin.crawl().await?;
+        let cache = Arc::clone(&self.cache);
         #[allow(unused_variables)]
-        let old_entries = match self.cache.get(T::IDENTIFIER) {
-            Some(entries) => entries,
-            None => todo!("retrieve cache from file"),
-        };
-        self.cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        let updated_entries = {
+            // read lock scope
+            let cache = cache.read().await;
+            #[allow(unused_variables)]
+            let old_entries = match cache.get(T::IDENTIFIER) {
+                Some(entries) => entries,
+                None => &self.read_cache(T::IDENTIFIER).await?,
+            };
 
-        // Compare with new and old: `updated_at` 설정
-        // and return the result
-        todo!()
+            // Compare with new and old: `updated_at` 설정
+            // and return the result
+            vec![]
+        };
+        {
+            // write lock scope
+            let mut cache = cache.write().await;
+            cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        }
+        Ok(SsufidSiteData {
+            title: "TODO".to_string(), // T::TITLE
+            source: T::IDENTIFIER.to_string(),
+            description: "TODO".to_string(), // T::DESC
+            items: updated_entries,
+        })
     }
 
-    pub async fn save_cache(&self) -> Result<(), std::io::Error> {
+    pub async fn save_cache(&self) -> Result<(), SsufidError> {
         // Save all caches into files
-        todo!()
+        let cache = Arc::clone(&self.cache);
+        let cache = cache.read().await;
+        let dir = std::path::Path::new(&self.cache_dir);
+        tokio::fs::create_dir_all(dir).await.unwrap();
+
+        for (id, posts) in &*cache {
+            let json = serde_json::to_string_pretty(&posts).unwrap();
+            // TODO replace to `?`
+            let mut file = match tokio::fs::File::create(dir.join(format!("{}.json", id))).await {
+                Ok(f) => f,
+                Err(_) => return Err(SsufidError::FileIOError),
+            };
+            file.write_all(json.as_bytes()).await.unwrap();
+        }
+        Ok(())
+    }
+
+    async fn read_cache(&self, id: &str) -> Result<Vec<SsufidPost>, SsufidError> {
+        let path = format!("{}/{}.json", self.cache_dir, id);
+        // TODO replace to `?`
+        let content = match tokio::fs::read_to_string(&path).await {
+            Ok(items) => items,
+            Err(_) => return Err(SsufidError::FileIOError),
+        };
+        // TODO
+        let items: Vec<SsufidPost> = match serde_json::from_str(&content) {
+            Ok(items) => items,
+            Err(_) => return Err(SsufidError::FileIOError),
+        };
+        Ok(items)
     }
 }
 
 pub trait SsufidPlugin {
     const IDENTIFIER: &'static str;
-    fn crawl(&self) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
+    fn crawl(
+        &self,
+    ) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
 }
 
 #[derive(Debug, Error)]
 pub enum SsufidError {
     #[error("crawl error")]
     CrawlError,
+
+    // 임시
+    #[error("file error")]
+    FileIOError,
     // TODO: 다양한 에러 타입 정의
 }
+
+// 임시 테스트
+#[cfg(test)]
+mod tests {
+    use tokio::io::AsyncWriteExt;
path: src/core.rs
user: login: 2wndrhs
body: 요 트레이트는 어떤 역할인가요?
없어도 컴파일은 되는 것 같아서요
created_at: 2025-03-19T09:41:37Z
author_association: MEMBER
diff_hunk: @@ -31,47 +33,144 @@ impl SsufidSiteData {
 }
 
 pub struct SsufidCore {
-    cache: HashMap<String, Vec<SsufidPost>>,
+    cache: Arc<RwLock<HashMap<String, Vec<SsufidPost>>>>,
     #[allow(dead_code)]
     cache_dir: String,
 }
 
 impl SsufidCore {
     pub fn new(cache_dir: &str) -> Self {
         Self {
-            cache: HashMap::new(),
+            cache: Arc::new(RwLock::new(HashMap::new())),
             cache_dir: cache_dir.to_string(),
         }
     }
 
-    pub async fn run<T: SsufidPlugin>(&mut self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
+    pub async fn run<T: SsufidPlugin>(&self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
         let new_entries = plugin.crawl().await?;
+        let cache = Arc::clone(&self.cache);
         #[allow(unused_variables)]
-        let old_entries = match self.cache.get(T::IDENTIFIER) {
-            Some(entries) => entries,
-            None => todo!("retrieve cache from file"),
-        };
-        self.cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        let updated_entries = {
+            // read lock scope
+            let cache = cache.read().await;
+            #[allow(unused_variables)]
+            let old_entries = match cache.get(T::IDENTIFIER) {
+                Some(entries) => entries,
+                None => &self.read_cache(T::IDENTIFIER).await?,
+            };
 
-        // Compare with new and old: `updated_at` 설정
-        // and return the result
-        todo!()
+            // Compare with new and old: `updated_at` 설정
+            // and return the result
+            vec![]
+        };
+        {
+            // write lock scope
+            let mut cache = cache.write().await;
+            cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        }
+        Ok(SsufidSiteData {
+            title: "TODO".to_string(), // T::TITLE
+            source: T::IDENTIFIER.to_string(),
+            description: "TODO".to_string(), // T::DESC
+            items: updated_entries,
+        })
     }
 
-    pub async fn save_cache(&self) -> Result<(), std::io::Error> {
+    pub async fn save_cache(&self) -> Result<(), SsufidError> {
         // Save all caches into files
-        todo!()
+        let cache = Arc::clone(&self.cache);
+        let cache = cache.read().await;
+        let dir = std::path::Path::new(&self.cache_dir);
+        tokio::fs::create_dir_all(dir).await.unwrap();
+
+        for (id, posts) in &*cache {
+            let json = serde_json::to_string_pretty(&posts).unwrap();
+            // TODO replace to `?`
+            let mut file = match tokio::fs::File::create(dir.join(format!("{}.json", id))).await {
+                Ok(f) => f,
+                Err(_) => return Err(SsufidError::FileIOError),
+            };
+            file.write_all(json.as_bytes()).await.unwrap();
+        }
+        Ok(())
+    }
+
+    async fn read_cache(&self, id: &str) -> Result<Vec<SsufidPost>, SsufidError> {
+        let path = format!("{}/{}.json", self.cache_dir, id);
+        // TODO replace to `?`
+        let content = match tokio::fs::read_to_string(&path).await {
+            Ok(items) => items,
+            Err(_) => return Err(SsufidError::FileIOError),
+        };
+        // TODO
+        let items: Vec<SsufidPost> = match serde_json::from_str(&content) {
+            Ok(items) => items,
+            Err(_) => return Err(SsufidError::FileIOError),
+        };
+        Ok(items)
     }
 }
 
 pub trait SsufidPlugin {
     const IDENTIFIER: &'static str;
-    fn crawl(&self) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
+    fn crawl(
+        &self,
+    ) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
 }
 
 #[derive(Debug, Error)]
 pub enum SsufidError {
     #[error("crawl error")]
     CrawlError,
+
+    // 임시
+    #[error("file error")]
+    FileIOError,
     // TODO: 다양한 에러 타입 정의
 }
+
+// 임시 테스트
+#[cfg(test)]
+mod tests {
+    use tokio::io::AsyncWriteExt;
path: src/core.rs
user: login: cometj03
body: 제 기억으론 `write_all` 메서드 사용하려면 필요합니다!
created_at: 2025-03-19T09:47:25Z
author_association: MEMBER
diff_hunk: @@ -31,47 +33,144 @@ impl SsufidSiteData {
 }
 
 pub struct SsufidCore {
-    cache: HashMap<String, Vec<SsufidPost>>,
+    cache: Arc<RwLock<HashMap<String, Vec<SsufidPost>>>>,
     #[allow(dead_code)]
     cache_dir: String,
 }
 
 impl SsufidCore {
     pub fn new(cache_dir: &str) -> Self {
         Self {
-            cache: HashMap::new(),
+            cache: Arc::new(RwLock::new(HashMap::new())),
             cache_dir: cache_dir.to_string(),
         }
     }
 
-    pub async fn run<T: SsufidPlugin>(&mut self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
+    pub async fn run<T: SsufidPlugin>(&self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
         let new_entries = plugin.crawl().await?;
+        let cache = Arc::clone(&self.cache);
         #[allow(unused_variables)]
-        let old_entries = match self.cache.get(T::IDENTIFIER) {
-            Some(entries) => entries,
-            None => todo!("retrieve cache from file"),
-        };
-        self.cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        let updated_entries = {
+            // read lock scope
+            let cache = cache.read().await;
+            #[allow(unused_variables)]
+            let old_entries = match cache.get(T::IDENTIFIER) {
+                Some(entries) => entries,
+                None => &self.read_cache(T::IDENTIFIER).await?,
+            };
 
-        // Compare with new and old: `updated_at` 설정
-        // and return the result
-        todo!()
+            // Compare with new and old: `updated_at` 설정
+            // and return the result
+            vec![]
+        };
+        {
+            // write lock scope
+            let mut cache = cache.write().await;
+            cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        }
+        Ok(SsufidSiteData {
+            title: "TODO".to_string(), // T::TITLE
+            source: T::IDENTIFIER.to_string(),
+            description: "TODO".to_string(), // T::DESC
+            items: updated_entries,
+        })
     }
 
-    pub async fn save_cache(&self) -> Result<(), std::io::Error> {
+    pub async fn save_cache(&self) -> Result<(), SsufidError> {
         // Save all caches into files
-        todo!()
+        let cache = Arc::clone(&self.cache);
+        let cache = cache.read().await;
+        let dir = std::path::Path::new(&self.cache_dir);
+        tokio::fs::create_dir_all(dir).await.unwrap();
+
+        for (id, posts) in &*cache {
+            let json = serde_json::to_string_pretty(&posts).unwrap();
+            // TODO replace to `?`
+            let mut file = match tokio::fs::File::create(dir.join(format!("{}.json", id))).await {
+                Ok(f) => f,
+                Err(_) => return Err(SsufidError::FileIOError),
+            };
+            file.write_all(json.as_bytes()).await.unwrap();
+        }
+        Ok(())
+    }
+
+    async fn read_cache(&self, id: &str) -> Result<Vec<SsufidPost>, SsufidError> {
+        let path = format!("{}/{}.json", self.cache_dir, id);
path: src/core.rs
user: login: EATSTEAK
body: Path 자료형을 사용해보면 어떨까요?
created_at: 2025-03-19T09:48:49Z
author_association: COLLABORATOR
diff_hunk: @@ -31,47 +33,144 @@ impl SsufidSiteData {
 }
 
 pub struct SsufidCore {
-    cache: HashMap<String, Vec<SsufidPost>>,
+    cache: Arc<RwLock<HashMap<String, Vec<SsufidPost>>>>,
     #[allow(dead_code)]
     cache_dir: String,
 }
 
 impl SsufidCore {
     pub fn new(cache_dir: &str) -> Self {
         Self {
-            cache: HashMap::new(),
+            cache: Arc::new(RwLock::new(HashMap::new())),
             cache_dir: cache_dir.to_string(),
         }
     }
 
-    pub async fn run<T: SsufidPlugin>(&mut self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
+    pub async fn run<T: SsufidPlugin>(&self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
         let new_entries = plugin.crawl().await?;
+        let cache = Arc::clone(&self.cache);
         #[allow(unused_variables)]
-        let old_entries = match self.cache.get(T::IDENTIFIER) {
-            Some(entries) => entries,
-            None => todo!("retrieve cache from file"),
-        };
-        self.cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        let updated_entries = {
+            // read lock scope
+            let cache = cache.read().await;
+            #[allow(unused_variables)]
+            let old_entries = match cache.get(T::IDENTIFIER) {
+                Some(entries) => entries,
+                None => &self.read_cache(T::IDENTIFIER).await?,
+            };
 
-        // Compare with new and old: `updated_at` 설정
-        // and return the result
-        todo!()
+            // Compare with new and old: `updated_at` 설정
+            // and return the result
+            vec![]
+        };
+        {
+            // write lock scope
+            let mut cache = cache.write().await;
+            cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        }
+        Ok(SsufidSiteData {
+            title: "TODO".to_string(), // T::TITLE
+            source: T::IDENTIFIER.to_string(),
+            description: "TODO".to_string(), // T::DESC
+            items: updated_entries,
+        })
     }
 
-    pub async fn save_cache(&self) -> Result<(), std::io::Error> {
+    pub async fn save_cache(&self) -> Result<(), SsufidError> {
         // Save all caches into files
-        todo!()
+        let cache = Arc::clone(&self.cache);
+        let cache = cache.read().await;
+        let dir = std::path::Path::new(&self.cache_dir);
+        tokio::fs::create_dir_all(dir).await.unwrap();
+
+        for (id, posts) in &*cache {
+            let json = serde_json::to_string_pretty(&posts).unwrap();
+            // TODO replace to `?`
+            let mut file = match tokio::fs::File::create(dir.join(format!("{}.json", id))).await {
+                Ok(f) => f,
+                Err(_) => return Err(SsufidError::FileIOError),
+            };
+            file.write_all(json.as_bytes()).await.unwrap();
+        }
+        Ok(())
+    }
+
+    async fn read_cache(&self, id: &str) -> Result<Vec<SsufidPost>, SsufidError> {
+        let path = format!("{}/{}.json", self.cache_dir, id);
+        // TODO replace to `?`
+        let content = match tokio::fs::read_to_string(&path).await {
+            Ok(items) => items,
+            Err(_) => return Err(SsufidError::FileIOError),
+        };
+        // TODO
+        let items: Vec<SsufidPost> = match serde_json::from_str(&content) {
+            Ok(items) => items,
+            Err(_) => return Err(SsufidError::FileIOError),
+        };
+        Ok(items)
     }
 }
 
 pub trait SsufidPlugin {
     const IDENTIFIER: &'static str;
-    fn crawl(&self) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
+    fn crawl(
+        &self,
+    ) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
 }
 
 #[derive(Debug, Error)]
 pub enum SsufidError {
     #[error("crawl error")]
     CrawlError,
+
+    // 임시
+    #[error("file error")]
+    FileIOError,
     // TODO: 다양한 에러 타입 정의
 }
+
+// 임시 테스트
+#[cfg(test)]
+mod tests {
+    use tokio::io::AsyncWriteExt;
path: src/core.rs
user: login: EATSTEAK
body: 요게 있어야 write_all이 될거에요
created_at: 2025-03-19T09:50:27Z
author_association: COLLABORATOR
diff_hunk: @@ -1,10 +1,12 @@
-use std::collections::HashMap;
+use std::{collections::HashMap, sync::Arc, vec};
 
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 use time;
+use tokio::io::AsyncWriteExt;
+use tokio::sync::RwLock;
 
-#[derive(Serialize, Deserialize, Debug)]
+#[derive(Serialize, Deserialize, Debug, PartialEq)]
 pub struct SsufidPost {
path: src/core.rs
user: login: EATSTEAK
body: p2) Optional 값들이 없어서 빌더가 필요한 수준은 아니지 않을까요?
created_at: 2025-03-19T09:51:16Z
author_association: COLLABORATOR
diff_hunk: @@ -31,47 +33,144 @@ impl SsufidSiteData {
 }
 
 pub struct SsufidCore {
-    cache: HashMap<String, Vec<SsufidPost>>,
+    cache: Arc<RwLock<HashMap<String, Vec<SsufidPost>>>>,
     #[allow(dead_code)]
     cache_dir: String,
 }
 
 impl SsufidCore {
     pub fn new(cache_dir: &str) -> Self {
         Self {
-            cache: HashMap::new(),
+            cache: Arc::new(RwLock::new(HashMap::new())),
             cache_dir: cache_dir.to_string(),
         }
     }
 
-    pub async fn run<T: SsufidPlugin>(&mut self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
+    pub async fn run<T: SsufidPlugin>(&self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
         let new_entries = plugin.crawl().await?;
+        let cache = Arc::clone(&self.cache);
         #[allow(unused_variables)]
-        let old_entries = match self.cache.get(T::IDENTIFIER) {
-            Some(entries) => entries,
-            None => todo!("retrieve cache from file"),
-        };
-        self.cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        let updated_entries = {
+            // read lock scope
+            let cache = cache.read().await;
+            #[allow(unused_variables)]
+            let old_entries = match cache.get(T::IDENTIFIER) {
+                Some(entries) => entries,
+                None => &self.read_cache(T::IDENTIFIER).await?,
+            };
 
-        // Compare with new and old: `updated_at` 설정
-        // and return the result
-        todo!()
+            // Compare with new and old: `updated_at` 설정
+            // and return the result
+            vec![]
+        };
+        {
+            // write lock scope
+            let mut cache = cache.write().await;
+            cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        }
+        Ok(SsufidSiteData {
+            title: "TODO".to_string(), // T::TITLE
+            source: T::IDENTIFIER.to_string(),
+            description: "TODO".to_string(), // T::DESC
+            items: updated_entries,
+        })
     }
 
-    pub async fn save_cache(&self) -> Result<(), std::io::Error> {
+    pub async fn save_cache(&self) -> Result<(), SsufidError> {
         // Save all caches into files
-        todo!()
+        let cache = Arc::clone(&self.cache);
+        let cache = cache.read().await;
+        let dir = std::path::Path::new(&self.cache_dir);
+        tokio::fs::create_dir_all(dir).await.unwrap();
+
+        for (id, posts) in &*cache {
+            let json = serde_json::to_string_pretty(&posts).unwrap();
+            // TODO replace to `?`
+            let mut file = match tokio::fs::File::create(dir.join(format!("{}.json", id))).await {
+                Ok(f) => f,
+                Err(_) => return Err(SsufidError::FileIOError),
+            };
+            file.write_all(json.as_bytes()).await.unwrap();
+        }
+        Ok(())
+    }
+
+    async fn read_cache(&self, id: &str) -> Result<Vec<SsufidPost>, SsufidError> {
+        let path = format!("{}/{}.json", self.cache_dir, id);
+        // TODO replace to `?`
+        let content = match tokio::fs::read_to_string(&path).await {
+            Ok(items) => items,
+            Err(_) => return Err(SsufidError::FileIOError),
+        };
+        // TODO
+        let items: Vec<SsufidPost> = match serde_json::from_str(&content) {
+            Ok(items) => items,
+            Err(_) => return Err(SsufidError::FileIOError),
+        };
+        Ok(items)
     }
 }
 
 pub trait SsufidPlugin {
     const IDENTIFIER: &'static str;
-    fn crawl(&self) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
+    fn crawl(
+        &self,
+    ) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
 }
 
 #[derive(Debug, Error)]
 pub enum SsufidError {
     #[error("crawl error")]
     CrawlError,
+
+    // 임시
+    #[error("file error")]
+    FileIOError,
     // TODO: 다양한 에러 타입 정의
 }
+
+// 임시 테스트
+#[cfg(test)]
+mod tests {
+    use tokio::io::AsyncWriteExt;
path: src/core.rs
user: login: 2wndrhs
body: 오잉 저는 `AsyncWriteExt`를 가져오지 않아도 테스트가 잘 수행되는데 무슨 차이일까요 🤔

created_at: 2025-03-19T09:53:41Z
author_association: MEMBER
diff_hunk: @@ -1,10 +1,12 @@
-use std::collections::HashMap;
+use std::{collections::HashMap, sync::Arc, vec};
 
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 use time;
+use tokio::io::AsyncWriteExt;
+use tokio::sync::RwLock;
 
-#[derive(Serialize, Deserialize, Debug)]
+#[derive(Serialize, Deserialize, Debug, PartialEq)]
 pub struct SsufidPost {
path: src/core.rs
user: login: 2wndrhs
body: `updated_at`이 `Option` 타입이고 필드 수가 많아서 빌더 패턴 적용하면 가독성이 좋아질 것 같아서요.
특히 테스트 데이터 생성할 때 유용하지 않을까요?
created_at: 2025-03-19T10:00:55Z
author_association: MEMBER
diff_hunk: @@ -31,47 +33,144 @@ impl SsufidSiteData {
 }
 
 pub struct SsufidCore {
-    cache: HashMap<String, Vec<SsufidPost>>,
+    cache: Arc<RwLock<HashMap<String, Vec<SsufidPost>>>>,
     #[allow(dead_code)]
     cache_dir: String,
 }
 
 impl SsufidCore {
     pub fn new(cache_dir: &str) -> Self {
         Self {
-            cache: HashMap::new(),
+            cache: Arc::new(RwLock::new(HashMap::new())),
             cache_dir: cache_dir.to_string(),
         }
     }
 
-    pub async fn run<T: SsufidPlugin>(&mut self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
+    pub async fn run<T: SsufidPlugin>(&self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
         let new_entries = plugin.crawl().await?;
+        let cache = Arc::clone(&self.cache);
         #[allow(unused_variables)]
-        let old_entries = match self.cache.get(T::IDENTIFIER) {
-            Some(entries) => entries,
-            None => todo!("retrieve cache from file"),
-        };
-        self.cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        let updated_entries = {
+            // read lock scope
+            let cache = cache.read().await;
+            #[allow(unused_variables)]
+            let old_entries = match cache.get(T::IDENTIFIER) {
+                Some(entries) => entries,
+                None => &self.read_cache(T::IDENTIFIER).await?,
+            };
 
-        // Compare with new and old: `updated_at` 설정
-        // and return the result
-        todo!()
+            // Compare with new and old: `updated_at` 설정
+            // and return the result
+            vec![]
+        };
+        {
+            // write lock scope
+            let mut cache = cache.write().await;
+            cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        }
+        Ok(SsufidSiteData {
+            title: "TODO".to_string(), // T::TITLE
+            source: T::IDENTIFIER.to_string(),
+            description: "TODO".to_string(), // T::DESC
+            items: updated_entries,
+        })
     }
 
-    pub async fn save_cache(&self) -> Result<(), std::io::Error> {
+    pub async fn save_cache(&self) -> Result<(), SsufidError> {
         // Save all caches into files
-        todo!()
+        let cache = Arc::clone(&self.cache);
+        let cache = cache.read().await;
+        let dir = std::path::Path::new(&self.cache_dir);
+        tokio::fs::create_dir_all(dir).await.unwrap();
+
+        for (id, posts) in &*cache {
+            let json = serde_json::to_string_pretty(&posts).unwrap();
+            // TODO replace to `?`
+            let mut file = match tokio::fs::File::create(dir.join(format!("{}.json", id))).await {
+                Ok(f) => f,
+                Err(_) => return Err(SsufidError::FileIOError),
+            };
+            file.write_all(json.as_bytes()).await.unwrap();
+        }
+        Ok(())
+    }
+
+    async fn read_cache(&self, id: &str) -> Result<Vec<SsufidPost>, SsufidError> {
+        let path = format!("{}/{}.json", self.cache_dir, id);
+        // TODO replace to `?`
+        let content = match tokio::fs::read_to_string(&path).await {
+            Ok(items) => items,
+            Err(_) => return Err(SsufidError::FileIOError),
+        };
+        // TODO
+        let items: Vec<SsufidPost> = match serde_json::from_str(&content) {
+            Ok(items) => items,
+            Err(_) => return Err(SsufidError::FileIOError),
+        };
+        Ok(items)
     }
 }
 
 pub trait SsufidPlugin {
     const IDENTIFIER: &'static str;
-    fn crawl(&self) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
+    fn crawl(
+        &self,
+    ) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
 }
 
 #[derive(Debug, Error)]
 pub enum SsufidError {
     #[error("crawl error")]
     CrawlError,
+
+    // 임시
+    #[error("file error")]
+    FileIOError,
     // TODO: 다양한 에러 타입 정의
 }
+
+// 임시 테스트
+#[cfg(test)]
+mod tests {
+    use tokio::io::AsyncWriteExt;
path: src/core.rs
user: login: cometj03
body: `use super::*;` 때문에 core 모듈에 있는 `use tokio::io::AsyncWriteExt;`한 것까지 받아오는 것 같아요

`use super::*;` 대신 `use super::{SsufidCore, SsufidPost};`으로 바꾸면 빌드가 안 되더라구요 
created_at: 2025-03-21T13:32:06Z
author_association: MEMBER
diff_hunk: @@ -31,47 +33,144 @@ impl SsufidSiteData {
 }
 
 pub struct SsufidCore {
-    cache: HashMap<String, Vec<SsufidPost>>,
+    cache: Arc<RwLock<HashMap<String, Vec<SsufidPost>>>>,
     #[allow(dead_code)]
     cache_dir: String,
 }
 
 impl SsufidCore {
     pub fn new(cache_dir: &str) -> Self {
         Self {
-            cache: HashMap::new(),
+            cache: Arc::new(RwLock::new(HashMap::new())),
             cache_dir: cache_dir.to_string(),
         }
     }
 
-    pub async fn run<T: SsufidPlugin>(&mut self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
+    pub async fn run<T: SsufidPlugin>(&self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
         let new_entries = plugin.crawl().await?;
+        let cache = Arc::clone(&self.cache);
         #[allow(unused_variables)]
-        let old_entries = match self.cache.get(T::IDENTIFIER) {
-            Some(entries) => entries,
-            None => todo!("retrieve cache from file"),
-        };
-        self.cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        let updated_entries = {
+            // read lock scope
+            let cache = cache.read().await;
+            #[allow(unused_variables)]
+            let old_entries = match cache.get(T::IDENTIFIER) {
+                Some(entries) => entries,
+                None => &self.read_cache(T::IDENTIFIER).await?,
+            };
 
-        // Compare with new and old: `updated_at` 설정
-        // and return the result
-        todo!()
+            // Compare with new and old: `updated_at` 설정
+            // and return the result
+            vec![]
+        };
+        {
+            // write lock scope
+            let mut cache = cache.write().await;
+            cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        }
+        Ok(SsufidSiteData {
+            title: "TODO".to_string(), // T::TITLE
+            source: T::IDENTIFIER.to_string(),
+            description: "TODO".to_string(), // T::DESC
+            items: updated_entries,
+        })
     }
 
-    pub async fn save_cache(&self) -> Result<(), std::io::Error> {
+    pub async fn save_cache(&self) -> Result<(), SsufidError> {
         // Save all caches into files
-        todo!()
+        let cache = Arc::clone(&self.cache);
+        let cache = cache.read().await;
+        let dir = std::path::Path::new(&self.cache_dir);
+        tokio::fs::create_dir_all(dir).await.unwrap();
+
+        for (id, posts) in &*cache {
+            let json = serde_json::to_string_pretty(&posts).unwrap();
+            // TODO replace to `?`
+            let mut file = match tokio::fs::File::create(dir.join(format!("{}.json", id))).await {
+                Ok(f) => f,
+                Err(_) => return Err(SsufidError::FileIOError),
+            };
+            file.write_all(json.as_bytes()).await.unwrap();
+        }
+        Ok(())
+    }
+
+    async fn read_cache(&self, id: &str) -> Result<Vec<SsufidPost>, SsufidError> {
+        let path = format!("{}/{}.json", self.cache_dir, id);
+        // TODO replace to `?`
+        let content = match tokio::fs::read_to_string(&path).await {
+            Ok(items) => items,
+            Err(_) => return Err(SsufidError::FileIOError),
+        };
+        // TODO
+        let items: Vec<SsufidPost> = match serde_json::from_str(&content) {
+            Ok(items) => items,
+            Err(_) => return Err(SsufidError::FileIOError),
+        };
+        Ok(items)
     }
 }
 
 pub trait SsufidPlugin {
     const IDENTIFIER: &'static str;
-    fn crawl(&self) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
+    fn crawl(
+        &self,
+    ) -> impl std::future::Future<Output = Result<Vec<SsufidPost>, SsufidError>> + Send;
 }
 
 #[derive(Debug, Error)]
 pub enum SsufidError {
     #[error("crawl error")]
     CrawlError,
+
+    // 임시
+    #[error("file error")]
+    FileIOError,
path: src/core.rs
user: login: cometj03
body: thiserror 쓰니까 편하네요 👍 https://github.com/yourssu/ssufid/pull/17/commits/1762069ee4eeec622cf1d665bf93a0762a1292a8
created_at: 2025-03-21T13:35:39Z
author_association: MEMBER
diff_hunk: @@ -1,10 +1,12 @@
-use std::collections::HashMap;
+use std::{collections::HashMap, sync::Arc, vec};
 
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 use time;
+use tokio::io::AsyncWriteExt;
+use tokio::sync::RwLock;
 
-#[derive(Serialize, Deserialize, Debug)]
+#[derive(Serialize, Deserialize, Debug, PartialEq)]
 pub struct SsufidPost {
path: src/core.rs
user: login: cometj03
body: 빌더 패턴으로 바꾸면 인스턴스 생성하는 코드가 구체적으로 어떻게 개선될까요?
created_at: 2025-03-21T13:36:33Z
author_association: MEMBER
diff_hunk: @@ -31,47 +33,144 @@ impl SsufidSiteData {
 }
 
 pub struct SsufidCore {
-    cache: HashMap<String, Vec<SsufidPost>>,
+    cache: Arc<RwLock<HashMap<String, Vec<SsufidPost>>>>,
     #[allow(dead_code)]
     cache_dir: String,
 }
 
 impl SsufidCore {
     pub fn new(cache_dir: &str) -> Self {
         Self {
-            cache: HashMap::new(),
+            cache: Arc::new(RwLock::new(HashMap::new())),
             cache_dir: cache_dir.to_string(),
         }
     }
 
-    pub async fn run<T: SsufidPlugin>(&mut self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
+    pub async fn run<T: SsufidPlugin>(&self, plugin: T) -> Result<SsufidSiteData, SsufidError> {
         let new_entries = plugin.crawl().await?;
+        let cache = Arc::clone(&self.cache);
         #[allow(unused_variables)]
-        let old_entries = match self.cache.get(T::IDENTIFIER) {
-            Some(entries) => entries,
-            None => todo!("retrieve cache from file"),
-        };
-        self.cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        let updated_entries = {
+            // read lock scope
+            let cache = cache.read().await;
+            #[allow(unused_variables)]
+            let old_entries = match cache.get(T::IDENTIFIER) {
+                Some(entries) => entries,
+                None => &self.read_cache(T::IDENTIFIER).await?,
+            };
 
-        // Compare with new and old: `updated_at` 설정
-        // and return the result
-        todo!()
+            // Compare with new and old: `updated_at` 설정
+            // and return the result
+            vec![]
+        };
+        {
+            // write lock scope
+            let mut cache = cache.write().await;
+            cache.insert(T::IDENTIFIER.to_string(), new_entries);
+        }
+        Ok(SsufidSiteData {
+            title: "TODO".to_string(), // T::TITLE
+            source: T::IDENTIFIER.to_string(),
+            description: "TODO".to_string(), // T::DESC
+            items: updated_entries,
+        })
     }
 
-    pub async fn save_cache(&self) -> Result<(), std::io::Error> {
+    pub async fn save_cache(&self) -> Result<(), SsufidError> {
         // Save all caches into files
-        todo!()
+        let cache = Arc::clone(&self.cache);
+        let cache = cache.read().await;
+        let dir = std::path::Path::new(&self.cache_dir);
+        tokio::fs::create_dir_all(dir).await.unwrap();
+
+        for (id, posts) in &*cache {
+            let json = serde_json::to_string_pretty(&posts).unwrap();
+            // TODO replace to `?`
+            let mut file = match tokio::fs::File::create(dir.join(format!("{}.json", id))).await {
+                Ok(f) => f,
+                Err(_) => return Err(SsufidError::FileIOError),
+            };
+            file.write_all(json.as_bytes()).await.unwrap();
+        }
+        Ok(())
+    }
+
+    async fn read_cache(&self, id: &str) -> Result<Vec<SsufidPost>, SsufidError> {
+        let path = format!("{}/{}.json", self.cache_dir, id);
path: src/core.rs
user: login: cometj03
body: 수정했습니다! https://github.com/yourssu/ssufid/pull/17/commits/2f3927f42daa426fcd9a0e2bdadc3797f09b3fd1
created_at: 2025-03-21T13:37:01Z
author_association: MEMBER
diff_hunk: @@ -1,10 +1,12 @@
-use std::collections::HashMap;
+use std::{collections::HashMap, sync::Arc, vec};
 
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 use time;
+use tokio::io::AsyncWriteExt;
+use tokio::sync::RwLock;
 
-#[derive(Serialize, Deserialize, Debug)]
+#[derive(Serialize, Deserialize, Debug, PartialEq)]
 pub struct SsufidPost {
path: src/core.rs
user: login: 2wndrhs
body: 빌더 패턴 없이 `SsufidPost` 인스턴스를 생성할 때는 플러그인이 신경쓸 필요가 없는 `Option` 타입의 `updated_at`필드까지 한번에 지정해야 하지만 빌더 패턴을 적용하면 플러그인이 `updated_at` 필드 설정이 필요한 경우에만 선택적으로 해당 메서드를 호출할 수 있어 더 명확하게 코드를 작성할 수 있다는 이점이 있을 것 같아요.

하지만 EATSTEAK 말처럼 Optional 값이 `updated_at` 밖에 없다보니 성급한 최적화 같기도 하네요.

```Rust
SsufidPost {
    id: "some-id".to_string(),
    title: "제목".to_string(),
    category: "카테고리".to_string(),
    url: "http://example.com".to_string(),
    created_at: time::OffsetDateTime::now_utc(),
    updated_at: None, // 반드시 명시 필요
    content: "내용".to_string(),
}
```

```Rust
SsufidPost::builder()
    .id("some-id")
    .title("제목")
    .category("카테고리")
    .url("http://example.com")
    .created_at(time::OffsetDateTime::now_utc())
    .updated_at(some_time) // 선택적으로 호출 가능
    .content("내용")
    .build()
```
created_at: 2025-03-21T16:38:14Z
author_association: MEMBER
diff_hunk: @@ -1,10 +1,12 @@
-use std::collections::HashMap;
+use std::{collections::HashMap, sync::Arc, vec};
 
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 use time;
+use tokio::io::AsyncWriteExt;
+use tokio::sync::RwLock;
 
-#[derive(Serialize, Deserialize, Debug)]
+#[derive(Serialize, Deserialize, Debug, PartialEq)]
 pub struct SsufidPost {
path: src/core.rs
user: login: cometj03
body: 플러그인에서 Post 인스턴스 생성할 때 유용할 것 같긴 하네요. 우선 이 PR에서는 보류하겠습니다!
created_at: 2025-03-22T01:55:09Z
author_association: MEMBER

PR comments:
user: login: cometj03
created_at: 2025-03-21T13:22:46Z
body: > 단순 궁금) HashMap<String, RwLock<Vec>> 를 사용할 때 어떤 어려움이 있었을까요?

일단 run에서 SsufidCore에 대한 mutable reference를 참조하지 않으려는게 목적이니까 run 메서드에서 HashMap에 insert 하는 게 불가능해요. 특정 키에 대한 값(벡터)을 업데이트 하는 것만 가능.

따라서 new 할 때, 존재하는 모든 플러그인에 대해 미리 HashMap의 원소를 빈 벡터로 초기화 해줘야 하는데, core는 플러그인의 구체적인 정보(얼마나 많은 플러그인이 있는지, 어떤 플러그인들이 있는지)에 대해 모르기 때문에 넣어줄 수 없다는 문제가 있더라구요
author_association: MEMBER
user: login: cometj03
created_at: 2025-03-21T13:40:36Z
body: 근데 CI에서 `cargo fmt` 해서 수정된 코드는 커밋 없이 반영이 되는건가요?
author_association: MEMBER
user: login: EATSTEAK
created_at: 2025-03-21T14:53:11Z
body: > @cometj03  run 메서드에서 HashMap에 insert 하는 게 불가능해요. 

앗 확인 ㅋㅋ....

> 근데 CI에서 cargo fmt 해서 수정된 코드는 커밋 없이 반영이 되는건가요?

fmt는 수정해주지는 않습니다. 진짜 체크만 해줘요
author_association: COLLABORATOR
user: login: cometj03
created_at: 2025-03-21T14:57:13Z
body: > fmt는 수정해주지는 않습니다. 진짜 체크만 해줘요

아하 그럼 커밋 전에 fmt 실행하는 걸 습관화해야겠군요
author_association: MEMBER
user: login: EATSTEAK
created_at: 2025-03-21T16:13:01Z
body: @cometj03 이거 rust-analyzer가 자동으로 해주게 하는 방법 있는데 나중에 공유할게요
author_association: COLLABORATOR
user: login: 2wndrhs
created_at: 2025-03-21T16:22:58Z
body: > 일단 run에서 SsufidCore에 대한 mutable reference를 참조하지 않으려는게 목적이니까 run 메서드에서 HashMap에 insert 하는 게 불가능해요. 특정 키에 대한 값(벡터)을 업데이트 하는 것만 가능.

완벽하게 이해되네요👍

그렇다면 `Arc`는 추후 멀티스레드 환경에서 `cache`에 접근하기 위함인가요?
author_association: MEMBER
user: login: cometj03
created_at: 2025-03-22T01:59:25Z
body: > 그렇다면 Arc는 추후 멀티스레드 환경에서 cache에 접근하기 위함인가요?

네 맞아요. 지금도 `Arc::clone(&self.cache)`으로 참조자를 하나 추가해서 사용하고 있습니당
author_association: MEMBER