1. 배경

상품의 자식 엔티티로 연관관계가 맺어져 있는 이미지가 부모 엔티티와의 연결이 끊겼을때 s3에 저장을 하면서 비용이 계속 발생하게 되는

고아객체 현상을 어떻게 관리하면 적절할지를 고민해본 과정을 정리하는 목적으로 작성되었습니다. 

2. 발생 원인

이미지 등록을 데이터베이스에 등록하게 되면 저장소가 매우 무거워질 수 있습니다. 
이를 방지하기 위해 클라우드 저장소(s3)에 데이터를 저장합니다. 
데이터베이스에 이미지 정보를 저장, 클라우드에 각각 저장하는 것이므로 시스템에서 동일하게 유지되지 않는 상황이 발생할 수 있습니다. 
 
  • 상황 A (업로드 후 중단): 사용자가 이미지를 업로드했으나, 최종 '저장' 버튼을 누르지 않고 브라우저를 닫음.
    (파일은 남고, DB 기록은 없음 → 고아 객체 발생)
  • 상황 B (서버 오류): 이미지는 스토리지에 올라갔는데, DB에 저장하는 순간 에러가 발생하여 트랜잭션이 롤백되며 파일만 남음.
  • 상황 C (삭제 실패): 게시글을 지워서 DB 데이터는 삭제되었는데, 스토리지의 파일을 지우는 로직이 실패함.

이미지 파일이 있다는 사실이 두 시스템에서 동일하게 유지되지 않는 정합성 불일치 문제가 발생합니다. 

 

3. 문제점 

당장 서비스 기능에는 문제가 없어 보일 수 있지만, 방치하면 다음과 같은 리스크가 쌓입니다.

  • 비용 증가: 사용하지 않는 '쓰레기 데이터'가 누적되어 스토리지 유지 비용이 불필요하게 상승합니다.
  • 운영 효율 저하: 데이터 백업, 마이그레이션, 인덱싱 시 불필요한 리소스를 소모하게 됩니다.
  • 데이터 오염: 실제 존재하지 않는 리소스를 참조하려는 시도가 발생할 수 있어 디버깅이 어려워집니다.

특히 s3를 이용한 이미지 등록 방식을 사용하면서 서비스되지 않는 이미지로 원인이 파악되지 않은채 계속해서 비용이 발생한다는 점은 큰 문제라 인식되어 이를 해결해보고자 합니다. 

4. 해결 방법 탐색 

이 정합성을 다시 맞추기 위해 보통 다음과 같은 방법을 사용합니다.

 

1) Batch 처리 

주기적으로 DB의 파일 리스트와 S3의 파일 리스트를 비교합니다.

  • 방법: DB에 존재하지 않는 S3 파일들을 식별하여 일괄 삭제합니다.
  • 장점: 로직이 단순하며 시스템 복잡도를 크게 높이지 않습니다.

2) 임시 경로 활용

파일의 상태를 임시/확정으로 분리하여 관리합니다.

  • 방법: 최초 업로드 시 temp/ 경로에 저장하고, DB 저장이 완료되는 시점에 images/ 정식 경로로 이동(Move)시킵니다.
  • 장점: temp/ 폴더만 주기적으로 비우면 되므로 관리가 매우 직관적입니다.

3) 트랜잭션 보장 

이벤트 기반 아키텍처에서 주로 사용하며, 작업 실패 시 후속 조치를 보장합니다.

  • 방법: DB 저장 실패 시 '파일 삭제 이벤트'를 발행하거나, 로컬 트랜잭션 종료 후 스토리지 작업을 수행하는 보상 트랜잭션을 설계합니다.
  • 장점: 실시간에 가까운 정합성을 유지할 수 있습니다.

 

5. 결과 

임시 경로를 활용하는 경우 DB에 등록된 파일만 존재하게 되므로 검증과 관리가 용이하지만,

파일을 이동시키는 과정에서 S3 API 호출 비용이 발생한다는 점에서 비용을 줄이기 위한 문제해결이 목적이므로 제외하였습니다. 

트랜잭션 보장 방식의 경우 대용량 서비스에 적합하며 실시간 정합성을 유지한다는 장점이 강력하지만, 이벤트 발행 설계가 필요하다는 점에서 오버엔지니어링이라는 판단으로 제외하였습니다 

 

@Scheduled(cron = "0 0 0 * * *") // 매일 새벽 12시 실행
	public void cleanupOrphanedImages() {

		log.error("파일 삭제 시작: ");
		LocalDateTime deleteTime = LocalDateTime.now().minusDays(1); // 데이터베이스에는 삭제 요청이 되었으나 처리되지 않는 이미지
		List<ProductImage> deletedImages = productImageRepository.findByDeletedAtBefore(deleteTime);

		for (ProductImage img : deletedImages) {
			try {
				s3deleteFile(img.getUrl());
				productImageRepository.delete(img);
			} catch (Exception e) {
				// S3 삭제 실패 시 로그를 남기고, 다음 배치 때 다시 시도
				log.error("파일 삭제 실패: {}",img.getId());
			}
		}
	}

 

 

1. 배경

중고판매 웹 사이트를 레퍼런스로 한 프로젝트의 상품 수정 api를 만들던 도중 생성 api의 dto의 유효성 검증 규칙이 많이 겹치는 부분에서 custom Valid를 적용할지에 대한 정보를 찾으며 경험한 과정을 작성합니다. 

 

2. Java Bean Validation 정의 

custom valid 적용 의사결정에 앞서 Java Bean Validation이 무엇인지 먼저 알아봅니다. 

출처: https://docs.spring.io/spring-framework/reference/core/validation/beanvalidation.html

 

Java Bean Validation :: Spring Framework

Bean Validation provides a common way of validation through constraint declaration and metadata for Java applications. To use it, you annotate domain model properties with declarative validation constraints which are then enforced by the runtime. There are

docs.spring.io

 

Bean Validation은 객체의 속성이나 메서드 매개변수에 제약 조건을 선언하고 런타임에서 자동으로 검증해 주는 기능입니다. 

valid 동작 흐름은 다음과 같습니다. 

1. Spring이 @Valid 발견
2. Bean Validation 실행
3. 하나라도 실패 → MethodArgumentNotValidException 발생
4. 컨트롤러 메서드는 실행 ❌

 

이러한 구조 덕분에 컨트롤러 실행 이전에 에러를 감지할 수 있게 됩니다. 

따라서 요청이 잘못된 것은 컨트롤러 도착 이전에 처리,

비즈니스 실패는 서비스에서 담당하도록 분리된 구조를 만들 수 있습니다. 

또한 컨트롤러는 “이미 정상적인 입력만 온다” 라는 전제를 가지므로 잘못된 요청은 아예 내부 로직에 접근할 수 없어 아키텍처 안정성을 보장할 수 있게 됩니다. 

 

Bean Validation의 사용 목적 

Bean Validation는 null 체크, 범위 등과 같은 요청 값 자체가 잘못된 경우에 사용합니다. 

@Min(1000)와 같은 상품 가격 정책을 제한하는 행위 즉 도메인 규칙을 Validation으로 사용하는 것은 좋지 않은데 

Bean Validation은 Spring이나 JPA가 실행하는 시점에만 적용되기 때문입니다. 

 

3. Custom Validation 정의

@Min, @NotNull 과 같이 이미 정의되어있는 것 이외에 직접 규칙을 정의하여 validation을 만들 수 있는데 이것을 Custom Validation이라고 합니다.  사용 방법을 간단히 요약하면 다음과 같습니다. 

 

1. 우선 검증에 필요한 설정들을 선언합니다. 

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UsernameValidator.class)
public @interface Username {
    String message() default "유효하지 않은 이름";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

 

2. 검증 로직이 작성된 Validator 클래스를 생성합니다. 

public class UsernameValidator
        implements ConstraintValidator<Username, String> { 
        // 어떤 어노테이션을 처리하는지, 어떤 타입의 값을 검증하는지 설정 
	
    @Override
    // 실제 검증이 실행되는 메서드 
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) return true;
        return value.length() >= 3;
    }
}

 

3. DTO 적용 예시입니다. 

public class SignupRequest {
    @Username // 직접 정의한 custom validation을 사용합니다
    private String username;
}

 

위의 코드에서도 알 수 있듯 긴 검증 로직을 @Username이라는 어노테이션 하나만으로 해석할 수 있게 됩니다.

또한 동일한 규칙이 적용되는 필드에 재사용할 수 있습니다. 

 

Custom Valid 사용 주의점 

Bean Validation의 본래 목적이 흐려질 수 있습니다.

값이 유효한지를 검증하는 것이나, 위의 형태로는 도메인의 정책을 검증하게 됩니다. 

뿐만아니라 실패추적이 어려워질 수 있습니다. 

@NotNull 검증이 실패했을 경우 에러메세지와 함께 전달되므로 그 즉시 무엇이 잘못되었는지 파악할 수 있으나 

@Username의 로직에 길이 제한 뿐아니라 문자열 제한 등 여러 조건을 섞어두었다면 어느 부분 때문에 에러를 발생했는지 추적하기 어렵습니다. 

 

4. valid vs Custom Valid 

custom valid 도입을 고려하게된 코드를 가져왔습니다. 

@NotBlank(message = "상품명은 필수입니다.")
@Size(max = 100, message = "상품명은 100자를 초과할 수 없습니다.")
@Pattern(
    regexp = "^[a-zA-Z0-9가-힣\\s()\\-]+$",
    message = "상품명에 허용되지 않은 문자가 포함되어 있습니다."
)
private String name;

총 3개의 조건이 설정되어 있고 상품 등록과 수정 모두 같은 규칙이 적용되어야 했습니다.

 

  • @NotBlank, @Size, @Pattern를 하나의 어노테이션으로 만들면 어떨까? 

도입 전 각각의 장단점을 생각했습니다. 

한줄씩 해석하지 않아도 되는 가독성이 좋아진다는 점, 같은 규칙이 사용되는 곳에 쉽게 재사용할 수 있다는 장점이 있습니다. 

그러나 위에서 정리된 valid의 사용 목적에서는 조금 어긋난다는 생각이 들었습니다. 

Validator 열어서 코드를 따라가야 실제 조건을 알 수 있게되고 어느 조건에서 실패했는지 파악하기가 어려워집니다. 

  • 내가 생각한 가독성이 보기 좋은 것을 기준으로 생각한 것은 아닐까? 

@NotBlank,@Size는 이미 정의된 어노테이션으로 다른 개발자들이 추가 학습 없이도 바로 유추가 가능한 반면, 

직접 어노테이션을 만들경우 의도가 바로 보이지 않고 설명이 추가되어야 합니다. 

즉 가독성이란 보기 편한것이라는 것 이외에 의미를 바로 해석할 수 있는 코드를 의미한다는 것을 놓치고 있었습니다. 

  • API가 늘어나도 동일 규칙이 유지될까?

지금은 허용되지 않은 문자열 확인 규칙만 있지만, 금칙어 목록을 관리한다면 반드시 custom valid가 필요하고, 한번 만들어 두면 쉽게 수정되지 않으며 에러메세지 또한 '금칙어 위반'처럼 하나로 표현하기에 적합합니다. 

 

5. 결론

단순히 코드가 길어진다고해서 가독성을 해치는 것은 아니라는 것을 알게되었습니다.

재사용성을 고려할때도 얼마나 자주 사용되는가 뿐만 아니라 얼마나 자주 수정되는가를 함께 고려해야한다는 것 또한 알게되었습니다. 

따라서 입력값 검증을 위한 어노테이션은 유지, 여러 조건이 하나의 규칙으로 묶을 수 있는 금칙어 또는 이름 규칙을 검증한다면 custom valid를 사용합니다. 

들어가며

프로젝트에서 사용자 인증 시스템을 구현하면서 여러 인증 방식을 비교하고, JWT 기반 인증을 선택한 경험을 공유합니다. 특히 초기 구현에서 JWT의 본질을 제대로 이해하지 못해 겪었던 시행착오와, 이를 개선해나간 과정을 다룹니다.


1. 인증 방식 비교 및 기술 선택

여러가지 인증 방법의 장단점을 비교합니다. 


1.1 쿠키 기반 인증

쿠키란 서버가 사용자의 웹 브라우저에 전송하는 작은 데이터 조각이다. 브라우저는 그 데이터 조각들을 저장해 놓았다가, 동일한 서버에 재 요청 시 저장된 데이터를 함께 전송한다. 이를 이용하면 사용자의 로그인 상태를 유지할 수 있다.

상태가 없는 HTTP 프로토콜에서 상태 정보를 기억시켜주기 때문에 과거엔 클라이언트 측에 정보를 저장할 때 쿠키를 주로 사용하곤 했다. 

 

요청 예시 

GET /api/data HTTP/1.1
Host: api.example.com
Cookie: sessionId=abc123; userId=456; token=xyz789
Accept: application/json

 

쿠키를 사용하는 게 데이터를 클라이언트 측에 저장할 수 있는 유일한 방법이었을 때는 이 방법이 타당했으나 위의 예시를 보면 알 수 있듯 모든 요청마다 쿠키가 함께 전송되기 때문에,  성능이 떨어지는 원인이 될 수 있다. 

뿐만아니라 쿠키가 클라이언트에 저장되고 자동으로 전송되는 구조 자체를 바꾸지는 못하므로 탈취가 쉬워 보안 측면에서 한계를 가진다. 이러한 이유로 본 프로젝트에서는 쿠키 기반 인증을 주요 인증 방식으로 사용하기에는 한계가 있다고 판단했다.

 

출처: https://developer.mozilla.org/ko/docs/Web/HTTP/Guides/Cookies


1.2 세션 기반 인증

세션은 하나의 고정된 정의가 있다 라고 하기보다는 “클라이언트와 서버 사이에서 유지되는 상태를 설명하는 개념” 에 가깝다. 

각 사용자마다 서버에 생성되는 상태 저장 공간을 세션이라고 한다. 그리고 수만 개의 세션 중 어느 것이 요청을 보낸 사용자의 세션인지를 찾기 위해 쿠키를 사용한다.

그런데 실제 서비스에서는 트래픽을 감당하기 위해 서버를 여러 대로 늘리는 스케일 아웃을 많이 사용하는데 

이때 세션 기반 인증 시스템은 치명적인 구조적 한계에 부딪힌다. 세션이 특정 서버의 메모리에 갇혀있기 때문에 발생 만약서버 A 가 고장 나서 죽으면 그 안에 있던 모든 세션 정보도 함께 증발하고 해당 사용자들은 강제로 로그아웃 된다.

그래서 이를 해결하기 위해 서버 메모리를 쓰지 않고 빠르고 공용화된 외부 저장소를 하나 둔다. Redis같은 초고속 인메모리 DB 를 별도로 설치하여 여기에 세션을 저장한다. 웹 서버를 100대로 늘려도 세션 공유 설정이 필요 없고 Redis 주소만 알려주면 된다. 

서버가 상태를 가지게 된다는 단점과 소규모 프로젝트에서는 구조적인 복잡성이 증가한다는 점을 고려해야 했다. 

 

출처: https://medium.com/@heizence6626/%EC%9D%B8%EC%A6%9D-%EC%9D%B8%EA%B0%80-%EA%B8%B0%EB%B3%B8-%EA%B0%9C%EB%85%90-%EC%84%B8%EC%85%98%EA%B3%BC-%EC%BF%A0%ED%82%A4-b0cec237618f


1.3 JWT(Json Web Token)

jwt는 온라인 네트워크에서 정보를 안전하게 통신할 때 사용하는 인터넷 표준 토큰이다. 필요한 정보를 모두 자체적으로 들고 있어서 서버 측에서 세션을 저장할 필요가 없다. 이런 특징 덕분에 JWT를 이용하면 서버 부하를 줄일 수 있고 확장성을 확보할 수 있다. 

그러나 신뢰할 수 있는 서비스를 사용하지 않으면 잘못된 구현이 취약점을 초래할 수 있습다. 다시말해 올바르게 구현된 경우 JWT는 강력한 보안 기능을 제공하지만 잘못된 설계는 오히려 세션 방식과 다를 바 없는 구조가 되거나 잘못된 키 관리, 또는 토큰 검증 오류등의 이유로 심각한 취약점을 유발할 수 있다. 

 

출처:

https://docs.tosspayments.com/resources/glossary/jwt

https://blog.logto.io/ko/jwtleul-eonje-sayonghaeya-hanayo


2. JWT를 선택한 이유

팀 프로젝트에서 Spring Security 기반의 REST API 서버를 만들어야 했고, 인증 구조를 직접 이해하고 설계해 보는 것을 학습 목표로 두고 있었다.

JWT를 사용하지 않고 Spring Security로 로그인을 구현했을 때, 로그인 이후 API 요청마다 필터 체인을 통과해야하고 UserDetailsService를 통해 SecurityContext에서 사용자 정보를 다시 조회하는 구조를 경험했다. 이 과정에서 자연스럽게 다음과 같은 의문이 들었다. "이미 로그인 시 인증이 완료되었는데, 왜 매번 요청마다 DB를 다시 조회해야할까?" 이 질문을 계기로 JWT의 핵심 가치가 단순한 토큰 발급이 아니라, 토큰 자체를 신뢰하는 구조라는 점을 이해하게 되었고, JWT를 도입하기로 결정했다.


3. 트러블 슈팅 - jwt의 본질을 이해하지 못한 초기 구현 

3.1 문제 상황

JWT를 처음 도입했을 당시에는 JWT를 ‘인증된 결과물’로 완전히 이해하지 못한 상태였다. 로그인 시 토큰을 발급했지만, API 요청이 들어올 때마다 토큰에서 userId를 추출한 뒤 다시 DB에서 사용자 엔티티를 조회하고 있었다.

결과적으로 JWT를 사용하면서도 API 요청마다 DB 조회가 발생했고, 구조적으로는 세션 방식과 큰 차이가 없는 상태가 되었다.

 

그 당시의 잘못된 구현 코드 

public Authentication getAuthentication(String token) {
       Claims claims = Jwts.parserBuilder()
          .setSigningKey(key)
          .build()
          .parseClaimsJws(token)
          .getBody();

       Collection<? extends GrantedAuthority> authorities =
          Arrays.stream(claims.get("auth").toString().split(","))
             .map(SimpleGrantedAuthority::new)
             .collect(Collectors.toList());

       // jwt로 부터 사용자 정보를 받아온 후 다시 DB를 조회하는 코드를 작성했다. 
       // UsernamePasswordAuthenticationToken의 인증 객체로 전달하기 위해 User를 필요로 했기 때문이다.
      
       User user = userRepository.findByEmail(claims.getSubject())
          .orElseThrow(() -> new JwtException("존재하지 않는 사용자"));

       CustomUserDetails principal = new CustomUserDetails(user);

       return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

 


3.2 원인 분석

문제의 원인은 JWT를 단순한 식별자처럼 사용하고 있었다는 점이었다. JWT의 핵심은 서버가 서명한 토큰은 위변조 여부만 검증되면 신뢰할 수 있다는 점인데, 이것을 간과하고 불필요하게 DB에서 다시 사용자 정보를 조회했다. 결과적으로 jwt의 stateless 장점을 전혀 살리지 못했다. 인증 단계와 인가 그리고 비즈니스 로직을 명확히 구분하지 못한 상태였다.


3.3 해결 방법

구조를 다음과 같이 수정했다.

  • 로그인 시에만 DB를 통해 사용자 인증 수행
  • 인증 성공 시 필요한 최소 정보(userId, role 등)를 포함한 JWT 발급
  • API 요청 시에는 토큰의 유효성만 검증
  • 토큰 정보로 Authentication 객체를 생성하여 SecurityContext에 저장

신뢰할 수 있는 토큰임을 증명하기 위해 유효성을 세밀하게 검증한다 

public boolean validateToken(String token) {
		try {
			Jwts.parserBuilder()
				.setSigningKey(key)
				.build()
				.parseClaimsJws(token);
			return true;
		} catch (SignatureException e) {
			log.error("JWT 서명이 유효하지 않습니다.");
			throw new CustomJwtException(ErrorCode.TOKEN_INVALID);

		} catch (MalformedJwtException e) {
			log.error("잘못된 형식의 토큰입니다.");
			throw new CustomJwtException(ErrorCode.TOKEN_INVALID);

		} catch (ExpiredJwtException e) {
			log.error("JWT 토큰이 만료되었습니다.");
			throw new CustomJwtException(ErrorCode.TOKEN_EXPIRED);

		} catch (UnsupportedJwtException e) {
			log.error("지원되지 않는 JWT 유형입니다.");
			throw new CustomJwtException(ErrorCode.TOKEN_INVALID);

		} catch (IllegalArgumentException e) {
			log.error("토큰이 비어있습니다.");
			throw new CustomJwtException(ErrorCode.TOKEN_EMPTY);

		} catch (Exception e) {
			log.error("유효하지 않은 토큰입니다");
			throw new CustomJwtException(ErrorCode.TOKEN_INVALID);
		}

	}

 

그리고 이후 API 요청 과정에서는 인증을 위해 DB를 조회하지 않도록 개선했다.

public Authentication getAuthentication(String token) {
		Claims claims = Jwts.parserBuilder()
			.setSigningKey(key)
			.build()
			.parseClaimsJws(token)
			.getBody();

		Collection<? extends GrantedAuthority> authorities =
			Arrays.stream(claims.get("auth").toString().split(","))
				.map(SimpleGrantedAuthority::new)
				.collect(Collectors.toList());

		Long userId = Long.parseLong(claims.get("userId").toString());
		UserRole userRole = UserRole.valueOf(claims.get("auth").toString().replace("ROLE_", ""));
		CustomUserDetails userDetails = new CustomUserDetails(userId, userRole);

		return new UsernamePasswordAuthenticationToken(userDetails, token, authorities);
	}

 

위의 인증 형태를 구성하기 위해 JWT 발급할때 최소 정보(userId, role 등)만을 포함한다. 

public String createToken(CustomUserDetails user) {
    String authority = user.getRole().getAuthority();

    long now = (new Date()).getTime();
    Date validity = new Date(now + this.tokenValidityInMilliseconds);

    return Jwts.builder()
       .setSubject(user.getUsername())
       .claim("auth", authority)
       .claim("userId", user.getUserId())
       .signWith(key, SignatureAlgorithm.HS512)
       .setExpiration(validity)
       .compact();
}

4. 배운점과 회고 - 개념 이해의 중요성

JWT를 단순히 사용하는 것과, 제대로 이해하고 사용하는 것은 전혀 다르다는 점을 체감했다. 어떤 문제를 해결하기 위해 존재하는지를 이해하는 것이 중요하다는 것을 배웠다. JWT를 도입했지만 그 본질을 이해하지 못해 DB 조회를 계속하는 실수를 했다. "이미 인증된 토큰"이라는 JWT의 핵심 개념을 제대로 이해한 후에야, Stateless 인증의 진가를 발휘할 수 있었다. 인증과 인가의 역할을 구분하고, 각 단계에서 무엇을 신뢰해야 하는지 고민한 경험은 이후 다른 기술을 학습하는 데에도 중요한 기준점이 될 것이라 생각한다.


5. TODO

현재 프로젝트에서는 인증 구조가 비교적 단순하여 Facade 패턴을 적용하지 않았다. 추후 OAuth 연동이나 인증 방식이 확장된다면, 인증 관련 로직의 재사용성과 구조 개선을 위해 Facade 패턴 도입을 검토할 계획이다.

+ Recent posts