<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>ga-bin.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Thu, 28 Dec 2023 02:34:24 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>ga-bin.log</title>
            <url>https://velog.velcdn.com/images/ga-bin/profile/b8caa0c6-53ab-4fc1-835e-2e2d85471d2e/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. ga-bin.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/ga-bin" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[jpa 계층형테이블 양방향 매핑 과정에서 무한루프, N+1문제 해결]]></title>
            <link>https://velog.io/@ga-bin/jpa-%EA%B3%84%EC%B8%B5%ED%98%95%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%96%91%EB%B0%A9%ED%96%A5-%EB%A7%A4%ED%95%91-%EA%B3%BC%EC%A0%95%EC%97%90%EC%84%9C-%EB%AC%B4%ED%95%9C%EB%A3%A8%ED%94%84-N1%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-hj4uqeym</link>
            <guid>https://velog.io/@ga-bin/jpa-%EA%B3%84%EC%B8%B5%ED%98%95%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%96%91%EB%B0%A9%ED%96%A5-%EB%A7%A4%ED%95%91-%EA%B3%BC%EC%A0%95%EC%97%90%EC%84%9C-%EB%AC%B4%ED%95%9C%EB%A3%A8%ED%94%84-N1%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-hj4uqeym</guid>
            <pubDate>Thu, 28 Dec 2023 02:34:24 GMT</pubDate>
            <description><![CDATA[<h3 id="문제상황">문제상황</h3>
<ul>
<li>계층형으로 구성된 테이블에서 양방향 매핑을 해 데이터를 들고와야 하는 상황이었다.</li>
<li>이런 상황에서 무한루프가 발생했고, 원인을 찾을 수 없었다.</li>
</ul>
<br>

<h3 id="코드">코드</h3>
<pre><code>public class Code {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long codeId;
    private String codename;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;upper_code_id&quot;)
    @JsonIgnore
    private Code parentCode;

    @OneToMany(mappedBy = &quot;parentCode&quot;, fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List&lt;Code&gt; childCodes;

    public CodeDTO toDTO() {
        Set&lt;Long&gt; convertedCodes = new HashSet&lt;&gt;();
        if (convertedCodes.contains(codeId)) {
            return null;
        }

        CodeDTO.CodeDTOBuilder builder = CodeDTO.builder();

        builder.codeId(codeId)
                .codename(codename);


        if(parentCode != null) {
            CodeDTO parentCodeDTO = parentCode.toDTO();
            builder.parentCodeDTO(parentCodeDTO);
        }

        if (childCodes != null &amp;&amp; !childCodes.isEmpty()) {
            List&lt;CodeDTO&gt; childCodeDTOs = childCodes.stream()
                    .map(Code::toDTO)
                    .filter(Objects::nonNull)
                    .collect(Collectors.toList());
            builder.childCodesDTO(childCodeDTOs);
        }

        return builder.build();
    }
 }</code></pre><ul>
<li>문제가 발생한 CODE테이블은 계층형테이블이며, 자기자신을 참조하고 있다.</li>
</ul>
<h3 id="무한루프-해결">무한루프 해결</h3>
<ul>
<li>처음에는 쿼리문을 가지고 오는 과정 자체가 문제인 줄 알았다.</li>
<li>하지만 날라가는 쿼리문은 이것이 전부였고, 그 후에 stackoverflow가 발생해 쿼리를 가지고오는 과정에서의 문제가 아님을 짐작했다.</li>
</ul>
<pre><code>Hibernate: select c1_0.code_id,c1_0.codename,c1_0.upper_code_id from code c1_0 where c1_0.codename=?
Hibernate: select c1_0.code_id,c1_0.codename,c1_0.upper_code_id from code c1_0 where c1_0.code_id=?
Hibernate: select c1_0.upper_code_id,c1_0.code_id,c1_0.codename from code c1_0 where c1_0.upper_code_id=?</code></pre><ul>
<li>문제가 된 부분은 다음과 같이 엔티티를 DTO로 변환하는 부분이었다.</li>
<li>부모는 자식을 변환하고, 자식은 부모를 변환하는 일이 계속 발생하니 stackoverflow가 발생한 것이다.</li>
<li>이를 해결하기 위해서는 자식이나 부모 둘 중 하나에서 변환과정을 끊어주면 된다.</li>
<li>나는 다음과 같이 자식이 부모를 dto로 변환하는 부분을 주석처리해 주었다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/kgb/post/07f67f24-fc51-4b84-8fcb-e715972f14f1/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/kgb/post/26137a88-f183-461a-ad25-175c231a0cd1/image.png" alt=""></p>
<ul>
<li>dto를 entity로 변환시키는 부분도 마찬가지로 처리해주었다.</li>
</ul>
<br>

<h3 id="n1-쿼리-해결">N+1 쿼리 해결</h3>
<ul>
<li><p>이렇게 무한루프를 해결하고 나서 부모 + 자식을 가지고오는 과정에서 n+1쿼리문이 생기는 문제가 발생했다.
<img src="https://velog.velcdn.com/images/kgb/post/48b83535-f725-4f3d-a21d-b293c2eb7652/image.png" alt=""></p>
</li>
<li><p>이는 양방향 참조관계를 설정할 때 해놓은 FetchType.LAZY때문으로, 자식이 필요할 때 쿼리를 발생시키는 방식이기 때문에 N+1참조 관계가 발생한다.
<img src="https://velog.velcdn.com/images/kgb/post/88e37398-365c-463f-856f-e2a71c7071de/image.png" alt=""></p>
</li>
<li><p>하지만 그렇다고 해서 한번에 모든 객체를 다 가지고 오면 속도 저하가 생길 수도 있으므로 EAGER로 할 수도 없는 노릇이었다.</p>
</li>
<li><p>결국은 부모 + 자식을 한번에 조인해서 가지고 오는 쿼리문을 작성해야했고, 다음과 같은 쿼리문으로 수정했다.</p>
</li>
</ul>
<pre><code>    @Query(&quot;SELECT c FROM Code c &quot; +
               &quot;LEFT JOIN FETCH c.parentCode &quot; +
               &quot;LEFT JOIN FETCH c.childCodes &quot; +
               &quot;WHERE c.codename = :codename&quot;)
    public Code findByCodename(String codename);</code></pre><ul>
<li>하지만 이렇게 작성해도 N+1 문제는 계속되었다. fetch문을 작성하는 과정에서는 join해서 쿼리문을 가지고오지만 FetchType.LAZY때문에 여전히 자식을 사용하는 과정에서 새로운 쿼리문이 발생했기 때문이다.</li>
</ul>
<pre><code>
User
Hibernate: select c1_0.code_id,c2_0.upper_code_id,c2_0.code_id,c2_0.codename,c1_0.codename,p1_0.code_id,p1_0.codename,p1_0.upper_code_id from code c1_0 left join code p1_0 on p1_0.code_id=c1_0.upper_code_id left join code c2_0 on c1_0.code_id=c2_0.upper_code_id where c1_0.codename=?
Hibernate: select c1_0.upper_code_id,c1_0.code_id,c1_0.codename from code c1_0 where c1_0.upper_code_id=?
Hibernate: select c1_0.upper_code_id,c1_0.code_id,c1_0.codename from code c1_0 where c1_0.upper_code_id=?
Hibernate: select c1_0.upper_code_id,c1_0.code_id,c1_0.codename from code c1_0 where c1_0.upper_code_id</code></pre><p><img src="blob:https://velog.io/df137773-6a94-41a9-9e04-b338c7c1f00f" alt="업로드중.."></p>
<p><img src="blob:https://velog.io/4c389200-9880-4dbf-98ac-f024c8e96d1f" alt="업로드중.."></p>
<ul>
<li>이 사진에서 childCodes를 childCodeDTOS로 변환하는 과정에서 각각의 자식들을 따로 불러오면서 해당 문제가 발생했다.</li>
<li>이를 해결하기 위해서 application.yml에 다음과 같이 설정했다.</li>
</ul>
<pre><code>  jpa:
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQL8Dialect
        default_batch_fetch_size: 1000</code></pre><ul>
<li>이렇게 하면 in을 사용해 쿼리문을 n번이아니라 1번만 더 날려서 해결을 해준다고 한다.</li>
<li>그 결과 다음과 같은 쿼리문이 발생했고, 계층형으로 데이터를 불러오는데 성공했다.</li>
</ul>
<pre><code>Hibernate: select c1_0.code_id,c2_0.upper_code_id,c2_0.code_id,c2_0.codename,c1_0.codename,p1_0.code_id,p1_0.codename,p1_0.upper_code_id from code c1_0 left join code p1_0 on p1_0.code_id=c1_0.upper_code_id left join code c2_0 on c1_0.code_id=c2_0.upper_code_id where c1_0.codename=?
Hibernate: select c1_0.upper_code_id,c1_0.code_id,c1_0.codename from code c1_0 where c1_0.upper_code_id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?.....</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[spring boot와 flutter를 연동한 소셜로그인 구현(feat. spring security)-2]]></title>
            <link>https://velog.io/@ga-bin/spring-boot%EC%99%80-flutter%EB%A5%BC-%EC%97%B0%EB%8F%99%ED%95%9C-%EC%86%8C%EC%85%9C%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84feat.-spring-security-2</link>
            <guid>https://velog.io/@ga-bin/spring-boot%EC%99%80-flutter%EB%A5%BC-%EC%97%B0%EB%8F%99%ED%95%9C-%EC%86%8C%EC%85%9C%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84feat.-spring-security-2</guid>
            <pubDate>Tue, 26 Dec 2023 07:28:26 GMT</pubDate>
            <description><![CDATA[<p>이번에는 jwt토큰과 관련된 로직을 해결해야한다.</p>
<br>

<h3 id="jwt를-이용한-인증-과정-절차">jwt를 이용한 인증 과정 절차</h3>
<p>일반적으로 jwt를 이용한 로그인 구현은 다음과 같은 과정을 통해 진행된다.</p>
<blockquote>
<ol>
<li>유저가 로그인에 성공하면 accessToken과 refreshToken을 발급한다.</li>
<li>발급한 accessToken과 refreshToken을 클라이언트에게 넘겨준다. 이 때, refreshToken은 db에 저장한다.</li>
<li>유저는 인증이 필요한 요청시 마다 accessToken을 넘겨준다.</li>
<li>서버에서는 filter를 통해 해당 accessToken을 검증한다.</li>
<li>검증에 실패할 경우 refreshToken을 클라이언트에게 다시 요청하고, 검증이 성공할 경우 클라이언트의 요청을 수행한다.</li>
<li>클라이언트가 refreshToken을 넘겨주면 해당 refreshToken을 검증한다.</li>
<li>refreshToken도 검증이 실패하면, 로그아웃을 시키고 다시 로그인을 유도한다.</li>
</ol>
</blockquote>
<p>이 부분은 크게 다르지 않으므로, 해당 순서와 동일하게 진행하면 된다.</p>
<br>

<h3 id="토큰-처리-클래스">토큰 처리 클래스</h3>
<p>우선 jwt토큰과 관련된 부분을 처리할 클래스를 생성한다. 이 클래스에는 토큰 발급, 검증, 추출, 업데이트 등 토큰과 관련된 기능들이 모여있다.</p>
<pre><code>package com.project.bookforeast.common.security.service;


import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import com.project.bookforeast.common.security.error.TokenErrorResult;
import com.project.bookforeast.common.security.error.TokenException;
import com.project.bookforeast.user.dto.UserDTO;
import com.project.bookforeast.user.entity.User;
import com.project.bookforeast.user.repository.UserRepository;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import jakarta.servlet.http.HttpServletRequest;

@Service
public class JwtUtil {


    private final UserRepository userRepository;

    @Autowired
    public JwtUtil(UserRepository userRepository) {
        this.userRepository = userRepository;
    }


    @Value(&quot;${jwt.secret-key}&quot;)
    private String SECRETKEY;


    private Long ACCESS_TOKEN_EXPIRATION_PERIOD = 600000L;


    private Long REFRESH_TOKEN_EXPIRATION_PERIOD = 3600000L;


    public String generateAccessToken(UserDTO userDTO) {
        return createToken(ACCESS_TOKEN_EXPIRATION_PERIOD, userDTO);
    }


    public String generateRefreshToken(UserDTO userDTO) {
        return createToken(REFRESH_TOKEN_EXPIRATION_PERIOD, userDTO);
    }


    private String createToken(Long expirationPeriod, UserDTO userDTO) {
        Map&lt;String, Object&gt; claims = new HashMap&lt;&gt;();
        claims.put(&quot;sub&quot;, userDTO.getSocialId());
        claims.put(&quot;iss&quot;, userDTO.getSocialProvider());

        return Jwts.builder()
                .addClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expirationPeriod))
                .signWith(SignatureAlgorithm.HS256, SECRETKEY)
                .compact();
    }



    public &lt;T&gt; T extractClaim(String token, Function&lt;Claims, T&gt; claimsResolver) {
        final Claims claims = Jwts.parser().setSigningKey(SECRETKEY).parseClaimsJws(token).getBody();
        return claimsResolver.apply(claims);
    }



    public boolean validateAccessToken(String accessToken) {
        if(accessToken == null || accessToken.length() &lt;= 0) {
            throw new TokenException(TokenErrorResult.ACCESS_TOKEN_NEED);
        }

        boolean isTokenExpired = checkTokenExpired(accessToken);
        if(isTokenExpired == true) {
            throw new TokenException(TokenErrorResult.TOKEN_EXPIRED);
        } else {
            return isTokenExpired;
        }
    }


    public Boolean validateRefreshToken(String refreshToken) {
        User user = userRepository.findByRefreshToken(refreshToken);

        if(user == null) {
            new TokenException(TokenErrorResult.TOKEN_EXPIRED);
        }

        String refreshTokenInDB = user.getRefreshToken(); 
        if(!refreshToken.equals(refreshTokenInDB) || checkTokenExpired(refreshTokenInDB)) {
            new TokenException(TokenErrorResult.TOKEN_EXPIRED);
        }

        return true;
    }


    public boolean checkTokenExpired(String token) {
        Date expirationDate = extractClaim(token, Claims::getExpiration);
        boolean isTokenExpired = expirationDate.after(new Date());
        return isTokenExpired;
    }


    public Map&lt;String, String&gt; initToken(UserDTO savedOrFindUser) {
        Map&lt;String, String&gt; tokenMap = new HashMap&lt;&gt;();
        String accessToken = generateAccessToken(savedOrFindUser);
        String refreshToken = generateRefreshToken(savedOrFindUser);

        tokenMap.put(&quot;accessToken&quot;, accessToken);
        tokenMap.put(&quot;refreshToken&quot;, refreshToken);

        updRefreshTokenInDB(refreshToken, savedOrFindUser);

        return tokenMap;
    }


    public Map&lt;String, String&gt; refreshingAccessToken(UserDTO userDTO, String refreshToken) {
        Map&lt;String, String&gt; tokenMap = new HashMap&lt;&gt;();
        String accessToken = generateAccessToken(userDTO);

        tokenMap.put(&quot;accessToken&quot;, accessToken);
        tokenMap.put(&quot;refreshToken&quot;, refreshToken);

        return tokenMap;
    }


    private void updRefreshTokenInDB(String refreshToken, UserDTO savedOrFindUser) {
        savedOrFindUser.setRefreshToken(refreshToken);
        userRepository.save(savedOrFindUser.toEntity());
    }


    public String extractTokenFromHeader(HttpServletRequest request) {
        String header = request.getHeader(&quot;Authorization&quot;);
        if(StringUtils.hasText(header) &amp;&amp; header.startsWith(&quot;Bearer &quot;)) {
            return header.substring(7);
        } else {
            throw new TokenException(TokenErrorResult.ACCESS_TOKEN_NEED);
        }
    }
}
</code></pre><p>물론 처음부터 이 메소드들을 다 작성한 것은 아니지만, 편의를 위해 한 번에 코드로 적어두었다.</p>
<br>

<h3 id="토큰-발급">토큰 발급</h3>
<p>1편을 통해 SecurityContext에 인증정보를 등록하는 것을 성공했으면, controller에서 initToken을 통해서 토큰을 생성해 준다.</p>
<pre><code>    @PostMapping(&quot;/social-login&quot;)
    public ResponseEntity&lt;Map&lt;String, String&gt;&gt; socialLogin(@RequestBody @Valid SocialLoginDTO socialLoginDTO)
    {

        UserDTO savedOrFindUser = userService.socialLogin(socialLoginDTO);
        securityService.saveUserInSecurityContext(socialLoginDTO);
        Map&lt;String, String&gt; tokenMap = jwtUtil.initToken(savedOrFindUser);

        return ResponseEntity.ok(tokenMap);
    }</code></pre><p><img src="https://velog.velcdn.com/images/kgb/post/56e952c1-b5c5-484c-a013-f0a2118a4d34/image.png" alt=""></p>
<p>토큰 생성시에는 refreshToken정보를 db에서 update해준다.
그 후 토큰 정보를 클라이언트에게 전달한다. 그러면 유저에게 다음과 같은 형태로 토큰 정보가 전달된다.</p>
<p><img src="https://velog.velcdn.com/images/kgb/post/0ff6073b-dfa2-4527-aeed-8049daaefd65/image.png" alt=""></p>
<p>이번 글에서는 토큰 생성까지만 해보도록 하고, 발급된 토큰을 이용한 검증은 다음 글에서 작성하겠다.</p>
]]></description>
        </item>
    </channel>
</rss>