스프링 시큐리티는 필터를 통해서 사용자의 요청이 서블릿에 전달되기 전에 인증과 권한 작업을 수행한다. 스프링 시큐리티는 기본적으로 세션과 쿠키 방식으로 인증을 하는데, 세션은 여러 문제점이 존재하기 때문에 JWT를 이용하여 인증을 진행할 것이다. 따라서 토큰 검증을 통한 인증 및 인가를 수행하기 위해 JWT Filter를 생성하고, 필터 체인에 등록하여 사용할 것이다.
목차
- JWT Filter와 토큰 생성 및 검증 로직 구현
- Spring Security 적용
- 로그인 시 access token 발급
- 토큰 관련 에러 처리 로직 구현
- 회고
JWT Filter와 토큰 생성 및 검증 로직 구현
동작 방식
- 사용자가 로그인 시, 토큰을 생성하여 발급
- 이후 사용자는 헤더에 토큰을 넣어 요청
- 사용자의 request Header에서 토큰을 가져옴
- Token의 유효성 검사를 실시
- 유효하면 Authentication 인증객체 생성
- SecurityContext에 저장
- 해당 Filter 과정이 끝나면 시큐리티에서 다음 Filter로 이동함
build.gradle
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
- 의존성 추가
CustomJwtFilter(Jwt Filter 구현)
@Slf4j
@RequiredArgsConstructor
public class CustomJwtFilter extends GenericFilterBean {
private final TokenProvider tokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
String token = tokenProvider.resolveToken((HttpServletRequest) request);
// 유효성 검증
if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
// SecurityContext에 Authentication 객체(토큰 인증과정 결과)를 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Security Context에 '{}' 인증 정보를 저장했습니다", authentication.getName());
} else {
log.debug("유효한 JWT 토큰이 없습니다");
}
chain.doFilter(request, response);
}
}
- 시큐리티는
UsernamePasswordAuthenticationFIlter에서 인증 객체를 생성하여 인증 관리자(AuthenticationManager)에게 인증 객체를 넘겨서 인증 처리를 위임한다.- 하지만 Rest API Server이기 때문에 해당 필터 매커니즘을 사용하지 않으며, JWT를 사용하여 인증 및 인가 처리를 한다.
- 따라서 사용자 정의 필터인
CustomJwtFilter를 필터 체인에 등록하여 사용한다.CustomJwtFilter는 유효한 토큰인지 검증하기 위한 Filter이다. - 해당 필터에서 인증 및 권한 처리를 진행하기 때문에 인증 관리자(AuthenticationManager)를 사용하지 않고, SecurityContext에 바로 인증 객체를 저장한다.
resolveToken()으로 헤더에서 토큰 정보를 추출하고,validateToken()으로 해당 토큰이 유효한지 검사한다.- 토큰 검증이 완료되면
getAuthentication()으로 Authentication(인증) 객체를 생성하여 SecurityContext에 저장한다.
- Authentication: 시큐리티에 저장할 수 있는 오브젝트의 타입으로, 이 안에 User 정보가 있어야 한다.
TokenProvider(토큰 생성 및 검증 로직)
@Slf4j
@Component
@RequiredArgsConstructor
public class TokenProvider implements InitializingBean { // 토큰 생성 및 검증
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_TYPE = "Bearer ";
private static final String AUTHORITIES_KEY = "auth";
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-token-validity-in-seconds}")
private long tokenValidityInMilliseconds;
private Key key;
@PostConstruct
protected void init() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
/**
* 헤더에서 토큰 정보 추출
*/
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
return bearerToken.substring(7);
}
return null;
}
/**
* 토큰 생성 - 로그인 요청 시 반환
*/
public String createToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date validity = new Date(now + this.tokenValidityInMilliseconds);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
/**
* 토큰에 저장한 유저 식별값(memberId)을 추출
* 토큰을 받아 Claims을 만들고 권한정보로 시큐리티 유저객체를 만들어 Authentication 객체 반환
*/
public Authentication getAuthentication(String token) {
// 토큰 복호화
Claims claims = parseClaims(token);
// 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
private Claims parseClaims(String accessToken) {
return Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken)
.getBody();
}
/**
* 토큰 유효성 검사
*/
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
throw new JwtException(TOKEN_WRONG_EXCEPTION.getMessage());
} catch (ExpiredJwtException e) {
throw new JwtException(TOKEN_EXPIRED_EXCEPTION.getMessage());
} catch (UnsupportedJwtException e) {
throw new JwtException(TOKEN_UNSUPPORTED_EXCEPTION.getMessage());
} catch (IllegalArgumentException e) {
throw new JwtException(TOKEN_INVALID_EXCEPTION.getMessage());
}
}
}
createToken()- 로그인 요청 시 토큰을 생성하여 반환한다. 이때 생성한 토큰이 JWT 필터가 적용될 때 검증하는 토큰이다.
- JWT는 헤더에 정의한 알고리즘으로 비밀키(
Key)를 해싱하여 생성한다.- 헤더와 페이로드의 값을 각각 Base64로 인코딩
- 인코딩한 값을 비밀키를 이용해 헤더에서 정의한 알고리즘으로 해싱
- 해싱한 값을 다시 Base64로 인코딩하여 생성
Key는hmacShaKeyFor()에secretKey문자열을 바이트 배열로 전달하고,Secretkey인스턴스를 반환받아 사용한다.- 이때 사용하는 비밀키 값(
secretKey)은 외부에 노출되어서는 안되기 때문에 application.properties에서 값을 주입 받아 사용한다. - HS512 알고리즘을 사용하는 경우, 64byte 이상의
secretKey를 사용해야 한다.
- 이때 사용하는 비밀키 값(
- 해당 Key로 토큰을 생성하여 반환한다.
signWith(key, SignatureAlgorithm.HS512): Key와 암호화 알고리즘 설정setExpiration(validity): 토큰 만료시간(tokenValidityInMilliseconds)설정
- [궁금한점] 인코딩한 jwt.secret를 다시 디코딩하여 사용하는 이유
- 보통 jwt.secret은 문자열을 Base64로 인코딩한 값으로 사용하는데, 이를 Key 생성할 때 다시 디코딩해주는 이유가 무엇인지 궁금해졌다.
- 여러 글들을 찾아보고 읽어보았지만, 납득이 될 만한 이유에 대해서는 결국 찾지 못했다.. ㅠㅅㅠ. jjwt 버전이 업되면서 Plain Text(평문) 자체를 Secret Key로 사용하는 것을 권장하는 않는다는 내용을 보긴했지만 이 또한 확실하지는 않다. 나중에 좀 더 찾아볼 예정이다.
- 참고
resolveToken()- 사용자가 요청했을 때 헤더에 있는 토큰을 추출한다.
validateToken()- 해당 토큰이 유효한지 검증하는 과정을 거친다.
- 토큰이 만료되었거나, 유효하지 않은 서명 또는 잘못된 토큰 등 토큰과 관련하여
Jwts가 Exception을 던져준다.
getAuthentication()
- 토큰을 복호화하여 Claims을 만들고, 권한정보로 시큐리티 유저객체를 만들어 Authentication 객체를 생성하여 반환한다.
- 이 인증 객체가 SecurityContext에 저장되고, 이후에 전역적으로 가져와서 사용하게 된다.
Spring Security 적용
SecurityUser와 CustomUserDetailsService
SecurityUser
@Slf4j
@Getter @Setter
public class SecurityUser extends User {
private Member member;
public SecurityUser(Member member) {
super(member.getMemberId(), member.getPassword(), AuthorityUtils.createAuthorityList(member.getRole().toString()));
log.info("SecurityUser member.memberId = {}", member.getMemberId());
log.info("SecurityUser member.nickname = {}", member.getNickname());
log.info("SecurityUser member.email = {}", member.getEmail());
log.info("SecurityUser member.role = {}", member.getRole().toString());
this.member = member;
}
}
User클래스는UserDetails인터페이스를 구현한 클래스이다.UserDetails: Authentication 객체에 저장할 수 있는 유일한 타입
SecurityUser는 스프링 시큐리티에서 관리하는 User 정보를 관리한다.
CustomUserDetailsService
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member findMember = memberRepository.findByMemberId(username)
.orElseThrow(() -> new BaseException(NICKNAME_NOT_EXIST));
log.info("loadUserByUsername member.memberId = {}", username);
return new SecurityUser(findMember);
}
}
- UserDetailsService 인터페이스는 스프링 시큐리티에서 인증 정보를 조회하기 위해 사용한다.
TokenProvider가 제공한 사용자 정보로 DB에서 사용자를 조회하여 SecurityUser(UserDetails)를 생성하여 반환한다.
SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomUserDetailsService userDetailsService;
private final TokenProvider tokenProvider;
@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder(); // BCrypt 해시 함수를 이용하여 패스워드를 암호화하는 구현체
}
@Override
public void configure(WebSecurity webSecurity) throws Exception {
webSecurity.ignoring().antMatchers("/css/**", "/js/**"); // 인증을 무시할 경로 설정
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(getPasswordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
/**
* REST API - JWT 추가
*/
http
.httpBasic().disable() // 기본설정 미사용
.csrf().disable() // 토큰 사용으로 csrf 보안이 불필요
.formLogin().disable()
// 세션을 사용 x -> STATELESS로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// api 경로
.and()
.authorizeRequests()
.antMatchers("/member/join", "/member/login", "/post/search").permitAll()
.anyRequest().authenticated() // 나머지 경로는 jwt 인증
// JwtFilter 추가
.and()
.addFilterBefore(new CustomJwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
}
}
- JWT를 이용하여 인증을 진행하기 때문에 세션을 사용하지 않는다.
UsernamePasswordAuthenticationFilter는 실제 폼 기반 인증을 처리하는 필터이기 때문에 해당 필터 이전에CustomJwtFilter를 등록시킨다.- 이렇게 되면
CustomJwtFilter가 먼저 실행되어 JWT 인증 로직을 처리하게 된다.
- 이렇게 되면
SecurityUtils
@Slf4j
@NoArgsConstructor
public class SecurityUtil {
/**
* JwtFilter에서 SecurityContext에 세팅한 유저 정보 리턴
*/
public static Optional<String> getCurrentMemberId() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
log.debug("Security Context에 인증 정보가 없습니다.");
return Optional.empty();
}
String memberId = null;
if (authentication.getPrincipal() instanceof UserDetails) {
UserDetails springSecurityUser = (UserDetails) authentication.getPrincipal();
memberId = springSecurityUser.getUsername();
} else if (authentication.getPrincipal() instanceof String) {
memberId = (String) authentication.getPrincipal();
}
return Optional.ofNullable(memberId);
}
public static void checkAuthorizedMember(String memberId) {
String loginId = SecurityUtil.getCurrentMemberId().orElseThrow(() -> new BaseException(NOT_LOGIN));
if(!memberId.equals(loginId)) throw new BaseException(NOT_AUTHORIZED);
}
}
getCurrentMemberId(): JwtFilter를 통해 SecurityContext에 유저의 정보가 저장되기 때문에 해당 유저의 정보를 가져오는 유틸성 메소드이다.SecurityContextHolder.getContext().getAuthentication()로 Authentication객체를 꺼내와서 memberId를 가져온다.
checkAuthorizedMember(): 인자로 들어오는 memberId와 SecurityContext에 저장되어 있는 유저의 정보가 같은지 확인한다.- 게시글을 수정할 때, 로그인 상태이면서 게시글을 작성한 유저와 로그인한 유저가 같아야하기 때문에 이때 사용한다.
로그인 시 access token 발급
MemberService
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {
/**
* 로그인
*/
public LoginResponseDto login(String memberId, String password) {
Member loginMember = memberRepository.findByMemberId(memberId)
.orElseThrow(() -> new BaseException(MEMBER_ID_NOT_EXIST));
if(!passwordEncoder.matches(password, loginMember.getPassword())) {
throw new BaseException(WRONG_PASSWORD);
}
String accessToken = authorize(memberId, password);
return LoginResponseDto.builder().accessToken(accessToken).build();
}
public String authorize(String memberId, String password) {
// 1. ID/PW 기반으로 AuthenticationToken 생성
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(memberId, password);
// 2. 실제 검증 로직 - CustomUserDetailsService에서 재정의한 loadUserByUsername 메서드 호출
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
// 인증 정보를 기준으로 jwt access 토큰 생성
return tokenProvider.createToken(authentication);
}
...
}
- anthorize()
- 로그인 ID와 PW기반으로 AuthenticationToken을 생성하여 실제 검증 로직에 넘긴다.
- AuthenticationManager의
authenticate()에서 실제 검증 로직이 실행된다.- CustomUserDetailsService에서 재정의한
loadUserByUsername()메서드가 호출되고 유저 정보가 일치한다면 Authentication 객체가 반환된다.
- CustomUserDetailsService에서 재정의한
- Authentication 인증 객체를 바탕으로 JWT access 토큰을 생성하여 발급한다.
LoginResponseDto
@Getter
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class)
public class LoginResponseDto {
private final String accessToken;
@Builder
public LoginResponseDto(String accessToken) {
this.accessToken = accessToken;
}
}
- 로그인 성공 시, 응답 DTO로 토큰을 넣어서 반환한다.
로그인 성공 시, API 응답 값
- 다음과 같이 로그인 성공시, 토큰이 잘 발급된다.
토큰 관련 에러 처리 로직 구현
1. 서블릿 기본 오류 화면 제공(EntryPoint, Handler 사용)
SecurityConfig
@Override
protected void configure(HttpSecurity http) throws Exception {
// ...
http
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 인증 실패 핸들링
.accessDeniedHandler(jwtAccessDeniedHandler) // 인가 실패 핸들링
// ...
}
- 인증이 안된 사용자가 인증이 필요한 엔드포인트로 접근하거나, 인증은 되어있지만 접근할 권한이 없다면 스프링 시큐리티에는 기본 설정되어 있는 상태코드(401, 403)와 함께 스프링 기본 오류 페이지를 보여준다.
- 이때 기본 오류 페이지가 아닌 커스텀 오류 페이지나, JSON 데이터를 응답해야 하는 경우 AuthenticationEntryPoint와 AccessDeniedHandler 인터페이스를 구현하고 구현체를 시큐리티에 등록하여 사용할 수 있다.
JwtAuthenticationEntryPoint
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401 에러 리턴
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
- AuthenticationEntryPoint: 인증 처리 과정에서 예외가 발생한 경우(401 Unauthoized) 예외를 핸들링하는 인터페이스
- 인증 예외가 발생하면 AuthenticationException을 발생시키고 AuthenticationEntryPoint에서 처리한다.
- AuthenticationEntryPoint 인터페이스 구현체의 commence 메소드를 실행한다.
JwtAccessDeniedHandler
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
//필요한 권한 없이 접근하려 할때 403 리턴
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
- AccessDeniedHandler: 인가 처리 과정에서 예외가 발생한 경우(403 Forbidden) 예외를 핸들링하는 인터페이스
- 인가 예외가 발생하면 AccessDeniedException을 호출시키고 AccessDeniedHandler에서 처리한다.
2. 토큰 에러 핸들러 필터(ExceptionHandlerFilter) 추가
스프링 시큐리티의 Filter에서 발생한 예외 처리
- 문제 발생
- AuthenticationEntryPoint와 AccessDeniedHandler는 기본 오류 화면을 제공해준다.
- 따라서 GlobalExceptionHandler와 같이 @RestControllerAdvice와 @ExceptionHandler을 이용하여 jwt 커스텀 예외를 생성하고, jwt 예외 핸들러를 만들려고 했었다.
- 하지만 Filter에서 발생한 예외는 @ControllerAdvice의 적용범위에 들어가지 않는다!
Filter➔Servlet➔Interceptor(서블릿 예외 처리 참고)- Filter는 Dispatcher Servlet보다 앞단에 존재하고, Handler Interceptor는 Servlet보다 뒷단에 존재한다.
- 따라서 Filter에서 발생하는 예외는 Exception Handler에서 처리를 하지 못한다.
- 따라서 토큰 예외를 처리하기 위해서는 CustomeJwtFilter보다 앞에 새로운 Filter를 정의해서 필터 체인에 추가해주어야 한다.
- AuthenticationEntryPoint와 AccessDeniedHandler는 기본 오류 화면을 제공해준다.
예외를 핸들링하는 Filter 추가
@Slf4j
public class ExceptionHandlerFilter extends OncePerRequestFilter {
/**
* 토큰 관련 에러 핸들링
* JwtTokenFilter 에서 발생하는 에러를 핸들링해준다.
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (JwtException e) {
// 토큰
setErrorResponse(request, response, e);
}
}
private void setErrorResponse(HttpServletRequest request, HttpServletResponse response, Throwable ex){
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
ObjectMapper objectMapper = new ObjectMapper();
Map<String, Object> data = new HashMap<>();
Map<String, Object> error = new HashMap<>();
error.put("status", HttpServletResponse.SC_UNAUTHORIZED);
error.put("message", ex.getMessage());
error.put("path", request.getServletPath());
data.put("error", error);
ApiResponse errorResponse = new ApiResponse(ResponseStatus.ERROR, data, "Unauthorized");
try{
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
} catch (IOException e){
e.printStackTrace();
}
}
}
- CustomJwtFilter에서 발생하는 예외를 처리하기 위한 Filter
filterChain.doFilter()로 다음 필터를 실행시키고, JwtException이 발생하면 에러 응답을 보낸다.- TokenProvider의 validateToken()에서 JwtException 예외가 발생한다.
SpringSecurity
@Override
protected void configure(HttpSecurity http) throws Exception {
// ...
http
.addFilterBefore(new CustomJwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new ExceptionHandlerFilter(), CustomJwtFilter.class);
// ...
}
- CustomJwtFilter보다 이전에 ExceptionHandlerFilter를 실행시켜, JwtFilter에서 발생하는 예외를 ExceptionHandlerFilter에서 처리한다.
토큰관련 예외 발생 시 , API 응답 값
회고
사실 이렇게 ExceptionHandlerFilter를 만들어보니 AuthenticationEntryPoint와 AccessDeniedHandler를 커스텀해도 예외를 처리하기에 충분해보였다. 사실 @ControllerAdvice 구현이 가능할 줄 알고 구현해보다가 안되는 것을 깨닫고, 어떤 방법이 있을까 찾아보다가 ExceptionFilter를 만들게 된 것이기 때문에 새로운 방법을 알게 되었다는 것에 만족한다. 찾아보니 인증 및 인가 수준에서 나올 수 있는 모든 예외들을 처리하는 handler가 이미 시큐리티에 다 구현되어 있어 커스텀만 잘 한다면 따로 예외처리 필터를 만들 필요도 없는 것 같았다. 이 부분도 좀 더 공부해서 개선하는 과정을 거쳐야겠다. 사실 고쳐야할 부분이 아직 많아서 차근차근 공부하면서 개선해야겠다.
이렇게 jwt를 이용하여 로그인하는 로직을 구현해보았는데 아직까지도 시큐리티에 대한 이론 부분이 많이 부족하다는 것을 느꼈다. 해서 시간이 될 때마다 시큐리티와 OAuth(https://www.inflearn.com/roadmaps/639)도 함께 공부할 예정이다!!! 야생형이라고 생각했던 나는.. 학자형일까? 코드를 작성하면서 이해가 안되는 부분을 하나하나 찾아가는 과정도 재미있긴 하지만 시큐리티가 유독 내용이 어려웠던 것인지, 그 찾아가는 과정이 조금 힘들었다. 그래도 큰 로직은 이해했기 때문에 강의를 들으면서 기초를 다져야겠다.
+ 요즘에 또 드는 생각이지만 새로운걸 공부하고 적용하는 과정이 너무 재미있다..!!! 달리자 !~!
'Project > PE-Community' 카테고리의 다른 글
[PE-Community] 계층형 댓글 구현 (0) | 2023.11.09 |
---|---|
[PE-Community] 게시글 및 게시판 구현(+파일 등록) (0) | 2023.10.25 |
[PE-Community] 회원가입 및 로그인 구현 (0) | 2023.09.07 |
[PE-Community] 기능 정의 및 DB 설계 (0) | 2023.08.22 |
[PE-Community] 토이 프로젝트 시작하기(주제 선정) (1) | 2023.08.21 |