Problem


n 개의 rating 순서가 주어질 때, 만들어질 수 있는 팀의 갯수를 구하는 문제입니다.

팀을 만드려면 3 개의 rating 이 오름차순 또는 내림차순으로 존재해야 합니다.



Solution

팀의 인원은 반드시 3 명이라는 점에 주목할 수 있습니다.

3 명이라면 어떤 값을 가운데 기준으로 잡았을 때, 왼쪽에 있는 값은 더 작고 오른족에 있는 값은 더 커야 오름차순이 됩니다.

또는 반대가 되면 내림차순이 됩니다.

아래와 같은 순서로 코드를 작성하면 해결할 수 있습니다.

  1. ratings 를 전체 순회하면서 가운데 기준값을 잡는다.
  2. 기준값 기준으로 왼쪽에서 더 작은 값 갯수, 오른쪽에서 더 큰 값 갯수를 구해 곱한다 (오름차순인 팀 경우의 수)
  3. 기준값 기준으로 왼쪽에서 더 큰 값 갯수, 오른쪽에서 더 작은 값 갯수를 구해 곱한다 (내림차순인 팀 경우의 수)
  4. 두 값을 더하면 만들어질 수 있는 모든 팀의 수가 나온다.

image

예를 들어 그림으로 표현하면 위와 같습니다.

6 을 기준으로 오름차순은 168, 368, 568 이 존재하고 내림차순은 962, 964, 762, 764 이 존재합니다.

오름차순으로 봤을 때 왼쪽에는 1, 3, 5 세개가 있고 오른쪽에는 8 하나가 존재하기 때문에 오름차순 팀이 만들어질 수 있는 경우의 수는 1 * 3 입니다.

내림차순을 보면 왼쪽에 9, 7 이 존재하고 오른쪽에 4, 2 가 존재하기 때문에 내림차순 팀이 만들어질 수 있는 경우의 수는 2 * 2 입니다.

그러므로 가운데 숫자가 6 일때 만들 수 있는 팀은 7 개가 됩니다.

이런식으로 앞에서부터 모든 숫자에 하나씩 가운데 숫자를 대입하면서 끝까지 돌면 만들 수 있는 모든 팀의 갯수를 구할 수 있습니다.



Java Code

class Solution {
    public int numTeams(int[] rating) {
        int teamCount = 0;

        for (int mid = 0; mid < rating.length; mid++) {
            int leftSmaller = 0;
            int leftLager = 0;
            int rightSmaller = 0;
            int rightLager = 0;

            for (int left = 0; left < mid; left++) {
                if (rating[left] < rating[mid]) {
                    leftSmaller++;
                } else {
                    leftLager++;
                }
            }

            for (int right = mid + 1; right < rating.length; right++) {
                if (rating[right] < rating[mid]) {
                    rightSmaller++;
                } else {
                    rightLager++;
                }
            }

            teamCount += (leftSmaller * rightLager) + (leftLager * rightSmaller);
        }

        return teamCount;
    }
}

1. Overview

1편에서는 OAuth 2.0 에 대한 간단한 개념을 알아보고 네이버, 카카오 앱 등록까지 완료했습니다.

2편에서는 직접 코드를 구현하면서 최종적으로 API 만들어봅니다.

샘플 코드를 볼 때 다음 내용들을 참고해주세요.

  • Spring Security 를 사용하지 않음
  • ID, PW 인증하는 기본 로그인 코드는 작성하지 않음
  • 회원을 의미하는 Member 테이블에 저장되는 데이터는 각자 설계하기 나름이고 여기서는 기본적인 데이터만 사용
  • OAuth 2.0 은 Client (웹, 앱) 개발자와의 협업이 필수지만 여기서는 Backend 코드만 작성
    • 클라이언트 없이 테스트 하는 방법 소개

2. Server 의 역할

서버에서 구현해야 하는 로직은 크게 세가지 입니다.

  1. 카카오/네이버와 같은 OAuth 플랫폼에 인증 후 프로필 정보 가져오기
  2. email 정보로 사용자 확인 (없으면 새로 가입처리)
  3. Access Token 생성 후 내려주기

3. 개발 환경

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.0.3'
    id 'io.spring.dependency-management' version '1.1.0'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

tasks.named('test') {
    useJUnitPlatform()
}

프로젝트 환경은 다음과 같습니다.

  • Spring Boot 3.0.3
  • Java 17
  • Spring Web
  • Spring Data JPA
  • H2 Database
  • Lombok
  • JWT 관련 라이브러리

4. 사전 세팅

4.1. application.yml

jwt:
  secret-key: Z29nby10bS1zZXJ2ZXItZGxyamVvYW9yb3JodG9kZ290c3Atam9vbmdhbmduaW0teWVvbHNpbWhpaGFsZ2VveW8K

oauth:
  kakao:
    client-id: 160cd4f66fc928d2b279d78999d6d018
    url:
      auth: https://kauth.kakao.com
      api: https://kapi.kakao.com
  naver:
    secret: W_2DmcLfYU
    client-id: Y2i4SlApP7A1KZsUoott
    url:
      auth: https://nid.naver.com
      api: https://openapi.naver.com

JWT 토큰 생성을 위한 Secret Key 와 OAuth 요청을 위한 여러가지 정보를 넣어둡니다.

Secret 값 같은 경우는 외부에 노출되지 않게 Vault 같은 보안 저장소에 넣을 수도 있습니다.

각자 본인이 등록한 Client ID 를 사용해야 합니다. (저는 게시글 작성 후 앱 삭제 예정)


4.2. Configuration

@Configuration
public class ClientConfig {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

RestTemplate 을 사용하기 위해 Spring Bean 컴포넌트로 등록하는 설정을 추가합니다.


5. Member 도메인 정의

public enum OAuthProvider {
    KAKAO, NAVER
}

회원의 로그인 타입을 저장하는 Enum 클래스입니다.

"OAuth 인증 제공자" 라는 의미에서 OAuthProvider 라는 네이밍을 사용했습니다.


@Getter
@Entity
@NoArgsConstructor
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;

    private String nickname;

    private OAuthProvider oAuthProvider;

    @Builder
    public Member(String email, String nickname, OAuthProvider oAuthProvider) {
        this.email = email;
        this.nickname = nickname;
        this.oAuthProvider = oAuthProvider;
    }
}

회원 정보를 담는 Member 엔티티입니다.

Email, Nickname 과 같은 프로필 정보나 인증 타입을 갖고 있습니다.

프로젝트 성격에 따라 회원 (Member) 도메인과 인증 (Authentication) 도메인을 분리하는 경우도 있으니 이 부분은 설계하기에 따라 바뀔 수 있습니다.


6. 외부 API 요청

외부 API 요청을 위한 Client 클래스를 만들어봅니다.

API 요청을 위해 RestTemplate 을 사용했지만, 선호도에 따라 다른 걸 사용해도 됩니다.

  • OAuthApiClient: 카카오나 네이버 API 요청 후 응답값을 리턴해주는 (인터페이스)
  • OAuthLoginParams: 카카오, 네이버 요청에 필요한 데이터를 갖고 있는 파라미터 (인터페이스)
  • KakaoTokens, NaverTokens: 인증 API 응답
  • OAuthInfoResponse: 회원 정보 API 응답 (인터페이스)
  • RequestOAuthInfoService: 외부 API 요청의 중복되는 로직을 공통화한 클래스

인터페이스를 많이 사용했는데 다음과 같은 장점이 있습니다.

  • 카카오, 네이버 외에 새로운 OAuth 로그인 수단이 추가되어도 쉽게 추가할 수 있음
  • "외부 Access Token 요청 -> 프로필 정보 요청 -> 이메일, 닉네임 가져오기" 라는 공통된 로직을 하나로 묶을 수 있음

6.1. OAuthLoginParams

public interface OAuthLoginParams {
    OAuthProvider oAuthProvider();
    MultiValueMap<String, String> makeBody();
}

OAuth 요청을 위한 파라미터 값들을 갖고 있는 인터페이스입니다.

이 인터페이스의 구현체는 Controller 의 @RequestBody 로도 사용하기 때문에 getXXX 라는 네이밍을 사용하지 않아야 합니다.


@Getter
@NoArgsConstructor
public class KakaoLoginParams implements OAuthLoginParams {
    private String authorizationCode;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.KAKAO;
    }

    @Override
    public MultiValueMap<String, String> makeBody() {
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("code", authorizationCode);
        return body;
    }
}

카카오 API 요청에 필요한 authorizationCode 를 갖고 있는 클래스입니다.


@Getter
@NoArgsConstructor
public class NaverLoginParams implements OAuthLoginParams {
    private String authorizationCode;
    private String state;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.NAVER;
    }

    @Override
    public MultiValueMap<String, String> makeBody() {
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("code", authorizationCode);
        body.add("state", state);
        return body;
    }
}

네이버는 authorizationCodestate 값을 필요로 합니다.


6.2. KakaoTokens, NaverTokens

Authorization Code 를 기반으로 타플랫폼 Access Token 을 받아오기 위한 Response Model 입니다.

여러 가지 값을 받아오지만 여기서 사용할 부분은 Access Token 뿐입니다.


@Getter
@NoArgsConstructor
public class KakaoTokens {

    @JsonProperty("access_token")
    private String accessToken;

    @JsonProperty("token_type")
    private String tokenType;

    @JsonProperty("refresh_token")
    private String refreshToken;

    @JsonProperty("expires_in")
    private String expiresIn;

    @JsonProperty("refresh_token_expires_in")
    private String refreshTokenExpiresIn;

    @JsonProperty("scope")
    private String scope;
}

https://kauth.kakao.com/oauth/token 요청 결과값입니다.

Kakao Developers - 카카오 로그인 토큰 받기 의 응답값 부분을 참고했습니다.


@Getter
@NoArgsConstructor
public class NaverTokens {

    @JsonProperty("access_token")
    private String accessToken;

    @JsonProperty("refresh_token")
    private String refreshToken;

    @JsonProperty("token_type")
    private String tokenType;

    @JsonProperty("expires_in")
    private String expiresIn;
}

https://nid.naver.com/oauth2.0/token 요청 결과값입니다.

Naver Developers - 로그인 API 명세의 접근 토큰 발급 요청 응답값을 참고했습니다.


6.3. OAuthInfoResponse

public interface OAuthInfoResponse {
    String getEmail();
    String getNickname();
    OAuthProvider getOAuthProvider();
}

Access Token 으로 요청한 외부 API 프로필 응답값을 우리 서비스의 Model 로 변환시키기 위한 인터페이스입니다.

카카오나 네이버의 email, nickname 정보를 필요로 하기 때문에 Getter 메서드를 추가했습니다.


@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
public class KakaoInfoResponse implements OAuthInfoResponse {

    @JsonProperty("kakao_account")
    private KakaoAccount kakaoAccount;

    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class KakaoAccount {
        private KakaoProfile profile;
        private String email;
    }

    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class KakaoProfile {
        private String nickname;
    }

    @Override
    public String getEmail() {
        return kakaoAccount.email;
    }

    @Override
    public String getNickname() {
        return kakaoAccount.profile.nickname;
    }

    @Override
    public OAuthProvider getOAuthProvider() {
        return OAuthProvider.KAKAO;
    }
}

https://kapi.kakao.com/v2/user/me 요청 결과값입니다.

Kakao Developers - 사용자 정보 가져오기 를 참고해서 만든 응답값입니다.

원래 더 많은 응답값이 오지만 필요한 데이터만 추려내기 위해 @JsonIgnoreProperties(ignoreUnknown = true) 를 사용했습니다.


@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
public class NaverInfoResponse implements OAuthInfoResponse {

    @JsonProperty("response")
    private Response response;

    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class Response {
        private String email;
        private String nickname;
    }

    @Override
    public String getEmail() {
        return response.email;
    }

    @Override
    public String getNickname() {
        return response.nickname;
    }

    @Override
    public OAuthProvider getOAuthProvider() {
        return OAuthProvider.NAVER;
    }
}

https://openapi.naver.com/v1/nid/me 요청 결과값입니다.

Naver Devlopers - 네이버 회원 프로필 조회 API 명세 를 참고해서 만든 응답값입니다.

마찬가지로 @JsonIgnoreProperties(ignoreUnknown = true) 를 사용해서 필요 없는 값들은 제외하고 원하는 값만 받도록 했습니다.


6.4. OAuthApiClient

public interface OAuthApiClient {
    OAuthProvider oAuthProvider();
    String requestAccessToken(OAuthLoginParams params);
    OAuthInfoResponse requestOauthInfo(String accessToken);
}

OAuth 요청 을 위한 Client 클래스입니다.

  • oAuthProvider(): Client 의 타입 반환
  • requestAccessToken: Authorization Code 를 기반으로 인증 API 를 요청해서 Access Token 을 획득
  • requestOauthInfo: Access Token 을 기반으로 Email, Nickname 이 포함된 프로필 정보를 획득

@Component
@RequiredArgsConstructor
public class KakaoApiClient implements OAuthApiClient {

    private static final String GRANT_TYPE = "authorization_code";

    @Value("${oauth.kakao.url.auth}")
    private String authUrl;

    @Value("${oauth.kakao.url.api}")
    private String apiUrl;

    @Value("${oauth.kakao.client-id}")
    private String clientId;

    private final RestTemplate restTemplate;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.KAKAO;
    }

    @Override
    public String requestAccessToken(OAuthLoginParams params) {
        String url = authUrl + "/oauth/token";

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> body = params.makeBody();
        body.add("grant_type", GRANT_TYPE);
        body.add("client_id", clientId);

        HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);

        KakaoTokens response = restTemplate.postForObject(url, request, KakaoTokens.class);

        assert response != null;
        return response.getAccessToken();
    }

    @Override
    public OAuthInfoResponse requestOauthInfo(String accessToken) {
        String url = apiUrl + "/v2/user/me";

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        httpHeaders.set("Authorization", "Bearer " + accessToken);

        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("property_keys", "[\"kakao_account.email\", \"kakao_account.profile\"]");

        HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);

        return restTemplate.postForObject(url, request, KakaoInfoResponse.class);
    }
}

Kakao Develpers 의 카카오 로그인 토큰 받기사용자 정보 가져오기 를 참고했습니다.

RestTemplate 을 활용해서 외부 요청 후 미리 정의해둔 KakaoTokens, KakaoInfoResponse 로 응답값을 받습니다.


@Component
@RequiredArgsConstructor
public class NaverApiClient implements OAuthApiClient {

    private static final String GRANT_TYPE = "authorization_code";

    @Value("${oauth.naver.url.auth}")
    private String authUrl;

    @Value("${oauth.naver.url.api}")
    private String apiUrl;

    @Value("${oauth.naver.client-id}")
    private String clientId;

    @Value("${oauth.naver.secret}")
    private String clientSecret;

    private final RestTemplate restTemplate;

    @Override
    public OAuthProvider oAuthProvider() {
        return OAuthProvider.NAVER;
    }

    @Override
    public String requestAccessToken(OAuthLoginParams params) {
        String url = authUrl + "/oauth2.0/token";

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> body = params.makeBody();
        body.add("grant_type", GRANT_TYPE);
        body.add("client_id", clientId);
        body.add("client_secret", clientSecret);

        HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);

        NaverTokens response = restTemplate.postForObject(url, request, NaverTokens.class);

        assert response != null;
        return response.getAccessToken();
    }

    @Override
    public OAuthInfoResponse requestOauthInfo(String accessToken) {
        String url = apiUrl + "/v1/nid/me";

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        httpHeaders.set("Authorization", "Bearer " + accessToken);

        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();

        HttpEntity<?> request = new HttpEntity<>(body, httpHeaders);

        return restTemplate.postForObject(url, request, NaverInfoResponse.class);
    }
}

Naver Developers 의 로그인 API 명세회원 프로필 조회 API 명세 를 참고했습니다.

RestTemplate 을 활용해서 외부 요청 후 미리 정의해둔 NaverTokens, NaverInfoResponse 로 응답값을 받습니다.


6.5. RequestOAuthInfoService

@Component
public class RequestOAuthInfoService {
    private final Map<OAuthProvider, OAuthApiClient> clients;

    public RequestOAuthInfoService(List<OAuthApiClient> clients) {
        this.clients = clients.stream().collect(
                Collectors.toUnmodifiableMap(OAuthApiClient::oAuthProvider, Function.identity())
        );
    }

    public OAuthInfoResponse request(OAuthLoginParams params) {
        OAuthApiClient client = clients.get(params.oAuthProvider());
        String accessToken = client.requestAccessToken(params);
        return client.requestOauthInfo(accessToken);
    }
}

지금까지 만든 OAuthApiClient 를 사용하는 Service 클래스입니다.

KakaoApiClient, NaverApiClient 를 직접 주입받아서 사용하면 중복되는 코드가 많아지지만 List<OAuthApiClient> 를 주입 받아서 Map 으로 만들어두면 간단하게 사용할 수 있습니다.

참고로 List<인터페이스> 를 주입받으면 해당 인터페이스의 구현체들이 전부 List 에 담겨옵니다.


7. JWT(Access Token) 생성

네이버, 카카오 인증이 완료되면 클라이언트에게 Access Token 을 내려주어야 합니다.

여기서 Access Token 은 내 서비스의 인증 토큰이지, 네이버나 카카오의 토큰이 아닙니다.

OAuth 플랫폼들의 Access Token 을 클라이언트에게 내려주면 플랫폼 별로 만료 기간 관리도 번거롭고 혹여나 탈취라도 당하면 안되기 때문에 반드시 직접 토큰을 만들어서 내려줍니다.

JWT 관련 부분은 이 글의 핵심 주제는 아니기 때문에 자세한 설명은 생략합니다.


7.1. JwtTokenProvider

@Component
public class JwtTokenProvider {

    private final Key key;

    public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public String generate(String subject, Date expiredAt) {
        return Jwts.builder()
                .setSubject(subject)
                .setExpiration(expiredAt)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();
    }

    public String extractSubject(String accessToken) {
        Claims claims = parseClaims(accessToken);
        return claims.getSubject();
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(accessToken)
                    .getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

JWT 토큰을 만들어주는 유틸 클래스입니다.


7.2. AuthTokens

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class AuthTokens {
    private String accessToken;
    private String refreshToken;
    private String grantType;
    private Long expiresIn;

    public static AuthTokens of(String accessToken, String refreshToken, String grantType, Long expiresIn) {
        return new AuthTokens(accessToken, refreshToken, grantType, expiresIn);
    }
}

사용자에게 내려주는 서비스의 인증 토큰 값입니다.


7.3. AuthTokensGenerator

@Component
@RequiredArgsConstructor
public class AuthTokensGenerator {
    private static final String BEARER_TYPE = "Bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일

    private final JwtTokenProvider jwtTokenProvider;

    public AuthTokens generate(Long memberId) {
        long now = (new Date()).getTime();
        Date accessTokenExpiredAt = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        Date refreshTokenExpiredAt = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);

        String subject = memberId.toString();
        String accessToken = jwtTokenProvider.generate(subject, accessTokenExpiredAt);
        String refreshToken = jwtTokenProvider.generate(subject, refreshTokenExpiredAt);

        return AuthTokens.of(accessToken, refreshToken, BEARER_TYPE, ACCESS_TOKEN_EXPIRE_TIME / 1000L);
    }

    public Long extractMemberId(String accessToken) {
        return Long.valueOf(jwtTokenProvider.extractSubject(accessToken));
    }
}

AuthTokens 을 발급해주는 클래스입니다.

  • generate: memberId (사용자 식별값) 을 받아 Access Token 을 생성
  • extractMemberId: Access Token 에서 memberId (사용자 식별값) 추출

8. Controller, Service

이제 지금까지 만든 코드를 갖고 최종 비즈니스 로직을 만들어봅니다.


8.1. OAuthLoginService

@Service
@RequiredArgsConstructor
public class OAuthLoginService {
    private final MemberRepository memberRepository;
    private final AuthTokensGenerator authTokensGenerator;
    private final RequestOAuthInfoService requestOAuthInfoService;

    public AuthTokens login(OAuthLoginParams params) {
        OAuthInfoResponse oAuthInfoResponse = requestOAuthInfoService.request(params);
        Long memberId = findOrCreateMember(oAuthInfoResponse);
        return authTokensGenerator.generate(memberId);
    }

    private Long findOrCreateMember(OAuthInfoResponse oAuthInfoResponse) {
        return memberRepository.findByEmail(oAuthInfoResponse.getEmail())
                .map(Member::getId)
                .orElseGet(() -> newMember(oAuthInfoResponse));
    }

    private Long newMember(OAuthInfoResponse oAuthInfoResponse) {
        Member member = Member.builder()
                .email(oAuthInfoResponse.getEmail())
                .nickname(oAuthInfoResponse.getNickname())
                .oAuthProvider(oAuthInfoResponse.getOAuthProvider())
                .build();

        return memberRepository.save(member).getId();
    }
}

처음 설명했던 로직을 그대로 작성했습니다.

  1. 카카오/네이버와 같은 OAuth 플랫폼에 인증 후 프로필 정보 가져오기
  2. email 정보로 사용자 확인 (없으면 새로 가입처리)
  3. Access Token 생성 후 내려주기

취향에 따라 findOrCreateMember 부분을 별도 MemberService 로 분리해도 상관없습니다.


코드를 보면 알 수 있듯이 네이버, 카카오에 특화된 로직이 아닌 공통된 로직이며 인터페이스만을 사용했습니다.

대신 login 메서드 호출 시 KakaoLoginParams, NaverLoginParams 둘 중에 뭐가 들어오냐에 따라 API 요청하는 곳이 달라집니다.

만약 새로운 Google, Facebook 로그인이 추가된다고 하더라도 이 코드는 수정할 필요가 없기 때문에 안전하게 추가 가능합니다.


8.2. AuthController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {
    private final OAuthLoginService oAuthLoginService;

    @PostMapping("/kakao")
    public ResponseEntity<AuthTokens> loginKakao(@RequestBody KakaoLoginParams params) {
        return ResponseEntity.ok(oAuthLoginService.login(params));
    }

    @PostMapping("/naver")
    public ResponseEntity<AuthTokens> loginNaver(@RequestBody NaverLoginParams params) {
        return ResponseEntity.ok(oAuthLoginService.login(params));
    }
}

사용자에게 요청을 받는 Controller 부분입니다.

딱히 특별한 부분은 없고 파라미터로 구현체를 받아서 직접 login 을 호출하는 차이밖에 없습니다.


9. Class Diagram

위에서 작성한 코드를 간단하게 표현하면 이렇게 나옵니다.


10. API 요청 테스트

클라이언트 없이 서버에서만 테스트를 진행해봅시다.


10.1. OAuth 로그인

네이버, 카카오 로그인 페이지를 직접 만들어서 접속 후 로그인 합니다.

1편에서도 한번 다뤘기 때문에 자세한 설명은 생략합니다.

로그인 후에 Redirect URI 로 전달된 Authorization Code 를 확인합니다.

  • 카카오: https://kauth.kakao.com/oauth/authorize?client_id=160cd4f66fc928d2b279d78999d6d018&redirect_uri=http://localhost:8080/kakao/callback&response_type=code
  • 네이버: https://nid.naver.com/oauth2.0/authorize?response_type=code&client_id=Y2i4SlApP7A1KZsUoott&state=hLiDdL2uhPtsftcU&redirect_uri=http://localhost:8080/naver/callback

10.2. API 호출하기

Postman, Curl, IntelliJ IDE 등 자신만의 API 호출 방법을 사용해서 직접 API 를 호출합니다.

저는 Talend API Tester 라는 크롬 확장프로그램을 사용했습니다.

응답값으로 Access Token 을 받아온다면 성공입니다.


10.2.1. Kakao 로그인 호출

카카오는 authorizationCode 파라미터만 추가해서 호출합니다.


10.2.2. Naver 로그인 호출

네이버는 authorizationCode 뿐만 아니라 state 값도 함께 받아서 전달합니다.


10.3. Member 정보 호출

간단한 Member API 를 만들어서 원하는 데이터가 잘 들어갔는지 확인합니다.


10.4. Access Token 검증

Access Token 검증은 두 가지 방법이 있습니다.

  1. API 만들어서 호출
  2. Test Code 에서 코드를 만들어 AuthTokensGenerator.extractMemberId 메서드를 직접 실행

둘 중에 편한 방법으로 확인하시면 됩니다.

위에서 획득한 Access Token 으로 정확한 memberId 를 얻을 수 있다면 성공입니다.


Conclusion

지금까지 Spring Boot 에서 OAuth 2.0 을 활용한 인증 기능을 개발했습니다.

OAuth 2.0 에 대해 잘 모를 때는 어렵고 막막하단 느낌이 들었는데 실제로 구현하고 나니 간단하다고 느꼈습니다.


Reference

1. Overview

서비스를 개발하다 보면 ID, Password 를 사용하는 기본 로그인 외에 SNS 를 활용한 소셜 로그인을 제공하는 경우도 있습니다.

구글 로그인이 대표적이며 국내에서는 네이버, 카카오 로그인을 많이 사용합니다.

이번 시리즈에서는 Spring Boot 환경에서 네이버, 카카오 로그인을 지원하는 기능을 만들어봅니다.

우선 1편에서는 간단한 OAuth 2.0 에 대한 설명과 앱 등록을 알아보고 2편에서 실제 구현 코드를 다룰 예정입니다.


2. OAuth 2.0 이란?

OAuth 2.0 을 간단하게 설명하면 어떤 서비스를 만들 때 사용자 개인정보와 인증에 대한 책임을 지지 않고 신뢰할 만한 타사 플랫폼에 위임하는 겁니다.

개인정보 관련 데이터를 직접 보관하는 것은 리스크가 큽니다.

보안적으로 문제되지 않도록 안전하게 관리해야 하고 ID/PW 에 관련된 지속적인 해킹 공격 등 여러 가지 신경써야 할 부분이 많습니다.

하지만 OAuth 2.0 을 사용해 신뢰할 수 있는 플랫폼 (구글, 페이스북, 네이버, 카카오 등) 에 개인정보, 인증 기능을 맡기면 서비스는 인증 기능에 대한 부담을 줄일 수 있습니다.


2.1. OAuth 2.0 Sequence Diagram

OAuth 2.0 은 일반적으로 위와 같은 플로우를 많이 사용합니다.

그래서 클라이언트와 서버측 모두 OAuth 2.0 플로우에 대해 숙지하고 있어야 합니다.


3. 카카오 앱 등록

카카오 로그인을 사용하기 위해선 우선 카카오에 내 서비스를 등록해야 합니다.


3.1. 앱 등록

https://developers.kakao.com/console/app 에 접속해서 앱을 추가합니다.


3.2. 플랫폼 등록

여기서는 테스트용으로 localhost 만 사용하기 때문에 따로 등록은 안했지만 도메인을 등록해야 사용 가능한 API 들도 존재합니다.

확인 후 필요한 경우 자신의 서비스 도메인을 등록해줍니다.


3.3. 로그인 API 활성화

로그인 API 를 활성화 하고 Redirect URI 를 등록합니다.

Redirect URI 를 등록하지 않으면 인증에 필요한 code 를 받을 수 없으므로 필수로 등록해야 합니다.

Redirect URI 로 code 를 받는 부분은 클라이언트의 영역이므로 같이 협업하는 웹, 앱 개발자가 있다면 등록을 부탁하면 됩니다.

여기서는 서버 개발만 해서 테스트할 예정이므로 http://localhost:8080/kakao/callback 으로 등록했습니다.


3.4. 동의항목 활성화

이제 동의항목으로 이동해서 필요한 데이터의 동의 항목을 활성화 합니다.

여기서는 테스트를 위해 닉네임, 이메일을 활성화 했습니다.

이메일 필수 동의를 받으려면 앱 검수가 필요하기 때문에 임시로 "선택 동의" 상태로 만듭니다.


3.5. 인가 코드 받기 테스트

이제 앱 화면에서 REST API 키 (Client ID) 값을 확인합니다.

client_id, redirect_uri, response_type 파라미터를 사용해서 카카오 로그인 페이지 URL 을 만듭니다.

다른 여러가지 파라미터 값들은 Kakao Developers - 인가 코드 받기 를 참고합니다.

https://kauth.kakao.com/oauth/authorize
?client_id=160cd4f66fc928d2b279d78999d6d018
&redirect_uri=http://localhost:8080/kakao/callback
&response_type=code

위 URL 에 접속하면 카카오 로그인 화면이 보입니다.

원래는 클라이언트에서 "카카오로 로그인하기" 버튼을 추가하고 사용자가 눌렀을 때 연결되어야 하지만 우리는 UI 가 없으므로 직접 URL 에 접속합니다.


전체 동의 후 계속하기를 누르면 우리가 등록한 Redirct URI 로 페이지가 이동합니다.

그 페이지 URL 의 파라미터를 확인하면 code 를 찾을 수 있습니다.

http://localhost:8080/kakao/callback
?code=IY6uM7PIvuWa5D1LYXYrnfvMcd1-0U36AwkwrHm_aUwud8-neFISILn6KzXuJesy0p4GOwopyV4AAAGGt3E46Q

이제 클라이언트에서 얻은 Authorization Code 로 우리가 만든 백엔드 API 를 호출해서 로그인을 진행할수 있습니다.


4. 네이버 앱 등록

카카오 앱 등록과 비슷하지만 네이버는 설정해야 하는 게 조금 더 적습니다.


4.1. 앱 등록

카카오 앱 등록과 마찬가지로 애플리케이션 이름, 사용할 API 를 선택 후 동의 항목은 이메일과 별명을 설정합니다.

서비스 URL 에는 본인이 만들고 있는 서비스의 URL 을 넣습니다.

Callback URL 은 네이버 로그인 후 이동할 URL 이며 Authorization Code 을 파라미터로 받는 URL 입니다.


4.2. 코드 받기 테스트

앱을 등록하고 나면 이렇게 정보를 얻을 수 있습니다.

네이버는 카카오와 달리 Client Secret 값을 추가로 제공하는데, 나중에 인가 코드를 요청할 때 필요합니다.

client_id, redirect_uri, response_type, state 파라미터를 사용해서 네이버 로그인 페이지 URL 을 만듭니다.

파라미터 값에 대한 상세한 설명은 Naver Developers - 네이버 로그인 요청 변수 를 참고하시면 됩니다.

https://nid.naver.com/oauth2.0/authorize
?response_type=code
&client_id=Y2i4SlApP7A1KZsUoott
&state=hLiDdL2uhPtsftcU
&redirect_uri=http://localhost:8080/naver/callback

로그인을 완료하면 우리가 등록해둔 Callback URL 로 이동합니다.

URL 의 파라미터를 확인하면 code, state 값을 볼 수 있습니다.

http://localhost:8080/naver/callback
?code=Sl6x32RuDGpdIXCjmV
&state=hLiDdL2uhPtsftcU

5. Conclusion

다음 포스팅에서는 Spring Boot 코드를 직접 짜보겠습니다.

Problem


삼각형의 꼭대기부터 가장 아래로 가는 최단 거리를 구하는 문제입니다.

위에서 아래로 내려갈 때 이동할 수 있는 곳은 인접한 대각선 밖에 없습니다.



Solution

현재까지 이동한 거리를 누적해서 더해나가다가 마지막에 최소값을 구하면 됩니다.

사실 이 문제는 위에서 아래로 내려가는 것보다 아래에서 위로 올라가는 Bottom Up 방식이 더 쉽게 구현할 수 있습니다.

우선 누적합을 저장해두는 accSum 배열을 선언합니다.

그리고 아래층부터 step 을 따라 올라가며 이동할 수 있는 거리를 구합니다.

양쪽 모두에서 이동할 수 있으므로 두 숫자 중 최솟값과 현재 step 값을 더하는 방식으로 쭉쭉 올라가면 됩니다.

image



Java Code

class Solution {
    public int minimumTotal(List<List<Integer>> triangle) {
        // init 0
        int[] accSum = new int[triangle.size() + 1];

        // bottom up
        for (int i = triangle.size() - 1; i >= 0; i--) {
            List<Integer> step = triangle.get(i);

            for (int j = 0; j < step.size(); j++) {
                int min = Math.min(accSum[j], accSum[j + 1]);
                accSum[j] = min + step.get(j);
            }
        }

        return accSum[0];
    }
}

History

  • 2022.11.26
    • 첫 작성
  • 2025.06.15
    • asdf 버전업으로 인한 명령어 변경 반영
    • Java 버전 corretto 21 로 변경

Overview

과거 포스트에서 이미 MacOS OpenJDK 설치 및 버전 관리에 대해 다뤄본 적이 있으나 asdf 를 이용해서 설치하는 방법을 안내하려고 합니다.

원래 Java 를 설치하려면 brew 를 사용하거나 직접 홈페이지에 들어가 JDK 파일을 다운받아야 합니다.

하지만 한 PC 에서 여러 Java 버전을 사용한다면 터미널에서 빌드할 때마다 Java 버전을 바꿔야하고 관리하기도 까다롭습니다.

jenv 라는 Java 버전 관리 툴이 존재하지만 jenv 는 Java 를 직접 설치할 수는 없습니다.

하지만 asdf 라는 툴을 사용하면 Java 의 설치/삭제를 간단하게 하고 버전 관리도 편하게 할 수 있습니다.

뿐만 아니라 asdf 는 Java 외의 여러 언어, 오픈소스 등의 버전도 관리할 수 있습니다.


1. asdf 설치

# install asdf
$ brew install asdf

# add to shell
$ echo . /opt/homebrew/opt/asdf/libexec/asdf.sh >> ~/.zshrc

homebrew 를 사용해서 asdf 를 설치합니다.

마지막의 add to shell 은 사용자마다 다릅니다.

저는 zsh 를 사용하고 있기 때문에 ~/.zshrc 에 추가했고 만약 bash 를 사용한다면 ~/.bash_profile 에 추가하면 됩니다.


2. Java Plugin 설치

$ asdf plugin add java
$ asdf plugin update java

설치를 위해선 플러그인을 먼저 설치해야 합니다.


3. 설치 가능한 Java 버전 리스트 확인

$ asdf list all java | grep corretto-21
corretto-21.0.0.34.1
corretto-21.0.0.35.1
corretto-21.0.1.12.1
corretto-21.0.2.13.1
corretto-21.0.3.9.1
corretto-21.0.4.7.1
corretto-21.0.5.11.1
corretto-21.0.6.7.1
corretto-21.0.7.6.1

설치할 수 있는 Java 버전을 확인합니다.

여러가지 버전이 있으나 AWS 에서 사용되는 corretto 버전을 사용하겠습니다.

JVM 버전은 21 을 사용했습니다.


4. Java 설치

# 설치
$ asdf install java corretto-21.0.7.6.1

# 설치된 확인
$ asdf list java
 *corretto-21.0.7.6.1

설치 후에는 asdf list <언어> 명령어로 설치된 버전을 확인할 수 있으며 asdf list 만 입력하면 설치된 모든 오픈 소스의 모든 버전을 볼 수 있습니다.


5. 사용할 버전 지정

$ asdf set java corretto-21.0.7.6.1

설치되었다고 끝난게 아니라 사용할 Java 버전을 지정해야 합니다.

프로젝트 별로 Java 버전을 다르게 사용한다면 맞춰서 설정할 수 있습니다.


6. JAVA_HOME 설정하기

$ . ~/.asdf/plugins/java/set-java-home.zsh

halcyon/asdf-java - JAVA_HOME 를 참고해서 본인이 쓰는 shell 에 맞게 입력합니다.

저는 ~/.zshrc 에 추가했습니다.


7. Java 설치 완료

$ java -version
openjdk version "21.0.7" 2025-04-15 LTS
OpenJDK Runtime Environment Corretto-21.0.7.6.1 (build 21.0.7+6-LTS)
OpenJDK 64-Bit Server VM Corretto-21.0.7.6.1 (build 21.0.7+6-LTS, mixed mode, sharing)

터미널에서 자바 버전을 확인해서 제대로 나온다면 설치 완료입니다.


Reference

Overview

Ruby 언어에 관한 정보를 정리합니다.

나중에 더 추가될 수도 있습니다.


1. Variables

number = 1
puts number # => 1

large_number = 2

변수 이름이 길어질 때는 snake_case 를 사용합니다.


2. Data Types

Ruby 에는 다음과 같은 타입들이 있습니다.

기본적으로 최상위 타입은 전부 Object 입니다.

  • Numbers
  • Strings (texts)
  • True, False, and Nil
  • Symbols
  • Arrays
  • Hashes

2.1. Numbers

a = 1   # Integer
b = 1.2 # Float

c = 1_000_000 # == 1,000,000

숫자 타입입니다.

Integer, Float, BigDecimal 등등이 존재합니다.


2.2. Strings

"hi" + "hi"         # => "hihi"
"hi" * 3            # => "hihihi"

"hello".upcase      # => "HELLO"
"hello".capitalize  # => "Hello"
"hello".length      # => 5
"hello".reverse     # => "olleh"

문자열입니다.

큰 따옴표가 아니라 작은 따옴표로 묶어서 사용할 수도 있습니다.


2.3. Symbols

Symbol 은 문자열과 비슷하지만 조금 다릅니다.

앞에 콜론이 붙어있으면 Symbol 입니다. (:something)

Symbol 은 보통 텍스트를 데이터 가 아닌 코드 로 사용할 때 많이 사용합니다.

예를 들면 Hash Key 같은 경우 Key 로 사용한다는 것에 의미가 있죠.

그리고 String 과 비교했을 때 가장 두드러지는 부분은 같은 Symbol 은 동일한 객체를 참조한다는 점입니다.

String 은 매번 다른 객체를 생성하지만 Symbol 은 동일한 객체를 재사용합니다.

"string".object_id # => 2409957680
"string".object_id # => 2682952200
"string".object_id # => 2409974840

:symbol.object_id # => 881948
:symbol.object_id # => 881948
:symbol.object_id # => 881948

2.4. Array

a = [1, 2, 3]

a[0]        # 1
a << 4      # a == [1, 2, 3, 4]
a[6] = 7    # a == [1, 2, 3, 4, nil, nil, 7]
a.first     # 1
a.last      # 7
a.length    # 7
a.compact   # [1, 2, 3, 4, 7]

a.compact 처럼 새로운 배열을 뽑을 때 현재 배열 자체를 바꾸고 싶다면 마지막에 ! 를 붙여주면 됩니다. (a.compact!)


2.5. Hash

dictionary = { "one" => "eins", "two" => "zwei", "three" => "drei" }
dictionary["one"]               # "eins"
dictionary["zero"] = "null"     # insert

Key, Value 로 지정할 수 있는 타입에는 제한이 없습니다.

위 예시에서는 둘다 String 이었지만 Key 로 Integer 를 사용해도 되고 Value 에 배열이 들어가도 됩니다.

가장 많이 사용되는 Key 타입은 Symbol 입니다.

Symbol Key Hash 는 좀더 간편하게 표현할 수 있게 지원해줍니다.

아래 두 문법은 동일한 기능을 갖습니다.

a = { one: "eins", two: "zwei", three: "drei" }
a = { :one => "eins", :two => "zwei", :three => "drei" }

a[:one] # => "eins"

2. String interpolation

str = "Ruby"
puts "Hello, #{str}"
puts "1 + 2 = #{1 + 2}"

#{} 로 감싸면 다른 변수를 넣을 수 있습니다.


3. Method

def add(a, b)
  a + b
end

add(1, 2) # => 3
add 1, 2  # => 3

Ruby 의 메서드는 return 이 없어도 맨 마지막 값을 자동으로 리턴합니다.

메서드를 호출할 때는 괄호로 감싸지 않아도 호출 가능합니다.


3.1. 괄호 생략

def print
  puts "Hello, World!"
end

print() # => Hello, World!
print # => Hello, World!

메서드를 정의할 때 파라미터가 필요 없다면 생략할 수 있습니다.

호출 역시 마찬가지로 생략 가능합니다.


3.2. Default parameter

def print(greeting = "Hello", target = "World")
  puts "#{greeting} #{target}"
end

print               # Hello World
print("Hi")         # Hi World
print("Hi", "Ruby") # Hi Ruby

파라미터 값이 없을 때의 기본값을 넣어줄 수 있습니다.


4. Class

class Person
end

클래스는 위와 같이 정의할 수 있습니다.

person = Person.new 처럼 객체를 생성할 수 있습니다.


4.1. Ruby Class 의 인스턴스, 클래스란?

Ruby 에서는 인스턴스 변수, 인스턴스 메서드, 클래스 변수, 클래스 메서드 등 인스턴스와 클래스라는 단어가 많이 나옵니다.

Ruby 에서 말하는 인스턴스, 클래스는 다음과 같습니다.

  • 인스턴스
    • 객체를 생성 (new) 해야 사용할 수 있는 변수, 메서드
    • 인스턴스 변수는 객체끼리 독립된 값을 가짐
  • 클래스
    • 객체 생성 없이 클래스 자체로 생성할 수 있는 변수, 메서드
    • 클래스 변수는 같은 클래스로 생성된 여러 객체가 공유함

4.2. 인스턴스 변수

class Person
  def print_name
    puts "My Name is #{@name}"
  end
end

p1 = Person.new
p1.print_name   # My Name is
p1.name = "woody"
p1.print_name   # My Name is woody

인스턴스 변수는 클래스 내에서 @ 가 붙어있는 변수를 의미합니다.

일회성으로 사용되고 끝나는 변수와 달리 생성된 인스턴스 내에서 계속 사용할 수 있습니다.


4.3. 클래스 변수

class Person
  @@name = "default"

  def name
    @@name
  end

  def name=(name)
    @@name = name
  end
end

p1 = Person.new
p2 = Person.new
p1.name     # default
p2.name     # default

p1.name = "woody"
p1.name     # woody
p2.name     # woody

클래스 변수는 모든 객체가 공유하는 변수입니다.

위 코드에서 볼수있듯이 p1 의 변수를 바꾸었지만 p2 의 변수도 똑같이 바뀐 것을 볼 수 있습니다.

이것이 인스턴스 변수와의 차이점입니다.


4.4. 상수

class Person
  AGE = 24
end

Person::AGE # 24

상수는 변하지 않는 변수입니다.

객체를 직접 생성하지 않아도 바로 사용할 수 있습니다.


4.5. 인스턴스 메서드, 클래스 메서드

class Sample
  def print
    puts "Instance Method"
  end

  def self.print
    puts "Class Method"
  end
end

Sample.new.print # Instance Method
Sample.print     # Class Method

인스턴스 메서드는 객체를 생성한 뒤에 사용하는 메서드, 클래스 메서드는 그대로 사용하는 메서드입니다.

인스턴스 메서드는 def 처럼 평범하게 정의하면 되지만 클래스 메서드는 def self 를 사용해서 정의해야 합니다.


4.6. 생성자

class Person
  def initialize(name)
    @name = name
  end
end

Person.new("woody") # => #<Person:0x000000012cebfeb0 @name="woody">
Person.new          # ArgumentError (wrong number of arguments (given 0, expected 1))

Ruby 에서는 initialize 메서드로 생성자를 선언할 수 있습니다.

생성자를 선언하지 않으면 파라미터가 없는 기본 생성자를 사용할 수 있습니다.

인스턴스 변수를 초기화할 때는 일반적으로 생성자를 사용합니다.


4.7. Accessor (Getter, Setter)

class Person
  attr_accessor :name
  attr_reader :age
  attr_writer :address
end

p = Person.new

# accessor (getter, setter)
p.name # => nil
p.name = "adsf"
p.name # => "asdf"

# reader (getter)
p.age # => nil
p.age # NoMethodError (undefined method `age=' for #<Person:0x000000012cb9ce90 @name="asdf">)

# writer (setter)
p.address # NoMethodError (undefined method `address' for #<Person:0x000000012cb9ce90 @name="asdf">)
p.address = "Seoul"

attr_accessor, attr_reader, attr_writer 를 사용해서 Getter, Setter 를 생성해줄 수 있습니다.

반대로 private 을 사용해서 Getter, Setter 를 숨길 수도 있습니다.

Accessor 로 지정한 변수는 인스턴스 변수로 사용 가능합니다.


4.8. 상속

class Fruit
  attr_accessor :name

  def print
    puts "This is Fruit"
  end

  def super_method
    puts "This is super class method"
  end
end

class Apple < Fruit
  def print
    puts "This is Apple"
  end
end

< 키워드를 사용해서 상속을 표현할 수 있습니다.

상속받은 클래스에서는 상위 클래스의 메서드나 변수를 사용할 수 있으며 오버라이드도 가능합니다.


4.9. 클래스 확장

클래스 확장이란 기존에 정의된 클래스에 새로운 메서드나 변수를 추가하는 것을 의미합니다.

새로운 기능을 추가하는 방법은 총 세가지지만 기존에 정의된 메서드를 중복 정의하는 경우 새롭게 덮어 씌운다는 공통점이 있습니다.

우선 Game 이라는 기본적인 클래스를 정의합니다.

class Game
  def initialize
    @name = "lol"
  end
end

1. << 키워드 사용

# 인스턴스
game = Game.new

class << game
  attr_accessor :age

  def print_one
    puts "print_one: #{@name}"
  end
end

# 클래스
class << Game
  def print_class
    puts "print_class"
  end
end

<< 키워드를 사용하면 동적으로 새로운 변수, 메서드를 추가할 수 있습니다.


2. 확장 함수로 정의

# 인스턴스
def game.print_two
  puts "print_two: #{@name}"
end

# 클래스
def Game.print_class
  puts "print_class"
end

Kotlin 의 확장 함수와 비슷한 문법입니다.


3. 클래스 분할 정의

class Game
  def print_three
    puts "print_three: #{@name}"
  end

  def self.print_class
    puts "print_class"
  end
end

Ruby 는 클래스를 여러 번 정의할 수 있습니다.

분할 정의된 클래스의 내용은 기존 클래스에 그대로 추가됩니다.


5. Module

모듈 (Module) 이란 여러 가지 기능을 모은 것을 의미합니다.

모듈은 크게 두 가지 목적으로 사용합니다.

  • Namespace 구분
    • 중복된 이름의 클래스를 사용할 때 충돌이 발생하지 않도록 합니다
    • V1, V2 처럼 버전 구분을 할 때 유용
  • 중복된 기능 모으기
    • 여러 곳에서 사용하는 중복된 기능을 분리해서 각각의 모듈에 담을 수 있습니다

5.1. Definition

module Sample
end

모듈은 위와 같이 정의할 수 있습니다.


5.2. Namespace

module V1
  class API
    def self.call
      puts "Call API v1"
    end
  end
end

module V2
  class API
    def self.call
      puts "Call API v2"
    end
  end
end

V1::API.call # Call API v1
V2::API.call # Call API v2

원래 동일한 이름의 클래스와 메서드를 선언하면 분할 정의로 취급하여 기존 메서드는 사라졌습니다.

하지만 모듈을 사용해 분리했기 때문에 두 클래스는 같은 이름과 같은 메서드를 같지만 다른 클래스, 메서드처럼 동작합니다.


5.3. Module Function

module Person
  ADDRESS = "Seoul"

  def age
    @age
  end

  def age=(age)
    @age = age
  end

  def company
    "company"
  end

  module_function :age, :age=
end

Person::ADDRESS # => "Seoul"
Person.age      # => nil
Person.age = 24
Person.age      # => 24
Person.company  # NoMethodError (undefined method `company' for Person:Module)

모듈에서 변수나 메서드를 정의할 수 있습니다.

모듈은 객체처럼 생성이 불가능하기 때문에 사실상 모든 변수와 메서드는 클래스 변수, 메서드라고 볼 수 있습니다.

정의된 메서드를 모듈에서 직접 사용하기 위해선 module_function 키워드를 사용해서 지정해줘야 합니다.


5.4. Mixin

Module 을 Class 에서 참조하면 마치 클래스의 메서드인것처럼 사용할 수 있습니다.

이걸 mix-in 이라고 부릅니다.

모듈을 mixin 하는 방법에는 include, prepend, extend 총 세가지가 있습니다.


5.4.1. Include

module IncludeModule
  def hello
    puts "Hello, Include"
  end
end

class Person
  include IncludeModule

  def hello
    puts "Hello, Person"
  end
end

person = Person.new
person.hello    # Hello, Person

include 키워드를 사용해서 모듈을 참조하면 모듈의 메서드를 인스턴스 메서드로 사용할 수 있습니다.

만약 동일한 이름의 메서드가 모듈과 클래스 양쪽에 정의되어 있다면 클래스의 메서드를 우선시합니다.


5.4.2. Prepend

module PrependModule
  def hello
    puts "Hello, Prepend"
  end
end

class Person
  prepend PrependModule

  def hello
    puts "Hello, Person"
  end
end

person = Person.new
person.hello    # Hello, Prepend

prependinclude 와 마찬가지로 모듈의 메서드를 인스턴스 메서드로 사용할 수 있습니다.

하지만 include 와 다르게 동일한 이름의 메서드가 정의되어 있을 경우 모듈의 메서드를 우선시합니다.


5.4.3. Extend

module ExtendModule
  def hello
    puts "Hello, Extend"
  end
end

class Person
  extend ExtendModule
end

Person.hello    # Hello, Extend

extend 는 다른 mixin 과 다르게 클래스 메서드로 사용 가능합니다.

중복 정의된 메서드는 include 와 마찬가지로 클래스에 있는 걸 우선시합니다.


5.4.4. Ancestors

만약 여러 개의 모듈을 한번에 include 하면 어떻게 될까?

include, prepend 가 여러개 섞여 있으면 어떻게 될까?

Ruby 에서는 어떤 오브젝트가 젤 우선시 되는지 ancestors 메서드로 확인할 수 있습니다.


module Include1
end

module Include2
end

module Prepend1
end

module Prepend2
end

class Human
end

class Person < Human
  include Include1
  include Include2
  prepend Prepend1
  prepend Prepend2
end

Person 클래스에서는 여러 모듈을 동시에 mixin 하고 있습니다.

prepend -> Person -> include 순서까지는 쉽게 짐작할 수 있지만 같은 키워드를 사용한 모듈들은 어떤게 우선시 되는지 알 수 없습니다.

게다가 상위 클래스에도 중복된 메서드가 정의되어 있다면 더욱 더 헷갈립니다.

이를 확인할 수 있는게 ancestors 메서드입니다.


Person.ancestors
=> [Prepend2, Prepend1, Person, Include2, Include1, Human, ActiveSupport::ToJsonWithActiveSupportEncoder, Object, PP::ObjectMixin, ActiveSupport::Dependencies::Loadable, ActiveSupport::Tryable, JSON::Ext::Generator::GeneratorMethods::Object, Kernel, BasicObject]

Person.ancestors 를 사용하면 해당 클래스가 참조하고 있는 모든 오브젝트가 나옵니다.

그리고 중복 정의된 메서드가 있으면 앞 순서에 있는 오브젝트의 메서드가 우선시됩니다.

그래서 Person 클래스에서 중복된 메서드가 있으면 Prepend2 모듈의 메서드가 사용 됩니다.

모든 클래스는 오브젝트라서 BasicObject 가 최상단에 있는 걸 알 수 있습니다.


Reference

'Framework > RubyOnRails' 카테고리의 다른 글

RubyOnRails 세션  (0) 2021.11.06
Ruby 의 as_json 과 to_json 의 차이  (0) 2021.06.06
RSpec Test Frameworks  (0) 2020.07.14
RubyOnRails - nil? empty? blank? present? 차이점  (0) 2020.07.14
Ruby Regular Expression (정규 표현식)  (0) 2020.07.14

Issue

Ruby Grape 와 Grape Entity 를 사용할 때 클래스를 참조하지 못하고 자꾸 NameError (uninitialized constant {Class}) 에러가 발생했습니다.

처음에는 Rails 버전과 Grape 버전이 호환되지 않아서 발생하는 이슈인가 해서 다운그레이드도 해봤지만 해결되지 않았습니다.


Solution

제가 이런 오류를 겪게된 이유는 크게 두가지였는데요.

첫번째는 제가 Ruby 에 익숙하지 않아서 발생한 일이고 다른 하나는 Grape 문서를 제대로 읽지 않아서 발생했습니다.


1. Ruby File 형식을 준수하고 파일 이름과 Class 이름이 일치해야 함

SimpleResponseEntity 라는 클래스를 사용한다고 하면 파일 이름을 simple_response_entity.rb 로 만들어야 합니다.

무심코 Java 에서의 버릇처럼 Camel Case 로 작성했던게 문제였습니다.


2. Grape 프레임워크는 Module 과 파일의 경로가 일치해야 함

Ruby Grape Docs > Mounting > Rails 파트를 보면 다음과 같은 문장이 있습니다.

Place API files into app/api. Rails expects a subdirectory that matches the name of the Ruby module and a file name that matches the name of the class. In our example, the file name location and directory for Twitter::API should be app/api/twitter/api.rb.


즉, Grape 관련된 기능을 사용하기 위해선 파일을 app/api 하위에 만들어야 하며 subdirectory 와 module 이름까지 일치해야 한다는 뜻입니다.

Rails 자체도 파일이나 경로를 굉장히 중요시하는 것처럼 Grape 도 비슷한 특징을 갖고 있는 것 같습니다.


Reference

Overview

MySQL 테이블을 설계할 때 보통 자주 사용하는 조건 컬럼에는 Index 를 추가합니다.

다양한 쿼리를 사용하는 경우 인덱스를 여러 개 추가하는데, 인덱스의 컬럼이 겹치면 원하지 않는 인덱스를 타서 Slow Query 가 발생할 수 있습니다.

Slow Query 의 발생가능한 원인과 해결 방법에 대해 간단히 알아봅니다.


1. Optimizer

SQL 쿼리를 호출하면 DBMS 에 존재하는 Optimizer 가 SQL 실행계획을 만들어줍니다.

옵티마이저는 두 가지 종류로 나뉩니다.

  • RBO (Rule Based Optimizer)
    • 미리 정해진 규칙대로만 쿼리를 수행
    • 인덱스가 존재하면 무조건 인덱스를 탐
    • 예측 가능하기 때문에 설계하기 쉽지만 그만큼 쿼리 자체를 잘 짜야함
  • CBO (Cost Based Optimizer)
    • 통계 정보 (Record 수, Index 컬럼 값 갯수) 를 기반으로 옵티마이저가 생각하는 가장 효율적인 쿼리를 수행
    • 인덱스가 존재해도 Table Full Scan 이 더 효율적이라고 생각하면 Full Scan 함
    • 예측이 힘들기 때문에 우리가 생각한 실행계획과 달라 Slow Query 가 발생할 수 있음
      • 특히 로컬, 개발 환경과 실제 운영 환경에서는 데이터의 갯수가 달라 같은 쿼리여도 다르게 수행될 수 있음

최근 대부분의 RDBMS 는 CBO 를 사용한다고 합니다.

예를 들어 우리가 WHERE ~ AND ~ 조건을 사용할 때 Index 컬럼 순서를 지키지 않았지만 자동으로 인덱스를 태우는 것도 다 CBO 덕분입니다.

하지만 CBO 를 사용하는 경우 우리 생각과는 다르게 동작할 수 있습니다.

여러 인덱스가 존재할 때 A 인덱스를 타는게 더 효율적임에도 B 인덱스를 타는 경우가 있습니다.

이런 경우에 사용하는 것이 바로 Index Hint 입니다. (쿼리 최적화의 또다른 방법으로 Optimizer Hint 라는 것도 있는데 이번 포스팅에서는 다루지 않습니다)


2. Index Hint

인덱스 힌트는 이름 그대로 옵티마이저가 인덱스를 선택할 때 도움을 줍니다.

특정 인덱스를 사용하지 않길 원하면 IGNORE, 사용하길 원하면 USE, FORCE 를 사용할 수 있습니다.

그렇다면 USEFORCE 의 차이는 뭘까요?

Stackoverflow - MySQL : FORCE INDEX vs USE INDEX 를 참고해보면 둘은 다음과 같은 차이가 있습니다.

  • USE INDEX
    • DB 옵티마이저에게 지정한 인덱스를 사용하라고 권장
    • 하지만 만약 Table Scan 이 더 빠르다면 옵티마이저는 인덱스 대신 Table Scan 수행 가능
  • FORCE INDEX
    • Table Sacan 이 더 효율적이어도 무조건 인덱스 사용
    • Index 를 사용할 수 없는 쿼리 (인덱스가 걸려있지 않은 컬럼이 조건) 인 경우에만 다른 방법 선택 가능

대부분의 경우에는 USE INDEX 만 사용해도 되지만 Full Scan 도 허용하지 않는 경우에는 FORCE INDEX 를 사용하면 될 것 같습니다.


Reference

'공부 > Database' 카테고리의 다른 글

MySQL Index 특징 및 유의사항 정리  (2) 2022.05.22
Cache 전략  (0) 2022.05.20
Redis 설치 및 명령어  (0) 2021.08.07

+ Recent posts