<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>jd.log</title>
        <link>https://velog.io/</link>
        <description>논리적으로 사고하고 해결하는 것을 좋아하는 개발자입니다.</description>
        <lastBuildDate>Mon, 09 Mar 2026 06:58:39 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <copyright>Copyright (C) 2019. jd.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/alstn_dev" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Flutter + Spring Boot로 앱 출시까지: 1인 개발 전과정 회고 (소셜로그인, JWT 등)]]></title>
            <link>https://velog.io/@alstn_dev/2025%EB%85%84-%EC%95%B1-%EA%B0%9C%EB%B0%9C-%EB%B0%8F-%EC%B6%9C%EC%8B%9C-%ED%9A%8C%EA%B3%A0-1%EC%9D%B8-%EA%B0%9C%EB%B0%9C%EB%B6%80%ED%84%B0-%EC%B6%9C%EC%8B%9C%EA%B9%8C%EC%A7%80-%EB%8F%84%EC%A0%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@alstn_dev/2025%EB%85%84-%EC%95%B1-%EA%B0%9C%EB%B0%9C-%EB%B0%8F-%EC%B6%9C%EC%8B%9C-%ED%9A%8C%EA%B3%A0-1%EC%9D%B8-%EA%B0%9C%EB%B0%9C%EB%B6%80%ED%84%B0-%EC%B6%9C%EC%8B%9C%EA%B9%8C%EC%A7%80-%EB%8F%84%EC%A0%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 09 Mar 2026 06:58:39 GMT</pubDate>
            <description><![CDATA[<p>2025년 한 해 동안 앱을 직접 기획하고 실제 스토어에 출시하는 도전을 해봤습니다. 현재 웹 프런트엔드와 백엔드 개발자로 일하고 있지만, 현업을 하다 보면 제가 속한 부서가 아닌 타 부서의 업무 흐름과 감수성을 이해해야 할 때가 많았습니다.</p>
<p>무엇보다 제로 베이스에서 기획부터 설계, 앱 개발(iOS, AOS), 백엔드 개발, QA, 서버 구성, 배포까지 전반적인 과정을 직접 경험해보는 것이 커리어에 큰 도움이 될 것이라 믿고 진행하게 되었습니다. (개발 과정에서 큰 도움을 준 Claude Code와 GPT에게 깊은 감사를 전합니다.)</p>
<h1 id="주제">주제</h1>
<p>주제는 <strong>풋살 관련 앱</strong>을 선정했습니다. 기존에도 풋살 매칭 앱은 많았지만, 대부분 모르는 사람들과의 매칭을 위한 시스템이었을 뿐, 제가 소속된 소모임이나 동아리를 직접 관리할 수 있는 앱은 없었기 때문입니다.</p>
<p>현재 제가 속한 풋살 소모임의 일정 관리 방식은 다음과 같았습니다.</p>
<ul>
<li>누군가 풋살장을 대여한 후 단톡방에 &quot;1/20일에 00축구장 18:00~20:00 5:5&quot;라고 공지합니다.</li>
<li>소모임 멤버들이 해당 채팅을 보고 투표로 참여/불참을 표시합니다.</li>
</ul>
<h3 id="🚩-문제점">🚩 문제점</h3>
<ul>
<li><strong>채팅 누락</strong>: 일정 관련 채팅이 너무 많이 생성되거나 취소 공지 등이 쌓일 경우, 채팅이 위로 밀려나 본인이 참여한 일정이 정상적으로 진행되는지 여부를 확인하기 어렵습니다.</li>
<li><strong>가시성 부족</strong>: 캘린더 형태로 확인이 불가능해 어떤 일정이 있는지 한눈에 파악하기 어렵습니다.</li>
<li><strong>팀 구성의 번거로움</strong>: 매번 현장에서 팀을 구성할 때 가위바위보를 하거나, 누군가 임의로 실력을 눈대중으로 맞춰 대강 배정하곤 했습니다.</li>
</ul>
<p>위와 같은 문제점을 개선하고 제 커리어를 쌓기 위해 직접 앱을 만들기로 결심했습니다.</p>
<h1 id="🛠️-설계">🛠️ 설계</h1>
<p>처음 설계 단계에서 가장 막혔던 부분은 <strong>회원가입</strong>이었습니다. 회원가입 시 본인인증 과정을 거쳐야 하는데, OTP 발송 비용을 최소화하고 싶었습니다. 초기에는 이메일 인증 방식을 고민했으나, 아이디/비밀번호 찾기 기능을 제공해야 하고 이 과정에서도 추가 비용과 공수가 들 것이라 생각했습니다.</p>
<p>무엇보다 사용자 입장에서 이메일 회원가입은 번거로운 절차이며 이탈률이 높을 것이라 예상되어, 편의성을 고려해 <strong>소셜 로그인</strong>으로 방향을 돌렸습니다.</p>
<h2 id="소셜-로그인">소셜 로그인</h2>
<p>가장 먼저 떠오른 것은 <strong>카카오 로그인</strong>이었습니다. 예전부터 구현해보고 싶었던 기능이었기에 이번 기회에 도전했습니다. iOS와 AOS 모두에서 비교적 쉽게 구현이 가능해 즐겁게 작업했습니다.</p>
<p>다만, 애플의 정책상 소셜 로그인 기능이 있다면 <strong>애플 로그인</strong>을 필수로 포함해야 한다는 사실을 알게 되었습니다. 처음에는 즈레 겁을 먹기도 했지만, 막상 부딪쳐보니 큰 어려움 없이 구현할 수 있었습니다.</p>
<p>소셜 로그인 구현 과정에서의 트러블슈팅은 아래 포스트에 상세히 정리해두었습니다.</p>
<ul>
<li><a href="https://velog.io/@alstn_dev/Apple-%EB%A1%9C%EA%B7%B8%EC%9D%B8-with-spring-bootfeat.-Sign-in-with-Apple">Apple 로그인 with Spring Boot (Sign in with Apple)</a></li>
<li><a href="https://velog.io/@alstn_dev/JWT-%EC%9D%B8%EC%A6%9D-%EA%B5%AC%ED%98%84-%EA%B0%9C%EC%84%A0%EA%B8%B0-Access-Token%EB%A7%8C-%EC%93%B0%EB%8D%98-%EC%95%B1%EC%97%90-Refresh-Token%EC%9D%84-%EB%B6%99%EC%9D%B4%EA%B8%B0%EA%B9%8C%EC%A7%80">JWT 인증 구현 개선기: Access Token만 쓰던 앱에 Refresh Token 붙이기</a></li>
<li><a href="https://www.google.com/search?q=https://velog.io/%40alstn_dev/Flutter-%25EC%2595%25B1-%25EC%259%EC%9E%90%EB%8F%99-%25EB%25A1%259C%25EA%25B7%25B8%25EC%259%EC%95%84%EC%9B%83-%25EB%25AC%25B8%25EC%25A0%259C-%25ED%2595%25B4%25EA%25B2%25B0%25EA%25B8%25B0-Refresh-Token-Race-Condition-%25EB%2594%2594%25EB%25B2%2584%25EA%25B9%2585">Flutter 앱 자동 로그아웃 문제 해결기 (Refresh Token Race Condition)</a></li>
</ul>
<h1 id="🖥️-백엔드">🖥️ 백엔드</h1>
<p>백엔드는 <strong>Spring Boot 3.x, Java, JPA, QueryDSL</strong>을 사용하여 구현했습니다.</p>
<h2 id="기술-스택-선택">기술 스택 선택</h2>
<p>Kotlin과 Spring 조합도 고민했지만, 현재 현업에서 Java를 사용하고 있어 숙련도가 높은 <strong>Java</strong>로 결정했습니다. <strong>QueryDSL</strong>은 캘린더 조회나 멤버 검색 등 동적 쿼리가 필요한 부분이 많을 것이라 예상하여 선택했는데, 실제로 매우 유용하게 활용했습니다. DB는 가벼우면서도 AWS 연동이 용이한 <strong>PostgreSQL</strong>을 선택했습니다.</p>
<h2 id="프로젝트-구조">프로젝트 구조</h2>
<p>패키지는 도메인별로 관리하는 구조를 택했습니다. 계층형(Layered) 구조보다는 도메인별로 <code>controller</code>, <code>service</code>, <code>repository</code>, <code>model</code>, <code>dto</code>를 모아두는 방식이 관련 코드를 탐색하기에 더 효율적이었습니다.</p>
<pre><code>src/main/java/com/myapp/squad/
├── common/          # 공통 (인증, 예외, 상수)
├── config/          # 설정
├── user/            # 사용자 도메인
├── group/           # 모임 도메인
└── event/           # 이벤트 도메인
</code></pre><h2 id="주요-도메인-설계">주요 도메인 설계</h2>
<p>크게 3가지 도메인으로 나눴습니다.</p>
<ul>
<li><strong>user</strong>: 사용자 인증/인가, 프로필 관리</li>
<li><strong>group</strong>: 모임 생성/관리</li>
<li><strong>event</strong>: 풋살 일정 생성, 참가 신청, 팀 배정</li>
</ul>
<h3 id="권한-체계">권한 체계</h3>
<p>모임 내 권한을 <code>LEADER &gt; ADMIN &gt; MEMBER</code>로 나누었습니다. 처음에는 단순하게 그룹 생성자인 <code>LEADER</code>만 관리할 수 있게 하려 했으나, 실제 동아리 운영 환경을 고려하여 여러 명이 운영에 참여할 수 있도록 <code>ADMIN</code> 역할을 추가했습니다.</p>
<ul>
<li><strong>LEADER</strong>: 모임 삭제, 리더 양도, 멤버 권한 변경, 멤버 티어 관리</li>
<li><strong>ADMIN</strong>: 멤버 승인/거절/제명, 이벤트 생성, 멤버 티어 관리</li>
<li><strong>MEMBER</strong>: 이벤트 생성, 참가, 본인 정보 수정</li>
</ul>
<p>리더 양도 기능 구현 시, 리더가 탈퇴하려고 할 때 다른 활동(<code>ACTIVE</code>) 멤버가 있다면 탈퇴를 막고 리더를 먼저 양도하도록 안내했습니다. 혼자 남은 리더만 탈퇴가 가능하도록 처리했습니다.</p>
<h3 id="멤버십-상태-관리">멤버십 상태 관리</h3>
<p>사용자의 상태를 여러 단계로 세분화했습니다.</p>
<ul>
<li><code>PENDING</code>: 가입 신청 후 대기</li>
<li><code>ACTIVE</code>: 승인된 활성 멤버</li>
<li><code>REJECT</code>: 가입 거절됨</li>
<li><code>LEFT</code>: 자발적 탈퇴</li>
<li><code>BANNED</code>: 제명됨 (재가입 불가)</li>
</ul>
<p>제명된 멤버는 재가입이 안 되게 막았습니다. 단, 운영자가 제명 해제를 하면 다시 신청할 수 있습니다. 악성 유저가 계속해서 재가입하는 것을 방지하기 위한 조치였습니다.</p>
<h2 id="인증-구현">인증 구현</h2>
<p>소셜 로그인을 구현하면서 JWT 인증을 직접 구축했습니다. Access Token은 1시간, Refresh Token은 30일로 설정했습니다.</p>
<h3 id="jwt-구조">JWT 구조</h3>
<ul>
<li><strong>Access Token</strong>: <code>userId</code>, <code>identifier</code>, <code>displayName</code>을 클레임에 담음</li>
<li><strong>Refresh Token</strong>: DB에 저장하고 만료 시간을 관리</li>
<li><strong>서명</strong>: SHA256 사용</li>
</ul>
<h3 id="인터셉터-기반-인증">인터셉터 기반 인증</h3>
<p>모든 <code>/api/**</code> 요청은 <code>JwtAuthInterceptor</code>를 거치도록 했습니다. Authorization 헤더에서 Bearer 토큰을 검증하며, 토큰이 만료되면 <code>TOKEN_EXPIRED</code> 에러를 내려주어 앱에서 Refresh Token으로 갱신하도록 유도했습니다. 인증 제외 경로(<code>auth/**</code>, <code>/health</code>, <code>/public/**</code>)도 별도로 설정했습니다.</p>
<h3 id="currentuserid-커스텀-어노테이션">@CurrentUserId 커스텀 어노테이션</h3>
<p>컨트롤러에서 현재 로그인한 사용자 ID를 쉽게 가져오기 위해 커스텀 어노테이션을 만들었습니다. <code>ArgumentResolver</code>로 구현했는데, 인터셉터에서 검증한 <code>userId</code>를 Request Attribute에 담아두고 리졸버에서 꺼내 주입하는 방식입니다.</p>
<pre><code>@GetMapping(&quot;/my&quot;)
public ApiResponse&lt;List&lt;PartyGroupRes&gt;&gt; getMyGroupList(@CurrentUserId Long userId) {
    // userId를 바로 사용 가능
}
</code></pre><h3 id="race-condition-문제">Race Condition 문제</h3>
<p>Access Token 만료 시 Refresh Token으로 갱신하는 로직을 구현하는 과정에서 Race Condition 문제를 겪었습니다. 동시에 여러 요청이 들어올 때 Refresh Token이 중복 갱신되는 이슈였는데, A 요청이 Refresh하는 동안 B 요청도 동일한 토큰으로 갱신을 시도하면 실패하게 됩니다. 이를 해결하기 위해 Refresh Token에 고유 식별자(jti)를 추가했습니다.</p>
<h2 id="이벤트일정-관리">이벤트(일정) 관리</h2>
<h3 id="참가-상태-관리">참가 상태 관리</h3>
<p>이벤트 참가 여부도 상태로 관리했습니다.</p>
<ul>
<li><code>APPLIED</code>: 참가 확정</li>
<li><code>WAITLISTED</code>: 대기 (정원 초과 시)</li>
<li><code>DECLINED</code>: 불참</li>
<li><code>CANCELED</code>: 참가 취소</li>
</ul>
<p>정원이 가득 차면 자동으로 <code>WAITLISTED</code> 상태가 되도록 했으며, 추후 알림 기능을 추가해 자리가 났을 때 대기자에게 알림을 보낼 예정입니다.</p>
<h3 id="스냅샷-패턴">스냅샷 패턴</h3>
<p>참가자 정보를 저장할 때 <strong>스냅샷 방식</strong>을 사용했습니다. 참가 신청 시점의 <code>displayName</code>, <code>profileImage</code>, <code>tier</code>를 <code>EventParticipant</code> 테이블에 복사해둡니다. 사용자가 이후에 정보를 변경하더라도 과거의 이벤트 기록은 당시의 정보로 유지되어야 하기 때문입니다. 또한 서비스 가입자가 아닌 &#39;용병&#39; 정보를 저장하는 데도 이 스냅샷 필드가 유용했습니다.</p>
<pre><code>EventParticipantEntity ep = EventParticipantEntity.builder()
        .event(event)
        .appUser(user)
        .displayName(user.getDisplayName())    // 스냅샷
        .profileImage(user.getProfileImage())  // 스냅샷
        .tier(groupMember.getTier())           // 스냅샷
        .build();
</code></pre><h3 id="동시성-제어">동시성 제어</h3>
<p>정원이 1자리 남았을 때 동시에 여러 명이 신청하는 경우를 방지하기 위해, 이벤트 조회 시 <strong>비관적 락(Pessimistic Lock)</strong>을 사용했습니다.</p>
<pre><code>@Query(&quot;SELECT e FROM EventEntity e WHERE e.id = :eventId&quot;)
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional&lt;EventEntity&gt; findByIdWithLock(@Param(&quot;eventId&quot;) Long eventId);
</code></pre><h2 id="팀-밸런싱-알고리즘">팀 밸런싱 알고리즘</h2>
<p>가장 재미있게 구현했던 기능입니다. 티어 기반의 자동 배정 시스템을 만들었습니다.</p>
<h3 id="티어-시스템">티어 시스템</h3>
<p>멤버마다 티어를 부여하고 가중치를 설정했습니다.</p>
<ul>
<li>PRO(1000점), SEMI_PRO(600점), AMATEUR(350점), JUNIOR(200점), BEGINNER(100점), ROOKIE(50점)
가중치는 주관적인 판단으로 정했으며, 실제 운영하며 조정해나갈 계획입니다.</li>
</ul>
<h3 id="snake-draft-알고리즘">Snake Draft 알고리즘</h3>
<p>팀 배정은 <strong>Snake Draft 방식</strong>을 사용했습니다. 높은 티어부터 1팀 → 2팀 → ... → n팀 → n팀 → ... → 1팀 순으로 배정하여 각 팀의 전력이 비슷해지도록 했습니다.</p>
<p>예를 들어 3팀에 6명을 배정한다면:</p>
<ol>
<li>PRO(1000) → 1팀</li>
<li>SEMI_PRO(600) → 2팀</li>
<li>AMATEUR(350) → 3팀</li>
<li>AMATEUR(350) → 3팀 (방향 전환)</li>
<li>JUNIOR(200) → 2팀</li>
<li>BEGINNER(100) → 1팀
결과적으로 팀 간 균형이 어느 정도 맞게 됩니다.</li>
</ol>
<pre><code>int teamIndex = 0;
boolean forward = true;

for (ParticipantWithTier participant : participants) {
    teams.get(teamIndex).addMember(participant);

    if (forward) {
        teamIndex++;
        if (teamIndex &gt;= numberOfTeams) {
            teamIndex = numberOfTeams - 1;
            forward = false;
        }
    } else {
        teamIndex--;
        if (teamIndex &lt; 0) {
            teamIndex = 0;
            forward = true;
        }
    }
}
</code></pre><h3 id="팀-배정-저장">팀 배정 저장</h3>
<p>밸런싱 결과는 미리보기로 보여주고, 운영자가 확인 후 저장하면 <code>team_number</code> 필드에 반영됩니다. 조회와 저장 API를 분리하여 운영자가 결과를 수정할 여지를 주었습니다.</p>
<h2 id="용병-시스템">용병 시스템</h2>
<p>모임 인원이 부족할 때 지인을 데려오는 경우를 고려해 용병 시스템을 추가했습니다. 용병은 <code>app_user_id</code>가 NULL이며, 이름과 티어만 입력받습니다. 동일 이벤트 내 닉네임 중복 체크도 적용했습니다.</p>
<pre><code>if (eventParticipantRepository.existsByEventIdAndDisplayNameAndParticipantType(
        eventId, req.guestNickname(), ParticipantType.GUEST)) {
    throw new BizException(BizExceptionCode.GUEST_NICKNAME_DUPLICATE);
}
</code></pre><p>용병 삭제는 운영자가 가능하지만, 회원은 본인이 직접 참가 취소를 해야 하도록 구분했습니다.</p>
<h2 id="예외-처리">예외 처리</h2>
<p><code>BizExceptionCode</code> enum을 통해 약 67개의 예외 코드를 체계적으로 정의했습니다. <code>GlobalExceptionHandler</code>에서 이를 잡아 일관된 응답 포맷으로 내려주며, 프런트엔드에서는 구체적인 코드를 보고 사용자에게 정확한 안내를 제공할 수 있습니다.</p>
<h2 id="배포">배포</h2>
<p>Docker로 컨테이너화하고 Docker Compose로 환경을 분리했습니다.</p>
<ul>
<li><strong>멀티 스테이지 빌드</strong>: 빌드용 이미지와 런타임 이미지를 분리하여 최종 이미지 크기를 최적화했습니다.</li>
<li><strong>환경 분리</strong>: 민감 정보는 <code>.env</code> 파일과 환경 변수를 통해 주입하여 코드에 직접 노출되지 않도록 신경 썼습니다.</li>
</ul>
<h2 id="어려웠던-점--배운-점">어려웠던 점 &amp; 배운 점</h2>
<ul>
<li><strong>QueryDSL 설정</strong>: AnnotationProcessor 설정과 Q클래스 생성 경로를 잡는 데 시간이 꽤 걸렸습니다.</li>
<li><strong>JPA N+1 문제</strong>: 특히 목록 조회 시 발생하는 문제를 fetch join으로 해결하며 성능 최적화를 경험했습니다.</li>
<li><strong>권한 체크</strong>: 복잡한 비즈니스 로직을 <code>ensureModerator</code> 같은 공통 메서드로 추상화하여 중복을 줄였습니다.</li>
<li><strong>데이터 정점</strong>: 탈퇴 시 관련 기록 정리 등 비즈니스 로직으로 처리해야 할 부분들을 꼼꼼히 챙겼습니다.</li>
<li><strong>설계의 중요성</strong>: 초반에 enum과 도메인 구조를 잘 정의해두는 것이 유지보수에 얼마나 큰 영향을 주는지 실감했습니다.</li>
</ul>
<h1 id="📱-프런트엔드-app">📱 프런트엔드 (App)</h1>
<p>프런트엔드는 <strong>Flutter</strong>를 선택했습니다. 웹 개발 경험이 있어 React Native와 고민했으나, Flutter의 UI 일관성과 성능, 그리고 Dart 언어가 Java와 유사하다는 점이 백엔드 개발자인 저에게 매력적이었습니다. <strong>Hot Reload</strong> 기능 덕분에 작업 생산성이 매우 높았습니다.</p>
<h2 id="프로젝트-구조-1">프로젝트 구조</h2>
<p>Clean Architecture를 참고하여 계층을 분리했습니다.</p>
<pre><code>lib/
├── core/           # 공통 (상수, 유틸, 테마)
├── data/           # 데이터 계층 (models, repositories, services)
└── presentation/   # UI 계층 (providers, screens, widgets)
</code></pre><h2 id="상태-관리-riverpod">상태 관리 (Riverpod)</h2>
<p><strong>Riverpod</strong>의 <code>StateNotifier</code> 패턴을 사용해 상태를 관리했습니다. 불변 상태 객체를 만들고 <code>copyWith</code>로 업데이트하는 방식은 코드를 매우 깔끔하게 만들어주었습니다.</p>
<pre><code>class AuthNotifier extends StateNotifier&lt;AuthState&gt; {
  Future&lt;void&gt; loginWithKakao() async {
    state = state.copyWith(isLoading: true);
    try {
      final result = await _repository.loginWithKakao(...);
      state = state.copyWith(isLoading: false, appUser: result.user);
    } catch (e) {
      state = state.copyWith(isLoading: false, error: e.toString());
    }
  }
}
</code></pre><h2 id="주요-화면-구성">주요 화면 구성</h2>
<ul>
<li><strong>홈 탭</strong>: <code>table_calendar</code>를 사용해 일정을 시각화하고, 데이터를 범위 기반으로 미리 로드해 성능을 최적화했습니다.</li>
<li><strong>상세 화면</strong>: 참가자 목록 확인 및 상태 변경, 운영자 전용 기능 제공.</li>
<li><strong>팀 밸런싱</strong>: 백엔드 결과를 확인하고 저장하는 운영자 전용 UI.</li>
<li><strong>멤버 관리</strong>: 승인/제명/티어 변경 기능을 탭별로 구분하여 제공.</li>
</ul>
<h2 id="api-통신-dio">API 통신 (Dio)</h2>
<p><strong>Dio</strong> 인터셉터를 통해 모든 요청에 JWT를 자동 첨부하고, 401(TOKEN_EXPIRED) 발생 시 자동으로 Refresh Token을 통해 갱신 후 원래 요청을 재시도하는 로직을 구축했습니다.</p>
<h2 id="인증-및-보안">인증 및 보안</h2>
<p>카카오(<code>kakao_flutter_sdk</code>)와 애플(<code>sign_in_with_apple</code>) 로그인을 연동했습니다. 토큰 정보는 보안을 위해 <code>flutter_secure_storage</code>에 저장하여 Android의 암호화된 SP와 iOS의 Keychain을 활용했습니다.</p>
<h2 id="캐싱-및-기타-기능">캐싱 및 기타 기능</h2>
<ul>
<li><strong>날짜 범위 기반 캐싱</strong>: 이미 로드된 범위를 기억해 부족한 데이터만 요청합니다.</li>
<li><strong>Hive 오프라인 캐싱</strong>: 프로필 등 기본 정보는 네트워크 없이도 확인할 수 있도록 캐싱했습니다.</li>
<li><strong>딥링크</strong>: <code>app_links</code>를 사용하여 초대 링크 클릭 시 앱의 특정 화면으로 바로 이동하게 했습니다.</li>
<li><strong>기타</strong>: 다크 모드 지원, <code>showcaseview</code>를 활용한 온보딩, 검색 디바운스(700ms) 및 최근 검색어 기능을 추가했습니다.</li>
</ul>
<h2 id="어려웠던-점--배운-점-1">어려웠던 점 &amp; 배운 점</h2>
<ul>
<li><strong>Dart 문법</strong>: Null Safety 개념을 이해하는 데 시간이 걸렸지만, 안정적인 코드 작성에 큰 도움이 되었습니다.</li>
<li><strong>비동기 처리</strong>: <code>mounted</code> 체크를 통해 화면 dispose 후 발생하는 에러를 방지하는 법을 배웠습니다.</li>
<li><strong>iOS 빌드</strong>: Xcode 설정 및 애플 개발자 계정 관리 등 인프라 측면의 복잡함을 경험했습니다.</li>
<li><strong>선언형 UI</strong>: 위젯 트리 방식에 익숙해지니 상태 변화에 따른 UI 표현이 매우 직관적이고 편했습니다.</li>
</ul>
<h1 id="🏁-마치며">🏁 마치며</h1>
<p>1인 개발은 고독한 과정이었지만, 기획부터 배포까지 전 과정을 훑으며 개발자로서 시야가 한층 넓어졌습니다. 특히 <strong>Claude Code</strong>와 같은 AI 도구들을 적극적으로 활용하며 개발 패턴을 잡는 데 큰 도움을 받았습니다. 이번 도전이 제 커리어에 중요한 이정표가 될 것이라 확신합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter 앱 자동 로그아웃 문제 해결기: Refresh Token Race Condition 디버깅]]></title>
            <link>https://velog.io/@alstn_dev/Flutter-%EC%95%B1-%EC%9E%90%EB%8F%99-%EB%A1%9C%EA%B7%B8%EC%95%84%EC%9B%83-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0-Refresh-Token-Race-Condition-%EB%94%94%EB%B2%84%EA%B9%85</link>
            <guid>https://velog.io/@alstn_dev/Flutter-%EC%95%B1-%EC%9E%90%EB%8F%99-%EB%A1%9C%EA%B7%B8%EC%95%84%EC%9B%83-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0-Refresh-Token-Race-Condition-%EB%94%94%EB%B2%84%EA%B9%85</guid>
            <pubDate>Sun, 07 Dec 2025 13:32:56 GMT</pubDate>
            <description><![CDATA[<h1 id="0003초-차이가-만든-자동-로그아웃-버그">0.003초 차이가 만든 자동 로그아웃 버그</h1>
<h2 id="문제-발견">문제 발견</h2>
<p>앱을 테스트하던 중 이상한 현상을 발견했습니다. 로그인 후 한 시간 정도 지나서 앱을 다시 켜면 정상적으로 작동하는데, 이 때 앱을 완전히 종료했다가 바로 다시 실행하면 로그아웃되어 있었습니다.</p>
<p>처음에는 클라이언트 쪽 문제인가 싶었는데, 서버 로그를 확인해보니 refresh token 갱신 과정에서 에러가 발생하고 있었습니다.</p>
<pre><code>ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction</code></pre><p>그리고 그 전날 로그에는 이런 에러도 있었습니다.</p>
<pre><code>DataIntegrityViolationException: duplicate key value violates unique constraint</code></pre><h2 id="원인-파악">원인 파악</h2>
<p>로그를 자세히 보니 패턴이 보이기 시작했습니다.</p>
<pre><code>00:17:40.182 [exec-9] POST &quot;/auth/refresh&quot;
00:17:40.182 [exec-7] POST &quot;/auth/refresh&quot;  // 동시 요청</code></pre><p>Access token이 만료되면 클라이언트에서 여러 API를 동시에 호출하는데, 이 API들이 모두 401 에러를 받으면서 거의 동시에 refresh token 요청을 보냅니다. 두 요청의 시간 차이는 정확히 <strong>0.003초</strong>였습니다.</p>
<p>JWT 생성 로직에도 문제가 있었습니다. <code>Instant.now()</code>를 사용해서 <code>iat</code>(발급 시각)을 설정했는데, Unix timestamp는 초 단위만 기록합니다. 같은 초에 생성되다 보니 동일한 JWT 토큰이 만들어져 에러가 발생했습니다.</p>
<pre><code>T1 (17.529s): Request A - JWT 생성 (iat: 1765109537)
T2 (17.532s): Request B - 동일한 JWT 생성 (같은 초)
T9 (17.566s): Request B - 200 OK
T10 (17.569s): Request A - UNIQUE 제약 위반으로 500 에러</code></pre><p>추가로 기존 토큰 갱신 로직에도 문제가 있었습니다.</p>
<ol>
<li>기존 토큰을 DB에서 삭제</li>
<li>새 토큰을 INSERT</li>
</ol>
<p>두 요청이 동시에 들어오면:</p>
<ul>
<li>첫 번째 요청: 토큰 삭제 성공 → 새 토큰 INSERT 성공</li>
<li>두 번째 요청: 토큰 삭제 시도 → 이미 삭제됨 → <code>ObjectOptimisticLockingFailureException</code></li>
</ul>
<p>결국 한쪽 요청은 에러를 받고, 클라이언트는 이를 인증 실패로 판단해 로그아웃 처리했습니다. (클라이언트단에서 refresh token api 사용 시 에러가 발생하면 로그아웃 되게 만들었었습니다.)</p>
<h2 id="해결-방법">해결 방법</h2>
<p>문제는 두 가지였습니다:</p>
<ol>
<li>같은 초에 생성된 JWT가 완전히 동일함</li>
<li>DELETE-INSERT 과정에서 발생하는 동시성 문제</li>
</ol>
<h3 id="jwt에-고유-id-추가">JWT에 고유 ID 추가</h3>
<p><code>jti</code>(JWT ID) claim에 UUID를 추가해서 같은 초에 생성된 토큰도 유니크하게 만들었습니다.</p>
<pre><code class="language-java">.setId(UUID.randomUUID().toString()) // jti claim 추가</code></pre>
<p>이제 토큰의 발급 시간이 같아도 각각 다른 UUID를 가지기 때문에 UNIQUE 제약 위반은 사라졌습니다.</p>
<p>하지만 동시성 문제는 여전히 남아있었습니다. DELETE-INSERT를 UPDATE로 바꾸는 것만으로는 아래와 같은 문제가 있었습니다.</p>
<p>두 요청이 동시에 UPDATE를 시도하면:</p>
<ul>
<li>첫 번째 요청: 토큰 A 생성 → UPDATE</li>
<li>두 번째 요청: 토큰 B 생성 → UPDATE (덮어씀)</li>
</ul>
<p>결국 첫 번째 요청을 받은 클라이언트는 무효한 토큰 A를 받게 됩니다.</p>
<p>동시에 여러 요청이 와도 순서대로 처리되게 하는 것이 목표었습니다.</p>
<h3 id="pessimistic-lock으로-순서-보장">Pessimistic Lock으로 순서 보장</h3>
<p>낙관적 락도 고민을 하였으나, 낙관적락을 사용할 경우 재시도를 해야하는데, 이미 클라이언트 단에 응답을 보냈던거라 고려대상에서 제외되었습니다.</p>
<p>결국 DB 레벨에서 row를 잠그도록 했습니다. 같은 사용자에 대한 요청이 동시에 오면, 첫 번째 요청이 처리되는 동안 두 번째 요청은 대기합니다.</p>
<pre><code class="language-java">@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;SELECT r FROM RefreshTokenEntity r WHERE r.userId = :userId&quot;)
Optional&lt;RefreshTokenEntity&gt; findByUserIdWithLock(@Param(&quot;userId&quot;) Long userId);</code></pre>
<h3 id="delete-제거-update만-사용">DELETE 제거, UPDATE만 사용</h3>
<p>문제의 근본 원인이었던 DELETE를 지우고, 같은 레코드의 token_value만 UPDATE합니다.</p>
<pre><code class="language-java">private String createAndSaveRefreshToken(Long userId) {
    RefreshTokenEntity token = refreshTokenRepository.findByUserIdWithLock(userId)
        .map(existing -&gt; {
            existing.setTokenValue(newTokenValue);
            existing.setExpiresAt(expiresAt);
            return existing;
        })
        .orElse(/* 첫 로그인 시만 새로 생성 */);

    refreshTokenRepository.save(token);
    return newTokenValue;
}</code></pre>
<h3 id="db-제약-조건-추가">DB 제약 조건 추가</h3>
<p>한 사용자당 하나의 refresh token만 존재하도록 DB 제약을 추가했습니다.</p>
<pre><code class="language-sql">CREATE UNIQUE INDEX uk_user_id ON refresh_token(user_id);</code></pre>
<h2 id="배포-후">배포 후</h2>
<p>변경사항을 배포한 후 며칠간 모니터링했는데, 더 이상 예외가 발생하지 않았습니다. 앱을 종료하고 바로 다시 켜도 로그인 상태가 유지되는 것을 확인했습니다.</p>
<p>Pessimistic Lock으로 인한 성능 저하가 걱정됐지만, refresh token 갱신은 한 시간에 한 번 정도만 발생하고 user_id별로 lock이 걸리기 때문에 체감할 수 있는 영향은 없었습니다.</p>
<h2 id="마무리">마무리</h2>
<p>동시성 제어에 대해 다시한번 해결 할 수 있던 경험이었고, 로그를 통하여 문제의 원인을 파악했습니다. 0.003초 차이가 만든 문제를, UUID 하나와 DB Lock으로 해결한 이야기였습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[에러 0%, JWT 인증에서 Refresh Token까지]]></title>
            <link>https://velog.io/@alstn_dev/JWT-%EC%9D%B8%EC%A6%9D-%EA%B5%AC%ED%98%84-%EA%B0%9C%EC%84%A0%EA%B8%B0-Access-Token%EB%A7%8C-%EC%93%B0%EB%8D%98-%EC%95%B1%EC%97%90-Refresh-Token%EC%9D%84-%EB%B6%99%EC%9D%B4%EA%B8%B0%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@alstn_dev/JWT-%EC%9D%B8%EC%A6%9D-%EA%B5%AC%ED%98%84-%EA%B0%9C%EC%84%A0%EA%B8%B0-Access-Token%EB%A7%8C-%EC%93%B0%EB%8D%98-%EC%95%B1%EC%97%90-Refresh-Token%EC%9D%84-%EB%B6%99%EC%9D%B4%EA%B8%B0%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Thu, 04 Dec 2025 03:18:03 GMT</pubDate>
            <description><![CDATA[<p>Flutter + Spring Boot 기반의 풋살 모임 관리 앱을 개발하면서 JWT 인증을 구현했습니다. 처음에는 Access Token만으로 충분할 것이라 생각했지만, 실제 릴리즈 테스트를 진행하면서 1시간마다 사용자가 로그아웃되는 문제가 발생했습니다. 이번 글에서는 이 문제를 Refresh Token으로 어떻게 해결했는지, 그리고 왜 Refresh Token Rotation이 필수인지 공유하려합니다.</p>
<h2 id="문제의-시작-access-token만으로는-부족했다">문제의 시작: Access Token만으로는 부족했다</h2>
<h3 id="초기-구현의-판단">초기 구현의 판단</h3>
<p>개발 초기에는 보안을 우선시하여 Access Token의 만료 시간을 1시간으로 설정했습니다. JWT의 장점인 Stateless를 유지하면서도 토큰 탈취 시 피해를 최소화하려는 의도였습니다.</p>
<pre><code class="language-yaml">app:
  jwt:
    secret: ${JWT_SECRET}
    access-exp-seconds: 3600  # 1시간</code></pre>
<h3 id="백엔드-핵심-구현">백엔드 핵심 구현</h3>
<p>JWT 생성과 검증은 JwtProvider에서 담당합니다.</p>
<pre><code class="language-java">@Component
public class JwtProvider {
    private final Key key;
    private final long accessExpSeconds;

    public String createAccessToken(Long userId, String identifier, String displayName) {
        Instant now = Instant.now();
        return Jwts.builder()
                .setSubject(String.valueOf(userId))
                .claim(&quot;identifier&quot;, identifier)
                .claim(&quot;name&quot;, displayName)
                .setIssuedAt(Date.from(now))
                .setExpiration(Date.from(now.plusSeconds(accessExpSeconds)))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }
}</code></pre>
<p>HS256 대칭키 방식을 선택한 이유는 서버가 하나뿐이라, 굳이 구조가 더 복잡한 RS256까지 쓸 필요는 없었습니다.</p>
<p>그래서 설정과 운영이 더 단순한 HS256 대칭키 방식을 선택했습니다. Subject에는 userId를, 커스텀 클레임에는 자주 사용되는 사용자 정보를 포함했습니다.</p>
<p>모든 API 요청은 JwtAuthInterceptor를 거칩니다.</p>
<pre><code class="language-java">@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
    String auth = req.getHeader(&quot;Authorization&quot;);
    if (auth == null || !auth.startsWith(&quot;Bearer &quot;)) {
        throw new BizException(BizExceptionCode.INVALID_TOKEN);
    }

    try {
        String token = auth.substring(7);
        Jws&lt;Claims&gt; jws = jwtProvider.parse(token);
        Long userId = Long.valueOf(jws.getBody().getSubject());
        req.setAttribute(ATTR_USER_ID, userId);
        return true;
    } catch (ExpiredJwtException ex) {
        throw new BizException(BizExceptionCode.TOKEN_EXPIRED);
    }
}</code></pre>
<h3 id="프론트엔드-구현">프론트엔드 구현</h3>
<p>Flutter에서는 Dio의 Interceptor를 활용해 모든 요청에 자동으로 토큰을 추가했습니다.</p>
<pre><code class="language-dart">_dio.interceptors.add(
  InterceptorsWrapper(
    onRequest: (options, handler) async {
      final token = _cachedToken ?? await _storage.getToken();
      if (token != null) {
        options.headers[&#39;Authorization&#39;] = &#39;Bearer $token&#39;;
      }
      return handler.next(options);
    },
    onError: (error, handler) async {
      if (error.response?.statusCode == 401) {
        await _storage.clear();
        clearAuthHeader();
      }
      return handler.next(error);
    },
  ),
);</code></pre>
<h3 id="문제-발견">문제 발견</h3>
<p>앱 릴리즈 테스트 후 문제가 명확해졌습니다.</p>
<pre><code>&quot;앱으로 경기 일정 보다가 갑자기 로그인 화면으로 튕김&quot;
&quot;아침에 로그인했는데 점심 때 다시 로그인하라고 함&quot;</code></pre><p>시뮬레이터로 테스트시에는 빌드 후 바로 앱을 사용하기 때문에 문제가 없었는데, 릴리즈 테스트 중에는 1시간마다 강제 로그아웃되는 현상이 쉽게 재현되었습니다.</p>
<h2 id="해결-방안-refresh-token-도입">해결 방안: Refresh Token 도입</h2>
<h3 id="설계-원칙">설계 원칙</h3>
<p>여러 해결 방안을 검토한 결과, Refresh Token이 최선이었습니다.</p>
<p><strong>검토한 다른 방안들:</strong></p>
<ul>
<li>Access Token 만료 시간 연장(30일): 토큰 탈취 시 장기간 사용 가능, 보안상 위험</li>
<li>Session 방식 전환: Stateless의 장점 포기, 서버 메모리 부담, 수평 확장 어려움</li>
</ul>
<p>Refresh Token은 보안과 사용자 경험 모두를 만족시킬 수 있었습니다.</p>
<p>핵심 설계는 아래와 같습니다.</p>
<ol>
<li><strong>DB 저장</strong>: Refresh Token은 DB에 저장</li>
<li><strong>Rotation</strong>: 갱신 시 새 Refresh Token 발급</li>
<li><strong>이중 검증</strong>: JWT 만료와 DB 만료 모두 확인</li>
<li><strong>사용자당 단일 토큰</strong>: 새 로그인 시 이전 토큰 삭제</li>
</ol>
<h3 id="데이터베이스-설계">데이터베이스 설계</h3>
<pre><code class="language-sql">CREATE TABLE refresh_token (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    token_value VARCHAR(500) NOT NULL,
    expires_at TIMESTAMP NOT NULL,
    created_at TIMESTAMP NOT NULL,
    CONSTRAINT fk_refresh_token_user FOREIGN KEY (user_id)
        REFERENCES app_user(id) ON DELETE CASCADE
);

CREATE UNIQUE INDEX idx_token_value ON refresh_token(token_value);
CREATE INDEX idx_user_id ON refresh_token(user_id);</code></pre>
<p><code>token_value</code>는 갱신 시마다 조회되므로 UNIQUE 인덱스를, <code>user_id</code>는 로그인 시 기존 토큰 삭제를 위해 일반 인덱스를 생성했습니다.</p>
<h3 id="백엔드-개선">백엔드 개선</h3>
<p>JwtProvider에 Refresh Token 생성 메서드를 추가했습니다.</p>
<pre><code class="language-java">public String createRefreshToken(Long userId) {
    Instant now = Instant.now();
    return Jwts.builder()
            .setSubject(String.valueOf(userId))
            .setIssuedAt(Date.from(now))
            .setExpiration(Date.from(now.plusSeconds(refreshExpSeconds)))
            .signWith(key, SignatureAlgorithm.HS256)
            .compact();
}</code></pre>
<p>Access Token과 달리 Refresh Token에는 userId만 포함합니다. 토큰의 용도가 &quot;Access Token 재발급&quot;으로 명확하므로 추가 정보가 불필요하며, 토큰 크기를 최소화할 수 있습니다.</p>
<p>가장 중요한 부분은 토큰 갱신 로직입니다.</p>
<pre><code class="language-java">@Transactional
public AuthRes refreshAccessToken(String refreshTokenValue) {
    try {
        // 1. JWT 검증
        Jws&lt;Claims&gt; claims = jwtProvider.parse(refreshTokenValue);
        Long userId = Long.parseLong(claims.getBody().getSubject());

        // 2. DB 조회 및 검증
        RefreshTokenEntity entity = refreshTokenRepository
                .findByTokenValue(refreshTokenValue)
                .orElseThrow(() -&gt; new BizException(BizExceptionCode.REFRESH_TOKEN_NOT_FOUND));

        if (entity.isExpired()) {
            refreshTokenRepository.delete(entity);
            throw new BizException(BizExceptionCode.REFRESH_TOKEN_EXPIRED);
        }

        // 3. 사용자 정보 조회
        AppUserEntity user = appUserRepository.findById(userId)
                .orElseThrow(() -&gt; new BizException(BizExceptionCode.USER_NOT_FOUND));

        // 4. 새 토큰 발급 (Rotation)
        String newAccessToken = jwtProvider.createAccessToken(
                user.getId(), user.getProviderUserId(), user.getDisplayName());
        String newRefreshToken = createAndSaveRefreshToken(user.getId());

        return new AuthRes(newAccessToken, newRefreshToken, UserDto.fromEntity(user));
    } catch (ExpiredJwtException e) {
        throw new BizException(BizExceptionCode.REFRESH_TOKEN_EXPIRED);
    }
}</code></pre>
<p><strong>Refresh Token Rotation의 필요성</strong></p>
<p>Rotation 없이 같은 Refresh Token을 재사용하면, 토큰이 탈취될 경우 30일간 계속 사용 가능합니다. Rotation을 적용하면 갱신할 때마다 새 토큰을 발급하고 이전 토큰을 삭제하므로, 탈취된 토큰은 한 번만 사용 가능합니다.</p>
<pre><code class="language-java">private String createAndSaveRefreshToken(Long userId) {
    // 기존 토큰 삭제
    refreshTokenRepository.findByUserId(userId)
            .ifPresent(refreshTokenRepository::delete);

    // 새 토큰 생성 및 저장
    String tokenValue = jwtProvider.createRefreshToken(userId);
    RefreshTokenEntity entity = RefreshTokenEntity.builder()
            .userId(userId)
            .tokenValue(tokenValue)
            .expiresAt(LocalDateTime.now().plusSeconds(jwtProvider.getRefreshExpSeconds()))
            .build();

    refreshTokenRepository.save(entity);
    return tokenValue;
}</code></pre>
<p><strong>이중 검증의 의미</strong></p>
<p>JWT 검증과 DB 검증 모두를 수행하였는데, 그 이유는 JWT 검증은 토큰의 무결성을, DB 검증은 서버 측 무효화 여부를 확인합니다. 로그아웃한 사용자의 JWT는 만료 전까지 유효하지만, DB에서 삭제되므로 사용할 수 없습니다.</p>
<h3 id="프론트엔드-자동-갱신">프론트엔드 자동 갱신</h3>
<p>만약 실제 Access Token이 만료되었을 때 사용자 입장에서 토큰 갱신을 인지하지 못하게 만드는 것이 핵심입니다. Dio Interceptor의 onError에서 401 에러를 감지하고 자동으로 갱신하도록 하였습니다.</p>
<pre><code class="language-dart">onError: (error, handler) async {
  if (error.response?.statusCode != 401) {
    return handler.next(error);
  }

  final errorCode = error.response?.data[&#39;code&#39;];

  if (errorCode == &#39;TOKEN_EXPIRED&#39;) {
    try {
      final refreshToken = await _storage.getRefreshToken();
      if (refreshToken == null) {
        await _storage.clear();
        return handler.next(error);
      }

      // 토큰 갱신
      final response = await _dio.post(
        &#39;/auth/refresh&#39;,
        data: {&#39;refreshToken&#39;: refreshToken},
        options: Options(headers: {}),
      );

      final newAccessToken = response.data[&#39;jwt&#39;];
      final newRefreshToken = response.data[&#39;refreshToken&#39;];

      await _storage.saveToken(newAccessToken);
      await _storage.saveRefreshToken(newRefreshToken);
      setAuthHeader(newAccessToken);

      // 원래 요청 재시도
      final options = error.requestOptions;
      options.headers[&#39;Authorization&#39;] = &#39;Bearer $newAccessToken&#39;;
      final retryResponse = await _dio.fetch(options);

      return handler.resolve(retryResponse);
    } catch (e) {
      await _storage.clear();
    }
  }

  return handler.next(error);
},</code></pre>
<p><code>handler.resolve()</code> 메서드를 사용하면 에러를 성공 응답으로 변환하여, 호출한 곳에서는 토큰 갱신이 발생했다는 것을 전혀 알 수 없습니다.</p>
<p>실제 동작 흐름은 다음과 같습니다.</p>
<pre><code>1. Repository: _dio.get(&#39;/api/groups&#39;)
2. Interceptor: Authorization 헤더 추가
3. 서버: 401 TOKEN_EXPIRED 응답
4. Interceptor onError: 갱신 감지
5. POST /auth/refresh 호출
6. 새 토큰 저장
7. 원래 요청 재시도
8. handler.resolve()로 성공 응답 반환
9. Repository: 응답 수신 (에러 발생 사실을 모름)</code></pre><h2 id="구현-결과">구현 결과</h2>
<h3 id="사용자-경험-개선">사용자 경험 개선</h3>
<p><strong>변경 전:</strong>
1시간 경과 후 API 호출 → 401 에러 → 자동 로그아웃 → 사용자가 수동으로 재로그인</p>
<p><strong>변경 후:</strong>
1시간 경과 후 API 호출 → 401 감지 → 자동 갱신(약 0.5초) → 원래 요청 재시도 → 정상 응답</p>
<p>사용자는 약간의 지연만 느낄 뿐, 로그인이 풀렸다는 사실을 알지 못합니다.</p>
<h3 id="보안성-강화">보안성 강화</h3>
<ol>
<li><strong>Access Token 만료 시간 유지</strong>: 여전히 1시간으로 유지하여 탈취 시 피해 최소화</li>
<li><strong>Refresh Token Rotation</strong>: 갱신마다 새 토큰 발급으로 재사용 공격 방지</li>
<li><strong>DB 기반 즉시 무효화</strong>: 로그아웃 시 DB에서 삭제하여 완전한 세션 종료</li>
<li><strong>이중 검증</strong>: JWT와 DB 모두 검증하여 안전성 강화</li>
</ol>
<h3 id="성능-측정">성능 측정</h3>
<ul>
<li>Refresh Token 조회(인덱스 사용): ~5ms</li>
<li>새 Refresh Token 저장: ~10ms</li>
<li>JWT 생성: ~1ms</li>
<li>총 소요 시간: ~16ms</li>
</ul>
<p>인덱스 덕분에 10만 건 기준으로 약 50배 빠른 조회가 가능했고, 16ms는 사용자가 체감하기 어려운 시간입니다. 무엇보다 토큰 갱신은 1시간에 한 번만 발생하므로 성능 문제는 없었습니다.</p>
<h2 id="주요-배운-점">주요 배운 점</h2>
<h3 id="refresh-token-rotation의-필수성">Refresh Token Rotation의 필수성</h3>
<p>처음에는 Refresh Token을 재사용하려 했으나, 여러 글을 읽으며 Rotation의 중요성을 깨달았습니다.</p>
<h3 id="interceptor의-강력함">Interceptor의 강력함</h3>
<p>Dio의 Interceptor를 활용하면 모든 API 호출에 자동으로 토큰 갱신 로직을 적용할 수 있습니다. 각 Repository에서 재시도 로직을 반복하는 대신, 한 곳에서 처리할 수 있었습니다.</p>
<h3 id="점진적-개선의-가치">점진적 개선의 가치</h3>
<p>처음부터 완벽한 시스템을 만들려고 했다면 출시가 늦어졌을 것입니다. MVP로 시작해 피드백을 받으며 개선하는 것이 더 효과적이었습니다.</p>
<h2 id="향후-개선-방향">향후 개선 방향</h2>
<p>현재 시스템도 안정적으로 동작하지만, 몇 가지 개선 방향을 고려하고 있습니다.</p>
<h3 id="만료된-토큰-자동-정리">만료된 토큰 자동 정리</h3>
<p>현재는 만료된 Refresh Token이 DB에 계속 쌓입니다. Spring Scheduler로 매일 새벽 자동 정리 작업을 추가할 예정입니다.</p>
<pre><code class="language-java">@Scheduled(cron = &quot;0 0 3 * * *&quot;)
public void cleanupExpiredTokens() {
    int deleted = refreshTokenRepository.deleteByExpiresAtBefore(LocalDateTime.now());
    log.info(&quot;Cleaned up {} expired tokens&quot;, deleted);
}</code></pre>
<h2 id="마치며">마치며</h2>
<p>JWT 인증은 단순해 보이지만, 실제 운영 환경에서는 많은 고려사항이 있다는걸 느꼈습니다. 특히 사용자 입장에서의 경험을 고려하며 보안에 대해서 충분히 대응해야한다는걸 한번 더 느꼈습니다.</p>
<p>JWT 인증을 구현하시는 분들에게 이 글이 도움이 되었으면 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Apple 로그인 with spring boot(feat. Sign in with Apple)]]></title>
            <link>https://velog.io/@alstn_dev/Apple-%EB%A1%9C%EA%B7%B8%EC%9D%B8-with-spring-bootfeat.-Sign-in-with-Apple</link>
            <guid>https://velog.io/@alstn_dev/Apple-%EB%A1%9C%EA%B7%B8%EC%9D%B8-with-spring-bootfeat.-Sign-in-with-Apple</guid>
            <pubDate>Thu, 20 Nov 2025 12:09:11 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-boot에서-apple-로그인sign-in-with-apple-구현하기">Spring Boot에서 Apple 로그인(Sign in with Apple) 구현하기</h1>
<p>iOS 환경에서 소셜 로그인을 구현할 때 <strong>Apple 로그인(Sign in with Apple)</strong> 은 선택지가 아니라 필수에 가깝습니다.<br>앱에서 다른 소셜 로그인을 제공하는 경우, Apple은 Apple 로그인을 반드시 함께 제공하도록 요구하기 때문입니다.</p>
<p>이 글에서는 &#39;스쿼드&#39;라는 앱을 구현하면서 <strong>Spring Boot 기반 백엔드에서 Apple Identity Token(JWT)을 검증하고 자체 인증 토큰을 발급하는 과정</strong>을 단계별로 정리했습니다. 실제 서비스에 적용해 운영 중인 구조를 토대로 작성했습니다.</p>
<hr>
<h2 id="기술-스택">기술 스택</h2>
<ul>
<li>Spring Boot 3.5.6  </li>
<li>Java 21  </li>
<li>Nimbus JOSE + JWT  </li>
<li>PostgreSQL  </li>
</ul>
<hr>
<h2 id="apple-로그인-전체-흐름">Apple 로그인 전체 흐름</h2>
<p>Apple 로그인은 아래 순서로 진행됩니다.</p>
<ol>
<li><strong>iOS 클라이언트</strong>에서 &quot;Sign in with Apple&quot; 실행  </li>
<li><strong>Apple</strong>이 사용자를 인증하고 Identity Token(JWT)을 발급  </li>
<li><strong>클라이언트 → 서버</strong>로 Identity Token 전달  </li>
<li><strong>백엔드</strong>에서 Identity Token 검증 및 사용자 정보 추출  </li>
<li><strong>백엔드</strong>에서 자체 액세스 토큰(JWT) 발급 후 반환  </li>
</ol>
<p>이 중 핵심은 4번, <strong>JWT 검증 단계</strong>입니다.<br>여기서 검증이 제대로 이뤄지지 않으면 로그인 전체가 실패합니다.</p>
<hr>
<h2 id="1-dependency-추가">1. Dependency 추가</h2>
<p>Apple이 발급한 JWT는 Nimbus JOSE + JWT로 검증합니다.
기존에 JWT를 사용하여 토큰을 만들고 있어서 재사용 하려 하였으나, Apple 공개 키(JWKS)를 읽어 RSA 서명을 검증하는 부분은 Nimbus가 표준이라고 하여 추가하였습니다.</p>
<pre><code class="language-gradle">dependencies {
    implementation &quot;com.nimbusds:nimbus-jose-jwt:9.37.3&quot;
}</code></pre>
<hr>
<h2 id="2-설정-파일-구성">2. 설정 파일 구성</h2>
<p>Apple Identity Token의 <code>aud</code> 검증을 위해 Bundle ID가 필요합니다.</p>
<pre><code class="language-yml"># application.yml
apple:
  bundle-id: com.myapp.squad</code></pre>
<p>환경별로 Bundle ID가 다를 수 있기 때문에 <code>application-*.yml</code> 분리를 추천합니다.</p>
<hr>
<h2 id="3-request-dto-정의">3. Request DTO 정의</h2>
<p>Apple 로그인 시 필요한 필드는 아래와 같이 정리했습니다.</p>
<pre><code class="language-java">public record AppleLoginReq(
        @NotBlank(message = &quot;Identity token은 필수입니다&quot;)
        String identityToken,
        String authorizationCode,
        String fullName // 첫 로그인 시에만 제공됨
) {}</code></pre>
<p>Apple은 <strong>첫 로그인 시에만 이메일/이름 정보를 내려줍니다</strong>.  
따라서 최초 로그인에서 해당 데이터를 반드시 저장하는 것이 좋습니다.</p>
<hr>
<h2 id="4-apple-jwt-validator-구현">4. Apple JWT Validator 구현</h2>
<p>Apple의 공개 키를 조회해 서명을 검증하고, 클레임(aud, iss, exp) 검증까지 포함한 실제 검증 로직입니다.</p>
<pre><code class="language-java">@Slf4j
@Component
public class AppleJwtValidator {

    private static final String APPLE_PUBLIC_KEYS_URL = &quot;https://appleid.apple.com/auth/keys&quot;;

    @Value(&quot;${apple.bundle-id}&quot;)
    private String appleBundleId;

    private final WebClient webClient;

    public AppleJwtValidator(WebClient.Builder webClientBuilder) {
        this.webClient = webClientBuilder.build();
    }

    public Map&lt;String, Object&gt; validateAndGetClaims(String identityToken) throws Exception {
        SignedJWT signedJWT = SignedJWT.parse(identityToken);

        if (!verifySignature(signedJWT)) {
            throw new IllegalArgumentException(&quot;Invalid JWT signature&quot;);
        }

        Map&lt;String, Object&gt; claims = signedJWT.getJWTClaimsSet().getClaims();

        validateAudience(claims);
        validateExpiration(signedJWT);
        validateIssuer(claims);

        return claims;
    }

    private void validateAudience(Map&lt;String, Object&gt; claims) {
        Object audClaim = claims.get(&quot;aud&quot;);
        boolean valid = false;

        if (audClaim instanceof String) {
            valid = appleBundleId.equals(audClaim);
        } else if (audClaim instanceof List) {
            valid = ((List&lt;?&gt;) audClaim).contains(appleBundleId);
        }

        if (!valid) {
            throw new IllegalArgumentException(&quot;Invalid audience: &quot; + audClaim);
        }
    }

    private void validateExpiration(SignedJWT signedJWT) throws Exception {
        Date exp = signedJWT.getJWTClaimsSet().getExpirationTime();
        if (exp == null || exp.before(new Date())) {
            throw new IllegalArgumentException(&quot;Token expired&quot;);
        }
    }

    private void validateIssuer(Map&lt;String, Object&gt; claims) {
        String issuer = (String) claims.get(&quot;iss&quot;);
        if (!&quot;https://appleid.apple.com&quot;.equals(issuer)) {
            throw new IllegalArgumentException(&quot;Invalid issuer: &quot; + issuer);
        }
    }

    private boolean verifySignature(SignedJWT signedJWT) throws Exception {
        String jwksJson = webClient.get()
                .uri(APPLE_PUBLIC_KEYS_URL)
                .retrieve()
                .bodyToMono(String.class)
                .block();

        if (jwksJson == null) {
            throw new IllegalStateException(&quot;Failed to fetch Apple public keys&quot;);
        }

        JWKSet jwkSet = JWKSet.parse(jwksJson);
        JWK jwk = jwkSet.getKeyByKeyId(signedJWT.getHeader().getKeyID());

        if (jwk == null) {
            throw new IllegalArgumentException(&quot;Public key not found for kid&quot;);
        }

        RSAKey rsaKey = jwk.toRSAKey();
        JWSVerifier verifier = new RSASSAVerifier(rsaKey);

        return signedJWT.verify(verifier);
    }

    public String extractAppleUserId(Map&lt;String, Object&gt; claims) {
        return (String) claims.get(&quot;sub&quot;);
    }

    public String extractEmail(Map&lt;String, Object&gt; claims) {
        return (String) claims.get(&quot;email&quot;);
    }
}</code></pre>
<hr>
<h2 id="5-authservice-구현">5. AuthService 구현</h2>
<p>JWT 검증이 끝나면, 사용자 조회/생성 후 자체 액세스 토큰을 발급합니다.</p>
<pre><code class="language-java">@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {

    private final AppleJwtValidator appleJwtValidator;
    private final JwtProvider jwtProvider;
    private final AppUserRepository appUserRepository;

    @Transactional
    public AuthRes loginWithApple(AppleLoginReq req) {
        try {
            Map&lt;String, Object&gt; claims = appleJwtValidator.validateAndGetClaims(req.identityToken());

            String appleUserId = appleJwtValidator.extractAppleUserId(claims);
            String email = appleJwtValidator.extractEmail(claims);

            AppUserEntity user = appUserRepository
                    .findByProviderUserIdAndSignupMethod(appleUserId, SignupMethod.APPLE)
                    .orElseGet(() -&gt; createAppleUser(appleUserId, email, req.fullName()));

            String jwt = jwtProvider.createAccessToken(
                    user.getId(),
                    user.getProviderUserId(),
                    user.getDisplayName()
            );

            return new AuthRes(jwt, UserDto.fromEntity(user));

        } catch (Exception e) {
            log.error(&quot;Apple login failed&quot;, e);
            throw new IllegalArgumentException(&quot;Apple 로그인 실패: &quot; + e.getMessage());
        }
    }

    private AppUserEntity createAppleUser(String appleUserId, String email, String fullName) {
        AppUserEntity user = new AppUserEntity();
        user.setProviderUserId(appleUserId);

        String name = (fullName != null &amp;&amp; !fullName.isBlank())
                ? fullName
                : &quot;이름을 입력해주세요&quot;;

        user.setDisplayName(name);
        user.setProfileImage(null);
        user.setSignupMethod(SignupMethod.APPLE);

        return appUserRepository.save(user);
    }
}</code></pre>
<hr>
<h2 id="6-controller-api">6. Controller API</h2>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/auth&quot;)
@RequiredArgsConstructor
public class AuthApiController {

    private final AuthService authService;

    @PostMapping(&quot;/apple&quot;)
    public ResponseEntity&lt;AuthRes&gt; loginWithApple(@RequestBody @Valid AppleLoginReq req) {
        return ResponseEntity.ok(authService.loginWithApple(req));
    }
}</code></pre>
<hr>
<h2 id="트러블슈팅-classcastexception">트러블슈팅: ClassCastException</h2>
<p>개발 과정에서 아래 오류가 발생했습니다.</p>
<pre><code>java.lang.ClassCastException: class java.util.ArrayList cannot be cast to class java.lang.String</code></pre><h3 id="원인">원인</h3>
<p>Apple의 <code>aud</code> 클레임은 <strong>문자열 또는 배열(List)</strong> 로 내려올 수 있습니다.<br>String으로 단정해 캐스팅하면 예외가 발생합니다.</p>
<h3 id="해결-방법">해결 방법</h3>
<p>타입에 따라 분기하면 됩니다.</p>
<pre><code class="language-java">Object audClaim = claims.get(&quot;aud&quot;);
boolean isValidAudience = false;

if (audClaim instanceof String) {
    isValidAudience = appleBundleId.equals(audClaim);
} else if (audClaim instanceof List) {
    isValidAudience = ((List&lt;String&gt;) audClaim).contains(appleBundleId);
}</code></pre>
<hr>
<h2 id="테스트">테스트</h2>
<pre><code class="language-bash">curl -X POST http://localhost:8080/auth/apple \
  -H &quot;Content-Type: application/json&quot; \
  -d &#39;{
    &quot;identityToken&quot;: &quot;eyJraWQiOiJIdl...&quot;,
    &quot;fullName&quot;: &quot;홍길동&quot;
  }&#39;</code></pre>
<hr>
<h2 id="주의할-점">주의할 점</h2>
<h3 id="1-첫-로그인-시-데이터-저장">1) 첫 로그인 시 데이터 저장</h3>
<p>이름/이메일은 <strong>첫 로그인에서만</strong> 제공됩니다.</p>
<h3 id="2-bundle-id-환경별-분리">2) Bundle ID 환경별 분리</h3>
<p>로컬/개발/운영에서 각각 다를 수 있습니다.</p>
<h3 id="3-apple-공개-키-캐싱">3) Apple 공개 키 캐싱</h3>
<p>Apple의 JWKS는 자주 바뀌지 않기 때문에 Redis로 캐싱하는 것도 고려할 수 있습니다.</p>
<h3 id="4-에러-처리">4) 에러 처리</h3>
<p>운영 환경에서는 더 세분화된 예외 코드와 로깅이 필요합니다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>Apple 로그인을 Spring Boot에서 구현할 때 가장 중요한 부분은 <strong>Identity Token 검증</strong>입니다.<br>이 단계만 안정적으로 구축하면 이후 인증 흐름은 비교적 단순합니다.</p>
<p>Apple 로그인은 처음 접하면 다소 복잡해 보이지만, 구조를 한 번 잡아두면 안정적으로 운영할 수 있습니다.</p>
<hr>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://developer.apple.com/documentation/sign_in_with_apple">https://developer.apple.com/documentation/sign_in_with_apple</a>  </li>
<li><a href="https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_to_your_app">https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_to_your_app</a>  </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Docker + Spring Boot 환경변수와 프로필, 제대로 이해하기]]></title>
            <link>https://velog.io/@alstn_dev/Docker..-%EA%B7%B8%EB%A6%AC%EA%B3%A0-RDSfeat-AWS</link>
            <guid>https://velog.io/@alstn_dev/Docker..-%EA%B7%B8%EB%A6%AC%EA%B3%A0-RDSfeat-AWS</guid>
            <pubDate>Thu, 13 Nov 2025 13:41:30 GMT</pubDate>
            <description><![CDATA[<h1 id="docker--spring-boot-환경변수와-프로필-제대로-이해하기">Docker + Spring Boot 환경변수와 프로필, 제대로 이해하기</h1>
<p>Squad 프로젝트를 진행하면서 처음으로 Docker와 RDS를 사용하게 되었습니다. 개발 환경에서는 문제없이 동작하던 애플리케이션을 운영 환경에 배포하려고 하니, <strong>환경변수, 프로필, 설정 파일의 관계</strong>가 명확하지 않아 어려움을 겪었습니다.</p>
<p>이 글에서는 제가 겪었던 시행착오를 바탕으로, Docker Compose와 Spring Boot가 어떻게 연결되는지 정리해보겠습니다.</p>
<pre><code>.env.prod → docker-compose → container env → Spring Boot → application-prod.yml</code></pre><h2 id="기존-방식-설정-파일을-직접-수정해서-배포">기존 방식: 설정 파일을 직접 수정해서 배포</h2>
<p>이전에 진행했던 프로젝트에서는 배포 방식이 단순했습니다.</p>
<p><strong>개발할 때:</strong></p>
<pre><code class="language-properties"># application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=admin
spring.datasource.password=1234</code></pre>
<p><strong>배포할 때:</strong></p>
<ol>
<li><code>application.properties</code> 운영 서버 cfg에 따로 존재</li>
<li>.env로 관리하지 않고 properties에 모든 내용이 작성</li>
</ol>
<p>이 방식의 문제점:</p>
<ul>
<li>실수로 운영 DB 정보가 Git에 커밋된다면 보안성 위험</li>
</ul>
<h2 id="새로운-방식-docker--환경변수--프로필">새로운 방식: Docker + 환경변수 + 프로필</h2>
<p>그런데 Docker를 사용하면서 갑자기:</p>
<ul>
<li><code>.env.prod</code> 파일</li>
<li><code>docker-compose.yml</code></li>
<li><code>application-prod.yml</code></li>
<li>환경변수 <code>${VARIABLE_NAME}</code></li>
</ul>
<p>이런 개념들이 한꺼번에 등장했습니다.</p>
<p><strong>&quot;왜 이렇게 복잡해진 거지?&quot;</strong></p>
<p>처음에는 이런 생각이 들었습니다. 기존에는 파일 하나만 수정하면 됐는데, 이제는 여러 파일과 환경변수를 관리해야 한다니 오히려 더 복잡해 보였습니다.</p>
<p>하지만 이해하고 나니, 이 방식이 훨씬 더 안전하고 편리하다는 것을 알게 되었습니다:</p>
<ol>
<li><strong>설정과 코드의 분리</strong>: 민감한 정보(DB 비밀번호, API 키)를 코드에서 완전히 분리</li>
<li><strong>환경별 자동 전환</strong>: <code>SPRING_PROFILES_ACTIVE</code>만 바꾸면 설정이 자동으로 전환</li>
<li><strong>Git 안전성</strong>: <code>.env.prod</code>는 <code>.gitignore</code>에 추가하면 절대 커밋되지 않음</li>
<li><strong>재사용성</strong>: 같은 Docker 이미지로 여러 환경에 배포 가능</li>
</ol>
<h2 id="문제-docker와-spring-boot-누가-무엇을-담당하는가">문제: Docker와 Spring Boot, 누가 무엇을 담당하는가?</h2>
<p>하지만 이 새로운 방식을 처음 접했을 때 가장 혼란스러웠던 부분은 &quot;어디서 어떤 값을 읽어오는가&quot;였습니다. <code>.env.prod</code> 파일을 만들고, <code>docker-compose.yml</code>에 <code>env_file</code>을 지정했지만, 이 값들이 Spring Boot에서 어떻게 사용되는지 명확하지 않았습니다.</p>
<h2 id="docker-compose의-역할">Docker Compose의 역할</h2>
<p>Docker Compose에서 <code>env_file</code>을 지정하면:</p>
<pre><code class="language-yaml">
services:
  app:
    ...
    env_file:
      - .env.prod
    environment:
      SPRING_PROFILES_ACTIVE: prod
</code></pre>
<p>Docker는 해당 파일의 모든 <code>KEY=VALUE</code> 쌍을 읽어 <strong>컨테이너의 환경변수로 등록</strong>합니다.</p>
<p>예를 들어 <code>.env.prod</code> 파일이 다음과 같다면:</p>
<pre><code>SPRING_DATASOURCE_URL=jdbc:postgresql://my-rds:5432/squad
SPRING_DATASOURCE_USERNAME=admin
SPRING_DATASOURCE_PASSWORD=123123
APPLE_BUNDLE_ID=xxx.xxxx.xxxxxx
JWT_SECRET=xxxx</code></pre><p>Docker는 파일 내용을 읽어 컨테이너 환경변수로 설정합니다. 이때 중요한 점은 <strong>Docker는 이 값들이 어디서 사용될지 알지 못한다</strong>는 것입니다. 단순히 환경변수를 설정하는 역할만 수행합니다.</p>
<h3 id="핵심-docker는-환경변수-전달자">핵심: Docker는 환경변수 전달자</h3>
<p>Docker Compose의 역할은 명확합니다. <code>.env.prod</code> 파일의 내용을 읽어 컨테이너 환경변수로 설정하는 것이 전부입니다. Spring Boot가 어떤 값을 필요로 하는지, 어떻게 사용할지는 관여하지 않습니다.</p>
<h2 id="spring-boot의-역할">Spring Boot의 역할</h2>
<p>그렇다면 <strong>어떤 환경변수가 필요한지는 누가 결정할까요?</strong> 답은 Spring Boot입니다.</p>
<p>Spring Boot는 설정 파일에서 <code>${VARIABLE_NAME}</code> 형태의 플레이스홀더를 발견하면, 런타임에 환경변수에서 해당 값을 읽어옵니다.</p>
<pre><code class="language-yaml">spring:
  datasource:
    url: ${SPRING_DATASOURCE_URL}
    username: ${SPRING_DATASOURCE_USERNAME}
    password: ${SPRING_DATASOURCE_PASSWORD}

apple:
  bundle-id: ${APPLE_BUNDLE_ID}</code></pre>
<p>위 설정을 보고 Spring Boot는 <code>SPRING_DATASOURCE_URL</code>, <code>SPRING_DATASOURCE_USERNAME</code> 등의 환경변수가 필요하다는 것을 인지하고, 컨테이너 환경변수에서 값을 가져옵니다.</p>
<h3 id="역할의-분리">역할의 분리</h3>
<ul>
<li><strong>Docker</strong>: 환경변수를 컨테이너에 전달</li>
<li><strong>Spring Boot</strong>: 필요한 환경변수만 선택적으로 사용</li>
</ul>
<h2 id="profile프로필이란">Profile(프로필)이란?</h2>
<p>여기서 잠깐, &quot;프로필&quot;이라는 용어가 생소할 수 있습니다. 프로필은 쉽게 말해 <strong>&quot;실행 환경&quot;</strong>을 의미합니다.</p>
<p>같은 애플리케이션 코드를 여러 환경에서 실행할 때, 환경마다 설정이 달라야 합니다. 예를 들어:</p>
<ul>
<li><strong>로컬 개발 환경(local)</strong>: 내 컴퓨터의 localhost DB 사용</li>
<li><strong>운영 환경(prod)</strong>: AWS RDS 사용, 실제 Apple 인증서와 JWT Secret 사용</li>
</ul>
<p>코드는 동일하지만, DB 주소나 API 키 같은 설정만 환경에 따라 달라지는 것이죠.</p>
<h3 id="프로필의-실제-사용-예시">프로필의 실제 사용 예시</h3>
<pre><code class="language-yaml"># application-local.yml (로컬 개발용)
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/squad
    username: admin
    password: 1234

apple:
  bundle-id: com.myapp.squad.dev

# application-prod.yml (운영 서버용)
spring:
  datasource:
    url: ${SPRING_DATASOURCE_URL}  # .env.prod에서 가져옴
    username: ${SPRING_DATASOURCE_USERNAME}
    password: ${SPRING_DATASOURCE_PASSWORD}

apple:
  bundle-id: ${APPLE_BUNDLE_ID}</code></pre>
<p>실행할 때 어떤 프로필을 사용할지 지정하면:</p>
<ul>
<li><code>SPRING_PROFILES_ACTIVE=local</code> → <code>application-local.yml</code> 사용</li>
<li><code>SPRING_PROFILES_ACTIVE=prod</code> → <code>application-prod.yml</code> 사용</li>
</ul>
<h3 id="프로필을-사용하는-이유">프로필을 사용하는 이유</h3>
<ol>
<li><strong>코드 수정 없이 환경만 바꿔서 실행 가능</strong></li>
<li><strong>민감 정보(DB 비밀번호 등)를 환경마다 다르게 관리</strong></li>
<li><strong>로컬에서 개발하다가 배포할 때 설정만 전환하면 됨</strong></li>
</ol>
<p>프로필 덕분에 하나의 코드베이스로 여러 환경을 유연하게 관리할 수 있습니다.</p>
<h3 id="기존-방식과의-비교">기존 방식과의 비교</h3>
<p><strong>기존 방식 (properties 직접 수정):</strong></p>
<pre><code>개발 완료 → properties 파일 수정 → 빌드 → 배포
         ↑ 내용이 properties에 그대로 작성</code></pre><p><strong>새로운 방식 (프로필 + 환경변수):</strong></p>
<pre><code>개발 완료 → 빌드 (코드는 그대로!) → 배포 시 SPRING_PROFILES_ACTIVE=prod만 지정
                                        ↑ .env.prod는 서버에만 존재, Git에는 없음</code></pre><p>코드와 설정이 완전히 분리되어, 같은 JAR 파일로 여러 환경에 배포할 수 있게 된 것입니다.</p>
<h2 id="profile-기반-설정-파일-로딩">Profile 기반 설정 파일 로딩</h2>
<p>Spring Boot가 <code>application-prod.yml</code> 파일을 자동으로 읽는 원리는 간단합니다.</p>
<pre><code class="language-yaml">environment:
  SPRING_PROFILES_ACTIVE: prod</code></pre>
<p>위와 같이 <code>SPRING_PROFILES_ACTIVE</code> 환경변수를 설정하면, Spring Boot는 다음 순서로 설정 파일을 로딩합니다:</p>
<ol>
<li><code>application.yml</code> (기본 설정)</li>
<li><code>application-prod.yml</code> (프로필별 설정)</li>
<li>중복되는 설정은 프로필 파일이 우선</li>
</ol>
<p>이는 Spring Boot의 <a href="https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.external-config.files.profile-specific">Profile 기반 설정 메커니즘</a>에 따른 것으로, <code>application-{profile}.yml</code> 네이밍 규칙을 따르면 자동으로 로딩됩니다.</p>
<h2 id="전체-흐름">전체 흐름</h2>
<p>전체 과정을 정리하면 다음과 같습니다:</p>
<pre><code>[1] .env.prod
    실제 환경변수 값 정의 (Git에는 커밋하지 않음!)
    ↓
[2] docker-compose.prod.yml
    env_file로 .env.prod 로딩
    SPRING_PROFILES_ACTIVE=prod 설정
    ↓
[3] Docker Container
    모든 환경변수가 컨테이너에 등록됨
    ↓
[4] Spring Boot
    SPRING_PROFILES_ACTIVE=prod 확인
    → application-prod.yml 자동 로딩
    → ${VARIABLE_NAME} 형태의 값을 환경변수에서 조회</code></pre><h2 id="로컬-개발-환경에서의-주의사항">로컬 개발 환경에서의 주의사항</h2>
<p>IntelliJ에서 애플리케이션을 실행할 때는 Docker가 개입하지 않으므로, 환경변수를 별도로 설정해야 합니다.</p>
<h3 id="기본-실행">기본 실행</h3>
<ul>
<li><code>application.yml</code>만 로딩됩니다.</li>
<li>프로필별 설정 파일은 사용되지 않습니다.</li>
</ul>
<h3 id="profile을-지정한-실행">Profile을 지정한 실행</h3>
<p>Run Configuration에서 <code>SPRING_PROFILES_ACTIVE=prod</code>를 설정하면:</p>
<ul>
<li><code>application-prod.yml</code>이 로딩됩니다.</li>
<li>필요한 환경변수를 Run Configuration의 Environment Variables에 직접 입력해야 합니다.<pre><code>SPRING_DATASOURCE_URL=...
SPRING_DATASOURCE_USERNAME=...
APPLE_BUNDLE_ID=...
JWT_SECRET=...</code></pre></li>
</ul>
<p>Docker처럼 <code>.env</code> 파일이 자동으로 로딩되지 않으므로, 로컬에서 운영 환경을 재현하려면 환경변수를 수동으로 관리해야 합니다.</p>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>.env.prod</code></td>
<td>환경변수 값을 저장하는 파일 (Git에 커밋 안 함)</td>
</tr>
<tr>
<td><code>docker-compose.yml</code></td>
<td><code>.env.prod</code>를 읽어 컨테이너 환경변수로 설정</td>
</tr>
<tr>
<td>컨테이너 환경변수</td>
<td>Spring Boot가 런타임에 참조하는 실제 값</td>
</tr>
<tr>
<td><code>SPRING_PROFILES_ACTIVE</code></td>
<td>로딩할 프로필 설정 파일 결정</td>
</tr>
<tr>
<td><code>application-prod.yml</code></td>
<td>프로필별 추가 설정 정의</td>
</tr>
</tbody></table>
<h2 id="마치며">마치며</h2>
<p>Docker와 Spring Boot의 역할을 명확히 구분하면, 환경변수와 설정 파일의 관계를 쉽게 이해할 수 있습니다.</p>
<ul>
<li><strong>Docker</strong>: 환경변수 전달</li>
<li><strong>Spring Boot</strong>: 프로필에 따라 설정 파일을 로딩하고, 필요한 환경변수 참조</li>
</ul>
<p>처음에는 기존의 단순한 방식보다 복잡해 보였지만, 이 구조 덕분에:</p>
<ul>
<li>민감한 정보를 안전하게 관리할 수 있고</li>
<li>같은 코드로 여러 환경에 배포할 수 있으며</li>
<li>실수로 운영 정보가 Git에 노출될 위험이 사라졌습니다.</li>
</ul>
<p>Squad(스쿼드) 프로젝트를 배포하면서 겪었던 시행착오가 좋은 학습 경험이 되었습니다. 다음에는 처음부터 이런 구조로 프로젝트를 설계할 수 있을 것 같습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링의 기본 에러 처리 방식]]></title>
            <link>https://velog.io/@alstn_dev/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%98-%EA%B8%B0%EB%B3%B8-%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC-%EB%B0%A9%EC%8B%9D</link>
            <guid>https://velog.io/@alstn_dev/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%98-%EA%B8%B0%EB%B3%B8-%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC-%EB%B0%A9%EC%8B%9D</guid>
            <pubDate>Thu, 20 Mar 2025 01:27:03 GMT</pubDate>
            <description><![CDATA[<h1 id="스프링의-기본-에러-처리-방식">스프링의 기본 에러 처리 방식</h1>
<h2 id="개요">개요</h2>
<p>Spring Boot로 웹을 개발하면서 에러 처리 메커니즘에 관해 궁금증이 생겼다. 특히 AJAX 요청과 일반 브라우저 요청에서 에러 처리가 다르게 동작하는 방식을 이해하고 싶어 알아보기 시작했다. 
이 글에서는 그러한 경험을 바탕으로 Spring의 기본 에러 처리 방식을 작성하여 기록하고자 한다.</p>
<h2 id="spring-boot의-기본-에러-처리">Spring Boot의 기본 에러 처리</h2>
<p>개발과정에서 특정 500 에러가 발생하였는데, 이를 Handler에도 정의를 안해놓는 경험이 있다. 이 때 이 에러는 어떻게 처리가 되는지에 대해 의문을 가졌고 알아보기 시작했다.
확인한 결과, Spring Boot는 기본적으로 모든 오류를 /error 경로로 매핑한다.
이는 서블릿 컨테이너에 &quot;전역&quot; 오류 페이지로 등록된다. 코드를 분석해보니 이 처리는 BasicErrorController라는 클래스를 통해 이루어진다는 점을 확인하였다.</p>
<blockquote>
<pre><code class="language-java">@Controller
@RequestMapping(&quot;${server.error.path:${error.path:/error}}&quot;)
public class BasicErrorController implements ErrorController {
    // ...
}</code></pre>
</blockquote>
<pre><code>
실제로 500 에러와 같은 서버 내부 오류가 발생했을 때, 기본적으로 HTML 페이지나 JSON 형태로 에러 정보가 반환되는 것을 확인하였다. 
이 과정에서 스프링이 클라이언트의 요청 유형을 감지하여 응답 형식을 다르게 처리한다는 점도 알게 되었다.

## 클라이언트 타입에 따른 차이점
개발 중 테스트를 통해 Spring Boot가 요청하는 클라이언트의 유형에 따라 다르게 에러를 처리한다는 것을 확인하였다.

1. 브라우저 클라이언트 요청 시

HTML을 요청하는 일반 브라우저 요청의 경우, 세부 에러 정보들을 포함한 HTML 형식의 에러 페이지가 반환되었다.
내부적으로 /error 경로로 리다이렉트되는 것을 확인할 수 있었다.


2. AJAX, API 등 요청 시

JSON/XML 등을 요청하는 방식의 경우, 에러 세부 정보와 HTTP 상태가 포함된 JSON 응답이 생성되었다.
리다이렉트 없이 API에 대한 응답으로 에러 정보가 반환되는 것을 확인하였다.
Accept: application/json 헤더가 이러한 처리를 가능하게 한다.

## /error 경로로의 디스패치 처리 과정
디버깅을 통해 스프링이 /error 경로로 요청을 디스패치하는 과정을 확인했다.
이는 실제 HTTP 요청을 보내는 것이 아니라 내부적으로 처리되는 방식으로, RequestDispatcher를 사용하여 요청을 다른 핸들러로 전달한다.

&gt;```java
RequestDispatcher dispatcher = request.getRequestDispatcher(&quot;/error&quot;);
dispatcher.forward(request, response); // 기존 요청을 &quot;/error&quot; 핸들러로 전달</code></pre><h2 id="개발-중-발생한-이슈-url-패턴과의-충돌">개발 중 발생한 이슈: URL 패턴과의 충돌</h2>
<p>프로젝트 개발 중 Pathvariable URL 패턴을 사용하는 컨트롤러와 에러 처리 간의 충돌하는 문제가 있었다.</p>
<blockquote>
<pre><code class="language-java">@GetMapping(&quot;/{path}&quot;)
public ResponseEntity&lt;?&gt; handlePath(@PathVariable String path) {
    // ...
}</code></pre>
</blockquote>
<pre><code>
이런 패턴이 있을 때 AJAX 요청에서 오류가 발생하면, 에러 처리가 BasicErrorController로 제대로 되지 않는 현상이 있었다.

1. 에러 발생 후 클라이언트가 루트 URL로 이동을 시도하고 있었다.
2. 이 요청이 /{path} 패턴과 일치하여 기본 오류 처리 대신 이 컨트롤러가 호출되었다.
3. 결과적으로 BasicErrorController가 사용되지 않고 원래 의도한 오류 처리가 되지 않고 있었다.

## 문제 해결
이 문제를 해결하기 위해 아래와 같이 시도해보았다.
1. RestControllerAdvice에 Exception.class 추가
가장 효과적이었던 방법으로, 모든 예외를 캐치하여 일관된 방식으로 처리할 수 있었다.

&gt;```java
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RestControllerExceptionHandler {
    //...
    @ExceptionHandler(Exception.class)
    public ResponseEntity&lt;Object&gt; handleAllExceptions(Exception ex, WebRequest request) {
        Map&lt;String, Object&gt; body = new HashMap&lt;&gt;();
        body.put(&quot;message&quot;, ex.getMessage());
        body.put(&quot;timestamp&quot;, new Date());
        body.put(&quot;status&quot;, HttpStatus.INTERNAL_SERVER_ERROR.value());
        return new ResponseEntity&lt;&gt;(body, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}</code></pre><ol start="2">
<li>URL 패턴 수정
가장 직관적이고 간단한 해결책으로, 광범위한 패턴 대신 더 구체적인 URL 패턴을 사용하였다.</li>
</ol>
<blockquote>
<pre><code class="language-java">@GetMapping(&quot;/category/{path}&quot;) // &#39;/{path}&#39; 대신 더 구체적인 패턴</code></pre>
</blockquote>
<p>```</p>
<ol start="3">
<li>추가 에러 대응
/error 디렉토리에 상태 코드별 페이지를 추가하여 사용자가 직관적으로 어떤 문제에 직면했는지 알 수 있도록 하였다.</li>
</ol>
<h2 id="결론">결론</h2>
<p>Spring Boot의 기본 에러 처리 메커니즘을 이해함으로써, 다양한 상황에서 발생할 수 있는 예상치 못한 동작을 처리할 수 있게 되었다. 
특히 AJAX 요청과 광범위한 URL 패턴을 함께 사용할 때의 문제점을 이해했고 이에 대응하는 방법을 알았다.</p>
<p>실제 프로덕션 환경에서는 반드시 사용자 정의 예외 핸들러를 구현하여 로깅, 모니터링, 오류 메시지 등의 요구사항을 충족시키는 것이 바람직하다는 결론을 내렸다.
프로젝트를 통해 AJAX 요청에서는 /error로 리다이렉션되지 않고 JSON 형식으로 에러 응답을 받게 된다는 점을 활용하여, 클라이언트에서 더 적절하게 에러를 처리하고 사용자 경험을 개선할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AOP를 이용한 권한관리]]></title>
            <link>https://velog.io/@alstn_dev/AOP%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EA%B6%8C%ED%95%9C%EA%B4%80%EB%A6%AC</link>
            <guid>https://velog.io/@alstn_dev/AOP%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EA%B6%8C%ED%95%9C%EA%B4%80%EB%A6%AC</guid>
            <pubDate>Sun, 06 Nov 2022 03:49:40 GMT</pubDate>
            <description><![CDATA[<p> 프로젝트를 진행하면서 페이지별로 접근권한을 설정해줘야 할 때가 있다. 이전에 작성한 코드는 session에 저장된 role을 꺼내어 페이지에 접근 할 때 마다 권한을 체크 하였다.</p>
<p>case1)</p>
<p>🙍‍♀️: &#39;관리&#39; 페이지에 &#39;관리자&#39; 권한을 가진 사용자만 접근 가능하게 해주세요
🙍‍♂️:(관리 컨트롤러에서 권한체크 한다.)넵, 완료했습니다.</p>
<p>🙍‍♀️: &#39;계정관리&#39;, &#39;게시글관리&#39; .. 등 약 5만개의 페이지에 권한을 부여해주세요.
🙍‍♂️: ...</p>
<p>case2)
예를 들어 아래 샘플 코드처럼 AuthController 클래스에 있는 컨트롤러는 사용자가 인증이 되었는지를 체크하는 로직이 필요하다.
각 컨트롤러에서 <code>인증체크로직1,2</code>를 중복되어 작성되었다.</p>
<pre><code class="language-java">@Controller
public class AuthController {

    @GetMapping(&quot;...&quot;)
    public String auth1() {
        //인증 체크 로직1.. 
        //인증 체크 로직2.. 

        return &quot;.../page1&quot;;
    }

    @GetMapping(&quot;...&quot;)
    public String auth2(Model model) {
        //인증 체크 로직1.. 
        //인증 체크 로직2.. 

        return &quot;.../page2&quot;;
    }</code></pre>
<p>이럴 때 사용하는게 AOP이다. <strong>공통된 기능을 하나로 묶어 여러 로직에 적용</strong>하는 것이다.</p>
<h1 id="aop란">AOP란?</h1>
<p>Aspect oriented Programming의 약자로 관점 지향 프로그래밍이다. (OOP는 객체 지향 프로그래밍) 관점 지향? 바로 이해가 안 갈 수 있지만 쉽게 말하면 어떤 관점을 중점적으로 볼것이냐 라는것이다. 위 예로 이야기 하자면 &#39;권한 체크&#39; 라는 관점이 생길 수 있고 이 권한을 체크 하기 위해서는 세션이라는 관점이 새로 생길 수 있다.</p>
<h1 id="어노테이션-생성">어노테이션 생성</h1>
<p>권한 관리를 하기 위해서 AuthAnnotation을 만들어 보았다.</p>
<pre><code class="language-java">@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCheck {
    Role role() default Role.ROLE_MEMBER;
}</code></pre>
<ul>
<li>Target : 이 어노테이션이 부착될 수 있는 타입을 의미한다.</li>
<li>Retention : 어느 시점까지 메모리를 가져 갈것인지 설정한다.</li>
</ul>
<h1 id="인터셉터-생성">인터셉터 생성</h1>
<p>인증체크하는 로직을 작성한다. 일부 코드는 생략 했습니다.
세션에 저장된 값이 없으면 로그인이 안된것으로 간주하고 예외발생, 세션에 저장된 권한과 AuchCheck에 설정된 권한이 다를 경우 예외발생.</p>
<pre><code class="language-java">...

public void checkAuth(HttpSession session, Object handler) throws Exception {
    HandlerMethod method = (HandlerMethod)handler;
    AuthCheck authCheck = method.getAnnotationMethod(AuthCheck.Class);

    ...
    MemeberSession member = session.getAttribute(&quot;member&quot;);
    //로그인 권한이 없으면 thorw
    if( member == null ) {
        throw Exception(...UNAUTHENTICATED);
    }
    if(!member.role.equals(authCheck.role()) {
        throw Exception(...NO_PERMISSION);
    }
    ...
}</code></pre>
<h1 id="결론">결론</h1>
<p>결론 적으로 처음에 작성되었던 컨트롤러 클래스의 코드는 아래와 같이 변경될 수 있다.</p>
<pre><code class="language-java">//변경전
@Controller
public class AuthController {

    @GetMapping(&quot;...&quot;)
    public String auth1() {
        //인증 체크 로직1.. 
        //인증 체크 로직2.. 

        return &quot;.../page1&quot;;
    }

    @GetMapping(&quot;...&quot;)
    public String auth2(Model model) {
        //인증 체크 로직1.. 
        //인증 체크 로직2.. 

        return &quot;.../page2&quot;;
    }
---------------------------------------------
//변경 후
@Controller
@AuthCheck
public class AuthController {

    @GetMapping(&quot;...&quot;)
    public String auth1() {
        ...
        return &quot;.../page1&quot;;
    }

    @GetMapping(&quot;...&quot;)
    public String auth2(Model model) {
        ...
        return &quot;.../page2&quot;;
    }</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[ModelMapper란?]]></title>
            <link>https://velog.io/@alstn_dev/ModelMapper%EB%9E%80</link>
            <guid>https://velog.io/@alstn_dev/ModelMapper%EB%9E%80</guid>
            <pubDate>Sun, 19 Jun 2022 06:45:53 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/alstn_dev/post/82d8193d-6c9f-41dd-a5ad-20241b88c409/image.png" alt=""></p>
<p>프로젝트를 구현하다 보면 Entity, Dto를 나누어 사용했던 경험이 있을 것이다. 여러가지 상황이 있지만 JPA를 사용할 때 예를 들어보겠다.</p>
<h1 id="builder-사용-했을-때">Builder 사용 했을 때</h1>
<p><img src="https://velog.velcdn.com/images/alstn_dev/post/08a696e2-0ab3-4eee-88a1-6bab759fde8b/image.png" alt="">
위와 같이 JPA를 이용해서 TEST Entity클래스에 데이터를 가져왔고, builder 패턴을 이용해서 test -&gt; TestResponse로 변환하는 작업을 한다. (<em>이게 뭐 어때서?</em>)🤔</p>
<p>틀리거나 잘못된 코드가 아니다. 더욱이 Entity로 리턴을 하지않고 Response로 변환해서 데이터를 넘기는 것부터 Entity와 Dto의 역할 구분을 명확히 하고 있다는 뜻이므로 절대 잘못되거나 틀린 코드가 아니다. </p>
<p>다만 저 Test라는 Entity가 가진 객체가 위 사진처럼 5개가 아닌, 10개.. 혹은 20.. 아니 수십개라고 가정해보자. 그러면 저 builder 부분의 코드 줄 또한 몇십줄이 될거고 손가락은 부서질거다..😵</p>
<h1 id="modelmapper-사용-했을-때">ModelMapper 사용 했을 때</h1>
<p>거두절미 하고 같은 코드에 ModelMapper를 사용하면 코드가 어떻게 되는지 바로 보면 아래와 같다.
<img src="https://velog.velcdn.com/images/alstn_dev/post/ad9cf1ff-0f8c-4f5d-bb7a-11b9dd6dee34/image.png" alt="">
위에 builder로 하나하나 객체를 매핑해주던 일을 안하고 저 코드 한줄로 해결이 된다.</p>
<h1 id="modelmapper-사용법">ModelMapper 사용법</h1>
<p>먼저 dependencies를 추가해준다.</p>
<blockquote>
<pre><code>dependencies {
   ...

    implementation &#39;org.modelmapper:modelmapper:2.3.2&#39;
}</code></pre></blockquote>
<p>이후 컨테이너가 run할 때 인스턴스를 생성해주도록 Bean을 등록해준다.
<img src="https://velog.velcdn.com/images/alstn_dev/post/df7f081d-0bd0-4285-abd2-4605e35a5e7b/image.png" alt="">
이후 사용하고자 하는 로직에 아래와 같이 코드를 추가해준다.</p>
<pre><code>        TestResponse response = modelMapper.map(test,TestResponse.class);
</code></pre><p>이 때 규칙이 하나 있다. 앞선 예시 기준으로 Entity와 Dto에 있는 객체들의 이름이 같아야 자동으로 매핑이된다. 물론 이름이 달라도 addMapping이라는 메소드를 통해 커스텀하게 매핑이 가능하지만 이번 포스티의 목표는 ModelMapper라는 것이 있구나 정도이기 때문에 작성은 하지 않겠다🙂</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot로 REST API 서버 만들기]]></title>
            <link>https://velog.io/@alstn_dev/Spring-Boot%EB%A1%9C-REST-API-%EC%84%9C%EB%B2%84-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@alstn_dev/Spring-Boot%EB%A1%9C-REST-API-%EC%84%9C%EB%B2%84-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Sat, 18 Jun 2022 05:21:26 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/alstn_dev/post/5f6a5ef7-e339-4955-a128-9624993a3b97/image.png" alt=""></p>
<p>회사 생활을 하면서 Spring Boot로 웹서버를 만들어 본 경험이 있다. 이를 바탕으로 팀을 꾸려 AWS + Spring Boot + Python 에 React를 곁들인 MAIB(Match for AI Bot)프로젝트를 구현하기로 하였다. 이 프로젝트는 Battle Ground라는 게임에서 친구들과 다같이 할 경우 자동적으로 밸런스에 맞는 팀을 생성하게 도와주는 프로젝트이다. 먼저 Spring Boot를 통해 Oracle과 Python, React를 통신하는 API 서버를 만들어 보려 한다.</p>
<p>Spring boot 2.6.8 + Gradle 7.4.1 + Java 11 의 스펙으로 만들고 DB는 Oralce XE 11G R2버전으로 구현하려 한다.</p>
<h1 id="📌-spring-boot-프로젝트-생성">📌 Spring Boot 프로젝트 생성</h1>
<p>먼저 Spring Initializr를 통해 Gradle 기반의 Spring Boot 프로젝트를 생성했다. 이름은 mas(Maib Api Server)로 지었다. 프로젝트를 생성했을 때 가장먼저 하는 작업은 java버전 및 gradle 버전을 확인 설정하는 과정을 한다. 이후 Maven Repository (<a href="https://mvnrepository.com/">https://mvnrepository.com/</a>) 에서 필요한 라이브러리들을 추가하였다. 처음에 프로젝트를 만들 때 Spring Ititializr에서 추가 할 수 있지만, 어디에 사용되는지도 모르고 추가하는것보다는 필요한 라이브러리들을 찾아 그때 그때 추가하는 것을 선호한다(이게 공부에 더 좋은것 같다)👋. 나는 아래와 같이 라이브러리들을 추가하였다.
<img src="https://velog.velcdn.com/images/alstn_dev/post/39d7479b-cb84-4c0e-955d-affcad3bd57f/image.png" alt=""></p>
<p>여기서 조금 헤매었던것이 롬복 관련설정이었다. 처음에는 <code>compileOnly &#39;org.projectlombok:lombok:1.18.12&#39;</code> 만 추가해서 사용하는데 자꾸 Getter가 동작을 안하여 에러가 발생하였다. 그래서 찾아보니 Gradle 5버전 이상에서는 <code>annotationProcessor &#39;org.projectlombok:lombok:1.18.12&#39;</code> 도 같이 추가해줘야 한다는것을 알고 같이 추가해줬다!
이후 나머지는 Oracle, JPA, MYBATIS관련 설정이다.</p>
<h1 id="🔩-oracle-연결하기">🔩 Oracle 연결하기</h1>
<p>이 프로젝트는 AWS EC2환경에 Oracle을 올려 둔 상태이다. 따라서 application.properties에 아래와 같이 추가하였다. []에 들어가는 항목은 미리 설정해둔 Oracle 및 AWS설정에 맞게 입력하면 된다.</p>
<pre><code>spring.datasource.driver-class-name=oracle.jdbc.driver.OracleDriver
spring.datasource.url=jdbc:[HOST]:[PORT]/XE
spring.datasource.username=[USSERNAME]
spring.datasource.password=[PASSWORD]</code></pre><h1 id="프로젝트-구조-잡기">프로젝트 구조 잡기</h1>
<p>보통 프로젝트를 생성하고 구조를 잡는데는 많은 스타일이 있다. 나도 처음 프로젝트를 진행 할 때에는 아래와 같이 프로젝트 구조를 잡았다.</p>
<blockquote>
<h3 id="project">project</h3>
<p>&amp;nbsp&amp;nbsp&amp;nbsp <strong>controller</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp        HomeController
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp       BoardController
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp       MemberController
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp        ...
&amp;nbsp&amp;nbsp&amp;nbsp <strong>model</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp        HomeModel
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp       BoardModel
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp       MemberModel
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp        ...
&amp;nbsp&amp;nbsp&amp;nbsp <strong>repository</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp        HomeRepositroy
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp       BoardRepositroy
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp       MemberRepositroy
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp        ...
&amp;nbsp&amp;nbsp&amp;nbsp <strong>service</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp        HomeService
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp       BoardService
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp       MemberService
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp        ...</p>
</blockquote>
<p>지금 다시봐도 정신이없다. BoardController를 찾으려면 controller 패키지를 열어 찾아야하며 지금은 3개만 보이지만 무수히 많은 컨틀로러가 있다고 하면.. 벌써 어지럽다..🤮
그래서 다음과 같이 프로젝트 구조를 변경하고 지금도 이 구조를 유지하며 사용하고 있다. 장점은 훨씬 깔끔하며 기능에 맞는 패키지를 찾아 그 안에서 해결할 수 있다. </p>
<blockquote>
<h3 id="project-1">project</h3>
<p>&amp;nbsp&amp;nbsp&amp;nbsp__home__
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp <strong>controller</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp        HomeController
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp <strong>model</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp       HomeModel
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp <strong>repository</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp       HomeRepositroy
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp <strong>service</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp        HomeService
&amp;nbsp&amp;nbsp&amp;nbsp__board__
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp <strong>controller</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp        BoardController
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp <strong>model</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp       BoardModel
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp <strong>repository</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp       BoardRepositroy
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp <strong>service</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp        BoardService
&amp;nbsp&amp;nbsp&amp;nbsp__member__
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp <strong>controller</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp        MemberController
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp <strong>model</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp       MemberModel
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp <strong>repository</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp       MemberRepositroy
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp <strong>service</strong>
&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp&amp;nbsp        MemberService
&amp;nbsp&amp;nbsp&amp;nbsp&amp;...</p>
</blockquote>
<h2 id="controller">Controller</h2>
<p>먼저 테스트를 위해 TEST라는 스키마 안에 HELLO라는 테이블에서 정보를 가져온느 api를 만들어 보려한다. 컨트롤러는 아래와 같이 구현을 하였다.
<img src="https://velog.velcdn.com/images/alstn_dev/post/fe9214e7-1641-4c3a-9d72-418d4f9f450b/image.png" alt="">
(오 그냥 기본이네~ 그런데 ResponseEntity는 뭐지..?) 
나도  처음에 ResponseEntity가 뭐지 생각했다. 그냥 ResponseDto를 넘겨 주면 안되나 싶었다. 정답은 없다. 다만 ResponseEntity를 사용하는 이유는 명확하다.</p>
<p>먼저 ResponseEntity는 httpentity를 상속받아 HttpStatus상태코드도 함께 넘겨준다. 내가 이 상태코드도 같이 넘겨주려 하는 이유는 Exception을 관리 하기 위해서이다. 프로젝트를 진행하다 보면 Exception관리가 상당히 중요하다. 그래서 이 프로젝트에도 따로 BizExceptionCode를 구현하여 코드 관리를 해주었다.</p>
<h2 id="model">Model</h2>
<p>Model에는 Entity, Response, Request 세가지를 사용하는데 이번에는 Request를 제외한 두가지만 사용하였다. 
Entity에는 아래와 같이 스키마와 테이블을 명시해주었고, 그옆엔 Response이다.
<img src="https://velog.velcdn.com/images/alstn_dev/post/8aaa2fac-64dd-443d-9658-a63151d562ed/image.png" alt=""></p>
<h2 id="jpa">JPA</h2>
<p>JPA를 사용하기 위해 아래와 같이 Repository를 추가하였다.<img src="https://velog.velcdn.com/images/alstn_dev/post/8d65a717-1766-4009-b55b-cc52dca9e440/image.png" alt="">
그리고 JPA를 사용하면 쿼리를 잘 보지 않게 되는데 이를 방지하고 application.properties에 아래와 같이 추가하여 쿼리문을 log에서 볼 수 있게 하였다.
<img src="https://velog.velcdn.com/images/alstn_dev/post/966cdeea-16a5-4b12-b203-ba93f2c84e53/image.png" alt=""></p>
<h2 id="service">Service</h2>
<p>서비스 단은 아래와 같이 구현아였다. 여기서 중요한 부분은 findById에서 <code>orElseTrow</code>를 통해 예외 관리를 해준 부분이다. 해당 코드를 보면 BizException이라는 커스텀 클래스를 통해 예외처리를 하고 있다.
<img src="https://velog.velcdn.com/images/alstn_dev/post/df6bbf99-6ca0-4810-bf86-39e158ee1091/image.png" alt=""></p>
<h2 id="exception">Exception</h2>
<p>Exception관리를 하기위해 아래 두 클래스를 생성하였다.
BizException에서는 RuntimeException을 상속받아 구현하였다.
<img src="https://velog.velcdn.com/images/alstn_dev/post/d6e21354-433e-4342-afe1-378e1be2f4b5/image.png" alt="">
BizExceptionCode에는 status, code, message를 통해 커스텀하게 에러관리를 할수 있게 구현하였다.
<img src="https://velog.velcdn.com/images/alstn_dev/post/05494ccf-0ba3-4eb4-8002-00399a372280/image.png" alt=""></p>
<h1 id="결론">결론</h1>
<p>이와 같이 처음에 프로젝트 구조를 잡는일은 매우 중요하며, 시간을 들일 가치가 충분히 있다. 마지막으로 위에서 작성한 api의 테스트 결과이다.</p>
<h3 id="결과">결과</h3>
<p><img src="https://velog.velcdn.com/images/alstn_dev/post/eb5d5381-d646-4111-9f8b-98a8844d46c8/image.png" alt=""></p>
<h3 id="쿼리-로그">쿼리 로그</h3>
<p><img src="https://velog.velcdn.com/images/alstn_dev/post/e13957c7-9dbe-4bf7-b955-3fa6b46ef88c/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Map보다 Dto를 사용해야 하는 이유]]></title>
            <link>https://velog.io/@alstn_dev/Map%EB%B3%B4%EB%8B%A4-Dto%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@alstn_dev/Map%EB%B3%B4%EB%8B%A4-Dto%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Sat, 28 May 2022 01:35:53 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/alstn_dev/post/56814835-311b-4879-98d2-3e3c29ddfd8f/image.png" alt=""></p>
<blockquote>
<p>💡 코드를 작성하면서 두가지 정보를 담아서 보내야 할 때 처음에 Map&lt;String, Object&gt;로 보냈습니다. 문득 Map과 Dto중에 어떤것이 효율적인지 궁금증이 생겨 개인적인 생각을 기록합니다.</p>
</blockquote>
<h1 id="map">Map</h1>
<p>Key와 Value가 한쌍으로 값을 저장하는 컬렉션입니다. 예를 들어 “이름” = “조민수”, “직업” = “개발자” 이런식으로 각 value에 해당되는 key를 부여하여 저장.</p>
<h1 id="dto">DTO</h1>
<p>계층 간의 데이터 교화을 하는 객체로, 보통 Getter, Setter로 구성됨</p>
<h1 id="map의-단점">Map의 단점</h1>
<h2 id="1-가독성이-떨어진다">1. 가독성이 떨어진다.</h2>
<p>Map은 위에서 언급한 것 처럼, Key와 Value로 저장이된다. 이때 해당 값을 보고 한번에 어떤 타입인지 파악하기 어렵다. 만약 이 map이 이중, 그 이상으로 되어있다면 가독성은 더욱 떨어질 것이다.</p>
<h2 id="2-에러를-쉽게-유발-할-수-있다">2. 에러를 쉽게 유발 할 수 있다.</h2>
<p>Map에 담겨진 값을 사용하려면 해당 key값을 통해 가져온다. 이 때 키의 값이 다르게 입력했을 때 에러를 쉽게 발견 하지 못할 수 있다.</p>
<p>추가적으로 Map은 보통 Map&lt;String, Object&gt; 형식으로 쓰이는데 이 때 Object에 int값이 들어가길 기대하지만 실수로 Long타입을 넣어도 컴파일 과정에서는 에러가 발생하지 않는다.</p>
<h2 id="3-불변성을-확보-할-수-없다">3. 불변성을 확보 할 수 없다.</h2>
<p>불변성이란 내부의 상태가 변하지 않는 성질이다.  불변성을 확보해야 하는 이유는 다양하지만 개인적으로 가장 큰 이유는 협업을 진행하면서 값이 보장된다면 다른사람이 개발한 함수를 위험, 부담없이 사용할 수 있다. 반대로 내가 개발한 함수를 다른 사람이 부담없이 사용할 수 있다. </p>
<p>하지마 Map은 이러한 불변성을 확보 하기 어렵다. 코드 작성 시 다른 사람이 잘못된 값을 put해버리면 코드는 엉망이 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[@builder vs 생성자]]></title>
            <link>https://velog.io/@alstn_dev/builder-vs-%EC%83%9D%EC%84%B1%EC%9E%90</link>
            <guid>https://velog.io/@alstn_dev/builder-vs-%EC%83%9D%EC%84%B1%EC%9E%90</guid>
            <pubDate>Fri, 27 May 2022 15:07:19 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>💡 JPA를 이용하면서 Entity와 Dto클래스를 사용하였다. 이 때 builder 패턴을 이용해서 개발을 하였는데 그 이유를 명확히 하고자 기록한다.</p>
</blockquote>
<h1 id="builder-패턴을-사용해야-하는-이유">Builder 패턴을 사용해야 하는 이유</h1>
<h2 id="builder-패턴의-장점">Builder 패턴의 장점</h2>
<ul>
<li>필요한 데이터만 설정 가능</li>
<li>명확한 데이터 설정이 가능</li>
<li>높은 가독성</li>
</ul>
<h3 id="필요한-데이터만-설정-가능">필요한 데이터만 설정 가능</h3>
<p>예를 들어 user라는 객체가 있고 name, age, gender이라는 파라미터를 받아야 하는데, 굳이 age라는 파라미터를 받지 않아도 되는 상황이 있다. 이 때 생성자를 이용해서 구현 하면 dummy값을 넣어줘야한다. 하지만 빌더 패턴을 이용하면 해당 값을 제외하고 객체를 생성할 수 있다.</p>
<pre><code class="language-java">...
@Data
@Builder
public class User {
    private String name;
    private int age;
    private String gender;
}
...

User user = User.builder()
            .name(name)
            .gender(gender)
            .build();</code></pre>
<h3 id="명확한-데이터-설정이-가능">명확한 데이터 설정이 가능</h3>
<p>만일 위 데이터에서 builder 패턴을 이용하지 않고 생성자를 이용해서 객체를 생성했다고 생각해보면 아래와 같이 코드를 작성 할 수 있다.</p>
<pre><code class="language-java">//생성자
public setUser(String name, String gender){
    this.name = name;
    this.gender = gender;
}

....
 //빌더 패턴
User user = User.builder()
            .name(name)
            .gender(gender)
            .build();</code></pre>
<p>이렇게 되면 개발자가 setUser(gender, name)으로 해도 에러가 나지않을 것이고 코드를 실행 후에 잘못을 알게된다. 따라서 이러한 방법보다 좀더 직관적인 빌더 패턴을 쓰는것이 코드를 작성할 떄 명확해 진다.</p>
<h3 id="높은-가독성">높은 가독성</h3>
<p>만일 파라미터의 갯수가 10개가 있다고 생각해보면 생성자를 이용해서 객체를 생성할 때 가독성이 떨어질 수 있다. 하지만 빌더패턴을 이용하면 위에서 언급한 것과 마찬가지로 명확해 지며 가독성을 높일 수 있다.</p>
<pre><code class="language-java">//생성자
setUser(a,b,c,d,e,f,g,h,i,j,k,...)

...
//빌더 패턴
User user = User.builder()
                        .id(a)
            .name(b)
            .email(c)
            .nickname(d)
            .mobileNumber(e)
            .gender(f)
            .address(g)
                            ...
            .role(k)
            .build();</code></pre>
<p>이처럼 생성자와 빌더 패턴 모두 생성시점에 값을 채워주는 역할은 같지만 개발하면서 느끼기에 builder패턴을 쓰는것이 더욱 명확하며 제 3자가 내 코드를 봤을 때 가독성이 높아 builder패턴을 선호하며 쓰고있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[RESTful API란?]]></title>
            <link>https://velog.io/@alstn_dev/RESTful-API%EB%9E%80</link>
            <guid>https://velog.io/@alstn_dev/RESTful-API%EB%9E%80</guid>
            <pubDate>Fri, 27 May 2022 14:58:24 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/alstn_dev/post/0666a555-8aee-4105-9df3-84277ded7c23/image.png" alt=""></p>
<h1 id="restful-api란">RESTful API란</h1>
<p>REpresentational State Transfer의 약어로, HTTP를 활용해서 데이터에 접근하고 데이터
를 다루는 API아키텍쳐 스타일이다.</p>
<h1 id="구성요소">구성요소</h1>
<ul>
<li>자원(Resource) : HTTP URL</li>
<li>자원에 대한 행위 : HTTP Method</li>
<li>자원에 대한 행위 내용 : HTTP Message Pay Load</li>
</ul>
<h1 id="rest의-특징">REST의 특징</h1>
<h3 id="1-유니폼-인터페이스">1) 유니폼 인터페이스</h3>
<p>Uniform Interface는 URI로 조작은 통일되고, 한정적 인터페이스로 수행하는 아키텍쳐 스타일</p>
<h3 id="2-무상태성stateless">2) 무상태성(Stateless)</h3>
<p>세션정보 또는 쿠키와 같은 작업을 위한 상태정보를 따로 저장하고 관리하지 않고 단순 요청만 처리하면된다. 이로인해 서비스의 자유도가 높아지고, 서버에서 불필요한 정보를 관리하지 않음으로서 구현이 단순해진다.</p>
<h3 id="3-캐싱cacheable">3) 캐싱(Cacheable)</h3>
<p>웹에서 사용하는 기존 인프라를 그대로 활용이 가능하다. 따라서 HTTP가 가진 캐싱기능이 적용이 가능하다.</p>
<h3 id="4-자체-표현-구조self-descriptiveness">4) 자체 표현 구조(Self-descriptiveness)</h3>
<p>RESP API 주소만 보고도 쉽게 이해할 수 있는 자체 표현구조로 되어있다.</p>
<h3 id="5-client---server-구조">5) Client - Server 구조</h3>
<p>클라이언트는 UI, Request, Server는 데이터 관라, 사용자 인증하는 것 처럼 역할이 확실히 구분되어 클라이언트와 서버에서 개발할 내용이 명확해지고 서로간 의존성이 줄어들게 된다.</p>
<h2 id="rest-api-uri-디자인-규칙">REST API URI 디자인 규칙</h2>
<h3 id="1-uri는-정보의-자원을-표현해야하므로-동사보단-명사를-사용">1) URI는 정보의 자원을 표현해야하므로 동사보단 명사를 사용</h3>
<p><strong>잘못된 예시</strong></p>
<pre><code class="language-java">GET /members/delete/1
GER /members/show/1</code></pre>
<p><strong>올바른</strong> <strong>예시</strong></p>
<pre><code class="language-java">DELETE /members/1
GET /members/1</code></pre>
<h3 id="2-슬래시는-계층-관계를-나타내는데-사용-마지막문자로-슬래시-포함-x">2) 슬래시(/)는 계층 관계를 나타내는데 사용( 마지막문자로 슬래시 포함 X)</h3>
<h3 id="3-언더바-_-는-사용하지말고-하이픈---을-사용">3) 언더바( _ )는 사용하지말고, 하이픈( - )을 사용</h3>
<h3 id="4-uri-경로에-대문자-사용을-피하고-소문자로-작성">4) URI 경로에 대문자 사용을 피하고, 소문자로 작성</h3>
<h3 id="5-파일-확장자는-uri에-미포함">5) 파일 확장자는 URI에 미포함</h3>
<p>Accept Header를 이용하여 보내자</p>
<pre><code class="language-java">http://localhost:8080/members/info/photo.jpg (x)</code></pre>
<h3 id="6-collection과-document">6) Collection과 Document</h3>
<p>Document는 객체이고 이 객체를 모아둔것이 Collection이다. 아래와 같이 Collection에 해당하는 단어는 복수형으로 표시하면 조금 더 이해하기 쉬운 URI를 만들 수 있다.</p>
<pre><code class="language-java">http://localhost:8080/sports/soccer</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Pathvariable vs RequestParam ?]]></title>
            <link>https://velog.io/@alstn_dev/Pathvariable-vs-RequestParam</link>
            <guid>https://velog.io/@alstn_dev/Pathvariable-vs-RequestParam</guid>
            <pubDate>Fri, 27 May 2022 14:46:05 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/alstn_dev/post/51973ce9-3055-4f07-8f3a-4d22e7006303/image.png" alt=""></p>
<blockquote>
<p>💡 스프링에서 데이터를 전달해주는 대표적인 방법 두가지가 있습니다. 사용을 하면서 비슷한 부분이 많지만 다른점이 뭐가 있을까에 대해 공부하며 기록해보았습니다.</p>
</blockquote>
<h1 id="공통점">공통점</h1>
<p>두 어노테이션 전부 데이터를 전달하기 위해 쓰입니다. uri를 통해 전달된 값을 파라미터로 받아오는 역할을 합니다.</p>
<h1 id="차이점">차이점</h1>
<h2 id="pathvariable">Pathvariable</h2>
<p>Pathvariable는 <code>[http://localhost:8080/board/10](http://localhost:8080/board/10)</code> 과 같이 uri를 사용합니다.</p>
<p>특징으로는 전달되는 값, 즉 위의 uri에서는 10과 같은 값을 하나밖에 전달 못한다는 점 입니다.</p>
<h2 id="requestparam">RequestParam</h2>
<p>RequestParam은 <code>[http://localhoust:8080/board?id=1&amp;page=2](http://localhoust:8080/board?id=1&amp;page=2)</code> 와 같이 uri를 사용합니다.</p>
<p>Pathvariable과는 다르게 인자를 하나 이상 받을 수 있습니다. 해당 어노테이션은 아래와 같이 사용할 수 있습니다. </p>
<pre><code class="language-java">public String getPage(@RequestParam(value=&#39;id&#39;, requires=false)Long id,
                                            @RequestParam(value=&#39;page&#39;, requires=false)int page) {
    ...
}</code></pre>
<p>특징으로는 아래와 같이 4개의 옵션이 있습니다.</p>
<ul>
<li>defaultValue : 값이 없을 때 기본적으로 전달 할 값</li>
<li>name : uri에서 바인딩 할 파라미터 이름</li>
<li>value : uri에서 바인딩하여 별칭으로 정할 값</li>
<li>required : 필수적으로 값이 전달되어저야 하는 파라미터</li>
</ul>
<p>결론적으로는 둘다 데이터를 받아오는데에 사용하고, 하나 이상의 값을 받아 올때에는 RequestParam을 사용하지만 필요에 따라 두 어노테이션을 혼합해서 사용 할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[IP접속과 도메인접속의 차이]]></title>
            <link>https://velog.io/@alstn_dev/IP%EC%A0%91%EC%86%8D%EA%B3%BC-%EB%8F%84%EB%A9%94%EC%9D%B8%EC%A0%91%EC%86%8D%EC%9D%98-%EC%B0%A8%EC%9D%B4</link>
            <guid>https://velog.io/@alstn_dev/IP%EC%A0%91%EC%86%8D%EA%B3%BC-%EB%8F%84%EB%A9%94%EC%9D%B8%EC%A0%91%EC%86%8D%EC%9D%98-%EC%B0%A8%EC%9D%B4</guid>
            <pubDate>Fri, 27 May 2022 14:08:40 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/alstn_dev/post/8543fcf9-d4b3-4ace-b32f-a04e19ad8230/image.png" alt=""></p>
<h1 id="ip란">IP란?</h1>
<p>우리가 여러 서비스를 이용할 때 다양한 서버에 연결을 하는데, 이 다양한 서버를 구분은 IP주소 체계를 바탕으로 이루어진다. IP주소는 0~255사이의 숫자로 이루어진 구조이다. </p>
<h1 id="도메인이란">도메인이란?</h1>
<p>이 숫자로 되어진 주소를 기억하기에 쉽지가 않아 기억하기 쉬운 문자 형태로 주소를 변환하는데 이것이 도메인이다. 해당 변환 작업을 DNS(Domain Name System)라고 한다. </p>
<p>로컬 PC는 IP로 <code>127.0.0.1</code> 은 도메인으로 localhost로 사용하지만 대부분 도메인은 일정 기간동안 비용을 지불하여 사용한다. </p>
<h1 id="dns란">DNS란?</h1>
<p>Domain Name System의 약자로 도메인 이름을 IP주소로 변환하거나 IP주소를 도메인주소로 변환하는것을 도와주는 시스템이다. </p>
<h1 id="dns-순서">DNS 순서</h1>
<ul>
<li>브라우저에 도메인주소를 입력한다.<ul>
<li>ex) <a href="http://www.naver.com">www.naver.com</a></li>
</ul>
</li>
<li>해당 도메인 값으로 DNS에서 IP주소를 찾는다<ul>
<li>ex) <a href="http://www.naver.com">www.naver.com</a> → 223.130.200.107</li>
</ul>
</li>
<li>해당 IP주소와 연결된 웹 서버로 요청을 보내 클라이언트에 응답을 주어 통신이 가능하도록 한다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Session Storage를 이용한 뒤로 가기]]></title>
            <link>https://velog.io/@alstn_dev/Session-Storage%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%92%A4%EB%A1%9C-%EA%B0%80%EA%B8%B0</link>
            <guid>https://velog.io/@alstn_dev/Session-Storage%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%92%A4%EB%A1%9C-%EA%B0%80%EA%B8%B0</guid>
            <pubDate>Thu, 07 Apr 2022 06:36:30 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/alstn_dev/post/c0e868ef-8f2f-4d09-b8f2-f4c59425cd5f/image.png" alt=""></p>
<blockquote>
<p>💡 프로젝트를 진행하면서 특정 목록(이하 List)을 조회하는 기능이 있습니다.<br>
이 때 검색은 페이지 이동없는 ajax로 비동기식 방식으로 진행을 합니다. <br>
목록중에 하나를 눌러 상세보기(이하 detailView)로 들어갈 수 있습니다. <br>
해당 detailView로 들어가고 나서 다시 뒤로가기 혹은 목록버튼을 누르면 이전에 ajax로 검색했던 결과는 사라지게됩니다.<br> 
URL 변경 없이 검색을 한것이고 브라우저상에서 뒤로가기를 눌렀을 때 ajax를 실행하지 않고 단순 이전 html 페이지를 보여주기 때문입니다. <br>
이러한 부분은 사용자 입장에서 너무 불편하다고 생각하여 <strong>Session Storage방식</strong>을 이용하여 구현하였습니다.</p>
</blockquote>
<h1 id="session-storage란">Session Storage란?</h1>
<p>Session Storage란 브라우저상에 Session을 key,value 형태로 저장하는 방식 입니다.</p>
<h1 id="적용">적용</h1>
<h2 id="뒤로가기-이벤트">뒤로가기 이벤트</h2>
<p>먼저 list에는 검색을 하는 함수가 있습니다. </p>
<pre><code class="language-jsx">function search(){

    var params = {
        searchType: $(&quot;#searchType&quot;).val(),
        keyword: $(&quot;#keyword&quot;).val(),
        pageNum: $(&quot;#pageNum&quot;).val(),
        pageSize: $(&quot;#pageSize&quot;).val()
    }

    $.ajax({
        type: &#39;GET&#39;,
        url: &#39;/search&#39;,
        data: params,
        success: function(){
            ...
        },
        error: function() {
            ...
        }
    });
}</code></pre>
<p>그리고 게시글 제목을 눌러 detailView로 이동을 한다고 가정을 해보면 해당 title에는 아래와 같이 이벤트를 달아주었습니다. 해당 제목부분 클릭 시 현재 검색되었던 조건들이 sessionStorage에 해당 항목들이 저장이 되고 url에 설정된 링크로 넘어가게 됩니다.</p>
<p>이벤트를 <code>$(”.title”).on(&#39;click&#39;,function(){...}</code>. 가 아닌 <code>$(document).on(&#39;click&#39;,&#39;.title&#39;function(){...}</code>로이벤트를 설정 한 이유는 해당 title은 검색을 하고 그려지는 영역이기 때문에 페이지를 로드하자마자 이벤트를 걸어주었습니다.</p>
<pre><code class="language-jsx">&lt;a class=&#39;title&#39; href=&#39;url..&#39;&gt;제목...&lt;/a&gt;

        ...
&lt;script type=&quot;text/javascript&quot;&gt;
    $(document).on(&#39;click&#39;,&#39;.title&#39;,function(){
        sessionStorage.setItem(&quot;searchType&quot;,$(&quot;#searchType&quot;).val();
        sessionStorage.setItem(&quot;keyword&quot;,$(&quot;#keyword&quot;).val();
        sessionStorage.setItem(&quot;pageNum&quot;,$(&quot;#pageNum&quot;).val();
        sessionStorage.setItem(&quot;pageSize&quot;,$(&quot;#pageSize&quot;).val();
    }
&lt;/script&gt;</code></pre>
<p><img src="blob:https://velog.io/09cc78b5-bead-438f-bcd0-efc9084199d7" alt="업로드중.."></p>
<p>이제 detailView에서 뒤로가기 이벤트가 발생되었는지 확인이 필요합니다. 그러기 위해서는 List에 아래와 같은 코드를 작성합니다.</p>
<p><code>onpageshow</code>는 페이지가 로드되면 실행됩니다. 페이지가 로드되면 해당 페이지 로드 이벤트에 뒤로가기값이 true인지 확인을 합니다. 값이 확인되면 if문을 타면서 html의 각 검색항목 부분에 detailView를 누르기전의 정보를 담습니다. 그리고 검색 함수를 실행하며 list들을 보여줍니다. 그 이후에는 해당 <code>sessionStorage</code>를 clear하며 비워줍니다.</p>
<pre><code class="language-jsx">window.onpageshow = function(ev) {
    if(ev.persisted || (window.performace &amp;&amp; window.performance.navigation.type ==2)){
        $(&quot;#searchType&quot;).val(sessionStorage.getItem(&quot;searchType&quot;));
        $(&quot;#keyword&quot;).val(sessionStorage.getItem(&quot;keyword&quot;));
        $(&quot;#pageNum&quot;).val(sessionStorage.getItem(&quot;pageNum&quot;));
        $(&quot;#pageSize&quot;).val(sessionStorage.getItem(&quot;pageSize&quot;));

        search();
    }
    sessionStorage.clear();
}</code></pre>
<h2 id="목록-버튼-누를-때">목록 버튼 누를 때</h2>
<p>뒤로가기는 앞서 말한것과 같이 해결을 하였습니다. 그래서 <strong>목록</strong> 버튼도 <code>window.history.back();</code> 을 달아서 뒤로가게 해야지 라는 생각을 할수도 있습니다. 하지만 프로젝트를 하다보면 detailView_1에서 의도치 않게 다른 detailView_2를 이동하는 경우가 있습니다. </p>
<p>이 때 <strong>목록</strong> 버튼을 단순 <code>window.history.back();</code> 을 이용하여 구현하였다면 사용자가 목록버튼을 눌렀을 때 detailView_2에서 detailView_1로 이동하게됩니다. 그러면 어떻게해?!!</p>
<p>그러면 이제 방법은 목록버튼은 단순 list로 가는 링크를 연결해야 하지만 이 때 브라우저에게 <strong>“나 목록으로 돌아간다. 아까 검색한거 세팅해놔!”</strong> 라는 신호를 줘야합니다. 이 부분은 아래와 같이 구현하였습니다.</p>
<pre><code class="language-jsx">&lt;button id=&quot;goListBtn&quot; type=&quot;button&quot;&gt;목록&lt;/button&gt;

    ...

&lt;script type=&quot;text/javascript&quot;&gt;
    $(&quot;#goListBtn&quot;).on(&#39;click&#39;,function(){
        sessionStorage.setItem(&#39;isGoList&#39;,&#39;Y&#39;);   //세션에 goList를 Y라고 세팅
        window.location.href = &#39;/list&#39;;
    }
&lt;/script&gt;</code></pre>
<p>그리고 위에서 설정한 조건도 수정이 필요합니다. 아래와 같이 목록으로 불러서 왔는지 확인을 해주고 맞다면 로직을 수행하게됩니다.</p>
<pre><code class="language-jsx">window.onpageshow = function(ev) {
    **var isGoList = sessionStorage.getItem(&#39;isGoList&#39;);**
    if(ev.persisted || (window.performace &amp;&amp; window.performance.navigation.type ==2)
            ||**isGoList  ==&#39;Y&#39;**){ //목록으로 돌아가기 버튼을 눌러서 왔는지 확인
        $(&quot;#searchType&quot;).val(sessionStorage.getItem(&quot;searchType&quot;));
        $(&quot;#keyword&quot;).val(sessionStorage.getItem(&quot;keyword&quot;));
        $(&quot;#pageNum&quot;).val(sessionStorage.getItem(&quot;pageNum&quot;));
        $(&quot;#pageSize&quot;).val(sessionStorage.getItem(&quot;pageSize&quot;));

        search();
    }
    sessionStorage.clear();
}</code></pre>
<p><img src="blob:https://velog.io/cf1d3158-e340-431d-9fc3-392181152e1d" alt="업로드중.."></p>
<h2 id="전체코드">전체코드</h2>
<h3 id="list">List</h3>
<pre><code class="language-jsx">&lt;a class=&#39;title&#39; href=&#39;url..&#39;&gt;제목...&lt;/a&gt;

        ...
&lt;script type=&quot;text/javascript&quot;&gt;

    function search(){
        var params = {
            searchType: $(&quot;#searchType&quot;).val(),
            keyword: $(&quot;#keyword&quot;).val(),
            pageNum: $(&quot;#pageNum&quot;).val(),
            pageSize: $(&quot;#pageSize&quot;).val()
        }

        $.ajax({
            type: &#39;GET&#39;,
            url: &#39;/search&#39;,
            data: params,
            success: function(){
                ...
            },
            error: function() {
                ...
            }
        });
    }

    ...

    $(document).on(&#39;click&#39;,&#39;.title&#39;,function(){
        sessionStorage.setItem(&quot;searchType&quot;,$(&quot;#searchType&quot;).val();
        sessionStorage.setItem(&quot;keyword&quot;,$(&quot;#keyword&quot;).val();
        sessionStorage.setItem(&quot;pageNum&quot;,$(&quot;#pageNum&quot;).val();
        sessionStorage.setItem(&quot;pageSize&quot;,$(&quot;#pageSize&quot;).val();
    }

    ...

    window.onpageshow = function(ev) {
        var isGoList = sessionStorage.getItem(&#39;isGoList&#39;);
        if(ev.persisted || (window.performace &amp;&amp; window.performance.navigation.type ==2)
                ||isGoList  ==&#39;Y&#39;){ //목록으로 돌아가기 버튼을 눌러서 왔는지 확인
            $(&quot;#searchType&quot;).val(sessionStorage.getItem(&quot;searchType&quot;));
            $(&quot;#keyword&quot;).val(sessionStorage.getItem(&quot;keyword&quot;));
            $(&quot;#pageNum&quot;).val(sessionStorage.getItem(&quot;pageNum&quot;));
            $(&quot;#pageSize&quot;).val(sessionStorage.getItem(&quot;pageSize&quot;));

            search();
        }
        sessionStorage.clear();
    }
&lt;/script&gt;</code></pre>
<h3 id="detailview">DetailView</h3>
<pre><code class="language-jsx">&lt;button id=&quot;goListBtn&quot; type=&quot;button&quot;&gt;목록&lt;/button&gt;

    ...

&lt;script type=&quot;text/javascript&quot;&gt;
    $(&quot;#goListBtn&quot;).on(&#39;click&#39;,function(){
        sessionStorage.setItem(&#39;isGoList&#39;,&#39;Y&#39;);   //세션에 goList를 Y라고 세팅
        window.location.href = &#39;/list&#39;;
    }
&lt;/script&gt;</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[@이벤트, import & export]]></title>
            <link>https://velog.io/@alstn_dev/%EC%9D%B4%EB%B2%A4%ED%8A%B8-import-export</link>
            <guid>https://velog.io/@alstn_dev/%EC%9D%B4%EB%B2%A4%ED%8A%B8-import-export</guid>
            <pubDate>Sat, 19 Mar 2022 15:15:05 GMT</pubDate>
            <description><![CDATA[<p><img src="https://images.velog.io/images/alstn_dev/post/067babd4-c5e6-4535-98d5-837f2117cbd6/image.png" alt=""></p>
<h1 id="이벤트">@이벤트</h1>
<p>JS에서 이벤트를 달려고 하면 onclik에 원하는 메소드를 넣어 동작을 했다. Vue에서는 @를 붙이고 원하는 이벤트를 넣을 수 있다. 아래 코드는 허위매물신고라는 버튼을 만든 후 클릭 시 해당 매물의 신고수의 갯수가 하나씩 늘어나느 코드이다. </p>
<p>먼저 매물의 갯수가 3개이므로 신고수의 data를 배열로 담았다. 이후 신고늘리기라는 함수를 만들어 클릭이벤트에 걸어주었다. Vue에서 함수를 만들 때는 methods 라고 선언 후 그 안에 함수를 작성하면 된다. 이후 초기화 버튼을 누르면 해당 신고수의 갯수를 0으로 초기화하는 매우 매우 간단한 함수연습 코들르 작성하였다.</p>
<pre><code class="language-jsx">&lt;div v-for=&quot;(product,i) in products&quot; :key=&quot;i&quot;&gt;    
     ...
    &lt;p&gt;신고수 : {{신고수[i]}} &lt;/p&gt;
    &lt;button style=&quot;cursor:pointer; margin-right : 20px&quot; @click=&quot;신고늘리기(i)&quot;&gt;허위매물신고&lt;/button&gt;
    &lt;button style=&quot;cursor:pointer&quot; @click=&quot;신고수[i]=0&quot;&gt;초기화&lt;/button&gt; 
&lt;/div&gt;

...

export default {
  name: &#39;App&#39;,
  data(){
    return{
      ...
      신고수 : [0,0,0],
      ...
    }
  },

  methods : {
    신고늘리기(i) {
      this.신고수[i]++;
    }
  },
  ...
}</code></pre>
<p><img src="https://images.velog.io/images/alstn_dev/post/5285a7ee-6b1d-452c-a535-5f0cae2b490f/image.png" alt=""></p>
<p>다음은 모달창을 만들고 모달창을 띄우는 이벤트를 연습해 보려고한다.간단하게 모달창을 하나 만들었다. 아래 코드에 나온 <strong>V-if</strong>는 thymeleaf의 if문법과 같다. 원하는 if문 “”안의 조건이되면 해당 div를 노출시킨다. 반대로 조건에 맞지 않을 경우에는 노출시키지 않는다. </p>
<pre><code class="language-jsx">&lt;div class=&quot;black-bg&quot; v-if=&quot;modal== true&quot;&gt;
    &lt;div class=&quot;white-bg&quot;&gt;
      &lt;span @click=&quot;modal=false&quot; style=&quot;float : right; cursor:pointer;&quot;&gt;x&lt;/span&gt;
      &lt;h4&gt;상세페이지&lt;/h4&gt;
      &lt;p&gt;상세페이지 내용&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;

...

&lt;style&gt;
body{
  margin: 0;
}
div {
  box-sizing: border-box;
}
.black-bg {
  width: 100%; height: 100%;
  background: rgba(0,0,0,0.5);
  position: fixed; padding: 20px;
}
.white-bg{
  widows: 100%; background: white;
  border-radius: 8px;
  padding: 20px;

}

...

&lt;/style&gt;</code></pre>
<p><img src="https://images.velog.io/images/alstn_dev/post/02e2a897-b48b-4c14-926f-b17e7cc92f92/image.png" alt=""></p>
<h1 id="import-export">Import, Export</h1>
<p>실무를 진행 하다 보면 api서버에서 필요한 데이터를 뽑아다가 쓰는 경우가 많다. 이를 연습해보기 위해 가상의 json형식의 data를 받아왔다고 생각하고 import, export하는 연습을 해보려 한다.</p>
<p>먼저 가상의 데이터이다. 해당 data들을 묶어 data라고 이름을 지었다. 그리고 realData.js파일에 저장을 해놓았다. 이후 해당 data를 다른 js에서도 쓸 수 있도록  <code>export</code>라는 것을 해줘야한다. 코드 제일 아래쪽을 보면 <code>export default roomData</code> 라고 적어놓았다. 이는 data라는 변수를 밖으로 빼! 라는 뜻이다. 이후 해당 데이터를 쓸 js에서 <code>import roomData from ‘./경로/파일명.js’</code>라고 적어주면 사용이 가능하다. </p>
<p>만약 하나이상의 변수를 export하고 싶다면 <code>export {data1, data2}</code>라고 적으면 된다. 이후 import시에도 <code>import {data1, data2} from &#39;./경로/파일명.js&#39;</code>라고 적어주면 사용이 가능하다. </p>
<p>이때 vue에서는 import된 변수를 사용하지 않으면 에러가 발생한다!</p>
<pre><code class="language-jsx">var roomData =  [{
    id : 0,
    title: &quot;Sinrim station 30 meters away&quot;,
    image: &quot;https://codingapple1.github.io/vue/room0.jpg&quot;,
    content: &quot;18년 신축공사한 남향 원룸 ☀️, 공기청정기 제공&quot;,
    price: 340000
    },
    {
    id : 1,
    title: &quot;Changdong Aurora Bedroom(Queen-size)&quot;,
    image: &quot;https://codingapple1.github.io/vue/room1.jpg&quot;,
    content: &quot;침실만 따로 있는 공용 셰어하우스입니다. 최대 2인 가능&quot;,
    price: 450000
    },

    ...

    {
    id : 5,
    title: &quot;Banziha One Room&quot;,
    image: &quot;https://codingapple1.github.io/vue/room5.jpg&quot;,
    content: &quot;반지하 원룸입니다. 비올 때 물가끔 새는거 빼면 좋아요&quot;,
    price: 370000
  }];

  export default roomData ;</code></pre>
<p>위 스크립트를 아래 스크립트에 import하였다. 해당 roomData를 import시켰고, div에 해당 import된 값을 적용하였다. for문을 돌려주어 해당 인자값들을 꺼내어 view에 뿌려주었다.</p>
<pre><code class="language-jsx">&lt;div v-for=&quot;(product,i) in rooms&quot; :key=&quot;i&quot;&gt;    
    &lt;img class=&quot;romm-img&quot; :src=&quot;product.image&quot;&gt;
    &lt;h4 @click=&quot;modal=true&quot; style=&quot;cursor:pointer&quot;&gt;{{product.title}}&lt;/h4&gt;
    &lt;h4&gt;{{product.content}}&lt;/h4&gt;
    &lt;h4&gt;가격 : {{product.price}}원&lt;/h4&gt;
    &lt;p&gt;신고수 : {{신고수[i]}} &lt;/p&gt;
    &lt;button style=&quot;cursor:pointer; margin-right : 20px&quot; @click=&quot;신고늘리기(i)&quot;&gt;허위매물신고&lt;/button&gt;
    &lt;button style=&quot;cursor:pointer&quot; @click=&quot;신고수[i]=0&quot;&gt;초기화&lt;/button&gt;
&lt;/div&gt;

...

&lt;script&gt;
import roomData from &#39;./realdata.js&#39;;

export default {
  name: &#39;App&#39;,
  data(){
    return{
      rooms : roomData,
...
    }
}
&lt;/script&gt;</code></pre>
<p><img src="https://images.velog.io/images/alstn_dev/post/e46297b0-3378-4294-bc67-eed15b236292/image.png" alt=""></p>
<p><em>참고 : <a href="https://codingapple.com/unit/vue-data-import-export/?id=139">https://codingapple.com/unit/vue-data-import-export/?id=139</a></em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Vue.js]반복문(v-for)]]></title>
            <link>https://velog.io/@alstn_dev/Vue.js%EB%B0%98%EB%B3%B5%EB%AC%B8v-for</link>
            <guid>https://velog.io/@alstn_dev/Vue.js%EB%B0%98%EB%B3%B5%EB%AC%B8v-for</guid>
            <pubDate>Tue, 15 Mar 2022 14:23:53 GMT</pubDate>
            <description><![CDATA[<p><img src="https://images.velog.io/images/alstn_dev/post/9102b817-8c42-4b86-8448-8dda20de2616/image.png" alt=""></p>
<h1 id="반복문">반복문</h1>
<p>동일한 문구가 반독될 때 반복문 사용을 한다. 예를 들어서 네비 상단바에 있는 각 태그들은 <code>&lt;a&gt;Home&lt;/a&gt;, &lt;a&gt;Shop&lt;/a&gt; , &lt;a&gt;About&lt;/a&gt;</code> 와 같이 a태그의 반복으로 구성되어 있다. 중복코드 작성을 줄이기 위해서 v-for라는 문법을 사용해 보려 한다.</p>
<h2 id="v-for">v-for</h2>
<pre><code class="language-jsx">&lt;a v-for=&quot;(k,i) in menu&quot; :key=&quot;i&quot;&gt;{{k + i}}&lt;/a&gt;

...

export default {
  name: &#39;App&#39;,
  data(){
    return{
      ...
      menu : [&#39;Home&#39;, &#39;Shop&#39;, &#39;About&#39;],
    }
  },
  components: {
  }
}</code></pre>
<p>위 코드는 v-for를 사용한 코드이다. v-for는 for문과 매우 유사한 성격을 띄고 있다. 먼저 태그 안에 속성값처럼 선언을 한다.</p>
<p><code>in</code>을 기준으로 좌우가 나누어지는데 위 코드상으로 좌측에 <code>(k,i)</code> 우측에 <code>menu</code>가 존재한다. </p>
<h3 id="in-우변-menu">in 우변 (menu)</h3>
<p>먼저 우측에 menu는 반복문이 실행될 횟수를 의미한다. 해당 부분에 숫자로 반복문의 횟수를 정의 해도 되지만 위 코드와 같이 변수를 할당하여 선언해줄수 있다.  </p>
<p>아래 data쪽에 menu라는 배열을 선언 해 놨고, 해당 배열의 크기는 3이다. 그러면 자동적으로 for문이 3번 동작한다는 뜻이 된다. </p>
<h3 id="in-좌변-ki">in 좌변 (k,i)</h3>
<p>다음으로 in 좌변을 보면 k와 i가 있다. </p>
<p>먼저 k는 a,b,c 와 같은 변수로 작명하여 선언하면 된다. 이 변수는 해당 for문을 돌면서 우변에 선언된 횟수만큼 변하게 된다. 우변에 선언된 값이 특정 배열과 같은 변수라면 배열에 담긴 값을 순서대로 꺼내게 되고, 일반 숫자라면 1,2,3..N 과 같이 우변에 선언된 N까지 증가하게 된다.</p>
<p>다음으로 i는 :key에서 한번더 불러지는것을 볼 수 있다. 이는 해당 태그가 for문을 돌면 해당 i값이 1씩 증가하게 되고, 이는 고유 번호처럼 동작을 한다. 내가 이해한 바로는 html의 id 와 유사한것으로 이해했다.</p>
<p><strong>&lt;우변이 특정 숫자 N일때&gt;</strong></p>
<p><em>k값이 1씩 증가하는것을 볼 수 있다.</em></p>
<pre><code class="language-jsx">&lt;a v-for=&quot;(k,i) in 3&quot; :key=&quot;i&quot;&gt;{{k}},{{i}}&lt;/a&gt;</code></pre>
<p><img src="https://images.velog.io/images/alstn_dev/post/2b19f40e-4bc2-46d1-8d99-f4f8a452032b/image.png" alt=""></p>
<p><strong>&lt;우변이 변수(배열)일 때&gt;</strong></p>
<p><em>k값이 menu에 선언된 값으로 바인딩되는것을 볼 수 있다.</em></p>
<pre><code class="language-jsx">&lt;a v-for=&quot;(k,i) in menu&quot; :key=&quot;i&quot;&gt;{{k}},{{i}}&lt;/a&gt;</code></pre>
<p><img src="https://images.velog.io/images/alstn_dev/post/1b280d5c-e325-43aa-afbb-a21f2de1a92e/image.png" alt=""><img src="https://images.velog.io/images/alstn_dev/post/5c93d9bb-1f0d-42f9-aa65-6adf6f00a0f8/image.png" alt=""></p>
<p>위 두 예시 모두 i 값이 자동으로 증가한다는 것을 보여주기 위해 i값도 바인딩 하여 표시를 해주었고, 그 결과 1씩 증가하는 모습을 확인할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Vue.js]데이터 바인딩]]></title>
            <link>https://velog.io/@alstn_dev/%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B0%94%EC%9D%B8%EB%94%A9</link>
            <guid>https://velog.io/@alstn_dev/%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B0%94%EC%9D%B8%EB%94%A9</guid>
            <pubDate>Tue, 15 Mar 2022 14:22:14 GMT</pubDate>
            <description><![CDATA[<h1 id="데이터-바인딩">데이터 바인딩</h1>
<pre><code class="language-jsx">&lt;script&gt;
export default {
  name: &#39;App&#39;,
  data(){
    return{
      // 데이터들 모두 모아두는곳 즉 데이터 보관함
      price1 : 60,
      price2 : 70,
    }
  },
  components: {
  }
}
&lt;/script&gt;</code></pre>
<p>뷰는 위처럼 data()라는 객체를 만들고 그 안에 return안에 object형식으로 필요한 데이터를 집어 넣으면 된다. 이 때 특이한점은 일반 javascript와 다르게 var, const, let을 선언해 주지 않고 바로 적는 모습이다. </p>
<pre><code class="language-jsx">&lt;div&gt;
    xx부동산
    &lt;h4&gt;xx매물&lt;/h4&gt;
    &lt;p&gt;{{price1}} 만원&lt;/p&gt;
  &lt;/div&gt;

  &lt;div&gt;
    xx부동산
    &lt;h4&gt;xx매물2&lt;/h4&gt;
    &lt;p&gt;{{price1}} 만원&lt;/p&gt;
  &lt;/div&gt;</code></pre>
<p>선언한 데이터를 본문에서 사용하려면 위와 같이 중괄호 두개 안에 원하는 변수명을 적으면 된다. 이를 데이터바인딩이라고 하는데 데이터 바인딩을 하는데는 크게 두가지 이유가 있다.</p>
<ul>
<li>본문에 하드코딩을 하면 나중에 변경하기가 어려움</li>
<li>vue가 제공하는 실시간 자동렌더링 기능을 사용하기 위함<ul>
<li>실시간 자동렌더링이란 아래 data에서 값 변경 시 실시간으로 html에 반영되어 동작이 된다. 이는 웹앱이나, 실시간 데이터 변경이 자주 일어나는곳에 유용하게 사용됨</li>
</ul>
</li>
</ul>
<h2 id="속성-데이터-바인딩">속성 데이터 바인딩</h2>
<p>뷰에서는 해당 값 뿐만 아니라 style과 같은 속성 또한 바인딩이 가능하다. </p>
<p>아래와 같이 해당 속성값을 data에 선언을 하고 사용할 곳에 변수명을 작성하면 된다. </p>
<p>이 때 특이한점은 속성에서의 data변수는 중괄호를 두개 쓰는 대신에 해당 html에 속성값 앞에 <code>:</code> 를 붙인다. 아래 코드에서도 <code>:style</code> 이라고 선언했다.</p>
<pre><code class="language-jsx">    &lt;div&gt;
    xx부동산
    &lt;h4 :style=&quot;스타일&quot;&gt;xx매물&lt;/h4&gt;
    &lt;p&gt;{{price1}} 만원&lt;/p&gt;
  &lt;/div&gt;

...

data(){
    return{
      // 데이터들 모두 모아두는곳 즉 데이터 보관함
      price1 : 60,
      스타일 : &#39;color : blue&#39;, 
    }
  },</code></pre>
<h2 id="배열-바인딩">배열 바인딩</h2>
<p>아래 코드와 같이 data를 배열로 선언한 후 배열값에 따라 바인딩이 가능하다.</p>
<pre><code class="language-jsx">    &lt;div&gt;
    xx부동산
    &lt;h4&gt;{{products[0]}}&lt;/h4&gt;
  &lt;/div&gt;

  &lt;div&gt;
    &lt;h4&gt;{{products[1]}}&lt;/h4&gt;
  &lt;/div&gt;

  &lt;div&gt;
    &lt;h4&gt;{{products[2]}}&lt;/h4&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;

export default {
  name: &#39;App&#39;,
  data(){
    return{
      // 데이터들 모두 모아두는곳 즉 데이터 보관함  
      products : [&#39;역삼동원룸&#39;,&#39;천호동원룸&#39;,&#39;마포구원룸&#39;],      
    }
  },
  components: {
  }
}
&lt;/script&gt;</code></pre>
<p><img src="https://images.velog.io/images/alstn_dev/post/fe134866-675f-4d04-a376-85eb6adad339/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Vue.js]Vue 시작하기]]></title>
            <link>https://velog.io/@alstn_dev/Vue-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@alstn_dev/Vue-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 14 Mar 2022 14:37:40 GMT</pubDate>
            <description><![CDATA[<p><img src="https://images.velog.io/images/alstn_dev/post/c4a4f2fc-7998-4471-be25-0b6608a3972d/image.png" alt=""></p>
<blockquote>
<p>💡 React와 Vue를 고민하다가 Vue를 공부해 보기로 하였다. 아무래도 카카오에서 React보다 Vue를 선호하고 코드도 훨씬 간결하고, JS와는 다른 새로운 언어이기 때문에 도전한다!</p>
</blockquote>
<h1 id="vue란">Vue란?</h1>
<p>2014년도에 릴리즈를 시작으로 꾸준하게 업데이트 및 발전하고 있는 자바스크립트 프레임워크이다.</p>
<p>컨트롤러 대신에 MVVM(Model - View - ViewModel) 패턴을 이용한다.</p>
<h1 id="mvvm이란">MVVM이란?</h1>
<p><img src="https://images.velog.io/images/alstn_dev/post/659a75c2-2634-4550-99bd-d7182b2f87ac/image.png" alt=""></p>
<p>Model View ViewModel 로 이루어진 개발 방식</p>
<ul>
<li>View : 보이는 화면</li>
<li>Dom : html 문서에 들어가는 tag, class, attributes와 같은 요소</li>
<li>Dom Listener : Dom 변경내역에 따라 즉각 반영하여 로직을 수행</li>
<li>Model : 데이터</li>
<li>Data Binding :  View에 표시되는 내용과 모델 데이터 동기화</li>
<li>View Model : 뷰와 모델의 중간 영역</li>
</ul>
<h1 id="vue-시작하기">Vue 시작하기</h1>
<h2 id="1-nodejs-설치">1. Node.js 설치</h2>
<p><a href="https://nodejs.org/ko/">Node.js 설치하기</a></p>
<p>JavaScript 기반의 Vue.js를 브라우저가 아닌 환경에서 빌드하고 구동하기 위해서 설치를 해야한다.</p>
<p>설치를 진행 할 때 중간에 나오는 <code>automatically install the necessary tools</code> 를 체크하고 설치하길 권장한다.</p>
<h2 id="2-vs-code설치">2. VS Code설치</h2>
<p><a href="https://code.visualstudio.com/">Visual Studio Code - Code Editing. Redefined 설치하기</a></p>
<p>OS환경에 맞추어 설치 진행하면된다. 코드작성 도구이다.</p>
<h2 id="3-vuejs-설치">3. Vue.js 설치</h2>
<p><img src="https://images.velog.io/images/alstn_dev/post/db16a44c-a9e2-4483-9582-e2f86d6aab6b/image.png" alt=""></p>
<p>VsCode를 열고 터미널을 열고 아래와 같이 <code>npm install -g @vue/cli</code>를 입력하여 설치한다.</p>
<p><img src="https://images.velog.io/images/alstn_dev/post/e357570d-20a6-48ba-a44b-95cfe8b23b7e/image.png" alt=""></p>
<p>만약 npm어쩌고 에러가 발생하면 node.js를 지우고 다시 설치한다. </p>
<h2 id="4-extenstion-설치">4. Extenstion 설치</h2>
<p><img src="https://images.velog.io/images/alstn_dev/post/06a7c9c6-8944-436f-9a36-3c89fa7086cf/image.png" alt=""></p>
<p>위 그림처럼 기본적으로 3개의 플러그인을 설치한다.</p>
<h2 id="5-프로젝트-생성">5. 프로젝트 생성</h2>
<p>원하는 경로에 폴더를 만들고 <code>File -&gt; Open Folder</code>를 선택하여 해당 Folder를 연다.</p>
<p>이후 아래와 같이 <code>vue create 프로젝트명</code>을 terminal에 입력하여 프로젝트를 생성한다. </p>
<p><img src="https://images.velog.io/images/alstn_dev/post/750176cc-ee17-4eb3-a773-236ee473fdcb/image.png" alt=""></p>
<p>이후 다시 <code>File -&gt; Open Folder</code>로 해당 프로젝트로 이동한다. 아래와 같이 해당 파일명이 굵게 표시되면 성공!</p>
<p><img src="https://images.velog.io/images/alstn_dev/post/ed221d23-7d61-4139-a92e-de80bec6f1d6/image.png" alt=""></p>
<h2 id="6-프로젝트-구조">6. 프로젝트 구조</h2>
<p><strong>App.vue</strong>가 메인이다. 여기에다가 코드를 짜면된다. 이후 작성한 코드를 실행시키고 싶다면 터미널에  <code>npm run -serve</code>를 입력하고 나오는 주소에 접속하면 html화면을 볼 수 있다.</p>
<p><strong>Vue는 크롬 및 web브라우저가 해석 할 수 없는 언어</strong>이다. 이를 <strong>main.js가 변환작업을 하여 html로 만들어 준 후 브라우저에 띄우는 방식</strong>이다. <strong>public을 열어보면 index.html이 있는 것을 확인</strong> 할 수 있다.</p>
<p><strong>node_modules</strong> 안에는 각종 라이브러리, 툴이 담겨있다.</p>
<p><strong>package.json</strong>에는 버전을 관리하는 코드들이 담겨져 있다. </p>
<p>해당 포스트는 <a href="https://www.youtube.com/channel/UCSLrpBAzr-ROVGHQ5EmxnUg">https://www.youtube.com/channel/UCSLrpBAzr-ROVGHQ5EmxnUg</a> 에 있는 vue강의를 참고하여 작성하였습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[형상관리란? Git이란?]]></title>
            <link>https://velog.io/@alstn_dev/%ED%98%95%EC%83%81%EA%B4%80%EB%A6%AC%EB%9E%80-Git%EC%9D%B4%EB%9E%80</link>
            <guid>https://velog.io/@alstn_dev/%ED%98%95%EC%83%81%EA%B4%80%EB%A6%AC%EB%9E%80-Git%EC%9D%B4%EB%9E%80</guid>
            <pubDate>Sun, 13 Mar 2022 13:46:23 GMT</pubDate>
            <description><![CDATA[<p><img src="https://images.velog.io/images/alstn_dev/post/89526369-0356-4dc9-a66f-75deba60a5c3/image.png" alt=""></p>
<blockquote>
<p>💡 개발을 진행할때에는 보통 혼자 진행하는 경우보다 팀 단위로 기능별로 파트를 나누어 진행하는 경우가 많다. 이 때 자주 사용하는 툴이 git이다.</p>
</blockquote>
<h1 id="형상관리란">형상관리란?</h1>
<p>형상관리는 어떠한 임무(프로젝트)를 수행할 때 각자 나눠서 맡은 부분을 진행하게 되는데 이 때 각자가 진행한 업무를 하나의 관리 도구에서 버전별로 관리하는것을 말한다. 다른말로는 버전관리 라고도 부른다. 대표적으로 git과 svn이 있다.</p>
<h1 id="git-vs-svn">Git vs SVN</h1>
<h2 id="1-svn">1. SVN</h2>
<ul>
<li>저장소가 하나로, 서버에 있다.</li>
<li>개발자가 Commit하면 해당 저장소를 보고 있는 모든 사람에게 공유된다.</li>
</ul>
<h2 id="2-git">2. Git</h2>
<ul>
<li>저장소가 로컬저장소 /원격 저장소로 두개가 존재한다.</li>
<li>로컬PC에서 코드 수정 후 commit시 로컬저장소에 저장된다.</li>
<li>이후 로컬저장소에서 push를 하면 원격저장소에 반영되어 저장된다.</li>
</ul>
<table>
<thead>
<tr>
<th></th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>SVN&nbsp;</td>
<td>비교적 직관적</td>
<td>충돌확률이 높고 에러 발생 시 수정 및 복구가 어려움</td>
</tr>
<tr>
<td>GIT</td>
<td>에러 발생 시 복구가 용이하고, 처리속도가 빠르다.</td>
<td>직관적이지 못하다. (sourceTree, gitHub Deskltop 같은 툴 이용 시 보다 쉽게 사용 가능)</td>
</tr>
</tbody></table>
<h1 id="git">Git</h1>
<h3 id="1-commit">1. Commit</h3>
<p>커밋이란 작업한 내용을 <strong>로컬저장소</strong>에 저장하는 것을 의미한다. 이 떄 작업한 내용, 변경사항에 대한 내용을 간략하게 남길 수 있다.(ex. 회원가입 로직 수정, 로그인 기능 개발..). 커밋 할 때 <strong>커밋단위는 프로젝트 혹은  팀마다 다를 수 있다. 기능별로 커밋하는 것을 선호!</strong></p>
<h3 id="2-push">2. Push</h3>
<p>커밋한 내용이 담겨있는 <strong>로컬저장소에서 원격저장소로 올리는 과정</strong>이다. SVN은 해당 커밋과 푸시과정이 하나로 통합되어 있다.</p>
<h3 id="3-pull">3. Pull</h3>
<p>Push와는 반대로 <strong>원격저장소</strong>에 있는 내용을 가져와서 로컬저장소에 저장하는 과정이다. </p>
<h3 id="4-저장소">4. 저장소</h3>
<p>앞서 많이 언급된 로컬저장소와 원격저장소가 존재한다. 로컬저장소는 내 pc의 개인 저장소 이고, 원격 저장소에는 공동 작업하는 개발자들이 보고 접근 가능한 저장소이다. </p>
<h3 id="5-branch">5. Branch</h3>
<p>가지라는 뜻으로 목적에 맞게 분기를 하여 작업을 진행한다. 주축이 되는 브랜치를 Master Branch라고 부르며 해당 마스터 브랜치에서 다른 브랜치를 생성하여 작업 후 다시 기존 마스터 브랜치로 merge하게 된다.</p>
<h3 id="6-merge">6. Merge</h3>
<p>분기로 나뉘어진 브랜치중 하나의 브랜치를 다른 브랜치와 합하는 과정이다. 이 때 한 파일을 서로 다르게 수정한 경우 충돌이 일어난다. </p>
<p>&nbsp;
깃은 공부할게 매우 많은 부분이다. 지속적으로 공부 후 추후에 포스팅 하겠습니다.</p>
]]></description>
        </item>
    </channel>
</rss>