<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>joeun-01.log</title>
        <link>https://velog.io/</link>
        <description>왔다 정보리</description>
        <lastBuildDate>Sat, 18 Apr 2026 05:17:29 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>joeun-01.log</title>
            <url>https://velog.velcdn.com/images/joeun-01/profile/adb258cd-31a7-46cb-a713-9d49e96b4a99/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. joeun-01.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/joeun-01" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Spring Boot] Spring Security로 소셜 로그인 구현하기 (카카오, 구글)]]></title>
            <link>https://velog.io/@joeun-01/Spring-Boot-Spring-Security%EB%A1%9C-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EA%B5%AC%EA%B8%80</link>
            <guid>https://velog.io/@joeun-01/Spring-Boot-Spring-Security%EB%A1%9C-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EA%B5%AC%EA%B8%80</guid>
            <pubDate>Sat, 18 Apr 2026 05:17:29 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이번에 진행한 프로젝트가 웹 기반이다 보니, 전부터 한 번 써보고 싶었던 Spring Security를 통해 소셜 로그인을 구현할 수 있는 기회가 생겼다. Spring Security로 해보는 건 처음이었는데, 구현할 당시에는 개발 자체보다 프론트 측에 어떤 형식으로 데이터를 넘겨줄지에 대한 이야기를 더 많이 나눴던 것 같다. 사용자에 따라 소셜 로그인 후에 분기 처리를 하는 과정이 가장 고민이 많이 됐다.</p>
</blockquote>
<br>

<h2 id="oauth2-authorization-code-flow">OAuth2 Authorization Code Flow</h2>
<hr>
<p>Spring Security OAuth2 Client는 표준 Authorization Code Flow를 따른다. 전체 흐름은 다음과 같다.</p>
<ol>
<li>사용자가 <code>/oauth2/authorization/{provider}</code>로 접근하면 Spring이 해당 Provider의 인증 페이지로 리다이렉트한다</li>
<li>사용자가 동의하면 Provider는 <code>redirect-uri</code>로 <strong>인가 코드(authorization code)</strong>를 전달한다</li>
<li>Spring이 이 코드를 다시 Provider에게 보내 <strong>Access Token</strong>으로 교환한다</li>
<li>Access Token으로 Provider의 사용자 정보 API를 호출해 프로필 정보를 가져온다</li>
<li><code>OAuth2UserService</code>에서 이 정보를 기반으로 내부 회원 정보와 매핑한다</li>
<li>인증이 완료되면 <code>SuccessHandler</code>가 호출되어 후처리를 진행한다</li>
</ol>
<p>이 중 대부분은 Spring이 자동으로 처리해주고, 우리가 직접 커스터마이징해야 하는 부분은 5번 사용자 정보 매핑과 6번 인증 완료 후처리 정도다.</p>
<br>


<h2 id="기본-설정">기본 설정</h2>
<hr>
<h3 id="1-카카오-로그인-설정-kakao-developers">1. 카카오 로그인 설정 (Kakao Developers)</h3>
<p><a href="https://developers.kakao.com/">Kakao Developers</a>에서 애플리케이션을 생성한 뒤 다음 설정을 진행한다.</p>
<p><strong>1) 웹 도메인 등록</strong></p>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/d5de9e8a-5ea7-4526-8fb6-397d846f2764/image.png" alt="웹 도메인 등록"></p>
<p><code>앱 → 제품 링크 관리 → 웹 도메인</code>에서 서비스 도메인을 등록한다. 로컬, 개발 서버, 운영 서버 세 개의 도메인을 모두 등록해두면 환경별로 번거로운 설정 변경이 없다.</p>
<p><strong>2) 카카오 로그인 활성화</strong></p>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/c251b6c3-ab6e-4b39-b789-a9859696edce/image.png" alt="카카오 로그인 활성화"></p>
<p><code>제품 설정 → 카카오 로그인 → 일반</code>에서 카카오 로그인을 활성화한다.</p>
<p><strong>3) 동의 항목 설정</strong></p>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/595d9233-3346-49bf-8b4e-7026a44c53bc/image.png" alt="동의 항목 설정"></p>
<p><code>제품 설정 → 카카오 로그인 → 동의 항목</code>에서 받고 싶은 사용자 정보를 설정한다. 이때 이메일을 받으려면 <strong>비즈 앱 전환이 필요</strong>한데, 비즈니스 정보 심사를 완료하면 추가 기능 신청이 가능하다.</p>
<p><strong>4) Client ID / Secret 확인</strong></p>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/d37b149b-f162-468b-b6a0-89948ea172cc/image.png" alt="플랫폼 키"></p>
<p><code>앱 → 플랫폼 키</code>에서 확인한다.</p>
<ul>
<li><code>client-id</code> : REST API 키</li>
<li><code>client-secret</code> : REST API 키 세부 정보에 들어가면 Client Secret 코드를 확인할 수 있다
<img src="https://velog.velcdn.com/images/joeun-01/post/9a2dcda0-c8ba-4dbc-b379-7a6b4b37c666/image.png" alt=""></li>
</ul>
<p><strong>5) Redirect URI 등록</strong></p>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/8d381eff-926a-4ebb-83f0-bcbb598054db/image.png" alt="Redirect URI 등록"></p>
<p>마찬가지로 로컬, 개발, 운영 서버 세 개의 URI를 모두 등록한다. 형식은 <code>{도메인}/login/oauth2/code/kakao</code>로, Spring Security OAuth2 Client의 기본 경로를 따른다.</p>
<br>

<h3 id="2-구글-로그인-설정-google-cloud-console">2. 구글 로그인 설정 (Google Cloud Console)</h3>
<p><a href="https://console.cloud.google.com/">Google Cloud Console</a>에서 프로젝트를 생성한 뒤 다음 설정을 진행한다.</p>
<p><strong>1) OAuth 동의 화면 설정</strong></p>
<p><code>API 및 서비스 → OAuth 동의 화면</code>으로 이동해 앱 정보를 등록한다.</p>
<p><strong>2) OAuth 클라이언트 ID 생성</strong></p>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/654a9259-5116-42cd-ba84-4c85a5adc8b9/image.png" alt="OAuth 클라이언트 ID 생성"></p>
<p><code>클라이언트 → OAuth 클라이언트 ID 생성</code>에서 웹 애플리케이션 유형으로 클라이언트를 생성한다.</p>
<p><strong>3) 승인된 리디렉션 URI 등록</strong></p>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/b85c7b70-9cae-47c0-9699-0f58679bcb58/image.png" alt="승인된 리디렉션 URI 등록"></p>
<p>카카오와 마찬가지로 <code>{도메인}/login/oauth2/code/google</code> 형식으로 등록한다.</p>
<p><strong>4) Client ID / Secret 확인</strong></p>
<ul>
<li><code>client-id</code> : 클라이언트 ID
<img src="https://velog.velcdn.com/images/joeun-01/post/50833d36-95f0-4aeb-9e06-25685aaaabea/image.png" alt="클라이언트 ID"></li>
<li><code>client-secret</code> : 클라이언트 보안 비밀번호
<img src="https://velog.velcdn.com/images/joeun-01/post/570dabad-1d40-40d3-a50f-a4ab417223d5/image.png" alt="클라이언트 보안 비밀번호"></li>
</ul>
<br>

<h2 id="spring-boot-구현">Spring Boot 구현</h2>
<hr>
<h3 id="1-환경-변수-설정">1. 환경 변수 설정</h3>
<p>발급받은 값들은 외부에 노출되면 안 되므로 환경 변수로 관리한다.</p>
<pre><code class="language-bash"># 카카오
KAKAO_CLIENT_ID=카카오_REST_API_키
KAKAO_CLIENT_SECRET=카카오_Client_Secret_코드

# 구글
GOOGLE_CLIENT_ID=구글_클라이언트_ID
GOOGLE_CLIENT_SECRET=구글_클라이언트_보안_비밀번호</code></pre>
<br>

<h3 id="2-application-oauthyml-설정">2. application-oauth.yml 설정</h3>
<p>Spring Security OAuth2 Client의 설정 파일이다.</p>
<pre><code class="language-yaml">spring:
  config:
    activate:
      on-profile: local
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: ${KAKAO_CLIENT_ID}
            client-secret: ${KAKAO_CLIENT_SECRET}
            client-name: Kakao
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8090/login/oauth2/code/kakao
            client-authentication-method: client_secret_post
            scope:
              - account_email
              - profile_nickname
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            client-name: Google
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8090/login/oauth2/code/google
            client-authentication-method: client_secret_post
            scope:
              - email
              - profile
        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id
          google:
            user-name-attribute: sub

success-url: http://localhost:8090/auth/callback
failure-url: http://localhost:8090/auth/error</code></pre>
<p>각 설정의 역할은 다음과 같다.</p>
<ul>
<li><code>client-authentication-method: client_secret_post</code> : 토큰 요청 시 <code>client_secret</code>을 요청 바디에 포함하는 방식이다. 카카오는 이 방식만 지원하기 때문에 명시적으로 지정해야 한다.</li>
<li><code>provider.kakao.*</code> : 카카오는 Spring이 기본 제공하는 Provider가 아니기 때문에 <code>authorization-uri</code>, <code>token-uri</code>, <code>user-info-uri</code>를 직접 지정해야 한다. 반면 구글은 기본 Provider로 등록되어 있어서 <code>user-name-attribute</code>만 지정하면 된다.</li>
<li><code>user-name-attribute</code> : Provider가 반환하는 사용자 식별자의 JSON 키를 의미한다. 카카오는 <code>id</code>, 구글은 <code>sub</code>로 서로 다르다.</li>
<li><code>success-url</code> / <code>failure-url</code> : 인증 결과를 프론트엔드로 전달할 리다이렉트 URL이다.</li>
</ul>
<br>

<h3 id="3-securityconfig-설정">3. SecurityConfig 설정</h3>
<p>Spring Security의 OAuth2 로그인을 활성화하고 커스텀 핸들러를 등록한다.</p>
<pre><code class="language-java">@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final OAuth2UserService oAuth2UserService;
    private final JwtService jwtService;

    @Value(&quot;${success-url}&quot;)
    private String oauth2SuccessUrl;

    @Value(&quot;${failure-url}&quot;)
    private String oauth2FailureUrl;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.oauth2Login(oauth2 -&gt; oauth2
                    .userInfoEndpoint(userInfo -&gt; userInfo
                            .userService(oAuth2UserService))
                    .successHandler(successHandler())
                    .failureHandler(failureHandler())
                )
                .csrf(AbstractHttpConfigurer::disable)
                .cors(withDefaults());

        return http.build();
    }
}</code></pre>
<p><code>userInfoEndpoint</code>에 커스텀 <code>OAuth2UserService</code>를 등록하면, Provider로부터 사용자 정보를 받아온 뒤 내부 로직을 태울 수 있다. REST API 서버이므로 CSRF는 비활성화했다.</p>
<br>

<h3 id="4-oauth2userservice---회원-상태별-분기-처리">4. OAuth2UserService - 회원 상태별 분기 처리</h3>
<p><code>DefaultOAuth2UserService</code>를 상속받아 Provider로부터 받은 사용자 정보를 내부 회원 정보와 매핑한다. 이 부분은 프로젝트의 비즈니스 로직에 따라 커스터마이징해서 진행하면 되고, Provider로부터 사용자 정보를 받는 부분 정도만 참고하면 된다.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class OAuth2UserService extends DefaultOAuth2UserService {
    private final MemberQueryService memberQueryService;
    private final MemberService memberService;
    private final JwtService jwtService;

    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        // 1. Provider 구분 (kakao / google)
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        Constant.SocialType socialType = Constant.SocialType.getSocialType(registrationId);

        // 2. Provider별 사용자 정보 파싱
        Map&lt;String, Object&gt; attributes = oAuth2User.getAttributes();
        OAuth2AccountInfo oAuth2AccountInfo;
        if (socialType == KAKAO) oAuth2AccountInfo = OAuth2AccountInfo.fromKakao(attributes);
        else if (socialType == GOOGLE) oAuth2AccountInfo = OAuth2AccountInfo.fromGoogle(attributes);
        else throw new RuntimeException(&quot;LOGIN FAIL&quot;);

        // 3. 기존 회원 조회
        String id = oAuth2AccountInfo.getId();
        Member member = memberQueryService.findMemberBySocialIdAndSocialType(id, socialType, Status.ACTIVE);

        Map&lt;String, Object&gt; newAttributes = new HashMap&lt;&gt;(attributes);

        if (member == null) {
            // 신규 회원 → 임시 멤버 생성 (NOT_JOINED)
            OAuth2Account account = oAuth2AccountInfo.getOAuth2Account();
            member = memberService.createSocialMember(account.getEmail(), account.getName(), id, socialType);
            newAttributes.put(&quot;userCode&quot;, id);

        } else if (member.getJoinStatus() == Constant.JoinStatus.NOT_JOINED) {
            // 회원가입 미완료 → userCode만 전달
            newAttributes.put(&quot;userCode&quot;, id);

        } else {
            // 기존 회원 (JOINED) → JWT 발급
            String refreshToken = jwtService.createRefreshToken(member.getId());
            newAttributes.put(&quot;accessToken&quot;, jwtService.createAccessToken(member.getId()));
            newAttributes.put(&quot;refreshToken&quot;, refreshToken);
            memberService.saveRefreshToken(member, refreshToken);
        }

        newAttributes.put(&quot;memberStatus&quot;, member.getJoinStatus());

        BoardPrincipal principal = BoardPrincipal.from(member, newAttributes);
        String nameAttributeKey = registrationId.equals(&quot;google&quot;) ? &quot;sub&quot; : &quot;id&quot;;

        return new DefaultOAuth2User(principal.getAuthorities(), principal.getAttributes(), nameAttributeKey);
    }
}</code></pre>
<p>회원 상태를 세 가지로 나눠 분기했다.</p>
<ul>
<li>신규 회원 : 소셜 계정으로 처음 접근한 경우
기본 정보(이메일, 이름)만 가진 임시 회원을 <code>NOT_JOINED</code> 상태로 생성하고 <code>userCode</code>를 전달한다.</li>
<li>NOT_JOINED : 이전에 소셜 인증은 거쳤지만 회원가입 양식을 완료하지 않은 경우
추가 정보 입력이 필요하므로 JWT 없이 <code>userCode</code>만 전달한다.</li>
<li>JOINED : 가입이 완료된 기존 회원
Access Token과 Refresh Token을 발급하고 DB에 Refresh Token을 저장한다.</li>
</ul>
<p><code>SuccessHandler</code>에서 사용할 데이터를 <code>newAttributes</code>에 미리 담아두었다. Spring Security 내부적으로 <code>OAuth2User</code>의 <code>attributes</code>는 이후 단계에서도 계속 참조 가능하기 때문에, 이 방식으로 상태를 넘기면 별도의 세션 저장소 없이 깔끔하게 처리할 수 있다.</p>
<br>

<h3 id="5-provider별-데이터-파싱">5. Provider별 데이터 파싱</h3>
<p>카카오와 구글은 사용자 정보 응답의 JSON 구조가 다르다. Provider별 파싱 로직을 분리해서 이후 로직은 동일한 객체(<code>OAuth2AccountInfo</code>, <code>OAuth2Account</code>)로 다루도록 추상화했다.</p>
<p><strong>1) OAuth2AccountInfo : 소셜 고유 ID와 계정 정보를 담는다</strong></p>
<pre><code class="language-java">public class OAuth2AccountInfo {
    private String id;
    private OAuth2Account oAuth2Account;

    public static OAuth2AccountInfo fromKakao(Map&lt;String, Object&gt; attributes) {
        return OAuth2AccountInfo.builder()
                .id(String.valueOf(attributes.get(&quot;id&quot;)))
                .oAuth2Account(OAuth2Account.fromKakao(
                    (Map&lt;String, Object&gt;) attributes.get(&quot;kakao_account&quot;)))
                .build();
    }

    public static OAuth2AccountInfo fromGoogle(Map&lt;String, Object&gt; attributes) {
        return OAuth2AccountInfo.builder()
                .id(String.valueOf(attributes.get(&quot;sub&quot;)))
                .oAuth2Account(OAuth2Account.fromGoogle(attributes))
                .build();
    }
}</code></pre>
<p><strong>2) OAuth2Account : 이름, 이메일을 담는다</strong></p>
<pre><code class="language-java">public class OAuth2Account {
    private String name;
    private String email;

    public static OAuth2Account fromKakao(Map&lt;String, Object&gt; attributes) {
        return OAuth2Account.builder()
                .name(getName((Map&lt;String, Object&gt;) attributes.get(&quot;profile&quot;)))
                .email((String) attributes.get(&quot;email&quot;))
                .build();
    }

    public static OAuth2Account fromGoogle(Map&lt;String, Object&gt; attributes) {
        return OAuth2Account.builder()
                .name(String.valueOf(attributes.get(&quot;name&quot;)))
                .email(String.valueOf(attributes.get(&quot;email&quot;)))
                .build();
    }
}</code></pre>
<p>구글은 <code>name</code>, <code>email</code>, <code>sub</code>가 모두 최상위에 있지만, 카카오는 <code>kakao_account</code> 객체 안에 <code>email</code>이 있고 <code>kakao_account.profile</code> 안에 닉네임이 있다.</p>
<br>

<h3 id="6-boardprincipal---userdetails--oauth2user-통합">6. BoardPrincipal - UserDetails + OAuth2User 통합</h3>
<p>Spring Security는 로그인 방식에 따라 인증 주체(<code>Principal</code>)의 타입이 다르다. 일반 로그인은 <code>UserDetails</code>, 소셜 로그인은 <code>OAuth2User</code>를 사용한다. 두 인터페이스를 따로 구현하면 로그인 방식에 따라 Principal 타입을 분기해야 하는 번거로움이 생긴다.</p>
<pre><code class="language-java">public class BoardPrincipal implements UserDetails, OAuth2User {
    private String email;
    private String pw;
    private Collection&lt;? extends GrantedAuthority&gt; authorities;
    private String nickname;
    private String profileImgUrl;
    private Map&lt;String, Object&gt; oAuth2Attributes;

    // 일반 회원용
    public static BoardPrincipal from(Member member) { ... }

    // OAuth2 회원용 (attributes 포함)
    public static BoardPrincipal from(Member member, Map&lt;String, Object&gt; oAuth2Attributes) { ... }

    // Spring Security 필수 메서드
    @Override public String getUsername() { return email; }
    @Override public String getPassword() { return pw; }

    // OAuth2 필수 메서드
    @Override public Map&lt;String, Object&gt; getAttributes() { return oAuth2Attributes; }
    @Override public String getName() { return email; }
}</code></pre>
<p><code>BoardPrincipal</code> 하나에서 두 인터페이스를 모두 구현함으로써, 컨트롤러나 서비스 계층에서는 로그인 방식과 무관하게 동일한 타입으로 인증 주체를 다룰 수 있다.</p>
<br>

<h3 id="7-successhandler--failurehandler---인증-결과-처리">7. SuccessHandler / FailureHandler - 인증 결과 처리</h3>
<p>인증이 완료되면 <code>SuccessHandler</code>에서 회원 상태에 따라 프론트엔드로 다른 정보를 전달한다.</p>
<pre><code class="language-java">@Bean
public AuthenticationSuccessHandler successHandler() {
    return (request, response, authentication) -&gt; {
        DefaultOAuth2User user = (DefaultOAuth2User) authentication.getPrincipal();
        Map&lt;String, Object&gt; attributes = user.getAttributes();

        Constant.JoinStatus joinStatus = Constant.JoinStatus.valueOf(
            String.valueOf(attributes.get(&quot;memberStatus&quot;)));

        String targetUrl;
        if (joinStatus == Constant.JoinStatus.NOT_JOINED) {
            // 회원가입 미완료 → userCode 전달 (프론트에서 추가 정보 입력 폼으로 이동)
            String userCode = attributes.get(&quot;userCode&quot;).toString();
            targetUrl = UriComponentsBuilder.fromUriString(oauth2SuccessUrl)
                    .queryParam(&quot;userCode&quot;, userCode)
                    .build().toUriString();
        } else {
            // 기존 회원 → JWT 쿠키 설정 + 토큰 쿼리 파라미터 전달
            String accessToken = attributes.get(&quot;accessToken&quot;).toString();
            String refreshToken = attributes.get(&quot;refreshToken&quot;).toString();

            response.addHeader(HttpHeaders.SET_COOKIE,
                    jwtService.createAccessTokenCookie(accessToken).toString());
            response.addHeader(HttpHeaders.SET_COOKIE,
                    jwtService.createRefreshTokenCookie(refreshToken).toString());

            targetUrl = UriComponentsBuilder.fromUriString(oauth2SuccessUrl)
                    .queryParam(&quot;accessToken&quot;, accessToken)
                    .queryParam(&quot;refreshToken&quot;, refreshToken)
                    .build().toUriString();
        }

        response.sendRedirect(targetUrl);
    };
}</code></pre>
<ul>
<li><code>NOT_JOINED</code> : <code>{success-url}?userCode={socialId}</code> 형태로 리다이렉트한다. 프론트에서는 이 <code>userCode</code>를 가지고 닉네임 등 추가 정보 입력 폼으로 사용자를 유도하고, 회원가입을 완료한다.</li>
<li><code>JOINED</code> : <code>{success-url}?accessToken={token}&amp;refreshToken={token}</code> 형태로 리다이렉트하면서 쿠키에도 JWT를 동시에 설정한다. 쿼리 파라미터와 쿠키 둘 다 설정한 이유는 프론트엔드 구현 방식에 따라 선택해서 쓸 수 있도록 하기 위함이다.</li>
</ul>
<p>인증 실패 시에는 단순히 <code>failure-url</code>로 리다이렉트한다.</p>
<br>

<h2 id="마치며">마치며</h2>
<hr>
<p>Spring Security의 OAuth2 Client는 표준 플로우의 대부분을 자동으로 처리해주기 때문에, 우리가 집중해야 할 부분은 Provider에서 회원 정보를 받아와서 비즈니스 로직에 맞게 처리하는 정도다. </p>
<p>또한 틀이 정해져있기 때문에 소셜 로그인을 한 번 구현해두면 확장이 쉽다. 실제로 카카오를 먼저 구현하고 구글을 나중에 구현했는데, 설정 추가 및 구글에서 제공하는 회원 정보에 맞게 구조를 수정하는 작업 정도만 진행하면 됐다. 카카오를 구현했을 때보다 시간이 훨씬 단축된 걸 보고 Spring Security로 구현해두길 잘했다는 생각이 들었다. 나중에 다른 소셜 로그인 방식이 추가되어도 큰 어려움 없이 확장할 수 있겠다.</p>
<br>

<h3 id="참고자료">참고자료</h3>
<ul>
<li><a href="https://docs.spring.io/spring-security/reference/servlet/oauth2/login/index.html">Spring Security - OAuth 2.0 Login</a></li>
<li><a href="https://developers.kakao.com/docs/latest/ko/kakaologin/common">Kakao Developers - 카카오 로그인</a></li>
<li><a href="https://developers.google.com/identity/protocols/oauth2">Google Identity - OAuth 2.0</a></li>
</ul>
<br>]]></description>
        </item>
        <item>
            <title><![CDATA[[블로그] Slack Socket Mode로 슬랙에서 상호작용하기]]></title>
            <link>https://velog.io/@joeun-01/Temp-Title</link>
            <guid>https://velog.io/@joeun-01/Temp-Title</guid>
            <pubDate>Thu, 09 Apr 2026 15:58:43 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이전에 Slack Interactivity를 Request URL 방식으로 구성했는데, Slack Socket Mode를 사용하면 이 과정 없이 WebSocket으로 바로 연결할 수 있다는 걸 알게 되었다. 그러면서 기존에 Slack Interactivity로 작성했던 코드를 Slack Socket Mode로 모두 변경하는 작업을 진행했다.</p>
</blockquote>
<br>

<h2 id="slack-socket-mode란">Slack Socket Mode란?</h2>
<hr>
<h2 id="socket-mode">Socket Mode</h2>
<p>Socket Mode는 Slack이 HTTP Request URL 대신 <strong>WebSocket 연결</strong>을 통해 페이로드를 전달하는 방식이다.</p>
<p>기존 Request URL 방식은 Slack이 우리 서버로 HTTP 요청을 직접 보내는 구조이기 때문에, 서버가 외부에서 접근 가능한 공개 URL을 가지고 있어야 한다. 반면 Socket Mode는 서버가 Slack에 WebSocket 연결을 먼저 열고, Slack은 그 연결을 통해 이벤트를 전달하는 방식이라 공개 URL이 필요 없다.</p>
<p>Socket Mode는 내부 도구나 개발 환경에 적합하다. 지속적인 WebSocket 연결을 유지해야 하고, 연결이 끊기면 재연결 처리가 필요하기 때문에 불특정 다수에게 공개되는 앱에는 Request URL 방식이 더 안정적일 수 있다.</p>
<table>
<thead>
<tr>
<th></th>
<th>Request URL</th>
<th>Socket Mode</th>
</tr>
</thead>
<tbody><tr>
<td>연결 방식</td>
<td>Slack → 서버 (HTTP)</td>
<td>서버 → Slack (WebSocket)</td>
</tr>
<tr>
<td>공개 URL 필요 여부</td>
<td>필요</td>
<td>불필요</td>
</tr>
<tr>
<td>ngrok 필요 여부</td>
<td>필요</td>
<td>불필요</td>
</tr>
<tr>
<td>적합한 환경</td>
<td>프로덕션, 공개 앱</td>
<td>내부 도구, 로컬 개발</td>
</tr>
</tbody></table>
<br>

<h2 id="slack-socket-mode-구현하기">Slack Socket Mode 구현하기</h2>
<hr>
<h3 id="1-socket-mode-활성화">1. Socket Mode 활성화</h3>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/8f8210b7-f522-4a10-9a43-f9efed015c3f/image.png" alt="Socket Mode"></p>
<p>Slack API에서 대상 앱으로 이동한 뒤, <strong>Settings → Socket Mode</strong> 메뉴에서 <strong>Enable Socket Mode</strong> 토글을 활성화한다.
활성화하면 기존에 설정했던 Request URL 방식은 비활성화되고, 이후 모든 페이로드는 WebSocket 연결로 수신된다.
<br></p>
<h3 id="2-app-level-token-발급">2. App-Level Token 발급</h3>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/91ae521b-1df5-45aa-bd85-b5ecd6213d60/image.png" alt="Scope"></p>
<p>Socket Mode를 사용하려면 일반 Bot 토큰과는 별도로 <strong>App-Level Token</strong>이 필요하다.</p>
<p><strong>Settings → Basic Information → App-Level Tokens</strong>에서 토큰을 생성할 수 있다. 이때 <code>connections:write</code> scope를 반드시 추가해야 하며, 생성된 <code>xapp-</code>으로 시작하는 토큰을 복사해둔다.
<br></p>
<h3 id="3-환경-변수-설정">3. 환경 변수 설정</h3>
<pre><code class="language-yaml">slack:
  oauth-token: ${SLACK_BOT_TOKEN}           # Bot User OAuth Token (xoxb-)
  app-level-token: ${SLACK_APP_LEVEL_TOKEN} # App-Level Token (xapp-)
  socket-mode-enabled: true</code></pre>
<p>각 항목의 역할은 다음과 같다.</p>
<ul>
<li><code>oauth-token</code> : Slack API 호출에 사용하는 Bot 토큰</li>
<li><code>app-level-token</code> : Socket Mode WebSocket 연결에 사용하는 토큰으로, 위에서 복사해둔 <code>xapp-</code> 값을 사용한다</li>
<li><code>socket-mode-enabled</code> : <code>false</code>로 설정하면 Socket Mode 관련 빈이 등록되지 않아 손쉽게 on/off 전환이 가능하다</li>
</ul>
<p>토큰 값은 외부에 노출되면 안 되므로 반드시 환경 변수로 관리해야 한다.
<br></p>
<h3 id="4-slackproperties-설정">4. SlackProperties 설정</h3>
<pre><code class="language-java">@ConfigurationProperties(prefix = &quot;slack&quot;)
@Getter
@RequiredArgsConstructor
public class SlackProperties {
    private final String oauthToken;
    private final String appLevelToken;
    private final boolean socketModeEnabled;
}</code></pre>
<p><code>@ConfigurationProperties</code>를 사용해 yaml의 <code>slack.*</code> 값을 하나의 클래스로 바인딩한다. 
이후 빈으로 주입받아 사용하면 된다.
<br></p>
<h3 id="5-slackconfig-설정">5. SlackConfig 설정</h3>
<pre><code class="language-java">@Configuration
@EnableConfigurationProperties(SlackProperties.class)
@RequiredArgsConstructor
public class SlackConfig {
    private final SlackProperties slackProperties;

    @Bean
    public MethodsClient methodsClient() {
        Slack slack = Slack.getInstance();
        return slack.methods(slackProperties.getOauthToken());
    }

    @Bean
    @ConditionalOnProperty(name = &quot;slack.socket-mode-enabled&quot;, havingValue = &quot;true&quot;)
    public String slackSocketModeInfo() {
        log.info(&quot;Slack Socket Mode is ENABLED - using WebSocket connection&quot;);
        return &quot;socket-mode&quot;;
    }
}</code></pre>
<p><code>MethodsClient</code>를 빈으로 등록해 Slack API를 호출할 수 있도록 하고, <code>@ConditionalOnProperty</code>를 통해 <code>socket-mode-enabled: true</code>일 때만 Socket Mode 관련 빈이 활성화되도록 구성한다.
<br></p>
<h3 id="6-slacksocketmodeclient-설정">6. SlackSocketModeClient 설정</h3>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = &quot;slack.socket-mode-enabled&quot;, havingValue = &quot;true&quot;)
public class SlackSocketModeClient {

    private final SlackProperties slackProperties;
    private final SlackSocketModeService slackSocketModeService;
    private SocketModeApp socketModeApp;

    @PostConstruct
    public void initialize() {
        App app = new App(AppConfig.builder()
                .singleTeamBotToken(slackProperties.getOauthToken())
                .build());

        // 정규식 패턴으로 모든 블록 액션을 캐치
        app.blockAction(Pattern.compile(&quot;.*&quot;), (req, ctx) -&gt; {
            slackSocketModeService.handleBlockAction(req, ctx);
            return ctx.ack();
        });

        socketModeApp = new SocketModeApp(slackProperties.getAppLevelToken(), app);

        Thread socketModeThread = new Thread(() -&gt; socketModeApp.start());
        socketModeThread.setName(&quot;slack-socket-mode&quot;);
        socketModeThread.setDaemon(true);
        socketModeThread.start();
    }

    @PreDestroy
    public void shutdown() {
        if (socketModeApp != null) {
            socketModeApp.stop();
        }
    }
}</code></pre>
<p><code>@PostConstruct</code>로 애플리케이션 시작 시 WebSocket 연결을 자동으로 초기화하고, <code>@PreDestroy</code>로 종료 시 연결을 안전하게 닫는다. <code>blockAction(Pattern.compile(&quot;.*&quot;))</code>으로 모든 액션을 단일 핸들러에서 수신한 뒤 <code>actionId</code> 기준으로 분기 처리하는 구조다. 한 가지 주의할 점은 Socket Mode가 블로킹 방식으로 동작하기 때문에, 별도 데몬 스레드에서 실행하지 않으면 메인 스레드가 블로킹된다는 것이다.
<br></p>
<h3 id="7-slacksocketmodeservice-설정">7. SlackSocketModeService 설정</h3>
<pre><code class="language-java">@Slf4j
@Service
@RequiredArgsConstructor
public class SlackSocketModeService {

    public void handleBlockAction(BlockActionRequest request, ActionContext context) {
        String actionId = request.getPayload().getActions().get(0).getActionId();
        String userId = request.getPayload().getUser().getId();
        String value = request.getPayload().getActions().get(0).getValue();

        // TODO: actionId에 따른 비즈니스 로직 처리 (비동기 권장)
        switch (actionId) {
            default:
                log.info(&quot;Unknown action ID: {}&quot;, actionId);
        }
    }
}</code></pre>
<p>수신된 블록 액션을 <code>actionId</code> 기준으로 분기해 비즈니스 로직을 처리한다. 주의할 점은 Slack이 일정 시간 내에 반드시 <code>ack()</code>를 요구한다는 것이다. 따라서 무거운 처리 로직은 비동기로 분리하는 것을 권장한다.
<br></p>
<h3 id="마지막으로">마지막으로</h3>
<p>외부에 공개할 필요 없는 내부용 앱이라면 Socket Mode가 더 나은 선택일 수 있으니, 상황에 맞게 Request URL 방식과 비교해서 골라보자!</p>
<br>]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] Slack Interactivity로 슬랙에서 상호작용하기]]></title>
            <link>https://velog.io/@joeun-01/Spring-Boot-Slack-Interactivity%EB%A1%9C-%EC%8A%AC%EB%9E%99%EC%97%90%EC%84%9C-%EC%83%81%ED%98%B8%EC%9E%91%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@joeun-01/Spring-Boot-Slack-Interactivity%EB%A1%9C-%EC%8A%AC%EB%9E%99%EC%97%90%EC%84%9C-%EC%83%81%ED%98%B8%EC%9E%91%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 06 Apr 2026 09:48:35 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>프로젝트를 진행하면서 Slack 메시지에 버튼을 달고, 클릭 시 서버에서 이를 처리하는 기능이 필요했다. Slack에서 상호작용을 하는 방법은 과정을 정리해두면 나중에도 도움이 될 것 같아 블로그로 남긴다.</p>
</blockquote>
<br>

<h2 id="slack-interactivity란">Slack Interactivity란?</h2>
<hr>
<h3 id="slack-interactivity">Slack Interactivity</h3>
<p>Slack Interactivity는 Slack 메시지 안에 버튼, 선택 메뉴, 모달 등의 UI 컴포넌트를 포함시키고, 사용자가 이를 조작했을 때 서버가 해당 이벤트를 수신해 처리할 수 있도록 하는 기능이다.</p>
<p>예를 들어 &quot;승인 / 반려&quot; 버튼이 포함된 알림 메시지를 Slack으로 보내고, 담당자가 버튼을 클릭하면 서버에서 해당 액션을 감지해 자동으로 후속 처리를 진행할 수 있다.</p>
<br>

<h2 id="slack-interactivity-구현하기">Slack Interactivity 구현하기</h2>
<hr>
<h3 id="1-slack-app-interactivity-활성화">1. Slack App Interactivity 활성화</h3>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/334b8077-b034-43ea-a41f-f910426bce40/image.png" alt="Slack App"></p>
<p>Slack API에서 대상 앱으로 이동한 뒤, <strong>Features → Interactivity &amp; Shortcuts</strong> 메뉴에서 기능을 활성화한다.</p>
<p>활성화 후 <strong>Request URL</strong>에 interactive 요청을 수신할 API 엔드포인트를 입력해야 한다. 이 URL로 사용자의 버튼 클릭 이벤트 등이 전달된다.
<br></p>
<h3 id="2-ngrok을-사용한-로컬-테스트-환경-설정">2. ngrok을 사용한 로컬 테스트 환경 설정</h3>
<blockquote>
<p>로컬 테스트 시에는 Slack이 HTTPS만 허용하기 때문에 ngrok을 사용해야 한다</p>
</blockquote>
<pre><code class="language-bash"># ngrok 설치
brew install ngrok

# 앱 포트에 맞게 ngrok 실행 (예: 8082)
ngrok http 8082</code></pre>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/81898334-4c7f-4225-a845-d7761a872a09/image.png" alt="Slack App"></p>
<p>ngrok 실행 후 생성된 <code>Forwarding</code> HTTPS URL을 복사해서, Slack App의 Request URL을 아래 형식으로 업데이트하면 된다.
<br></p>
<h4 id="ngrok-사용-시-주의사항">ngrok 사용 시 주의사항</h4>
<ol>
<li>무료 플랜은 세션당 2시간 제한이 있으며, 재시작하면 URL이 변경된다</li>
<li>URL이 변경될 때마다 Slack App 설정도 함께 업데이트해야 한다</li>
<li>ngrok 대시보드(<code>http://127.0.0.1:4040</code>)에서 요청 내역을 실시간으로 확인할 수 있다<br>

</li>
</ol>
<h4 id="로컬-테스트-순서">로컬 테스트 순서</h4>
<ol>
<li>Spring Boot 앱 실행 (localhost:8082)</li>
<li>ngrok 실행 → HTTPS URL 생성</li>
<li>Slack App의 Request URL 업데이트</li>
<li><code>/slack/test-button</code> API 호출로 버튼 메시지 전송</li>
<li>버튼 클릭 → ngrok을 통해 로컬 서버로 전달<br>

</li>
</ol>
<h3 id="3-환경-변수-설정">3. 환경 변수 설정</h3>
<pre><code class="language-yaml">slack:
  signing-secret: ${SLACK_SIGNING_SECRET}</code></pre>
<p><code>signing-secret</code>은 Slack 요청의 진위 여부를 검증하기 위한 서명 시크릿이다. Slack App 설정 페이지의 <strong>Basic Information → App Credentials</strong>에서 확인할 수 있다. 외부에 노출되면 안 되므로 반드시 환경 변수로 관리해야 한다.
<br></p>
<h3 id="4-slacksignatureverifier-설정">4. SlackSignatureVerifier 설정</h3>
<pre><code class="language-java">@Slf4j
@Component
public class SlackSignatureVerifier {

    private static final String SLACK_SIGNATURE_VERSION = &quot;v0&quot;;
    private static final String HMAC_ALGORITHM = &quot;HmacSHA256&quot;;
    private static final long MAX_TIMESTAMP_AGE_SECONDS = 300; // 5분

    public boolean verifySignature(String signingSecret, String slackSignature, String timestamp, String requestBody) {
        // 타임스탬프 검증 (재생 공격 방지)
        if (!isTimestampValid(timestamp)) return false;

        // 서명 생성 및 비교 (상수 시간 비교로 타이밍 공격 방지)
        String baseString = SLACK_SIGNATURE_VERSION + &quot;:&quot; + timestamp + &quot;:&quot; + requestBody;
        String computedSignature = generateSignature(signingSecret, baseString);

        return constantTimeEquals(slackSignature, computedSignature);
    }
    // ...
}</code></pre>
<p>Slack이 전송한 요청이 실제 Slack에서 온 것인지 HMAC-SHA256 방식으로 검증한다. 보안을 위해 두 가지를 추가로 처리한다.</p>
<ol>
<li>재생 공격(Replay Attack) 방지 : 타임스탬프를 검증해 5분 이상 지난 요청은 차단한다</li>
<li>타이밍 공격 방지 : 서명 비교 시 상수 시간 비교(Constant Time Equals)를 사용한다<br>

</li>
</ol>
<h3 id="5-slackinteractivitypayload-모델-정의">5. SlackInteractivityPayload 모델 정의</h3>
<pre><code class="language-java">@Getter
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class SlackInteractivityPayload {
    private String type;
    private User user;
    private List&lt;Action&gt; actions;

    @JsonProperty(&quot;response_url&quot;)
    private String responseUrl;

    // 중첩 클래스: User, Action, Container, Channel, Message 등
    // ...

    public Action getFirstAction() {
        return actions != null &amp;&amp; !actions.isEmpty() ? actions.get(0) : null;
    }
}</code></pre>
<p>Slack이 전송하는 interactive payload를 역직렬화하기 위한 모델이다. <code>@JsonIgnoreProperties(ignoreUnknown = true)</code>를 적용해 불필요한 필드는 무시한다. <code>getFirstAction()</code>, <code>findActionById()</code> 헬퍼 메서드로 액션을 쉽게 조회할 수 있다.
<br></p>
<h3 id="6-slackinteractivityservice-설정">6. SlackInteractivityService 설정</h3>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
@Slf4j
public class SlackInteractivityService {
    private final ObjectMapper objectMapper;
    private final SlackSignatureVerifier signatureVerifier;
    private final SlackProperties slackProperties;

    public boolean verifyRequest(String slackSignature, String timestamp, String requestBody) {
        return signatureVerifier.verifySignature(slackProperties.getSigningSecret(), slackSignature, timestamp, requestBody);
    }

    public String extractPayloadFromRequestBody(String requestBody) {
        // form data에서 payload 파라미터 추출 후 URL 디코딩
    }

    public SlackInteractivityPayload parseJsonPayload(String payloadJson) throws JsonProcessingException {
        return objectMapper.readValue(payloadJson, SlackInteractivityPayload.class);
    }
}</code></pre>
<p>Slack 요청 서명 검증, payload 추출 및 파싱을 담당한다. 한 가지 주의할 점은 Slack의 interactive 요청이 <code>application/x-www-form-urlencoded</code> 형식으로 전달된다는 것이다. 따라서 <code>payload</code> 파라미터를 직접 추출한 후 JSON으로 파싱하는 과정이 필요하다.
<br></p>
<h3 id="7-slackinteractivitycontroller-설정">7. SlackInteractivityController 설정</h3>
<pre><code class="language-java">@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping(&quot;/slack/interactive&quot;)
public class SlackInteractivityController {
    private final SlackInteractivityService slackInteractivityService;

    @Hidden
    @PostMapping(value = &quot;&quot;, consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public ResponseEntity&lt;?&gt; handleInteraction(
            HttpServletRequest request,
            @RequestHeader(&quot;X-Slack-Signature&quot;) String slackSignature,
            @RequestHeader(&quot;X-Slack-Request-Timestamp&quot;) String timestamp
    ) {
        String requestBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);

        if (!slackInteractivityService.verifyRequest(slackSignature, timestamp, requestBody)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }

        String payload = slackInteractivityService.extractPayloadFromRequestBody(requestBody);
        SlackInteractivityPayload slackPayload = slackInteractivityService.parseJsonPayload(payload);

        // TODO: actionId에 따른 비즈니스 로직 처리 (비동기 권장)

        return ResponseEntity.ok().build();
    }
}</code></pre>
<p>서버 내부에서만 사용하는 컨트롤러이기 때문에 <code>@Hidden</code>을 적용해 Swagger 문서에서 제외했다.</p>
<p>또한, Slack은 <strong>3초 내에 응답</strong>을 요구하므로, 비즈니스 로직은 비동기로 처리하고 즉시 <code>200 OK</code>를 반환해야 한다. 예외가 발생하더라도 Slack의 재시도를 방지하기 위해 항상 <code>200 OK</code>를 반환하는 것이 좋다.</p>
<p>이후에는 요청으로 들어온 <code>actionId</code>를 기반으로 원하는 비즈니스 로직을 연결하면 된다.
<br></p>
<h3 id="마지막으로">마지막으로</h3>
<p>Slack 공식 문서를 순서대로 따라가면 어렵지 않게 Slack Interactivity를 구성할 수 있다. 버튼 클릭 한 번으로 승인/반려, 알림 처리 등 다양한 워크플로우를 자동화할 수 있으니, Slack을 메인 협업 도구로 쓰고 있는 경우에는 활용해보면 좋을 것 같다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GitHub Actions로 프론트엔드 배포하기]]></title>
            <link>https://velog.io/@joeun-01/GitHub-Actions-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%9E%90%EB%8F%99-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@joeun-01/GitHub-Actions-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%9E%90%EB%8F%99-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 17 Feb 2026 15:44:33 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>기존에 OCI DevOps로 빌드-배포 파이프라인을 설정하다가 잘 되지 않아 GitHub Actions를 사용하여 파이프라인을 구축하게 되었다. GitHub Actions를 사용하니 복잡한 설정 없이 deploy.yml 파일 하나로 자동 배포 설정이 가능했고, 막히고 있던 프론트엔드 배포를 빠르게 완료할 수 있었다.</p>
</blockquote>
<br>

<h2 id="github-actions">GitHub Actions</h2>
<hr>
<h3 id="github-actions-1">GitHub Actions</h3>
<p>GitHub에서 제공하는 CI/CD 플랫폼이다. 레포지토리에 특정 이벤트(푸시, PR 등)가 발생하면 자동으로 정의된 작업을 실행시킬 수 있다. <code>.github/workflows/</code> 디렉토리에 YAML 파일을 만들면 설정이 완료된다.
<br></p>
<h3 id="장점">장점</h3>
<ol>
<li>GitHub가 제공하는 러너에서 실행되어 별도 CI/CD 서버가 필요 없다</li>
<li>SSH 키, API 키 등을 GitHub에서 안전하게 관리할 수 있다</li>
<li>워크플로우 실행 로그를 GitHub Actions 탭에서 바로 확인할 수 있다</li>
<li>파일 전송, SSH 접속 등 다양한 액션을 가져다 쓸 수 있다<br>

</li>
</ol>
<h2 id="github-actions로-프론트-배포하기">GitHub Actions로 프론트 배포하기</h2>
<hr>
<h3 id="1-서버-환경-구성">1. 서버 환경 구성</h3>
<table>
<thead>
<tr>
<th>컨테이너</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>nginx-proxy</td>
<td>SSL 인증서 처리 + 리버스 프록시</td>
</tr>
<tr>
<td>frontend</td>
<td><code>nginx:alpine</code> 기반, Vite 빌드 정적 파일 서빙</td>
</tr>
<tr>
<td>backend</td>
<td>Spring Boot 애플리케이션</td>
</tr>
</tbody></table>
<p>서버 환경은 각 프로젝트에 맞게 구성하면 된다. 이번 프로젝트의 경우, Docker Compose로 여러 컨테이너를 띄워서 프론트, 서버 환경을 구성했다. 클라이언트 요청은 <code>nginx-proxy</code> → <code>frontend</code> 순서로 전달되며, Vite로 빌드된 정적 파일은 <code>frontend</code> 컨테이너의 <code>/usr/share/nginx/html/</code>에서 서빙한다. 배포 시 이 경로의 파일만 교체하고 Nginx를 리로드하면 반영된다.
<br></p>
<h3 id="2-secrets-등록">2. Secrets 등록</h3>
<table>
<thead>
<tr>
<th>Secret 이름</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>SSH_PRIVATE_KEY</code></td>
<td>운영 서버 접속을 위한 SSH 개인키</td>
</tr>
<tr>
<td><code>INSTANCE_IP</code></td>
<td>운영 서버의 공인 IP 주소</td>
</tr>
<tr>
<td><code>VITE_SERVER_URL_API</code></td>
<td>백엔드 API 서버 주소</td>
</tr>
<tr>
<td><code>VITE_GOOGLE_MAPS_API_KEY</code></td>
<td>프로젝트 환경 변수</td>
</tr>
</tbody></table>
<p>Repository → Settings → Secrets and variables → Actions에서 등록한다. GitHub Actions에서 SSH 접속과 환경 변수를 사용하려면 먼저 Secrets를 등록해야 한다. Secrets는 워크플로우 로그에 자동으로 마스킹 처리된다.
<br></p>
<h3 id="3-워크플로우-파일-정의">3. 워크플로우 파일 정의</h3>
<pre><code class="language-yaml">name: Build and Deploy Frontend

on:
  push:
    branches:
      - main

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: &#39;18&#39;

      - name: Install dependencies
        run: npm install

      - name: Build
        env:
          VITE_SERVER_URL_API: ${{ secrets.VITE_SERVER_URL_API }}
          VITE_GOOGLE_MAPS_API_KEY: ${{ secrets.VITE_GOOGLE_MAPS_API_KEY }}
        run: npm run build

      - name: Deploy to Instance
        uses: appleboy/scp-action@master
        with:
          host: ${{ secrets.INSTANCE_IP }}
          username: ubuntu
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          source: &quot;dist/*&quot;
          target: &quot;/tmp/deploy&quot;
          strip_components: 1

      - name: Update Frontend Container
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.INSTANCE_IP }}
          username: ubuntu
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            echo &quot;=== Deploying to frontend container ===&quot;
            docker exec frontend sh -c &quot;rm -rf /usr/share/nginx/html/*&quot;
            docker cp /tmp/deploy/. frontend:/usr/share/nginx/html/
            docker exec frontend nginx -s reload
            rm -rf /tmp/deploy
            echo &quot;✓ Deployment complete!&quot;</code></pre>
<p><code>.github/workflows/deploy.yml</code> 파일에 전체 배포 프로세스를 정의한다. <code>main</code> 브랜치에 푸시하면 워크플로우가 자동으로 실행된다.</p>
<h4 id="3-1-실행-환경">3-1. 실행 환경</h4>
<p>GitHub가 제공하는 Ubuntu 러너에서 작업을 실행한다. 서버를 따로 관리하지 않아도 되며, 작업이 끝나면 자동으로 정리된다.</p>
<h4 id="3-2-steps-상세">3-2. Steps 상세</h4>
<p><strong>1. 코드 체크아웃 및 Node.js 설정</strong></p>
<p>레포지토리 코드를 가져오고 Node.js 18 버전을 설치한다.</p>
<p><strong>2. 의존성 설치</strong>
<code>npm install</code>로 의존성을 설치한다. 매번 클린 환경에서 시작하므로 로컬 환경 차이로 인한 문제가 없다.</p>
<p><strong>3. 빌드</strong>
환경 변수를 설정하고 <code>npm run build</code>를 실행한다. Vite가 빌드할 때 환경 변수 값을 읽어서 번들에 포함시키기 때문에, <code>import.meta.env.VITE_SERVER_URL_API</code> 같은 코드가 실제 값으로 치환된다.</p>
<p><strong>4. SCP로 파일 전송</strong>
<code>appleboy/scp-action</code>을 사용해 SSH를 통해 빌드 결과물을 운영 서버로 전송한다. <code>strip_components: 1</code> 옵션으로 <code>dist/</code> 폴더 구조를 제거하고 내부 파일만 전송하여, <code>/tmp/deploy/index.html</code> 형태로 저장된다.</p>
<p><strong>5. SSH로 배포 스크립트 실행</strong></p>
<pre><code class="language-bash">docker exec frontend sh -c &quot;rm -rf /usr/share/nginx/html/*&quot;  # 기존 파일 삭제
docker cp /tmp/deploy/. frontend:/usr/share/nginx/html/      # 새 파일 복사
docker exec frontend nginx -s reload                         # Nginx 리로드
rm -rf /tmp/deploy                                           # 임시 디렉토리 정리</code></pre>
<p><code>docker cp</code>에서 점(<code>.</code>)은 &quot;현재 디렉토리의 모든 내용&quot;을 의미한다. 기존 파일 삭제와 새 파일 복사 사이에 짧은 공백이 생길 수 있으므로 완전한 무중단 배포는 아니지만, <code>nginx -s reload</code>는 기존 연결을 끊지 않고 설정을 반영하므로 리로드 자체는 무중단으로 처리된다.
<br></p>
<h3 id="4-nginx-설정">4. Nginx 설정</h3>
<pre><code class="language-nginx">server {
    listen 443 ssl;
    server_name roominus.kr;

    # ... SSL 설정 생략

    # 모든 요청을 frontend 컨테이너로 프록시
    location / {
        proxy_pass http://frontend:80;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # 정적 파일은 1년 캐싱 (Vite가 파일명에 해시를 붙이므로 캐시 무효화 걱정 없음)
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        proxy_pass http://frontend:80;
        proxy_set_header Host $host;
        expires 1y;
        add_header Cache-Control &quot;public, no-transform&quot;;
        access_log off;
    }
}</code></pre>
<p><code>frontend</code>는 Docker Compose의 서비스명으로, Docker 네트워크 내부에서 컨테이너를 찾는다. 배포 시 <code>frontend</code> 컨테이너 내부의 <code>/usr/share/nginx/html/</code> 파일만 교체하면 이 설정이 새 파일을 서빙한다.
<br></p>
<h2 id="마치며">마치며</h2>
<hr>
<p>OCI DevOps를 사용하면서 막히는 부분이 많았는데 GitHub Actions 덕분에 프론트 배포를 빠르게 마무리할 수 있었다. 물론 각각의 장단점이 있겠지만, 복잡한 세팅이 필요없는 경우에는 GitHub Actions를 활용하면 좋을 것 같다.
<br></p>
<h3 id="참고-자료">참고 자료</h3>
<p><a href="https://docs.github.com/en/actions">GitHub Actions 공식 문서</a>
<a href="https://github.com/appleboy/scp-action">appleboy/scp-action - GitHub</a>
<a href="https://github.com/appleboy/ssh-action">appleboy/ssh-action - GitHub</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] Logbook으로 HTTP 요청/응답 로깅하기]]></title>
            <link>https://velog.io/@joeun-01/Spring-Boot-Logbook%EC%9C%BC%EB%A1%9C-HTTP-%EC%9A%94%EC%B2%AD%EC%9D%91%EB%8B%B5-%EB%A1%9C%EA%B9%85%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@joeun-01/Spring-Boot-Logbook%EC%9C%BC%EB%A1%9C-HTTP-%EC%9A%94%EC%B2%AD%EC%9D%91%EB%8B%B5-%EB%A1%9C%EA%B9%85%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 16 Feb 2026 10:34:50 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>API를 개발하다 보면 HTTP 요청과 응답을 로깅해야 할 때가 있다. 단순히 <code>log.info()</code>로 남기기에는 매번 코드에 직접 작성해야 하고, 민감 정보 마스킹이나 환경별 설정 분리까지 고려하면 상당히 번거로워진다. 이를 해결할 수 있는 방법을 찾다가 Zalando에서 만든 Logbook이라는 라이브러리를 알게 되었고, 설정만으로 HTTP 통신을 자동으로 로깅할 수 있었다. Logbook에 대해 정리하고자 한다!</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/ce4b6a5c-a014-475c-bcca-77bb9c8916b6/image.png" alt="Logbook 예시"></p>
<h2 id="logbook이란">Logbook이란?</h2>
<hr>
<h3 id="logbook">Logbook</h3>
<blockquote>
<p><a href="https://github.com/zalando/logbook">https://github.com/zalando/logbook</a></p>
</blockquote>
<p>Zalando에서 만든 HTTP 요청/응답 전용 로깅 라이브러리이다. 일반 로깅과 달리 HTTP 요청/응답에 특화되어 있어 요청과 응답을 분리해서 로깅할 수 있다. 설정만 하면 모든 HTTP 통신이 자동으로 로깅되며, REST API, WebClient, RestTemplate, Feign 등과 연동이 가능하다.
<br></p>
<h3 id="logbook의-장점">Logbook의 장점</h3>
<ol>
<li><strong>유연한 로깅 전략</strong> : 특정 상태 코드, URL 패턴 등의 조건부 로깅이 가능하고, 성능을 위한 비동기 로깅을 지원한다.</li>
<li><strong>다양한 출력 포맷 지원</strong> : HTTP, JSON, CURL 및 커스텀 포맷터를 지원한다.</li>
<li><strong>보안 및 프라이버시</strong> : 민감한 정보를 자동으로 마스킹하고, 헤더나 바디에 대한 커스텀 마스킹 규칙을 설정할 수 있다.<br>

</li>
</ol>
<h3 id="주요-컴포넌트">주요 컴포넌트</h3>
<table>
<thead>
<tr>
<th>컴포넌트</th>
<th>역할</th>
<th>상세</th>
</tr>
</thead>
<tbody><tr>
<td>Condition</td>
<td>어떤 요청을 로깅할지 결정</td>
<td>URL 패턴, HTTP 메서드, 헤더 기반 필터링</td>
</tr>
<tr>
<td>Sink</td>
<td>로그를 실제로 출력</td>
<td>SLF4J, Apache Commons Logging 등과 연동</td>
</tr>
<tr>
<td>Strategy</td>
<td>요청/응답 처리 방식을 결정</td>
<td>default, status-at-least, body-only-if-status-at-least, without-body</td>
</tr>
<tr>
<td><br></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<h3 id="strategy-종류">Strategy 종류</h3>
<table>
<thead>
<tr>
<th>Strategy</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>default</code></td>
<td>모든 요청/응답 로깅</td>
</tr>
<tr>
<td><code>status-at-least</code></td>
<td>특정 상태 코드 이상만 로깅</td>
</tr>
<tr>
<td><code>body-only-if-status-at-least</code></td>
<td>특정 상태 코드 이상일 때만 body 포함</td>
</tr>
<tr>
<td><code>without-body</code></td>
<td>body 제외하고 로깅</td>
</tr>
<tr>
<td><br></td>
<td></td>
</tr>
</tbody></table>
<h3 id="logbook-로깅-단계">Logbook 로깅 단계</h3>
<p>Logbook은 요청이 들어오면 다음 4단계를 거쳐 로깅을 수행한다.</p>
<ol>
<li><strong>Conditional Phase</strong> : 특정 요청이 로깅될지 여부를 결정한다. 헬스 체크 등의 특정 요청을 제외하도록 조건을 설정할 수 있다.</li>
<li><strong>Filtering Phase</strong> : 로깅하기 전에 요청 데이터를 수정한다. 민감한 정보를 제거하거나 특정 요소들이 표시되는 방식을 커스터마이징 할 수 있다.</li>
<li><strong>Formatting Phase</strong> : 요청과 응답 데이터를 HTTP, JSON 등 특정 형식으로 가공한다.</li>
<li><strong>Writing Phase</strong> : 포맷된 로그 메세지를 파일, 콘솔, 또는 외부 시스템에 출력한다.<br>

</li>
</ol>
<h2 id="logbook-주요-설정">Logbook 주요 설정</h2>
<hr>
<h3 id="설정-항목">설정 항목</h3>
<table>
<thead>
<tr>
<th>설정</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>logbook.filter.enabled</code></td>
<td>LogbookFilter 사용 여부</td>
</tr>
<tr>
<td><code>logbook.format.style</code></td>
<td>로그 출력 형식 (http, json, curl, splunk)</td>
</tr>
<tr>
<td><code>logbook.minimum-status</code></td>
<td>특정 HTTP 상태 코드 이상의 응답부터 로깅 ex) 400 설정 시 4xx, 5xx만 로깅</td>
</tr>
<tr>
<td><code>logbook.obfuscate.headers</code></td>
<td>마스킹할 헤더 목록 ex) Authorization, Cookie</td>
</tr>
<tr>
<td><code>logbook.obfuscate.parameters</code></td>
<td>마스킹할 URL 쿼리 파라미터 목록 ex) password, access_token</td>
</tr>
<tr>
<td><code>logbook.obfuscate.json-body-fields</code></td>
<td>마스킹할 JSON 바디 필드 목록 ex) password, email</td>
</tr>
<tr>
<td><code>logbook.obfuscate.replacement</code></td>
<td>마스킹된 부분에 표시할 문자 (기본값 XXX)</td>
</tr>
<tr>
<td><code>logbook.predicate.include</code></td>
<td>특정 경로만 로깅</td>
</tr>
<tr>
<td><code>logbook.predicate.exclude</code></td>
<td>특정 경로는 로깅에서 제외</td>
</tr>
<tr>
<td><code>logbook.write.chunk-size</code></td>
<td>로그를 작은 조각으로 나눌 크기 (기본 8192byte)</td>
</tr>
<tr>
<td><code>logbook.write.max-body-size</code></td>
<td>바디가 너무 클 때 잘라낼 최대 글자수 (기본 8192byte)</td>
</tr>
<tr>
<td><br></td>
<td></td>
</tr>
</tbody></table>
<h3 id="설정-시-주의사항">설정 시 주의사항</h3>
<ul>
<li><code>include</code> vs <code>exclude</code> : exclude 설정이 include 설정보다 우선적으로 적용된다.</li>
<li><code>filter.enabled</code> : false로 설정하면 모든 로깅 필터가 비활성화된다.</li>
<li><code>secure-filter.enabled</code> : false로 설정하면 obfuscate 마스킹이 무시된다.<br>

</li>
</ul>
<h3 id="마스킹obfuscate-범위-정리">마스킹(obfuscate) 범위 정리</h3>
<p>마스킹 대상에 따라 설정 위치가 다르다. 각 설정의 대상과 예시를 정리하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>설정</th>
<th>대상</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td><code>obfuscate.headers</code></td>
<td>HTTP 헤더 값</td>
<td>Authorization: ***</td>
</tr>
<tr>
<td><code>obfuscate.parameters</code></td>
<td>URL 쿼리 파라미터 값</td>
<td>?password=***</td>
</tr>
<tr>
<td><code>obfuscate.json-body-fields</code></td>
<td>JSON 바디의 필드 값 (yml)</td>
<td>{&quot;password&quot;:&quot;***&quot;}</td>
</tr>
<tr>
<td><code>BodyFilter</code> Bean</td>
<td>JSON 바디의 필드 값 (Java)</td>
<td>{&quot;email&quot;:&quot;***&quot;}</td>
</tr>
</tbody></table>
<p><code>obfuscate.json-body-fields</code>는 yml 설정만으로 간단히 적용 가능하지만, <code>logbook-json</code> 모듈의 <code>JsonBodyFilters</code>를 사용한 BodyFilter Bean 등록 방식이 더 세밀한 제어가 가능하다.
<br></p>
<h2 id="logbook-적용하기">Logbook 적용하기</h2>
<hr>
<h3 id="1-의존성-설정">1. 의존성 설정</h3>
<pre><code class="language-groovy">// Logbook - HTTP 요청/응답 로깅
implementation &#39;org.zalando:logbook-spring-boot-starter:3.9.0&#39;
implementation &#39;org.zalando:logbook-json:3.9.0&#39;</code></pre>
<br>

<h4 id="의존성-설명">의존성 설명</h4>
<table>
<thead>
<tr>
<th>의존성</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>logbook-spring-boot-starter</code></td>
<td>Spring Boot 자동 설정 및 기본 로깅 기능을 제공한다</td>
</tr>
<tr>
<td><code>logbook-json</code></td>
<td><code>JsonBodyFilters</code> 등 JSON 바디 필터링 기능을 제공한다. <code>logbook-spring-boot-starter</code>에 자동으로 포함되지 않으므로 명시적으로 추가해야 한다</td>
</tr>
<tr>
<td><br></td>
<td></td>
</tr>
</tbody></table>
<h3 id="2-application-logyml-작성">2. application-log.yml 작성</h3>
<pre><code class="language-yaml">logbook:
  filter:
    enabled: true
  obfuscate:
    replacement: &quot;***&quot;
    headers:
      - Authorization
      - Cookie
      - Set-Cookie
    parameters:
      - password
      - access_token
      - refresh_token
      - code

---
spring:
  config:
    activate:
      on-profile: local

logbook:
  format:
    style: http
  predicate:
    exclude:
      - path: /actuator/**
      - path: /swagger-ui/**
      - path: /v3/api-docs/**
      - path: /swagger-resources/**
      - path: /favicon.ico

logging:
  level:
    org.zalando.logbook: TRACE

---
spring:
  config:
    activate:
      on-profile: dev

logbook:
  format:
    style: json
  predicate:
    exclude:
      - path: /actuator/**
      - path: /swagger-ui/**
      - path: /v3/api-docs/**
      - path: /swagger-resources/**
      - path: /favicon.ico

logging:
  level:
    org.zalando.logbook: TRACE

---
spring:
  config:
    activate:
      on-profile: prod

logbook:
  format:
    style: json
  minimum-status: 400
  write:
    max-body-size: 2048
  predicate:
    exclude:
      - path: /actuator/**
      - path: /swagger-ui/**
      - path: /v3/api-docs/**
      - path: /swagger-resources/**
      - path: /favicon.ico
      - path: /excel-upload/**

logging:
  level:
    org.zalando.logbook: TRACE</code></pre>
<p>공통 영역에 마스킹 설정을 두어 모든 프로필에서 동일하게 적용되도록 하고, 각 프로필별로 포맷과 로깅 범위를 다르게 설정하였다.
<br></p>
<h4 id="환경별-설정-설명">환경별 설정 설명</h4>
<table>
<thead>
<tr>
<th>환경</th>
<th>포맷</th>
<th>로깅 범위</th>
<th>특이사항</th>
</tr>
</thead>
<tbody><tr>
<td>공통 (default)</td>
<td>-</td>
<td>전체</td>
<td>마스킹 설정 (헤더, 파라미터)</td>
</tr>
<tr>
<td>local</td>
<td>http</td>
<td>전체</td>
<td>가독성 좋은 HTTP 포맷</td>
</tr>
<tr>
<td>dev</td>
<td>json</td>
<td>전체</td>
<td>구조화된 JSON 포맷</td>
</tr>
<tr>
<td>prod</td>
<td>json</td>
<td>400 이상만</td>
<td><code>minimum-status: 400</code>, <code>max-body-size: 2048</code> 제한</td>
</tr>
</tbody></table>
<p><code>logging.level.org.zalando.logbook: TRACE</code> 설정이 있어야 HTTP 요청/응답 로깅이 동작한다.
prod 환경에서 <code>minimum-status: 400</code>을 설정하면 정상 응답(2xx, 3xx)은 로깅하지 않아 성능 부담을 줄일 수 있다. 또한, prod 환경에서 <code>max-body-size: 2048</code>을 설정하면 대용량 응답 바디가 잘려서 로깅되어 로그 저장 비용을 절감할 수 있다.
<br></p>
<h3 id="3-logbookconfig-작성">3. LogbookConfig 작성</h3>
<pre><code class="language-java">@Configuration
public class LogbookConfig {

    @Bean
    public BodyFilter bodyFilter() {
        return JsonBodyFilters.replaceJsonStringProperty(
                Set.of(
                        &quot;password&quot;,
                        &quot;accessToken&quot;, &quot;refreshToken&quot;,
                        &quot;code&quot;,
                        &quot;email&quot;
                ),
                &quot;***&quot;
        );
    }
}</code></pre>
<p>Logbook 전체를 Bean으로 등록하지 않고, <code>BodyFilter</code>만 Bean으로 등록하면 yml 설정과 커스텀 로직을 동시에 사용할 수 있다. <code>JsonBodyFilters.replaceJsonStringProperty()</code>는 <code>logbook-json</code> 모듈에 포함되어 있으며, JSON 바디 내 지정된 필드 값을 <code>***</code>로 마스킹한다.
<br></p>
<h4 id="주의사항">주의사항</h4>
<p>Logbook 전체를 Bean으로 만들면 (<code>Logbook.builder()...build()</code>) application.yml에 정의한 설정이 모두 무시된다. 커스텀 로직이 필요한 컴포넌트(BodyFilter, HttpLogWriter 등)만 개별 Bean으로 등록해야 yml 설정과 공존할 수 있다.
<br></p>
<h4 id="마스킹-설정-구분">마스킹 설정 구분</h4>
<table>
<thead>
<tr>
<th>마스킹 대상</th>
<th>설정 위치</th>
<th>설정 방법</th>
</tr>
</thead>
<tbody><tr>
<td>HTTP 헤더 (Authorization, Cookie 등)</td>
<td>yml</td>
<td><code>obfuscate.headers</code></td>
</tr>
<tr>
<td>URL 쿼리 파라미터 (password, token 등)</td>
<td>yml</td>
<td><code>obfuscate.parameters</code></td>
</tr>
<tr>
<td>JSON 바디 필드 (password, email 등)</td>
<td>Java Config</td>
<td><code>BodyFilter</code> Bean + <code>JsonBodyFilters</code></td>
</tr>
<tr>
<td><br></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<h2 id="실행-결과">실행 결과</h2>
<hr>
<h3 id="http-포맷-local-환경---요청">http 포맷 (local 환경) - 요청</h3>
<pre><code>2026-02-16T18:15:56.915+09:00 TRACE 79267 --- [room-in-us] [nio-8090-exec-1] org.zalando.logbook.Logbook              : Incoming Request: c3f73321e96883eb
Remote: 0:0:0:0:0:0:0:1
POST http://localhost:8090/login HTTP/1.1
accept: */*
accept-encoding: gzip, deflate, br, zstd
accept-language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
connection: keep-alive
content-length: 65
content-type: application/json
cookie: ***
host: localhost:8090
origin: http://localhost:8090
referer: http://localhost:8090/api/swagger-ui/index.html
sec-ch-ua: &quot;Not(A:Brand&quot;;v=&quot;8&quot;, &quot;Chromium&quot;;v=&quot;144&quot;, &quot;Google Chrome&quot;;v=&quot;144&quot;
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: &quot;macOS&quot;
sec-fetch-dest: empty
sec-fetch-mode: cors
sec-fetch-site: same-origin
user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36

{&quot;email&quot;:&quot;***&quot;,&quot;password&quot;:&quot;***&quot;}</code></pre><p>요청의 <code>cookie</code> 헤더가 <code>***</code>로 마스킹되고, 바디의 <code>email</code>, <code>password</code> 필드도 <code>***</code>로 마스킹된 것을 확인할 수 있다.
<br></p>
<h3 id="http-포맷-local-환경---응답">http 포맷 (local 환경) - 응답</h3>
<pre><code>2026-02-16T18:15:57.255+09:00 TRACE 79267 --- [room-in-us] [nio-8090-exec-1] org.zalando.logbook.Logbook              : Outgoing Response: c3f73321e96883eb
Duration: 346 ms
HTTP/1.1 200 OK
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Connection: keep-alive
Content-Type: application/json
Date: Mon, 16 Feb 2026 09:15:57 GMT
Expires: 0
Keep-Alive: timeout=60
Pragma: no-cache
Set-Cookie: ***, ***
Transfer-Encoding: chunked
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0

{&quot;memberId&quot;:1,&quot;accessToken&quot;:&quot;***&quot;,&quot;refreshToken&quot;:&quot;***&quot;}</code></pre><p>응답 헤더의 <code>Set-Cookie</code>와 바디의 <code>accessToken</code>, <code>refreshToken</code> 필드가 <code>***</code>로 마스킹된 것을 확인할 수 있다.
<br></p>
<h2 id="마치며">마치며</h2>
<hr>
<p>Logbook을 통해 설정만으로 HTTP 요청/응답을 자동으로 로깅할 수 있었다. 특히 환경별로 로깅 전략을 다르게 가져갈 수 있어, local에서는 가독성 좋은 HTTP 포맷으로 전체 로깅을 하고, prod에서는 JSON 포맷으로 에러 응답만 로깅하는 식으로 분리할 수 있었다. 라이브러리를 통해 편하게 로그를 관리할 수 있다는 점이 큰 장점으로 느껴졌다!
<br></p>
<h3 id="참고-자료">참고 자료</h3>
<p><a href="https://github.com/zalando/logbook">Logbook - GitHub</a>
<a href="https://blog.nashtechglobal.com/introducing-zalando-logbook-and-how-to-integrate-it-with-spring-boot/">Introducing Zalando Logbook and How to Integrate It with Spring Boot - NashTech Blog</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] 디스코드 웹훅 전송하기]]></title>
            <link>https://velog.io/@joeun-01/%EB%94%94%EC%8A%A4%EC%BD%94%EB%93%9C-%EC%9B%B9%ED%9B%85-%EC%A0%84%EC%86%A1%ED%95%98%EB%8A%94-%EB%B2%95</link>
            <guid>https://velog.io/@joeun-01/%EB%94%94%EC%8A%A4%EC%BD%94%EB%93%9C-%EC%9B%B9%ED%9B%85-%EC%A0%84%EC%86%A1%ED%95%98%EB%8A%94-%EB%B2%95</guid>
            <pubDate>Thu, 12 Feb 2026 13:57:27 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>사이드 프로젝트를 진행하면서 회원가입 시 디스코드 웹훅을 전송하도록 하는 로직을 담당하게 되었다. 생각했던 것보다 더 쉽게 할 수 있었고, 이번에 정리해두면 나중에도 활용할 수 있을 것 같아 블로그를 작성하게 되었다.</p>
</blockquote>
<br>

<h3 id="1-디스코드-웹후크-생성-및-url-복사">1. 디스코드 웹후크 생성 및 URL 복사</h3>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/ae7fdde2-366f-4f03-8db3-360beccda300/image.png" alt="디스코드 웹훅 설정"></p>
<p>먼저 디스코드 웹훅을 보낼 채널을 생성해야 한다. 채널을 생성했다면 채널 설정에서 연동 → 웹후크로 들어가서 웹후크를 생성할 수 있다. 웹훅 이름은 원하는대로 설정할 수 있으며, 웹후크 URL 복사을 통해 웹훅 URL을 복사해둬야 추후에 사용할 수 있다. 설정에 들어가면 언제든 URL을 복사할 수 있다!</p>
<h3 id="2-의존성-설정">2. 의존성 설정</h3>
<pre><code class="language-groovy">dependencyManagement {
    imports {
        mavenBom &quot;org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}&quot;
    }
}</code></pre>
<pre><code class="language-groovy"> // FeignClient
implementation &#39;org.springframework.cloud:spring-cloud-starter-openfeign&#39;</code></pre>
<p>다음으로는 Spring Cloud OpenFeign을 사용하기 위한 의존성을 추가해야 한다. 이때 <code>springCloudVersion</code>은 사용 중인 Spring Boot 버전과 호환되는 버전을 지정해야 한다.</p>
<h3 id="3-환경-변수-설정">3. 환경 변수 설정</h3>
<pre><code class="language-yaml">discord:
  name: discord-feign-client
  webhook-url: ${DISCORD_WEBHOOK_URL}</code></pre>
<ol>
<li><code>name</code>
<code>@FeignClient</code>의 <code>name</code> 속성과 매핑된다.</li>
<li><code>webhook-url</code>
웹훅 URL은 외부에 노출되면 안 되기 때문에 환경 변수로 넣어준다.
위에서 복사해둔 웹훅 URL을 사용하면 된다.<br>

</li>
</ol>
<h3 id="4-웹훅-메세지-템플릿-정의">4. 웹훅 메세지 템플릿 정의</h3>
<pre><code class="language-java">@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public enum DiscordEventMessage {
    SIGN_UP_EVENT(
            &quot;&quot;&quot;
            🚪 신규 회원 🚪
            • 닉네임 : %s
            • 현재 가입 유저 수 : %d명
            • 플랫폼 : %s
            &quot;&quot;&quot;);

    private final String message;
}</code></pre>
<p>디스코드 웹훅으로 회원가입 알림을 보내기로 했기 때문에 위 형식으로 메세지를 보내기로 결정했다.</p>
<h3 id="5-feignclient-설정">5. FeignClient 설정</h3>
<pre><code class="language-java">@EnableFeignClients</code></pre>
<p>메인 Application 클래스에 <code>@EnableFeignClient</code>를 적용하여 FeignClient 인터페이스가 빈으로 등록되도록 활성화한다. 이 설정이 없으면 <code>@FeignClient</code>가 붙은 인터페이스가 스캔되지 않아 에러가 발생한다.</p>
<h3 id="6-discordfeignclient-설정">6. DiscordFeignClient 설정</h3>
<h4 id="6-1-discordmessage-정의">6-1. DiscordMessage 정의</h4>
<pre><code class="language-java">@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class DiscordMessage {
    private String content;
}</code></pre>
<p>FeignClient의 요청 본문으로 사용되는 DTO를 정의한다.</p>
<h4 id="6-2-feignclient-인터페이스-정의">6-2. FeignClient 인터페이스 정의</h4>
<pre><code class="language-java">@FeignClient(name = &quot;${discord.name}&quot;, url = &quot;${discord.webhook-url}&quot;)
public interface DiscordFeignClient {
    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    void sendMessage(@RequestBody DiscordMessage discordMessage);
}</code></pre>
<p><code>DiscordFeignClient</code> 클래스에서는 디스코드 웹훅 URL로 POST 요청을 보낼 수 있는 메서드를 정의한다. 먼저 <code>@FeignClient</code>의 <code>name</code>과 <code>url</code>을 정의한 프로퍼티로 바인딩한다. 위에서 정의한 <code>DiscordMessage</code> 객체를 JSON 형식의 요청 본문으로 전달한다.</p>
<h3 id="7-discordmessageprovider-설정">7. DiscordMessageProvider 설정</h3>
<pre><code class="language-java">@Slf4j
@RequiredArgsConstructor
@Component
public class DiscordMessageProvider {
    private final DiscordFeignClient discordFeignClient;
    private final Environment environment;

    public void sendMessage(String content) {
        sendMessageToDiscord(DiscordMessage.builder()
                .content(content)
                .build());
    }

    private void sendMessageToDiscord(DiscordMessage discordMessage) {
        if (!isProdProfile()) {
            log.info(&quot;Discord Message : {}&quot;, discordMessage.getContent());
            log.info(&quot;Discord 메세지 전송 생략 (prod 환경 아님)&quot;);
            return;
        }

        try {
            discordFeignClient.sendMessage(discordMessage);
        } catch (FeignException e) {
            log.error(&quot;Discord Webhook Error : {}&quot;, e.getMessage());
            throw new CustomException(ResponseCode.FAIL_DISCORD_WEBHOOK);
        }
    }

    private boolean isProdProfile() {
        return Arrays.asList(environment.getActiveProfiles()).contains(&quot;prod&quot;);
    }
}</code></pre>
<p><code>DiscordProvider</code>는 디스코드 메세지 전송을 담당한다. 코드에서 바로 <code>FeignClient</code> 인터페이스를 호출하지 않고 Provider 클래스에서 추가 로직을 처리할 수 있도록 했다. 또한 실제 운영 페이지에서 회원가입한 경우에만 웹훅이 보내져야 하기 때문에 <code>isProdProfile()</code>을 통해 운영 환경에서만 웹훅이 전송되도록 설정했다. 로컬/개발 환경에서는 로그만 남기고 전송을 생략한다. 마지막으로 <code>FeignException</code>을 통해 웹훅 전송 실패 시 커스텀 예외를 던진다.</p>
<h3 id="8-회원가입-로직에-디스코드-웹훅-로직-추가">8. 회원가입 로직에 디스코드 웹훅 로직 추가</h3>
<pre><code class="language-java">String discordMessage = String.format(
        DiscordEventMessage.SIGN_UP_EVENT.getMessage(),
        member.getNickname(),
        joinedMembers.size(),
        member.getSocialType()
);

discordMessageProvider.sendMessage(discordMessage);</code></pre>
<p>마지막으로 회원가입 로직이 끝나면 Provider에 작성한 메서드를 호출해서 디스코드 웹훅을 보내주면 된다! 여기에서는 회원가입이 완료된 후, 유저 정보 및 현재까지 가입한 유저 수를 메세지 템플릿에 기입하여 완성된 메세지를 디스코드로 전송하도록 해두었다.</p>
<h3 id="마지막으로">마지막으로</h3>
<p>간단한 설정만 하면 Spring Boot에서 디스코드 웹훅을 보낼 수 있다. 디스코드 웹훅을 활용해서 서비스의 중요한 알림 혹은 로그 등을 구성원이 함께 확인할 수 있도록 하면 좋겠다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] NumberExpression 괄호 처리 문제 ]]></title>
            <link>https://velog.io/@joeun-01/Spring-Boot-NumberExpression-%EA%B4%84%ED%98%B8-%EC%B2%98%EB%A6%AC-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@joeun-01/Spring-Boot-NumberExpression-%EA%B4%84%ED%98%B8-%EC%B2%98%EB%A6%AC-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Mon, 09 Feb 2026 08:03:27 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>QueryDSL의 NumberExpression을 사용하면서 계산 결과가 예상과 달라 문제를 겪은 경험이 있다. 해결 방법은 간단했지만 같은 문제를 여러 번 겪었고, 방심해서 놓치기 쉬운 부분이라 생각이 되어 블로그로 과정을 남겨두려 한다!</p>
</blockquote>
<br>

<h3 id="문제-상황">문제 상황</h3>
<p>⭐️ QueryDSL에서 <code>NumberExpression</code>의 메서드 체이닝으로 수식을 작성하면, 코드상으로는 연산 순서가 명확해 보이지만 체이닝을 SQL로 변환할 때 괄호가 자동으로 생성되지 않는다. ⭐️</p>
<p>자바 코드에서 <code>a.add(b).divide(c)</code>라고 작성하면, <code>(a + b) / c</code>를 의도하고 작성했을 확률이 높다. 체이닝 순서대로 먼저 <code>a + b</code>를 계산한 뒤 <code>c</code>로 나누는 것처럼 읽히기 때문이다. 하지만 QueryDSL은 이 체이닝을 SQL로 변환할 때 <strong>괄호 없이 연산자를 나열</strong>하기 때문에, SQL의 연산자 우선순위 규칙이 그대로 적용되어 의도와 다른 결과가 나올 수 있다.<br></p>
<h4 id="예시-코드">예시 코드</h4>
<blockquote>
<p>에러가 발생한 실제 코드는 외부에 공개할 수 없기 때문에 간단한 예시 코드로 대체한다</p>
</blockquote>
<pre><code class="language-java">// 개발자의 의도: (a + b) / c
NumberExpression&lt;Integer&gt; result = a.add(b).divide(c);</code></pre>
<h4 id="의도한-수식">의도한 수식</h4>
<pre><code>(a + b) / c = (10 + 20) / 5 = 6</code></pre><h4 id="실제-생성되는-sql과-결과">실제 생성되는 SQL과 결과</h4>
<pre><code class="language-sql">-- 괄호 없이 생성됨
a + b / c
-- SQL 연산자 우선순위에 의해 나눗셈이 먼저 수행됨
10 + 20 / 5 = 14</code></pre>
<p>자바 코드의 메서드 호출 순서는 SQL의 연산 순서를 보장하지 않는다.<br></p>
<h3 id="해결책">해결책</h3>
<p><code>Expressions.numberTemplate()</code>을 사용하여 괄호를 명시적으로 포함할 수 있다.</p>
<pre><code class="language-java">// ✅ numberTemplate으로 괄호를 직접 명시
NumberExpression&lt;Integer&gt; result = Expressions.numberTemplate(
    Integer.class,
    &quot;({0} + {1}) / {2}&quot;,
    a, b, c
);</code></pre>
<br>

<h3 id="정리">정리</h3>
<table>
<thead>
<tr>
<th>상황</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>단순 체이닝 (<code>a.multiply(b)</code>)</td>
<td>우선순위 이슈가 없는 경우 결과에 영향 없음</td>
</tr>
<tr>
<td>덧셈/뺄셈 후 곱셈/나눗셈 체이닝</td>
<td>SQL 우선순위와 충돌 → <code>numberTemplate</code> 필요</td>
</tr>
<tr>
<td>복잡한 수식</td>
<td>전체를 <code>numberTemplate</code>으로 작성 권장</td>
</tr>
</tbody></table>
<p>메서드 체이닝이 코드의 가독성을 높여주지만, QueryDSL은 체이닝 순서를 SQL 괄호로 변환해주지 않는다. 연산 순서가 중요한 수식에서는 <code>numberTemplate</code>을 통해 괄호를 직접 작성해야 한다!<br></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] E2E 테스트 - RestAssured]]></title>
            <link>https://velog.io/@joeun-01/E2E-%ED%85%8C%EC%8A%A4%ED%8A%B8-RestAssured</link>
            <guid>https://velog.io/@joeun-01/E2E-%ED%85%8C%EC%8A%A4%ED%8A%B8-RestAssured</guid>
            <pubDate>Mon, 20 Oct 2025 16:11:18 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>API가 제대로 동작하는 것을 확인하기 위해서 통합 테스트를 작성하게 되었다. Spring Boot에서는 RestAssured라는 라이브러리를 사용해서 통합 테스트를 작성한다는 것을 알게 되었고, 하는 김에 사용 방법을 정리해보기로 했다. 참고로 이 테스트는 Testcontainers로 테스트 환경을 구축하였다.</p>
</blockquote>
<br>

<h2 id="e2e-end-to-end-테스트">E2E (End to End) 테스트</h2>
<hr>
<h3 id="e2e-end-to-end-테스트-1">E2E (End to End) 테스트</h3>
<p>E2E(End to End) 테스트는 실제 사용자 관점에서 애플리케이션의 전체 흐름을 검증하는 테스트 방식이다. HTTP 요청을 보내고 응답을 받기까지의 전체 플로우를 테스트하기 때문에 실제 운영 환경과 가장 유사한 조건에서 테스트할 수 있다는 장점이 있다.</p>
<p>E2E 테스트의 가장 큰 특징은 실제 데이터베이스를 사용한다는 점이다. 이를 통해 단순히 기능이 동작하는지를 넘어서 실제 사용자가 겪을 수 있는 시나리오를 그대로 재현할 수 있다. 또한 각 테스트는 독립적으로 실행되어 테스트 간 간섭을 방지한다.
<br></p>
<h3 id="단위-테스트와-e2e-테스트">단위 테스트와 E2E 테스트</h3>
<h4 id="단위-테스트-unit-test">단위 테스트 (Unit Test)</h4>
<p>단위 테스트는 개별 컴포넌트인 Repository나 Service만을 대상으로 테스트한다. 
Mock 객체를 활용하여 외부 의존성을 제거하기 때문에 빠른 실행 속도를 보장한다.
<br></p>
<h4 id="e2e-테스트-end-to-end-test">E2E 테스트 (End-to-End Test)</h4>
<p>E2E 테스트는 HTTP 요청부터 응답까지 전체 플로우를 검증한다. 실제 데이터베이스를 사용하고 운영 환경과 유사한 조건을 제공하기 때문에 통합적인 동작을 확인할 수 있다. 단위 테스트로는 발견하기 어려운 컴포넌트 간 상호작용 문제를 찾아낼 수 있다.
<br></p>
<h2 id="restassured">RestAssured</h2>
<hr>
<h3 id="restassured-1">RestAssured</h3>
<p>RestAssured는 REST API를 테스트하기 위한 Java DSL이다. HTTP 요청과 응답을 검증하는 데 특화되어 있으며, Given-When-Then 패턴을 사용하여 가독성 높은 테스트 코드를 작성할 수 있다. 또한 메서드 체이닝 방식을 통해 직관적이고 읽기 쉬운 테스트 코드를 작성할 수 있다.
<br></p>
<h3 id="given-when-then-패턴">Given-When-Then 패턴</h3>
<p>RestAssured는 Given-When-Then 패턴을 기반으로 동작한다. 이 패턴은 테스트를 세 단계로 명확하게 구분하여 가독성을 높여준다.</p>
<pre><code class="language-java">@Test
void exampleTest() {
    // given - 테스트 준비
    LoginRequest request = new LoginRequest(&quot;test@example.com&quot;, &quot;password&quot;);

    // when - 실제 동작 수행
    ExtractableResponse&lt;Response&gt; response = RestAssured.given()
        .body(request)
        .post(&quot;/app/auths&quot;)
        .then()
        .extract();

    // then - 결과 검증
    assertThat(response.statusCode()).isEqualTo(200);
}</code></pre>
<p>Given 단계에서는 테스트에 필요한 데이터와 상태를 준비한다. 요청 객체나 헤더, 파라미터 등을 설정하는 단계다. When 단계에서는 실제 API를 호출한다. RestAssured의 메서드 체이닝을 사용하여 요청을 보낸다. Then 단계에서는 응답 상태 코드와 응답 본문 등을 검증한다.
<br></p>
<h3 id="http-요청">HTTP 요청</h3>
<pre><code class="language-java">// GET
RestAssured.given().log().all()
    .when()
    .get(&quot;/api/users&quot;)
    .then().log().all()
    .extract();

// POST (JSON)
RestAssured.given().log().all()
    .body(request)
    .contentType(MediaType.APPLICATION_JSON_VALUE)
    .when()
    .post(&quot;/api/users&quot;)
    .then().log().all()
    .extract();

// PATCH
RestAssured.given().log().all()
    .body(request)
    .contentType(MediaType.APPLICATION_JSON_VALUE)
    .when()
    .patch(&quot;/api/users/1&quot;)
    .then().log().all()
    .extract();

// DELETE
RestAssured.given().log().all()
    .when()
    .delete(&quot;/api/users/1&quot;)
    .then().log().all()
    .extract();</code></pre>
<p>POST나 PATCH 요청처럼 Body가 필요한 경우 <code>body()</code> 메서드로 요청 객체를 전달하고, <code>contentType()</code>으로 Content-Type을 지정해주면 된다.
<br></p>
<h3 id="헤더-쿠키-파라미터">헤더, 쿠키, 파라미터</h3>
<pre><code class="language-java">// 헤더
RestAssured.given()
    .header(&quot;Authorization&quot;, &quot;Bearer &quot; + accessToken)
    .when()
    .get(&quot;/api/users/profile&quot;)

// 쿠키
RestAssured.given()
    .cookie(&quot;refreshToken&quot;, refreshToken)
    .when()
    .post(&quot;/api/refresh&quot;)

// 쿼리 파라미터
RestAssured.given()
    .queryParam(&quot;page&quot;, 1)
    .queryParam(&quot;size&quot;, 10)
    .when()
    .get(&quot;/api/users&quot;)

// Path Variable
RestAssured.given()
    .pathParam(&quot;userId&quot;, 123)
    .when()
    .get(&quot;/api/users/{userId}&quot;)</code></pre>
<p>API 테스트를 작성하다 보면 인증 토큰을 헤더에 담거나 쿼리 파라미터를 전달해야 하는 경우가 많다. 특히 인증이 필요한 API를 테스트할 때는 로그인 후 받은 토큰을 헤더에 담아서 요청을 보내는 방식을 사용하면 된다.
<br></p>
<h3 id="응답-검증">응답 검증</h3>
<h4 id="상태-코드-검증">상태 코드 검증</h4>
<pre><code class="language-java">assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());</code></pre>
<br>

<h4 id="json-응답-추출">JSON 응답 추출</h4>
<table>
<thead>
<tr>
<th>메서드</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td><code>getString()</code></td>
<td>이름, 이메일, 메시지 등</td>
</tr>
<tr>
<td><code>getInt()</code></td>
<td>나이, 개수, 페이지 번호 등</td>
</tr>
<tr>
<td><code>getLong()</code></td>
<td>ID, 타임스탬프 등</td>
</tr>
<tr>
<td><code>getBoolean()</code></td>
<td>상태, 플래그 등</td>
</tr>
<tr>
<td><code>getList()</code></td>
<td>목록 데이터</td>
</tr>
<tr>
<td><br></td>
<td></td>
</tr>
</tbody></table>
<h4 id="검증-메서드">검증 메서드</h4>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>isEqualTo()</code></td>
<td>정확히 일치하는지</td>
</tr>
<tr>
<td><code>isNotNull()</code></td>
<td>null이 아닌지</td>
</tr>
<tr>
<td><code>contains()</code></td>
<td>부분 문자열 포함</td>
</tr>
<tr>
<td><code>hasSize()</code></td>
<td>배열/리스트 크기</td>
</tr>
<tr>
<td><code>isGreaterThan()</code></td>
<td>숫자 비교</td>
</tr>
<tr>
<td><br></td>
<td></td>
</tr>
</tbody></table>
<h3 id="인증-처리">인증 처리</h3>
<pre><code class="language-java">// 토큰 획득
ExtractableResponse&lt;Response&gt; loginResponse = RestAssured.given()
    .body(loginRequest)
    .contentType(MediaType.APPLICATION_JSON_VALUE)
    .when()
    .post(&quot;/api/auth/login&quot;)
    .then()
    .extract();

String accessToken = loginResponse.jsonPath().getString(&quot;result.accessToken&quot;);

// 토큰 사용
RestAssured.given()
    .header(&quot;Authorization&quot;, &quot;Bearer &quot; + accessToken)
    .when()
    .get(&quot;/api/users/profile&quot;)</code></pre>
<p>이런 방식으로 실제 사용자가 로그인하고 인증된 상태로 API를 호출하는 플로우를 그대로 재현할 수 있다.
<br></p>
<h3 id="로깅">로깅</h3>
<p>RestAssured는 요청과 응답을 로깅할 수 있는 다양한 옵션을 제공한다.</p>
<h4 id="요청-로깅">요청 로깅</h4>
<ul>
<li><code>given().log().all()</code> : 모든 요청 정보</li>
<li><code>given().log().headers()</code> : 요청 헤더</li>
<li><code>given().log().body()</code> : 요청 바디<br>

</li>
</ul>
<h4 id="응답-로깅">응답 로깅</h4>
<ul>
<li><code>then().log().all()</code> : 모든 응답 정보</li>
<li><code>then().log().status()</code> : 응답 상태 코드</li>
<li><code>then().log().ifError()</code> : 에러 시에만 로깅<br>

</li>
</ul>
<h2 id="e2e-테스트-환경-구축하기">E2E 테스트 환경 구축하기</h2>
<hr>
<h3 id="1-의존성-설정">1. 의존성 설정</h3>
<pre><code class="language-groovy">testImplementation &#39;io.rest-assured:rest-assured&#39;</code></pre>
<br>

<h3 id="2-acceptancetest-기본-설정">2. AcceptanceTest (기본 설정)</h3>
<pre><code class="language-java">@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
@ActiveProfiles(&quot;test&quot;)
@Sql(scripts = &quot;/sql/data.sql&quot;, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
class UserAcceptanceTest {

    @LocalServerPort
    int port;

    @BeforeEach
    void setUp() {
        RestAssured.port = port;
    }

    @Test
    void 테스트_메서드() {
        // 테스트 코드 작성
    }
}</code></pre>
<p>모든 E2E 테스트에서 공통으로 사용할 기본 설정을 담은 클래스를 작성한다.</p>
<ul>
<li><code>RANDOM_PORT</code> : 충돌 방지를 위해 랜덤 포트를 사용한다.</li>
<li><code>@DirtiesContext</code> : 데이터 격리를 위해 각 테스트마다 Spring Context를 초기화한다.</li>
<li><code>@ActiveProfiles(&quot;test&quot;)</code> : test 프로필을 활성화한다.</li>
<li><code>@Sql</code> : 각 테스트 전에 초기 데이터를 삽입하는 SQL문을 실행한다.<br>

</li>
</ul>
<h2 id="restassured로-e2e-테스트-작성하기">RestAssured로 E2E 테스트 작성하기</h2>
<hr>
<h3 id="로그인-→-정보-조회">로그인 → 정보 조회</h3>
<p>로그인 후 받은 토큰으로 사용자 정보를 조회하는 시나리오이다.</p>
<pre><code class="language-java">@DisplayName(&quot;로그인 후 정보 조회를 진행한다&quot;)
@Test
void fullUserJourney() {
    // 1. 로그인
    LoginRequest loginRequest = new LoginRequest(
        &quot;user@example.com&quot;, &quot;password&quot;
    );

    ExtractableResponse&lt;Response&gt; loginResponse = RestAssured.given().log().all()
        .body(loginRequest)
        .contentType(MediaType.APPLICATION_JSON_VALUE)
        .when()
        .post(&quot;/app/auths&quot;)
        .then().log().all()
        .extract();

    String accessToken = loginResponse.jsonPath().getString(&quot;result.accessToken&quot;);
    Long userId = loginResponse.jsonPath().getLong(&quot;result.userId&quot;);

    // 2. 사용자 정보 조회
    ExtractableResponse&lt;Response&gt; userInfoResponse = RestAssured.given().log().all()
        .header(&quot;Authorization&quot;, &quot;Bearer &quot; + accessToken)
        .when()
        .get(&quot;/app/users/&quot; + userId)
        .then().log().all()
        .extract();

    assertThat(userInfoResponse.statusCode())
            .isEqualTo(HttpStatus.OK.value());

    assertThat(userInfoResponse.jsonPath().getString(&quot;result.userInfo.email&quot;))
        .isEqualTo(&quot;user@example.com&quot;);
}</code></pre>
<p>각 단계의 응답에서 필요한 데이터를 추출하여 다음 단계에 활용한다. 하드코딩된 값이 아닌 동적으로 생성된 값을 사용하기 때문에 실제 운영 환경과 동일한 플로우를 테스트할 수 있다. 또한 <code>log().all()</code>을 사용하여 모든 요청과 응답을 로깅하면 문제가 발생했을 때 디버깅이 훨씬 수월하다.
<br></p>
<h3 id="예외-상황-테스트하기">예외 상황 테스트하기</h3>
<p>정상 케이스만큼 중요한 것이 예외 상황에 대한 테스트다. 잘못된 입력에 대해 적절한 에러 응답을 반환하는지 확인해야 한다.</p>
<pre><code class="language-java">@DisplayName(&quot;빈 이메일과 비밀번호로 로그인하면 400 Bad Request를 반환한다&quot;)
@Test
void loginWithEmptyCredentials() {
    UserLoginRequest loginRequest = new UserLoginRequest(&quot;&quot;, &quot;&quot;);

    ExtractableResponse&lt;Response&gt; response = RestAssured.given().log().all()
        .body(loginRequest)
        .contentType(MediaType.APPLICATION_JSON_VALUE)
        .when()
        .post(&quot;/app/auths&quot;)
        .then().log().all()
        .extract();

    assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value());
}</code></pre>
<p>이처럼 유효성 검증이 제대로 동작하는지, 적절한 상태 코드를 반환하는지 확인하는 테스트도 작성해야 한다.
<br></p>
<h2 id="마치며">마치며</h2>
<p>E2E 테스트를 도입하면서 API의 전체 플로우를 검증할 수 있게 되었다. 테스트 코드 덕분에 내가 작성한 API가 의도한대로 잘 동작하는지 확인할 수 있었다. 또한 RestAssured의 문법이 직관적이라 어렵지 않게 작성할 수 있던 것 같다.
<br></p>
<hr>
<h3 id="참고-자료">참고 자료</h3>
<p><a href="https://rest-assured.io/">REST Assured</a>
<a href="https://loopstudy.tistory.com/427">REST-Assured 알아보기 (테스트를 위한 클라이언트 객체)</a>
<a href="https://velog.io/@jjy5349/RestAssured%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%9E%91%EC%84%B1%ED%95%98%EA%B8%B0">RestAssured를 이용한 테스트 코드 작성하기</a>
<a href="https://velog.io/@chocochip/RestAssured-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0">REST-assured 알아보기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] TestFixtures로 테스트 의존성 관리하기]]></title>
            <link>https://velog.io/@joeun-01/TestFixtures%EB%A1%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%9D%98%EC%A1%B4%EC%84%B1-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@joeun-01/TestFixtures%EB%A1%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%9D%98%EC%A1%B4%EC%84%B1-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 09 Oct 2025 14:57:30 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>테스트 코드를 작성하다보면 중복 코드가 많이 발생한다. 특히 멀티 모듈 환경에서는 test 폴더의 코드를 다른 모듈에서 사용할 수 없기 때문에 모듈마다 같은 코드를 반복해서 작성해야 했다. 이렇게 되면 이후에 유지보수도 어렵게 된다. 같은 내용을 모듈마다 작성하다 보니 다른 방법이 없을지 찾아보게 되었고 TestFixtures라는 것을 알게 되었다. </p>
</blockquote>
<br> 

<h2 id="testfixtures">TestFixtures</h2>
<hr>
<h3 id="testfixtures-1">TestFixtures</h3>
<p>Gradle에서 제공하는 기능으로, 테스트 관련 코드를 다른 모듈에서 재사용할 수 있다. 예를 들어 공통으로 사용하는 Builder 클래스나 Helper 클래스를 한 곳에서 관리할 수 있다. 또한 상위 모듈의 테스트 전용 의존성까지 함께 전파되어 각 모듈에서 의존성을 중복으로 추가하지 않아도 된다. 따라서 TestFixtures를 사용하면 테스트 코드 관련 중복 코드를 최소화할 수 있고 유지보수에 용이하다.
<br></p>
<h3 id="testfixtures의-장점">TestFixtures의 장점</h3>
<ol>
<li>모든 모듈이 동일한 테스트 설정을 공유하여 일관된 테스트 환경을 제공한다.</li>
<li>테스트 관련 공통 설정을 한 곳에서 관리할 수 있어 중복 코드를 제거할 수 있다.</li>
<li>테스트 설정이 변경되거나 새로운 모듈을 추가할 때 <code>testFixtures</code> 폴더만 수정하면 모든 곳에 반영된다. 따라서 유지보수가 용이하다.<br>

</li>
</ol>
<h3 id="testfixtures-구조">TestFixtures 구조</h3>
<pre><code>project-root
├── core
│   ├── src/main           # 일반 코드
│   ├── src/test           # core 모듈 테스트
│   └── src/testFixtures   # 🎯 공유할 테스트 설정
│       └── java/com/example/core
│           ├── config
│           │   └── TestContainerConfig.java
│           └── test
│               └── AcceptanceTestConfig.java
├── 하위 모듈 1              # testFixtures 의존성 사용
└── 하위 모듈 2              # testFixtures 의존성 사용
</code></pre><p>TestFixtures를 사용하면 위와 같은 멀티 모듈 환경에서 상위 모듈(core)의 <code>src/testFixtures</code> 디렉토리에 있는 코드를 하위 모듈에서 사용할 수 있게 된다.
<br></p>
<h2 id="testfixtures-적용하는-법">TestFixtures 적용하는 법</h2>
<hr>
<h3 id="1-core-모듈-의존성-설정">1. Core 모듈 의존성 설정</h3>
<pre><code class="language-groovy">plugins {
    id &#39;java-test-fixtures&#39;  // TestFixtures 활성화
}

dependencies {
    testFixturesApi &#39;org.springframework.boot:spring-boot-starter-test&#39;
    testFixturesImplementation &#39;org.testcontainers:testcontainers&#39;
    testFixturesImplementation &#39;org.testcontainers:junit-jupiter&#39;
    testFixturesImplementation &#39;org.testcontainers:mysql&#39;
    testFixturesImplementation &#39;org.testcontainers:jdbc&#39;
    testFixturesApi &#39;io.rest-assured:rest-assured&#39;
}
</code></pre>
<ul>
<li><code>java-test-fixtures</code> : TestFixtures 기능을 활성화한다</li>
<li><code>testFixturesApi</code> : 하위 모듈에 테스트 관련 의존성을 전파한다</li>
<li><code>testFixturesImplementation</code> : 하위 모듈에 테스트 관련 의존성을 전파하지 않으며, core 모듈 내부에서만 사용된다.<br>

</li>
</ul>
<h3 id="2-공유할-테스트-코드-설정-작성-ex-config-파일">2. 공유할 테스트 코드 설정 작성 (ex. Config 파일)</h3>
<pre><code class="language-java">public abstract class BaseAcceptanceTest {
    static MySQLContainer&lt;?&gt; mysqlContainer;

    @BeforeAll
    static void startContainer() {
        mysqlContainer = new MySQLContainer&lt;&gt;(&quot;mysql:8.0&quot;)
                .withDatabaseName(&quot;testdb&quot;)
                .withUsername(&quot;test&quot;)
                .withPassword(&quot;test&quot;)
                .withReuse(true);  // 컨테이너 재사용으로 속도 향상

        mysqlContainer.start();
    }

    @AfterAll
    static void stopContainer() {
        if (mysqlContainer != null) {
            mysqlContainer.stop();
        }
    }

    @DynamicPropertySource
    static void registerProperties(DynamicPropertyRegistry registry) {
        registry.add(&quot;spring.datasource.url&quot;, mysqlContainer::getJdbcUrl);
        registry.add(&quot;spring.datasource.username&quot;, mysqlContainer::getUsername);
        registry.add(&quot;spring.datasource.password&quot;, mysqlContainer::getPassword);
        registry.add(&quot;spring.datasource.driver-class-name&quot;, () -&gt; &quot;com.mysql.cj.jdbc.Driver&quot;);
    }
}</code></pre>
<p><code>java-test-fixtures</code> 플러그인 활성화 후 IntelliJ에서 New → Directory 선택 시 <code>testFixtures</code> 옵션이 나타난다. 반드시 <code>src/testFixtures</code> 폴더 하위에서 코드를 작성해야 하위 모듈에서 접근이 가능하다. 그 외 폴더에 있는 코드는 하위 모듈에서 참조할 수 없다.
<br></p>
<h3 id="3-하위-모듈에서-테스트-옵션-사용">3. 하위 모듈에서 테스트 옵션 사용</h3>
<h4 id="3-1-의존성-추가">3-1. 의존성 추가</h4>
<pre><code class="language-groovy">dependencies {
    implementation project(&#39;:core&#39;)
    testImplementation(testFixtures(project(&#39;:core&#39;)))
}</code></pre>
<p>하위 모듈의 <code>build.gradle</code>에서 <code>testImplementation</code> 을 통해 <code>core</code> 모듈의 <code>testFixtures</code> 코드를 참조하여 사용할 수 있다. <code>project</code> 안에 받아올 모듈 이름을 적어주면 된다. 만약 모듈 이름이 <code>core-web</code>이라면 <code>project(&#39;:core-web&#39;)</code>처럼 작성하면 된다. 의존성을 추가하지 않으면 하위 모듈에서는 <code>testFixtures</code> 코드를 불러올 수 없다.
<br></p>
<h4 id="3-2-코드에서-사용">3-2. 코드에서 사용</h4>
<pre><code class="language-java">class HealthControllerTest extends BaseAcceptanceTest {
    @Test
    void healthCheck() {
        // given &amp; when
        ExtractableResponse&lt;Response&gt; response = RestAssured.given().log().all()
                .when()
                .get(&quot;/health/check&quot;)
                .then().log().all()
                .extract();

        // then
        assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());
    }
}</code></pre>
<p>의존성을 추가하고 나면 <code>core</code> 모듈에서 정의한 설정을 하위 모듈에서 사용할 수 있다. 여기에서는 위에서 추가한 <code>BaseAcceptanceTest</code>를 상속받았기 때문에 Testcontainers 설정이 자동으로 적용되어 MySQL 컨테이너가 실행된다.
<br></p>
<h2 id="마치며">마치며</h2>
<hr>
<p>TestFixtures를 사용하니 각 모듈에서 공통으로 사용하는 코드를 편리하게 관리할 수 있었다. 테스트 코드를 작성할 때 중복 코드가 발생하는 것을 보며 &#39;이게 최선일까?&#39;라는 생각을 계속 했는데 TestFixtures를 적용하니 코드가 확실히 정돈된 느낌을 받았다.
<br></p>
<h3 id="참고-자료">참고 자료</h3>
<p><a href="https://docs.gradle.org/current/userguide/java_testing.html#sec:java_test_fixtures">Testing in Java &amp; JVM projects</a>
<a href="https://bottom-to-top.tistory.com/58">Gradle TestFixtures 이용하여 테스트 코드 중복 줄이기</a>
<a href="https://medium.com/@jojiapp/gradle-multi-module%EC%97%90%EC%84%9C-testfixtures%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%A4%91%EB%B3%B5-%EC%A4%84%EC%9D%B4%EA%B8%B0-3a4737f574f">[Gradle] Multi Module에서 testFixtures를 이용하여 테스트 코드 중복 줄이기</a>
<a href="https://toss.tech/article/how-to-manage-test-dependency-in-gradle">테스트 의존성 관리로 높은 품질의 테스트 코드 유지하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] Testcontainers로 테스트 환경 구축하기]]></title>
            <link>https://velog.io/@joeun-01/Testcontainers%EB%A1%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@joeun-01/Testcontainers%EB%A1%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 08 Oct 2025 08:30:09 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>테스트 코드를 작성하면서 테스트 환경을 구축하는 것에 어려움을 겪었다. 특히 테스트를 할 때는 보통 내장 DB인 H2를 많이 사용하게 되는데, H2를 사용하면 테스트 환경과 운영 환경 사이에 간극이 생기는 경우가 있었다. 이를 해결할 수 있는 방법이 없을까 찾아보다가 Testcontainers라는 것을 알게 되었고, 덕분에 복잡한 설정 없이 테스트 환경을 구축할 수 있었다. Testcontainers에 대해 기억하기 위해 블로그를 작성하고자 한다!</p>
</blockquote>
<br> 

<h2 id="testcontainers">Testcontainers</h2>
<hr>
<h3 id="testcontainers-1">Testcontainers</h3>
<p>도커 컨테이너에서 실행할 수 있는 테스트 라이브러리이다. 도커 컨테이너에서 실행이 되기 때문에 반드시 도커가 설치되어 있어야 하며, 모든 상황에서 일관된 테스트 환경을 보장한다.
또한, 도커 컨테이너를 활용해서 외부 의존성을 포함한 테스트 환경 구축 및 관리를 편리하게 할 수 있다는 장점이 있다. 테스트가 시작되면 자동으로 컨테이너가 실행되고, 테스트가 종료되면 자동으로 컨테이너가 정리된다.
<br></p>
<h3 id="testcontainers의-장점">Testcontainers의 장점</h3>
<ol>
<li>다양한 언어와 테스트 프레임워크를 지원한다. </li>
<li>복잡한 설정 없이도 컨테이너화된 데이터베이스 인스턴스를 사용하여 테스트를 진행할 수 있다. 로컬에 MySQL이나 PostgreSQL 등의 DB를 직접 설치하지 않아도 되고, 도커만 있으면 실행이 가능하다. </li>
<li>실제 운영 환경과 동일한 테스트 환경을 구축할 수 있다. H2와 같은 내장 DB가 아닌 실제 DB 컨테이너에서 테스트를 진행하기 때문에 운영 환경에서 발생할 수 있는 문제를 미리 발견할 수 있다.<br>

</li>
</ol>
<h3 id="주의사항">주의사항</h3>
<p>Testcontainers를 사용하기 전에 알아두어야 할 몇 가지 주의사항이 있다. </p>
<ol>
<li>도커 컨테이너를 사용하기 때문에 도커 데몬이 실행 중이어야 한다.</li>
<li>로컬 뿐만 아니라 CI/CD 환경에서도 도커가 사용 가능하도록 설정되어 있어야 한다.</li>
<li>실행 할 때마다 도커 이미지 다운로드에 의한 시간이 소요될 수 있다. 재사용 옵션을 활성화하면 첫 실행 이후에는 이 시간을 줄일 수 있다.<br>

</li>
</ol>
<h2 id="h2-내장-db-vs-testcontainers">H2 내장 DB vs Testcontainers</h2>
<hr>
<h3 id="기존-방식h2의-문제점">기존 방식(H2)의 문제점</h3>
<pre><code class="language-yaml">spring:
  datasource:
    url: jdbc:h2:mem:testdb
    username: username
    password: password
    driver-class-name: org.h2.Driver</code></pre>
<p>H2를 사용하면 간편하게 내장 DB를 통해 테스트를 할 수 있지만 문제점이 있다.
MySQL 혹은 PostgreSQL 등의 전용 문법이 H2에서 동작하지 않는 경우가 있다. 반대로 H2에서는 동작하지만, 타DB에서 지원하지 않는 문법도 있다. 따라서 테스트는 통과했으나 운영 환경에서 오류가 발생할 수 있다.
<br></p>
<h3 id="testcontainers-방식">Testcontainers 방식</h3>
<pre><code class="language-yaml">spring:
  datasource:
    url: jdbc:tc:mysql:8.0:///testdb?useUnicode=true&amp;characterEncoding=UTF-8&amp;serverTimezone=UTC
    username: username
    password: password
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver</code></pre>
<p>하지만 Testcontainers를 사용하면 이와 같은 문제를 해결할 수 있다. yml 파일을 통해 Testcontainers의 DB를 설정하는 코드이다. 이렇게 하면 MySQL 컨테이너에서 테스트가 가능하다. 물론, 설정을 통해 MySQL 외에도 다양한 DB를 사용할 수 있다. 개발자 환경에 DB를 따로 설치하지 않아도 되며 컨테이너를 통해 알아서 처리된다.
<br></p>
<h2 id="testcontainers-구현하기">Testcontainers 구현하기</h2>
<hr>
<h3 id="1-의존성-설정">1. 의존성 설정</h3>
<pre><code class="language-groovy">dependencies {
    testImplementation &#39;org.springframework.boot:spring-boot-starter-test&#39;
    testImplementation &#39;org.testcontainers:testcontainers&#39;
    testImplementation &#39;org.testcontainers:junit-jupiter&#39;
    testImplementation &#39;org.testcontainers:mysql&#39;
    testImplementation &#39;org.testcontainers:jdbc&#39;
}</code></pre>
<p><code>testImplementation</code>을 사용하여 테스트 범위에만 의존성을 추가한다.
<br></p>
<h4 id="1-1-의존성-설명">1-1. 의존성 설명</h4>
<table>
<thead>
<tr>
<th>의존성</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>spring-boot-starter-test</code></td>
<td>Spring Boot 테스트 라이브러리<br>JUnit, AssertJ, Mockito 등 테스트를 위한 기본 라이브러리를 포함한다</td>
</tr>
<tr>
<td><code>testcontainers</code></td>
<td>Testcontainers 코어 라이브러리</td>
</tr>
<tr>
<td><code>junit-jupiter</code></td>
<td><code>@Testcontainers</code>, <code>@Container</code> 등의 어노테이션을 제공한다</td>
</tr>
<tr>
<td><code>mysql</code></td>
<td>MySQL 전용 컨테이너 모듈<br>사용하는 DB에 따라 다른 라이브러리 사용할 수 있다 (예: <code>postgresql</code>, <code>mongodb</code>)</td>
</tr>
<tr>
<td><code>jdbc</code></td>
<td>JDBC URL을 통한 간편한 컨테이너 실행을 지원한다</td>
</tr>
<tr>
<td><br></td>
<td></td>
</tr>
</tbody></table>
<h3 id="2-testcontainerconfig">2. TestContainerConfig</h3>
<pre><code class="language-groovy">@TestConfiguration
@Testcontainers
public class TestContainerConfig {
    @Container
    static MySQLContainer&lt;?&gt; mysql = new MySQLContainer&lt;&gt;(&quot;mysql:8.0&quot;)
            .withDatabaseName(&quot;testdb&quot;)
            .withUsername(&quot;username&quot;)
            .withPassword(&quot;password&quot;)
            .withReuse(true); // 컨테이너 재사용 활성화

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add(&quot;spring.datasource.url&quot;, mysql::getJdbcUrl);
        registry.add(&quot;spring.datasource.username&quot;, mysql::getUsername);
        registry.add(&quot;spring.datasource.password&quot;, mysql::getPassword);
        registry.add(&quot;spring.datasource.driver-class-name&quot;, () -&gt; &quot;com.mysql.cj.jdbc.Driver&quot;);
    }

    @Bean
    public MySQLContainer&lt;?&gt; mysqlContainer() {
        return mysql;
    }
}</code></pre>
<p>Testcontainers 설정을 위한 Config 파일을 작성한다. 
Config 파일을 작성해도 되고, 위처럼 yml 파일을 통해 Testcontainers 설정을 할 수도 있다. 
<br></p>
<h4 id="2-1-어노테이션-설명">2-1. 어노테이션 설명</h4>
<table>
<thead>
<tr>
<th>어노테이션</th>
<th>상세 설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>@TestConfiguration</code></td>
<td>테스트 설정 클래스 지정<br>테스트 실행 시에만 로드되도록 한다</td>
</tr>
<tr>
<td><code>@Testcontainers</code></td>
<td>Testcontainers의 생명주기를 관리한다</td>
</tr>
<tr>
<td><code>@Container</code></td>
<td>관리해야 하는 컨테이너임을 표시한다<br>static으로 선언하면 클래스 레벨에서 컨테이너를 공유할 수 있어, 여러 테스트 메서드에서 동일한 컨테이너를 사용할 수 있다<br>이렇게 하지 않으면 테스트 메서드가 실행될 때마다 새로운 컨테이너가 생성되어 테스트 속도가 느려질 수 있다</td>
</tr>
<tr>
<td><code>@DynamicPropertySource</code></td>
<td>컨테이너 실행 이후에 동적으로 생성되는 정보를 환경변수로 주입한다<br>주입된 설정으로 실제 DB에 연결하여 테스트를 수행한다</td>
</tr>
<tr>
<td><br></td>
<td></td>
</tr>
</tbody></table>
<h4 id="2-2-testcontainerconfig-동작-원리">2-2. TestContainerConfig 동작 원리</h4>
<ol>
<li>테스트 실행 시 <code>@Testcontainers</code>가 <code>@Container</code> 필드를 찾아 MySQL 컨테이너를 시작한다.</li>
<li>컨테이너 시작 후 랜덤 포트가 할당된다.</li>
<li><code>@DynamicPropertySource</code>가 동적 정보를 Spring에 주입한다.</li>
<li>테스트 코드에서 주입된 설정으로 실제 MySQL을 연결한다.</li>
<li>모든 테스트 종료 후 컨테이너는 자동으로 정리된다.<br>

</li>
</ol>
<h3 id="3-테스트-작성---e2e-테스트">3. 테스트 작성 - E2E 테스트</h3>
<pre><code class="language-java">@SpringBootTest
@ContextConfiguration(classes = TestContainerConfig.class)
class HealthControllerTest {

    @Test
    void healthCheck() {
        ExtractableResponse&lt;Response&gt; response = RestAssured.given().log().all()
                .when()
                .get(&quot;/health/check&quot;)
                .then().log().all()
                .extract();

        assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());
    }
}</code></pre>
<p><code>@ContextConfiguration</code>을 통해 앞서 작성한 Testcontainers 설정을 주입한다. 이렇게 하면 MySQL 컨테이너에 연결되어 테스트가 실행된다. H2가 아닌 실제 MySQL DB에서 테스트를 진행하기 때문에 운영 환경과 동일한 조건에서 검증이 가능하다.
<br></p>
<h3 id="추가-성능-최적화---컨테이너-재사용">추가) 성능 최적화 - 컨테이너 재사용</h3>
<h4 id="컨테이너-재사용이-필요한-이유">컨테이너 재사용이 필요한 이유</h4>
<p>Testcontainers는 기본적으로 테스트를 진행할 때마다 새 컨테이너를 생성한다. 테스트를 실행할 때마다 컨테이너 생성/삭제로 인한 오버헤드가 발생하는데, 재사용 옵션을 통해 이를 개선할 수 있다.
<br></p>
<h4 id="컨테이너-재사용-설정---config-파일">컨테이너 재사용 설정 - Config 파일</h4>
<pre><code class="language-java">@Container
static MySQLContainer&lt;?&gt; mysql = new MySQLContainer&lt;&gt;(&quot;mysql:8.0&quot;)
        .withDatabaseName(&quot;testdb&quot;)
        .withUsername(&quot;username&quot;)
        .withPassword(&quot;password&quot;)
        .withReuse(true);</code></pre>
<p>Config 파일에서 <code>withReuse()</code> 옵션을 통해 컨테이너 재사용 옵션을 추가할 수 있다. <code>withReuse(true)</code>로 설정하면 첫 실행에는 컨테이너를 생성하고, 이후 실행에서는 이미 만들어진 기존 컨테이너를 재사용한다.
<br></p>
<h4 id="컨테이너-재사용-설정---yml-파일">컨테이너 재사용 설정 - yml 파일</h4>
<pre><code class="language-yaml">testcontainers:
  reuse:
    enable: true</code></pre>
<p>Config 파일과 마찬가지로 yml 파일에서도 컨테이너 재사용 옵션을 추가할 수 있다.
<br></p>
<h2 id="마치며">마치며</h2>
<hr>
<p>Testcontainers를 통해 yml 혹은 Config 파일을 정의하여 편리하게 테스트 환경을 구축할 수 있었다. Testcontainers를 사용하면 실제 운영 환경과 동일한 DB에서 테스트를 할 수 있어, 운영 환경에서 발생할 수 있는 문제를 사전에 발견할 수 있다. 이번에는 단일 모듈에서 테스트 코드를 작성하여 테스트 의존성을 따로 관리하지 않아도 괜찮았지만 멀티 모듈 환경에서는 TestFixtures를 통해 하위 모듈에 의존성을 전파해주어야 한다. 이 방식도 정리하고자 한다!
<br></p>
<h3 id="참고-자료">참고 자료</h3>
<p><a href="https://testcontainers.com/">Testcontainers</a>
<a href="https://testcontainers.com/guides/replace-h2-with-real-database-for-testing/">The simplest way to replace H2 with a real database for testing</a>
<a href="https://java.testcontainers.org/modules/databases/mysql/">MySQL Module - Testcontainers for Java</a>
<a href="https://dev.gmarket.com/76">Testcontainers로 통합테스트 만들기</a>
<a href="https://helloworld.kurly.com/blog/delivery-testContainer-apply/">TestContainers로 유저시나리오와 비슷한 통합테스트 만들어 보기</a>
<a href="https://dami97.tistory.com/73">Spring Boot에서 Testcontainers로 통합 테스트 환경 구축하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[GitHub] 계정 여러 개 사용하는 법]]></title>
            <link>https://velog.io/@joeun-01/GitHub-%EA%B3%84%EC%A0%95-%EC%97%AC%EB%9F%AC-%EA%B0%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B2%95</link>
            <guid>https://velog.io/@joeun-01/GitHub-%EA%B3%84%EC%A0%95-%EC%97%AC%EB%9F%AC-%EA%B0%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B2%95</guid>
            <pubDate>Thu, 21 Aug 2025 06:39:14 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>업무를 진행하다가 프로젝트 전용 깃허브 계정을 사용해야 하는 일이 있었다. 해당 프로젝트 전용 깃허브 계정도 사용해야 하고, 기존에 쓰던 깃허브 계정도 사용해야 하는 상황에서 어떻게 하면 두 계정을 동시에 사용할 수 있을지 알아보게 되었다. 생각보다 과정은 간단했고, 한 번 정리해두면 좋을 것 같아 블로그를 올리게 되었다.</p>
</blockquote>
<br>

<h4 id="1-계정별-ssh-키-발급">1. 계정별 ssh 키 발급</h4>
<pre><code class="language-powershell">ssh-keygen -t rsa -b 4096 -C &quot;깃허브 계정 이메일&quot; -f ~/.ssh/폴더명</code></pre>
<p><code>-f ~/.ssh/폴더명</code> 옵션 없이 명령어를 사용하는 경우에는 기본 폴더인 <code>~/.ssh/id_rsa</code>에 키 발급이 진행된다.
<br></p>
<h4 id="2-ssh-config-파일-수정">2. ssh config 파일 수정</h4>
<p>2-1. config 파일 열기</p>
<pre><code class="language-powershell">nano ~/.ssh/config</code></pre>
<p>2-2. config 파일 수정하기</p>
<pre><code class="language-powershell"># 계정 1
Host 계정 별칭
    HostName github.com
    User git
    IdentityFile ~/.ssh/폴더명         

# 계정 2
Host 계정 별칭
    HostName github.com
    User git
    IdentityFile ~/.ssh/폴더명</code></pre>
<p>계정 별칭 부분에 사용하고 싶은 Host 이름을 설정한다.
IdentityFile에는 위에서 SSH 키를 발급한 위치를 적어준다.</p>
<p>2-3. <code>Ctrl + O</code> → <code>Enter</code> : 파일을 저장한다
2-4. <code>Ctrl + X</code> : 파일을 나간다
<br></p>
<h4 id="3-ssh-key-값-확인">3. ssh key 값 확인</h4>
<pre><code class="language-powershell">cat ~/.ssh/폴더명.pub</code></pre>
<br>

<h4 id="4-github-계정에-ssh-key-등록">4. GitHub 계정에 SSH Key 등록</h4>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/6e4ffd8b-2795-4819-9977-9cf4094d1cc9/image.png" alt="SSH Key 등록"></p>
<br>

<h4 id="5-지정한-host-이름을-사용해서-클론-진행">5. 지정한 Host 이름을 사용해서 클론 진행</h4>
<pre><code class="language-powershell">git clone git@Host이름:joeun-01/programming_practice.git</code></pre>
<p>클론할 때는 SSH 설정으로 들어가서 받아야 한다. 
Host이름: 부분을 위에서 설정한 Host 이름으로 바꿔서 클론을 진행한다.
<br></p>
<h4 id="6-github-config-설정">6. GitHub config 설정</h4>
<pre><code class="language-powershell">git config user.name &quot;깃허브 계정 이름&quot;
git config user.email &quot;깃허브 계정 이메일&quot;</code></pre>
<p>프로젝트별로 GitHub 계정 설정을 다르게 해야 하기 때문에, 프로젝트 하위에 들어가서 config 설정을 해야 한다. 이렇게 하면 해당 프로젝트에만 계정 설정이 진행된다.
<br></p>
<h4 id="7-github-config-확인">7. GitHub config 확인</h4>
<pre><code class="language-powershell">git config --global user.name  # 기본 계정 닉네임
git config --global user.email  # 기본 계정 이메일

git config user.name  # 프로젝트 계정 닉네임
git config user.email  # 프로젝트 계정 이메일</code></pre>
<p>두 명령어를 모두 사용해서 확인해보면 <code>--global</code>을 붙인 경우에는 기본 계정에 대한 정보가, 붙이지 않은 경우에는 해당 프로젝트 내에서 설정한 계정에 대한 정보가 나온다!
<br></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] Docker 환경에서 Selenium WebDriver 설정하기]]></title>
            <link>https://velog.io/@joeun-01/Docker-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-Selenium-WebDriver-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@joeun-01/Docker-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-Selenium-WebDriver-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 29 Jul 2025 08:02:22 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Docker 환경에서 Selenium으로 웹 크롤링을 해야 할 일이 있었다. 로컬 환경에서는 따로 chromium을 다운받지 않고도 실행이 잘 되었지만, Docker 환경에서 돌려보니 크롬을 인식하지 못해서 WebDriver가 초기화되지 않는 문제가 발생했다. 그 과정을 어떻게 해결했는지 정리해보려 한다!</p>
</blockquote>
<p>참고로 내가 마주한 에러는 다음과 같았다.</p>
<pre><code>Error creating bean with name &#39;webDriver&#39; defined in class path resource</code></pre><br>

<h2 id="chromium-설치">Chromium 설치</h2>
<hr>
<h3 id="1-dockerfile를-설정한다">1. Dockerfile를 설정한다</h3>
<pre><code class="language-docker"># Chromium 설치
RUN apk add --no-cache \
    gcompat glib nss libxcb libgcc \
    chromium \
    chromium-chromedriver</code></pre>
<p>Dockerfile에서 Chromium을 설치해야 한다. A1pine Linux 환경에서는 <code>apk</code>를 통해 설치할 수 있다.</p>
<h4 id="패키지-옵션">패키지 옵션</h4>
<table>
<thead>
<tr>
<th>패키지</th>
<th>역할</th>
<th>중요도</th>
</tr>
</thead>
<tbody><tr>
<td><code>gcompat</code></td>
<td>Alpine Linux와 GNU C 라이브러리 호환성 제공</td>
<td>필수</td>
</tr>
<tr>
<td><code>glib</code></td>
<td>Chromium 엔진 프로세스/메모리 관리</td>
<td>필수</td>
</tr>
<tr>
<td><code>nss</code></td>
<td>HTTPS/SSL 암호화 처리</td>
<td>HTTPS 링크 접근에 필수</td>
</tr>
<tr>
<td><code>libxcb</code></td>
<td>X11 클라이언트 라이브러리</td>
<td>헤드리스 모드에서 렌더링에 필수</td>
</tr>
<tr>
<td><code>libgcc</code></td>
<td>GCC 런타임 라이브러리</td>
<td>C++ 예외처리 및 기본 런타임 기능</td>
</tr>
<tr>
<td><code>chromium</code></td>
<td>브라우저</td>
<td>필수</td>
</tr>
<tr>
<td><code>chromium-chromedriver</code></td>
<td>Selenium과 Chromium을 연결하는 드라이버</td>
<td>필수</td>
</tr>
</tbody></table>
<p>여기서 사용한 패키지 외에도 자신이 필요한 패키지를 같이 다운받아서 사용하면 된다.<br></p>
<h3 id="2-webdriverconfig를-작성한다">2. WebDriverConfig를 작성한다</h3>
<pre><code class="language-java">@Configuration
public class SeleniumConfig {
    @Bean
    public WebDriver webDriver() {
        // ChromeDriver 경로 설정 (선택)
        String chromeDriverPath = &quot;/usr/bin/chromedriver&quot;;
        System.setProperty(&quot;webdriver.chrome.driver&quot;, chromeDriverPath);

        // Chromium 바이너리 경로 (필수)
        ChromeOptions options = new ChromeOptions();

        options.setBinary(&quot;/usr/bin/chromium-browser&quot;);

        // 헤드리스 모드
        options.addArguments(&quot;--headless=new&quot;);
        options.addArguments(&quot;--no-sandbox&quot;);
        options.addArguments(&quot;--disable-dev-shm-usage&quot;);
        options.addArguments(&quot;--disable-gpu&quot;);

        return new ChromeDriver(options);
    }
}</code></pre>
<p>설치한 Chromium을 Spring Boot 환경에서 사용하기 위해 <code>WebDriverConfig</code>를 작성해준다. 이때 Chromium 브라우저를 찾기 위해 chromium-browser의 위치를 확인해서 <code>setBinary()</code>에 넣어줘야 한다.</p>
<h4 id="설정-옵션">설정 옵션</h4>
<table>
<thead>
<tr>
<th>옵션</th>
<th>설명</th>
<th>중요도</th>
</tr>
</thead>
<tbody><tr>
<td><code>--headless=new</code></td>
<td>GUI 없이 실행하는 헤드리스 모드</td>
<td>필수</td>
</tr>
<tr>
<td><code>--no-sandbox</code></td>
<td>샌드박스 보안 기능 비활성화</td>
<td>Docker 컨테이너에 필수</td>
</tr>
<tr>
<td><code>--disable-dev-shm-usage</code></td>
<td>/dev/shm 공유 메모리 비활성화</td>
<td>메모리 부족 방지</td>
</tr>
<tr>
<td><code>--disable-gpu</code></td>
<td>GPU 하드웨어 가속 비활성화</td>
<td>안정성 향상</td>
</tr>
<tr>
<td><br></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<h2 id="webdriver-메모리-최적화">WebDriver 메모리 최적화</h2>
<hr>
<h3 id="1-scope를-prototype으로-지정한다">1. Scope를 Prototype으로 지정한다</h3>
<pre><code class="language-java">@Bean
@Scope(&quot;prototype&quot;)
public WebDriver webDriver() {
    // 로직 구현
}</code></pre>
<p>기본적으로 Bean Scope는 singleton으로 설정이 되어 있다. Scope를 singleton으로 설정하면 애플리케이션 전체에서 하나의 <code>WebDriver</code> 인스턴스를 공유하게 된다.
하지만 Scope를 prototype으로 설정하면 <code>getBean()</code>을 호출할 때마다 새로운 <code>WebDriver</code> 인스턴스를 생성한다. prototype을 설정된 Bean은 Spring이 생성만 담당하고, 소멸은 개발자가 직접 관리해야 한다. 따라서 반드시 수동으로 <code>quit()</code>을 호출해야 한다.<br></p>
<h3 id="2-webdriver를-사용할-때마다-생성-및-해제를-진행한다">2. WebDriver를 사용할 때마다 생성 및 해제를 진행한다</h3>
<pre><code class="language-java">private final ApplicationContext context;

public void useWebDriver() {
        WebDriver webDriver = context.getBean(WebDriver.class);

        try {
            // 로직 구현
        } finally {
            webDriver.quit();
        }
}</code></pre>
<p>스케쥴러가 돌 때마다 <code>WebDriver</code> 인스턴스를 생성하고 해제할 수 있도록 한다. 이때 WebDriver는 <code>@Autowired</code>로 직접 주입하면 안 되고, <code>context.getBean(WebDriver.class)</code>로 매번 새로운 인스턴스를 생성해야 한다. 
<code>WebDriver</code> 사용이 끝나면 <code>webDriver.quit()</code>을 통해 인스턴스를 해제한다. 이렇게 하면 <code>WebDriver</code>를 사용할 때만 메모리가 사용되고, <code>quit()</code>이 호출되면 사용하던 메모리를 해제하기 때문에 메모리 최적화에 도움이 된다.<br></p>
<h2 id="참고-자료">참고 자료</h2>
<hr>
<p><a href="https://velog.io/@cobin_dev/%ED%81%AC%EB%A1%AC-%EB%93%9C%EB%9D%BC%EC%9D%B4%EB%B2%84-%EC%85%80%EB%A0%88%EB%8B%88%EC%9B%80-%EB%8F%84%EC%BB%A4-%ED%99%98%EA%B2%BD-%EC%97%B0%EB%8F%99">크롬 드라이버, 셀레니움, 도커 환경 연동</a>
<a href="https://dream-and-develop.tistory.com/484">[Spring] 빈 스코프란? (Singleton, Prototype Scope)</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[AWS] AWS Amplify와 Route53으로 프론트 배포하기]]></title>
            <link>https://velog.io/@joeun-01/AWS-AWS-Amplify%EC%99%80-Route53%EC%9C%BC%EB%A1%9C-%ED%94%84%EB%A1%A0%ED%8A%B8-%EB%B0%B0%ED%8F%AC%ED%95%98%EB%8A%94-%EB%B2%95</link>
            <guid>https://velog.io/@joeun-01/AWS-AWS-Amplify%EC%99%80-Route53%EC%9C%BC%EB%A1%9C-%ED%94%84%EB%A1%A0%ED%8A%B8-%EB%B0%B0%ED%8F%AC%ED%95%98%EB%8A%94-%EB%B2%95</guid>
            <pubDate>Wed, 16 Jul 2025 16:35:00 GMT</pubDate>
            <description><![CDATA[<h2 id="aws-amplify--route53">AWS Amplify / Route53</h2>
<hr>
<h3 id="amplify">Amplify</h3>
<p>정적 웹 사이트(React, Vue) 등을 CI/CD 방식으로 배포할 수 있는 서비스이다. 
GitHub와 연동해서 브랜치 기반으로 자동 배포가 가능하며, 프리티어 기준으로 월 1,000분의 무료 빌드 시간이 제공된다.<br></p>
<h3 id="route53">Route53</h3>
<p>AWS의 DNS(Domain Name System) 서비스이다.
도메인 등록 및 레코드 관리가 가능하고, 외부 도메인을 위임받아 사용하는 것도 가능하다. 이번 배포에서는 가비아에서 구매한 도메인을 통해 배포를 진행하였다. 호스팅 영역 당 월 $0.5가 과금된다.<br></p>
<h3 id="dns-레코드-타입">DNS 레코드 타입</h3>
<ul>
<li>A 레코드 : IPv4 주소를 직접 연결한다. 웹 서버의 고정 IP가 있을 때 사용한다.</li>
<li>CNAME 레코드 : 다른 도메인 이름으로 연결한다. CDN 연결, 서브도메인 관리 등에 사용한다.</li>
<li>NS 레코드 : 네임 서버를 지정한다. 도메인의 권한을 위임할 때 사용한다.</li>
<li>TXT 레코드 : 도메인 인증용으로 사용한다. SSL 인증서 발급, 보안 설정 등에 사용한다.<br><br></li>
</ul>
<h2 id="aws-amplify와-route53으로-프론트-배포하는-과정">AWS Amplify와 Route53으로 프론트 배포하는 과정</h2>
<hr>
<h3 id="1-route53-호스팅-영역-생성">1. Route53 호스팅 영역 생성</h3>
<p>먼저 가비아에서 구매한 도메인을 Route53에 등록해야 한다.</p>
<h4 id="1-1-호스팅-영역을-생성한다">1-1. 호스팅 영역을 생성한다</h4>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/ec85da88-543d-420f-8341-e4805f311135/image.png" alt="호스팅 영역 생성"></p>
<p>가비아에서 구매한 도메인을 입력한다. 유형은 <strong>퍼블릭 호스팅 영역</strong>을 선택한다.<br></p>
<h4 id="1-2-생성한-호스팅-영역의-ns-레코드를-확인한다">1-2. 생성한 호스팅 영역의 NS 레코드를 확인한다</h4>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/a1c6c2e6-8bd4-414f-a276-92064df9bc64/image.png" alt="호스팅 영역 레코드 확인"></p>
<p>호스팅 영역을 생성하면 4개의 NS 레코드가 생성된다. 이 NS 레코드를 가비아에 입력해야 한다.<br></p>
<h3 id="2-가비아-네임-서버-설정">2. 가비아 네임 서버 설정</h3>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/3688376c-bde5-46b9-9866-c44afbcd6070/image.png" alt="가비아 네임 서버 설정"></p>
<p>가비아 홈페이지의 도메인 관리 페이지에 들어가면 네임 서버를 설정할 수 있다.
Route53에서 생성된 4개의 NS를 1~4차에 각각 입력한다. AWS에서 제공하는 NS 레코드에 있는 <code>.</code>을 제거하고 입력해야 한다.
이렇게 하면 가비아에서 구매한 도메인의 네임 서버 권한이 Route53에 위임된다. 해당 도메인의 모든 DNS 레코드는 Route53에서 관리하게 된다. NS 레코드 변경 후 적용까지 최대 48시간이 소요된다고 써있지만, 실제로는 몇 분 안에 완료되었다.<br></p>
<h3 id="3-amplify-앱-생성">3. Amplify 앱 생성</h3>
<h4 id="3-1-github-레포지토리를-연결한다">3-1. GitHub 레포지토리를 연결한다</h4>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/58b3f694-948b-43c3-b7aa-bbea654fb254/image.png" alt="GitHub 레포지토리 연결"></p>
<p>소스코드 제공 업체로 GitHub를 선택하고 다음을 누르면 깃허브에 권한을 허용하도록 하는 페이지가 나온다. 여기서 원하는 레포지토리를 골라서 Amplify가 접근할 수 있도록 권한을 허용해준다.<br></p>
<h4 id="3-2-배포할-브랜치를-선택한다">3-2. 배포할 브랜치를 선택한다</h4>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/28fd583c-f0a5-446b-a5e2-d43e16343c9c/image.png" alt="배포 브랜치 선택"></p>
<p>선택한 브랜치에 푸시가 될 때마다 자동으로 배포가 진행된다.
이번에는 개발 웹 페이지를 배포할 것이기 때문에 develop 브랜치를 선택하였다. 나중에 운영 웹 페이지를 배포하고 싶다면 main 브랜치를 선택하면 된다. 그 외 원하는 브랜치를 선택해서 배포할 수 있다.<br></p>
<h4 id="3-3-빌드-설정을-확인한다">3-3. 빌드 설정을 확인한다</h4>
<pre><code class="language-yaml">version: 1
frontend:
    phases:
        preBuild:
            commands:
                - &#39;npm ci --cache .npm --prefer-offline&#39;
        build:
            commands:
                - &#39;npm run build&#39;
    artifacts:
        baseDirectory: dist
        files:
            - &#39;**/*&#39;
    cache:
        paths:
            - &#39;.npm/**/*&#39;
</code></pre>
<p>기본 설정값은 다음과 같다. 필요하면 buildspec.yml을 커스텀하여 사용할 수 있다.<br></p>
<h4 id="3-4-배포를-진행한다">3-4. 배포를 진행한다</h4>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/7f0434ee-aecb-43c7-8ba9-8688978fe900/image.png" alt="배포 완료"></p>
<p>초기 배포가 완료되면 임시로 Amplify URL이 생성된다. 해당 URL로 접속하면 배포된 웹 페이지를 확인할 수 있다. 추후에 도메인을 연결하면 임시 URL은 사라진다.<br></p>
<h3 id="4-배포-웹-페이지에-도메인-연결">4. 배포 웹 페이지에 도메인 연결</h3>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/7d94c452-b35f-4bb0-932a-5565b1067abf/image.png" alt="도메인 연결"></p>
<p>Amplify 콘솔의 호스팅 -&gt; 도메인 관리에 들어가면 Route53에 등록한 도메인을 불러올 수 있다. 도메인을 연결하면 자동으로 AWS Certificate Manager를 통해 무료 SSL 인증서가 발급된다. 자동으로 HTTPS 설정이 되고, Route53에 필요한 레코드를 생성해준다.</p>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/2607a0f3-f0dc-4406-81a9-1307d61a6986/image.png" alt="브랜치 연결"></p>
<p>참고로 나는 개발 웹 페이지를 배포하는 것이기 때문에 main 브랜치는 사용하지 않고, 서브 도메인에 develop 브랜치를 연결해주었다.<br></p>
<h3 id="5-환경-변수-설정">5. 환경 변수 설정</h3>
<h4 id="5-1-환경-변수를-추가한다">5-1. 환경 변수를 추가한다</h4>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/2ba912a1-365b-4523-9005-456b329a8b8e/image.png" alt="환경 변수 설정"></p>
<p>Amplify 콘솔의 호스팅 -&gt; 환경 변수에서 사용하고자 하는 환경 변수를 추가할 수 있다. 브랜치별로 다른 환경 변수를 설정할 수 있다.<br></p>
<h4 id="5-2-빌드-설정을-수정한다">5-2. 빌드 설정을 수정한다</h4>
<pre><code class="language-yaml">version: 1
frontend:
    phases:
        preBuild:
            commands:
                - &#39;npm ci --cache .npm --prefer-offline&#39;
        build:
            commands:
                - BASE_URL=${BASE_URL}
                - &#39;npm run build&#39;
    artifacts:
        baseDirectory: dist
        files:
            - &#39;**/*&#39;
    cache:
        paths:
            - &#39;.npm/**/*&#39;</code></pre>
<p>추가한 환경 변수를 빌드 시에 불러올 수 있도록 buildspec.yml 파일을 수정해줘야 한다.
Amplify 콘솔의 호스팅 -&gt; 빌드 설정에 들어가면 수정할 수 있다.<br></p>
<h3 id="6-웹-페이지-배포-완료">6. 웹 페이지 배포 완료!</h3>
<p>프론트 배포가 처음이라 처음에는 겁을 많이 먹었다. 하지만 Amplify 설정 페이지 자체가 단계별로 따라올 수 있도록 친절하게 되어 있고, 자동으로 해주는 부분이 많아서 생각보다 수월하게 프론트 배포를 할 수 있었다. 프리티어를 사용하면 매달 1,000분의 무료 빌드 시간이 제공되니 활용해보면 좋을 것 같다.<br><br></p>
<h2 id="참고-자료">참고 자료</h2>
<hr>
<p><a href="https://velog.io/@ssssujini99/Web-AWS-Route53%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0">[Web/AWS] AWS Route53을 이용하여 도메인 연결</a>
<a href="https://jindevelopetravel0919.tistory.com/267">[AWS] Amplify 배포 (React)</a>
<a href="https://velog.io/@chun_gil/AWS-Amplify-1-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%95%B1-%EB%B0%B0%ED%8F%AC-%EB%B0%8F-%ED%98%B8%EC%8A%A4%ED%8C%85">🛠 AWS Amplify - 1) 프론트엔드 앱 배포 및 호스팅</a>
<a href="https://velog.io/@dlruddms5619/AWS-Amplify-%EB%B0%B0%ED%8F%AC-%EC%8B%9C-env-%ED%8C%8C%EC%9D%BC-%EB%B3%80%EC%88%98-%EC%B2%98%EB%A6%AC">[AWS] Amplify 배포 시 env 파일 변수 처리</a>
<a href="https://www.recodelog.com/blog/aws/amplify-dns">AWS Amplify 외부 도메인 연결하는 방법 (feat. Route53)</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] Custom Annotation으로 유효성 검사하기]]></title>
            <link>https://velog.io/@joeun-01/Spring-Boot-Custom-Annotation%EC%9C%BC%EB%A1%9C-%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%82%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@joeun-01/Spring-Boot-Custom-Annotation%EC%9C%BC%EB%A1%9C-%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%82%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 22 Jun 2025 17:25:30 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>API 개발을 하면서 유효성 검사를 진행하다 보면, 기본 어노테이션으로는 검증이 불가능한 경우가 있다. 그럴 때 직접 어노테이션을 개발해서 유효성 검사를 진행할 수 있다. 이렇게 유효성 검사를 위한 커스텀 어노테이션을 만들어두면 다른 곳에서도 편리하게 사용할 수 있다.</p>
</blockquote>
<h2 id="custom-annotation">Custom Annotation</h2>
<hr>
<h3 id="annotation">Annotation</h3>
<p>빈 관리, 의존성 주입, 트랜잭션 관리 등 다양한 기능을 처리할 수 있다. 특히 Spring Boot에서는 어노테이션을 통해 설정을 최소화하고 자동화를 구현할 수 있다. 다만, 어노테이션을 사용하면 로직이 숨겨지기 때문에 무분별하게 사용하는 것은 좋지 않다. 
이번에는 어노테이션을 통해 DTO 유효성 검사를 진행하고자 한다.
<br></p>
<h3 id="annotation-구조">Annotation 구조</h3>
<pre><code class="language-java">@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface Annotation {
   ...
}</code></pre>
<p>어노테이션을 정의할 때는 다음과 같은 메타 어노테이션을 사용한다.</p>
<ul>
<li><code>@Target</code> : 어노테이션의 적용 대상을 지정한다.</li>
<li><code>@Retention</code> : 어노테이션의 지속 시간을 지정한다.</li>
<li><code>@Inherited</code> : 어노테이션이 상속되도록 설정한다.<br>

</li>
</ul>
<h3 id="annotation-특징">Annotation 특징</h3>
<ol>
<li><code>@interface</code>로 어노테이션 클래스를 정의한다. <code>@interface</code>로 정의하면 컴파일러가 자동으로 <code>java.lang.Annotation</code>을 상속하도록 처리해준다.</li>
<li><code>default</code> 키워드로 기본값을 설정할 수 있다. 다만, null로는 설정할 수 없다.</li>
<li>반환 타입은 기본형, String, Class, enum, Annotation만 가능하다. (배열 포함)</li>
<li><code>Class.getAnnotation()</code>, <code>Method.getAnnotation()</code> 등을 통해 어노테이션 정보를 얻을 수 있다.<br>

</li>
</ol>
<h3 id="elementtype-target">ElementType (@Target)</h3>
<table>
<thead>
<tr>
<th>ElementType</th>
<th>적용 대상</th>
</tr>
</thead>
<tbody><tr>
<td><code>TYPE</code></td>
<td>Class, Interface</td>
</tr>
<tr>
<td><code>FIELD</code></td>
<td>객체 필드 (enum, 상수 포함)</td>
</tr>
<tr>
<td><code>METHOD</code></td>
<td>메소드</td>
</tr>
<tr>
<td><code>PARAMETER</code></td>
<td>매개변수</td>
</tr>
<tr>
<td><code>CONSTRUCTOR</code></td>
<td>생성자</td>
</tr>
<tr>
<td><code>LOCAL_VARIABLE</code></td>
<td>지역 변수</td>
</tr>
<tr>
<td><code>ANNOTATION_TYPE</code></td>
<td>어노테이션</td>
</tr>
<tr>
<td><code>PACKAGE</code></td>
<td>패키지</td>
</tr>
<tr>
<td><code>TYPE_PARAMETER</code></td>
<td>매개변수의 타입</td>
</tr>
<tr>
<td><code>TYPE_USE</code></td>
<td>매개변수 사용 시</td>
</tr>
<tr>
<td><code>@Target</code>에서 어노테이션 적용 대상을 지정하는 옵션이다.</td>
<td></td>
</tr>
<tr>
<td>유효성 검사 시에는 주로 <code>FIELD</code>, <code>METHOD</code>, <code>PARAMETER</code>, <code>TYPE_USE</code>를 많이 사용한다.</td>
<td></td>
</tr>
<tr>
<td><br></td>
<td></td>
</tr>
</tbody></table>
<h3 id="retentionpolicy-retention">RetentionPolicy (@Retention)</h3>
<table>
<thead>
<tr>
<th>RetentionPolicy</th>
<th>유지 시점</th>
</tr>
</thead>
<tbody><tr>
<td><code>SOURCE</code></td>
<td>컴파일러 사용 후 삭제</td>
</tr>
<tr>
<td><code>CLASS</code></td>
<td>클래스 파일에는 포함, 런타임에는 접근 불가</td>
</tr>
<tr>
<td><code>RUNTIME</code></td>
<td>런타임까지 유지 (프로그램에서 접근 가능)</td>
</tr>
</tbody></table>
<p><code>@Retention</code>에서 어노테이션 유지 시점을 지정하는 옵션이다.
커스텀 어노테이션을 만들 때는 어플리케이션 동작에 영향을 주는 <code>RUNTIME</code>을 가장 많이 사용한다.
<br></p>
<h2 id="custom-annotation으로-유효성-검사하기">Custom Annotation으로 유효성 검사하기</h2>
<hr>
<h3 id="1-custom-annotation을-생성한다">1. Custom Annotation을 생성한다</h3>
<pre><code class="language-java">@Documented
@Constraint(validatedBy = LevelFormatValidator.class)
@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE_USE })
@Retention(RetentionPolicy.RUNTIME)
public @interface LevelFormat {
    String message() default &quot;점수는 0.5 단위로 입력해 주세요.&quot;;

    Class&lt;?&gt;[] groups() default {};

    Class&lt;? extends Payload&gt;[] payload() default {};

    boolean nullable() default false;
}</code></pre>
<ul>
<li><code>@Documented</code><ul>
<li>Javadoc 문서에 포함되도록 설정한다.</li>
<li>기본적으로 Java는 Javadoc에 어노테이션을 포함하지 않는다.</li>
</ul>
</li>
<li><code>@Contraint</code><ul>
<li>Bean Validation 어노테이션임을 표시한다.</li>
<li><code>validatedBy</code>로 실제 검증 로직을 구현할 클래스를 지정한다.</li>
</ul>
</li>
<li>필수 속성<ul>
<li><code>message()</code> : 검증 실패 시 반환할 메시지.</li>
<li><code>groups()</code> : 검증 그룹을 지정한다. (선택적 검증용)</li>
<li><code>payload()</code> : 검증 메타데이터를 전달한다. (심각도, 카테고리 등)</li>
</ul>
</li>
<li>커스텀 속성<ul>
<li><code>nullable()</code> : 해당 필드의 null 가능 여부를 지정한다.<br>

</li>
</ul>
</li>
</ul>
<h3 id="2-유효성-검증-로직을-작성한다">2. 유효성 검증 로직을 작성한다</h3>
<pre><code class="language-java">public class LevelFormatValidator implements ConstraintValidator&lt;LevelFormat, Float&gt; {
    private Boolean nullable;

    public void initialize(LevelFormat levelFormat) {
        this.nullable = levelFormat.nullable();
    }

    @Override
    public boolean isValid(Float value, ConstraintValidatorContext context) {
        // nullable이 true이고 value가 null일 경우 바로 검증을 성공 처리한다
        if (value == null) {
            return nullable;  // value가 null이고 nullable이 false라면 검증을 실패 처리한다
        }

        return (value * 2) % 1 == 0; // 0.5 단위인지 확인 (예: 2.5 * 2 = 5, 3.4 * 2 = 6.8)
    }
}</code></pre>
<p><code>ConstraintValidator</code> 인터페이스를 상속받아서 실제 검증 로직을 구현한다. 검증할 어노테이션 타입과 검증 대상 데이터의 타입을 정의하면, Bean Validation이 이 인터페이스를 통해 검증 로직을 호출한다.</p>
<ul>
<li><code>initialize()</code> : 어노테이션 속성값을 받아서 Validator를 초기화한다</li>
<li><code>isValid()</code> : 실제 검증 로직을 구현한다<br>

</li>
</ul>
<h3 id="3-필드에-어노테이션을-적용한다">3. 필드에 어노테이션을 적용한다</h3>
<pre><code class="language-java">@LevelFormat(nullable = false)
@Schema(description = &quot;만족도&quot;, example = &quot;3.5&quot;)
private Float level;</code></pre>
<p>만든 어노테이션을 실제 필드에 적용하면 된다. <code>@Valid</code> 혹은 <code>@Validated</code>를 함께 사용해야 자동으로 필드 유효성 검사를 진행한다.</p>
<p>이렇게 간단하게 커스텀 어노테이션을 만들어서 DTO 유효성 검사를 진행할 수 있다. 다른 유효성 검사를 진행하고 싶다면 또다른 어노테이션을 만들어서 진행하면 된다. 만든 어노테이션은 다른 곳에서도 자유롭게 사용할 수 있다.
<br></p>
<h3 id="➕-커스텀-메세지-처리">➕ 커스텀 메세지 처리</h3>
<pre><code class="language-java">public class LevelFormatValidator implements ConstraintValidator&lt;LevelFormat, Float&gt; {
    @Override
    public boolean isValid(Float value, ConstraintValidatorContext context) {
        if (value == null) return nullable;

        if ((value * 2) % 1 != 0) {
            // 기본 메시지 비활성화
            context.disableDefaultConstraintViolation();

            // 상황에 맞는 커스텀 메시지 생성
            String customMessage = String.format(&quot;입력값 %.1f는 0.5 단위가 아닙니다. 예: 1.0, 1.5, 2.0&quot;, value);

            context.buildConstraintViolationWithTemplate(customMessage)
                    .addConstraintViolation();

            return false;
        }

        return true;
    }
}</code></pre>
<p>입력값에 따라 동적으로 메세지를 생성하고 싶은 경우에는 <code>ConstraintValidatorContext</code>를 사용할 수 있다.</p>
<ul>
<li><code>disableDefaultConstraintViolation()</code> : 어노테이션의 기본 메세지를 비활성화한다</li>
<li><code>buildConstraintViolationWithTemplate()</code> : 동적으로 생성한 메시지를 설정한다</li>
<li><code>addConstraintViolation()</code> : 새로운 제약 조건을 추가한다<br>

</li>
</ul>
<h2 id="참고자료">참고자료</h2>
<hr>
<p><a href="https://itconquest.tistory.com/entry/Spring-Boot-Annotation-%EA%B0%9C%EB%85%90-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0">[Spring Boot] Annotation 개념 이해하기, 주요 어노테이션</a>
<a href="https://dev-gyus.github.io/java/2021/03/11/Retention-Target.html">[Java] @Retention, @Target에 대하여</a>
<a href="https://donghyeon.dev/spring/2020/08/18/Spring-Annotation%EC%9D%98-%EC%9B%90%EB%A6%AC%EC%99%80-Custom-Annotation-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B0/">Spring Annotation의 원리와 Custom Annotation 만들어보기</a>
<a href="https://jwlim94.tistory.com/17">커스텀 어노테이션(Custom Annotation) 만들기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] Path Variable, Query Parameter, Request Body]]></title>
            <link>https://velog.io/@joeun-01/Spring-Boot-Path-Variable-Query-Parameter-Request-Body</link>
            <guid>https://velog.io/@joeun-01/Spring-Boot-Path-Variable-Query-Parameter-Request-Body</guid>
            <pubDate>Fri, 20 Jun 2025 01:47:00 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Path Variable, Query Parameter, Request Body는 API 개발을 하다보면 반드시 알아야 하는 내용이다. 그리고 각각을 어느 상황에 써야 하는지도 확실하게 알고 사용을 해야 하는데, 얼마 전까지만 해도 확실한 이해 없이 그냥 사용만 하고 있다는 느낌을 받았다. 그때 알아보며 정리한 내용을 공유하려고 한다!</p>
</blockquote>
<h2 id="path-variable">Path Variable</h2>
<hr>
<h3 id="path-variable-1">Path Variable</h3>
<p>Path Variable은 URL 경로의 일부로 포함되는 변수이다. 특성 리소스를 식별할 때 사용하며, 필수 값이기 때문에 누락 시 404 에러가 발생한다. 계층적인 리소스 관계를 표현할 수 있기 때문에 RESTful 설계 원칙에 부합한다.
또한, URL 기반이기 때문에 캐싱에 유리하고 CDN에 활용할 수 있다는 장점이 있다.</p>
<p>다음과 같이 API 버전을 Path Variable로 관리할 수도 있다.</p>
<pre><code class="language-java">@GetMapping(&quot;/v{version}/users/{userId}&quot;)</code></pre>
<br>

<h3 id="restful-설계-원칙">RESTful 설계 원칙</h3>
<p>Path Variable을 제대로 활용하려면 RESTful 설계 원칙을 이해하면 좋다.</p>
<ol>
<li><p>리소스 기반 URL 설계</p>
<pre><code class="language-java"> GET /users/123
 POST /users</code></pre>
<ul>
<li>복수형으로 목록을 표현한다. (ex. users, orders)</li>
<li>이미 HTTP 메서드가 동사 역할을 하기 때문에 URI에 동사를 사용하는 것은 지양한다. 경우에 따라 동사를 사용하는 경우도 있다.</li>
<li>단어 구분이 필요한 경우에는 <code>-</code> 사용을 권장한다. (user-profiles)</li>
</ul>
</li>
<li><p>HTTP 메서드 활용 </p>
<ul>
<li>GET : 조회 (멱등성 O, 안전함)</li>
<li>POST : 생성 (멱등성 X)</li>
<li>PUT : 전체 수정 (멱등성 O)</li>
<li>PATCH : 부분 수정 (멱등성 X)</li>
<li>DELETE : 삭제 (멱등성 O)</li>
</ul>
</li>
<li><p>계층적 리소스 표현 - Path Variable</p>
<ul>
<li><code>GET /users/123/orders</code> - 123번 사용자의 주문 목록</li>
<li><code>GET /departments/5/employees</code> - 5번 부서의 직원 목록<br>

</li>
</ul>
</li>
</ol>
<h3 id="예시">예시</h3>
<pre><code class="language-java">// URI
GET /users/{userId}

// Spring Boot
@GetMapping(&quot;/users/{userId}&quot;)
public User getUser(
        @PathVariable Long userId
) {...}</code></pre>
<br>

<h3 id="주의할-점">주의할 점</h3>
<ol>
<li>URL 구조의 일관성을 유지해야 한다. (복수형 명사 사용 등)</li>
<li>Path Variable 검증을 해야 한다.</li>
<li>특수 문자가 들어온 경우 인코딩 처리가 필요하다.</li>
<li>선택적 매개변수는 Path Variable이 아닌 Query Parameter를 사용해야 한다.</li>
<li>계층이 너무 깊은 경우에는 유지보수가 어렵다.
 ex) <code>/products/{category}/{subcategory}/{brand}</code></li>
<li>날짜나 상태값 같이 자주 바뀌는 값은 Query Parameter가 더 적합하다.<br>

</li>
</ol>
<h3 id="보안-관련-주의사항">보안 관련 주의사항</h3>
<ul>
<li>SQL Injection 방지를 위한 숫자/문자 검증이 필요하다.</li>
<li>Directory Traversal 공격 방지를 위한 <code>../</code> 패턴 체크가 필요하다.<br>

</li>
</ul>
<h2 id="query-parameter">Query Parameter</h2>
<hr>
<h3 id="query-parameter-1">Query Parameter</h3>
<p>Query Parameter는 URL 뒤에 ?key=value 형태로 전달되는 매개변수다. 선택적 조건이나 필터링에 주로 사용되며, 데이터 조회 시 유연성을 제공한다. 페이징을 사용하면 대용량 데이터를 효율적으로 처리할 수 있다.
<br></p>
<h3 id="query-parameter-예시">Query Parameter 예시</h3>
<pre><code class="language-java">// URI
GET /users?page=1&amp;size=10

// Spring Boot
@GetMapping(&quot;/users&quot;)
public List&lt;User&gt; getUsers(
    @RequestParam(defaultValue = &quot;0&quot;) int page,
    @RequestParam(defaultValue = &quot;10&quot;) int size
) {...}</code></pre>
<br>

<h3 id="주의할-점-1">주의할 점</h3>
<ol>
<li>파라미터 검증 및 기본값 설정을 해야 한다.</li>
<li>Path Variable과 마찬가지로 단어 구분은 -으로 하는 것을 권장한다.</li>
<li>URL 길이 제한이 있기 때문에 대용량 데이터는 전송할 수 없다. (브라우저별 약 2048자)</li>
<li>복잡한 객체 구조는 Query Parameter로 표현하기 어렵다.</li>
<li>URL이 노출되기 때문에 민감한 정보는 사용할 수 없다.</li>
<li>페이징과 정렬 요청, 응답값을 표준화해야 한다.<br>

</li>
</ol>
<h3 id="보안-관련-주의사항-1">보안 관련 주의사항</h3>
<ul>
<li>XSS 방지를 위한 특수문자 이스케이프가 필요하다.</li>
<li>SQL Injection 방지 처리가 필요하다.</li>
<li>민감한 정보(비밀번호, 토큰 등) 사용을 금지해야 한다.<br>

</li>
</ul>
<h2 id="request-body">Request Body</h2>
<hr>
<h3 id="request-body-1">Request Body</h3>
<p>HTTP 요청 본문에 포함되는 데이터로, JSON, XML, Form Data 등 다양한 형태로 보낼 수 있어 복잡하고 민감한 데이터를 안전하게 전송할 수 있다.</p>
<p>주로 POST, PUT, PATCH, DELETE에서 사용된다.</p>
<ul>
<li>POST : 새로운 리소스 생성 (HttpStatus 201 권장)</li>
<li>PUT : 전체 리소스 교체</li>
<li>PATCH : 부분 업데이트</li>
<li>DELETE : 삭제하면서 추가 정보가 필요한 경우<br>

</li>
</ul>
<h3 id="request-body-예시">Request Body 예시</h3>
<pre><code class="language-java">// URI 및 Request Body
POST /users
Content-Type: application/json

{
  &quot;name&quot; : &quot;가나다&quot;,
  &quot;email&quot; : &quot;ganada@example.com&quot;,
  &quot;age&quot; : 30
}

// Spring Boot
@PostMapping(&quot;/users&quot;)
public User createUser(
        @RequestBody CreateUserRequest request
) {...}</code></pre>
<br>

<h3 id="주의할-점-2">주의할 점</h3>
<ol>
<li>데이터 검증 및 보안 처리를 해야 한다. (<code>@Valid</code> 사용 권장)</li>
<li>Content-Type을 명시적으로 처리해야 한다.</li>
<li>요청 크기 제한을 설정해야 한다.</li>
<li>GET 요청에서는 Request Body를 보낼 수 없는 경우가 많다.</li>
<li>동일한 요청을 식별하기 어렵기 때문에 캐싱 효율이 떨어진다.<br>

</li>
</ol>
<h3 id="보안-관련-주의사항-2">보안 관련 주의사항</h3>
<ul>
<li>JSON Payload 크기 제한을 설정해야 한다.</li>
<li>악성 스크립트 필터링을 적용해야 한다.</li>
<li>파일 업로드 시 확장자/크기 제한이 필요하다.<br>

</li>
</ul>
<h2 id="path-variable-vs-query-parameter-vs-request-body">Path Variable vs Query Parameter vs Request Body</h2>
<hr>
<table>
<thead>
<tr>
<th></th>
<th>Path Variable</th>
<th>Query Parameter</th>
<th>Request Body</th>
</tr>
</thead>
<tbody><tr>
<td>위치</td>
<td>URL 경로 내</td>
<td>URL 뒤 <code>?key=value</code></td>
<td>HTTP Body</td>
</tr>
<tr>
<td>필수 여부</td>
<td>필수</td>
<td>선택적</td>
<td>메서드에 따라 다름</td>
</tr>
<tr>
<td>보안</td>
<td>보통</td>
<td>낮음 (URL 노출)</td>
<td>높음</td>
</tr>
<tr>
<td>복잡도</td>
<td>단순</td>
<td>단순~보통</td>
<td>복잡</td>
</tr>
<tr>
<td>캐싱</td>
<td>유리</td>
<td>유리</td>
<td>불리</td>
</tr>
<tr>
<td>테스트</td>
<td>URL로 가능</td>
<td>URL로 가능</td>
<td>도구 필요</td>
</tr>
<tr>
<td><br><br></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<h2 id="참고-자료">참고 자료</h2>
<hr>
<p><a href="https://restfulapi.net/">What is REST?: REST API Tutorial</a>
<a href="https://developer.mozilla.org/ko/docs/Web/HTTP/Status">HTTP 상태 코드 - HTTP | MDN</a>
<a href="https://spring.io/guides/tutorials/rest/">Getting Started | Building REST services with Spring</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] if vs switch]]></title>
            <link>https://velog.io/@joeun-01/if-vs-switch</link>
            <guid>https://velog.io/@joeun-01/if-vs-switch</guid>
            <pubDate>Sun, 13 Apr 2025 09:06:47 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>개발을 진행하면서 if문과 switch문을 각각 언제 사용하는 게 좋은지에 대한 고민이 항상 있었다. 사실 내가 짜는 코드에서는 그렇게 복잡하고 다양한 조건을 사용할 일이 없기 때문에 둘 중 어떤 것을 사용해도 큰 의미가 없겠지만, 한 번 알아보면 좋을 것 같다는 생각이 들어 정리를 하게 되었다.
참고로 나는 그동안 주로 if문을 사용하다가, 단일 값 조건이 많은 경우에는 가독성을 위해 switch문을 사용했었다. 특히 -&gt;로 break문 없이 switch문을 종료할 수 있게 되면서 더 편리하게 사용할 수 있게 되었던 것 같다.</p>
</blockquote>
<h2 id="if문">if문</h2>
<hr>
<h3 id="if문-1">if문</h3>
<pre><code class="language-java">if (조건식) {
    명령문;
}</code></pre>
<p>가장 기본적인 조건문으로, 단일 조건을 검사할 때 사용한다. 
조건에 따라 코드의 실행 흐름을 제어할 수 있으며 주어진 조건이 true일 때만 코드 블록이 실행된다. 조건식은 반드시 boolean 값이어야 한다.
<br></p>
<h3 id="if-else문">if-else문</h3>
<pre><code class="language-java">if (조건식) {
    명령문1;
} else {
    명령문2;
}</code></pre>
<p>조건에 따라 두 가지 중 하나를 선택할 때 사용한다.
조건이 true면 <code>if</code> 블록을 실행하고, 그렇지 않으면 <code>else</code> 블록을 실행한다.
<br></p>
<h3 id="if-else-if문">if-else-if문</h3>
<pre><code class="language-java">if (조건1) {
    명령문1;
} else if (조건2) {
    명령문2;
} else {
    명령문3;
}</code></pre>
<p>여러 개의 조건을 순차적으로 검사해야 할 때 사용한다. 
조건이 true이면 해당 블록만 실행되고 그 뒤는 무시된다. <code>else</code>는 생략할 수 있다.
<br></p>
<h2 id="switch문">switch문</h2>
<hr>
<h3 id="switch문-1">switch문</h3>
<pre><code class="language-java">switch (변수) {
    case 값1:
        명령문;
        break;
    case 값2:
        명령문;
        break;
    default:
        명령문; // 모든 case와 일치하지 않을 때 실행
}</code></pre>
<p>하나의 변수값에 따라 여러 경우를 처리할 때 사용하는 조건문이다. 
특정 값들과 정확히 일치하는지 비교할 때 유용하다. <code>break</code>를 쓰지 않으면 다음 case로 연속 실행되며, (fall-through 현상) <code>default</code>는 모든 case와 일치하지 않을 경우 실행된다.
int, char, String, enum 등의 고정 값만 사용 가능하다.
<br></p>
<h3 id="fall-through">fall-through</h3>
<pre><code class="language-java">int day = 1;
switch (day) {
    case 1:
    case 2:
    case 3:
        System.out.println(&quot;평일입니다.&quot;);
        break;
    case 4:
        System.out.println(&quot;목요일입니다.&quot;);
        break;
}</code></pre>
<p>switch문에서 break를 사용하지 않았을 때 발생한다. 
<code>break</code>를 쓰지 않으면 다음 case로 연속 실행되기 때문에, 의도적으로 여러 case에서 같은 처리를 할 때 사용할 수 있다.
<br></p>
<h3 id="java-14-switch-표현식">Java 14+ switch 표현식</h3>
<pre><code class="language-java">String result = switch (day) {
    case 1, 2, 3 -&gt; &quot;평일&quot;;
    case 4 -&gt; &quot;목요일&quot;;
    default -&gt; &quot;기타&quot;;
};</code></pre>
<p>Java 14부터는 switch문에서 값을 바로 반환할 수 있게 되었다.
<code>-&gt;</code>를 사용하면 <code>break</code> 없이 간결하게 작성할 수 있다.
<br></p>
<h2 id="성능-비교">성능 비교</h2>
<hr>
<h3 id="if-vs-switch">if vs switch</h3>
<p>일반적으로 switch문이 성능적으로 더 유리하나, 조건의 개수와 특성에 따라 달라진다.</p>
<ol>
<li>조건이 일정 개수를 넘으면 컴파일러가 switch 최적화 (jump table or hash table) 를 적용한다.</li>
<li><code>if-else-if</code>는 순차적으로 조건을 평가하므로 case가 많을수록 성능 저하가 있다.</li>
<li>작은 분기 수에서는 성능 차이 거의 없기 때문에 가독성과 용도에 따라 선택하는 것이 좋다.<br>

</li>
</ol>
<h3 id="switch문-최적화-기법">switch문 최적화 기법</h3>
<table>
<thead>
<tr>
<th><strong>최적화 방식</strong></th>
<th><strong>설명</strong></th>
</tr>
</thead>
<tbody><tr>
<td>Jump Table</td>
<td>int/enum 값이 연속적일 때 빠르게 분기 가능</td>
</tr>
<tr>
<td>Hash Table</td>
<td>String 값일 때 <code>hashCode()</code> 기반 분기 최적화</td>
</tr>
<tr>
<td><br></td>
<td></td>
</tr>
</tbody></table>
<h3 id="상황에-따른-추천-조건문">상황에 따른 추천 조건문</h3>
<table>
<thead>
<tr>
<th>상황</th>
<th>추천 조건문</th>
</tr>
</thead>
<tbody><tr>
<td>범위 조건 혹은 복합 조건 (x &gt; 10 &amp;&amp; x &lt; 20)</td>
<td>if</td>
</tr>
<tr>
<td>단일 값 비교</td>
<td>switch</td>
</tr>
<tr>
<td>많은 case로 인해 성능 최적화 필요</td>
<td>switch</td>
</tr>
<tr>
<td>읽기 쉬운 코드 필요</td>
<td>switch</td>
</tr>
<tr>
<td>조건 우선순위가 중요한 경우</td>
<td>if-else-if</td>
</tr>
<tr>
<td><br></td>
<td></td>
</tr>
</tbody></table>
<h3 id="목적에-따른-추천-조건문">목적에 따른 추천 조건문</h3>
<table>
<thead>
<tr>
<th>목적</th>
<th>추천</th>
</tr>
</thead>
<tbody><tr>
<td>Enum 상태 분기 처리</td>
<td>switch</td>
</tr>
<tr>
<td>복잡한 인증 조건, 사용자 권한 판단</td>
<td>if-else-if</td>
</tr>
<tr>
<td>단순 메뉴나 명령어 처리</td>
<td>switch</td>
</tr>
<tr>
<td>조건 순서에 따라 로직 우선순위 부여</td>
<td>if-else-if</td>
</tr>
<tr>
<td><br></td>
<td></td>
</tr>
</tbody></table>
<h2 id="참고-자료">참고 자료</h2>
<hr>
<p><a href="https://velog.io/@jeong11/Java%EB%B0%98%EB%B3%B5%EB%AC%B8-if-switch">[Java] 조건문_if문, switch문</a>
<a href="https://stackoverflow.com/questions/767821/is-else-if-faster-than-switch-case">Is &quot;else if&quot; faster than &quot;switch() case&quot;?</a>
<a href="https://kldp.org/node/62262">switch vs if  어떤 때 어느게 효율적인가요? | KLDP</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MySQL] 새로운 유저 만들기]]></title>
            <link>https://velog.io/@joeun-01/MySQL-%EC%83%88%EB%A1%9C%EC%9A%B4-%EC%9C%A0%EC%A0%80-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@joeun-01/MySQL-%EC%83%88%EB%A1%9C%EC%9A%B4-%EC%9C%A0%EC%A0%80-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Tue, 11 Mar 2025 02:28:59 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>한 번에 프로젝트를 여러 개 진행하게 되면서 로컬에서 테스트를 위한 DB를 만들 때 이름을 짓기가 곤란한 문제가 발생했다. 프로젝트별로 MySQL 유저를 따로 두면 이 문제를 해결할 수 있어 유저 만드는 법을 알아보았고, 한 번 정리해두면 앞으로 유용하게 사용할 수 있을 것 같아 글을 작성한다!</p>
</blockquote>
<br>

<h4 id="1-mysql-root-계정으로-접속한다">1. MySQL Root 계정으로 접속한다</h4>
<pre><code class="language-sql">mysql -u root -p</code></pre>
<br>

<h4 id="2-새로운-유저를-만든다">2. 새로운 유저를 만든다</h4>
<pre><code class="language-sql">CREATE USER &#39;test&#39;@&#39;localhost&#39; IDENTIFIED BY &#39;test1234&#39;;</code></pre>
<p><code>test</code> : 새롭게 만들 유저의 이름을 지정한다
<code>test1234</code> : 새롭게 만들 유저의 비밀번호를 지정한다
<br> </p>
<h4 id="3-새로-만든-유저에게-권한을-부여한다">3. 새로 만든 유저에게 권한을 부여한다</h4>
<pre><code class="language-sql">GRANT ALL PRIVILEGES ON *.* TO &#39;test&#39;@&#39;localhost&#39;;
FLUSH PRIVILEGES;</code></pre>
<p>ALL PRIVILEGES는 SELECT, INSERT, CREATE, ALTER 등 데이터베이스의 모든 권한을 부여하는 것을 의미한다. 만약 특정 권한만 부여하고 싶다면 아래처럼 부여하고 싶은 권한만 작성하면 된다.</p>
<pre><code class="language-sql">// SELECT, INSERT, UPDATE, DELETE 권한을 부여한다
GRANT SELECT, INSERT, UPDATE, DELETE PRIVILEGES ON *.* TO &#39;test&#39;@&#39;localhost&#39;;

// CREATE, DROP 권한을 부여한다
GRANT CREATE, DROP PRIVILEGES ON *.* TO &#39;test&#39;@&#39;localhost&#39;;

// 권한 위임이 가능하도록 설정한다
GRANT ALL PRIVILEGES ON *.* TO &#39;test&#39;@&#39;localhost&#39; WITH GRANT OPTION;</code></pre>
<br>

<p>모든 데이터베이스가 아닌 특정 데이터베이스 혹은 테이블에만 권한을 적용하고 싶다면 아래와 같이 진행한다.</p>
<pre><code class="language-sql">// 특정 데이터베이스만 권한을 적용한다
GRANT ALL PRIVILEGES ON test.* TO &#39;test&#39;@&#39;localhost&#39;;

// 특정 테이블에만 권한을 적용한다
GRANT ALL PRIVILEGES ON test.test TO &#39;test&#39;@&#39;localhost&#39;;</code></pre>
<br>

<p>권한을 부여한 후, <code>FLUSH PRIVILEGES</code>를 해야 변경 사항을 즉시 적용할 수 있다. 일반적으로 최신 MySQL에서는 자동으로 권한이 적용되지만, 반영이 되지 않는 경우를 고려하여 마지막에 항상 실행하는 게 좋다.
<br></p>
<h4 id="4-유저가-제대로-생성됐는지-확인한다">4. 유저가 제대로 생성됐는지 확인한다</h4>
<pre><code class="language-sql">SELECT User, Host FROM mysql.user;</code></pre>
<p>위 쿼리를 실행하면 아래처럼 표가 나오는데, 표에서 생성한 유저가 존재하는지 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/0d76c3e5-107a-4540-8cb7-7a9ed91120a0/image.png" alt="SELECT User 결과"></p>
<p>추가로, 만약 유저를 삭제하고 싶다면 <code>DROP USER</code> 를 통해 삭제할 수 있다.</p>
<pre><code class="language-sql">DROP USER ‘test’@’localhost’;</code></pre>
<br>

<h4 id="5-새로운-유저로-mysql에-접속한다">5. 새로운 유저로 MySQL에 접속한다</h4>
<pre><code class="language-sql">mysql -u test -p</code></pre>
<p>마무리로 설정한 비밀번호를 입력하면 새로운 유저로 접속이 가능하다!
그 이후에 CREATE DATABASE, CREATE TABLE 등 원하는 작업을 진행하면 된다.
<br></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[2024-하반기 ICT 인턴십 후기]]></title>
            <link>https://velog.io/@joeun-01/2024-%ED%95%98%EB%B0%98%EA%B8%B0-ICT-%EC%9D%B8%ED%84%B4%EC%8B%AD-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@joeun-01/2024-%ED%95%98%EB%B0%98%EA%B8%B0-ICT-%EC%9D%B8%ED%84%B4%EC%8B%AD-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Sun, 09 Mar 2025 11:58:53 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>ICT 인턴십 과정에서 무엇을 경험했고, 그 경험을 통해 나는 어떤 것을 얻어갈 수 있었는지 공유하는 글을 작성하고자 한다. 어디까지나 개인적인 경험에서 비롯된 회고록이다!</p>
</blockquote>
<h3 id="ict-인턴십을-하기-전">ICT 인턴십을 하기 전</h3>
<p>ICT 인턴십에 지원하기 전의 나는 인턴십을 하고 싶다는 마음과 당분간 쉬고 싶다는 마음을 동시에 가지고 있었다. 나름 쉬는 시간 없이 달려왔다는 생각하고 있었기 때문인데, 그런 와중 운이 좋게 ICT 인턴십을 통해 소프트스퀘어드 라는 회사에서 4개월간 인턴을 하게 되었다. 결국 나는 다시 쉴 수 없는 상태가 되었지만, 그때도 지금도 4-2를 막연하게 쉬는 것보다는 인턴을 하게 된 것이 천만 배는 잘된 일이라고 생각한다. </p>
<p>아무튼 본격적으로 인턴 회고를 하기 전에 나는 인턴십을 통해 어떤 경험을 얻고자 했는지에 대해 적어보려고 한다. </p>
<h4 id="1-실무-경험">1. 실무 경험</h4>
<p>그동안 동아리 혹은 학교에서 꾸준히 프로젝트를 진행하긴 했지만, 실무와는 거리가 먼 경험을 해왔다. 먼저 취업한 선배들에게 듣기로는 그냥 프로젝트와 실무에서 경험하는 개발은 완전히 다르다고 들었기 때문에 회사에서 정규직으로 근무하기 전 실무를 경험할 수 있는 자리가 있다면 좋겠다고 항상 생각을 해왔다. 상황에 따라 정도는 다르겠지만 인턴십을 하게 되면 아무래도 실무에서는 어떤 프로세스로 일이 진행되고, 어떤 고민을 하는지 경험할 수 있을 거라는 기대감이 있었다.</p>
<h4 id="2-사회-경험">2. 사회 경험</h4>
<p>물론 대학교에서도 사회 경험을 할 수 있고, 알바를 통해서도 다양한 경험을 쌓을 수 있다. 알바를 하면서 손님을 대하는 방법, 같이 일하는 사람들과 잘 지내기 위한 방법 등을 배울 수 있는데, 나는 알바 경험이 많지 않기 때문에 사회 경험이 부족한 편이라는 생각을 했다. 직장 동료를 대하는 것은 친구를 대하는 것과는 확실히 다르다. 그래서 사회에서는 다른 사람을 어떻게 대해야 하는지, 어떻게 대화를 이끌어 나가야 하는지에 대한 고민이 많았다. </p>
<h4 id="3-정규직-전환">3. 정규직 전환</h4>
<p>인턴십 공고에 정규직으로 채용할 계획이 있다는 문구가 있었고, 요즘 취업 시장이 너무 안 좋다 보니 인턴을 통해 정규직 전환까지 노려보자는 생각이 있었다. 어디든 들어가고 싶다는 마음도 있었고, 스타트업에 취업하는 것도 나쁘지 않겠다고 생각하고 있었기 때문에 정규직까지 이어진다면 베스트일 것이라고 생각했다. 나는 아직 경험이 많지 않기 때문에 좀 더 다양한 경험이 필요하고, 그 지점을 스타트업에서 채울 수 있지 않을까 했다.
<br></p>
<h3 id="인턴십-과정-회고">인턴십 과정 회고</h3>
<p>나의 인턴십 경험은 초반 2개월과 후반 2개월이 꽤 다른 편이었다. 후반 2개월은 부서가 달라졌고, 맡은 업무도 달라지면서 꽤 큰 환경의 변화가 있었기 때문이다.</p>
<h4 id="초반-2개월">초반 2개월</h4>
<p>일단 기본적인 배경을 설명하자면, 우리 회사는 외주-작업자를 매칭하는 플랫폼을 운영하고 있고, 나는 이 회사에서 이미 외주 작업자로 일을 하고 있었다. 솔직히 인턴을 시작하고 2개월 동안은 회사에 출근해서 기존에 하던 외주 프로젝트를 진행하는 느낌이었다. 내가 진행하던 프로젝트의 일정이 많이 밀려있던 상태였고 나는 인하우스 인원으로서 프로젝트를 기간 안에 끝낼 수 있도록 해당 프로젝트에 거의 모든 시간을 쏟았다. 그 외에도 다양한 회의에 참여하긴 했지만, 상대적으로 경험이 많이 부족하다 보니 내가 회의에서 기여할 수 있는 부분은 많이 없었다. 그래서 그냥 회사에 출근하는 외주 작업자가 된 느낌이었다. 그래도 회사에 매일 출근하면서 직장 동료분들과 친해지기도 하고 점점 적응이 되기는 했다. 함께 회사에 출근하던 인턴분들과 매일 밥을 먹으면서 좋은 인연도 만들 수 있었다. 물론 외주 프로젝트를 하면서 기존에 경험하지 못했던 새로운 기술을 적용할 수 있었다. 민감한 데이터의 경우 보안을 신경 쓰고, 락을 거는 등의 작업을 하면서 한층 성장하는 느낌을 받았다.</p>
<h4 id="후반-2개월">후반 2개월</h4>
<p>해당 프로젝트 일정이 점점 마무리되고 나서는 회사 내부 프로덕트 개발에 참여하게 되었다. 이미 개발이 된 부분도 모두 갈아엎고, 테이블 설계부터 다시 시작하는 대규모 리팩토링이었다. 프로덕트 정기 회의에 참여하고, 설계 회의에 들어가기 시작하면서 실무를 경험한다는 느낌을 제대로 받기 시작했다. 물론 외주도 기존에 내가 했던 경험과는 완전히 달랐다. 하지만 회사 내부 인원과 진행하는 것도 아니고, 딱 내가 맡은 파트만 개발하면 되기 때문에 회사 내에서 프로젝트를 진행한다는 느낌은 받지 못했던 것 같다. 점점 회사에도 적응하고, 팀 회식도 하면서 더더욱 회사에 다니고 있다는 느낌을 받게 되었다.</p>
<h4 id="총정리">총정리</h4>
<p>4개월 간의 짧은 기간이었지만 기존에 친구끼리 프로젝트를 하던 때와는 확실히 다른 경험을 얻을 수 있었다. 나는 서비스 배포를 해본 적도 없고 항상 비슷한 난이도의 개발을 진행해 왔다. 물론 점점 다양한 기술을 사용하고 더 좋은 코드를 작성하기 위한 고민을 하긴 했으나, 사실상 API 공장과 다름없는 경험이었다. </p>
<p>하지만 실무를 경험하면서 다양한 도메인을 경험하고, 개발적인 측면에서도 이전과는 다른 고민을 할 수 있었다. 예를 들어, API를 호출할 때 보안을 고려하여 권한을 분리한다거나, 데이터 일관성을 보장하기 위해 트랜잭션에 락을 건다거나, 더 나은 성능을 위해 비동기를 도입한다거나 하는 경험은 이전에 해본 적 없는 경험이었다. 상사분이 해주시는 코드 리뷰를 통해 내가 놓치고 있던 부분을 알게 되었고 문제를 해결하기 위해서는 어떻게 고민해야 하는지 배웠다. </p>
<p>그리고 내가 그동안 얼마나 좁은 시야를 가지고 개발을 하고 있었는지 깨달았다. 개인적으로 가장 기억에 남았던 건 DB 설계 회의였다. 나는 그동안 최대한 필요한 데이터만 저장하도록 DB를 설계하는 게 맞다고 생각했다. 그러나 유동성이 높은 데이터의 경우 최대한 DB에 다 저장하고 필요할 때마다 꺼내서 쓰는 게 좋을 수 있다는 관점을 이번에 처음 알게 되었다. 이렇게 새로운 관점을 경험하고 실무에서는 어떤 고민을 하는지 직접적으로 경험하면서 많은 성장을 한 것 같다. 구체적으로 어떤 경험을 했는지는 바로바로 정리를 해야 했는데 게으른 나 자신 때문에 실패했다. 소중한 경험이 잊혀지기 전에 정리를 해야 하는데 정말 반성해야겠다. </p>
<p>실무 경험뿐만 아니라, 사회 경험도 나의 큰 관심사 중 하나였다. 내가 생각하는 나의 문제점은 사회성이 부족하다는 것이다. 사회 생활을 하기 위해서는 어느 정도 대화를 이끌어 나가는 기술이 필요하다. 하루만 보고 말 사이가 아니니까 당연한 일이다. 문제는 나는 낯을 많이 가리기도 하고, 타인에게 궁금한 게 없는 편이라 말을 거는 걸 굉장히 어려워한다. 그래도 인턴분들이 계실 때는 매일 같이 밥도 먹고 나이대도 비슷해서 어느 정도 친해질 수 있었는데 그 외에는 많이 어려워했던 것 같다. 물론, 이건 지금도 어렵다. 우리 회사는 100% 자율 재택이라 회사에 출근하지 않는 분들이 더 많긴 했지만, 최대한 많은분들을 마주하기 위해서 거의 매일 출근을 했다. 얼굴도장 한 번이라도 찍는 게 좋다고 생각했는데 괜찮은 선택이지 않았을까? 아무튼! 이건 아직도 나에게 큰 과제로 남아있지만, 어떻게 행동해야 하는지 알았으니, 앞으로 계속 노력하다 보면 조금씩 나아지지 않을까 라는 희망을 가지고 있다.
<br></p>
<h3 id="정규직-전환">정규직 전환</h3>
<p>미리 얘기하자면 나는 좋은 기회가 생겨 정규직으로 전환이 되었다! 정규직으로 전환하는 과정에서 예상보다 더 많은 경험을 할 수 있었다. 우선 정규직 전환을 위해 대표님과 커피챗을 진행하는 과정에서 배운 게 정말 많다. </p>
<ol>
<li><p>모든 질문에는 다 이유가 있다.
커피챗에서 대표님이 이 회사에서 일하고 싶은 이유와 어떤 점이 마음에 들었는지를 여쭤보셨다. 나는 이런 대화 주제가 그냥 나의 열정과 태도를 보기 위한 질문이라고 생각했는데 더 깊은 의미가 있었다. 인사 담당자는 계속해서 질문을 통해 내가 회사에 남아있도록 하는 원동력을 파악할 것이라는 얘기를 해주셨는데 전혀 내가 생각해 보지 못한 관점이었다. 내가 생각한 장점이 모두 사라지면 회사를 떠날 거냐는 질문도 하셨다. 이런 질문 외에도 짧은 대화를 통해 자신들이 필요한 정보를 얻기 위한 유도 질문을 끊임없이 진행할 것이라는 생각에 좀 더 신중하게 대답해야겠다고 다짐을 하게 되었다.</p>
</li>
<li><p>나의 강점을 어필할 수 있어야 한다.
워낙 실력이 좋은 개발자들이 많고, 요즘은 개발자 풀이 넘쳐나는 세상이다. 이런 세상에서 나는 자신감이 많이 없는 상태라 커피챗에서도 나에 대한 어필을 많이 하지 못했던 것 같다. 이 모습을 보고 대표님이 겸손한 모습은 좋으나, 자기 자신을 확실하게 어필할 수 있어야 한다는 얘기를 해주셨다. 그래야 상대방도 나에 대한 확신을 가질 수 있다. 정말 맞는 얘기이다. 비슷한 얘기를 모의 면접에서도 들은 적이 있는데 이 점은 확실히 고쳐야 하는 부분이라고 생각했다.</p>
</li>
<li><p>비슷한 맥락으로, 나를 증명하는 것은 굉장히 어려운 일이다.
이건 연봉 협상을 할 때 크게 느꼈다. 대표님이 나에게 희망 연봉은 얼마이고, 내가 해당 연봉을 받아야 하는 이유를 가져오라고 하셨다. 일단 이제 막 첫 직장을 다니게 된 입장에서 연봉을 내가 먼저 불러야 한다는 것도 나에게는 큰 부담이었고, 심지어 이 연봉을 받아야 하는 이유를 입증받아야 한다니 정말 막막했다. 그래서 나는 회사의 작업자 시급 정산표를 근거로 나의 월급을 계산해서 근거로 가져갔다. 하지만 결국 내가 원하는 만큼은 받지 못했고, 준비를 잘 해오긴 했으나 부족한 부분이 많다는 피드백을 받았다. 이번에는 대표님이 경험을 위해 이런 자리를 마련하신 거기도 하고 첫 연봉 협상이라 큰 무리 없이 진행되었으나, 다음 연봉 협상 차례가 되면 나는 어떤 근거를 가져가야 할지 벌써 걱정이 된다. 1년 새에 성장한 모습과 실적을 내는 모습을 모두 보여줘야 하는데 정말 쉽지 않다.</p>
<br>

</li>
</ol>
<h3 id="마무리하며">마무리하며</h3>
<p>정규직으로 일하게 된 지 벌써 2개월이라는 시간이 지났다. 그동안 대규모 프로덕트 리팩토링을 마무리하였고, 앞으로 또 많은 일을 하게 될 것이다. 아직 사회 초년생인 만큼 최대한 많이 배우고, 또 배운 만큼 성장한 모습을 보여줄 수 있는 사람이 될 수 있도록 끝없이 노력할 것이다. 앞으로 많은 고난이 있겠지만 꿋꿋하게 이겨내고 멋진 사회인이 될 나의 모습을 기대한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[가천대학교 SW아카데미 4기 회고 - 1]]></title>
            <link>https://velog.io/@joeun-01/%EA%B0%80%EC%B2%9C%EB%8C%80%ED%95%99%EA%B5%90-SW%EC%95%84%EC%B9%B4%EB%8D%B0%EB%AF%B8-4%EA%B8%B0-%ED%9A%8C%EA%B3%A0-1</link>
            <guid>https://velog.io/@joeun-01/%EA%B0%80%EC%B2%9C%EB%8C%80%ED%95%99%EA%B5%90-SW%EC%95%84%EC%B9%B4%EB%8D%B0%EB%AF%B8-4%EA%B8%B0-%ED%9A%8C%EA%B3%A0-1</guid>
            <pubDate>Thu, 19 Dec 2024 12:21:00 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><a href="https://sw.gachon.ac.kr/cms/?p=30&amp;idx=623">가천대학교 SW아카데미 모집글</a>
서류 지원 기간 : 24.01.22 (월) ~ 24.01.28 (일)
면접 기간 : 24.02.02 (목) ~ 24.02.03 (금)
교육 기간 : 24.03.04 (월) ~ 24.08.29 (목)</p>
</blockquote>
<h2 id="지원-동기">지원 동기</h2>
<p>가천대학교 SW아카데미는 가천대학교 4학년만 지원할 수 있고, 한 학기 동안 진행되는 일종의 부트캠프이다. SW아카데미가 처음 생겼을 때는 내가 2학년을 마치고 휴학을 하고 있는 상태였고 4학년 1학기가 되면서 나도 이 아카데미를 신청해야겠다는 생각을 했다. 사실 이전에 SW아카데미를 했던 친구들의 말을 들어봤을 때 쉬운 결정은 아니었다. 매일 9시까지 학교에 가서 수업을 듣고 프로젝트를 해야 했고, 방학 때도 매일 9시까지 학교에 가서 기업 실무 프로젝트를 진행해야 하기 때문이다. <del>이 아카데미를 하는 친구들이 그 당시에 정말 힘들어했다.</del> 그래도 이 아카데미를 해야겠다고 결정한 제일 큰 이유는 마땅한 4-1 계획이 없었기 때문이다. 따로 동아리나 부트캠프를 지원한 것도 없고 그렇다고 취업 준비를 할 마음의 준비는 되어있지 않았다. 그리고 후기를 들어봤을 때 백엔드를 하는 사람이라면 이 아카데미를 해서 나쁠 건 없을 거라고 생각했다. 수업도 백엔드 위주고, 서버비를 지원받으며 MSA나 CDC 같은 어려운 기술을 실제로 경험해볼 수 있는 기회였기 때문이다. 마지막으로 <del>지금은 사라진 것 같지만</del> 1, 2기는 카엔프와 함께 사업을 진행했기 때문에 다양한 인턴이나 해커톤 등의 기회가 있었다. 아무튼 이 아카데미를 지원하면 힘들 거라는 건 알고 있었지만 결국 나는 SW아카데미를 지원하게 됐다. <br></p>
<blockquote>
<p>지원 과정은 서류 지원만 하면 모두 붙여줬기 때문에 생략하겠다!</p>
</blockquote>
<br>

<h2 id="학기-회고">학기 회고</h2>
<h3 id="학기-중-강의">학기 중 강의</h3>
<p>먼저 학기 중에 들었던 강의에 대한 간단한 후기를 적어보겠다. 내가 강의를 열심히 들은 편은 아니라 자세한 내용을 쓸 수가 없긴 하다.. 학기 동안 진행되는 하나의 프로젝트가 하나 주어지고, 각 강의에서 프로젝트를 진행하기 위한 개념 및 실습을 진행하는 형식이었다. <br><br></p>
<p>*<em>데이터관리기술 : *</em> </p>
<p>데이터관리기술 강의에서는 대체적으로 DB 관련 내용을 배웠다. DB에 대한 기본적인 개념부터 SQL, Elastic Search, CDC 등을 배웠으며 가장 어려웠던 건 아무래도 CDC 관련 파트였다. </p>
<p>*<em>시스템아키텍처 : *</em></p>
<p>시스템아키텍처 강의에서는 아키텍처 및 클라우드에 대해서 배웠다. 우리에게 주어진 프로젝트가 반드시 MSA 방식으로 구현이 되어야 했기 때문에 이 개념에 대해 잘 알아야 했다. </p>
<p>*<em>웹 애플리케이션 개발 : *</em></p>
<p>웹 애플리케이션 개발에서는 실제로 MSA 환경에서 돌아가는 프로젝트 실습을 진행했다. 웹 애플리케이션 개발에서 처음으로 리액트 코드를 작성해봤는데 매우 헤맸던 기억이 있다. 웹 애플리케이션 개발에서 학기 중 프로젝트에 대한 전반적인 발표를 진행했다.</p>
<h3 id="학기-중-프로젝트">학기 중 프로젝트</h3>
<p>학기 중 프로젝트 주제는 바로 <strong>AI를 활용한 블로그 개발</strong>이었다. 사실 처음에 이 주제를 듣고 도대체 어떤 프로젝트를 해야할지 감이 잡히지 않았다. 정말 다양한 주제 후보들이 있었지만, 블로그라는 틀에 맞추기가 쉽지 않아서 기획 회의를 정말 오랫동안 했다. 첫 번째 고민은 우리에게 주어진 블로그라는 큰 주제를 따를 것인지, 아니면 그냥 커뮤니티 성격을 띈 서비스를 만들 것인지였다. 팀원들과 긴 시간 동안 논의를 했고, 주제를 확정한 다음에 세부적인 기획을 하는 시간까지 합치면 한 달 이상은 기획에 시간을 소요했다. 그 와중에 과제로 기획 산출물을 내야했고 처음에 애매하게 정해둔 기획들이 계속 수정되면서 초반에 좀 지치지 않았나 싶다. 살면서 그렇게 오랫동안 토론을 한 적은 처음이자 마지막이지 않을까.. 그리고 외부 디자이너와 함께 프로젝트를 진행해서 우리 - 디자이너 간의 기획 공유 및 이해에 걸리는 시간이 좀 더 들기도 했다. 그치만 우리를 위해 디자인을 해주신 너무 고마운 분들이시다......</p>
<p>그렇게 정해진 우리의 주제는 <code>사회 초년생을 위한 소비 일상 공유 블로그</code> 이다. 블로그의 큰 기능은 다음과 같다.</p>
<ol>
<li>가계부 및 소비 관련 자유글 작성</li>
<li>작성한 가계부를 바탕으로 소비 달력(통계) 및 레포트 제공</li>
<li>소비/경제에 관련된 퀴즈 제공</li>
</ol>
<p>사라진 기획 중에 소비/경제 관련 챌린지, 블로그 내 게임 등이 있었는데 이 친구들은 너무 커뮤니티적인 성격을 띄는 거 같아서 구현하지 않기로 했다. 개발을 하는 와중에도 이게 블로그에 맞는 기능인지 커뮤니티에 맞는 기능인지에 대한 고민을 끝없이 했다. 특히 나는 좀 보수적인 성격이라 교수님이 얘기해주신 걸 최대한 지키려고 했다. 지금 생각해보면 그렇게까지 목매지 않아도 됐을 것 같은데..! 그래도 최대한 주어진 양식을 지키고자 했던 우리의 노력이니 후회하지 않는다. </p>
<p>이 아카데미의 큰 특징 중 하나는 MSA 프로젝트를 진행한다는 것이다. 나는 MSA에 대해 얘기만 들어봤지 실제로 구현한 적은 없기 때문에 굉장히 겁을 먹은 상태였다. 특히 초반에 인프라 관련 얘기를 할 때 가장 심적으로 힘든 상태였는데, 애초에 인프라를 잘 모르기도 하고 내가 덜컥 백엔드 파트장이라는 역할을 맡아버려서 더 부담감이 컸다. <del>(아직도 내가 왜 백엔드 파트장을 맡았는지는 모르겠다. 내 성격이 유한 편이라 백엔드 파트의 완충제 역할을 위해 PM이 나에게 파트장을 맡겼다고 듣긴 했지만..)</del> 우리 팀에서 인프라를 담당하는 인원이 두 명이었는데 항상 그 둘의 이야기를 듣고 집에서 무슨 얘기를 했는지 찾아보고 그런 식으로 진행을 했던 것 같다. 잘 모르는 상태에서 내 의견을 내야 하는 시간이 가장 괴로웠다. </p>
<p>어쨌든 인프라는 인프라 담당에게 맡기고 나는 실제로 돌아가는 비즈니스 로직을 작성하는 역할을 맡았기 때문에 일단 인프런에서 MSA 관련 강의를 듣기 시작했다. 백엔드도 인프라 구성이 바뀜에 따라 적용해야 하는 기술이 다르기 때문에 강의를 반드시 들어야했다. 나같은 경우에는 이론만 들어서는 감이 잘 오지 않기 때문에 반드시 실습이 동반되는 강의로 찾았다. 진심으로 이 강의가 없었다면 나는 아마 프로젝트를 시작조차 하지 못했을 거다. <code>Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA)</code> 이라는 강의인데 실습에서 사용한 것들을 프로젝트에서 많이 활용했다. 강의 외적으로 추가 공부를 한 부분은 Kafka인데 사실상 이게 구현에 가장 많은 시간이 걸린 것 같다. 나는 Spring에 내장된 Debezium MySQL Connector를 이용하여 Kafka를 구현하기로 했고, 이 자료가 생각보다 많이 없어서 고생을 좀 했다. 없는 자료 끌어모아서 열심히 참고하고 우리의 좋은 친구인 챗지피티를 어느 정도 활용하여 개발을 진행했다. 다른 서비스의 읽기 작업을 위해 따로 만들어둔 DB에 데이터가 자동으로 이동하는 모습을 보고 얼마나 기뻤는지 모른다. </p>
<br>

<h3 id="학기-중-프로젝트-후기">학기 중 프로젝트 후기</h3>
<p>프로젝트를 진행하면서 추가적인 과제도 계속 해야 했고, 학교 수업도 들어야 했기 때문에 프로젝트를 한 학기 동안 진행하면서도 기간이 넉넉하다는 생각은 딱히 들지 않았다. 매일 아침 9시까지 가면서 체력도 많이 떨어진 상태였고, 체력이 떨어지니 나 스스로 예민해진 느낌을 받았다. 어떻게든 이 고통을 완화할 수 있는 방법을 찾기 위해 이것저것 취미 활동도 시작했다. 이때 가진 취미들은 아직도 내 삶을 버틸 수 있게 해주는 좋은 친구들이다. 아무튼 시간이 조금 지난 지금 생각해봐도 정말 힘든 시간이었다. 특히 한 강의실에 너무 많은 사람이 있다는 점도 힘들었다. 나는 사람이 많으면 기가 빨리는 편이라 나에게 특별히 더 힘든 점으로 다가왔다. 그래도 이 아카데미를 하길 잘했다고 생각하는 점은 내가 또 어디에서 MSA를 경험해보겠나 하는 것이다. 사실 MSA를 하기 위해서는 서버도 여러 개 띄워야 하고, DB도 여러 개를 둬야 해서 비용이 많이 든다. 학생 신분으로, 학생 신분이 아니더라도 지원을 받는 게 아니라면 다시는 MSA를 하지 못할 것 같다. 절대 쉽게 도전할 수 있는 비용이 아니다. 우리가 진행한 학기 프로젝트가 완벽한 MSA를 구현했다고는 생각하지 않지만, MSA 환경에서 사용하는 Spring Cloud의 다양한 기술, 그리고 멀티 Datasource를 활용한 CQRS 패턴, Kafka를 통한 데이터 동기화 등을 경험해본 것 만으로도 나에게 큰 도움이 되었다. 하지만 다시는 하고 싶지 않다. 너무 고려할 게 많았고 지금 내 실력에 비해 과분한 능력을 요구하는 것 같다. 그래도 결론은 경험해봐서 좋았다!</p>
<br>

<h4 id="to-be-continue">TO BE CONTINUE...</h4>
<p>생각보다 글이 너무 길어서 방학 회고 및 전체 후기는 2탄에서 진행하도록 하겠다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[2024-하반기 ICT 인턴십 합격 후기]]></title>
            <link>https://velog.io/@joeun-01/ICT-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@joeun-01/ICT-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Wed, 30 Oct 2024 08:47:07 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><a href="https://www.ictintern.or.kr/homepage/system/systemView.do">ICT 인턴십 홈페이지</a>
서류 지원 기간 : 6월 21일 ~ 7월 9일 15시
온라인 코딩 테스트 : 7월 10일 ~ 7월 11일
서류 및 면접 전형 : 7월 12일 ~ 7월 26일 15시
인턴십 기업 확정 : 7월 29일 ~ 7월 30일</p>
</blockquote>
<h2 id="지원-동기">지원 동기</h2>
<p>4학년 1학기가 끝나고 곧 취업을 해야 한다는 생각에 고민이 많았다. 학점을 거의 다 채워서 4-2에는 한 과목만 들으면 되는 상황이었고, 나에게는 두 가지 계획이 있었다. 첫 번째가 이 ICT 인턴십이었고, 두 번째는 조금 쉬면서 취업 준비를 하는 거였다. 그동안 쉼없이 달려와서 (남들이 보기에는 아닐 수도 있지만) 조금 쉬고 싶은 마음이 어느 정도 있었던 것 같다. 두 번째 계획도 좋지만 4-2는 학교에서 연계하는 인턴을 할 수 있는 마지막 기회고, 평소에도 현장실습이나 ICT를 꼭 해보고 싶다는 마음이 있었기 때문이 일단 지원해보기로 했다. <br><br></p>
<h2 id="지원-과정">지원 과정</h2>
<p>ICT 인턴십은 한 번에 총 세 군데를 지원할 수 있다. 취준에 들어가기 전 코딩 테스트라도 많이 봐보자는 생각이 있었기 때문에 두 곳은 온라인 코딩 테스트를 보는 곳으로 지원했다. 코딩 테스트를 보는 곳은 당연히 난이도도 높고 인기가 많은 회사였기 때문에 합격을 기대하기 보단 정말 코딩 테스트를 보기 위한 지원이었다. 그리고 나머지 한 곳이 현재 다니고 있는 소프트스퀘어드라는 회사다. 소프트스퀘어드를 지원한 이유는 꽤 많은데, 일단 나에게 익숙한 회사라는 점이 가장 컸다. 2년 동안 UMC라는 동아리를 하면서 얻은 게 정말 많은데 이 UMC를 만들고 관리하는 회사가 소프트스퀘어드다. UMC를 오랫동안 하다보니 UMC 말고도 회사에서 주최하는 다른 사업에도 참여한 적이 몇 번 있는데, 그때마다 좋은 경험을 얻어간 기억이 있었기 때문에 이 회사에 지원하게 되었다. <br><br></p>
<h2 id="온라인-코딩-테스트">온라인 코딩 테스트</h2>
<p>온라인 코딩 테스트는 7월 10일 ~ 7월 11일 총 이틀 동안 진행되었다. 원하는 시간에 사이트에 들어가서 테스트를 보는 형식이었고, 사실 온라인 코딩 테스트에 대해서는 할 수 있는 얘기가 많이 없다. 당시에 학교에서 진행하는 부트캠프 때문에 시간이 많이 없어서 코테에 제대로 임하지 못했다. 부트캠프를 진행하면서 정신 없이 코딩 테스트를 본 것 같은데, 중간에 멈출 수도 있고 검색도 가능한 자유로운 코딩 테스트였다. 물론 나는 잘 못봤다!<br><br></p>
<h2 id="서류-및-면접-전형">서류 및 면접 전형</h2>
<h3 id="서류-전형">서류 전형</h3>
<p>당연히 코딩 테스트를 보는 기업 두 곳은 떨어졌고, 운이 좋게 소프트스퀘어드에서 면접을 보자는 제안이 왔다. </p>
<p>면접 이야기를 하기 전에 서류 이야기를 잠시 해보자면, 서류는 이력서와 자기소개서를 내는 방식이었다. 둘 다 자유 양식이라 도대체 무슨 정보를 적어야할지 고민이 많이 됐는데, 사람인 사이트의 도움을 받아 겨우 작성하였다. 이력서에는 그동안의 동아리 활동, 해외연수 경험, 자격증 등의 정보와 자리를 채우기 위한 사람인 인적성 결과를 넣었다. 솔직히 이력서를 만들면서 이런 내용으로 내가 ICT 인턴십에 합격할 수 있을까 하는 의구심이 있었던 것 같다. 그래서 이력서에 보여줄 수 없는 내용은 최대한 추가로 제출하는 포트폴리오에 담기 위해 노력했다.</p>
<p>자소서는 사람인에서 자기만의 질문을 선택하여 만들 수 있는 기능을 이용하였다. 질문은 자기소개서에 가장 많이 나오는 내용과 내가 대답을 잘할 수 있다고 판단되는 것만 넣었다. </p>
<blockquote>
<ol>
<li>자기소개 2. 성격의 장/단점 3. 동아리/봉사활동 4. 입사 후 포부 </li>
</ol>
</blockquote>
<p>이렇게 총 4가지 질문을 골랐고 특히 3번에 집중을 한 것 같다. 내가 생각했을 때 가장 어필이 될만한 점이 3번이라고 생각했기 때문이다. 3번 문항에서 UMC라는 동아리 활동을 2년 동안 그리고 회장까지 한 경험을 중점적으로 작성하였는데, 실제로 UMC를 하면서 그리고 운영진을 하면서 느낀 점이 많기 때문에 문항에 답변하는 게 크게 어렵지 않았다. 성격의 장/단점에도 꽤 공을 들였는데, 그 이유는 장/단점이 자기소개 문항보다도 더 나를 잘 표현할 수 있는 문항이라고 생각했기 때문이다. 나는 이런 장점이 있고, 이런 단점이 있지만 단점을 보완하는 과정에서 이런 시너지를 낼 수 있습니다 등의 내용으로 작성하였다. 특히 단점 부분을 오히려 나를 어필하는 내용으로 보일 수 있도록 신경을 많이 썼다.<br></p>
<h3 id="면접-전형">면접 전형</h3>
<p>우선 나의 면접은 7월 23일 화요일 오후 4시부터 30분 가량 진행되었다. 복장에 대한 안내는 따로 없었지만, IT 회사인 점을 감안하여 너무 꾸미지는 않되 단정한 느낌을 낼 수 있는 옷으로 결정했다. 면접은 자기소개와 자기소개서에 적은 내용을 위주로 준비했다. 서류에는 적지 못했지만 내가 이 회사에 관심이 많았고, 참여한 활동이 많다는 것을 드러낼 수 있도록 자기소개를 생각해두었다. 그리고 자기소개서에 적은 내용과 맞지 않는 내용을 답변했을 때 마이너스가 될 거라 생각해서 내 답변과 싱크로율을 맞출 수 있도록 했다. 추가로 기술 질문에 대한 대비도 진행했다!</p>
<p>면접은 온라인으로 진행되었으며, 간단한 자기소개로 시작했다. 30초 정도의 짧은 자기소개를 요청주셔서 원래 내가 하고 싶었던 얘기를 최대한 덜어내고 자기소개를 진행했다. 자기소개까지는 긴장이 돼서 목소리도 떨리고 그랬는데, 면접관 분이 최대한 편안한 분위기를 만들어 주셔서 그 후로는 긴장이 조금씩 풀리기 시작했다. 면접에 대한 자세한 이야기는 할 수 없지만 내가 직접 경험한 것들은 내가 누구보다 잘 알고 있기 때문에 수월하게 답변할 수 있었다. 잘 모르는 것들은 솔직하게 얘기하고, 아는 내용은 최대한 답변하려고 노력했다. 지금 생각해보면 최대한 외운 내용이 아닌 내 생각을 전달하고자 한 게 면접 결과에 긍정적인 영향을 주었던 것 같다. <br><br></p>
<h2 id="최종-합격">최종 합격</h2>
<p><img src="https://velog.velcdn.com/images/joeun-01/post/7a244c1c-413b-493d-8137-e34830d1cc66/image.png" alt="최종 합격 메일">
면접 결과는 최종 합격! ICT를 지원하면서 나는 아직 취업할 준비가 되지 않았구나라는 생각을 많이 했다. 다른 사람들은 나에게 프로젝트도 많이 했고, 다양한 경험을 했다고 말하지만 나는 그 말이 공감되지 않았기 때문이다. 실제로 이력서를 쓰려고 하니 마땅한 프로젝트도 없고 어필할 수 있는 내용이 없다고 생각했다. 그래도 결과를 조금이나마 기대했던 건, 내가 이 회사에서 진행하는 동아리, 행사 그리고 외주 등의 경험을 많이 했다는 점이다. 특히 외주를 진행하고 있다는 게 큰 어필 요소가 될 수 있을 거라고 생각했다. UMC 회장을 한 경험도 어필이 꽤 될 수 있을 거라 생각했는데 어쨌든 내가 이 회사에 관심이 많다는 것을 보여줄 수 있는 증거가 될 거라 생각했기 때문이다. 이 내용들이 합격에 영향을 미쳤는지는 모르겠지만, 나를 좋게 봐주셔서 현재 인턴십 활동을 하고 있고 새로운 경험을 얻어가고 있다. 나중에 인턴십을 마치고 나는 어떤 사람이 되어 있을지 궁금하다.</p>
]]></description>
        </item>
    </channel>
</rss>