require

const React = require('react');

const Example; = () => {};

module.export = Example;

 

import

import React from'react';

const Example; = () => {};

export default Example;

 

require는 노드 문법이고 import는 ES6문법이다.

두개를 섞어쓰면 당연히 에러가난다.

 

 

 

들어가기전에...

신규프로젝트가 있어서 산출물 작업때 JSDoc을 사용하려고 생각했는데,

프로젝트에서 JSDoc을 사용하지 않게되어 ㅎㅎ 개인적으로 정리하여 작성합니다.

그냥 이런저런 주석이 있구나 생각을 정리하는 유익한 시간이었습니다. 

 

*프로젝트에서 사용할만하거나 많이 사용되는 주석 위주로 작성하였으며 출처는 jsdoc문서입니다.

전체 주석에 대하여 궁금하면 https://jsdoc.app/  jsdoc 문서를 참고해주세요.

 

JSDoc란

Javasript 소스코드 파일에 주석을 달기위해 사용되는 마크업언어입니다.

JSDoc에 포함하는 주석을 사용하여 코드를 작성하고 인터페이스를 설명하는 문서를 생성할 수 있습니다.

전체 이미지 / docdash 템플릿

 

JSDoc 설치 / 사용법

0. 프로젝트 설정

JSDoc을 설정하려는 프로젝트 폴더에서 진행한다.

// cmd
npm init -y

 

1. 설치

// cmd
npm i --save-dev jsdoc

테마 설치

https://github.com/clenemt/docdash

// cmd
npm install docdash

 

2. 해당하는 파일 문서화(jsdoc.conf 사용 시 파일에 작성된 incloude에 적힌 경로로 적용됨)

- 단일 파일

단일 파일

// cmd
jsdoc index.js

 

- 단일 폴더내에 복수 파일

단일 폴더내에 복수파일

// cmd
jsdoc ./js

 

- 복수 파일

복수 파일

//cmd
jsdoc ./js main.js

 

3. jsdoc.conf 설정하기

루트 폴더의 위치에서 jsdoc.conf 파일 생성 후 아래 코드 삽입

1. include의 경로 수정할 것 (해당파일 경로)

2. template 테마 docdash사용 시 docdash다운로드 받아야함 (template는 테마)

jsdoc.conf 생성

// jsdoc.conf
{
  "source" : {
    "include" : "./assets/js/layerPopup.js",
    "includePattern" : ".js$"
  },
  "plugins" : [
      "plugins/markdown"
  ],
  "opts" : {
    "template" : "node_modules/docdash",
    "encoding" : "utf8",
    "destination" : "./docs",
    "recurse" : true,
    "verbose" : true
  },
  "templates" : {
    "cleverLinks" : false,
    "monospaceLinks" : false,
    "default" : {
        "outputSourceFiles" : false
    },
    "docdash" : {
      "static" : false,
      "sort" : true
    }
  }
}

 

4. 결과물

// cmd
jsdoc -c jsdoc.conf README.md

- jsdoc -c : 참조하는 (설정파일 configuration)

- jsdoc.conf : 설정파일

- README.md : readme.md 파일 포함하여 생성

 

 

자동 생성된 docs 폴더에서 index.html 확인

(자동생성되는 폴더의 기본 네이밍은 out이지만 jsdoc.conf 파일에서 docs로 변경하였습니다.)

docs 폴더 구조

 

주석 설명

문서 설명 주석

문서를 설명하는 주석으로 문서 상단에 입력하여 문서를 설명한다.

 

@author

작성자 식별, 이메일 주소가 있으면 이름 뒤에 꺽쇠괄호로 작성한다.

이메일주소 입력 시에 출력화면에 메일주소가 노출되지는 않으나 앞에 작성한 텍스트(작성자)를 클릭할 경우 mailto 태그처럼 이메일 주소를 식별해준다.

문법

// @author <name>
// @author <name> [<emailAddress>]

예제

/**
 * @author Jane Smith <jsmith@example.com>
 */

function MyClass() {}

출력화면

출력 이미지

 

@version  

라이브러리 버전 정보

문법

// @version 버전정보

예제

/**
 * Solves equations of the form a * x = b. Returns the value
 * of x.
 * @version 1.2.3
 */

function solver(a, b) {
	return b / a;
}

출력화면

출력 이미지

 

@copyright 

파일 개요, 설명에 대한 저작권 정보 (w.@file)

문법

// @copyright <some copyright text>

 

@file (@fileoverview, @overview)

파일에 대한 설명

문법

// @file <some text>

예제

/**
 * @file This is my cool script.
 * @copyright Michael Mathews 2011
 */

출력화면

출력 이미지

 

@license

소프트웨어 라이센스

표준오픈소스 라이브러리 식별자(identifier) 입력  https://spdx.org/licenses/

문법

// @license <identifier>​

예제

/**
 * Utility functions for the foo package.
 * @module foo/util
 * @license Apache-2.0
 */

예제2

독립라이센스가 있는 파일

/**
 * @license
 * Copyright (c) 2015 Example Corporation Inc.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

출력화면

출력 이미지

 

함수에 사용할 수 있는 주석

 

@this

해당 함수내부의 this가 참조하는 것을 표시

문법

// @this <namePath>

예제

/** @constructor */
function Greeter(name) {
	setName.apply(this, name);
}

/** @this Greeter */
function setName(name) {
  /** document me */
  this.name = name;
}

출력화면

출력 이미지

 

@constant (@const) 

상수를 표시

문법

// @constant [<type> <name>]

예시

/**
 * @constant
 * @type {string}
 * @default
 */
const RED = 'FF0000';

/** @constant {number} */
var ONE = 1;

 

@description (@desc)

설명을 표시

문법

// @description <some description>

예제

첫번째 줄일때에는 생략가능

/**
 * Add two numbers.
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
function add(a, b) {
	return a + b;
}

예제2

첫번째 줄이 아닐때

/**
 * @param {number} a
 * @param {number} b
 * @returns {number}
 * @description Add two numbers.
 */
function add(a, b) {
	return a + b;
}

출력화면

출력 이미지

 

@throws (@exception)

메소드에 의해 발생된 오류나 예외사항을 표시

단일 주석에 두번 포함하지말 것

문법

// @throws free-form description
// @throws {<type>}
// @throws {<type>} free-form description

예제

/**
 * @throws {DivideByZero} Argument x must be non-zero.
 */

function baz(x) {}

출력화면

출력 이미지

 

@param (@arg, @argument)

함수에 전달받은 인자 값의 이름

유형 및 설명을 표시

문법

// @param  <someParamName>
// @param {<type>} <someParamName>
// @param {<type>} <some description>

예제

단일 속성

/**
 * @param {string} somebody - Somebody's name.
 */
function sayHello(somebody) {
	alert('Hello ' + somebody);
}

예제2

복수 속성

/**
 * @param {(string|string[])} [somebody=John Doe] - Somebody's name, or an array of names.
 */
 function sayHello(somebody) {
 	if (!somebody) {
    	somebody = 'John Doe';
    } else if (Array.isArray(somebody)) {
    	somebody = somebody.join(', ');
    }
    
    alert('Hello ' + somebody);
}

예제3

파라미터에 속성이 있을때

/**
 * Assign the project to an employee.
 * @param {Object} employee - The employee who is responsible for the project.
 * @param {string} employee.name - The name of the employee.
 * @param {string} employee.department - The employee's department.
 */
Project.prototype.assign = function(employee) {
	// ...
};

예제4

파라미터가 배열, 속성이 있을경우

/**
 * Assign the project to a list of employees.
 * @param {Object[]} employees - The employees who are responsible for the project.
 * @param {string} employees[].name - The name of an employee.
 * @param {string} employees[].department - The employee's department.
 */
Project.prototype.assign = function(employees) {
	// ...
};

출력화면

출력 이미지

 

@requires 

필요한 모듈이 있음을 표현

문법

// @requires <someModuleName>

예제

/**
 * This class requires the modules {@link module:xyzcorp/helper} and
 * {@link module:xyzcorp/helper.ShinyWidget#polish}.
 * @class
 * @requires module:xyzcorp/helper
 * @requires xyzcorp/helper.ShinyWidget#polish
*/
function Widgetizer() {}

출력화면

출력 이미지

 

@callback

콜백으로 받은 인자 및 반환 값에 대한 정보 제공

문법

// @callback <namepath>

예제

클래스 별

/**
 * @class
 */
function Requester() {}

/**
 * Send a request.
 * @param {Requester~requestCallback} cb - The callback that handles the response.
 */
Requester.prototype.send = function(cb) {
	// code
};

/**
 * This callback is displayed as part of the Requester class.
 * @callback Requester~requestCallback
 * @param {number} responseCode
 * @param {string} responseMessage
 */

예제2

글로벌

/**
 * @class
 */
function Requester() {}

/**
 * Send a request.
 * @param {requestCallback} cb - The callback that handles the response.
 */
Requester.prototype.send = function(cb) {
	// code
};

/**
 * This callback is displayed as a global member.
 * @callback requestCallback
 * @param {number} responseCode
 * @param {string} responseMessage
*/

 

@todo

해야하거나, 완료해야할 작업이 필요할때 표시

단일 주석에 두번 사용 금지

문법

// @todo text describing thing to do.

예제

/**
 * @todo Write the documentation.
 * @todo Implement this function.
 */
function foo() {
	// write me
}

출력화면

출력 이미지

 

@return (@returns)

함수가 반환하는 값을 표시

문법

// @returns [{type}] [description]

예제

/**
 * Returns the sum of a and b
 * @param {number} a
 * @param {number} b
 * @returns {number} Sum of a and b
 */
function sum(a, b) {
	return a + b;
}

출력화면

출력이미지

 

@see

연관성 있는 문서나 리소스 참조함을 표시

{@link} 와 같이 사용 가능

문법

// @see <namepath>
// @see <text>

예제

/**
 * Both of these will link to the bar function.
 * @see {@link bar}
 * @see bar
 */
function foo() {}

// Use the inline {@link} tag to include a link within a free-form description.
/**
 * @see {@link foo} for further information.
 * @see {@link http://github.com|GitHub}
 */
function bar() {}

출력화면

출력이미지

 

@link ({@linkcode}, {@linkplain})

namepath 또는 url에 대한 링크 생성

문법

// {@link namepathOrURL}
// [link text]{@link namepathOrURL}
// {@link namepathOrURL|link text}
// {@link namepathOrURL link text (after the first space)}

예제

/*
 ** See {@link MyClass} and [MyClass's foo property]{@link MyClass#foo}.
 * Also, check out {@link http://www.google.com|Google} and
 * {@link https://github.com GitHub}.
 */
function myFunction() {}

 

@since

클래스, 메서드 등이 특정 버전에서 추가되었을때 사용

문법

// @since <versionDescription>

예제

/**
 * Provides access to user information.
 * @since 1.0.1
 */
function UserRecord() {}

출력화면

출력이미지

 

이벤트

@fires | @event | @listens

 

@fires (@emits)

메소드가 호출 될 때

지정된 유형의 이벤트를 발생시킬 수 있음을 표현

문법

// @fires <className>#<eventName>
// @fires <className>#[event:]<eventName>

 

@event

특정 이벤트를 정의

문법

// @event <className>#<eventName>
// @event <className>#[event:]<eventName>

 

@listens

지정된 이벤트를 수신하는 것을 표현

문법

// @listens <eventName>

예제

define('hurler', [], function () {
  /**
   * Event reporting that a snowball has been hurled.
   *
   * @event module:hurler~snowball
   * @property {number} velocity - The snowball's velocity, in meters per second.
   */

  /**
   * Snowball-hurling module.
   *
   * @module hurler
   */
  var exports = {
    /**
     * Attack an innocent (or guilty) person with a snowball.
     *
     * @method
     * @fires module:hurler~snowball
     */
    attack: function () {
    	this.emit('snowball', { velocity: 10 });
    }
  }; 
  
  return exports;  
});
  
define('playground/monitor', [], function () {
    /**
     * Keeps an eye out for snowball-throwers.
     *
     * @module playground/monitor
     */
    var exports = {
      /**
       * Report the throwing of a snowball.
       *
       * @method
       * @param {module:hurler~event:snowball} e - A snowball event.
       * @listens module:hurler~event:snowball
       */
      reportThrowage: function (e) {
          this.log('snowball thrown: velocity ' + e.velocity);
      }
    }; 
    
    return exports;
});

 

@example

예제 제공

<caption>태그를 @example 뒤에 사용하여 캡션기능 제공 가능

문법

// @example 
// @exmple <caption>captionText</caption>

예제

/**
 * Solves equations of the form a * x = b
 * @example
 * // returns 2
 * @example <caption>Example usage of method1.</caption>
 * // returns 2
 * globalNS.method1(5, 10);
 * @returns {Number} Returns the value of x for the equation.
 */
globalNS.method1 = function (a, b) {
	return b / a;
};

출력화면

출력이미지

 

@global

역함수 표현, 로컬에 작성되고 전역에 할당된 태그 사용에 유용

예제

(function() {
  /** @global */
  var foo = 'hello foo';
  this.foo = foo;
}).apply(window);

출력화면

출력 이미지

 

@namespace

네임스페이스 프로그래밍 시 객체 표현

문법

// @namespace [[{<type>}] <SomeName>]

예제

/**
 * My namespace.
 * @namespace
 */
var MyNamespace = {
  /** documented as MyNamespace.foo */
  foo: function() {},
  
  /** documented as MyNamespace.bar */
  bar: 1
};

예제2

/**
 * A namespace.
 * @namespace MyNamespace
 */

/**
 * A function in MyNamespace (MyNamespace.myFunction).
 * @function myFunction
 * @memberof MyNamespace
 */

예제3

/** @namespace window */
/**
 * Shorthand for the alert function.
 * Refer to it as {@link window."!"} (note the double quotes).
 */
window["!"] = function(msg) { 
	alert(msg); 
};

 

@inner

네임스페이스 태그의 부모-자녀 참조

예제

/** @namespace */
var MyNamespace = {
  /**
   * foo is now MyNamespace~foo rather than MyNamespace.foo.
   * @inner
   */
  foo: 1
};

 

@alias

네임스페이스 태그의 멤버 참조처리

내부 함수 내에 클래스 정의할 때 유용하다

예제

/** @namespace */
var Apple = {};(function(ns) {

  /**
   * @namespace
   * @alias Apple.Core
   */
  var core = {}; 

  /** Documented as Apple.Core.seed */
  core.seed = function() {}; 
  ns.Core = core;

})(Apple);

 

클래스를 설명하는 주석

class 키워드를 사용했거나 생성자를 통해 개발한 경우 해당할 수 있습니다.

 

@class (@constructor)

함수 생성자로 표시

문법

// @class [<type> <name>]

예제

/**
 * Creates a new Person.
 * @class
 */
function Person(){}
var p = new Person();

출력화면

출력이미지

 

 

@classdesc

함수 생성자 설명

@class가 선언되어있어야 한다.

문법

// @classdesc <some description>

예제

/**
 * This is a description of the MyClass constructor function. 
 * @class
 * @classdesc This is a description of the MyClass class.
 */
function MyClass() {}

출력화면

출력 이미지

 

@constructs

객체리터럴를 사용하여 클래스를 정의했을때 해당 멤버 표시

@lends와 사용할 수 있다.

문법

// @constructs [<name>]

예제

makeClass('Menu',
  /**
   * @constructs Menu
   * @param items
   */
  function (items){},
  {
    /** @memberof Menu# */
    show: function(){}
  }
);

// @lends와 사용
var Person = makeClass(
  /** @lends Person.prototype */
  {
    /** @constructs */
    initialize: function(name) {
        this.name = name;
    },
    /** Describe me. */
    say: function(message) {
        return this.name + " says: " + message;
    }
  }
);

출력화면

출력이미지

 

@lends 

함수 생성자의 멤버

문법

// @lends <namepath>

예제

/** @class */
var Person = makeClass(
  /** @lends Person */
  {
    /**
     * Create a `Person` instance.
     * @param {string} name - The person's name.
     */
    initialize: function(name) {
        this.name = name;
    },
    /**
     * Say something.
     * @param {string} message - The message to say.
     * @returns {string} The complete message.
     */
    say: function(message) {
        return this.name + " says: " + message;
    }
  }
);

 

@abstract (@virtual)

상속하는 객체에서 재정의하는 멤버 식별(오버라이딩 객체)

예제

/**
 * Generic dairy product.
 * @constructor
 */
function DairyProduct() {}

/**
 * Check whether the dairy product is solid at room temperature.
 * @abstract
 * @return {boolean}
 */
DairyProduct.prototype.isSolid = function() {
	throw new Error('must be implemented by subclass!');
};

/**
 * Cool, refreshing milk.
 * @constructor
 * @augments DairyProduct
 */
function Milk() {}

/**
 * Check whether milk is solid at room temperature.
 * @return {boolean} Always returns false.
 */
Milk.prototype.isSolid = function() {
	return false;
};

출력화면

출력이미지

 

@augments (@extends)

클래스 기반이나 프로토타입 기반에서 상속을 나타내고 상위 객체를 추가함

문법

// @augments <namepath>

예제

단일상속

/**
 * @constructor
 */
function Animal() {
	/** Is this animal alive? */
	this.alive = true;
}

/**
 * @constructor
 * @augments Animal
 */
function Duck() {}
Duck.prototype = new Animal();

예제2

다중 상속

/**
 * Abstract class for things that can fly.
 * @class
 */
function Flyable() {
	this.canFly = true;
}

/** Take off. */
Flyable.prototype.takeOff = function() {
	// ...
};

/**
 * Abstract class representing a bird.
 * @class
 */
function Bird(canFly) {
	this.canFly = canFly;
}

/** Spread your wings and fly, if possible. */
Bird.prototype.takeOff = function() {
  if (this.canFly) {
    this._spreadWings()
    ._run()
    ._flapWings();
  }
};

/**
 * Class representing a duck.
 * @class
 * @augments Flyable
 * @augments Bird
 */
function Duck() {} // Described in the docs as "Spread your wings and fly, if possible."
Duck.prototype.takeOff = function() {
	// ...
};

 

주석 정리 후 모듈에 적용을 해봤는데, 아직 정확한 개념이 박히지 않아서 오래걸리는군요 ㅎㅎ..

개발 시 주석을 잘 달기 위해 다른 문서화 도구들도 봐야할 것 같습니다.

 

 

 

 

채팅 기능 구현

 

 

기본 동작은 이해했으니 진도를 쭉 나가겠습니다.

구현해야하는 기본 기능을 먼저 확인해보겠습니다.

 

 

> 추가할 기능 

 

TODO

- 사용자가 들어오고 나가는 부분을 캐치하여 서버가 안내하는 말을 노출한다.

- 메세지를 전송하고 받아야한다.

- 스타일을 입힌다.

 

 

> 사용자가 들어오고 나가는 부분을 캐치하여 서버가 안내하는 말을 노출한다.

 

입장할 때 작성했던 connection이 기억나는가요? 

퇴장할 때 이벤트도 같은 곳에 추가합니다.

사용자가 입장 시(소켓연결) 발생하는 이벤트 호출에 대한 바인딩 작업은 app.js의 connect 콜백함수 내부에서 작성되었습니다.

// app.js

io.sockets.on('connection', function(socket){
    socket.on('newUserConnect', function(name){
        socket.name = name;
        var message = name + '님이 접속했습니다';

        io.sockets.emit('updateMessage', {
            name : 'SERVER',
            message : message
        });
    });

    socket.on('disconnect', function(){
        var message = socket.name + '님이 퇴장했습니다';
        socket.broadcast.emit('updateMessage', {
            name : 'SERVER',
            message : message
        });
    });
});

 

코드 설명 

 

> newUserConnect 접속한다 -> 대화명 저장 -> 메세지 설정 -> 메세지 업데이트 함수호출

newUserConnect와 매우 유사한 동작이기 때문에 코드또한 비슷합니다.

socket.on('disconnect', function(){
    var message = socket.name + '님이 퇴장했습니다';

    socket.broadcast.emit('updateMessage', {
        name : 'SERVER',
        message : message
    });
});

다만

emit시에 io.sockets을 쓰던 newUserConnect와 다르게

disconnect시에는 socket.broadcast라는 객체를 사용합니다.

 

여기서 io.sockets과 socket.broadcast의 차이를 알 수 있습니다. 

io.sockets은 나를 포함한 전체 소켓이고

socket.broadcase은 나를 제외한 전체 소켓을 뜻합니다.

 

내가 접속을 종료하는데 나에게 emit할 필요가 없기 때문입니다.

 

 

> 나를 제외한 전체 소켓에 updateMessage 이벤트를 호출하며 data를 객체리터럴로 전달합니다.

disconnect 이벤트 역시 서버에서 전달하는 것이므로 name에는 SERVER로 적어줍니다.

socket.broadcast.emit('updateMessage', {
    name : 'SERVER',
    message : message
});

 

서버를 종료했다가 시작한 후 브라우저 두개의 창에서 각각 localhost:8080으로 접속하여 줍니다. 

// cdm

node app.js

그 후 하나의 창을 닫으면 열려있는 창에서 disconnect시 문구가 확인됩니다.

 

> 메세지를 전송하고 받아야한다.

 

이제 구조를 전체적으로 수정하겠습니다.

// index.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>chat-app</title>
</head>
<body>
    <div>
        <div id="info"></div>
        <div id="chatWindow"></div>
        <div>
            <input id="chatInput" type="text">
            <button id="chatMessageSendBtn">전송</button>
        </div>
    </div>

<script src="/socket.io/socket.io.js"></script>
<script src="/js/index.js"></script>
</body>
</html>

 

코드 설명

 

>

info 

서버가 전달하는 텍스트를 노출하는 영역으로 현재 작업한 코드에서는 connect, disconnect 시에 SERVER가 updataMessage를 호출하고 있습니다.

chatWindow 

사용자간의 채팅 텍스트를 노출하는 영역

chatInput 

사용자가 채팅 텍스트를 작성할 input

chatMessageSendBtn 

사용자가 채팅 텍스트를 작성 후 전송할 버튼

<div>
    <div id="info"></div>
    <div id="chatWindow"></div>
    <div>
        <input id="chatInput" type="text">
        <button id="chatMessageSendBtn">전송</button>
    </div>
</div>

 

// index.js

'use strict';

var socket = io();

socket.on('connect', function(){
    var name = prompt('대화명을 입력해주세요.', '');
    socket.emit('newUserConnect', name);
});

socket.on('updateMessage', function(data){
    if(data.name === 'SERVER'){
        var info = document.getElementById('info');
        info.innerHTML = data.message;
    }else{

    }
});

var sendButton = document.getElementById('chatMessageSendBtn');
var chatInput = document.getElementById('chatInput');

sendButton.addEventListener('click', function(){
    var message = chatInput.value;
    
    if(!message) return false;

    socket.emit('sendMessage', {
        message
    });

    chatInput.value = '';
});

 

코드 설명

 

> 사용자가 input에 채팅 텍스트를 입력 후 전송 버튼을 클릭했을 때, 실행될 함수를 작성해줍니다.

var sendButton = document.getElementById('chatMessageSendBtn');
var chatInput = document.getElementById('chatInput');

sendButton.addEventListener('click', function(){
    var message = chatInput.value;
    
    if(!message) return false;
   
    socket.emit('sendMessage', {
        message
    });

    chatInput.value = '';
});

 

 

> message는 input의 value값이 되며 메세지가 비었을 경우는 전송되면 안되니까 return false 해줍니다.

var message = chatInput.value;

if(!message) return false;

 

 

> 메세지가 비어있지않고 정상적이면 sendMessage라는 이벤트를 호출하며

사용자가 입력한 텍스트를 파라미터로 전달합니다.

(sendMessage 이벤트 호출 시 실행될 함수는 현재 작업한 적이 없기 때문에 app.js에서 추가 작업해줘야합니다.)

socket.emit('sendMessage', {
    message
});

 

 

> emit 후에 input의 valuer 값을 비워줍니다.

비워주지않으면 전송 후에도 input에 메세지가 그대로 있습니다.

   chatInput.value = '';
});

 

// app.js

io.sockets.on('connection', function(socket){
    socket.on('newUserConnect', function(name){
        socket.name = name;
        
        io.sockets.emit('updateMessage', {
            name : 'SERVER',
            message : name + '님이 접속했습니다.'
        });
    });

    socket.on('disconnect', function(){
        io.sockets.emit('updateMessage', {
            name : 'SERVER',
            message : socket.name + '님이 퇴장했습니다.'
        });
    });

    socket.on('sendMessage', function(data){
        data.name = socket.name;
        io.sockets.emit('updateMessage', data);
    });
});

 

코드 설명

 

> index.js에서 호출한 sendMessage 이벤트 함수입니다.

채팅 텍스트를 파라미터로 전송하였는데,

name값이 있어야 updateMessage의 데이터 구조에 맞기 때문에(또한 채팅시 name값이 있어야하기에) sendMessage에서 data.name값을 담아서 updateMessage를 호출해줍니다.

최초 소켓 연결 시 newUserConnect 함수에서 socket.name값을 저장했기 때문에 여기서 사용해줍니다.

socket.on('sendMessage', function(data){
    data.name = socket.name;
    io.sockets.emit('updateMessage', data);
});

 

 

> updateMessage 부분을 수정해주겠습니다.

index.js

'use strict';

var socket = io();

socket.on('connect', function(){
    var name = prompt('대화명을 입력해주세요.', '');
    socket.emit('newUserConnect', name);
});

var chatWindow = document.getElementById('chatWindow');
socket.on('updateMessage', function(data){
    if(data.name === 'SERVER'){
        var info = document.getElementById('info');
        info.innerHTML = data.message;

        setTimeout(() => {
            info.innerText = '';
        }, 1000);

    }else{
        var chatMessageEl = drawChatMessage(data);
        chatWindow.appendChild(chatMessageEl);
    }
});

function drawChatMessage(data){
    var wrap = document.createElement('p');
    var message = document.createElement('span');
    var name = document.createElement('span');

    name.innerText = data.name;
    message.innerText = data.message;

    name.classList.add('output__user__name');
    message.classList.add('output__user__message');

    wrap.classList.add('output__user');
    wrap.dataset.id = socket.id;
   
    wrap.appendChild(name);
    wrap.appendChild(message);

    return wrap;
}

var sendButton = document.getElementById('chatMessageSendBtn');
var chatInput = document.getElementById('chatInput');
sendButton.addEventListener('click', function(){
    var message = chatInput.value;

    if(!message) return false;    

    socket.emit('sendMessage', {
        message
    });

    chatInput.value = '';
});

 

 

>> 채팅이 작성될 객체를 가져와서 updateMessage 내부에 if else로 분기해줍니다.

if에는 서버에서 작성되어 전달되어오는 텍스트를 위한 작업,

그리고 else에는 사용자가 작성하여 전달되어오는 텍스트를 위한 작업입니다.

이건 사용자 입력(updateMessage)이 있을때마다 반복됩니다.

var chatWindow = document.getElementById('chatWindow');

socket.on('updateMessage', function(data){
    if(data.name === 'SERVER'){
        var info = document.getElementById('info');
        info.innerHTML = data.message;

        setTimeout(() => {
            info.innerText = '';
        }, 1000);
        
    }else{
        var chatMessageEl = drawChatMessage(data);
        chatWindow.appendChild(chatMessageEl);
    }
});

 

 

>> 서버에서 전달되어오면 간단히 innerHTML에 메세지만 삽입해줍니다.

그리고 1초뒤에 안내메세지이기 때문에 메세지가 지워질 수 있도록 setTimeout으로 설정해줍니다.

var info = document.getElementById('info');
info.innerHTML = data.message;

setTimeout(() => {
    info.innerText = '';
}, 1000);

 

 

>> 사용자가 전달한 텍스트는 drawChatMessage()함수를 통해 객체를 생성해 chatWindow에 삽입해줍니다.

var chatMessageEl = drawChatMessage(data);
chatWindow.appendChild(chatMessageEl);

 

 

>> 채팅은 텍스트 갯수가 계속 늘어나야하기 때문에 객체로 만들어 append 해줍니다.

function drawChatMessage(data){
    var wrap = document.createElement('p');
    var message = document.createElement('span');
    var name = document.createElement('span');

    name.innerText = data.name;
    message.innerText = data.message;

    name.classList.add('output__user__name');
    message.classList.add('output__user__message');

    wrap.classList.add('output__user');
    wrap.dataset.id = socket.id;    

    wrap.appendChild(name);
    wrap.appendChild(message);

    return wrap;
}

 

>> data를 인자로 받습니다. 

data에는 data.name(대화명) 과 data.message (대화텍스트)가 담겨있습니다.

function drawChatMessage(data){

 

 

>> createElement 메소드를 사용해서 p태그와 span 태그들을 생성합니다.

wrap = 전체를 감싸줄 객체

message = 메세지를 담을 객체

name = 대화명을 담을 객체

var wrap = document.createElement('p');
var message = document.createElement('span');
var name = document.createElement('span');

 

 

>> innerText를 통해 대화명과 대화를 객체에 담아줍니다.

name.innerText = data.name;
message.innerText = data.message;

 

 

>> 객체를 컨트롤 하기 위해 class나 id를 추가해줍니다.

name.classList.add('output__user__name');
message.classList.add('output__user__message');

wrap.classList.add('output__user');
wrap.dataset.id = socket.id;

 

 

>> wrap 객체안에 대화명과 메세지를 담은 객체를 넣어주고 wrap 객체를 return 해줍니다. 

리턴된 객체는 updateMassage에서 채팅창에 넣어줍니다.

wrap.appendChild(name);
wrap.appendChild(message);

return wrap;

 

 

서버를 종료했다가 다시 실행시켜서 두개의 탭을 열어 접속합니다.

node app.js

 

 

서버가 전달하는 텍스트의 영역은

객체를 생성하면서 삽입하는게 아니라 텍스트만 단순히 덮여쓰고 있으므로 줄이 늘어나지 않습니다.

 

 

사용자가 입력하는 대화내용은 대화를 입력하는 만큼 추가됩니다. 

drawChatMessage 함수를 통해 객체를 생성하여 삽입하기 때문입니다.

 

 

지금까지 진행한 부분의 흐름을 정리해보겠습니다.

 

-입장 시

connect (index.js) -> newUserConnect (app.js) -> updateMessage (index.js)

소켓연결, 대화명입력받음 ->  대화명저장, 메세지 작성, 메세지 전달 -> 브라우저에 데이터 삽입

 

-대화 시

click (index.js) -> sendMessage (app.js) -> updateMessage (index.js) 

클릭, 메세지 작성 -> 대화명저장, 메세지 전달 -> 브라우저에 데이터 삽입

 

-퇴장 시

disconnect (app.js) -> updateMessage (index.js)

소켓종료, 메세지작성, 메세지 전달 -> 브라우저에 데이터 삽입

 

 

여기까지 간단한 기본 기능을 추가한 채팅앱이었습니다.

 

 

 

외관 꾸미기

 

css를 추가해서 조금 더 나은 모습으로 바꾸어보겠습니다.

src하위로 css폴더를 생성하고 그 아래 index.css파일을 만들겠습니다.

 

폴더구조는 아래와 같습니다.

 

index.html의 태그들에 class들을 추가하겠습니다.

id로는 js를 컨트롤하고 class로는 style를 컨트롤하도록 나누어 작업하기 위함입니다.

// index.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <link rel="stylesheet" href="./css/index.css">
    <title>chat-app</title>
</head>
<body>
    <div class="app__wrap">
        <div id="info" class="app__info"></div>
        <div id="chatWindow" class="app__window"></div>
        <div class="app__input__wrap">
            <input id="chatInput" type="text" class="app__input" autofocus placeholder="대화를 입력해주세요.">
            <button id="chatMessageSendBtn" class="app__button">전송</button>
        </div>
    </div>

<script src="/socket.io/socket.io.js"></script>
<script src="/js/index.js"></script>
</body>
</html>

 

코드 설명

 

> input에 autofocus나 placeholder의 속성을 주겠습니다.

 

autofocus는 브라우저를 열면 포커스가 인풋에 커서가 자동으로 가게 하는 속성이고

placeholder는 인풋의 힌트를 주는 속성이며 선택사항입니다.

<input id="chatInput" type="text" class="app__input" autofocus placeholder="대화를 입력해주세요.">

 

> index.css의 링크를 로드하는것도 잊지말아야합니다.

<link rel="stylesheet" href="./css/index.css">

 

> 스타일은 따로 설명하지 않겠지만 디자인 감각이 없기때문에... 

material design에서 color tool 부분을 그대로 따라했습니다.

그대로 복사하여 사용하시면 됩니다.

index.css

@charset "utf-8";

.app__wrap{
    margin: 0 auto;
    padding: 50px 0 0;
    position: relative;
    width: 100%;
    max-width: 350px;
    min-width: 200px;
    font-size: 14px;
    border-top: 20px solid #5c007a;
    box-sizing: border-box;
    box-shadow: 1px 1px 5px rgba(0,0,0,0.1);
}

.app__info{
    position: absolute;
    top: 0;
    width: 100%;
    height: 50px;
    text-align: center;
    line-height: 50px;
    color: #fff;
    background: #8e24aa;
}

.app__window{
    overflow-y: auto;
    padding: 10px 20px;
    height: 400px;
    background: #e1e2e1;
}

.output__user{
    margin: 0;
    margin-bottom: 10px;
}

.output__user__name{
    margin-right:10px;
    font-weight:700;
}

.app__input__wrap{
    padding: 10px;
    background: #f5f5f6;
}

.app__input__wrap:after{
    content:'';
    display:block;
    clear:both;
}

.app__input{
    float: left;
    display: block;
    width: 80%;
    height: 25px;
    border: 1px solid #ccc;
    box-sizing: border-box;
}

.app__button{
    float: left;
    display: block;
    width: 20%;
    height: 25px;
    border: 1px solid #ccc;
    border-left: 0;
    background: #fff;
    box-sizing: border-box;
    cursor: pointer;
}

 

 

스타일 적용이 마치면 아래와 같아집니다.

 

> 대화가 길어지면 스크롤이 생기게되는데, 스크롤이 계속 상단을 보고 있습니다.

새로운 채팅 텍스트가 생성되면 하단을 볼수있게 스크립트 하나 추가하겠습니다.

// index.js

var chatWindow = document.getElementById('chatWindow');
socket.on('updateMessage', function(data){
    if(data.name === 'SERVER'){
        var info = document.getElementById('info');
        info.innerHTML = data.message;       

        setTimeout(() => {
            info.innerText = '';
        }, 1000);

    }else{
        var chatMessageEl = drawChatMessage(data);
        chatWindow.appendChild(chatMessageEl);

        chatWindow.scrollTop = chatWindow.scrollHeight;
    }
});

 

코드 설명

 

> updateMessage 가 실행될 때 사용자 대화일 경우, else 부분이 실행되고

chatWindow의 스크롤을 chatWindow의 스크롤 높이만큼 내려주는 부분입니다.

chatWindow.scrollTop = chatWindow.scrollHeight;

 

 

이렇게하면 길어져도 대화 입력 시 스크롤이 올라가지않고 항상 최신 대화를 볼 수 있습니다.

 

 

 


 

 

최종 코드

 

// index.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <link rel="stylesheet" href="./css/index.css">
    <title>chat-app</title>
</head>
<body>
    <div class="app__wrap">
        <div id="info" class="app__info"></div>
        <div id="chatWindow" class="app__window"></div>
        <div class="app__input__wrap">
            <input id="chatInput" type="text" class="app__input" autofocus placeholder="대화를 입력해주세요.">
            <button id="chatMessageSendBtn" class="app__button">전송</button>
        </div>
    </div>

<script src="/socket.io/socket.io.js"></script>
<script src="/js/index.js"></script>
</body>
</html>

 

// index.js

'use strict';

var socket = io();
var chatWindow = document.getElementById('chatWindow');
var sendButton = document.getElementById('chatMessageSendBtn');
var chatInput = document.getElementById('chatInput');

socket.on('connect', function(){
    var name = prompt('대화명을 입력해주세요.', '');
    socket.emit('newUserConnect', name);
});

socket.on('updateMessage', function(data){
    if(data.name === 'SERVER'){
        var info = document.getElementById('info');
        info.innerHTML = data.message;

        setTimeout(() => {
            info.innerText = '';
        }, 1000);

    }else{
        var chatMessageEl = drawChatMessage(data);
        chatWindow.appendChild(chatMessageEl);

        chatWindow.scrollTop = chatWindow.scrollHeight;
    }
});

sendButton.addEventListener('click', function(){
    var message = chatInput.value;

    if(!message) return false;
   
    socket.emit('sendMessage', {
        message
    });

    chatInput.value = '';
});

function drawChatMessage(data){
    var wrap = document.createElement('p');
    var message = document.createElement('span');
    var name = document.createElement('span');

    name.innerText = data.name;
    message.innerText = data.message;

    name.classList.add('output__user__name');
    message.classList.add('output__user__message');

    wrap.classList.add('output__user');
    wrap.dataset.id = socket.id;

    wrap.appendChild(name);
    wrap.appendChild(message);

    return wrap;
}

 

// app.js

const express = require('express');
const http = require('http');
const app = express();
const server = http.createServer(app);
const fs = require('fs');
const io = require('socket.io')(server);

app.use(express.static('src'));

app.get('/', function(req, res){
    fs.readFile('./src/index.html', (err, data) => {
        if(err) throw err;

        res.writeHead(200, {
            'Content-Type' : 'text/html'
        })
        .write(data)
        .end();
    });
});

io.sockets.on('connection', function(socket){
    socket.on('newUserConnect', function(name){
        socket.name = name;

        io.sockets.emit('updateMessage', {
            name : 'SERVER',
            message : name + '님이 접속했습니다.'
        });
    });

    socket.on('disconnect', function(){
        io.sockets.emit('updateMessage', {
            name : 'SERVER',
            message : socket.name + '님이 퇴장했습니다.'
        });
    });

    socket.on('sendMessage', function(data){
        data.name = socket.name;
        io.sockets.emit('updateMessage', data);
    });
});

server.listen(8080, function(){
    console.log('서버 실행중...');
});

 

// index.css

@charset "utf-8";

.app__wrap{
    margin: 0 auto;
    padding: 50px 0 0;
    position: relative;
    width: 100%;
    max-width: 350px;
    min-width: 200px;
    font-size: 14px;
    border-top: 20px solid #5c007a;
    box-sizing: border-box;
    box-shadow: 1px 1px 5px rgba(0,0,0,0.1);
}

.app__info{
    position: absolute;
    top: 0;
    width: 100%;
    height: 50px;
    text-align: center;
    line-height: 50px;
    color: #fff;
    background: #8e24aa;
}

.app__window{
    overflow-y: auto;
    padding: 10px 20px;
    height: 400px;
    background: #e1e2e1;
}

.output__user{
    margin: 0;
    margin-bottom: 10px;
}

.output__user__name{
    margin-right:10px;
    font-weight:700;
}

.app__input__wrap{
    padding: 10px;
    background: #f5f5f6;
}

.app__input__wrap:after{
    content:'';
    display:block;
    clear:both;
}

.app__input{
    float: left;
    display: block;
    width: 80%;
    height: 25px;
    border: 1px solid #ccc;
    box-sizing: border-box;
}

.app__button{
    float: left;
    display: block;
    width: 20%;
    height: 25px;
    border: 1px solid #ccc;
    border-left: 0;
    background: #fff;
    box-sizing: border-box;
    cursor: pointer;
}

 

 

 

이것으로 간단한 채팅앱 구현을 마무리하겠습니다.

 

 

 

 

 

socket.io 모듈을 사용해 통신 기본 설정하기

 

> 클라이언트 스크립트 파일 생성 후 로드시키기

 

src 하위에 js 폴더를 생성하고 그 안에 index.js 파일을 생성합니다.

폴더구조는 아래와 같습니다.

 

통신 스크립트를 추가하기 전에 앞서서 index.html에 관련 js 파일을 로드합니다.

// index.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>chat-app</title>
</head>
<body>
    <div>welcome</div>

<script src="/socket.io/socket.io.js"></script>
<script src="/js/index.js"></script>
</body>
</html>

 

index.js는 생성했는데,

socket.io.js도 생성해야하나요? 라고 질문하신다면 아니라고 답하겠습니다.

socket.io.js는 서버 실행 시 자동으로 생성되기때문에 <script> 코드만 추가하면 됩니다.

 

 

> 클라이언트-서버 양방향 스크립트 추가하기

 

서버와 클라이언트가 통신하기위해서는 app.js(서버)-index.js(클라이언트) 모두 수정이 되어야합니다.

// app.js

const express = require('express');
const http = require('http');
const app = express();
const server = http.createServer(app);
const fs = require('fs');
const io = require('socket.io')(server);

app.use(express.static('src'));

app.get('/', function(req, res){
    fs.readFile('./src/index.html', (err, data) => {
        if(err) throw err;

        res.writeHead(200, {
            'Content-Type' : 'text/html'
        })
        .write(data)
        .end();
    });
});

io.sockets.on('connection', function(socket){ 

});

server.listen(8080, function(){
    console.log('서버 실행중...');
});

 

코드 설명

 

> socket.io 모듈을 불러와서 io 변수에 담고,

io.sockets.on에 connection 이벤트가 호출되면 실행될 함수를 바인딩해줍니다.

io.sockets은 나를 포함한 모든 소켓의 객체이며 'connection' 이벤트는 소켓이 연결되면 호출되는 이벤트입니다.

const io = require('socket.io')(server);
io.sockets.on('connection', function(socket){ 

});

 

 

 

> on 메서드를 통해 이벤트를 바인딩할 수 있으며 emit 메서드를 통해 이벤트를 호출할 수 있습니다.

on은 수신 emit은 발신으로 생각하면 쉽습니다.

io.sockets.on('connection', function(socket){  

});

 

 

> connection의 콜백함수를 보면 socket 인자가 전달되어 오는데, socket은 접속된 해당 소켓의 객체입니다.

소켓 연결 중에 어떠한 이벤트를 바인딩하고 싶다하면

connection의 콜백함수 스코프 내부에 이벤트리스너들을 작성하면 됩니다.

// index.js

'use strict';

var socket = io();

socket.on('connect', function(){
    console.log('connect');
});

 

>> socket.io 실행 후 해당 객체를 리턴받아 socket 변수에 담습니다.

var socket = io();

 

 

>> 담겨진 변수를 통해 connect 이벤트에 바인딩합니다.

connect 이벤트는 소켓이 연결되면 호출됩니다.

socket.on('connect', function(){
    console.log('connect');
});

 

 

> 사용자 입력을 받아 대화명 저장하기

 

// index.js

'use strict';

var socket = io();

socket.on('connect', function(){
    var name = prompt('대화명을 입력해주세요.', '');
    socket.emit('newUserConnect', name);
});

 

코드 설명

 

> prompt는 window 메소드로 사용자가 텍스트를 입력할 수 있는 대화상자를 띄웁니다.

대화명을 입력받은 후 name 변수에 담아줍니다.

var name = prompt('대화명을 입력해주세요.', '');

 

 

> 저장한 name값을 newUserConnect 이벤트를 호출하면서 파라미터로 전달해줍니다.

지금 emit하는 newUserConnect는 커스텀 이벤트로 다른 이름이어도 상관없습니다.

예를들어 newUserInit이어도 상관없습니다.

다만 app.js에서 이벤트 호출시 실행되는 on 함수를 추가할 것인데, 이때의 네임과 동일해야합니다.

socket.emit('newUserConnect', name);

connect, connection, disconnect 등... 정해진 기본이벤드들이 있습니다. 

기본이벤트를 제외하고는 커스텀이벤트를 사용하면 됩니다.

 

 

// app.js

const express = require('express');
const http = require('http');
const app = express();
const server = http.createServer(app);
const fs = require('fs');
const io = require('socket.io')(server);

app.use(express.static('src'));

app.get('/', function(req, res){
    fs.readFile('./src/index.html', (err, data) => {
        if(err) throw err;

        res.writeHead(200, {
            'Content-Type' : 'text/html'
        })
        .write(data)
        .end();
    });
});

io.sockets.on('connection', function(socket){
    socket.on('newUserConnect', function(name){
        socket.name = name;
    });
});

server.listen(8080, function(){
    console.log('서버 실행중...');
});

 

index.js(클라이언트)에서

newUserConnect라는 이벤트를 emit했고 app.js(서버)에서 이벤트를 처리하는 함수를 생성하겠습니다.

 

코드 설명

 

> newUSerConnect 이벤트 호출 시 name값을 파라미터로 전달했었는데, 기억하시나요?

그 파라미터가 function의 name값으로 전달되어왔고 socket.name에 저장해둡니다.

io.sockets.on('connection', function(socket){
    socket.on('newUserConnect', function(name){
        socket.name = name;
    });
});

 

테스트 : index.html에 대화명 삽입해보기

socket.name에 저장한값을 index.html에서 확인 할 수 있게 수정해보겠습니다.

// index.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>chat-app</title>
</head>
<body>
    <div id="info"></div>

<script src="/socket.io/socket.io.js"></script>
<script src="/js/index.js"></script>
</body>
</html>

welcome 문구를 지우고 동적인 값을 넣기 위해 div 태그에 id값을 넣었습니다.

 

 

// app.js

io.sockets.on('connection', function(socket){
    socket.on('newUserConnect', function(name){
        socket.name = name;

        var message = name + '님이 접속했습니다';

        io.sockets.emit('updateMessage', {
            name : 'SERVER', 
            message : message
        });
    });
});

 

코드 설명 

 

> socket.name에 name값만 저장하던 부분에 메세지를 작성하여 message 변수에 담았습니다.

그다음

io.sockets.emit을 통해 updateMessage라는 이벤트를 호출하며 해당 데이터들을 객체리터럴로 전송합니다.

newUserConnect는 서버에서 전달하는 메세지이므로 name에는 서버라고 작성해줍니다.

socket.on('newUserConnect', function(name){
    socket.name = name;

    var message = name + '님이 접속했습니다';

    io.sockets.emit('updateMessage', {
        name : 'SERVER',
        message : message
    });
});

 

 

> updateMessage 이벤트 바인딩된 부분이 없으니 추가해야겠죠?

서버쪽에서 호출했으니 클라이언트쪽 js에서 수정하여 index.html에 노출하도록 하겠습니다.

// index.js

socket.on('updateMessage', function(data){
    var infoEl= document.getElementById('info');
    infoEl.innerHTML = data.message;
});

 

 

>> updateMessage를 호출했으니, 동일한 네이밍으로 바인딩합니다.

전달했던 data를 인자로 받아서 처리해줍니다.

socket.on('updateMessage', function(data){

 

 

>> index.html의 div 태그의 id값을 이용해서 객체를 가져와서 userName이라는 변수에 담고 

innserHTML을 이용해서 userName 영역에 data.message를 넣어줍니다.

var infoEl = document.getElementById('info');
infoEl.innerHTML = data.message;

 

서버를 종료했다가 다시 실행합니다.

// cmd

node app.js

 

웹에 접속하면 아래와 같이 입력창에 입력한 [대화명]님이 접속했습니다. 라는 텍스트가 나옵니다.

 

 

 

기능동작에 대한 이해가 어느정도 되었으니

(4)회차에서는 채팅기능을 구현하겠습니다. 

 

 

 

+ Recent posts