포트폴리오

티스토리 구독 서비스 이전에 존재했던, 티스토리 이웃서비스 티네스(Tines) 개발 돌아보기

 

티스토리 이웃 서비스, 티네스(Tines)

티스토리에는 지난 수년 간 구독서비스가 존재하지 않았다. 지금은 구독서비스가 추가된지 몇 년이 지났고, 티스토리가 고수하던 티스토리 초대장이 있어야만 블로그를 만들 수 있었던 때도 이미 지나가고 없다. 내가 개발자로 성장하는 동안에도 이러한 구독서비스는 없었는데, 어느 정도 실력이 쌓이고서는 한 번 만들어보자는 생각이 들었다.

 

티네스 서비스는 2018-2019 년 사이에 운영, 개발되었고, 현재 운영 중단 상태. 1년도 운영되지 못했다. 내가 티네스를 만들고 1년 내에 티스토리가 구독 서비스를 런칭했기 때문이다. 따라서 나는 눈물을 머금고 서비스를 중단할 수 밖에 없었다. 내 소중한 포트폴리오가 될 수 있었던 것이었는데! 참으로 아쉽기만 하다. 현재 티네스 프로젝트의 소스코드는 오픈해두었으므로 언제든 확인할 수 있다.

 

https://github.com/pronist/tines.kr

 

pronist/tines.kr

Tistory Neighborhood Services, Tines. Contribute to pronist/tines.kr development by creating an account on GitHub.

github.com

과거에 짠 코드여서 그런지, 코드의 의도는 알겠지만 굳이 이렇게 해야했을까? 싶은 코드도 있다. 컨벤션도 그다지 지켜지지 않은 것 같고 여러모로 문제점이 눈에 보인다. 그러나 그 당시에는 최선을 다해서 만든 것이다. 문제점이 지금의 내 시야에 들어온다는 것은 나도 앞으로 나아갔다는 이야기가 된다.

어떤 기능을 제공했나?

웹 서비스

티네스는 기본적으로 티스토리 API 를 통한 로그인과 로그아웃, 어플리케이션 내부에서의 블로그를 구독하고 구독자 및 이웃을 확인할 수 있으며 내가 구독한 블로그의 3일 이내의 포스트를 확인할 수 있다. 또한 관리기능을 제공하여 티네스에 노출할 블로그를 설정하고 삭제할 수 있다. 티스토리는 기본적으로 다중 블로그 운영을 허용하고 있기 때문에 이는 필수적인 기능이었다.

위젯

그 외에 위젯이라는 기능도 제공했는데, 이것은 네이버 이웃커넥터처럼 사용자의 블로그에 내 구독자와 이웃을 표시할 수 있는, 말그대로 작은 위젯이다.

Open API

위젯을 사용하지 않고, 사용자가 직접 티네스 API 를 사용하여 어플리케이션을 구성할 수 있도록 Open API 를 구성했다. 구독자, 이웃의 수를 얻어오거나 등록된 블로그를 검색하고, 로그인과 로그아웃도 할 수 있도록 제공했다.

라이브러리와 프레임워크

티네스는 PHP 어플리케이션이다. 따라서 PHP 가 메인으로 쓰였으며, 그 외에 프론트엔드는 VueJS 를 사용했다. 사용한 프레임워크는 라라벨이다. 라라벨은 현재 PHP 생태계를 이끌고 있는 주요 프레임워크 중 하나다. 따라서 나는 라라벨을 사용하여 개발해보기도 했다. 버전은 5.6이다. 현재 포스트를 쓰고 있는 시점에서 라라벨은 8.x 가 나왔는데, 버전 관리 정책을 바꾼 것인지 요즘에는 메이저 버전 위주로만 업데이트 하는 듯 보인다.

 

톺아보기

이제 내가 만들 티네스 어플리케이션을 하나하나씩 뜯어보기로 하자. 라라벨은 MVC 패턴을 따르고 있기 때문에 Model, View, Controller 가 기본적으로 나뉘어 있으나 이 포스트에서 MVC 에 대한 설명을 자세하게 하는 것은 주제를 벗어나기 때문에 궁금하다면 아래의 포스트를 살펴보자.

 

https://pronist.tistory.com/43

 

PHP: MVC(Model, View, Controller)

MVC(Model, View, Controller) 모델, 뷰, 컨트롤러로 분리하는 이 아키텍쳐는 많은 프레임워크에서 사용되는 개념이다. 모델은 코드상 어플리케이션에서 사용되는 데이터인 데이터베이스를 클래스화

pronist.tistory.com

URL 인터페이스

라라벨은 또한 URL 포맷을 Restful 한 형태를 따르는 것을 좋아하기 때문에 아래와 같이 구성되었다. 참고로 PHP 7.x 에서는 어노테이션이 없기 때문에 별도의 PHP 파일에서 설정을 하는 과정이 필요하다. 어노테이션을 내가 좋아하는 것도 아니지만, 그렇다고 아주 싫지는 않다.

Web Routes

이 파일에는 사용자가 실제로 보는 웹페이지에 대한 라우트 설정이 담겨있으며 티네스에서 제공하는 웹 라우트는 다음과 같다. (나 왜 컨트롤러 이름에 복수형을 사용했지???)

URL Method Controller View Description
/ GET Home::__invoke blogs.blade.php 메인
/auth/login GET AuthController::login . 로그인
/auth/logout GET AuthController::logout . 로그아웃
/search GET Search::__invoke searh.blade.php 블로그 검색
/subscribers GET Subscribers::__invoke subscribers.blade.php 내 구독자
/posts GET Posts::__invoke posts.blade.php 3일 이내의 새 글보기
/neighbors GET Neighbors::__invoke neighbors.blade.php 내 이웃
/manage GET Manage::__invoke manage.blade.php 블로그 관리
/blogs POST BlogsController::store . 블로그 등록하기
/blogs/{blogName} DELETE BlogsController::destory . 블로그 삭제하기
/widget{component} GET Widget::__invoke widget.blade.php 위젯
__invoke() 는 PHP 의 매직 메서드로 객체 자체를 호출할 때 사용하는 메서드다. 객체 자체를 호출한다는게 무슨 말인지 모를 수 있지만, 사실 그다지 신경 쓸 필요는 없다.

모든 경로를 다 알아볼 수는 없는 노릇이기 때문에 중요한 것들만 알아본다. 지금 포스트를 적는 시점에서는 이 프로젝트가 몇 년 전이기 때문에 기억이 제대로 안 날지도 모르겠지만 그냥 적어보기로 한다.

API Routes

해당 설정에서는 티네스에서 제공하는 API 서비스의 라우트를 설정한다. 이것을 먼저 언급하는 이유는 web.php 에 있는 라우트의 일부 구현에서 API 라우트를 호출하기 때문이다.

URL Methods Controller Description
/v1/auth/login POST Api\v1\AuthController::login 로그인
/v1/auth/logout GET Api\v1\AuthController::logout 로그아웃
/v1/blogs GET Api\v1\Blogs::__invoke 블로그 검색
/v1/subscribers GET Api\v1\Subscribers::__invoke 구독자 검색
/v1/posts GET Api\v1\Posts::__invoke 3일 이내의 새 글보기
/v1/neighbors GET Api\v1\NeighborsController::index 이웃 검색
/v1/neighbors POST Api\v1\NeighborsController::store 이웃 추가
/v1/neighbors/{blogName} DELETE Api\v1\NeighborsController::destroy 이웃 삭제

티네스 API 의 사용법에 대한 일부 내용은 티네스 레포 위키에도 작성되어 있다. 서비스가 아직 온전하게 활성화가 안 된 상태에서 런칭 시점부터 API 를 함께 제공했던 것이 상당히 오버스러울 수는 있겠지만, 나름 괜찮은 경험이 되었던 듯하다.

 

https://github.com/pronist/tines.kr/wiki

 

pronist/tines.kr

Tistory Neighborhood Services, Tines. Contribute to pronist/tines.kr development by creating an account on GitHub.

github.com

데이터베이스

티네스의 데이터베이스 구성은 아주 간단하다. 단순 유저간의 관계를 맺어주는 그 이상의 역할은 거의 하지 않았기 때문이다. 서비스가 조금 더 발전할 여지가 있었다면 테이블을 확장했을텐데 조기 종료되는 바람에 초기 상태에서 그대로 유지될 수 밖에 없었다.

 

ERD 구성은 위와 같다. 먼저, 유저를 중심으로 생각해보자. 유저는 여러 개의 블로그를 등록할 수 있으며 또한 이웃을 여럿 가질 수 있다. 한편, 여러 개의 블로그는 한 명의 유저에 소속될 수 있으며 다수의 이웃, 그러니까 블로그의 입장에서보면 해당 블로그를 구독하는 다수의 구독자를 가질 수 있다. 유저의 관점에서 했기 때문에 neighbors 라는 테이블 이름이 되었지만, 블로그의 입장에서보면 subscribers 가 된다.

기능 및 비지니스 로직

여기서는 티네스에 쓰인 일부 핵심 비지니스 로직을 설명한다. 티스토리 API 의 경우 내가 예전에 만들어둔 Tistory API for PHP 를 사용한다. 이 부분에서 나올 코드들은 몇 년전에 작성한 코드들이라 지금하면 코드가 약간 달라질 수는 있겠지만 기본적으로 틀은 똑같을 것이다.

 

https://github.com/pronist/tistory.php

 

pronist/tistory.php

Tistory API for PHP. Contribute to pronist/tistory.php development by creating an account on GitHub.

github.com

인증

티네스에서 중요한 것중 하나는 이웃을 추가하고 삭제하는 것 이외에도 인증부분이다. 인증을 할 때 웹에서 처리 할 때 티스토리 API 를 통해 엑세스 토큰으로 받아오긴 한다만, 티네스에서는 API 서비스도 제공하고 있어서 JWT(Json Web Token)인증도 함께 겸하고 있다. 아래의 코드는 로그인의 일부인데, 티스토리 API 를 거쳐 엑세스 토큰을 받으면 API 라우트에도 로그인 요청을 한다.

아래의 코드는 많은 부분을 생략한 것이다. 그 이후의 코드도 생략한 코드가 나온다.

AuthController::login(Request, \GuzzleHttp\Client)

 

$client 변수는 \GuzzleHttp\Client 타입이며 기본적으로 API 라우트에 요청을 하도록 되어있다. /v1/auth/login 에 요청한 결과의 응답에 JWT 토큰이 담겨있다는 것을 보면, 해당 라우트에서 JWT 토큰을 생성하고 응답해준다는 것을 알 수 있다.

if($code) {
    try {
        /** 티스토리 API */
        $access_token = \Pronist\Tistory\Auth::getAccessToken(
            env('TISTORY_CLIENT_ID'),
            env('TISTORY_SECRET_KEY'),
            env('TISTORY_CALLBACK'),
            $request->get('code')
        );
    }
    catch(AuthenticationException $e) {
        return response()->json(['message' => $e->getMessage()], 500);
    }
    try {
        $response = $client->request('post', 'auth/login', [
            'form_params' => [
                'access_token' => $access_token
            ]
        ]);

        $response = json_decode($response->getBody()->getContents());

        /**
         * $response->token: 이 토큰은 JWT 이다.
         */
        Auth::guard('web')->login(
            Auth::guard('api')
                ->setToken($response->token)
                ->user()
        );
    }
    catch(\Exception $e) {
        return response()->json(['message' => $e->getMessage()], 500);
    }
}
else {
    return redirect(\Pronist\Tistory\Auth::getPermissionUrl(
        env('TISTORY_CLIENT_ID'),
        env('TISTORY_CALLBACK'),
        'code'
    ));
}

이제 그럼 JWT 토큰을 생성하고 로그인을 담당하는 API 라우트에서는 주로 어떤 일을 하는지 알아보자. 먼저 새로운 유저가 들어오면 대표 블로그를 티네스에 등록해야 하며, 기존 유저라면 블로그 정보를 새로 갱신한다. 블로그의 이름이나 설명을 바꾸었거나 하는 경우에 갱신이 필요하기 때문이다.

 

그 다음 티스토리 엑세스 토큰을 JWT 에 담고 반환한다. 이 토큰은 티스토리 엑세스 토큰이라는 다소 중요한 정보를 담고있으므로 본인 이외에는 노출되지 않는 것을 전제로 한다. 여기서 Thumbnail::build() 메서드는 마지막 파라매터로 넘겨준 값에 따라 갱신하거나 생성하는 등의 역할을 한다. 구현은 중요하지 않으므로 생략. Authentication::createNewUser() 메서드는 유저를 새로 생성하고 블로그를 등록하는 일을 한다.

/** 유저 블로그 정보를 얻어옵니다. */
try {
    $user = \Pronist\Tistory\Blog::info($access_token);
}
catch(BadResponseException $e) {
    return response()->json(['message' => $e->getMessage()], 500);
}
try {
    /** 기존 유저인가요? */
    $currentUser = \App\User::where('email', '=', $user->id)->first()?: null;

    /**
    * 새로운 유저인 경우 대표 블로그만 추가하고
    * 기존 유저인 경우 블로그의 정보를 갱신합니다.
    */
    if(!$currentUser) {
        /** 새 유저 생성 */
        $currentUser = Authentication::createNewUser($user);
    }
    else {
        /** 서비스에 등록된 모든 블로그의 정보를 갱신합니다. */
        foreach($user->blogs as $blog) {
            /** 현재 유저가 해당 블로그를 등록했습니까? */
            $registerd = $currentUser->blogs()->where('name', '=', $blog['name'])->first();
            if($registerd) {
                Thumbnail::build((object) $blog, $registerd, 'update');
            }
        }
    }

    /** api 로그인 */
    $token = auth()->claims(['access_token' => $access_token])->login($currentUser);

    return response()->json([
        'token' => $token,
        'token_type' => 'bearer',
        'expires_in' => auth()->factory()->getTTL() * 60
    ], 201);
}
catch(\Exception $e) {
    return response()->json(['message' => $e->getMessage()], 500);
}

AuthController::logout(Request, \GuzzleHttp\Client)

 

로그아웃에서 중요한 것은 API 라우트에 있는 로그아웃 요청을 날리는 것이다. 그럼 해당 토큰은 무효화 될 것이다.

/** 로그아웃합니다. */
Auth::guard('web')->logout();

$client->request('get', 'auth/logout', [
    'headers' => [
        'Authorization' => 'Bearer '. session()->get('token')
    ]
]);

구독

ERD 에서도 봤겠지만, 유저는 여러 개의 블로그를 소유할 수도 있지만, 구독할 수도 있다.

 

blogsController::store(Request)

 

자기 자신의 블로그를 추가하는 일이 발생하지 않도록 미리 처리를 해주도록 하자.

$user = auth()->user();
$blog_id = \App\Blog::where('name', '=', $request->post('name'))->first()->id;

/** 자기블로그 추가를 막습니다. */
if(\App\Blog::where('user_id', '=', $user->id)->where('id', '=', $blog_id)->first()) {
    return response()->json(['message' => 'Cannot append my own blog'], 400);
}
else {
    /** 이웃을 추가합니다. */
    \App\Neighbor::create(['user_id' => $user->id, 'blog_id' => $blog_id]);
    return response()->json([], 201);
}

blogsController::destory(string)

 

이웃을 날리는 것은 아주 쉽다. 이미 모델 차원에서 관계를 설정해놓았기 때문에 저렇게 호출이 가능하다.

$id = \App\Blog::where('name', '=', $name)->first()->id;

/** 
 * 이웃을 삭제합니다. 
 */
auth()->user()->neighbors()->detach($id);

블로그 관리

가지고 있는 티스토리 블로그 중에 티네스에 등록할 블로그를 설정하고 삭제할 수 있다. 가지고 있는 티스토리 블로그라도 티네스에 등록하고 싶지 않은 블로그가 있을테니까 말이다.

 

BlogsController::store(Reuqest)

 

해당 컨트롤러는 블로그를 아직 티네스에 등록이 안 된 블로그를 추가로 등록할 수 있도록 한다. Thumbnail::getUnregistered() 메서드를 통해 아직 등록이 안 된 블로그를 불러오고 유저가 요청한 블로그에 대해 등록을 진행한다.

try {
    $unregistered = Thumbnail::getUnregistered(
        \App\Blog::where('user_id', '=', auth()->user()->id)->get(), 
        $request->session()->get('access_token')
    );
    foreach($unregistered as $blog) {
        if($blog['name'] == $request->post('name')) {
            Thumbnail::build((object) $blog, Auth::guard('web')->user()->blogs(), 'create');
        }
    }
    return response()->json([], 201);
}
catch(\Exception $e) {
    return response()->json(['message' => $e->getMessage()], 500);
}

BlogsController::destroy(string)

 

이미 등록한 블로그를 삭제한다. 하지만 대표의 블로그의 경우는 특수하기 때문에 삭제할 수 없도록 했다. 

$registered = \App\Blog::where('user_id', '=', auth()->user()->id)->get();

/** 대표 블로그는 삭제할 수 없습니다. */
if(\App\Blog::where('name', '=', $name)->first()->default == '1') {
    return response()->json(['message' => 'Cannot remove default blog'], 400);
}
foreach($registered as $blog) {
    if($blog->name == $name) {
        auth()->user()->blogs()->where('name', '=', $name)->delete();
        return response()->json([], 204);
    }
}

위젯

위젯은 사용자의 블로그에 직접 iframe 형태로 얻을 수 있게 만든 네이버 이웃커넥터 같은 것이다. 실제로 저것을 만들 떄도 네이버 이웃커넥터를 참고하긴 했었다. VueJS 컴포넌트로 구성했으나 티네스 프로젝트에서 프론트엔드에 대한 것을 강조할 생각은 전혀 없으므로 생략하기로 하자.

 

다만 아이디어의 측면에서는 이것이 중요하다고 생각된다. 티네스 웹서비스에서 위젯을 구성해서 사용자가 포함할 수 있도록 한다는 점 말이다. 이러한 시도는 일반적인 서비스에서는 잘 하지 않지만, 티네스는 조금 특수한 케이스이기 때문에 한 번 해볼까 싶어서 해봤다.

인프라 환경

개발

개발 환경에서는 코드 편집자체는 Windows 에서 했지만, Laravel Homestead 를 사용, 자동으로 가상머신을 구성하고 환경을 만들어 주는 vagrantvirtualbox 에 가상화 환경을 만들고 호스트 OS 에서는 가상머신을 프록시로 연결하여 프론트엔드 개발을 위해 Webpack Dev Serverhot reload 를 사용했다. 가상머신에서 서버를 켜고 호스트 OS 에서 또 서버를 켜야하는건 상당히 번거롭다. 

프로덕션

AWS EC2, Amazon RDBMS 서비스를 사용해서 배포했던 것으로 기억한다. 이걸 만들 때는 Docker 의 존재도 모르고 있었으므로 여러모로 환경구축과 테스트 및 배포에 대해서는 공부해볼만한 것이 많다는 것을 새삼 느낀다. 관심을 가지고 공부하는 블록체인의 일종인 이더리움도 따지고 보면 어플리케이션 배포 환경 중 하나다. 이더리움 플랫폼은 스마트 컨트렉트가 동작할 수 있는 환경이니까 말이다.

테스트와 배포 자동화

.env 처럼 프로젝트 자체에서 큰 변화없이 공용으로 사용되는 파일이나 폴더에 대해서는 공유 폴더에 별도로 두었기에 릴리즈 폴더에 있는 공용 파일들은 공용 폴더에 있는 파일에 링크된다. 주 어플리케이션 폴더는 현재 버전의 릴리즈 폴더와 링크를 하는 방식으로 하여 무중단 배포가 가능하도록 하였다.

/**
 * Deploy Laravel Project on Server
 */
function deploy()
{
    /** 
     * Calling configuration
     */
    $connection = config('deploy.connection');
    $remote     = config('deploy.remote');
    $root       = config('deploy.root');
    $shared     = config('deploy.shared');
    $releases   = config('deploy.releases');
    $dist       = config('deploy.dist');
    $group      = config('deploy.group');

    $requires = [ $shared, $releases ];

    $shareds = [
        "$shared/.env"    => "$releases/$dist/.env",
        "$shared/storage" => "$releases/$dist/storage",
        "$shared/cache"   => "$releases/$dist/bootstrap/cache"
    ];

    /** 
     * Execute commands over SSH
     */
    (new \App\Classes\Command($connection))
        ->require($requires)
        ->download($releases, $remote, $dist)
        ->copy($shared, $releases, $dist)
        ->shared($shareds)
        ->composer($releases, $dist)
        ->link($releases, $dist, $root)
        ->chmod($shared)
        ->chgrp($group, $releases, $dist)
    ;
}

SSH 클라이언트 역할을 할 수 있는 스크립트를 작성하여 배포했다. 이것도 그다지 좋은 방법이라고는 볼 수 없다는 것을 잘 알고 있다. Git Action, TravisCI, JenkinsCD/CI 를 위한 도구가 있었지만 난 이걸 만들당시에 사용하지 않았다.

 

테스트도 그다지 하지 않은 것 같다. 아니, 전혀하지 않았다. 요즘 들어서야 코드 커버리지를 100% 로 만들기 위해 노력하고 유닛 테스트를 작성하고 자동화를 하려고 시도를 해보는 것인데, 이 프로젝트를 만들때는 그렇게 할 생각을 전혀 하지 않았다. 이는 자랑이 아니긴 하지만, 이렇게 회고해야 나중에 새로운 프로젝트를 할 때라도 테스트와 배포 자동화를 그럴듯하게 하기 나름이다.