서론
먼저 카카오, 구글 OAuth는 전에 구현해본 적이 있었지만, LINE OAuth를 구현하게된 계기를 먼저 얘기해보려고 한다. 일단 SOPT에서 최종적으로하는 프로젝트의 주제가 일본고객을 대상으로 하는 K-뷰티 큐레이션 서비스였기때문에, 일본인 고객들이 우리 서비스의 주 타겟 층이였다

일본내 Line 점유율

물론 소셜로그인에 대한 구체적인 비율을 직접적으로 공개하는 공식 통계는 없지만, 다양한 신뢰할 만한 데이터와 업계 리포트를 통해 LINE이 압도적으로 높은 소셜 네트워크 수단임을 알 수 있었다

https://datareportal.com/reports/digital-2025-japan
위 통계를 보더라도, 압도적으로 LINE 점유율이 일본에서 높다는 사실을 알 수 있었기에, 사용자 진입 장벽을 낮추기 위해서는 라인 소셜 로그인이 필수라고 생각했다
이제 실제로 구현한 코드들을 보면서, 구체적인 플로우를 설명해보겠다
본문

먼저, 가장 다른 로그인들과 달랐던 정보는 소셜 로그인을 할때 필요한 권한과 관련한 내용들이였다. 위에서도 나와있듯이, line은 이메일 정보를 가져올때도 별도의 제출할 문서들이 있었는데 해당 내용은 뒤에서 좀 더 자세히 다뤄보려고한다
Line 소셜 로그인 기초 세팅
LINE Developers 콘솔에서 채널 생성
- https://developers.line.biz/console/ 에서 신규 채널 생성
- Web용 LINE Login 활성화
- Channel ID(클라이언트 ID), Channel Secret(클라이언트 시크릿) 발급
Redirect URI(콜백 URL) 등록
프론트엔드 / 백엔드에서 사용할 콜백 URL을 LINE Developers 콘솔에 등록
형식은 아래와 같았다
https://백엔드/callback/${connector_id}, connector_id
https://프론트 도메인 주소/callback/${connector_id}, connector_id
여기서 clientId는 앱의 채널 ID를 의미하는데, 첫번째 단계에서 생성한 콘솔 채널에서 확인할 수 있다. clientSecret 는 앱의 채널 비밀번호를 의미하고 마찬가지로 콘솔 채널에서 확인할 수 있다
Scope 및 권한 설정
앞에서 다뤘던 이메일, 이름, 성별, 생일, 번호, 주소 등등의 추가로 필요한 정보다.

이메일 주소도 권한 신청 버튼을 눌러서 승인이 완료되어야 사용이 가능하다고 해서....... 앱에서 동의를 요청하는 방식 (개인정보 동의)과 이메일 주소를 사용하는 이유를 설명하는 스크린샷을 제출했다 🥺 기획분들 리스펙 (물론 일본어로)
또한 추가로 성별, 생일, 번호, 주소 등등의 정보들을 얻기 위해선, 결론적으로 LINE에서 민감한 추가 정보를 얻으려면 개발자가 직접 신청서를 제출하고, LINE의 승인을 받아야돼서, 누구나 자동으로 받을 수 있는 권한이 아니라고 생각해주면 될거같다. (보통 3~7일 정도 걸린다고 함)

첫번째는 사용자의 개인정보를 어떻게 수집, 이용, 보관, 보호하는지에 대한 정책이 명시된 웹페이지의 주소를 입력하는 URL이고,
두번째는 서비스 이용에 대한 규칙, 권리, 책임 등을 안내하는 이용약관 페이지의 주소를 입력하는 곳이라고 한다
이거 둘다 optional이라서 선택사항이긴한데, 실제로 서비스 공개나 민감한 정보 권한 신청 시에는 필수로 요구될 수 있어서, LINE의 심사 기준을 충족하기 위해 미리 준비해 두는 것이 좋다고해서 우리는 미리 제출했었다
이제 초기세팅까지 끝났으니까, 실제 구현 플로우를 설명해보려한다
소셜 로그인 플로우
일단 간단하게 코드없이 플로우만 먼저 설명해보겠다
1. 로그인 버튼 클릭
사용자가 “LINE으로 로그인” 버튼 클릭

2. 프론트 인증 요청 URL로 리다이렉트
인증 요청 URL로 리다이렉트 사전세팅 2번에서 등록된 URL
ex) 프론트엔드에서 아래와 같은 인증 요청 URL로 리다이렉트, https://백엔드/callback/${connector_id}, connector_id,
http://프론트 도메인 주소/callback/${connector_id}, connector_id
3. 사용자 인증 및 동의
LINE 로그인 화면에서 사용자 인증 및 권한 동의 동의 시, LINE이 등록된 콜백 URL로 code와 state를 전달하며 리다이렉트
4. 콜백 URL에서 code 추출
프론트엔드에서 code와 state 파라미터 추출 ex) https://도메인주소/oauth/line/callback?code=abc123&state=xyz789
state 란 ?
CSRF(사이트 간 요청 위조) 공격을 방지하기 위해 사용하는 임의의 난수 값 로그인 요청을 보낼떄, 생성된 state 값을 생성하여 붙여주는 구조이다. 콜백 시 동일한 값인지 백엔드에서 검증을 통해서 보안적인 이점을 가져올 수 있다고한다
5. 백엔드로 code 를 전달
프론트엔드에서 백엔드 API로 code 랑 state를 전달 → 민감한 정보라서 requestBody 로 받기로 했다
6. 백엔드에서 세부 로직 처리 후 토큰까지 발급
백엔드는 code만 전달 받아서, 소셜 회원가입 / 로그인을 분기로 처리해서
최초 회원가입일시 → 입력받을 뷰티 프로필 API 호출 (피부톤, 퍼스널 컬러, 피부타입) → access_token 및 refresh_token 발급 로그인일시 → access_token 및 refresh_token 발급
이때 refresh_token 을 redis 라는 라이브러리에 저장해서, 재발급 로직으로 구현
자세한 내용은 아래 포스팅을 참고해주면 더 좋을거같다
https://huncozyboy.tistory.com/54
Refresh Token은 어떻게 관리할까
서론최근에 웹서비스를 개발할때는 REST API를 중심으로 클라이언트와 서버 간 상태를 최대한 분리하는 것을 지향한다. 이 과정에서 토큰 기반 인증(Token-based Authentication) 은 서버가 세션 상태를
huncozyboy.tistory.com
실제로 구현한 내용
application-local.yml
line:
client-id: ${LINE_CHANNEL_ID}
client-secret: ${LINE_CHANNEL_SECRET}
redirect-uri: ${LINE_CALLBACK_URL}
base-url: ${LINE_BASE_URL}
scope: ${LINE_SCOPE}
먼저 첫번째로는, line 관련 민감한 정보들을 환경변수로 등록해주었다. 여기서 scope는 앞서 설명했었던 권한들을 의미하며, 우리는 아직 추가적인 권한은 승인이 안돼서 LINE_SCOPE=openid profile email 로만 선언해주었다
WebClientConfig
그다음 WebClientConfig를 구현해서, Line 자체 토큰 발급 URL에 접근하고, 유저 프로필 조회 URL 등에 접근해서 정보를 얻어올 수 있도록 설계했다
@Configuration
public class WebClientConfig {
@Value("${line.base-url}")
private String lineBaseUrl;
@Bean
public WebClient lineWebClient(WebClient.Builder builder) {
return builder
.baseUrl(lineBaseUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.build();
}
}
LineProperties
LineProperties에서는 앞서 얘기했었던 clientId, clientSecret, redirectUri 주입받아 사용할 수 있도록 한번에 설계해주었다
@Component
@ConfigurationProperties(prefix = "line")
@Getter
@Setter
public class LineProperties {
private String clientId;
private String clientSecret;
private String redirectUri;
private String baseUrl;
private String scope;
}
LineOAuthClient
private final WebClient lineWebClient;
private final LineProperties props;
public LineTokenResponse issueToken(String code) {
return lineWebClient.post()
.uri(LineConstants.TOKEN_PATH)
.body(BodyInserters.fromFormData(LineConstants.PARAM_GRANT_TYPE, LineConstants.GRANT_TYPE_AUTH_CODE)
.with(LineConstants.PARAM_CODE, code)
.with(LineConstants.REDIRECT_URI, props.getRedirectUri())
.with(LineConstants.CLIENT_ID, props.getClientId())
.with(LineConstants.PARAM_CLIENT_SECRET, props.getClientSecret()))
.retrieve()
.bodyToMono(LineTokenResponse.class)
.block();
}
public LineProfileResponse fetchProfile(String accessToken) {
return lineWebClient.get()
.uri(LineConstants.PROFILE_PATH)
.headers(h -> h.setBearerAuth(accessToken))
.retrieve()
.bodyToMono(LineProfileResponse.class)
.block();
}
}
LineOAuthClient에서는 issueToken 메서드로 "/oauth2/v2.1/token" URL에 접근해서 다른 Line 소셜 로그인과 관련한 URL에 접근할 수 있는 토큰을 조회해왔고 , fetchProfile 메서드를 활용해서 "/v2/profile" URL에 접근해서 유저 프로필 정보를 얻을 수 있는 로직을 구현했다
위 URL들에 접근해서 유저의 정보를 가져오는 로직은 LineOAuthClient로 구현했지만, 포스트맨에 직접 넣어보면 아래와 같은 느낌이라서 참고만 해주면 더 좋을거같다

추가적으로, https://api.line.me/oauth2/v2.1/verify?access_token= 에 접근해서, 받아온 라인 JWT 토큰 유효성 검증까지 할 수 있으니까, 토큰에 담긴 정보들이 궁금하면 이것도 참고해주면 더 좋을거같다

AuthService
@Transactional
public JwtTokenDto loginWithLine(String code) {
try {
LineTokenResponse tokenResp = oAuthClient.issueToken(code);
LineProfileResponse profile = oAuthClient.fetchProfile(tokenResp.access_token());
String lineUserId = profile.userId();
Optional<User> userOpt = userRepository.findByLineId(lineUserId);
User user;
OauthLoginStatus loginStatus;
if (userOpt.isPresent()) {
user = userOpt.get();
user.updateLastLoginAt();
userRepository.save(user);
loginStatus = OauthLoginStatus.LOGIN;
} else {
user = User.createLineUser(lineUserId);
user = userRepository.save(user);
loginStatus = OauthLoginStatus.REGISTER;
}
String accessToken = jwtProvider.generateAccessToken(user.getId(), user.getRole().name());
String refreshToken = jwtProvider.generateRefreshToken(user.getId());
return JwtTokenDto.of(accessToken, refreshToken, loginStatus);
} catch (StateValidationException ex) {
log.warn("State 검증 실패: {}", ex.getMessage());
throw ex;
} catch (Exception ex) {
log.error("LINE OAuth 로그인 처리 중 오류 발생", ex);
throw new OauthException(ErrorMessage.OAUTH_ERROR);
}
}
public String generateLineLoginUrl() {
String state = stateService.generateState();
return AUTHORIZE_PATH +
PARAM_RESPONSE_TYPE +
PARAM_CLIENT_ID + props.getClientId() +
PARAM_REDIRECT_URI + props.getRedirectUri() +
PARAM_STATE + state +
PARAM_SCOPE;
}
AuthService의 loginWithLine 메서드는 인가 코드(code)를 받아 @Transactional 범위에서 다음과 같은 순서로 처리한다. 먼저 앞서 설명한 로직대로, oAuthClient.issueToken(code)를 호출해서 LINE의 /oauth2/v2.1/token 엔드포인트로부터 액세스 토큰을 발급받고, 이 토큰을 oAuthClient.fetchProfile(accessToken)으로 전달해 /v2/profile에서 사용자 프로필 정보를 조회한다
반환된 프로필 정보에서 추출한 라인 유저ID로 기존 사용자인지 확인한 뒤, 있으면 updateLastLoginAt()으로 마지막 로그인 시각을 갱신하고, 없으면 User.createLineUser(lineUserId)로 신규 엔티티를 생성,저장한다. 두가지 로직 모두 다 jwtProvider.generateAccessToken(userId, role)과 generateRefreshToken(userId)로 JWT를 발행해 JwtTokenDto.of(accessToken, refreshToken, loginStatus) 형태로 반환해주는 로직으로 구현했다
트러블 슈팅
사용자 이름이 불러와지지 않는 문제
일단 첫번째로 사용자의 이름이 불러와지지 않는 문제가 있었다, 해당 부분과는 관련해서 공식문서를 좀 찾아봤더니....

GET https://api.line.me/oauth2/v2.1/userinfo 에서 얻을 수 있는 유저에대한 리스폰스 값과

GET https://api.line.me/v2/profile 에서 얻을 수 있는 응답 필드가 달라서 생긴 문제였었고, 별도의 API로 분리해서 해당 문제를 해결해 원하는 유저의 정보를 받아올 수 있었다
관련한 코드
public LineProfileDto fetchProfile(String accessToken) {
return lineWebClient.get()
.uri(LineConstants.PROFILE_PATH)
.headers(h -> h.setBearerAuth(accessToken))
.retrieve()
.bodyToMono(LineProfileDto.class)
.block();
}
public LineUserInfoDto fetchUserInfo(String accessToken) {
return lineWebClient
.get()
.uri(LineConstants.USER_INFO_PATH)
.headers(h -> h.setBearerAuth(accessToken))
.retrieve()
.bodyToMono(LineUserInfoDto.class)
.block();
}
이메일 필드가 저장이 안되는 문제
마지막으로는 유저 이름 저장까지 확인했는데, 디비단에 이메일 필드가 null로 들어오는 문제가 발생했었다. 사실 해당 내용이 제일 별거 없던 트러블 슈팅이였는데, 결론만 먼저 얘기하자면 라인 소셜로그인은 별도로 이메일 필드를 지정하지 않으면 초기에 회원가입을 했더라도, 해당 정보가 들어오지 않게된다. 그래서 맨 처음엔 로직적인 문제인줄 알고 Scope 유저 이메일 업데이트까지 확인하였으나 각종 테스트 이후 내 라인 계정 이메일이 등록이 안되어있었다는 것을 확인했다 🥺
물론 제일 사소한 내용이였지만, 해당 내용을 토대로 추후 선택적 프로필에서 이메일까지 입력받는 것을 아이디어로 낼 수 있었고, 추후 유저가 이메일을 수동으로 입력한 뒤에, 라인에서 재로그인 했을때를 대비해서 아래의 유저 케이스까지 구현한 설계를 할 수 있었다
1. 초기 LOCOCO 서비스 회원가입시 라인 이메일 필드가 비어있었음
2. 유저가 라인 앱을 이용하다가, 이메일 필드를 추가해놨음
3. 추후 LOCOCO 서비스 재로그인시 이메일 필드 업데이트
관련한 코드
if (userOpt.isPresent()) {
user = userOpt.get();
user.updateLastLoginAt();
user.updateEmail(email);
user.updateDisplayName(displayName);
userRepository.save(user);
loginStatus = OauthLoginStatus.LOGIN;
} else {
user = User.createLineUser(lineUserId, email, displayName);
user = userRepository.save(user);
loginStatus = OauthLoginStatus.REGISTER;
}
마치며
사실 위에 나온 트러블 슈팅과 관련한 내용들은 자체적으로 테스트를 하면서, 또는 공식문서를 다시 천천히 보면서 해결할 수 있었던 내용들이였지만, 쿠키를 저장하는데 해당 플로우로는 처음 진행해봤어서 시간이 조금 걸렸었다. 쿠키 관련 트러블 슈팅 내용은 새로운 포스팅으로 공부했었던 내용과 함께 다른 포스팅을 작성해보려고 한다
'Spring > OAuth' 카테고리의 다른 글
| Instagram Basic Display API vs Graph API 무슨 차이가 있을까 (OAuth) (0) | 2025.09.22 |
|---|---|
| OAuth의 개념과 필요성 (0) | 2025.06.09 |
| P-프로젝트(구글 OAUTH, Spring) (4) | 2024.12.27 |