[Spring] 일정 관리 API with JPA

thezz9·2025년 4월 3일
2

개요

지난 번에 구현했던 일정 관리 API 과제의 심화 버전이다.

이전과 다른 요구 사항 중 큰 것들만 나열한다면 다음과 같다.

  • JPA 사용
  • Filter 사용
  • 댓글 게시판 추가
  • 쿠키/세션을 활용한 로그인 로직 구현
  • Bcrypt를 활용한 비밀번호 암호화 구현

이번 구현에서는 여러 관점에서 어떤 방법이 더 나을지 고민해서 설계하는 데 초점을 맞췄다. 기술적인 어려움은 크게 없었기에 트러블 슈팅보다는 튜터님들께 받은 피드백을 토대로 리팩토링한 과정을 중심으로 정리해보려고 한다.


1. 예외 처리는 어느 레이어에서 해야 할까?

기존 코드

// UserRepository
User findUserByEmailOrElseThrow(String email);

default User findUserByEmailOrElseThrow(String email) {
	return findUserByEmail(email)
		.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
        "이메일 " + email + "에 해당하는 사용자가 존재하지 않습니다."));
}

수정 코드

// LoginService
Optional<User> user = userRepository.findUserByEmail(dto.getEmail());

if (user.isEmpty() || passwordEncoder.matches(dto.getPassword(), user.get().getPassword())) {
	throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,
	"아이디 또는 비밀번호가 일치하지 않습니다.");
}

로그인 로직에서 사용자를 조회하는 과정의 예외 처리를 어느 레이어에서 할지 고민이 있었다.
물론 로그인 로직은 비즈니스 로직이라는 것을 알고 있었지만 코드의 가독성을 고려해 예외 처리를 어디서 할지 고민했다.

1. Repository 레이어에서 예외 처리

  • 메서드 한 줄만 추가하면 돼서 코드가 간결해진다.
  • 여러 곳에서 동일한 예외 처리를 해야 할 경우 중복을 줄일 수 있다.

2. Service 레이어에서 예외 처리

  • 여러 곳에서 동일한 예외 처리를 해야 할 경우 중복이 많아져서 가독성이 떨어질 수 있다.

이번 과제에서는 사용할 곳이 한 곳뿐이었지만, 추후에 많아질 것을 가정하고 Repository 레이어에서 예외 처리를 하는 방식으로 구현했었다.

로그인 로직의 흐름을 정리해보면

1️⃣ 사용자가 여러 기능을 사용하려면 로그인이 필요하다.
2️⃣ 로그인을 위해 이메일과 비밀번호를 입력받는다.
3️⃣ 입력받은 이메일로 사용자 정보를 조회한다. (이 단계의 예외 처리 고민)
4️⃣ 조회한 정보의 비밀번호와 사용자가 입력한 비밀번호를 비교한다.
5️⃣ 검증에 성공하면 세션을 설정하고 실패하면 예외를 던진다.

로그인 로직의 핵심은 "사용자 조회와 인증 수행"이다. 즉, 비즈니스 로직이다.
그래서 Service 레이어에서 예외 처리를 하는 방식으로 수정했다.
또, 지금 생각해보니 로그인 과정 중에 발생하는 예외를 UserRepository에서 처리하는 것도 이상하긴 하다.


2. 세션 정보 출력 메서드 static 사용

로그 출력 메서드

@Slf4j
public class SessionLogger {

    public static void logSessionInfo(HttpSession session) {
        log.info("session.getId()={}", session.getId());
        log.info("session.getMaxInactiveInterval()={}", session.getMaxInactiveInterval());
        log.info("session.getCreationTime()={}", session.getCreationTime());
        log.info("session.getLastAccessedTime()={}", session.getLastAccessedTime());
        log.info("session.isNew()={}", session.isNew());
    }

로그인 필터

@Slf4j
public class LoginFilter implements Filter {

    private static final String[] ALLOWED_PATHS = {"/", "/api/users/signup", "/api/login", "/api/logout"};

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String requestURI = httpRequest.getRequestURI();

        if (isAllowedPath(requestURI)) {
            chain.doFilter(request, response);
            return;
        }

        log.info("로그인 필터 로직 실행");

        HttpSession session = httpRequest.getSession(false);

        if (session == null || session.getAttribute("userId") == null) {
            log.warn("로그인되지 않은 사용자 요청: {}", requestURI);
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "로그인 해주세요.");
        }

		// 세션 정보 출력
        SessionLogger.logSessionInfo(session);

        chain.doFilter(request, response);
    }

    private boolean isAllowedPath(String requestURI) {
        return PatternMatchUtils.simpleMatch(ALLOWED_PATHS, requestURI);
    }

로그인 필터에서 세션 정보를 출력하는 메서드를 static으로 사용할지 고민했다.
성능과 보안, 두 가지 관점에서 했던 고민한 내용은 다음과 같다.

1️⃣ new 키워드로 객체를 생성해서 사용을 하면 메모리 낭비가 너무 심하지 않을까? (성능)
2️⃣ 정적 메서드로 사용하면 혹시 세션 정보 탈취 가능성이 있을까? (보안)

1번 고민을 했던 이유는 다음과 같다.
지정한 URI를 제외한 모든 경로에 접속하면 로그인 필터가 실행되고, 실행될 때마다 세션 정보 출력 메서드가 호출된다.
즉, 접근 및 실행 횟수가 많을 가능성이 크다.

만약 new 키워드를 사용해 매번 SessionLogger 객체를 생성하고 logSessionInfo() 메서드를 사용한다면, GC 모델에 따라 메모리 해제 시간이 달라지겠지만 메모리 낭비가 발생할 수 있다고 생각했다.
그래서 static으로 구현했는데, 이 부분이 2번 고민과 연결된다.

2번 고민의 결론부터 말하자면 jar로 배포된 파일 내부까지 침투해 세션 정보를 탈취하는 건 거의 불가능이라고 한다. 대부분의 보안 관련 이슈들은 단순한 구조에서 발생한다고 한다.
보안쪽은 잘 몰라서 했던 고민이였는데, 새로운 사실들을 알게 됐으니 의미 없는 고민은 아니었던 거 같다.


3. 높은 수준이 뭐고 낮은 수준이 무엇인가?

기존 코드

public void updateUser(UserUpdateRequestDto dto) {
	this.username = dto.getUsername();
}
    
@Transactional
@Override
public UserResponseDto updateUser(Long id, UserUpdateRequestDto dto) {
	User user = userRepository.findUserByIdOrElseThrow(id);

	if (passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
		throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다.");
	}

	user.updateUser(dto);
	return new UserResponseDto(user);
    }

기존 코드의 문제점은 코드 가독성을 중요하게 생각하고 작성한 탓에 UserUpdateRequestDto에서 값을 개별적으로 꺼내지 않고 dto 객체 자체를 통째로 넘긴다는 것이다. 이게 왜 문제가 되냐면, 낮은 수준의 객체를 높은 수준의 객체 내부에서 직접 참조하기 때문이다.

그렇다면 높은 수준과 낮은 수준이 무엇인지 알아야 한다.
"클린 아키텍처 의존성 규칙"이라는 키워드로 공부하면 도움이 된다.
클린 아키텍처에선 객체를 4계층으로 분류하지만, 이 코드에선 두 가지 객체만 비교하는 것이기 때문에 높고 낮음이라는 표현을 쓰는 것이다.

  • 높은 수준 : 도메인의 핵심 비즈니스 로직을 포함하는 객체
  • 낮은 수준 : 단순히 데이터를 전달하는 역할을 하는 객체

이 코드에서 높은 수준의 객체는 User 낮은 수준의 객체는 UserUpdateRequestDto라고 보면 된다. 만약 UserUpdateRequestDto의 구조가 변경되면, 이 변경이 User 객체까지 전파될 가능성이 있다.

수정 코드

public void updateUser(String username) {
	this.username = username;
}
    
@Transactional
@Override
public UserResponseDto updateUser(Long id, UserUpdateRequestDto dto) {
	User user = userRepository.findUserByIdOrElseThrow(id);

	if (passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
		throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다.");
	}

	user.updateUser(dto.getUsername());
	return new UserResponseDto(user);
    }

그럼 String을 사용하는 것은 괜찮나? 라는 의문이 들 수도 있다.
String, Long과 같은 기본적인 값 객체들은 사실상 변경 가능성이 없고, 모든 도메인에서 공통적으로 사용하는 가장 기본적인 타입이기 때문에 개발자가 직접 작성하는 엔터티보다 훨씬 안정적인 타입이다.
엔터티가 DTO 같은 특정한 요청 객체를 직접 참조하는 것보다, 기본 값 타입을 사용하는 것이 더 유연하고 안정적인 설계다.
이렇게 작은 수정만으로도 의존성 방향을 올바르게 유지하면서 더 객체지향적인 코드로 개선할 수 있다.


느낀점

객체지향적으로 모든 부분을 완벽하게 설계하고 구현하면 좋겠지만, 코드의 가독성을 중요하게 생각하고 구현하다 보니 객체지향적인 부분을 놓치게 된다.
적당한 중간 지점을 찾는 게 쉽지 않다.
그래도 확실히 이전보다는 객체지향적 사고가 아주 조금 트인 것 같다.

이번 과제를 하면서 블로그에 정리할 몇 가지 키워드들도 얻었다.
영속성 컨텍스트, 클린 아키텍처, JPQLnativeQuery의 장단점 등.
이것들도 하나하나 부지런히 정리하면서 정진해 나가야겠다.

profile
개발 취준생

0개의 댓글