<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>qkr10 님의 블로그</title>
    <link>https://qkr10.tistory.com/</link>
    <description>qkr10 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Sun, 16 Aug 2026 07:11:50 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>qkr10</managingEditor>
    <item>
      <title>[개발기록/CEC 프로젝트] 4. 테스트 코드 작성기</title>
      <link>https://qkr10.tistory.com/5</link>
      <description>&lt;p&gt;이번 글에서는 CEC 프로젝트에 아래 내용들을 적용한 이야기를 작성하겠다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#1.%20%ED%94%BD%EC%8A%A4%EC%B2%98%20%EC%9E%AC%EC%82%AC%EC%9A%A9&quot;&gt;1. 픽스처 재사용&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#1-1.%20%ED%85%8C%EC%8A%A4%ED%8A%B8%20%EC%BD%94%EB%93%9C%EC%99%80%20%ED%94%BD%EC%8A%A4%EC%B2%98%EC%97%90%20%EB%8C%80%ED%95%B4%EC%84%9C&quot;&gt;1-1. 테스트 코드와 픽스처에 대해서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#1-2.%20%ED%94%BD%EC%8A%A4%EC%B2%98%20%EC%9E%AC%EC%82%AC%EC%9A%A9%20%EC%BD%94%EB%93%9C%20%EC%9E%91%EC%84%B1%ED%95%98%EA%B8%B0&quot;&gt;1-2. 픽스처 재사용 코드 작성하기&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2.%20S3%20%ED%8C%8C%EC%9D%BC%20%EC%97%85%EB%A1%9C%EB%93%9C%20%ED%85%8C%EC%8A%A4%ED%8A%B8&quot;&gt;2. S3 파일 업로드 테스트&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#2-1.%20%ED%98%84%EC%9E%AC%20%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%9D%98%20%ED%8C%8C%EC%9D%BC%20%EC%97%85%EB%A1%9C%EB%93%9C%20%EB%B0%A9%EC%8B%9D%EC%97%90%20%EB%8C%80%ED%95%B4%EC%84%9C%20%28Presigned%20URL%29&quot;&gt;2-1. 현재 프로젝트의 파일 업로드 방식에 대해서 (Presigned URL)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2-2.%20S3ApiUtil%20%ED%81%B4%EB%9E%98%EC%8A%A4%20%EC%9E%91%EC%84%B1%ED%95%98%EA%B8%B0&quot;&gt;2-2. S3ApiUtil 클래스 작성하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2-3.%20S3%20%ED%8C%8C%EC%9D%BC%20%EC%97%85%EB%A1%9C%EB%93%9C%20%ED%85%8C%EC%8A%A4%ED%8A%B8%20%EC%BD%94%EB%93%9C%20%EC%9E%91%EC%84%B1%ED%95%98%EA%B8%B0&quot;&gt;2-3. S3 파일 업로드 테스트 코드 작성하기&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3.%20%EB%A1%9C%EA%B7%B8%EC%9D%B8%20%ED%85%8C%EC%8A%A4%ED%8A%B8&quot;&gt;3. 로그인 테스트&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#3-1.%20%ED%98%84%EC%9E%AC%20%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%9D%98%20%EB%A1%9C%EA%B7%B8%EC%9D%B8%20%EB%B0%A9%EC%8B%9D%EC%97%90%20%EB%8C%80%ED%95%B4%EC%84%9C%20%28JWT%29&quot;&gt;3-1. 현재 프로젝트의 로그인 방식에 대해서 (JWT)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3-2.%20%EB%A1%9C%EA%B7%B8%EC%9D%B8%20%ED%85%8C%EC%8A%A4%ED%8A%B8%20%EC%BD%94%EB%93%9C%20%EC%9E%91%EC%84%B1%ED%95%98%EA%B8%B0&quot;&gt;3-2. 로그인 테스트 코드 작성하기&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;1. 픽스처 재사용&lt;/h1&gt;
&lt;h4&gt;1-1. 테스트 코드와 픽스처에 대해서&lt;/h4&gt;
&lt;p&gt;테스트 코드란 &lt;code&gt;어플리케이션의 특정 기능을 테스트 하는 코드&lt;/code&gt;이다.&lt;br&gt;테스트가 실패하면, 코드에 수정이 필요함을 알려주고,&lt;br&gt;성공하면 코드가 의도대로 동작하고 있다는 신뢰를 준다.&lt;/p&gt;
&lt;p&gt;예를들어, 덧셈 기능을 테스트 하는 코드는 다음과 같을 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;//given
int a = 1;
int b = 2;

//when
int result = add(a, b);

//then
assert result == 3;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위에서 given 절은 테스트에 필요한 값이나 상태를 준비하는 부분이고,&lt;br&gt;when 절은 테스트할 대상이 작동하는 부분,&lt;br&gt;then 절은 결과를 검증하는 부분이다.&lt;/p&gt;
&lt;p&gt;이때 given 절에서 사용하는 테스트용 데이터나 설정 값을 &lt;a href=&quot;https://en.wikipedia.org/wiki/Test_fixture#Software&quot;&gt;픽스처&lt;/a&gt;라고 한다.&lt;br&gt;다음 단락에서는 이러한 픽스처를 재사용 가능한 형태로 만들어, 가독성과 유지보수성을 높이는 방법을 소개한다.&lt;/p&gt;
&lt;h4&gt;1-2. 픽스처 재사용 코드 작성하기&lt;/h4&gt;
&lt;p&gt;CEC 프로젝트에 강의실 대여 기능이 있다.&lt;br&gt;이 기능의 테스트들에서 반복적으로 등장하는 given 절의 코드를.&lt;br&gt;아래와 같이 재사용 가능한 픽스처로 정의했다.&lt;br&gt;(&lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/test/java/com/backend/server/fixture/ClassroomFixture.java&quot;&gt;링크&lt;/a&gt;)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@AllArgsConstructor  
@Getter  
public enum ClassroomFixture {  
    강의실1(  
            &amp;quot;강의실1&amp;quot;,  
            &amp;quot;설명1&amp;quot;,  
            LocalTime.of(8, 0),  
            LocalTime.of(20, 0),  
            null,  
            1L,  
            Status.AVAILABLE),  
    강의실2(  
            &amp;quot;강의실2&amp;quot;,  
            &amp;quot;설명2&amp;quot;,  
            LocalTime.of(6, 0),  
            LocalTime.of(18, 0),  
            null,  
            1L,  
            Status.AVAILABLE);  

    private final String name;  
    private final String description;  
    private final LocalTime startTime;  
    private final LocalTime endTime;  
    private final String imageUrl;  
    private final Long managerId;  
    private final Status status;  

    public Classroom 엔티티_생성(User manager) {  
        return Classroom.builder()  
                .name(name)  
                .description(description)  
                .startTime(startTime)  
                .endTime(endTime)  
                .imageUrl(imageUrl)  
                .manager(manager)  
                .status(status)  
                .build();  
    }  

    public AdminClassroomRequest 등록_요청_생성(Long managerId) {  
        return AdminClassroomRequest.builder()  
                .name(name)  
                .description(description)  
                .startTime(startTime.format(DateTimeFormatter.ofPattern(&amp;quot;HH:mm&amp;quot;)))  
                .endTime(endTime.format(DateTimeFormatter.ofPattern(&amp;quot;HH:mm&amp;quot;)))  
                .imageUrl(imageUrl)  
                .managerId(managerId)  
                .build();  
    }  

    public void 엔티티와_비교(Classroom classroom) {  
        assertEquals(name, classroom.getName());  
        assertEquals(description, classroom.getDescription());  
        assertEquals(startTime, classroom.getStartTime());  
        assertEquals(endTime, classroom.getEndTime());  
        assertEquals(imageUrl, classroom.getImageUrl());  
        assertEquals(status, classroom.getStatus());  
    }  
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;코드를 살펴보면 아래와 같이 코드를 작성했다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;자바의 enum 생성자 문법을 활용하여, 재사용할 픽스처 데이터를 넣었다.&lt;/li&gt;
&lt;li&gt;db 에 저장할때 jpa의 엔티티 객체를 생성하여 저장해야 하니, 엔티티 객체를 만들어주는 메소드가 있다.&lt;/li&gt;
&lt;li&gt;강의실 등록 테스트 코드의 &lt;code&gt;given&lt;/code&gt; 절에 등록 요청 객체를 만드는 코드가 중복될 것을 고려하여, 등록 요청 객체를 만드는 메소드를 작성하였다.&lt;/li&gt;
&lt;li&gt;강의실 등록/수정 등이 제대로 이뤄졌는지 확인하는 코드가 &lt;code&gt;then&lt;/code&gt; 절에서 중복될 것을 고려하여, db 에 저장된 엔티티와 비교하는 메소드를 작성하였다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;결과적으로, 픽스처를 테스트 데이터와 관련된 중복된 코드를 줄일 수 있다는 것을 확인하였다.&lt;/p&gt;
&lt;h1&gt;2. S3 파일 업로드 테스트&lt;/h1&gt;
&lt;h4&gt;2-1. 현재 프로젝트의 파일 업로드 방식에 대해서 (Presigned URL)&lt;/h4&gt;
&lt;p&gt;아래 시퀸스 다이어그램은 CEC 프로젝트에서 파일이 업로드되는 과정을 간단히 표현한 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;sequenceDiagram
    participant C as Client(browser)
    participant B as Backend Server
    participant S as AWS S3 Server

    C-&amp;gt;&amp;gt;B: 1. Presigned URL 요청
    B-&amp;gt;&amp;gt;B: 2. 키값을 통해 Presigned URL 생성
    B--&amp;gt;&amp;gt;C: 3. Presigned URL 반환

    C-&amp;gt;&amp;gt;S: 4. Presigned URL 로 파일 업로드
    S--&amp;gt;&amp;gt;C: 5. 잘 업로드 되었는지 결과 반환

    C-&amp;gt;&amp;gt;B: 6. 저장된 파일 URL 전송
    B-&amp;gt;&amp;gt;B: 7. DB에 파일 URL 저장
    B--&amp;gt;&amp;gt;C: 8. 잘 저장되었는지 결과 반환&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;S3 에 실제 파일이 저장되고, DB에는 해당 파일의 URL만 저장된다.&lt;br&gt;예를들어, 첨부파일이 포함된 공지사항을 작성하는 경우의 흐름은 다음과 같다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;첨부파일&lt;/code&gt;을 서버에 업로드하는 과정 : 1,2,3,4,5번 화살표&lt;/li&gt;
&lt;li&gt;&lt;code&gt;작성자 정보&lt;/code&gt;, &lt;code&gt;공지사항 제목/내용&lt;/code&gt;, &lt;code&gt;첨부파일 URL&lt;/code&gt; 등 을 서버에 업로드하는 과정 : 6,7,8번 화살표&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Presigned URL 방식은 아래와 같은 장점이 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;클라이언트가 S3에 직접 파일을 업로드하므로, 트래픽 비용을 절약할 수 있다.&lt;/li&gt;
&lt;li&gt;Presigned URL 의 만료 시간이나, 발급 정책을 조정하여 보안을 강화하기 용이하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;아래와 같은 단점도 있다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Presigned URL 생성을 위해 AWS SDK 를 사용해야 하므로, 코드 복잡도와 인지 부하가 늘어난다.&lt;/li&gt;
&lt;li&gt;파일 업로드와 DB 저장이 불일치 할 수 있다.&lt;ul&gt;
&lt;li&gt;ex) 사용자가 파일을 S3에 업로드한 뒤, 게시글 등록을 취소하거나 오류가 발생하는 경우.&lt;/li&gt;
&lt;li&gt;파일 업로드용 API 가 따로 존재한다면 생길 수 있는 상황임.&lt;/li&gt;
&lt;li&gt;파일 저장 비용이 크지 않고, 업로드 기능이 악용되지 않는다면 이 문제는 크게 대비하지 않아도 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;현재 테스트 해야할 API 는 다이어그램 상에서 첫번째 API 이다.&lt;br&gt;아래 두가지 사항을 테스트하면 되는 것이다. (외부 저장소를 사용하는 테스트이므로 통합 테스트이다)&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;1. Presigned URL 요청&lt;/code&gt; 가 발생하면, &lt;code&gt;3. Presigned URL 반환&lt;/code&gt; 까지 잘 이루어지는지 여부&lt;/li&gt;
&lt;li&gt;&lt;code&gt;3. Presigned URL 반환&lt;/code&gt; 에서 반환된 주소로 &lt;code&gt;4. Presigned URL 로 파일 업로드&lt;/code&gt; 가 잘 되는지 여부&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;2-2. S3ApiUtil 클래스 작성하기&lt;/h4&gt;
&lt;p&gt;파일 업로드를 테스트하기 위해선 아래 3가지 기능을 구현해야 한다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;테스트용 파일을 업로드 하는 기능.&lt;/li&gt;
&lt;li&gt;테스트용 파일이 잘 업로드 되었는지 검증하는 기능.&lt;/li&gt;
&lt;li&gt;테스트용 파일을 삭제하는 기능.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;아래는 위 3가지 기능을 구현한 &lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/test/java/com/backend/server/support/S3ApiUtil.java&quot;&gt;S3ApiUtil 클래스&lt;/a&gt;의 코드이다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;@Component 어노테이션로 스프링 빈으로 등록하여, 어디서든 의존성 주입받아 쓸 수 있게 하였다.&lt;/li&gt;
&lt;li&gt;RestTemplate, S3Client, S3Properties 세가지 클래스의 빈을 의존성 주입받아서 사용하고 있다.&lt;ol&gt;
&lt;li&gt;RestTemplate 은 스프링에서 제공하는 편리하게 REST API 요청을 하기위한 클래스이다. &lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/test/java/com/backend/server/config/RestTemplateConfig.java&quot;&gt;RestTemplateConfig 클래스&lt;/a&gt; 에서 RestTemplate 클래스의 인스턴스에 로깅기능을 추가하고 빈으로 등록하였다. 검증을 위한 조회 요청을 하려고 의존성 주입받았다.&lt;/li&gt;
&lt;li&gt;S3Client 는 AWS SDK 에서 제공하는 클래스이다. 파일 삭제, 업로드를 하기 위해 사용한다.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/main/java/com/backend/server/config/S3Config.java&quot;&gt;S3Properties 클래스&lt;/a&gt;는 이와 관련된 각종 설정값 등을 설정파일로부터 가져오는 클래스이다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Component
public class S3ApiUtil {

    @Autowired RestTemplate restTemplate;
    @Autowired S3Client s3Client;
    @Autowired S3Properties s3Properties;

    public void upload(String presignedUrl, String content) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentLength(content.getBytes(StandardCharsets.UTF_8).length);

        ResponseEntity&amp;lt;String&amp;gt; response = restTemplate.exchange(
                URI.create(presignedUrl),
                HttpMethod.PUT,
                new HttpEntity&amp;lt;&amp;gt;(content, headers),
                String.class
        );

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }

    public ResponseEntity&amp;lt;String&amp;gt; get(String fileKey) {
        final String url = String.format(&amp;quot;https://%s.s3.%s.amazonaws.com/%s&amp;quot;,
                s3Properties.getBucket(), s3Properties.getRegion(), fileKey);

        try {
            return restTemplate.exchange(
                    url,
                    HttpMethod.GET,
                    new HttpEntity&amp;lt;&amp;gt;(new HttpHeaders()),
                    String.class
            );
        } catch (RuntimeException e) {
            return null;
        }
    }

    public void delete(String fileKey) {
        DeleteObjectRequest request = DeleteObjectRequest.builder()
                .bucket(s3Properties.getBucket())
                .key(fileKey)
                .build();
        s3Client.deleteObject(request);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 위 S3ApiUtil 클래스를 이용한 테스트 코드를 작성하면 된다.&lt;/p&gt;
&lt;h4&gt;2-3. S3 파일 업로드 테스트 코드 작성하기&lt;/h4&gt;
&lt;p&gt;아래 두가지 사항을 테스트하는 통합 테스트 코드를 작성하면 된다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;클라이언트가 &lt;code&gt;Presigned URL 요청&lt;/code&gt; 하면, 백엔드가 &lt;code&gt;Presigned URL 반환&lt;/code&gt; 을 잘 하는지 여부&lt;/li&gt;
&lt;li&gt;반환된 URL 로 &lt;code&gt;Presigned URL 로 파일 업로드&lt;/code&gt; 까지 잘 되는지 여부&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;아래 코드가 작성한 테스트 코드이다. (&lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/test/java/com/backend/server/api/common/s3/controller/CommonPresignedUrlControllerTest.java&quot;&gt;링크&lt;/a&gt;)&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/test/java/com/backend/server/config/ControllerTest.java&quot;&gt;@ControllerTest 어노테이션&lt;/a&gt;은 테스트 클래스에 자주 붙이는 어노테이션을 모은 것이다.&lt;/li&gt;
&lt;li&gt;MockMvc 을 이용하여 백엔드에 요청을 보내고 있다. &lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/test/java/com/backend/server/config/MockMvcConfig.java&quot;&gt;MockMvcConfig 클래스&lt;/a&gt;에서 빈으로 등록했기 때문에 의존성 주입받아서 사용할 수 있었다.&lt;/li&gt;
&lt;li&gt;시나리오를 &lt;code&gt;Presigned URL 을 발급받는 시나리오&lt;/code&gt;, &lt;code&gt;잘 업로드 되는지 검증하는 시나리오&lt;/code&gt; 두가지로 나누어 각각 given, when, then 절을 두었다.&lt;/li&gt;
&lt;li&gt;S3 를 사용하는 테스트이므로, 적지만 비용도 들 수 있고, 시간도 오래 걸릴수 있다. 따라서 &lt;a href=&quot;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/context/junit/jupiter/EnabledIf.html&quot;&gt;@EnabledIf 어노테이션&lt;/a&gt;을 사용하여 integration-test 라는 프로필이 설정된 경우에만 테스트가 실행되도록 하였다.&lt;/li&gt;
&lt;li&gt;현재 시간 텍스트가 저장된 짧은 텍스트 파일을 즉석에서 만들어 업로드를 테스트하였다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@ControllerTest
@DisplayName(&amp;quot;CommonPresignedUrlController&amp;quot;)
class CommonPresignedUrlControllerTest {

    @Autowired S3ApiUtil s3ApiUtil;
    @Autowired MockMvc mockMvc;

    @Nested
    class Presigned_URL_API_는 {

        @Test
        @EnabledIf(expression = &amp;quot;#{&amp;#39;${spring.profiles.active:default}&amp;#39; == &amp;#39;integration-test&amp;#39;}&amp;quot;, loadContext = true)
        void 파일_업로드가_가능한_URL을_응답한다() throws Exception {
            /* presigned url 을 발급받습니다. */
            //given
            final String fileContent = LocalDateTime.now().toString();
            final String fileName = String.format(&amp;quot;%s.txt&amp;quot;, fileContent);
            System.out.printf(&amp;quot;file name : %s\tfile content : %s\n&amp;quot;, fileName, fileContent);

            //when
            ResultActions result = mockMvc.perform(get(&amp;quot;/api/s3/presigned-url&amp;quot;)
                    .param(&amp;quot;fileName&amp;quot;, fileName));

            //then
            result.andExpect(status().isOk());

            String presignedUrl = toJsonPathDocument(result).read(&amp;quot;$.data&amp;quot;, String.class);
            /* presigned url 을 발급받습니다. */

            /* presigned url 로 파일이 잘 업로드 되는지 테스트 */
            //given
            s3ApiUtil.upload(presignedUrl, fileContent);

            try {
                //when
                ResponseEntity&amp;lt;String&amp;gt; response = s3ApiUtil.get(fileName);

                //then
                assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
                assertThat(response.getBody())
                        .as(&amp;quot;업로드한 파일 내용과 업로드된 파일 내용이 일치하는지 확인합니다.&amp;quot;)
                        .isEqualTo(fileContent);
            } finally {
                // 테스트로 업로드한 파일을 삭제하고, 잘 삭제되었는지 확인합니다.
                s3ApiUtil.delete(fileName);

                assertThat(s3ApiUtil.get(fileName))
                        .as(&amp;quot;테스트용 파일이 삭제되었는지 확인합니다.&amp;quot;)
                        .isNull();
            }
            /* presigned url 로 파일이 잘 업로드 되는지 테스트 */
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;3. 로그인 테스트&lt;/h1&gt;
&lt;p&gt;블로그 이전 글에서 &lt;code&gt;Rate Limit 기능&lt;/code&gt; 과 &lt;code&gt;로그인 실패 횟수 제한 기능&lt;/code&gt;을 구현하였다.&lt;br&gt;이 기능이 정상적으로 동작하는지, 그리고 로그인이 정상적으로 이루어지는지 테스트 코드를 작성하게 되었다.&lt;/p&gt;
&lt;h4&gt;3-1. 현재 프로젝트의 로그인 방식에 대해서 (JWT)&lt;/h4&gt;
&lt;p&gt;현재 프로젝트는 인증 / 인가 에 JWT 방식을 사용한다.&lt;br&gt;현재 프로젝트의 &lt;code&gt;로그인 과정&lt;/code&gt;, &lt;code&gt;인증이 필요한 API 요청&lt;/code&gt; 로직을 각각 시퀸스 다이어그램으로 그리면 아래와 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;sequenceDiagram
    participant C as 클라이언트 (브라우저)
    participant B as 백엔드
    participant DB as DB
    participant R as Redis

    Note over C,R:   로그인 과정
    C-&amp;gt;&amp;gt;B: 1. 로그인 요청 (학번, 비밀번호)
    B&amp;lt;&amp;lt;-&amp;gt;&amp;gt;R: 2. 로그인 기록 추가 및 로그인 횟수 초과 여부 확인
    B&amp;lt;&amp;lt;-&amp;gt;&amp;gt;DB: 3. 사용자 정보 조회 및 인증
    B-&amp;gt;&amp;gt;B: 4. 엑세스 토큰, 리프레시 토큰 생성
    B&amp;lt;&amp;lt;-&amp;gt;&amp;gt;R: 5. 리프레시 토큰 저장
    alt 로그인 횟수 초과 / 에러 / 예외 발생
        B--&amp;gt;&amp;gt;C: ❌ 6. 로그인 실패 응답
    else 로그인 성공
        B&amp;lt;&amp;lt;-&amp;gt;&amp;gt;R: 7. 로그인 기록 초기화
        B--&amp;gt;&amp;gt;C: ✅ 8. 엑세스 토큰, 리프레시 토큰 반환
    end

    Note over C,R:   인증이 필요한 API 요청
    C-&amp;gt;&amp;gt;B: 9. 액세스 토큰을 포함한 요청 전송
    B-&amp;gt;&amp;gt;B: 10. 액세스 토큰 유효성 검증 (서명 및 만료 확인) → 인증
    B&amp;lt;&amp;lt;-&amp;gt;&amp;gt;DB: 11. 사용자 권한 확인 → 인가
    alt 인증 / 인가 실패
        B--&amp;gt;&amp;gt;C: ❌ 12. 인증 / 인가 실패 응답
    else 인증 / 인가 성공
        B--&amp;gt;&amp;gt;C: ✅ 13. 요청한 자원 응답
    end&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JWT 는 &lt;code&gt;인증 / 인가 / 정보 교환&lt;/code&gt;을 목적으로 만들어진 기술(RFC 7519)이다. (&lt;a href=&quot;https://www.jwt.io/introduction#how-json-web-tokens-work&quot;&gt;jwt.io 의 JWT 설명 참고&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;일반적으로 JWT 로그인을 구현할때 두 종류의 JWT 토큰을 발급한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;엑세스 토큰 : 만료시간이 짧으며, 로그인 이후 API 호출 시 인증에 사용된다.&lt;/li&gt;
&lt;li&gt;리프레시 토큰 : 엑세스 토큰이 만료되면 재발급을 위해 사용되며, 주로 HttpOnly 쿠키에 저장한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;짧은 만료시간의 액세스 토큰&lt;/code&gt; 및 &lt;code&gt;탈취를 대비한 리프레시 토큰의 블랙리스트 관리&lt;/code&gt; 가 일반적인 전략이다.&lt;/p&gt;
&lt;p&gt;JWT가 널리 사용되기 이전에는, 로그인 기능 구현에 &lt;strong&gt;세션(Session)&lt;/strong&gt; 이 주로 사용되었다.&lt;br&gt;톰캣 웹 서버의 세션 기능을 사용하는 경우, 동작하는 방식은 다음과 같다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;톰캣이 클라이언트 브라우저의 쿠키에 &lt;code&gt;JSESSIONID&lt;/code&gt; 라는 키를 저장한다.&lt;/li&gt;
&lt;li&gt;톰캣이 &lt;code&gt;JSESSIONID&lt;/code&gt;에 대응하는 &lt;code&gt;세션 객체&lt;/code&gt;를 생성하고, 메모리(또는 외부 저장소)에 저장한다.&lt;/li&gt;
&lt;li&gt;생성된 &lt;code&gt;세션 객체&lt;/code&gt;는 백엔드 애플리케이션에서 접근할 수 있다.&lt;/li&gt;
&lt;li&gt;백엔드 애플리케이션은 &lt;code&gt;세션 객체&lt;/code&gt; 에 로그인 여부나 민감한 정보 등 사용자 상태를 저장할 수 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;JWT 는 세션 방식에 비하여, 아래와 같은 장점이 있다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;민감하지 않은 정보를 토큰내에 담아서 API 호출 횟수 및 트래픽을 절감할 수 있다.&lt;/li&gt;
&lt;li&gt;벡엔드가 인증 토큰을 직접 다루므로, 인증 / 인가 전용 서버를 직접 구현 가능하다. 즉, 수평적 확장을 하기 쉬워지고, OAuth2 표준대로 소셜 로그인용 서버를 만들 수 있고, 도메인마다 서버를 나누기 쉬워진다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;무상태 서버&lt;/code&gt;(= 클라이언트의 정보를 가지지 않아서 확장이 쉬운 서버)에 더 가깝다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;물론 JWT 구현이 어렵고, 프로그래머의 역량에 따라 보안에 문제가 생길 확률이 높은 등등 단점도 많다.&lt;/p&gt;
&lt;h4&gt;3-2. 로그인 테스트 코드 작성하기&lt;/h4&gt;
&lt;p&gt;테스트 할 시나리오는 아래와 같다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;로그인 API 는 사용가능한 엑세스 토큰을 응답한다.&lt;ul&gt;
&lt;li&gt;로그인 요청 → 응답 받은 액세스 토큰으로 “내 정보 조회” API 호출&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;로그인 API 는 일분간 연속 5회 실패시 Rate Limit 제한에 걸린다.&lt;/li&gt;
&lt;li&gt;로그인 API 는 로그인 성공시 실패 횟수가 초기화 된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;로그인 API 를 테스트하는 코드는 아래와 같다. (&lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/test/java/com/backend/server/api/common/auth/controller/CommonAuthControllerTest.java&quot;&gt;링크&lt;/a&gt;)&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;pw 가 틀린 경우 / 맞은 경우의 중복 코드를 private 메소드로 분리시켰다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;@BeforeEach 에서 Redis 에 저장되는 키를 Mockito 라이브러리로 캡처하여, @AfterEach 에서 해당 키들을 삭제함으로써 테스트 간 격리를 유지했다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Rate Limit 기능을 무시하고 테스트해야 하는 경우를 위해, FakeRateLimitConfig 클래스를 만들어서 다른 테스트 코드에서는 Rate Limit 를 무효화 시킬수 있도록 했다. (&lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/test/java/com/backend/server/config/FakeRateLimitConfig.java&quot;&gt;링크&lt;/a&gt;)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;class CommonAuthControllerTest {

 @Autowired private MockMvc mockMvc;
 @Autowired private PasswordEncoder passwordEncoder;
 @Autowired private UserRepository userRepository;

 @MockitoSpyBean private RateLimitRepository rateLimitRepository;

 final private List&amp;lt;String&amp;gt; capturedKeys = new ArrayList&amp;lt;&amp;gt;();

 @BeforeEach
 void setUp() {
     //로그인 테스트 진행할 계정 등록
     userRepository.save(MOCK_MVC_테스트시_로그인_계정.엔티티_생성(passwordEncoder, null));

     //redis 에 추가되는 rate limit 관련 key 를 캡처.
     doAnswer(invocation -&amp;gt; {
         String key = (String) invocation.getArguments()[0];
         capturedKeys.add(key);
         return invocation.callRealMethod();
     }).when(rateLimitRepository).add(any(), any());
 }

 @AfterEach
 void tearDown() {
     //다음 테스트에 영향이 없도록, 캡쳐한 rate limit 관련 key 들을 redis 에서 삭제.
     capturedKeys.forEach(rateLimitRepository::delete);
 }

 @Nested
 class 로그인_API_는 {

     @Test
     void 사용가능한_엑세스_토큰을_응답한다() throws Exception {
         /* 1. 로그인 */
         //given
         ...
         //when
         ...
         //then
         ...
         /* 1. 로그인 */

         /* 2. 발급 받은 엑세스 토큰으로 내 정보 조회하기 */
         //given
         ...
         //when
         ...
         //then
         ...
         /* 2. 발급 받은 엑세스 토큰으로 내 정보 조회하기 */
     }

     @Test
     void 일분간_연속_5회_실패시_Rate_Limit_에_걸린다() throws Exception {
         /* 1. 연속 5회 실패 */
         for (int i = 0; i &amp;lt; 5; i++)
             비밀번호가_틀린_로그인_요청을_보낸다().andExpect(status().isInternalServerError());

         /* 2. 6회 째에 올바르게 로그인 정보를 기입해도 429(Too many request) 에러 발생 */
         올바른_로그인_요청을_보낸다().andExpect(status().isTooManyRequests());
     }

     @Test
     void 로그인_성공시_실패_횟수가_초기화_된다() throws Exception {
         // 1. 4회 실패
         for (int i = 0; i &amp;lt; 4; i++)
             비밀번호가_틀린_로그인_요청을_보낸다().andExpect(status().isInternalServerError());

         // 2. 1회 성공
         올바른_로그인_요청을_보낸다().andExpect(status().isOk());

         // 3. 4회 싶패
         for (int i = 0; i &amp;lt; 4; i++)
             비밀번호가_틀린_로그인_요청을_보낸다().andExpect(status().isInternalServerError());

         // 4. 1회 성공
         올바른_로그인_요청을_보낸다().andExpect(status().isOk());
     }

     private ResultActions 올바른_로그인_요청을_보낸다() throws Exception {
         //given
         final CommonSignInRequest correctSignInRequest = MOCK_MVC_테스트시_로그인_계정.로그인_요청_생성();

         //when
         return mockMvc.perform(post(&amp;quot;/api/auth/sign-in&amp;quot;)
                 .contentType(MediaType.APPLICATION_JSON)
                 .content(convertToJson(correctSignInRequest)));
     }

     private ResultActions 비밀번호가_틀린_로그인_요청을_보낸다() throws Exception {
         //given
         final CommonSignInRequest wrongSignInRequest = MOCK_MVC_테스트시_로그인_계정.로그인_요청_생성();
         wrongSignInRequest.setPassword(&amp;quot;wrongPassword&amp;quot;);

         //when
         return mockMvc.perform(post(&amp;quot;/api/auth/sign-in&amp;quot;)
                 .contentType(MediaType.APPLICATION_JSON)
                 .content(convertToJson(wrongSignInRequest)));
     }
 }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;여기까지 로그인 관련 테스트 코드를 모두 작성하였다.&lt;br&gt;지금 다시 본 프로젝트를 시작한다면, JWT 대신 세션 기반 인증 방식을 선택할 것 같다.&lt;br&gt;&lt;code&gt;리프레시 토큰 로테이션&lt;/code&gt;, &lt;code&gt;CSRF 토큰&lt;/code&gt;, &lt;code&gt;리프레시 토큰 블랙리스트 관리 전략&lt;/code&gt; 등은 보안을 위해 반드시 고려해야 하는 주제이지만, 이를 직접 구현하려면 상당한 시간과 노력이 필요한 것 같다.&lt;/p&gt;
&lt;p&gt;특히 이러한 보안 관련 기능들은 보통 프론트엔드 코드도 같이 작성해야 해서, 많은 노력이 필요하다고 생각한다.&lt;/p&gt;</description>
      <category>개발기록/CEC 프로젝트</category>
      <category>Junit5</category>
      <category>JWT</category>
      <category>mockito</category>
      <category>presigned</category>
      <category>S3</category>
      <category>스프링</category>
      <category>코드</category>
      <category>테스트</category>
      <category>픽스처</category>
      <author>qkr10</author>
      <guid isPermaLink="true">https://qkr10.tistory.com/5</guid>
      <comments>https://qkr10.tistory.com/5#entry5comment</comments>
      <pubDate>Wed, 22 Oct 2025 12:47:50 +0900</pubDate>
    </item>
    <item>
      <title>[개발기록/CEC 프로젝트] 3. Rate Limit 기능을 어노테이션으로 구현하기</title>
      <link>https://qkr10.tistory.com/4</link>
      <description>&lt;p&gt;이 프로젝트가 마무리 되고, 내가 구현한 기능중에 개선점이 없는지 다시한번 살펴보았다.&lt;br&gt;그런데 로그인 기능에 &lt;code&gt;실패 횟수 제한 기능&lt;/code&gt;이 없는 것이었다. 아무리 기획한 내용에 해당 기능이 명시되지 않았다고는 해도, 아주 기본적인 기능조차 없는데도 내 기술력을 어필하기 위한 개선점을 찾고 있었던 것이 부끄러웠다.&lt;/p&gt;
&lt;p&gt;이후 &lt;code&gt;로그인 실패 횟수 제한 기능&lt;/code&gt;을 찾아보며, Rate Limit(= 클라이언트가 서버에 요청할 수 있는 횟수를 제한하는 기술)에 대해 알게 되었다. Rate Limit 와 &lt;code&gt;로그인 실패 횟수 제한 기능&lt;/code&gt; 는 비슷한 점이 많은 기능이므로 함께 구현하게 되었다.&lt;/p&gt;
&lt;p&gt;주요 요구사항은 아래와 같다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;로그인 실패 횟수 제한&lt;ol&gt;
&lt;li&gt;로그인 성공시 로그인 실패한 횟수가 초기화 되어야 한다.&lt;/li&gt;
&lt;li&gt;10분당 5회까지만 실패할 수 있게 해도 보안이 크게 향상될 것이다.&lt;/li&gt;
&lt;li&gt;로그인 시도한 id 를 기준으로 동일한 클라이언트인지 판별해야 한다.&lt;ol&gt;
&lt;li&gt;본 프로젝트는 학과에서 사용할 것이므로, ip 로 판별하면 학생 모두가 접속이 안될수 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;일반적인 API&lt;ol&gt;
&lt;li&gt;동일 요청에 초당 5회 정도의 제한을 두면, 사용에도 불편이 없을 것이고, 혹시 모를 공격에 의한 비용 폭탄도 방지할 수 있을 것이다.&lt;/li&gt;
&lt;li&gt;이미 로그인한 id 를 기준으로 동일한 클라이언트인지 판별해야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Rate Limit 란?&lt;/h3&gt;
&lt;p&gt;클라이언트가 서버에 요청할 수 있는 횟수를 제한하는 기술.&lt;br&gt;이를 구현하기 위한 다양한 방식이 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Token Bucket&lt;ul&gt;
&lt;li&gt;클라이언트가 가진 토큰을 일정 시간마다 충전시켜주고, 요청시마다 토큰을 하나씩 소모하는 방식.&lt;/li&gt;
&lt;li&gt;실 서비스에서 많이 사용. 순간 버스트를 버킷의 크기로 제한할 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Leaky Bucket&lt;ul&gt;
&lt;li&gt;서버가 동일한 시간동안 정해진 양의 토큰만 처리할 수 있음.&lt;/li&gt;
&lt;li&gt;네트워크 장비 (스위치/라우터) 에서 많이 사용. QoS(혼잡제어)를 위해 주로 사용. 버스트 처리 힘듬.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Fixed Window Counter&lt;ul&gt;
&lt;li&gt;정해진 시간대 별로 요청한 횟수를 카운팅한다.&lt;/li&gt;
&lt;li&gt;예) 하루에 10번 요청이 가능한 제한을 Fixed Window Counter 로 구현하면, 오늘 23시 59분에 10번, 내일 00시 00분에 10번 요청하여 단시간에 20번 요청할수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Sliding Window&lt;ul&gt;
&lt;li&gt;Sliding Window Counter&lt;ul&gt;
&lt;li&gt;Fixed Window 처럼 시간대 별로 요청한 횟수를 카운팅 하되, 적절히 보간하여 단시간에 요청이 몰리는 것을 막는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Sliding Window Log&lt;ul&gt;
&lt;li&gt;매 요청마다 로그를 남겨서, 이전 로그들을 바탕으로 요청을 받을지 여부를 정한다.&lt;/li&gt;
&lt;li&gt;가장 정확한 방법이다. 구현 난이도나 서버 부하가 크다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;위 방식들중 무엇을 사용할 것인가?&lt;/h3&gt;
&lt;p&gt;내가 고려한 조건은 아래와 같다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;단시간에 원래 제한을 넘어서는 요청을 하면 안된다.&lt;/li&gt;
&lt;li&gt;횟수 제한 내에서는 연속으로 요청할 수 있어야 한다.&lt;/li&gt;
&lt;li&gt;최근 요청한 시각을 표시하는 기능을 나중에 구현할 수도 있는데, 그때 로그를 조회하여 구현하고 싶다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;Fixed Window Counter&lt;/code&gt; 는 1번 조건을 만족하지 못한다.&lt;br&gt;&lt;code&gt;Leaky Bucket&lt;/code&gt;은 2번 조건을 만족하지 못한다.&lt;br&gt;&lt;code&gt;Token Bucket&lt;/code&gt; 과 &lt;code&gt;Sliding Window Counter&lt;/code&gt; 는 로그를 저장하지 않으므로 3번 조건을 만족하지 못한다.&lt;br&gt;따라서 &lt;code&gt;Sliding Window Log&lt;/code&gt; 방식을 선택하게 되었다.&lt;/p&gt;
&lt;h3&gt;Sliding Window Log 를 어떻게 구현하는게 좋을까?&lt;/h3&gt;
&lt;p&gt;Sliding Window Log 를 구현하기 위해서는 로그를 저장할 DB가 있어야 한다.&lt;br&gt;RDB 는 로그를 저장하기에는 비용이 높은 자원이므로, In-memory DB를 사용하는게 맞다고 판단하였다.&lt;br&gt;그 중에서도 현재 프로젝트에서 사용중이며, 기능도 다양한 Redis 를 사용하는게 맞다고 판단하였다.&lt;/p&gt;
&lt;p&gt;Redis 가 지원하는 다양한 자료구조 중에서, 나에게 적합한 것을 고르기 위해 고려해야 했던 것들은 아래와 같다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;로그 삽입, 특정 시간동안의 로그에 대한 조회, 특정 시간동안의 로그에 대한 삭제가 빨라야 한다.&lt;/li&gt;
&lt;li&gt;시간이 오래 지난 로그들을 만료시키는 기능이 있어야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Redis 는 &lt;a href=&quot;https://redis.io/docs/latest/develop/data-types/sorted-sets/&quot;&gt;Sorted sets&lt;/a&gt; 라는 자료구조를 지원하고 있는데, 위 두가지 조건에 딱 맞는다.&lt;/p&gt;
&lt;p&gt;Sorted sets 자료구조는 &lt;a href=&quot;https://redis.io/docs/latest/commands/zadd/&quot;&gt;ZADD&lt;/a&gt;, &lt;a href=&quot;https://redis.io/docs/latest/commands/zrange/&quot;&gt;ZRANGE&lt;/a&gt;, &lt;a href=&quot;https://redis.io/docs/latest/commands/zremrangebyscore/&quot;&gt;ZREMRANGEBYSCORE&lt;/a&gt; 명령어들을 지원하는데, 각각 삽입, 구간에 대한 조회, 구간에 대한 삭제 명령어이다. 트리가 N개의 노드를 가질때, 각각 O(log(N)), O(log(N) + M), O(log(N) + M) 시간 복잡도를 가진다. 따라서 1번 조건을 만족한다.&lt;/p&gt;
&lt;p&gt;또한 키 하나와 트리 하나가 대응되며, 키에 만료 시간을 걸 수 있기 때문에, 로그를 트리에 저장하고 키에 만료시간을 걸면 로그들이 필요없어지는 순간에 한꺼번에 만료시킬 수 있다. 물론 트리에 새로운 로그를 추가하고 만료시간을 갱신할수도 있다. 키에 만료 시간을 걸수 있기에 2번 조건을 만족한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;이 Sorted sets 자료구조를 자바 콜렉션으로 표현하자면, HashMap&amp;lt;String, TreeMap&amp;lt;Double, String&amp;gt;&amp;gt; 와 비슷하다. 각각의 키마다 대응되는 트리가 존재하고, 트리의 각 데이터는 (우선순위 점수, 값) 쌍으로 이루어져 있다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;이제 Sliding WIndow Log 방식으로 Rate Limit 를 구현해보자&lt;/h3&gt;
&lt;p&gt;아래는 Sliding Window Log 를 구현하기 위해 어떤 코드를 작성했는지 그림으로 나타낸 것이다.&lt;br&gt;&lt;code&gt;A&lt;/code&gt;가 &lt;code&gt;B&lt;/code&gt;를 화살표로 가리키면, &lt;code&gt;A&lt;/code&gt;가 &lt;code&gt;B&lt;/code&gt;를 사용/호출/의존한다는 의미이다.&lt;br&gt;1번부터 6번 순서대로 내가 어떤 식으로 코드를 작성했는지 소개하겠다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
    user[&amp;quot;사용자 요청&amp;quot;] --&amp;gt; java --&amp;gt; redis[&amp;quot;Redis&amp;quot;]

    subgraph java[&amp;quot;Java 코드 및 Lua 스크립트&amp;quot;]
        script[&amp;quot;1\. 저장(만료기간 설정, 결과 반환), 삭제 Lua 스크립트&amp;quot;]
        config[&amp;quot;2\. RedisScriptConfig 클래스&amp;quot;] --&amp;gt; script
        repo[&amp;quot;3\. RedisRateLimitRepository 클래스&amp;quot;] --&amp;gt; config
        aspect[&amp;quot;5\. RateLimiterAspect 클래스&amp;quot;] --&amp;gt; repo
        anno[&amp;quot;4\. LimitRequestPerTime 어노테이션&amp;quot;]
        aspect --&amp;gt; anno
        aspect --&amp;gt; method[&amp;quot;6\. RateLimitMethodInfo 클래스&amp;quot;]
    end&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;1. 저장(만료기간 설정, 결과 반환), 삭제 Lua 스크립트&lt;/h3&gt;
&lt;p&gt;우선 Redis 에 로그를 &lt;code&gt;저장(만료기간 설정, 결과 반환)&lt;/code&gt;하고 &lt;code&gt;삭제&lt;/code&gt;하는 Lua 언어 스크립트를 짜야한다.&lt;br&gt;&lt;code&gt;Spring Data Redis&lt;/code&gt; 에서 제공하는 &lt;a href=&quot;https://docs.spring.io/spring-data/redis/reference/api/java/org/springframework/data/redis/core/RedisTemplate.html&quot;&gt;RedisTemplate&lt;/a&gt; 을 사용하면, 자바만 가지고 레디스의 모든 명령어를 쓸수 있다. 하지만 그럼에도 Lua 스크립트를 쓰는데에는, 성능/원자성 등의 이유가 있다. (&lt;a href=&quot;https://redis.io/docs/latest/develop/programmability/eval-intro/&quot;&gt;공식문서&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;로그를 &lt;code&gt;저장(만료기간 설정, 결과 반환)&lt;/code&gt;하는 Lua 코드(&lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/main/resources/META-INF/scripts/rate_limit/add.lua&quot;&gt;깃허브 링크&lt;/a&gt;) 는 아래와 같다.&lt;br&gt;깃허브 링크를 클릭하면 더 상세한 설명을 볼수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-lua&quot;&gt;-- 인자를 받음
local key = KEYS[1]
local windowMillis = tonumber(ARGV[1])
local threshold = tonumber(ARGV[2])

-- 현재 시간을 밀리초 단위로 읽음
local t = redis.call(&amp;#39;TIME&amp;#39;)
local nowMillis = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)

-- window 범위를 벗어난 요청 기록들을 삭제
redis.call(&amp;quot;ZREMRANGEBYSCORE&amp;quot;, key, 0, nowMillis - windowMillis)

-- 요청 기록의 개수를 조회
local count = redis.call(&amp;quot;ZCARD&amp;quot;, key)
if count &amp;gt;= threshold then
    -- count 가 threshold 를 넘겼다면, 음수로 window 범위에서 가장 오래된 요청 시간을 반환
    local firstRequestTime = redis.call(&amp;quot;ZRANGE&amp;quot;, key, 0, 0)[1]
    return -tonumber(firstRequestTime)
end

-- 새 요청 기록 추가
redis.call(&amp;quot;ZADD&amp;quot;, key, nowMillis, nowMillis)

-- 메모리 낭비를 막기 위해, Key 에 만료시간 걸기
redis.call(&amp;quot;EXPIRE&amp;quot;, key, (windowMillis / 1000) + 1)

-- 요청 기록의 개수 반환
return count + 1&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. RedisScriptConfig 클래스&lt;/h3&gt;
&lt;p&gt;1.에서 Lua 스크립트 코드를 작성했으니, 이제 코드를 자바 코드로 쓸 수 있게 불러와야 한다.&lt;br&gt;또한 불러온 스크립트를 호출할 때마다 사용하는 RedisTemplate.execute() 메소드에 두가지 문제점이 있었다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;메소드의 인자가 가변인자여서 스크립트가 어떤 인자를 가지는지 알수 없었다.&lt;/li&gt;
&lt;li&gt;스크립트가 숫자를 인자로 받더라도 문자열로 변환해서 넘겨줘야 하는 불편함이 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;따라서 불러온 스크립트마다 메소드를 만들어서 불편함을 줄이려고 했다.&lt;br&gt;(&lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/main/java/com/backend/server/config/RedisScriptConfig.java&quot;&gt;깃허브 링크&lt;/a&gt;)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Component
@RequiredArgsConstructor
public class RedisScriptConfig {

    private final RedisTemplate&amp;lt;String, String&amp;gt; redisTemplate;

    private final RedisScript&amp;lt;Long&amp;gt; rateLimit_add = RedisScript.of(
            new ClassPathResource(&amp;quot;META-INF/scripts/rate_limit/add.lua&amp;quot;),
            Long.class);

    private final RedisScript&amp;lt;Long&amp;gt; rateLimit_delete = RedisScript.of(
            new ClassPathResource(&amp;quot;META-INF/scripts/rate_limit/delete.lua&amp;quot;),
            Long.class);

    public Long rateLimitAdd(String key, long windowMillis, int threshold) {
        return redisTemplate.execute(
                rateLimit_add,
                Collections.singletonList(key),
                String.valueOf(windowMillis),
                String.valueOf(threshold));
    }

    public Long rateLimitDelete(String key) {
        return redisTemplate.execute(
                rateLimit_delete,
                Collections.singletonList(key));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. RedisRateLimitRepository 클래스&lt;/h3&gt;
&lt;p&gt;로그를 &lt;code&gt;저장(만료기간 설정, 결과 반환)&lt;/code&gt; 하는 Lua 스크립트를 보면, 반환하는 시나리오가 두가지로 나뉜다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;요청 횟수 제한을 초과하면 &lt;code&gt;window 범위내에서 가장 오래된 요청 시간&lt;/code&gt; 을 음수로 바꾸어 반환한다.&lt;/li&gt;
&lt;li&gt;요청 횟수 제한을 넘지 않으면 &lt;code&gt;window 범위내에서 현재까지 요청받은 개수&lt;/code&gt;를 반환한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;위 시나리오를 보면 커다란 문제가 있는데, 어떤 시나리오에서든 공통적으로 숫자 값을 반환하며, 그 숫자가 음수인지 양수인지에 따라 &lt;code&gt;특별한 의미&lt;/code&gt;를 가진다는 것이다.&lt;br&gt;미래의 내가 이해하기 쉬운 코드 또는 유지보수가 쉬운 코드 를 작성하려면, &lt;code&gt;처음 본 사람의 입장&lt;/code&gt;에서도 그 의도가 명확해야 한다고 생각한다.&lt;br&gt;이 스크립트/메소드를 &lt;code&gt;처음 본 사람의 입장&lt;/code&gt;에서 이 코드들의 의도를 생각해 봤을때, 스크립트 파일명이나 메소드 명에 적혀있는 &lt;code&gt;Add / Delete&lt;/code&gt;라는 단어를 통해, &lt;code&gt;Rate Limit 를 구현하기 위한 기록을 추가 / 삭제&lt;/code&gt; 하는 것을 가장 큰 의도라고 여길 것이다.&lt;br&gt;반환값이 음수일때만 &lt;code&gt;특별한 의미&lt;/code&gt;가 있다는 것은 인지 부하도 크고, &lt;code&gt;처음 본 사람의 입장&lt;/code&gt;에서는 그 &lt;code&gt;특별한 의미&lt;/code&gt;를 모를 수밖에 없을 것이다. 그렇다고 해서, 이 의미있는 Long값 정보를 버리고 Boolean값 참/거짓만 반환하는 것도 최선이 아니라고 판단했다.&lt;br&gt;따라서, 요청 횟수 제한을 초과하였을 때는 &lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/main/java/com/backend/server/api/common/exceptionHandler/exception/TooManyRequestException.java&quot;&gt;커스텀 예외&lt;/a&gt;를 throw 하고 예외 클래스내에 정보를 담도록 하였고, 요청 횟수 제한을 넘지 않으면 그대로 정보를 반환하도록 코드를 짰다.&lt;/p&gt;
&lt;p&gt;또한, 이러한 코드가 어디에 위치해야 할지 고민했는데, 외부 저장소와 밀접하게 연관된 RedisScriptConfig 클래스를 사용한다는 점 때문에, Repository 레이어에 위치시켰다. 또한 이 클래스가 RateLimitRepository 인터페이스를 구현하게 하고, 사용할때도 RateLimitRepository 인터페이스로 사용하여, 레디스가 아닌 다른 기술을 써도 변경이 이 이상 전파되지 않게 했다.&lt;/p&gt;
&lt;p&gt;RedisScriptConfig 클래스와 RedisRateLimitRepository 클래스를 합칠지도 고민했는데, Lua 스크립트를 불러오는 것만 전담하는 RedisScriptConfig 클래스가 필요하다고 생각되어 분리해 두었다.&lt;br&gt;(&lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/main/java/com/backend/server/model/repository/rateLimit/RedisRateLimitRepository.java&quot;&gt;깃허브 링크&lt;/a&gt;)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Component
@RequiredArgsConstructor
public class RedisRateLimitRepository implements RateLimitRepository {

    private final RedisScriptConfig scripts;

    @Override
    public Long add(String key, LimitRequestPerTime limit) throws TooManyRequestException {
        final long windowMillis = limit.timeUnit().toMillis(limit.time());
        final int threshold = limit.count();

        Long result = scripts.rateLimitAdd(key, windowMillis, threshold);

        // 요청 횟수 제한을 초과했을때
        if (result &amp;lt; 0) {
            LocalDateTime now = LocalDateTime.now();
            LocalDateTime retryAvailableAt = Instant
                    .ofEpochMilli(-result + windowMillis)
                    .atZone(ZoneId.systemDefault())
                    .toLocalDateTime();
            Duration retryAfter = Duration.between(now, retryAvailableAt);
            throw new TooManyRequestException(limit, retryAfter);
        }

        return result;
    }

    @Override
    public Long delete(String key) {
        return scripts.rateLimitDelete(key);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. LimitRequestPerTime 어노테이션&lt;/h3&gt;
&lt;p&gt;우리는 스프링에서 지원하는 AOP 라는 기능을 사용할 것이다. 이를 사용하면, &lt;code&gt;특정 어노테이션이 붙은 메소드&lt;/code&gt;가 호출되기 전이나, 반환된 후에 원하는 로직을 실행할 수 있는데, 우리가 만드는 &lt;code&gt;클라이언트의 요청을 조건에 따라 제한하는 기능&lt;/code&gt;을 구현하기에 아주 적합하다.&lt;/p&gt;
&lt;p&gt;아래는 주요 요구사항이다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;로그인 실패 횟수 제한&lt;ol&gt;
&lt;li&gt;로그인 성공시 로그인 실패한 횟수가 초기화 되어야 한다.&lt;/li&gt;
&lt;li&gt;10분당 5회까지만 실패할 수 있게 해도 보안이 크게 향상될 것이다.&lt;/li&gt;
&lt;li&gt;로그인 시도한 id 를 기준으로 동일한 클라이언트인지 판별해야 한다.&lt;ol&gt;
&lt;li&gt;본 프로젝트는 학과에서 사용할 것이므로, ip 로 판별하면 학생 모두가 접속이 안될수 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;일반적인 API&lt;ol&gt;
&lt;li&gt;동일 요청에 초당 5회 정도의 제한을 두면, 사용에도 불편이 없을 것이고, 혹시 모를 공격에 의한 비용 폭탄도 방지할 수 있을 것이다.&lt;/li&gt;
&lt;li&gt;이미 로그인한 id 를 기준으로 동일한 클라이언트인지 판별해야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;그에 따라 어노테이션에 필요한 인자는 다음과 같다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;몇초에 몇회 제한을 걸 것인지&lt;ul&gt;
&lt;li&gt;&lt;code&gt;time&lt;/code&gt; : 시간, &lt;code&gt;timeUnit&lt;/code&gt; : 시간단위, &lt;code&gt;count&lt;/code&gt; : 횟수&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;무엇을 기준으로 클라이언트를 식별할 것인지&lt;ul&gt;
&lt;li&gt;&lt;code&gt;identifier&lt;/code&gt; : 기본값은 로그인한 계정 id(= 학번) / SpEL 을 이용한 표현식(= 로그인 시도한 id)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;이 어노테이션이 붙은 메소드가 정상 반환되면, 초기화 시킬 것인지(= 로그인 성공시 실패 횟수 초기화)&lt;ul&gt;
&lt;li&gt;&lt;code&gt;resetOnSuccess&lt;/code&gt; : 정상 반환시 초기화 여부&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;(&lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/main/java/com/backend/server/support/rateLimit/LimitRequestPerTime.java&quot;&gt;깃허브 링크&lt;/a&gt;)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;/**
 * rate limit (요청 횟수 제한)을 적용시키는 어노테이션 입니다.&amp;lt;br&amp;gt;
 * 사용자 식별자와 패키지경로+클래스명+메소드명 을 기준으로 동일한 요청인지 판단합니다.&amp;lt;br&amp;gt;
 * 어노테이션이 중복으로 적용된 경우 &amp;quot;메소드, 클래스, 인터페이스, 부모클래스&amp;quot; 중에서 가장 앞에 있는 것이 적용됩니다.
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface LimitRequestPerTime {

    /**
     * SpEL 표현식으로 매개변수에서 사용자 식별자(String 타입)를 가져와 사용할수 있습니다.&amp;lt;br&amp;gt;
     * (기본값 : 로그인한 계정의 사용자 학번)&amp;lt;br&amp;gt;
     * 예를 들어, 이 필드에 &amp;quot;Sting.valueOf(#a.getId())&amp;quot; 을 대입한 경우 아래와 같은 뜻이 됩니다.&amp;lt;br&amp;gt;
     * 이 어노테이션이 붙은 메소드에 a 라는 매개변수가 있다. a.getId() 메소드를 호출한 결과를 문자열로 변환시켜서 사용자 식별자로 사용하겠다.
     */
    String identifier() default &amp;quot;&amp;quot;;

    /**
     * 요청 제한 시간&amp;lt;br&amp;gt;
     * (기본값 : 1)
     */
    int time() default 1;

    /**
     * 요청 제한 시간 단위&amp;lt;br&amp;gt;
     * (기본값 : TimeUnit.MINUTES)
     */
    TimeUnit timeUnit() default TimeUnit.MINUTES;

    /**
     * 요청 제한 횟수&amp;lt;br&amp;gt;
     * (기본값 : 5)
     */
    int count() default 5;

    /**
     * 이 어노테이션이 붙은 메소드가 예외 throw 없이 정상적으로 반환되었을때, 기존 요청 기록을 삭제하는 기능&amp;lt;br&amp;gt;
     * (기본값 : false)&amp;lt;br&amp;gt;
     * 예를 들어, 로그인 성공시 로그인 실패 횟수를 초기화 시키는 것을 구현할수 있습니다.
     */
    boolean resetOnSuccess() default false;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5. RateLimiterAspect 클래스&lt;/h3&gt;
&lt;p&gt;이제 여태까지 작성한 코드와 스프링의 AOP 기능을 사용하여, 클라이언트의 요청이 제한되어야 하는 경우, 메소드의 실행을 막는 코드를 작성하면 된다.&lt;/p&gt;
&lt;p&gt;아래 코드의 interceptor() 에서 joinPoint.proceed() 를 호출하고 있는데, 그 코드가 실행되면 요청 횟수 제한에 걸리지 않았다는 것이다. 바로 윗줄에서 rateLimitRepository.add() 를 호출하고 있는데, 이 코드가 3.에서 설명한 코드이다. 여기서 예외가 발생하면 요청 횟수 제한에 걸렸다는 뜻이고, &lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/main/java/com/backend/server/api/common/exceptionHandler/controller/CommonExceptionController.java#L83&quot;&gt;예외 핸들링 컨트롤러&lt;/a&gt;가 실행되게 된다.&lt;/p&gt;
&lt;p&gt;RateLimitMethodInfo 라는 클래스를 사용하고 있는데, 해당 클래스에 대해서는 다음 문단에서 설명하겠다.&lt;/p&gt;
&lt;p&gt;아래 코드의 createKey() 는 (사용자의 식별자, API의 식별자)을 이어붙인 문자열을 생성한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;API의 식별자는 어떻게 생성할까? 현재 프로젝트는 API 하나에 컨트롤러 계층의 메소드와 서비스 계층의 메소드가 하나씩 1:1:1 로 대응하는 구조이므로, 메소드의 식별자가, API의 식별자를 대신할 수 있다. 메소드의 식별자를 생성하려면 &lt;code&gt;리플렉션&lt;/code&gt;을 사용해야 하며, 해당 코드는 RateLimitMethodInfo 클래스에 있다.&lt;/li&gt;
&lt;li&gt;사용자의 식별자는 어떻게 생성할까?&lt;ul&gt;
&lt;li&gt;현재 로그인을 한 사용자인 경우 =&amp;gt; 로그인 한 사용자는 매 API 호출마다 인증필터를 거치고, 인증이 성공한 계정은 &lt;code&gt;Spring Security&lt;/code&gt; 에서 지원하는 &lt;code&gt;SecurityContext&lt;/code&gt; 내의 &lt;code&gt;Principal&lt;/code&gt; 객체에 계정의 정보를 담아두었다. &lt;code&gt;SecurityContext&lt;/code&gt; 을 통해 &lt;code&gt;Principal&lt;/code&gt; 객체을 꺼내오고, 꺼내온 객체에서 해당 계정의 id 를 가져오면 사용자의 식별자로 쓸 수 있다.&lt;/li&gt;
&lt;li&gt;현재 로그인을 하지 않은 경우 =&amp;gt; ip, cookie 등을 사용한 방법이 있겠지만, 이런 방법들은 사용이 제한되거나 효용성이 떨어질 수 있다. 대신, 메소드에 인자로 넘겨진 값을 사용하여 식별자로 쓸수 있게 했다. 예를들어, 로그인 시도한 id를 기반으로 동일한 클라이언트인지 식별하여도, 패스워드를 여러번 시도하는 공격을 방어하는 것에는 충분히 효과적이라고 생각한다.&lt;ul&gt;
&lt;li&gt;메소드에 인자로 넘겨진 값을 스프링 AOP 상에서 가져오기 위해서는 메소드의 매개변수 이름등의 정보가 필요하다. 이를 위해선 &lt;code&gt;리플렉션&lt;/code&gt;을 사용해야 하며, 해당 코드는 RateLimitMethodInfo 클래스에 있다.&lt;/li&gt;
&lt;li&gt;또한, 메소드의 매개변수 이름등의 정보가 있더라도, 여러가지 매개변수중에 특정 값을 가져와야 하고, 매개변수가 객체인 경우 해당 객체의 특정 멤버를 가져와야 하는데, 이것을 동적으로 가능하게 해주는 SpEL 이라는 기술이 있다. 미리 EvaluationContext 객체에 매개변수 값들을 넣어놓으면, LimitRequestPerTime 어노테이션의 identifier 인자에 따라서 SpEL 파서가 값을 가져오도록 구현하였다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;(&lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/736fb032acf970a15c7f20841ab84984ffba4023/src/main/java/com/backend/server/support/rateLimit/RateLimiterAspect.java&quot;&gt;깃허브 링크&lt;/a&gt;)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RateLimiterAspect {

    private final RateLimitRepository rateLimitRepository;

    @Around(&amp;quot;@within(com.backend.server.support.rateLimit.LimitRequestPerTime) || @annotation(com.backend.server.support.rateLimit.LimitRequestPerTime)&amp;quot;)
    public Object interceptor(ProceedingJoinPoint joinPoint) throws Throwable {
        final RateLimitMethodInfo info = RateLimitMethodInfo.of(joinPoint);
        final String key = createKey(joinPoint, info);

        rateLimitRepository.add(key, info.getAnnotation());

        Object result = joinPoint.proceed();

        if (info.getAnnotation().resetOnSuccess())
            rateLimitRepository.delete(key);

        return result;
    }

    /**
     * 1. 사용자 식별자를 해싱하여 clientKey를 생성한다.&amp;lt;br&amp;gt;
     * 2. prefixKey + clientKey + methodKey를 반환한다.
     */
    private String createKey(ProceedingJoinPoint joinPoint, RateLimitMethodInfo info) {
        // 1. 사용자 식별자를 해싱하여 clientKey를 생성한다.
        String identifier;
        if (info.isUsingSpEL()) {
            // 식별자를 SpEL로 지정한 경우
            final EvaluationContext context = new StandardEvaluationContext();
            for (int i = 0; i &amp;lt; info.getParameters().length; i++)
                context.setVariable(info.getParameters()[i].getName(), joinPoint.getArgs()[i]);
            identifier = info.getParser().getValue(context, String.class);
        } else {
            // 식별자 기본값은 로그인한 유저의 학번
            final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            final LoginUser loginUser = (LoginUser) authentication.getPrincipal();
            identifier = loginUser.getStudentNumber();
        }
        if (identifier == null || identifier.isEmpty())
            throw new IllegalArgumentException(&amp;quot;RateLimiterAspect.createKey() : 클라이언트 식별자가 존재하지 않습니다.&amp;quot;);
        final long clientKey = identifier.hashCode();

        // 2. prefixKey + clientKey + methodKey를 반환한다.
        String prefixKey = &amp;quot;rate_limit&amp;quot;;
        return String.format(&amp;quot;%s:%d:%d&amp;quot;, prefixKey, info.getMethodKey(), clientKey);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6. RateLimitMethodInfo 클래스&lt;/h3&gt;
&lt;p&gt;위 문단의 createKey() 메소드에 대한 설명을 보면 &lt;code&gt;리플렉션&lt;/code&gt;을 사용하는 코드는 RateLimitMethodInfo 에 있다고 쓰여있다. 이 클래스는 리플렉션 등 부하가 큰 기술을 사용한 결과를 캐싱하여 성능을 올리기 위해 만들었다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;리플렉션&lt;/code&gt;은 클래스, 메소드 등의 정보를 가져오는 기술인데, 느린 속도와 코드가 이해하기 어렵다는게 단점이라고 할 수 있다. 이미 메모리에 로딩된 클래스 메타데이터를 가져오기 때문에, 파일 입출력 / 시스템 콜만큼 느리진 않지만, 느리긴 느리다.&lt;/p&gt;
&lt;p&gt;따라서 매 요청마다 리플렉션을 쓰지 않도록, 캐싱하는 기능이 필요했고 그 코드를 RateLimitMethodInfo 클래스에 위치시켰다. 추가로 SpEL 표현식을 파싱하는 코드도 여기에 위치시켰다.&lt;/p&gt;
&lt;p&gt;(&lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/main/src/main/java/com/backend/server/support/rateLimit/RateLimitMethodInfo.java&quot;&gt;깃허브 링크&lt;/a&gt;)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;/**
 * Rate Limit 기능에서 메소드에 관한 정보를 캐싱하는 클래스입니다.&amp;lt;br&amp;gt;
 * 1. 어노테이션이나 메소드 정보를 리플렉션으로 가져오는 부하를 줄일 수 있습니다.&amp;lt;br&amp;gt;
 * 2. SpEL parser 객체를 생성하는 부하를 줄일수 있습니다.
 */
public class RateLimitMethodInfo {

    @Getter private LimitRequestPerTime annotation;
    @Getter private long methodKey;
    @Getter private Parameter[] parameters;
    @Getter private Expression parser;
    @Getter private boolean isUsingSpEL;

    private static final ExpressionParser expressionParser = new SpelExpressionParser();
    private static final ConcurrentMap&amp;lt;Method, RateLimitMethodInfo&amp;gt; cache = new ConcurrentHashMap&amp;lt;&amp;gt;();

    private void setAnnotation(Method method) {
        // 메소드, 클래스, 인터페이스, 부모클래스 순으로 어노테이션을 검색하여, 처음으로 검색된 어노테이션을 가져옵니다.
        annotation = AnnotatedElementUtils.findMergedAnnotation(method, LimitRequestPerTime.class);
        if (annotation == null)
            throw new IllegalArgumentException(&amp;quot;RateLimitInfo::new : 어노테이션을 찾을수 없습니다.&amp;quot;);

        // 어노테이션이 SpEL을 사용할때만, SpEL 에 필요한 객체를 생성합니다.
        if (annotation.identifier() == null || annotation.identifier().isEmpty()) {
            isUsingSpEL = false;
            parameters = null;
            parser = null;
        } else {
            isUsingSpEL = true;
            parameters = method.getParameters();
            parser = expressionParser.parseExpression(annotation.identifier());
        }
    }

    private void setMethodKey(MethodSignature signature) {
        // 클래스의 패키지 경로 + 클래스 명 + 메소드 명 을 해싱하여, 해당 메소드만의 키를 생성합니다.
        final String className = signature.getDeclaringTypeName();
        final String methodName = signature.getName();
        methodKey = String.join(&amp;quot;#&amp;quot;, className, methodName).hashCode();
    }

    private RateLimitMethodInfo(MethodSignature signature, Method method) {
        setAnnotation(method);
        setMethodKey(signature);
    }

    public static RateLimitMethodInfo of(ProceedingJoinPoint joinPoint) {
        final MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        final Method method = signature.getMethod();

        if (!cache.containsKey(method))
            return cache.computeIfAbsent(method, (m) -&amp;gt; new RateLimitMethodInfo(signature, m));
        return cache.get(method);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;후기&lt;/h3&gt;
&lt;p&gt;누가 시키지 않았지만, 다른 기능보다도 필수적이라는 내 판단에 의해 기능을 추가하게 되었고, 완성한 것에 대해서, 개발 지식이 늘어난 것에 대해서 뿌듯하다. 하지만 분명 부족한 점도 많았는데, 다음에 다시 구현한다면 다르게 구현 할 것 같다.&lt;/p&gt;
&lt;h6&gt;1. 다시 구현한다면 토큰 버킷 방식으로 구현해 볼 것이다.&lt;/h6&gt;
&lt;p&gt;Redis Sorted sets 를 이용한 Sliding Window Log 방식 구현의 난이도를 과소평가 하여, 예상보다 시간이 오래 걸린듯 하다. 정확성이 매우 중요하지 않다면 토큰 버킷이 간단하고 성능도 좋다고 생각한다. 또한, &lt;code&gt;예상 대기 시간 표시 기능&lt;/code&gt;은 토큰 버킷으로도 충분히 구현가능하며, &lt;code&gt;최근 첫/마지막 요청 일시 표시 기능&lt;/code&gt;은 과연 그 기능이 Rate Limit 구현과 함께 구현해야할지, 로깅 기능이나 별도의 기능을 따로 만드는게 맞는지를 고민해야 할 것이다.&lt;/p&gt;
&lt;h6&gt;2. 성능을 위해 리플렉싱을 줄이기 위한 캐싱을 하는데, SpEL을 사용한다는건 모순 아닐까.&lt;/h6&gt;
&lt;p&gt;SpEL 표현식의 파싱 결과(AST(추상구문트리))는 캐싱되기 때문에, setVariable(), getValue() 두가지 메소드만 로그인 요청마다 호출될 것이다. 성능에 가장 영향을 주는 파싱 과정이 생략되더라도, 이게 어느정도 성능에 영향을 주는지 테스트 해보면 좋겠다. 성능에 영향을 준다면, 직접 간단한 표현식 파서를 만들거나 라이브러리를 가져다 쓸 수도 있을것이다.(사칙연산 기능은 필요 없으니까, SpEL 은 닭잡는데 소잡는 칼을 쓰는 것일 수 있다)&lt;/p&gt;
&lt;h6&gt;3. 일반적인 서비스에서의 로그인 횟수 제한 기능과는 거리가 있다.&lt;/h6&gt;
&lt;p&gt;학과 조교와 학생들이 운영하고 사용할 것을 가정하였으니 관리 부담을 줄이기 위해, 계정 잠금을 없애는 등, 제한을 약화시킨 것도 있다. Rate Limit 와 로그인 횟수 제한은 분명 다른 기능이고, 이번 프로젝트에서만 비용(노력 + 시간) 절감을 위해 함께 구현해 봤다고 봐야 맞을 것이다.&lt;/p&gt;
&lt;p&gt;아마 다음 글은 &lt;code&gt;S3 파일 업로드 API 의 테스트 코드 작성기&lt;/code&gt;, &lt;code&gt;테스트 코드 성능 개선 및 테스트 격리 구현기&lt;/code&gt;, &lt;code&gt;Rate Limit 기능의 테스트 코드 작성기&lt;/code&gt;등을 작성할 것 같다. 이번 글은 첫번째 글보다는 정돈된 듯 해서 마음에 든다. 이런식으로 쭉 쓸 수 있다면 좋겠다.&lt;/p&gt;</description>
      <category>개발기록/CEC 프로젝트</category>
      <category>rate limit</category>
      <category>Redis</category>
      <category>Sorted Sets</category>
      <category>Spring</category>
      <category>슬라이딩 윈도우</category>
      <category>횟수 제한</category>
      <author>qkr10</author>
      <guid isPermaLink="true">https://qkr10.tistory.com/4</guid>
      <comments>https://qkr10.tistory.com/4#entry4comment</comments>
      <pubDate>Fri, 3 Oct 2025 14:04:59 +0900</pubDate>
    </item>
    <item>
      <title>양구군에서 다양한 축제와 행사 즐기기</title>
      <link>https://qkr10.tistory.com/3</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;지금으로부터 약 1주일 전, 학창시절 친구 하나가 같이 놀러가자며 양구군에서 열리는 축제의 서포터즈를 신청 하자고 했다. 1박 2일 일정이었고, 첫날 서포터즈로서 축제를 돕고, 이튿날 관광을 하는 코스였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 친구가 축제까지 쫓아다닐 정도로 외향?적이고 활동적인 친구였는지 잠시 의문을 품었지만, 서포터즈에게 주는 여행 지원 혜택에 대한 안내를 보고, 나도 오랜만에 힐링 여행을 즐기기 위해 신청하게 되었다. 나중에 알고보니 친구 역시 지인에게 영업당한 것이었다 ㅋㅋ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼, 친구에게 영업당해 가긴 했지만 정말 재미있었고, 특히 같이 가신 서포터즈 분들의 활기찬 성격과 즐기는 모습에 나까지 점점 동화되어 더 재미있게 느껴졌다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;여행 기록&lt;/h1&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;공수리 마을 주막할매 축제&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 서포터즈 활동을 하게된 축제이다. 파로호가 내려다 보이는 위치에서 진행되었으며, 먹을거리와 즐길거리가 상당했다. 그중 장난감 활/총으로 과녁을 맞추는 부스와, 윷놀이, 비석치기 부스를 우리 서포터즈들이 운영하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 재미있었던 활동은 보트를 타고 파로호를 누비는 활동이었는데, 워터 슬라이드에서 느끼는 스릴 / 물을 가로지르는 재미를 느낄 수 있었다.&lt;br /&gt;특히 파로호는 수많은 중공군을 물리치는등 6.25 전쟁 당시의 역사가 깊게 연관된 장소였는데, 집에 와서 아버지께 여쭤보니 아버지도 파로호를 알고 계셨다... 낚시를 좋아하셔서 아시는 건가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 축제 기간동안 양구군을 들리게 된다면 한번쯤 가서 국밥도 먹고 경품 추첨도 즐겨보는건 어떨까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 내가 파로호에서 보트를 탄 영상이다. 원래 커브를 돌때가 진짜 스릴있는데 그건 찍지 못했다.&lt;br /&gt;&lt;br /&gt;만약 가게 된다면 운전해주시는 분께 쎄게 달려달라고 해보자. 그럼 한참동안 신나게 커브를 돌 수 있을 것이다 ㅋㅋ 물론 물이 좀 튀는건 감안해야 한다.&lt;/p&gt;

            &lt;figure class=&quot;unsupported component-kakaotv&quot; contenteditable=&quot;false&quot; style=&quot;background:#000;margin:16px 0;min-height:72px;padding:10px 16px;display:flex;align-items:center;justify-content:center;text-align:center;box-sizing:border-box;width:100%;max-width:100%;&quot;&gt;
                &lt;p contenteditable=&quot;false&quot; style=&quot;margin:0;color:#8a8a8a;font-size:13px;line-height:1.6;user-select:none;pointer-events:none;&quot;&gt;동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.&lt;/p&gt;
            &lt;/figure&gt;
        
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;박수근 미술관&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고등학생때 음악과 미술 과목중 미술 과목을 선택하여 들은 나인 만큼. 박수근님의 작품은 어렴풋이 기억하고 있었다. 투박한 그림체로 유명한 것을 알고 있었는데, 실제로 내가 보게될 줄은 몰랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;박수근 미술관은 몇개의 건물로 이루어져있었고, 각 건물마다 이동하는 시간이 있기 때문에 2시간 이상으로 넉넉하게 잡는다면 좋을것 같다. 5개의 건물중 어린이 미술관을 제외하면 4개이고, 이중 2개는 특별한 기획전시를 하는 곳으로 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작품을 봤을때 &lt;code&gt;캔버스가 아니라 돌에다 그린거 아닌가?&lt;/code&gt; 하는 생각이 들 정도로 질감이 눈에 띄었는데, 나중에 검색해보니 박수근님은 캔버스에 물감을 바르고 굳히는 것을 반복하여 투박한 질감을 나타내었다고 한다.&lt;br /&gt;아래는 작품 사진중 하나이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;2.jpg&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NbZww/btsQVFLP3Ny/tMASHlgfHr4aK39JWi6hY0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NbZww/btsQVFLP3Ny/tMASHlgfHr4aK39JWi6hY0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NbZww/btsQVFLP3Ny/tMASHlgfHr4aK39JWi6hY0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNbZww%2FbtsQVFLP3Ny%2FtMASHlgfHr4aK39JWi6hY0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;533&quot; data-filename=&quot;2.jpg&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작품 뿐만 아니라, 건물도 눈에 띄었다.&lt;br /&gt;건축에 문외한인 내 눈에도 건물 밖의 폭포나 돌담같은 조형물과 건물이 어우러지며, 건물과 건물을 이동할때 이리저리 코너를 돌며 끊임없이 시선을 환기시켜주는게 느껴졌다. 검색결과 2002년에 한국 건축가 협회에서 특선을 수여받은 건물이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 사진은 &lt;code&gt;박수근 기념 전시관&lt;/code&gt; 건물 내부의 통로 사진이다. 이 건물에 매표소가 있고, 바로 옆에 주차장이 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;3.jpg&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/X0isR/btsQVCaxMLP/F6d0K3aKu4AOPYv6KRvHKk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/X0isR/btsQVCaxMLP/F6d0K3aKu4AOPYv6KRvHKk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/X0isR/btsQVCaxMLP/F6d0K3aKu4AOPYv6KRvHKk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FX0isR%2FbtsQVCaxMLP%2FF6d0K3aKu4AOPYv6KRvHKk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;300&quot; data-filename=&quot;3.jpg&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;아래 사진은 위 사진에 보이는 통로의 오른쪽 창이다. 아래 사진에서 물길과 건물로 들어온 입구를 볼 수 있고, 이 물길은 통로 아래를 지나서, 건물 바깥쪽으로 이어진다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;4.jpg&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cYV94I/btsQVVgAgx6/cKkmIq3npkWoeW1UvQ4X81/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cYV94I/btsQVVgAgx6/cKkmIq3npkWoeW1UvQ4X81/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cYV94I/btsQVVgAgx6/cKkmIq3npkWoeW1UvQ4X81/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcYV94I%2FbtsQVVgAgx6%2FcKkmIq3npkWoeW1UvQ4X81%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;300&quot; data-filename=&quot;4.jpg&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;양구 여행지 추천&lt;/h1&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;파로호 꽃섬&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이튿날 코스였지만 아쉽게도 비가 오는 바람에 들어가지 못한, 비운의 섬이다.&lt;br /&gt;섬이라고는 하지만, 다리가 놓여져 충분히 건널 수 있게 되어있어서 다음에 양구를 간다면 이곳을 방문하는게 어떨까 한다.&lt;br /&gt;아래는 구글에서 주워온 사진인데, 꽃이 참 많다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;7.jpg&quot; data-origin-width=&quot;270&quot; data-origin-height=&quot;312&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ctmlcv/btsQYbCi7Qn/CpPKv2QIm0Mh7uro4Lemx1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ctmlcv/btsQYbCi7Qn/CpPKv2QIm0Mh7uro4Lemx1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ctmlcv/btsQYbCi7Qn/CpPKv2QIm0Mh7uro4Lemx1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fctmlcv%2FbtsQYbCi7Qn%2FCpPKv2QIm0Mh7uro4Lemx1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;270&quot; height=&quot;312&quot; data-filename=&quot;7.jpg&quot; data-origin-width=&quot;270&quot; data-origin-height=&quot;312&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;국토정중앙천문대&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫날의 마지막 코스였던 천문대이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설명해 주신 분이 누구셨는지에 대해서는 잘 기억이 안나지만, 참 설명도 맛깔나고 재미있게 잘 해주셨다.&lt;br /&gt;국내에서 일반인이 볼수 있는 망원경중 세번째로 크다고 하셨다.&lt;br /&gt;겨울에는 별이 아주 잘 보인다고 하며, 대신 추운것은 각오해야 한다고 하셨다 ㅋㅋ&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;별멍?&lt;br /&gt;매월 하루씩 &lt;code&gt;별멍&lt;/code&gt;(옥상에 누워서 별을 관찰) 프로그램을 운영한다고 하셨다.&lt;br /&gt;겨울에 중무장하고 &lt;code&gt;별멍&lt;/code&gt;한다면 상당히 좋은 추억이 될 듯 하다. 천문대 옆에 캠핑장도 있으니 캠핑장을 알아보고 이용하는 것도 괜찮을 것 같다.&lt;br /&gt;&lt;a href=&quot;https://www.ygtour.kr/Home/H50000/H50100/H50101/boardList?cate_id=9&amp;amp;search_type=1&amp;amp;search_keyword=&quot;&gt;양구 천문대 공지사항&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://yanggu.ticketplay.zone/portal/index&quot;&gt;양구 천문대 캠핑장&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://www.yna.co.kr/view/AKR20250309017700062&quot;&gt;양구 천문대 &lt;code&gt;별멍&lt;/code&gt; 관련 기사&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;후기&lt;/h1&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;감상&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;혼자서 노는걸 잘하고, 좋아하는 편인데, 정말 오랜만에 단체로 여행을 가니 정말 재미있었다.&lt;br /&gt;가족과 놀러간 것을 제외하면, 고등학생 이후 처음으로 &lt;code&gt;1박 이상 단체 여행&lt;/code&gt;을 한거 같은데, 아무래도 성인들끼리 여행을 간 것이기 때문에 훨씬 자유로울 수밖에 없었던 것 같다.&lt;br /&gt;모르는 사람끼리 패키지 여행을 간다면 이런 비슷한 느낌이 날것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 너무 자유로웠어도 각자 따로 노는 느낌이었을거 같은데, 첫날 축제 운영을 돕는 미션을 수행하며 내적 친밀감이 조금 생겼던 것 같고, 그때 이후로 좀 더 편해진것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 저런 활동을 했지만, 결국은 사람이 좋았기에 좋은 기억으로 남은것 같다. 서포터즈 운영진 분들이 티는 내지 않으셨지만 진행하면서 스트레스 많으셨을텐데 참 친절하셔서 좋았고, 참가자 분들은 성격도, 인상도 좋으셨다. 양구군 주민 분들도 흥이 많고 친절하셨다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;할인 혜택에 대해&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;박수근 미술관과 국토정중앙천문대는 사이버 군민증이 있으면 양구군민에 준하는 할인 혜택을 받을수 있었다.&lt;br /&gt;물론 사이버 군민증이나 신분증등을 보여 드려야 받을 수 있는 혜택이므로 잘 찾아보고 준비해가야 할것 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;박수근 미술관과 국토정중앙천문대는 나와 내 친구같은 파주시민(접경지역시장군수협의회주민)도 군민에 준하는 할인 혜택을 받을 수 있었다.&lt;br /&gt;&lt;a href=&quot;https://yanggudmo.co.kr/dmo_benefit&quot;&gt;사이버 군민증 제휴 관광지 목록&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://yanggudmo.co.kr/dmo&quot;&gt;사이버 군민증 발급 사이트 (금방 한다)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;양구여행꿀페스타에 대해&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;양구에서 최대 10만원까지 양구사랑상품권으로 환급해주는 이벤트가 있다고 한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;8.png&quot; data-origin-width=&quot;966&quot; data-origin-height=&quot;1426&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/brt78E/btsQWe79yzu/cRz0zm8QC5jIY0U139sHN1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/brt78E/btsQWe79yzu/cRz0zm8QC5jIY0U139sHN1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/brt78E/btsQWe79yzu/cRz0zm8QC5jIY0U139sHN1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbrt78E%2FbtsQWe79yzu%2FcRz0zm8QC5jIY0U139sHN1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;590&quot; data-filename=&quot;8.png&quot; data-origin-width=&quot;966&quot; data-origin-height=&quot;1426&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;참여 자격
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;양구 외 지역 거주자&lt;/li&gt;
&lt;li&gt;사이버 군민증 소지자&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;지원 규모
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;10-19만원 사용시 -&amp;gt; 5만원 양구사랑상품권지급&lt;/li&gt;
&lt;li&gt;20만원 사용시 -&amp;gt; 10만원 양구사랑상품권지급&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;신청 방법
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;대표자 1인이 &lt;a href=&quot;https://docs.google.com/forms/d/e/1FAIpQLSdzhExp63A9pbEFAGJseXbRCTOdb1B0Dwfnj68J4jI31mMY_g/viewform&quot;&gt;구글폼&lt;/a&gt; 작성(만19세 이상만 가능)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.google.com/forms/d/e/1FAIpQLSdzhExp63A9pbEFAGJseXbRCTOdb1B0Dwfnj68J4jI31mMY_g/viewform&quot;&gt;https://docs.google.com/forms/d/e/1FAIpQLSdzhExp63A9pbEFAGJseXbRCTOdb1B0Dwfnj68J4jI31mMY_g/viewform&lt;/a&gt;&lt;br /&gt;이왕 간다면, 혜택 받을 수 있는거 최대한 챙겨서 가면 좋겠다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>기타</category>
      <author>qkr10</author>
      <guid isPermaLink="true">https://qkr10.tistory.com/3</guid>
      <comments>https://qkr10.tistory.com/3#entry3comment</comments>
      <pubDate>Tue, 30 Sep 2025 22:04:16 +0900</pubDate>
    </item>
    <item>
      <title>[개발기록/CEC 프로젝트] 2. HTTPS 인증서 발급 및 갱신 자동화 스크립트 짜기</title>
      <link>https://qkr10.tistory.com/2</link>
      <description>&lt;h1&gt;서론&lt;/h1&gt;
&lt;p&gt;팀원들의 프론트 개발을 돕기 위해, 백엔드 API 서버를 구축하여 원격으로 요청할수 있게 했다.&lt;/p&gt;
&lt;p&gt;이때 내부적인 사정이 있어서, 추후 더 좋은 성능의 EC2 인스턴스로 마이그레이션하기로 예정되어 있었고,&lt;br&gt;나중에 편하게 마이그레이션하기 위해, 인증서 발급 과정을 쉘 스크립트로 자동화하게 되었다.&lt;/p&gt;
&lt;p&gt;쉘 스크립트 파일에 작성한 로직은 아래와 같고, 실제 &lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/main/docker/nginx-ssl/start-certbot.sh&quot;&gt;코드는 여기&lt;/a&gt;서 볼수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;---
config:
      theme: redux
---
flowchart TD
    subgraph 인증서_생성
        E[&amp;quot;이메일 입력 받기 (read -r EMAIL)&amp;quot;]
        E --&amp;gt; F[&amp;quot;임시 nginx 실행&amp;quot;]
        F --&amp;gt; G[&amp;quot;(도메인 소유권을 증명) 인증서 생성&amp;quot;]
        G --&amp;gt; H[&amp;quot;임시 nginx 중단&amp;quot;]
    end

    A[&amp;quot;스크립트 시작&amp;quot;] --&amp;gt; C{&amp;quot;인증서 존재 여부&amp;quot;}

    C -- &amp;quot;아니오&amp;quot; --&amp;gt; 인증서_생성
    인증서_생성 --&amp;gt; I[&amp;quot;nginx 시작&amp;quot;]
    I --&amp;gt; N{&amp;quot;크론탭에 이미 등록되었나?&amp;quot;}

    C -- &amp;quot;네&amp;quot; --&amp;gt; J[&amp;quot;인증서 갱신&amp;quot;]
    J --&amp;gt; K[&amp;quot;nginx 리로드&amp;quot;]
    K --&amp;gt; N

    N -- &amp;quot;아니오&amp;quot; --&amp;gt; O[&amp;quot;현재 스크립트를 매일 실행시킨다&amp;quot;]
    N -- &amp;quot;예&amp;quot; --&amp;gt; Q[&amp;quot;스크립트 종료&amp;quot;]
    O --&amp;gt; Q&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;도메인 소유권 증명&lt;/code&gt; 에 대해 전혀 몰랐기 때문에, 저 코드를 짜는게 예상만큼 쉽지 않았다.&lt;br&gt;&lt;code&gt;도메인 소유권 증명&lt;/code&gt; 이 뭔지, &lt;code&gt;ACME 프로토콜&lt;/code&gt; 이 뭔지에 대해서 이어서 설명하겠다.&lt;/p&gt;
&lt;hr&gt;
&lt;h1&gt;ACME 프로토콜이란?&lt;/h1&gt;
&lt;p&gt;acme 프로토콜은 certbot, acme.sh 등의 프로그램이 CA 와 소통하여 인증서를 발급받는 http 상에서 동작하는 프로토콜이다. &lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc8555&quot;&gt;RFC 8555&lt;/a&gt; 를 보면 아주 상세히 설명되어 있다.&lt;br&gt;이 acme 프로토콜의 가장 핵심적인 부분을 꼽으라면 도메인 소유권을 증명하는 부분이라고 생각한다.&lt;/p&gt;
&lt;p&gt;acme 프로토콜은 도메인 소유권을 증명하는 두가지 방법을 지원한다. (http-01, dns-01)&lt;br&gt;(아래 내용에서 클라이언트는 acme 프로토콜의 issuer인, 인증서를 발급받는 쪽을 의미한다)&lt;/p&gt;
&lt;p&gt;http-01 방법을 간단히 설명하면 아래와 같다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;CA 에서 토큰을 클라이언트에 준다.&lt;/li&gt;
&lt;li&gt;클라이언트가 example.com 도메인의 약속된 url 에 웹 서버를 통해 토큰을 노출시킨다.&lt;ol&gt;
&lt;li&gt;이 과정은 nginx 설정을 잘 해준다면 nginx 플러그인 없이도 자동으로 가능하다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;CA 는 자기가 클라이언트에 준 토큰을 example.com 의 약속된 url 에서 확인한다.&lt;/li&gt;
&lt;li&gt;CA 는 클라이언트가 example.com 도메인을 소유하고 있다는 것을 확인하였다!&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;dns-01 방법을 간단히 설명하면 아래와 같다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;CA 에서 토큰을 클라이언트에 준다.&lt;/li&gt;
&lt;li&gt;클라이언트 측은 example.com 도메인의 DNS에 TXT 레코드를 추가하고 토큰을 넣는다.&lt;ol&gt;
&lt;li&gt;이 과정을 자동으로 진행하려면 &lt;a href=&quot;https://help.zerossl.com/hc/en-us/articles/4409936415001-Overview-of-the-changes-to-Wildcard-and-Multi-Domain-certificates&quot;&gt;certbot 의 플러그인&lt;/a&gt;을 쓰거나 &lt;a href=&quot;https://help.zerossl.com/hc/en-us/articles/4409936415001-Overview-of-the-changes-to-Wildcard-and-Multi-Domain-certificates&quot;&gt;acme.sh&lt;/a&gt; 를 쓰자.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;CA 는 자기가 클라이언트에 준 토큰을 example.com 의 TXT 레코드를 확인한다.&lt;ol&gt;
&lt;li&gt;윈도우 cmd 기준 nslookup -type=txt example.com 명령어를 통해 조회할수 있다고 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;CA 는 클라이언트가 example.com 도메인을 소유하고 있다는 것을 확인하였다!&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;내가 위에서 짠 쉘 스크립트는 http-01 방법 (HTTP Challenge) 를 사용한다.&lt;br&gt;http-01 방법을 사용할때 주의할 점은, http-01 방법으로는 certbot 기준으로 와일드카드 인증서를 발급받을 수 없다. 와일드카드 인증서란, 특정 도메인의 하위 도메인을 전부 포함하는 인증서를 말한다. RFC 8555 에는 http-01 로 와일드카드 인증서를 발급하지 못한다고 쓰여있진 않지만, 와일드카드 인증서는 dns-01 로 발급해 주는것이 업계 표준으로 보인다.&lt;br&gt;(&lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc8555/#section-9.7.8&quot;&gt;RFC 8555 9.7.8&lt;/a&gt; : validation method 가 http-01 이든 dns-01 이든 identifier type 은 dns 이다.)&lt;br&gt;(&lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc8555/#section-7.1.3&quot;&gt;RFC 8555 7.1.3.&lt;/a&gt; : identifier type 가 dns 면 와일드카드가 가능하다고 나와있다.)&lt;br&gt;(&lt;a href=&quot;https://help.zerossl.com/hc/en-us/articles/4409936415001-Overview-of-the-changes-to-Wildcard-and-Multi-Domain-certificates&quot;&gt;하지만 ZeroSSL 은 http 방식으로 와일드카드를 지원하지 않는다고 한다.&lt;/a&gt;)&lt;br&gt;(&lt;a href=&quot;https://help.zerossl.com/hc/en-us/articles/4409936415001-Overview-of-the-changes-to-Wildcard-and-Multi-Domain-certificates&quot;&gt;Let&amp;#39;s Encrypt 도 http 방식으로 와일드카드를 지원하지 않는다.&lt;/a&gt;)&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;아무튼, 자동화 쉘 스크립트를 짜는 과정에서 위와 같은 내용을 조사하였다.&lt;br&gt;사실 1회-2회 정도만 사용할 스크립트이기 때문에, 서론에서 말한 쉘 스크립트를 꼭 짜야할 필요는 없었다.&lt;br&gt;스크립트에 투자한 시간과 노력 대비 가치는 떨어졌지만, 애초에 공부와 실습 목적이 강했던 프로젝트 였고, 이 과정에서 알게된 지식들이 언젠가는 도움이 될거라고 생각한다.&lt;/p&gt;
&lt;p&gt;아마 다음 개발 일지 글은 rate limit 기능 구현과정을 쓰게 될거 같은데, 가독성을 중요시 생각하며 작성하겠다.&lt;/p&gt;</description>
      <category>개발기록/CEC 프로젝트</category>
      <category>acme</category>
      <category>Certbot</category>
      <category>DNS</category>
      <category>HTTP</category>
      <author>qkr10</author>
      <guid isPermaLink="true">https://qkr10.tistory.com/2</guid>
      <comments>https://qkr10.tistory.com/2#entry2comment</comments>
      <pubDate>Tue, 30 Sep 2025 17:38:26 +0900</pubDate>
    </item>
    <item>
      <title>[개발기록/CEC 프로젝트] 1. OpenAPI 양식으로 명세서 작성하기</title>
      <link>https://qkr10.tistory.com/1</link>
      <description>&lt;h1&gt;1. 개요&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1-1. 문제 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트가 화면을 API 와 연동하면서, &lt;b&gt;API 간의 일관성의 부족으로&lt;/b&gt; 프론트가 수정 요청하는 상황이 있었다.&lt;br /&gt;&lt;b&gt;관리자 웹&lt;/b&gt; API는 어떻게든 수정해서 프론트와 연동을 완료했지만, &lt;b&gt;사용자 웹&lt;/b&gt; API는 &lt;b&gt;관리자 웹&lt;/b&gt;보다도 훨씬 더 일관성이 떨어졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 사례는 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;페이지네이션 공통 응답 DTO 를 사용해야 하는데, JPA Pageable 을 그대로 응답하는 경우가 있었다.&lt;/li&gt;
&lt;li&gt;모든 API 응답은, status, message, data 필드가 있어야 하는데, 그 규칙을 따르지 않은 경우가 있었다.&lt;/li&gt;
&lt;li&gt;필드명이 일관성 없는 경우는 너무나 많았다.&lt;/li&gt;
&lt;li&gt;필요한 API 가 없는 경우도 있었다. (게시글 좋아요 취소 / 조회)&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당시 사용자 웹 API는 &lt;a href=&quot;https://editor.swagger.io/&quot;&gt;Swagger Editor&lt;/a&gt;에서&lt;br /&gt;File &amp;gt; Import URL을 통해 &lt;a href=&quot;https://raw.githubusercontent.com/CEC-project/CEC-Back/refs/heads/main/docs/user-api-docs-before.yaml&quot;&gt;이 주소&lt;/a&gt;를 붙여넣으면 확인할 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이대로라면 프론트는 물론 백엔드도 수정을 반복하며 고통받을것이 분명했다.&lt;br /&gt;기한을 절대 맞출 수 없을 것이라는 확신이 들었고, 문제를 파악하고 해결해야겠다고 결심했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1-2. 문제 분석&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;팀원중 한명이 노션에 작성한 API 명세서가 유명무실한 것이 가장 큰 원인이다. 왜 그랬을까?
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기획이 변경될때, API 명세서가 최신화 되지 않았다.&lt;/li&gt;
&lt;li&gt;노션 무료 워크스페이스 용량이 초과되어 더이상 작성할 수 없었다.&lt;/li&gt;
&lt;li&gt;노션 명세서는 가독성이 떨어지는 문제가 있다.&lt;/li&gt;
&lt;li&gt;팀원이 AI를 이용해 작성한 API 명세서라서, 빠진 필드나 기획에 맞지 않는 부분도 있었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;명세서가 문제면 다시 쓰면 되는데, 작성하는데 &lt;b&gt;비용(=시간과 노력)&lt;/b&gt; 이 부담스러웠다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;문제를 해결하자고 결심한 떄를 기준으로, 13일후에 프로젝트를 마치자고 팀원들과 이야기가 되어있었다. 부족한점을 보완할 것까지 생각하면, 시간이 빠듯하다는 뜻이다.&lt;/li&gt;
&lt;li&gt;API 엔드포인트 목록만 작성한다면 비용이 줄어 들겠지만, 심각한 문제들은 응답 필드들에서 발견되었으므로, API 응답에 대한 명세가 필요했다.&lt;/li&gt;
&lt;li&gt;전문 기획자들은 보통 엑셀로 작성하지만, 모든 API 응답이 status, message, data 필드를 공통으로 가지고, 특히 조회 응답시에 DTO가 공통으로 쓰이는 상황이다. 이때 API 별로 각각의 필드명과 그 타입을 일일이 엑셀로 쓰자니 비용이 너무 많이 들 것이었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1-3. 문제 해결&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 문단에서 설명할 &lt;b&gt;OpenAPI 명세서&lt;/b&gt;를 도입해서 위 문제를 해결하였다.&lt;/p&gt;
&lt;h1&gt;2. OpenAPI 란?&lt;/h1&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;200&quot; data-origin-height=&quot;200&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/x0dpq/btsPftMXIuS/dtKzKZSa8lfQKymlwB6o91/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/x0dpq/btsPftMXIuS/dtKzKZSa8lfQKymlwB6o91/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/x0dpq/btsPftMXIuS/dtKzKZSa8lfQKymlwB6o91/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fx0dpq%2FbtsPftMXIuS%2FdtKzKZSa8lfQKymlwB6o91%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;200&quot; height=&quot;200&quot; data-origin-width=&quot;200&quot; data-origin-height=&quot;200&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenAPI 는 API 명세서 양식중 하나로, yaml 또는 json 형식으로 작성된다.&lt;br /&gt;아래는 간단한 예시이며, &lt;a href=&quot;https://editor.swagger.io/&quot;&gt;스웨거 에디터&lt;/a&gt;에 붙여넣으면 보기 좋게 만들어준다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;openapi: 3.0.3
info:
  title: Title
  version: 1.0.0
paths:
  /api/v1/posts/{id}:
    get:
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: 'OK'
          content:
            'application/json':
              schema:
                $ref: '#/components/schemas/PostResponse'
components:
  schemas:
    PostResponse:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        content:
          type: string&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명세서가 yaml 이나 json 파일같은 텍스트 파일이므로, 다음과 같은 장점이 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2-1. 버전관리가 쉽다.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그냥 깃에 명세서 파일을 올리면, 누가 어떻게 수정했는지 관리할수 있다. &lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/main/docs/user-api-docs-after.yaml&quot;&gt;예시&lt;/a&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2-2. 명세서를 파싱해서 다루는 것이 쉽다.&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;명세서를 파싱해서 보기 좋게 만들어 줄수있다. &lt;a href=&quot;https://swagger.io/tools/swagger-ui/&quot;&gt;Swagger UI&lt;/a&gt; &lt;a href=&quot;https://github.com/swagger-api/swagger-ui&quot;&gt;깃허브&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;명세서를 파싱해서 온갖 언어로 백엔드/프론트 코드를 자동 생성할수도 있다. &lt;a href=&quot;https://github.com/OpenAPITools/openapi-generator&quot;&gt;openapi-generator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;본인이 담당하는 부분의 명세서만 볼수있게 분할시킬 수도 있다. &lt;a href=&quot;https://github.com/qkr10/openapi-splitter&quot;&gt;내가 짠 코드&lt;/a&gt;. &lt;a href=&quot;https://github.com/CEC-project/CEC-Back/tree/main/docs/after&quot;&gt;결과 예시&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2-3. 백엔드 코드로부터 역으로 명세서를 생성할수도 있다.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/springdoc/springdoc-openapi&quot;&gt;springdoc-openapi&lt;/a&gt; 라이브러리를 사용하면,&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;OpenAPI 양식의 명세서 자동 생성&lt;/li&gt;
&lt;li&gt;Swagger UI 로 명세서를 보기좋게 만들어줌 &lt;a href=&quot;https://dev.api.bmvcec.store/swagger-ui/index.html&quot;&gt;예시&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;을 자동으로 해준다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 &lt;code&gt;2-2-1&lt;/code&gt;, &lt;code&gt;2-2-2&lt;/code&gt;, &lt;code&gt;2-3&lt;/code&gt; 기능은 Intellij에서도 &lt;a href=&quot;https://www.jetbrains.com/help/idea/openapi.html&quot;&gt;지원&lt;/a&gt;된다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;3. Swagger UI 란?&lt;/h1&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;300&quot; data-origin-height=&quot;86&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/y9OSS/btsPhzEw9Vt/XXCjZ2rMZ8O5IBrI2qu60k/tfile.svg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/y9OSS/btsPhzEw9Vt/XXCjZ2rMZ8O5IBrI2qu60k/tfile.svg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/y9OSS/btsPhzEw9Vt/XXCjZ2rMZ8O5IBrI2qu60k/tfile.svg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fy9OSS%2FbtsPhzEw9Vt%2FXXCjZ2rMZ8O5IBrI2qu60k%2Ftfile.svg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;300&quot; height=&quot;86&quot; data-origin-width=&quot;300&quot; data-origin-height=&quot;86&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Swagger UI 는 OpenAPI 양식의 명세서를 보기 좋게 시각화해주는 JavaScript 라이브러리이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다양한 옵션을 전달하여 사용자가 필요에 맞게 설정할 수도 있다. (&lt;a href=&quot;https://swagger.io/docs/open-source-tools/swagger-ui/usage/installation/&quot;&gt;공식 문서&lt;/a&gt;)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;API를 &lt;b&gt;태그순 / 경로순 / 메서드순&lt;/b&gt;으로 정렬하는 옵션&lt;/li&gt;
&lt;li&gt;각 섹션을 &lt;b&gt;접기/펼치기&lt;/b&gt; 설정하는 옵션 등&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 위와같은 Swagger UI의 설정들을 사용자가 &lt;b&gt;GUI로 직접 선택&lt;/b&gt;할 수 있도록 만들고,&lt;br /&gt;해당 설정을 &lt;b&gt;쿠키에 저장하여 자동으로 불러오는 편의 기능&lt;/b&gt;을 구현해 보았다.&lt;br /&gt;&lt;a href=&quot;https://github.com/CEC-project/CEC-Back/tree/565808907505abe4fd0997315cdc05bcc9d1e422/src/main/resources/META-INF/resources/webjars/swagger-ui&quot;&gt;직접 작성한 JS 코드&lt;/a&gt;.&lt;br /&gt;&lt;a href=&quot;https://dev.api.bmvcec.store/swagger-ui/index.html&quot;&gt;예시&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;4. 결론&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4-1. OpenAPI 명세서를 도입해 해결한 문제들&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;노션 워크스페이스 용량 초과 문제
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;git과 github를 사용하여 OpenAPI 명세서를 저장하여 해결&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;노션 명세서 가독성 문제
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Swagger UI 나 인텔리제이 내장기능으로 보기좋게 만들어서 해결&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;API 일관성 부족 문제
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;내가 명세서를 작성하고, 코드리뷰 시 명세서 기준으로 확인하여 해결&lt;br /&gt;내가 작성한 명세서와 자동 생성된 명세서가 동일한 양식이므로, 검사하는 비용이 매우 줄어듬&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;명세서를 다시 작성하는 비용 문제
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기존 백엔드 코드로 자동 생성된 명세서를, 필요한 부분만 수정하는 방식으로 해결&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4-2. 아쉬웠던 점 &amp;amp; 한계&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;팀원들이 코드리뷰를 거쳐야지만 내가 명세서에 적은대로 수정해 주었다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;yaml 명세서 간에 &lt;a href=&quot;https://wepplication.github.io/tools/compareDoc/&quot;&gt;diff를 보여줄 수 있는 사이트&lt;/a&gt;를 소개하고, &lt;a href=&quot;https://www.jetbrains.com/help/idea/openapi.html&quot;&gt;intellij로 편하게 볼 수 있다&lt;/a&gt;고 &lt;a href=&quot;https://github.com/CEC-project/CEC-Back/blob/565808907505abe4fd0997315cdc05bcc9d1e422/docs/api-rule.md&quot;&gt;전파&lt;/a&gt;했다. 하지만 springdoc-openapi 로 자동 생성된 yaml 명세서를 보는법은 충분히 설명하지 못했다.&lt;/li&gt;
&lt;li&gt;다시 그떄로 돌아간다면, 디스코드에서 직접 시연했을 것 같다. 사실 보기 좋게 띄우는 방법만 안다면, 작업한 내용이 명세서와 일치하는지 금방 확인할수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;OpenAPI 명세서로 코드를 자동 생성할 수 있지만, 기존 아키텍처에 맞춰 적용하려면 노하우가 필요하다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;코드를 자동 생성해주는 프로그램들은, 요청/응답 타입 코드와, API 테스트해주는 코드 수준에 그친다.&lt;/li&gt;
&lt;li&gt;결과물을 정규식을 사용해 변형하거나, 아니면 생성기 자체의 코드를 수정하거나.. 이런 비용이 추가적으로 들것 같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;노션/엑셀 기반 명세서에 비해, 명세서를 직접 작성하기 위한 &lt;a href=&quot;https://swagger.io/specification/&quot;&gt;사전지식&lt;/a&gt;이 너무 많다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;물론 기존 명세서와 큰틀에서 같기 때문에, 조금만 알려주면 누구나 쓸수 있다고 생각한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;애초에 이런 일이 안생기는게 맞았다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;팀이 만들어지고 나중에 합류하긴 했지만, 이렇게 심각한 문제가 될것을 내가 미리 알았더라면, 훨씬 적은 비용으로 해결할 수 있었을 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>개발기록/CEC 프로젝트</category>
      <category>API 명세서</category>
      <category>OpenAPI</category>
      <category>swagger</category>
      <category>명세서</category>
      <category>스웨거</category>
      <author>qkr10</author>
      <guid isPermaLink="true">https://qkr10.tistory.com/1</guid>
      <comments>https://qkr10.tistory.com/1#entry1comment</comments>
      <pubDate>Sun, 13 Jul 2025 13:46:13 +0900</pubDate>
    </item>
  </channel>
</rss>