[SAFY 개발일지] API 명세를 통해 레포지토리 추상화하기

이전 글: Solution Challenge 2024 참여 후기

필요한 정보 수집하기

개발을 시작하기 전 가장 먼저 해야 할 일은 바로 API를 명세하는 일이다.

 

먼저 개발해야 하는 화면을 살펴보자.

홈화면

홈화면홈화면홈화면
홈화면 피그마

교육의 각 카테고리를 Education, 그리고 행동에 관한 카테고리를 Action이라 하겠다(Action으로 할 지 Pose로 할 지를 고민했었다).

 

홈 화면을 구현하기 위해서는, Education 리스트 안에 썸네일 사진, 제목, 설명, 그리고 디테일 정보가 필요하다.

디테일 정보의 경우 Education Card 우상단의 버튼을 누르면, 디테일한 정보가 펼쳐지도록 디자인하였다.

교육 디테일 화면

각 카드를 클릭하면, 퀴즈에 대한 정보가 나온다.

Quiz 화면 피그마

퀴즈 화면은 퀴즈의 상태에 따른 정보로, 사용자가 퀴즈를 풀었는지에 따라

  • 풀지 않았으면 회색
  • 풀었는데
    • 맞았으면 녹색
    • 맞았으면 적색

으로 표시한다.

퀴즈 풀기 화면

저 퀴즈 아이콘을 클릭하면

퀴즈 내부 화면 피그마퀴즈 내부 화면 피그마
퀴즈 내부 화면 피그마

위와 같이 퀴즈를 풀 수 있는 화면이 뜬다.

 

이때 퀴즈의 경우 크게 두 가지 타입으로 나뉘어지는데, 객관식순서 맞추기이다.

 

객관식의 경우 여러 선택지 중 하나를 선택하는 타입의 퀴즈이다. 선택지가 O, X인 OX 문제도 구조상 객관식 퀴즈에 속한다.

순서 맞추기 퀴즈의 경우 여러 선택지를 올바른 순서로 배열하는 타입의 퀴즈이다.

행동 디테일 화면

Action Detail View Figma
Action Detail View Figma

한편 사용자의 행동에 관한 Action Card를 클릭하면 위와 같은 화면이 나온다. 제목, 동영상, 그리고 설명이 나온다.

영상 제출 화면

Action Submit View
Action Submit View Figma

현재 제공되는 액션 교육은 CPR이 유일하다. 사용자가 CPR하는 모습을 찍고, 이를 올릴 수 있는 화면은 위와 같다. 위 화면은 서버로부터 GET 요청을 보낼 일은 없고, 사용자의 영상을 서버로 올리는 요청을 해야 한다.

API 명세하기

개발해야 하는 주요 화면은 전부 살펴보았으니 이제 API를 명세할 차례이다.

먼저 여기서 내가 가져갈 전략이 있다. 바로 상위 모델과 디테일 모델을 구분하는 것이다.

 

위 화면에서 공통적으로, 모델의 리스트 화면(Education, Action List 화면)과, 그리고 해당 모델의 위젯(카드나 아이콘 버튼)을 눌렀을 때 나오는 디테일 화면(Education Detail 화면, Quiz Detail 화면)이 있다.

 

그래서 나의 전략은 Model-DetailModel로 나누는 것이다.

 

상위 모델을 부모 클래스로, 하위 Detail 모델을 자식 클래스로 정의하면, 캐싱을 쉽게 가져갈 수 있다.

캐싱에 대한 내용은 다음 글로 넘기겠다.

Education

EducationDetailModel

먼저 EducationModel에서 필요한 정보를 나열해보자.

{
    "id": 0,
    "title": "String",
    "description": "String?",
    "thumbUrl": "String?",
    "detail": "String?",
    "images": ["List<String>?"]
}

위 정보들이 있다면 화면을 구현할 수 있다.

 

여기서 Description과 Detail의 차이라면, Description은 EducationCard의 하단에 나오는 짧막한 설명글이고, Detail은 자세한 설명 보기를 눌렀을 때 나오는 html 형식의 긴 설명글이다.

 

images 역시 설명 보기를 눌렀을 때 나오는 사진들을 의미한다.

Education 모델의 필드Education 모델의 필드
Education 모델의 필드

이 정보들이 리스트 형태로 온다.

이 정보들은 GET /education으로 요청할 것이다.

{
    "meta": {
        "count": 1
    },
    "data": [
        {
            "id": 0,
            "title": "String",
            "description": "String?",
            "thumbUrl": "String?",
            "detail": "String?",
            "images": ["List<String>?"]
        }
    ]
}

이렇게 오게끔 디자인하였다.

 

이때 meta 정보가 굳이 필요한가 의아할 수 있다. count 정보는 사실 HTTP Header에도 넣을 수 있는 정보라서, 불필요해보인다.

이는 개인의 선호 영역인데, 나의 경우는 meta 정보는 관습적으로 넣는다. 특히 List 형태의 경우 Pagination이 들어갈 수도 있는데, Pagination의 경우 meta를 따로 두는 것이 API를 깔끔하게 명세하기 유리하기 때문이다.

 

물론 Pagination에 필요한 정보 역시 Body가 아닌 Header에 추가하는 전략 역시 유효하기에, 이는 각 프로젝트에 따라 백엔드와 상의해보고 결정할 문제이다.

EducationDetailModel

이 정보는 GET /education/{education-id} 로 요청 시 나오는 정보이다.

Quiz 화면 피그마
Quiz 화면 피그마

EducationCard를 눌렀을 때 나오는 Education Detail View에서 추가되는 정보는 Quiz 정보들밖에 없다.

EducationDetailModel:

{
    "id": 0,
    "title": "String",
    "description": "String?",
    "thumbUrl": "String?",
    "detail": "String?",
    "images": ["List<String>?"]
    "quizzes": [
        {
            "id": 0,
            "isSolved": 0
        }
    ]
}

Q. detail과 images는 EducationDetailModel에 있어야 하는 정보들 아닌가?

맞는 말이다! 원래는 위 정보들은 EducationDetailModel에 있어야 하는 정보들이었다.

 

하지만 왜 위 두 속성이 상위 모델로 이동하게 되었냐면

Education 모델의 필드
Education Card List View

 

원래 이 화면에서, 카드의 우상단 디테일 버튼이 존재하지 않았다. Education의 자세한 정보를 보려면 카드를 눌러서 DetailView에서 확인했어야 했다.

 

그런데 개발 과정에서 디테일 보기 버튼이 카드에 추가되면서, detail 설명과 images 역시 한 번에 오도록 변경했다.

그렇다면 detail 버튼을 눌렀을 때 detail 정보를 가져오도록 하면 안 되나?

어… 그것도 맞는 말이다. 사실 글을 쓰면서 이 방법이 생각이 났다.

 

두 방식은 장단점이 있는데

detail, images의 위치 장점 단점
`GET /education`에서 한 번에 오는 경우
  • detail 보기 버튼을 눌렀을 때 로딩 없이 바로 설명글을 볼 수 있음
  • detail 보기 버튼을 눌렀을 때 별도의 로직을 고민하지 않아도 됨
  • detail, images 정보가 추가되므로, 리스트뷰 입장에서는 필요도 없는 정보를 기다리느라 로딩이 더 오래 걸림(홈 화면에서 EducationCard를 그릴 때는 detail, images 정보가 필요 없으므로…)
`GET /education/{education-id}`에서 오는 경우
  • detail, images가 없으므로 좀 더 빠르게 Education Card View를 보여줄 수 있음
  • detail, images 정보를 명확하게 분리할 수 있음
  • detail 버튼을 눌렀을 때 detail 정보가 뜨는 게 느릴 수 있음
  • detail 버튼을 눌러도 그 안에 quiz 정보가 뜨는 건 아님. 그렇다면 엄격하게 설계 시 quiz 정보를 또다시 분리하는 것도 고민해야 함. 그렇게 하면 모델이 3개로 늘어나야 하거나, 혹은 nullable 처리를 복잡하게 가져가야 함.

 

장단점이 명확하긴 한데, 후자의 방식 중 단점 두 번째, quiz 정보의 경우, 사실 nullable 처리가 그리 복잡하진 않을 것 같거니와 별도의 상태를 따로 만드는 것도 그리 코드를 헤치지 않는 선에서 해결될 것 같다.

 

quizzes 정보의 경우, 사실 별도의 API에서 요청하는 방법도 있다. 예를 들어 GET /quiz?education=0이런 식으로 query parameter를 이용해서 요청하는 방법을 생각해볼 수 있다. 후자의 방식으로 요청하게 된다면, 위 방식이 더욱 더 자연스러운 방식이 될 것이다.

 

솔직히 지금 시점에서는 후자의 방식이 더 끌리긴 한다. 지금 다시 API 디자인하라고 하면 후자의 방식으로 디자인할 것 같다. 하지만 이미 전자의 방식으로 디자인하였기에 어쩔 수 없다...

Quiz

QuizStatusModel

EducationModel에 대응하는 퀴즈의 모델이 QuizStatusModel이다. 이때 QuizModel이라고 명명하지 않은 이유는, 이 모델이 실제 Quiz 정보를 담는다고 하기에는 너무 빈약한 정보만을 담고 있기 때문이다.

{
    "id": 0,
    "isSolved": 0
}

isSolved는 유저의 풀이 상태를 나타내는 데, 0인 경우 풀지 않았음을, 1인 경우 풀었는데 틀렸음을, 2인 경우 풀었는데 맞았음을 의미한다.

 

위 모델의 리스트가 GET /education/0과 같이 EducationDetailModel을 요청하였을 때 하위 목록으로 온다.

 

…이렇게 하는 방법이 있고, 사실 개발 중간에 바뀔 뻔한 내용이기는 한데 GET /quiz?education=0 이런 식으로 query parameter를 이용해서 요청하는 방법도 있기는 하다. 이는 RESTful에서 컬렉션과 리소스의 분류에 대한 논의인데, 결론부터 말하자면 둘 다 맞는 방법이다.

 

쿼리 파라미터를 이용하는 방법의 주장은, QuizStatusModel이라는 리소스는 어쨌건 quiz collection에 속해 있는 리소스이고, 그렇기 때문에 path가 /quiz가 되는 게 더 직관적이라는 주장이다.

 

하지만 이 모델이 Quiz 컬렉션에 들어가는 자원인 지는 더 고민해봐야 한다. QuizStatusModel은 Quiz라는 자원의 정보를 나타낸다고 보기보다는, 유저의 풀이 상태에 따라 달라지는 Quiz-User 사이의 모델에 더 가깝다고 볼 수 있다. DB의 시점까지 내려와서 본다면, 위 QuizStatusModel은 엄밀히 말해 Quiz 테이블을 조회하지 않는다. 유저가 이 문제를 풀었는지, 그 Solved Status를 모아두는 테이블을 조회한다. 그렇기 때문에 이 자원은 Quiz 컬렉션에 들어가는 자원으로 보기는 어렵다는 게 나의 입장이다(QuizModel이 아닌 QuizStatusModel로 명명한 또다른 이유이다).

 

결론적으로 Detail 모델에 QuizStatusModel을 포함하는 방식으로 디자인이 정해졌다.

뭐 둘 다 실제 현장에서 많이 쓰이는 방식이자 둘 다 RESTful한 방식으로 알고 있다. 정답이 있진 않다. 선택만이 존재할 뿐이다.

 

다만 쿼리 파라미터를 사용하지 않음으로써 한 가지 아주 큰 이점이 생겼다. 레포지토리 역시 추상화시킬 수 있고, 이는 곧 상속화의 이점을 아주 자연스럽게 가져갈 수 있게 되었다.

QuizDetailModel

GET /quiz/{quiz-id}로 요청 시 나오는 모델이다.

{
  "id": 1,
  "type": "MULTIPLE_CHOICE", // "ORDERING" or "MULTIPLE_CHOICE"
  "data": {
    "description": "지진 대피소 표시로 올바른 것을 고르세요",
    "answer": 1,
    "options": [
      {
        "number": "0",
        "description": "지진 대피소 표시 1"
      },
      {
        "number": "1",
        "description": "지진 대피소 표시 2",
        "imageUrl": "images/2.jpg"
      },
      {
        "number": "2",
        "description": "지진 대피소 표시 3",
        "imageUrl": "images/3.jpg"
      },
      {
        "number": "3",
        "description": "지진 대피소 표시 4",
        "imageUrl": "images/4.jpg"
      }
    ]
  }
}

여기서 아주 중요한 특징이 하나 있다. 퀴즈의 타입 별로 data가 달라진다.

 

위의 예시는 MULTIPLE_CHOICE(객관식) 타입이다. 이 경우 data에 answer가 int형으로 존재한다. 이때 answer는 정답인 option의 인덱스 위치를 나타낸다. 인덱스 번호임과 동시에 number라는 속성의 값과도 일치한다.

 

여기서 option number는 사실상 PK로 받아들여도 괜찮다. 실제 DB 상에는 Quiz와 Option을 연결해주는 부분키가 따로 존재한다((quizId, optionId) 형태의 복합키). 다만 PK의 경우 만약 옵션을 한 번 지웠다가 새로 생성하면, 기존의 PK를 사용하지 못할 수도 있기에, 별도의 number라는 일반 int형 컬럼을 따로 두었다. 이 부분은 백엔드에서 개발 시에 낸 아이디어인데, 굉장히 합리적이다. number는 PK가 아니기에, id라는 이름을 사용하지 않고 number라는 이름을 사용했다(Flutter 코드 상에서는 id라고 받아들여도 무방하다).

 

만약에 type이 ORDERING(순서 맞추기)일 경우, answer가 사라진다. 대신 options의 순서 그대로 정답이 된다.

{
  "description": "순서를 올바르게 배치하시오",
  "options": [
    {
      "id": 1,
      "description": "지진 대피소 표시 1"
    },
    {
      "id": 2,
      "description": "지진 대피소 표시 2",
      "imageUrl": "images/2.jpg"
    },
    {
      "id": 3,
      "description": "지진 대피소 표시 3",
      "imageUrl": "images/3.jpg"
    },
    {
      "id": 4,
      "description": "지진 대피소 표시 4",
      "imageUrl": "images/4.jpg"
    }
  ]
}

Flutter 상에서는 이 options를 랜덤으로 셔플해서 배치하도록 하였다.

 

물론 타입 별로 data를 나누지 않을 방법은 충분히 있다. ORDERING에서 answer에 아무 의미 없는 0을 넣어 남겨둘 수도 있다. 혹은 MULTIPLE_CHOICE에서 answer를 없애고, 무조건 0번 인덱스의 Option이 답이 되도록 설계할 수도 있다. 이때도 Flutter 상에서 Option을 셔플해서 배치해주면 된다.

 

다만 이럴 경우 타입이 다양해질 경우에는 어떻게 대응할 것인가? 예를 들어, 주관식이라는 타입이 새로 등장한다면? 이 경우 답은 int형이 아닌 String 형이 될 수도 있다. 물론 이때도 가능한 정답을 options에 넣어서, option의 description에 일치하는 String만 정답으로 처리할 수도 있다. 이때는 Flutter 상에서 option을 보이지 않게 하면 된다.

 

하지만 더 다양한 타입이 등장한다면? 결국에는 타입 별로 데이터를 변경해야만 하는 시점이 올 수 있다. 그리고 데이터를 언제나 일관적으로 유지한다면, 각기 다른 타입 별로 option을 배치하느라 백엔드에서 고민이 더 많아질 수도 있다. 그래서 그냥 처음부터 타입 별로 데이터가 다르게 들어올 수 있게끔 디자인하였다.

Action

ActionModel

{
    "id": 0,
    "title": "CPR",
    "description": "Let's learn How to do CPR correctly",
    "thumbUrl": "https://firebasestorage.googleapis.com/v0/b/solution-2024-safety-edu.appspot.com/o/assets%2Fimages%2Fthumbnail%2Fcpr.webp?alt=media&token=1454397f-b24e-4afe-bf09-3f3d2a3e499b"
}

Action의 경우 서버 자체가 달라진다. 하지만 요청 양식은 거의 비슷하다. Flutter 상의 컴포넌트도 동일하게 공유한다. 한 가지 차이점이라면 여기서는 detail 정보와 images 정보가 없다.

 

GET /action 요청 시:

{
    "meta": {
        "count": 1
    },
    "data": [
        {
            "id": 0,
            "title": "CPR",
            "description": "Let's learn How to do CPR correctly",
            "thumbUrl": "https://firebasestorage.googleapis.com/v0/b/solution-2024-safety-edu.appspot.com/o/assets%2Fimages%2Fthumbnail%2Fcpr.webp?alt=media&token=1454397f-b24e-4afe-bf09-3f3d2a3e499b"
        }
    ]
}

위처럼 meta 데이터와 함께 리스트 형태로 오는 것까지 동일하다.

 

실제 코드 상에서는 구현되지 아니하였지만 detail 정보와 images 정보를 제외하면 EducationModel과 필드가 거의 일치한다. 그래서 상속 구조나 인터페이스 구조로 만들 수도 있기는 한데, 굳이 그렇게까지 추상화를 해야할까 싶어서 그러지는 않았다(대신 후술하겠지만 IModelWithId 인터페이스를 만들어서 PK를 가진 모델들을 implements시켰다).

ActionDetailModel

GET /action/{action-id} 로 요청 시 detail 정보가 들어온다.

{
    "id": 0,
    "title": "CPR",
    "description": "Let's learn How to do CPR correctly",
    "thumbUrl": "https://firebasestorage.googleapis.com/v0/b/solution-2024-safety-edu.appspot.com/o/assets%2Fimages%2Fthumbnail%2Fcpr.webp?alt=media&token=1454397f-b24e-4afe-bf09-3f3d2a3e499b",
    "detail": "<h2>Try CPR Yourself</h2>\n<p>Watch the video and follow these steps!</p>\n<h3>Preparing for CPR</h3>\n<ol>\n<li>Assess the scene.</li>\n<li>Check the patient's responsiveness.</li>\n<li>Call 911.</li>\n<li>Retrieve an AED (Automated External Defibrillator).</li>\n<li>Check the patient's breathing.</li>\n</ol>\n<h3>CPR Procedure</h3>\n<ol>\n<li>Lay the patient flat on a firm surface.</li>\n<li>Identify the correct hand placement for chest compressions.</li>\n<li>Place the heel of one hand on the lower half of the breastbone.</li>\n<li>Interlock fingers after positioning, keeping the fingers off the palm.</li>\n<ul>\n<li><em>Fractures may occur if chest compressions are performed with fingers rather than palms.</em></li>\n</ul>\n<li>Ensure shoulders, elbows, and wrists are perpendicular.</li>\n<li>Press down vertically on the patient's chest about 5cm deep.</li>\n<li>Perform 30 compressions at a rate of 100 to 120 per minute.</li>\n</ol>",
    "videoUrl": "https://www.youtube.com/watch?v=q7J2T6MFA9g&t=84s"
}

ActionDetailModel의 경우 images 대신 videoUrl이 들어간다.

모델을 만들어보자

API 명세를 만들었다면 이제 실제 코드 상에서 Repository Layer를 만들 차례이다.

 

원래 Repository Layer는 Model, Repository, 그리고 DTO로 구성된다.

 

다만 여기서는 DTO를 사용하지 않고, Model을 직접 사용했다. 백엔드 개발하는 것도 아니고, 테이블의 형태가 달라지는 DB를 다루는 것도 아니고, 보통 프론트엔드에서는 들어오는 모델 그대로를 사용하기에 DTO를 굳이 사용해야 하나, 프론트엔드인데 너무 투머치 아닌가 하는 생각이었다. 그리고 일부러 아키텍처 구조를 생각하면서 API를 기껏 명세했다.

 

사실 따지고보면, QuizDetailModel에 isSolved 와 같은 정보가 필요 없는데도 일부러 상속 구조를 위해 남겨두었다(DTO를 안 쓰기 위해서).

 

하지만 DTO를 안 쓰니깐, API가 변경될 뻔한 상황(위에서 퀴즈에 쿼리 파라미터를 사용해서, 따로 요청하는 상황)에서 막막하기는 하더라. 변경이 되었을 때도 다행히 DTO를 만들지 않고 기존의 Model에 주입시킬 수 있긴 했다. 그래서 장단점이 있긴 한 것 같다.

 

Json Serializable과 freezed를 이용했다. 기본적으로 freezed를 이용하되, freezed는 상속을 지원하지 않으므로 상속 구조가 필요한 곳에서는 Json Serializable을 이용했다.

IModelWithId 인터페이스로 엔티티 클래스 묶기

id를 가지는 모델들을 인터페이스로 묶자. 이럴 경우 추후에 Repository와 StateNotifier를 추상화하기에 아주 도움이 된다. 이 인터페이스의 역할은 장고에서 모델 클래스를 구현할 때 Model 모듈을 상속받는 것과 비슷하다.

typedef Id = int;

extension IdExtension on String {
  Id toId() => int.parse(this);
}

/// id를 가지는 모델의 추상 클래스
abstract interface class IModelWithId<T extends Id> {
  final T id;

  const IModelWithId({
    required this.id,
  });

    @override
  String toString() {
    return 'id: $id';
  }

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;

    return other is IModelWithId && other.id == id;
  }

  @override
  int get hashCode => id.hashCode;
}

아래의 toString, operator==, hashCode는 구현해도 되고 안 해도 된다. 다만 toString을 구현해두면 디버깅 시에 아주 편리하다.

엔티티와 값 객체

동등성 체크 로직 역시 인터페이스에 구현해두는 편이 좋다. 왜냐하면 이 인터페이스는 엔티티를 나타내기 때문이다.

PK를 가지는 모델을 엔티티라고 한다. 엔티티의 경우 두 객체의 PK가 같으면, 같은 객체로 취급한다.

final person1 = Person(
    name: "Peter", // pk
    age: 10,
);

final person2 = Person(
    name: "Peter", // pk
    age: 24,
);

print(person1 == person2); // true

Person 엔티티에서 name을 PK라 한다면, “Peter”라는 사람은 같은 사람(객체)를 의미한다(동명 이인은 없다고 가정). 세월이 지나 나이가 변한다 하더라도 두 객체는 같은 사람을 나타낸다. 그렇기 때문에 여기서 동등성 체크 로직은 나이를 신경쓰지 않아야 한다. PK가 같으면 같은 객체이다.

 

그래서 엔티티의 경우 동등성 체크 로직을 놓친다면, 이상한 버그로 꼬일 수도 있다. 그렇기에 인터페이스에 구현해두는 편이 바람직하다고 볼 수 있다.

 

한편 Dart 외의 언어에서는 interface에 메소드를 추가하는 것을 허용하지 않는 경우가 많다. interface에 메소드를 이용할 수 있는 건 Dart의 특권 중 하나이다.

 

참고로 엔티티와 다르게, 필드 중 단 하나라도 값이 다르면 다른 객체 취급을 해야 하는 모델을 값 객체라고 부른다. 이 프로젝트에서는 QuizOption, QuizDataModel 등이 값 객체라 할 수 있다.

개별 모델 구현하기

QuizStatusModel

/// 정답의 상태
///
/// [AnswerStatus.none] : 아직 풀지 않은 경우
/// [AnswerStatus.correct] : 풀고 정답인 경우
/// [AnswerStatus.wrong] : 풀었는데 틀린 경우
enum AnswerStatus {
  @JsonValue(0)
  none,

  @JsonValue(1)
  correct,

  @JsonValue(2)
  wrong,
}

extension AnswerStatusExtension on int {
  AnswerStatus toAnswerStatus() {
    switch (this) {
      case 0:
        return AnswerStatus.none;
      case 1:
        return AnswerStatus.correct;
      case 2:
        return AnswerStatus.wrong;
      default:
        throw ArgumentError.value(this, 'value', 'Invalid AnswerStatus value');
    }
  }
}

@JsonSerializable()
class QuizStatusModel implements IModelWithId {
//   const factory QuizStatusModel({
//     required AnswerStatus answerStatus,
//     required Id id,
//   }) = _QuizStatusModel;

//   factory QuizStatusModel.fromJson(Map<String, dynamic> json) =>
//       _$QuizStatusModelFromJson(json);
//
  @override
  final Id id;

  @JsonKey(name: 'isSolved')
  final AnswerStatus answerStatus;

  QuizStatusModel({
    required this.answerStatus,
    required this.id,
  });

  factory QuizStatusModel.fromJson(Map<String, dynamic> json) =>
      _$QuizStatusModelFromJson(json);

  QuizStatusModel copyWith({
    Id? id,
    AnswerStatus? answerStatus,
  }) {
    return QuizStatusModel(
      id: id ?? this.id,
      answerStatus: answerStatus ?? this.answerStatus,
    );
  }
}

solvedStatus는 JSON에서 int형으로 들어온다. 이를 Dart 코드 상에서는 enum으로 받고, JSONKEY를 이용해서 변환해준다.

 

한편 Dart의 기능 중 하나인 extension을 만들어두면, 0.toAnswerStatus()와 같이 int에다가 직접 method를 추가 가능하다. 매우 강력한 기능이니 애용하도록 하자.

 

한편 isSolved의 경우 원래 API 명세 단계에서 answerStatus라는 이름을 사용하기로 합의했다.

 

isSolved의 경우 true, false로 나타냈었는데, 나중에 유저가 틀렸는지 맞았는지에 따라 퀴즈 아이콘 색상을 빨간색, 녹색으로 표시하는 기능을 추가하면서 boolean이 아닌 enum으로 변경되었다. 그래서 isSolved에서 answerStatus로 필드명이 변경되었다.

 

그런데 실제 API를 받아보니깐 answerStatus로 바뀌지 않고 isSolved가 그대로 사용되고 있어서, 그냥 flutter code 상에서는 더 직관적인 answerStatus를 사용하고, 대신 JSONKEY를 붙여서 대응하였다.

EducationModel

@JsonSerializable()
class EducationModel implements IModelWithId {
  @override
  final Id id;

  // 카테고리 제목
  final String title;

  // 카테고리 설명
  final String? description;

  // 카테고리 이미지
  final String? thumbUrl;

  // 카테고리 상세 내용
  final String? detail;

  /// detail 페이지에 표시할 이미지들(nullable)
  final List<String>? images;

  const EducationModel({
    required this.id,
    required this.title,
    required this.description,
    required this.thumbUrl,
    required this.detail,
    required this.images,
  });

  factory EducationModel.fromJson(Map<String, dynamic> json) =>
      _$EducationModelFromJson(json);

  factory EducationModel.copyWith({
    required EducationModel model,
    Id? id,
    String? title,
    String? description,
    String? thumbUrl,
    String? detail,
    List<String>? images,
  }) {
    return EducationModel(
      id: id ?? model.id,
      title: title ?? model.title,
      description: description ?? model.description,
      thumbUrl: thumbUrl ?? model.thumbUrl,
      detail: detail ?? model.detail,
      images: images ?? model.images,
    );
  }
}

JsonSerializable은 freezed와 달리 copyWith를 자동생성해주지 않는다. 그래서 필요하다면 따로 구현해야 한다.

EducationDetailModel

@JsonSerializable()
class EducationDetailModel extends EducationModel {
  final List<QuizStatusModel> quizzes;

  const EducationDetailModel({
    required super.id,
    required super.title,
    required super.description,
    required super.thumbUrl,
    required super.detail,
    required super.images,
    required this.quizzes,
  });

  factory EducationDetailModel.fromJson(Map<String, dynamic> json) =>
      _$EducationDetailModelFromJson(json);
}

JsonSerializable을 이용하였기에 위처럼 상속 구조를 사용할 수 있다. 그리고 Dart 최신 버전의 경우 super 키워드를 통해서 부모 클래스의 초기화를 굉장히 직관적으로 작성할 수 있다.

퀴즈마다 데이터가 달라진다면 어떻게 파싱해야 할까?

다른 모델들은 어렵지 않게 구현할 수 있지만, QuizDetailModel은 조금 까다롭다. type에 따라 데이터가 달라지기 때문이다.

 

먼저 type을 enum으로 정의하자.

enum QuizType {
  @JsonValue('MULTIPLE_CHOICE')
  multipleChoice,

  @JsonValue('ORDERING')
  order,
}

extension QuizTypeExtension on String {
  QuizType toQuizType() {
    switch (this) {
      case 'MULTIPLE_CHOICE':
        return QuizType.multipleChoice;
      case 'ORDERING':
        return QuizType.order;
      default:
        throw ArgumentError.value(this, 'value', 'Invalid QuizType value');
    }
  }
}

그리고 Detail 모델을 만들기 전에 먼저 QuizDataModel을 만들자.

@freezed
sealed class QuizDataModel with _$QuizDataModel {
  const factory QuizDataModel.ordering({
    required String description,
    required List<QuizOption> options,
  }) = QuizDataOrdering;

  const factory QuizDataModel.multipleChoice({
    required String description,
    required List<QuizOption> options,
    required int answer,
  }) = QuizDataMultipleChoice;

  factory QuizDataModel.fromJson(Map<String, dynamic> json) =>
      _$QuizDataModelFromJson(json);
}

freezed의 union 기능을 이용한다면, QuizDataModel을 implements하는 QuizDataOrdering, QuizDataMultipleChoice를 쉽게 만들 수 있다. fromJson 메소드도 두 타입 각각 알아서 잘 생성이 된다.

이제 QuizDetailModel을 만들자.

class QuizDetailModel extends QuizStatusModel {
  final QuizType type;
  final QuizDataModel data;

  QuizDetailModel({
    required this.type,
    required this.data,
    required super.id,
    required super.answerStatus,
  });

    factory QuizDetailModel.fromJson(Map<String, dynamic> json) {
        // fromJson 구현
    }
}

여기서 fromJson을 직접 구현해야 한다. type에 맞추어서 각각의 QuizDataModel에 대해 Json 파싱을 진행하는 코드를 만들어보자.

factory QuizDetailModel.fromJson(Map<String, dynamic> json) {
  final id = json['id'] as Id;
  final type = (json['type'] as String).toQuizType();
  final data = json['data'] as Map<String, dynamic>;
  final answerStatus = (json['answerStatus'] as int).toAnswerStatus();

  if (type == QuizType.multipleChoice) {
    return QuizDetailModel(
      id: id,
      answerStatus: answerStatus,
      type: QuizType.multipleChoice,
      data: QuizDataMultipleChoice.fromJson(data),
    );
  } else {
    return QuizDetailModel(
      id: id,
      answerStatus: answerStatus,
      type: QuizType.order,
      data: QuizDataOrdering.fromJson(data),
    );
  }
}

id, type, data, answerStatus는 공통으로 있는 필드이므로 그대로 직렬화한다.

 

if 문이 중요한 데, 타입에 맞추어서 data: QuizDataMultipleChoice.fromJson(data), data: QuizDataOrdering.fromJson(data) 를 다르게 파싱해주면 된다.

한편 QuizOption 모델은 아래와 같다.

@freezed
class QuizOption with _$QuizOption {
  const factory QuizOption({
    required int number,
    String? description,
    String? imageUrl,
  }) = _QuizOption;

  factory QuizOption.fromJson(Map<String, dynamic> json) =>
      _$QuizOptionFromJson(json);
}

QuizDetailModel의 구현은 다소 복잡했기에 전체 코드를 남긴다.

 

전체 코드:

더보기
enum QuizType {
  @JsonValue('MULTIPLE_CHOICE')
  multipleChoice,

  @JsonValue('ORDERING')
  order,
}

extension QuizTypeExtension on String {
  QuizType toQuizType() {
    switch (this) {
      case 'MULTIPLE_CHOICE':
        return QuizType.multipleChoice;
      case 'ORDERING':
        return QuizType.order;
      default:
        throw ArgumentError.value(this, 'value', 'Invalid QuizType value');
    }
  }
}

/// ### 퀴즈 모델
///
/// - [id] 퀴즈의 ID
/// - [type] 퀴즈의 타입
///   - [QuizType.multipleChoice] 객관식 퀴즈
///   - [QuizType.order] 순서 맞추기 퀴즈
/// - [item] 퀴즈의 내용
///
/// 예시:
/// ```json
/// {
///   "id": 1,
///   "type": "MULTIPLE_CHOICE", // "ORDERING" or "MULTIPLE_CHOICE"
///   "item": {
///     "description": "지진 대피소 표시로 올바른 것을 고르세요",
///     "answer": 1,
///     "options": [
///       {
///         "id": "0",
///         "description": "지진 대피소 표시 1"
///       },
///       {
///         "id": "1",
///         "description": "지진 대피소 표시 2",
///         "imageUrl": "images/2.jpg"
///       },
///       {
///         "id": "2",
///         "description": "지진 대피소 표시 3",
///         "imageUrl": "images/3.jpg"
///       },
///       {
///         "id": "3",
///         "description": "지진 대피소 표시 4",
///         "imageUrl": "images/4.jpg"
///       }
///     ]
///   }
/// }
/// ```
class QuizDetailModel extends QuizStatusModel {
  final QuizType type;
  final QuizDataModel data;

  QuizDetailModel({
    required this.type,
    required this.data,
    required super.id,
    required super.answerStatus,
  });

  factory QuizDetailModel.fromJson(Map<String, dynamic> json) {
    final id = json['id'] as Id;
    final type = (json['type'] as String).toQuizType();
    final data = json['data'] as Map<String, dynamic>;
    final answerStatus = (json['answerStatus'] as int).toAnswerStatus();

    if (type == QuizType.multipleChoice) {
      return QuizDetailModel(
        id: id,
        answerStatus: answerStatus,
        type: QuizType.multipleChoice,
        data: QuizDataMultipleChoice.fromJson(data),
      );
    } else {
      return QuizDetailModel(
        id: id,
        answerStatus: answerStatus,
        type: QuizType.order,
        data: QuizDataOrdering.fromJson(data),
      );
    }
  }
}

/// 퀴즈 아이템 모델
///
/// [QuizItemModel.ordering]과 [QuizItemModel.multipleChoice] 두 가지가 존재함
@freezed
sealed class QuizDataModel with _$QuizDataModel {
  /// 순서 맞추기 퀴즈 타입\
  /// 이때 options.id가 그대로 순서가 된다
  ///
  /// `answer` 필드가 없음
  ///
  /// 예시:
  /// ```json
  /// {
  ///   "description": "지진 대피소 표시로 올바른 것을 고르세요",
  ///   "options": [
  ///     {
  ///       "id": 1,
  ///       "description": "지진 대피소 표시 1"
  ///     },
  ///     {
  ///       "id": 2,
  ///       "description": "지진 대피소 표시 2",
  ///       "imageUrl": "images/2.jpg"
  ///     },
  ///     {
  ///       "id": 3,
  ///       "description": "지진 대피소 표시 3",
  ///       "imageUrl": "images/3.jpg"
  ///     },
  ///     {
  ///       "id": 4,
  ///       "description": "지진 대피소 표시 4",
  ///       "imageUrl": "images/4.jpg"
  ///     }
  ///   ]
  /// }
  /// ```
  const factory QuizDataModel.ordering({
    required String description,
    required List<QuizOption> options,
  }) = QuizDataOrdering;

  /// 객관식 퀴즈 타입
  ///
  /// 예시:
  /// ```json
  /// {
  ///   "description": "지진 대피소 표시로 올바른 것을 고르세요",
  ///   "answer": 1,
  ///   "options": [
  ///     {
  ///       "id": "0",
  ///       "description": "지진 대피소 표시 1"
  ///     },
  ///     {
  ///       "id": "1",
  ///       "description": "지진 대피소 표시 2",
  ///       "imageUrl": "images/2.jpg"
  ///     },
  ///     {
  ///       "id": "2",
  ///       "description": "지진 대피소 표시 3",
  ///       "imageUrl": "images/3.jpg"
  ///     },
  ///     {
  ///       "id": "3",
  ///       "description": "지진 대피소 표시 4",
  ///       "imageUrl": "images/4.jpg"
  ///     }
  ///   ]
  /// }
  /// ```
  const factory QuizDataModel.multipleChoice({
    required String description,
    required List<QuizOption> options,
    required int answer,
  }) = QuizDataMultipleChoice;

  factory QuizDataModel.fromJson(Map<String, dynamic> json) =>
      _$QuizDataModelFromJson(json);
}

/// 퀴즈의 선택지
///
/// - [id] 선택지 id는 `int`여야 함
///   - 만약 [ORDERING] 타입의 퀴즈라면, id의 순서가 정답이 됨
/// - [description] 퀴즈 설명
/// - [imageUrl] 퀴즈 이미지
///
/// **둘 중 하나는 있어야 함**
@freezed
class QuizOption with _$QuizOption {
  const factory QuizOption({
    required int number,
    String? description,
    String? imageUrl,
  }) = _QuizOption;

  factory QuizOption.fromJson(Map<String, dynamic> json) =>
      _$QuizOptionFromJson(json);
}

레포지토리를 만들어보자

Repository Layer에서 가장 중요한 Repository를 만들 차례다.

레포지토리 인터페이스로 묶기

여기서도 마찬가지로 인터페이스부터 만들자. 엔티티 모델 클래스를 IModelWithId로 묶은 보람이 여기서부터 드러난다.

abstract interface class IModelListRepository<T extends IModelWithId> {
  Future<ModelList<T>> fetch();
}

abstract interface class IDetailRepository<T extends IModelWithId>
    implements IModelListRepository<T> {
  Future<T> getDetail({required Id id});
}

Dart에서도 C++의 template concept같은 기능을 제공한다. <T extends IModelWithId> 로 제네릭을 작성하면, 타입 T는 IModelWithId를 implements, extends하는 타입만 올 수 있게 된다.

 

 

한편 현재까지의 개발 과정에서 모든 레포지토리는 fetch, getDetail을 사용한다. 그럼에도 두 레포지토리를 나눠놓은 이유는 따로 있는데, 일부 레포지토리의 경우 fetch는 사용하는 데 getDetail을 사용하지 않을 가능성이 다분했기 때문이다.

 

그게 rankRepository이다. 원래는 유저의 퀴즈 성적, CPR과 같은 Pose Estimation 성적을 기반으로 랭킹을 매기려고 했었다. 그런데 개발 과정에서 어느샌가 랭킹이 사라졌고, 자연스레 레포지토리를 구분해놓은 의미 역시 퇴색되었다….

Retrofit을 이용해서 Repository 구현하기

모델만 잘 정의하다면 Retrofit을 이용하여 RESTful API 레포지토리를 아주 쉽게 구현할 수 있다.

Education, Quiz Repository

final educationRepositoryProvider = Provider<EducationRepository>(
  (ref) {
    final dio = ref.watch(dioProvider);

    return EducationRepository(dio);
  },
);

@RestApi(baseUrl: '/education')
abstract class EducationRepository
    implements IDetailRepository<EducationModel> {
  factory EducationRepository(Dio dio) = _EducationRepository;

  @override
  @GET('')
  @Headers({'accessToken': 'true'})
  Future<ModelList<EducationModel>> fetch();

  @override
  @GET('/{id}')
  @Headers({'accessToken': 'true'})
  Future<EducationDetailModel> getDetail({
    @Path() required Id id,
  });
}
final quizRepositoryProvider = Provider<QuizRepository>(
  (ref) {
    final dio = ref.watch(dioProvider);

    return QuizRepository(dio);
  },
);

@RestApi(baseUrl: '/quiz')
abstract class QuizRepository implements IDetailRepository<QuizStatusModel> {
  factory QuizRepository(Dio dio) = _QuizRepository;

    /// depreciated
  @override
  @GET('/')
  @Headers({'accessToken': 'true'})
    @Deprecated('Quiz fetch does not work') // 실제로는 작동하지 않는 함수임
  Future<ModelList<QuizStatusModel>> fetch();

  @override
  @GET('/{id}')
  @Headers({'accessToken': 'true'})
  Future<QuizDetailModel> getDetail({
    @Path() required Id id,
  });

  @POST('/{id}')
  @Headers({
    'accessToken': 'true',
    'Content-Type': 'application/json',
  })
  Future<void> submit({
    @Path() required Id id,
    @Body() required UserAnswerModel userAnswer,
  });
}

헤더에 붙은 accessToken에 대한 내용은 이전에 쓴 JWT 토큰에 관한 내용과 동일하다.

 

 

@RestApi(baseUrl: '/quiz')

여기서 붙는 baseUrl은 dio에 붙는 baseUrl 뒤에 덧붙여진다.

 

그래서 dio에서 서버의 baseUrl을 지정했으면, 위 퀴즈 레포지토리는 알아서 /{server-url}/quiz 이런 식으로 path가 설정된다.

 

한편 QuizRepository의 fetch 함수의 경우, 사실은 사용되지 않는 함수이다. 왜냐면 List<QuizStatusModel>의 경우 이미 EducationDetailModel을 받아올 때 내부 속성으로 갖고 있기 때문이다. 또한 GET /quiz라는 url 자체가 존재하지 않는다.

위 함수를 구현한 이유는 순전히 상속 과정에서 fetch 함수를 구현할 문법상의 필요가 있었기 때문이다.

그렇기 때문에 사용하지 말라는 의미에서 @Depreciated Annotation을 붙여주었다.

한편 퀴즈를 풀었으면, 퀴즈를 풀었다고 서버에 알려야 하기 때문에 submit 함수도 추가된다.

ActionRepository

final actionRepositoryProvider = Provider<ActionRepository>(
  (ref) {
    final dio = ref.watch(dioProvider);
    final env = ref.watch(envProvider);

    return ActionRepository(dio, baseUrl: '${env.actionApiUrl}/action');
  },
);

@RestApi()
abstract class ActionRepository implements IDetailRepository<ActionModel> {
  factory ActionRepository(Dio dio, {String baseUrl}) = _ActionRepository;

  @override
  @GET('/')
  @Headers({'accessToken': 'true'})
  Future<ModelList<ActionModel>> fetch();

  @override
  @GET('/{id}')
  @Headers({'accessToken': 'true'})
  Future<ActionDetailModel> getDetail({
    @Path() required Id id,
  });
}

한편 Action의 경우 baseUrl 자체가 완전히 달라지기에 위와 같이 해야 한다.

ActionRepository(dio, baseUrl: '${env.actionApiUrl}/action');
factory ActionRepository(Dio dio, {String baseUrl}) = _ActionRepository;

이렇게 지정해주어야 dio의 base url을 신경쓰지 않고, 완전히 새로운 url로 덮어쓴다.

사실 원래 프론트 상에서 서버의 변화를 신경쓰면 잘못된 거긴 하다. 원래의 정석적인 개발은, 서버 단에서 API 게이트웨이를 만들고, 모바일에서는 언제나 같은 서버 url로 요청을 보내야 한다. 서버에서 redirection 등을 통해서 firebase functions 등의 url로 요청을 보내는 게 정석적인 방법이다.

다만 서버 측 개발자들이 이런 방식을 시도해본 경험 자체가 전무했기에, 빠른 개발을 위해서 프론트 단에서 서로 다른 서버 주소로 요청을 보내도록 개발했다.

mixin 사용을 시도해봤다

한편 fetch, getDetail 함수를 mixin으로 사용하는 방법도 고민했다. 이 부분을 사실 꽤 많이 고민했는데, getDetail만 있고 fetch가 없는 경우도 고려해봐야 하기 때문이다.

왜냐면 사실 QuizRepository의 경우 fetch 함수가 없다. GET /quiz라는 url이 존재하지 않기 때문이다. 그런데 QuizRepository에서 getDetail을 사용하고 싶은 경우, 위 2차 계층 상속 구조에서는 fetch 함수 역시 구현해야 한다. Retrofit을 사용하기에 fetch 함수를 일단 구현해놓고, QuizStateNotifier의 fetch 함수에서 repository의 fetch 함수를 사용하지 않도록, 아무 의미없는 빈 로직을 오버라이드하였다. 이게 얼마나 비효율적인 구현인가…

그래서 mixin을 사용해서, EducationRepository의 경우 fetch, getDetail을 모두 with로 붙이고, QuizRepository의 경우 getDetail만 with로 붙이는 방법을 고민했다.

abstract interface class IModelListRepository<T extends IModelWithId> {
  Future<ModelList<T>> fetch();
}

mixin GetDetailRepositoryMixin<T extends IModelWithId> {
  Future<T> getDetail({required Id id});
}

다만 이 방식은 provider를 구현하는 단계에서 막혔다.

class ModelListProvider<Model extends IModelWithId,
        Repository extends IModelListRepository<Model>>
    extends StateNotifier<ModelListState> {
  final Repository repository;

  ModelListProvider({
    required this.repository,
  }) : super(ModelListLoading());

  Future<void> fetch() async {
    // 구현 생략
  }
}

mixin DetailProviderMixin<Model extends IModelWithId,
        Repository extends IModelListRepository<Model> with GetDetailRepositoryMixin> // 여기서 문제 발생  
    on ModelListProvider<Model, Repository> {
  Future<void> getDetail({
    required Id id,
  }) async {
    // 만약 데이터가 없다면 fetch()를 호출
    if (state is! ModelList) {
      await fetch();
    }

    // 구현 생략

    final response = await repository.getDetail(id: id); // 문제 발생

    // 구현 생략
  }
}

이론상 여기까지만 잘 되면 좋았겠으나…

Error
결국에 실패...

Repository extends IModelListRepository<Model> with GetDetailRepositoryMixin // with 지원 안 함

제네릭 내부에서 with를 지원하지 않아서 턱하고 막혔다… 결국 많은 고민을 한 이 mixin을 이용하는 방법은 실패로 돌아갔다.


 

Repository까지 구현했다. 잘 추상화된 Repository가 구현되었으므로, 이제는 이를 이용해서 Service Layer를 개발할 차례이다.

다음 개발일지에서 캐싱 로직을 어떻게 일반화하였는 지 작성하겠다.

 

그리고 으레 그렇듯이 개발할 때는 몰랐지만 지금 돌아보면 개선할 점들이 조금씩 보이기 시작한다. API 명세부터 벌써 이게 맞나 의심이 들기 시작하지만 어쩌겠어… 이렇게 배워가는 거겠지.