개요

Spring Security 에서 ThreadLocal에대해서 언급한적이 있어 글로 남겨봅니다.

 

ThreadLocal이란?

ThreadLocal은 Java에서 제공하는 클래스로, java.lang 패키지에 존재합니다.

각 스레드마다 독립적인 변수를 가질 수 있게 해주는 기능인데 쉽게 말해, ThreadLocal에 저장된 데이터는 해당 Thread만 접근할 수 있는 데이터 저장소라고 할 수 있습니다. 개인이 가지는 사물함이라고 생각하시면 쉽습니다.

 

 

 

동시성 문제

그럼 왜 ThreadLocal을 알아야 할까요? JAVA는 멀티쓰레딩 환경으로 한 메소드에 동시에 접근이 가능합니다. 그래서 항상 동시성문제에 신경써야하죠. 특히 Spring Container에서는 Bean으로 등록해 객체를 싱글톤으로 관리해 자원을 최소화 합니다. 하나의 객체를 여러명이 사용하면 문제가 생기기 마련입니다. 읽기 메소드는 멱등성을 보장받아 문제가 생기지않지만 수정, 생성, 삭제 등 데이터가 변경되었을 때 문제가 생기죠.

 

싱글톤패턴에 대해서 알고싶다면 아래 글을 읽어보세요!

 

 

자바(JAVA) - 싱글톤 패턴(Singleton Pattern)

싱글톤 패턴 싱글톤 패턴은 단 하나의 유일한 객체를 만들기 위한 코드 패턴입니다. 싱글톤 패턴을 따르는 클래스는 생성자가 여러번 호출되더라도 실제 생성되는 객체는 단 하나이고, 최초 생

tmd8633.tistory.com

 

 

자바(JAVA) - 싱글톤 패턴(Singleton Pattern)

싱글톤 패턴 싱글톤 패턴은 단 하나의 유일한 객체를 만들기 위한 코드 패턴입니다. 싱글톤 패턴을 따르는 클래스는 생성자가 여러번 호출되더라도 실제 생성되는 객체는 단 하나이고, 최초 생

tmd8633.tistory.com

 

public class BankAccount {
    private int balance = 0;
    
    // 동시성 문제가 발생하는 메서드
    public int transfer(int amount) {
        int currentBalance = balance;         // 현재 잔액 읽기
        balance = currentBalance + amount;    // 잔액 업데이트
        return balance;
    }
    
    public int getBalance() {
        return balance;
    }
}

 

가령 BankAccount 객체가 싱글톤으로 관리되고, 여러 사용자가 BankAccount를 사용한다고 했을때,

 

  1. 사용자A : bankAccount.transfer(1000)
  2. 사용자A 현재 잔액 읽음 : 현재 잔액 0
  3. 사용자B : bankAccount.transfer(1000)
  4. 사용자B 현재 잔액 읽음 : 현재 잔액 0
  5. 사용자A : 잔액 업데이트 - 결과 반환 1000
  6. 사용자B : 잔액 업데이트 - 결과 반환 1000

최종 잔액이 2000이 될 것으로 예상했지만 동시성 문제로 최종 잔액이 1000이 되었습니다.

 

 

 

ThreadLocal의 특징

 

  • 스레드 안전성: 각 스레드가 자신만의 독립된 변수를 가지므로, 동기화 없이도 스레드 안전성을 보장합니다.
  • 데이터 격리: 다른 스레드의 데이터에 접근할 수 없어 데이터 격리가 완벽하게 이루어집니다.
  • 성능: 동기화가 필요 없으므로, synchronized 키워드 사용 대비 성능상 이점이 있습니다.

 

 

ThreadLocal

public class ThreadLocal<T> {
    
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }
    
    boolean isPresent() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        return map != null && map.getEntry(this) != null;
    }
    
    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            map.set(this, value);
        } else {
            createMap(t, value);
        }
    }
    
    public void remove() {
        ThreadLocalMap m = getMap(Thread.currentThread());
        if (m != null) {
            m.remove(this);
        }
    }

    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
    
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

    static class ThreadLocalMap {

        static class Entry extends WeakReference<java.lang.ThreadLocal<?>> {
            Object value;

            Entry(java.lang.ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
    }
}

 

ThreadLocal은 ThreadLocalMap을 가지고있고 여기에서 key, value로 데이터를 보관합니다.

그리고 이때 get()메소드에서 Thread.currentThread()를 사용해 Thread를 꺼내고 그 ThreadLocalMap을 반환해서 가져오게됩니다.

 

 

이제 개념을 알았으니 어디에서 사용하고 있는지 간단하게 알아보겠습니다.

 

 

 

사용 사례

 

Spring Security

public class SecurityContextHolder {

    // ... 코드 생략

    private static void initializeStrategy() {
        if ("MODE_PRE_INITIALIZED".equals(strategyName)) {
            Assert.state(strategy != null, "When using MODE_PRE_INITIALIZED, setContextHolderStrategy must be called with the fully constructed strategy");
        } else {
            if (!StringUtils.hasText(strategyName)) {
                strategyName = "MODE_THREADLOCAL"; // ThreadLocal 전략이 Default
            }

            if (strategyName.equals("MODE_THREADLOCAL")) {
                strategy = new ThreadLocalSecurityContextHolderStrategy();
            } else if (strategyName.equals("MODE_INHERITABLETHREADLOCAL")) {
                strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
            } else if (strategyName.equals("MODE_GLOBAL")) {
                strategy = new GlobalSecurityContextHolderStrategy();
            } else {
                try {
                    Class<?> clazz = Class.forName(strategyName);
                    Constructor<?> customStrategy = clazz.getConstructor();
                    strategy = (SecurityContextHolderStrategy)customStrategy.newInstance();
                } catch (Exception ex) {
                    ReflectionUtils.handleReflectionException(ex);
                }

            }
        }
    }
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
    private static final ThreadLocal<Supplier<SecurityContext>> contextHolder = new ThreadLocal();
    
    // ... 코드 생략
}

 

 

 

 

Transaction

public class DataSourceTransactionManager extends AbstractPlatformTransactionManager implements ResourceTransactionManager, InitializingBean {

    // ... 코드 생략
    
    protected Object doGetTransaction() {
        DataSourceTransactionObject txObject = new DataSourceTransactionObject();
        txObject.setSavepointAllowed(this.isNestedTransactionAllowed());
        ConnectionHolder conHolder = (ConnectionHolder)TransactionSynchronizationManager.getResource(this.obtainDataSource());
        txObject.setConnectionHolder(conHolder, false);
        return txObject;
    }
    
    protected void doBegin(Object transaction, TransactionDefinition definition) {
    
        // ... 코드 생략
        if (txObject.isNewConnectionHolder()) {
            TransactionSynchronizationManager.bindResource(this.obtainDataSource(), txObject.getConnectionHolder());
        }    
        // ... 코드 생략
    }
}

public abstract class TransactionSynchronizationManager {
    private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal("Transactional resources");
    private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations = new NamedThreadLocal("Transaction synchronizations");
    private static final ThreadLocal<String> currentTransactionName = new NamedThreadLocal("Current transaction name");
    private static final ThreadLocal<Boolean> currentTransactionReadOnly = new NamedThreadLocal("Current transaction read-only status");
    private static final ThreadLocal<Integer> currentTransactionIsolationLevel = new NamedThreadLocal("Current transaction isolation level");
    private static final ThreadLocal<Boolean> actualTransactionActive = new NamedThreadLocal("Actual transaction active");
    
    // ... 코드 생략
}

 

 

 

Hibernate

public class ThreadLocalSessionContext extends AbstractCurrentSessionContext {
    private static final CoreMessageLogger LOG = (CoreMessageLogger)Logger.getMessageLogger(CoreMessageLogger.class, ThreadLocalSessionContext.class.getName());
    private static final Class<?>[] SESSION_PROXY_INTERFACES = new Class[]{Session.class, SessionImplementor.class, EventSource.class, LobCreationContext.class};
    private static final ThreadLocal<Map<SessionFactory, Session>> CONTEXT_TL = ThreadLocal.withInitial(HashMap::new);
    
    // ... 코드 생략
}

 

 

 

 

ThreadLocal 주의사항

 

메모리 누수

ThreadLocal 사용 후에는 반드시 remove()를 호출하여 메모리 누수를 방지해야 합니다. 특히 스레드 풀을 사용하는 환경에서는 더욱 중요합니다.

 

try {
    // ThreadLocal 사용
    userContext.set(new UserContext());
    // 비즈니스 로직
} finally {
    // 반드시 삭제
    userContext.remove();
}

가능하면 try-with-resources 패턴을 사용해 자원을 반납하면 안전하게 사용할 수 있습니다.

 

성능 고려사항

  1. ThreadLocal은 각 스레드마다 별도의 메모리를 사용합니다.
  2. 많은 수의 ThreadLocal 변수를 사용하면 메모리 사용량이 증가할 수 있습니다.
  3. get()과 set() 연산은 매우 빠르지만, 너무 빈번한 접근은 피하는 것이 좋습니다.

 

static final 선언

static final로 선언하는걸 권장합니다. 위에 사용 사례를 보시면 모두 static final로 선언되어있는걸 보실 수 있습니다.

 

  • static 사용 이유
    • ThreadLocal 객체 자체는 모든 스레드가 공유해도 됩니다
    • 각 스레드마다 값을 따로 저장하는 것은 ThreadLocal의 내부 구현이 처리함
    • 불필요한 인스턴스 생성을 방지할 수 있음
    • 전역적으로 접근이 필요한 경우가 많음 (예: Transaction, Security 등)
  • final 사용 이유
    • ThreadLocal 인스턴스 자체는 변경될 필요가 없음
    • 한 번 생성된 후에는 참조가 변경되면 안됨
    • 실수로 ThreadLocal 참조를 바꾸는 것을 방지
    • 불변성을 보장하여 스레드 안전성을 높임

 

 

 

끝!

Spring Security

Spring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Spring Security를 통해 쉽게 해결할 수 있습니다. 다만 이 프래임워크가 워낙 방대하고 구조를 이해하고 있지않으면 활용하기 쉽지 않습니다. 하나의 게시물로 모든 Security 기능에 대해서 소개하지 못하므로, 이 글에서는 프레임워크에 대해 기본개념과 주요기능에 대해서 소개하고 각 기능에 대한 상세한 설명은 다음 글에서 이어가도록 하겠습니다.

 

이 글은 Spring Boot 3.4.0, Spring Security 6.4.1 환경에서 제작되었습니다.

 

 

 

들어가기전에

Security를 공부하기전에 짧게 기본개념을 잡고 가겠습니다. 이해하는데 도움이 될겁니다.

 

 

1. Filter

Spring Security는 Servlet Filter를 기반으로 동작합니다.

Client - Filter - DispatcherServlet - Interceptor - Controller

 

 

2. 용어

용어 설명
Authentication 인증
  • 사용자가 자신이 주장하는 사람이 맞는지 확인하는 과정
  • '신원 확인'의 과정
    예) 공항에서 여권으로 신원을 확인하는 것
Authorization 인가
  • 인증된 사용자가 특정 리소스에 접근할 수 있는 권한이 있는지 확인하는 과정
  • '권한 확인'의 과정
    예) 회사에서 직급에 따라 접근할 수 있는 시스템이 다른 것
Principal 접근주체
  • 시스템에 접근하는 사용자, 디바이스 또는 다른 시스템 등의 주체
  • 인증 이후 SecurityContext에 저장되는 인증된 사용자의 정보
    예) 사원증을 소지한 직원
Role 권한
  • 특정 리소스에 대한 접근 권한을 그룹화한 것
  • 사용자에게 부여될 수 있는 권한의 집합
    예) 회사에서의 직급 (사원, 대리, 과장 등)

 

용어 간의 관계

1. Authentication -> Principal 생성
   (인증 성공 시 Principal 정보가 생성됨)

2. Principal -> Role 부여
   (인증된 사용자에게 역할이 부여됨)

3. Role -> Authorization 결정
   (부여된 역할에 따라 권한이 결정됨)

 

 

3. SecurityContextHolder

SecurityContextHolder는 인증된 사용자의 정보를 저장하는 곳입니다.

 

 

4. Filter, Authentication, Authorization

Security에서 크게 Filter, Authentication, Authorization 으로 나눌 수 있습니다. 아키텍처를 볼 때, 이렇게 3개로 구분해서 보면 이해가 조금 더 쉬울 수 있습니다.

 

 

 

 

 

 

1. 아키텍처

 

출처 : https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html

 

시큐리티 공식문서에서 가져온 아키텍처입니다. 이 사진을 보고 이해해야할것은 아래와 같습니다.

 

  1. SecurityFilterChain 은 Filter에서 동작한다.
  2. Authentication을 통해 인증과정을 수행한다.
    인증에 성공하면 SecurityContextHolder에 인증된 사용자의 정보를 담는다.
    인증에 실패하면 SecurityContextHolder가 비워집니다.(cleared out)

 

 

2. Filter

 

2-1.  DelegatingFilterProxy

 

서블릿 컨테이너와 Spring의 ApplicationContext 사이의 연결점입니다. 서블릿 컨테이너는 자체 표준을 사용하여  필터 인스턴스를 등록할 수 있지만 Bean에 대해서는 알지 못합니다. 그래서 DelegatingFilterProxy 에 Bean 등록을 위임하도록 합니다.

 

한줄정리 : DelegatingFilterProxy는 Servlet Container - Spring ApplicationContext를 연결하는 역할을 한다!

 

 

2-2. FilterChainProxy과  SecurityFilterChain

 

  1. FilterChainProxy는 보안필터의 시작점입니다.
  2. 요청 URL에 따라(RequestMatcher) 적절한 SecurityFilterChain을 선택할 수 있도록 합니다.
  3. Spring Security의 HttpFirewall을 적용하여 특정 유형의 공격으로부터 애플리케이션을 보호합니다.
  4. FilterChianProxy는 bean 이기때문에 DelegatingFilterProxy로 감싸져있습니다.

 

 

요청 URL에 따라 SecurityFilterChain에서 Filter를 선택

 

 

 

2-3. SecurityFilterChain 내부 Filter

 

spring-security/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterOrderRegistration.java at

Spring Security. Contribute to spring-projects/spring-security development by creating an account on GitHub.

github.com

SecurityFilterChain에는 여러 Filter가 존재하는데 그 Filter의 순서는  FilterOrderRegistration.class 를 통해 확인할 수 있습니다. Filter의 순서를 외울필요는 없지만 어느 정도 흐름은 알고있는게 좋습니다. 

각 Filter의 용도와 사용법은 다음 글에서 알아보도록하겠습니다.

 

 

 

 

3.  Authentication (인증)

사용자 정보를 가지고 인증을 하는 과정입니다.

 

3-1. SecurityContextHolder

 

 

  • SecurityContextHolder는 인증된 사용자의 정보를 담고있는 Container입니다. Spring Security는 SecurityContextHolder가 어떻게 채워지는지 묻지않습니다. 그저 값이 포함되어있으면 인증된 사용자로 인식합니다. 즉, SecurityContextHolder가 비어있으면 인증되지않은 사용자, 비어있지않으면 인증된 사용자로 인식됩니다.
  • ThreadLocal을 사용하여 보안정보를 저장합니다. 즉, SecurityContext는 동일한 스레드내에서는 어디서든 보안 정보를 쉽게 접근 가능합니다. 요청 처리가 끝나면 반드시 쓰레드의 정보를 지워야 하지만 FilterChainProxy가 이 청소를 자동으로 해주기때문에 매우 안전하게 사용할 수 있습니다.
  • ThreadLocal에 대해 안전하지않은 어플리케이션에 대해서는 ThreadLocal 설정을 변경할 수 있습니다.
 

[JAVA] ThreadLocal에 대해서 알아보자

개요Spring Security 에서 ThreadLocal에대해서 언급한적이 있어 글로 남겨봅니다. ThreadLocal이란?ThreadLocal은 Java에서 제공하는 클래스로, java.lang 패키지에 존재합니다.각 스레드마다 독립적인 변수를

tmd8633.tistory.com

 

3-2. SecurityContext

인증된 객체(Authentication)를 보관하는 역할을 합니다. SecurityContextHolder로 부터 가져올 수 있습니다.

 

 

3-3. Authentication

현재 접근하는 사용자의 정보와 권한을 담은 인터페이스 입니다. isAuthenticated() 메소드로부터 인증되었는지 확인할 수 있습니다.

 

용어 설명
principal 아이디/비밀번호로 인증할때 사용자를 식별하는 역할을 합니다. UserDetails의 인스턴스로 사용됩니다.
credentials 비밀번호로 사요됩니다. 사용자가 인증된 후에는 비밀번호가 지워집니다.
authorities 사용자에게 부여된 권한들입니다. GrantedAuthority의 List형식으로 되어있습니다.

 

GrantedAuthority

주체에게 부여된 권한입니다. "ROLE_ADNATIOR", "ROLE_HR_SUPERVISOR" 과 같은 '역할'로 사용됩니다.

사용자 아이디/비밀번호 기반 인증을 사용할 때, UserDetailsService에 의해 로드됩니다.

 

 

3-4. AuthenticationManager

AuthenticationManager는 Spring Security의 필터가 인증을 수행하는 방식을 정의하는 인터페이스입니다.

AuthenticationManager는 인터페이스이므로 따로 구현할 수 있지만 기본적으로 ProviderManager가 구현되어있습니다.

 

 

3-5. AuthenticationProvider

실제 인증에 대한 부분을 담당합니다. 인증전의 Authentication 객체를 받아서 인증이 완료된 Authentication 객체를 반환합니다.

 

 

3-6. UserDetailService

UserDetailService를 implements해서 DB 데이터를 주입하여 UserDetails 를 반환하게 합니다.

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

 

 

3-7. UserDetails

인증된 사용자로써 사용될 인터페이스 입니다. implements해서 Custom해야합니다.

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    default boolean isAccountNonExpired() {
        return true;
    }

    default boolean isAccountNonLocked() {
        return true;
    }

    default boolean isCredentialsNonExpired() {
        return true;
    }

    default boolean isEnabled() {
        return true;
    }
}

 

 

4. Authorization (인가)

인증된 정보를 가지고 접근에 대한 허용여부를 결정하는 과정입니다. GrantedAuthority에 String getAuthority() 메소드가 사용됩니다.

기본적인 규칙으로 'ROLE_' 접두사가 포함됩니다. 즉, "USER"라는 권한이 있는 경우 GrantedAuthority#getAuthority 를 사용하면 "ROLE_USER" 권한을 찾습니다.

 

'ROLE_' 접두사를 변경할 수 있습니다.

@Bean
static GrantedAuthorityDefaults grantedAuthorityDefaults() {
    return new GrantedAuthorityDefaults("MYPREFIX_");
}
// static 필수!!

 

 

 

 

5. 인증처리 과정

 

 

5-1. HTTP 요청

사용자가 로그인 요청을 합니다.

 

5-2 AuthenticationFilter 인증

SecurityFilterChain에서 로그인을 담당하는 UsernamePasswordAuthenticationFilter 에서 인증을 처리합니다.

 

5-3. UsernameAuthenticationToken 발급

아이디와 비밀번호를 통해 토큰을 발급하고 AuthenticationManager로 토큰을 보냅니다.

 

5-4. AuthenticationProvider 인증

인증된 객체를 UserDetailsService에게 넘겨줍니다.

 

5-5. UserDetails 생성

전달된 인증된 객체를 DB에서 조회하여 데이터를 UserDetails 반환

 

5-6. AuthenticationProvider

인증에 성공하면 AuthenticationProvider에서 AuthenticationManager에게 인증에 성공한 객체를 반환

 

5-7. AuthenticationManager

인증에 성공한 객체를 AuthentifacionFIlter에 전달

 

5-8. SecurityContextHolder

인증에 성공한 객체는 SecurityContextHolder에 저장

 

 

 

6. 설정

SecurityFilterChain 에 어떤 기능을 사용할지 설정하는 부분입니다. 이 부분에서는 Filter의 기능을 사용자의 API에 맞게 수정할 수 있고, 해당 Filter의 전과 후에 Custom Filter를 넣는 등 여러 기능을 설정할 수 있습니다.

 

6-1. 기본설정

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.csrf(Customizer.withDefaults())
            .httpBasic(Customizer.withDefaults())
            .formLogin(Customizer.withDefaults())
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            );

        return http.build();
    }

}

 

메소드 필터 설명
csrf CsrfFilter csrf에 대한 설정
httpBasic BasicAuthenticationFilter HTTP Basic Authentication에 대한 설정
formLogin UsernamePasswordAuthenticationFilter form login에 대한 설정
authorizeHttpRequests AuthorizationFilter 권한에 대한 설정

 

위의 4개의 메소드뿐만아니라 수많은 filter에 대한 설정을 할 수 있습니다.

 

Customizer.withDefaults() 는 Security 기본설정에 따른다는 메소드입니다.

 

CSRF에 대해서 이해하기

 

[CS][Spring Security] CSRF란?

CSRF란?CSRF Cross-Site Request Forgery의 약자로 인증된 사용자의 권한을 악용하여 해당 사용자가 의도하지 않은 요청을 웹사이트에 전송하는 공격 기법입니다. 공격자는 사용자가 이미 인증된 상태를

tmd8633.tistory.com

 

 

6-2. Filter 추가

http.

// atFilter를 filter로 변경
addFilterAt(Filter filter, Class<? extends Filter> atFilter)

// beforeFilter 전에 filter를 추가
addFilterBefore(Filter filter, Class<? extends Filter> beforeFilter)

// afterFilter 후에 filter를 추가
addFilterAfter(Filter filter, Class<? extends Filter> afterFilter)
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // ...
        .addFilterAfter(new TenantFilter(), AnonymousAuthenticationFilter.class); 
    return http.build();
}

 

 

Filter를 Bean으로 등록

Filter를 Bean으로 선언하면 Spring Container에 등록되면서 Filter가 2번 호출 될 수 있습니다. 이를 방지하기 위해 setEnabled(false) 로 설정해주어야 합니다.

 

@Bean
public FilterRegistrationBean<TenantFilter> tenantFilterRegistration(TenantFilter filter) {
    FilterRegistrationBean<TenantFilter> registration = new FilterRegistrationBean<>(filter);
    registration.setEnabled(false);
    return registration;
}

이렇게 하면 HttpSecurity 에 추가한 것만 유효하게 됩니다.

 

 

 

참고자료

공식문서 : https://docs.spring.io/spring-security/reference/servlet/architecture.html

 

플라이웨이트 패턴이란?

플라이웨이트 패턴은 많은 수의 유사한 객체를 효율적으로 관리하기 위한 구조적 디자인 패턴입니다. 객체의 내부 상태를 공유함으로써 메모리 사용을 최적화합니다. 코드를 보면서 이해해봅시다.

 

 

재사용 가능 여부

플라이웨이트 패턴은 자원의 재사용 여부를 먼저 따져봐야합니다. 같은 자원을 공유해도 문제가 없는 데이터를 재사용함으로써 메모리를 절약할 수 있으니까요.

예제로 전쟁시뮬레이션 게임을 만든다고 가정해보겠습니다. 하나의 객체를 만들때 텍스쳐, 사운드, 폴리곤, 객체의 좌표가 사용된다고 해보겠습니다.

 

 

 

플라이웨이트 패턴 적용 전

먼저 메모리 사용량을 표시하기 위해 메모리 클래스를 하나 만들겠습니다.

public class Memory {

    public static int size = 0;

    public static void print() {
        System.out.println("메모리 사용량 : " + size + "MB");
    }
}

 

 

public class Soldier {

    private final int size = 10;
    private final String texture;
    private final String sound;
    private final int polygons;

    private double positionX;
    private double positionY;

    public Soldier(String texture, String sound, int polygons, double positionX, double positionY) {
        this.texture = texture;
        this.sound = sound;
        this.polygons = polygons;
        this.positionX = positionX;
        this.positionY = positionY;

        // 메모리 사용량 증가
        Memory.size += size;
    }

}

 

public class Field {

    // 지형 타일 크기
    public static final int CANVAS_SIZE = 1000;

    public void render(String texture, String sound, int polygons, double positionX, double positionY) {
        Soldier soldier = new Soldier(texture, sound, polygons, positionX, positionY);
        System.out.println("병사 생성 : x : " + positionX + ", " + " y : " + positionY);
    }
}

 

 

    public static void main(String[] args) {
        Field field = new Field();

        for (int i = 0; i < 1000; i++) {
            field.render(
                "robe.png",
                "solider.wav",
                200,
                Field.CANVAS_SIZE * Math.random(),
                Field.CANVAS_SIZE * Math.random()
            );
        }

        Memory.print(); // 메모리 사용량 : 10000MB
    }

 

Solider 1000명을 Field에 rendering하는 코드를 간단하게 작성해보았습니다. 이때 무려 메모리를 10000mb나 사용되었네요. 만약 병사의 수가 증가한다면 기하급수적으로 메모리를 사용할 것입니다. 이 코드를 플라이웨이트 패턴을 이용해 메모리 사용량을 줄여보겠습니다.

 

 

플라이웨이트 패턴 적용 후

public class Soldier {

    private final int size = 2;

    private SoliderModel model;
    private double positionX;
    private double positionY;

    public Soldier(SoliderModel model, double positionX, double positionY) {
        this.model = model;
        this.positionX = positionX;
        this.positionY = positionY;

        // 메모리 사용량 증가
        Memory.size += size;
    }

}
public class SoliderModel {

    private final int size = 8;
    private final String texture;
    private final String sound;
    private final int polygons;

    public SoliderModel(String texture, String sound, int polygons) {
        this.texture = texture;
        this.sound = sound;
        this.polygons = polygons;

        Memory.size += size;
    }

}

 

Solider에서 재사용이 가능한 텍스쳐, 사운드, 폴리곤의 수를 SoliderModel 클래스로 새로 만들었습니다.

메모리 사용량은 SoliderModel = 8, Solider = 2 로 분배되었습니다.

 

public class SoliderModelFactory {

    private static final Map<String, SoliderModel> cache = new HashMap<>();

    public static SoliderModel getModel(String texture, String sound, int polygons) {
        String key = texture + sound + polygons;
        return cache.computeIfAbsent(key, k -> new SoliderModel(texture, sound, polygons));
    }
    
    public static void clear() {
        cache.clear();
    }
}

 

Factory를 만들고 한번 만들었던 모델데이터는 이곳에서 재사용됩니다.

 

public class Field {

    // 지형 타일 크기
    public static final int CANVAS_SIZE = 1000;

    public void render(String texture, String sound, int polygons, double positionX, double positionY) {
        SoliderModel model = SoliderModelFactory.getModel(texture, sound, polygons);
        Soldier soldier = new Soldier(model, positionX, positionY);
        System.out.println("병사 생성 : x : " + positionX + ", " + " y : " + positionY);
    }
}

필드에서 SoliderModelFactory.getModel 로 모델데이터를 가져와 Solider를 만들어 렌더링합니다.

 

    public static void main(String[] args) {
        Field field = new Field();

        for (int i = 0; i < 1000; i++) {
            field.render(
                "robe.png",
                "solider.wav",
                200,
                Field.CANVAS_SIZE * Math.random(),
                Field.CANVAS_SIZE * Math.random()
            );
        }

        Memory.print(); // 메모리 사용량 : 2008MB
        SoliderModelFactory.clear();
    }

메모리 사용량을 출력해본 결과 2008MB 가 나왔습니다. 플라이웨이트 적용전보다 무려 80% 감소되었습니다. 반복이 많아질 수록 경량화될겁니다.

 

 

주의점

렌더링이 끝났다면 factory를 초기화해줄 필요가 있습니다. 관리되고있는 인스턴스는 GC에서 제거하지 않기 때문에 메모리 누수가 발생할 수 있습니다.  참고로 이런 캐싱방식은 StringConstantPool과 같은 개념이라고 할 수 있습니다.

 

 

[JAVA] String의 불변성(immutability)

들어가며JAVA를 처음 접할 때, String은 불변객체라고 배우고 지나갔습니다. 하지만 실제 String을 다루다보면 어째서 불변객체인지 의문이 들곤합니다.  String str = "apple"; str = "banana";왜냐하면, 재할

tmd8633.tistory.com

String Constant Pool과 String 불변성에 대해서 더 알고싶다면 해당 게시물을 읽어보시는걸 추천드립니다.

 

 

마치며

플라이웨이트 패턴은 메모리 사용량을 크게 줄일 수 있는 강력한 도구입니다. 하지만 모든 상황에서 적합한 것은 아니며, 다음과 같은 상황에서 고려해볼 만합니다:

  1. 애플리케이션에서 많은 수의 유사한 객체를 사용할 때
  2. 객체의 상태를 내부와 외부로 명확히 구분할 수 있을 때
  3. 메모리 사용량이 중요한 제약 조건일 때

 

 

 

 

 

복합 패턴 개요

복합 패턴은 객체들을 트리 구조로 구성하여 부분-전체 계층구조를 구현하는 패턴입니다. 이 패턴을 사용하면 클라이언트에서 개별 객체와 복합 객체를 동일하게 다룰 수 있습니다. 오늘은 이 패턴에 대해서 공부해보겠습니다.

출처 : https://ko.wikipedia.org/wiki/%EC%BB%B4%ED%8F%AC%EC%A7%80%ED%8A%B8_%ED%8C%A8%ED%84%B4

 

 

 

뭐하는 패턴이지?

위의 구조를 보면 당최 뭐하는 패턴인지 감이 안잡힐 수 있습니다. 간단하게 말하면 복합객체와 단일객체를 동일한 타입을 취급해서 단순화시키는 것인데요. 예를들어 폴더 - 파일의 구조를 가진다고 했을때, 폴더와 파일을 하나의 객체로 동일하게 취급해서 구조를 단순화 시키는 것입니다. 폴더안에는 파일이 들어갈 수 있고, 폴더안에는 폴더가 들어갈 수 있는 구조를 생각했을 때 이걸 효과적으로 단일화 시킬 수 있다는 뜻이죠. 코드를 보면서 이해해보도록 하겠습니다.

 

 

 

복합 패턴의 구조

위의 사진에서 Leaf는 단일객체, Composite는 복합객체를 뜻합니다. 우리는 Leaf -> 파일, Composite -> 폴더로 치환해서 코드를 구현해보겠습니다.

 

public interface FileComponent {

    int getSize();
    String getTitle();

}

 

public class File implements FileComponent {

    private final String title;
    private final int size;

    public File(String title, int size) {
        this.title = title;
        this.size = size;
    }

    @Override
    public int getSize() {
        return size;
    }

    @Override
    public String getTitle() {
        return title;
    }
}

 

public class Folder implements FileComponent {

    private final String title;
    private final List<FileComponent> files = new ArrayList<>();

    public Folder(String title) {
        this.title = title;
    }

    @Override
    public int getSize() {
        int size = 0;
        for (FileComponent file : files) {
            size += file.getSize();
        }
        return size;
    }

    @Override
    public String getTitle() {
        return title;
    }

    public void add(FileComponent file) {
        files.add(file);
    }

    public void remove(FileComponent file) {
        files.remove(file);
    }

}

 

FileComponent interface를 만들고 파일과 폴더에서 구현했습니다.

 

 

    public static void main(String[] args) {
        Folder folder1 = new Folder("폴더1");
        folder1.add(new File("파일1", 300));

        Folder folder2 = new Folder("폴더2");
        folder2.add(new File("파일2", 1000));
        folder2.add(new File("파일3", 500));


        Folder folder3 = new Folder("폴더2");


        folder1.add(folder2);
        folder1.add(folder3);

        System.out.println("folder1.getTitle() = " + folder1.getTitle()); // 폴더1
        System.out.println("folder2.getSize() = " + folder1.getSize()); // 1800
    }

 

폴더1을 만들고 파일1을 넣었습니다.

폴더2를 만들고 파일2와 파일3을 넣었습니다.

그리고 아무것도 들어있지않은 폴더3을 만들었습니다.

 

마지막으로 폴더1에 폴더2와 폴더3을 넣었습니다.

 

이제 folder1의 title과 size를 출력해보면 내부에 들어있는 모든 파일의 크기가 출력됩니다.

 

 

 

복합 패턴의 장단점

장점

  1. 클라이언트 코드를 단순화
  2. 새로운 종류의 구성요소를 쉽게 추가
  3. 전체-부분 관계를 표현하기 용이

단점

  1. 설계가 지나치게 일반화될 수 있음
  2. 제약사항을 추가하기 어려움

 

 

 

복합패턴을 이용해 메뉴시스템 구현

복합패턴에 대해서 배웠으니 이 패턴을 활용해서 메뉴시스템을 구현해보겠습니다.

 

public abstract class MenuComponent {

    protected String name;
    protected String description;

    public MenuComponent(String name, String description) {
        this.name = name;
        this.description = description;
    }

    public abstract void print();
}
public class MenuItem extends MenuComponent {

    private int price;

    public MenuItem(String name, String description, int price) {
        super(name, description);
        this.price = price;
    }

    @Override
    public void print() {
        System.out.println(name + ": " + description + " - " + price + "원");
    }
}
public class Menu extends MenuComponent {
    
    private List<MenuComponent> menuComponents = new ArrayList<>();

    public Menu(String name, String description) {
        super(name, description);
    }

    public void add(MenuComponent menuComponent) {
        menuComponents.add(menuComponent);
    }

    @Override
    public void print() {
        System.out.println("\n" + name + ": " + description);
        System.out.println("--------------------");

        for (MenuComponent menuComponent : menuComponents) {
            menuComponent.print();
        }
    }
}

 

폴더 - 파일 구조와 동일하게 구현했습니다. 조금 다른게 있다면 interface -> abstract가 변경된게 있습니다만 무시해도됩니다.

 

    public static void main(String[] args) {
        // 메인 메뉴
        Menu mainMenu = new Menu("메인 메뉴", "메뉴를 선택해주세요");

        // 커피 메뉴
        Menu coffeeMenu = new Menu("커피", "커피를 선택해주세요");
        coffeeMenu.add(new MenuItem("아메리카노", "진한 에스프레소의 맛과 향을 부드럽게 즐길 수 있는 아메리칸 스타일의 커피입니다.", 4500));
        coffeeMenu.add(new MenuItem("카메라떼", "진한 에스프레소에 우유를 넣어 풍부한 커피향을 부드럽게 즐실 수 있습니다.", 5200));

        // 음식 메뉴
        Menu foodMenu = new Menu("음식", "커피와 함께 즐길 음식을 선택해주세요");
        foodMenu.add(new MenuItem("크로크무슈", "햄과 치즈를 차곡차곡 쌓아 올려 만든 프랑스식 샌드위치로 사르르 녹아내린 모짜렐라 치즈가 입안 가득 풍성한 맛", 4500));
        foodMenu.add(new MenuItem("페스토 햄 파니니", "터키햄과 모짜렐라치즈를 두둑하게 채운 대중적인 파니니", 6500));

        // 메인메뉴에 커피, 음식메뉴를 추가
        mainMenu.add(coffeeMenu);
        mainMenu.add(foodMenu);

        // 전체 메뉴 출력
        mainMenu.print();
    }
메인 메뉴: 메뉴를 선택해주세요
--------------------

커피: 커피를 선택해주세요
--------------------
아메리카노: 진한 에스프레소의 맛과 향을 부드럽게 즐길 수 있는 아메리칸 스타일의 커피입니다. - 4500원
카메라떼: 진한 에스프레소에 우유를 넣어 풍부한 커피향을 부드럽게 즐실 수 있습니다. - 5200원

음식: 커피와 함께 즐길 음식을 선택해주세요
--------------------
크로크무슈: 햄과 치즈를 차곡차곡 쌓아 올려 만든 프랑스식 샌드위치로 사르르 녹아내린 모짜렐라 치즈가 입안 가득 풍성한 맛 - 4500원
페스토 햄 파니니: 터키햄과 모짜렐라치즈를 두둑하게 채운 대중적인 파니니 - 6500원

 

출력결과입니다.

 

 

마치며

복합 패턴은 트리 구조의 객체 구성을 다룰 때 매우 유용한 패턴입니다. 특히 전체-부분 관계를 표현해야 하는 상황에서 강력한 도구가 될 수 있습니다. 하지만 설계가 지나치게 일반화될 수 있다는 점을 주의해야 하며, 실제 요구사항에 맞게 적절히 제약을 추가하는 것이 중요합니다.

Stream API 개요

Java Stream API는 데이터 처리를 위한 강력한 도구이지만, 잘못 사용하면 오히려 성능이 저하될 수 있습니다. 이번 포스트에서는 Stream API의 효율적인 사용법과 병렬 스트림 활용 방법에 대해 알아보겠습니다.

 

 

 

병렬 스트림(Parallel Stream)

 

기본 사용법

// 순차 스트림
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
                .mapToInt(n -> n)
                .sum();

// 병렬 스트림
int parallelSum = numbers.parallelStream()
                        .mapToInt(n -> n)
                        .sum();

// 기존 스트림을 병렬로 변환
int anotherSum = numbers.stream()
                       .parallel()
                       .mapToInt(n -> n)
                       .sum();

 

병렬 스트림이 효과적인 경우

// 1. 데이터가 많은 경우
List<Integer> largeList = new ArrayList<>(1000000);
// 리스트 초기화...

long count = largeList.parallelStream()
                     .filter(n -> n % 2 == 0)
                     .count();

// 2. 독립적인 연산이 많은 경우
double average = largeList.parallelStream()
                         .mapToDouble(this::complexCalculation)
                         .average()
                         .orElse(0.0);

private double complexCalculation(int number) {
    // CPU 집약적인 계산
    return Math.sqrt(Math.pow(number, 2));
}

 

 

 

성능 최적화 전략

 

적절한 데이터 구조 선택

// ArrayList - 병렬 처리에 좋음
List<Integer> arrayList = new ArrayList<>();
arrayList.parallelStream()...

// LinkedList - 병렬 처리에 비효율적
List<Integer> linkedList = new LinkedList<>();
linkedList.stream()...  // 순차 처리 권장

// Array - 가장 효율적
int[] array = {1, 2, 3, 4, 5};
Arrays.stream(array).parallel()...

 

 

Unboxing 오버헤드 방지

// 비효율적인 방법 (boxing/unboxing 발생)
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
                .mapToInt(n -> n)  // unboxing
                .sum();

// 효율적인 방법
int[] primitiveNumbers = {1, 2, 3, 4, 5};
int sum = Arrays.stream(primitiveNumbers)
               .sum();

 

 

적절한 중간 연산 사용

// 비효율적인 방법
List<String> result = strings.stream()
                           .filter(s -> s.length() > 3)
                           .sorted()  // 전체 정렬 후 필터링
                           .limit(5)
                           .collect(Collectors.toList());

// 효율적인 방법
List<String> betterResult = strings.stream()
                                 .filter(s -> s.length() > 3)
                                 .limit(5)  // 먼저 개수 제한
                                 .sorted()  // 필요한 요소만 정렬
                                 .collect(Collectors.toList());

 

 

병렬 스트림 주의사항

 

상태 공유 피하기

// 잘못된 예 - 상태 공유로 인한 문제
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> result = new ArrayList<>();

numbers.parallelStream()
       .map(n -> n * 2)
       .forEach(result::add);  // 동시성 문제 발생

// 올바른 예
List<Integer> safeResult = numbers.parallelStream()
                                .map(n -> n * 2)
                                .collect(Collectors.toList());

 

 

순서 의존성 주의

// 순서에 의존적인 작업 - 병렬 처리 부적합
String result = strings.parallelStream()
                      .reduce("", (a, b) -> a + b);  // 순서 보장 안됨

// 올바른 방법
String betterResult = String.join("", strings);  // 더 효율적

 

 

주의점

  1. 데이터 크기가 작은 경우 순차 처리가 더 효율적
  2. 공유 상태 수정은 피하기
  3. 올바른 데이터 구조 선택
  4. 순서 의존성 고려
  5. 성능 측정 후 판단

 

마치며

Stream API의 효율적인 사용과 병렬 스트림의 적절한 활용은 애플리케이션의 성능을 크게 향상시킬 수 있습니다. 하지만 무조건적인 병렬 처리가 아닌, 상황에 맞는 적절한 선택이 중요합니다.

StringTokenizer

StringTokenizer는 문자열을 지정된 구분자를 기준으로 토큰화하는 Java의 유틸리티 클래스입니다.

 

        String str = "a;b;c;d;e";

        StringTokenizer st = new StringTokenizer(str, ";");
        while (st.hasMoreTokens()) {
            System.out.print(st.nextToken()); // abcde
        }
        
        StringTokenizer st = new StringTokenizer(str, "abc");
        while (st.hasMoreTokens()) {
            System.out.print(st.nextToken()); // de
        }

가령 "a;b;c;d;e" 라는 문자열에서 ";" 문자열로 나누어 토큰화 하고싶다면 위 처럼 사용하는 것입니다.

위와 같은 역할을 수행하는게 split() 메소드인데요 split() 메소는 정규식을 사용하기때문에 활용성이 높고 정규식을 잘 이해하고있다면 더욱 편한게 사실입니다.

그러면 이 StringTokenizer를 왜 사용했을까요? split() 메소드는 내부에서 정규식을 사용하기때문에 StringTokenizer보다 속도가 4배정도 느리다고 합니다. 그래서 속도가 중요한 부분에서 이 StringTokenizer를 사용했었죠. 하지만 현재는 사용하지않는것을 권장하고있습니다. 이 글에서 이 부분에 대해서 알아보겠습니다.

 

 

 

 

Document

JAVA 5 StringTokenizer

JAVA 6 StringTokenizer

JAVA 7 StringTokenizer

JAVA 8 StringTokenizer

JAVA 9 StringTokenizer

JAVA 10 StringTokenizer

JAVA 11 StringTokenizer

JAVA 21 StringTokenizer

 

StringTokenizer is a legacy class that is retained for compatibility reasons although its use is discouraged in new code. It is recommended that anyone seeking this functionality use the split method of String or the java.util.regex package instead.

StringTokenizer는 호환성을 이유로 유지되는 레거시 클래스이지만 새 코드에서는 사용이 권장되지 않습니다. 이 기능을 원하는 사람은 대신 String의 split 메서드나 java.util.regex 패키지를 사용하는 것이 좋습니다.

 

문서에는 StringTokenizer는 새 코드에서는 사용하는것을 권장하지 않는다고 작성되어있습니다.

 

StringTokenizer의 주석에도 권장하지 않는다고 하는데 왜 그런지 간단하게 알아보겠습니다.

 

 

 

 

권장하지 않는 이유

 

유연성의 문제

StringTokenizer는 매우 단순한 방식으로 문자열을 분리합니다. 기본적으로 정규 표현식을 지원하지 않기 때문에 복잡한 문자열 처리에는 한계가 있습니다.  split() 메소드나 java.util.regex 패키지를 사용하면 정규표현식을 지원하여 복잡한 구분자를 처리하여 더 유연하고 안정적인 방식으로 문자열을 분리할 수 있습니다. 그러니 StringTokenizer를 사용할 이유가 없어지고 있는 것이지요.

 

 

클래스 개선문제

StringTokenizer는 Java 초기에 도입된 클래스입니다. 이후 자바가 발전하면서 더 나은 기능을 제공하는 클래스들이 도입되면서 StringTokenizer의 사용이유가 더욱 줄어들었습니다. 따라서 새로운 기능이 추가되거나 개선되지 않습니다.

 

제한된 기능

정규표현식을 지원하지않고, 복합한 문자열 처리에 한계가있습니다. 또한 반환 타입이 String[] 같은 배열이 아닙니다.

 

 

 

 

결론

StringTokenizer의 장점도 존재합니다. 정규표현식이 없기때문에 상대적으로 처리속도가 빠릅니다. (split의 약 4배)

그리고 간단한 문자열을 처리하기에는 StringTokenizer를 사용하는것이 더 적합할 수 있습니다.

 

결론은 더 이상 StringTokenizer를 사용하지 말고 split 메소드를 사용하는게 현대적인 방법이라고 생각합니다.

 

개요

오늘은 Java 14부터 도입된 Record와 Java 15에서 preview로 시작된 Sealed Classes 에 대해서 알아보겠습니다.
참고로 Sealed Classes 는 Java 17부터 정식적으로 확정된 것같습니다.

 

JDK 17 Release Notes, Important Changes, and Information

These notes describe important changes, enhancements, removed APIs and features, deprecated APIs and features, and other information about JDK 17 and Java SE 17. In some cases, the descriptions provide links to additional detailed information about an issu

www.oracle.com

 
 

Record

Record는 불변(immutable) 데이터 객체를 생성하기 위한 새로운 유형의 클래스입니다. 주로 데이터를 운반하는 목적으로 사용되며, 기존의 많은 코드를 줄여줍니다.
 

Record 적용 전

public class Student {

    private final String name;
    private final int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Student student = (Student) o;
        return age == student.age && Objects.equals(name, student.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }

    @Override
    public String toString() {
        return "Student[name=" + name + ", age=" + age + "]";
    }

}

 

Record 적용 후

public record Student(String name, int age) { }

 

  • 모든 필드가 private final로 선언됩니다
  • 자동으로 equals(), hashCode(), toString() 메서드가 생성됩니다
  • 생성자와 접근자(getter)가 자동으로 생성됩니다
  • 상속이 불가능합니다

IntelliJ에서 record 를 추천하기도합니다.

 

    public static void main(String[] args) {
        Student student = new Student("철수", 20);

        System.out.println("student = " + student);
        System.out.println("student.name() = " + student.name());
        System.out.println("student.age() = " + student.age());
        
        // student = Student[name=철수, age=20]
        // student.name() = 철수
        // student.age() = 20
    }

 
 
 
 

Sealed Classes

Sealed Classes는 상속을 제한하는 새로운 방법을 제공합니다. 클래스나 인터페이스가 어떤 클래스에 의해 상속(extends)/구현(implements) 될 수 있는지 명시적으로 선언할 수 있습니다.
 

public sealed class Shape permits Circle, Rectangle, Square {
    // 공통 속성 및 메서드
}

public final class Circle extends Shape {
    private final double radius;
    // Circle 구현
}

public final class Rectangle extends Shape {
    private final double width;
    private final double height;
    // Rectangle 구현
}

public final class Square extends Shape {
    private final double side;
    // Square 구현
}

 
permits 한 클래스에서 sealed class를 구현하지 않거나, permits 에 명시되지않은 클래스에서 sealed 클래스를 구현하려고하면 에러가 발생합니다.
 

public sealed class Shape permits Circle, Rectangle, Square { // 에러!!
    // 공통 속성 및 메서드
}

public class Circle {
    private final double radius;
    // Circle 구현
}
public sealed class Shape permits Circle, Rectangle, Square {
    // 공통 속성 및 메서드
}

public final class Triangle extends Shape { // 에러!!

}

 

  • 상속/구현하는 클래스는 final, non-sealed, sealed 중 하나를 선언해야 합니다.
  • sealed class와 permit 된 subclass는 한 패키지 내에 존재해야 합니다.
  •  
키워드 설명
final 더 이상의 상속을 금지
sealed 제한된 상속
non-sealed 상속 허용

 

final

public final class Circle extends Shape {
    // Circle 구현
}

 

sealed

public sealed class Rectangle extends Shape permits ColoredRectangle {
    // Rectangle 구현
}

 

non-sealed

public non-sealed class Square extends Shape {
    // Square 구현
}

 
 

Sealed Classes 를 써야하는 이유가 있을까?

먼저 Sealed Classes를 도입하면 좋은 점을 알아보겠습니다.
 

타입 안정성 강화

  • 상속 가능한 클래스를 명확히 제한해서 타입 안정성을 높힙니다.
  • 컴파일 시점에서 가능한 모든 하위 타입을 알 수 있습니다.
  • switch 문법에서 하위 클래스가 모두 존재한다는 것을 컴파일러가 보장합니다. (switch JAVA 21 문법)

도메인 모델링 개선

  • 도메인 모델의 제약조건을 코드로 표현할 수 있습니다
  • 특정 타입이 가질 수 있는 모든 변형을 명시적으로 정의할 수 있습니다

 
sealed로 온라인 결제 시스템에서 지원하는 결제 수단을 예시로 만들어보겠습니다.

public sealed interface PaymentMethod permits 
    CreditCard, 
    BankTransfer, 
    DigitalWallet {
    
    boolean process(int amount);
    String getPaymentInfo();
}

public final class CreditCard implements PaymentMethod {
    private final String cardNumber;
    private final String expiryDate;
    private final String cvv;

    @Override
    public boolean process(int amount) {
        // 신용카드 결제 처리 로직
        return true;
    }

    @Override
    public String getPaymentInfo() {
        return "Card: " + cardNumber.substring(12);
    }
}

public final class BankTransfer implements PaymentMethod {
    private final String accountNumber;
    private final String bankCode;

    @Override
    public boolean process(int amount) {
        // 계좌이체 처리 로직
        return true;
    }

    @Override
    public String getPaymentInfo() {
        return "Bank: " + bankCode + "-" + accountNumber;
    }
}

public final class DigitalWallet implements PaymentMethod {
    private final String walletId;
    
    @Override
    public boolean process(int amount) {
        // 디지털 월렛 결제 처리 로직
        return true;
    }

    @Override
    public String getPaymentInfo() {
        return "Wallet: " + walletId;
    }
}

// 결제 처리 서비스
public class PaymentService {
    public String processPayment(PaymentMethod method, int amount) {
    	// JDK 21 switch 문법
        return switch (method) {
            case CreditCard card -> 
                "신용카드 결제: " + card.getPaymentInfo();
            case BankTransfer transfer -> 
                "계좌이체: " + transfer.getPaymentInfo();
            case DigitalWallet wallet -> 
                "디지털 월렛: " + wallet.getPaymentInfo();
        }; // 모든 결제 수단이 처리됨을 컴파일러가 보장
    }
}

 

  • 지원하는 결제 수단을 명확하게 제한할 수 있습니다.
  • 새로운 결제 수단 추가 시 컴파일러가 관련된 모든 코드를 체크합니다.
  • switch 문에서 모든 케이스를 처리했는지 컴파일 시점에 확인할 수 있습니다.

 

개요

오늘은 객체 지향 프로그래밍(OOP)의 핵심 개념인 상속(Inheritance)과 복합(Composition)에 대해 설명해드리겠습니다. 이 두 개념은 코드 재사용과 유지보수성을 높이는 중요한 설계 방식입니다. 예제와 함께 특징을 살펴보며 공부해도록 하겠습니다.

 

 

 

상속

상속은 OOP에서 가장 기본개념중에 하나입니다. 부모 - 자식 클래스로 정의하여 자식클래스에서 부모 클래스의 메소드를 override, 재사용 할 수 있다는 특징이 있습니다.

 

@AllArgsConstructor
public class Car {

    private final String brand;
    private final String model;
    private final int year;

    public void start() {
        System.out.println("차량이 시동됩니다.");
    }

    public void stop() {
        System.out.println("차량이 정지합니다.");
    }

}

 

public class ElectricCar extends Car {

    private int batteryCapacity;
    private int batteryLevel;

    public ElectricCar(String brand, String model, int year, int batteryCapacity) {
        super(brand, model, year);
        this.batteryCapacity = batteryCapacity;
        this.batteryLevel = 100;
    }

    @Override
    public void start() {
        System.out.println("전기차가 조용히 시동됩니다.");
    }

    public void charge() {
        System.out.println("배터리를 충전합니다.");
        this.batteryLevel = 100;
    }
    
}

 

ElectricCar 클래스는 Car 클래스를 상속받고있습니다. 따라서 Car와 ElectricCar는 부모-자식 관계입니다.

ElectricCar는 Car의 기능을 재사용하거나 새로운 기능을 손쉽게 추가할 수 있고, 때에 따라서 Override 할 수 있습니다. 그리고 Car 에서 변경된 사항은 ElectricCar에도 그대로 반영됩니다.

 

 

 

복합

@AllArgsConstructor
public class Engine {

    private final String type;
    private final int hp;

    public void start() {
        System.out.println(type + " 엔진이 가동됩니다.");
    }

}

 

@AllArgsConstructor
public class GasCar {

    private final Car car; // 복합관계
    private final Engine engine; // 복합관계

    public void start() {
        car.start();
        engine.start();
    }
}

 

Engine 클래스와 GasCar 클래스를 구현했습니다. GasCar는 Car와 Engine을 필드에서 참조하고있습니다.

이처럼 GasCar가 Car를 필드로 가지는 것이 복합관계라고합니다. GasCar를 통해 Car 메소드를 호출하는 방식이죠.

 

 

상속의 장단점

장점

  1. 코드 재사용성
    • 부모 클래스의 기능을 그대로 물려받아 사용할 수 있어 코드 중복을 줄일 수 있습니다.
    • 공통 기능을 부모 클래스에 구현하면 모든 자식 클래스가 활용할 수 있습니다.
  2. 간단한 확장성
    • 기존 클래스를 수정하지 않고도 새로운 기능을 추가할 수 있습니다.
  3. 다형성 구현
    • 부모 타입으로 자식 객체를 참조할 수 있어 유연한 프로그래밍이 가능합니다.
Car car = new ElectricCar("Tesla", "Model 3", 2024, 75);

 

단점

  1. 강한 결합도
    • 부모 클래스의 변경이 모든 자식 클래스에 영향을 미칩니다.
    • 부모 클래스의 내부 구현을 자식 클래스가 알아야 할 수 있습니다.
  2. 취약한 기반 클래스 문제
    • 부모 클래스의 변경이 예상치 못한 자식 클래스의 동작 변경을 일으킬 수 있습니다.
  3. 단일 상속 제한
    • Java는 다중 상속을 지원하지 않아 한 클래스만 상속할 수 있습니다.

 

복합의 장단점

장점

 

  1. 유연한 설계
    • 런타임에 구성요소를 변경할 수 있습니다.
    • 다중 복합이 가능합니다.
  2. 캡슐화 향상
    • 내부 구성요소의 구현을 완전히 숨길 수 있습니다.
    • 인터페이스를 통한 통신이 가능합니다.
  3. 느슨한 결합도
    • 각 클래스가 독립적으로 동작하여 유지보수가 용이합니다.
// Engine을 쉽게 교체할 수 있음
public void updateEngine(Engine newEngine) {
    this.engine = newEngine;
}

 

 

 

단점

  1. 코드 가독성
    • 복잡한 구성의 경우 코드 추적이 어려울 수 있습니다.
  2. 구현의 복잡성
    • 여러 클래스를 조합하여 사용하므로 초기 설계가 더 복잡할 수 있습니다.
// 복합 관계에서는 위임 메서드를 많이 작성해야 할 수 있음
public class GasCar {
    private Car car;
    private Engine engine;
    
    public void start() { car.start(); }
    public void stop() { car.stop(); }
    public void accelerate() { engine.accelerate(); }
    // ... 더 많은 위임 메서드들
}

 

 

결론

상속과 복합에 대한 글을 읽다보면 'IS-A' 일때는 상속, 'HAS-A' 일때는 복합을 사용하라는 글이 많습니다.

'전기차는 차다' -> 상속,

'차는 엔진을 갖고있다' -> 복합,

이렇게 이해하면 조금더 와닿을것 같습니다.

 

최근의 객체지향 설계에서는 "상속보다는 복합을 사용하라"는 원칙이 널리 받아들여지고 있습니다. 복합이 더 유연하고 안전한 설계를 제공하기 때문입니다. 하지만 각각의 상황에 맞는 적절한 선택이 중요합니다.

 

마지막으로 위의 예제를 상속과 복합관계를 이용하여 CasCar를 조금 더 깔끔하게 바꿔보면서 글을 마치겠습니다.

 

@AllArgsConstructor
public class Car {

    private final String brand;
    private final String model;
    private final int year;
    private final Engine engine;

    public void start() {
        System.out.println("차량이 시동됩니다.");
        engine.start();
    }

    public void stop() {
        System.out.println("차량이 정지합니다.");
    }

}

public class GasCar extends Car {

    private final int fuelCapacity;
    private double currentFuel;

    public GasCar(String brand, String model, int year, Engine engine, int fuelCapacity) {
        super(brand, model, year, engine);
        this.fuelCapacity = fuelCapacity;
        this.currentFuel = fuelCapacity;
    }

    public void refuel(double amount) {
        currentFuel = Math.min(currentFuel + amount, fuelCapacity);
    }

}

 

차는 엔진을 가지고있다 -> private final Engine engine

가솔린차는 차이다 -> GasCar extends Vehicle

 

퍼사드 패턴

퍼사드 패턴은 복잡한 클래스들을 편하게 사용하기위해 인터페이스를 구성하는 구조 패턴입니다.

 

 

 

예제

IOT 를 조작하는 컨트롤러를 만든다고 가정해보겠습니다.

컨트롤러는 일어날때 커튼을 열고, 전등을 키고, 스피커에서 음악이 재생되도록 하고싶고,

집을 떠날때는 전등을 끄고, 스키퍼에서 음악이 멈추도록 하고싶습니다.

 

public class Remote {

    private final Lights lights;
    private final Curtains curtains;
    private final Speaker speaker;

    public Remote(Lights lights, Curtains curtains, Speaker speaker) {
        this.lights = lights;
        this.curtains = curtains;
        this.speaker = speaker;
    }

    public void wakeUp() {
        lights.on();
        curtains.open();
        speaker.musicStart();
    }

    public void leaveHome() {
        lights.off();
        speaker.musicStop();
    }
    
}
public class Lights {

    public void on() { ... }
    public void off() { ... }
    
}

public class Curtains {

    public void open() { ... }
    public void close() { ... }
    
}

public class Speaker {

    public void musicStart() { ... }
    public void musicStop() { ... }
    
}

 

이렇게 작성한다면 Remote를 사용하는 사용자는 내부 클래스의 구현에 대해서 알 필요가 없어지고, 내부 클래스의 수정이 일어나도 Remote 코드만 수정하면 되므로 유연성이 향상됩니다.

 

 

이 퍼사드 패턴은 디자인패턴에 대해서 알지 못하더라도 그냥 자연스럽게 만들어지는 패턴같습니다. 쉽고 유용한 패턴이니 잘 적용해보시기 바랍니다.

데코레이터 패턴

데코레이터 패턴은 객체를 동적으로 기능을 확장하거나 추가할 수 있는 패턴입니다. 특징으로는 객체를 감싸는 방식으로 기능을 추가하는건데요. 말로 설명하는 것보다 예제를 통해 배워보도록 하겠습니다.

 

 

 

예제

커피를 예로 들어보겠습니다.

커피의 종류는 아메리카노, 카페라떼, 등등이 있습니다.

이 커피들에 설탕이나 샷을 추가할 수 있다고 했을때, 어떻게 구현해야 할까요?

 

커피와 추가메뉴를 나누고 거기에서 커피객체에 메뉴를 추가하는 식으로 구현할 수 있을것입니다. 그러나 오늘은 데코레이터 패턴을 사용해상속을 통한 확장이 아닌 객체를 감싸는 방식으로 확장을 해보겠습니다.

 

전체적인 구조는 다음과 같습니다.

 

 

 

Coffee

public interface Coffee {

    String getDescription();
    int getCost();
    
}
public class Americano implements Coffee {

    @Override
    public String getDescription() {
        return "아메리카노";
    }

    @Override
    public int getCost() {
        return 5000;
    }
}

 

Coffee에는 메뉴의 이름을 반환하는 getDescription가 가격을 반환하는 getCost()가 있습니다.

Coffee를 상속받은 Americano를 구현했습니다.

 

 

 

CoffeeDecorator

public abstract class CoffeeDecorator implements Coffee {

    private final Coffee coffee;

    public CoffeeDecorator(Coffee coffee) {
        this.coffee = coffee;
    }

    @Override
    public String getDescription() {
        return coffee.getDescription();
    }

    @Override
    public int getCost() {
        return coffee.getCost();
    }

}

 

public class ShotDecorator extends CoffeeDecorator {

    public ShotDecorator(Coffee coffee) {
        super(coffee);
    }

    @Override
    public String getDescription() {
        return super.getDescription() + ", 샷 추가";
    }

    @Override
    public int getCost() {
        return super.getCost() + 500;
    }

}

public class SugarDecorator extends CoffeeDecorator {
    
    public SugarDecorator(Coffee coffee) {
        super(coffee);
    }

    @Override
    public String getDescription() {
        return super.getDescription() + ", 설탕 추가";
    }

    @Override
    public int getCost() {
        return super.getCost() + 300;
    }

}

 

CoffeeDecorator를 상속받아 커피에 추가할 수 있는 옵션인 설탕과 샷 객체를 구현했습니다.

CoffeeDecorator의 구현객체를 보면 멤버변수에 Coffee를 가지고있습니다. 추가옵션은 Coffee가 반드시 필요하기 때문입니다.

 

실행결과

    public static void main(String[] args) {
        Coffee coffee = new SugarDecorator(new ShotDecorator(new Americano()));

        System.out.println(coffee.getDescription());
        System.out.println(coffee.getCost());

        // 아메리카노, 샷 추가, 설탕 추가
        // 5800
    }

 

coffee 변수안에 Decorator를 사용해서 추가옵션을 넣어 줄 수 있었습니다.

이렇게 하면 기존코드를 수정하거나 변경없이도 추가 확장할 수 있습니다.

 

여기서 살짝 아쉬운부분은 CoffeeDecorator에서 Coffee를 이미 Override를 했기때문에 Decorator를 구현할때 Override를 강제하기 않는다는것이었는데요. 그래서 강제할 수 있도록 약간 더 수정해보았습니다.

 

public abstract class CoffeeDecorator implements Coffee {

    private final Coffee coffee;

    public CoffeeDecorator(Coffee coffee) {
        this.coffee = coffee;
    }

    @Override
    public String getDescription() {
        return coffee.getDescription() + getExtra();
    }

    @Override
    public int getCost() {
        return coffee.getCost() + getExtraCost();
    }

    abstract String getExtra();
    abstract int getExtraCost();
    
}

 

public class ShotDecorator extends CoffeeDecorator {

    public ShotDecorator(Coffee coffee) {
        super(coffee);
    }

    @Override
    String getExtra() {
        return ", 샷 추가";
    }

    @Override
    int getExtraCost() {
        return 500;
    }
}

public class SugarDecorator extends CoffeeDecorator {
    
    public SugarDecorator(Coffee coffee) {
        super(coffee);
    }

    @Override
    String getExtra() {
        return ", 설탕 추가";
    }

    @Override
    int getExtraCost() {
        return 300;
    }
}

 

CoffeeDecorator에서 getExtra()와 getExtraCost()를 추상화하여 상속받은 클래스에게 구현을 강제하게 했습니다.

이렇게 하니까 더 코드가 깔끔해진것 같습니다.

 

이 데코레이터 패턴은 유연하게 확장이 가능하고, 책임을 분리하고, DIP를 준수하는 등 좋은 장점들이 많으나 코드 복잡성이 증가하고, 순서에 의존하는 단점도 존재합니다.

+ Recent posts