Total 10건 1 페이지

RedisSession 실제 구현

1. 서론: 왜 Filter에서 세션을 직접 다뤄야 할까요?

웹 서비스를 운영하다 보면 트래픽이 늘어나면서 자연스럽게 서버(Tomcat 등)를 여러 대로 증설하게 됩니다.
이때 가장 먼저 마주하는 난관이 바로 '세션 불일치' 문제입니다.
 

예를 들어, 사용자가 1번 서버에서 로그인을 성공했는데 로드밸런서가 다음 요청을 2번 서버로 보내버리면 어떻게 될까요?
2번 서버에는 해당 사용자의 세션 정보가 없기 때문에 순식간에 로그아웃 처리가 되어버립니다.
실제로 쇼핑몰 결제 직전에 서버가 바뀌면서 장바구니가 모두 날아가는 등 치명적인 사용자 경험 저하로 이어질 수 있죠.
 

이러한 문제를 해결하기 위해 **Redis 세션 클러스터링(Session Clustering)**을 도입하게 됩니다.
모든 서버가 하나의 Redis 저장소를 바라보게 하여 세션을 공유하는 방식입니다.

최근에는 Spring Session 같은 훌륭한 라이브러리가 이 과정을 대신해주지만, 레거시 환경이나 아키텍처의 핵심 원리를 제대로 이해하기 위해서는
서블릿 필터(Filter) 단에서 세션 객체를 직접 제어해보는 경험이 무척 중요합니다.
 

이 글에서는 요청을 가로채어 우리가 만든 커스텀 세션(RedisSession)을 적용하는 전체 과정을 4개의 핵심 클래스 구현과 함께
차근차근 논리적으로 살펴보겠습니다.


2. 본론: RedisSession 핵심 클래스 4가지 구현하기

1) RedisSession: 세션 데이터의 실체

가장 먼저 만들 클래스는 실제 세션 데이터를 담아둘 RedisSession입니다.
이 객체는 기존 톰캣이 제공하는 세션과 완벽하게 동일한 역할을 수행해야 하므로,
반드시 HttpSession 인터페이스를 implements 하여 모든 메서드를 구현해야 합니다.

여기서 가장 중요한 것은 세션의 상태를 나타내는 3가지 플래그(Flag) 값입니다.
이 원리를 이해하셔야 불필요한 네트워크 I/O를 줄일 수 있습니다.
 

  1. changed (또는 dirty): 세션 내의 데이터가 하나라도 변경되었는지를 나타냅니다.
    이 값이 true일 때만 Redis에 데이터를 덮어써서 불필요한 저장을 막습니다.

  2. valid: 세션이 현재 유효한지(로그아웃되거나 만료되지 않았는지)를 판단합니다.

  3. isNew: 이번 요청에서 새롭게 생성된 세션인지를 확인합니다.
     

package mvc.controller.redis;

import java.io.Serializable;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import com.fasterxml.jackson.annotation.JsonIgnore;

import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpSession;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class RedisSession implements HttpSession, Serializable {
	private static final long serialVersionUID = -7024556060254149497L;

	@JsonIgnore
	@Getter
	@Setter
	private volatile boolean changed = false;

	@JsonIgnore
	@Getter
	@Setter
	private volatile boolean valid = true;

	@JsonIgnore
	private volatile boolean newSession;

	// 직렬화 대상
	@Setter(AccessLevel.NONE)
	private String id;

	@Setter(AccessLevel.NONE)
	private long creationTime;
	
	@Setter
	private long lastAccessedTime;
	private int maxInactiveInterval = 1800;

	@Getter
	@Setter
	private volatile Map<String, Object> attributes = new ConcurrentHashMap<>();

	@JsonIgnore
	private transient ServletContext servletContext;

	public RedisSession(final String id) {
		if (id == null || id.isEmpty()) {
			throw new IllegalArgumentException("세션 ID가 비어있습니다.");
		}
		this.id = id;
		this.newSession = true;
		this.creationTime = System.currentTimeMillis();
		this.lastAccessedTime = this.creationTime;
		this.changed = true;
	}

	private void checkValid() {
		if (!this.valid) {
			throw new IllegalStateException("세션이 이미 무효화되었습니다.");
		}
	}

	/* ---------- HttpSession ---------- */

	@Override
	public String getId() {
		checkValid();
		return this.id;
	}

	@Override
	public long getCreationTime() {
		checkValid();
		return this.creationTime;
	}

	@Override
	public long getLastAccessedTime() {
		checkValid();
		return this.lastAccessedTime;
	}

	@Override
	public ServletContext getServletContext() {
		checkValid();
		return this.servletContext;
	}

	@Override
	public void setMaxInactiveInterval(final int interval) {
		checkValid();
		this.maxInactiveInterval = interval;
		this.changed = true;
	}

	@Override
	public int getMaxInactiveInterval() {
		checkValid();
		return this.maxInactiveInterval;
	}

	@Override
	public Object getAttribute(String name) {
		checkValid();
		if (name == null || name.isEmpty()) {
			throw new IllegalArgumentException("속성명이 비어있습니다.");
		}
		
		return this.attributes.get(name);
	}

	@Override
	public void setAttribute(String name, Object value) {
		checkValid();

		if (name == null || name.isEmpty()) {
			throw new IllegalArgumentException("속성명이 비어있습니다.");
		}

		if (value == null) {
			removeAttribute(name);
			return;
		}

		this.attributes.put(name, value);
		this.changed = true;
	}
	
	@JsonIgnore
	@Override
	public Enumeration<String> getAttributeNames() {
		checkValid();
		return Collections.enumeration(this.attributes.keySet());
	}

	@Override
	public void removeAttribute(final String name) {
		checkValid();
		if (name == null || name.isEmpty()) {
			throw new IllegalArgumentException("속성명이 비어있습니다.");
		}
		if (this.attributes.remove(name) != null) {
			this.changed = true;
		}
	}

	@Override
	public void invalidate() {
		checkValid();
		this.valid = false;
		this.attributes.clear();
		this.changed = true;
	}

	@Override
	public boolean isNew() {
		checkValid();
		return this.newSession;
	}

	public void refreshLastAccessedTime() {
		checkValid();
		this.lastAccessedTime = System.currentTimeMillis();
		this.changed = true;
	}

	public void setNew(final boolean newSession) {
		this.newSession = newSession;
	}

	public void setServletContext(final ServletContext servletContext) {
		this.servletContext = servletContext;
	}
}

2) RedisSessionManager: 세션의 생애주기 관리자

세션 객체를 만들었다면, 이 객체를 실제 Redis 서버에 넣고 빼는 역할을 할 '본체'가 필요합니다.
RedisSessionManager는 세션의 생성, 저장, 로드, 삭제를 전담합니다.

매번 객체를 생성하여 자원을 낭비하지 않도록 싱글톤(Singleton) 패턴으로 구현하는 것이 일반적입니다.

 
package mvc.controller.redis;

import common.util.xml.ConfigXml;
import common.util.xml.XmlTreeNode;
import lombok.extern.log4j.Log4j2;
import mvc.model.DataMapper;
import redis.clients.jedis.DefaultJedisClientConfig;
import redis.clients.jedis.RedisClient;

@Log4j2
public class RedisSessionManager {
	private volatile RedisClient client;
	
	private static final String KEY_PREFIX = "sess:";
	private static final int EXPIRE_SECONDS = 1800;

	private static volatile RedisSessionManager instance;

	private RedisSessionManager() {

		XmlTreeNode node = ConfigXml.getNode("redis");
		if(node==null || node.isEmpty()) {
			this.client = null;
			return;
		}
		
		String url = ConfigXml.get("redis.url");
		Integer port = ConfigXml.getInt("redis.port");

		String username = ConfigXml.get("redis.username");
		String password = ConfigXml.get("redis.password");

		try {

			DefaultJedisClientConfig config = DefaultJedisClientConfig.builder()
				    .connectionTimeoutMillis(1000)
				    .socketTimeoutMillis(2000)
				    .user(username)
				    .password(password)
				    .build();

			RedisClient client = RedisClient.builder().hostAndPort(url, port).clientConfig(config).build();
			client.ping();
			
			this.client = client;
			log.info("RedisSessionManager initialized successfully.");

		} catch (Exception e) {
			throw e;
		}

	}

	public static synchronized RedisSessionManager getInstance() throws Exception {
		if (instance == null) {
			try {
				instance = new RedisSessionManager();
			} catch (Exception e) {
				throw e;
			}
		}
		return instance;
	}

	public void saveSession(RedisSession session) {
		try {
			
			String key = KEY_PREFIX + session.getId();
			
			String jsonValue = DataMapper.MAPPER.writeValueAsString(session);
			client.setex(key, EXPIRE_SECONDS, jsonValue);
	        session.setChanged(false);
	        
		} catch (Exception e) {
			log.error("[RedisSession] Save saveSession Error: {}", e.getMessage(), e);
			throw e;
		}
	}

	public RedisSession loadSession(String sessionId) {
		try {
			
			String key = KEY_PREFIX + sessionId;

			RedisSession session = null;
			String json = client.get(key);
			if(json!=null) {
				session = DataMapper.MAPPER.readValue(json, RedisSession.class);
			}
			
			return session;
		} catch (Exception e) {
			log.error("[RedisSession] Save loadSession Error: {}", e.getMessage(), e);
			throw e;
		}
	}

	public void deleteSession(String sessionId) {
		String key = KEY_PREFIX + sessionId;
		client.del(key);
	}

	public void updateTTL(String sessionId) {
		String key = KEY_PREFIX + sessionId;
		client.expire(key, EXPIRE_SECONDS);
	}

	public void close() {
		if (client != null) {
			client.close();
		}
	}
}

3) RedisSessionRequest: 원본 Request를 감싸는 래퍼(Wrapper)

이제 기존 로직(컨트롤러나 JSP)에서 request.getSession()을 호출했을 때, 톰캣의 기본 세션이 아닌
우리가 만든 RedisSession을 반환하도록 속이는 작업이 필요합니다.

이를 위해 HttpServletRequestWrapper를 상속받아 getSession() 메서드를 재정의(Override)합니다.

 
package mvc.controller.redis;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpSession;
import lombok.Getter;

public class RedisSessionRequest extends HttpServletRequestWrapper {

	@Getter
	private final HttpSession session;
    private final boolean hasSessionIdCookie;

    public RedisSessionRequestWrapper(HttpServletRequest request, HttpSession session, final boolean hasSessionIdCookie) {
        super(request);
        this.session = session;
        this.hasSessionIdCookie = hasSessionIdCookie;
    }

    @Override
    public HttpSession getSession(boolean create) {
        if (!create && !this.hasSessionIdCookie) {
            return null;
        }
        return this.session;
    }
}

4) RedisSessionFilter: 모든 요청을 오케스트레이션하다

드디어 대망의 필터입니다. 이곳은 앞서 만든 3개의 클래스를 조립하여 클라이언트의 요청이 들어올 때 세션을 준비하고,
응답이 나갈 때 세션을 처리하는 이벤트 구조의 중심입니다.

 
package mvc.controller.redis;

import java.io.IOException;
import java.util.UUID;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class RedisSessionFilter implements Filter {
	private volatile RedisSessionManager sessionManager = null;

	private static final String COOKIE_NAME = "RSESSIONID";

	@Override
	public void init(FilterConfig filterConfig) {
		try {
			sessionManager = RedisSessionManager.getInstance();
			log.info("RedisSessionFilter initialized: REDIS_SESSION");
		} catch (Exception e) {
			sessionManager = null;
		}
	}

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
		if (this.sessionManager == null) {
			synchronized (this) {
				if (this.sessionManager == null) {
					chain.doFilter(request, response);
					return;
				}
			}
		}

		if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
			chain.doFilter(request, response);
			return;
		}

		final HttpServletRequest httpRequest = (HttpServletRequest) request;
		final HttpServletResponse httpResponse = (HttpServletResponse) response;

		String sessionId = null;
		final Cookie[] cookies = httpRequest.getCookies();
		if (cookies != null) {
			for (final Cookie c : cookies) {
				if (COOKIE_NAME.equals(c.getName())) {
					sessionId = c.getValue();
					break;
				}
			}
		}

		boolean issueCookie = false;
		String cookieHeader = null;

		if (sessionId == null || sessionId.isEmpty()) {
			sessionId = UUID.randomUUID().toString();
			issueCookie = true;
			
			boolean secure = httpRequest.isSecure();
			if (!secure) {
				final String xfProto = httpRequest.getHeader("X-Forwarded-Proto");
				if (xfProto != null && xfProto.equalsIgnoreCase("https")) {
					secure = true;
				}
			}

			final StringBuilder sb = new StringBuilder(160);
			sb.append(COOKIE_NAME).append("=").append(sessionId);
			sb.append("; Path=/");
			sb.append("; HttpOnly");
			if (secure) {
				sb.append("; Secure");
			}

			sb.append("; SameSite=Lax");

			cookieHeader = sb.toString();
			httpResponse.addHeader("Set-Cookie", cookieHeader);
		}

		RedisSession redisSession = null;
		
		final RedisSessionManager manager = sessionManager;

		try {
			redisSession = manager.loadSession(sessionId);
		} catch (Exception e) {
			this.sessionManager = null;
			chain.doFilter(request, response);
			return;
		}

		if (redisSession == null) {
			redisSession = new RedisSession(sessionId);
		} else {
			redisSession.setNew(false);
		}

		redisSession.setServletContext(request.getServletContext());

		boolean isRedis = false;

		HttpServletRequestWrapper wrapRequest = null;
		
		try {
			wrapRequest = new RedisSessionRequest(httpRequest, redisSession, !issueCookie);
			isRedis = true;
		} catch(Exception e) {
			wrapRequest = new HttpServletRequestWrapper(httpRequest);
		}
		
		try {
			
			chain.doFilter(wrapRequest, response);
			
		} finally {
			if (isRedis) {
				try {
					if (redisSession != null && redisSession.isValid()) {
						if (redisSession.isNew() || redisSession.isChanged()) {
							redisSession.refreshLastAccessedTime();
							manager.saveSession(redisSession);
						} else {
							manager.updateTTL(sessionId);
						}
					} else {
						if (sessionId != null && !sessionId.isEmpty()) {
							manager.deleteSession(sessionId);
						}
					}
				} catch (Exception ignore) {
					this.sessionManager = null;
				}
			}
		}
	}

	@Override
	public void destroy() {
	}
}

3. 결론

이렇게 4개의 클래스를 직접 구현해보면 웹 애플리케이션이 클라이언트의 상태를 어떻게 유지하는지,
 밑바닥 원리를 완벽하게 통제하고 이해할 수 있게 됩니다.

특히 필터의 try-finally 블록을 통해 예외 발생 시에도 세션 유실을 방어하고, Redis 장애 시 chain.doFilter()로 우회하여
서비스 중단을 막는 구조는 실무에서 고가용성(High Availability) 아키텍처를 설계할 때 매우 중요한 통찰을 제공합니다.
 

단순히 라이브러리를 가져다 쓰는 것을 넘어, 이러한 동작 원리를 이해하고 있다면 어떠한 프레임워크 환경에서도
유연하게 대처할 수 있는 탄탄한 기본기가 될 것입니다.

  • More
  • 등록일 2026-03-06
  • 조회 8

자바 람다(Lambda) 기초

안녕하세요! 오늘은 자바 프로그래밍의 꽃이라고 불리는 **'람다(Lambda)'**에 대해 이야기해 보려 합니다.
아마 자바를 공부하다 보면 -> 처럼 생긴 화살표 기호를 본 적이 있으실 텐데요.
"대체 이게 뭐야?" 싶었던 분들을 위해, 아주 쉽고 친절하게 설명해 드릴게요.
 



1. 람다(Lambda)가 대체 뭔가요?

한마디로 정의하자면, 람다는 **'이름이 없는 함수(익명 함수)'**입니다.

우리가 보통 메서드를 만들 때는 이름도 붙이고, 반환 타입도 쓰고, 복잡한 절차를 거치죠?
하지만 람다를 사용하면 이런 번거로운 절차를 싹 생략하고, **"기능 그 자체"**를 변수처럼 다룰 수 있게 됩니다.
 

왜 쓸까요?

  • 가독성: 코드가 획기적으로 짧아집니다.

  • 생산성: 복잡한 익명 클래스 구현을 한 줄로 끝낼 수 있습니다.

  • 병렬 처리: 뒤에서 배울 스트림(Stream) API와 결합하면 대용량 데이터 처리도 아주 쉬워집니다.
     



2. 아주 쉬운 예제로 비교해보기

백문이 불여일견! 우리가 숫자 두 개를 더하는 기능을 만든다고 가정해 봅시다.

 

기존의 방식 (익명 클래스)

람다가 없던 시절에는 인터페이스를 구현하기 위해 아래처럼 길게 코드를 짜야 했습니다.
 

// 숫자를 더하는 인터페이스가 있다고 가정
interface Calculator {
    int sum(int a, int b);
}

// 실제 사용 시
Calculator cal = new Calculator() {
    @Override
    public int sum(int a, int b) {
        return a + b;
    }
};



람다를 사용한 방식

똑같은 기능을 람다로 표현하면 어떻게 될까요?
 

Calculator cal = (a, b) -> a + b;

어떤가요? 서너 줄이 넘던 코드가 단 한 줄로 줄어들었습니다.
(a, b)는 입력받을 재료(파라미터)이고, -> 뒤에 있는 a + b는 실제 수행할 행동(로직)입니다.
 



3. 실무에서는 어떻게 쓰일까요? (구체적 사례)

가장 대표적인 사례가 바로 리스트 정렬입니다.
문자열 리스트를 글자 길이 순서대로 정렬하고 싶을 때, 예전에는 복잡한 비교기(Comparator)를 만들어야 했지만 지금은 다릅니다.
 

List<String> names = Arrays.asList("Apple", "Banana", "Kiwi");

// 람다를 활용한 정렬
names.sort((s1, s2) -> s1.length() - s2.length());

System.out.println(names); // 결과: [Kiwi, Apple, Banana]

Tip: 람다는 아무 곳에서나 쓸 수 있는 건 아닙니다.
        **'함수형 인터페이스'**라고 해서, 추상 메서드가 딱 하나만 있는 인터페이스에서만 사용할 수 있어요.
        자바에서는 @FunctionalInterface 어노테이션으로 이를 보장하곤 합니다.
 



4. 람다 작성 시 주의사항

람다가 편하다고 해서 모든 곳에 남발하는 것은 좋지 않습니다. 신뢰성 있는 코드를 위해 다음 세 가지를 기억하세요.

  1. 가독성이 최우선: 람다식이 너무 길어지면(예: 10줄 이상), 오히려 코드를 읽기 어렵게 만듭니다. 그럴 때는 그냥 별도의 메서드로 분리하는 것이 낫습니다.

  2. 디버깅의 어려움: 이름 없는 함수이기 때문에 에러가 발생했을 때 추적이 일반 메서드보다 조금 까다로울 수 있습니다.

  3. 지역 변수 제약: 람다 내부에서 외부의 지역 변수를 사용할 때는 그 변수가 사실상 수정 불가능(final)한 상태여야 합니다.
     


람다로 변신 가능한 메서드의 '자격 조건'


람다식을 사용하려면 해당 메서드가 속한 인터페이스가 반드시 **'함수형 인터페이스(Functional Interface)'**여야 합니다.

 

[ 함수형 인터페이스란? ]

추상 메서드(내용이 비어 있는 메서드)가 딱 하나만 존재하는 인터페이스를 말합니다.
만약 인터페이스에 구현해야 할 메서드가 두 개 이상이라면, 컴퓨터는 람다식이 어떤 메서드를 구현한 것인지 알 수 없기 때문에 에러가 발생합니다.



[ 어떤 구조를 줄일 수 있나요? ]

보통 다음과 같은 흐름을 가진 코드를 람다로 대체합니다.

  • 입력(파라미터)은 있고 반환(Return)은 없는 구조: 데이터를 소비만 하는 경우 (Consumer)

  • 입력은 없고 반환만 있는 구조: 데이터를 생성해서 주기만 하는 경우 (Supplier)

  • 입력과 반환이 모두 있는 구조: 데이터를 받아서 가공한 뒤 돌려주는 경우 (Function)

  • 입력을 받아 참/거짓(boolean)을 반환하는 구조: 필터링 조건으로 사용하는 경우 (Predicate)


[ 람다 변환 공식 ]

복잡한 메서드 구조에서 딱 핵심 재료만 남긴다고 생각하면 쉽습니다.

  1. 메서드 이름과 반환 타입을 지웁니다. (이름이 없어도 하나뿐인 메서드라 알아서 찾아갑니다.)
     

    자바 컴파일러는 이미 인터페이스를 통해 이 메서드가 무엇인지 알고 있습니다. 따라서 중복되는 정보는 다 지워줍니다.
    접근 제어자 (public, private 등) → 삭제
    반환 타입 (void, int 등) → 삭제
    메서드 이름 → 삭제
     

  2. 파라미터의 타입을 지웁니다. 파라미터가 2개 이상일 때 괄호 ()는 유지합니다. (컴파일러가 문맥을 보고 추론합니다.)

  3. 남은 파라미터와 실행 코드 사이에 **화살표(->)**를 꽂아줍니다.

 

5. 람다, 실전에서는 이렇게 씁니다! (실무 사례 보충)

 

① list 정렬 (sort)

람다식을 사용해서 리스트를 정렬할 수 있습니다.
 

[비포: 전통적인 compare 메소드 구현]

List<Integer> list = new ArrayList<>(List.of(5,10,3,6,1,4,9,8,7,2));

list.sort(new Comparator<Integer>() {
    public int compare(Integer i1, Integer i2) {
        return i1.compareTo(i2);
    }
});


[애프터: 람다 활용1]

List<Integer> list = new ArrayList<>(List.of(5,10,3,6,1,4,9,8,7,2));

list.sort((i1,i2)->i1.compareTo(i2));


[애프터: 람다 활용2]

List<Integer> list = new ArrayList<>(List.of(5,10,3,6,1,4,9,8,7,2));

list.sort((i1,i2)->{
    return i1.compareTo(i2);
});
  • sort 의 파라미터는 Comparator 인데, 이 클래스틑 compare 메소드가 단 하나 존재합니다. 
    이 때 compare의 파라미터를 위와 같이 단순하게 줄여 사용할 수 있습니다.

 


② 리스트의 모든 요소 출력하기 (forEach)

기존에는 리스트 안에 있는 내용을 하나씩 꺼내 보려면 for 문을 돌려야 했죠.
하지만 람다를 쓰면 리스트에게 직접 "야, 이거 하나씩 출력해!"라고 명령할 수 있습니다.


[비포: 전통적인 for-each 문]

List<String> fruits = Arrays.asList("사과", "바나나", "포도");

for (String fruit : fruits) {
    System.out.println(fruit);
}


[애프터: 람다 활용]

List<String> fruits = Arrays.asList("사과", "바나나", "포도");

fruits.forEach(fruit -> System.out.println(fruit));
  • fruit라는 이름으로 요소를 하나씩 받아서(->), 출력해라! 라는 뜻입니다. 훨씬 직관적이죠?
     



③ 조건에 맞는 데이터만 골라내기 (Stream Filter)

리스트에서 특정 조건(예: 글자 수가 3개 이상인 것)만 뽑아낼 때도 람다는 빛을 발합니다.


[실전 코드 예시]

List<String> names = Arrays.asList("김철수", "이영희", "박지성", "제임스");

// '김'씨 성을 가진 사람만 필터링해서 출력하기
names.stream()
     .filter(name -> name.startsWith("김"))
     .forEach(name -> System.out.println(name)); 
  • filter 안에 들어간 람다식이 "이 조건에 맞는 애들만 통과시켜!"라는 문지기 역할을 합니다.
     



④ 새로운 스레드(Thread) 만들기 (Runnable)

자바에서 병렬 처리를 위해 스레드를 만들 때, 예전에는 익명 클래스라는 복잡한 구조를 써야 했습니다. 람다를 쓰면 "할 일"만 딱 전달하면 됩니다.


[비포: 복잡한 익명 클래스]

Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("작업시작");
    }
});

thread.start();


[애프터: 람다 활용]

Thread thread = new Thread(() -> System.out.println("작업시작"));
thread.start();
  • 입력값이 없을 때는 빈 괄호 ()만 써주면 됩니다. 코드가 훨씬 가벼워졌죠?
     





초보자를 위한 핵심 요약

람다를 사용할 때는 다음을 기억하세요.
1) (파라미터)->결과
2) (파라미터)->{return 결과}
3) (파라미터)->{가공}


결론

람다는 현대 자바 개발자에게 선택이 아닌 필수입니다.
처음에는 -> 기호가 낯설 수 있지만, 몇 번 직접 타이핑해 보면 이만큼 편한 도구도 없다는 걸 깨닫게 될 거예요.

오늘 배운 "(파라미터) -> {행동}" 구조만 기억해도 절반은 성공입니다!
이제 여러분의 프로젝트에 람다를 적용해 코드를 다이어트시켜 보세요.
 



출처 및 참고:

  • More
  • 등록일 2026-03-04
  • 조회 18

Redis Session 개념 및 로직 설명

Redis Session의 핵심 컴포넌트 이해: Filter, Request, Manager, Session

서버를 여러 대 두는 분산 환경에서 '세션 관리'는 서비스의 안정성을 결정짓는 핵심 요소입니다.
단순히 Redis를 연결하는 것을 넘어, 내부적으로 세션이 어떻게 생성되고 관리되는지
그 메커니즘을 이해하는 것은 장애 대응과 성능 최적화에 큰 도움이 됩니다.

오늘은 Redis Session 구조의 뼈대를 이루는 4가지 핵심 컴포넌트의 역할과 상호작용 로직을 정리해 보겠습니다.
 


1. RedisSessionFilter: 표준 세션을 Redis로 전환하는 스위치

역할: 모든 HTTP 요청의 흐름을 제어하는 진입점

이 컴포넌트는 서블릿 필터(Servlet Filter)로서, 애플리케이션으로 들어오는 모든 요청을 가장 먼저 처리합니다.
기존의 톰캣(Tomcat) 같은 WAS가 관리하던 로컬 세션 메커니즘을 Redis 기반으로 갈아끼우는 역할을 수행합니다.
JSESSIONID 대신 Redis에서 사용할 SESSIONID를 쿠키에 넣는 작업을 하고,
해당 쿠키를 기반으로 세션을 공유합니다.

  • 동작 로직:

    • HttpServletRequest를 가로채 Redis 전용 래퍼 객체로 감쌉니다.

    • 요청이 완료되는 시점에 세션의 최종 상태를 확인하고, 변경 사항이 있다면 이를 Redis 데이터베이스에 반영(Commit)하도록 지시합니다.
       

2. RedisSessionRequest: 세션 조회 로직의 재정의

역할: 애플리케이션과 Redis 저장소 사이의 중개자

개발자가 코드상에서 request.getSession()을 호출할 때, 실제로 동작하는 것은 바로 이 래핑된 요청 객체입니다.

  • 동작 로직:

    • 사용자의 요청에 포함된 세션 쿠키(Session ID)를 분석합니다.

    • 로컬 메모리가 아닌 외부 저장소(Redis)에서 세션 데이터를 찾아오도록 로직이 설계되어 있어,
      서버가 바뀌어도 동일한 세션을 유지할 수 있게 합니다.
       

3. RedisSessionManager: 세션 데이터의 생명주기 관리

역할: 실질적인 Redis CRUD 및 영속성 담당

Manager는 이름 그대로 세션의 생성, 저장, 조회, 삭제를 총괄하는 컨트롤러입니다. Redis와의 직접적인 통신은 대개 이 계층을 통해 이루어집니다.

  • 동작 로직:

    • 직렬화 및 저장: 세션 객체를 Redis에 저장 가능한 형태(바이트 배열 등)로 변환하고, 적절한 만료 시간(TTL)을 부여합니다.

    • 조회 및 복원: Redis에 저장된 데이터를 가져와 다시 Java 객체 형태로 복구합니다.

    • 네트워크 지연이나 Redis 연결 오류 등에 대한 예외 처리를 담당하는 핵심 지점이기도 합니다.
       

4. RedisSession: 분산 환경에 최적화된 데이터 모델

역할: 사용자 정보를 담는 저장소이자 변경 감지 모델

표준 HttpSession 인터페이스를 구현한 클래스로, 실제 사용자의 로그인 정보나 상태값이 저장되는 곳입니다.

  • 동작 로직:

    • Dirty Check: 세션 내부의 속성(Attribute)이 변경되었는지 실시간으로 추적합니다.

    • 데이터가 실제로 변경된 경우에만 Redis에 다시 쓰는 방식을 채택하여, 불필요한 네트워크 트래픽과 Redis의 I/O 부하를 최소화합니다.
       


실무 운영을 위한 한 마디

Redis 세션을 도입할 때 가장 흔히 겪는 문제는 '객체 직렬화' 관련 오류입니다.
세션에 담기는 모든 객체는 반드시 직렬화가 가능해야 하며, 클래스 구조가 변경될 경우 세션 복원 과정에서 에러가 발생할 수 있습니다.
따라서 세션에는 가급적 가볍고 직렬화에 안전한 데이터 위주로 담는 것이 운영 측면에서 유리합니다.

또한, 세션 만료 시간 설정 시 Redis의 TTL과 애플리케이션의 세션 타임아웃 설정이 일치하는지 반드시 확인해야 세션이 예상보다 일찍 끊기는 현상을 방지할 수 있습니다.

[이 게시물은 이재민님에 의해 2026-03-04 13:56:31 설정 가이드에서 이동 됨]
  • More
  • 등록일 2026-03-01
  • 조회 22

진수 변환 함수 (62진수 까지)


public final class NumberUtil {
 
    private static final char[] DIGITS = {
        '0','1','2','3','4','5','6','7','8','9',
        'a','b','c','d','e','f','g','h','i','j',
        'k','l','m','n','o','p','q','r','s','t',
        'u','v','w','x','y','z',
        'A','B','C','D','E','F','G','H','I','J',
        'K','L','M','N','O','P','Q','R','S','T',
        'U','V','W','X','Y','Z'
    };
 
    /**
     * 2 ~ 62 진법 변환 (최적화 버전)
     */
    public static String numberToBase(long num, int radix) {
 
        if (radix < 2 || radix > 62) {
            throw new IllegalArgumentException("radix는 2 이상 62 이하만 가능합니다.");
        }
 
        if (num == 0L) {
            return "0";
        }
 
        boolean negative = (num < 0L);
        long value = negative ? -num : num;
 
        // long 최대 자릿수: 64bit → 2진수 기준 64자리
        char[] buffer = new char[65];
        int index = buffer.length;
 
        while (value != 0L) {
            buffer[--index] = DIGITS[(int)(value % radix)];
            value /= radix;
        }
 
        if (negative) {
            buffer[--index] = '-';
        }
 
        return new String(buffer, index, buffer.length - index);
    }
}
[이 게시물은 이재민님에 의해 2026-03-04 13:54:35 개발에서 이동 됨]
  • More
  • 등록일 2026-02-25
  • 조회 31

Instagram4j - Maven Dependency

<dependency> 
  <groupid>com.github.instagram4j</groupid>
  <artifactid>instagram4j</artifactid>
  <version>2.0.7</version>
</dependency>
[이 게시물은 이재민님에 의해 2026-03-04 13:54:27 개발에서 이동 됨]
  • More
  • 등록일 2026-02-25
  • 조회 27

개인정보 (이름) 마스킹 로직


public static String maskName(String name) {
 
    if (name == null || name.isEmpty()) {
        return name;
    }
 
    int length = name.codePointCount(0, name.length());
 
    // 한 글자
    if (length == 1) {
        return name;
    }
 
    // 두 글자
    if (length == 2) {
        int firstEnd = name.offsetByCodePoints(0, 1);
        return name.substring(0, firstEnd) + "*";
    }
 
    // 세 글자 이상
    int firstEnd = name.offsetByCodePoints(0, 1);
    int lastStart = name.offsetByCodePoints(0, length - 1);
 
    StringBuilder sb = new StringBuilder(name.length());
    sb.append(name, 0, firstEnd);
 
    for (int i = 1; i < length - 1; i++) {
        sb.append('*');
    }
 
    sb.append(name, lastStart, name.length());
 
    return sb.toString();
}

[이 게시물은 이재민님에 의해 2026-03-04 13:54:19 개발에서 이동 됨]
  • More
  • 등록일 2026-02-25
  • 조회 25

Java Thread-Safe 간략 메모


public class ThreadSafeTest {
    public int number = 0;
    
    public void method() {
    	// 멤버변수는 Thread Safe 하지 않음 - 동기화 필요 {
        this.number = this.number+1; 
        this.number++; 
        // }
        
        // Thread Safe 하므로 동기화 필요 없음 {
    	int number = 0; 
        number++;
        // }
        
        // Thread Safe 하지 않음 - 동기화 필요 {
        method_a(); 
        // }
        
        // Thread Safe 함 {
        method_b(); 
        // }
    }

    public int method_a() {
    	this.number++;
        return this.number;
    }
    
    public int method_b() {
    	int number = 0;
        number++;
        return number;
    }
}

[이 게시물은 이재민님에 의해 2026-03-04 13:54:05 개발에서 이동 됨]
  • More
  • 등록일 2026-02-25
  • 조회 31

"이미 참여한 휴대폰 번호입니다." 라고?? - 초보 개발자가 반드시 알아야 할 Thread Safe와 동…

1. 한 지붕 아래 여러 식구, 'Thread' 이야기

java 언어를 다루다 보면 **'Thread(스레드)'**라는 단어를 한 번쯤 들어보셨을 거예요.&nbsp;
쉽게 비유하자면, 웹 서버라는 큰 집에서 동시에 일을 처리하는 '일꾼'들이라고 생각하면 편해요.

우리가 만든 사이트에 접속자가 많아지면, 구글이나 네이버 같은 서버는 수많은 일꾼(Thread)을 내보내 각자의 요청을 처리하게 합니다. 

덕분에 우리는 여러 사람이 동시에 접속해도 기다리지 않고 서비스를 이용할 수 있죠. 

하지만 이 일꾼들이 **'같은 도구(공유 자원)'**를 동시에 쓰려고 할 때 문제가 시작됩니다.
 

2. "분명 처음 응모한 휴대폰인데..." 나를 괴롭혔던 그 에러

제가 예전에 이벤트 사이트를 개발했을 때의 일입니다.

이벤트 참여 버튼을 누르면 휴대폰 번호를 체크해서 중복 참여를 막는 로직이었는데,

문제가 생겨 처음 참여하는 사람인데도 자꾸 **"이미 참여한 휴대폰 번호입니다"**라는 에러가 발생하는 겁니다.

'x됐다....'

수시간을 씨름하다 찾아낸 결론은,

범인은 바로 동기화 처리가 되지 않은 **Map<String, Object> param** 필드였습니다.

  • 사건의 전말: 1번 일꾼이 A라는 사람의 번호를 Map에 담아 검사하는 도중, 2번 일꾼이 B라는 사람의 번호를 같은 Map에 덮어써 버린 거죠.

  • 결과: 1번 일꾼은 분명 A의 정보를 처리 중이었는데, 결과는 B의 중복 체크 결과를 반환하게 됩니다.

동시 접속자가 몰릴수록 이런 데이터 꼬임 현상은 심해졌고, 제 메일함은 컴플레인으로 폭발 직전이었답니다. 

이때 필요한 개념이 바로 **Thread Safe(스레드 안전)**입니다.

다음은 해당 jsp 소스와 java 소스입니다.

<%!
MultipartRequest req;
Map param;
Map qparam;
%>
<%
try {
 
    req = new MultipartRequest(pageContext);
 
} catch (FileUploadException ex) {
    ...
} catch (Throwable th) {
    ...
}
 
param = req.getParameter();
qparam = req.getQueryParameter();
...
%>

위에 <%! ... %> 선언부가 보이죠? 우선 여기 들어가는 변수들은 필드(멤버변수)가 되게 됩니다.

나중에 컴파일된 jspService 구조를 이해하실 수 있을 겁니다.

이 필드들은 여러 쓰레드(쉽게 말해 동시접속자) 가 접근했을 때 쓰레드에 취약하게 됩니다.

public class MultipartRequest extends HttpServletRequestWrapper {
    private final Map parameter;
    ...
 
    //생성자
    public MultipartRequest(PageContext pageContext) {
        여기서 param 세팅 
        (동기화처리 안해서 문제 발생!)
    }
}

마찬가지로 이번엔 더 위험한 소스입니다.

필드로 선언한 parameter 이놈 정말 위험합니다.

반드시 동기화 처리를 거쳐야 합니다. 

그렇지 않으면, 수많은 스레드들이 접근해서 구워삶아 유효하지 않은 객체가 될겁니다.
 

3. Thread Safe란 무엇이고, 왜 해야 할까?

Thread Safe는 여러 일꾼이 동시에 같은 데이터에 접근하더라도, 

데이터가 오염되지 않고 계산 결과가 늘 정확하게 유지되는 상태를 말해요.

만약 동기화를 구현하지 않는다면, 여러분의 소스코드는 언제 터질지 모르는 시한폭탄과 같습니다. 

사용자의 돈이 걸린 결제 시스템이나, 방금 제 사례처럼 중요한 개인정보를 다루는 곳에서 

동기화 오류가 난다면... 정말 아찔하죠?

이해를 돕기 위해 아주 쉬운 코드 예제 두 가지를 살펴볼게요.
 

❌ 예제 1: 위험한 소스코드 (동기화 없음)


public class DangerCounter {
    private int count = 0;

    public void addCount() {
        // 여러 스레드가 동시에 접근하면 숫자가 꼬여요!
        count++; 
    }
}

✅ 예제 2: 안전한 소스코드 (synchronized 사용)

synchronized 키워드를 붙여주면, 한 번에 한 명의 일꾼만 이 메서드를 쓸 수 있게 줄을 세워줍니다.

public class SafeCounter {
    private int count = 0;

    // 한 번에 하나의 스레드만 접근 가능!
    public synchronized void addCount() {
        count++;
    }
}

사실 제가 겪었던 Map 문제라면 ConcurrentHashMap 같은 전용 클래스를 쓰는 것이 효율적이고 안전합니다.
이 내용은 추후에 기회가 되면 올려보겠습니다.
 

4. 안전한 코딩은 선택이 아닌 필수입니다

동기화는 단순히 "알면 좋은 기술"이 아닙니다. 

서비스를 운영하는 개발자로서 사용자의 데이터를 보호하기 위한 최소한의 안전장치예요.

안일하고 가볍게 생각해 평소 처럼 배포했던 소스코드가 

수많은 사용자에게 "이미 참여한 번호입니다"라는 황당한 경험을 줄 수 있습니다.

내가 작성한 코드에 여러 일꾼이 동시에 달려들어도 안전한지(Thread Safe), 

공유 자원을 건드리는 곳에 적절한 동기화 처리가 되어 있는지 항상 의심하고 확인하세요.

여러분의 코드가 시한폭탄이 될지, 튼튼한 성벽이 될지는 바로 이 '동기화' 한 줄에 달려 있습니다!

[이 게시물은 이재민님에 의해 2026-03-04 13:53:46 개발에서 이동 됨]
  • More
  • 등록일 2026-02-27
  • 조회 23

서비스 확장을 위한 세션 클러스터링(Session Clustering) 분석과 선택 가이드 (톰캣 vs Re…

서비스 확장을 위한 세션 클러스터링(Session Clustering) 분석과 선택 가이드


웹 서비스 규모가 커지면 단일 서버만으로는 트래픽을 감당하기 어려워집니다.
여러 대의 서버로 부하를 분산하는 스케일 아웃(Scale-out) 과정에서 반드시 해결해야 할 과제가 바로 세션 관리입니다.
 

사용자가 A 서버에서 로그인한 뒤 다음 요청이 B 서버로 전달될 때, 로그인 정보가 공유되지 않으면 서비스 연속성이 끊기게 됩니다.
이러한 세션 불일치 문제를 해결하여 어떤 서버로 접속하더라도 동일한 상태를 유지하게 하는 기술이 세션 클러스터링입니다.
 

실무에서 주로 검토되는 두 가지 방식인 Tomcat 자체 복제Redis 공유 저장소 방식을 비교하여 최적의 설계 방향을 제시합니다.
 


1. Tomcat 세션 복제 방식 (Session Replication)


Tomcat의 클러스터링 기능을 활용하여 각 WAS(Web Application Server)가 세션 데이터를 서로 복제하여 공유하는 방식입니다.

  • 구조: 모든 서버가 동일한 세션 정보를 나누어 가집니다. 별도의 외부 데이터베이스가 필요 없습니다.

  • 장점: 초기 인프라 구축 비용이 낮고 설정이 비교적 간단하여 소규모 시스템에 적합합니다.

  • 단점: 서버 수가 늘어날수록 복제에 필요한 네트워크 트래픽과 메모리 사용량이 기하급수적으로 증가합니다.
             통상적으로 4~5대 이상의 서버 구성에서는 성능 저하가 뚜렷하게 나타납니다.

  • 현재 사용 여부: 확장성 한계로 인해 대규모 트래픽을 처리하는 현대적인 마이크로서비스 아키텍처(MSA)나 클라우드 환경에서는 거의 사용되지 않습니다.
     


2. Redis 기반 세션 저장소 방식 (Session Storage)


세션 데이터를 외부의 고속 메모리 데이터베이스인 Redis에 저장하고, 모든 서버가 이를 공유하는 방식입니다.

  • 구조: 애플리케이션 서버는 세션 데이터를 직접 보유하지 않고(Stateless), 필요할 때마다 Redis에서 읽어옵니다.

  • 장점: 서버 대수와 상관없이 일관된 성능을 유지하며 확장성이 매우 뛰어납니다.
             서버 장애 시에도 세션 데이터가 유실되지 않아 사용자 경험이 안정적입니다.

  • 단점: Redis 인프라를 별도로 운영해야 하며, Redis 자체의 가용성(High Availability)을 확보하기 위한
             추가 설정(Sentinel, Cluster 등)이 필요합니다.

  • 현재 사용 여부: 트래픽 변동이 잦고 확장이 빈번한 대다수의 현대적 웹 서비스에서 표준으로 채택하고 있습니다.
     


3. 방식별 비교 분석

비교 항목 Tomcat 세션 복제 Redis 세션 저장소
확장성 낮음 (서버 증가 시 오버헤드 급증) 매우 높음 (Scale-out에 최적화)
데이터 일관성 복제 시차에 따른 불일치 가능성 중앙 관리로 즉각적인 일관성 보장
자원 효율성 모든 서버에 중복 저장 필요할 때만 조회하여 메모리 효율적
운영 환경 고정된 수의 소규모 서버군 클라우드 및 오토 스케일링 환경
 

4. 세션 클러스터링 설계의 핵심 선택 기준


성능과 확장성 면에서 Redis를 활용한 방식이 실무적으로 훨씬 유리합니다.

특히 트래픽에 따라 서버가 자동으로 생성되고 삭제되는 클라우드 환경(Auto Scaling)에서는
서버 간 직접 복제 방식이 구조적으로 불가능에 가깝습니다.

시스템을 '상태가 없는(Stateless)' 구조로 설계하여 관리 포인트를 외부 저장소로 일원화하는 것이
장기적인 안정성 측면에서 올바른 방향입니다.
 


5. 왜 Redis인가?

 

최근의 서비스 환경은 트래픽 변화에 따라 서버 대수를 유동적으로 조절하는
Auto Scaling이 기본입니다.
서버가 수시로 생성되고 소멸되는 환경에서, 서버끼리 데이터를 주고받는 방식은 구조적으로
결함이 생길 수밖에 없습니다. 상태가 없는(Stateless) 애플리케이션 서버를 지향하는
현대 설계 철학에도 Redis 방식이 완벽히 부합합니다.

 

6. 도입 시 고려해야 할 위험 요소


Redis 방식을 도입할 때 주의할 점은 단일 장애점(SPOF) 문제입니다.
세션 저장소인 Redis에 장애가 발생하면 전체 서비스의 로그인이 마비됩니다.
따라서 실제 운영 환경에서는 반드시 Redis를 이중화하거나 클러스터로 구성하여 물리적 장애에 대비해야 합니다.
 

반면, Tomcat 복제 방식은 구현은 쉬워 보이지만 서버가 늘어날수록
관리가 불가능해지는 '성능의 덫'에 빠지기 쉽습니다. 따라서 초기 단계부터 미래의 확장성을 고려한 설계가 필요합니다.


결론


안정적인 웹 서비스 운영을 위해서는 서버 간 의존성을 낮추고 데이터 관리 주체를 명확히 분리하는 것이 중요합니다.
인프라 관리 부담이 조금 있더라도 Redis 기반의 세션 클러스터링을 구축하는 것이 서비스 신뢰도와 향후 확장성을 보장하는 가장 확실한 길입니다.

[이 게시물은 이재민님에 의해 2026-03-04 13:53:38 개발에서 이동 됨]
  • More
  • 등록일 2026-03-01
  • 조회 24

외부 라이브러리 없이 구현하는 효율적인 파일 업로드: request.getParts() 활용법

웹 애플리케이션을 개발하다 보면 프로필 사진 업로드나 문서 첨부 같은 파일 업로드 기능은
필수적으로 들어가게 마련입니다.

예전에는 Apache Commons FileUpload 같은 외부 라이브러리를 사용하는 것이 당연시되었지만,
서블릿 3.0 표준이 등장하면서 상황이 완전히 바뀌었습니다.
 

이제는 복잡한 설정 없이도 자바 표준 API인 HttpServletRequest.getParts()를 통해
아주 우아하게 파일을 주고받을 수 있게 되었죠.
오늘은 실무에서 바로 적용할 수 있는 getParts() 사용법과 주의사항을 깊이 있게 다뤄보겠습니다.
 



1. 왜 request.getParts()를 사용해야 할까?

가장 큰 이유는 표준성간결함입니다.
라이브러리 의존성을 줄이는 것은 프로젝트의 가벼움을 유지하고 보안 취약점 관리 측면에서도 매우 유리합니다.

  • 의존성 제거 : pom.xml이나 build.gradle에 별도의 라이브러리를 추가할 필요가 없습니다.

  • 직관적인 API : Part 인터페이스를 통해 파일명, 크기, Content-Type 등을 쉽게 가져올 수 있습니다.

  • 성능 최적화 : 서블릿 컨테이너(Tomcat 등) 레벨에서 직접 처리하므로 데이터 처리 효율이 높습니다.
  • 기본 API : 톰캣에 가장 최적화된 로직으로 동작합니다.
 


2. 기본 설정: @MultipartConfig

request.getParts()를 사용하기 위해서는 서블릿에 멀티파트 데이터를 처리할 준비가 되었다고 알려줘야 합니다.
이때 사용하는 것이 @MultipartConfig 어노테이션입니다.
 

@WebServlet("/upload")
@MultipartConfig(
    fileSizeThreshold = 1024 * 1024 * 1,  // 1MB (메모리에서 처리할 임계치)
    maxFileSize = 1024 * 1024 * 10,       // 10MB (파일 하나당 최대 크기)
    maxRequestSize = 1024 * 1024 * 15      // 15MB (전체 요청 최대 크기)
)
public class FileUploadServlet extends HttpServlet {
    // ... 구현부
}

이 설정이 없으면 getParts()를 호출했을 때 멀티파트 요청이 아니라는 예외가 발생할 수 있으니 반드시 체크해야 합니다.
 



3. 실무형 구현 코드: request.getParts()

이제 실제 컨트롤러(서블릿) 단계에서 파일을 어떻게 처리하는지 코드로 살펴보겠습니다.
 

protected void doPost(HttpServletRequest request, HttpServletResponse response) 
        throws ServletException, IOException {
    
    // 저장 경로 설정 (실제 서버 경로 혹은 외부 저장소)
    String uploadPath = "C:/uploads"; 
    File uploadDir = new File(uploadPath);
    if (!uploadDir.exists()) uploadDir.mkdir();

    // 1. 모든 Part 가져오기 (파일뿐만 아니라 일반 텍스트 데이터도 Part로 전달됨)
    Collection<Part> parts = request.getParts();

    for (Part part : parts) {
        // 2. 파일인 경우만 필터링 (파일명이 존재하는 경우)
        String fileName = getFileName(part);
        if (fileName != null && !fileName.isEmpty()) {
            // 3. 파일 저장
            part.write(uploadPath + File.separator + fileName);
            System.out.println(fileName + " 저장 완료!");
        }
    }
}

// 헤더에서 파일명을 추출하는 헬퍼 메서드
private String getFileName(Part part) {
    String contentDisp = part.getHeader("content-disposition");
    for (String content : contentDisp.split(";")) {
        if (content.trim().startsWith("filename")) {
            return content.substring(content.indexOf("=") + 2, content.length() - 1);
        }
    }
    return null;
}

실제 사례 Tip: 실제 상용 서비스에서는 위 코드처럼 파일명을 그대로 저장하면 보안 위험(Path Traversal)이 있거나
                       파일명 중복 문제가 발생할 수 있습니다. 저 같은 경우 실무에서 UUID.randomUUID()를 활용해 파일명을 치환하고,
                       DB에는 원본 파일명과 저장 파일명을 따로 관리하는 방식을 선호합니다.
 



4. 핵심 체크포인트

파일 업로드는 보안과 직결되는 문제입니다. 구글이 강조하는 신뢰성(Trustworthiness)을 높이기 위해 다음 사항을 꼭 고려하세요.

  1. 확장자 검증: 클라이언트 측 검증은 필수지만, 서버 측에서도 허용된 확장자(jpg, png, pdf 등)인지 반드시 재검증해야 합니다.

  2. 파일 크기 제한: 무분별하게 큰 파일이 업로드되면 서버 리소스가 고갈됩니다. @MultipartConfig의 설정을 통해 원천 봉쇄하세요.

  3. 저장 위치 분리: 업로드된 파일이 실행 권한을 가지지 않도록 WAS의 웹 루트(Web Root) 외부 경로에 저장하는 것이 보안의 정석입니다.
     



결론

request.getParts()는 현대적인 자바 웹 개발에서 가장 깔끔한 파일 업로드 해결책입니다.
라이브러리에 의존하지 않고 표준 API를 깊이 있게 이해하고 사용하는 것만으로도
여러분의 코드는 한층 더 견고해질 것입니다.

오늘 다룬 내용을 바탕으로 더 안전하고 효율적인 파일 업로드 로직을 구축해 보시기 바랍니다.
 



출처 및 참고:

[이 게시물은 이재민님에 의해 2026-03-04 13:53:30 개발에서 이동 됨]
  • More
  • 등록일 2026-03-04
  • 조회 11

검색