반응형
Jake Seo
제이크서 위키 블로그
Jake Seo
전체 방문자
오늘
어제
  • 분류 전체보기 (715)
    • 일상, 일기 (0)
    • 백준 문제풀이 (1)
    • 릿코드 문제풀이 (2)
    • 알고리즘 이론 (10)
      • 기본 이론 (2)
      • 배열과 문자열 (8)
    • 데이터베이스 (15)
      • Planet Scale (1)
      • MSSQL (9)
      • 디비 기본 개념 (1)
      • SQLite 직접 만들어보기 (4)
    • 보안 (7)
    • 설계 (1)
    • 네트워크 (17)
      • HTTP (9)
      • OSI Layers (5)
    • 회고 (31)
      • 연간 회고 (2)
      • 주간 회고 (29)
    • 인프라 (52)
      • 도커 (12)
      • AWS (9)
      • 용어 (21)
      • 웹 성능 (1)
      • 대규모 서비스를 지탱하는 기술 (9)
    • 깃 (7)
    • 빌드 도구 (7)
      • 메이븐 (6)
      • 그레이들 (0)
    • Java (135)
      • 이펙티브 자바 (73)
      • 자바 API (4)
      • 자바 잡지식 (30)
      • 자바 디자인 패턴 (21)
      • 톰캣 (Tomcat) (7)
    • 프레임워크 (64)
      • next.js (14)
      • 스프링 프레임워크 (28)
      • 토비의 스프링 (6)
      • 스프링 부트 (3)
      • JPA (Java Persistence API) (5)
      • Nest.js (8)
    • 프론트엔드 (48)
      • 다크모드 (1)
      • 노드 패키지 관리 매니저 (3)
      • CSS (19)
      • Web API (11)
      • tailwind-css (1)
      • React (5)
      • React 새 공식문서 요약 (1)
      • HTML (Markup Language) (5)
    • 자바스크립트 (108)
      • 모던 자바스크립트 (31)
      • 개념 (31)
      • 정규표현식 (5)
      • 코드 스니펫 (1)
      • 라이브러리 (6)
      • 인터뷰 (24)
      • 웹개발자를 위한 자바스크립트의 모든 것 (6)
      • 팁 (2)
    • Typescript (49)
    • 리눅스와 유닉스 (10)
    • Computer Science (1)
      • Compiler (1)
    • IDE (3)
      • VSCODE (1)
      • IntelliJ (2)
    • 세미나 & 컨퍼런스 (1)
    • 용어 (개발용어) (16)
      • 함수형 프로그래밍 용어들 (1)
    • ORM (2)
      • Prisma (2)
    • NODEJS (2)
    • cypress (1)
    • 리액트 네이티브 (React Native) (31)
    • 러스트 (Rust) (15)
    • 코틀린 (Kotlin) (4)
      • 자바에서 코틀린으로 (4)
    • 정규표현식 (3)
    • 구글 애널리틱스 (GA) (1)
    • SEO (2)
    • UML (2)
    • 맛탐험 (2)
    • 리팩토링 (1)
    • 서평 (2)
    • 소프트웨어 공학 (18)
      • 테스팅 (16)
      • 개발 프로세스 (1)
    • 교육학 (1)
    • 삶의 지혜, 통찰 (1)
    • Chat GPT (2)
    • 쉘스크립트 (1)
    • 컴파일 (2)
    • Dart (12)
    • 코드팩토리의 플러터 프로그래밍 (4)
    • 플러터 (17)
    • 안드로이드 스튜디오 (1)
    • 윈도우즈 (1)
    • 잡다한 백엔드 지식 (1)
    • 디자인 패턴 (1)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 자바
  • 싱글톤 패턴
  • 자바 디자인패턴
  • Javadoc 자바독 자바주석 주석 Comment
  • 스프링 검증
  • 자료구조
  • 자바스크립트 면접
  • 싱글톤
  • 객체복사
  • MSSQL
  • serverless computing
  • item7
  • 프로그래머의 뇌
  • bean Validation
  • 도커공식문서
  • 메이븐 페이즈
  • 자바스크립트
  • 디자인패턴
  • Java
  • Pre-rendering
  • 빈 검증
  • 자바스크립트 인터뷰
  • 토비의 스프링
  • 싱글턴
  • 슬로우 쿼리
  • 이펙티브 자바
  • item9
  • 이펙티브 자바 item9
  • 팩터리 메서드 패턴
  • 자바 검증
  • prerendering
  • Next.js
  • 추상 팩터리 패턴
  • 메이븐 골
  • rust
  • 참조 해제
  • 메이븐 라이프사이클
  • 러스트
  • item8
  • 플라이웨이트패턴
  • 이펙티브자바
  • pnpm
  • 작업기억공간
  • try-with-resources
  • 느린 쿼리
  • NEXT JS
  • 서버리스 컴퓨팅
  • 알고리즘
  • next js app
  • 외래키 제약조건

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
Jake Seo

제이크서 위키 블로그

회고/주간 회고

회사에서 있었던 레디스 코드 대형 리팩토링 회고

2023. 4. 28. 19:13

초기의 상황 설명

  • 회사에 Redis 를 이용하는 코드가 있었다.
  • 환경은 자바, 스프링 프레임워크 (4.3.30), Lettuce 를 사용했다.
  • 레디스 이용시 CPU 나 메모리가 팍팍 튀는 현상이 생겼다.
  • 레디스의 코드를 살펴보니 커넥션 풀을 직접 관리하고 있었고, 클라이언트 코드도 정제가 잘 안되어있어 코드의 의도도 알아보기 힘들었다.
  • 리팩토링을 결심했다.

기존 레디스 코드 살펴보기

레디스 유틸

// 레디스 유틸 함수쪽 내용

public void nonClusterAppend(String key, String value) throws Exception {
    RedisClient client = RedisClient.create(getRedisUri());
    client.setOptions(ClientOptions.builder().autoReconnect(true).build());

    try (GenericObjectPool<StatefulRedisConnection<String, String>> redisPool
                  = ConnectionPoolSupport.createGenericObjectPool(client::connect, createPoolConfig());
          StatefulRedisConnection<String, String> connection = redisPool.borrowObject()) {

        connection.async().append(key, value);
        connection.async().expire(key, expire);

    } finally {
        client.shutdown();
    }
}

public String clusterGet(String key) throws Exception {
    RedisClusterClient clusterClient = RedisClusterClient.create(getRedisUri());
    clusterClient.setOptions(ClusterClientOptions.builder().autoReconnect(true).build());

    String retValue;

    try (GenericObjectPool<StatefulRedisConnection<String, String>> redisPool
                  = ConnectionPoolSupport.createGenericObjectPool(clusterClient::connect, createPoolConfig());
          StatefulRedisConnection<String, String> connection = redisPool.borrowObject()) {

        retValue = connection.sync().get(key);

    } finally {
        clusterClient.shutdown();
    }

    return retValue;
}

레디스 클라이언트

// 레디스 클라이언트쪽 내용

private List<ResultListVO> getResultListRedis(String dataId) {
    // redis client 생성
    deprecatedRedisConnectionFactory = new DeprecatedRedisConnectionFactory(redisUtil.DB_TWO);

    // redis key 생성
    String key = deprecatedRedisConnectionFactory.createRedisKey(new String[]{dataId, "resultList"});

    List<ResultListVO> resultList = new ArrayList<>();

    try {
        String resultList = deprecatedRedisConnectionFactory.nonClusterGet(key);

        if (resultList != null && !resultList.equals("")) {
            resultList = objectMapperUtil.specifiedList(resultList, ResultListVO.class);
            logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
        } else {
            String jsonStr = deprecatedRedisConnectionFactory.listMapToJsonString(inter.getListRedis(dataId));
            resultList = objectMapperUtil.specifiedList(jsonStr, ResultListVO.class);
            deprecatedRedisConnectionFactory.nonClusterSet(key, jsonStr);
            logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
        }

    } catch (RedisConnectionException redisConnectionException) {
        logger.error("대략 데이터를 찾는데 실패했다는 로깅");

        resultList = inter.getList(dataId);

    } catch (Exception exception) {
        logger.error("대략 데이터를 찾다가 예외가 발생했다는 로깅");
    }

    return resultList;
}

private List<ResultListVO> getResultListRedis2(String dataId) {
    // redis client 생성
    deprecatedRedisConnectionFactory = new DeprecatedRedisConnectionFactory(redisUtil.DB_TWO);

    // redis key 생성
    String key = deprecatedRedisConnectionFactory.createRedisKey(new String[]{dataId, "resultList"});

    List<ResultListVO> resultList = new ArrayList<>();

    try {
        String resultList = deprecatedRedisConnectionFactory.nonClusterGet(key);

        if (resultList != null && !resultList.equals("")) {
            resultList = objectMapperUtil.specifiedList(resultList, ResultListVO.class);
            logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
        } else {
            String jsonStr = deprecatedRedisConnectionFactory.listMapToJsonString(inter.getListRedis(dataId));
            resultList = objectMapperUtil.specifiedList(jsonStr, ResultListVO.class);
            deprecatedRedisConnectionFactory.nonClusterSet(key, jsonStr);
            logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
        }

    } catch (RedisConnectionException redisConnectionException) {
        logger.error("대략 데이터를 찾는데 실패했다는 로깅");

        resultList = inter.getList(dataId);

    } catch (Exception exception) {
        logger.error("대략 데이터를 찾다가 예외가 발생했다는 로깅");
    }

    return resultList;
}

private List<ResultListVO> getResultListRedis3(String dataId) {
    // redis client 생성
    deprecatedRedisConnectionFactory = new DeprecatedRedisConnectionFactory(redisUtil.DB_TWO);

    // redis key 생성
    String key = deprecatedRedisConnectionFactory.createRedisKey(new String[]{dataId, "resultList"});

    List<ResultListVO> resultList = new ArrayList<>();

    try {
        String resultList = deprecatedRedisConnectionFactory.nonClusterGet(key);

        if (resultList != null && !resultList.equals("")) {
            resultList = objectMapperUtil.specifiedList(resultList, ResultListVO.class);
            logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
        } else {
            String jsonStr = deprecatedRedisConnectionFactory.listMapToJsonString(inter.getListRedis(dataId));
            resultList = objectMapperUtil.specifiedList(jsonStr, ResultListVO.class);
            deprecatedRedisConnectionFactory.nonClusterSet(key, jsonStr);
            logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
        }

    } catch (RedisConnectionException redisConnectionException) {
        logger.error("대략 데이터를 찾는데 실패했다는 로깅");

        resultList = inter.getList(dataId);

    } catch (Exception exception) {
        logger.error("대략 데이터를 찾다가 예외가 발생했다는 로깅");
    }

    return resultList;
}

기존 레디스 코드의 문제점

  • DRY 원칙을 위배한다.
    • 코드의 중복이 너무 많다.
  • SRP 원칙을 위배한다.
    • 너무 많은 책임이 존재한다.
      • 새 레디스 커넥션을 연결, 키를 생성, JSON 형태로 변환, JSON 형태를 다시 자바 객체로 변환 등...
  • 커넥션 풀을 직접 관리한다.
  • try ... catch ... finally 구문을 직접 이용하여 모듈화가 힘들다.
    • 거기에 throw Exception 을 던지기 까지해서 호출하는 메서드에서 받아줘야 한다.
  • 커넥션 풀 혹은 리소스 정리에 대한 기술적인 관심사에 대한 코드가 대부분이라 실제로 무슨 작업을 하고 싶은건지 알기 어렵다.
  • JSON 변환에 Serialize 를 사용하지 않고, 직접 자바 애플리케이션 단에서 수행한다.
    • 퍼포먼스상 손해가 발생한다.
  • 키를 생성할 때마다 new String[]{dataId, "resultList"} 을 통해 새로운 문자열 배열 객체를 생성한다.

문제 해결 방향성

레디스 클라이언트 헬퍼 클래스를 사용하자

  • Redis 연결에서 생길 수 있는 다양한 문제를 해결한 라이브러리를 사용하자.
  • 스프링에서 제공하는 RedisTemplate 이 있으니 이를 이용하여 관리하는 방향으로 변경하자.
  • 이를 사용함으로써 커넥션 풀과 같은 커넥션 기술적인 코드의 추상화가 가능하다.
  • 리소스 정리도 물론 자동으로 가능해진다.

중복 코드는 최대한 없애자

  • 중복 코드는 유지보수를 어렵게 하고 전체적으로 코드의 변화를 막는다.

비효율적인 코드는 다시 짜자

  • 문자열 배열을 통해 키를 생성하는 부분 등의 코드는 비효율적으로 구성된 것이 보이므로 이런 것들은 하나씩 개편하며 진행해야 할 것 같다.

try ... catch 나 Exception 은 없앨 수 있다면 없애자

  • Exception 을 잡아서 정상 흐름으로 돌리지 않는 이상은 어차피 에러 메세지 로그만 남기고 끝나므로 별다른 의미가 없다.
  • 차라리 호출 메서드에서 이런것들을 신경쓸 필요 없도록 없애는 게 낫겠다.

깔끔하게 만들자

  • 개발자가 하고 싶은 건 그냥 레디스 키를 이용해서 데이터를 가져오거나 저장하는 것. 데이터가 없으면 추가하는 부분조차도 신경쓰고 싶지 않다.

주요하게 바뀐 코드

레디스 유틸 (기존)

public void nonClusterAppend(String key, String value) throws Exception {
    RedisClient client = RedisClient.create(getRedisUri());
    client.setOptions(ClientOptions.builder().autoReconnect(true).build());

    try (GenericObjectPool<StatefulRedisConnection<String, String>> redisPool
                  = ConnectionPoolSupport.createGenericObjectPool(client::connect, createPoolConfig());
          StatefulRedisConnection<String, String> connection = redisPool.borrowObject()) {

        connection.async().append(key, value);
        connection.async().expire(key, expire);

    } finally {
        client.shutdown();
    }
}
  • 직접 커넥션 풀과 같은 리소스를 관리했다.
  • 거기에 템플릿 코드가 일부 결합되어 있어 커넥션 + 템플릿 코드의 역할을 동시에 했기 때문에 SRP 원칙을 위배하고 있다.
  • 커넥션은 연결에 대한 풀만 제공하고, 템플릿이 키와 유효기간 만료를 설정하는 것이 좋아보인다.

레디스 유틸 (개편)

@Bean("redisConnectionFactory")
@Primary
public RedisConnectionFactory redisConnectionFactory() {
    return getLettuceConnectionFactory(0);
}

@Bean("redisTemplate")
@Primary
public RedisTemplate<?, ?> redisTemplate(@Qualifier("redisConnectionFactory") RedisConnectionFactory redisConnectionFactory) {
    return getRedisTemplate(redisConnectionFactory, RedisSerializerType.STRING);
}
  • LettuceConnectionFactory 에 커넥션을 맺는 역할을 위임했다.
    • 자바의 NIO 네트워크 프레임워크인 netty 를 통해 구현된 커넥션 관리 도구이다.
  • RedisTemplate 에게는 템플릿 코드와 관련된 역할을 위임했다.
    • 템플릿에는 RedisConnectionFactory 를 주입받아서 사용하는데 이전에 만들어두었던 LettuceConnectionFactory 를 주입하여 이용하고 있다.

레디스 클라이언트 (기존)

/**
 * Redis key 생성
 *
 * @param params key Value
 * @return redis Key
 */
public String createRedisKey(String[] params) {
    StringBuilder retKeyVal = new StringBuilder("");
    for (String param : params) {
        if (!retKeyVal.toString().equals("")) {
            retKeyVal.append("_");
        }
        retKeyVal.append(param);
    }
    return retKeyVal.toString();
}

private List<ResultListVO> getResultListRedis(String dataId) {
    // redis client 생성
    deprecatedRedisConnectionFactory = new DeprecatedRedisConnectionFactory(redisUtil.DB_TWO);

    // redis key 생성
    String key = deprecatedRedisConnectionFactory.createRedisKey(new String[]{dataId, "resultList"});

    List<ResultListVO> resultList = new ArrayList<>();

    try {
        String resultList = deprecatedRedisConnectionFactory.nonClusterGet(key);

        if (resultList != null && !resultList.equals("")) {
            resultList = objectMapperUtil.specifiedList(resultList, ResultListVO.class);
            logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
        } else {
            String jsonStr = deprecatedRedisConnectionFactory.listMapToJsonString(inter.getListRedis(dataId));
            resultList = objectMapperUtil.specifiedList(jsonStr, ResultListVO.class);
            deprecatedRedisConnectionFactory.nonClusterSet(key, jsonStr);
            logger.info("대략 레디스에서 해당 데이터를 찾는 로깅");
        }

    } catch (RedisConnectionException redisConnectionException) {
        logger.error("대략 데이터를 찾는데 실패했다는 로깅");

        resultList = inter.getList(dataId);

    } catch (Exception exception) {
        logger.error("대략 데이터를 찾다가 예외가 발생했다는 로깅");
    }

    return resultList;
}
  • 위의 코드가 각 메서드마다 복붙되어 있었다.
  • 위에서 언급한 몇가지 문제가 있다.
    • DRY 원칙 위배
    • 핵심 로직을 보기 어려움
      • 결국 핵심 로직은 레디스에 값이 있으면 레디스의 값을 활용하고 없으면 DB 에서 끌어와 값을 먼저 레디스에 넣고 다음부터는 그 값을 활용하는 것임
    • 퍼포먼스 낮음, 가독성 안좋음
      • 직렬화를 이용한 방식이 아니라 데이터를 문자열로 가져와서 파싱
      • 단순히 _ 로 연결된 키를 생성하는데 new String() 으로 문자열 객체를 생성하고 있음.

레디스 클라이언트 (개편)

/**
 * 레디스 키값을 생성할 때 이용되는 메서드
 * 인자로 받은 문자들 사이를 _ 로 이은 단일 문자열을 생성한다.
 * @param strings
 * @return
 */
public static String joinStringsByUnderscore(String ...strings) {
    return String.join("_", strings);
}

/**
 * key 문자열을 이용해 레디스에서 값을 가져온다.
 * 값이 존재하지 않는다면 defaultValueSupplier 에 있는 값을 가져와 저장한다.
 * @param redisTemplate
 * @param defaultValueSupplier
 * @param keyElems
 * @return
 * @param <T>
 */
public static <T> T getOrSetIfNotFound(RedisTemplate<String, ?> redisTemplate, Supplier<T> defaultValueSupplier, String ...keyElems) {
    String key = joinStringsByUnderscore(keyElems);

    @SuppressWarnings("unchecked")
    ValueOperations<String, T> ops = (ValueOperations<String, T>) redisTemplate.opsForValue();

    T obj = ops.get(key);

    if (obj == null || "".equals(obj)) {
        obj = defaultValueSupplier.get();
        ops.set(key, obj);
    }

    return obj;
}

public List<Map<String, Object>> getResultList(String id) {
    return RedisUtils.getOrSetIfNotFoundSet(
            redisTemplate_1_jackson
            , () -> inter.getResultList(id)
            , id, "field", "v2"
    );
}
  • 기존에 가지던 문제를 개선
    • DRY 원칙을 위배하던 문제
      • getOrSetIfNotFoundSet 이라는 새로운 레디스 전용 유틸 메서드를 추가하여 기존의 로직 추상화
    • SRP 원칙을 위배하던 문제
      • 커넥션 관련은 RedisTemplate 이 처리하고 직렬화 등은 미리 만들어진 Serializer 에서 처리해준다.
      • 개발자는 오직 비즈니스 로직만 생각하면 된다.
    • 핵심 로직이 잘 보이지 않던 문제
      • 짧고 간략한 구성과 메서드 이름으로 핵심 로직을 파악하기 쉽도록 수정
    • 퍼포먼스 문제
      • String 객체를 따로 생성하지 않아도 되도록 변경
    • 기존 코드 가독성 문제
      • 의미 있는 네이밍을 주어 개선
  • 기존의 인터페이스 훼손하지 않는 식으로 1차적 리팩토링
    • 이후 2차 리팩토링이 필요할 수 있음
  • 두번째 인자인 defaultValueSupplier 에는 다양한 값이 등장할 수 있음
    • 무엇이든 기본값으로 정하고 싶은 값이 오면 됨

변경된 레디스 클라이언트 사용법

DB 번호에 맞는 컴포넌트를 @RedisTemplate_n_serializer 애노테이션으로 끌고오기

  • @RedisTemplate_n_serializer 애노테이션으로 해당 DB 의 번호를 담당하는 RedisTemplate 객체를 주입받을 수 있다.
@RedisTemplate_1_jackson
RedisTemplate<String, Object> redisTemplate_1_jackson;
  • 기존 코드처럼 객체를 생성할 필요가 없다.
    • 커넥션은 thread-safe 하므로 사실 매번 새로운 객체를 생성할 필요가 없다.
    • 메모리나 CPU 같은 리소스의 낭비가 덜하다.
DeprecatedRedisConnectionFactory redisConnectionFactory = new DeprecatedRedisConnectionFactory(redisUtil.DB_TWO)

Case1: RedisTemplate 에서 제공하는 기본 동작 이용하기

  • RedisTemplate 에서 제공하는 기본 동작을 이용해도 된다.
redisTemplate_1_jackson.opsForValue().set("key", "value");

Case2: RedisUtils 를 이용한 동작 이용하기

  • redisTemplate 를 인자로 이용하면 된다.
RedisUtils.<Map<String, Object>>getOrSetIfNotFoundSet(
        redisTemplate_1_jackson
        , () -> inter.getAuthorDetail(ancId)
        , ancId, "authorInfo", "v2"
);

Case3: RedisTaskComponent 를 이용한 동작 이용하기

  • 여러 컴포넌트에서 재활용될 수 있는 레디스 공통로직을 작성해두고 재활용하면 좋다.
    • 범용적으로 사용되는 유틸 개념과는 다르게 명확한 비즈니스가 있을 때 유용하다.
    • 아래는 데이터 이용수 실시간 조회에 대한 비즈니스 로직을 구현해둔 것이다.
@Component
public class RedisTaskComponent {
    @RedisTemplate_11_integer
    private RedisTemplate<String, Object> redisTemplate_11_integer;

    public int getOrSetNodeUsedCount(String nodeId, int value) {
        return RedisUtils.getOrSetIfNotFound(
                redisTemplate_11_integer,
                () -> value,
                nodeId
        );
    }

    public void incrementUsedCount(String nodeId) {
        int count = getOrSetNodeUsedCount(nodeId, 0);
        redisTemplate_11_integer.opsForValue().set(nodeId, count + 1);
    }
}
반응형
저작자표시 비영리 (새창열림)

'회고 > 주간 회고' 카테고리의 다른 글

Google Search Console 수집 불가 및 Page Insight 점수 안 뜨는 문제 관련 문제 회고 (쿠키, API 설계 관련 문제)  (0) 2023.06.14
레디스 코드 변경과 함께 일어났던 인프라 문제 회고  (1) 2023.05.30
인텔리제이 톰캣 로그 실종 및 스프링 세션 springSessionRepositoryFilter(DelegatingFilterProxy 타입) 빈을 찾지 못하는 에러에 대한 회고  (0) 2023.04.24
잘못된 도파민 보상 체계를 만들었던 경험 회고  (0) 2023.04.23
스프링 초기 세팅 시 뜬 에러 메세지 관련 회고 (Invalid bean definition with name 'xxx' defined in null)  (0) 2023.04.17
    '회고/주간 회고' 카테고리의 다른 글
    • Google Search Console 수집 불가 및 Page Insight 점수 안 뜨는 문제 관련 문제 회고 (쿠키, API 설계 관련 문제)
    • 레디스 코드 변경과 함께 일어났던 인프라 문제 회고
    • 인텔리제이 톰캣 로그 실종 및 스프링 세션 springSessionRepositoryFilter(DelegatingFilterProxy 타입) 빈을 찾지 못하는 에러에 대한 회고
    • 잘못된 도파민 보상 체계를 만들었던 경험 회고
    Jake Seo
    Jake Seo
    ✔ 잘 보셨다면 광고 한번 클릭해주시면 큰 힘이 됩니다. ✔ 댓글로 틀린 부분을 지적해주시면 기분 나빠하지 않고 수정합니다. ✔ 많은 퇴고를 거친 글이 좋은 글이 된다고 생각합니다. ✔ 간결하고 명료하게 사람들을 이해 시키는 것을 목표로 합니다.

    티스토리툴바