spring_2기[본캠프]/과제

[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 14

minwoo95 2026. 5. 4. 21:22

https://github.com/Hot6-NovelCraft/Hot6-NovelCraft

 

GitHub - Hot6-NovelCraft/Hot6-NovelCraft

Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.

github.com

개인별 배포용 레퍼지토리 [ AWS ]

https://github.com/MinWoo1995/Hot6-NovelCraft-local

 

GitHub - MinWoo1995/Hot6-NovelCraft-local

Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.

github.com

 

오늘 작업 요약

NovelCraft 웹소설 창작 플랫폼 포트폴리오 증거 자료 확보를 위해 다음 항목들을 진행했다.

  1. Prometheus + Grafana 모니터링 구축
  2. 이벤트 참여 동시성 부하테스트 (Redisson 분산락)
  3. 수익 환전 동시성 부하테스트 (Redis SETNX 분산락)
  4. 멘토링 멘티 수락 동시성 테스트 (낙관적 락)
  5. 멘토링 피드백 V1/V2 동시성 비교 (비관적 락)
  6. revenues 인덱스 성능 비교
  7. 캐시 성능 비교 (이벤트 목록 / 수익 통계 / 도서 검색)
  8. Redis Record 클래스 역직렬화 버그 수정

1. Prometheus + Grafana 모니터링 구축

배경

이벤트 참여 부하 테스트를 돌리면서 Grafana 대시보드로 실시간 지표를 캡처해 포트폴리오 증거 자료로 남기려고 구축했다. "동시 200명 요청, 정합성 100%, 평균 응답 Xms"를 Grafana 대시보드 스크린샷 한 장으로 증명하는 게 말 백 마디보다 강력하다.

구성도

Spring Boot (IntelliJ 실행)
    ↓ /actuator/prometheus 노출
Prometheus (Docker, 포트 9095)
    ↓ 15초마다 scrape
Grafana (Docker, 포트 3000)
    ↓ 시각화 대시보드

 

 1단계: build.gradle 의존성 추가

 
gradle
implementation 'org.springframework.boot:spring-boot-starter-actuator'
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'

 

2단계: application.yml 설정 추가

 
yaml
management:
  endpoints:
    web:
      exposure:
        include: health, prometheus, metrics
  endpoint:
    health:
      show-details: always
  metrics:
    tags:
      application: novelcraft

 

3단계: SecurityConfig + JwtFilter 수정

Actuator 엔드포인트가 Security 필터와 JwtFilter에 막혀 있어서 두 곳 모두 수정이 필요했다.

SecurityConfig .requestMatchers 부분에 추가:

 
java
, "/actuator/**"

JwtFilter shouldNotFilter 메서드에 추가:

 
 
java
|| path.startsWith("/actuator")

 

4단계: docker-compose.yml에 Prometheus + Grafana 추가

기존 Kafka 3노드 클러스터가 있는 docker-compose.yml에 두 컨테이너를 추가했다.

 
yaml
prometheus:
  image: prom/prometheus:latest
  container_name: prometheus
  ports:
    - "9095:9090"   # Kafka-2가 9093 포트를 사용해 9095로 설정
  volumes:
    - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
    - prometheus_data:/prometheus
  networks:
    - kafka-net

grafana:
  image: grafana/grafana:latest
  container_name: grafana
  ports:
    - "3000:3000"
  environment:
    GF_SECURITY_ADMIN_USER: admin
    GF_SECURITY_ADMIN_PASSWORD: admin
  volumes:
    - grafana_data:/var/lib/grafana
  depends_on:
    - prometheus
  networks:
    - kafka-net

 

5단계: prometheus.yml 설정

 
yaml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'novelcraft'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['host.docker.internal:8080']

host.docker.internal은 Docker 컨테이너에서 Mac 로컬의 Spring Boot 앱에 접근할 수 있게 해주는 주소다.

 

6단계: Grafana 대시보드 구성

  1. http://localhost:3000 접속 (admin/admin)
  2. Connections → Data Sources → Prometheus 추가
  3. URL: http://prometheus:9090 (컨테이너 내부 통신이라 localhost가 아닌 컨테이너 이름 사용)
  4. Dashboards → Import → ID 4701 (Spring Boot 전용 대시보드) 임포트
  5. Application 변수: Query에 label_values(application) 입력 → novelcraft 확인
  6. Instance 변수: Query에 label_values(jvm_info, instance) 입력

트러블슈팅: Prometheus 포트 충돌

기본 포트 9090이 Kafka-2 브로커와 충돌할 수 있어 호스트 포트를 9095로 변경했다. Grafana에서 Prometheus 연결 시에는 내부 컨테이너 통신이라 prometheus:9090을 사용한다.

트러블슈팅: Grafana Application 변수 설정

4701 대시보드 임포트 후 Application 드롭다운에 아무것도 안 떴다. 변수 설정에서 Target data source가 비어 있어서 발생. Variables → Application → Target data source를 Prometheus로 선택하고 Query에 label_values(application) 입력 후 novelcraft가 나타나면 성공이다. Instance 변수도 동일하게 label_values(jvm_info, instance) 입력.

 


2. 이벤트 참여 동시성 부하테스트 (Redisson 분산락)

시나리오

maxParticipants=100인 이벤트에 200명이 동시에 참여 신청. 정확히 100명만 참여가 성공하는지 데이터 정합성 검증.

k6 시나리오 구성

두 가지 시나리오를 동시에 실행했다.

 
 
javascript
export const options = {
    scenarios: {
        // 시나리오 1: 동시 참여 신청 (200명 동시 요청)
        spike_participate: {
            executor: 'shared-iterations',
            vus: 200,        // 동시 가상 유저 200명
            iterations: 200, // 총 200번 요청 (유저당 1번)
            maxDuration: '60s',
            exec: 'participateTest',
        },
        // 시나리오 2: 조회 API 지속 부하 (Grafana 수치 확보용)
        sustained_read: {
            executor: 'constant-vus',
            vus: 50,         // 동시 가상 유저 50명
            duration: '30s', // 30초 동안 지속
            startTime: '0s',
            exec: 'readTest',
        },
    },
};
  • shared-iterations: 200개의 iteration을 200개의 VU가 나눠서 처리. 각 VU는 로그인 후 이벤트 참여 요청 1회 실행.
  • constant-vus: 50명이 30초 동안 계속 이벤트 목록/상세 조회를 반복해 Grafana에 의미 있는 트래픽 생성.

테스트 유저: loadtest1~200@test.com (200명, BCrypt 해싱 후 DB에 사전 삽입)

Before (분산락 버그 있는 버전)

문제: @Transactional과 Redisson 분산락 해제 순서 버그

 
 
java
// 잘못된 코드: 트랜잭션 커밋 전에 락이 해제됨
@Transactional
public void participate(Long eventId, Long userId) {
    RLock lock = redissonClient.getLock("event:" + eventId);
    lock.lock();
    try {
        // DB 로직...
    } finally {
        lock.unlock(); // 여기서 락 해제 → 트랜잭션은 아직 커밋 안 됨
    }
    // 메서드 종료 시 @Transactional이 커밋 → 이미 락 해제 후라 다른 요청이 진입 가능
}

결과: 101명 참여 → 정합성 깨짐

 

After (분산락 Fix)

해결: @Transactional 제거 후 락 안에서 별도 서비스 메서드 호출

 
 
java
// UserEventService - @Transactional 제거
public void participate(Long eventId, Long userId) {
    RLock lock = redissonClient.getLock("event:" + eventId);
    // leaseTime을 3초에서 10초로 증가
    boolean acquired = lock.tryLock(5, 10, TimeUnit.SECONDS);
    if (!acquired) throw new CustomException(ErrorCode.TOO_MANY_REQUESTS);
    try {
        eventParticipateService.execute(eventId, userId); // 별도 @Transactional 서비스
    } finally {
        lock.unlock(); // 트랜잭션 커밋 완료 후 락 해제
    }
}

// EventParticipateService - @Transactional 적용
@Transactional
public void execute(Long eventId, Long userId) {
    // DB 로직 처리 후 커밋 완료
}

 

부하테스트 결과 (After)

 
 
총 요청:    200명
참여 성공:  100명 (✅ maxParticipants와 정확히 일치)
참여 실패:  100명 (maxParticipants 초과)
정합성:     100% PASS

Grafana 관측 수치 (k6 실행 중)

지표수치
RPS (초당 요청 수) 약 40 req/s
CPU 사용률 74.3%
JVM 스레드 수 295개
GC Pause 시간 1.72ms

실행전

 

실행후


3. 수익 환전 동시성 부하테스트 (Redis 분산락)

시나리오

잔액 100만원인 작가가 동시에 10번 전액 환전 요청. 정확히 1건만 성공해야 한다.

k6 시나리오 구성

 
javascript
spike_withdrawal: {
    executor: 'shared-iterations',
    vus: 10,        // 동시 가상 유저 10명 (같은 유저가 10번)
    iterations: 10,
    maxDuration: '30s',
}

 

방어 메커니즘

1차 방어: Redis SETNX 분산락

 
java
String lockKey = "lock:withdrawal:" + authorId;
String lockValue = UUID.randomUUID().toString();
Boolean acquired = redisTemplate.opsForValue()
    .setIfAbsent(lockKey, lockValue, 5, TimeUnit.SECONDS);

단순 get 후 delete는 비원자적이라 다른 요청의 락을 실수로 해제할 수 있다. Lua Script로 비교와 삭제를 하나의 원자적 연산으로 처리했다.

 
 
lua
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

2차 방어: PENDING 상태 중복 체크

 

결과

총 요청:    10건
환전 성공:  1건 (✅)
환전 실패:  9건 (잔액 부족)
잔액:       0원
정합성:     100% PASS


4. 멘토링 멘티 수락 동시성 테스트 (낙관적 락)

시나리오

멘토 1명(maxMentees=3)에게 PENDING 상태의 멘토십 10개에 대해 동시에 수락 요청.

k6 설정

 
javascript
spike_accept: {
    executor: 'shared-iterations',
    vus: 10,
    iterations: 10,
    maxDuration: '30s',
}

 

동작 원리 (낙관적 락)

 
java
@Entity
public class Mentor {
    @Version
    private Integer version;
}

10건이 동시에 같은 Mentor 엔티티의 version(0)을 읽고, 첫 번째 커밋만 성공하면서 version이 1로 증가한다. 나머지 9건은 version 불일치로 OptimisticLockingFailureException이 발생해 실패한다.

 

결과

ACCEPTED: 1건 (✅)
PENDING:  9건
maxMentees: 2 (1 차감됨)
version: 1 (낙관적 락 버전 증가 확인)

재시도 로직이 없어 1건만 성공했지만 슬롯 초과 없이 정합성이 100% 유지됐다.

 

포트폴리오 스토리

동시 10건 수락 요청에서 낙관적 락(@Version)이 동시성 충돌을 감지하여 1건만 성공. maxMentees 초과 없이 정합성 100% 유지.


5. 멘토링 피드백 V1/V2 동시성 비교 (비관적 락)

시나리오

멘토가 ACCEPTED 상태의 멘토십에 동시에 5건 피드백 작성. sessionNumber(회차 번호) 중복 없이 순서대로 저장되는지 확인.

k6 설정

javascript
spike_feedback: {
    executor: 'shared-iterations',
    vus: 5,
    iterations: 5,
    maxDuration: '30s',
}

 

V1 결과 (동시성 보호 없음)

총 요청: 5건
피드백 성공: 1건
피드백 실패: 4건 (유니크 제약 위반으로 400 에러)
오류 메시지: "데이터 저장에 실패하였습니다" (모호한 에러)

문제: 5건이 모두 같은 sessionNumber(1)로 저장 시도. DB 유니크 제약에 의해 첫 번째만 통과하고 나머지는 모호한 에러가 반환된다. 사용자 입장에서 왜 실패했는지 알 수 없다.

 

V2 결과 (비관적 락 적용)

총 요청: 5건
피드백 성공: 5건 (✅)
sessionNumber: 1, 2, 3, 4, 5 (중복 없이 순서대로)
totalSessions: 5

해결: findByIdWithLock()으로 멘토링 row에 SELECT FOR UPDATE(비관적 락)를 걸어 요청을 직렬화. 각 요청이 이전 요청의 커밋 후 totalSessions를 읽어 다음 sessionNumber를 계산한다.

Before/After 비교

항목V1V2
성공 건수 1 5
실패 이유 유니크 제약 위반 → 모호한 에러 -
sessionNumber 처리 중복 시도 → DB가 막음 비관적 락으로 직렬화
사용자 경험 4건 "데이터 저장 실패" 모두 정상 처리

Before 결과

After 결과


6. revenues 인덱스 성능 비교

목적

revenues 테이블에 10만 건 더미 데이터를 삽입하고, 인덱스 추가 전후 쿼리 성능을 EXPLAIN ANALYZE로 비교한다.

더미 데이터 생성

loadtest 유저 50명 × 각 2000건 = 총 10만 건 삽입

 

추가한 인덱스

sql
CREATE INDEX idx_revenue_author_type ON revenues (author_id, type);
CREATE INDEX idx_revenue_author_type_date ON revenues (author_id, type, created_at);

 

결과 비교

쿼리 1: 수익 현황 집계

항목BeforeAfter
실행 시간 24.7ms 1.15ms
스캔 방식 Full Table Scan (100,015건) Index Lookup
개선율 - 약 21배

 

쿼리 2: 월별 수익 통계

항목BeforeAfter
실행 시간 16.2ms 1.29ms
스캔 방식 Full Table Scan Index Range Scan
개선율 - 약 12배

 

쿼리 3: 수익 TOP 10 집계 (트레이드오프)

항목BeforeAfter
실행 시간 32.3ms 54ms
결과 - 오히려 느려짐

전체 테이블을 type 기준으로 집계하는 쿼리는 어차피 모든 행을 읽어야 하기 때문에 인덱스가 도움이 안 된다. 오히려 인덱스 스캔 오버헤드가 추가돼 더 느려졌다. 인덱스는 선택도(selectivity)가 높은 컬럼에 효과적이다.

 

적용전

 

적용후


7. 캐시 성능 비교

7-1. 이벤트 목록 조회 캐시

API: GET /api/events?status=ONGOING&page=0&size=10
캐시: RedisTemplate, TTL 5분

Cache Miss (DB 조회): 33.5ms
Cache Hit  (Redis):   6.6ms
개선율: 약 5배 (80.3%)
checks: 10/10 성공

 

7-2. 수익 통계 조회 캐시

API: GET /api/revenues/me/statistics?period=MONTHLY&year=2026
캐시: RedisTemplate, TTL 1시간
데이터: revenues 10만 건 + GROUP BY 집계 쿼리

Cache Miss (DB 집계): 181.5ms
Cache Hit  (Redis):    6.1ms
개선율: 약 7.4배 (86.5%)
checks: 10/10 성공

GROUP BY + 집계 쿼리는 데이터가 많을수록 DB 조회가 느리기 때문에 캐시 효과가 가장 극적으로 나왔다.

7-3. 도서 검색 캐시 (국립도서관 외부 API)

API: GET /api/v1/national-library/books/search?query=스프링&page=1&size=10
캐시: RedisTemplate, TTL 10분
특징: 국립도서관 외부 API 호출

Cache Miss (외부 API): 231.9ms
Cache Hit  (Redis):     43.8ms
개선율: 약 2배 (53.1%)
checks: 10/10 성공

Cache Hit이 43ms로 다른 API에 비해 느린 이유는 응답 크기가 27kB로 크기 때문이다.

캐시 성능 비교 종합

APICache MissCache Hit개선율
이벤트 목록 조회 33.5ms 6.6ms 약 5배
수익 통계 조회 181.5ms 6.1ms 약 7.4배
도서 검색 231.9ms 43.8ms 약 2배


8. Redis Record 클래스 역직렬화 버그 수정

문제 발견

수익 현황 조회 캐시 테스트 중 두 번째 요청부터 500 에러 발생.

SerializationException: Could not read JSON:
Unexpected token (START_OBJECT), expected START_ARRAY:
need Array value to contain `As.WRAPPER_ARRAY` type information

 

원인 분석

GenericJackson2JsonRedisSerializer가 Redis에 저장할 때 타입 정보 없이 순수 JSON으로 저장한다. 꺼낼 때 Jackson이 타입을 알 수 없어 LinkedHashMap으로 역직렬화한다. 이를 RevenueOverviewResponse로 강제 캐스팅하면 ClassCastException이 발생한다.

activateDefaultTyping(NON_FINAL) 옵션으로 해결하려 했지만 Java Record 클래스는 final로 컴파일되기 때문에 해당 옵션이 적용되지 않는다.

 

해결 방법

ObjectMapper로 직접 직렬화/역직렬화한다.

java
// 저장 시 (JSON 문자열로 직렬화)
String json = objectMapper.writeValueAsString(response);
redisTemplate.opsForValue().set(cacheKey, json, CACHE_TTL);

// 조회 시 (JSON 문자열로 역직렬화)
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached instanceof String jsonStr) {
    return objectMapper.readValue(jsonStr, RevenueOverviewResponse.class);
}

RevenueService와 StatisticsService 두 곳에 적용했다. RedisConfig는 팀원 영향 없이 원래대로 복구했다.

 

핵심 교훈

Redis에 Java Record 클래스를 캐싱할 때는 GenericJackson2JsonRedisSerializer의 자동 역직렬화가 동작하지 않는다. ObjectMapper로 명시적으로 직렬화/역직렬화해야 한다.


9. k6 VUs(Virtual Users) 설정 개념 정리

shared-iterations: 총 N개의 iteration을 M개의 VU가 나눠서 처리. 동시 참여처럼 정확히 N번의 요청을 보내야 할 때 사용.

constant-vus: M명의 VU가 duration 동안 계속 반복 요청. 지속적인 부하를 주고 싶을 때 사용. Grafana에 의미 있는 트래픽 패턴을 만들기 위해 사용.

이벤트 참여 부하테스트에서 두 시나리오를 동시에 실행한 이유:

  • spike_participate(VUs 200, iterations 200): 정합성 검증 목적
  • sustained_read(VUs 50, duration 30s): Grafana 수치 확보 목적

두 시나리오가 동시에 실행되어 Grafana에서 RPS 40 req/s, CPU 74.3%, 스레드 295개, GC 1.72ms를 관찰했다.


10. 트러블슈팅 모음

트러블슈팅 1: MySQL @변수 세션 문제
SQL 파일 실행 시 세션 변수가 초기화되지 않아 데이터 미삽입. Subquery 방식으로 변경해 해결.

 

트러블슈팅 2: k6 한글 파라미터 인코딩
query=스프링 직접 입력 시 인코딩 문제로 checks 0% 발생. URL 인코딩 값 %EC%8A%A4%ED%94%84%EB%A7%81 사용으로 해결.

 

트러블슈팅 3: Redis Record 역직렬화
섹션 8 참고.

 

트러블슈팅 4: NovelStatus enum 값 불일치
더미 SQL에서 'SERIALIZING' 사용 → 실제 enum은 'ONGOING'. SELECT DISTINCT status FROM novels;로 확인 후 수정.

 

트러블슈팅 5: Grafana 변수 Target data source 미설정
4701 대시보드 임포트 후 Application 드롭다운이 비어 있었음. Variables 편집에서 Target data source를 Prometheus로 수동 선택해야 한다.


11. 최종 포트폴리오 증거 자료 정리


모니터링 구축 Prometheus + Grafana 대시보드 캡처
이벤트 참여 동시성 (분산락) k6 결과 + DB 100건 정확 + Grafana 수치 (RPS 40, CPU 74.3%)
수익 환전 동시성 (Redis 분산락) k6 결과 + DB 1건 정확
멘토링 수락 동시성 (낙관적 락) k6 결과 + DB ACCEPTED 1건 + version=1 증가
피드백 V1/V2 비교 (비관적 락) k6 결과 + DB sessionNumber 1~5
revenues 인덱스 성능 EXPLAIN ANALYZE Before/After (21배, 12배, 트레이드오프)
이벤트 목록 캐시 k6 결과 (33ms → 6.6ms, 5배)
수익 통계 캐시 k6 결과 (181ms → 6.1ms, 7.4배)
도서 검색 캐시 k6 결과 (231ms → 43ms, 2배)

12. Kafka 도입 스토리 정리

도입 배경

기존에는 이벤트 참여, 멘토링 수락 등 여러 도메인에서 알림 발송을 동기로 처리했다.

문제점:

  1. 강결합: 알림 발송 실패 시 핵심 비즈니스 로직도 함께 실패
  2. 성능 저하: 알림 처리 시간이 API 응답 시간에 포함됨
  3. OOM 위험: 이벤트 생성 시 전체 READER 유저를 한 번에 조회하면 수만 명일 경우 OOM 발생 위험

 

Kafka 도입으로 해결한 것

기존: 이벤트 참여 → [알림 발송] → 응답 반환  (동기, 실패 시 롤백)

도입 후: 이벤트 참여 → 응답 반환 (빠름)
                  ↓ ApplicationEventPublisher
              @TransactionalEventListener(AFTER_COMMIT)
                  ↓ 트랜잭션 커밋 후 Kafka 발행
              알림 Consumer (비동기)

대량 알림 발송 시에는 전체 READER 유저를 1000건씩 페이지 단위로 배치 조회하여 순차적으로 이벤트를 발행한다.

 

왜 @Async가 아닌 Kafka인가?

항목@AsyncKafka
메시지 유실 서버 재시작 시 유실 디스크 영속화로 보존
재처리 별도 구현 필요 Consumer 재시작으로 재처리
확장성 JVM 내부 스레드 풀 한계 Consumer Group으로 수평 확장
모니터링 어려움 토픽/파티션/오프셋으로 추적 가능

알림 유실 없이 안정적으로 처리해야 하고, 향후 여러 Consumer(이메일, 앱 푸시, SMS 등)로 확장 가능한 구조가 필요했기 때문에 Kafka를 선택했다.

 

안정성과 확장성을 위한 고도화 고민

Kafka 도입으로 비동기 처리와 도메인 간 결합도 해제라는 큰 성과를 얻었지만, 시스템의 신뢰도를 100%로 끌어올리기 위해 다음의 고도화 전략들을 고민하고 있다.

1. 데이터 유실 제로를 위한 'Transactional Outbox Pattern'

현재는 AFTER_COMMIT 시점에 이벤트를 발행하여 정합성을 맞추고 있다. 하지만 아주 희박한 확률로 DB 커밋은 성공했으나 Kafka 발행 직전 네트워크 장애가 발생할 경우 메시지가 유실될 수 있다. 이를 완벽히 방지하기 위해 비즈니스 데이터와 메시지를 하나의 트랜잭션으로 DB(Outbox Table)에 저장하고, 별도 프로세스가 발행을 보장하는 패턴을 검토 중이다.

2. 예외 상황의 격리와 재처리를 위한 'DLT(Dead Letter Topic)'

비동기 처리 중 Consumer에서 예상치 못한 에러가 발생했을 때, 무한 재시도로 인해 전체 시스템이 지연되는 것을 막아야 한다. 실패한 메시지는 별도의 Dead Letter Topic으로 격리하여 장애 원인을 분석하고, 정상화 이후 해당 메시지만 선별적으로 재처리할 수 있는 운영 프로세스를 구상하고 있다.

3. 대규모 트래픽 대비 'Cursor 기반 페이징 & 병렬 처리'

수만 명의 유저에게 알림을 보낼 때, 현재의 페이지 단위 조회를 더욱 최적화하기 위해 ID 기반 Cursor 페이징을 적용하여 조회 성능을 유지하고자 한다. 또한, 알림량이 늘어날 경우 Kafka의 Partition을 확장하고 Consumer Group의 인스턴스를 수평 확장(Scale-out)하여 처리량을 극대화하는 설계를 염두에 두고 있다.

마치며 모든 프로젝트에 이런 고도로 복잡한 패턴이 정답은 아닐 것이다. 하지만 서비스가 성장함에 따라 마주칠 병목 구간을 미리 예측하고, 그에 맞는 아키텍처를 제시할 수 있는 능력이 진정한 기술적 깊이라고 믿는다. 단순히 기술을 '사용'하는 것을 넘어, 시스템의 전체적인 흐름과 운영의 안정성을 책임지는 개발자로 성장해 나가고자 한다.