지난번 Pre-Project를 진행한다고 글을 남긴 후 꽤 많은 시간이 지났다.
조금 부족하지만 괜찮은 결과물을 만들어 낸것에 대해서 매우 뿌듯하고 맡았던 파트에 대해서 메모를 하기위해 왔다.
자세한 코드는 https://github.com/codestates-seb/seb45_pre_007 에서 확인이 가능하다
일단 Stackoverflow 사이트를 간단하게 구현하는 것임으로 각 파트를 나누는 회의를 진행했을 때 Spring Security 파트를 맡게 되었다 다른 API, Service는 그나마 경험할 기회가 많았지만 Security는 아직 부족하다 생각하여 의견을 내었고 다른 팀원 분들도 찬성해주셔서 작업을 진행하였다.
세션말고 전부터 해보고 싶었던 JWT 인증 방식을 사용하기로 했다.
그전에는 FrameWork라는 뜻과 기능이 많이 와닿지 않았는데 Security를 해보니 정말 많은 공부가 되어가는 시간이였다
만들어가는 과정을 같이 블로깅했으면 좋았을텐데 처음 프로젝트이다 보니 여유가 없어 완성된걸 블로깅할 수 밖에 없는게 너무 속상하다..
먼저 Security의 기본이 되는 SecurityConfiguration를 먼저 구현을 진행하였다.
JWT를 사용할 것이기에 JwtTokenizer와 권한(Authority)을 다루는 데 도움을 주는 유틸리티 클래스 CustomAuthorityUtils를 각각 DI 받은 모습니다.
@Configuration
public class SecurityConfiguration {
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
public SecurityConfiguration(JwtTokenizer jwtTokenizer, CustomAuthorityUtils authorityUtils) {
this.jwtTokenizer = jwtTokenizer;
this.authorityUtils = authorityUtils;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.headers().frameOptions().sameOrigin() //H2 웹 콘솔에 정상적으로 접근 가능하도록 설정
.and()
.csrf().disable() //CSRF 공격에 대한 설정
.cors(withDefaults()) // CORS 설정을 추가
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)//.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)를 통해서 세션을 생성하지 않도록 설정
.and()
.formLogin().disable() // JSON 포맷 전달 방식 사용을 위해 비활성화
.httpBasic().disable() // request 전송마다 로그인 정보를 받지 않을 것임으로 비활성화
.apply(new CustomFilterConfigurer())
.and()
.authorizeHttpRequests(authorize -> authorize
.antMatchers("/users/login").permitAll() // 로그인은 모든 사용자에게 허용
// .antMatchers("/users/oauth/**").permitAll() // OAuth 토큰 요청은 모든 사용자에게 허용
.antMatchers(HttpMethod.GET, "/questions").permitAll() //질문 목록을 보는건 모든 사용자에게 허용
.antMatchers(HttpMethod.POST, "/questions").hasRole("USER") // 질문을 생성하는건 인증된 사용자에게만 혀용
.antMatchers(HttpMethod.GET, "/questions/{questionId}").permitAll() //질문을 선택해 조회하는 기능은 인증된 사용자에게만 혀용
.antMatchers(HttpMethod.GET, "/questions/{questionId}/answers").permitAll() //특정 답변을 선택해 조회하는 기능은 인증된 사용자에게만 혀용
.antMatchers(HttpMethod.POST,"/questions/{questionId}/answers/**").hasRole("USER") // 답변과 관련된 경로는 인증된 사용자에게만 허용
.antMatchers(HttpMethod.POST,"/questions/{questionId}/comments/**").hasRole("USER") // 댓글과 관련된 경로는 인증된 사용자에게만 허용
.anyRequest().permitAll()
);
return httpSecurity.build();
}
그리고 추가로 Password의 암호화와 Cors에 대한 추가 설정을 추가하고 Spring Security의 기본 필터로만 하기 어려운 작업을 Custom하기 위해 CustomFilterConfigurer를 추가하였다
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
CorsConfigurationSource corsConfigurationSource(){
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000")); // Http 통신을 허용할 URL 주소 로컬에서 할 경우 ("*")로도 상관없음
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE"));// 지정한 요청에 대한 Http Method에 대한 통신 허용
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity>{
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
jwtAuthenticationFilter.setFilterProcessesUrl("/users/login");
// 로그인 URL 설정
jwtAuthenticationFilter.setAuthenticationSuccessHandler(new UserAuthenticationSuccessHandler());// 로그인 성공시 호출
jwtAuthenticationFilter.setAuthenticationFailureHandler(new UserAuthenticationFailureHandler());// 실패시 호출
builder.addFilter(jwtAuthenticationFilter);
JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils);
builder
.addFilter(jwtAuthenticationFilter)
.addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class);
}
}
그리고 JWT를 구현하기 위해 JwtTokenizer 구현
@Slf4j
@Component
public class JwtTokenizer {
@Getter
@Value("abcdefgabcdefgabcdefgabcdefgabcdefg")
//JWT 생성 및 검증 시 사용되는 Secret Key 정보로 원래는 노출되면 안되지만 pre-project이며 배포를 위한 EC2에서 오류를 일으키는걸 방지하기 위해 직접 추가함
private String secretKey;
@Getter
@Value("${jwt.access-token-expiration-minutes}") //Access Token에 대한 만료 시간 정보
private int accessTokenExpirationMinutes;
@Getter
@Value("${jwt.refresh-token-expiration-minutes}") //Refresh Token에 대한 만료 시간 정보
private int refreshTokenExpirationMinutes;
public String encodeBase64SecretKey(String secretKey){
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String generateAccessToken(Map<String, Object> claims,
String subject,
Date expiration,
String base64EncodedSecretKey){
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey){
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey){
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
log.info("getClaims");
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
log.info("Get claims");
log.info(String.valueOf(claims));
return claims;
}
public void verifySignature(String jws, String base64EncodedSecretKey){
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
log.info("verifySignature");
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
}
public Date getTokenExpiration(int expirationMinutes) {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, expirationMinutes);
Date expiration = calendar.getTime();
return expiration;
}
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
Key key = Keys.hmacShaKeyFor(keyBytes);
return key;
}
}
.parseClaimsJws(jws); 여기의 값을 key 값을 실수로 집어 넣었더니 IntelliJ에서 문제 없이 Run은 되지만 postman으로 계정 생성이 안되어서 하루를 꼬박날렸다.. Log를 찍어보며 테스트를해보며 하나하나보면서 겨우 찾아서 해결을 완료했다..
public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey){
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
log.info("getClaims");
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
log.info("Get claims");
log.info(String.valueOf(claims));
return claims;
}
encodeBase64SecretKey(String secretKey): 주어진 비밀키를 Base64로 인코딩하여 반환
generateAccessToken(Map<String, Object> claims, String subject, Date expiration, String base64EncodedSecretKey): 주어진 정보를 바탕으로 Access Token을 생성하여 반환
generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey): 주어진 정보를 바탕으로 Refresh Token을 생성하여 반환
getClaims(String jws, String base64EncodedSecretKey): 주어진 JWT 문자열과 비밀키를 사용하여 JWT 내의 클레임(클레임은 페이로드에 들어가는 정보)을 추출
verifySignature(String jws, String base64EncodedSecretKey): 주어진 JWT 문자열과 비밀키를 사용하여 JWT 서명을 검증
getTokenExpiration(int expirationMinutes): 주어진 만료 시간(분)을 기반으로 만료 시간을 계산하여 반환
getKeyFromBase64EncodedKey(String base64EncodedSecretKey): 주어진 Base64 인코딩된 비밀키를 디코딩하여 HMAC SHA 키로 변환
위와 같은 역활들을 하고 있다.
그리고 로그인 Post 요청을 담아내기위한 Dto 구현
@Getter //로그인할 때 필요한 정보가 있는 Dto
public class LoginDto {
private String userEmail;
private String password;
}
그리고 사용자 정보를 데이터베이스에서 가져와 Spring Security에 제공하며, Spring Security의 인증 및 권한 관련 작업을 위해 CustomUserDetailsService를 구현
만약 여기 코드가 문제가 있으면 로그인 작업이 정상적을 안될 수 있다.
@Slf4j
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
private final CustomAuthorityUtils authorityUtils;
public CustomUserDetailsService(UserRepository userRepository, CustomAuthorityUtils authorityUtils) {
this.userRepository = userRepository;
this.authorityUtils = authorityUtils;
}
@Override
public UserDetails loadUserByUsername(String userEmail) throws UsernameNotFoundException {
log.info("loadUserByUsername before");
Optional<User> optionUser = userRepository.findByUserEmail(userEmail);
log.info("loadUserByUsername after");
log.info(String.valueOf(optionUser));
User findUser = optionUser.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return new CustomUserDetails(findUser);
}
private final class CustomUserDetails extends User implements UserDetails {
CustomUserDetails(User user) {
// 계정의 있는 Entity 변수명을 그대로 써야함
setUserId(user.getUserId());
setUserEmail(user.getUserEmail());
setHashedUserPassword(user.getHashedUserPassword());
setRoles(user.getRoles());
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorityUtils.createAuthorities(this.getRoles());
}
@Override
public String getPassword() {
return getHashedUserPassword();
}
@Override
public String getUsername() {
return getUserEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
}
그리고 권한 분류를하기위해 CustomAuthorityUtils를 생성
Admin은 시간 상 생략하여 작업을 진행하기로 함
@Component
public class CustomAuthorityUtils {
private final List<GrantedAuthority> USER_ROLES = AuthorityUtils.createAuthorityList("ROLE_USER");
private final List<String> USER_ROLES_STRING = List.of("USER");
public List<String> createRoles(String UserEmail) {
return USER_ROLES_STRING;
}
public List<GrantedAuthority> createAuthorities(List<String> roles) {
List<GrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
return authorities;
}
}
그리고 로그인에 성공했을 때와 실패했을 때를 표시하기 위해 Handler 메서드를 구현했다.
성공했을 때는 표시만 하도록 log만 추가
@Slf4j
public class UserAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
log.error("# Authentication failed: {}", exception.getMessage());
sendErrorResponse(response);
}
private void sendErrorResponse(HttpServletResponse response) throws IOException {
Gson gson = new Gson();
ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class));
}
}
@Slf4j
public class UserAuthenticationSuccessHandler implements AuthenticationSuccessHandler{
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
log.info("# Authenticated successfully!");
}
}
Failhandler에서 쓰일 ErrorResponse 제공
@Getter
@Setter
public class ErrorResponse {
private int status;
private String error;
private String message;
public static ErrorResponse of(HttpStatus httpStatus) {
ErrorResponse errorResponse = new ErrorResponse();
errorResponse.setStatus(httpStatus.value());
errorResponse.setError(httpStatus.getReasonPhrase());
errorResponse.setMessage("Authentication failed");
return errorResponse;
}
}
JWT 기반의 인증을 수행하며, 사용자의 인증 정보를 받아 검증하고 인증이 성공하면 JWT 토큰을 생성하여 응답 헤더에 추가하기 위해 JwtAuthenticationFilter를 구현
@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtTokenizer jwtTokenizer;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
this.authenticationManager = authenticationManager;
this.jwtTokenizer = jwtTokenizer;
}
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
ObjectMapper objectMapper = new ObjectMapper();
LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUserEmail(), loginDto.getPassword());
log.info("# Try login");
return authenticationManager.authenticate(authenticationToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws ServletException, IOException {
User user = (User) authResult.getPrincipal();
String accessToken = delegateAccessToken(user);
String refreshToken = delegateRefreshToken(user);
response.setHeader("UserId", user.getUserId().toString());
// FE팀에서 응답 헤더 중 UserId가 없다고 하여 추가
response.setHeader("Authorization", "Bearer " + accessToken);
response.setHeader("Refresh", refreshToken);
response.setHeader("Access-Control-Expose-Headers", "Authorization");
response.addHeader("Access-Control-Expose-Headers", "UserId");
// FE팀에서 응답 헤더 중 UserId와 Jwt 헤더를 확인하지 못해 Access-Control- Expose-Headers를 추가
log.info("# successfulAuthentication");
this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult); // 추가
}
private String delegateAccessToken(User user) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getUserId()); // Payload에 UserId 추가
claims.put("userEmail", user.getUserEmail());
claims.put("roles", user.getRoles());
String subject = user.getUserEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
return accessToken;
}
private String delegateRefreshToken(User user) {
String subject = user.getUserEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
return refreshToken;
}
}
클라이언트 요청에 포함된 JWT 토큰을 검증하고, 토큰 내의 정보를 추출하여 사용자 인증 및 권한을 설정하는 역할
@Slf4j
public class JwtVerificationFilter extends OncePerRequestFilter {
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
public JwtVerificationFilter(JwtTokenizer jwtTokenizer,
CustomAuthorityUtils authorityUtils) {
this.jwtTokenizer = jwtTokenizer;
this.authorityUtils = authorityUtils;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Map<String, Object> claims = verifyJws(request);
setAuthenticationToContext(claims);
log.info(claims.toString());
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String authorization = request.getHeader("Authorization");
return authorization == null || !authorization.startsWith("Bearer");
}
private Map<String, Object> verifyJws(HttpServletRequest request) {
String jws = request.getHeader("Authorization").replace("Bearer ", "");
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
Map<String, Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody();
return claims;
}
private void setAuthenticationToContext(Map<String, Object> claims) {
String userEmail = (String) claims.get("userEmail");
List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles"));
Authentication authentication = new UsernamePasswordAuthenticationToken(userEmail, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
위의 클래스 중
shouldNotFilter가 주어진 요청이 JWT 토큰을 가지고 있는지 검사하고 요청 시 Authorization 헤더가 없거나 Bearer 토큰이 아닌 경우 필터를 적용하지 않는다.
verifyJws는 주어진 요청의 JWT 토큰을 검증하고, 토큰 내의 클레임 정보를 추출하여 반환한다.
setAuthenticationToContext는 추출한 클레임 정보를 기반으로 사용자의 인증 및 권한 정보를 설정하고, Spring Security의 SecurityContextHolder에 인증 정보를 저장한다.
이렇게 Jwt 구현이 완료된 후 작성자만 해당 작성물에 접근할 수 있도록 추가 설정을 도와주었다.
질문을 생성할 때 인증된 사용가 정보를 가져와 UserId가 데이터 베이스에 들어갈 수 있게 추가해줬다.
// 현재 인증된 사용자 정보 가져오기
@PostMapping
public ResponseEntity<SingleQuestionResponseDto> postQuestion(@RequestBody QuestionDto questionDto) {
// 현재 인증된 사용자 정보 가져오기
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
//현재 인증된 사용자 정보를 가져오기 위해 Spring Security의 SecurityContextHolder를 사용하여 인증 정보를 가져온다.
User currentUser = userService.findUserByEmail(auth.getPrincipal().toString());
//현재 사용자의 이메일을 기반으로 사용자 정보를 데이터베이스에서 조회한다.
// DTO에서 Entity로 변환
Question questionToCreate = mapper.questionPostDtoToQuestion(questionDto);
// 사용자 정보 설정
questionToCreate.setUser(currentUser);
// 서비스에서 엔티티 생성 및 저장
Question createdQuestion = questionService.createQuestion(questionToCreate);
SingleQuestionResponseDto responseDto = mapper.questionToSingleQuestionResponseDto(createdQuestion);
return new ResponseEntity<>(responseDto, HttpStatus.CREATED);
}
이렇게 생성 시 DB에 UserId가 올바르게 들어 간걸 볼 수 있다.
!

이제 다른 사용자가 접근할 수 없도록 추가 구현을 해줘야한다.
public Question updateQuestion(long questionId, Question questionToUpdate, User user) {
//todo : 수정할 권한이 있는지 확인
Question existingQuestion = getQuestion(questionId);
if (!existingQuestion.getUser().equals(user)) {
throw new AccessDeniedException("You do not have permission to update this question.");
} // 이렇게 추가하여 questionId에 있는 UserId가 일치하는지 체크할 수 있다.
if (questionToUpdate.getQuestionTitle() != null) {
existingQuestion.setQuestionTitle(questionToUpdate.getQuestionTitle());
}
if (questionToUpdate.getQuestionContent() != null) {
existingQuestion.setQuestionContent(questionToUpdate.getQuestionContent());
}
//return existingQuestion; 트랜잭션을 구현하면 이걸로 사용
return questionRepository.save(existingQuestion);
}
그리고 응답은 서비스에서 예외처리를 해줬을 때 403에러가 나오도록 추가해줬다.
@PatchMapping("/{questionId}")
public ResponseEntity<SingleQuestionResponseDto> patchQuestion(@PathVariable long questionId,
@RequestBody QuestionDto questionDto) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
User user = userService.findUserByEmail(auth.getPrincipal().toString());
// 업데이트할 데이터를 DTO 에서 new Entity 로 변환
Question questionToUpdate = mapper.questionPatchDtoToQuestion(questionDto);
try {
// QuestionService 를 사용해서 업데이트된 Entity 를 new Entity 에 저장
Question updatedQuestion = questionService.updateQuestion(questionId, questionToUpdate, user);
// 업데이트된 Entity 를 다시 DTO 로 변환
SingleQuestionResponseDto responseDto = mapper.questionToSingleQuestionResponseDto(updatedQuestion);
return new ResponseEntity<>(responseDto, HttpStatus.OK);
} catch (AccessDeniedException e) {
log.error("게시물을 작성한 User가 아닙니다");
return new ResponseEntity<>(HttpStatus.FORBIDDEN);
}
}
여기까지가 필자가 맡아서한 파트이고 공통 작업은 중간중간 지원해주는 느낌으로 진행을 했다.
이제 바로 Main Project를 시작할텐데 산업 군 중에 금융파트가 있는걸 확인했다.
이번에 Security를 직접 만져보니 정말 많은 공부가 된 것 같아서 보다 약한 트랜잭션 부분을 보강하기 위해서는 금융파트가 적절해 보여서 선택할 예정이다.
다음 Project 때는 조금 더 작업했던 과정을 담기 위해 노력할 것이다.. ㅜ