쌤! 저 오늘 학원 가야하는데요.

SteadySlower·2022년 1월 21일
0
post-thumbnail

기능 소개 📅

포스팅의 제목은 아마 생활 지도 담당 선생님이 가장 많이 듣는 말 중에 하나일 것 같은데요. 원래 생활 지도를 걸리면 그 날 봉사활동을 하는 것이 원칙입니다. 하지만 학생들은 다양한 이유로 봉사활동을 미루고 싶어합니다. 따라서 지금 구현한 명단 기능은 생활지도 담당 선생님의 업무를 100% 커버하지 못합니다.

따라서 이번에는 봉사활동 지도 기능을 추가하겠습니다. 해당 기능으로는 봉사활동을 마친 학생을 마쳤다고 체크할 수도 있고 봉사활동을 미루는 학생이 언제 봉사활동을 할 것인지 지정할 수도 있습니다.

계획

봉사활동을 실시할 날짜를 저장하기 (DateFormatter)

현재 DB에 날짜를 저장하는 column은 createdAt과 updatedAt 밖에 없습니다. 해당 column을 봉사활동 날짜를 저장하기 위해서 쓰는 것은 부적합합니다.

따라서 table에 새로운 column을 만들고 String으로 날짜를 저장합시다. 그리고 API를 통해 읽어온 String을 DateFormatter를 활용해서 Date 객체로 바꾸어서 클라이언트에서 사용할 계획입니다. 저장하는 것은 이 반대로 하면 될 것 같네요.

봉사활동 미루기 기능

봉사활동을 미루는 기능을 구현하기 위해서는 DatePicker를 사용하면 될 것 같습니다. 봉사활동을 실시할 날짜를 고르고 봉사활동을 미루는 날짜를 수정하는 것이죠.

추가적으로 학생이 봉사활동을 미룬 학생인지 아닌지 저장할 필요가 있습니다. 왜냐하면 교칙 상 봉사활동을 미루는 것은 1번만 할 수 있거든요. 봉사활동을 미뤘는지 여부는 status column에 저장하도록 하겠습니다.

구현 (DB 및 서버 수정)

DB에 date column 추가하기

봉사활동을 해야하는 날을 의미하는 date 컬럼을 추가했습니다.

서버 API들 수정

자잘하게 수정한 부분이 많아서 코드를 다 첨부하기 보다는 수정한 부분을 간략하게 나열하겠습니다.

  1. Guidance를 POST할 때 date를 인자로 받도록 수정
  2. Guidance를 GET할 때 보내는 JSON 데이터에 date와 status를 추가함.

👉  결론적으로 Guidance Model을 수정하기 위함입니다.

구현: Guidance Model 부분 수정하기

Utilities: String을 Date 객체로

날짜를 다루기 위해서 Utilities에 두 함수를 추가합니다. 첫번째는 String으로 Date객체를 만드는 것이고 두번째는 오늘 Date를 String으로 바꾸는 함수입니다. 첫번째는 서버에서 온 데이터를 파싱할 때 사용하고 두번째는 서버에 데이터를 볼낼 때 사용합니다. 시간을 저장할 필요는 없으므로 dateFormat은 년-월-일의 구조를 가집니다.

class Utilities {
       
    // string으로 date 객체 만들기
    func makeDateFromString(dateString: String) -> Date {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd"
        let date = dateFormatter.date(from: dateString)
        return date!
    }
    
    // date를 string으로 바꾸기
    func getTodayDateString() -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd"
        let date = Date()
        return dateFormatter.string(from: date)
    }
}

GuidanceRawData 수정

서버에서 보내주는 데이터를 postman으로 확인해보면 아래와 같습니다. 거기에 맞추어서 RawData Model을 수정합니다.

struct GuidanceRawData: Codable {
    let studentID: Int
    let grade: Int
    let classNumber: Int
    let number: Int
    let name: String
    let profileURLImage: String?
    let guidanceID: Int
    let reason: String
    let detail: String?
    let date: String
    let status: String
}

Guidance 수정

봉사활동을 미뤘는지 여부를 알려주는 지표인 Bool값인 isDelayed를 추가했습니다. 그리고 서버에서 오는 String값인 dateString을 Date 객체로 바꾸어 init합니다.

struct Guidance {
    let id: Int
    let student: Student
    let reason: GuidanceReason
    let date: Date
    let isDelayed: Bool
    
    init(rawData: GuidanceRawData) {
        self.id = rawData.guidanceID
        self.student = Student(from: rawData)
        self.reason = GuidanceReason(from: rawData)
        
        self.date = Utilities().makeDateFromString(dateString: rawData.date)
        
        switch rawData.status {
        case "VALID": self.isDelayed = false
        case "DELAYED": self.isDelayed = true
        default: self.isDelayed = false
        }
    }
}

GuidanceService 수정

이제 서버에 POST를 할 때 date를 String으로 보내주어야 합니다. Param에 오늘 날짜를 String으로 바꾸어 추가해주도록 합시다.

class GuidanceService {
    static let shared = GuidanceService()
    
    func uploadGuidance(studentID: Int, reason: GuidanceReason, completionHandler: @escaping (Guidance) -> Void) {
        var params: [String: String] = ["studentID": "\(studentID)"]
        params["reason"] = reason.description

	//⭐️ 추가한 부분
        params["date"] = Utilities().getTodayDateString()
        
        if case .others(let detail) = reason {
            if let detail = detail {
                params["detail"] = detail
            }
        }
        
        AF.request("\(SERVER_BASE_URL)/guidances", method: .post, parameters: params, encoder: JSONParameterEncoder.default).responseDecodable(of: Response<GuidanceRawData>.self) { data in
            guard let response = data.value else { return }
            guard response.isSuccess == true else { return }
            guard let guidanceRawData = response.result else { return }
            let guidance = Guidance(rawData: guidanceRawData)
            completionHandler(guidance)
        }
    }
}

구현: 봉사지도 UI 만들기

지도 명단과 거의 동일한 모습입니다. segment와 table view로 이루어져있습니다. 자세한 코드는 생략하겠습니다.

차이점은 cell에 버튼이 2가지 있다는 것입니다. 첫번째 버튼은 봉사활동을 이행했을 때를 위한 버튼이고 두 번째는 봉사활동을 미룰 때를 위한 버튼입니다.

Tip) 버튼 이미지 크기 버튼 크기와 동일하게 맞추기

UIButton의 setImage를 통해서 이미지를 지정하면 버튼 크기에 맞추어 이미지가 세팅되는 것이 아니라 이미지의 크기에 맞추어 이미지가 세팅됩니다. 따라서 원본 이미지가 작으면 버튼 크기에 상관 없이 작은 이미지가 나옵니다.

하지만 contentVerticalAlignment와 contentHorizontalAlignment를 통해 세로, 가로 정렬을 .fill로 해주면 버튼을 꽉 채우도록 이미지가 확대되는 것을 볼 수 있습니다.

let completeButton: UIButton = {
    let button = UIButton()
    button.widthAnchor.constraint(equalToConstant: 40).isActive = true
    button.heightAnchor.constraint(equalToConstant: 40).isActive = true
    button.setImage(UIImage(systemName: "play.circle"), for: .normal)
    // 버튼 이미지 크기 버튼 크기에 맞추는 코드
    button.contentVerticalAlignment = .fill
    button.contentHorizontalAlignment = .fill

    button.addTarget(self, action: #selector(completeButtonTapped), for: .touchUpInside)
    return button
}()

구현: 오늘 지도할 학생 명단 받아오는 API

보통 서버에서 해당 API를 구현해주고 클라이언트 개발자는 그 API를 사용하는 것이 맞는 방법이지만 컴동쌤은 iOS 개발에 집중하고 싶습니다. 그래서 현재 있는 API를 활용해서 오늘 지도할 학생의 명단을 만들어 보겠습니다.

일단 서버에서 rawdata를 받아서 Guidance 객체로 파싱하는 것까지는 동일합니다. 다만 이번에는 고차함수 filter를 통해서 봉사 날짜가 오늘과 동일하거나 이전의 것만을 거릅니다.

💡 Date 객체를 단순하게 비교 연산자를 통해서 비교하는 것도 가능하다고 합니다만 calendar의 compare 메소드를 사용하는 이유는 ‘하루’ 단위로 비교하고 싶기 때문입니다. compare를 사용하면 비교하고자 하는 시간의 단위를 지정할 수 있습니다.

func fetchGuidancesToManageToday(completionHandler: @escaping ([Guidance]) -> Void) {
    AF.request("\(SERVER_BASE_URL)/guidances").responseDecodable(of: Response<[GuidanceRawData]>.self) { data in
        guard let response = data.value else { return }
        guard response.isSuccess == true else { return }
        guard let rawdata = response.result else { return }
        
        let guidances = rawdata.map { rawData in
            return Guidance(rawData: rawData)
        }
        
        // 봉사 일정이 오늘 + 이전의 것만 filtering
        let calendar = Calendar.current
        let today = Date()
        
        let todayGuidances = guidances.filter { guidance in
            let date = guidance.date
            return calendar.compare(date, to: today, toGranularity: .day) != .orderedDescending
        }
        
        completionHandler(todayGuidances)
    }
}

구현: 봉사지도 viewModel 만들기

VC를 위한 VM만들기

기존에 StudentListViewModel과 거의 유사합니다. filter의 종류과 guidances를 불러오는 API 메소드만 달라졌을 뿐입니다.

import Foundation

class GuidanceManageViewModel {
    
    private var _guidances: [Guidance] {
        didSet {
            filterGuidances()
        }
    }
        
    lazy var guidances: [Guidance] = _guidances
    
    var filter: GuidanceManageListFilter = .first {
        didSet {
            filterGuidances()
        }
    }
    
    init() {
        self._guidances = [Guidance]()
        GuidanceService.shared.fetchGuidancesToManageToday { [weak self] guidances in
            self?._guidances = guidances
        }
    }
    
    func resetGuidances() {
        GuidanceService.shared.fetchGuidancesToManageToday { [weak self] guidances in
            self?._guidances = guidances
        }
    }
    
    private func filterGuidances() {
        switch filter {
        case .first:
            self.guidances = _guidances.filter { guidance in
                guidance.student.grade == 1
            }
        case .second:
            self.guidances = _guidances.filter { guidance in
                guidance.student.grade == 2
            }
        case .third:
            self.guidances = _guidances.filter { guidance in
                guidance.student.grade == 3
            }
        }
    }
}

Cell을 위한 VM 만들기

StudentListCellViewModel과 유사한 점이 많습니다. 다만 봉사 일정이 지났다면 (ex. 원래 어제 해야하는데 안하고 간 경우) 일정이 빨간색으로 나타나도록 구현했습니다. 아래 예시를 보시면 오늘 (1월 20일) 기준으로 봉사일이 지난 학생은 빨간색으로 일정이 표시되도록 했습니다. 여기에도 마찬가지로 calendar의 compare 메소드를 사용합니다. 또한 isDelayed를 확인하고 한번 연기된 일정이라면 (연기)라는 문자열을 추가합니다.

import UIKit

struct GuidanceManageCellViewModel {
    
    // MARK: - Properties
    
    let guidance: Guidance
    
    // MARK: - LabelText
    
    var infoLabelText: String {
        let student = guidance.student
        return "\(student.grade)학년 \(student.classNumber)\(student.number)\(student.name)"
    }
    
    private var scheduleString: String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "MM월 dd일"
        var scheduleString = dateFormatter.string(from: guidance.date)
        // 한번 연기한 일정이라면 표시되도록
        if guidance.isDelayed == true {
            scheduleString.append(contentsOf: " (연기)")
        }
        return scheduleString
    }
    
    // 봉사 일정이 지났다면 일정이 빨간색으로 출력되도록 함
    private var scheduleStringColor: UIColor {
        let calendar = Calendar.current
        
        let date = guidance.date
        let today = Date()
        
        if calendar.compare(date, to: today, toGranularity: .day) == .orderedSame {
            return UIColor.black
        } else {
            return UIColor.red
        }
    }
    
    var scheduleAttributedText: NSAttributedString {
        let attributedString = NSMutableAttributedString(string: "봉사일정: ", attributes: [.font: UIFont.systemFont(ofSize: 16), .foregroundColor: UIColor.gray])
        attributedString.append(NSAttributedString(string: scheduleString, attributes: [.font: UIFont.systemFont(ofSize: 18), .foregroundColor: scheduleStringColor]))
        return attributedString
    }
    
    // MARK: - profileImage
    
    lazy var profileImage: UIImage = {
        return UIImage(systemName: "person.fill")!
    }()
}

구현: 봉사 완료 및 미루기 서버 API

완료 API 구현하기

완료 API는 Guidance 테이블에서 status의 column의 값을 ‘COMPLETE’로 변경하면 됩니다. 데이터를 일부 수정하는 것이므로 patch method로 구현하면 됩니다.

구체적인 코드는 기존에 만들었던 삭제 API와 거의 동일합니다. 코드만 첨부하겠습니다.

// router
router.patch('/completion', (req, res) => {
    controller.completeGuidance(req, res)
});
// controller
/**
 * API No. 6
 * API Name : 생활지도 완료 API
 * [PATCH] /guidances/completion
 */

 exports.completeGuidance = async function (req, res) {

    /**
     * Body : guidanceID
     */

     const { guidanceID } = req.body;

    const result = await guidanceService.completeGuidance(guidanceID);

    return res.send(response(responses.SUCCESS, result));

};
// service
// 생활지도 완료 처리
exports.completeGuidance = async function (guidanceID) {

    const connection = await pool.promise().getConnection(async (conn) => conn);
    const result = await guidanceDao.updateGuidanceStatusToComplete(connection, guidanceID);

    console.log(`완료된 생활지도 : ${guidanceID}`)

    connection.release();

    const newData = await guidanceProvider.getAllGuidances();

    return newData;
};
// dao
// guidance 완료 처리
exports.updateGuidanceStatusToComplete = async function (connection, guidanceID) {
    const query = `
        UPDATE HSB.Guidance
        SET status = "COMPLETE"
        WHERE id = ?;
    `;
    const row = await connection.query(query, guidanceID);
    return row;
};

연기 API 구현하기

완료 API는 Guidance 테이블에서 status의 column의 값을 ‘DELAYED’로 변경하고 새로운 date를 덮어씌우면 됩니다. 데이터를 일부 수정하는 것이므로 patch method로 구현하면 됩니다.

바로 위에 있는 완료 API와 거의 동일합니다. 코드만 첨부하겠습니다.

// router
router.patch('/delay', (req, res) => {
    controller.delayGuidance(req, res)
});
// controller
/**
 * API No. 7
 * API Name : 생활지도 연기 API
 * [PATCH] /guidances/delay
 */

 exports.delayGuidance = async function (req, res) {

    /**
     * Body : guidanceID, date
     */

     const { guidanceID, date } = req.body;

    const result = await guidanceService.delayGuidance(guidanceID, date);

    return res.send(response(responses.SUCCESS, result));

};
// service
// 생활지도 연기 처리
exports.delayGuidance = async function (guidanceID, date) {

    //TODO: DB에 있는 guidanceID가 VALID한 데이터인지 확인

    const connection = await pool.promise().getConnection(async (conn) => conn);
    const params = [ date, guidanceID ];
    const result = await guidanceDao.updateGuidanceDate(connection, params);

    console.log(`연기된 생활지도 : ${guidanceID}`)

    connection.release();

    const newData = await guidanceProvider.getAllGuidances();

    return newData;
};
// dao
// guidance 연기 처리
exports.updateGuidanceDate = async function (connection, params) {
    const query = `
        UPDATE HSB.Guidance
        SET
            status = "DELAYED",
            date = ?
        WHERE id = ?;
    `;
    const row = await connection.query(query, params);
    return row;
};

구현: 클라이언트에서 완료 및 연기 API 함수

완료 API

Alamofire를 활용해서 생활지도를 완료처리하는 API 함수를 만듭니다. Body에 parameter를 담아서 보내고 method를 서버에 구현한 대로 fetch로 하면 됩니다.

삭제 API와 거의 동일한 구조를 가지고 있습니다. 요청을 보내면 GuidanceRawDate Array를 응답으로 받습니다. 다만 해당 API는 전체 명단이 아니라 오늘 봉사활동을 하는 학생들만 보여주는 VC에서 사용해야 하므로 filter를 통해 date가 오늘인 Guidance만 골라내야 합니다.

func completeGuidance(guidanceID: Int, completionHandler: @escaping ([Guidance]) -> Void) {
    
    let params: [String: String] = ["guidanceID": "\(guidanceID)"]
    
    AF.request("\(SERVER_BASE_URL)/guidances/completion", method: .patch, parameters: params, encoder: JSONParameterEncoder.default).responseDecodable(of: Response<[GuidanceRawData]>.self) { data in
        guard let response = data.value else { return }
        guard response.isSuccess == true else { return }
        guard let rawdata = response.result else { return }
        let guidances = rawdata.map { rawData in
            return Guidance(rawData: rawData)
        }
        
        let calendar = Calendar.current
        let today = Date()
        
        let todayGuidances = guidances.filter { guidance in
            let date = guidance.date
            return calendar.compare(date, to: today, toGranularity: .day) != .orderedDescending
        }
        
        completionHandler(todayGuidances)
    }
}

연기 API

위의 완료 API와 거의 동일합니다. 다만 date를 parameter에 추가해야 합니다. 등록할 때와 마찬가지로 Date 객체를 String (년-월-일)로 바꾸어서 요청을 보냅니다.

나머지 부분은 완료 API와 동일합니다.

func delayGuidance(guidanceID: Int, date: Date, completionHandler: @escaping ([Guidance]) -> Void) {
    var params: [String: String] = ["guidanceID": "\(guidanceID)"]
    params["date"] = Utilities().makeDateToString(date: date)
    
    AF.request("\(SERVER_BASE_URL)/guidances/delay", method: .patch, parameters: params, encoder: JSONParameterEncoder.default).responseDecodable(of: Response<[GuidanceRawData]>.self) { data in
        guard let response = data.value else { return }
        guard response.isSuccess == true else { return }
        guard let rawdata = response.result else { return }
        let guidances = rawdata.map { rawData in
            return Guidance(rawData: rawData)
        }
        
        let calendar = Calendar.current
        let today = Date()
        
        let todayGuidances = guidances.filter { guidance in
            let date = guidance.date
            return calendar.compare(date, to: today, toGranularity: .day) != .orderedDescending
        }
        
        completionHandler(todayGuidances)
    }
}

구현: 완료 및 연기 Button 기능

위에서 구현한 대로 각 Cell에는 완료를 의미하는 버튼과 연기를 의미하는 두 가지 버튼이 있습니다. 이 두 가지 버튼에 연결할 기능 함수를 구현하도록 해보겠습니다.

뷰모델 및 델리게이트 메소드 구현

먼저 뷰모델에 완료를 위한 메소드를 추가하겠습니다. 네트워크를 통해 완료, 연기 처리 요청을 보내고 받아온 업데이트된 guidances를 뷰모델에 세팅합니다.

func completeGuidance(guidance: Guidance, completionHandler: @escaping () -> Void) {
    GuidanceService.shared.completeGuidance(guidanceID: guidance.id) { [weak self] guidances in
        self?._guidances = guidances
        completionHandler()
    }
}

func delayGuidance(guidance: Guidance, date: Date, completionHandler: @escaping () -> Void) {
    GuidanceService.shared.delayGuidance(guidanceID: guidance.id, date: date) { [weak self] guidances in
        self?._guidances = guidances
        completionHandler()
    }
}

델리게이트 패턴 (프로토콜)을 구현한 cell의 코드는 이제 익숙하실 것 같으니 생략하겠습니다. VC에 델리게이트 메소드를 정의합시다. cell의 버튼이 클릭되면 아래 정의된 델리게이트 메소드를 실행합니다.

// MARK: - GuidanceManageCellDelegate

extension GuidanceManageController: GuidanceManageCellDelegate {
    func completeButtonTapped(in cell: GuidanceManageCell) {
        guard let guidance = cell.guidance else { return }
        showCompleteAlert(guidance: guidance)
    }
    
    func delayButtonTapped(in cell: GuidanceManageCell) {
        guard let guidance = cell.guidance else { return }
        showDelayActionSheet(guidance: guidance)
    }
}

완료 버튼 기능 구현

델리게이트 메소드가 실행되면 완료 처리 여부를 확인하는 Alert를 띄우는 함수를 실행합니다. 해당 alert에서 ‘완료’를 누르면 최종적으로 viewModel을 통해 완료 요청을 보내고 새로 세팅된 guidances를 반영해서 테이블 뷰를 업데이트 합니다.

func showCompleteAlert(guidance: Guidance) {
    let alert = UIAlertController(title: "생활지도를 완료 처리합니다.", message: viewModel.guidanceCompleteMessage(guidance: guidance), preferredStyle: .alert)
    let cancel = UIAlertAction(title: "취소", style: .cancel, handler: nil)
    let complete = UIAlertAction(title: "완료", style: .destructive) { _ in
        self.viewModel.completeGuidance(guidance: guidance) {
            self.tableView.reloadData()
        }
    }
    alert.addAction(cancel)
    alert.addAction(complete)
    self.present(alert, animated: true, completion: nil)
}

연기 버튼 기능 구현

연기 버튼 기능 구현은 완료 버튼 보다 한 단계 더 복잡합니다. 완료 여부는 yes / no 만 결정하면 되는 것과 달리 연기는 ‘언제’로 연기할 것인지 사용자의 입력을 받아야 합니다.

DatePickerViewController

사용자에게 날짜를 입력을 받을 때 사용하는 것이 UIDatePicker입니다. 다만 우리는 UIDatePicker를 UIAlertController에 넣어서 사용할 예정이므로 UIViewController로 한단계 감싸야 합니다. 아래 코드는 UIDatePicker만 하나 존재하는 ViewController를 구현한 것입니다.

DatePicker에는 몇가지 옵션을 주었는데요. preferredDatePickerStyle를 .inline로 설정해서 달력이 바로 나오도록 했습니다. (기본적으로 날짜를 터치해야 날짜를 고를 수 있는 UI가 나옵니다.) datePickerMode는 날짜, 시간, 날짜 + 시간 등 어떤 Date를 고를 것인지

class DatePickerViewController: UIViewController {
    
    // MARK: - Properties
    
    let datePicker: UIDatePicker = {
        let picker = UIDatePicker()
        picker.preferredDatePickerStyle = .inline
        picker.datePickerMode = .date
        picker.minimumDate = Date()
        return picker
    }()
    
    // MARK: - LifeCycle
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        configurePicker()
    }
    
    // MARK: - Helpers
    
    func configurePicker() {
        view.addSubview(datePicker)
        datePicker.translatesAutoresizingMaskIntoConstraints = false
        datePicker.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        datePicker.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
        datePicker.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
        datePicker.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
    }
}

actionSheet 안에 datePicker 넣기

완료 기능과 동일하게 UIAlertController를 사용하되 이번에는 .alert가 아니라 .actionSheet 스타일을 적용합시다. 그리고 위에 구현했던 DatePickerViewController를 setValue를 통해서 contentViewController에 넣어줍니다.

UIAlertController에는 contentViewController에 할당된 영역이 있습니다. 아래 코드를 통해 actionSheet의 버튼들 위에 할당된 영역에 해당 VC가 보여질 것입니다.

나머지 UIAlertAction은 완료 기능과 거의 동일한 코드입니다.

func showDelayActionSheet(guidance: Guidance) {
    let actionSheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)

    let datePickerController = DatePickerViewController()
    actionSheet.setValue(datePickerController, forKey: "contentViewController")

    let delay = UIAlertAction(title: "연기", style: .default) { _ in
        let date = datePickerController.datePicker.date
        self.viewModel.delayGuidance(guidance: guidance, date: date) {
            self.tableView.reloadData()
        }
    }
    let cancel = UIAlertAction(title: "취소", style: .cancel, handler: nil)

    actionSheet.addAction(delay)
    actionSheet.addAction(cancel)

    self.present(actionSheet, animated: true, completion: nil)
}

결과

완료 기능

연기 기능

actionSheet 위에 DatePicker가 있어서 연기할 날짜를 고를 수 있습니다!

마치며

  1. 이번 포스팅이 생각보다 길어졌습니다. 구현해야 할 것이 많더라구요.
  2. 매번 새로운 기능에 도전하는 것이 즐겁습니다.
  3. 앱이 거의 마무리 단계로 가는군요. 설렙니다!
profile
백과사전 보다 항해일지(혹은 표류일지)를 지향합니다.

0개의 댓글