Today's Codekata
// 소수 만들기
class Solution {
public int solution(int[] nums) {
int answer = 0;
for (int i = 0; i < nums.length - 2; i++) {
for (int j = i + 1; j < nums.length - 1; j++) {
for (int k = j + 1; k < nums.length; k++) {
int num = nums[i] + nums[j] + nums[k];
int count = 0;
for (int l = 1; l <= num; l++) {
if (num % l == 0) {
count++;
}
}
if (count == 2) {
answer += 1;
}
}
}
}
return answer;
}
}
코드도 길고 비효율적으로 보이기도 하지만 정답에 도달했음에 만족한다.
// 덧칠하기
class Solution {
public int solution(int n, int m, int[] section) {
int answer = 0;
int painted = 0;
for (int i = 0; i < section.length; i++) {
if (section[i] > painted) {
painted = section[i] + m - 1;
answer++;
}
}
return answer;
}
}
항상 문제를 마주하는 첫 순간에는 막막한 기분이 드는데, 풀고 나면 느껴지는 성취감이 있다. 너무 긴 시간 고민하는 것이 아쉽다.
-- 자동차 대여 기록에서 장기/단기 대여 구분하기
SELECT HISTORY_ID, CAR_ID,
DATE_FORMAT(START_DATE, "%Y-%m-%d") AS START_DATE,
DATE_FORMAT(END_DATE, "%Y-%m-%d") AS END_DATE,
CASE WHEN DATEDIFF(END_DATE, START_DATE) >= 29 THEN '장기 대여'
ELSE '단기 대여' END AS RENT_TYPE
FROM CAR_RENTAL_COMPANY_RENTAL_HISTORY
WHERE START_DATE BETWEEN '2022-09-01' AND '2022-09-30'
ORDER BY HISTORY_ID DESC
-- 자동차 평균 대여 기간 구하기
SELECT CAR_ID,
ROUND(AVG(DATEDIFF(END_DATE, START_DATE) + 1), 1) AS AVERAGE_DURATION
FROM CAR_RENTAL_COMPANY_RENTAL_HISTORY
GROUP BY CAR_ID
HAVING ROUND(AVG(DATEDIFF(END_DATE, START_DATE) + 1), 1) >= 7
ORDER BY AVERAGE_DURATION DESC, CAR_ID DESC;
-- 헤비 유저가 소유한 장소
SELECT ID, NAME, HOST_ID
FROM PLACES
WHERE HOST_ID IN (
SELECT HOST_ID
FROM PLACES
GROUP BY HOST_ID
HAVING COUNT(*) > 1
)
ORDER BY ID;
`DATEDIFF()` 는 날짜 간의 차이를 계산할 때 쓰이는 함수이다. 다만 종료일이 포함되지 않음에 주의해서 사용해야 하겠다.
마지막 문제의 `WHERE`절은 서브쿼리를 활용해서 공간을 2개 이상 가진 HOST_ID만 추출해서 `IN`으로 괄호 안에 포함된 값만 필터링해준다. 이처럼 서브쿼리는 `FROM`뿐 아니라 `JOIN`,`IN` 등 다양하게 활용이 가능하다. 같은 테이블을 참조하더라도, 논리적으로는 별개의 결과 집합으로 동작한다. 즉, 완전히 별개의 테이블으로 볼 수 있을 것이다.
일정 관리 앱
회원가입과 로그인
@Slf4j
public class LoginFilter implements Filter {
private static final String[] WHITE_LIST = {"/", "/users/signup", "/users/login", "/users/logout"};
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
HttpServletResponse httpResponse = (HttpServletResponse) response;
log.info("로그인 필터 작동중 - 요청 URI: {}", requestURI);
if (!isWhiteList(requestURI)) {
HttpSession session = httpRequest.getSession(false);
if (session == null || session.getAttribute("LOGIN_USER") == null) {
httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "로그인 해주세요.");
return;
}
}
chain.doFilter(request, response);
}
private boolean isWhiteList(String requestURI) {
return PatternMatchUtils.simpleMatch(WHITE_LIST, requestURI);
}
}
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean loginFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginFilter());
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
// 로그인
@PostMapping("/login")
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
User user = userService.login(request.email(), request.password());
HttpSession session = httpRequest.getSession();
session.setAttribute("LOGIN_USER", user.getId());
return ResponseEntity.ok("로그인 되었습니다.");
}
회원가입과 로그인을 구현해서 로그인한 사용자만 특정 API를 사용할 수 있게 하려면 어떻게 해야할지 고민해보다가 `Filter`를 활용한 인증 처리를 구현해봤다. Spring에는 `Filter`라는 인터페이스가 있는데, 모든 HTTP 요청이 컨트롤러에 도달하기 전에 먼저 요청을 가로채서 검사할 수 있다. 그래서 로그인 여부를 확인하는 필터를 만듬으로써 로그인 즉 인증되지 않은 사용자는 특정 API에 접근하지 못하게 막을 수 있었다. 처음엔 굉장히 복잡하게 느껴졌는데, '요청을 가로채서 검사한다!' 라는 부분에 집중하니 훨씬 명확해졌다. 인증된 사용자에게만 권한을 부여하고 직접 그런 흐름을 제어해보는 것이 흥미로웠다.
Validation 과 GlobalExceptionHandler
@Transactional
public ScheduleResponse update(Long id, UpdateScheduleRequest request) {
Schedule findSchedule = scheduleRepository.findByIdOrElseThrow(id);
if (request.title() != null) {
findSchedule.updateTitle(request.title());
}
if (request.content() != null) {
findSchedule.updateContent(request.content());
}
return ScheduleResponse.from(findSchedule);
}
// 일정 수정 (제목 or 내용)
@PatchMapping("/{id}")
public ResponseEntity<ScheduleResponse> update(
@PathVariable Long id,
@RequestBody UpdateScheduleRequest request
) {
if ((request.title() == null || request.title().isBlank()) &&
(request.content() == null || request.content().isBlank())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정할 항목을 하나 이상 입력해주세요!");
}
ScheduleResponse updatedSchedule = scheduleService.update(id, request);
return new ResponseEntity<>(updatedSchedule, HttpStatus.OK);
}
각 요청DTO, Entity, Controller에 어노테이션들을 활용해서 Validation 처리(입력값 검증)을 해줬다. 실제 사용자 입장에서 각각의 필드들이 가질 조건들을 고려해보며 설정해주다보니 일정 수정 API에서 작은 고민이 생겼다. 제목과 내용을 둘다 수정하는 것보다 일정 제목이나 일정 내용중에 하나만 수정하는 경우도 있을 수 있다고 생각했다. 그렇다면 당연히 요청DTO에서 필수값 처리를 할 수는 없어서 Controller에서 조건을 직접 검사해줘야 했다. 하지만 이런 방식은 로직이 분산된다고 느껴져서 추후를 생각하면 좋은 방식은 아니라고 느꼈다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<String> handleValidationException(MethodArgumentNotValidException ex) {
String errorMessage = ex.getBindingResult()
.getAllErrors()
.get(0)
.getDefaultMessage();
return ResponseEntity.badRequest().body(errorMessage);
}
}
`@RestControllerAdvice`를 활용해서 전역 예외 처리 클래스인 `GlobalExceptionHandler` 클래스를 만들었다. 입력값 검증 과정에서 `MethodArgumentNotValidException`이 발생하면, 해당 예외를 처리하여 각 필드에 지정된 검증 메시지를 클라이언트에게 반환하도록 만들었다. 예외 처리 메서드에서는 `BindingResult`를 통해 발생한 모든 오류 중 첫 번째 오류 메시지를 추출하고, 이를 `400 Bad Request`로 응답하고 있다. 이렇게 하면 유효성 검증 실패 시 사용자에게 명확한 피드백을 제공할 수 있어보인다.
비밀번호 암호화
@Component
public class PasswordEncoder {
public String encode(String rawPassword) {
return BCrypt.withDefaults().hashToString(BCrypt.MIN_COST, rawPassword.toCharArray());
}
public boolean matches(String rawPassword, String encodedPassword) {
BCrypt.Result result = BCrypt.verifyer().verify(rawPassword.toCharArray(), encodedPassword);
return result.verified;
}
}
@Transactional
public UserResponse signUp(String username, String email, String password) {
String encodedPassword = passwordEncoder.encode(password);
User user = new User(username, email, encodedPassword);
userRepository.save(user);
return UserResponse.from(user);
}
@Transactional(readOnly = true)
public User login(String email, String rawPassword) {
User user = userRepository.findByEmail(email).orElseThrow(
() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "존재하지 않는 이메일입니다."));
if (!passwordEncoder.matches(rawPassword, user.getPassword())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다.");
}
return user;
}
`encode()` 메서드로 평문 비밀번호를 해싱해주고, `matches()` 메서드를 통해 입력값과 저장된 해시값을 비교한다. 서비스 로직에서 사용자가 회원가입할 때 입력한 비밀번호는 이제 DB에 저장되기 전에 암호화되고, 로그인 시 입력된 비밀번호와 저장된 해시값을 비교하여 인증 처리를 하게 된다. 단방향 해시를 사용하는 이유는 암호화된 해시값은 복호화가 불가능하기 때문에 DB를 탈취해도 원래 비밀번호를 알 수 없다. 그래서 로그인 시에는 비교 방식으로 인증하는 것이 가장 안전한 방법이 된다.
마치며
이번 일정 관리 앱을 만들면서 회원가입과 로그인, 비밀번호 암호화, 인증 필터, 입력값 검증, 전역 예외 처리 등 웹 애플리케이션의 핵심적인 보안과 안정성 요소들을 직접 구현해볼 수 있었다. 특히 비밀번호 암호화는 단순한 기능 구현을 넘어서, 사용자의 민감한 정보를 어떻게 안전하게 다룰 것인가에 대한 고민의 시작이었다. 단방향 해시 알고리즘을 사용함으로써 복호화가 불가능한 안전한 저장 방식과 비교 기반 인증 흐름을 이해하게 되었고, 그 과정에서 보안의 중요성을 느낄 수 있었다. 또한 Filter를 활용한 인증 처리와 `@RestControllerAdvice`를 통한 예외 핸들링은 Spring의 구조적인 장점을 활용해 명확하고 일관된 흐름을 만들어갈 수 있었다. 아직 부족한 점도 많고, 더 개선할 수 있는 부분도 많지만 직접 고민하고 구현해보며 얻은 경험은 그 어떤 튜토리얼보다 값진 배움이었다. 앞으로 더 다양한 인증 방식과 보안 전략을 탐구해보고 싶다. 그리고 그 모든 과정들을 기록하며, 꾸준히 성장하려고 한다.