[NodeJS] DI 적용, typedi module 컨테이너

Onam Kwon·2023년 3월 20일
0

Node JS

목록 보기
18/25

node.js plain Javascript DI 적용

  • Typescript가 아닌 plain Javascript, 순수 자바스크립트에 DI(dependency injection, 의존성 주입)을 하는 과정을 설명하겠습니다.
  • Typescript를 사용하면 @Service 데코레이터를 이용해 쉽게 적용할 수 있지만 plain Javascript 에서는 데코레이터를 지원하지 않지만 Awilix, typedi이 두가지 모듈은 자바스크립트에서도 DI를 적용할 수 있는 기능을 지원합니다.
    • 해당 게시글은 typedi모듈의 container를 이용해 이를 구현하도록 하겠습니다.

DI(Dependency Injection, 의존성 주입)을 적용하는 이유

  • 아래는 DI를 적용하면 나타나는 장점은 다음과 같다.
  • Decoupling: DI는 components간의 연결을 끊어준다. 특정 클래스는 의존하는 클래스가 어디서 왔는지, 어떤식으로 구성되어 있는지 등에 대해 알아야 할 필요가 전혀 없다.
  • Testability: DI는 유닛 테스트를 적용하기 쉽게 만들어준다. mock이나 다른 임의의 의존성을 주입하는 방법을 통해 고립된 component의 테스트가 가능하다. 이로인해 쉽게 작성가능한 테스트, 빠른 문제 파악등 신속한 개발 과정을 얻는데 도움을 준다.
  • Reusability: DI는 코드의 재사용성을 높여준다. 종속성을 시스템의 다른 부분에 주입할 수 있는 독립된 구성 요소로 분리하면 여러 프로젝트나 모듈에서 코드를 재사용할 수 있다.
  • Flexibility: DI는 코드에 유연성을 부여한다. 종속성을 주입하면 인터페이스 변경을 하지 않고 구성 요소의 구현을 변경할 수 있다. 이는 시스템을 보다 쉽게 발전시키며 변화하는 요구사항에 쉽게 적응할 수 있게 만들어준다.
  • 전체적으로 DI는 더 나은 코드 구성, 테스트력 그리고 유지력을 제공한다.

미리 알아야할 개념

typedi

  • 위에서 언급했지만 해당 게시글에서는 plain Javascript와 typedi 모듈을 사용해 DI를 적용하는 방법에 대해 알아보도록 하겠습니다.
  • Awilix, typedi 이 두가지 모듈중 하나를 사용하면 @Service 데코레이터를 사용하지 않고 DI를 적용할 수 있습니다.
  • typedi 모듈을 통해 컨테이너 라는 공간에 클래스를 등록한 후 원하는 곳에서 등록된 클래스를 사용하는 개념입니다.
  • router에서 container에 등록된 controller 객체를 가져와 함수를 호출하는 과정을 구현하겠습니다. 또한 controller 클래스는 service layer에 의존하고 있으며 service는 repository에 의존하고 있습니다.
    • router -> controller -> service -> repository

controller

  • 아래는 router에서 사용할 BoardController 클래스 파일입니다.
/**
 * boardConroller.js
 */

const path = require('path');

class BoardController {
    constructor(container) {
        this.serviceInstance = container.get('boardService');
    }

    handleMainRequest = async (req, res, next) => {
        const user = req.user;
        const { search, 'current-page': currentPage, 'items-per-page': itemsPerPage } = req.query;
        const { articles, pagination } = await this.serviceInstance.getPaginatedArticlesByTitle(search, currentPage, itemsPerPage);
        if(articles.length===0) {
            return res.status(400).send('There is no matching result.').end();
        }
        return res.render(path.join(__dirname, '../../views/board/board'), {
            articles, 
            user: (user) ? (user.id) : ('Guest'),
            pagination,
            search
        });
    }

    handleGetArticleForm2 = async (req, res) => {
        const user = req.user;
        const { resourceType, id } = req.params;
        console.log('resourceType:', resourceType);
        try {
            switch(resourceType) {
                case 'write': {
                    return res.render(path.join(__dirname, '../../views/board/boardWrite'), {user:user});
                }
                case 'edit': {
                    const article = await this.serviceInstance.getArticleById(id);
                    if(user.id===article.AUTHOR) {
                        return res.render(path.join(__dirname, '../../views/board/editArticle'), {user:user, article:article});
                    }
                }
                default: 
                    return res.status(400).send('No matching request.');
            }
        } catch(err) {
            console.error(err);
            return res.status(500).send(err.message);
        }
    }

    handleGetArticleForm = async (req, res) => {
        try {
            const user = req.user;
            return res.render(path.join(__dirname, '../../views/board/boardWrite'), {user:user});
        } catch(err) {
            console.error(err);
            return res.status(500).send(err.message);
        }
    }
    
    handleGetAutoComplete = async (req, res, next) => {
        try {
            const { keyStroke } = req.query;
            const titles = await this.serviceInstance.searchTitleByChar(keyStroke);
            return res.status(200).send(titles).end();
        } catch(err) {
            console.error(err); 
            return res.status(500).send(err.message);
        }
    }

    handleGetArticle = async (req, res, next) => {
        try {
            const user = req.user;
            const { id } = req.params;
            const { article, comments } = await this.serviceInstance.getArticleItems(id);
            return res.render(path.join(__dirname, '../../views/board/article'), {user, article, comments});
        } catch(err) {
            console.error(err); 
            return res.status(500).send(err.message);
        }
    }

    handleDeleteResource = async (req, res, next) => {
        const user = req.user;
        const { resourceType, id } = req.params;
        const isUserValidated = await this.serviceInstance.validateUserWithAuthor(user.id, resourceType, id);
        if(!isUserValidated) {
            return res.status(400).send('Account not matched.').end();
        }
        try {
            switch(resourceType) {
                case 'article': {
                    var affectedRows = await this.serviceInstance.deleteArticleById(id);
                    break;
                }
                case 'comment': {
                    var affectedRows = await this.serviceInstance.deleteComment(id);
                    break;
                }
                default: 
                    break;
            }
            if(affectedRows===1) {
                return res.status(200).send(`${resourceType} has been removed.`).end(); 
            } else {
                return res.status(400).send('Something went wrong').end(); 
            }
        } catch(err) {
            console.error(err);
            return res.status(500).send(err.message);
        }
    }
    
    handlePostResource = async (req, res) => {
        const user = req.user;
        /**
         * In article, id refers nothing. undefined.
         * In comment, id refers article_id.
         * In reply, id refers comment_id that a user is replying to.
         */
        const { resourceType, id } = req.params;
        try {
            switch(resourceType) {
                case 'article': {
                    const { title, content } = req.body; 
                    var affectedRows = await this.serviceInstance.insertArticle(title, content, user.id);
                    break;
                }
                case 'comment': {
                    const { content } = req.body;
                    var affectedRows = await this.serviceInstance.insertComment(id, user.id, content);
                    break;
                }
                case 'reply': {
                    const { group_num, content } = req.body;
                    var affectedRows = await this.serviceInstance.insertReply(id, user.id, group_num, content);
                    break;
                }
                default:
                    return res.status(400).send('Invalid resource type.');
            }
            if(affectedRows===1) {
                return res.status(200).send(`${resourceType} has been posted.`);
            } else {
                return res.status(400).send('Something went wrong.');
            }
        } catch(err) {
            console.error(err);
            return res.status(500).send(err.message);
        }
    }

    handleUpdateResource = async (req, res) => {
        const user = req.user;
        const { resourceType, id } = req.params;
        const isUserValidated = await this.serviceInstance.validateUserWithAuthor(user.id, resourceType, id);
        if(!isUserValidated) {
            return res.status(400).send('Account not matched.').end();
        }
        try {
            switch(resourceType) {
                case 'article': {
                    const { title, content } = req.body;
                    var affectedRows = await this.serviceInstance.updateArticle(id, title, content);
                    break;
                }
                case 'comment': {
                    const { content } = req.body;
                    var affectedRows = await this.serviceInstance.editCommentByNum(id, content);
                    break;
                }
                default:
                    return res.status(400).json({ error: 'Invalid resource type.' });
            }
            if(affectedRows===1) {
                return res.status(200).send(`${resourceType} has been updated.`);
            } else {
                return res.status(400).json({ error: 'Something went wrong.' });s 
            }
        } catch(err) {
            console.error(err);
            return res.status(500).send(err.message);
        }
    }

    showEditingArticle = async (req, res) => {
        const user = req.user;
        const { id } = req.params;
        const article = await this.serviceInstance.getArticleById(id);
        if(user.id===article.AUTHOR) {
            return res.render(path.join(__dirname, '../../views/board/editArticle'), {user:user, article:article});
        }
    }

}

module.exports = BoardController;
  • 해당 클래스는 BoardService 클래스에 의존하고 있으며 constructor(생성자)를 통해 container를 주입받아 컨테이너에서 boardService 객체를 꺼내 의존성을 주입받고 있습니다.
    • 위 파일은 전체 파일이며 클래스 외부 어디에서도 boardService.js 파일을 가져오지 않았습니다.
  • 클래스 전체를 모듈화해 사용할 예정이므로 맨 아래에 module.exports = BoardController를 토해 모듈화해준다.

service

// boardService.js

class boardService {
    #REQUEST_URL
    constructor(container) {
        const config = container.get('config');
        this.#REQUEST_URL = 'https://kauth.kakao.com/oauth/authorize?response_type=code&client_id='+config.KAKAO.REST_API_KEY+'&redirect_uri='+config.KAKAO.REDIRECT_URI; 
        this.repository = container.get('MySQLRepository');
    }

    convertDateFormat(date) {
        date = date.toLocaleString('default', {year:'numeric', month:'2-digit', day:'2-digit'});
        let year = date.substr(6,4);
        let month = date.substr(0,2);
        let day = date.substr(3,2);
        let convertedDate = `${year}-${month}-${day}`;
        return convertedDate;
    }
    
    convertTableDateFormat(table) {
        for(let i=0;i<table.length;i++) {
            table[i].POST_DATE = this.convertDateFormat(table[i].POST_DATE);
            table[i].UPDATE_DATE = this.convertDateFormat(table[i].UPDATE_DATE);
        }
        return table;
    }
    
    convertArticleDateFormat(article) {
        article.POST_DATE = this.convertDateFormat(article.POST_DATE);
        article.UPDATE_DATE = this.convertDateFormat(article.UPDATE_DATE);
        return article;
    }
    
    async getTitlesIncludeString(titles, search) {
        let result = [];
        for(let i=0;i<titles.length;i++) {
            if(titles[i].TITLE.includes(search)) result.push(titles[i]);
        }
        return result;
    }
    convertToNumber(number, defaultValue) {
        // Ensuring that the variable value is an integer greater than or equal to 1.
        number = Math.max(1, parseInt(number)); 
        // Making sure that the number is always a number, and if it's not, it defaults to 1.
        number = !isNaN(number) ? number:defaultValue; 
        return number;
    }

    async getMatchingTitleCount(key) {
        // const sql = `SELECT COUNT(TITLE) AS matchingTitleCount FROM BOARD WHERE MATCH(TITLE) AGAINST(? IN NATURAL LANGUAGE MODE);`;
        key ??= '';
        const sql = `SELECT COUNT(TITLE) AS matchingTitleCount FROM BOARD WHERE TITLE LIKE ?;`;
        const values = [`%${key}%`];
        const [[res]] = await this.repository.executeQuery(sql, values);
        return res.matchingTitleCount;
    }

    async getPageItems(totalItems, currentPage, itemsPerPage) {
        currentPage = this.convertToNumber(currentPage, 1);
        itemsPerPage = this.convertToNumber(itemsPerPage, 10);

        const totalPages = Math.ceil(totalItems/itemsPerPage);
        currentPage = currentPage>totalPages ? 1 : currentPage;
        const startIndex = (currentPage-1) * itemsPerPage;
        const endIndex = (currentPage===totalPages) ? totalItems-1 : (currentPage*itemsPerPage-1);
        return {
            currentPage,
            itemsPerPage,
            totalPages,
            startIndex,
            endIndex
        };
    }

    /**
     * ArticlesService
     */
    async getAllArticles() {
        const sql = `SELECT * FROM BOARD ORDER BY BOARD_NO DESC;`;
        let [articles] = await this.repository.executeQuery(sql);
        articles = this.convertTableDateFormat(articles);
        return articles;
    }

    async searchArticlesByTitle(title, startIndex, endIndex) {
        title ??= '';
        const sql = `SELECT * FROM BOARD WHERE TITLE LIKE ? ORDER BY BOARD_NO DESC LIMIT ? OFFSET ?;`;
        const LIMIT = endIndex-startIndex+1
        const OFFSET = startIndex===0 ? 0 : startIndex;
        const values = [`%${title}%`, LIMIT, OFFSET];
        let [articles] = await this.repository.executeQuery(sql, values);
        articles = this.convertTableDateFormat(articles);
        return articles;
    }

    async searchTitleByChar(keyStroke) {
        const sql = `SELECT TITLE FROM BOARD WHERE TITLE LIKE ? ORDER BY BOARD_NO DESC;`;
        const values = [`%${keyStroke}%`];
        let [titles] = await this.repository.executeQuery(sql, values);
        return titles;
    }

    async getArticleById(id) {
        const sql = `SELECT * FROM BOARD WHERE BOARD_NO=?;`;
        const values = [ id ];
        let [article] = await this.repository.executeQuery(sql, values);
        article = this.convertArticleDateFormat(article[0]);
        return article;
    }

    async insertArticle(title, content, author) {
        const sql = `INSERT INTO BOARD (TITLE, content, POST_DATE, UPDATE_DATE, AUTHOR) VALUES ?;`;
        const date_obj = new Date();
        const post_date = date_obj.getFullYear() +"-"+ parseInt(date_obj.getMonth()+1) +"-"+ date_obj.getDate();
        const update_date = post_date;
        let values = [
            [title, content, post_date, update_date, author]
        ];
        const [res] = await this.repository.executeQuery(sql, [values]);
        return res.affectedRows;
    }

    async deleteArticleById(id) {
        const sql = `DELETE FROM BOARD WHERE BOARD_NO=?;`;
        const values = [ id ];
        const [res] = await this.repository.executeQuery(sql, values);
        return res.affectedRows;
    }

    async updateArticle(article_num, title, content) {
        const time = this.getTime(); 
        const sql = 'UPDATE BOARD SET TITLE=?, content=?, UPDATE_DATE=? WHERE BOARD_NO=?;';
        const values = [title, content, time, article_num];
        const [res] = await this.repository.executeQuery(sql, values);
        return res.changedRows;
    }

    /**
     * CommentService
     */

    async getMaxCommentOrder(id, group_num) {
        const sql = `SELECT MAX(comment_order) AS maxCommentOrder FROM comment WHERE article_num=? AND group_num=?;`;
        const values = [ id, group_num];
        const [[res]] = await this.repository.executeQuery(sql, values);
        res.maxCommentOrder ??= 0;
        return res.maxCommentOrder;
    }
    
    async getNewGroupNum(id) {
        const sql = `SELECT MAX(comment.group_num) AS maxGroupNum FROM BOARD LEFT JOIN comment ON BOARD.BOARD_NO=comment.article_num WHERE BOARD.BOARD_NO=?;`;
        const values = [ id ];
        const [[res]] = await this.repository.executeQuery(sql, values);
        res.maxGroupNum ??= 0;
        return res.maxGroupNum+1;
    }
    
    getTime() {
        const date_obj = new Date();
        let date = date_obj.getFullYear() +"-"+ parseInt(date_obj.getMonth()+1) +"-"+ date_obj.getDate()+" ";
        let time = date_obj.getHours() +":"+ date_obj.getMinutes() +":"+ date_obj.getSeconds();
        time = date+time;
        return time;
    }
    
    async getCommentsByArticleId(article_num) {
        // const sql = "SELECT * FROM COMMENT WHERE article_num='"+article_num+"';";
        const sql = `SELECT * FROM comment WHERE article_num=? ORDER BY group_num, comment_order ASC;`
        const values = [ article_num ];
        let [comments] = await this.repository.executeQuery(sql, values);
        return comments;
    }
    
    async insertComment(article_num, author, content) {
        const sql = `INSERT INTO comment (article_num, author, time, class, comment_order, group_num, content) VALUES ?;`;
        const time = this.getTime();
        const depth = 0;
        const new_group = await this.getNewGroupNum(article_num);
        // const comment_order = parseInt(length) + 1;
        const comment_order = await this.getMaxCommentOrder(article_num, new_group)+1;
        let values = [
            [article_num, author, time, depth, comment_order, new_group, content]
        ];
        const [res] = await this.repository.executeQuery(sql, [values]);
        return res.affectedRows;
    }
    
    async editCommentByNum(id, content) {
        const query = `UPDATE comment SET content=? WHERE comment_num=?;`;
        const values = [id, content];
        const [res] = await this.repository.executeQuery(query, values);
        return res.affectedRows;
    }
    
    async insertReply(article_num, author, group_num, content) {
        const sql = `INSERT INTO comment (article_num, author, time, class, comment_order, group_num, content) VALUES ?;`;
        const time = this.getTime();
        const depth = 1;
        const comment_order = await this.getMaxCommentOrder(article_num, group_num)+1;
        let values = [
            [article_num, author, time, depth, comment_order, group_num, content]
        ];
        const [res] = await this.repository.executeQuery(sql, [values]);
        return res.affectedRows;
    }
    
    async getCommentAuthorById(id) {
        const sql = `SELECT author FROM comment WHERE comment_num=?;`;
        const values = [ id ];
        const [[commentAuthor]] = await this.repository.executeQuery(sql, values);
        return commentAuthor.author;
    }
    
    async deleteComment(id) {
        const sql = `UPDATE comment SET author='deleted', content='deleted', time=NULL WHERE comment_num=?;`;
        const values = [ id ];
        const [res] = await this.repository.executeQuery(sql, values);
        return res.affectedRows;
    }

    async getPaginatedArticlesByTitle(title, currentPage, itemsPerPage) {
        const matchingTitleCount = await this.getMatchingTitleCount(title); 
        const pagination = await this.getPageItems(matchingTitleCount, currentPage, itemsPerPage);
        const articles = await this.searchArticlesByTitle(title, pagination.startIndex, pagination.endIndex);
        return { articles, pagination };
    }

    async getArticleItems(id) {
        const article = await this.getArticleById(id);
        const comments = await this.getCommentsByArticleId(id);
        return { article, comments };
    }

    async validateUserWithAuthor(userId, resourceType, resourceId) {
        switch(resourceType) {
            case 'article': {
                const article = await this.getArticleById(resourceId);
                return userId===article.AUTHOR;
            }
            case 'comment': {
                const commentAuthor = await this.getCommentAuthorById(resourceId); 
                return userId===commentAuthor;
            }
            default:
                return false;
        }
    }
}

module.exports = boardService;
  • controller가 의존하고 있는 service layer이며 해당 클래스도 repository에 의존하고 있다.
  • 위와 마찬가지로 이 클래스 또한 생성자에서 container에 등록되어 있는 repository를 주입받는다.

repository

// Connecting MySQL

const mysql = require('mysql2/promise');

class MySQLRepository {
    constructor(container) {
        console.log('MySQL has been connected...');
        const config = container.get('config');
        this.pool = mysql.createPool({
            connectionLimit: 10,
            host: 'localhost',
            user: config.MYSQL.USER,
            password: config.MYSQL.PASSWORD,
            database: 'board_db'
        });
    }

    async executeQuery(sql, values) {
        let connection = null;
        let res = null;
        try {
            connection = await this.pool.getConnection();
            res = await connection.query(sql, values);
            return res;
        } catch(err) {
            console.log('err:', err);
            throw new Error(err);
        } finally {
            if(connection) {
                connection.release();
            }
        }
    }
}

module.exports = MySQLRepository;
  • service가 의존하고 있는 repository이며 해당 클래스는 config를 의존하고 있다.
  • 위와 마찬가지로 이 클래스 또한 생성자에서 container를 받으며 config를 주입받는다.

config

const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });

/**
 * Reason why to use both config.js and .env file is to use auto complete feature.
 * And config is manipulated by registering into a container.
 */
const config = {
    // Jasonwebtoken
    JWT: {
        SECRET: process.env.SECRET_KEY
    },
    // MySQL DB connection
    MYSQL: {
        USER: process.env.SQL_USER,
        PASSWORD: process.env.SQL_PASSWORD,
        DATABASE: process.env.MYSQL_DATABASE,
        ROOT_PASSWORD: process.env.MYSQL_ROOT_PASSWORD
    },
};

module.exports = config;
  • config.js는 .env파일을 직접적으로 가져와 config 객체에 값을 할당해준다.
  • .env파일을 바로 사용하지 않고 config 객체를 굳이 하나 더 만들어 사용하는 이유는 이런 방식을 사용하면 코드를 짤 때 자동완성 기능을 사용해 오타를 줄일 수 있으며, config 객체 또한 container에 등록해 좀더 편하게 가져와 사용할 수 있을 뿐만 아니라 모든 의존성을 한곳에서 관리할 수 있다.

container 등록

  • 우선 container.js 파일에서 프로젝트에 사용할 모듈들을 가져온다.
  • 이후, container.set() 를 이용해 컨테이너에 등록할 수 있으며, 나중에 등록된 컨테이너를 가져와 사용할 수 있다.
// container.js

const container = require('typedi').Container;

const config = require('../../config/config');
const MySQLRepository = require('../MySQLRepository');
const BoardController = require('../controllers/board/BoardController');
const boardService = require('../board/boardService');

container.set('config', config);
container.set('MySQLRepository', new MySQLRepository(container));
container.set('boardService', new boardService(container));
container.set('BoardController', new BoardController(container));

module.exports = container;
  • 컨테이너를 등록하는 방법은 위와 같으며, MySQLRepository의 경우 첫번째 인자에 등록시 사용할 이름을 전달해 주며(이경우 MySQLRepository) 두번째 인자에는 MySQLRepository 객체를 넘겨준다. 이때, MySQLRepository 클래스는 생성자에서 DI를 적용하기 때문에 container를 전달해야한다.
  • 한가지 더 주의해야 할 점은 컨테이너에 등록하는 순서인데, 의존하는 클래스가 없는 순으로 등록해야한다.
    • config는 의존하는 클래스가 없으므로 제일 우선순위를 차지한다(.env파일은 바로 가져오니 제외).
    • 두번째로 MySQLRepository가 컨테이너에 등록되어 있는 config를 의존하므로 그 다음 우선순위를 차지한다.
    • 세번째로 boardService가 컨테이너에 등록되어 있는 MySQLRepository를 의존하므로 그 다음 우선순위를 차지한다.
    • 마지막으로 BoardController가 컨테이너에 등록되어 있는 boardService를 의존하므로 마지막 우선순위를 차지한다.
  • 컨테이너 등록을 마친 후 module.exports = container를 해준다.
  • 이 외에도 주의해야 할 점은 circular dependency를 주의해야 하는데 서로다른 두개의 클래스가 서로 의존하는 경우를 말한다.
    • 컨테이너에 등록하려면 클래스 파일을 가져와 객체를 생성해야 하는데 의존하고 있는 클래스가 아직 컨테이너에 등록되어 있지 않는 상황이 맞물려 있어 발생함.

등록된 객체 가져오기

  • container.get() 을 사용해 가져올 수 있다.
/**
 * boardRouter.js
 */

const container = require('../../models/container/container');
const BoardControllerInstance = container.get('BoardController');

router.get('/', BoardControllerInstance.handleMainRequest);
// Here resourceType is a placeholder. Depends on its value, routers handle an article or comment.
router.delete('/:resourceType/:id', FilterInstance.authenticationMethodDistinguisher, BoardControllerInstance.handleDeleteResource);
router.post('/:resourceType/:id?', FilterInstance.authenticationMethodDistinguisher, BoardControllerInstance.handlePostResource);
router.put('/:resourceType/:id', FilterInstance.authenticationMethodDistinguisher, BoardControllerInstance.handleUpdateResource);

module.exports = router;
  • container.js에서 모든 객체를 등록했다면 이를 사용할 파일에서 위처럼 가져올 수 있다.
    • 파일 전체를 container 객체에 넣어준 후 container.get() 을 사용해 원하는 객체를 가져올 수 있다.
    • 이때 컨테이너 등록시 사용했던 이름을 함께 넘겨주며 원하는 객체를 선택할 수 있다.
  • 가져온 객체는 위처럼 평소 사용하던 대로 사용할 수 있다.
  • 추가로 계층별로 필요한 모듈만 가져와 사용하기 때문에 코드의 윗부분이 훨씬 깔끔해지는 효과도 있다.

github

  • https://github.com/kon6443/aws
    • controllers/board/
    • controllers/user/
      • router, controller
    • models/board/
    • models/user/
      • service layer, repository
    • models/container/
      • container
    • config/
      • config
  • 질문 및 잘못된 정보 지적 환영합니다.
profile
권오남 / Onam Kwon

0개의 댓글