1. 코틀린에서 변수를 다루는 방법

1. 변수 선언 키워드 - var과 val의 차이점

// Java
long number1 = 10L;
final long number2 = 10L;
// Kotlin
var number1 = 10L // 가변
val number2: Long = 10L // 불변 - Java의 final 변수와 같다.

 

Kotlin은 number1처럼 타입을 명시해주지 않아도 컴파일러가 추론해주기 때문에 의무적으로 타입을 작성해주지 않아도 된다. 단, 값을 할당하지 않았을 때는(아직 초기화하지 않았을 때) 타입을 선언해주어야 컴파일 에러가 나지 않는다.

타입을 작성하고 싶다면 number2처럼 변수명 뒤에 콜론(:) 뒤에 타입을 작성해주면 된다. 

var과 val 중 어떤 것을 사용해야할지 헷갈리때는 모든 변수는 우선 val로 만들고 필요한 경우에만 var로 변경하자

2. Kotlin에서의 Primitive Type

// Java
long number1 = 10L;
Long number3 = 1_000L;
// Kotlin
var number1 = 10L
var number3 = 1_000L

 

Kotlin에서 타입은 Reference 타입으로 표시되지만 연산이 수행될때는 Kotlin이 알아서 내부적으로 Primitive 타입으로 바꿔서 불필요한 객체 생성이 일어나지 않도록 한다.

즉, 프로그래머가 boxing과 unboxing을 고려하지 않도록 Kotlin이 자동으로 처리해준다.

3. Kotlin에서의 nullable 변수

var number3: Long? = 1_000L
number3 = null

 

Java는 Reference 타입의 경우 null을 허용하지만 Kotlin은 기본적으로 null을 허용하지 않는다. 만약 null을 허용하고 싶으면 위와 같이 타입 뒤에 물음표(?)를 작성하여 null을 허용한다고 선언해야한다.

4. Kotlin에서의 객체 인스턴스화

// Java
Person person = new Person("key");
// Kotlin
var person = Person("key")

 

Kotlin에서는 객체를 인스턴스화 할때 new를 붙이지 않는다.

2. 코틀린에서 null을 다루는 방법

1. Kotlin에서의 null 체크

// Java에서의 null 체크
public boolean startsWithA1(String str) {
    if (str == null) {
        throw new IllegalArgumentException("null이 들어왔습니다");
    }
    
    return str.startsWith("A");
}

public Boolean startsWithA2(String str) {
    if (str == null) {
        return null;
    }
    
    return str.startsWith("A");
}

public boolean startsWithA3(String str) {
    if (str == null) {
        return false;
    }
    
    return str.startsWith("A");
}
// Kotlin에서의 null 체크
fun startsWithA1(str: String?): Boolean {
    if (str == null) {
        throw IllegalArgumentException("null이 들어왔습니다")
    }
    return str.startsWIth("A")
}

fun startsWithA2(str: String?): Boolean? {
    if (str == null) {
        return null
    }
    return str.startsWIth("A")
}

fun startsWithA3(str: String?): Boolean {
    if (str == null) {
        return false
    }
    return str.startsWIth("A")
}

 

null이 들어올 수 있는 파라미터에는 타입 뒤에 물음표(?)를 붙여주고 startsWithA2 메서드처럼 return 타입으로 명시된 Boolean 값이 아닌 null 값이 반환될수도 있는 경우 return 타입 뒤에 물음표(?)를 붙여준다.

코틀린에서 null이 들어갈 수 있는 타입은 완전히 다르게 간주되며 한번 null 검사를 하면 이후부터는 컴파일러가 non-null임을 알 수 있다.

2. Safe Call과 Elvis 연산자

fun main() {
    // Safe Call(?)
    val str1: String? = "ABC"
    str1.length // 불가능
    str1?.length // 가능
    
    // Elvis 연산자(?:)
    val str2: String? = "ABC"
    str2?.length ?: 0
}

 

  • Safe Call(번수명?) : null이 아니면 실행하고 null이면 실행하지 않는다. (null을 리턴)
  • Elvis 연산자(?:) : 앞의 연산 결과가 null이면 뒤의 값을 사용
// Safe Call과 Elvis 연산자가 적용된 코드
fun startsWithA1(str: String?): Boolean {
    return str?.startsWith("A") 
        ?: throw IllegalArgumentException("null이 들어왔습니다")
}

fun startsWithA2(str: String?): Boolean? {
    return str?.startsWith("A")
}

fun startsWithA3(str: String?): Boolean {
    return str?.startsWith("A") ?: false
}

 

Elvis 연산은 early return에서도 사용할 수 있다.

 

public long calculate(Long number) {
    if (number == null) {
        return 0;
    }
    
    ...
}
fun calculate(number: Long?): Long {
    number ?: return 0
    
    ...
}

 

number 변수가 null인 경우 0을 return, null이 아니면 아래 비즈니스 로직을 수행

3. 널 아님 단언!!

fun startsWith(str: String?): Boolean {
    return str!!.startsWith("A")
}

 

nullable 타입이지만 절대 null이 될 수 없는 경우 "!!"로 단언할 수 있다.

"!!" 선언했음에도 불구하고 null이 들어오게 되면 런타임에서 NullPointerException 에러가 발생한다.

3. 코틀린에서 Type을 다루는 방법

1. 기본 타입

val number1 = 3    // Int
val number2 = 3L   // Long
val number3 = 3.0f // Float
val number3 = 3.0  // Double

 

코틀린에서는 선언된 기본값을 보고 타입을 추론한다.

 

val number1 = 4
val number2: Long = number1 // Type mismatch 컴파일 에러
val number2: Long = number1.toLong() // 명시적으로 타입 변환해줌

 

Java에서 기본 타입간의 변환은 암시적으로 이루어 지지만 Kotlin에서는 명시적으로 이루어져야 한다.

2. 타입 캐스팅

// Java 타입 캐스팅
public static void printAgeIfPerson(Object obj) {
    if (obj instanceof Person) {
        Person person = (Person) obj;
        ...
    }
}
fun printAgeIfPerson(obj: Any) {
    if (obj is Person) {
        val person = obj as Person
        ...
    }
}

 

Java에서는 괄호()안에 타입을 명시하여 타입 캐스팅을 해줘야 했지만 Kotlin에서는 as라는 키워드를 사용하여 타입 캐스팅을 한다.

Kotlin은 또한 스마트 캐스트라는 기능을 제공하는데 if문으로 타입체크를 해주었기 때문에 굳이 Person 객체로 타입 캐스팅을 하지 않아도 Kotlin이 Person 객체로 인식한다.

 

만약 null 객체가 넘어오는 경우에는 as 뒤에 물음표(?)를 붙여서 null일 수도 있다는 것을 명시해주어야 하고 Person 객체의 메서드를 호출할때는 person 변수명 뒤에 ?를 붙여서 Safe Call로 호출해야 한다.

3. Kotlin의 3가지 특이한 타입(Any, Unit, Nothing)

Any

  • Java의 Object 역할 -> 모든 객체의 최상위 타입
  • 모든 Primitive Type의 최상의 타입도 Any
  • Any 자체로는 null을 포함할 수 없다. 만약 null을 포함하고 싶다면 Any?로 표현
  • Any에 equals / hashCode / toString 존재

Unit

  • Unit은 Java의 void와 동일한 역할
  • void와 다르게 Unit은 그 자체로 타입 인자로 사용 가능 (추가 학습 필요)
  • 함수형 프로그래밍에서 Unit은 단 하나의 인스턴스만 갖는 타입을 의미한다. 즉, 코틀린의 Unit은 실제 존재하는 타입이라는 것을 표현

Nothing

  • Nothing은 함수가 정상적으로 끝나지 않았다는 사실을 표현
  • 무조건 예외를 반하는 함수 / 무한 루프 함수 등등
// Nothing 예시
fun fail(message: String): Nothing {
    throw IllegalArgumentException(message)
}

4. String Interpolation, String indexing

// Java
Person person = new Person("key", 30);
String log = String.format("사람의 이름은 %s이고 나이는 %s세 입니다", person.getName(), person.getAge());

String str = "ABCDE";
char ch = str.charAt(0); // A
// Kotlin
val person = new Person("key", 30);
val log = "사람의 이름은 ${person.name}이고 나이는 ${person.age}세 입니다"

val str = "ABCDE"
val ch = str[0] // A

 

문자열에 인덱스로 접근할 때 Java는 charAt() 메서드를 사용하지만 Kotlin은 배열처럼 대괄호[]를 사용해 접근할 수 있다.

4. 코틀린에서 연산자를 다루는 방법

1. 단항 연산자 / 산술 연산자

  • 단항 연산자(++, --), 산술 연산자(+, -, *, /, %), 산술대입 연산자(+=, -=, *=, /=, %=) 모두 Java와 동일하다.

2. 비교 연산자와 동등성, 동일성

  • 비교 연산자(>, <, >=, <=) 모두 Java와 동일하지만 객체 간의 관계를 비교할 때는 자동으로 compareTo를 호출해준다.
  • Java에서는 동일성을 비교에는 == 를 사용하고 동등성을 비교할 때는 equals()를 호출했지만 Kotlin에서는 동일성에 ===을 사용하고 동등성에 ==를 사용한다.

3. 논리 연산자 / 코틀린에 있는 특이한 연산자

  • 논리 연산자(&&, ||, !) 모두 Java와 동일하며 Java와 마찬가지로 Lazy 연산을 수행한다.
  • in / !in : 컬렉션이나 범위에 포함되어 있다, 포함되어 있지 않다.
  • a..b : a부터 b까지의 범위 객체를 생성한다.
  • a[i] : a에서 특정 Index i로 값을 가져온다.
  • a[i] = b : a의 특정 Index i에 b를 넣는다.

4. 연산자 오버로딩

  • Kotlin에서는 객체마다 연산자를 직접 정의할 수 있다.
data class Money (
    val amount: Long
) {
    operator fun plus(other: Money): Money {
        return Money(this.amount + other.amount)
    }
}

 

즉, 위와 같은 Kotlin 클래스가 정의되어 있을 때 plus 메서드를 호출해도 되지만 +연산자로 대체할 수 있다.

 

fun main() {
    val money1 = Money(1_000L)
    val money2 = Money(2_000L)
    println(money1.plus(money2)) // Money(amount=3000)
    println(money1 + money2) // Money(amount=3000)
}

 

 

들어가기 전에

사이드 프로젝트로 진행했던 프로젝트에서 사용자 권한이 USER, COMPANY, INSTRUCTOR, ADMIN으로 나누어져 있었고 각 권한마다 접근이 가능한 API가 달라 권한 체크 로직이 필요한 상황이었다. 

 

프로젝트 아키텍처는 MSA 구조였으며, Spring Cloud Gateway에서 JWT 토큰으로부터 사용자 PK와 권한 정보를 추출하여 각 마이크로서비스로 라우팅하고 있었다. 따라서, 해당 권한 정보를 기반으로 API 접근을 제한할 수 있을 것이라고 판단했다.

 

Spring MVC 레벨에서 동작하며 컨트롤러 진입 전후에 특정 로직을 처리하는 Interceptor와 비즈니스 로직과 서브로직을 분리하여 횡단 관심사를 처리하는 AOP 중 고민했지만 특정 URI 경로에 대해 권한을 검사하는 Interceptor와 달리 커스텀 어노테이션으로 쉽게 권한을 체크할 API를 지정할 수 있는 AOP를 사용하기로 했다.

 

구현하기

RoleAuthorization

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RoleAuthorization {
    String[] roles();
}

AOP 포인트 컷으로 사용할 커스텀 어노테이션이다. 해당 어노테이션이 붙은 메소드에서는 권한을 체크해야 한다.

  • @Retention : 런타임 시까지 해당 어노테이션이 유지된다는 것을 의미, 어플리케이션이 실행 중에도 이 어노테이션 정보를 참조할 수 있다.
  • @Target : 해당 어노테이션이 메소드에만 적용될 수 있음을 의미한다.
  • String[] roles(): 어노테이션을 선언할 때 접근가능한 권한을 선언하기 위한 변수

RoleAuthorizationAop

@Aspect
@Component
public class RoleAuthorizationAop {
    @Pointcut("@annotation(com.example.companyservice.common.security.RoleAuthorization)")
    public void roleAuthorizationPointcut() {}

    @Around("roleAuthorizationPointcut()")
    public Object roleAuthorizationAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        RoleAuthorization roleAuthorization = signature.getMethod().getAnnotation(RoleAuthorization.class);
        String[] requiredRoles = roleAuthorization.roles();

        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
                .currentRequestAttributes()).getRequest();
        String userRole = Utils.parseRole(request);

        if (Arrays.asList(requiredRoles).contains(userRole)) {
            return joinPoint.proceed();
        } else {
            throw new ApiException(ExceptionEnum.ACCESS_NOW_ALLOW_EXCEPTION);
        }
    }
}

RoleAuthorization 어노테이션이 선언되어 있는 메소드가 호출됐을 때 실행되는 권한체크 로직이다.

어노테이션을 포인트 컷 지시자로 지정하기 위해 roleAuthorizationPointcut 메소드를 생성하고 @Pointcut 어노테이션에 RoleAuthorization 어노테이션 인터페이스의 패키지 경로를 작성해주었다.

MethodSignature signature = (MethodSignature) joinPoint.getSignature();
RoleAuthorization roleAuthorization = signature.getMethod().getAnnotation(RoleAuthorization.class);
String[] requiredRoles = roleAuthorization.roles();

ProceedingJoinPoint의 Signature로부터 어노테이션 정보를 추출하고 어노테이션에 선언된 권한 정보를 조회한다. 그런 다음 Spring Cloud Gateway로부터 전달받은 권한과 비교하여 포함하면 정상적으로 메소드를 호출하고 포함하지 않으면 권한 에러를 발생한다.

 

어노테이션 포인트 컷 지시자는 아래와 같이 어노테이션 인터페이스를 인자로 받음으로써 더 간단하게 지정할 수 있다.

@Aspect
@Component
public class RoleAuthorizationAop {

    @Around("@annotation(roleAuthorization)")
    public Object roleAuthorizationAdvice(ProceedingJoinPoint joinPoint, RoleAuthorization roleAuthorization) throws Throwable {
        String[] requiredRoles = roleAuthorization.roles();

        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
                .currentRequestAttributes()).getRequest();
        String userRole = Utils.parseRole(request);

        if (Arrays.asList(requiredRoles).contains(userRole)) {
            return joinPoint.proceed();
        } else {
            throw new ApiException(ExceptionEnum.ACCESS_NOW_ALLOW_EXCEPTION);
        }
    }
}

이제 권한 체크를 위한 AOP 설정이 모두 끝났다.

특정 API에 대해 접근을 제한하고 싶으면 아래와 같이 메소드에 @RoleAuthorization 어노테이션을 선언하고 허용 권한을 명시해주면 된다.

@GetMapping("/members")
@RoleAuthorization(roles = {"USER"})
@Operation(summary = "마이페이지 - 내 정보 수정 화면에 필요한 정보 조회", description = "내 정보 수정을 위한 내 정보 조회\n[피그마 링크](https://www.figma.com/file/nYEBH6aqCI37ZX0X6w7Ena?embed_host=notion&kind=file&mode=design&node-id=9914-16871&t=T0LzXHd8One1Acu9-0&type=design&viewer=1)")
@ApiResponse(responseCode = "200", description = "성공", content = @Content(schema = @Schema(implementation = MemberDetailResponseDto.class)))
public ResponseEntity<BaseResponseDto<MemberDetailResponseDto>> getMemberDetail(HttpServletRequest request) {
    long memberId = Utils.parseAuthorizedId(request);

    return ResponseEntity.status(HttpStatus.OK)
            .body(new BaseResponseDto<>(memberService.getMemberDetail(memberId)));
}

이번 프로젝트에서 AOP와 커스텀 어노테이션을 활용해 권한 체크 로직을 구현함으로써 코드의 중복을 줄이고 보다 모듈화된 방식으로 권한 관리를 처리할 수 있었습니다.

들어가기 전에

프로젝트를 진행하면 할 수록 쌓여만 가는 DTO 클래스를 보면서 관리의 필요성을 느끼게 되었다. 같은 도메인을 조회하더라도 API 마다 요구하는 데이터 필드가 다르기 때문에 불필요한 데이터 전송을 최소화하기 위해 API 마다 DTO 클래스를 생성하다 보니 유지보수하기 힘든 지경에까지 이르게 되었다. DTO 클래스 관리를 위한 리팩토링이 필요하다는 결론에 다달았고 상속 구조와 Static Inner 클래스를 사용하여 DTO 클래스를 관리했던 경험을 기록하고자 한다.

상속 구조를 이용한 DTO 관리

API 마다 데이터 구조가 다르다하더라도 공통적으로 조회하는 필드는 존재한다. 이 때 공통 필드를 모아놓은 DTO 클래스를 생성하고 새로운 API를 개발할 때 공통 DTO를 상속받아 새 DTO 클래스를 생성하면 같은 필드를 DTO 마다 작성할 필요가 없기 때문에 DTO 클래스를 관리하기가 쉬워진다.

FolderReseponse

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class FolderResponse {

    private Long id;

    private String name;

    private int count;
}

Folder 엔티티에 대한 ResponseDto 클래스이다.

PK, 이름, Folder에 포함된 데이터 수를 전송하며 Folder 데이터를 조회할 때 공통적으로 조회하는 필드이다.

 

만약 폴더에 포함된 데이터 정보까지 같이 조회해야하는 API를 개발해야한다고 가정해보자.

데이터 정보를 포함한 DTO 클래스를 아래와 같이 작성했다.

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class FolderDetailResponse {

    private Long id;

    private String name;

    private int count;

    private List<ArchiveResponse> archiveResponses;
}

id, name, count 필드가 FolderResponse 클래스와 중복되는데 위와 같은 경우 문제점은 만약 Folder 데이터를 조회하는 API를 여러 사람이 개발할 때 필드명이 제각각일 수 있다는 점이다. 또한 응답 데이터 구조에 변동이 생겼을 때 모든 DTO의 응답 구조를 수정해야한다는 점이다. 이를 방지하기 위해 상속 구조를 사용하여 아래와 같이 수정했다.

FolderResponse

@Getter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class FolderResponse {

    private Long id;

    private String name;

    private int count;

    public static FolderResponse fromEntity(Folder folder) {
        return fromEntity(folder, builder());
    }

    public static <T extends FolderResponse> T fromEntity(Folder folder, FolderResponse.FolderResponseBuilder<T, ?> builder) {
        return builder
                .id(folder.getId())
                .name(folder.getName())
                .count(folder.getFolderArchives().size())
                .build();
    }
}

FolderDetailResponse

@Getter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class FolderDetailResponse extends FolderResponse {

    private List<ArchiveResponse> archiveResponses;

    public static FolderDetailResponse fromEntity(Folder folder) {
        return fromEntity(
                folder,
                builder().archiveResponses(folder.getFolderArchives().stream().map(fa -> ArchiveResponse.fromEntity(fa.getArchive())).toList()));
    }
}

부모 클래스의 필드를 빌더 패턴으로 생성하기 위해 @SuperBuilder 어노테이션을 사용했다.

또한 FolderResponse 클래스를 상속 받은 자식 클래스에서 부모 클래스 필드를 포함한 응답 데이터를 생성하기 위해 FolderResponse 클레스에 제네릭 빌더 메서드를 구현했다.

 

위와 같은 방법으로 상속 구조를 이용해 DTO를 관리하면 공통 필드가 많을수록 중복된 코드를 줄일 수 있고, 유지보수성을 크게 향상시킬 수 있다. 또한, 새로운 API 개발 시 공통 필드를 재사용함으로써 일관된 응답 구조를 유지할 수 있고, 구조 변경이 있을 때도 부모 DTO만 수정하면 되기 때문에 수정 범위가 최소화된다.

Static Inner Class

상속 구조를 사용하더라도 API 마다 Dto 클래스를 생성해야 하는 것은 변함없다. FolderDetailResponse 클래스의 ArchiveResponse Dto 클래스의 경우 다른 API에서도 단독으로 사용되기 때문에 상관없지만 해당 API에서만 사용하는 Response 클래스의 경우 전부 따로 생성하면 관리해야 할 클래스 파일이 늘어나게 된다.

 

하지만 아래와 같이 FolderResponse 클래스 내부에서 Inner 클래스를 사용하면 별도의 Dto 클래스 파일을 생성할 필요없이 FolderResponse 클래스 안에서 전부 관리할 수 있다.

FolderResponse

@Getter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class FolderResponse {

    private Long id;

    private String name;

    private int count;

    private OwnerResponse ownerResponse;

    public static FolderResponse fromEntity(Folder folder, OwnerResponse ownerResponse) {
        return fromEntity(folder, ownerResponse, builder());
    }

    public static <T extends FolderResponse> T fromEntity(Folder folder, OwnerResponse ownerResponse, FolderResponse.FolderResponseBuilder<T, ?> builder) {
        return builder
                .id(folder.getId())
                .name(folder.getName())
                .count(folder.getFolderArchives().size())
                .ownerResponse(ownerResponse)
                .build();
    }

    @Builder
    public static class OwnerResponse {
    
        private long userId;

        private String name;

        public OwnerResponse from(User user) {
            return builder()
                    .userId(user.getId())
                    .name(user.getName())
                    .build();
        }
    }
}

폴더의 소유자 정보를 갖고 있는 OwnerResponse 클래스를 FolderResponse 클래스에서 static inner 클래스로 생성하였다.

이제 OwnerResponse 클래스에 대한 정보를 보고싶다면 클래스를 이동할 필요없이 FolderResponse에서 확인할 수 있으며 만약 OwnerResponse 클래스를 다른 API에서도 공용으로 사용하게 된다면 따로 클래스 파일로 분리하여 공용 DTO 패키지에서 관리하면 훨씬 수월할 것이다.

 

물론 가장 중요한 점은 체계적인 패키지 구조로 Dto 클래스를 구분하고 명확한 클래스 명명 규칙으로 한눈에 Dto 클래스를 식별할 수 있어야 한다는 점이다.

들어가기 전에

추상 팩토리 패턴이란??

추상 팩토리 패턴(Abstract Factory Pattern)이란, 서로 관련된 객체들의 집합을 생성할 수 있는 인터페이스를 제공하면서 구체적인 클래스는 지정하지 않는 생성 패턴 중 하나이다. 즉, 클라이언트는 구체적인 클래스에 의존하지 않고, 인터페이스를 통해 객체를 생성하고 사용하게 된다. 이 패턴을 사용하면 객체 생성 로직을 클라이언트 코드에서 분리하고, 다양한 구체적인 클래스들을 교체할 수 있는 유연성을 제공할 수 있다.

 

소셜 로그인을 구현할 때 Provider 마다 CLIENT_ID, REDIRECT_URI, CLIENT_SECRET이 다르고 사용자 정보도 다르게 넘어온다. if문으로 분기처리하여 구현할 수 있지만 추상 팩토리 패턴을 사용하면 서비스 코드에서 매번 분기처리할 필요없이 Provider 별로 제공된 팩토리 객체의 메소드를 호출하여 관련 객체를 생성함으로써 유지보수가 쉽고 깔끔한 코드를 작성할 수 있게 된다.

 

구현하기

추상 팩토리 패턴은 객체 생성 코드를 인터페이스로 추상화하고 상황별로 구현 클래스를 작성한 후 타입을 추상화한 인터페이스로 선언하여 사용한다.

 

OAuthRequestBodyFactory

package com.plcok.common.oauth;

import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

import java.util.Map;

public interface OAuthRequestBodyFactory {

    MultiValueMap<String, String> createRequestBody(String token);

    default MultiValueMap<String, String> createDefaultRequestBody(
            String clientId,
            String redirectUri,
            String createSecret,
            String code
    ) {
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", "authorization_code");
        body.add("client_id", clientId);
        body.add("redirect_uri", redirectUri);
        body.add("client_secret", createSecret);
        body.add("code", code);
        return body;
    }

    String getRequestUrl();

    String getUserInfoRequestUrl();

    OAuth2Attributes createOauthAttribute(Map<String, Object> map);
}

 

객체 생성 코드를 추상화한 인터페이스이다.

  • createRequestBody : AccessToken을 받아오기 위해 작성해야 할 RequestBody 생성 메소드
  • createDefaultRequestBody : CLIENT_ID, REDIRECT_URI, CLIENT_SECRET, Code를 파라미터로 받아 RequestBody를 생성. 템플릿은 동일하기 때문에 디폴트 메소드로 선언, createRequestBody를 호출했을 때 구현부에서 호출한다.
  • getRequestUrl : AccessToken을 받아오기 위해 요청해야 할 URL
  • getUserInfoRequestUrl : AccessToken으로 사용자 정보를 받아오기 위해 요청해야 할 URL
  • createOauthAttribute : 조회한 사용자 정보를 담아둘 객체를 생성하는 메소드

 

OAuthRequestBodyFactory

package com.plcok.common.oauth;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;

import java.util.Map;

@Component
public class KakaoRequestBodyFactory implements OAuthRequestBodyFactory {

    @Value("${spring.security.oauth2.client.registration.kakao.client-id}")
    private String CLIENT_ID;

    @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
    private String REDIRECT_URI;

    @Value("${spring.security.oauth2.client.registration.kakao.client-secret}")
    private String CLIENT_SECRET;

    @Override
    public MultiValueMap<String, String> createRequestBody(String code) {
        return createDefaultRequestBody(CLIENT_ID, REDIRECT_URI, CLIENT_SECRET, code);
    }

    @Override
    public String getRequestUrl() {
        return "https://kauth.kakao.com/oauth/token";
    }

    @Override
    public String getUserInfoRequestUrl() {
        return "https://kapi.kakao.com/v2/user/me";
    }

    @Override
    public OAuth2Attributes createOauthAttribute(Map<String, Object> map) {
        return OAuth2Attributes.ofKakao(map);
    }
}

 

Provider가 카카오일 때 OAuthRequestBodyFactory 인터페이스에서 추상화한 메소드를 구현한 클래스이다. createRequestBody 메소드에서는 인터페이스에서 선언한 디폴트 메소드를 호출하고 있으며 createOauthAttribute 메소드에서는 OAuth2Attributes 클래스에서 선언한 카카오 사용자 정보를 생성하는 정적 팩토리 메소드를 호출하고 있다.

 

application.yml 파일에서 선언한 CLIENT_ID, REDIRECT_URI, CLIENT_SECRET 정보를 가져오기 위해 @Component 어노테이션을 선언하고 컴포넌트 스캔의 대상이 되도록 하여 빈 객체로 등록했다.

 

Provider가 구글인 경우 팩토리 객체도 구현하자

 

GoogleRequestBodyFactory

package com.plcok.common.oauth;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;

import java.util.Map;

@Component
public class GoogleRequestBodyFactory implements OAuthRequestBodyFactory {

    @Value("${spring.security.oauth2.client.registration.google.client-id}")
    private String CLIENT_ID;

    @Value("${spring.security.oauth2.client.registration.google.redirect-uri}")
    private String REDIRECT_URI;

    @Value("${spring.security.oauth2.client.registration.google.client-secret}")
    private String CLIENT_SECRET;

    @Override
    public MultiValueMap<String, String> createRequestBody(String code) {
        return createDefaultRequestBody(CLIENT_ID, REDIRECT_URI, CLIENT_SECRET, code);
    }

    @Override
    public String getRequestUrl() {
        return "https://www.googleapis.com/oauth2/v4/token";
    }

    @Override
    public String getUserInfoRequestUrl() {
        return "https://www.googleapis.com/oauth2/v2/userinfo";
    }

    @Override
    public OAuth2Attributes createOauthAttribute(Map<String, Object> map) {
        return OAuth2Attributes.ofGoogle(map);
    }
}

 

OAuth2Attributes

package com.plcok.common.oauth;

import com.plcok.user.entity.enumerated.ProviderType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.Map;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class OAuth2Attributes {

    private String email;

    private ProviderType providerType;

    private String providerUserId;

    public static OAuth2Attributes ofKakao(Map<String, Object> attributes) {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        String email = String.valueOf(kakaoAccount.get("email"));
        String providerUserId = String.valueOf(attributes.get("id"));

        return builder()
                .email(email)
                .providerType(ProviderType.KAKAO)
                .providerUserId(providerUserId)
                .build();
    }

    public static OAuth2Attributes ofGoogle(Map<String, Object> attributes) {
        String email = String.valueOf(attributes.get("email"));
        String providerUserId = String.valueOf(attributes.get("id"));

        return builder()
                .email(email)
                .providerType(ProviderType.GOOGLE)
                .providerUserId(providerUserId)
                .build();
    }
}

 

처음에 언급했듯이 인증 서버로부터 넘어오는 사용자 정보도 Provider 별로 다르다. 인증 서버로부터 넘어온 사용자 정보를 담아둘 객체이다. Provider 별로 정적 팩토리 메서드를 선언했다.

 

AuthUserController

package com.plcok.user.controller;

import com.plcok.user.dto.response.SocialLoginResponse;
import com.plcok.user.service.AuthUserService;
import com.plcok.common.oauth.GoogleRequestBodyFactory;
import com.plcok.common.oauth.KakaoRequestBodyFactory;
import com.fasterxml.jackson.core.JsonProcessingException;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
@Tag(name = "인증 관련 API")
public class AuthUserController {

    private final AuthUserService authUserService;
    private final GoogleRequestBodyFactory googleRequestBodyFactory;
    private final KakaoRequestBodyFactory kakaoRequestBodyFactory;

    @GetMapping("/user/kakao/callback")
    public ResponseEntity<SocialLoginResponse> kakaoLogin(@RequestParam("code") String code) throws JsonProcessingException {
        String accessToken = authUserService.getToken(code, kakaoRequestBodyFactory);
        return ResponseEntity.status(HttpStatus.OK)
                .body(authUserService.login(accessToken, kakaoRequestBodyFactory));
    }

    @GetMapping("/user/google/callback")
    public ResponseEntity<SocialLoginResponse> googleLogin(@RequestParam("code") String code) throws JsonProcessingException {
        String accessToken = authUserService.getToken(code, googleRequestBodyFactory);
        return ResponseEntity.status(HttpStatus.OK)
                .body(authUserService.login(accessToken, googleRequestBodyFactory));
    }
}

 

소셜 로그인 요청을 받는 RestController이다. Provider 별로 API를 생성하고 구현 팩토리를 파라미터로 넘겼다.

 

getToken

@Override
@Transactional(readOnly = true)
public String getToken(String code, OAuthRequestBodyFactory factory) throws JsonProcessingException {
    // HTTP Header 생성
    HttpHeaders headers = new HttpHeaders();
    headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

    // HTTP Body 생성
    MultiValueMap<String, String> body = factory.createRequestBody(code);

    // HTTP 요청 보내기
    HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(body, headers);
    RestTemplate rt = new RestTemplate();
    ResponseEntity<String> response = rt.exchange(
            factory.getRequestUrl(),
            HttpMethod.POST,
            tokenRequest,
            String.class
    );

    // HTTP 응답 (JSON) -> 액세스 토큰 파싱
    String responseBody = response.getBody();
    ObjectMapper objectMapper = new ObjectMapper();
    JsonNode jsonNode = objectMapper.readTree(responseBody);
    return jsonNode.get("access_token").asText();
}

 

인증 서버로부터 AccessToken을 받아오는 getToken 메소드이다. HTTP Body를 생성하는 코드를 보면 factory.createRequestBody 메소드를 호출하고 있는데 OAuthRequestBodyFactory 인터페이스를 파라미터로 받기 때문에 호출 클래스에서 어떤 구현 팩토리를 넘기느냐에 따라 다른 RequestBody를 생성한다. 아래는 전체 서비스 코드이다.

 

AuthUserService

package com.plcok.user.service;

import com.plcok.common.oauth.OAuth2Attributes;
import com.plcok.common.oauth.OAuthRequestBodyFactory;
import com.plcok.user.entity.UserProvider;
import com.plcok.common.security.JwtUtil;
import com.plcok.user.entity.User;
import com.plcok.user.dto.response.SocialLoginResponse;
import com.plcok.user.repository.UserProviderRepository;
import com.plcok.user.repository.UserRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import java.util.Map;

@Service
@RequiredArgsConstructor
@Slf4j
public class AuthUserServiceImpl implements AuthUserService {

    private final JwtUtil jwtUtil;

    private final UserRepository userRepository;

    private final UserProviderRepository userProviderRepository;

    @Override
    @Transactional
    public SocialLoginResponse login(String token, OAuthRequestBodyFactory factory) {
        Map<String, Object> map = getUserAttributes(factory, token);
        OAuth2Attributes attributes = factory.createOauthAttribute(map);

        User user = findOrCreateUser(attributes);
        return SocialLoginResponse
                .from(jwtUtil.createToken(user.getId(), user.getRole(), user.getCreatedAt()));
    }

    private User findOrCreateUser(OAuth2Attributes attributes) {
        return userProviderRepository.findByProviderTypeAndProviderUserId(attributes.getProviderType(), attributes.getProviderUserId())
                .map(UserProvider::getUser)
                .orElseGet(() -> createUser(attributes));
    }

    private User createUser(OAuth2Attributes attributes) {
        User user = new User();
        userRepository.save(user);
        UserProvider userProvider = UserProvider.of(attributes.getProviderType(), user, attributes.getProviderUserId());
        userProviderRepository.save(userProvider);
        return user;
    }

    private Map<String, Object> getUserAttributes(OAuthRequestBodyFactory factory, String token) {
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(token);
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(headers);
        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<String> response = restTemplate
                .exchange(factory.getUserInfoRequestUrl(), HttpMethod.GET, request, String.class);
        return parseResponseBody(response.getBody());
    }

    private Map<String, Object> parseResponseBody(String responseBody) {
        try {
            return new ObjectMapper().readValue(responseBody, new TypeReference<Map<String, Object>>() {});
        } catch (Exception e) {
            throw new RuntimeException("Failed to parse user attributes", e);
        }
    }

    @Override
    @Transactional(readOnly = true)
    public String getToken(String code, OAuthRequestBodyFactory factory) throws JsonProcessingException {
        // HTTP Header 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HTTP Body 생성
        MultiValueMap<String, String> body = factory.createRequestBody(code);

        // HTTP 요청 보내기
        HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(body, headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                factory.getRequestUrl(),
                HttpMethod.POST,
                tokenRequest,
                String.class
        );

        // HTTP 응답 (JSON) -> 액세스 토큰 파싱
        String responseBody = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);
        return jsonNode.get("access_token").asText();
    }
}

 

추상 팩토리 패턴을 사용한 이 구조는 Provider 별로 다른 요청 처리를 깔끔하게 분리해준다. 새로운 Provider가 추가될 때도 기존 코드를 크게 수정하지 않고 팩토리 객체만 구현하면 되므로, 유지보수가 용이하고 코드의 복잡도를 줄일 수 있다.

Garbage Collection

Garbage Collection(이하 GC) JVM에서 메모리 관리를 위한 중요한 기능입니다. GC 메모리의 Heap 영역 할당된 객체 중에서 이상 참조되지 않는 객체를 제거하여 메모리를 확보하는 작업을 말합니다. 과정에서 불필요한 객체가 메모리에서 제거됨으로써, 프로그램의 메모리 사용 효율을 높이고 OutOfMemoryError 같은 문제를 예방할 있습니다.

 

Stop The World

GC 동작하는 동안 GC 관련 Thread 제외한 나머지 Thread 모두 멈추게 되는데, 이를 Stop-The-World라고 합니다. 현상은 GC 수행될 때마다 발생하기 때문에, GC 자주 실행되거나 실행 시간이 길어지면 성능 저하로 이어질 있습니다. 이러한 이유로 GC 최적화는 애플리케이션 개발에 매우 중요한 요소 중 하나 입니다.

 

GC 종류

GC는 크게 두 가지로 구분됩니다:

  • Minor GC: Young Generation 영역에서 발생
  • Major GC (Full GC): Old Generation에서 발생

이를 이해하려면 먼저 JVM Heap 영역 구조 알아야 합니다.

 

Heap 구조

Young Generation

Young Generation은 새로 생성된 객체들이 저장되는 영역입니다. Young Generation의 GC는 Minor GC라고 불리며, 이 영역은 다시 EdenSurvivor1, Survivor2 영역으로 나뉩니다.

  • Eden 영역: 객체가 Heap에 최초로 할당되는 장소입니다. Eden 영역이 가득 차면, 참조가 있는 객체는 Survivor 영역으로 이동하고, 참조가 없는 객체는 삭제됩니다. 이 과정이 바로 Minor GC입니다.
  • Survivor 영역: Eden에서 살아남은 객체가 저장됩니다. Minor GC가 반복되며 살아남은 객체는 Old Generation으로 이동하게 됩니다.

 

Old Generation

Old Generation은 Young Generation에서 살아남은 오래된 객체들이 저장되는 영역입니다. 애플리케이션에서 일정 횟수 이상 참조된 객체가 Old Generation으로 이동합니다. 이곳에서 발생하는 GC는 **Major GC(또는 Full GC)**라고 불리며, 이 과정은 Minor GC보다 오래 걸립니다. Old Generation으로의 이동을 줄이면 Full GC 빈도를 줄여 성능을 개선할 수 있습니다.

 

Permanent Generation

Permanent Generation은 클래스의 메타 데이터나 메소드의 메타 데이터, Static 변수, 상수와 같은 JVM 실행에 필요한 데이터 저장됩니다. JDK 8 이후에는 Metaspace 변경되었습니다.

 

GC의 성능 최적화

1. Young Generation, Old Generation 크기 조정

Young Generation 크기를 적절히 조정하면, 객체가 Old Generation으로 이동하기 전에 Minor GC로 삭제될 확률을 높일 수 있습니다. Old Generation 크기를 키우면 Full GC 횟수가 감소하지만 실행 시간이 길어지고 크기를 줄이면 실행시간이 감소하지만 횟수가 증가하고 Out Of Memory Exception이 발생할 수 있기 때문에 실행중인 어플리케이션에 적절한 크기를 설정하는 것이 중요합니다.

 

2. Object 생성 최소화

Object 생성을 최소화하면 Old Generation으로의 객체 이동이 줄어들기 때문에 Major GC 발생 빈도를 줄여 애플리케이션의 Stop-The-World 현상을 줄일 수 있습니다. 예를 들면 문자열 데이터가 가변적인 상황에서는 String 객체보다는 StringBuilder나 StringBuffer를 사용하면 객체 생성을 줄일 수 있습니다.

 

3. GC 알고리즘 선택

JVM 다양한 GC 알고리즘을 제공하며, 애플리케이션 특성에 맞는 GC 알고리즘을 선택하는 것이 중요합니다. 대표적으로 Serial GC, Parallel GC, CMS GC, G1 GC 등이 있습니다.

Java에서는 문자열을 다루기 위한 다양한 클래스가 제공되는데, 가장 대표적인 것이 String, StringBuilder, 그리고 StringBuffer입니다. 가지 클래스는 모두 문자열을 다루지만, 변경 가능성(Mutability) 스레드 안전성(Thread Safety) 측면에서 차이가 있습니다.

 

String 클래스

String 클래스는 불변(Immutable) 객체로, 한 번 생성된 문자열은 수정할 수 없습니다. 문자열이 변경될 때마다 새로운 String 객체가 생성됩니다.

 

특징

  • 불변성: String은 한 번 생성되면 내용을 변경할 수 없습니다.
  • 새로운 객체 생성: 문자열을 조작할 때마다 새로운 객체가 생성되므로 문자열을 자주 변경하는 경우 메모리 효율이 떨어질 수 있습니다.
  • 리터럴 활용: String 객체는 String Pool에 저장되어, 동일한 리터럴 값이 있을 경우 같은 객체를 공유합니다.
String str = "Hello";
str = str + " World"; // 새로운 객체가 생성됨

 

StringBuilder 클래스

StringBuilder 클래스는 가변(Mutable) 객체로, 문자열을 직접 변경할 수 있습니다. 하지만 StringBuilder 클래스는 스레드 안전성을 제공하지 않기 때문에, 멀티 스레드 환경에서 문자열을 조작할 때 동시성 이슈가 발생할 수 있습니다.

 

특징

  • 가변성: 문자열을 조작할 때 동일 객체 내에서 변경됩니다.
  • 성능 우수: String에 비해 메모리 효율이 높고, 속도가 빠릅니다.
  • 스레드 안전성 미제공: 여러 스레드에서 동시에 접근할 경우, 안전하지 않을 있습니다.
StringBuilder sb = new StringBuilder("Hello");
sb.append(" World"); // 같은 객체 내에서 문자열이 변경됨
System.out.println(sb.toString()); // "Hello World" 출력

 

StringBuffer 클래스

StringBuffer는 StringBuilder와 유사하게 가변 객체이지만, **스레드 안전(Thread-Safe)**합니다. 모든 메서드가 동기화(Synchronized) 되어 있어, 여러 스레드에서 접근해도 안전하게 사용할 수 있습니다. 다만, 이로 인해 StringBuilder보다 속도가 약간 느립니다.

 

특징

  • 가변성: StringBuilder와 동일하게 같은 객체 내에서 문자열을 변경합니다.
  • 스레드 안전성 제공: 메서드가 동기화되어 있어, 멀티스레드 환경에서도 안전하게 사용할 수 있습니다.
  • 성능: 스레드 동기화 처리로 인해 StringBuilder보다 느리지만, String보다는 성능이 좋습니다.
StringBuffer sbf = new StringBuffer("Hello");
sbf.append(" World"); // 같은 객체 내에서 문자열이 변경됨
System.out.println(sbf.toString()); // "Hello World" 출력

 

멀티쓰레드 환경 StringBuilder, StringBuffer 테스트

StringBuilderStringBuffer를 각각 2개의 스레드를 생성하여 "A" 문자열을 100000번씩 append하는 테스트 코드입니다.

100000번씩 두 번 더했기 때문에 문자열 길이가 200000이 나오는 것을 예상했지만 StringBuilder는 멀티쓰레드 환경에서 안전성을 보장하지 않기 때문에 기대한 값이 나오지 않았고 StringBuffer는 기대한 값이 나와 테스트를 통과한 것을 확인할 수 있습니다.

또한 실행 시간을 측정한 결과 동기화 처리로 인해 StringBuffer 속도가 41ms로 StringBuilder보다 오래 걸린 것을 확인할 수 있습니다.

@SpringBootTest
public class TestClass {

    @Test
    public void stringBuilderTest() {
        StringBuilder sharedBuilder = new StringBuilder();

        Runnable task = () -> {
            for (int i = 0; i < 100000; i++) {
                sharedBuilder.append("A");
            }
        };

        // 두 개의 스레드가 동일한 StringBuilder 인스턴스를 동시에 수정
        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        long startTime = System.currentTimeMillis();
        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long endTime = System.currentTimeMillis();

        // 실행 시간 계산 및 출력
        long executionTime = endTime - startTime;
        System.out.println("StringBuilder Thread execution time: " + executionTime + " ms"); // 4ms
        Assertions.assertNotEquals(200000, sharedBuilder.length());
    }

    @Test
    public void stringBufferTest() {
        StringBuffer sharedBuffer = new StringBuffer();

        Runnable task = () -> {
            for (int i = 0; i < 100000; i++) {
                sharedBuffer.append("A");
            }
        };

        // 두 개의 스레드가 동일한 StringBuilder 인스턴스를 동시에 수정
        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        long startTime = System.currentTimeMillis();
        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long endTime = System.currentTimeMillis();

        // 실행 시간 계산 및 출력
        long executionTime = endTime - startTime;
        System.out.println("StringBuffer Thread execution time: " + executionTime + " ms"); // 41ms
        Assertions.assertEquals(200000, sharedBuffer.length());
    }
}

 

String, StringBuilder, StringBuffer 비교

구분 String StringBuilder StringBuffer
변경 가능성 불변 (Immutable) 가변 (Mutable) 가변 (Mutable)
스레드 안전성 안전하지 않음 안전하지 않음 스레드 안전 (Synchronized)
속도 가장 느림 가장 빠름 StringBuilder보다 느림
사용 환경 변경이 필요 없는 문자열 단일 스레드에서 자주 변경하는 문자열 멀티스레드에서 자주 변경하는 문자열

 

Exception과 Error의 차이

Exception Error 모두 Java에서 예외와 오류의 최상위 클래스인 Throwable 클래스를 상속하며, 프로그램 실행 발생하는 예외적인 상황을 나타냅니다. 하지만 Exception Error 의미와 목적에서 차이가 있습니다.

 

Error는 애플리케이션 코드로 처리할 수 없는 시스템적 문제를 나타내며, 대부분 JVM 레벨에서 발생합니다. Error는 발생할 경우 프로그램이 즉시 종료될 가능성이 높고, 일반적으로 try-catch 구문으로 처리하지 않습니다. Error는 메모리 부족, 스택 오버플로우와 같은 시스템 자원 문제로 인해 발생하며, 개발자가 해결할 수 없는 경우가 대부분입니다.

 

Error의 대표 예시

  1. OutOfMemoryError
    메모리가 부족할 발생하는 오류로, 주로 객체를 너무 많이 생성하거나 메모리 리소스를 과도하게 사용했을 발생합니다.
  2. StackOverflowError
    재귀 호출이 무한히 반복될 발생하는 오류입니다.
  3. VirtualMachineError
    가상 머신의 심각한 문제가 발생했을 발생합니다.

Exception 프로그램 실행 중에 발생할 있는 예상 가능한 예외 상황 나타냅니다. 주로 사용자 입력 오류, 파일 입출력 오류 프로그램이 처리할 있는 문제들로, Java에서는 try-catch 구문을 통해 Exception 처리할 있습니다. Exception 처리함으로써 프로그램이 강제 종료되지 않고 정상 흐름을 유지할 있게 합니다.

구분 Exception Error
의미 예상 가능한 예외 상황 시스템 수준의 심각한 문제
처리 방법 try-catch로 처리 가능 일반적으로 처리하지 않음
대표 예시 IOException, SQLException OutOfMemoryError, StackOverflowError
복구 가능성 복구 가능성이 있음 복구 불가능, 프로그램 종료 가능성 큼

 

개발을 하다 보면 다양한 예외 상황을 만나게 됩니다. Java에서는 이러한 예외를 체크 예외 언체크 예외 구분하며, 예외를 적절히 처리하는 방법으로 예외 복구, 예외 회피, 예외 전환 있습니다. 이어서 예외의 종류와 효과적인 처리 방법을 정리해 보겠습니다.

Exception

Exception 클래스는 체크 예외와 언체크 예외로 구분되는데 체크 예외는 Exception 클래스의 서브클래스이면서 RuntimeException 클래스를 상속하지 않은 것들이고, 언체크 예외는 RuntimeException 클래스를 상속한 클래스들을 말합니다.

 

체크 예외 (Checked Exception)

일반적으로 예외라고 하면 RuntimeException 클래스를 상속 받지 않은 체크 예외를 의미하며 체크 예외가 발생할 수 있는 메소드를 사용할 경우 반드시 예외를 처리하는 코드를 함께 작성해 주어야 합니다. 만약 예외 처리를 해주지 않으면 컴파일 에러가 발생합니다.

 

체크 예외의 예시

  1. IOException: 파일 입출력 시 파일이 없거나 접근할 수 없을 때 발생
  2. SQLException: SQL 문법 에러, DB 접근 실패, 서버나 네트워크 문제로 발생

체크 예외의 장단점

  • 장점: 컴파일 단계에서 예외를 강제하기 때문에, 놓치기 쉬운 예외를 미리 잡을 수 있는 안전장치 역할을 합니다.
  • 단점: 모든 체크 예외를 잡거나 던져야 하기 때문에 번거로워질 있습니다.

언체크 예외 (Unchecked Exception)

언체크 예외는 런타임 예외라고도 불리며 RuntimeException 클래스를 상속한 예외가 여기에 속합니다. 명시적인 예외처리를 강제하지 않기 때문에 catch 문으로 잡거나 throws로 선언하지 않아도 되며 선언하지 않을 경우 자동으로 예외를 던집니다. 주로 프로그램의 로직 오류나 예측 불가능한 상황에서 발생합니다.

 

언체크 예외의 예시

  1. NullPointerException: 할당되지 않은 객체를 사용하려고 할 때 발생
  2. IllegalArgumentException: 허용되지 않는 값으로 메서드를 호출할 때 발생

언체크 예외의 장단점

  • 장점: 신경 쓰고 싶지 않은 예외를 무시할 수 있어 코드 작성이 간편합니다.
  • 단점: 컴파일 단계에서 오류를 잡아주지 않아 예외가 발생할 가능성을 쉽게 놓칠 있습니다.

예외처리 방법

체크 예외와 언체크 예외를 구분해도 실제로 예외가 발생했을 때는 이를 어떻게 처리할지 고민해야 합니다. Java에서는 크게 세 가지 방식(예외 복구, 예외처리 회피, 예외 전환)으로 예외를 처리할 수 있습니다.

예외 복구

첫 번째로 예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 방법이 있습니다. 예외가 발생하더라도 정상 상태로 복구 있다면 방법을 사용합니다. 예외 상황에 따라 특정 횟수까지 재시도를 하거나, 대체 로직을 통해 정상 상태로 돌려놓을 있습니다.

int maxretry = MAX_RETRY
int cnt = 0
while (cnt < MAX_RETRY) {
    cnt++
    try {
    	...    		// 예외가 발생할 가능성이 있는 시도
        return;		// 작업 성공
    } catch (SomeException e) {
    	// 로그 출력 후 정해진 시간만큼 대기
    } finally {
    	// 리소스 반납. 정리 작업
    }
}
throw new RetryFailedException(); // 최대 재시도 횟수를 넘기면 직접 예외 발생

예외처리 회피

두 번째로 예외처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 전달하는 방법이 있습니다. 예외를 회피하는 것은 예외를 복구하는 것처럼 의도가 분명해야 합니다. 다른 오브젝트에게 예외처리 책임을 지게 하거나, 자신을 사용하는 쪽에서 예외를 다루는 게 최선의 방법이라는 확신이 있을때만 사용해야 합니다.

// 예외처리 회피 1
public void add() throws SQLException {
    ...
}
// 예외처리 회피 2
public void add() throws SQLException {
    try {
        ...
    } catch (SQLException e) {
        throw e;
    }
}

예외 전환

마지막으로 발생한 예외를 그대로 던지는 대신 의미를 부여한 새로운 예외로 전환하여 던지는 방법입니다.

 

예외 전환 사용 목적

1. 내부에서 발생한 예외를 그대로 던지는 것이 그 예외상황에 대한 적절한 의미를 부여해주지 못하는 경우, 의미를 분명하게 해줄 수 있는 예외로 바꿔주기 위해 사용합니다.

 

예를 들어 앞서 말했듯이 SQLException경우 문법 문제이거나 로직 문제 또는 서버 쪽에 문제가 생겼을 때도 발생할 수 있는데 이 모든 경우에 단순히 SQLException으로 throw를 하면 개발자 입장에서는 원인을 찾기가 쉽지 않습니다. 그렇기 때문에 같은 SQLException이라도 원인을 세분화해서 다른 Exception으로 전환 후 보내준다면 보다 쉽게 에러를 해결할 수 있게 됩니다.

 

2. 예외를 처리하기 쉽고 단순하게 만들기 위해서 사용합니다.

주로 예외처리를 강제하는 체크 예외를 언체크 예외인 런타임 예외로 바꾸는 경우에 사용한다.

// 회원가입시 id가 중복되어 에러가 발생한 경우
public void add(User user) throws DuplicateUserIdException, SQLException {
    try {
    	...
    } catch (SQLException e) {
    	// ErrorCode가 MySQL의 "Duplicate Entry(1062)"이면 예외 전환
        // ErrorCode는 데이터베이스 종류에 따라 다름
        if (e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY) {
            throw DuplicateUserIdException();
        } else {
            throw e; // 그 외의 경우는 SQlException 그대로 전달
        }
    }
}

 

'Java' 카테고리의 다른 글

[Java] Garbage Collection이란?  (0) 2024.11.11
[Java] String, StringBuilder, StringBuffer  (0) 2024.11.05
[Java] 스트림(Stream)이란  (0) 2024.11.04
[Java] 제네릭이란?  (0) 2024.11.03
[Java] final 필드, 메소드, 클래스  (0) 2024.10.30

스트림이란?

Stream은 데이터 소스를 추상화한 형태로, 컬렉션에 저장된 요소들을 하나씩 참조하여 람다식으로 처리할 수 있게 하는 자바 8의 반복자입니다. 쉽게 말해 데이터의 흐름을 다루기 위한 도구로, 데이터를 필터링하고 변환하며 집계할 수 있도록 돕습니다. Stream을 활용하면 복잡한 반복문을 줄일 수 있고, 가독성을 높일 수 있습니다. 또한 내부적으로 최적화된 처리 방식을 제공하여 대용량 데이터를 효율적으로 다룰 수 있습니다.

스트림의 특징

  1. 간결한 코드: 기존의 반복자(iterator) 사용하는 방식보다 간결하고 직관적인 코드를 작성할 있습니다.
  2. 선언적 프로그래밍: '무엇을' 것인지를 명시하여 '어떻게' 것인지를 신경 필요가 없습니다.
  3. 병렬 처리 지원: 간단한 메서드 호출로 손쉽게 병렬 처리를 구현할 있어 성능 향상에 기여합니다.
  4. 가독성 향상: 데이터 처리의 흐름이 명확하게 드러나 코드의 가독성이 높아집니다.
  5. 유연성 증가: 컬렉션 이외의 데이터 소스(배열, 파일 등)에서도 동일한 방식으로 스트림을 사용할 수 있습니다.

Stream이 추가 되기 전인 자바 7 버전까지는 List<String> 컬렉션에서 요소를 순차적으로 처리하기 위해 아래와 같이 Iterator 반복자를 사용하거나 for 문을 활용하여 직접 구현했었습니다.

public class StreamTest {

    public static void main(String[] args) {
        List<String> nameList = new ArrayList<>();
        nameList.add("홍길동");
        nameList.add("스프링");
        nameList.add("스트림");
        Iterator<String> iterator = nameList.iterator();
        while (iterator.hasNext()) {
            String name = iterator.next();
            System.out.println(name);
        }
    }
}

하지만 자바 8 버전 부터는 Stream을 사용하면 더욱 간결하게 동일한 기능을 구현할 수 있습니다. stream() 메서드를 호출하여 스트림을 생성하고, forEach 메서드로 각 요소를 처리합니다.

public class StreamTest2 {

	public static void main(String[] args) {
        List<String> nameList = new ArrayList<>();
        nameList.add("홍길동");
        nameList.add("스프링");
        nameList.add("스트림");
        
        // nameList 리스트에 저장된 요소인 name을 파라미터로 해서 순차적으로 출력
        nameList.stream().forEach(name -> System.out.println(name));
    }
}

 

중간 처리와 최종 처리

Stream API는 크게 두 가지 처리 방식으로 구성됩니다.

  • 중간 처리 (Intermediate Operation): 데이터를 변환하거나 필터링하는 작업입니다. 예를 들어, 필터링(filter()), 매핑(map()), 정렬(sorted()) 등의 메소드가 여기에 해당합니다. 중간 처리 메소드는 지연 연산으로, 최종 처리 메소드가 호출될 때까지 실행되지 않습니다.
  • 최종 처리 (Terminal Operation): 스트림의 모든 데이터를 처리하고 종료하는 작업입니다. 예를 들어, 반복(forEach()), 카운팅(count()), 변환(collect()) 등이 있습니다. 최종 처리가 호출되면 중간 처리 메소드가 실행되며, 결과를 반환하고 스트림이 종료됩니다.

주요 Stream 메소드

Stream은 다양한 메소드를 제공하여 데이터를 효율적으로 다룰 수 있습니다.

filter()

스트림의 요소 조건에 맞는 요소만을 걸러냅니다.

List<String> names = Arrays.asList("홍길동", "이몽룡", "성춘향");
List<String> filteredNames = names.stream()
                                  .filter(name -> name.startsWith("이"))
                                  .collect(Collectors.toList());

map()

스트림의 요소를 지정된 함수(Function) 적용하여 새로운 요소로 변환합니다.

List<String> names = Arrays.asList("홍길동", "이몽룡", "성춘향");
List<Integer> nameLengths = names.stream()
                                 .map(String::length)
                                 .collect(Collectors.toList());

collect()

스트림의 요소를 모아서 새로운 컬렉션이나 다른 형태의 결과로 반환합니다.

List<String> names = Arrays.asList("홍길동", "이몽룡", "성춘향");
Map<String, Integer> nameLengthMap = names.stream()
                                          .collect(Collectors.toMap(
                                              name -> name,          // Key: 이름 자체
                                              name -> name.length()  // Value: 이름의 길이
                                          ));

forEach()

스트림의 각 요소에 대해 지정된 동작을 수행합니다.

List<String> names = Arrays.asList("홍길동", "이몽룡", "성춘향");
names.stream().forEach(System.out::println);

reduce()

스트림의 모든 요소를 하나의 결과로 합치는 연산을 수행합니다.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream().reduce(0, Integer::sum);

적용 예시

stream().map().collect(toList())는 리스트 요소들을 map()을 이용해 새로운 요소로 매핑한 뒤 collect(toList())를 이용해 매핑된 요소들을 새로운 컬렉션(리스트)에 저장한 후 리턴해 줍니다.

 

아래는 cartId로 찾은 제네릭 타입이 CartProduct인 cartProductList를 stream().map().collect(toList())를 이용해 제네릭 타입이 CartProductResponseDto인 responseDto 리스트로 매핑하여 리턴한 예시입니다.

@Transactional(readOnly = true)
public List<CartProductResponseDto> getAllCartProduct(Long userId) {
    Cart cart = cartRepository.findByUserId(userId)
            .orElseThrow(() -> new ErrorCustomException(ErrorCode.NO_AUTHENTICATION_ERROR));
    List<CartProduct> cartProductList = cartProductRepository.findAllByCartId(cart.getId());
    List<CartProductResponseDto> responseDto = cartProductList
            .stream()
            .map(o -> new CartProductResponseDto(o))
            .collect(toList());
    return responseDto;
}

 

Java Stream API는 반복문을 대체할 수 있는 강력한 도구로, 데이터 필터링, 매핑, 정렬 및 집계와 같은 작업을 더 직관적이고 효율적으로 처리할 수 있습니다. 특히 람다식과 함께 사용하면 복잡한 데이터를 다루는 코드가 훨씬 간결해져 생산성을 높일 수 있습니다.

 

하지만 스트림은 일회용이기 때문에 재사용이 불가하거나 스트림 내에서 발생하는 예외는 일반적인 try-catch 블록으로 처리하기 어렵다는 단점도 존재하기 때문에 상황에 맞게 사용하는 것이 중요하겠습니다.

+ Recent posts