Back to til
Aug 11, 2025
4 min read

[TIL] 2025-08-11 | 일정 관리 앱 개발

1. 오늘 학습한 내용

1.1. 일정 관리 앱 프로젝트

1.1.1. 여러 곳에서 의존하는 findByIdOrElseThrow() 의 위치

여러 도메인의 Service에서 사용되는 특정 도메인의 기본 조회 및 Null에 대한 exception을 합친 메서드인 findByIdOrElseThrow함수의 위치에 관한 문제에 대해 여전히 답을 내리지 못했습니다.

같은 클래스내에서 반복 사용이 일어난다면 Service에서 private 메소드로 작성하면 되겠지만, 여러 도메인에서 필요한 상황에서는 적용할 수 없습니다. 주말 내내 고민했지만, 어떤 방식이 확실히 맞는 방향인지 답을 내리지 못하고 일단 default메서드를 적용하기로 했습니다.

  1. 해당 Servicepublic으로 정의하고 다른 Service에서 메소드를 참조

    순환 참조가 나타날 수 있습니다.

  2. repository를 의존하여 findById()를 가져온 뒤, OrElse를 통한 throw하는 부분을 반복 사용

    필요한 곳마다 반복해서 findById()부터 orElse를 통한 throw까지 반복하여 작성하는 방법입니다. 제일 편리하지만 중복된 코드가 여러 곳에 나타날 수 있으며, Exceptionthrow하는 부분의 변화가 일어난다면 유지보수가 번거로울 수 있습니다.

  3. Respository Interface에 default 메서드로 작성

    원래 default메서드의 도입 취지를 고려하면, 옳은 방법은 아니라고 생각합니다. default메서드는 하위 호환을 위해 제공된 기능이며, 비즈니스 로직을 구현체가 아닌 인터페이스에 담기 위한 목적으로 생긴 것이 아니기 때문입니다.

    그리고 비즈니스 로직에 의해 커스텀으로 정의한 ExceptionRepository에서 throw하는 게 맞는지도 애매합니다. 그러나, 별도의 패키지나 클래스 생성 없이 다른 Service들에서 쉽게 의존할 수 있기 때문에 구현상으로는 제일 간편합니다.

  4. 별도의 조회 전용 Finder 클래스를 만들어 작성

    서비스를 분리하여 Query 역할을 하는 클래스와 Command 역할을 하는 클래스로 나누는 방법입니다. 역할이 명확해지며, @Transactional 도 Class 단위로 도입가능하다. 다만, 현재는 프로젝트의 규모가 작은 만큼 하나의 메소드를 위해 클래스를 만드는 경우도 생길 수 있습니다.

    이런 역할을 하는 Class들을 묶으면 Facade 패턴과 유사해지는 느낌도 있습니다. 물론 도입 목적은 다릅니다.

public interface UserRepository extends JpaRepository<User, Long> {

  default User findByIdOrThrow(Long userId) {
      return findById(userId)
              .orElseThrow(() -> new CustomBusinessException(ErrorCode.USER_NOT_FOUND));
  }
  
  ...

일단 3번을 적용했고, 프로젝트 마지막에 4번으로 바꿀 가능성이 있습니다.

1.1.2. @valid 적용

@Valid를 이용하여 Validation을 도입했습니다. 이전 프로젝트에서는 일부러, Validator를 객체지향을 이용해서 구현하는 연습을 해보았었는데, 이번에 사용한 어노테이션이 사용하는 측면에선 편하긴 합니다.

// GlobalExceptionHandler.java

 @ExceptionHandler(MethodArgumentNotValidException.class)
  public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException e) {
      return ApiResponse.error(
              ErrorCode.METHOD_ARGUMENT_NOT_VALID.getHttpStatus(),
              ErrorCode.METHOD_ARGUMENT_NOT_VALID.getCode(),
              ErrorCode.METHOD_ARGUMENT_NOT_VALID.getMessage(
                      Objects.requireNonNull(e.getBindingResult().getFieldError()).getDefaultMessage())
      );
  }

함께 정의한 message를 현재 비즈니스에서 정의한 exception을 처리하고 있는 GlobalExceptionHandler에서 처리하도록 개발했습니다.

throw하는 MethodArgumentNotValidException 에 대한 핸들러를 추가하여 정의한 default message를 받아와 사용자에게 함께 안내하게 됩니다.

1.1.3. 비밀번호 해시 적용

비밀번호를 해시를 적용하여 저장하도록 passwordEncoder 를 도입했습니다. class로 작성하여 @Component로 등록 후, 필요한 곳에서 Bean을 주입받아 사용합니다.

// PasswordEncoderConfig.java

@Component
public class PasswordEncoderConfig {

    public String encode(String rawPassword) {
        return BCrypt.withDefaults().hashToString(BCrypt.MIN_COST, rawPassword.toCharArray());
    }

    public boolean matches(String rawPassword, String encodedPassword) {
        return BCrypt.verifyer().verify(rawPassword.toCharArray(), encodedPassword).verified;
    }
}

1.1.4. Session을 이용한 유저 정보 확인

서비스의 로직 중에서 유저 정보의 확인이 필요한 곳들은, 로그인을 통해 생성된 Session을 통해 유저 정보를 확인하도록 로직을 추가했습니다. 저는 session이 최소한의 유저 정보를 가지는 것이 맞다고 생각하여, 고유식별자인 userId만 저장하도록 sessionAttirbute를 구성했습니다.

가장 간단한 방법은 유저 정보 확인이 필요한 곳에서 반복해서 해당 로직을 구현하고 있는 메소드를 반복해서 호출하는 것입니다. 다만 유지보수하기에 어려움이 당연히 예상되기 때문에, JavaSpring에서 제공하는 다른 방법을 적용해보았습니다.

// SessionUserId.java

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface SessionUserId {
}
// SessionUserIdArgumentResolver.java

@Component
@RequiredArgsConstructor
public class SessionUserIdArgumentResolver implements HandlerMethodArgumentResolver {

    private final HttpSession httpSession;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {

        boolean hasLoginUserAnnotation = parameter.hasParameterAnnotation(SessionUserId.class);
        boolean hasLongType = Long.class.isAssignableFrom(parameter.getParameterType());
        return hasLoginUserAnnotation && hasLongType;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("userId");
    }
}

@interface를 통해 custom Annotation 을 생성하고, HandlerMethodArgumentResolver 인터페이스를 구현하는 Resolver를 작성합니다. 여기에 정의하려는 Annotation과 타입 그리고 Annotation 통해 가져올 정보를 session에서 받아오는 로직을 작성합니다.

// WebConfig.java

		... 
		
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("userId");
    }
    
    ...

마지막으로, WebMvcConfigurer 를 구현하는 Config 클래스에서 해당 ResolverHandlerMethodArgumentResolver에 추가하는 메소드를 작성하면 됩니다.

내일은 7단계와 8단계까지 모두 개발하여 일차적인 개발을 마무리하는 것이 목표입니다.

2. 더 알아볼 내용 / 다음에 할 내용

  • 프로젝트 요구사항 7단계와 8단계 개발
  • 비밀번호 암호화에 사용되는 여러 알고리즘과 서버에서의 적용 예시
  • 도메인 패키지 구조의 장단점