<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>no-oneho.log</title>
        <link>https://velog.io/</link>
        <description>이렇게 짜면 요구사항이나 기획이 변경됐을 때 불편하지 않을까? 라는 생각부터 시작해 설계를 해나가는 개발자</description>
        <lastBuildDate>Wed, 18 Jun 2025 11:57:41 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>no-oneho.log</title>
            <url>https://velog.velcdn.com/images/no-oneho/profile/238fa445-de96-49e7-8702-a7b772a3ad68/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. no-oneho.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/no-oneho" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[일친 (IlChin) - Webflux로 외부 API 호출]]></title>
            <link>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-Webflux%EB%A1%9C-%EC%99%B8%EB%B6%80-API-%ED%98%B8%EC%B6%9C</link>
            <guid>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-Webflux%EB%A1%9C-%EC%99%B8%EB%B6%80-API-%ED%98%B8%EC%B6%9C</guid>
            <pubDate>Wed, 18 Jun 2025 11:57:41 GMT</pubDate>
            <description><![CDATA[<p>깃헙에 등록된 repo의 정보를 등록해야할 차례가 왔다.</p>
<p>그럴려면 깃허브의 api를 호출해야할 필요가 있고, repo를 등록할 때 유효성을 먼저 검증하도록 하겠다.</p>
<p>주로 api를 스프링에서 호출할 때 사용하는 방식으로는</p>
<p>주로 RestTemplate와 WebClinet를 이용한 방법이 있는데 RestTemplate의 경우 현재 maintenance모드(더 이상 기능추가가 안된다)이기 때문에 계속해서 기능및 관리가 되는 WebClinet를 사용하기로 했다.</p>
<p>WebClinet를 사용하려면 webflux 의존성을 먼저 추가해줘야한다.</p>
<pre><code>implementation &#39;org.springframework.boot:spring-boot-starter-webflux&#39;</code></pre><p>를 gradle에 추가하여 의존성을 받아준다.</p>
<p>API를 호출하게 되면 코드도 비동기성 성질을 띄게 만드는것이 시간이나 관점측면에서 매우 효율적인데 webflux를 이용하여 reactive 프로그래밍을 사용할 수 있게되고
그러한 비동기 시퀀스들을 사용할 수 있게 만들어주는게 webflux의 Mono와 Flux이다</p>
<p>여기서는 Mono 객체를 사용하도록 하겠다.</p>
<p>아래는 트러블슈팅의 과정이므로 완성코드는 맨 아래에 위치해있으니 조심!</p>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;api/github&quot;)
@RequiredArgsConstructor
public class GithubRepositoryInfoController {

    private final GithubRepositoryInfoService githubRepositoryInfoService;

    @Auth
    @PostMapping
    public Response&lt;GithubRepositoryInfoCreate&gt; createGithubRepositoryInfo(@RequestBody GithubRepositoryInfoCreate request) {
        return Api.success(200, &quot;깃헙 레포 등록 완료&quot;, githubRepositoryInfoService.createGitHubRepositoryInfo(request));
    }

}</code></pre>
<p>먼저 컨트롤러를 만들고,</p>
<pre><code class="language-java">    @Transactional
    public GithubRepositoryInfoCreate createGitHubRepositoryInfo(GithubRepositoryInfoCreate request) {
        Mono&lt;String&gt; apiResponse = webClient.get()
                .uri(API_URL + &quot;/repos/{owner}/{repo}&quot;, request.owner(), request.repo())
                .headers(header -&gt; header.setBearerAuth(request.token()))
                .retrieve()
                .bodyToMono(String.class)
                .onErrorMap((e) -&gt; {
                    throw new CustomException(ExtendApiException.BAD_REQUEST_INFO);
                });

        apiResponse
                .flatMap(response -&gt; {
                    if (response == null || response.trim().isEmpty()) {
                        return Mono.error(new CustomException(ExtendApiException.BAD_REQUEST_INFO));
                    }
                    return Mono.empty();
                })
                .subscribe(
                        success -&gt; {
                        },
                        error -&gt; {
                            throw new CustomException(ExtendApiException.BAD_REQUEST_INFO);
                        }
                );

        GithubRepositoryInfo githubRepositoryInfo = GithubRepositoryInfo.createEntity(request.owner(), request.repo(), request.token());
        repositoryInfoRepository.save(githubRepositoryInfo);

        return new GithubRepositoryInfoCreate(githubRepositoryInfo.getOwner(), githubRepositoryInfo.getRepo(), githubRepositoryInfo.getToken());
    }</code></pre>
<p>서비스를 만들었다. 직접 코드를 실행해보니 저장도 잘되고 코드도 잘 실행되는거같았다.</p>
<p>유효성 검증을 위해 일부러 맞지않는 값을 넣었는데..</p>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/55c0dedd-26ca-4705-bcaa-35791a95cb62/image.png" alt=""></p>
<p>콘솔에는 예외가 찍히나</p>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/5396fe62-bd81-4780-8d25-78e079df7bf5/image.png" alt=""></p>
<p>실제 코드 동작은 예외가 캐치되지않고 끝까지 코드가 실행되었다.</p>
<p>왜그랬을까를 생각해보니 Mono로 선언한 부분은 비동기로 코드가 실행됨에 따라 다음 코드들이 github api 호출에 대한 결과값을 기다리지않고 실행되는것이였다.</p>
<p>그러다보니 메서드가 객체를 반환 할 때 reactive 스트림의 실행결과를 기다리지 않아 수정할 필요가 생겼다.</p>
<p>이에따라 컨트롤러부터 Mono타입을 반환하게 변경하였다.
그리고 Api 호출이 끝나기를 대기하고 코드를 실행하도록 변경하였다.</p>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;api/github&quot;)
@RequiredArgsConstructor
public class GithubRepositoryInfoController {

    private final GithubRepositoryInfoService githubRepositoryInfoService;

    @Auth
    @PostMapping
    public Mono&lt;Response&lt;GithubRepositoryInfoCreate&gt;&gt; createGithubRepositoryInfo(@RequestBody GithubRepositoryInfoCreate request) {
        return githubRepositoryInfoService.createGitHubRepositoryInfo(request)
                .map(result -&gt; Api.success(200, &quot;깃헙 정보 생성 완료&quot;, result));
    }

}
</code></pre>
<p>서비스코드도 변경된 리턴값에 따라 수정하여</p>
<pre><code class="language-java"> @Transactional
    public Mono&lt;GithubRepositoryInfoCreate&gt; createGitHubRepositoryInfo(GithubRepositoryInfoCreate request) {
        return webClient.get()
                .uri(API_URL + &quot;/repos/{owner}/{repo}&quot;, request.owner(), request.repo())
                .headers(header -&gt; header.setBearerAuth(request.token()))
                .retrieve()
                .bodyToMono(String.class)
                .flatMap(response -&gt; {
                    if (response == null || response.isEmpty()) {
                        return Mono.error(new CustomException(ExtendApiException.BAD_REQUEST_INFO));
                    }
                    GithubRepositoryInfo githubRepositoryInfo = GithubRepositoryInfo.createEntity(request.owner(), request.repo(), request.token());
                    return Mono.fromCallable(() -&gt; repositoryInfoRepository.save(githubRepositoryInfo))
                            .subscribeOn(Schedulers.boundedElastic())
                            .map(savedInfo -&gt; new GithubRepositoryInfoCreate(savedInfo.getOwner(), savedInfo.getRepo(), savedInfo.getToken()));
                })
                .onErrorMap((e) -&gt; {
                    throw new CustomException(ExtendApiException.BAD_REQUEST_INFO);
                });

    }</code></pre>
<p>이와같은 최종코드가 나오게되었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[일친 (IlChin) - Spring Validation 적용]]></title>
            <link>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-Spring-Validation-%EC%A0%81%EC%9A%A9</link>
            <guid>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-Spring-Validation-%EC%A0%81%EC%9A%A9</guid>
            <pubDate>Wed, 11 Jun 2025 10:23:29 GMT</pubDate>
            <description><![CDATA[<p>이제 dto별 유효성 검증을 하도록 하겠다.</p>
<p>이전에 만든 메서드가 있긴있지만 해당 메서드는 여러번 dto가 재사용될 때 유효성 검증 항목이 상이할 때만 사용하고 일반적으론 편하게 Spring Validation을 쓰도록 하겠다.</p>
<p>먼저 의존성을 받아준다</p>
<p>build.gradle</p>
<pre><code>implementation &#39;org.springframework.boot:spring-boot-starter-validation&#39;</code></pre><p>그리고 사용할 dto나 파일에 가서 붙여준다</p>
<pre><code class="language-java">public record CreateReq(
        @NotBlank(message = &quot;부서명은 빈 값일 수 없습니다.&quot;) String name,
        @NotBlank(message = &quot;부서설명은 빈 값일 수 없습니다.&quot;) String description,
        @NotNull(message = &quot;부서장을 선택해주세요.&quot;) Long managerUserId,
        String tel
) {
}
</code></pre>
<p>가장 많이 사용하는게 @NotNull, @NotEmpty, @NotBlank 이렇게 세가지이다.</p>
<p>서로 역할이 조금씩 다르므로 주의하도록 한다.
<strong>@NotNull: Null값만 검증 실패
@NotEmpty: Null과 &quot;&quot; 빈 문자열만 검증 실패
@NotBlank: Null과 &quot;&quot;및 &quot; &quot;와 같이 공백문자만 존재하는 문자열도 검증 실패</strong></p>
<p>여기서 NotEmpty, NotBlank는 검증하는 &quot;&quot; 자체가 이미 문자열이므로 문자열타입에만 적용 가능한 검증이다.</p>
<p>그 외 타입에서 null검사를 하고싶으면 @NotNull을 사용하자</p>
<p>그리고 컨트롤러에 가서 </p>
<pre><code class="language-java">@Auth
    @PostMapping
    public Response&lt;DepartmentResp&gt; createDepartment(@RequestBody @Valid CreateReq createReq) {
        return Api.success(200, &quot;부서 생성 완료&quot;, departmentService.createDepartment(createReq));
    }</code></pre>
<p>@Valid 라는 어노테이션을 인자로 받을 dto앞에 붙여주면 끝
<br><br><br><br><Br><br><br>
진짜 끝일까?</p>
<p>한번 실제로 api를 호출해보면
<img src="https://velog.velcdn.com/images/no-oneho/post/eef97564-d66f-47fe-bc46-d00490aa3438/image.png" alt="">
이와같이 정돈되지않은 에러메시지가 그대로 노출이 되어버리는데
  Spring Validation에서 잡힌 Exception도 advice에서 따로 처리를 해줘야한다.</p>
<pre><code class="language-java">  @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity&lt;Response&lt;Map&lt;String, String&gt;&gt;&gt; handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        Map&lt;String, String&gt; errors = new HashMap&lt;&gt;();
        List&lt;FieldError&gt; fieldErrors = e.getBindingResult().getFieldErrors();
        for (FieldError fieldError : fieldErrors) {
            errors.put(fieldError.getField(), fieldError.getDefaultMessage());
        }
        Response&lt;Map&lt;String, String&gt;&gt; errorResponse = Api.error(HttpStatus.BAD_REQUEST, &quot;유효성 검증에 실패하였습니다.&quot;, errors);
        return new ResponseEntity&lt;&gt;(errorResponse, HttpStatus.BAD_REQUEST);

    }</code></pre>
<p>  나는 이런느낌으로 만들었다. MethodArgumentNotValidException에서 유효성 검증이 실패한 필드와 해당 메시지는 List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors(); List를 만들어 가져 올 수 있고
  각 인덱스마다 getField()와 getDefaultMessage() 로 꺼내서 쓸 수 있다.</p>
<p>  그럼 다시 API를 호출해보자</p>
<p>  <img src="https://velog.velcdn.com/images/no-oneho/post/c27e24bd-f8b1-49cd-bb02-2d744d2bf6cb/image.png" alt=""></p>
<p>  이제 원하는대로 잘 나온다. 프론트 개발자 입장에선 key을 통해 컴포넌트를 참조해서 에러이벤트를 화면에 띄어주면 될 거 같다.</p>
<p>  아마 실제 개발환경이였다면 http status를 400이 아닌 999같이 커스텀해서 유효성 검증용 에러코드로 전달해드렸을듯..?</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[일친 (IlChin) - Request 필드 중 null 값 체크 메서드 만들기]]></title>
            <link>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-Request-%ED%95%84%EB%93%9C-%EC%A4%91-null-%EA%B0%92-%EC%B2%B4%ED%81%AC-%EB%A9%94%EC%84%9C%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-Request-%ED%95%84%EB%93%9C-%EC%A4%91-null-%EA%B0%92-%EC%B2%B4%ED%81%AC-%EB%A9%94%EC%84%9C%EB%93%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Wed, 04 Jun 2025 07:11:24 GMT</pubDate>
            <description><![CDATA[<p>Dto 단계에서 validation으로 걸 수도 있지만 그렇게 해버리면 유효성 검증이 dto에 너무 의존하여 dto를 재사용할 때 번거롭거나 새로 만들어야 할 수도 있을 것 같았다.</p>
<p>그래서 유틸메서드로 하나 만들어서 사용하도록 해보겠다.</p>
<pre><code class="language-java">    public static boolean areFieldsNotNullOrEmpty(Object obj, String... fields) {
        try {
            for (String field : fields) {
                Field field1 = obj.getClass().getDeclaredField(field);
                field1.setAccessible(true);
                Object value = field1.get(obj);
                if (value == null) {
                    return false;
                }
                if (value instanceof String &amp;&amp; ((String) value).trim().isEmpty()) {
                    return false;
                }

            }
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
        return true;
    }</code></pre>
<p>이것이 null 값이나 빈칸에 대한 검증 메서드이다.</p>
<p>자바 Reflection을 사용하여 만들었으며
필드 이름을 문자열로 여러개를 받아 하나하나 검증하는 메서드이다.
obj.getClass().getDeclaredField(fieldName)를 통해 obj 안에 해당 메서드 이름이 존재하면 null 값을 체크하고, 이름 자체가 존재하지 않는 메서드라면 catch로 넘어가게 된다.</p>
<p>이는 서버개발자의 실수일 경우가 대다수라 runtimeException으로 빼고 해당 예외는 advice를 통해 500에러로 빠진다.</p>
<p>아무튼 실제 사용 예시를 보면</p>
<pre><code class="language-java">if(!Api.areFieldsNotNullOrEmpty(patchUserReq, &quot;email&quot;, &quot;fullName&quot;, &quot;phoneNumber&quot;)) {
            throw new CustomException(UserException.BAD_REQUEST_PATCH);
        }</code></pre>
<p>이런식으로 사용할 수 있다. 이렇게 해두면 null 값 검증이 필요한 필드만 예외처리를 따로 할 수 있어 간편하게 사용할 수 있을 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[일친 (IlChin) - User API 만들기]]></title>
            <link>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-User-API-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-User-API-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Sun, 01 Jun 2025 12:07:04 GMT</pubDate>
            <description><![CDATA[<p>이제 유저쪽 api를 만들시간이다.</p>
<p>유저 api는</p>
<h2 id="1-회원관리-회원-등록-조회-수정-삭제">1. 회원관리 (회원 등록, 조회, 수정, 삭제)</h2>
<table>
<thead>
<tr>
<th>엔드포인트</th>
<th>메서드</th>
<th>요청 데이터</th>
<th>응답 예시</th>
<th>설명</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td><code>/api/login</code></td>
<td>POST</td>
<td><code>{ &quot;username&quot;: ..., &quot;password&quot;: ... }</code></td>
<td><code>{ &quot;token&quot;: ... }</code></td>
<td>로그인 후 JWT 토큰 발급</td>
<td></td>
</tr>
<tr>
<td><code>/api/users</code></td>
<td>POST</td>
<td><code>{ &quot;username&quot;: ..., &quot;password&quot;: ... }</code></td>
<td>성공 메시지 또는 유저 상세</td>
<td>회원 등록</td>
<td></td>
</tr>
<tr>
<td><code>/api/users/me</code></td>
<td>GET</td>
<td>-</td>
<td>내 정보 상세</td>
<td>내 정보 조회</td>
<td></td>
</tr>
<tr>
<td><code>/api/users/{id}</code></td>
<td>PATCH</td>
<td><code>{ &quot;fullName&quot;: ..., &quot;phone&quot;: ... }</code></td>
<td>수정 완료 메시지</td>
<td>회원 정보 수정</td>
<td>이름, 핸드폰, 이메일 수정가능, 관리자, 부서 팀장일 경우 타인의 회원 정보 수정 가능</td>
</tr>
<tr>
<td><code>/api/users/{id}</code></td>
<td>GET</td>
<td>-</td>
<td>회원 상세 정보</td>
<td>특정 회원 정보 조회</td>
<td>관리자나 해당 부서 팀장만 타인의 상세 정보 조회 가능</td>
</tr>
<tr>
<td><code>api/users/password</code></td>
<td>PATCH</td>
<td><code>{&quot;password&quot;: ..., &quot;confirmPassword&quot;: ...}</code></td>
<td>패스워드 변경 완료</td>
<td></td>
<td></td>
</tr>
<tr>
<td><code>/api/users/{id}</code></td>
<td>DELETE</td>
<td>-</td>
<td>삭제 성공</td>
<td>회원 삭제</td>
<td>소프트딜리트, 관리자 권한, 부서 팀장만 사용 가능</td>
</tr>
<tr>
<td><code>/api/users</code></td>
<td>GET</td>
<td><code>{&quot;pageSize&quot;: ..., &quot;pageNumber&quot;: ..., &quot;departmet&quot;: ..., &quot;sortType&quot;: ..., &quot;searchType&quot;: ..., &quot;searchType&quot;: ...}</code></td>
<td>회원 리스트</td>
<td>회원 리스트 조회</td>
<td>관리자 권한 일 경우 모든 회원 정보및 부서별 조회 가능, 그 외 본인의 부서 소속 회원만 조회 가능</td>
</tr>
<tr>
<td><code>/api/users/{id}/role</code></td>
<td>PATCH</td>
<td><code>{&quot;role&quot;: ...}</code></td>
<td>변경 성공</td>
<td>회원 권한 변경</td>
<td><code>{ADMIN, MEMBER, GUEST}</code>, 관리자 권한만 사용 가능</td>
</tr>
</tbody></table>
<p>일단 이정도가 있고 현 시점에서 user &lt;-&gt; userProfile 테이블만 조인하면 된다.
조인같은경우 jpa의 entity끼리만 묶고 물리db에선 제외할것이다.</p>
<p>userProfile.class</p>
<pre><code class="language-java">@Column(name = &quot;user_id&quot;, nullable = false)
    private Long userId;</code></pre>
<p>기존에 작성된 이 컬럼을</p>
<pre><code class="language-java">@JoinColumn(name = &quot;user_id&quot;, nullable = false)
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;</code></pre>
<p>이렇게 변경해준다.</p>
<p>변경 후 컴파일을 돌려 QClass에 반영해주고</p>
<p>제일 간단한 내 정보 조회 api를 만들어보자</p>
<pre><code class="language-java">//UserController
    @Auth
    @GetMapping
    public Response&lt;UserProfileResp&gt; getCurrentUserProfile() {
        return Api.success(200, &quot;내 정보 조회 완료&quot;, userService.getCurrentUserProfile());
    }

//UserProfileResp
@Builder
public record UserProfileResp(
        String username,
        String email,
        String role,
        String fullName,
        String phoneNumber
) {}

//UserService
    public UserProfileResp getCurrentUserProfile() {
        User user = getCurrentUser();
        return userRepository.findUserProfileByUser(user);
    }

//findUserProfileByUser Method
@Override
    public UserProfileResp findUserProfileByUser(User currentUser) {
        return jpaQueryFactory
                .select(Projections.constructor(UserProfileResp.class,
                        user.username,
                        user.email,
                        user.role,
                        userProfile.fullName,
                        userProfile.phone))
                .from(user)
                .join(userProfile).on(user.eq(userProfile.user))
                .where(
                        user.eq(currentUser)
                )
                .fetchFirst();
    }</code></pre>
<p>여기서 주의해야할점은 queryDSL에 생성자 주입 방식이다.
Projections.constructor는 매개변수로
(x.class, a, b, c, d, e) 를 받는데 dto 클래스 내부에 필드 순서와 쿼리에 적는 컬럼의 순서가 일치해야하고 불일치할 시 에러가 발생하므로 꼼꼼하게 체크해야한다.</p>
<p>결과를 보면
<img src="https://velog.velcdn.com/images/no-oneho/post/89d06e1a-8e0a-40e9-a518-9183d2e5ac3e/image.png" alt="">
원하는대로 잘 나오는것을 알 수 있다.</p>
<p>이런 느낌으로 나머지 api도 작성해나가면 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[일친 (IlChin) - 페이지네이션 기능 개발]]></title>
            <link>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-%EA%B8%B0%EB%8A%A5-%EA%B0%9C%EB%B0%9C</link>
            <guid>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-%EA%B8%B0%EB%8A%A5-%EA%B0%9C%EB%B0%9C</guid>
            <pubDate>Sun, 01 Jun 2025 10:03:21 GMT</pubDate>
            <description><![CDATA[<p>저번 포스팅에서 공통 반환 dto를 생성 할 때 제네릭 타입으로 선언해둬서 반환으로 원하는 모든 타입을 넣을 수 있다.</p>
<p>그렇다면 입맛대로 새로 만든 리스폰스 클래스도 반환타입으로 지정이 가능하다는건데
그 점에서 페이지네이션 기능이 담긴 dto를 만들어서 해당 기능이 필요할 땐 해당 클래스를 사용해 써보도록 하겠다.</p>
<p>먼저 dto 클래스를 하나 생성한다</p>
<p>SearchPageResponse.class</p>
<pre><code class="language-java">public record SearchPageResponse&lt;T&gt;(
    Long totalElements,
    Long totalPages,
    Integer currentPage,
    Integer pageSize,
    List&lt;T&gt; content) {}</code></pre>
<p><strong>record</strong>는 자바14쯤에서 새롭게 나온 클래스 타입인데, record 타입으로 선언하면 효율적이고 간결하게 dto 생성이 가능하다, <strong>getter, setter는 물론이고 생성자, toString같은 메서드, 그리고 불변성 보장</strong>도 해주다 보니 여러모로 유용하다</p>
<p>물론 lombok을 사용해도 되긴하지만 코드량을 좀 더 줄이고 간단하게 만들기위해 record로 생성했다.</p>
<p>다시 본론으로 와서 페이지네이션 기능은 99% 사용자의 편의성을 위해 만들어진 기능인데 이에따라 사용자와 좀 더 가까운곳에서 개발하는 프론트엔드쪽에서 활용해야한다고 생각한다.</p>
<p>그렇기때문에 만약 프론트엔드 개발자와 협업중이라면 서로 어떤 값들이 필요한지 문서화를 미리미리 해두는게 좋다.</p>
<p>여기서는 최대한 보편적인 페이지네이션 클래스로 생성하여 </p>
<p><strong>totalElements</strong> 에는 <strong>전체 데이터의 개수</strong> (토탈페이지와 연관, 또는 전체 데이터개수 반환 필요할 때)</p>
<p><strong>totalPages</strong> 에는 <strong>전체 페이지의 수</strong></p>
<p><strong>currentPage</strong> 는 <strong>현재 페이지</strong></p>
<p><strong>pageSize</strong> 는 <strong>페이지당 담길 개수</strong></p>
<p><strong>content</strong> 는 <strong>담겨있는 정보</strong></p>
<p>정도를 넣었고 객체 생성을 위해 빌더 메서드 하나만 생성하도록 하겠다.
생성자가 자동으로 들어가서 굳이 안넣어도 되지만.. 그냥 다른곳들은 빌더로 생성을 하는데 여기만 생성자로 하면 코드가 좀 일관성 없어보여서 넣는다.</p>
<pre><code class="language-java">@Builder
public record SearchPageResponse&lt;T&gt;(
        Long totalElements,
        Long totalPages,
        Integer currentPage,
        Integer pageSize,
        List&lt;T&gt; content) {

    public static &lt;T&gt; SearchPageResponse&lt;T&gt; of(
            Long totalElements,
            Long totalPages,
            Integer currentPage,
            Integer pageSize,
            List&lt;T&gt; content
    ) {
        return SearchPageResponse.&lt;T&gt;builder()
                .totalElements(totalElements)
                .totalPages(totalPages)
                .currentPage(currentPage)
                .pageSize(pageSize)
                .content(content)
                .build();
    }
}</code></pre>
<p>그러면 일단 이런 형태가 완성이 되는데, 원래 dto마다 전부 테스트를 돌릴 생각은 없지만 해당 dto는 앞으로도 자주 사용할 코드기에 테스트코드로 한번 돌려보자</p>
<p>테스트 코드</p>
<pre><code class="language-java">class SearchPageResponseTest {

    @Test
    void 정상_데이터_생성_테스트() {
        // Given
        Long totalElements = 100L;
        Long totalPages = 10L;
        Integer currentPage = 1;
        Integer pageSize = 10;
        List&lt;String&gt; content = Arrays.asList(&quot;Item1&quot;, &quot;Item2&quot;, &quot;Item3&quot;);

        // When
        SearchPageResponse&lt;String&gt; response = SearchPageResponse.of(
                totalElements,
                totalPages,
                currentPage,
                pageSize,
                content
        );

        // Then
        assertNotNull(response);
        assertEquals(totalElements, response.totalElements());
        assertEquals(totalPages, response.totalPages());
        assertEquals(currentPage, response.currentPage());
        assertEquals(pageSize, response.pageSize());
        assertEquals(content, response.content());
        assertEquals(3, response.content().size());
    }

    @Test
    void 빈_콘텐츠_생성_테스트() {
        // Given
        Long totalElements = 0L;
        Long totalPages = 0L;
        Integer currentPage = 1;
        Integer pageSize = 10;
        List&lt;String&gt; content = List.of();

        // When
        SearchPageResponse&lt;String&gt; response = SearchPageResponse.of(
                totalElements,
                totalPages,
                currentPage,
                pageSize,
                content
        );

        // Then
        assertNotNull(response);
        assertEquals(totalElements, response.totalElements());
        assertEquals(totalPages, response.totalPages());
        assertEquals(currentPage, response.currentPage());
        assertEquals(pageSize, response.pageSize());
        assertTrue(response.content().isEmpty());
    }

    @Test
    void Null_생성_테스트() {
        // When
        SearchPageResponse&lt;String&gt; response = SearchPageResponse.of(
                null,
                null,
                null,
                null,
                null
        );

        // Then
        assertNotNull(response);
        assertNull(response.totalElements());
        assertNull(response.totalPages());
        assertNull(response.currentPage());
        assertNull(response.pageSize());
        assertNull(response.content());
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/036fac19-21ee-4d5d-85df-bc2070420e4c/image.png" alt=""></p>
<p>실제 사용은 현재 프로젝트엔 없지만 예전에 했던 프로젝트 코드를 보면</p>
<p>controller</p>
<pre><code class="language-java">@Auth
    @GetMapping(&quot;memo-list&quot;)
    @Operation(summary = &quot;메모 리스트 조회&quot;, description = &quot;작성된 메모 리스트를 조회합니다.\n&quot;
        + &quot;empIdx 필드를 null로 주시면 전체 조회입니다.&quot;)
    public Response&lt;SearchPageResponse&lt;MemoItem.Response&gt;&gt; getMemoList(
        @RequestParam(name = &quot;pageSize&quot;) Integer pageSize,
        @RequestParam(name = &quot;pageNumber&quot;) Integer pageNumber,
        @RequestParam(name = &quot;empIdx&quot;, required = false) Long empIdx
    ) {
        return new Response&lt;&gt;(HttpStatus.OK.value(), memoService.getCallMemoList(pageSize, pageNumber-1, empIdx));
    }</code></pre>
<p> service</p>
<pre><code> public SearchPageResponse&lt;MemoItem.Response&gt; getCallMemoList(Integer pageSize, Integer pageNumber, Long empIdx) {

        Employee employee = employeeRepository.findByIdx(AuthHolder.getUserId())
            .orElseThrow(() -&gt; new CustomException(EmployeeErrorCode.NOT_FOUND_EMPLOYEE));

        Pageable pageable = Pageable.ofSize(pageSize).withPage(pageNumber);
        List&lt;Memo&gt; memos = memoRepository.findMemoList(pageable, empIdx, employee);
        List&lt;MemoItem.Response&gt; memoItems = memos.stream()
            .map(MemoItem.Response::from)
            .toList();

        Long total = memoRepository.countMemoList(empIdx, employee);
        Long totalPage = (long)Math.ceil((double)total / pageSize);
        return SearchPageResponse.of(total, totalPage, pageNumber + 1, pageSize, memoItems);
    }</code></pre><p> 이런 느낌으로 사용하면 된다</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[일친 (IlChin) - API 명세서 작성]]></title>
            <link>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-API-%EB%AA%85%EC%84%B8%EC%84%9C-%EC%9E%91%EC%84%B1</link>
            <guid>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-API-%EB%AA%85%EC%84%B8%EC%84%9C-%EC%9E%91%EC%84%B1</guid>
            <pubDate>Sat, 31 May 2025 09:07:18 GMT</pubDate>
            <description><![CDATA[<h1 id="그룹웨어-mvp-api-리스트">그룹웨어 MVP API 리스트</h1>
<h2 id="1-회원관리-회원-등록-조회-수정-삭제">1. 회원관리 (회원 등록, 조회, 수정, 삭제)</h2>
<table>
<thead>
<tr>
<th>엔드포인트</th>
<th>메서드</th>
<th>요청 데이터</th>
<th>응답 예시</th>
<th>설명</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td><code>/api/login</code></td>
<td>POST</td>
<td><code>{ &quot;username&quot;: ..., &quot;password&quot;: ... }</code></td>
<td><code>{ &quot;token&quot;: ... }</code></td>
<td>로그인 후 JWT 토큰 발급</td>
<td></td>
</tr>
<tr>
<td><code>/api/users</code></td>
<td>POST</td>
<td><code>{ &quot;username&quot;: ..., &quot;password&quot;: ... }</code></td>
<td>성공 메시지 또는 유저 상세</td>
<td>회원 등록</td>
<td></td>
</tr>
<tr>
<td><code>/api/users/me</code></td>
<td>GET</td>
<td>-</td>
<td>내 정보 상세</td>
<td>내 정보 조회</td>
<td></td>
</tr>
<tr>
<td><code>/api/users/{id}</code></td>
<td>PATCH</td>
<td><code>{ &quot;fullName&quot;: ..., &quot;phone&quot;: ... }</code></td>
<td>수정 완료 메시지</td>
<td>회원 정보 수정</td>
<td>이름, 핸드폰, 이메일 수정가능, 관리자, 부서 팀장일 경우 타인의 회원 정보 수정 가능</td>
</tr>
<tr>
<td><code>/api/users/{id}</code></td>
<td>GET</td>
<td>-</td>
<td>회원 상세 정보</td>
<td>특정 회원 정보 조회</td>
<td>관리자나 해당 부서 팀장만 타인의 상세 정보 조회 가능</td>
</tr>
<tr>
<td><code>api/users/password</code></td>
<td>PATCH</td>
<td><code>{&quot;password&quot;: ..., &quot;confirmPassword&quot;: ...}</code></td>
<td>패스워드 변경 완료</td>
<td></td>
<td></td>
</tr>
<tr>
<td><code>/api/users/{id}</code></td>
<td>DELETE</td>
<td>-</td>
<td>삭제 성공</td>
<td>회원 삭제</td>
<td>소프트딜리트, 관리자 권한, 부서 팀장만 사용 가능</td>
</tr>
<tr>
<td><code>/api/users</code></td>
<td>GET</td>
<td><code>{&quot;pageSize&quot;: ..., &quot;pageNumber&quot;: ..., &quot;departmet&quot;: ..., &quot;sortType&quot;: ..., &quot;searchType&quot;: ..., &quot;searchType&quot;: ...}</code></td>
<td>회원 리스트</td>
<td>회원 리스트 조회</td>
<td>관리자 권한 일 경우 모든 회원 정보및 부서별 조회 가능, 그 외 본인의 부서 소속 회원만 조회 가능</td>
</tr>
<tr>
<td><code>/api/users/{id}/role</code></td>
<td>PATCH</td>
<td><code>{&quot;role&quot;: ...}</code></td>
<td>변경 성공</td>
<td>회원 권한 변경</td>
<td><code>{ADMIN, MEMBER, GUEST}</code>, 관리자 권한만 사용 가능</td>
</tr>
</tbody></table>
<h2 id="2-부서-관리">2. 부서 관리</h2>
<table>
<thead>
<tr>
<th>엔드포인트</th>
<th>메서드</th>
<th>요청 데이터</th>
<th>응답 예시</th>
<th>설명</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td><code>/api/departments</code></td>
<td>POST</td>
<td><code>{ &quot;name&quot;: ..., &quot;description&quot;: ... }</code></td>
<td>부서 생성 데이터</td>
<td>부서 생성</td>
<td></td>
</tr>
<tr>
<td><code>/api/departments</code></td>
<td>GET</td>
<td><code>{ &quot;name&quot;: ..., &quot;description&quot;: ... }</code></td>
<td>부서 조회 데이터</td>
<td>부서 리스트 조회</td>
<td></td>
</tr>
<tr>
<td><code>/api/departments/{id}</code></td>
<td>GET</td>
<td><code>{ &quot;name&quot;: ..., &quot;description&quot;: ... }</code></td>
<td>부서 조회 데이터</td>
<td>부서 조회</td>
<td></td>
</tr>
<tr>
<td><code>/api/departments/{id}</code></td>
<td>PATCH</td>
<td><code>{ &quot;name&quot;: ..., &quot;description&quot;: ... }</code></td>
<td>부서 수정 데이터</td>
<td>부서 수정</td>
<td></td>
</tr>
<tr>
<td><code>/api/departments/{id}</code></td>
<td>DELETE</td>
<td><code>{ &quot;name&quot;: ..., &quot;description&quot;: ... }</code></td>
<td>부서 삭제 데이터</td>
<td>부서 삭제</td>
<td></td>
</tr>
</tbody></table>
<h2 id="3-프로젝트-관리">3. 프로젝트 관리</h2>
<table>
<thead>
<tr>
<th>엔드포인트</th>
<th>메서드</th>
<th>요청 데이터</th>
<th>응답 예시</th>
<th>설명</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td><code>/api/projects</code></td>
<td>POST</td>
<td><code>{ &quot;name&quot;: ..., &quot;description&quot;: ... }</code></td>
<td>프로젝트 생성 데이터</td>
<td>프로젝트 생성</td>
<td></td>
</tr>
<tr>
<td><code>/api/projects</code></td>
<td>GET</td>
<td>-</td>
<td>참여중인 프로젝트 목록</td>
<td>내 프로젝트 리스트 조회</td>
<td>관리자 권한일 시 전체 조회</td>
</tr>
<tr>
<td><code>/api/projects/{id}</code></td>
<td>GET</td>
<td>-</td>
<td>프로젝트 상세</td>
<td>단일 프로젝트 상세 조회</td>
<td></td>
</tr>
<tr>
<td><code>/api/projects/{id}</code></td>
<td>PUT</td>
<td><code>{ &quot;name&quot;: ..., &quot;description&quot;: ... }</code></td>
<td>수정 메시지</td>
<td>프로젝트 수정</td>
<td></td>
</tr>
<tr>
<td><code>/api/projects/{id}</code></td>
<td>DELETE</td>
<td>-</td>
<td>삭제 성공 메시지</td>
<td>프로젝트 삭제</td>
<td></td>
</tr>
</tbody></table>
<h2 id="4-프로젝트-멤버-및-태스크">4. 프로젝트 멤버 및 태스크</h2>
<table>
<thead>
<tr>
<th>엔드포인트</th>
<th>메서드</th>
<th>요청 데이터</th>
<th>응답 예시</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>/api/projects/{id}/members</code></td>
<td>GET</td>
<td>-</td>
<td>멤버 목록</td>
<td>프로젝트 멤버 조회</td>
</tr>
<tr>
<td><code>/api/projects/{id}/members</code></td>
<td>POST</td>
<td><code>{ &quot;userId&quot;: ..., &quot;role&quot;: ... }</code></td>
<td>추가 메시지</td>
<td>멤버 초대/추가</td>
</tr>
<tr>
<td><code>/api/tasks</code></td>
<td>POST</td>
<td><code>{ &quot;projectId&quot;: ..., &quot;title&quot;: ..., &quot;assigneeId&quot;: ... }</code></td>
<td>태스크 생성</td>
<td>태스크 생성</td>
</tr>
<tr>
<td><code>/api/tasks</code></td>
<td>GET</td>
<td><code>?projectId=...</code></td>
<td>태스크 목록</td>
<td>태스크 조회</td>
</tr>
<tr>
<td><code>/api/tasks/{id}</code></td>
<td>PUT</td>
<td><code>{ &quot;status&quot;: ... }</code></td>
<td>업데이트 메시지</td>
<td>태스크 상태 변경</td>
</tr>
<tr>
<td><code>/api/tasks/{id}</code></td>
<td>DELETE</td>
<td>-</td>
<td>삭제 성공</td>
<td>태스크 삭제</td>
</tr>
</tbody></table>
<h2 id="5-댓글-및-파일-업로드">5. 댓글 및 파일 업로드</h2>
<table>
<thead>
<tr>
<th>엔드포인트</th>
<th>메서드</th>
<th>요청 데이터</th>
<th>응답 예시</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>/api/comments</code></td>
<td>POST</td>
<td><code>{ &quot;target&quot;: &quot;task/issue/etc&quot;, &quot;targetId&quot;: ..., &quot;content&quot;: ... }</code></td>
<td>댓글 등록</td>
<td>댓글 작성</td>
</tr>
<tr>
<td><code>/api/files/upload</code></td>
<td>POST</td>
<td>(multipart/form-data)</td>
<td>업로드 성공</td>
<td>문서 업로드</td>
</tr>
</tbody></table>
<h2 id="6-알림-및-기타">6. 알림 및 기타</h2>
<table>
<thead>
<tr>
<th>엔드포인트</th>
<th>메서드</th>
<th>요청 헤더/파라미터</th>
<th>응답 예시</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>/api/notifications</code></td>
<td>GET</td>
<td>인증 헤더</td>
<td>알림 리스트</td>
<td>내 알림 조회</td>
</tr>
</tbody></table>
<hr>
<p><em>※ 참고: 추가 설계 예정.</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[일친 (IlChin) - 로그인 정보 어노테이션 만들기]]></title>
            <link>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%A0%95%EB%B3%B4-%EC%96%B4%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%A0%95%EB%B3%B4-%EC%96%B4%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Fri, 30 May 2025 09:17:48 GMT</pubDate>
            <description><![CDATA[<p>일반적으로 로그인한 사용자의 정보가 필요하면 컨트롤러에서</p>
<pre><code class="language-java">@AuthenticationPrincipal CustomUserDetails userDetails</code></pre>
<p>를 인자로 받아 사용한다.</p>
<p>근데 내가 실제로 사용해봤을 땐 번거롭기도하고 코드복잡도도 올라간다고 느껴서 편리하게 사용 가능한 어노테이션을 직접 만들어보겠다.</p>
<p>우선 어노테이션 선언 파일을 하나 만들어준다</p>
<pre><code class="language-java">@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auth {

    boolean includeUserIdx() default true;

}</code></pre>
<p>그리고 이 어노테이션을 사용해 데이터를 담거나 꺼내올수있는 holder를 하나 만들어줄거다</p>
<p>AuthHolder.class</p>
<pre><code class="language-java">public class AuthHolder {

    private static final ThreadLocal&lt;Long&gt; userIdxHolder = new ThreadLocal&lt;&gt;();

    public static void setUserId(Long userId) {
        userIdxHolder.set(userId);
    }

    public static Long getUserId() {
        return userIdxHolder.get();
    }

    public static void clearUserIdx() {
        userIdxHolder.remove();
    }

}
</code></pre>
<p>ThreadLocal을 통해 관리를 하도록 하겠다.</p>
<p>이제 매번 복잡한 코드를 생략하고, 인증절차를 미리 컨트롤러에 도달하기전에 미리 하기 위해 인터셉터를 만들어주자</p>
<pre><code class="language-java">@RequiredArgsConstructor
@Component
public class AuthInterceptor implements HandlerInterceptor {


    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {


        try{
            if (handler instanceof HandlerMethod handlerMethod) {
                Auth authAnnotation = handlerMethod.getMethodAnnotation(Auth.class);

                if (authAnnotation != null) {
                    boolean includeUserIdx = authAnnotation.includeUserIdx();
                    if (includeUserIdx) {

                        if (!request.getHeader(&quot;Authorization&quot;).startsWith(&quot;Bearer &quot;)) {
                            throw new CustomException(UserException.HANDLE_ACCESS_DENIED);
                        }
                        String token = request.getHeader(&quot;Authorization&quot;).split(&quot; &quot;)[1];

                        Long userId = TokenProvider.getUserIdFromToken(token);
                        request.setAttribute(&quot;userId&quot;, userId);
                        AuthHolder.setUserId(userId);
                    }
                    return true;
                }
            }

            return HandlerInterceptor.super.preHandle(request, response, handler);
        } catch (NullPointerException e) {
            throw new CustomException(UserException.HANDLE_ACCESS_DENIED);
        }

    }

}
</code></pre>
<p>간단한 토큰 검증 후 TokenProvider에서 userId를 꺼내 AuthHolder에 넣어주는 코드이다.</p>
<p>이제 이 인터셉터를 mvcConfig에 인터셉터로 사용합니다~ 하고 명시해준다</p>
<pre><code class="language-java">@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

    private final AuthInterceptor authInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authInterceptor);
    }

}</code></pre>
<p>이제 @Auth가 컨트롤러 메서드에 들어가면 이 api는 인증정보가 반드시 필요하게된다.</p>
<pre><code class="language-java">    @Auth
    @GetMapping(&quot;success&quot;)
    public Response&lt;Long&gt; hello() {
        return Api.success(200, &quot;성공 메시지&quot;, AuthHolder.getUserId());
    }</code></pre>
<p>이런 테스트용 api를 하나 만들어주고 호출해보면
<img src="https://velog.velcdn.com/images/no-oneho/post/fe31cc13-0e1a-4eb2-acf1-fb57946bcf6e/image.png" alt=""></p>
<p>실제로 /api/*<em>/auth/*</em> 과 상관없는 api 인데도 인증절차를 거치게 된다.
또한 성공데이터에 유저번호를 가져오는 코드를 통해
<img src="https://velog.velcdn.com/images/no-oneho/post/71114a5f-d868-4d8e-98e1-f5bc0c1ada7a/image.png" alt="">
현재 로그인한 유저의 유저번호를 가져올 수 있고</p>
<p>비즈니스 코드에 사용할 땐 이 유저번호를 통해 유저를 db에서 조회해서 사용할 수 있다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[일친 (IlChin) - 회원 로그인, 회원가입(5)]]></title>
            <link>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%ED%9A%8C%EC%9B%90-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%855</link>
            <guid>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%ED%9A%8C%EC%9B%90-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%855</guid>
            <pubDate>Fri, 30 May 2025 08:49:24 GMT</pubDate>
            <description><![CDATA[<p>이제 security config 파일에서 필터체인을 걸며 해야하는 부분이다</p>
<ol>
<li><p>CORS, CSRF, 폼 로그인: 모두 비활성화
로컬에서만 돌릴 서버기 때문에 전부 비활성화</p>
</li>
<li><p>Stateless 모드로 세션 비활성화
sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 를 통해 서버가 세션을 저장하지 않고, 매 요청마다 토큰 기반으로 인증을 처리하는 구조를 명시</p>
</li>
<li><p>커스텀 TokenFilter 등록
addFilterBefore(new TokenFilter(), UsernamePasswordAuthenticationFilter.class)를 통해 이전포스트에서 생성한 Filter를 등록해준다.</p>
</li>
<li><p>경로 보호
requestMatchers(&quot;/api/*<em>/auth/*</em>&quot;).authenticated()<br>인증이 필요한 url을 명시
반면, 그 외 모든 요청은 공개(permitAll) 처럼 설정해준다.</p>
</li>
<li><p>기타
추후 추가할 나머지 예외처리exceptionHandling()은 확장성 고려해 비워둔다</p>
</li>
</ol>
<p>이제 이대로 파일을 만들어보자</p>
<pre><code class="language-java">@Configuration
@EnableWebSecurity
public class SecurityConfig {

    CorsConfigurationSource corsConfigurationSource() {
        return request -&gt; {
            CorsConfiguration config = new CorsConfiguration();
            config.setAllowedHeaders(Collections.singletonList(&quot;*&quot;));
            config.setAllowedMethods(Collections.singletonList(&quot;*&quot;));
            config.setAllowedOriginPatterns(Collections.singletonList(&quot;*&quot;));
            config.setAllowCredentials(true);
            return config;
        };
    }

    @Bean
    public SecurityFilterChain securityFilterChai(HttpSecurity http) throws Exception {
        http
                .cors(cors -&gt; cors.configurationSource(corsConfigurationSource()))
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagement -&gt;
                        sessionManagement
                                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(new TokenFilter(), UsernamePasswordAuthenticationFilter.class)
                .authorizeHttpRequests(
                        authorizeRequests -&gt; authorizeRequests
                                .requestMatchers(&quot;/api/**/auth/**&quot;)
                                .authenticated()
                                .anyRequest().permitAll()
                )
                .headers(
                        httpSecurityHeadersConfigurer -&gt; httpSecurityHeadersConfigurer
                                .frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)
                )
                .exceptionHandling((exceptionConfig) -&gt; {});

        return http.build();
    }
}</code></pre>
<p>이제 진짜 거의 다왔다.</p>
<p>일단 현재까지 개발한 내용으로 권한 인증이 필요한 api를 테스트용으로 하나 만들어보자</p>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/36420251-5ccf-4a1c-aa39-9d47ef335e3e/image.png" alt=""></p>
<p>인증토큰을 제대로 넣은 경우 의도한대로 동작을 하고있다.
그럼 토큰이 잘못되었거나 변조되었다면? 아니면 기간만료일경우
아무튼 토큰 유효성 검증을 통과 못한 경우엔 어떻게 될까?</p>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/b9d60f0d-e73c-4edc-a9af-6c886ce3c991/image.png" alt="">
에러는 뜬다.. 근데 이 에러는 우리가 이전에 만든 에러 response하고 형태가 전혀 다르다</p>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/64627b08-415c-42b9-a3c5-70381fbd9f8c/image.png" alt="">
이런 json으로 반환하는게 기대값이였는데 왜 그럴까?</p>
<p>Spring security 필터 단계에서 발생하는 예외는 DispatcherServlet가 호출되기 전에 발생하게 된다.
반면 저번에 만들어둔 GlobalExceptionHandler은 DispatcherServlet이 요청을 받아서 처리할 때 호출된다.</p>
<p>그렇기 때문에 Advice로는 해당 예외를 캐치할수가없다</p>
<p>흠.. 그렇다면 어떤식으로 해결하면 될까?
바로 Spring security 내부적으로 exception을 담당하는 필터를 한번 더 거치게 만들면 된다.</p>
<p>바로 만들어보자</p>
<pre><code class="language-java">public class TokenExceptionHandleFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws
            ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (SignatureException | ExpiredJwtException e) {
            setErrorResponse(response, UserException.CANT_ACCESS);
        } catch (AccessDeniedException e) {
            setErrorResponse(response, UserException.HANDLE_ACCESS_DENIED);
        } catch (JwtException e) {
            filterChain.doFilter(request, response);
        }
    }

    private void setErrorResponse(HttpServletResponse response, Error errorCode) throws IOException {
        Response&lt;String&gt; exceptionResponse = new Response&lt;&gt;(errorCode.getStatus().value(), errorCode.getMessage());

        // 응답 상태 코드를 401로 설정 (인증 실패).
        response.setStatus(errorCode.getStatus().value());

        response.setContentType(&quot;application/json&quot;);

        response.setCharacterEncoding(&quot;utf-8&quot;);

        try (PrintWriter writer = response.getWriter()) {
            ObjectMapper objectMapper = new ObjectMapper();
            writer.write(objectMapper.writeValueAsString(exceptionResponse));
        }
    }

}</code></pre>
<p>그 후 새로 만든 핸들필터를 securityConfig에 필터체인을 걸어준다</p>
<pre><code class="language-java">.addFilterBefore(new TokenExceptionHandleFilter(), UsernamePasswordAuthenticationFilter.class)</code></pre>
<p>추가적으로 작업할 예외가 있다면 커스텀해서 security config에 </p>
<pre><code class="language-java">.exceptionHandling((exceptionConfig) -&gt; {});</code></pre>
<p>부분에 추가하면 된다</p>
<p>예를들어</p>
<pre><code class="language-java">@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException, ServletException {

        Response&lt;String&gt; exceptionResponse = new Response&lt;&gt;(403, &quot;권한이 없는 메뉴에 접근하셨습니다.&quot;);

        response.setStatus(HttpServletResponse.SC_FORBIDDEN);

        response.setContentType(&quot;application/json&quot;);

        response.setCharacterEncoding(&quot;utf-8&quot;);

        try (PrintWriter writer = response.getWriter()) {
            ObjectMapper objectMapper = new ObjectMapper();
            writer.write(objectMapper.writeValueAsString(exceptionResponse));
        }
    }
}</code></pre>
<p>이런 파일을 하나 생성하고
Security Config 파일에 의존성 주입을 해준 후</p>
<pre><code class="language-java">.exceptionHandling((exceptionConfig) -&gt; {
                    exceptionConfig.accessDeniedHandler(accessDeniedHandler);
                });</code></pre>
<p>이런식으로 추가하면 된다.</p>
<pre><code class="language-java">@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomAccessDeniedHandler accessDeniedHandler;
    private final CustomAuthenticationEntryPoint authenticationEntryPoint;

    CorsConfigurationSource corsConfigurationSource() {
        return request -&gt; {
            CorsConfiguration config = new CorsConfiguration();
            config.setAllowedHeaders(Collections.singletonList(&quot;*&quot;));
            config.setAllowedMethods(Collections.singletonList(&quot;*&quot;));
            config.setAllowedOriginPatterns(Collections.singletonList(&quot;*&quot;));
            config.setAllowCredentials(true);
            return config;
        };
    }

    @Bean
    public SecurityFilterChain securityFilterChai(HttpSecurity http) throws Exception {
        http
                .cors(cors -&gt; cors.configurationSource(corsConfigurationSource()))
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagement -&gt;
                        sessionManagement
                                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(new TokenExceptionHandleFilter(), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new TokenFilter(), UsernamePasswordAuthenticationFilter.class)
                .authorizeHttpRequests(
                        authorizeRequests -&gt; authorizeRequests
                                .requestMatchers(&quot;/api/**/auth/**&quot;)
                                .authenticated()
                                .anyRequest().permitAll()
                )
                .headers(
                        httpSecurityHeadersConfigurer -&gt; httpSecurityHeadersConfigurer
                                .frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)
                )
                .exceptionHandling((exceptionConfig) -&gt; {
                    exceptionConfig.accessDeniedHandler(accessDeniedHandler);
                    exceptionConfig.authenticationEntryPoint(authenticationEntryPoint);
                });

        return http.build();
    }
}</code></pre>
<p>이게 완성된 내 security config 파일이다</p>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/c68f5b6e-c4e4-4e9d-a062-bd76df07cf19/image.png" alt="">
이런 느낌으로 원하는대로 잘 떨어진다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[일친 (IlChin) - 회원 로그인, 회원가입(4)]]></title>
            <link>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%ED%9A%8C%EC%9B%90-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%854</link>
            <guid>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%ED%9A%8C%EC%9B%90-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%854</guid>
            <pubDate>Fri, 30 May 2025 06:48:27 GMT</pubDate>
            <description><![CDATA[<p>이번 포스팅에선 Spring security와 로그인을 합치기 위해
첫번째로 필터를 개발하도록 하겠다.</p>
<p>필터의 역할은 클라이언트가 요청할 때마다 헤더의 담긴 jwt 토큰을 읽어 유효성 검증을 통해 <strong>유효하다면</strong> SecurityContext에 올려주고, <strong>유효하지않다면</strong> 인증실패처리를 해주는 기능을 가지고있다.</p>
<p>그 후 만든 필터를 SecurityConfig 파일을 만들어 등록해주는 것 까지 해보겠다.</p>
<p>우선 TokenFilter.class를 만들어주자</p>
<pre><code class="language-java">public class TokenFilter extends OncePerRequestFilter {


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

    }
}
</code></pre>
<p>이것이 내가 만들 토큰필터의 기본 구조이다.
이제 이 필터에 매 요청시 검증하는 로직을 만들것이다.</p>
<p>일단 검증 단계를 설계해보자 나같은 경우는 우선</p>
<ol>
<li>헤더에 토큰 속성이 존재하는지</li>
<li>존재한다면 Bearer 로 시작하는 정상 토큰인지</li>
<li>만료시간이 초과된 토큰인지</li>
</ol>
<p>이렇게 세단계를 우선 검증해볼것이다.</p>
<p>이 단계를 검증하려면 TokenProvider 클래스에 만료시간 체크 메서드 추가가 필요하니 그 메서드부터 만들어주자</p>
<p>TokenProvider.class</p>
<pre><code class="language-java">public static boolean isExpired(String token) {
        Date expiredDate = extractClaims(token).getExpiration();
        return expiredDate.before(new Date());
    }</code></pre>
<p>그 후 앞서 말한 세가지 검증을 Filter 단에 추가해보자</p>
<pre><code class="language-java">@RequiredArgsConstructor
public class TokenFilter extends OncePerRequestFilter {


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        //헤더에 토큰을 담는 속성이 없으면 비로그인으로 판단
        if (authorizationHeader == null) {
            filterChain.doFilter(request, response);
            return;
        }
        //Bearer 로 시작하는 정상 토큰인지 유효성 검사
        if (!authorizationHeader.startsWith(&quot;Bearer &quot;)) {
            filterChain.doFilter(request,response);
            return;
        }
        String token = authorizationHeader.split(&quot; &quot;)[1];

        //토큰 만료시간 검사
        if (TokenProvider.isExpired(token)) {
            filterChain.doFilter(request, response);
            return;
        }
    }
}</code></pre>
<p>이제 세가지 단계를 넘어간 경우 정상적으로 시스템에서 사용이 가능한 토큰임을 검증했다. </p>
<p>그리고 토큰에서 필요한 정보를 꺼내서 나머지 로직을 진행하면 된다.</p>
<p>그리고 토큰에서 필요한 정보를 꺼내쓰는 메서드를 만들고
이 때 이전에는 토큰안에 넣지않았지만 향후 권한정보가 필요할 수 있으므로
TokenProvider에서 토큰생성코드 수정과 필요한 몇가지 메서드를 만들어주자</p>
<pre><code class="language-java">public static String createToken(User user) {
        Date expiryDate = Date.from(
                Instant.now()
                        .plus(1, ChronoUnit.DAYS)
        );

        Claims claims = Jwts.claims();
        claims.put(&quot;id&quot;, user.getId());
        claims.put(&quot;username&quot;, user.getUsername());
        //추가 된 부분
        claims.put(&quot;role&quot;, user.getRole());

        return Jwts.builder()
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .setClaims(claims)
                .setExpiration(expiryDate)
                .compact();
    }

    public static Long getUserIdFromToken(String token) {
        return extractClaims(token).get(&quot;id&quot;, Long.class);
    }

    public static String getUsernameFromToken(String token) {
        return extractClaims(token).get(&quot;username&quot;, String.class);
    }

    public static String getRoleFromToken(String token) {
        return extractClaims(token).get(&quot;role&quot;, String.class);
    }</code></pre>
<p>그리고 사용자 정보를 인증객체로 생성하기 위해 UserDetails를 상속받는 클래스를 생성해주자
권한 부분은 일단 간단하게 만들고 추후 권한에 따른 설정같은게 늘어나면 기능확장을 하면 된다.</p>
<p>CustomUserDetails.class</p>
<pre><code class="language-java">@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final String userRole;

    @Override
    public Collection&lt;? extends GrantedAuthority&gt; getAuthorities() {
        Set&lt;GrantedAuthority&gt; authorities = new HashSet&lt;&gt;();
        if (userRole.equals(&quot;ADMIN&quot;)) {
            authorities.add(new SimpleGrantedAuthority(&quot;ROLE_ADMIN&quot;));
        }

        if (userRole.equals(&quot;MEMBER&quot;)) {
            authorities.add(new SimpleGrantedAuthority(&quot;ROLE_MEMBER&quot;));
        }

        if (userRole.equals(&quot;GUEST&quot;)) {
            authorities.add(new SimpleGrantedAuthority(&quot;ROLE_GUEST&quot;));
        }

        return authorities;
    }

    @Override
    public String getPassword() {
        return &quot;&quot;;
    }

    @Override
    public String getUsername() {
        return &quot;&quot;;
    }
}</code></pre>
<p>이런 느낌으로 만들어주자</p>
<p>그리고 UsernamePasswordAuthenticationToken 객체를 하나 생성해서 CustomUserDetails 로 만든 인증객체에 사용자 정보도 넣어 스프링 시큐리티에서 사용할 수 있게 만들어 준다.</p>
<p>다음으로는 요청에 대한 인증 정보를 스프링 시큐리티 컨택스트에 넣는거로 인증 인가 부분을 마무리 할 수 있다.</p>
<p>전체코드</p>
<pre><code class="language-java">public class TokenFilter extends OncePerRequestFilter {


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        //헤더에 토큰을 담는 속성이 없으면 비로그인으로 판단
        if (authorizationHeader == null) {
            filterChain.doFilter(request, response);
            return;
        }
        //Bearer 로 시작하는 정상 토큰인지 유효성 검사
        if (!authorizationHeader.startsWith(&quot;Bearer &quot;)) {
            filterChain.doFilter(request, response);
            return;
        }
        String token = authorizationHeader.split(&quot; &quot;)[1];

        //토큰 만료시간 검사
        if (TokenProvider.isExpired(token)) {
            filterChain.doFilter(request, response);
            return;
        }

        Long userId = TokenProvider.getUserIdFromToken(token);
        String username = TokenProvider.getUsernameFromToken(token);
        String role = TokenProvider.getRoleFromToken(token);

        CustomUserDetails userDetails = new CustomUserDetails(role);

        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null,
                userDetails.getAuthorities());

        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authentication);
        filterChain.doFilter(request,response);

    }
}</code></pre>
<p>요약하자면 TokenFilter 에서는 jwt 토큰의 유효성을 검증하고
검증한 후 </p>
<ul>
<li>JWT 토큰에 담긴 사용자 정보를 바로 추출</li>
<li>그 정보를 스프링 시큐리티 인증 객체에 세팅</li>
<li>이로써 이후 요청에 대한 인증, 권한 체크가 원활히 수행됨</li>
</ul>
<p>이러한 핵심 기능을 수행하는 단계라고 볼 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[일친 (IlChin) - 회원 로그인, 회원가입(3)]]></title>
            <link>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%ED%9A%8C%EC%9B%90-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%853</link>
            <guid>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%ED%9A%8C%EC%9B%90-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%853</guid>
            <pubDate>Fri, 30 May 2025 05:36:16 GMT</pubDate>
            <description><![CDATA[<p>이제 토큰 생성을 만들었으니 로그인 기능을 구현해보자</p>
<p>이번 포스팅은 UserService 쪽에 로그인 메서드를 만드는 간단한 작업이다</p>
<pre><code class="language-java">    @Transactional
    public LoginResp login(LoginReq login) {
        User user = userRepository.findByUsername(login.getUsername())
                .orElseThrow(()-&gt;new CustomException(UserException.BAD_REQUEST_LOGIN));

        if (!passwordEncoder.matches(login.getPassword(), user.getPassword())) {
            throw new CustomException(UserException.BAD_REQUEST_LOGIN);
        }

        String token = TokenProvider.createToken(user);
        return LoginResp.from(user, token);
    }</code></pre>
<p>간단한 유효성 검사와 토큰 발급이 포함된 로그인 서비스 코드이다.
jpa나 쿼리사용, resp와 같은건 본인에 맞게 잘 응용해서 만들어서 사용하면 된다</p>
<p>이제 컨트롤러 코드를 만들어보자</p>
<pre><code class="language-java">@PostMapping(&quot;/login&quot;)
    public Response&lt;LoginResp&gt; login(@RequestBody LoginReq loginReq) {
        return Api.success(200, &quot;success&quot;, userService.login(loginReq));
    }</code></pre>
<p>여기도 별건 없다.</p>
<p>이제 토큰이 잘 발급되는지 실행해보면
<img src="https://velog.velcdn.com/images/no-oneho/post/f5eaec0e-fb0c-434d-891e-e897ef524526/image.png" alt=""></p>
<p>어제 만들어둔 아이디와 비밀번호로 잘 로그인됨이 확인 되었다.</p>
<p>이제 다음 포스팅은 드디어 Spring security와 연동하는 부분이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[일친 (IlChin) - 회원 로그인, 회원가입(2)]]></title>
            <link>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%ED%9A%8C%EC%9B%90-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%852</link>
            <guid>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%ED%9A%8C%EC%9B%90-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%852</guid>
            <pubDate>Fri, 30 May 2025 04:49:04 GMT</pubDate>
            <description><![CDATA[<p>이번 포스팅에서는 드디어 회원 인증/인가 부분인 로그인을 구현하도록 하겠다</p>
<p>먼저 다른 일반적인 방식과는 특이하게 UserDetailsService를 상속받지않고 구현하는것으로 포스팅을 하겠다!</p>
<p>우선 암호화 키를 하나 설정해준다.
다만 이 암호화 키 또한 환경변수로 둬서 노출가능성을 최소화하는 방식으로 하겠다.</p>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/8e27b5ae-52f8-4b6c-8175-3823c7f5a052/image.png" alt="">
실행환경에 환경변수를 설정해두고 (지금 방식은 인텔리제이에서 로컬 실행용으로만 사용하는 환경변수 설정이다)</p>
<p>이후 application.yml 에 명시해주고 코드에서 사용하면 된다!
<img src="https://velog.velcdn.com/images/no-oneho/post/318d3825-4195-417e-959a-d4cce6b37bf2/image.png" alt=""></p>
<p>이제 토큰로그인 방식으로 구현하기위해 tokenProvider 클래스를 하나 만들어준다.</p>
<pre><code class="language-java">@Service
public class TokenProvider {

    private static String SECRET_KEY;

    @Value(&quot;${secret-key-source}&quot;)
    public void setSecretKey(String secretKey) {
        SECRET_KEY = secretKey;
    }

    public String createToken(User user) {
        return null;
    }

    private static Claims extractClaims(String token) {
        return null;
    }

}</code></pre>
<p>이게 기본 구조이다. createToken은 토큰을 만드는 메서드, extractClaims은 토큰에서 정보를 추출하는 메서드 라고보면된다.</p>
<p>이제 뭘 구현할지 하나씩 생각해보자
우리가 사용할 jwt 토큰에 필요한 필수정보는 유저의 식별값, 유저의 아이디, 만료기한
이정도가 될 수 있다.</p>
<p>쉽게 생각하면 토큰을 만들고 토큰에서 정보를 빼내올 때 어떤 정보를 나중에 사용할지 고민하면 스펙에 맞게 설계하고 코드를 작성하면 된다.</p>
<p>그럼 유저의 식별값과 아이디부터 해보자</p>
<p>식별값과 아이디의 경우 createToken 메서드에서 인자로 받는 User 객체안에 들어있을거기 때문에 간단하다.</p>
<pre><code class="language-java">public String createToken(User user) {

        Claims claims = Jwts.claims();
        claims.put(&quot;id&quot;, user.getId());
        claims.put(&quot;username&quot;, user.getUsername());

        return null;
    }</code></pre>
<p>그리고 그 다음은 만료기한이다. </p>
<pre><code class="language-java">public String createToken(User user) {
        Date expiryDate = Date.from(
                Instant.now()
                        .plus(1, ChronoUnit.DAYS)
        );

        Claims claims = Jwts.claims();
        claims.put(&quot;id&quot;, user.getId());
        claims.put(&quot;username&quot;, user.getUsername());

        return null;
    }</code></pre>
<p>이렇게 만들면 jwt 토큰에는 식별값, 아이디, 만료기한이 들어있게 된다. 이제 이걸 토큰 형식으로 변환해서 return 해주면된다.</p>
<pre><code class="language-java">public String createToken(User user) {
        Date expiryDate = Date.from(
                Instant.now()
                        .plus(1, ChronoUnit.DAYS)
        );

        Claims claims = Jwts.claims();
        claims.put(&quot;id&quot;, user.getId());
        claims.put(&quot;username&quot;, user.getUsername());

        return Jwts.builder()
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .setClaims(claims)
                .setExpiration(expiryDate)
                .compact();
    }</code></pre>
<p>토큰 생성부분 코드이다. 이제 이걸 테스트코드로 잘 나오는지 테스트해보자</p>
<p>TokenTest.class</p>
<pre><code class="language-java">public class TokenTest {

    private TokenProvider tokenProvider;

    @BeforeEach
    public void setUp() {
        tokenProvider = new TokenProvider();
        tokenProvider.setSecretKey(&quot;test&quot;);
    }

    @Test
    public void 토큰생성_테스트() {
        User user = User.builder()
                .id(1L)
                .username(&quot;test&quot;)
                .password(&quot;&lt;PASSWORD&gt;&quot;)
                .build();
        String token = tokenProvider.createToken(user);
        System.out.println(token);
    }



}</code></pre>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/003fcaaf-30a1-4e98-ae65-f2cbe42ae081/image.png" alt=""></p>
<p>결과도 잘 나오는걸 확인 할 수 있다.</p>
<p>근데 토큰만 있으면 실제로 넘어간 데이터가 잘 저장됐는지 확인이 안되지않나?
그 테스트는 이제 정보를 추출할 메서드를 마저 만든 후 그 메서드를 테스트하며 확인해볼것이다.</p>
<pre><code class="language-java">public Claims extractClaims(String token) {
        return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
    }</code></pre>
<p>이러한 정보추출 메서드를 만들었다.
접근제한은 테스트에서 사용하기위해 일단 public
테스트가 확인되면 다시 prvaite 으로 변경할것이다.</p>
<p>이제 이 코드또한 테스트코드로 돌려보자</p>
<p>TokenTest.class</p>
<pre><code class="language-java">public class TokenTest {

    private TokenProvider tokenProvider;
    private String token;
    private final User user = User.builder()
            .id(1L)
            .username(&quot;test&quot;)
            .password(&quot;&lt;PASSWORD&gt;&quot;)
            .build();

    @BeforeEach
    public void setUp() {
        tokenProvider = new TokenProvider();
        tokenProvider.setSecretKey(&quot;test&quot;);
        token = tokenProvider.createToken(user);
    }

    @Test
    public void 토큰정보_확인_테스트() {
        Claims claims = tokenProvider.extractClaims(token);
        assertEquals(Long.valueOf(claims.get(&quot;id&quot;).toString()), user.getId());
        assertEquals(claims.get(&quot;username&quot;).toString(), user.getUsername());
    }
}</code></pre>
<p>token 생성의 선행작업을 위해 생성 테스트 코드를 BeforeEach로 위치를 변경하였다.
<img src="https://velog.velcdn.com/images/no-oneho/post/6899d6c7-4c96-4a62-b30a-dd780612d468/image.png" alt="">
테스트 결과도 성공이므로 로그인했을때 정보를 담고있는 jwt 토큰을 생성하는데는 성공적이다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[일친 (IlChin) - 회원 로그인, 회원가입(1)]]></title>
            <link>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%ED%9A%8C%EC%9B%90-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85</link>
            <guid>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%ED%9A%8C%EC%9B%90-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85</guid>
            <pubDate>Thu, 29 May 2025 09:59:28 GMT</pubDate>
            <description><![CDATA[<p>이번 포스팅은 회원가입을 포스팅해보겠다</p>
<p>먼저 이번 포스팅에 필요한 의존성이다</p>
<pre><code>implementation &#39;org.springframework.boot:spring-boot-starter-security&#39;
implementation group: &#39;io.jsonwebtoken&#39;, name: &#39;jjwt&#39;, version: &#39;0.9.1&#39;</code></pre><p>인증 인가의 경우</p>
<p>상대적으로 간단하고 순서상 회원가입이 되어야하기때문에 회원가입을 먼저 개발해보자</p>
<p>비즈니스에 맞게 회원가입 dto를 작성해보자
<img src="https://velog.velcdn.com/images/no-oneho/post/446abafc-deea-469f-a8ac-56f311cd4653/image.png" alt="">
<img src="https://velog.velcdn.com/images/no-oneho/post/db89a7cc-1e5b-42a2-ae99-d867ddaff5e9/image.png" alt=""></p>
<p>내 비즈니스에서는 받아야할 정보가 username(아이디로 쓸 컬럼), email, password, role, department, full_name 이다.</p>
<p>부서 비즈니스는 아직 미개발이므로 그 쪽은 임시코드로 개발하도록 하겠다</p>
<pre><code class="language-java">@Getter
@NoArgsConstructor
@Builder
@AllArgsConstructor
public class SignUp {

    private String username;
    private String email;
    private String password;
    private String confirmPassword;
    private String role;
    private Integer department;
    private String phone;
    private String fullName;

}</code></pre>
<p>이렇게 만든 SignUp Request를 우리는 User와 UserProfile로 변경해주어야한다</p>
<pre><code class="language-java">@Builder
    public static User SignUpToUser(SignUp signUp, String passwordEnc) {
        return User.builder()
                .username(signUp.getUsername())
                .email(signUp.getEmail())
                .password(passwordEnc)
                .role(signUp.getRole())
                .createdAt(LocalDateTime.now())
                .isDeleted(false)
                .build();
    }


        @Builder
    public static UserProfile SignUpToUserProfile(Long userId, SignUp signUp) {
        return UserProfile.builder()
                .userId(userId)
                .departmentId(signUp.getDepartment())
                .fullName(signUp.getFullName())
                .phone(signUp.getPhone())
                .build();
    }</code></pre>
<p>각 엔티티에 빌더 메서드를 하나 생성해 만들어주는 코드를 작성한다.</p>
<p>그 다음은 드디어 서비스 코드다.</p>
<p>먼저 서비스단에 비즈니스 코드를 적기전에 코드를 구상해본다</p>
<p>회원가입에 들어가는 기능이라면</p>
<ol>
<li>아이디 중복체크</li>
<li>비밀번호와 비밀번호 확인 체크</li>
<li>비밀번호 암호화</li>
</ol>
<p>간단하게 이정도 기능을 가지고 있다.
그럼 아이디 중복체크와 비밀번호 체크는 자바 코드상으로 해결하면 되고
비밀번호 암호화는 어떻게할까?</p>
<p>암호화의 종류는 정말 많고 복잡하다. 다만 우리가 쓰는 Spring Security 의존성에는 편리하게 사용할 수 있는 인터페이스가 미리 정의되어있어</p>
<p>설정파일을 통해 bean에 미리 등록해주면 의존성 주입을 받아 사용할 수 있게된다.</p>
<p>PasswordEncConfig.class</p>
<pre><code class="language-java">@Configuration
public class PasswordEncConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}</code></pre>
<p>UserService</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final UserProfileRepository userProfileRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public Void createUser(SignUp signUp) {
        checkUserExist(signUp.getUsername());
        checkPasswordConfirm(signUp.getPassword(), signUp.getConfirmPassword());

        User user = userRepository.save(User.SignUpToUser(signUp, passwordEncoder.encode(signUp.getPassword())));
        userProfileRepository.save(UserProfile.SignUpToUserProfile(user.getId(), signUp));
        return null;
    }


    private void checkUserExist(String username) {
        if (!userRepository.isUsernameAvailable(username)) {
            throw new CustomException(UserException.ALREADY_EXISTS);
        }
    }

    private void checkPasswordConfirm(String password, String confirmPassword) {
        if (!password.equals(confirmPassword)) {
            throw new CustomException(UserException.MISS_MATCH_PASSWORD);
        }
    }
}
</code></pre>
<p>완성된 간단한 회원가입 로직이다.
음.. 특이한점은 userProfileRepository을 저장할 때 user.getId()를 바로 가져오는 형태인데 </p>
<p>save로 먼저 저장을 하고 저장한 객체에 접근하여 데이터를 변경하여도 DB에 적용이 된다.
이는 JPA의 영속성과 관련된 부분으로 save를 통해 user를 영속성 컨테이너에 저장하여 저장된 영속 엔티티들은 DB와 동일성을 보장해주기때문에 가능한 방법이다.</p>
<p>또 createUser가 Void 타입을 반환하도록 되어있는데 이는 나중에 토큰을 반환하는 형식으로 변경하기 위한 임시코드이다.</p>
<p>이제 마지막으로 컨트롤러를 작성하러 가자</p>
<p>UserController.class</p>
<pre><code class="language-java">@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/api/user&quot;)
public class UserController {

    private final UserService userService;

    @PostMapping(&quot;/sign-up&quot;)
    public Response&lt;Void&gt; signUp(@RequestBody SignUp signUp) {
        return Api.success(200, &quot;success&quot;, userService.createUser(signUp));
    }

}</code></pre>
<p>컨트롤러 코드이다.</p>
<p>이제 서버를 실행하고 해당 api를 호출해보자</p>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/5442589d-6671-4161-a41d-a126170d0310/image.png" alt="">
<img src="https://velog.velcdn.com/images/no-oneho/post/e859da3c-b3fa-41b3-adaa-66499ae2378c/image.png" alt=""></p>
<p>유효성 검사도 체크 해주고
이제 가입을 해보자</p>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/1a12bcf8-2328-4a0d-9a43-3e456cb41c58/image.png" alt="">
<img src="https://velog.velcdn.com/images/no-oneho/post/3e2aab16-91e0-42e4-9273-5f5fa5c80812/image.png" alt="">
<img src="https://velog.velcdn.com/images/no-oneho/post/bbad4508-0436-4c2e-888a-840227cf7251/image.png" alt=""></p>
<p>의도한대로 user 테이블과 userProfile 테이블에 잘 들어감을 확인했다!</p>
<p>이제 대망의 로그인기능을 다음에 포스팅 해보겠다</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[일친 (IlChin) - AOP를 통한 공통 에러 처리]]></title>
            <link>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-AOP%EB%A5%BC-%ED%86%B5%ED%95%9C-%EA%B3%B5%ED%86%B5-%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-AOP%EB%A5%BC-%ED%86%B5%ED%95%9C-%EA%B3%B5%ED%86%B5-%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Thu, 29 May 2025 08:01:59 GMT</pubDate>
            <description><![CDATA[<p>이번 포스팅은 비즈니스 로직을 시작하기 전 마지막 단계로
AOP를 통해 공통 에러를 처리해보는 시간을 가지겠다.</p>
<p>먼저 AOP가 무엇인가 부터 간단하게 설명하자면  <strong>Aspect Oriented Programming,</strong> 우리나라 말로는 <b><strong>관점지향 프로그래밍</strong></b> 정도로 불린다</p>
<p>그래서 이게 뭔데? 관점적으로 지향하는 프로그래밍을 어디다 쓰는건데?</p>
<p>이게 무슨말이냐하면 프로젝트를 개발하는 개발자의 관점을 생각해보자
이때 <strong>핵심적으로 생각해야할 관점이 존재할 것이고, 부가적으로 생각할 관점</strong>이 존재할것이다.</p>
<p>예를들어 비즈니스 로직, 즉 <strong>프로젝트를 개발하는 목적과 연관</strong>되며 고민을 오래하고 시간을 쏟아야하는것을 <strong>핵심적인 관점</strong>으로 바라보도록 한다면?</p>
<p>부가적인 관점은 메서드 시간체크, 로그스탬프, <strong>에러처리</strong> 등은 개발자가 비즈니스 로직을 개발할 때 부가적으로 생각해야할 것들이 된다.</p>
<p>그렇다면 관점지향 프로그래밍을 적용하게 되면 어떻게 변할까?</p>
<p>쉽게말해 부가적으로 따라오는것들을 비즈니스 코드와 함께 코딩을 해야하는게 아니라 알아서 처리되도록 프로그래밍을 해두는것이 관점지향 프로그래밍을 쉽게 설명한것이 된다.</p>
<p>살짝 딥하게 들어가자면 스프링 빈의 생명주기에 따라 컨텍스트 내부에 외부 요청이 들어오게되면 AOP를 사용하지 않았을 때 비즈니스 로직과 함께 돌게되어 코드의 복잡성과 개발자의 피로도가 높아지지만</p>
<p>AOP를 사용하면 외부 요청이 들어올때 중간에 요청을 가로채서 처리할 부분을 처리해 준 후 요청이 도착하여 비즈니스 로직을 수행할 수 있다.</p>
<p>그럼 AOP를 사용하여 에러처리를 해보도록 하자</p>
<p>먼저 에러에 들어갈 내용이 담긴 인터페이스를 하나 생성해준다</p>
<p>Error.class</p>
<pre><code class="language-java">public interface Error {
    HttpStatus getStatus();
    String getMessage();
}</code></pre>
<p>그리고 이 인터페이스를 상속받아주는 클래스를 하나 더 생성한다</p>
<p>CustomCxception.class</p>
<pre><code class="language-java">@Getter
public class CustomException extends RuntimeException{

    private final Error error;
    private final String errorMessage;

    public CustomException(Error error) {
        super(error.getMessage());
        this.error = error;
        this.errorMessage = error.getMessage();
    }

}</code></pre>
<p>마지막으로 비즈니스나 도메인, 뭐 한곳에 몰아도 상관없지만 에러를 미리 정의해둘 ENUM 파일을 생성해준다.</p>
<p>SampleError.class</p>
<pre><code class="language-java">@Getter
@AllArgsConstructor
public enum SampleError implements Error {


    SAMPLE_ERROR(HttpStatus.BAD_REQUEST, &quot;sample error&quot;)
    ;

    private final HttpStatus status;
    private final String message;

}</code></pre>
<p>이제 준비는 끝났고, 서두에서 설명한 AOP를 사용하러 가보자</p>
<p>GlobalExceptionHandler.class</p>
<pre><code class="language-java">@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public ResponseEntity&lt;Response&lt;Void&gt;&gt; handleException(Exception e) {
        Response&lt;Void&gt; errorResponse = Api.error(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
        return new ResponseEntity&lt;&gt;(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
    }

    @ExceptionHandler(CustomException.class)
    public ResponseEntity&lt;Response&lt;Void&gt;&gt; handleCustomException(CustomException e) {
        Response&lt;Void&gt; errorResponse = Api.error(e.getError().getStatus(), e.getMessage());
        return new ResponseEntity&lt;&gt;(errorResponse, e.getError().getStatus());
    }
}</code></pre>
<p>@RestControllerAdvice 는 Controller단에 AOP를 적용하기위한 어노테이션이다.
나머지는 본인에 코드에 맞게 에러를 처리해주면 된다.</p>
<p>특이사항으로는 Exception 전체를 받는 handleException 메서드가 있는데 예외처리를 실수로 못했을 경우 500처리를 해주는 메서드라고 보면 된다.</p>
<p>이제 이걸 실 코드에 적용을 하면</p>
<pre><code class="language-java">   @GetMapping(&quot;error&quot;)
    public Response&lt;String&gt; error() {
        int a = 1;
        if (a == 1) {
            throw new CustomException(SampleError.SAMPLE_ERROR);
        }
        return Api.success(200, &quot;성공 메시지&quot;, &quot;Hello World&quot;);
    }</code></pre>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/282183f3-7512-4b0f-a21a-616f2bab7019/image.png" alt=""></p>
<p>이렇게 간단하게 적용을 할 수 있다.</p>
<p>실제로 비즈니스 코드에 적용 할 때는 try/catch가 확연하게 줄어 가독성도 좋아 항상 사용하고 있다.</p>
<p>다만 딱 한가지 문제가 있는데 <strong>해당 기능만으로는 필터단에서 발생하는 Exception을 잡을수가 없다</strong></p>
<p>예를들어 회원의 인증/인가 부분의 경우 Spring Security를 사용하게되면 대부분 Filter를 사용하여 앞단을 처리하게 될텐데 그렇게되면 요청이 들어올 때</p>
<p><strong>요청 ---&gt; 필터 ---&gt; AOP(에러처리) ---&gt; 컨트롤러</strong></p>
<p>이런 형식으로 도달하게 된다. 그러므로 필터단에서 에러가 발생해도 내가 원하는 형태로 리스폰스를 잡아줄수가 없는데 요건 다음 포스팅에 회원 인증/인가 부분을 개발하며 함께 다뤄보겠다</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[일친 (IlChin) - 리스폰스 DTO 생성]]></title>
            <link>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%EB%A6%AC%EC%8A%A4%ED%8F%B0%EC%8A%A4-DTO-%EC%83%9D%EC%84%B1</link>
            <guid>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%EB%A6%AC%EC%8A%A4%ED%8F%B0%EC%8A%A4-DTO-%EC%83%9D%EC%84%B1</guid>
            <pubDate>Thu, 29 May 2025 07:11:29 GMT</pubDate>
            <description><![CDATA[<p>현재 프로젝트는 sql 관련 세팅 (repo, querydsl 세팅등) 정도로 해두었다.
이번 포스팅에는 모든 리스폰스를 처리하는 dto를 통해 일관된 구조로 응답을 내려보도록 하겠다.</p>
<p>먼저 내가 원하는 형식의 dto를 하나 생성한다
/dto/Response.class</p>
<pre><code class="language-java">@Getter
@NoArgsConstructor
@Builder
public class Response&lt;T&gt; {

    private String message;
    private HttpStatus code;

    @JsonInclude(JsonInclude.Include.NON_NULL)
    private T data;

    @Builder
    public Response(String message, HttpStatus code, T data) {
        this.message = message;
        this.code = code;
        this.data = data;
    }

}</code></pre>
<p>첫번째 String 의 경우 응답 결과에 따른 메시지, 오류 메시지를 담는 공간
code는 HttpStatus code를 담는 공간</p>
<p>그리고 제일 중요한 data 변수가 응답으로 보내줄 핵심 데이터가 들어갈 공간이다.
이 경우 data 변수안에 리스트, 정수, 문자열, 튜플등 가지각색에 데이터가 들어갈 가능성이 있는 변수 공간이기에 제네릭 Type으로 지정해서 만들어줬다.</p>
<p>그럼 dto를 만들었으니 이 DTO를 반환시켜줄 클래스를 생성하러 가보자</p>
<p>/utils/Api.class</p>
<pre><code class="language-java">public class Api {
    public static &lt;T&gt; Response&lt;T&gt; success(HttpStatus code, String message, T data) {
        return new Response&lt;&gt;(message, code.value(), data);
    }

    public static &lt;T&gt; Response&lt;T&gt; success(Integer code, String message, T data) {
        return new Response&lt;&gt;(message, code, data);
    }

    public static &lt;T&gt; Response&lt;T&gt; error(Integer code, String message) {
        return new Response&lt;&gt;(message, code, null);
    }

    public static &lt;T&gt; Response&lt;T&gt; error(HttpStatus code, String message) {
        return new Response&lt;&gt;(message, code.value(), null);
    }

}</code></pre>
<p>단순 반환 구조, code의 경우 편의성을 위해 오버로딩 메서드 하나정도 더 만들어뒀다.</p>
<p>이제 이렇게 만든 구조를 테스트 코드를 사용해 테스트해보자</p>
<details>
  <summary>테스트코드</summary>

<pre><code class="language-java">@Test
    void 정상_반환_테스트_숫자() throws Exception {
        String message = &quot;성공&quot;;
        Integer data = 1;
        Integer code = 200;

        Response&lt;Integer&gt; response = Api.success(code, message, data);
        assertNotNull(response);
        assertEquals(code, response.getCode());
        assertEquals(message, response.getMessage());
        assertEquals(data, response.getData());
    }

    @Test
    void 정상_반환_테스트_문자열() throws Exception {
        String message = &quot;성공&quot;;
        String data = &quot;데이터&quot;;
        Integer code = 200;

        Response&lt;String&gt; response = Api.success(code, message, data);
        assertNotNull(response);
        assertEquals(code, response.getCode());
        assertEquals(message, response.getMessage());
        assertEquals(data, response.getData());
    }

    @Test
    void 정상_반환_테스트_리스트() throws Exception {
        String message = &quot;성공&quot;;
        List&lt;Integer&gt; data = List.of(1, 2, 3);
        Integer code = 200;

        Response&lt;List&lt;Integer&gt;&gt; response = Api.success(code, message, data);
        assertNotNull(response);
        assertEquals(code, response.getCode());
        assertEquals(message, response.getMessage());
        assertEquals(data, response.getData());
    }

    @Test
    void 정상_반환_테스트_HTTP_코드() throws Exception {
        String message = &quot;성공&quot;;
        Integer data = 1;

        Response&lt;Integer&gt; response = Api.success(HttpStatus.OK, message, data);
        assertNotNull(response);
        assertEquals(HttpStatus.OK.value(), response.getCode());
        assertEquals(message, response.getMessage());
        assertEquals(data, response.getData());
    }



    @Test
    void 에러_반환_테스트() throws Exception {
        Integer code = 400;
        String message = &quot;잘못된 요청&quot;;

        Response&lt;Void&gt; response = Api.error(code, message);

        assertNotNull(response);
        assertEquals(message, response.getMessage());
        assertEquals(code, response.getCode());
        assertNull(response.getData());
    }

    @Test
    public void 에러_반환_테스트_HTTP_코드() {
        String message = &quot;서버 오류&quot;;
        Response&lt;Void&gt; response = Api.error(HttpStatus.INTERNAL_SERVER_ERROR, message);

        assertNotNull(response);
        assertEquals(message, response.getMessage());
        assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), response.getCode()); // 검증
        assertNull(response.getData());
    }</code></pre>
</details>


<p>  <img src="https://velog.velcdn.com/images/no-oneho/post/46f6e92d-253a-4a4d-a933-953a0748eec1/image.png" alt=""></p>
<p>  테스트 결과도 잘 나오는걸 확인 할 수 있다.</p>
<p>  새롭게 만든 클래스를 테스트해봤으니 이제 실제 컨트롤러에 적용해서 확인을 해보도록 하겠다.</p>
<p>/controller/HelloworldController.class</p>
<pre><code class="language-java">  @RestController
@RequestMapping(&quot;/hello&quot;)
public class HelloWorldController {

    @GetMapping(&quot;&quot;)
    public Response&lt;String&gt; hello() {
        return Api.success(200, &quot;성공 메시지&quot;, &quot;Hello World&quot;);
    }

}</code></pre>
<p>  아주아주 간단한 컨트롤러이다. 호출해보면?
  <img src="https://velog.velcdn.com/images/no-oneho/post/84fc8976-4d94-489b-9b0c-d5e83b0c8dc8/image.png" alt=""></p>
<p>  예상대로 반환값이랑 json 형태로 잘 넘어오는걸 확인할 수 있다!</p>
<p>  다음은 에러처리를 위한 포스팅을 하겠다</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[QueryDSL 5.0.0 취약점? 지원중단? 그렇다면..]]></title>
            <link>https://velog.io/@no-oneho/QueryDSL-5.0.0-%EC%B7%A8%EC%95%BD%EC%A0%90-%EC%A7%80%EC%9B%90%EC%A4%91%EB%8B%A8-%EA%B7%B8%EB%A0%87%EB%8B%A4%EB%A9%B4</link>
            <guid>https://velog.io/@no-oneho/QueryDSL-5.0.0-%EC%B7%A8%EC%95%BD%EC%A0%90-%EC%A7%80%EC%9B%90%EC%A4%91%EB%8B%A8-%EA%B7%B8%EB%A0%87%EB%8B%A4%EB%A9%B4</guid>
            <pubDate>Wed, 28 May 2025 10:15:42 GMT</pubDate>
            <description><![CDATA[<p>일친 개발 세팅을 하며 queryDSL을 적용하는데
<img src="https://velog.velcdn.com/images/no-oneho/post/d5679253-34dc-4a3b-9a43-e34a616cdbfd/image.png" alt="">
엥! 이게뭐람 5.0.0 취약점 발견?</p>
<p>그동안 써왔던 버전이기도 하고 오래동안 업데이트 된 기억이 없어 관련 자료를 찾아봤다.</p>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/1ad8e217-b8eb-496d-a724-d22b78d0fe16/image.png" alt="">
제미니가 말하길 이런 취약점이 발견되었다고 하는데? 아니 그러면 이거 쓰던 사람들은 어쩌고요??</p>
<p>참으로 고맙게도 OpenFeign 팀에서 기존 지원 중단된 QueryDSL을 지원해주고 있다는 포스팅을 보았다.</p>
<p><a href="https://medium.com/@rlaeorua369/openfeign-querydsl-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98-%EC%B4%9D%EC%A0%95%EB%A6%AC-dee89cb3ec05">QueryDSL 개발 중단 이후, OpenFeign QueryDSL로의 전환 배경</a></p>
<p>이 포스팅에 아주 잘 정리되어있어 읽어본 후 관련해서 OpenFeign에서 만든 QueryDSL을 찾아보니 기존에 사용하던 의존성보다 주입하는 난이도도 쉽고</p>
<p>무엇보다 플러그인도 따로 설치를 안해도 된다는 점에서 진입장벽이 가벼워 시도해보았다.</p>
<h3 id="settingquerydslgradle">setting/querydsl.gradle</h3>
<pre><code>// querydsl.gradle
def querydslSrcDir  = layout.buildDirectory.dir(&quot;generated/querydsl&quot;).get().asFile

tasks.withType(JavaCompile).configureEach {
    options.getGeneratedSourceOutputDirectory().set(file(querydslSrcDir))
}

sourceSets {
    main {
        java {
            srcDirs += querydslSrcDir
        }
    }
}
configurations {
    querydsl.extendsFrom compileClasspath
}</code></pre><h3 id="buildgradle">./build.gradle</h3>
<pre><code>plugins {
    ...중략
}
def queryDslVersion = &quot;6.11&quot;

apply from: &#39;config/querydsl.gradle&#39;

...중략

dependencies {
    ...중략
    // querydsl
    implementation(&quot;io.github.openfeign.querydsl:querydsl-core:${queryDslVersion}&quot;)
    implementation(&quot;io.github.openfeign.querydsl:querydsl-jpa:$queryDslVersion&quot;)
    annotationProcessor(&quot;io.github.openfeign.querydsl:querydsl-apt:$queryDslVersion:jpa&quot;)
    ...중략
}</code></pre><p>이렇게 세팅해주고 compile을 돌려보면?</p>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/a500c354-596b-4868-b802-498e95ee3c9a/image.png" alt=""></p>
<p>QClass가 아주 잘 생성됨을 확인할 수 있다!</p>
<p>잊기전에 포스팅하기!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[일친 (IlChin) 시스템 개발기 (2) - ERD설계]]></title>
            <link>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0-2-ERD%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@no-oneho/%EC%9D%BC%EC%B9%9C-IlChin-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0-2-ERD%EC%84%A4%EA%B3%84</guid>
            <pubDate>Wed, 28 May 2025 07:58:25 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/no-oneho/post/6b7051af-a28f-4f95-a088-680744b66f86/image.png" alt=""></p>
<h1 id="그룹웨어-시스템-erd-설계-개요">그룹웨어 시스템 ERD 설계 개요</h1>
<p>일친 시스템의 ERD를 설계해보았다. 쇠뿔도 단김에 빼라고 기획을 하니 ERD도 금방금방 나온거같다. 평소엔 좀 더 딥하고 큰 범위로 ERD를 설계했는데 이번 프로젝트의 목표 기한은 길어야 한달, 웬만하면 2주로 끝내고싶기에 최대한 간단하게 기획했다.</p>
<hr>
<h2 id="1-엔티티-목록">1. 엔티티 목록</h2>
<h3 id="user-회원"><code>User</code> (회원)</h3>
<ul>
<li><code>id</code> : BIGINT (PK)</li>
<li><code>username</code> : VARCHAR</li>
<li><code>email</code> : VARCHAR</li>
<li><code>password</code> : VARCHAR</li>
<li><code>role</code> : VARCHAR</li>
<li><code>created_at</code> : DATETIME</li>
</ul>
<h3 id="user_profile-유저-프로필"><code>User_Profile</code> (유저 프로필)</h3>
<ul>
<li><code>id</code> : BIGINT (PK)</li>
<li><code>user_id</code> : BIGINT (FK, User)</li>
<li><code>department_id</code> : BIGINT (FK, Department)</li>
<li><code>full_name</code> : VARCHAR</li>
<li><code>phone</code> : VARCHAR</li>
</ul>
<h3 id="department-부서"><code>Department</code> (부서)</h3>
<ul>
<li><code>id</code> : BIGINT (PK)</li>
<li><code>name</code> : VARCHAR</li>
<li><code>description</code> : TEXT</li>
<li><code>user_id</code> : BIGINT (FK, User, 부서장)</li>
<li><code>tel</code> : VARCHAR</li>
</ul>
<h3 id="project-프로젝트"><code>Project</code> (프로젝트)</h3>
<ul>
<li><code>id</code> : BIGINT (PK)</li>
<li><code>owner_id</code> : BIGINT (FK, User)</li>
<li><code>name</code> : VARCHAR</li>
<li><code>description</code> : TEXT</li>
<li><code>webhook</code> : TEXT</li>
<li><code>created_at</code> : DATETIME</li>
<li><code>state</code> : VARCHAR</li>
</ul>
<h3 id="project_member-프로젝트-멤버"><code>Project_Member</code> (프로젝트 멤버)</h3>
<ul>
<li><code>id</code> : BIGINT (PK)</li>
<li><code>project_id</code> : BIGINT (FK, Project)</li>
<li><code>user_id</code> : BIGINT (FK, User)</li>
<li><code>role</code> : VARCHAR</li>
<li><code>joined_at</code> : DATETIME</li>
</ul>
<h3 id="task-업무-태스크"><code>Task</code> (업무 태스크)</h3>
<ul>
<li><code>id</code> : BIGINT (PK)</li>
<li><code>project_id</code> : BIGINT (FK, Project)</li>
<li><code>assignee_id</code> : BIGINT (FK, User)</li>
<li><code>title</code> : VARCHAR</li>
<li><code>description</code> : TEXT</li>
<li><code>state</code> : VARCHAR</li>
<li><code>due_date</code> : DATE</li>
<li><code>created_at</code> : DATETIME</li>
</ul>
<h3 id="issue-이슈"><code>Issue</code> (이슈)</h3>
<ul>
<li><code>id</code> : BIGINT (PK)</li>
<li><code>project_id</code> : BIGINT (FK, Project)</li>
<li><code>reporter_id</code> : BIGINT (FK, User)</li>
<li><code>task_id</code> : BIGINT (FK, Task)</li>
<li><code>pr_id</code> : BIGINT (FK, Pull_Request)</li>
<li><code>state</code> : VARCHAR</li>
<li><code>title</code> : VARCHAR</li>
<li><code>description</code> : TEXT</li>
<li><code>created_at</code> : DATETIME</li>
<li><code>updated_at</code> : DATETIME</li>
</ul>
<h3 id="pull_request-pr"><code>Pull_Request</code> (PR)</h3>
<ul>
<li><code>id</code> : BIGINT (PK)</li>
<li><code>project_id</code> : BIGINT (FK, Project)</li>
<li><code>title</code> : VARCHAR</li>
<li><code>url</code> : VARCHAR</li>
<li><code>status</code> : VARCHAR</li>
<li><code>creator_login</code> : VARCHAR</li>
<li><code>created_at</code> : DATETIME</li>
</ul>
<h3 id="comment-댓글"><code>Comment</code> (댓글)</h3>
<ul>
<li><code>id</code> : BIGINT (PK)</li>
<li><code>content</code> : TEXT</li>
<li><code>target</code> : VARCHAR — 댓글 또는 이슈, 태스크 대상 구분</li>
<li><code>target_id</code> : BIGINT — 대상 엔티티 ID</li>
<li><code>created_at</code> : DATETIME</li>
<li><code>user_id</code> : BIGINT (FK, User)</li>
</ul>
<hr>
<h2 id="2-관계-정리">2. 관계 정리</h2>
<ul>
<li><p><strong>User</strong> ↔ <strong>User_Profile</strong> : 일대일 관계</p>
</li>
<li><p><strong>User</strong> ↔ <strong>Project</strong> : 프로젝트 소유자 (1:N)</p>
</li>
<li><p><strong>Project</strong> ↔ <strong>Project_Member</strong> : 다대일 관계 (프로젝트별 여러 멤버)</p>
</li>
<li><p><strong>User</strong> ↔ <strong>Project_Member</strong> : 다대일 (회원이 여러 프로젝...)</p>
</li>
<li><p><strong>Project</strong> ↔ <strong>Task</strong> : 일대다</p>
</li>
<li><p><strong>User (assignee)</strong> ↔ <strong>Task</strong> : 일대다</p>
</li>
<li><p><strong>User (reporter)</strong> ↔ <strong>Issue</strong> : 일대다</p>
</li>
<li><p><strong>Project</strong> ↔ <strong>Issue</strong> : 일대다</p>
</li>
<li><p><strong>Issue</strong> ↔ <strong>Pull_Request</strong> : 일대일 또는 다대일 (ID 연동)</p>
</li>
<li><p><strong>Comment</strong> ↔ 대상 (이슈, 태스크, 댓글 등) : polymorphic 형태로 대상 ID와 구분해서 저장</p>
</li>
</ul>
<hr>
<h2 id="erd-설계-왜-이렇게-했을까">ERD 설계, 왜 이렇게 했을까?</h2>
<ul>
<li><p><strong>유연성과 확장성</strong> 중심의 설계</p>
<ul>
<li>최대한 유연성과 확장성에 초점을 맞춰서 설계해보았다. 앞으로 프로젝트가 어떻게 변하든, 쉽게 대응할 수 있게 만들어 본것이다. 예를 들어, 핵심 개체들 관계는 너무 복잡하지 않게 딱 필요한 것만 딱 넣었으며, 새 기능이나 필드가 추가될 때도 어렵지 않게 고치거나 확장할 수 있게 설계하였다.</li>
</ul>
</li>
<li><p><strong>FK 제약 최소화 및 자바 엔티티 중심 설계</strong></p>
<ul>
<li>일단 DB에서 강제로 연관관계를 묶기보단, 자바 쪽에서 관리하는 게 훨씬 편하다. 실제로는 자바(Spring Data JPA, Hibernate 쓰는 애들)에서 데이터 무결성을 체크하게 하고, DB는 그냥 연결된 관계를 일부러 강하게 묶지 않게 되면, 물리 테이블에 저장된 정보를 수정하거나 제거할 때 제약조건에 걸리지 않아 개발 할 때 여러모로 편하고 확장성도 보장된다.</li>
</ul>
</li>
<li><p><strong>폴리모픽 연관 관계 고려</strong></p>
<ul>
<li>댓글 같은 경우는 대상이 여러 가지일 수 있어서, ‘이벤트 대상이 이슈야? 태스크야? 아니면 댓글 자체야?’ 하는 문제를 해결하려고 target과 target_id라는 필드 하나로 컴팩트하게 만들어서 써보았다. 이렇게 하면 추후에 기능을 더 늘리거나 다른 대상도 쉽게 연결할 수 있음으로 따로 연관관계를 새롭게 맺지않아도 되어 확장성이 좋다!</li>
</ul>
</li>
<li><p><strong>외부 시스템 연동 및 연관 정보 관리</strong></p>
<ul>
<li>Pull Request(PR)는 GitHub 등 외부 시스템과 연동하는 경우가 많기 때문에, <code>creator_login</code>과 같이 문자열로 저장하는 방식을 선택했다 실제  유저 테이블에 저장되지 않는 정보기 때문에 문자열로 그때그때 보여주는게 나아보여서? 채택한 방식</li>
<li>아마 추후에 필요시 별도로 깃헙 계정 연동 테이블을 만들어, 외부 계정이나 사용자 정보와 연동하는 것도 가능하도록 추가 개발해서 API 연동 시 유연성을 확보할 수 도 있을 것같다.</li>
</ul>
</li>
<li><p><strong>PR 캐싱 전략과 데이터 일관성</strong></p>
<ul>
<li>PR 정보의 경우 깃허브에 올라간 정보를 직접 API로 불러올 예정이다. 이때 호출이 너무 잦게되면 해당 api측에서 부하가 걸릴수도, 호출 제한이 걸릴지도 모르는 일이다. 이에따라 호출을 최소화하기 위해 PR정보를 불러올 때 마다 호출하기보단 캐싱처리로 중복데이터는 서버에 저장된 데이터로 보여줄 예정이다. 다만 새로운 데이터가 있을 경우 새롭게 받아와야하는데 이 경우는 캐시 키를 뭐로 줘야할지 아직 고민중이다.</li>
</ul>
</li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[일친 (IlChin) 시스템 개발기 (1)]]></title>
            <link>https://velog.io/@no-oneho/%EA%B0%9C%EB%B0%9C%EC%82%AC-%EA%B7%B8%EB%A3%B9%EC%9B%A8%EC%96%B4-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0-1</link>
            <guid>https://velog.io/@no-oneho/%EA%B0%9C%EB%B0%9C%EC%82%AC-%EA%B7%B8%EB%A3%B9%EC%9B%A8%EC%96%B4-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0-1</guid>
            <pubDate>Wed, 28 May 2025 06:09:03 GMT</pubDate>
            <description><![CDATA[<h1 id="소규모-그룹웨어-시스템-mvp-기획">소규모 그룹웨어 시스템 MVP 기획</h1>
<h2 id="개요">개요</h2>
<ul>
<li><strong>목표</strong>: 2주~4주 내로 개발 가능한 소규모 그룹웨어 MVP 구축</li>
<li><strong>기능 범위</strong>: 회원관리, 팀 업무 관리, 사내 협업 플랫폼, GitHub 연동 포함</li>
<li><strong>기본 방침</strong>: 프론트엔드 미구성, 백엔드 중심 개발, 이후 확장 가능</li>
</ul>
<hr>
<h2 id="핵심-기능-리스트">핵심 기능 리스트</h2>
<h3 id="1-회원관리">1. 회원관리</h3>
<ul>
<li>회원 등록, 조회, 수정, 삭제</li>
<li>로그인/로그아웃</li>
<li>역할 및 권한 부여 (관리자 / 사용자)</li>
<li>비밀번호 변경 및 프로필 관리</li>
</ul>
<h3 id="2-팀-업무-관리도구">2. 팀 업무 관리도구</h3>
<ul>
<li>프로젝트/팀 생성 및 삭제</li>
<li>태스크 생성, 배정, 상태 변경 (예정/진행/완료)</li>
<li>태스크 담당자 지정</li>
<li>댓글 및 첨부파일 업로드</li>
<li>마감일 설정 및 알림 (간단한 수준)</li>
</ul>
<h3 id="3-사내-협업-플랫폼">3. 사내 협업 플랫폼</h3>
<ul>
<li>공지사항 게시 및 조회</li>
<li>일정 공유 또는 캘린더</li>
<li>문서 저장소 (파일 업로드/공유)</li>
<li>간단 메시지 또는 채팅</li>
</ul>
<h3 id="4-github-연동-기능">4. GitHub 연동 기능</h3>
<ul>
<li>프로젝트 생성 시 GitHub 저장소 또는 이슈 연동 선택</li>
<li>GitHub 이슈 목록 조회</li>
<li>PR 상태 조회</li>
<li>연동 정보 저장 (API 토큰 또는 OAuth 기반)</li>
</ul>
<hr>
<h2 id="개발-전략">개발 전략</h2>
<ul>
<li><strong>백엔드 중심</strong>로 설계 및 개발</li>
<li>REST API 기반 구현</li>
<li>인증/권한 관리 포함</li>
<li>차후 프론트엔드와 연동 가능하게 설계</li>
</ul>
<hr>
<h2 id="기대-효과">기대 효과</h2>
<ul>
<li>빠른 MVP 출시로 기본 협업 환경 마련</li>
<li>이후 필요 기능 확장 가능</li>
<li>실질 업무에 바로 활용 가능 수준의 시스템 구축</li>
</ul>
<hr>
<h3 id="참고">참고</h3>
<p>최대한 짧은 시간안에 핵심 기능을 구현하는게 목표로, 그에 맞게 추가 기능의 확장성을 최대한 고려하여 개발해나갈것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[아무도 안써서쓰는 Qodana + Jacoco]]></title>
            <link>https://velog.io/@no-oneho/%EC%95%84%EB%AC%B4%EB%8F%84-%EC%95%88%EC%8D%A8%EC%84%9C%EC%93%B0%EB%8A%94-Qodana-Jacoco</link>
            <guid>https://velog.io/@no-oneho/%EC%95%84%EB%AC%B4%EB%8F%84-%EC%95%88%EC%8D%A8%EC%84%9C%EC%93%B0%EB%8A%94-Qodana-Jacoco</guid>
            <pubDate>Wed, 10 Apr 2024 10:08:29 GMT</pubDate>
            <description><![CDATA[<p>젯브레인에 Qodana 라는 코드 품질 관리 툴이 있다.</p>
<p>해당 툴을 인텔리제이에 적용하면 
<img src="https://velog.velcdn.com/images/no-oneho/post/d5200026-e421-45e6-9f9a-c644e6937a74/image.png" alt="">
이런식으로 코드 품질에 대한 정보를 나타내주는데</p>
<p>현재 진행중인 프로젝트에서 TDD 방법론으로 개발하기로 하여 테스트 커버리지 적용을 위해 적용해보았다. 
근데 문제점이 정작 우리가 궁금했던 테스트 커버리지는 제공이 안되던거였는데<img src="https://velog.velcdn.com/images/no-oneho/post/b7c390e3-74b4-41de-8b59-5dea042884e8/image.png" alt=""></p>
<p>문서를 아무리 뒤져도 인텔리제이 내에서 커버리지 실행하면 저것도 나와야한다고 문서에 나와있었다.</p>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/7f98a704-d5bd-4ca3-81a7-694361c78b20/image.png" alt=""></p>
<p>그래서 한참 삽질을 하다 선택한것이 Jacoco에서 제공해주는 코드 커버리지 보고서를 함께 사용하는 방법이였다.</p>
<p>일단 나는 Qodana 를 Ultmate Plus 버전을 사용중이다 2달 무료버전을 준다기에
해당 버전으로 이번 프로젝트를 진행할거같다.</p>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/d7b8f9e4-9c81-40f0-afa5-0d5e7020a32a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/477969e9-2927-42d3-b46d-4203eeeb5261/image.png" alt=""></p>
<p>로컬 실행은 대충 이정도가 끝이다. 이 상태로는 코드커버리지를 알려주지않는데 이제부터 jacoco 세팅을 시작해보겠다.</p>
<pre><code class="language-gradle">subprojects {
    apply plugin: &#39;jacoco&#39;
}</code></pre>
<p>먼저 build.gradle 에 jacoco 를 설정해준다. 진행중인 프로젝트 같은 경우는 멀티모듈로 되어있어 루트 프로젝트가 아닌 subproject에 공통으로 걸어줬다.</p>
<pre><code class="language-gradle">    jacoco {
        toolVersion = &#39;0.8.10&#39;
        setReportsDirectory(file(&quot;${rootDir}/.qodana/code-coverage&quot;))
    }

       test {
        useJUnitPlatform()
        finalizedBy &#39;jacocoTestReport&#39;
    }

    acocoTestReport {
        reports {
            xml.required = true
            html.required = false
        }
        finalizedBy &#39;jacocoTestCoverageVerification&#39;
    }

    jacocoTestCoverageVerification {
        violationRules {
            rule {
                enabled = true
                element = &#39;CLASS&#39;
                // includes = []

                limit {
                    counter = &#39;LINE&#39;
                    value = &#39;COVEREDRATIO&#39;
                    minimum = 0.00
                }

                excludes = []
            }
        }
    }</code></pre>
<p>jacoco 그룹엔 버전과 저장 위치를 명시해준다. 해당 저장위치는 qodana가 코드 품질을 검사할 때 코드 검사 보고서를 읽는 위치이다.</p>
<p>acocoTestReport 그룹에는 qodana에서 사용할 수 있게 xml 을 허용해준다.
우리 프로젝트에선 html로 읽을 필요가 없어 html 파일도 false 로 바꿔줬다.</p>
<p>jacocoTestCoverageVerification 그룹에는 코드 커버리지 검사 룰인데
minimun 보다 코드 커버리지가 낮으면 빌드나 테스트를 실패시킨다.</p>
<p>해당 룰은 아직 미정상태로 추후 룰과 점수를 정하여 적용할 듯 싶다.</p>
<p>이제 ./gradlew test 를 하게되면 test - acocoTestReport - jacocoTestCoverageVerification 순으로 실행이 되어 ${rootDir}/.qodana/code-coverage 이 위치에 xml 파일이 생성이 된다.</p>
<p>그 후 코다나로 코드 보고서를 실행 시켜 보면
<img src="https://velog.velcdn.com/images/no-oneho/post/a802ca20-600c-4546-8f77-8ca861a5cdd7/image.png" alt=""></p>
<p>아까완 다르게 코드 커버리지가 보임을 확인 할 수 있다.</p>
<p>이제 로컬에선 완료했으니 git action을 통해 push 나 pr 때 사용할 수 있게 자동화해보자</p>
<pre><code class="language-yml">name: qodana

on:
  push:
    branches:
      - develop
      - &#39;chore/test-coverage/#37&#39;
  pull_request:
    branches:
      - &quot;**&quot;

jobs:
  qodana:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      checks: write
    steps:
      - uses: actions/checkout@v3
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # to check out the actual pull request commit, not the merge commit
          fetch-depth: 0

      - name: Set up JDK 17
        uses: actions/setup-java@v2
        with:
          java-version: &#39;17&#39;
          distribution: &#39;adopt&#39;
          cache: &#39;gradle&#39;

      - name: Validate Gradle Wrapper
        run: chmod +x ./gradlew --version
        working-directory: ./

      - name: Create code coverage folder if not exists
        run: |
          mkdir -p .qodana/code-coverage
        working-directory: ./

      - name: permission
        run: chmod +x gradlew 
        working-directory: ./

      - name: Run Tests
        run: ./gradlew test
        working-directory: ./    

      - name: Archive coverage data
        uses: actions/upload-artifact@v2
        with:
          name: gradle-coverage-data-jacoco
          path: .qodana/code-coverage

      - name: &#39;Qodana Scan&#39;
        uses: JetBrains/qodana-action@v2023.2
        env:
          QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
</code></pre>
<pre><code class="language-yml">- name: Create code coverage folder if not exists
        run: |
          mkdir -p .qodana/code-coverage
        working-directory: ./</code></pre>
<p>  해당 명령으로 jacoco가 실행 후 저장될 디렉토리를 미리 생성해준다.</p>
<pre><code class="language-yml">        - name: Run Tests
        run: ./gradlew test
        working-directory: ./ </code></pre>
<p>  그 후 test 를 실행하여 아까 build.gradle에 작성했던 것 처럼 test - acocoTestReport - jacocoTestCoverageVerification 순으로 실행이 될 수 있게 해준다.</p>
<pre><code class="language-yml">      - name: Archive coverage data
        uses: actions/upload-artifact@v2
        with:
          name: gradle-coverage-data-jacoco
          path: .qodana/code-coverage</code></pre>
<p> qodana scan 에서 파일을 읽을 수 있게 해당 디렉토리에 파일을 업로드 해준다.</p>
<pre><code class="language-yml">       - name: &#39;Qodana Scan&#39;
        uses: JetBrains/qodana-action@v2023.2
        env:
          QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}</code></pre>
<p>마지막으로 qodana scan 명령으로 qodana 스캔을 실행한다.</p>
<p>QODANA_TOKEN 은 Qodana.cloud 에 프로젝트를 찾아보면 찾을 수 있다. 해당 토큰을 action 키로 저장하여 action에서 변수로 사용할 수 있다.</p>
<p>그 후 pr을 통해 체크해보면
<img src="https://velog.velcdn.com/images/no-oneho/post/7983b17f-56ef-492b-9ee7-82ead2871ca9/image.png" alt="">
 action이 예상대로 잘 돌아가는것을 확인 할 수 있고</p>
<p> <img src="https://velog.velcdn.com/images/no-oneho/post/267eff10-4956-4407-a3c7-64db9adef202/image.png" alt="">
내가 PR한 코드 품질에 대해 리뷰도 해준다.!!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[회원가입 및 로그인 인증처리 (작성중)]]></title>
            <link>https://velog.io/@no-oneho/%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EB%B0%8F-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D%EC%B2%98%EB%A6%AC-%EC%9E%91%EC%84%B1%EC%A4%91</link>
            <guid>https://velog.io/@no-oneho/%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EB%B0%8F-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D%EC%B2%98%EB%A6%AC-%EC%9E%91%EC%84%B1%EC%A4%91</guid>
            <pubDate>Tue, 31 Oct 2023 16:28:12 GMT</pubDate>
            <description><![CDATA[<p>이번에는 회원가입및 로그인 인증처리 포스팅을 작성해보겠다.</p>
<p>UserDetailsService 를 상속받아 사용하는 방식이 일반적이지만 나는 사용하지않고 구현해보았다.</p>
<p>앞서 의존성을 받아준다</p>
<pre><code class="language-gradle">implementation &#39;org.springframework.boot:spring-boot-starter-security&#39;
implementation group: &#39;io.jsonwebtoken&#39;, name: &#39;jjwt&#39;, version: &#39;0.9.1&#39;

testImplementation &#39;org.springframework.security:spring-security-test&#39;</code></pre>
<p>회원가입은 간단하다</p>
<p>회원가입에 request로 사용할 dto를 하나 선언해준다.</p>
<pre><code class="language-java">@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = &quot;회원가입 요청 DTO&quot;)
public class SignupRequest {

    @NotBlank
    @ApiModelProperty(value = &quot;이메일 입력 필드&quot;, dataType = &quot;String&quot;)
    private String email;
    @NotBlank
    @ApiModelProperty(value = &quot;패스워드 입력 필드&quot;, dataType = &quot;String&quot;)
    private String password;
    @NotBlank
    @ApiModelProperty(value = &quot;패스워드 확인 입력 필드&quot;, dataType = &quot;String&quot;)
    private String passwordCheck;
    @NotBlank
    @ApiModelProperty(value = &quot;닉네임 입력 필드&quot;, dataType = &quot;String&quot;)
    private String nickname;

    @ApiModelProperty(value = &quot;이미지 파일 입력 필드&quot;, dataType = &quot;MultipartFile&quot;)
    private MultipartFile profileFile;

}</code></pre>
<pre><code class="language-java">    @PostMapping(value = &quot;/signup&quot;, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    @Operation(summary = &quot;회원 가입 API&quot;, description = &quot;폼데이터로 요청&quot;)
    public Response&lt;LoginResponse&gt; signup(@ModelAttribute SignupRequest signupRequest) {

        return ApiUtils.success(HttpStatus.CREATED, &quot;회원 가입 성공&quot;, userService.signup(signupRequest));
    }</code></pre>
<p>스펙에 따라 컨트롤러에서 요청을 받고</p>
<pre><code class="language-java">
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public LoginResponse signup(SignupRequest signupRequest) {
        checkDuplicateEmail(signupRequest.getEmail());
        checkConfirmPassword(signupRequest.getPassword(), signupRequest.getPasswordCheck());
        User user = User.from(signupRequest, passwordEncoder.encode(signupRequest.getPassword()));

        userRepository.save(user);
        user.setProfileUrl(uploadImageFile(signupRequest.getProfileFile(), user));
        String accessToken = TokenProvider.createToken(user);

        return LoginResponse.from(user, accessToken);
    }

   private void checkDuplicateEmail(String email) {
        if (userRepository.existsByEmail(email)) {
            throw new CustomException(UserErrorCode.DUPLICATE_USER_ID);
        }
    }

   private void checkConfirmPassword(String password, String passwordCheck) {
        if (!password.equals(passwordCheck)) {
            throw new CustomException(UserErrorCode.NOT_MATCH_PASSWORD_CONFIRM);
        }
    }
</code></pre>
<p>일반적인 회원가입이다. 한가지 짚어보고 넘어갈 부분은</p>
<pre><code>userRepository.save(user);
user.setProfileUrl(uploadImageFile(signupRequest.getProfileFile(), user));</code></pre><p>이 부분</p>
<p>save로 먼저 저장을 하고 저장한 객체에 접근하여 데이터를 변경하여도 DB에 적용이 된다.
이는 JPA의 영속성과 관련된 부분으로 save를 통해 user를 영속성 컨테이너에 저장하여 저장된 영속 엔티티들은 DB와 동일성을 보장해준다.</p>
<p>그 후 로그인 로직을 처리해보도록 하자</p>
<p>먼저 jwt 방식의 토큰 로그인을 구현하기 위해 TokenPorvider 클래스를 생성한다</p>
<pre><code class="language-java">@Service
public class TokenProvider {

    private static String SECRET_KEY;

    @Value(&quot;${secret-key-source}&quot;)
    public void setKey(String value) {
        SECRET_KEY = value;
    }

    public static String createToken(User user) {
        Date expiryDate = Date.from(
                Instant.now()
                        .plus(1, ChronoUnit.DAYS)
        );

        Claims claims = Jwts.claims();
        claims.put(&quot;userId&quot;, user.getId());
        claims.put(&quot;userEmail&quot;, user.getEmail());

        return Jwts.builder()
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .setClaims(claims)
                .setExpiration(expiryDate)
                .compact();
    }

    // Claims에서 loginId 꺼내기
    public static String getUserEmail(String token) {
        return extractClaims(token).get(&quot;userEmail&quot;).toString();
    }

    public static Long getUserId(String token) {
        return Long.valueOf(extractClaims(token).get(&quot;userId&quot;).toString());
    }

    // 밝급된 Token이 만료 시간이 지났는지 체크
    public static boolean isExpired(String token) {
        Date expiredDate = extractClaims(token).getExpiration();
        // Token의 만료 날짜가 지금보다 이전인지 check
        return expiredDate.before(new Date());
    }

    private static Claims extractClaims(String token) {
        return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
    }

}</code></pre>
<p>당연히 시크릿키는 노출되면 안된다.
간단한 코드들만 있으니 서비스에 맞게 커스텀해서 사용하면 된다</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AOP를 이용한 공통 에러 처리]]></title>
            <link>https://velog.io/@no-oneho/AOP%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EA%B3%B5%ED%86%B5-%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@no-oneho/AOP%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EA%B3%B5%ED%86%B5-%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Mon, 30 Oct 2023 21:05:01 GMT</pubDate>
            <description><![CDATA[<p>이전 포스트에서 만든 공통 응답을 비즈니스 에러에서도 동일하게 내려주고싶어졌다.</p>
<p>왜 why? 프론트 개발자 입장에선 그게 당연~히 편하다</p>
<p>응답에 대한 Type Interface를 미리 정의 해두면 성공이든 실패든 예외처리가 간편하게 가능하니까</p>
<p>먼저 ErrorCode interface를 정의해둔다</p>
<pre><code class="language-java">public interface ErrorCode {

    int getCode();

    String getMessage();
}</code></pre>
<p>뭐 별건 없다 단순한 인터페이스 이제 이걸 상속받을 도메인 별 ErrorEnum을 생성한다</p>
<pre><code class="language-java">@Getter
@AllArgsConstructor
public enum UserErrorCode implements ErrorCode {
    DUPLICATE_USER_ID(HttpStatus.CONFLICT.value(), &quot;중복 되는 아이디 입니다.&quot;),
    NOT_MATCH_PASSWORD_CONFIRM(HttpStatus.BAD_REQUEST.value(), &quot;비밀번호와 비밀번호 확인이 일치하지 않습니다&quot;),
    NOT_FOUND_USER(HttpStatus.NOT_FOUND.value(), &quot;로그인 한 유저를 찾을 수 없습니다&quot;),
    BAD_REQUEST_PASSWORD(HttpStatus.BAD_REQUEST.value(), &quot;로그인 정보를 다시 확인하세요&quot;),
    HANDLE_ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), &quot;로그인이 필요합니다.&quot;),
    CANT_ACCESS(HttpStatus.UNAUTHORIZED.value(), &quot;접근권한이 없습니다&quot;),
    FAIL_KAKAO_LOGIN(HttpStatus.BAD_REQUEST.value(), &quot;카카오 로그인 실패!&quot;),
    ;

    private final int code;
    private final String message;
}
</code></pre>
<p>ErrorCode의 code부분을 HttpStatus로 리턴받게 할 수도 있지만 커스텀 에러코드의 사용을 생각해서 int로 선언하였다.</p>
<p>그럼 이렇게 생성한 ErrorCode를 실제 사용할 CustomException 파일을 만들어보자</p>
<pre><code class="language-java">@Getter
public class CustomException extends RuntimeException {

    private final ErrorCode errorCode;
    private final String errorMessage;

    public CustomException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
        this.errorMessage = errorCode.getMessage();
    }

}
</code></pre>
<p>RuntimeException 을 상속받아 생성자에 super로 넣어주면 끝!</p>
<p>이제 잘 만든 에러들을 미리 정의해둔 공통 응답 폼에 맞춰 aop단에 만들어보자</p>
<pre><code class="language-java">@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(Exception.class)
    public ResponseEntity&lt;Response&gt; handleException(Exception e) {

        Response errorResponse = new Response(false, HttpStatus.INTERNAL_SERVER_ERROR.value(), e.getMessage(), e.getCause());

        // 에러 응답 생성
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR.value()).body(errorResponse);
    }

    @ExceptionHandler(CustomException.class)
    public ResponseEntity&lt;Response&gt; handleCustomException(CustomException e) {
        // 에러 정보를 담은 ErrorResponse 객체 생성
        Response errorResponse = new Response(false, e.getErrorCode().getCode(), e.getErrorMessage(), null);

        // 에러 응답 생성
        return ResponseEntity.status(e.getErrorCode().getCode()).body(errorResponse);
    }
}
</code></pre>
<p><code>@RestControllerAdvice</code> 어노테이션을 붙이면 json으로 응답을 내려줄 수 있다.</p>
<p><code>@ControllerAdvice</code> 를 사용하면 에러 페이지를 노출 시켜 줄 수 있다.</p>
<p>정의해둔 에러 외에 개발자의 실수로 인한 런타임 에러도 일단 잡아서 같은 응답으로 내려줘야 하기때문에 Exception.class 받는 메서드와 CustomException.class를 받는 메서드로 나누어 작성한다.</p>
<p>응답은 ResponseEntity로 본인이 만든 공통 폼 DTO를 반환해주면 된다.</p>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/3e465eda-780c-4653-a109-05eb24ba6a23/image.png" alt=""></p>
<h3 id="실패케이스">실패케이스</h3>
<p><img src="https://velog.velcdn.com/images/no-oneho/post/e59ad03f-b6f6-49b0-acf2-971927fbc32f/image.png" alt=""></p>
<h3 id="성공케이스">성공케이스</h3>
<p>이제 실제로 비즈니스 로직에 Error를 throw해보자</p>
<pre><code class="language-java">    private User validUser(Long userId) {
        return userRepository.findById(userId).orElseThrow(() -&gt; new CustomException(UserErrorCode.NOT_FOUND_USER));
    }</code></pre>
<p>아주 간단하게 </p>
<pre><code class="language-java">throw new CustomException(ErrorCode.Error)</code></pre>
<p>형식으로 지정해주면 비즈니스 로직을 타다 throw를 만나면 반환해준다.</p>
<p>여기까지 하면 모든 에러가 공통 처리가 되었을까?</p>
<p>정답은 아니다. 그 이유는 Spring security 를 이용한 로그인및 인증처리 방식에서
인증처리는 보통 컨트롤러에 실제 요청이 도달하기 전 Filter 단에서 처리가 되는 반면,
AOP는 컨트롤러에 요청이 도달하기 직전에 실행이 되므로
Filter가 AOP보다 앞단에서 실행된다고 생각하면 된다.</p>
<p>그렇다면 인증처리와 같은 Filter 단에서 나오는 에러는 Spring security에서의 에러가 뿌려지게 되므로 내가 예상한 에러 응답과 다른 응답이 뿌려지는데</p>
<p>이에 대해선 순서에 맞게 로그인및 회원가입을 포스팅 한 후 작성하도록 하겠다.</p>
]]></description>
        </item>
    </channel>
</rss>