반응형
해당 글에서는 Spring Boot Security내에 ‘인증’ 방식을 JWT를 이용하여서 사용자의 인증을 구성하는 환경 설정방법에 대해서 이해하기 위한 글입니다.
💡 [참고] 해당 글은 이전에 작성한 'JWT 이론'에 대해서 이해를 하고 구성을 하시면 크게 도움이 됩니다
1) 개발 환경
💡 Spring Security & JWT 개발 환경을 구성하기 위해 사용한 개발환경입니다.
개발 환경 | 버전 | 비고 |
java | 11 | |
Spring Boot | 2.7.5 | |
Spring Boot Starter Security | 2.7.5 | Spring Framework : 5.7.4 |
io.jsonwebtoken:jjwt | 0.9.1 | |
Spring Boot Starter Data JDBC | 2.7.5 | |
mybatis | 2.2.2 | |
lombok | latest | |
postgresql | latest | |
빌드관리도구 | Gradle 7.5 | |
개발 툴 | IntelliJ IDEA 2022.3 | |
API 테스트 툴 | Postman |
2) JWT 환경 설정하기
1. build.gradle 라이브러리 추가
💡build.gradle 라이브러리 추가
- 빌드 관리 도구로 ‘Gradle’을 이용하여서 주요 라이브러리를 추가하였습니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security' // Spring Security
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' // Spring Boot JDBC + HikariCP
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.2' // Spring Boot MyBatis
implementation "io.jsonwebtoken:jjwt:0.9.1" // Spring Json-Web-Token
compileOnly 'org.projectlombok:lombok' // Lombok
runtimeOnly 'org.postgresql:postgresql' // PostgreSQL
}
2. WebSecurityConfig.java
💡 WebSecurityConfig.java
- Spring Security의 환경설정을 구성하기 위한 클래스입니다.
- 웹 서비스가 로드될 때 Spring Container에 의해 관리가 되는 클래스이며 사용자에 대한 ‘인증’과 ‘인가’에 대한 구성을 Bean 메서드로 주입을 합니다. 추가로 JWT 처리를 위한 Filter를 @Bean으로 등록하였습니다.
💡 JWT를 구성하기 위해서 사용된 부분
- 해당 주석 중 2번에 [STEP3]에서 해당 ‘JWT 필터’를 등록합니다.
- 해당 주석 중 9번에 JwtAuthorizationFilter() 클래스를 참조합니다.
package com.adjh.multiflexapi.config;
import com.adjh.multiflexapi.config.filter.CustomAuthenticationFilter;
import com.adjh.multiflexapi.config.filter.JwtAuthorizationFilter;
import com.adjh.multiflexapi.config.handler.CustomAuthFailureHandler;
import com.adjh.multiflexapi.config.handler.CustomAuthSuccessHandler;
import com.adjh.multiflexapi.config.handler.CustomAuthenticationProvider;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
/**
* Spring Security 환경 설정을 구성하기 위한 클래스입니다.
* 웹 서비스가 로드 될때 Spring Container 의해 관리가 되는 클래스이며 사용자에 대한 ‘인증’과 ‘인가’에 대한 구성을 Bean 메서드로 주입을 한다.
*/
@Slf4j
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
/**
* 1. 정적 자원(Resource)에 대해서 인증된 사용자가 정적 자원의 접근에 대해 ‘인가’에 대한 설정을 담당하는 메서드이다.
*
* @return WebSecurityCustomizer
*/
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
// 정적 자원에 대해서 Security를 적용하지 않음으로 설정
return web -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
/**
* 2. HTTP에 대해서 ‘인증’과 ‘인가’를 담당하는 메서드이며 필터를 통해 인증 방식과 인증 절차에 대해서 등록하며 설정을 담당하는 메서드이다.
*
* @param http HttpSecurity
* @return SecurityFilterChain
* @throws Exception Exception
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
log.debug("[+] WebSecurityConfig Start !!! ");
http
// [STEP1] 서버에 인증정보를 저장하지 않기에 csrf를 사용하지 않는다.
.csrf().disable()
// [STEP2] 토큰을 활용하는 경우 모든 요청에 대해 '인가'에 대해서 적용
.authorizeHttpRequests(authz -> authz.anyRequest().permitAll())
// [STEP3] Spring Security JWT Filter Load
.addFilterBefore(jwtAuthorizationFilter(), BasicAuthenticationFilter.class)
// [STEP4] Session 기반의 인증기반을 사용하지 않고 추후 JWT를 이용하여서 인증 예정
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// [STEP5] form 기반의 로그인에 대해 비 활성화하며 커스텀으로 구성한 필터를 사용한다.
.formLogin().disable()
// [STEP6] Spring Security Custom Filter Load - Form '인증'에 대해서 사용
.addFilterBefore(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
// [STEP7] 최종 구성한 값을 사용함.
return http.build();
}
/**
* 3. authenticate 의 인증 메서드를 제공하는 매니져로'Provider'의 인터페이스를 의미합니다.
* - 과정: CustomAuthenticationFilter → AuthenticationManager(interface) → CustomAuthenticationProvider(implements)
*
* @return AuthenticationManager
*/
@Bean
public AuthenticationManager authenticationManager() {
return new ProviderManager(customAuthenticationProvider());
}
/**
* 4. '인증' 제공자로 사용자의 이름과 비밀번호가 요구됩니다.
* - 과정: CustomAuthenticationFilter → AuthenticationManager(interface) → CustomAuthenticationProvider(implements)
*
* @return CustomAuthenticationProvider
*/
@Bean
public CustomAuthenticationProvider customAuthenticationProvider() {
return new CustomAuthenticationProvider(bCryptPasswordEncoder());
}
/**
* 5. 비밀번호를 암호화하기 위한 BCrypt 인코딩을 통하여 비밀번호에 대한 암호화를 수행합니다.
*
* @return BCryptPasswordEncoder
*/
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 6. 커스텀을 수행한 '인증' 필터로 접근 URL, 데이터 전달방식(form) 등 인증 과정 및 인증 후 처리에 대한 설정을 구성하는 메서드입니다.
*
* @return CustomAuthenticationFilter
*/
@Bean
public CustomAuthenticationFilter customAuthenticationFilter() {
CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager());
customAuthenticationFilter.setFilterProcessesUrl("/api/v1/user/login"); // 접근 URL
customAuthenticationFilter.setAuthenticationSuccessHandler(customLoginSuccessHandler()); // '인증' 성공 시 해당 핸들러로 처리를 전가한다.
customAuthenticationFilter.setAuthenticationFailureHandler(customLoginFailureHandler()); // '인증' 실패 시 해당 핸들러로 처리를 전가한다.
customAuthenticationFilter.afterPropertiesSet();
return customAuthenticationFilter;
}
/**
* 7. Spring Security 기반의 사용자의 정보가 맞을 경우 수행이 되며 결과값을 리턴해주는 Handler
*
* @return CustomLoginSuccessHandler
*/
@Bean
public CustomAuthSuccessHandler customLoginSuccessHandler() {
return new CustomAuthSuccessHandler();
}
/**
* 8. Spring Security 기반의 사용자의 정보가 맞지 않을 경우 수행이 되며 결과값을 리턴해주는 Handler
*
* @return CustomAuthFailureHandler
*/
@Bean
public CustomAuthFailureHandler customLoginFailureHandler() {
return new CustomAuthFailureHandler();
}
/**
* 9. JWT 토큰을 통하여서 사용자를 인증합니다.
*
* @return JwtAuthorizationFilter
*/
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter();
}
}
3. AuthConstants.java
💡 AuthConstants.java
- 해당 클래스는 상수로 정의한 파일이며 JWT 토큰에서 Header에 키 값으로 사용되는 authorization 값과 클라이언트에서 JWT로 전송을 할 때 사용하는 “BEARER” 값을 상수로 정의하였습니다.
package com.adjh.multiflexapi.common.codes;
/**
* JWT 관련된 상수로 사용 되는 파일
*
* @author lee
* @fileName AuthConstants
* @since 2022.12.23
*/
public final class AuthConstants {
public static final String AUTH_HEADER = "Authorization";
public static final String TOKEN_TYPE = "BEARER";
}
4. TokenUtil.java
💡TokenUtil.java
- 해당 클래스는 JWT에서 사용되는 토큰 관련 유틸들을 관리하는 클래스입니다.
- JWT를 생성하거나 유효성을 체크하는 등의 전반적으로 처리되는 기능들을 모아둔 클래스입니다.
- 해당 부분 중 private static final String jwtSecretKey로 Secret 키를 지정한 부분이 있습니다. 예시를 위해서 선언하였지만, 보안적인 이슈를 생각하여 환경파일 내에 넣어두고 별도로 관리하며 사용하는 것을 권장드립니다.
package com.adjh.multiflexapi.common.utils;
import com.adjh.multiflexapi.model.UserDto;
import io.jsonwebtoken.*;
import lombok.extern.log4j.Log4j2;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* JWT 관련된 토큰 Util
*
* @author lee
* @fileName TokenUtils
* @since 2022.12.23
*/
@Log4j2
public class TokenUtils {
// @Value(value = "${custom.jwt-secret-key}")
private static final String jwtSecretKey = "exampleSecretKey";
/**
* 사용자 정보를 기반으로 토큰을 생성하여 반환 해주는 메서드
*
* @param userDto UserDto : 사용자 정보
* @return String : 토큰
*/
public static String generateJwtToken(UserDto userDto) {
// 사용자 시퀀스를 기준으로 JWT 토큰을 발급하여 반환해줍니다.
JwtBuilder builder = Jwts.builder()
.setHeader(createHeader()) // Header 구성
.setClaims(createClaims(userDto)) // Payload - Claims 구성
.setSubject(String.valueOf(userDto.getUserSq())) // Payload - Subject 구성
.signWith(SignatureAlgorithm.HS256, createSignature()) // Signature 구성
.setExpiration(createExpiredDate()); // Expired Date 구성
return builder.compact();
}
/**
* 토큰을 기반으로 사용자 정보를 반환 해주는 메서드
*
* @param token String : 토큰
* @return String : 사용자 정보
*/
public static String parseTokenToUserInfo(String token) {
return Jwts.parser()
.setSigningKey(jwtSecretKey)
.parseClaimsJws(token)
.getBody()
.getSubject();
}
/**
* 유효한 토큰인지 확인 해주는 메서드
*
* @param token String : 토큰
* @return boolean : 유효한지 여부 반환
*/
public static boolean isValidToken(String token) {
try {
Claims claims = getClaimsFormToken(token);
log.info("expireTime :" + claims.getExpiration());
log.info("userId :" + claims.get("userId"));
log.info("userNm :" + claims.get("userNm"));
return true;
} catch (ExpiredJwtException exception) {
log.error("Token Expired");
return false;
} catch (JwtException exception) {
log.error("Token Tampered");
return false;
} catch (NullPointerException exception) {
log.error("Token is null");
return false;
}
}
/**
* Header 내에 토큰을 추출합니다.
*
* @param header 헤더
* @return String
*/
public static String getTokenFromHeader(String header) {
return header.split(" ")[1];
}
/**
* 토큰의 만료기간을 지정하는 함수
*
* @return Calendar
*/
private static Date createExpiredDate() {
// 토큰 만료시간은 30일으로 설정
Calendar c = Calendar.getInstance();
c.add(Calendar.HOUR, 8); // 8시간
// c.add(Calendar.DATE, 1); // 1일
return c.getTime();
}
/**
* JWT의 "헤더" 값을 생성해주는 메서드
*
* @return HashMap<String, Object>
*/
private static Map<String, Object> createHeader() {
Map<String, Object> header = new HashMap<>();
header.put("typ", "JWT");
header.put("alg", "HS256");
header.put("regDate", System.currentTimeMillis());
return header;
}
/**
* 사용자 정보를 기반으로 클래임을 생성해주는 메서드
*
* @param userDto 사용자 정보
* @return Map<String, Object>
*/
private static Map<String, Object> createClaims(UserDto userDto) {
// 공개 클레임에 사용자의 이름과 이메일을 설정하여 정보를 조회할 수 있다.
Map<String, Object> claims = new HashMap<>();
log.info("userId :" + userDto.getUserId());
log.info("userNm :" + userDto.getUserNm());
claims.put("userId", userDto.getUserId());
claims.put("userNm", userDto.getUserNm());
return claims;
}
/**
* JWT "서명(Signature)" 발급을 해주는 메서드
*
* @return Key
*/
private static Key createSignature() {
byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(jwtSecretKey);
return new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName());
}
/**
* 토큰 정보를 기반으로 Claims 정보를 반환받는 메서드
*
* @param token : 토큰
* @return Claims : Claims
*/
private static Claims getClaimsFormToken(String token) {
return Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(jwtSecretKey))
.parseClaimsJws(token).getBody();
}
/**
* 토큰을 기반으로 사용자 정보를 반환받는 메서드
*
* @param token : 토큰
* @return String : 사용자 아이디
*/
public static String getUserIdFromToken(String token) {
Claims claims = getClaimsFormToken(token);
return claims.get("userId").toString();
}
}
5. JwtAuthorizationFilter.java
💡JwtAuthorizationFilter.java
- 해당 클래스는 Spring Security의 환경설정을 구성하는 단계에서 필터로 등록한 클래스이며, 지정한 URL 별 JWT 유효성 검증을 수행하며 직접적인 사용자 '인증' (*STEP6 참고)을 확인합니다.
package com.adjh.multiflexapi.config.filter;
import com.adjh.multiflexapi.common.codes.AuthConstants;
import com.adjh.multiflexapi.common.codes.ErrorCode;
import com.adjh.multiflexapi.common.utils.TokenUtils;
import com.adjh.multiflexapi.config.exception.BusinessExceptionHandler;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.SignatureException;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.json.simple.JSONObject;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
/**
* 지정한 URL 별 JWT 유효성 검증을 수행하며 직접적인 사용자 '인증'을 확인합니다.
*
* @author lee
* @fileName JwtAuthorizationFilter
* @since 2022.12.23
*/
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain chain)
throws IOException, ServletException {
// 1. 토큰이 필요하지 않은 API URL에 대해서 배열로 구성합니다.
List<String> list = Arrays.asList(
"/api/v1/user/login",
"/api/v1/test/generateToken"
// "api/v1/code/codeList"
);
// 2. 토큰이 필요하지 않은 API URL의 경우 => 로직 처리 없이 다음 필터로 이동
if (list.contains(request.getRequestURI())) {
chain.doFilter(request, response);
return;
}
// 3. OPTIONS 요청일 경우 => 로직 처리 없이 다음 필터로 이동
if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
chain.doFilter(request, response);
return;
}
// [STEP1] Client에서 API를 요청할때 Header를 확인합니다.
String header = request.getHeader(AuthConstants.AUTH_HEADER);
logger.debug("[+] header Check: " + header);
try {
// [STEP2-1] Header 내에 토큰이 존재하는 경우
if (header != null && !header.equalsIgnoreCase("")) {
// [STEP2] Header 내에 토큰을 추출합니다.
String token = TokenUtils.getTokenFromHeader(header);
// [STEP3] 추출한 토큰이 유효한지 여부를 체크합니다.
if (TokenUtils.isValidToken(token)) {
// [STEP4] 토큰을 기반으로 사용자 아이디를 반환 받는 메서드
String userId = TokenUtils.getUserIdFromToken(token);
logger.debug("[+] userId Check: " + userId);
// [STEP5] 사용자 아이디가 존재하는지 여부 체크
if (userId != null && !userId.equalsIgnoreCase("")) {
// TODO: [STEP6] 실제 DB로 조회를 하여 유효한 사용자 인지 확인(인증)하는 부분이 들어가면 될것 같습니다.
chain.doFilter(request, response);
} else {
throw new BusinessExceptionHandler("TOKEN isn't userId", ErrorCode.BUSINESS_EXCEPTION_ERROR);
}
// 토큰이 유효하지 않은 경우
} else {
throw new BusinessExceptionHandler("TOKEN is invalid", ErrorCode.BUSINESS_EXCEPTION_ERROR);
}
}
// [STEP2-1] 토큰이 존재하지 않는 경우
else {
throw new BusinessExceptionHandler("Token is null", ErrorCode.BUSINESS_EXCEPTION_ERROR);
}
} catch (Exception e) {
// Token 내에 Exception이 발생 하였을 경우 => 클라이언트에 응답값을 반환하고 종료합니다.
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
PrintWriter printWriter = response.getWriter();
JSONObject jsonObject = jsonResponseWrapper(e);
printWriter.print(jsonObject);
printWriter.flush();
printWriter.close();
}
}
/**
* 토큰 관련 Exception 발생 시 예외 응답값 구성
*
* @param e Exception
* @return JSONObject
*/
private JSONObject jsonResponseWrapper(Exception e) {
String resultMsg = "";
// JWT 토큰 만료
if (e instanceof ExpiredJwtException) {
resultMsg = "TOKEN Expired";
}
// JWT 허용된 토큰이 아님
else if (e instanceof SignatureException) {
resultMsg = "TOKEN SignatureException Login";
}
// JWT 토큰내에서 오류 발생 시
else if (e instanceof JwtException) {
resultMsg = "TOKEN Parsing JwtException";
}
// 이외 JTW 토큰내에서 오류 발생
else {
resultMsg = "OTHER TOKEN ERROR";
}
HashMap<String, Object> jsonMap = new HashMap<>();
jsonMap.put("status", 401);
jsonMap.put("code", "9999");
jsonMap.put("message", resultMsg);
jsonMap.put("reason", e.getMessage());
JSONObject jsonObject = new JSONObject(jsonMap);
logger.error(resultMsg, e);
return jsonObject;
}
}
4) 전송 테스트 : 토큰 발급
1. TestController.java
💡 사전에 구성한 TokenUtils의 generateJwtToken 함수를 통해서 JWT를 발급받습니다.
package com.adjh.multiflexapi.controller;
import com.adjh.multiflexapi.common.codes.AuthConstants;
import com.adjh.multiflexapi.common.codes.SuccessCode;
import com.adjh.multiflexapi.common.response.ApiResponse;
import com.adjh.multiflexapi.common.utils.TokenUtils;
import com.adjh.multiflexapi.model.UserDto;
import io.swagger.v3.oas.annotations.Operation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("api/v1/test")
public class TestController {
/**
* [API] 사용자 정보를 기반으로 JWT를 발급하는 API
*
* @param userDto UserDto
* @return ApiResponseWrapper<ApiResponse> : 응답 결과 및 응답 코드 반환
*/
@PostMapping("/generateToken")
@Operation(summary = "토큰 발급", description = "사용자 정보를 기반으로 JWT를 발급하는 API")
public ResponseEntity<ApiResponse> selectCodeList(@RequestBody UserDto userDto) {
String resultToken = TokenUtils.generateJwtToken(userDto);
ApiResponse ar = ApiResponse.builder()
// BEARER {토큰} 형태로 반환을 해줍니다.
.result(AuthConstants.TOKEN_TYPE + " " + resultToken)
.resultCode(SuccessCode.SELECT.getStatus())
.resultMsg(SuccessCode.SELECT.getMessage())
.build();
return new ResponseEntity<>(ar, HttpStatus.OK);
}
}
[참고] 사전에 필터 내에 토큰 처리를 제외할 URL을 정의하였습니다.
2. API 호출
💡 구성한 API를 호출하여서 JWT 값을 반환받았습니다.
3. 유효한 토큰 확인
[참고] 공식사이트 - JWT debug
💡 인코딩 된 토큰 값을 좌측에 넣어보면 발급한 사용자 정보가 오른쪽에 출력이 됩니다.
💡 토큰 생성 시 넣어둔 값의 유효성을 확인하고 내용도 확인하였습니다.
5) 전송 테스트 -1 : 토큰 미 존재 시
1. JWT 값을 넣지 않고 API 통신 시 테스트
💡 토큰이 존재하지 않는다고 수행됩니다.
6) 전송 테스트 -2 : 토큰 존재 시
1. 필터 확인
💡 해당 필터에서 토큰을 제외하는 URL로 빼두지 않은 상태입니다.
2. 발급받은 토큰값을 Header에 넣어둡니다.
💡 Postman의 Authorization 탭에서 ‘Bearer Token’을 선택하고 발급받은 토큰 값을 넣어줍니다.
3. API를 호출합니다.
💡 결과값을 잘 반환해 주는 것을 확인하였습니다.
4. Token을 빼고 API를 호출합니다.
💡 토큰을 빼고 API를 호출하는 경우 토큰이 존재하지 않음을 반환해주고 있습니다.
[참고] 해당 환경을 구성하기 위해 작성한 사전글에 대해서 공유합니다.
[참고] 해당 글을 기반으로 구성한 Git Repository입니다.
오늘도 감사합니다 😄
반응형
'Java > Spring Boot' 카테고리의 다른 글
[Java] Spring Boot AOP(Aspect-Oriented Programming) 이해하고 설정하기 (2) | 2023.03.01 |
---|---|
[Java] Spring Boot Tomcat Access Log 이해하고 설정하기 (0) | 2023.02.26 |
[Java] Spring Boot Security 이해하기 -3: JWT(JSON Web Token) 이해하기 (0) | 2022.12.21 |
[Java] Spring Boot Security 이해하기 -2 : Spring Boot 2.x 버전 환경 구성하기 (0) | 2022.12.18 |
[Java] Spring Boot Security 이해하기 -1 : 2.7.x 버전 구조 및 파일 이해 (0) | 2022.12.18 |